Deploy and call a Compact contract¶
Outcome: your app deploys a compiled Compact contract, gets the
contract address, and calls one of its circuits with witnesses — going
end-to-end from .compact source to an on-chain transaction.
Prerequisites¶
- The previous two recipes completed (SDK added, sigil bootstrapped).
- A
.compactcontract that has been compiled with the matching compactc version — the runtime version pinned in your contract'spackage.json(@midnight-ntwrk/compact-runtime) must match what the SDK expects. If you mismatch, the runtime will throw a bytecode-version error at load. - Compiled artifacts under
contract/src/managed/<contract-name>/: contract/index.js— the contract runtime entrykeys/*.prover,keys/*.verifier— per-circuit proving + verifying keyszkir/*.bzkir— the ZK intermediate representation
If you don't have these yet, run npm run compact (or your project's
equivalent) inside the contract/ directory first.
Compact authoring is a 'you provide' prereq
This recipe wires a pre-compiled Compact contract into your
Android app. It does not teach Compact authoring — writing a
.compact source file, installing compactc, or setting up the
JS toolchain. For that, see Hello Compact,
or clone the Midnight Network sample
contracts. This recipe assumes
the contract/src/managed/<name>/ directory already exists;
getting to that point is covered there.
Step 1 — Sync the compiled artifacts into your app's assets¶
The SDK's compact engine loads contract code + circuit keys from your APK's assets. The canonical layout is:
assets/runtime/<contract-alias>-contract.jsassets/keys/*.prover,*.verifier,*.bzkir
There are two ways to wire it. The Gradle plugin is the recommended
path; it's published to Maven Central as
0.1.0-alpha05. The hand-rolled Copy task is
the equivalent if you'd rather not add the plugin — it produces the
same asset layout the plugin would.
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral() // (1)
}
}
plugins {
id("com.android.application")
id("io.github.kuiralabs.contract") version "0.1.0-alpha05"
}
kuiraContract {
source.set("../contract/src/managed/your-contract") // (1b)
// alias.set("your-contract") // (2)
}
- The plugin ships to Maven Central. Add
mavenCentral()topluginManagement.repositoriessoplugins { id(...) }can resolve it. (It is not listed on the Gradle Plugin Portal, so this repo entry is required.) 1b.sourceis resolved relative to theapp/module, andcontract/is a sibling ofapp/, so the path needs the leading../—../contract/src/managed/<name>. (The hand-rolled tab below reaches the same directory differently, viarootProject.file("contract").) aliasis optional — defaults to the dirname ofsource. Socontract/src/managed/penaltyresolves to aliaspenalty, which lands the contract JS asassets/runtime/penalty-contract.js.
The plugin registers two tasks:
validateKuiraContractSource— verification task that always runs and fails fast with a helpful message if the source directory is missing ("compile your contract first — npm run compact …"). Catches the "forgot to compile" mistake at build time, not at runtime.syncContractAssets— the actual copy:contract/index.js→assets/runtime/<alias>-contract.js,keys/*.{prover,verifier}andzkir/*.bzkir→assets/keys/. Wired intopreBuildso it runs automatically before any APK is assembled.
If you'd rather not add the plugin, hand-roll the same task. This is what the plugin replaces — same output, more lines.
val contractDir = rootProject.file("contract")
val contractManaged = file("$contractDir/src/managed/your-contract")
val syncContractAssets = tasks.register<Copy>("syncContractAssets") {
description = "Sync compiled Compact contract artifacts into app assets."
group = "build"
from("$contractManaged/contract") {
include("index.js")
rename { "your-contract-contract.js" }
into("runtime")
}
from("$contractManaged/keys") {
include("*.prover", "*.verifier")
into("keys")
}
from("$contractManaged/zkir") {
include("*.bzkir")
into("keys")
}
into("src/main/assets")
doFirst {
if (!contractManaged.exists()) {
throw GradleException(
"Contract not compiled at $contractManaged — run " +
"`npm run compact` in your contract directory first.",
)
}
}
}
tasks.named("preBuild") { dependsOn(syncContractAssets) }
To switch to the plugin later, replace this entire block with the
four-line kuiraContract { source.set("…") } plugin pattern in the
other tab.
Verify: after ./gradlew :app:assembleDebug, unzip the resulting
APK and confirm assets/runtime/your-contract-contract.js,
assets/keys/*.prover, assets/keys/*.verifier, assets/keys/*.bzkir
are all present.
Step 2 — Build the contract handle¶
import android.content.Context
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.compact.WitnessResult
suspend fun buildContract(
context: Context,
sdk: MidnightSdk,
contractAddress: String? = null, // (1)
): MidnightContract {
// Deploy embeds each circuit's verifier key on-chain — load the bytes.
val verifier = context.assets
.open("keys/yourCircuit.verifier").use { it.readBytes() }
return MidnightContract.create(sdk.config) { // (2) config is positional
name = "yourcontract"
contractJs = context.assets.open("runtime/yourcontract-contract.js") // (3)
if (contractAddress != null) this.address = contractAddress
coinPublicKey = sdk.coinPublicKey
circuitVerifierKeys = mapOf("yourCircuit" to verifier)
witness("localSecret") { WitnessResult(null, ByteArray(32) { 0 }) } // (4)
}
}
nullwhile you're still going to calldeploy()— set this to the returned address for every subsequent call.create()takes the SDK'sMidnightConfig(sdk.config) as its first argument — not ansdkbuilder property.contractJsis anInputStreamfrom your assets — the syncedruntime/<alias>-contract.js, not a path string.witness(name) { … }— stub; replace with your contract's actual witness layout. (A contract with no private state, like the counter, omits this.) For typed witnesses (Vector<N, T>,Bytes<32>, …), pack the bytes by hand.
yourCircuit and keys/yourCircuit.verifier are placeholders — substitute
your circuit's real name. For the Hello Compact counter
that's increment, so the verifier is keys/increment.verifier and the
key in circuitVerifierKeys is "increment".
Verify: the function returns without throwing — meaning the QuickJS runtime found and loaded your contract JS, and witness descriptors typecheck against the bytecode.
Step 3 — Deploy¶
The embedded wallet must hold Dust first
Deploying (and calling) burns Dust to pay for proving + on-chain
execution. On a brand-new, unfunded embedded wallet, deploy() (or
call()) hangs forever at the "Balancing" stage — it's waiting
for Dust that never arrives.
Fund and register Dust before your first deploy:
-
Airdrop NIGHT to the embedded wallet's address (the airdrop command takes the raw embedded-wallet address directly):
mn airdrop <amount> --wallet <embedded-wallet-address> --network undeployed -
Register Dust in-app — tap Register dust in the wallet panel. The CLI cannot register Dust for the embedded wallet; this step has to happen inside the app that owns the seed.
// ProvingKeyManager lives in com.midnight.kuira.core.compact.proving.
// Required once before the first deploy/call: stage your circuit's proving
// keys (+ BLS params) from assets where the on-device prover looks. Idempotent.
ProvingKeyManager(context).installCircuitKeysFromAssets()
val deployResult = contract.deploy()
val address = deployResult.contractAddress
Log.i("MyApp", "Deployed at $address")
deploy() performs the deploy transaction, waits for the indexer to
confirm it, and returns the contract address. Re-use this address for
every subsequent MidnightContract.create(sdk.config) { … address = address }
call.
Verify: address should be a 64-character hex string. Rebuild the
handle with this.address = address and call contract.ledger() — it
returns the deployed contract's initial state.
Step 4 — Call a circuit¶
Circuit arguments are positional varargs in the circuit's declared order — plain Kotlin values, auto-converted to JS. The first argument is the circuit name:
// e.g. a counter's `increment(by: Uint64)`:
val receipt = contract.call("increment", 1L)
// with progress feedback (proving → balancing → submitting):
val receipt = contract.call("increment", 1L) { stage ->
Log.d("MyApp", "call stage: $stage") // ContractCallStage
}
call(circuitName, vararg args, onProgress):
- Generates the ZK proof using the artifacts you synced in step 1.
- Submits the resulting transaction via the wallet.
- Waits for indexer confirmation.
- Returns a
TransactionReceipt(tx hash, status, timings) — not the new state. Read state separately withcontract.ledger()orobserveLedger()(below).
Anchor deadlines to chain time, not the device clock
If a circuit takes a deadline argument, derive it from the latest
block timestamp, not System.currentTimeMillis() — wall-clock
drift between the device and the chain can blow the deadline and get
the transaction rejected.
Verify: the transaction confirms within ~10s on PREPROD. Query
contract.ledger() and check the field your circuit mutates.
Prefer the generated typed facade¶
contract.call("increment", 1L) is stringly-typed — a wrong circuit name
or argument type only fails on-device. The io.github.kuiralabs.contract
plugin also generates a typed <Name>Contract facade from your compiled
contract-info.json, so the same call is checked at compile time:
// Generated from contract-info.json: wrap the raw handle, then call
// circuits as typed methods carrying their real argument + return types.
val counter = CounterContract(contract)
// increment(by: Uint64) -> increment(by: BigInteger); the value is
// range-checked to the declared width. A Long or a String here won't compile.
val receipt = counter.increment(1.toBigInteger())
// progress callback is the same trailing lambda:
counter.increment(1.toBigInteger()) { stage -> Log.d("MyApp", "stage: $stage") }
The facade emits, per circuit: a call-style method for a write, read<Name>()
for an on-chain view (a value-returning circuit), and local<Name>() for a
pure circuit (computed locally, no deploy). Value returns come back typed —
BigInteger, ByteArray, generated data classes / enums, List<T>, tuples.
contract.call(...) stays available as an escape hatch for anything the facade
doesn't model yet (it lists those in a KDoc note).
Step 5 — React to state changes (optional)¶
Rather than poll ledger() in a loop, observeLedger() emits the
current state immediately, then a fresh MidnightLedger every time the
contract's on-chain state actually changes:
// Untyped: read fields by name off the raw MidnightLedger.
contract.observeLedger().collect { ledger ->
val count = ledger.getUint64("count") // your circuit's field
// update UI…
}
// Typed (generated facade): a val per exported ledger field, decoded for you.
CounterContract(contract).observeLedger().collect { ledger ->
val count = ledger.count // BigInteger, no field name, no cast
}
CounterContract(contract).ledger() is the one-shot equivalent — a typed
CounterLedger snapshot. Fields the facade can't type yet (a Map/Set
ledger ADT) are read via the contract's own view circuits (read<Name>()) or
ledger().getRaw(...).
Both are backed by the chain's block stream (falling back to polling on a raw config), and de-duplicate the raw state before decoding — an unchanged block costs only a state fetch, not a re-decode.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
Contract not compiled at … at Gradle time |
Step 1 prereq not done. | Run npm run compact in contract/. |
| Deploy or call hangs at "Balancing" and never finishes | The embedded wallet has no Dust. | Fund it (mn airdrop … --network undeployed) and tap Register dust in-app, then retry. |
Unsupported bytecode version at runtime |
Your compactc emitted bytecode for a different runtime version than the SDK ships. | Pin compactc to match your contract's @midnight-ntwrk/compact-runtime version. |
Indexer says contract not found after deploy |
Indexer hasn't caught up yet. | Add a 3–5s delay between deploy and first call. |
Invalid witness at call time |
Witness ByteArray length doesn't match the circuit's declared shape. |
Cross-check the witness layout against the circuit's declared shape. |
Deadline expired even though you set it in the future |
Using System.currentTimeMillis() instead of chain time. |
Switch to chain-anchored time (last block's timestamp). |
What's next¶
- Back up wallet data across devices — testing on PreProd? The wallet's first sync replays from genesis (slow); cloud backup turns it into a fast delta restore. Set it up before you test on PreProd so you're not stuck watching a genesis replay — and note it needs a one-time Google OAuth client or it silently no-ops.
- Browse the API reference for the full
MidnightContract,MidnightWallet, and circuit-execution surface. - Security § Verifying releases — pin and verify the exact SDK artifact for your release builds.