Skip to content

Encrypted send: denominated change on the new wormhole circuits - #734

Draft
illuzen wants to merge 5 commits into
mainfrom
cursor/encrypted-send-denominated-change-ed51
Draft

illuzen wants to merge 5 commits into
mainfrom
cursor/encrypted-send-denominated-change-ed51

Conversation

@illuzen

@illuzen illuzen commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Do not merge until the chain verifies the new qp-zk-circuits (proof-of-burn leaf, batch-level exits, N=9 / M=32). This PR assumes those circuits are live. Switch the Cargo.toml git pins to the published crates once that release is cut.

What changes

Encrypted sends no longer pay amount + one change output. Change in the last batch of a send is paid out as fixed-size bills (1-2-5 series, smallest bill 1 token: 1, 2, 5, 10, 20, 50, ...) plus one sub-bill remainder, each to its own fresh change address. Change leaves therefore look like everyone else's leaves instead of carrying a unique amount that subset-sum over the public leaf amounts could tie back to a spend.

  • Rust FFI (quantus_sdk/rust/src/api/wormhole.rs): leaf proofs are proofs of burn (no exits/fee on the leaf); aggregate_proofs(proof_bytes_list, exits, volume_fee_bps, bins_dir) takes the batch's exit list; binaries generated at production::PRIVATE_BATCH_SHAPE under a new v5.0.0 artifact dir. wormhole_compute_output_amount removed. FRB bindings regenerated (library stem kept as rust_lib_quantus_wallet; FRB 2.12's bundled cargo_metadata can't parse the edition-2024 crates now in the graph and would emit UNKNOWN).
  • Coin selection (wormhole_coin_selection.dart): plans are per private batch (WormholeSpendPlan → WormholeBatchPlan{inputs, recipientScaled, billsScaled, remainderScaled}); denominateChange is greedy over the bill series within the exit-slot budget (1 recipient + bills + remainder ≤ 32). Candidate selections (largest-first, smallest sufficient single input, best-fit prefix + completer) are ranked by fewest change outputs, then fewest inputs, then least change. Planning default is the chain's N=9.
  • Send service (wormhole_send_service.dart, circuit_manager.dart): batch-level WormholeBatchSpend{burns, exits} validated against the shape in config.json (num_leaf_proofs, num_exit_slots); claims pay one net exit per batch.
  • Encrypted account (encrypted_account_service.dart): reserves one change index per change output, records all of them in PendingSpend.changeOutputs; legacy single-changeAddress pending-spend files still load.
  • Mobile: 1Click deposit refusal is now per batch (each private batch is one deposit).

Open item: Rust pin is one rev behind the chain's shape

The Rust crates are pinned to qp-zk-circuits 080bc897 (production N=10). The chain's shape is N=9 / M=32 from qp-zk-circuits #210 (dc68518), but that rev pins serde_json = "=1.0.154" (#206) while qp-rusty-crystals-hdwallet 4.1.1 (crates.io and git main) pins serde_json = "=1.0.150", so Cargo cannot resolve a graph containing both. Once either side relaxes its pin, bumping the eight rev = entries in quantus_sdk/rust/Cargo.toml is the only change needed: the Rust side reads production::PRIVATE_BATCH_SHAPE from the crate, and the Dart side already plans for N=9 and validates against the generated config.json at send time.

Proving cost of the new circuits (measured)

wormhole-memprof from qp-zk-circuits, release build, on this agent's 4-vCPU shared Intel Xeon VM with the machine otherwise idle. (An earlier version of this table was taken while cargo builds were running and overstated every phase ~2.5x.) The circuit dimensions match upstream (private_batch n=9 m=32 unpadded_gates=23469 degree_bits=15).

v4.4.0 (N=7, 2 exits/leaf) main 080bc897 (N=10, leaf rate 7) main dc68518 (N=9, leaf rate 6) #200 / #210 figures on an M4
leaf proof, each ~45 ms, 14 MB ~275 ms, ~105 MB ~150 ms, ~56 MB ~130 ms (rate 7), ~63 ms (rate 6)
private-batch prove 3.5 s 6.5 s 6.7 s ~3.0 s
full client transfer (leaves + prove) — ~9.3 s (10 leaves) ~8.1 s (9 leaves) ~3.6 s (9 leaves)
peak RSS 1.09 GB 1.9 GB 1.89 GB 2,245 MB on (7, 16) in #200

This VM is a constant ~2.2x slower than the M4 in every phase, so the relative effects are the same as upstream measured: #210 halves the leaf cost (rate 7 → 6), and versus the v4.4.0 circuits the leaf is ~3x and the private-batch prove ~2x on the same hardware, with peak memory ~1.75x higher. The private-batch circuit build (~3 s here) is not part of the per-send path; the app loads the prover from the generated binaries.

Verification

  • cargo check, cargo fmt, cargo test --lib wormhole (29 passed) and the ignored wormhole_proof_generates release test pass against the pinned rev.
  • flutter test in quantus_sdk: 742 passed (incl. native-tagged tests against a locally built release dylib); mobile-app: 730 passed.
  • flutter analyze . --fatal-infos clean on quantus_sdk; the mobile-app analyzer hangs on this machine (known), so flutter test served as the compile check there.
  • dart format --line-length=120 applied to all changed files.
Open in Web Open in Cursor 

cursoragent and others added 4 commits November 10, 2026 12:04
…xits)

Pin the circuit crates to qp-zk-circuits main rev 080bc897 and qp-plonky2 1.6.0.
Leaf proofs no longer carry exits or the fee; aggregate_proofs takes the
batch's exit list and volume_fee_bps instead. Circuit binaries are generated at
the production shape (10 burns, 32 exit slots) under a new v5.0.0 directory.
Regenerated flutter_rust_bridge bindings.

Co-authored-by: illuzen <illuzen@users.noreply.github.com>
Plans are now per private batch. Change in the last batch is paid as 1-2-5
series bills (min 1 token) plus one sub-bill remainder, each its own exit, so
change leaves are interchangeable with other users' leaves instead of being
identifiable by their unique amount. Input selection ranks candidates by
change-output count, then input count, then change amount, within the
exit-slot budget.

Co-authored-by: illuzen <illuzen@users.noreply.github.com>
WormholeSendService submits batch-level exits validated against the circuit
shape reported by CircuitManager (leaf proofs and exit slots). Encrypted
accounts reserve one change index per change output and record all of them in
the pending spend; older single-change pending-spend files still load.

Co-authored-by: illuzen <illuzen@users.noreply.github.com>
Co-authored-by: illuzen <illuzen@users.noreply.github.com>
@illuzen illuzen added the bot-review Request automated review from review-bot label Nov 10, 2026 — with Cursor

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes
Reviewer: GPT-6.1 Sol

Reviewed head 13dd998d6049f6605aabaf9eb27c8245b96f3552 against base a8f1661dde9df650714e59c54b5c5fd578e6d4d1.

  1. [P1] Bound each payout to u32 before crossing the FFI. wormhole_coin_selection.dart:264–278 now combines a batch's recipient payment into one exit, while Rust ExitOutput.amount remains u32. Two valid inputs of 3_000_000_000 scaled units can produce a selected recipient exit of 5_000_000_000. sendSpends accepts it, and the generated sse_encode_u_32 calls ByteData.setUint32, which serializes that value as 705_032_704. The circuit permits outputs below the fee-adjusted burn total, so this can prove and consume the full inputs while paying much less than the confirmed amount. The sibling reward-claim path has the same issue: those inputs generate one exit of 5_997_600_000. Reproduced the oversized send plan, successful send validation, serializer truncation, and oversized claim output. Enforce per-exit u32 bounds before FFI and plan/split both send and claim batches accordingly.

  2. [P1] Keep change reservations within the mnemonic recovery gap. encrypted_account_service.dart:632–640 skips every index reserved by another send, but one denominated send can now reserve more than the discovery gap of 20. Reproduction: select an input of 1_000_400_161 scaled units and send 1; its change needs 22 addresses. Hold that send before submission, let a second send submit change at indices 22 and 23, then fail the first send. Indices 0–21 remain unused. Running the real discoverUsedIndicesBatched against an indexer fixture containing the successful change at 22/23 returns an empty set. A fresh mnemonic restore therefore misses those funds, even though the original device's persisted index can find them. Serialize change-bearing sends until earlier reservations are committed or released, or otherwise guarantee that unused reservation ranges cannot exceed the recovery gap.

  3. [P2] Reconcile pending change per output. encrypted_account_service.dart:400–401 considers change arrived only when every output address is present, but lines 432–434 still add the entire record's change while any output is missing. When change outputs are 500 and 99 scaled units and only the 500-unit bill has arrived, that bill is already spendable and another 599 units are counted as pending: balance becomes 1,099 instead of 599. Reproduced through EncryptedAccountService.load. Keep the all-outputs condition for pruning the record, but calculate pending balance from only the individual outputs that have not arrived.

Validation:

  • Existing focused SDK suites: 76 passed; mobile encrypted-send strategy: 7 passed.
  • Five additional isolated regression probes reproduced the three findings above.
  • timeout 60 cargo +nightly test --locked --offline --lib wormhole: 29 passed, 1 ignored after a warm-cache retry; the initial build reached the 60-second cap.
  • Changed Dart files passed read-only formatting checks; cargo fmt --check and git diff --check passed. GitHub CI is green at the reviewed head.

Local Flutter is 3.44.4, while the repository pins 3.47.7, so locked Melos bootstrap failed on SDK-constrained dependencies. Focused Dart checks used scratch manifests with compatible dependency resolutions and unchanged PR sources; local analysis and an end-to-end on-chain proof submission were not performed. The matching chain-circuit rollout prerequisite in the PR description remains.

@n13 n13 removed the bot-review Request automated review from review-bot label Nov 10, 2026
@n13
n13 marked this pull request as draft November 11, 2026 05:30
@n13

n13 commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

Made into draft

qp-zk-circuits #210 moves the production private batch to 9 leaves (leaf FRI
rate 6 / 19 queries). The Rust side reads the shape from the pinned crate; the
Dart planning default follows the chain. The Rust pin cannot move to that rev
yet: it pins serde_json =1.0.154 against qp-rusty-crystals-hdwallet's =1.0.150.

Co-authored-by: illuzen <illuzen@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants