🧐 Motivation
The concurrency harness landing with the note-token core (#743) runs dry only. Its cases are already backend-neutral and the seam is in place, but createConcurrencyHarness throws on live and each spec is gated with describe.skipIf(isLiveBackend()).
Dry replay is the real verifying path, not a simulation — but it covers one of the two couplings between a transcript and the state it was built on, and none of the node's behaviour. A concurrency verdict is not fully validated until it is observed on chain.
📝 What exists today
contracts/test-utils/concurrency/:
types.ts — ConcurrencyHarness with four ops: snapshot / build / land / attempt, plus Call, Pending, Attempt, Outcome
DryReplayHarness.ts — replays transcripts via QueryContext.runTranscript
race.ts — build both calls on one snapshot, land the first, apply the second
backend.ts — the seam; throws on live
parties.ts — createParties / labelledSecret, contract-agnostic
First consumer: ConfidentialNoteFungibleToken.concurrency.test.ts, 7 cases (4 commuting, 3 conflicting).
📝 What needs doing
Implement LiveConcurrencyHarness behind the existing seam so the same cases run unchanged in both backends, then drop the skipIf.
The design needs no same-block trickery. A conflict is a divergence between the state a transcript was built on and the state it is applied to, not a wall-clock race. So: pin two builds to one snapshot, submit sequentially, and the second is evaluated against a state that no longer exists. Deterministic, no flake.
APIs verified to exist (so this doesn't get rediscovered)
| need |
API |
| the snapshot triple |
publicDataProvider.queryZSwapAndContractState(addr) → [ZswapChainState, ContractState, LedgerParameters] |
| build without submitting |
createUnprovenCallTxFromInitialStates(zkConfigProvider, options, walletEncPubKey) → .private.unprovenTx |
| pin the build to a snapshot |
initialContractState / initialZswapChainState / ledgerParameters are explicit options |
| submit, blocking |
submitTx(providers, { unprovenTx, circuitId }) → FinalizedTxData (waits indefinitely) |
| submit, bounded |
submitTxAsync(...) → txId, then publicDataProvider.watchForTxData(txId) |
| caller identity |
walletProvider.getCoinPublicKey() / .getEncryptionPublicKey() |
Classify from typed signals, never from error text
The runtime's error strings are static data inside the .wasm binary; OnchainProgramError is a Rust enum that wasm-bindgen erases to a plain Error. There is nothing to import and nothing worth pattern-matching. Use instead:
CallTxFailedError / TxFailedError (midnight-js-contracts) — carry finalizedTxData + circuitId
SucceedEntirely / FailEntirely / FailFallible, SegmentSuccess / SegmentFail, TxStatus (midnight-js-types)
TransactionResult (ledger-v8) — type: 'success' | 'partialSuccess' | 'failure' and error?: string, i.e. the ledger's own message read at runtime
isDeterministicRejection (RPC 1010 "Invalid Transaction"), already in LiveSimulatorBackend.ts
Plumbing
- Extract the provider + compiled-contract helpers out of
LiveSimulatorBackend.ts into a shared module. The concurrency harness needs the raw unproven-tx path, not the simulator's fused call, and should not rebuild five providers.
- Publish the worker's
WalletPool + LocalTestConfiguration from live.setup.ts. They are module-locals there today and a spec cannot import that file without re-running the whole funding setup. Same singleton-per-worker shape as registerLiveBackend.
- Give each party its own wallet alias (
SIGNER1, SIGNER2, …) so fee coins never collide and produce a false conflict unrelated to the contract.
- Unify
HarnessOptions on { artifactName, contractConstructor, actors: { witnesses, walletAlias } }. Both backends then build what they need from one description: dry does new Ctor(witnesses), live does compileArtifact(name, Ctor, witnesses). Note two parties means two compiled contracts (different witnesses) pointed at one deployed address.
⚠️ Open question, only settled by running it
A guaranteed-segment failure keeps the transaction out of any block. Every conflict in the note core is guaranteed-segment (no Kernel.checkpoint anywhere), so there may be no FinalizedTxData and no TransactionResult to read — the signal would be a submit-time rejection or a timeout instead. FailEntirely + TransactionResult.error is the shape a fallible-segment failure produces.
attempt() needs a bounded wait either way. submitTx waiting indefinitely is not usable for the rejected path.
📝 Also worth adding once live exists
An effects-divergence case. run_transcript forwards only program + gas to query and ignores transcript.effects; the declared-vs-recomputed effects check runs in the ledger after the transcript does. So that coupling is structurally invisible to the dry backend and is live-only.
✅ Acceptance criteria
🚫 Out of scope
- Mempool / sequencer ordering behaviour (P2 in the Concurrency on Midnight design doc) — a separate question about the node, not about a contract's concurrency properties.
- Concurrency suites for the other token families. Those follow per contract, once this harness runs in both backends.
🧐 Motivation
The concurrency harness landing with the note-token core (#743) runs dry only. Its cases are already backend-neutral and the seam is in place, but
createConcurrencyHarnessthrows on live and each spec is gated withdescribe.skipIf(isLiveBackend()).Dry replay is the real verifying path, not a simulation — but it covers one of the two couplings between a transcript and the state it was built on, and none of the node's behaviour. A concurrency verdict is not fully validated until it is observed on chain.
📝 What exists today
contracts/test-utils/concurrency/:types.ts—ConcurrencyHarnesswith four ops:snapshot/build/land/attempt, plusCall,Pending,Attempt,OutcomeDryReplayHarness.ts— replays transcripts viaQueryContext.runTranscriptrace.ts— build both calls on one snapshot, land the first, apply the secondbackend.ts— the seam; throws on liveparties.ts—createParties/labelledSecret, contract-agnosticFirst consumer:
ConfidentialNoteFungibleToken.concurrency.test.ts, 7 cases (4 commuting, 3 conflicting).📝 What needs doing
Implement
LiveConcurrencyHarnessbehind the existing seam so the same cases run unchanged in both backends, then drop theskipIf.The design needs no same-block trickery. A conflict is a divergence between the state a transcript was built on and the state it is applied to, not a wall-clock race. So: pin two builds to one snapshot, submit sequentially, and the second is evaluated against a state that no longer exists. Deterministic, no flake.
APIs verified to exist (so this doesn't get rediscovered)
publicDataProvider.queryZSwapAndContractState(addr)→[ZswapChainState, ContractState, LedgerParameters]createUnprovenCallTxFromInitialStates(zkConfigProvider, options, walletEncPubKey)→.private.unprovenTxinitialContractState/initialZswapChainState/ledgerParametersare explicit optionssubmitTx(providers, { unprovenTx, circuitId })→FinalizedTxData(waits indefinitely)submitTxAsync(...)→txId, thenpublicDataProvider.watchForTxData(txId)walletProvider.getCoinPublicKey()/.getEncryptionPublicKey()Classify from typed signals, never from error text
The runtime's error strings are static data inside the
.wasmbinary;OnchainProgramErroris a Rust enum that wasm-bindgen erases to a plainError. There is nothing to import and nothing worth pattern-matching. Use instead:CallTxFailedError/TxFailedError(midnight-js-contracts) — carryfinalizedTxData+circuitIdSucceedEntirely/FailEntirely/FailFallible,SegmentSuccess/SegmentFail,TxStatus(midnight-js-types)TransactionResult(ledger-v8) —type: 'success' | 'partialSuccess' | 'failure'anderror?: string, i.e. the ledger's own message read at runtimeisDeterministicRejection(RPC 1010 "Invalid Transaction"), already inLiveSimulatorBackend.tsPlumbing
LiveSimulatorBackend.tsinto a shared module. The concurrency harness needs the raw unproven-tx path, not the simulator's fused call, and should not rebuild five providers.WalletPool+LocalTestConfigurationfromlive.setup.ts. They are module-locals there today and a spec cannot import that file without re-running the whole funding setup. Same singleton-per-worker shape asregisterLiveBackend.SIGNER1,SIGNER2, …) so fee coins never collide and produce a false conflict unrelated to the contract.HarnessOptionson{ artifactName, contractConstructor, actors: { witnesses, walletAlias } }. Both backends then build what they need from one description: dry doesnew Ctor(witnesses), live doescompileArtifact(name, Ctor, witnesses). Note two parties means two compiled contracts (different witnesses) pointed at one deployed address.A guaranteed-segment failure keeps the transaction out of any block. Every conflict in the note core is guaranteed-segment (no
Kernel.checkpointanywhere), so there may be noFinalizedTxDataand noTransactionResultto read — the signal would be a submit-time rejection or a timeout instead.FailEntirely+TransactionResult.erroris the shape a fallible-segment failure produces.attempt()needs a bounded wait either way.submitTxwaiting indefinitely is not usable for the rejected path.📝 Also worth adding once live exists
An effects-divergence case.
run_transcriptforwards onlyprogram+gastoqueryand ignorestranscript.effects; the declared-vs-recomputed effects check runs in the ledger after the transcript does. So that coupling is structurally invisible to the dry backend and is live-only.✅ Acceptance criteria
createConcurrencyHarnessreturns a working harness onMIDNIGHT_BACKEND=liveConfidentialNoteFungibleToken.concurrency.test.tsruns green in both backends with no per-backend branching in the casesskipIf(isLiveBackend())removed from the specsecond-rejectedattempt()bounded; the guaranteed-vs-fallible behaviour documented from an actual runLiveSimulatorBackend, not duplicatedtest:harnessstill passes on a clean checkout (it has nodependsOn: ["compile"], so no harness test may import a compiled artifact)🚫 Out of scope