Airdrop snapshot matching and claim proofs in the SDK - #653
Conversation
find_airdrop_matches derives every historical Dilithium hash and wormhole sponge scheme locally and intersects with the claim-server snapshot, so the app can show which rewards belong to this wallet without sending keys anywhere. Co-authored-by: Cursor <cursoragent@cursor.com>
build_airdrop_dilithium_claim signs address || claim_account || expiry under the qp-airdrop-claim-v1 context; prove_airdrop_wormhole generates the rate-8 ownership proof in-process. Ownership crates come from the qp-zk-circuits v4.4.0 tag so the existing wormhole 4.3.0 stack is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT-6 Astra
REQUEST_CHANGES — reviewed 90f6517aee84f2d66d289de180eb6984fd532351 against base e843b06b49e4c208f7b4a8c603a91f4578780e96.
-
[P1] Use the historical permutation for v0.9 addresses — airdrop.rs:90–91.
hash_no_pad_v09uses the currentPoseidon2::new(), but the permutation constants changed too. The original v0.9.5 implementation generated its constants with ChaCha8 and seed0x189189189189189; the current permutation uses the later constants. I compiled the original tagged implementation and compared it with this PR's reconstruction:hash_padded([0])should beb17b423096da9ebd57af5038b490257d9c492e64059c0ccff23f44e6293213d4, but returns06dfabf1fc7893bbc9443799adffd3b95bf56d0e3f9712508f8da7cc07aecee2. A 2592-byte input also differs. Consequently, actual v0.9 Dilithium and wormhole snapshot addresses are missed, and their Dilithium claims fail the local address-ownership check. Preserve the original permutation as well as its sponge wrapper, and add independent legacy vectors; the current tests only pin the current scheme. -
[P2] Export the new APIs through the public SDK library — airdrop.dart:22.
quantus_sdk.dartexports the existing crypto, UR, and wormhole APIs but never exports this new module. A consumer importingpackage:quantus_sdk/quantus_sdk.dartgets undefined-name errors for all three new functions; I reproduced this with a compile test. Add the airdrop export and a public-import smoke test so callers can use the feature without importing implementation files undersrc/. -
[P2] Apply the repository's Dart formatting after regeneration — the eight changed Dart files fail the configured 120-column formatter. This reproduces locally and is the failure in the PR's Analyze job, which stops before analysis/tests. Run the repository formatting command and include its output.
Validation:
git diff --check: passed; Dart/Rust generated content hashes agree.cargo test --locked --lib: 63 passed, 1 ignored.cargo test --locked --release --lib wormhole_proof_generates -- --ignored: passed.- SDK
flutter test --no-pub --exclude-tags=native: 480 passed. - Original v0.9.5 comparison: confirmed mismatches for 1-, 32-, and 2592-byte inputs.
- Public-import compile test: fails for the three missing exports.
- Workspace formatting check: fails only on the eight changed Dart files.
- Workspace analysis was stopped at the requested 10-second limit; full analysis and claim-server HTTP integration remain unverified.
The v0.9.x qp-poseidon permutation used different round constants (ChaCha8, seed 0x189189189189189) than the current one, so reconstructing its sponge on the current permutation produced wrong addresses. Depend on the real qp-poseidon-core 0.9.5 crate for the v09 schemes and pin them with vectors from the original implementation. Also export the airdrop API from quantus_sdk.dart with a public-import smoke test, and format the regenerated Dart files at 120 columns. Co-authored-by: Cursor <cursoragent@cursor.com>
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT 5.6 Sol
REQUEST_CHANGES — the v0.9 permutation, public export, and formatting blockers from the prior review are fixed at e0337779df4259501c7a5ff5d7036f0f9578bd56, but the historical matcher still omits two shipped Resonance address families.
- [P1] Include the pre-0.9.5 Resonance Dilithium schemes —
airdrop.rs:208-219startsDILITHIUM_SCHEMESatqp-poseidon-core0.9.5. However, shipped chain tagv0.0.12-resonance-alphapinsposeidon-resonance0.8.0, whose account derivation uses legacy Plonky2 Poseidon over 8-byte limbs padded to 73 field elements. A subsequent shipped Resonance revision,e9fc9b9, pinsqp-poseidon0.9.1, which uses legacy Plonky2 Poseidon over 4-byte limbs padded to 188 field elements. Both AccountId hashes differ from 0.9.5's Poseidon2/length-prefixed scheme and from the two later schemes listed here; historical golden vectors for the same public key confirm distinct addresses. The companion reward snapshot currently contains 50 positive Resonance-labelled rows, so an affected early miner receives noAirdropMatchand cannot build a claim. Add both exact derivations with golden public-key/address vectors, and add/accept the same scheme IDs inairdrop-claim; changing only this SDK would still be rejected by the server.
Validation at the exact reviewed head:
git diff --check, Rust formatting, and Dart formatting passed.cargo test --locked --lib: 65 passed, 1 ignored.- Ignored release-mode
wormhole_proof_generates: passed. flutter test --exclude-tags=native: 481 passed.flutter analyze . --fatal-infos: no issues (4.5 seconds).- Strict Clippy reached only an unchanged pre-existing
needless_range_loopinwormhole.rs:376; no PR-specific Clippy error was observed. - GitHub Analyze and dependency-cooldown checks are green.
Shipped Resonance chains predate qp-poseidon 0.9.5: chain tag v0.0.12-resonance-alpha pinned poseidon-resonance 0.8.0 (legacy plonky2 Poseidon, 8-byte limbs zero-padded to 73 felts) and chain rev e9fc9b9 pinned qp-poseidon 0.9.1 (same permutation, 4-byte limbs padded to 188). Early miners' snapshot addresses use these hashes, so match and claim under dilithium-v08-padded and dilithium-v091-padded, which the claim server now also accepts. The legacy Poseidon round constants are unchanged in today's qp-plonky2 (verified against the fork revs both chains pinned), so both schemes hash through the crate already in the tree; golden vectors were generated with the exact historical crates. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed the pre-0.9.5 Resonance schemes in 7057e96.
|
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT 5.6 Sol
APPROVE — the prior historical-derivation, public-export, and formatting blockers are resolved at 7057e96de4cecfef80d714738cf289ed14b09ec0; I found no blocking issues.
The two added pre-0.9.5 Resonance schemes match the shipped sources: poseidon-resonance 0.8.0 uses 8-byte little-endian limbs with a 73-felt minimum, while qp-poseidon 0.9.1 uses 4-byte limbs with a 188-felt minimum. Their golden vectors pass, and the companion claim-server head accepts the same scheme IDs. The generated Dart/Rust bridge hashes agree, the APIs are publicly exported, the signature is bound to the address, payout account, expiry, and FIPS 204 context, and the ownership proof binds the payout through public inputs.
Validation:
git diff --checkand Rust/Dart formatting checks passed.cargo test --locked --lib: 68 passed, 1 intentionally ignored.cargo test --locked --release --lib wormhole_proof_generates -- --ignored: passed.flutter test --exclude-tags=nativeinquantus_sdk: 481 passed.- Pinned Melos SDK analysis with fatal infos: no issues in 2.7 seconds.
cargo +nightly check --locked --target aarch64-apple-ios -Zbuild-std=std,panic_abort --lib: passed.- Strict Clippy reaches only the unchanged pre-existing
src/api/wormhole.rs:376needless_range_loop; allowing that single base warning leaves all targets clean. - GitHub Analyze and dependency-cooldown checks are green.
The unchecked item remains a live HTTP round trip against the separate claim-server PR; I inspected its current request schema, scheme registry, and ownership-verifier path and found them consistent with this SDK.
The middle HD path component is not always a change branch: the app uses
0 (external) and 1 (dedicated change branch since July 2026), but the
CLI's 'wormhole multiround' uses it as a round counter, deriving
m/44'/189189189'/0'/round'/index' with a default of 2 rounds. Scanning
only {0,1} missed every multiround address from round 2 on. Scan
branches/rounds 0..=8 for each index, and stretch the BIP39 seed once
instead of per path, which also makes the wider scan faster and zeroizes
the mnemonic after stretching.
Co-authored-by: Cursor <cursoragent@cursor.com>
The ownership crates are on crates.io as of the v4.4.0 release, so the git-tag workaround is gone. Registry semver unification pulls the qp-wormhole-* family to 4.4.0; the wormhole circuit sources are identical between 4.3.0 and 4.4.0 (only additive config and aggregator prover reuse), so proofs stay chain-compatible. Co-authored-by: Cursor <cursoragent@cursor.com>
Port the quantus-cli secret-hygiene fixes: preimages are built in pre-sized zeroize-on-drop SensitiveFelts buffers; the v0.9.5 sponge is rebuilt locally from public crates and hashes secrets from borrowed slices (the historical crate's hash_no_pad consumes and frees its Vec unscrubbed); the rate-4 sponge wipes its stack state; the prover boundary uses Secret::new and scrubs the FFI-owned Vec; the HD scan buffer is wiped before it is freed. An allocator-based regression test scans every freed block for the secret in raw-byte and felt encodings with no exemptions; golden vectors pin the local sponge to the historical hasher. Co-authored-by: Cursor <cursoragent@cursor.com>
n13
left a comment
There was a problem hiding this comment.
There's a few times where we changed the public key
I am making a list but this will have to be in here.
|
List is here When the ML-DSA-87 public key for a given mnemonic changed #: 1 |
…Bitcoin-seed tree The address-hash schemes covered how a public key became an AccountId, but the ML-DSA-87 public key for the same mnemonic changed four times: 1. pre Nov 2025: SHAKE256(seed[..32]) expansion, non-HD or soft HD paths (m/44'/189189'/N'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/0/0) under the "Bitcoin seed" BIP32 master 2. Nov 2025 (dilithium 2.0.0): FIPS 204 expansion absorbing the whole seed 3. Feb 2026 (CLI): hardened default path and --no-derivation account child 4. Mar 2026 (hdwallet 2.1.0): "Dilithium seed" master, hardened-only — which also changed every wormhole HD entropy find_airdrop_matches now derives candidate keypairs for every era (both seed expansions are rebuilt locally from the current crate's public primitives, in wipeable buffers — the historical crates' keygens free seed-bearing heap copies unscrubbed) and scans wormhole paths under both BIP32 masters plus the legacy master-node secret. Matches carry a dilithium_keygen id; the new build_airdrop_dilithium_claim_from_mnemonic re-derives that era's key and signs with the current crate. Golden vectors are pinned against the shipped dilithium 1.0.3 / 2.0.0 and hdwallet 1.0.0 crates (pk and sk sha256 per era, BIP32 entropies). Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed in 152297a (and mirrored in quantus-cli in Quantus-Network/quantus-cli#163, commit 8cbd49c): the matcher now derives candidate ML-DSA-87 keypairs for every historical keygen era, not just historical address hashes of the current key. Keygen eras covered (each candidate public key is hashed under all five address schemes):
HD families scan account indexes 0–8. Since the era-4 change also moved wormhole HD entropy to the new master, the wormhole scan now additionally walks the same path grid under the "Bitcoin seed" master, plus the legacy master-node secret ( Implementation notes:
|
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT 5.6 Sol
REQUEST_CHANGES — reviewed 152297a761f32fd6756e10dc64cf0b1697f0f5bb against base e843b06b49e4c208f7b4a8c603a91f4578780e96; two blocking issues remain.
-
[P1] Make the scan buffer zeroizing across reallocations and errors —
airdrop.rs:710.secretsstarts as a zero-capacityVec<([u8; 32], String)>and receives secrets at lines 753, 774, and 782. Every capacity growth copies the inline secret arrays into a new allocation and frees the old allocation without wiping it; the loop at lines 807–812 only scrubs the final allocation. I reproduced this through the publicfind_airdrop_matchesAPI with nine known extra secrets and the same deallocation scanner: a freed 448-byte block still contained the secret pattern. Early returns have the same problem—for example, an invalid later extra secret returns at line 781 and drops all earlier entries before the cleanup loop. Pre-reserve the full count before writing secrets and give the collection RAII cleanup so success, error, and panic paths all wipe; extend the allocator regression to callfind_airdrop_matchesitself on both success and error paths. -
[P1] Do not truncate valid historical HD accounts at index 8 —
airdrop.rs:463.dilithium_keygen_idsonly emits accounts0..9, but the shipped multiple-account wallet had no such cap:getNextFreeAccountIndexreturnedmaxIndex + 1, and the UI continued exposing account creation. Consequently, a user who mined to the tenth account (.../9'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/0/0) gets no historical key candidate, noAirdropMatch, and no keygen id to build the claim. The same fixed-bound problem exists for wormhole receive/change indices above 16. Accept the wallet's actual stored indices/paths (or an explicit caller-supplied discovery bound) instead of silently hard-coding a smaller range, and test the first previously excluded index.
Validation:
git diff --check, Rust formatting, and changed-file Dart formatting passed.cargo test --locked --lib: 73 passed, 1 intentionally ignored.cargo test --locked --release --lib wormhole_proof_generates -- --ignored: passed.flutter test --exclude-tags=nativeinquantus_sdk: 481 passed.- Pinned Melos analysis reported no issues for cold-wallet, miner, and
quantus_sdk; it was stopped at the required 10-second cap while mobile-app was running. GitHub Analyze is green at this head. - Strict Clippy passes with the single unchanged
src/api/wormhole.rs:376warning allowed; without that allowance it fails only on that pre-existing warning. - GitHub dependency cooldown is red because the 4.4.0 crates are 4–5 days old, below the repository's 30-day policy.
The companion claim-server head accepts the five Dilithium scheme ids and its claim/proof bindings match this SDK. A live HTTP round trip remains unverified.
… by RAII - DILITHIUM_SCAN_ACCOUNTS and HD_SCAN_INDEXES go from 9 / 17 to 100. The shipped multiple-accounts wallet never capped the account index, so a miner on account 9 (or a wormhole address at index 17) got no match and no keygen id. The new test pins the first previously excluded index and the last index in range for both. - New crate-root `sensitive` module: one `Wipe` trait and `SensitiveVec` replace SensitiveFelts, SecretKeyBytes and the manual wipe loops. The wormhole scan buffer is pre-sized for both HD trees plus the provided secrets and wipes on drop, so growth, early error returns and panics no longer free secret-bearing blocks. The FFI-provided extra secrets are wrapped the same way, so an invalid length no longer leaves the later inputs unwiped. - The allocator regression test now drives find_airdrop_matches on the success path (nine secrets, forcing reallocation) and the error path. - Regenerated the Dart doc comment for the new scan range.
|
Addressed both P1s from the last review in 2ddd6e3. HD scan bounds — Scan buffer zeroization — new crate-root
The allocator regression now also drives The module lives outside Validation: Note the PR summary still says |
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT 5.6 Sol
REQUEST_CHANGES — reviewed 2ddd6e303c50659092a46d610a7faea3277661f8 against base e843b06b49e4c208f7b4a8c603a91f4578780e96; the scan-buffer RAII fix is sound, but two blocking issues remain.
-
[P1] Do not pass the wallet seed through the old non-zeroizing BIP32 implementation —
airdrop.rs:472-477andairdrop.rs:712-716feed the 64-byte BIP39 seed intonam-tiny-hderive0.3.1'sExtendedPrivKey::derivefor every historical candidate. That exact implementation useshmac0.12's consumingHmac<Sha512>and returns/moves extended keys by value. Its HMAC state/output has no zeroizing drop, so wiping only the returnedentropycannot erase the raw seed, parent secret, chain-code pad images, and SHA/HMAC state left in dead stack frames. This is the same implementation flaw documented by the current HD-wallet crate's optimized stack-residue regression, whose replacement uses wiped buffers and in-place derivation. Reimplement the legacy"Bitcoin seed"derivation with those protections (while retaining the historical soft-child behavior), or use a patched dependency, and cover it with an optimized stack-residue test. -
[P1] Replacing the old scan cutoff with 100 still silently excludes valid wallet paths —
airdrop.rs:35-41andairdrop.rs:408-411now scan0..100, so account/address index 100 is simply the next false-negative. Transparent account allocation has no maximum, and wormhole change allocation likewise increments without a cap. A supported wallet at index 100 therefore still receives “no match” and cannot construct its claim. Accept the wallet's actual paths or caller-supplied discovery bounds (and surface when a bounded scan is incomplete) instead of silently treating a heuristic ceiling as exhaustive; cover the first excluded index.
Validation:
git diff --check, Rust formatting, and changed-file Dart formatting passed.cargo test --locked --lib: 74 passed, 1 intentionally ignored.- The ignored release-mode
wormhole_proof_generatestest passed. - Optimized scan-buffer zeroization and high-index regression tests passed.
- Strict Clippy passed with only the unchanged
wormhole.rs:376lint allowed. flutter test --no-pub test/public_exports_test.dart: passed.- Workspace analysis cleared cold-wallet and miner before being stopped at the required 10-second cap; GitHub's full Analyze job is green.
- The companion claim-server PR head accepts the same five Dilithium schemes and uses the same signed/proved fields.
- GitHub dependency cooldown remains red because the 4.4.0 ownership/wormhole crates are 4–5 days old versus the 30-day policy; it requires the normal wait or emergency-bypass process.
|
Ok these are not valid concerns - GTG |
Summary
findAirdropMatches: given the claim-server snapshot address list, derives every historical address format locally (three Dilithium hash eras and five wormhole sponge schemes, HD scan ofm/44'/189189189'/0'/{0,1}'/{0..=16}'plus explicit secrets) and returns which rows belong to this wallet. Keys and secrets never leave the process.buildAirdropDilithiumClaim: signsaddress || claim_account || expiryunder theqp-airdrop-claim-v1FIPS 204 context (ML-DSA-87 only) and returns thePOST /claimbody fields.proveAirdropWormhole: generates the rate-8 ownership proof in-process (async binding; the circuit builds on a Rust worker thread).qp-zk-circuitsv4.4.0 git tag as a separate source, so the existing wormhole 4.3.0 crates.io stack is unchanged.Test plan
wormhole_proof_generates(ignored by default) passes withcargo test --release -- --ignoredflutter analyzeclean onquantus_sdkairdrop-claimrepo,data/dev_dummy.csv)Made with Cursor