DEV Community

Cory Dabrowski
Cory Dabrowski

Posted on

USDM round-trip, a ZK invoice contract, and every wall I hit on the way

Over the sprint I moved USDM Cardano → Midnight → Cardano, built a web front-end for the bridge, and shipped an original Compact contract that settles USDM invoices in zero knowledge. Repos:

Here's what the docs didn't tell me.

How VIA actually carries USDM

The c2m leg is easy to underestimate until you watch it happen. The CLI builds a Cardano tx that locks USDM at VIA's lock-release script and emits a send_request UTxO naming the recipient. VIA validators pick it up after 1 Preprod block and mint native USDM on Midnight. No wrapped token anywhere, the issuer's contracts do the lock/mint. My transfers:

  • c2m: 8be160c6ae65c17d3b82875863bb40f566001e9ee259af0f2109d0819849b562 → 10 USDM minted to mn_addr_preview1l2dszn6...
  • m2c: burn tx 00e6a9937503d018abd1113921578edf8a902d0b6d4f2e90b2a4ab50d2ac687baf → released on Cardano

The m2c leg is where Midnight gets real. The CLI calls the bridge circuit on VIA's USDM gateway, your local proof server generates the ZK proof, the circuit burns the USDM, and DUST pays the fee. One gotcha: the released USDM lands on your enterprise address (payment key only, stake credential stripped). If you're watching your base address and see nothing, check addr_test1v..., not addr_test1q....

Proof server: match the version to the ledger

docker run -d -p 6300:6300 midnightntwrk/proof-server:8.0.3, and the tag matters. The proof server major tracks the ledger version (ledger-v8 pairs with 8.0.3), and the newest tag on Docker Hub is for a future ledger. I run it under colima on macOS. First boot downloads proving keys for a few minutes before it answers on :6300.

Initial sync and the wallet state cache

First wallet sync on Preview replayed ~177,000 indexes in about 6 minutes, then wallet-state.json makes every subsequent run start in seconds. Worth knowing: the cache is per-wallet. Point WALLET_STATE_FILE somewhere new when you switch mnemonics, or the SDK will try to restore another wallet's state.

DUST, and the balance that reads as zero when it isn't

There's no DUST faucet. You get tNIGHT (the preview faucet dispenses 5,000), register it for dust generation (make-dust.mjs in the usdm-bridge package, or Lace), and DUST accrues over hours. The registration fee is quoted "payable from the dust the UTxOs will generate", so you can register with zero DUST.

The trap that cost me a night: the facade's state().dust is undefined even while DUST is accruing. DUST is a time-parameterized balance, so you have to ask the dust local state for walletBalance(new Date()). My wallet showed "undefined" for hours while actually holding 1,000+ DUST. If your balance tool says zero, query with a timestamp before you panic.

Designing a Compact contract around USDM

My DApp is a private invoice registry. An invoice exists on-chain only as H("private-invoice:v1:", amount, payer, salt), payment happens in USDM, then either party proves knowledge of the preimage in ZK and the invoice flips to settled. The chain learns that it settled, never the amount and never who paid.

Deployed on Preview at 3340415ce4cb387a51cec39897d9f0dc5152b843ca3a41eb16b5960ea549458c:

  • deploy 002762c67fdda000b3210d088b4928556087cc6e72f59a2d8b1d6c89ae78475003
  • createInvoice 00d564cffecaa61bd420e77325358026640b02994fdaf23ee91bd9295df6ba87af
  • USDM payment (2 USDM, app layer) 00119d79cda2deaa5975a0a919ec11e34b7ff2d58249742f0b517e92cd78ccb791
  • settleInvoice (ZK) 005c6d98c6e15858abe82bd395e617a91daa68eb278a16b43976e2a525fc5f4031

Design decisions that weren't obvious:

  1. USDM lives at the application layer. The contract manages private settlement facts; the pay command does a native unshielded USDM transfer via wallet.transferTransaction. Unshielded token custody inside a contract buys nothing here and costs a lot of circuit complexity.

  2. The disclosure checker is flow-insensitive. Compare a public circuit parameter against witness data anywhere in the circuit and every ledger use of that parameter needs disclose(), even a member() lookup before the comparison. The clean pattern is const c = disclose(commitment) at the top of the circuit, then use c throughout.

  3. Pin your compiler to your runtime. compactc 0.34 emits code for compact-runtime 0.19; the current midnight-js 4.0.4 stack runs 0.15. The pairing that works today: compact compile +0.30.0. Symptom of a mismatch: version errors at contract load, not at compile.

  4. One WASM ledger per process. My deploy died with "expected instance of ZswapSecretKeys". npm had installed ledger-v8 8.1.1 at the root while a dependency nested 8.0.3, and objects from one WASM instance fail class checks in the other. "overrides": { "@midnight-ntwrk/ledger-v8": "8.0.3" } fixed it.

  5. Share a pure circuit between chain and client. commitmentOf is an exported pure circuit, so the CLI computes commitments with pureCircuits.commitmentOf(...) and the off-chain hash can never drift from the in-circuit one.

The takeaway

The full loop works today on Preview with a laptop, a Docker container, and patience with faucets: stablecoin in from Cardano, private business logic in a Compact contract, ZK settlement, stablecoin back out. The privacy model (public that, private what) maps naturally onto payments, and USDM being native on both chains means no wrapper risk anywhere in the story.

Top comments (0)