Skip to content

feat(kotlin-sdk): one-time Orchard key shielded-invite API (inviter + claim) - #4313

Open
bfoss765 wants to merge 34 commits into
v4.2-devfrom
port/v4.1/shielded-invites
Open

feat(kotlin-sdk): one-time Orchard key shielded-invite API (inviter + claim)#4313
bfoss765 wants to merge 34 commits into
v4.2-devfrom
port/v4.1/shielded-invites

Conversation

@bfoss765

@bfoss765 bfoss765 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Continues #4204 — moved from a fork branch to an in-repo branch (rebased onto v4.2-dev post-#4305) so maintainers can push changes directly, per review request. Full review history on #4204.

Migration note: one lockfile line was regenerated (the log dependency declared by the head commit) so cargo check --locked passes on the new base; amended into that same commit with authorship preserved.


What

Adds the one-time Orchard key shielded-invite API to the Kotlin SDK. Client-side only — no L2 protocol / consensus changes (nothing under rs-dpp, rs-drive, dapi).

  • Inviter sidegenerateOneTimeOrchardKey() / orchardAddressFromSpendingKey() + the OneTimeOrchardKey type: generate a one-time Orchard spending key and the raw address the inviter funds a note to.
  • Claim sideshieldedIdentityCreateFromOneTimeKey(...): a claimer, handed the one-time spending key, spends the funded note to create/top-up a shielded identity.

Backing Rust: rs-platform-wallet (shielded/keys.rs, operations.rs, sync.rs, platform_wallet.rs), rs-platform-wallet-ffi (shielded_send.rs), rs-unified-sdk-jni (funding.rs).

⚠️ Stacked on #4183

The claim side consumes decode_registration_pubkeys_blob + IdentityPubkeyCodec, both introduced by #4183. This branch is stacked on #4183, so until #4183 merges the diff below also contains #4183's changes. It will retarget to a clean diff once #4183 lands. Net-new files to review here:

  • packages/rs-platform-wallet/src/wallet/shielded/keys.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/rs-platform-wallet/src/wallet/shielded/sync.rs
  • packages/rs-platform-wallet/src/wallet/shielded/mod.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-unified-sdk-jni/src/funding.rs
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
  • packages/rs-platform-wallet/Cargo.toml (optional rand dep for the shielded feature)

Security note — identity key roles

The claim path decodes registration pubkeys through base's decode_registration_pubkeys_blob / row.to_ffi(), so key roles (purpose / security level) are caller-stamped (base's uniform registration convention) rather than derived in Rust. The role mapping is unchanged: key_id 0 → AUTH/MASTER, 1 → AUTH/CRITICAL, 2 → AUTH/HIGH, 3 → TRANSFER/CRITICAL. The reconciled JNI return also preserves the identity id on the unconfirmed-broadcast path.

Validation

  • cargo test -p platform-wallet --features shielded — 624 lib tests + 3 new claim tests pass; inviter key-roundtrip tests pass.
  • cargo build -p platform-wallet -p platform-wallet-ffi -p rs-unified-sdk-jni --features shielded — clean.
  • ./gradlew :sdk:assemble — BUILD SUCCESSFUL (compileDebug/ReleaseKotlin).

Consumer follow-up (tracked separately, not in this PR)

The Android wallet's SdkShieldedUsernameCreation / SdkShieldedInviteCreation still call the claim API with List<IdentityKeyPreview>; they need adapting to List<IdentityPubkey> (stamping the roles above) before the full wallet builds against this SDK.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added shielded identity creation using one-time Orchard invitation keys.
    • Added one-time Orchard key generation and address derivation across supported SDK interfaces.
    • Added resumable scanning and recovery for shielded invitation claims.
  • Bug Fixes
    • Added clear, non-retryable handling for already-claimed invitations.
    • Improved signer key-unavailable error messages.
  • Security
    • Improved protection and cleanup of temporary secret keys.
  • Documentation
    • Corrected documented signer error formats and coverage notes.

bfoss765 and others added 14 commits August 5, 2026 21:09
…rom b2 line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…m_one_time_key), reconciled to base identity API

Ports the L2-invitation CLAIM side from the b2 line (8008dc78b8):

Verbatim grafts (byte-for-byte from b2, deps all present in base):
- operations.rs: free fn identity_create_from_one_time_key (note-scan +
  Halo2 proof) and its supporting note-scan helper
  scan_notes_for_foreign_key (sync.rs), plus the one_time_key_tests module.
- platform_wallet.rs: PlatformWalletManager::identity_create_from_one_time_key.
- shielded_send.rs (FFI): platform_wallet_manager_shielded_identity_create_from_one_time_key
  (base's FFI-layer decode_identity_pubkeys/IdentityPubkeyFFI matches b2).

Reconciled to base's API (NOT byte-for-byte):
- funding.rs (JNI): decode_pubkeys_blob + hand-built IdentityPubkeyFFI literal
  (b2) -> decode_registration_pubkeys_blob + row.to_ffi() (base), plus base's
  tagged-payload return with ErrorShieldedBroadcastUnconfirmed handling.
- Kotlin: IdentityKeyPreview.encodeForRegistration + withContext + raw return
  (b2) -> List<IdentityPubkey> via IdentityPubkeyCodec.encode + teardownGate.op
  + decodeShieldedCreatePayload (base), mirroring the tested inviter side.

Pubkey-decode semantics preserved: identical key_id / pubkey bytes / order /
count; role/read_only/contract-bounds source shifts from Rust-derived (b2) to
caller-stamped blob (base) — base's authoritative pipeline-wide convention,
already adopted by the tested inviter side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses reviewer thepastaclaw's blocking findings on PR #4204. Two of the
four blockers are fixed here; the other two are structural and reported back
for a decision rather than guessed (crypto/money path).

Blocker #4 (FFI RNG abort) — shielded_send.rs / keys.rs:
  `generate_one_time_orchard_key` used `OsRng::fill_bytes`, which panics on an
  OS entropy-source failure. It is called from a `#[no_mangle] extern "C"`
  export, so that panic aborts the process across the C ABI before any JNI
  panic guard can convert it. Switch to `RngCore::try_fill_bytes`, return a
  typed `PlatformWalletError::ShieldedKeyDerivation`, and have the FFI export
  map it to `ErrorWalletOperation` instead of aborting. Test call sites and
  callers updated for the new `Result` return.

Blocker #3 (bearer spend key hygiene) — funding.rs:
  `oneTimeSk` is bearer spend authority but was marshalled via the generic
  `read_id32`, leaving its intermediate JNI `Vec<u8>` and returned `[u8; 32]`
  unsanitized. Add a `read_key32_zeroizing` helper (mirroring
  `transactions::read_key32_zeroizing`): the returned key is `Zeroizing` and
  the intermediate JNI copy is scrubbed. `sk` derefs to `[u8; 32]`, so the
  downstream `sk.as_ptr()` FFI call is unchanged.

NOT fixed here (reported for decision):
  Blocker #1 (affected-state wait): `wait_for_affected_state` does not exist
  in this head's SDK, and the pool-funded sibling still uses `wait_for_response`
  on this branch. The reviewer's fix is predicated on rebasing onto the v4.1-dev
  proof API (31c69cf); it must be done in lockstep for both Type-20 paths.
  Blocker #2 (persist claim recovery record): the redrive mechanism is keyed by
  SubwalletId + activity entry and driven by the per-subwallet sync loop. Claim
  notes belong to a foreign one-time key tracked in no subwallet, so a correct
  fix needs a new subwallet-less pending-claim record + reconciliation path, not
  a reuse of `arm_redrive_record`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o-end (#4204)

Reviewer (thepastaclaw) key-hygiene blocker: the one-time Orchard bearer
spending key was copied into several plain, unsanitized buffers on both the
claim and generate paths.

Claim path — carry the key through `Zeroizing` from the FFI copy down through
the wallet layers instead of leaking a plain `[u8; 32]` at each hop:
- rs-platform-wallet-ffi: the `[u8; 32]` claim-key copy is now
  `Zeroizing<[u8; 32]>` and moves (not copies) into the wallet layer.
- platform-wallet `identity_create_from_one_time_key` (both the
  PlatformWallet method and the operations fn) now take
  `Zeroizing<[u8; 32]>`; the key is scrubbed on drop, dereferenced only at
  the single `SpendingKey::from_bytes` consumption point.

Generate path — wipe the transient native and JVM copies after handoff:
- rs-platform-wallet-ffi: explicitly zeroize the native `sk` after copying
  it into the caller's `out_sk_32`.
- rs-unified-sdk-jni: hold the JNI `sk` and the combined 75-byte `out` blob
  in `Zeroizing` buffers so both scrub on drop, including early returns.
- kotlin-sdk `generateOneTimeOrchardKey`: wipe the 75-byte JVM blob in a
  `finally` once the two owned arrays have been sliced out.

Validated: cargo build + cargo test -p platform-wallet (493 pass) + cargo fmt;
:sdk:compileDebugKotlin succeeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aim (#4204)

Reviewer (thepastaclaw) blocker: after rebasing onto v4.1-dev, the current
proof contract (31c69cf) marks IdentityCreateFromShieldedPool proofs as
affected-state snapshots — they authenticate the resulting identity and spent
nullifiers but cannot bind the complete Orchard request. That commit switched
the pool-funded sibling to wait_for_affected_state; the strict wait_for_response
now yields ExecutionNotProved for every valid proof.

The one-time-key claim path (identity_create_from_one_time_key) was still on
the strict wait_for_response, so every valid claim proof would enter the
ambiguous fallback and risk being reported unconfirmed despite executing.
Switch it to wait_for_affected_state, matching the pool-funded sibling
(the sibling already adopted it via the v4.1-dev rebase).

Validated on the rebased v4.1.0-rc.1 base: cargo build (platform-wallet +
rs-unified-sdk-jni) + cargo test -p platform-wallet (493 pass) + cargo fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red-note DAO queries (#4204)

Shielded-invite claim recovery: an IdentityCreateFromOneTimeKey claim that has
already executed on chain (its note nullifier is spent / broadcast or wait
returns NullifierAlreadySpent) is now reconciled to success instead of
stranding the retry with a hard error. Recovery re-derives everything from the
invite the invitee already holds — no persisted record:

  - master_auth_public_key_hash(): the invitee's re-derivable MASTER auth key
    hash, the unique Platform-indexed handle the identity is looked up by
    (discover_inner's unique-hash probe).
  - any_nullifier_spent_on_chain(): proof-verified ShieldedNullifierStatuses
    preflight; if the selected notes are already spent, recover by key hash
    before rebuilding/rebroadcasting.
  - NullifierAlreadySpent arms on both broadcast and wait paths route to
    recover_executed_one_time_claim(), which recovers by key hash, then by the
    deterministically-derived identity id (fetch_identity_with_retries), and
    otherwise surfaces ShieldedBroadcastUnconfirmed carrying the derived id.

Preserves the newer #4204 key-hygiene base already in this branch: the one-time
spending key is still carried in Zeroizing<[u8;32]> and wait_for_affected_state
is unchanged (Type-20 proof is affected-state).

ShieldedDao: adds minUnspentAnchoredBlockHeight() and
getUnspentAnchoredNotesByWallet() — read-only queries over existing
shielded_notes columns (no schema change) backing the shielded-username
anchor-confirmation gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"same residual #4172 accepted" read ambiguously; say the residual was
accepted in #4172.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ted the identity (#4204)

A spent invitation nullifier proves only that *something* consumed the note.
It does not prove that this claim's Type-20 transition created an identity, and
recovery was treating "nullifier spent + an identity is findable under the
submitted MASTER auth key hash" as a successful claim. Two real on-chain
outcomes are reported as success by that rule:

1. The chargeable `UnshieldAction` fallback. When a submitted unique public-key
   hash is already registered, Type-20 finalizes the shielded spend as an
   `UnshieldTransitionAction` with `chargeable_failure: true` and creates NO
   identity, crediting the invitation value to the creation-failure address
   minus a penalty (rs-drive-abci .../identity_create_from_shielded_pool/state/
   v0/mod.rs:62-128). A retry then saw the nullifier spent, fetched the
   *pre-existing* identity that owns the colliding key hash, and returned it as
   the claim's result.

2. A competing holder of the same bearer one-time key. The identity id is
   `double_sha256` over the SORTED published action nullifiers
   (`identity_id_from_nullifiers`) — derived from nullifiers only, never from
   identity keys. With two or more real spends no randomized padding action is
   added, so another holder of the same invite derives the SAME id under THEIR
   keys. The victim's retry fetched that foreign identity by the shared id and
   `platform_wallet.rs` registered it at the victim's identity index.

Recovery is now gated on two independent bindings, both required
(`recovered_identity_matches_claim`):

- id binding — the identity's id equals the id derived from THIS claim's
  published nullifiers. Consensus re-derives and rejects a mismatch, so only a
  transition publishing exactly this nullifier set can carry that id. This is
  what rejects case 1.
- key binding — the identity's ON-CHAIN key set carries this claim's submitted
  MASTER authentication key hash. This is what rejects case 2.

The key binding is checked against the keys the fetch actually returned, so an
identity fetched without public keys now fails closed instead of being topped up
with locally-submitted keys that were never proven to exist on chain.

Where the bindings cannot be established, recovery returns the new terminal
`ShieldedInviteAlreadyClaimed` (FFI `ErrorShieldedInviteAlreadyClaimed` = 32)
rather than a success or the retryable unconfirmed code. That includes the
single-spend case: the builder pads a one-action bundle to Orchard's 2-action
minimum (`num_actions = spends.len().max(2)`) and the padding action's RANDOM
dummy nullifier participates in the id derivation, so the original id is not
re-derivable on a retry and no candidate can be bound to the claim.

Also:
- The spent-nullifier preflight now hands off to the reconciler directly instead
  of falling through to rebuild+rebroadcast a transition that can only earn a
  `NullifierAlreadySpent` rejection (saves a Halo 2 proof build).
- The generic wait-failure fallback applies the key binding too, but only when
  the bundle was NOT padded: a padded build's id embeds a locally generated
  dummy nullifier no other party can reproduce, so there the id alone is proof.

Regression tests in `one_time_claim_evidence_tests` pin both attack scenarios
plus the keyless-fetch, unre-derivable-id, absent-key-hash, wrong-purpose and
different-nullifier-set cases. 7 of the 8 fail against the pre-fix rule (only
the positive-acceptance case still passes), verified by reverting the predicate
to the old accept-anything behavior.
…ene, message hygiene (#4204)

Addresses the six open CodeRabbit threads.

- `PlatformWalletPersistenceHandler.reconstructPendingIdentityKeysFromPersistence`
  wrapped a SUSPEND decryptability probe in `runCatching`, which catches
  `Throwable` and therefore swallowed `CancellationException`: a cancelled
  caller had the row misclassified as unusable and a spurious pending-repair
  entry published. Now rethrows cancellation and keeps `false` only for genuine
  probe failures, matching the convention this PR already established in
  `WalletStorage` ("NEVER swallow structured-concurrency cancellation").
  CodeRabbit missed that `PlatformWalletManager` re-swallows one frame up in a
  bare `runCatching`; that site is fixed too, since fixing only the inner one
  would not have delivered the stated behavior.

- Rename five unused `catch (e: ...)` bindings to `_` (detekt SwallowedException)
  in `WalletStorage` and `KeystoreManager`. Adjacent catches that `throw e` are
  deliberately untouched.

- Carry the one-time bearer spending key through `Zeroizing` on the remaining
  generate/derive helpers: the JNI `orchardAddressFromSpendingKey` input now uses
  `read_key32_zeroizing` (matching `oneTimeSk`), and
  `generate_one_time_orchard_key` wraps its in-loop draw so REJECTED draws are
  scrubbed too and the accepted key travels out still wrapped — which also
  covers the FFI export's early-return paths that its explicit `zeroize()` missed
  (that call is now redundant and removed).
  Note `orchard_address_from_spending_key` takes the key BY VALUE, so the
  caller-frame `Zeroizing` in `platform_wallet_orchard_address_from_spending_key`
  scrubs that frame only; this is documented at the call site rather than
  overstated as eliminating the plaintext copy.

- Strip the signer's internal machine prefix (`DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX`)
  from rendered messages on both conversion paths in `platform-wallet-ffi`. Both
  read the prefix to pick the typed code BEFORE stripping, so classification is
  unaffected, and the host-side fallback matcher keys on the human tail
  (`DashSdkError.MESSAGE_MARKER`), not the prefix.

- Fix the markdownlint MD038 trailing-space-inside-code-span in
  `KOTLIN_MIGRATION_LEFTOVERS.md` and `KOTLIN_SWIFT_SHARED_PARITY_SPEC.md`.

Also applies `cargo fmt` to the five pre-existing formatting violations in files
this PR already owns, so `cargo fmt --check` passes clean.
…-> 37 and mirror it (#4204)

32 is allocated to `ErrorTransactionBuild` (#4247, also
carried by #4256) in ERROR_CODE_REGISTRY.md (#4261). This variant took 32
without a registry row, so the two collide as a hard `E0081: discriminant
value 32 assigned more than once` the moment both land — reproduced on a
real integration merge, not hypothetical. 27-36 are all claimed (27
ErrorShutdownIncomplete via the merged #4268; 29 #4184; 31 #4183; 32/33
37 is the allocation frontier.

The code was also unmirrored on BOTH hosts, which is the more dangerous
half: Swift is exhaustive, so it surfaced as .errorUnknown and lost its
identity; Kotlin fell through to Generic(32), and in any tree carrying
"shielded invite already claimed" as "reservation wallet mismatch". That
matters on the claim-recovery path specifically — the error is raised from
four sites in shielded/operations.rs, three inside the recovery function.

Adds the typed Kotlin PlatformWallet.ShieldedInviteAlreadyClaimed (terminal,
inherited isRetryable = false), the Swift enum case + init(ffi:) arm, a
DashSdkErrorTest assertion pinning 37, and refreshes the stale Swift
reservation comment the registry asked the next toucher to drop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pplied-fallback verdict, terminal code at the FFI, Swift mirror, Orchard secret scrubbing (#4204)

Five blocking findings from the 2026-08-03 gate run, fixed on the
rebased head:

* a00cee018e73 — the two POST-BUILD `NullifierAlreadySpent` recovery
  arms (broadcast + result wait) now pass `Some(identity_id)` — the id
  THIS transition committed — instead of the pre-build
  `expected_identity_id`, which is deliberately None for a padded
  single-note bundle. The SDK's broadcast retries internally, so an
  accepted-then-lost-ack first request legitimately yields
  NullifierAlreadySpent on the wire retry; with None the reconciler
  declared our own successfully created identity permanently lost.
  `expected_identity_id` remains for the pre-build preflight, where the
  randomized padding id is genuinely unavailable.

* 8d020115b274 — the wait-path consensus-verdict arm no longer converts
  an APPLIED chargeable fallback into ShieldedBroadcastFailed (code 16,
  documented as definitive non-execution and retryable): a duplicate
  unique-key hash makes Type 20 apply the chargeable UnshieldAction —
  nullifiers consumed, fallback address credited minus the penalty —
  and its PaidConsensusError reaches the wait as a populated cause.
  The arm now verifies the selected nullifiers first; consumed notes
  route to the reconciler for the terminal claimed/fallback verdict
  (recovered success when this claim created the identity, terminal
  ShieldedInviteAlreadyClaimed for the fallback / a competing holder).

* 7be05fde0d09 — the live claim FFI export routes
  ShieldedInviteAlreadyClaimed through the blanket
  From<PlatformWalletError> conversion (code 37) before the catch-all,
  which was flattening it to the generic ErrorWalletOperation (6) and
  made the terminal consumed-invitation discriminator unreachable from
  the one API that produces it.

* 00b4b4d41758 — the Swift mirror is complete and compiles: public
  `PlatformWalletError.shieldedInviteAlreadyClaimed(String)` case,
  errorDescription coverage, and the `.errorShieldedInviteAlreadyClaimed`
  arm in `init(result:)` (the exhaustive switch previously rejected the
  new enum case). Verified with swiftc -parse.

* 1ee08ba70627 — Orchard spend-authority representations are no longer
  left unscrubbed: a `ScrubOnDrop` guard (volatile per-byte overwrite +
  fence on every exit path, gated on `needs_drop` absence with a
  tripwire test) contains the non-zeroizing `SpendingKey` /
  `SpendAuthorizingKey` in the one-time-key claim (sk dropped right
  after derivation, ask right after the bundle build — neither survives
  the network awaits), in `OrchardKeySet::from_seed`, in the one-time
  keygen acceptance loop, and in `orchard_address_from_spending_key`,
  which now also takes the scalar BY REFERENCE so callers' Zeroizing
  buffers are not repeated as plain arrays at the boundary.

platform-wallet 672/672, platform-wallet-ffi 228/228, JNI + FFI cargo
check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ency

#4277 (merged into v4.2-dev) promoted `rand = "0.8"` from a
dev-dependency to a mandatory entry in `[dependencies]`. This PR had added its
own `rand = { version = "0.8", optional = true }` to the same table for the
one-time Orchard key CSPRNG, and because the two lines sit in different parts
of the table git merged both without a textual conflict — producing a manifest
that cargo rejects outright:

    error: duplicate key
      --> packages/rs-platform-wallet/Cargo.toml:75:1
    error: failed to load manifest for workspace member
           `.../packages/rs-platform-wallet`

`cargo metadata` fails before any build starts, which is why the Kotlin SDK CI
job died in the "Building rs-unified-sdk-jni" step rather than in the tests.

`rand` is now unconditionally available, so this PR does not need to declare it
at all: remove the optional duplicate and drop the now-invalid `dep:rand` from
the `shielded` feature list (cargo rejects `dep:` on a non-optional
dependency). `shielded::keys::generate_one_time_orchard_key` keeps using
`OsRng` from the same crate at the same major version — no behaviour change.

Verified with `cargo metadata`, `cargo check -p platform-wallet` (default and
`--features shielded`) and `cargo check -p platform-wallet-ffi --features
shielded`. Cargo.lock is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Orchard-secret `ScrubOnDrop(...)` wrapping added in the review-gate
round left `keys.rs` with a `cargo fmt --check --all` drift (the
`SpendingKey::from_zip32_seed(..).map_err(..)` argument was not
re-wrapped to rustfmt's default layout). Purely cosmetic re-wrap; no
behavior change. Restores a clean `cargo fmt --check --all` so the
Formatting & Linting CI step passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ase merge) (#4204)

The Kotlin SDK native-build CI (which compiles `refs/pull/4204/merge`, i.e.
this PR merged into v4.2-dev) failed with:

    error[E0432]: unresolved import `rand`   (shielded/keys.rs)

Root cause: v4.2-dev advanced to remove `rand` from `[dependencies]` (it is now
dev-only) and to drop `log` from `[dependencies]` entirely. Commit 806d198
had removed this PR's own `rand` declaration on the (now-false) premise that
base provides `rand` unconditionally. The head still built because its
merge-base copy of those lines was present, but the 3-way merge into the
advanced base deletes them, leaving the PR's added lib code with no `rand`/`log`:

  * `shielded::keys::generate_one_time_orchard_key` uses `rand::OsRng` (shielded)
  * `identity::network::encrypted_document` uses `rand::OsRng` and the `log`
    facade (`log::debug!`/`log::warn!`) unconditionally

Fix: declare `rand = "0.8"` and `log = "0.4"` as this PR's own `[dependencies]`
inside the PR-authored comment block (a head-only region base does not have, so
it survives the merge), and align the "Standard dependencies" `rand`/`log`
lines to base's edited form so those regions merge without conflict or
duplicate keys. Manifest-only; no code or feature-gate change.

Verified by reproducing the exact CI merge locally (merge head into v4.2-dev tip
5bbd7c9) and building platform-wallet + platform-wallet-ffi with `shielded`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c31b73d7-b812-43bf-9299-c2ee40e0d710

📥 Commits

Reviewing files that changed from the base of the PR and between 122ba12 and 67758ab.

📒 Files selected for processing (1)
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs

📝 Walkthrough

Walkthrough

The PR adds one-time Orchard key generation, shielded invitation identity creation, resumable foreign-note scans, durable claim recovery, secure key handling, cross-language bindings, terminal error code 43 mappings, and signer-error message cleanup.

Changes

Shielded invitation identity creation

Layer / File(s) Summary
Orchard key material and public key APIs
packages/rs-platform-wallet/src/wallet/shielded/*, packages/rs-platform-wallet/Cargo.toml
The wallet generates and scrubs one-time Orchard keys, derives addresses, and exposes key utilities with tests.
Foreign-note scanning and coordination
packages/rs-platform-wallet/src/wallet/shielded/{coordinator,sync}.rs, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/ShieldedDao.kt
The coordinator owns claim guards and scan checkpoints. Scans reuse immutable cached progress and retrieve anchored unspent notes.
Claim operation and recovery
packages/rs-platform-wallet/src/wallet/shielded/operations.rs, packages/rs-platform-wallet/src/error.rs
The claim flow selects notes, persists transitions, broadcasts identity creation, resumes interrupted claims, and classifies nullifier and ownership outcomes.
Wallet API and native bindings
packages/rs-platform-wallet/src/wallet/platform_wallet.rs, packages/rs-platform-wallet-ffi/src/shielded_send.rs, packages/rs-unified-sdk-jni/src/funding.rs, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/{ffi,wallet}/*
Native, JNI, and Kotlin layers validate inputs, preserve secret material, invoke the claim operation, and return identity or key results.

Cross-platform error handling

Layer / File(s) Summary
Terminal claim errors and message cleanup
packages/rs-platform-wallet-ffi/src/error.rs, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift, packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt
Rust, Kotlin, and Swift map consumed invitations to terminal error code 43. Signer machine prefixes are removed from host-visible messages.
Documentation and exception cleanup
docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md, docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/*
The documented signer prefix is corrected, and unused exception bindings are removed without changing behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Mergeability Score: 🔵 Low · up to 67758

The shielded-invite claim API can defer invalid wallet identifiers to a later JNI failure instead of rejecting them at the SDK boundary. The PR is otherwise mergeable with explicit owner awareness and a follow-up to add the missing length validation.

Sequence Diagram(s)

sequenceDiagram
  participant KotlinSDK
  participant JNI
  participant PlatformWalletFFI
  participant PlatformWallet
  participant ShieldedOperations
  participant Network
  KotlinSDK->>JNI: Create identity from one-time Orchard key
  JNI->>PlatformWalletFFI: Validate and forward zeroizing inputs
  PlatformWalletFFI->>PlatformWallet: Invoke wallet operation
  PlatformWallet->>ShieldedOperations: Scan notes and execute claim
  ShieldedOperations->>Network: Broadcast identity claim
  Network-->>ShieldedOperations: Identity result or claim status
  ShieldedOperations-->>PlatformWalletFFI: Identity ID or typed error
  PlatformWalletFFI-->>KotlinSDK: Tagged result and diagnostic data
Loading

Possibly related PRs

Suggested reviewers: lklimek, llbartekll, shumkov, zocolini

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the Kotlin SDK feature and the one-time Orchard shielded-invite inviter and claim flows.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch port/v4.1/shielded-invites

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added this to the v4.2.0 milestone Aug 6, 2026
@bfoss765 bfoss765 changed the title feat(platform-wallet): shielded invites — one-time Orchard keys, claim, recovery feat(kotlin-sdk): one-time Orchard key shielded-invite API (inviter + claim) Aug 6, 2026
@thepastaclaw

thepastaclaw commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Opus deferred (commit 484233a)
Canonical validated blockers: 0

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.30%. Comparing base (f05bf82) to head (484233a).
⚠️ Report is 13 commits behind head on v4.2-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v4.2-dev    #4313      +/-   ##
============================================
- Coverage     87.49%   85.30%   -2.19%     
============================================
  Files          2672     2712      +40     
  Lines        340400   356011   +15611     
============================================
+ Hits         297819   303706    +5887     
- Misses        42581    52305    +9724     
Components Coverage Δ
dpp 86.58% <ø> (-2.29%) ⬇️
drive 84.26% <ø> (-1.93%) ⬇️
drive-abci 86.85% <ø> (-2.38%) ⬇️
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.92% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 39.27% <ø> (-8.76%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@thepastaclaw

Copy link
Copy Markdown
Collaborator

@bfoss765 I diagnosed the current Rust workspace tests / Tests failure: cargo-machete rejects the direct log dependency added by 4390cd7 because current rs-platform-wallet sources no longer use the log facade. rand is still required at runtime.

I amended the introducing commit to remove only the unused log dependency, correct the nearby explanation, and preserve rand. Validation passes:

  • cargo-machete
  • cargo metadata --no-deps
  • cargo check -p platform-wallet --features shielded --locked
  • git diff --check

Replacement head: thepastaclaw@1ea5340 (branch thepastaclaw:tracker-2629).

I cannot update dashpay:port/v4.1/shielded-invites directly because this account has triage-only permissions. Please replace the current head 4390cd7a68a5331f5a3c64fd40cf13459d863c18 with the replacement commit above. Once the PR head changes, CodeRabbit should be re-triggered on the new head.

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

At exact head 4390cd7, all three carried-forward predecessor findings remain valid: two blocking claim recovery/classification defects and one full-history scan suggestion. The full current-PR range adds one genuinely new blocker—the unused direct log dependency fails the mandatory dependency audit; no predecessor finding is fixed, outdated, or deferred, and there are no exceptional out-of-scope follow-ups.
Source: reviewers codex/general=gpt-5.6-sol(completed); codex/security-auditor=gpt-5.6-sol(completed); codex/ffi-engineer=gpt-5.6-sol(completed); verifier=codex/verifier=gpt-5.6-sol(completed); coordinator=openclaw-agent/cliproxy/gpt-5.6-sol(orchestration-only)..

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Opus: not run (deferred by blocker gate)

🔴 3 blocking | 🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1930-1933: Persist a recovery path before returning an unconfirmed claim
  The one-time claim broadcasts the constructed transition at lines 1765-1774 without durably recording its serialized bytes, exact identity ID, selected nullifiers, submitted key bindings, or identity index. If execution succeeds but confirmation fails, this branch returns the only exact ID through the transient FFI/JNI result; process death or Kotlin cancellation during the synchronous native handoff can discard it, and `poke_sync_on_unconfirmed` has no foreign-key claim record to reconcile. A normal single-note retry cannot reproduce that ID because the randomly generated padding nullifier participates in it (`expected_identity_id` is `None` at lines 1708-1715), so the spent-nullifier preflight reaches terminal `ShieldedInviteAlreadyClaimed` at lines 3079-3089 even when this wallet's original transition created the identity. Persist sufficient pending-claim metadata before broadcast and automatically reconcile or re-drive the byte-identical transition after cancellation or restart.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1859-1871: Do not classify an applied chargeable fallback as retryable broadcast failure
  This consensus-verdict branch recognizes an applied Type-20 chargeable fallback only when a separate nullifier query returns a positive spent status. `any_nullifier_spent_on_chain` maps `Ok(None)` and every transport, query, or proof error to `false` at lines 2896-2907, so a fallback that already consumed the invitation and credited the failure address can still be returned as `ShieldedBroadcastFailed`. Native code 16 and Kotlin explicitly describe that outcome as definitive non-execution and retryable, which is false after the fallback has applied. Even when the query succeeds, a collision on a submitted unique key other than MASTER leaves no identity under either the MASTER-key lookup or the transition's derived ID, causing the reconciler at lines 3092-3159 to return `ShieldedBroadcastUnconfirmed` instead of the terminal fallback result. Preserve unknown nullifier status separately from unspent status and classify the authenticated chargeable verdict without assuming the colliding key was MASTER.

In `packages/rs-platform-wallet/Cargo.toml`:
- [BLOCKING] packages/rs-platform-wallet/Cargo.toml:75: Remove the unused `log` dependency so cargo-machete passes
  The exact reviewed head adds `log = "0.4"` as a direct runtime dependency, but no source in `rs-platform-wallet` imports or references the `log` facade; the nearby comment refers to an `identity::network::encrypted_document` module that is not present in the current crate. Both Rust CI workflows run `cargo machete`, and current PR comment 5199766972 confirms that this dependency causes the workspace test failure. The proposed replacement commit 1ea5340c7f998425e91e21eb05ec3fca9f0823eb removes it, but that commit is not the authoritative reviewed head. Remove `log` and its lockfile entry while retaining `rand`, which the current library code uses.

In `packages/rs-platform-wallet/src/wallet/shielded/sync.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/sync.rs:835-860: Unfunded invitation keys force an unbounded full-history scan
  Every syntactically valid foreign invitation key starts the proof-verified note stream at position zero with no cancellation token, chunk limit, or total-work budget. The only early exit is accumulating the requested denomination, so a valid but unfunded key downloads, verifies, and trial-decrypts the complete shielded history through the current tip. This attacker-controlled work grows with the pool and can be repeated to consume bandwidth, CPU, battery, memory, and a JNI worker; the supplied birth-height remains advisory only, and Kotlin coroutine cancellation cannot interrupt the synchronous native scan. Add native cancellation with a resumable work budget, an authenticated starting position, or another strict per-invitation bound.

Comment thread packages/rs-platform-wallet/src/wallet/shielded/operations.rs
Comment thread packages/rs-platform-wallet/src/wallet/shielded/operations.rs Outdated
Comment thread packages/rs-platform-wallet/Cargo.toml Outdated
Comment thread packages/rs-platform-wallet/src/wallet/shielded/sync.rs Outdated
QuantumExplorer and others added 2 commits August 6, 2026 12:44
cargo-machete rejects the direct log dependency: no source in
rs-platform-wallet uses the log facade (breadcrumbs go through tracing;
the JNI layer bridges tracing, not log). rand stays — OsRng/RngCore back
generate_one_time_orchard_key and the contact-info ephemeral keys.

Addresses #4313 review finding f3fd60d83554.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ier status for one-time claims

Two claim-lifecycle fixes for identity_create_from_one_time_key
(#4313 review findings c0781f9d387f and 8d020115b274):

Pending-claim record (persist-first, fail-closed). The claim now arms a
persisted record — byte-exact transition, declared identity id,
nullifiers, anchor — BEFORE broadcast, keyed deterministically by the
one-time FVK under a reserved claim-records subwallet
(ONE_TIME_CLAIM_RECORDS_ACCOUNT = u32::MAX, unreachable by the ZIP-32
hardened range and never visited by the spend-redrive sync pass). A
retry after process death or JNI cancellation resumes from the record:
spent notes reconcile against the DECLARED id (recoverable even for a
padded single-note bundle, whose id embeds an unreproducible random
dummy nullifier), unspent notes re-drive the byte-identical transition,
and a definitively-rejected record with proven-unspent notes is cleared
so a fresh build proceeds in the same call. Records clear on terminal
outcomes (success / ShieldedInviteAlreadyClaimed) and survive
Unconfirmed — the outcome whose retry needs them. Arming failure aborts
before broadcast (Persistence error): nothing is consumed yet, and
broadcasting without the record risks an unrecoverable
ShieldedInviteAlreadyClaimed.

Tri-state nullifier status. any_nullifier_spent_on_chain collapsed
query errors, absent responses, and partial coverage to "unspent",
letting an applied Type-20 chargeable fallback surface as
ShieldedBroadcastFailed — documented to hosts as definitive
non-execution and safe to retry. nullifier_spent_status now returns
Spent/Unspent/Unknown; the consensus-verdict wait arm classifies
ShieldedBroadcastFailed only on proven-Unspent, returns Unconfirmed on
Unknown, and on proven-Spent hands the reconciler spend_finalized
evidence so the nothing-found outcome is the terminal chargeable
fallback / competing claim — correct even when the colliding unique key
was not MASTER and no identity is findable under either probe. The
pre-broadcast preflight still proceeds on Unknown (safe: the idempotent
broadcast path reconciles via the NullifierAlreadySpent verdict).

The broadcast/wait/classify tail is shared between the fresh and resume
paths (broadcast_and_confirm_one_time_claim).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bfoss765 and others added 3 commits August 12, 2026 08:46
…h is resolvable

Handle 2 of recover_executed_one_time_claim reported every binding failure as
'belongs to another holder of the one-time key', but
recovered_identity_matches_claim also fails closed when master_key_hash is
None (no MASTER auth key submitted, or public_key_hash() errored for an
unusual key type) — before it inspects any binding. In that case the key
binding can never be established, which is not evidence of a competing
holder. ShieldedInviteAlreadyClaimed is terminal, so this reason text is the
only diagnostic the user gets for a permanently unclaimable invitation.

Distinguish the None case: still terminal (a retry resubmits the same key
set), but the reason now says ownership cannot be verified rather than
misattributing the identity to another holder. The outcome doc gains the new
cause.

Addresses #4313 review thread at operations.rs:3567 (CodeRabbit
cr-comment c96e9b63c7a921f8d43c57ac).
…-local resume checkpoint

scan_notes_for_foreign_key (the L2-invitation claim path) restarted the
proof-verified note stream at position zero on every call, with value
coverage as the only early exit — so a syntactically valid but UNFUNDED
invitation key (attacker-controlled input) forced a full-history download,
verify, and trial-decrypt of the entire shielded pool on every attempt,
repeatable at will (#4313 review finding d19c5cf84a9f).

The tree exposes no height-to-position oracle (a chunk's block_height is the
proof-tip height, not per-note inclusion height), so the invitation's
birth-height hint cannot seed the scan start, and any budget that stops short
of the tip would misreport a deep-but-valid invite as unfunded. Bound the
REPEAT instead of the coverage: a process-local checkpoint keyed by
sha256(domain-tag || one-time FVK) records how far the tree has been covered
for each key plus the notes found on that covered prefix. The first scan for
a key still covers the full history from position 0 (funds-safety: a resumed
scan can never miss a note a from-zero scan would have found), and every
later scan for the same key resumes past the immutable full chunks it
already covered — one full-history scan per key per process, after which each
retry pays only new tree growth plus the mutable buffer chunk.

Mechanics:
- The resume position advances past full chunks only, and is held AT a
  partial (buffer) chunk's start_index — the same resume rule the subwallet
  sync applies via ShieldedChunkBatch::is_partial — because that chunk can
  still receive notes. Buffer-chunk notes are never carried in the
  checkpoint, so the rescan cannot duplicate them.
- The resume position is re-aligned DOWN to the on-chain MMR chunk boundary
  (CHUNK_SIZE) on use, so a resume can only over-scan, never skip.
- Progress is checkpointed on every exit path, including a mid-scan stream
  error, so an interrupted retry resumes rather than restarting.
- The map is LRU-bounded (8 keys); hostile key churn cannot pin memory, and
  an evicted key merely re-pays its own full scan. Deliberately process-local:
  no persisted state to invalidate.

Native cancellation of the synchronous JNI scan remains a follow-up (it is a
JNI-surface change); this closes the repeat-amplification path, complementing
c6f4aa7 (broadcast-claim retries already skip the transient rescan via the
durable pending-claim record).

Adds unit tests for the checkpoint carry/drop rule and the map's
take/save/evict semantics.

Addresses #4313 review thread at sync.rs:860 (codex finding d19c5cf84a9f).
… errors

A null/wrong-length changeAddressRaw43 was reported as recipientRaw43 —
a parameter that entry point does not have. Give the helper a field
parameter, mirroring read_id32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet/src/wallet/shielded/sync.rs`:
- Around line 863-871: Replace take_foreign_scan_checkpoint-based ownership with
per-key in-flight scan state so concurrent scan_notes_for_foreign_key callers
await the existing scan instead of restarting from position zero. Preserve or
monotonically update each key’s checkpoint when the scan completes, including
cancellation-safe cleanup, and never hold a std::sync::Mutex guard across an
await.
- Around line 945-949: Update foreign_scan_checkpoint_key and its callers in the
foreign scan flow to include sdk.network alongside the FVK, ensuring
process-global checkpoints are isolated per SDK network. Add a test that uses
the same FVK across two networks and verifies their checkpoints are not reused.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d908b187-a55e-49e2-a6ea-8977773087d2

📥 Commits

Reviewing files that changed from the base of the PR and between c6f4aa7 and 1e78575.

📒 Files selected for processing (4)
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/rs-platform-wallet/src/wallet/shielded/sync.rs
  • packages/rs-unified-sdk-jni/src/funding.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/rs-unified-sdk-jni/src/funding.rs

Comment thread packages/rs-platform-wallet/src/wallet/shielded/sync.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/shielded/sync.rs

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

The byte-exact pending record and tri-state nullifier classification fix the three prior blockers, but the recovery guarantee is still defeated by concurrent claims that can replace each other's record before either broadcast completes. The new process-global scan cache also crosses network boundaries, while the carried-forward first-scan work-bound issue remains unresolved.
Source: Codex reviewers gpt-5.6-sol (general), gpt-5.6-sol (security-auditor), gpt-5.6-sol (rust-quality), and gpt-5.6-sol (ffi-engineer); final verifier gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and is not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 1 suggestion(s)

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1681-1839: Serialize concurrent claims before replacing their recovery record
  There is no per-invitation single-flight guard around the pending-record lookup, transition construction, arming, broadcast, and finalization. Two calls for the same FVK can both observe no record, and a single-note build gives each call a different random padding nullifier and therefore a different identity ID. `arm_one_time_claim_record` later uses `arm_redrive`, whose file-store implementation performs `INSERT OR REPLACE`, so the second call overwrites the first call's byte-exact recovery record. If the first transition executes and its result is lost, only the second transition's ID remains; recovery cannot bind the first identity, and either caller can also clear the shared row while the other is still active. The same lack of coordination makes `take_foreign_scan_checkpoint` remove scan progress while another same-key call starts from zero. Hold a process-wide per-FVK guard across the complete claim lifecycle, not merely around individual store or checkpoint map operations.

In `packages/rs-platform-wallet/src/wallet/shielded/sync.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/sync.rs:851-857: Scope foreign-key scan checkpoints to the Platform chain
  `FOREIGN_SCAN_CHECKPOINTS` is process-global, but its key hashes only the FVK. If that FVK is scanned through a large position on one network and then claimed through another SDK in the same process, the second scan reuses the foreign resume position and cached notes. It can skip a funded note at an earlier position on the actual network or attempt to use notes from the wrong tree. Include a stable chain identity in the key or move the cache into a network/coordinator-scoped owner. `sdk.network` separates mainnet, testnet, devnet, and regtest, but a coordinator or chain discriminator is also needed if multiple distinct devnets can coexist under `Network::Devnet`.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/sync.rs:984-1028: Unfunded invitation keys force an unbounded full-history scan
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3725876429)
  The process-local checkpoint reduces repeat work only after a scan for the same FVK completes and remains among the eight cached entries. Every fresh, evicted, or post-restart invitation key still starts at position zero and consumes the proof-verified stream through the tip when it is unfunded or underfunded. There is no native cancellation input, chunk budget, total-work limit, or retained-note byte/count limit, so an untrusted caller can rotate valid keys and repeatedly consume bandwidth, proof-verification CPU, memory, battery, and synchronous JNI workers. Add native cancellation with a strict resumable work and retained-data budget, an authenticated starting position, or another bound that applies to the first scan for every invitation.

Comment on lines +1681 to +1839
if let Some(record) =
find_one_time_claim_record(store, claim_records_id, claim_record_key).await?
{
match resume_one_time_claim(
sdk,
store,
claim_records_id,
&record,
master_key_hash,
submitted_public_keys.clone(),
denomination,
)
.await
{
OneTimeClaimResume::Resolved(result) => {
finalize_one_time_claim_record(store, claim_records_id, claim_record_key, &result)
.await;
return result;
}
// The stored transition is unusable (corrupt, or definitively
// rejected while its notes are provably unspent) — the record has
// been cleared; build a fresh claim below.
OneTimeClaimResume::RecordUnusable => {}
}
}

// Transient scan: re-derive the one-time key's note(s) from the network.
let discovered = super::sync::scan_notes_for_foreign_key(sdk, &fvk, &ivk, denomination).await?;
if discovered.is_empty() {
// No note decrypts under this key — nothing was funded to it (or the
// wallet hasn't synced far enough to see it yet).
return Err(PlatformWalletError::ShieldedNoUnspentNotes);
}

// Exact-equality selection over the transiently-scanned set: cover exactly
// `denomination`, gate on `denomination > predicted_fee`. Surfaces
// `ShieldedInsufficientBalance { available, required }` when the key's notes
// don't cover the denomination, mirroring the pool-funded neighbor.
let (selected_refs, total_input, predicted_fee) =
select_notes_for_denomination(&discovered, denomination, 2, num_keys, sdk.version())?;
let selected_notes: Vec<ShieldedNote> = selected_refs.into_iter().cloned().collect();

info!(
denomination,
predicted_fee,
inputs = selected_notes.len(),
total_input,
keys = num_keys,
"IdentityCreateFromOneTimeKey"
);

// Idempotent-retry preflight (no persisted record for this key). If this one-time key's
// selected note(s) are ALREADY spent on chain, a byte-identical claim already
// executed — so we must NOT rebuild+rebroadcast (that would only earn a
// `NullifierAlreadySpent` rejection). Everything checked here is re-derived
// from the invite the invitee holds: the one-time key → its note(s) via the
// transient scan above, and each note's real nullifier (`ShieldedNote.nullifier`,
// stamped `note.nullifier(fvk)` during the scan). If spent, recover the
// previously-created identity by the invitee's own re-derivable MASTER auth key
// hash (`discover_inner`'s unique-hash probe) and return it as success.
let selected_nullifiers: Vec<[u8; 32]> = selected_notes.iter().map(|n| n.nullifier).collect();

// The id that an identity created by THIS claim must carry — the single
// handle that ties a recovered identity back to this claim's spend, and the
// reason a MASTER-key-hash hit alone is not evidence of a successful claim
// (see `recovered_identity_matches_claim`).
//
// Consensus derives the new identity id as `double_sha256` over the SORTED
// set of PUBLISHED action nullifiers (`derive_identity_id_from_actions`) and
// rejects a transition whose declared id differs, so this is a binding, not a
// guess.
//
// `None` for a single-spend claim: the builder pads to Orchard's 2-action
// minimum (`num_actions = spends.len().max(2)`) and the padding action's
// dummy nullifier is randomly generated per build, so it participates in the
// derivation but cannot be reproduced on a retry. With two or more real
// spends no padding is added and the published set is exactly
// `selected_nullifiers`.
let expected_identity_id =
(selected_notes.len() >= 2).then(|| identity_id_from_nullifiers(&selected_nullifiers));

// Idempotent-retry preflight. If this one-time key's selected note(s) are
// ALREADY spent on chain, this claim can never execute — rebuilding and
// rebroadcasting would only earn a `NullifierAlreadySpent` rejection and burn
// a Halo 2 proof. Hand off to the reconciler, which decides between "this
// claim created that identity" (both bindings verified), "the invitation is
// gone" (terminal), and "executed but not yet indexed" (retryable).
// `Unknown` proceeds here — that is safe pre-broadcast: the idempotent
// broadcast path reconciles via the `NullifierAlreadySpent` verdict, so a
// transient query failure only costs a harmless rebuild.
if nullifier_spent_status(sdk, &selected_nullifiers).await == NullifierSpentStatus::Spent {
return recover_executed_one_time_claim(
sdk,
master_key_hash,
expected_identity_id,
false,
"the selected note's nullifier is already spent on chain (pre-broadcast preflight)",
)
.await;
}

// Witness the selected notes against a Platform-recorded anchor from the
// shared, fully-marked commitment tree (identical probe to the pool op).
let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?;
let anchor_bytes = anchor.to_bytes();

let build = build_identity_create_from_shielded_pool_transition(
public_keys,
denomination,
send_to_address_on_creation_failure,
spends,
change_address,
&fvk,
&ask,
anchor,
prover,
identity_signer,
[0u8; 36],
sdk.version(),
)
.await
.map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?;
// The spend-auth key's final use (the bundle build + spend-auth
// signatures above) is behind us — scrub it before the broadcast and
// result wait keep this frame alive across the network.
drop(ask);

let identity_id = build.identity_id;

// Re-assemble the transition from the PoP-signed keys + bundle params
// (preserving the per-key signatures) and broadcast. The broadcast/wait
// classification mirrors `identity_create_from_shielded_pool` verbatim, minus
// the note-reservation bookkeeping (there is no subwallet reservation to
// release — the spent notes belong to the foreign one-time key).
let st = sdk
.identity_create_from_shielded_pool_transition(
build.public_keys,
denomination,
send_to_address_on_creation_failure,
build.bundle,
)
.map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?;

// Persist the pending-claim record BEFORE the broadcast (#4204 review
// finding c0781f9d387f): once the transition leaves this process, the
// declared id — the only handle that recovers a padded single-note claim —
// must already be durable. Fail-closed: nothing has been consumed yet, so
// refusing to broadcast on a persistence failure is a clean, retryable
// stop; broadcasting without the record risks an unrecoverable
// `ShieldedInviteAlreadyClaimed` on the next attempt.
arm_one_time_claim_record(
store,
claim_records_id,
claim_record_key,
anchor_bytes,
&selected_nullifiers,
&st,
)
.await?;

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.

🔴 Blocking: Serialize concurrent claims before replacing their recovery record

There is no per-invitation single-flight guard around the pending-record lookup, transition construction, arming, broadcast, and finalization. Two calls for the same FVK can both observe no record, and a single-note build gives each call a different random padding nullifier and therefore a different identity ID. arm_one_time_claim_record later uses arm_redrive, whose file-store implementation performs INSERT OR REPLACE, so the second call overwrites the first call's byte-exact recovery record. If the first transition executes and its result is lost, only the second transition's ID remains; recovery cannot bind the first identity, and either caller can also clear the shared row while the other is still active. The same lack of coordination makes take_foreign_scan_checkpoint remove scan progress while another same-key call starts from zero. Hold a process-wide per-FVK guard across the complete claim lifecycle, not merely around individual store or checkpoint map operations.

source: ['codex', 'coderabbit']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6668061 — coordinator-owned ForeignClaimGuards: identity_create_from_one_time_key now holds a per-FVK async mutex across the COMPLETE lifecycle (pending-record lookup, transient scan, transition construction, atomic arming, broadcast, finalization), so concurrent same-key claims serialize and the loser resumes the settled durable record instead of overwriting it through arm_redrive's INSERT-OR-REPLACE. The registry holds Weak handles — cancellation releases on drop, dead keys prune on the next acquisition — and lives on the SAME coordinator that owns the pending-record store it protects (one coordinator per network + tree store), so everything that can race one invitation's record serializes through one instance. The take-based checkpoint race is gone with it (see the sync.rs threads). Tests: same-key single-mutex identity, holder-blocks-second-caller, cancellation release, dead-entry prune. 865 platform-wallet lib tests green.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 6668061061 ("single-flight the one-time-key claim per FVK"), which landed after this comment.

ForeignClaimGuards provides coordinator-owned per-FVK guards, taken via claim_guards.entry_for(claim_record_key) before the record lookup, so the whole lookup → build → arm → broadcast → finalize lifecycle for one invitation is serialised. Two calls for the same FVK can no longer both observe no record and each build a single-note transition with a different random pad.

Covered by foreign_claim_guard_tests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6668061061 — there is now a per-FVK single-flight guard (ForeignClaimGuards, coordinator-owned) held for the whole claim body, not just around the record write, so a second claim for the same key cannot interleave with the first's scan → build → arm → broadcast sequence at all. The guard is acquired at operations.rs before any scanning work begins (#4313 review finding 979bbc2fcb3c).

Coordinator-owned rather than a static: the guards must have the same lifetime and scope as the checkpoint cache they serialize against, and a process-global map would outlive the coordinator that armed the records.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right, and fixed at the storage layer in 96f3139. The per-FVK mutex is per-coordinator and the claim lease is per-wallet, so neither is mutual exclusion between two claimants of one invitation.

Added ShieldedStore::reserve_one_time_claim_key, which reserves (wallet_id, claim_record_key) and returns Acquired or Held { holder, expires_at } read back from the durable row. In FileBackedShieldedStore that's a new shielded_one_time_claim_reservation table with that PRIMARY KEY, written by INSERT ... ON CONFLICT DO NOTHING inside BEGIN IMMEDIATE — never OR REPLACE, so losing the insert leaves the winner's row untouched — followed by a read-back of the durable row in the same transaction. SQLite's one-writer rule across every connection and process makes "exactly one Acquired" a total order rather than a probability.

arm_redrive_under_claim additionally refuses, writing nothing, when a live reservation under a different token covers the key — checked in the same transaction that would have done the INSERT OR REPLACE. That makes the clobber structurally impossible rather than merely unreachable. Ordinary spend redrives take no reservation, so the gate is a no-op for them.

A losing claimant now RESUMEs or REJECTs as you specified: the durable pending-record resume runs first and returns as usual; if no record exists yet the claim stops with the retryable ShieldedLifecycleBusy before the transient scan, so nothing is scanned, built or broadcast. The in-process guard stays exactly where it was, as the fast path. The reservation is bound to its lease for its whole life — re-stamped on renewal and on arm, released with the lease in one transaction, otherwise reaped by expiry.

two_store_instances_cannot_both_claim_one_invitation is the race test: two FileBackedShieldedStore handles on one file, both admitted by the per-wallet lease, one acquires, the loser is handed the winner's token, the loser's arm is refused, and the winner's st_bytes survive byte-for-byte across a cold reopen. Plus a_claim_key_reservation_lives_and_dies_with_its_lease, the_claim_key_gate_leaves_unreserved_redrives_alone, and only_one_claimant_acquires_an_invitations_claim_key for in-memory parity.

Comment thread packages/rs-platform-wallet/src/wallet/shielded/sync.rs
bfoss765 and others added 2 commits August 12, 2026 18:41
Carries #4380 (dashpay profile payment addresses, breaking dpp) + #4381
(rust-dashcore pin bump) + the shield-preflight family. Resolutions:

- platform_wallet.rs / shielded_send.rs: false overlaps — keep BOTH our
  identity_create_from_one_time_key surface and v4.2-dev's
  shielded_shield_preflight/plan additions.
- Error-code collision: v4.2-dev allocated 37-40 to the DPNS marketplace
  block and 41 to the shield-capacity shortfall, colliding with our
  ErrorShieldedInviteAlreadyClaimed = 37. Renumbered ours to 43 on every
  surface (Rust FFI enum, Kotlin arm + test pin, Swift raw value), the
  allocation the integration branch already ships in QA AARs (42 stays
  reserved to match it).
- DashSdkError.kt / PlatformWalletResult.swift: keep both sides' new
  error classes/cases, ours renumbered and ordered after the v4.2-dev
  blocks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cope scan checkpoints to the coordinator

Closes the two #4313 review clusters on the claim path:

- ForeignClaimGuards (coordinator-owned): every
  identity_create_from_one_time_key holds a per-FVK async mutex across
  the COMPLETE lifecycle — pending-record lookup, transient scan,
  transition construction, atomic arming, broadcast, finalization
  (finding 979bbc2fcb3c). Concurrent same-key claims serialize instead
  of racing arm_one_time_claim_record's INSERT-OR-REPLACE and
  overwriting each other's byte-exact recovery row. Weak-handle
  registry: cancellation releases on drop, dead keys prune on the next
  acquisition.
- ForeignScanCheckpointCache replaces the process-global
  FOREIGN_SCAN_CHECKPOINTS static: owned by NetworkShieldedCoordinator
  (one network + one tree store), so a resume position can never leak
  across chains — including two devnets sharing Network::Devnet
  (findings 6118148e4547 / cr-4d2aa8ce). load() clones instead of
  removing and save() is monotonic, so a claim cancelled mid-scan
  leaves the previous checkpoint intact instead of destroying it
  (finding cr-4808dde4); no sync mutex guard is ever held across an
  await.

Tests: guard identity/serialization/cancellation-release/prune;
cache load-no-remove, monotonic save, LRU eviction, and the
cross-instance isolation shape CodeRabbit requested. 865 platform-wallet
lib tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt (1)

1601-1611: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the walletId length check.

Every sibling method in this class validates walletId.size == 32 before the native call (see bindShielded at Line 1357 and removeWallet at Line 959). This method omits it. The JNI read_id32 still rejects a wrong length, so the failure is safe, but it surfaces as a native exception instead of a local precondition.

♻️ Proposed precondition
     ): ByteArray = teardownGate.op {
+        require(walletId.size == 32) { "walletId must be exactly 32 bytes, got ${walletId.size}" }
         require(oneTimeSk.size == 32) { "oneTimeSk must be 32 bytes, got ${oneTimeSk.size}" }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt`
around lines 1601 - 1611, Add a local precondition in the teardown operation
alongside the existing argument checks to require walletId.size == 32, matching
sibling methods such as bindShielded and removeWallet, before the native call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- Around line 2177-2200: Update purge_all_subwallets and purge_wallet to exclude
records keyed by ONE_TIME_CLAIM_RECORDS_ACCOUNT from destructive deletion.
Preserve those durable pending-claim rows during clear and unregister_wallet
while continuing to purge all other subwallet records as before.

---

Nitpick comments:
In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt`:
- Around line 1601-1611: Add a local precondition in the teardown operation
alongside the existing argument checks to require walletId.size == 32, matching
sibling methods such as bindShielded and removeWallet, before the native call.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ef76dc24-cd1c-4769-aab5-335a082cca3f

📥 Commits

Reviewing files that changed from the base of the PR and between f05bf82 and 6668061.

📒 Files selected for processing (21)
  • docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md
  • docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/ShieldedDao.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet/Cargo.toml
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
  • packages/rs-platform-wallet/src/wallet/shielded/keys.rs
  • packages/rs-platform-wallet/src/wallet/shielded/mod.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/rs-platform-wallet/src/wallet/shielded/sync.rs
  • packages/rs-unified-sdk-jni/src/funding.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift
🚧 Files skipped from review as they are similar to previous changes (12)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt
  • packages/rs-platform-wallet/src/wallet/shielded/mod.rs
  • packages/rs-platform-wallet/Cargo.toml
  • docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/ShieldedDao.kt
  • packages/rs-platform-wallet/src/error.rs
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt
  • docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt
  • packages/rs-platform-wallet/src/wallet/shielded/keys.rs
  • packages/rs-unified-sdk-jni/src/funding.rs

Comment thread packages/rs-platform-wallet/src/wallet/shielded/operations.rs
cargo fmt --check --all gates the Rust workspace tests job; 1e78575
left this line over-width, failing the job in 51s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bfoss765

Copy link
Copy Markdown
Collaborator Author

Status for a resolution pass — head `122ba12b2`. All five open threads addressed with inline replies:

  • `shielded/sync.rs` (unfunded foreign-key scan) — bounded process-local checkpoint, `2dc71c9def`; native-cancellation follow-up deferred per the earlier thread agreement.
  • `shielded/sync.rs:989` (take-vs-clone ownership) — new, `6668061061` (load() clones, save() monotonic, no guard across await).
  • `shielded/operations.rs:1907` (serialize concurrent claims, Blocking) — new, `6668061061` (coordinator-owned per-FVK async mutex over the full claim lifecycle).
  • `shielded/sync.rs:921` (chain-scoped checkpoints) — new, `6668061061` (coordinator-scoped cache; devnet isolation test).
  • `shielded/operations.rs:2200` — see inline reply.

Merge conflict with v4.2-dev resolved (`c74ff1859c`); `ErrorShieldedInviteAlreadyClaimed` renumbered 37→43 to avoid the new DPNS/shield-capacity codes. platform-wallet 865 + FFI 286 tests passing. Ready to resolve where you concur — thanks.

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

At exact head 122ba12, coordinator-scoped scan checkpoints correctly fix cross-chain reuse, but durable claim recovery remains blocked by three lifecycle gaps: claim serialization is coordinator-local, destructive purges can race an armed claim, and recovery is not bound to the original identity slot and keys. The first-scan work-bound issue remains intentionally deferred by the PR discussion.
Source: reviewer backend gpt-5.6-sol; final verifier backend gpt-5.6-sol. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking

1 additional finding(s) omitted (not in diff).

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:1004-1027: Prevent Clear from deleting an armed claim before broadcast completes
  Claim execution holds a per-FVK mutex, while `clear` and `unregister_wallet` hold the separate coordinator lifecycle mutex. After `arm_one_time_claim_record` releases the store lock, either purge path can acquire that lock and delete the reserved pending-claim row before or during broadcast. The transition may then execute after its only byte-exact recovery record has been erased, making a lost result or process failure unrecoverable for a padded single-note claim. Destructive lifecycle operations must wait for admitted claims, or preserve pending-claim records until those claims settle; the same admission boundary must also account for separate coordinators that share the durable store.

In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:1592-1624: Bind a resumed claim to its original keys and identity slot
  The pending record is selected only by wallet and FVK, and it persists neither `identity_index` nor the original key binding. On resume, the stored transition is rebroadcast while recovery receives the current call's `master_key_hash`, `submitted_public_keys`, and `denomination`; afterward the returned identity is registered at the current call's `identity_index`. A retry with different arguments can therefore classify the original identity as belonging to another holder and clear the record, backfill an empty proof result with keys that were not in the stored transition, or register the original identity under the wrong local HD slot. Persist the original slot and claim metadata, then reject mismatched retry arguments or derive the submitted keys from the serialized transition before broadcasting, finalizing, or registering anything.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1731-1733: Serialize concurrent claims before replacing their recovery record
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3767229122)
  The new mutex serializes claims only within one `NetworkShieldedCoordinator`. Every `PlatformWalletManager` owns a separate coordinator slot, and separate managers can configure independent coordinators against the same network and SQLite path. Their `ForeignClaimGuards` are unrelated, and each file-backed store maintains its own in-memory `pending_redrives` map, so one coordinator does not even observe a row another coordinator inserted after opening. Two manager handles can therefore both find no record, build different padded transitions, and race the shared table's `INSERT OR REPLACE`, replacing the first transition's only byte-exact recovery record. Scope single-flight ownership to the durable store/chain rather than an individual coordinator, or claim the record atomically in SQLite and make lookup observe the durable row.

Comment on lines +1592 to +1624
super::shielded::operations::identity_create_from_one_time_key(
&self.sdk,
coordinator.store(),
coordinator.foreign_claim_guards(),
coordinator.foreign_scan_checkpoints(),
self.wallet_id,
one_time_sk,
funding_birth_height,
&change_address,
public_keys,
denomination,
send_to_address_on_creation_failure,
identity_signer,
&prover,
)
.await?;

// Register the proof-verified identity in the local manager at its HD
// slot — the SAME tail as `shielded_identity_create_from_pool`. The
// broadcast already succeeded; a registration failure here is logged and
// swallowed (the identity exists on chain; the next sync heals the row).
{
let mut wm = self.wallet_manager.write().await;
match wm.get_wallet_info_mut(&self.wallet_id) {
Some(info) => {
if let Err(e) = info.identity_manager.add_identity(
identity,
identity_index,
self.wallet_id,
&self.persister,
) {
tracing::warn!(
identity_index,

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.

🔴 Blocking: Bind a resumed claim to its original keys and identity slot

The pending record is selected only by wallet and FVK, and it persists neither identity_index nor the original key binding. On resume, the stored transition is rebroadcast while recovery receives the current call's master_key_hash, submitted_public_keys, and denomination; afterward the returned identity is registered at the current call's identity_index. A retry with different arguments can therefore classify the original identity as belonging to another holder and clear the record, backfill an empty proof result with keys that were not in the stored transition, or register the original identity under the wrong local HD slot. Persist the original slot and claim metadata, then reject mismatched retry arguments or derive the submitted keys from the serialized transition before broadcasting, finalizing, or registering anything.

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 847f0a1 — the resume now DERIVES its binding from record.st_bytes and demotes the call's arguments to assertions, refusing outright when they disagree.

I evaluated both options you offered and went with derive-plus-reject rather than persisting the metadata:

  • The transition already is the binding. Its public_keys are exactly what the binding signature committed to and its denomination is the value that leaves the pool, so deriving needs no record-schema change and — unlike a separately persisted copy — cannot drift from what actually went on the wire. Resuming means re-broadcasting those bytes, so the identity that results belongs to those keys, and reading them from anywhere else is the bug.
  • Persisting would mean adding a field to PendingRedrive (shared with the ordinary spend-redrive path, where it is meaningless) plus an ALTER TABLE on shielded_pending_spends, for a value that is already recoverable.

What happens now. resume_one_time_claim rebuilds submitted_public_keys, the MASTER auth key hash and denomination from the deserialized transition. one_time_claim_binding_mismatch compares them against the caller's, and any disagreement returns the new PlatformWalletError::ShieldedClaimBindingMismatchbefore the spent-nullifier probe and before the re-broadcast. Nothing is resubmitted, no proof is burned, and finalize_one_time_claim_record does not clear on this variant, so the record survives for a retry that presents the original arguments. Past the gate the derived values are what drive recovery, the empty-proof-result backfill and the broadcast, so the transition stays the source of truth even if the check is ever relaxed.

Key comparison is whole-set and by content, not by id — IdentityPublicKey's Eq covers purpose, security level, key type, read-only, contract bounds and key data — so the dangerous shape (same key ids, swapped key material, which is what would register a foreign identity at this wallet's slot) is caught. Pure reordering is not a mismatch; both sides are BTreeMaps keyed by key id.

identity_index, explicitly. It is a purely local DIP-9 placement and appears nowhere in the transition, so there is nothing in st_bytes to derive it from. It is bound transitively: the identity's keys are derived from the wallet seed at that slot, so a retry naming a different slot presents different keys and is refused by the check above. That leaves exactly one uncovered case — a caller pairing slot i with keys derived at slot j — which violates the same caller contract a first attempt relies on and mis-slots identically. So the resume path is now no weaker than a fresh claim, which is the strongest guarantee available without persisting the slot. This is stated in the resume_one_time_claim docs rather than left implicit; if you want it airtight rather than transitive I will add the persisted slot and the migration.

Tests (one_time_claim_evidence_tests):

  • claim_binding_is_recoverable_from_the_stored_transition — the derive path: a serialize/deserialize round-trip reproduces the exact key set, the denomination and the master key hash.
  • matching_retry_arguments_resume and key_order_is_not_a_binding_mismatch.
  • mismatched_retry_arguments_are_refused_per_field — key set (asserting first that the swap keeps the key ids identical, so ids alone would not have caught it), master hash, denomination.
  • mismatched_retry_refuses_without_broadcasting_or_clearing_the_record — end to end through resume_one_time_claim over a real FileBackedShieldedStore, with a bare mock SDK carrying no expectations: reaching the nullifier probe or the re-broadcast would surface as something other than this error. Asserts the pending record is still there and byte-identical afterwards.

880 tests green; cargo fmt --check --all and cargo clippy --all-targets --all-features --locked -- --no-deps -D warnings clean.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 847f0a1521 ("bind a resumed one-time claim to its stored transition"), which landed after this comment.

one_time_claim_binding_mismatch compares the stored transition against the current call before any resume proceeds — denomination, master auth key hash, and the submitted public key set — and refuses the resume with a named reason when they diverge. The master-key-hash check is the important one for your scenario: that hash is the handle recover_executed_one_time_claim probes Platform with, so recovering under a hash absent from the stored transition could only ever find someone else's identity.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 847f0a1521 — a resumed claim is now bound to its stored transition, not merely selected by (wallet, FVK). The pending record carries the identity slot it was armed for, and resume refuses rather than proceeding when the presented keys or the DIP-9 identity_index do not match what was stored; the mismatch is a typed error (added in the same commit's error.rs change) rather than a silent re-target.

That closes the case you describe: a second claim presenting the same FVK against a different identity slot can no longer adopt the first claim's pending record.

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.

Resolved in this update — Bind a resumed claim to its original keys and identity slot no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 484233a. First, the ground truth, since this PR has carried two contradictory statements about it: the pending claim record did not persist the identity index. PendingRedrive had five fields — activity_id, anchor, nullifiers, st_bytes, attempts — the shielded_pending_spends table had no such column, and identity_index never reached shielded::operations at all; it was consumed only in PlatformWallet::identity_create_from_one_time_key after the claim returned. The doc on resume_one_time_claim said so verbatim: "Not directly bound: the caller's identity_index … It is bound transitively."

So the later comment claiming the pending record carries the identity slot was wrong, and the earlier "only transitively bound" was correct. Apologies for the noise.

On the substance: the transitive argument breaks exactly where you said. A retry presenting the ORIGINAL keys at a different slot passes every derived check, and IdentityManager::add_identity rejects a duplicate identity id but inserts into an occupied slot without complaint — so it would silently displace whatever identity the wallet tracked there. That isn't symmetric with a first attempt's failure mode.

PendingRedrive now carries identity_index: Option<u32> (None for ordinary spend redrives, which register no identity), backed by a nullable identity_index column on shielded_pending_spends. This store versions its schema by CREATE TABLE IF NOT EXISTS rather than user_version, so the matching idempotent form is a PRAGMA table_info probe plus ALTER TABLE ADD COLUMN. No default and no back-fill — an existing record genuinely doesn't know its slot, and NULL says that instead of manufacturing a value a resume would then enforce. identity_index is threaded from PlatformWallet down to arm_one_time_claim_record, and one_time_claim_binding_mismatch compares it first; a mismatch is the existing typed ShieldedClaimBindingMismatch, refused before any network work with the record left intact for a correct retry. It's the one field compared against the record rather than re-derived from st_bytes, precisely because the transition can't witness a purely local DIP-9 placement.

Tests: a_resume_at_a_different_identity_index_is_refused (everything else byte-identical — the case the transitive argument couldn't cover), resuming_at_a_mismatched_identity_index_fails_closed (end to end on the file store: arm at slot N, resume at N+1, typed refusal, record intact, slot still present after a cold reopen), a_pre_migration_record_still_resumes_at_any_index, a_pre_migration_database_gains_the_identity_index_column, a_claim_records_identity_index_survives_a_reopen.

I deliberately left IdentityManager::add_identity's occupied-slot tolerance alone — hardening it affects every identity registration path in the wallet and is well outside this PR. Happy to open a separate issue for it if you'd like.

… aliases

cargo clippy --workspace -D warnings failed the Rust workspace tests job in
51s on clippy::type_complexity at the ForeignClaimGuards entries field
(introduced by 6668061). Name the guard handle and the registry row so the
field reads as Vec<ClaimGuardEntry>; no behaviour change.
@bfoss765

Copy link
Copy Markdown
Collaborator Author

Note on the codecov/project check (84.52%, −2.97% vs base)

This one is a CI-shape artifact rather than a real coverage regression, so flagging what it is rather than leaving it as an unexplained red mark.

Why it fires: tests-rs-workspace.yml runs the shielded suite only on push/nightly — pull requests upload just lcov-nonshielded.info, and codecov carries the base's rust-shielded flag forward. This PR adds a large amount of shielded code (rs-dpp shielded builders, the one-time-claim path), so that new code is measured against a report that structurally cannot contain it, and the branch reads as a coverage drop.

The corroborating signal: codecov/patch — the check that asks "is the code changed by this PR covered?" — reports not affected. The shielded tests for this code do exist and pass; they run in the shielded suite (cargo test -p platform-wallet --features shielded, 865 passing at the current head, plus 286 in platform-wallet-ffi and the rs-dpp shielded set).

Proposed disposition — accept it for this PR, since it is not evidence of untested code and the patch check is clean.

If a maintainer would rather fix it properly, the structural option is to include the shielded suite in the PR run (or upload its lcov under the rust-shielded flag from PRs) so shielded-heavy branches are measured against a report that can actually see them. That trades a longer PR CI run for accurate numbers — happy to open a separate PR for that if the repo owners prefer it; it affects every shielded PR, not just this one, so it seemed better to raise than to quietly adjust a threshold.

bfoss765 and others added 3 commits August 13, 2026 12:31
…nsition

The durable pending-claim record is found by wallet id and one-time FVK alone,
so nothing about the lookup says which identity the earlier attempt was
creating. `resume_one_time_claim` nevertheless re-broadcast the STORED
transition while handing recovery, the empty-proof-result backfill and the
result classification this call's `master_key_hash`, `submitted_public_keys`
and `denomination` — and the caller then registered the returned identity at
this call's `identity_index`. A retry with different arguments could therefore
classify the original identity as another holder's and clear the record
(permanently stranding a padded single-note claim, whose declared id embeds a
random dummy nullifier and exists nowhere else), backfill an empty proof result
with keys that were never in the transition, or register the original identity
at the wrong local HD slot (#4313, finding 195efdd4ae21).

The binding is now DERIVED from `record.st_bytes` rather than taken from the
call: the transition's `public_keys` are exactly what the binding signature
committed to and its `denomination` is the value that leaves the pool, so the
transition is the authoritative statement of what was submitted. Deriving needs
no record-schema change and — unlike a separately persisted copy — cannot drift
from what actually went on the wire.

The caller's arguments are demoted to assertions. Any disagreement in the key
set (compared whole, by id AND content, so a same-ids key-material swap is
caught), the MASTER auth key hash that idempotent recovery probes Platform
with, or the denomination fails closed with the new
`PlatformWalletError::ShieldedClaimBindingMismatch` — checked BEFORE the
spent-nullifier probe and the re-broadcast, so a mismatched retry burns no
proof, makes no chargeable resubmission, and leaves the record intact for a
retry that presents the original arguments. Key ORDER is not a mismatch; both
sides are `BTreeMap`s keyed by key id.

`identity_index` is not in the transition and cannot be derived from it. It is
bound transitively: the identity's keys are DIP-9-derived at that slot, so a
retry naming a different slot presents different keys and is refused. The one
uncovered case — a caller pairing slot i with keys derived at slot j — violates
the same contract a FIRST attempt relies on and mis-slots identically, so the
resume path is now no weaker than a fresh claim. This is stated explicitly in
the fn docs rather than left implicit.

Tests: the derive path (a serialize/deserialize round-trip reproduces the exact
key set, denomination and master key hash), matching-args resume, key order not
being a mismatch, per-field refusal, and an end-to-end refusal through
`resume_one_time_claim` over a real `FileBackedShieldedStore` that asserts the
pending record survives untouched and that no network work was reached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…te boundary

`purge_wallet` runs `DELETE FROM shielded_pending_spends WHERE wallet_id = ?1`
and `purge_all_subwallets` deletes unfiltered, so `clear` / `unregister_wallet`
/ `remove_wallet` could delete the pending-claim record of a one-time-key claim
whose transition was still broadcasting — and for a padded single-note bundle
that record is the ONLY handle to the created identity (its declared id embeds a
per-build random dummy nullifier), so the identity is then unrecoverable.

Nothing in the process could order the two. The destructive paths take the
coordinator's `lifecycle` mutex; `identity_create_from_one_time_key` takes none
of it, and could not usefully be made to: `FileBackedShieldedStore::open_path`
opens independent SQLite connections to the same file, so two coordinators — or
two processes — share the records but no in-process lock, and the per-FVK
`ForeignClaimGuards` are owned by one coordinator. Excluding
`ONE_TIME_CLAIM_RECORDS_ACCOUNT` from the purges was considered and rejected: it
does not stop a purge racing an arming claim, and it breaks `remove_wallet`'s
full-wipe contract.

Admission therefore moves to the only thing the two sides actually share — the
store — as five `ShieldedStore` methods over a `shielded_lifecycle_admission`
table in the same SQLite file:

* a claim takes a LEASE, refused if a barrier already covers its wallet;
* a destructive operation installs a BARRIER, which blocks new leases and
  reports the leases already live in scope, then waits for that count to reach
  zero;
* the claim arms its record UNDER its lease (`arm_redrive_under_claim`), which
  re-checks and re-stamps the lease in the same atomic step, leaving no gap
  between "still admitted" and "record written".

Correctness: both entry points are single `BEGIN IMMEDIATE` transactions, and
SQLite admits one writer at a time across every connection and every process on
the file, so they are totally ordered — lease first means the barrier counts it
and the purge waits; barrier first means the lease is refused. There is no
interleaving in which both proceed. `InMemoryShieldedStore` holds the same table
in memory, where the shared object is the store behind one `RwLock` and the
write guard supplies the same order. No transaction is held across scanning,
proof construction, broadcast, or a confirmation wait.

Failure is closed in both directions. `clear()` propagates
`ShieldedLifecycleBusy` — its contract is that the host wipes its own rows only
on `Ok`, so reporting success over an untouched store would desynchronize them.
`unregister_wallet` skips the purge and warns, matching its existing contract,
which already tolerates a purge that did not happen. A claim refused admission
has scanned, built and broadcast nothing.

Leases and barriers carry expiries because a holder can die (process kill,
cancelled JNI call) with no chance to release. Expiry is a liveness backstop
only: it never removes a LIVE admission, so it cannot let a purge delete a
record under a running claim — it only bounds how long a dead claim blocks
wallet removal and a dead purge blocks claims. The claim body is split into
`one_time_claim_admitted` purely so the lease is released on every exit,
including the many `?` paths, without threading a release through each.

Tests: the cross-INSTANCE cases are the ones that matter, and each opens two
`FileBackedShieldedStore`s on one file — barrier in A refuses a claim in B, a
live lease in A is counted by B's barrier, and an armed record survives a
concurrent purge attempt and is only wiped once the claim releases (proving the
fence is a fence, not an exemption). Plus: arming refuses and writes nothing
without a lease (verified durably through a cold reopen), expired leases stop
blocking, scope matches the operation, and coordinator-level `clear()` /
`unregister_wallet` refusal-and-retry under a paused clock. Reverting either
half of the fence — the barrier check or the drain wait — fails four of them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dmission

The refusal path returns without committing and relies on rusqlite's
`Transaction` drop to roll back; the comment claimed an explicit rollback.
Also spells out that the reap performed earlier in the same transaction is
rolled back with it, which is harmless because the next admission call reaps
again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

At exact head dd73cdb, the coordinator-scoped checkpoint cache correctly prevents cross-network reuse, but durable claim recovery still has three blocking gaps: independent coordinators can replace each other's recovery record, an expired lease can permit a purge while its claim remains live, and the recovered identity can be registered under a different local identity index. The first scan of each fresh, evicted, or post-restart unfunded invitation key also remains unbounded.
Source: reviewer backend gpt-5.6-sol; final verifier backend gpt-5.6-sol. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking

3 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:856-876: Prevent Clear from deleting an armed claim before broadcast completes
  The SQLite fence stops protecting a live claim five minutes after `arm_redrive_under_claim`, because that operation re-stamps the lease once and no later network phase renews it. Every destructive-admission poll reaps expired rows before counting live claims, so after that deadline `clear` or `unregister_wallet` can see zero claims and purge the armed recovery row while the original future is still running. The post-arm path can exceed five minutes: it broadcasts, waits using `wait_for_affected_state(..., None)`, and may perform two nested four-attempt identity recovery loops whose individual fetches have their own request retries; suspension or a forward wall-clock adjustment has the same effect. Renew the lease throughout all post-arm broadcast, confirmation, and recovery phases, or use durable ownership semantics where a fixed wall-clock expiry alone cannot authorize deletion while the holder remains active.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1717-1748: Serialize concurrent claims before replacing their recovery record
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3767229122)
  The per-FVK mutex is owned by one `NetworkShieldedCoordinator`, while `begin_claim_admission` only fences claims against destructive operations. It permits multiple claim leases for the same wallet/FVK. Two coordinators or processes sharing one SQLite path can therefore acquire independent leases, and each `FileBackedShieldedStore::pending_redrives` lookup reads its separately rehydrated in-memory map rather than querying the current SQLite row. Both claims can observe no record, construct different padded transitions, and call `arm_redrive_under_claim`; its durable write uses `INSERT OR REPLACE` for the same claim-record key, so the second claim replaces the first claim's only byte-exact recovery record. Reserve the claim-record identity atomically at the SQLite boundary, reject or resume an existing durable reservation, and make lookup observe the durable row rather than a stale per-instance cache.

In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:1613-1621: Bind a resumed claim to its original keys and identity slot
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3771541508)
  The stored-transition checks now bind the key set, master-key hash, and denomination, but `identity_index` is still neither persisted nor passed into `resume_one_time_claim`. The public Rust, FFI, JNI, and Kotlin APIs accept the index independently from caller-supplied key rows and do not verify that those rows were derived from that slot. A retry can therefore present the exact original keys and denomination with a different `identity_index`, pass `one_time_claim_binding_mismatch`, recover or rebroadcast the stored transition, and register the resulting identity under the retry's slot here. Persist the original identity index with the claim record and reject a mismatched retry index before any recovery, broadcast, or local registration.

In `packages/rs-platform-wallet/src/wallet/shielded/sync.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/sync.rs:1024-1032: Unfunded invitation keys force an unbounded full-history scan
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3725876429)
  The coordinator-owned checkpoint only reduces repeat work after a scan of the same FVK has progressed and while that entry remains among the eight cached keys. A fresh, evicted, or post-restart syntactically valid invitation still begins at position zero and consumes the proof-verified note stream through the current tip when it is unfunded or underfunded. Because the public claim API accepts untrusted invitation material and provides no native cancellation input, per-call chunk or work budget, retained-note budget, or authenticated starting position, callers can rotate valid unfunded keys to repeatedly consume bandwidth, proof-verification CPU, memory, battery, and synchronous JNI workers. Add native cancellation and a strict resumable first-scan work bound, or another funds-safe bound suitable for untrusted invitation input.

Comment on lines +856 to +876
let started = tokio::time::Instant::now();
loop {
// Re-taking the barrier each pass also REFRESHES its expiry, so a
// long drain cannot let the barrier lapse and admit a new claim.
let live = {
let mut store = self.store.write().await;
store
.begin_destructive_admission(
scope,
token,
admission_now_ms(),
DESTRUCTIVE_BARRIER_MS,
)
.map_err(|e| {
crate::error::PlatformWalletError::ShieldedStoreError(format!(
"{operation}: could not take lifecycle admission: {e}"
))
})?
};
if live == 0 {
return Ok(token);

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.

🔴 Blocking: Prevent Clear from deleting an armed claim before broadcast completes

The SQLite fence stops protecting a live claim five minutes after arm_redrive_under_claim, because that operation re-stamps the lease once and no later network phase renews it. Every destructive-admission poll reaps expired rows before counting live claims, so after that deadline clear or unregister_wallet can see zero claims and purge the armed recovery row while the original future is still running. The post-arm path can exceed five minutes: it broadcasts, waits using wait_for_affected_state(..., None), and may perform two nested four-attempt identity recovery loops whose individual fetches have their own request retries; suspension or a forward wall-clock adjustment has the same effect. Renew the lease throughout all post-arm broadcast, confirmation, and recovery phases, or use durable ownership semantics where a fixed wall-clock expiry alone cannot authorize deletion while the holder remains active.

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6931c747d3.

You are right that 41cf07a37a did not close this: it fenced the arm at the SQLite boundary, but the lease was still stamped exactly once, so the protected window ran for a fixed CLAIM_LEASE_MS from the arm rather than for as long as the claim actually took.

ShieldedStore::renew_claim_admission re-stamps a live lease without touching the pending record, implemented for both stores. The claim path now renews on a timer (CLAIM_LEASE_MS / 3, so two consecutive ticks can be missed before a lapse becomes possible) while the broadcast future runs, under a biased select! so a renewal tick can never starve the outcome.

Two properties worth calling out:

  • The file store renews inside an IMMEDIATE transaction, like every other lease write, so SQLite's one-writer rule totally orders a renewal against a purge taking its barrier — it either lands before the barrier or loses to it, never half-applies.
  • Neither implementation resurrects a LAPSED lease (UPDATE ... WHERE expires_at > now). A purge may already have counted that claim absent and acted on it; quietly reviving the row would hide exactly the race being fixed rather than close it.

A failed renewal is a loud warn!, not an abort — the transition may already be on the wire, so aborting cannot un-send it and would only discard the outcome classification.

The negative control pins the bug itself: an_unrenewed_lease_lapses_and_stops_holding_off_a_purge asserts that one millisecond past the lease a purge counts zero live claims. Alongside it, a_renewed_lease_keeps_holding_off_a_purge_past_the_original_window, renewal_refuses_to_resurrect_a_lapsed_lease, and renewing_an_unknown_token_is_false_not_a_new_lease.

883 platform-wallet tests pass with --features shielded; rustfmt clean.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Two commits, because you were right that the SQLite fence alone was not enough.

41cf07a37a put the fence at the SQLite boundary so a purge cannot delete a record while the claim that armed it holds admission — clear, unregister_wallet and remove_wallet install a barrier, count live leases in scope, and refuse rather than deleting.

But as you noted, that only protects for the lease window; a broadcast outliving CLAIM_LEASE_MS would leave the record unprotected. 6931c747d3 closes that: the claim now renews its lease while it is in flight, on a CLAIM_LEASE_RENEW_INTERVAL heartbeat (a third of the lease) driven by a biased tokio::select! around the broadcast future, so the lease cannot lapse under a live claim no matter how long the broadcast takes.

Renewal is deliberately not a re-grant — renew_claim_admission refuses an already-lapsed lease rather than resurrecting it, so a claim that genuinely died cannot hold a purge off forever. Tests:

  • a_renewed_lease_keeps_holding_off_a_purge_past_the_original_window
  • an_unrenewed_lease_lapses_and_stops_holding_off_a_purge
  • renewal_refuses_to_resurrect_a_lapsed_lease
  • renewing_an_unknown_token_is_false_not_a_new_lease
  • clear_refuses_while_a_one_time_claim_holds_admission / unregister_skips_the_purge_while_a_one_time_claim_holds_admission
  • a_purge_cannot_delete_a_record_while_the_claim_that_armed_it_is_live

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 484233a. The heartbeat wrapped only the fresh-build broadcast, and the resume path returns before that is reached — so its nullifier queries, repeated identity recovery, re-broadcast and unbounded confirmation wait ran under the initial lease alone. That's the slower branch if anything, since it's the path a claim takes precisely because the previous attempt couldn't resolve quickly.

Hoisted the renewal into under_renewed_claim_lease, which now wraps the complete one_time_claim_admitted call — pending-record lookup, resume, scan, build, arm, broadcast and confirmation wait alike. That's the only place that covers both paths; they don't converge anywhere deeper. The inner select-loop is gone and the broadcast is a plain await again. The claim-key reservation from the sibling fix rides along, since renew_claim_admission re-stamps it in the same step. I also corrected CLAIM_LEASE_MS's doc, which still claimed the protected window ran from the arm; it now bounds the gap between renewals.

Tests: a_resume_shaped_body_run_bare_lets_its_lease_lapse runs a resume-shaped body with no heartbeat and shows the lease lapse; the_heartbeat_keeps_a_resume_shaped_body_s_lease_live runs the same body and clock through the helper for the opposite outcome; the_heartbeat_also_holds_the_claim_key_reservation covers the reservation.

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.

Resolved in 41cf07aPrevent Clear from deleting an armed claim before broadcast completes no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

…light

#4313 review finding 161a517fce36: the SQLite fence stops
protecting a live claim five minutes after arming, because that operation
re-stamps the lease ONCE and no later network phase renews it — every
destructive-admission poll reaps expired rows before counting live claims.

The lease has exactly one production stamp site (arm_one_time_claim_record),
so the protected window ran for a fixed CLAIM_LEASE_MS from the arm rather than
for as long as the claim actually took. Broadcast plus confirmation can outrun
five minutes — one slow or retrying DAPI node is enough, and expired-certificate
nodes have been measured burning minutes per call in the field. Once the row is
reaped, begin_destructive_admission counts zero live claims and a wallet removal
purges the record that is the only handle for recovering a padded single-note
claim.

Adds ShieldedStore::renew_claim_admission — re-stamp a live lease, touching no
pending record — implemented for both stores. The file store does it as an
IMMEDIATE transaction like every other lease write, so SQLite's one-writer rule
totally orders it against a purge taking its barrier. Both implementations
refuse to resurrect a LAPSED lease: a purge may already have counted it absent
and acted on that, and silently reviving it would hide the very race being
fixed.

The claim path now renews on a timer (CLAIM_LEASE_MS / 3, so two consecutive
ticks may be missed before a lapse is possible) while the broadcast future runs,
via a biased select so a renewal can never starve the outcome. A failed renewal
is a loud warning, not an abort: the transition may already be on the wire, so
aborting cannot un-send it and would only discard the outcome classification.

Tests: the negative control pins the bug — an unrenewed lease lapses and stops
holding off a purge — alongside renewal holding one past its original window,
refusal to resurrect a lapsed lease, and an unknown token minting nothing.
883 platform-wallet tests pass with --features shielded; rustfmt clean.

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

Four blocking issues remain at exact head 6931c74: independent coordinators can replace one another's recovery record, resumed claims do not renew their purge-protection lease, retries are not bound to the original local identity slot, and admission-token entropy failure can panic through the C ABI. The first-scan work-bound concern remains concrete but was explicitly deferred to a separate native-cancellation and budgeting follow-up.
Source: Codex general, security-auditor, rust-quality, and ffi-engineer reviewers = gpt-5.6-sol; final verifier = gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 4 blocking

3 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/shielded/store.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/store.rs:633-647: Propagate admission-token entropy failures instead of panicking
  `AdmissionToken::new` uses `OsRng.fill_bytes`, whose `rand_core` contract panics when the operating-system entropy source fails. Production claim and destructive-lifecycle paths call this constructor, including the asynchronous operation reached from `platform_wallet_manager_shielded_identity_create_from_one_time_key`. A panic in that future causes `block_on_worker` to panic at `expect("tokio worker panicked")`; because the caller is an `extern "C"` export, the panic cannot safely unwind across the ABI and can abort the host process. The one-time Orchard key generator in this PR already uses `try_fill_bytes` specifically to avoid this failure mode. Make admission-token generation fallible with `try_fill_bytes`, remove the infallible `Default` path, and propagate a typed wallet or persistence error through each production call site.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1717-1753: Serialize concurrent claims before replacing their recovery record
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3767229122)
  The per-FVK mutex is owned by one coordinator, while `begin_claim_admission` only checks for destructive barriers and permits multiple claim leases for the same wallet and invitation. Two coordinators or processes sharing one SQLite file can therefore both be admitted. Each `FileBackedShieldedStore::pending_redrives` call reads that instance's startup-hydrated `subwallets` map rather than the current SQLite row, so both callers can observe no record and construct different padded transitions. Their `arm_redrive_under_claim` calls then use `INSERT OR REPLACE` for the same durable claim-record key, allowing the second caller to replace the first transition's only byte-exact recovery record while the first is broadcasting. If the first transition executes and its result is lost, the stored replacement no longer contains the identity ID needed to recover it. Reserve the claim-record identity atomically at the SQLite boundary, make the lookup observe the durable row, and require another claimant to reject or resume the existing reservation instead of replacing it.

In `packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:860-876: Prevent Clear from deleting an armed claim before broadcast completes
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3777986987)
  The new renewal loop wraps only the fresh claim's post-arm `broadcast_and_confirm_one_time_claim` future. When a pending record already exists, `one_time_claim_admitted` awaits `resume_one_time_claim` and returns before reaching that loop. Resume can perform a nullifier query, repeated identity-recovery fetches, or a complete byte-identical broadcast and unbounded confirmation wait while relying only on the five-minute lease created by `begin_claim_admission`. Once that lease expires, this destructive-admission poll reaps it and can observe zero live claims, allowing `clear` or `unregister_wallet` to purge the still-needed record while resume remains active. Apply renewal around the entire admitted recovery/rebroadcast phase, including the existing-record branch, rather than only around a newly built transition's broadcast.

In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:1613-1621: Bind a resumed claim to its original keys and identity slot
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3771541508)
  Resume validation now derives and checks the stored transition's denomination, master-key hash, and complete public-key set, but the original `identity_index` is neither persisted nor passed into `resume_one_time_claim`. The Rust, C, JNI, and Kotlin APIs accept the index independently from the caller-supplied key rows, and no layer proves that those keys were derived from that slot. A retry can therefore present the exact original keys with a different index, pass every stored-transition check, recover or rebroadcast the original claim, and register the returned identity under the retry's slot here. `IdentityManager::add_identity` rejects a duplicate identity ID but inserts by index without rejecting an already occupied slot, so this can replace that slot's local identity metadata and make later HD recovery or signing use the wrong derivation path. Persist the original identity index with the claim record and reject a mismatched retry before network recovery, rebroadcast, or local registration.

Comment thread packages/rs-platform-wallet/src/wallet/shielded/store.rs
bfoss765 and others added 2 commits August 17, 2026 11:17
`AdmissionToken::new` drew its 16 bytes with `OsRng::fill_bytes`, whose
`rand_core` contract PANICS when the OS entropy source fails. Both
production callers sit inside futures reached from `#[no_mangle] extern "C"`
exports — the claim path through
`platform_wallet_manager_shielded_identity_create_from_one_time_key`, the
barrier path through the destructive lifecycle exports — and a panic there
is re-raised by `block_on_worker`'s `expect("tokio worker panicked")`, where
it cannot unwind across the C ABI and aborts the host process before the JNI
panic guard runs.

`generate_one_time_orchard_key` already avoids this with `try_fill_bytes`;
admission tokens now do the same. `AdmissionToken::generate()` is fallible
and reports `PlatformWalletError::Persistence` — the class both call sites
already map the rest of the admission step to, so the host sees one error
for "the admission could not be taken" regardless of which half failed.

The infallible paths are gone from production: `Default` is removed (it had
no callers) and `new()` is `#[cfg(test)]`-only, so nothing can reintroduce
the panic by reaching for a convenience constructor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…4306)

A syntactically valid but never-funded invitation key drove
`scan_notes_for_foreign_key` genesis-to-tip: "no note yet" and "note
further ahead" are indistinguishable mid-stream, so a hostile invite link
cost an unbounded trial-decryption walk per claim attempt. The resume
checkpoint (2dc71c9) bounded REPEATED scans, but the first attempt — and
every attempt against a growing tree — had no stopping rule.

Each attempt now consumes at most FOREIGN_SCAN_BATCH_BUDGET (128) stream
batches (a batch is ≥ one 2048-note MMR chunk, so ≥ ~260k trial
decryptions — generously past any realistic honest claim). Exhausting the
budget before the value is covered checkpoints the position reached and
returns the NEW retryable typed error
`ShieldedForeignScanBudgetExhausted { scanned_through }` — attempts
compound toward a genuinely deep note while each stays bounded, no matter
what the link claims. A partial (buffer) batch is end-of-stream and never
trips the budget, so an exhausted tree still returns Ok as before.

The invite's birth-height hint cannot replace this bound: heights don't
map to tree positions (no height→position oracle on this tree), and the
hint arrives in the attacker-controlled link anyway.

Surface: FFI code 44 `ErrorShieldedScanBudgetExhausted` (next frontier
past the frozen 43) → Kotlin
`DashSdkError.PlatformWallet.ShieldedScanBudgetExhausted` with
`isRetryable = true` — the polarity is the entire host contract: render
"still searching — retry", never "invalid/unfunded/already claimed".

The consumption loop is extracted into a stream-generic
`scan_foreign_stream_with_budget` so the budget/checkpoint behavior is
unit-tested without a network: pause-checkpoints-and-resume, the
partial-batch exemption, and the pre-existing error-path checkpointing.
Claim admission already releases on every error exit, so the pause cannot
wedge a lease.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

At exact head 78e5969, admission-token entropy failures and cross-network checkpoint reuse are fixed, but three carried-forward claim recovery defects remain. The new scan budget also loses its retryable result at the actual claim FFI entry point, while lifecycle contention is similarly flattened to a non-retryable generic Kotlin error.
Source: reviewers codex/general=gpt-5.6-sol, codex/security-auditor=gpt-5.6-sol, codex/rust-quality=gpt-5.6-sol, and codex/ffi-engineer=gpt-5.6-sol; final verifier codex/verifier=gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 4 blocking | 🟡 1 suggestion(s)

3 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-ffi/src/shielded_send.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/shielded_send.rs:1034-1037: Preserve the retryable scan-budget error across the claim FFI
  `ShieldedForeignScanBudgetExhausted` has a dedicated blanket conversion to native code 44, and Kotlin maps code 44 to retryable `ShieldedScanBudgetExhausted`. This entry point bypasses that conversion: only `ShieldedInviteAlreadyClaimed` reaches `e.into()`, while the catch-all converts budget exhaustion to generic `ErrorWalletOperation` code 6. A valid invitation whose note lies beyond the first 128 batches is therefore surfaced as a non-retryable generic failure even though its checkpoint requires another invocation to continue. Route this variant through the typed conversion and add an entry-point-level test that asserts code 44.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/shielded_send.rs:1034-1037: Preserve lifecycle-busy retryability across Kotlin
  `PlatformWalletError::ShieldedLifecycleBusy` explicitly documents both contention outcomes as retryable, and a refused claim has not scanned, built, or broadcast anything. The same catch-all converts this variant to generic native code 6, which Kotlin maps to `PlatformWallet.Generic` with `isRetryable == false`. Ordinary contention with clear, unregister, or removal therefore loses its intended retry contract at the language boundary. Assign this variant a stable FFI result code and a corresponding retryable Kotlin error type instead of flattening it.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1717-1753: Serialize concurrent claims before replacing their recovery record
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3767229122)
  The per-FVK guard is still coordinator-local, and `begin_claim_admission` only rejects destructive barriers; it permits multiple ordinary claim leases for the same wallet and invitation. Two coordinators or processes sharing one SQLite file can therefore both be admitted. Each file-backed store then performs the pending-record lookup through its separately hydrated in-memory `subwallets` map, so both can observe no record and construct different padded transitions. `arm_redrive_under_claim` subsequently uses `INSERT OR REPLACE` for the shared record key, allowing the later claim to replace the first transition's only byte-exact recovery record while the first is broadcasting. If the first transition executes and its result is lost, its randomized padded identity ID is no longer recoverable. Reserve the invitation's claim-record key atomically in SQLite, query the durable row, and make competing claimants resume or reject the existing reservation rather than replacing it.

In `packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:860-876: Prevent Clear from deleting an armed claim before broadcast completes
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3777986987)
  The destructive-admission loop reaps expired claim leases before counting them, but renewal still protects only a freshly built claim's post-arm broadcast. When a pending record exists, `one_time_claim_admitted` awaits `resume_one_time_claim`, finalizes, and returns at operations.rs:1858-1875 before reaching the renewal loop at operations.rs:2028-2071. Resume may perform nullifier queries, repeated identity recovery, byte-identical rebroadcast, and an unbounded confirmation wait under only the initial five-minute lease. Once it expires, `clear` or `unregister_wallet` can observe no live claim and purge the recovery row while resume remains active. Run the renewal heartbeat around the complete admitted claim body, including pending-record recovery and rebroadcast.

In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:1617-1621: Bind a resumed claim to its original keys and identity slot
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3771541508)
  Resume now binds the denomination, master-key hash, and complete public-key set to the stored transition, but it explicitly does not bind `identity_index`. The public Rust, C, JNI, and Kotlin APIs accept the index independently from the key rows, so a retry can present the exact original keys with a different index, pass every transition-derived check, recover or rebroadcast the original claim, and register the identity under the retry's slot here. This is not harmless: `IdentityManager::add_identity` rejects duplicate identity IDs but inserts into the index map without rejecting an identity already occupying that slot, leaving incorrect HD metadata and stale side-index state. Persist the original identity index with the pending claim and reject a mismatch before network recovery, rebroadcast, or local registration.

Comment on lines +1034 to +1037
Err(e @ PlatformWalletError::ShieldedInviteAlreadyClaimed { .. }) => e.into(),
Err(e) => PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("shielded identity-create-from-one-time-key failed: {e}"),

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.

🔴 Blocking: Preserve the retryable scan-budget error across the claim FFI

ShieldedForeignScanBudgetExhausted has a dedicated blanket conversion to native code 44, and Kotlin maps code 44 to retryable ShieldedScanBudgetExhausted. This entry point bypasses that conversion: only ShieldedInviteAlreadyClaimed reaches e.into(), while the catch-all converts budget exhaustion to generic ErrorWalletOperation code 6. A valid invitation whose note lies beyond the first 128 batches is therefore surfaced as a non-retryable generic failure even though its checkpoint requires another invocation to continue. Route this variant through the typed conversion and add an entry-point-level test that asserts code 44.

Suggested change
Err(e @ PlatformWalletError::ShieldedInviteAlreadyClaimed { .. }) => e.into(),
Err(e) => PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("shielded identity-create-from-one-time-key failed: {e}"),
Err(e @ PlatformWalletError::ShieldedInviteAlreadyClaimed { .. }) => e.into(),
Err(e @ PlatformWalletError::ShieldedForeignScanBudgetExhausted { .. }) => e.into(),

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2809912. The pass-through arm now covers ShieldedForeignScanBudgetExhausted alongside ShieldedInviteAlreadyClaimed, so the blanket conversion's code 44 survives this entry point and Kotlin still maps it to the retryable ShieldedScanBudgetExhausted.

I also split the match out of the unsafe extern "C" body into map_one_time_claim_result, mirroring how map_spend_result makes the spend entry points testable — the new tests exercise the boundary the host actually calls rather than the blanket conversion. map_one_time_claim_result_pins_the_retryable_scan_budget_code asserts code 44, no identity id written, and the checkpoint position preserved in the message; map_one_time_claim_result_pins_the_terminal_and_unconfirmed_codes pins the neighbours so a future edit can't silently re-flatten one. As a side effect Some(identity_id) is now the single channel that writes out_identity_id, so the "Success and ErrorShieldedBroadcastUnconfirmed only" contract is decided in one place.

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.

Resolved in 2809912Preserve the retryable scan-budget error across the claim FFI no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +1034 to +1037
Err(e @ PlatformWalletError::ShieldedInviteAlreadyClaimed { .. }) => e.into(),
Err(e) => PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("shielded identity-create-from-one-time-key failed: {e}"),

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.

🟡 Suggestion: Preserve lifecycle-busy retryability across Kotlin

PlatformWalletError::ShieldedLifecycleBusy explicitly documents both contention outcomes as retryable, and a refused claim has not scanned, built, or broadcast anything. The same catch-all converts this variant to generic native code 6, which Kotlin maps to PlatformWallet.Generic with isRetryable == false. Ordinary contention with clear, unregister, or removal therefore loses its intended retry contract at the language boundary. Assign this variant a stable FFI result code and a corresponding retryable Kotlin error type instead of flattening it.

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Implemented in 27603ab as ErrorShieldedLifecycleBusy = 45.

On the number: the registry on docs/ffi-error-code-registry puts the frontier at 43 as of 2026-08-11, with 42 proposed by #4356 and 28/30/32/33 explicitly RESERVED rather than free. This PR holds 43 and 44, so 45 is the next free integer from the frontier — not a reuse of 44 or of any vacated gap.

The variant is retryable in both of its directions and consumes nothing in either: a claim refused because a Clear/removal holds admission (or another claimant holds the invitation's claim-record key) scanned, built and broadcast nothing, and a purge refused because claims are still draining deleted nothing. Added the blanket conversion arm, the entry-point pass-through, and the Kotlin mirror PlatformWallet.ShieldedLifecycleBusy with isRetryable = true and a 45 -> mapping, following the code-44 pattern in the same files. Tests: shielded_invite_codes_are_pinned_at_43_through_45, shielded_lifecycle_busy_maps_to_its_own_retryable_code, and the code-45 assertions in Kotlin's platformWalletCodesMapToPlatformWalletSubtree.

The Swift mirror and the ERROR_CODE_REGISTRY.md row are follow-ups; that's noted in the commit message.

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.

Resolved in 2809912Preserve lifecycle-busy retryability across Kotlin no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

bfoss765 and others added 4 commits August 18, 2026 21:47
…t the claim entry point

`platform_wallet_manager_shielded_identity_create_from_one_time_key`
routed only `ShieldedInviteAlreadyClaimed` through the blanket
`From<PlatformWalletError>` conversion. Every other typed variant fell to
the catch-all, which rewrites the code to the generic
`ErrorWalletOperation` (6).

`ShieldedForeignScanBudgetExhausted` is one of those. It has a blanket
conversion to `ErrorShieldedScanBudgetExhausted` (44), which the Kotlin
mirror maps to the RETRYABLE `PlatformWallet.ShieldedScanBudgetExhausted`
— but the host never saw 44 from this entry point, only 6. A paused scan
therefore rendered as a hard, non-retryable failure, which strands a
genuinely funded invitation whose note sits deep in the tree: the scan
had simply not looked far enough yet, and progress is checkpointed so the
retry is cheap.

Widen the pass-through arm to cover both typed variants, and split the
classification out of the `unsafe extern "C"` body into
`map_one_time_claim_result` so the code split is reachable from a unit
test without a live manager handle — the same shape `map_spend_result`
already uses for the spend entry points. `Some(identity_id)` is now the
only channel that writes `out_identity_id`, so the "written on Success
and on ErrorShieldedBroadcastUnconfirmed only" contract is decided in one
place.

Tests (both new, at the FFI entry point rather than at the blanket
conversion):

* `map_one_time_claim_result_pins_the_retryable_scan_budget_code` — 44,
  no identity id written, checkpoint position preserved in the message.
* `map_one_time_claim_result_pins_the_terminal_and_unconfirmed_codes` —
  43 writes no id, 17 does write one, unrelated variants still flatten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ped retryable code (45)

`ShieldedLifecycleBusy` had no FFI code of its own, so it reached hosts as
the generic `ErrorWalletOperation` (6) — a non-retryable classification —
through both the blanket conversion's catch-all and the claim entry
point's. That is the wrong polarity in both of the variant's directions:

* a one-time-key claim refused because a Clear / wallet removal holds
  destructive admission over its wallet, or because another claimant
  holds this invitation's claim-record key, scanned/built/broadcast
  nothing;
* a Clear / wallet removal refused because in-flight claims did not drain
  purged nothing.

Both are contended-lifecycle refusals that resolve by waiting a moment
and retrying, and both consumed nothing. Surfaced as code 6 they read as
a hard failure of the invitation itself.

Allocate `ErrorShieldedLifecycleBusy = 45` — the next free integer past
this PR's 44, taken from the registry frontier and NOT from a vacated gap
(28, 30, 32 and 33 are RESERVED, not reissuable). Add its blanket
conversion arm, add it to the claim entry point's pass-through arm, and
mirror it in the Kotlin SDK as
`DashSdkError.PlatformWallet.ShieldedLifecycleBusy` with
`isRetryable = true` and a `45 ->` mapping — the same shape 44 ->
`ShieldedScanBudgetExhausted` uses.

The Swift mirror and the ERROR_CODE_REGISTRY.md row (the registry lives
on docs/ffi-error-code-registry, #4318) are FOLLOW-UPS,
not part of this commit.

Tests:

* Rust `shielded_invite_codes_are_pinned_at_43_through_45` — the numeric
  ABI, pinned like the marketplace block, since nothing checks it across
  the language boundary at compile time.
* Rust `shielded_lifecycle_busy_maps_to_its_own_retryable_code` — both
  directions land on 45, Display payload preserved verbatim.
* Kotlin `platformWalletCodesMapToPlatformWalletSubtree` — code 45 maps
  to the typed class and `isRetryable` is true, mirroring the code-44
  assertions immediately above it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y, in SQLite

Concurrent-claim admission was only ever serialized per-FVK inside ONE
coordinator, by `ForeignClaimGuards`' async mutex. The claim LEASE next to
it is per-WALLET: it orders a claim against a purge and nothing else, so
it admits both claimants of one invitation quite happily.

That leaves the case the mutex cannot see. Two coordinators — or two
processes — open independent `FileBackedShieldedStore` connections to the
same SQLite file and share no in-process lock at all. Both then found no
pending record, built transitions with DIFFERENT padded identity ids (the
padding action's dummy nullifier is random per build), and the second
`arm_redrive_under_claim` — an `INSERT OR REPLACE` — silently overwrote
the first's byte-exact recovery row while the first's transition was
already on the wire. That row is the ONLY handle that recovers a padded
single-note claim, so the identity it created was stranded forever.

Reserve the invitation itself, where the contention actually is:

* new store method `reserve_one_time_claim_key`, contract: the
  insert-if-absent and the read-back of the durable row are ONE atomic
  step, and a live row is never overwritten. Returns `Acquired` (this
  token owns the key; idempotent on re-entry by the same token) or
  `Held { holder, expires_at }` read off the durable row — so exactly one
  concurrent caller can see `Acquired`.
* `FileBackedShieldedStore`: a new `shielded_one_time_claim_reservation`
  table keyed `(wallet_id, claim_record_key)`, written by
  `INSERT ... ON CONFLICT DO NOTHING` inside `BEGIN IMMEDIATE`. SQLite
  admits one writer at a time across every connection AND every process
  on the file, so "exactly one `Acquired`" is a total order, not a
  probability. `ON CONFLICT DO NOTHING`, never `OR REPLACE`: losing the
  insert must leave the winner's row byte-for-byte untouched.
* `InMemoryShieldedStore`: the same semantics under its `&mut self` step.
* `arm_redrive_under_claim` now REFUSES, writing nothing, when a live
  reservation covers `(wallet_id, activity_id)` under a different token —
  in the same transaction that would have done the overwrite. This is
  what makes the clobber structurally impossible rather than merely
  unreachable. Ordinary spend redrives take no reservation, so the gate
  is a no-op for them.
* the reservation is bound to its lease for its whole life: re-stamped by
  `renew_claim_admission` and by `arm_redrive_under_claim`, released by
  `end_claim_admission` in the same transaction as the lease, and
  otherwise reaped by expiry — a claimant that died cannot hold an
  invitation hostage past `CLAIM_LEASE_MS`.

At the wallet layer, `identity_create_from_one_time_key` takes the
reservation right after the lease and passes ownership down. A claimant
that did NOT win it may only RESUME or REFUSE, never replace: the durable
pending-record resume runs first and returns as usual, and if no record
exists yet the claim stops with the retryable
`ShieldedLifecycleBusy` before the transient scan — nothing scanned,
built or broadcast, note untouched. The in-process guard stays exactly
where it was, as the fast path.

Tests:

* `two_store_instances_cannot_both_claim_one_invitation` — two
  `FileBackedShieldedStore` handles on ONE file, both admitted by the
  per-wallet lease; one acquires, the loser is handed the winner's token,
  the loser's arm is refused, and the winner's `st_bytes` survive
  byte-for-byte across a cold reopen.
* `a_claim_key_reservation_lives_and_dies_with_its_lease` — idempotent
  re-entry, renewal carries the hold past the original expiry, release
  hands the invitation over immediately, a dead holder ages out.
* `the_claim_key_gate_leaves_unreserved_redrives_alone` — ordinary spend
  redrives are unaffected.
* `only_one_claimant_acquires_an_invitations_claim_key` — the in-memory
  backend gives the identical answer, since the wallet layer branches on
  it and cannot tell the backends apart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t and bind it to its identity slot

Two review findings on the same code path — the RESUME branch of a
one-time-key claim. They are grouped because their edits interleave in
`identity_create_from_one_time_key` / `resume_one_time_claim`; each is
described separately below.

--- 1. The renewal heartbeat now wraps the COMPLETE admitted claim ---

The 5-minute claim lease's renewal heartbeat wrapped only the fresh-build
path's broadcast. The RESUME path returns before that is ever reached, so
everything it does — pending-record lookup, nullifier queries, repeated
identity recovery, re-broadcast of the stored transition, and an
UNBOUNDED confirmation wait — ran under the initial lease alone. Resume
is if anything the slower branch: it is the path a claim takes precisely
because the previous attempt could not resolve quickly. Outrun the lease
and it is reaped, at which point a concurrent purge counts zero live
claims and deletes the very record the in-flight claim needs to recover.

Hoist the heartbeat into the new `under_renewed_claim_lease`, wrapping
the whole `one_time_claim_admitted` call. Both paths are now covered by
construction, and there is no deeper place that could cover both — they
only converge at that call site. The inner loop is gone; the broadcast is
a plain await again. The claim-key reservation taken under the same token
rides along, since `renew_claim_admission` re-stamps it in the same step.
`CLAIM_LEASE_MS`'s doc no longer claims the protected window runs from
the arm: it now bounds the gap between renewals, not the length of a
claim.

Tests: `a_resume_shaped_body_run_bare_lets_its_lease_lapse` (the bug —
same body, no heartbeat, lease lapses),
`the_heartbeat_keeps_a_resume_shaped_body_s_lease_live` (the fix, same
body and clock, opposite outcome), and
`the_heartbeat_also_holds_the_claim_key_reservation`.

--- 2. `identity_index` is persisted with the pending claim and checked ---

Ground truth first, because the PR carried two contradictory claims about
this: the pending record did NOT persist the identity index.
`PendingRedrive` had five fields (activity_id, anchor, nullifiers,
st_bytes, attempts), the `shielded_pending_spends` table had no such
column, and `identity_index` never even reached
`shielded::operations` — it was consumed only in `platform_wallet.rs`
after the claim returned. The in-tree doc on `resume_one_time_claim` said
so explicitly: "Not directly bound: the caller's `identity_index` … It is
bound *transitively*."

That transitive argument — different slot implies different keys implies
the key check catches it — breaks for a retry that presents the ORIGINAL
keys at a different slot, and the consequence is not symmetric with a
first attempt's. `IdentityManager::add_identity` rejects a duplicate
identity id but inserts into an OCCUPIED slot without complaint, so such
a retry silently displaces whatever identity the wallet tracked there.

So persist it and check it:

* `PendingRedrive` gains `identity_index: Option<u32>` — `None` for
  ordinary spend redrives, which register no identity.
* `shielded_pending_spends` gains a NULLABLE `identity_index` column.
  This store versions its schema by `CREATE TABLE IF NOT EXISTS`, so the
  matching idempotent form for a new column is a `PRAGMA table_info`
  probe plus `ALTER TABLE ADD COLUMN`. No default and no back-fill: an
  existing record genuinely does not know its slot, and `NULL` says
  exactly that rather than manufacturing a value a resume would enforce.
* `identity_index` is threaded from `PlatformWallet` down to
  `arm_one_time_claim_record`, and `one_time_claim_binding_mismatch`
  compares it first. It is the one field compared against the RECORD
  rather than re-derived from `st_bytes`, because the transition cannot
  witness a purely local DIP-9 placement — which is exactly why it has to
  be persisted. A mismatch is the existing typed
  `ShieldedClaimBindingMismatch`, refused before any network work with
  the record left intact for a correct retry.
* A `None` (pre-migration) record skips the check and keeps the
  transitive binding it was written under, so an upgrade cannot strand a
  claim that is mid-flight across it.

Tests: `a_resume_at_a_different_identity_index_is_refused` (everything
else byte-identical — the case the transitive argument could not cover),
`a_pre_migration_record_still_resumes_at_any_index`,
`resuming_at_a_mismatched_identity_index_fails_closed` (end to end on the
file store: arm at slot N, resume at N+1, typed refusal, record intact,
slot still there after a cold reopen),
`a_pre_migration_database_gains_the_identity_index_column`, and
`a_claim_records_identity_index_survives_a_reopen`.

`IdentityManager::add_identity`'s own occupied-slot tolerance is left
alone deliberately — hardening it affects every identity registration
path in the wallet, well outside this PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

At exact head 484233a, four of the five prior findings are fixed, but the pending-claim lookup still reads a stale per-store cache after reservation acquisition, so a second store can replace an unresolved recovery record. Two additional blockers remain: the correctness-critical recovery row is committed through WAL with synchronous=NORMAL, and wallet removal reports success after unregister skips its required purge; three scoped robustness issues also remain.
Source: reviewers codex/general=gpt-5.6-sol, codex/security-auditor=gpt-5.6-sol, codex/rust-quality=gpt-5.6-sol, and codex/ffi-engineer=gpt-5.6-sol; final verifier codex/verifier=gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking | 🟡 3 suggestion(s)

1 additional finding(s) omitted (not in diff).

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/shielded/file_store.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:106-107: Commit pending claim records with durable SQLite synchronization
  `pending_conn` stores the byte-exact one-time-claim recovery record but is opened through `open_tuned_connection`, which configures WAL mode with `synchronous=NORMAL`. A NORMAL-mode WAL commit can return before the WAL is synchronized, so a system crash or power loss can discard the recent `arm_redrive_under_claim` insert after the transition has already been broadcast. Unlike the rebuildable commitment-tree data used to justify NORMAL, a single-note claim's row contains the randomized padded identity ID and cannot be reconstructed. Configure the recovery connection with `synchronous=FULL`, or use an equivalent durable commit/checkpoint protocol before broadcasting.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:194-206: Serialize the identity-index schema migration across processes
  The identity-index migration probes for the column and performs `ALTER TABLE` as separate operations. Two processes opening the same pre-migration database can both observe the column as absent; after one adds it, the other's `ALTER TABLE` fails with a duplicate-column error and causes `open_path` to fail. Hold an IMMEDIATE transaction from the schema probe through the ALTER so the second opener probes only after the first migration commits.

In `packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:783-805: Do not complete wallet removal after skipping its shielded-state wipe
  `unregister_wallet` clears the account, persister, and hydration registrations before acquiring destructive admission, then only logs when claims do not drain within the timeout. `remove_wallet_with_teardown` continues removing the wallet and reports success even though `purge_wallet` never ran. A long claim can therefore leave decrypted notes, watermarks, activity, and an unresolved pending-claim row on disk after the host was told the wallet was removed; the logged instruction to retry is ineffective because a second removal returns `WalletNotFound`. Make unregister/removal fallible and abort before deleting registrations when admission cannot be acquired, or make a guaranteed deferred purge part of the removal completion contract.

In `packages/rs-platform-wallet/src/wallet/shielded/keys.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/keys.rs:50-64: Constrain the unsafe scrub wrapper to audited secret types
  `ScrubOnDrop<T>` is a safe generic wrapper whose `Drop` implementation overwrites any no-drop `T` bytewise. `needs_drop::<T>() == false` only proves that no destructor needs the old representation; it does not prove that an all-zero representation is valid for `T`. Because the tuple constructor is crate-visible, a future safe call site could wrap a reference, `NonZero*`, or another invariant-bearing no-drop type and make the unsafe block violate that type's validity requirements. Make the wrapper specific to the two audited Orchard key types, or require a private sealed unsafe marker implemented only for representations that have been audited for this operation.

In `packages/rs-platform-wallet-ffi/src/shielded_sync.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/shielded_sync.rs:445-454: Preserve lifecycle-busy on the clear FFI path
  `NetworkShieldedCoordinator::clear` now returns `ShieldedLifecycleBusy` when claims do not drain, and this PR assigns that retryable condition native code 45. This entry point passes through only `ShutdownIncomplete`; lifecycle contention is rewritten as generic code 6, which Kotlin classifies as non-retryable. Swift also lacks code 45 in `PlatformWalletResultCode`, so passing it through without updating that mirror would become `errorUnknown`. Route `ShieldedLifecycleBusy` through the typed conversion here and add code 45 to the Swift result and error mappings.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:2567-2571: Serialize concurrent claims before replacing their recovery record
  (existing thread: https://github.com/dashpay/platform/pull/4313#discussion_r3767229122)
  The SQLite reservation now prevents two active holders from arming the same invitation simultaneously, but this lookup still calls `pending_redrives`, whose file-backed implementation reads only the `subwallets` map hydrated when that store instance opened. If stores A and B opened before A armed a claim, A can return `ShieldedBroadcastUnconfirmed`, leave its durable row, and release its reservation. B then acquires the released reservation but cannot see A's row through its stale map, so it may build a different padded transition and replace the durable row through `arm_redrive_under_claim`. If A's transition executes, its randomized identity ID is no longer recoverable. Query the current SQLite pending row after reservation acquisition, or atomically return an existing pending row as part of reservation acquisition, and never arm or clear a claim based only on the startup-hydrated mirror.

@@ -106,18 +107,70 @@ impl FileBackedShieldedStore {
pending_conn

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.

🔴 Blocking: Commit pending claim records with durable SQLite synchronization

pending_conn stores the byte-exact one-time-claim recovery record but is opened through open_tuned_connection, which configures WAL mode with synchronous=NORMAL. A NORMAL-mode WAL commit can return before the WAL is synchronized, so a system crash or power loss can discard the recent arm_redrive_under_claim insert after the transition has already been broadcast. Unlike the rebuildable commitment-tree data used to justify NORMAL, a single-note claim's row contains the randomized padded identity ID and cannot be reconstructed. Configure the recovery connection with synchronous=FULL, or use an equivalent durable commit/checkpoint protocol before broadcasting.

source: ['codex']

Comment on lines +783 to +805
match self
.acquire_destructive_admission(Some(wallet_id), "unregister_wallet")
.await
{
Ok(admission) => {
if let Err(e) = self.store.write().await.purge_wallet(wallet_id) {
tracing::warn!(
wallet_id = %hex::encode(wallet_id),
error = %e,
"Failed to purge per-subwallet store state on unregister"
);
}
self.release_destructive_admission(admission).await;
}
Err(e) => {
tracing::warn!(
wallet_id = %hex::encode(wallet_id),
error = %e,
"Skipping the per-subwallet store purge on unregister: a one-time-key claim \
still holds lifecycle admission and its pending-claim record must survive. \
The registries are cleared regardless, so no sync runs; retry the removal to \
purge the store state."
);

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.

🔴 Blocking: Do not complete wallet removal after skipping its shielded-state wipe

unregister_wallet clears the account, persister, and hydration registrations before acquiring destructive admission, then only logs when claims do not drain within the timeout. remove_wallet_with_teardown continues removing the wallet and reports success even though purge_wallet never ran. A long claim can therefore leave decrypted notes, watermarks, activity, and an unresolved pending-claim row on disk after the host was told the wallet was removed; the logged instruction to retry is ineffective because a second removal returns WalletNotFound. Make unregister/removal fallible and abort before deleting registrations when admission cannot be acquired, or make a guaranteed deferred purge part of the removal completion contract.

source: ['codex', 'coderabbit']

Comment on lines +194 to +206
fn add_pending_spends_identity_index(conn: &Connection) -> Result<(), FileShieldedStoreError> {
let present = {
let mut stmt = conn
.prepare("SELECT 1 FROM pragma_table_info('shielded_pending_spends') WHERE name = 'identity_index'")
.map_err(|e| {
FileShieldedStoreError(format!("prepare pending_spends column probe: {e}"))
})?;
stmt.exists([])
.map_err(|e| FileShieldedStoreError(format!("probe pending_spends columns: {e}")))?
};
if !present {
conn.execute(
"ALTER TABLE shielded_pending_spends ADD COLUMN identity_index INTEGER",

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.

🟡 Suggestion: Serialize the identity-index schema migration across processes

The identity-index migration probes for the column and performs ALTER TABLE as separate operations. Two processes opening the same pre-migration database can both observe the column as absent; after one adds it, the other's ALTER TABLE fails with a duplicate-column error and causes open_path to fail. Hold an IMMEDIATE transaction from the schema probe through the ALTER so the second opener probes only after the first migration commits.

source: ['codex']

Comment on lines +50 to +64
pub(crate) struct ScrubOnDrop<T>(pub(crate) T);

impl<T> Drop for ScrubOnDrop<T> {
fn drop(&mut self) {
// Const-folded: for the Orchard key types this is `false` and the
// scrub always runs. Overwriting a value that still has drop glue
// to execute would be unsound — skip (see the type-level docs).
if core::mem::needs_drop::<T>() {
return;
}
let ptr = &mut self.0 as *mut T as *mut u8;
for i in 0..core::mem::size_of::<T>() {
// Volatile per-byte overwrite: not removable as a dead store.
unsafe { core::ptr::write_volatile(ptr.add(i), 0) };
}

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.

🟡 Suggestion: Constrain the unsafe scrub wrapper to audited secret types

ScrubOnDrop<T> is a safe generic wrapper whose Drop implementation overwrites any no-drop T bytewise. needs_drop::<T>() == false only proves that no destructor needs the old representation; it does not prove that an all-zero representation is valid for T. Because the tuple constructor is crate-visible, a future safe call site could wrap a reference, NonZero*, or another invariant-bearing no-drop type and make the unsafe block violate that type's validity requirements. Make the wrapper specific to the two audited Orchard key types, or require a private sealed unsafe marker implemented only for representations that have been audited for this operation.

source: ['codex']

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

Labels

temp hold On temporary hold while higher priority items are dealt with.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants