Skip to content

feat(swift-sdk,platform-wallet): wire shielded send end-to-end (all 4 transitions)#3603

Open
QuantumExplorer wants to merge 12 commits intov3.1-devfrom
platform-wallet/shielded-spend-ffi
Open

feat(swift-sdk,platform-wallet): wire shielded send end-to-end (all 4 transitions)#3603
QuantumExplorer wants to merge 12 commits intov3.1-devfrom
platform-wallet/shielded-spend-ffi

Conversation

@QuantumExplorer
Copy link
Copy Markdown
Member

@QuantumExplorer QuantumExplorer commented May 5, 2026

Issue being fixed or feature implemented

The Send Dash sheet's four shielded flows all fell through to a
placeholder error ("Shielded sending is being rebuilt — see
follow-up PR") even though ShieldedWallet::transfer /
unshield / withdraw / shield already exist on the Rust
side. Three of them needed only the bound shielded wallet's
cached SpendAuthorizingKey (no host signer); the fourth
(shield, Type 15) needed a host Signer<PlatformAddress> plus
a real per-input nonce fetch (the spend builder previously
stubbed nonces to 0, which drive-abci rejected on broadcast).

This PR threads all four flows end-to-end so the full Send Dash
matrix actually works.

What was done?

platform-wallet

  • New PlatformWalletError::ShieldedNotBound to distinguish
    "wallet has no shielded sub-wallet" from build / broadcast
    failures.

  • New PlatformWallet wrappers (feature-gated shielded):

    • shielded_transfer_to(recipient_raw_43, amount, prover) — Type 16
    • shielded_unshield_to(to_platform_addr_bytes, amount, prover) — Type 17
    • shielded_withdraw_to(to_core_address, amount, core_fee_per_byte, prover) — Type 19
    • shielded_shield_from_account(account_index, amount, signer, prover) — Type 15

    Each takes the prover by value because OrchardProver is
    impl'd on &CachedOrchardProver. The shield_from_account
    helper auto-selects input addresses from the named Platform
    Payment account in ascending derivation order, covering
    amount + 0.01 DASH fee buffer (on-chain fee comes off
    input 0 via DeductFromInput(0)).

  • ShieldedWallet::shield now fetches per-input nonces from
    Platform via AddressInfo::fetch_many and increments them
    before handing to build_shield_transition. Removes the
    long-standing nonce=0 placeholder + TODO.

rs-platform-wallet-ffi

New module shielded_send (feature-gated shielded):

  • platform_wallet_shielded_warm_up_prover() — fire-and-forget
    global, no manager handle.
  • platform_wallet_shielded_prover_is_ready() — bool getter
    for a UI affordance.
  • platform_wallet_manager_shielded_transfer / unshield / withdraw — manager-handle FFIs that resolve the wallet,
    instantiate a CachedOrchardProver, and forward to the
    wallet wrappers via runtime().block_on(...).
  • platform_wallet_manager_shielded_shield(handle, wallet_id, account_index, amount, signer_address_handle) — additionally
    takes a *mut SignerHandle (Swift's KeychainSigner.handle)
    cast to &VTableSigner. Same shape
    platform_address_wallet_transfer uses;
    VTableSigner already implements both
    Signer<PlatformAddress> and Signer<IdentityPublicKey>.

swift-sdk

New async methods on PlatformWalletManager:
shieldedTransfer(walletId:recipientRaw43:amount:),
shieldedUnshield(walletId:toPlatformAddress:amount:),
shieldedWithdraw(walletId:toCoreAddress:amount:coreFeePerByte:),
shieldedShield(walletId:accountIndex:amount:addressSigner:).

All run on Task.detached(priority: .userInitiated) so the
~30 s first-call Halo 2 proof build doesn't block the main
actor. shieldedShield keeps the KeychainSigner alive across
the detached work the same way topUpFromAddresses does.

Static helpers PlatformWalletManager.warmUpShieldedProver()
and PlatformWalletManager.isShieldedProverReady.

swift-example-app

  • SendViewModel.executeSend gains a walletManager
    parameter and replaces all four shielded placeholder
    branches with the real FFI calls. The .platformToShielded
    branch constructs a KeychainSigner from the modelContext
    the same way TopUpIdentityView / RegisterNameView /
    FriendsView already do.
  • SwiftExampleAppApp.bootstrap fires
    warmUpShieldedProver() on a background task at app start
    so the first user-initiated shielded send doesn't pay the
    build cost inline.

Send matrix after this PR

Source Destination Status
Core Core works
Platform Shielded works
Shielded Shielded works
Shielded Platform works
Shielded Core works

Type 18 (shield_from_asset_lock — direct Core L1 → Shielded
without going through Platform first) is still unwired; tracked
separately.

How Has This Been Tested?

  • cargo fmt --all, cargo clippy --workspace --all-features --locked -- --no-deps -D warnings clean.
  • bash build_ios.sh --target sim --profile dev green.
  • E2E shielded behaviour can't be exercised on this PR alone
    in CI — needs a running dashmate stack and a wallet with
    Platform credits — but the underlying spend builders + sign
    • broadcast are unit-tested in DPP, the input-selection +
      nonce-fetch glue is straightforward, and the
      KeychainSigner / VTableSigner / Signer<PlatformAddress>
      path is the same one platform_address_wallet_transfer and
      topUpFromAddresses already use in production.

Breaking Changes

None.

SendViewModel.executeSend gains a required walletManager
parameter, but the only call site is in-tree
(SendTransactionView) and is updated in the same commit set.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added shielded operations: shield (platform→shielded), shielded transfer, unshield (shielded→platform), and withdraw (shielded→core).
    • SDK/UI: Send flow now supports shielded paths with separate L1 (duffs) and shielded (credits) amounts.
    • Per-wallet shielded binding and per-wallet DB support for shielded state.
  • Performance

    • Prover warm-up and readiness checks; background warm-up at app startup to reduce first-use latency.
  • Reliability

    • Improved nonce handling, witness construction, and broadcast diagnostics for more robust shielded operations.

…draw end-to-end

Shielded send was stubbed out behind a "rebuilt in follow-up PR"
placeholder for the four send flows even though
`ShieldedWallet::transfer` / `unshield` / `withdraw` already exist
on the Rust side and need only the bound shielded wallet's cached
`SpendAuthorizingKey` (no host signer). This commit threads them
through to the Swift Send sheet.

platform-wallet
- New `PlatformWalletError::ShieldedNotBound` so the wrapper can
  distinguish "wallet has no shielded sub-wallet" from a build /
  broadcast failure.
- New `PlatformWallet` wrappers under the existing `shielded`
  feature: `shielded_transfer_to(recipient_raw_43, amount, prover)`,
  `shielded_unshield_to(to_platform_addr_bytes, amount, prover)`,
  `shielded_withdraw_to(to_core_address, amount, core_fee_per_byte,
  prover)`. Each takes the prover by value because `OrchardProver`
  is impl'd on `&CachedOrchardProver` (not the bare struct), and
  forwards `&prover` into the underlying `ShieldedWallet` op.
  Address parsing is inline — Orchard 43-byte raw → `PaymentAddress`,
  bincode `PlatformAddress::from_bytes`, `dashcore::Address` from
  string with network-match check.

platform-wallet-ffi
- New module `shielded_send` (feature-gated `shielded`):
  - `platform_wallet_shielded_warm_up_prover()` —
    fire-and-forget global, no manager handle.
  - `platform_wallet_shielded_prover_is_ready()` — bool getter
    for a UI affordance.
  - `platform_wallet_manager_shielded_transfer/unshield/withdraw`
    — manager-handle FFIs that resolve the wallet, instantiate
    a `CachedOrchardProver`, and forward to the wallet wrappers
    via `runtime().block_on(...)`.

swift-sdk
- New `PlatformWalletManager` async methods:
  `shieldedTransfer(walletId:recipientRaw43:amount:)`,
  `shieldedUnshield(walletId:toPlatformAddress:amount:)`,
  `shieldedWithdraw(walletId:toCoreAddress:amount:coreFeePerByte:)`.
  All run on a `Task.detached(priority: .userInitiated)` so the
  ~30 s first-call proof build doesn't block the main actor.
- Static helpers `PlatformWalletManager.warmUpShieldedProver()`
  and `PlatformWalletManager.isShieldedProverReady`.

swift-example-app
- `SendViewModel.executeSend` gains a `walletManager` parameter
  and replaces three of the four shielded placeholder branches
  with the real FFI calls (Shielded → Shielded, Shielded →
  Platform, Shielded → Core). The Platform → Shielded branch
  retains a clearer placeholder because Type 15 still needs the
  per-input nonce fetch the Rust spend builder stubs to zero.
- `SwiftExampleAppApp.bootstrap` kicks off
  `warmUpShieldedProver()` on a background task at app start so
  the first user-initiated shielded send doesn't pay the build
  cost inline.

Verified:
- `cargo fmt --all`, `cargo clippy --workspace --all-features
  --locked -- --no-deps -D warnings` clean.
- `bash build_ios.sh --target sim --profile dev` green
  (** BUILD SUCCEEDED **).

The end-to-end story is still missing Platform → Shielded
(blocked on the spend builder's nonce TODO) and a host
`Signer<PlatformAddress>` adapter, plus the optional Type 18
`shield_from_asset_lock`. Wallets that already have shielded
balance can now move it freely.
@QuantumExplorer QuantumExplorer requested a review from shumkov as a code owner May 5, 2026 23:13
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

Warning

Rate limit exceeded

@QuantumExplorer has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 15 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 31b7612d-656e-4f66-8bfa-60b4be4a3e6b

📥 Commits

Reviewing files that changed from the base of the PR and between 3ffce1a and 4ba2c42.

📒 Files selected for processing (11)
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet-ffi/src/shielded_sync.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/src/wallet/shielded/file_store.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/store.rs
  • packages/rs-platform-wallet/src/wallet/shielded/sync.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
📝 Walkthrough

Walkthrough

Adds conditional shielded-wallet support across FFI, core Rust, and Swift layers (prover lifecycle + shielded ops), replaces placeholder nonce/witness code with real platform nonce fetching and Merkle witnesses, seeds in-memory address balances from persisted state, and updates Swift UI to use dual-amount flows wired to new APIs.

Changes

Shielded Wallet FFI → Core → Swift Flow

Layer / File(s) Summary
FFI Module Declaration & Re-export
packages/rs-platform-wallet-ffi/src/lib.rs
Adds pub mod shielded_send; and #[cfg(feature = "shielded")] pub use shielded_send::*;.
FFI Bindings
packages/rs-platform-wallet-ffi/src/shielded_send.rs
New C ABI functions: prover warm-up/is-ready and shielded ops (transfer, shield, unshield, withdraw). Validate inputs, resolve wallet handle, extract signer vtable, run async ops on worker thread, map errors to PlatformWalletFFIResult.
PlatformWallet API (Rust)
packages/rs-platform-wallet/src/wallet/platform_wallet.rs
Adds async shielded APIs under shielded feature: shielded_transfer_to, shielded_unshield_to, shielded_withdraw_to, shielded_shield_from_account that validate binding, resolve addresses/inputs, build inputs, and delegate to ShieldedWallet.
Error Surface
packages/rs-platform-wallet/src/error.rs
Adds ShieldedNotBound variant with message "Shielded sub-wallet not bound: call bind_shielded first".
Swift FFI Wrappers
packages/swift-sdk/.../PlatformWalletManagerShieldedSync.swift
Adds warmUpShieldedProver, isShieldedProverReady, and async wrappers mapping inputs to FFI calls for transfer/shield/unshield/withdraw (includes keep-alive signer handling).
Swift App Wiring & UI
packages/swift-sdk/SwiftExampleApp/...
Bootstrapping warms prover in background; SendViewModel gains amountDuffs/amountCredits, executeSend accepts walletManager and dispatches shielded/core flows via PlatformWalletManager FFI; SendTransactionView forwards new arg; WalletDetailView switches ShieldedService context on wallet change.
FFI Export Gate
Cargo.toml / feature gating
Shielded surface exposed only when shielded feature enabled (implicit via cfg annotations).

Shield Construction & Address Restoration

Layer / File(s) Summary
Shield Build: nonce fetching & diagnostics
packages/rs-platform-wallet/src/wallet/shielded/operations.rs
Replaces placeholder input nonce with per-address nonce fetch via AddressInfo::fetch_many, constructs inputs_with_nonce, builds claimed-input diagnostics, enhances broadcast error handling to detect and format AddressesNotEnoughFunds diagnostics.
Spend/Witness plumbing
packages/rs-platform-wallet/src/wallet/shielded/operations.rs, .../file_store.rs
Deserializes notes, fetches Merkle witnesses via store.witness(position), and constructs SpendableNote with merkle paths for real spend proofs. file_store.rs implements witness via internal commitment tree.
Trait & In-Memory Store API
packages/rs-platform-wallet/src/wallet/shielded/store.rs
Changes ShieldedStore::witness signature to return Result<Option<grovedb_commitment_tree::MerklePath>, Error>; updates InMemoryShieldedStore accordingly and updates docs to describe typed MerklePath witness.
Persisted balances iterator
packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs
Adds persisted_balances() returning iterator over persisted per-account addresses and funds for seeding in-memory state.
Initialize from persisted wiring
packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs
During initialize_from_persisted, pushes persisted per-account address balances into in-memory core wallet via manager write-lock and set_address_credit_balance before provider initialization.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant SwiftApp as Swift App
  participant FFI as rs-platform-wallet-ffi
  participant Wallet as PlatformWallet (Rust)
  participant Network as Platform Network
  participant Prover as CachedOrchardProver

  SwiftApp->>FFI: platform_wallet_manager_shielded_transfer(walletId, recipient, amount)
  FFI->>Wallet: resolve_wallet(handle) & spawn worker task
  Wallet->>Network: fetch AddressInfo for input addresses (nonces & balances)
  Network-->>Wallet: address nonces & balances
  Wallet->>Prover: request proving keys / build proof
  Prover-->>Wallet: proving key / proof result
  Wallet->>Network: broadcast state transition
  Network-->>Wallet: broadcast success / error (maybe AddressesNotEnoughFunds)
  Wallet-->>FFI: return mapped PlatformWalletFFIResult
  FFI-->>SwiftApp: return to caller
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

"I warmed the prover in a burrow bright,
I fetched each nonce by moonlit night,
From Swift to FFI, proofs take flight,
I stitched witnesses into the light,
A rabbit hops with shielded delight! 🐇✨"

🚥 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 accurately describes the main change: wiring all four shielded send transitions end-to-end across the swift-sdk and platform-wallet, which aligns with the PR's core objectives.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch platform-wallet/shielded-spend-ffi

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions github-actions Bot added this to the v3.1.0 milestone May 5, 2026
@thepastaclaw
Copy link
Copy Markdown
Collaborator

thepastaclaw commented May 5, 2026

Review Gate

Commit: 4ba2c423

  • Debounce: 1m ago (need 30m)

  • CI checks: checks still running (1 pending)

  • CodeRabbit review: comment found

  • Off-peak hours: off-peak (02:18 AM PT Wednesday)

  • Run review now (check to override)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "26004ca4372464d491a53b7e4b6eb45c4a9b197838789ae89e130579ab26a3ee"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

…pe 15)

Completes the four shielded send flows by lighting up Type 15.
The Rust spend pipeline already had `ShieldedWallet::shield` but
stubbed every input's nonce to 0, which drive-abci rejected on
broadcast. This commit:

platform-wallet
- `ShieldedWallet::shield` now fetches per-input nonces from
  Platform via `AddressInfo::fetch_many` and increments them
  before handing to `build_shield_transition`. Removes the
  long-standing `nonce=0` placeholder + TODO.
- New `PlatformWallet::shielded_shield_from_account` helper
  with auto input selection: walks the chosen Platform Payment
  account's addresses in ascending derivation order and picks
  enough to cover `amount + 0.01 DASH` fee buffer (the
  on-chain fee comes off input 0 via `DeductFromInput(0)`).
  Returns `ShieldedInsufficientBalance` if the account total
  can't cover the request.

rs-platform-wallet-ffi
- New `platform_wallet_manager_shielded_shield(handle,
  wallet_id, account_index, amount, signer_address_handle)`
  in `shielded_send.rs`. Takes a `*mut SignerHandle`
  (Swift's `KeychainSigner.handle`) and casts to
  `&VTableSigner` — same shape `platform_address_wallet_transfer`
  uses, since `VTableSigner` already implements
  `Signer<PlatformAddress>`.

swift-sdk
- New async method `PlatformWalletManager.shieldedShield(
  walletId:accountIndex:amount:addressSigner:)`. Threads the
  `KeychainSigner` keepalive through the detached task the
  same way `topUpFromAddresses` does.

swift-example-app
- `SendViewModel.executeSend`'s `.platformToShielded` branch
  now constructs a `KeychainSigner` and calls
  `walletManager.shieldedShield(...)`. Replaces the last of
  the four shielded placeholder errors.

The full Send Dash matrix is now real:

| Source     | Destination  | Status     |
|------------|--------------|------------|
| Core       | Core         | works      |
| Platform   | Shielded     | works (this PR) |
| Shielded   | Shielded     | works      |
| Shielded   | Platform     | works      |
| Shielded   | Core         | works      |

Type 18 (`shield_from_asset_lock`) — direct Core L1 → Shielded
without going through Platform first — is still unwired; tracked
separately.
@QuantumExplorer QuantumExplorer changed the title feat(swift-sdk,platform-wallet): wire shielded transfer/unshield/withdraw end-to-end feat(swift-sdk,platform-wallet): wire shielded send end-to-end (all 4 transitions) May 5, 2026
QuantumExplorer and others added 3 commits May 6, 2026 06:40
… restore + send credits at credits scale

Two adjacent bugs that surfaced together when sending Platform →
Shielded immediately after a fresh app launch:

**`shielded_shield_from_account` reported `available 0`** even
though the wallet detail showed 1.005 DASH on the Platform
Payment account. `PlatformAddressWallet::initialize_from_persisted`
was only seeding the *provider*'s `found` map — the source it
hands to the SDK's incremental sync — but never pushing those
balances into the in-memory `ManagedPlatformAccount.address_balances`
map. Spend paths that enumerate funded addresses
(`shielded_shield_from_account`,
`PlatformAddressWallet::addresses_with_balances`,
`account.address_credit_balance`) all read from
`address_balances`, so they returned 0 until the first BLAST sync
finished and `provider::on_address_found` repopulated it.

Walk `persisted.per_account` at restore time and call
`set_address_credit_balance(addr, balance, None)` on the matching
`ManagedPlatformAccount` for each entry, mirroring the same
`apply_changeset` path the steady-state sync writes through. New
public accessor `PerAccountPlatformAddressState::persisted_balances()`
exposes the iteration without leaking the inner `found` map.

**Send screen sent at duffs scale.** `SendViewModel.amount`
unconditionally multiplied the typed DASH value by 1e8 (L1 duffs).
Right for `coreToCore` but wrong for the four flows that touch
the credits ledger (1 DASH = 1e11), which underpaid by 1000×.
Typing 0.5 DASH for a Platform → Shielded shield turned into
50_000_000 credits (~0.0005 DASH) on the wire — error-message
gave it away as `required 1050000000 = amount + fee_buffer`.

Split into `amountDuffs` and `amountCredits`. `executeSend`
picks `amountCredits` for `shieldedToShielded`,
`shieldedToPlatform`, `shieldedToCore`, `platformToShielded`;
`coreToCore` still uses `amountDuffs`. The legacy `amount`
property aliases `amountDuffs` so any caller that hadn't been
audited still gets Core-correct semantics.

Verified: `cargo clippy --workspace --all-features --locked
-- --no-deps -D warnings` clean, `bash build_ios.sh --target
sim --profile dev` green.
Halo 2 circuit synthesis recurses past the ~512 KB iOS dispatch-thread
stack and crashes with EXC_BAD_ACCESS on the first
`synthesize(config.clone(), V1Pass::<_, CS>::measure(pass))?` call when
the future is polled directly on the calling thread. Switch the four
shielded spend FFI entry points (transfer/unshield/withdraw/shield)
from `runtime().block_on(...)` to `block_on_worker(...)` so the proof
runs on a tokio worker with the configured 8 MB stack — the exact case
`runtime.rs` was set up for.

For `shield`, transmute the borrowed `&VTableSigner` to `&'static`
inside the FFI call: the caller retains ownership of the signer handle
and we block until the worker future completes, so the painted
lifetime never actually escapes the call. `VTableSigner` is
`Send + Sync` per its `unsafe impl` in rs-sdk-ffi, so the resulting
reference is `Send + 'static` — exactly what `block_on_worker` needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…adcast failure

`AddressesNotEnoughFundsError` from drive-abci already carries
`addresses_with_info: BTreeMap<PlatformAddress, (AddressNonce, Credits)>`
— Platform's actual per-address nonce and remaining balance after
the bundle's `DeductFromInput(0)` strategy deducts the shield
amount. Stringifying with `e.to_string()` discarded everything but
`required_balance` (the fee), leaving the host with no way to tell
*which* input fell short or whether the local-cache balance
disagreed with Platform.

Pattern-match the broadcast `dash_sdk::Error` for the structured
consensus error (via `Error::Protocol(ProtocolError::ConsensusError)`
or `Error::StateTransitionBroadcastError { cause }`), then format
both the local claim list and Platform's view side-by-side. Add a
per-input `tracing::info!`/`warn!` before broadcast so the same
data is visible in logs even on success — and hosts can spot
local-cache drift by comparing claimed_credits vs platform_balance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 (5)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift (2)

407-438: 💤 Low value

Consider validating toCoreAddress is non-empty.

Other methods explicitly reject empty inputs; an empty toCoreAddress here would be passed straight to withCString and across the FFI as a zero-length C string. Rust will reject it, but a host-side guard produces a clearer error and avoids the detached-task hop.

♻️ Suggested guard
         guard walletId.count == 32 else {
             throw PlatformWalletError.invalidParameter(
                 "walletId must be exactly 32 bytes"
             )
         }
+        guard !toCoreAddress.isEmpty else {
+            throw PlatformWalletError.invalidParameter(
+                "toCoreAddress is empty"
+            )
+        }
🤖 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift`
around lines 407 - 438, Add a precondition that toCoreAddress is non-empty in
shieldedWithdraw before spawning the detached Task: check that
toCoreAddress.isEmpty == false and if empty throw
PlatformWalletError.invalidParameter with a clear message like "toCoreAddress
must be non-empty"; place this validation alongside the existing walletId and
handle guards at the top of the function so the invalid input is rejected on the
host side rather than passed into
withCString/platform_wallet_manager_shielded_withdraw.

374-378: 💤 Low value

Tighter validation on toPlatformAddress would catch host-side mistakes earlier.

The doc says the address is "bincode-encoded PlatformAddress0x00 ‖ 20-byte hash for P2PKH", which implies a 21-byte payload for P2PKH. The current guard only rejects empty buffers, so a malformed length (e.g. raw 20-byte hash without the discriminant byte) gets passed to FFI and produces a less-actionable error from Rust. Consider rejecting clearly invalid lengths up-front.

♻️ Suggested validation
-        guard !toPlatformAddress.isEmpty else {
-            throw PlatformWalletError.invalidParameter(
-                "toPlatformAddress is empty"
-            )
-        }
+        // Bincode-encoded `PlatformAddress`: 1-byte discriminant + payload.
+        // P2PKH today is `0x00 ‖ 20 bytes` = 21 bytes. Reject anything that
+        // can't possibly be a valid encoding before crossing the FFI boundary.
+        guard toPlatformAddress.count >= 2 else {
+            throw PlatformWalletError.invalidParameter(
+                "toPlatformAddress is too short to be a bincode PlatformAddress"
+            )
+        }
🤖 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift`
around lines 374 - 378, The guard that only rejects an empty toPlatformAddress
is too weak; in PlatformWalletManagerShieldedSync validate the bincode-encoded
PlatformAddress length (for P2PKH expect exactly 21 bytes: leading discriminant
0x00 + 20-byte hash) before calling the FFI. Replace the empty check on
parameter toPlatformAddress with a length check and throw
PlatformWalletError.invalidParameter with a clear message like
"toPlatformAddress must be 21 bytes (0x00 || 20-byte hash)" when the size is
invalid so malformed 20-byte raw hashes are rejected earlier.
packages/rs-platform-wallet/src/wallet/shielded/operations.rs (1)

73-90: 💤 Low value

Hoist the use statements to module scope.

The three use imports (FetchMany, AddressInfo, BTreeSet) are placed inside the function body. While valid, this departs from the existing pattern in this file (all other imports are at the top). Moving them up keeps the import surface discoverable.

♻️ Proposed move
@@ top of file
 use std::collections::BTreeMap;
+use std::collections::BTreeSet;

 use dash_sdk::platform::transition::broadcast::BroadcastStateTransition;
+use dash_sdk::platform::FetchMany;
+use dash_sdk::query_types::AddressInfo;
@@ inside shield()
-        // Fetch the current address nonces from Platform. Each
-        // input address has a per-address nonce that the next
-        // state transition must use as `last_used + 1`.
-        // ...
-        use dash_sdk::platform::FetchMany;
-        use dash_sdk::query_types::AddressInfo;
-        use std::collections::BTreeSet;
-
         let address_set: BTreeSet<PlatformAddress> = inputs.keys().copied().collect();
🤖 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/rs-platform-wallet/src/wallet/shielded/operations.rs` around lines
73 - 90, Move the three local imports used in this block up to module scope to
match the file's import pattern: remove the in-function uses of FetchMany,
AddressInfo, and BTreeSet and add them to the top-level use statements for the
module so callers like AddressInfo::fetch_many(&self.sdk, ...) and references to
PlatformAddress and inputs.keys() keep working; ensure you import
dash_sdk::platform::FetchMany, dash_sdk::query_types::AddressInfo, and
std::collections::BTreeSet at the file top and then delete the redundant
in-function use lines in operations.rs.
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift (2)

116-118: 💤 Low value

canSend keys off amountDuffs even for credits-based flows.

Today amountDuffs != nilamountCredits != nil because both parsers gate on the same Double > 0 predicate, so this is correct in practice. It will silently break the moment one parser gains stricter validation (e.g., the Decimal switch suggested elsewhere, or an upper-bound check). Consider keying off the right unit per detectedFlow so the invariant is local rather than implicit.

🤖 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift`
around lines 116 - 118, The computed property canSend currently checks
amountDuffs regardless of flow; change it to validate the correct amount field
based on detectedFlow (e.g., if detectedFlow indicates a credits-based flow
require amountCredits != nil, otherwise require amountDuffs != nil) while still
checking detectedFlow != nil and !isSending; locate and update the canSend
property and use detectedFlow’s discriminator (enum case or helper like
isCredits) to pick the right amount variable (amountCredits or amountDuffs) so
the invariant is explicit and local.

92-108: ⚡ Quick win

Consider parsing amounts via Decimal to avoid float rounding.

Double(amountString) * 100_000_000_000 is binary-floating-point multiplication, so user-friendly inputs that aren't representable in base-2 quietly truncate by one or more credit. Example: "1.23"Double1.2299999999999999* 1e11 ≈ 122999999999.99998UInt64(...)122999999999 (intent: 123_000_000_000). For credits this is a one-credit dust loss per send; for any amount whose decimal string has >15.95 significant digits the rounding gets larger.

♻️ Decimal-based parsing
-    var amountDuffs: UInt64? {
-        guard let double = Double(amountString), double > 0 else { return nil }
-        return UInt64(double * 100_000_000)
-    }
+    var amountDuffs: UInt64? {
+        guard let dash = Decimal(string: amountString), dash > 0 else { return nil }
+        let duffs = (dash * Decimal(100_000_000)) as NSDecimalNumber
+        return duffs.uint64Value
+    }
@@
-    var amountCredits: UInt64? {
-        guard let double = Double(amountString), double > 0 else { return nil }
-        return UInt64(double * 100_000_000_000)
-    }
+    var amountCredits: UInt64? {
+        guard let dash = Decimal(string: amountString), dash > 0 else { return nil }
+        let credits = (dash * Decimal(100_000_000_000)) as NSDecimalNumber
+        return credits.uint64Value
+    }

(Optionally round explicitly via NSDecimalNumberHandler if you want banker's rounding rather than uint64Value's default.)

🤖 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift`
around lines 92 - 108, The parsing in amountDuffs and amountCredits uses Double
which causes binary-floating rounding loss; update both computed properties
(amountDuffs and amountCredits) to parse amountString with Decimal (or
NSDecimalNumber), perform the multiplication using Decimal (100_000_000 and
100_000_000_000 respectively), then convert to UInt64 using an explicit rounding
mode (via NSDecimalNumberHandler or Decimal's rounded(_:)) to avoid off-by-one
dust; keep the same guard for positive values and return nil on parse failure.
🤖 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 91-103: The code currently treats a None AddressInfo as an error;
change handling so that when infos.get(&addr) returns Some(None) (proof of
absence) you treat the starting nonce as 0 and compute nonce = 0 + 1, instead of
failing; keep an error only if the map lacks the key entirely (infos.get(&addr)
is None). Concretely, update the inputs loop that populates inputs_with_nonce
(and the lookup of infos.get(&addr) / info.nonce) to accept opt.as_ref().map(|i|
i.nonce).unwrap_or(0) and then insert (nonce + 1, credits) for that addr;
preserve the PlatformWalletError only for a truly missing map entry.

---

Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- Around line 73-90: Move the three local imports used in this block up to
module scope to match the file's import pattern: remove the in-function uses of
FetchMany, AddressInfo, and BTreeSet and add them to the top-level use
statements for the module so callers like AddressInfo::fetch_many(&self.sdk,
...) and references to PlatformAddress and inputs.keys() keep working; ensure
you import dash_sdk::platform::FetchMany, dash_sdk::query_types::AddressInfo,
and std::collections::BTreeSet at the file top and then delete the redundant
in-function use lines in operations.rs.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift`:
- Around line 407-438: Add a precondition that toCoreAddress is non-empty in
shieldedWithdraw before spawning the detached Task: check that
toCoreAddress.isEmpty == false and if empty throw
PlatformWalletError.invalidParameter with a clear message like "toCoreAddress
must be non-empty"; place this validation alongside the existing walletId and
handle guards at the top of the function so the invalid input is rejected on the
host side rather than passed into
withCString/platform_wallet_manager_shielded_withdraw.
- Around line 374-378: The guard that only rejects an empty toPlatformAddress is
too weak; in PlatformWalletManagerShieldedSync validate the bincode-encoded
PlatformAddress length (for P2PKH expect exactly 21 bytes: leading discriminant
0x00 + 20-byte hash) before calling the FFI. Replace the empty check on
parameter toPlatformAddress with a length check and throw
PlatformWalletError.invalidParameter with a clear message like
"toPlatformAddress must be 21 bytes (0x00 || 20-byte hash)" when the size is
invalid so malformed 20-byte raw hashes are rejected earlier.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift`:
- Around line 116-118: The computed property canSend currently checks
amountDuffs regardless of flow; change it to validate the correct amount field
based on detectedFlow (e.g., if detectedFlow indicates a credits-based flow
require amountCredits != nil, otherwise require amountDuffs != nil) while still
checking detectedFlow != nil and !isSending; locate and update the canSend
property and use detectedFlow’s discriminator (enum case or helper like
isCredits) to pick the right amount variable (amountCredits or amountDuffs) so
the invariant is explicit and local.
- Around line 92-108: The parsing in amountDuffs and amountCredits uses Double
which causes binary-floating rounding loss; update both computed properties
(amountDuffs and amountCredits) to parse amountString with Decimal (or
NSDecimalNumber), perform the multiplication using Decimal (100_000_000 and
100_000_000_000 respectively), then convert to UInt64 using an explicit rounding
mode (via NSDecimalNumberHandler or Decimal's rounded(_:)) to avoid off-by-one
dust; keep the same guard for positive values and return nil on parse failure.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 013dd0c5-e525-4073-932a-7dd9f3ff4d1e

📥 Commits

Reviewing files that changed from the base of the PR and between cc19a4e and a8d9b14.

📒 Files selected for processing (11)
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift

Comment thread packages/rs-platform-wallet/src/wallet/shielded/operations.rs
The shield transition uses `DeductFromInput(0)` as its fee strategy,
which drive-abci interprets as "after each input has had its claim
deducted, take the fee out of input 0's *remaining* balance" (see
the doc comment on `deduct_fee_from_outputs_or_remaining_balance_of_inputs_v0`
in rs-dpp). "Input 0" is the BTreeMap-smallest key.

The previous selection code claimed the full balance of every picked
input, so every input's remaining was 0, and `DeductFromInput(0)` had
nothing to bite into. Platform rejected the broadcast with
`AddressesNotEnoughFundsError` showing "total available is less than
required <fee>".

Sort candidates by address bytes (BTreeMap order), skip leading dust
addresses whose balance can't reserve the fee buffer (so the next
funded address becomes the bundle's input 0), then claim only what's
needed to cover `amount` — capping input 0's claim at
`balance - FEE_RESERVE_CREDITS` so its post-claim remaining stays
≥ FEE_RESERVE for the network's fee deduction step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

PR wires the four shielded send flows end-to-end. One blocking issue: the Shielded→Platform path passes bech32m-payload bytes (type byte 0xb0/0x80) to a Rust entry point that decodes via bincode (expects 0x00/0x01), so unshield will fail to decode any real platform recipient. Several real suggestions around nonce overflow, fee-reserve fallback selection, and the unusual &'static transmute. 4 lower-priority findings dropped.

Reviewed commit: 6c72239

🔴 1 blocking | 🟡 7 suggestion(s) | 💬 2 nitpick(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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift`:
- [BLOCKING] lines 240-249: Shielded→Platform sends pass the wrong platform-address byte format to Rust
  `DashAddress.parse` returns the 21-byte bech32m payload — the type byte is 0xb0 (P2PKH) or 0x80 (P2SH) per `PlatformAddress::to_bech32m_string` in `packages/rs-dpp/src/address_funds/platform_address.rs:222-242`. Those bytes are passed straight through to `platform_wallet_manager_shielded_unshield`, which calls `PlatformAddress::from_bytes` (`platform_wallet.rs:413`). `from_bytes` is bincode-decoded and expects the storage variant index — 0x00 for P2pkh, 0x01 for P2sh (see the test at `platform_address.rs:1386-1387` and the explicit `to_bytes`/`from_bytes` doc-comments at 311-319/333-337). So a normal user-entered address fails to decode and the unshield broadcast can't proceed. The fix is to either translate the type byte at the Swift→Rust boundary (0xb0→0x00, 0x80→0x01), or to expose an FFI entry point that accepts the bech32m-encoded string and goes through `PlatformAddress::from_bech32m_string` instead.
- [SUGGESTION] lines 221-242: shielded→shielded and shielded→platform branches re-parse the untrimmed recipient text
  `detectAddressType()` (line 153) trims whitespace before calling `DashAddress.parse`, so a pasted address with a trailing newline is recognised and the send button is enabled. The shielded→shielded branch (lines 221) and shielded→platform branch (line 240) then re-parse `recipientAddress` without trimming, so the same input hits a `Recipient is not …` error at submit time. The Core branch (line 205) and Shielded→Core branch (line 261) already trim — these two should match.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [SUGGESTION] line 167: Address nonce increment can wrap silently at u32::MAX
  `AddressNonce` is a `u32`, and `info.nonce + 1` on the line that builds `inputs_with_nonce` will panic in debug and wrap to 0 in release once an address reaches the ceiling. Drive treats `u32::MAX` as exhausted, so wrapping submits a transition with nonce 0 — drive-abci then rejects it as a replay, after the wallet has spent ~30 s building the Halo 2 proof. Practically unreachable today, but a `checked_add(1).ok_or(PlatformWalletError::ShieldedBuildError(...))` keeps the failure mode legible and matches the conservative style used elsewhere in this crate.
- [SUGGESTION] lines 117-145: Reimplements rs-sdk's canonical address-nonce fetch instead of reusing it
  rs-sdk has `fetch_inputs_with_nonce`, `nonce_inc`, and `ensure_address_balance` in `packages/rs-sdk/src/platform/transition/address_inputs.rs:12-40` that encapsulate exactly this fetch-and-increment dance plus a hard balance check. They are `pub(crate)` today, so platform-wallet can't reach them directly, but a single-line visibility change would let this code re-use the canonical helpers. As written, the new shield path will silently drift from the SDK's behaviour — for example the SDK enforces a balance check that this implementation only `warn!`s on.
- [SUGGESTION] lines 108-168: Concurrent shields on the same wallet TOCTOU on the fetched address nonce
  Nonces are fetched via `AddressInfo::fetch_many`, incremented locally, then handed to the builder. Two concurrent calls to `ShieldedWallet::shield` for the same wallet (e.g. user double-taps Send, or app retries while the first is still proving) both observe the same `info.nonce`, both build with `info.nonce + 1`, and the second to land at drive-abci is rejected with a nonce conflict. Not exploitable, but produces an opaque user-facing failure after a ~30 s proof. Either serialise shield-class operations on a per-wallet mutex inside `ShieldedWallet`, or document at the FFI boundary that hosts must enforce single-flight.

In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [SUGGESTION] lines 553-556: Fall-through input selection can pick a tiny address as input 0 with no real fee headroom
  When no candidate has `balance > FEE_RESERVE_CREDITS`, `viable_input_0` falls through to 0 and `usable` becomes the entire candidate slice. The total-balance check still requires `total_usable >= amount + FEE_RESERVE_CREDITS`, so practical broadcasts usually still succeed — actual mempool fees on Type 15 are ~20M credits, well below any candidate that can contribute. But in pathological dust scenarios (every funded address holds < actual fee) the chosen input 0's remaining balance can be smaller than the fee, and the broadcast will fail only after the ~30 s proof. Since the comment at lines 547-552 already acknowledges this case will be rejected by the network, it's cheaper to short-circuit here with `ShieldedInsufficientBalance { available: total_usable, required: amount + FEE_RESERVE_CREDITS }` when no candidate exceeds the reserve, instead of producing a bundle that's known to be on the boundary.
- [SUGGESTION] lines 469-610: shielded_shield_from_account selection logic has no Rust unit coverage
  `shielded_shield_from_account` carries non-trivial selection rules that directly determine whether shield broadcasts succeed: skipping leading addresses below `FEE_RESERVE_CREDITS`, reserving fee headroom only on input 0, walking BTreeMap order, and accumulating to `amount`. None of this is covered by a focused Rust test, so a future refactor can reintroduce the original `viable_input_0`-style failure without tripping CI. Worth a deterministic unit test against a synthetic managed account covering: dust-first-address case, exact-reserve case, and amount-equal-to-total case.

In `packages/rs-platform-wallet-ffi/src/shielded_send.rs`:
- [SUGGESTION] lines 268-271: Use the established usize round-trip pattern instead of transmuting the signer borrow to 'static
  `block_on_worker` requires `F: 'static`, and the new shield path satisfies this with `mem::transmute::<&VTableSigner, &'static VTableSigner>(...)`. It is sound today only because `block_on_worker` (`runtime.rs`) parks on the spawned future to completion — any future change that lets it return early (timeout, cancellation, shutdown select!) silently turns this into a use-after-free. Other call sites in this crate (e.g. `identity_top_up.rs:117-122`) solve the same `Send + 'static` constraint by round-tripping the signer pointer through `usize` and re-materializing the `&VTableSigner` *inside* the future, which captures only `Send + 'static` data and avoids the lifetime fiction entirely. Aligning the shield path to that pattern would remove a sharp edge from the FFI surface at zero behavioural cost.

Comment on lines +240 to +249
let parsed = DashAddress.parse(recipientAddress, network: network)
guard case .platform(let addressBytes) = parsed.type else {
error = "Recipient is not a platform address"
return
}
try await walletManager.shieldedUnshield(
walletId: wallet.walletId,
toPlatformAddress: addressBytes,
amount: amountCredits
)
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: Shielded→Platform sends pass the wrong platform-address byte format to Rust

DashAddress.parse returns the 21-byte bech32m payload — the type byte is 0xb0 (P2PKH) or 0x80 (P2SH) per PlatformAddress::to_bech32m_string in packages/rs-dpp/src/address_funds/platform_address.rs:222-242. Those bytes are passed straight through to platform_wallet_manager_shielded_unshield, which calls PlatformAddress::from_bytes (platform_wallet.rs:413). from_bytes is bincode-decoded and expects the storage variant index — 0x00 for P2pkh, 0x01 for P2sh (see the test at platform_address.rs:1386-1387 and the explicit to_bytes/from_bytes doc-comments at 311-319/333-337). So a normal user-entered address fails to decode and the unshield broadcast can't proceed. The fix is to either translate the type byte at the Swift→Rust boundary (0xb0→0x00, 0x80→0x01), or to expose an FFI entry point that accepts the bech32m-encoded string and goes through PlatformAddress::from_bech32m_string instead.

source: ['codex']

🤖 Fix this 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift`:
- [BLOCKING] lines 240-249: Shielded→Platform sends pass the wrong platform-address byte format to Rust
  `DashAddress.parse` returns the 21-byte bech32m payload — the type byte is 0xb0 (P2PKH) or 0x80 (P2SH) per `PlatformAddress::to_bech32m_string` in `packages/rs-dpp/src/address_funds/platform_address.rs:222-242`. Those bytes are passed straight through to `platform_wallet_manager_shielded_unshield`, which calls `PlatformAddress::from_bytes` (`platform_wallet.rs:413`). `from_bytes` is bincode-decoded and expects the storage variant index — 0x00 for P2pkh, 0x01 for P2sh (see the test at `platform_address.rs:1386-1387` and the explicit `to_bytes`/`from_bytes` doc-comments at 311-319/333-337). So a normal user-entered address fails to decode and the unshield broadcast can't proceed. The fix is to either translate the type byte at the Swift→Rust boundary (0xb0→0x00, 0x80→0x01), or to expose an FFI entry point that accepts the bech32m-encoded string and goes through `PlatformAddress::from_bech32m_string` instead.

"Shield input"
);
}
inputs_with_nonce.insert(addr, (info.nonce + 1, credits));
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: Address nonce increment can wrap silently at u32::MAX

AddressNonce is a u32, and info.nonce + 1 on the line that builds inputs_with_nonce will panic in debug and wrap to 0 in release once an address reaches the ceiling. Drive treats u32::MAX as exhausted, so wrapping submits a transition with nonce 0 — drive-abci then rejects it as a replay, after the wallet has spent ~30 s building the Halo 2 proof. Practically unreachable today, but a checked_add(1).ok_or(PlatformWalletError::ShieldedBuildError(...)) keeps the failure mode legible and matches the conservative style used elsewhere in this crate.

💡 Suggested change
Suggested change
inputs_with_nonce.insert(addr, (info.nonce + 1, credits));
let next_nonce = info.nonce.checked_add(1).ok_or_else(|| {
PlatformWalletError::ShieldedBuildError(format!(
"input address nonce exhausted on platform: {:?}",
addr
))
})?;
inputs_with_nonce.insert(addr, (next_nonce, credits));

source: ['claude', 'codex']

🤖 Fix this 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`:
- [SUGGESTION] line 167: Address nonce increment can wrap silently at u32::MAX
  `AddressNonce` is a `u32`, and `info.nonce + 1` on the line that builds `inputs_with_nonce` will panic in debug and wrap to 0 in release once an address reaches the ceiling. Drive treats `u32::MAX` as exhausted, so wrapping submits a transition with nonce 0 — drive-abci then rejects it as a replay, after the wallet has spent ~30 s building the Halo 2 proof. Practically unreachable today, but a `checked_add(1).ok_or(PlatformWalletError::ShieldedBuildError(...))` keeps the failure mode legible and matches the conservative style used elsewhere in this crate.

Comment on lines +553 to +556
let viable_input_0 = candidates
.iter()
.position(|(_, balance)| *balance > FEE_RESERVE_CREDITS)
.unwrap_or(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: Fall-through input selection can pick a tiny address as input 0 with no real fee headroom

When no candidate has balance > FEE_RESERVE_CREDITS, viable_input_0 falls through to 0 and usable becomes the entire candidate slice. The total-balance check still requires total_usable >= amount + FEE_RESERVE_CREDITS, so practical broadcasts usually still succeed — actual mempool fees on Type 15 are ~20M credits, well below any candidate that can contribute. But in pathological dust scenarios (every funded address holds < actual fee) the chosen input 0's remaining balance can be smaller than the fee, and the broadcast will fail only after the ~30 s proof. Since the comment at lines 547-552 already acknowledges this case will be rejected by the network, it's cheaper to short-circuit here with ShieldedInsufficientBalance { available: total_usable, required: amount + FEE_RESERVE_CREDITS } when no candidate exceeds the reserve, instead of producing a bundle that's known to be on the boundary.

source: ['claude']

🤖 Fix this 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/platform_wallet.rs`:
- [SUGGESTION] lines 553-556: Fall-through input selection can pick a tiny address as input 0 with no real fee headroom
  When no candidate has `balance > FEE_RESERVE_CREDITS`, `viable_input_0` falls through to 0 and `usable` becomes the entire candidate slice. The total-balance check still requires `total_usable >= amount + FEE_RESERVE_CREDITS`, so practical broadcasts usually still succeed — actual mempool fees on Type 15 are ~20M credits, well below any candidate that can contribute. But in pathological dust scenarios (every funded address holds < actual fee) the chosen input 0's remaining balance can be smaller than the fee, and the broadcast will fail only after the ~30 s proof. Since the comment at lines 547-552 already acknowledges this case will be rejected by the network, it's cheaper to short-circuit here with `ShieldedInsufficientBalance { available: total_usable, required: amount + FEE_RESERVE_CREDITS }` when no candidate exceeds the reserve, instead of producing a bundle that's known to be on the boundary.

Comment on lines +221 to +242
let parsed = DashAddress.parse(recipientAddress, network: network)
guard case .orchard(let recipientRaw) = parsed.type else {
error = "Recipient is not a shielded address"
return
}
try await walletManager.shieldedTransfer(
walletId: wallet.walletId,
recipientRaw43: recipientRaw,
amount: amountCredits
)
successMessage = "Shielded transfer complete"

case .shieldedToPlatform:
// Shielded → Platform: spend notes, credit the
// platform address (also credits scale).
guard let amountCredits else {
error = "Invalid amount"
return
}
let parsed = DashAddress.parse(recipientAddress, network: network)
guard case .platform(let addressBytes) = parsed.type else {
error = "Recipient is not a platform address"
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: shielded→shielded and shielded→platform branches re-parse the untrimmed recipient text

detectAddressType() (line 153) trims whitespace before calling DashAddress.parse, so a pasted address with a trailing newline is recognised and the send button is enabled. The shielded→shielded branch (lines 221) and shielded→platform branch (line 240) then re-parse recipientAddress without trimming, so the same input hits a Recipient is not … error at submit time. The Core branch (line 205) and Shielded→Core branch (line 261) already trim — these two should match.

source: ['codex']

🤖 Fix this 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift`:
- [SUGGESTION] lines 221-242: shielded→shielded and shielded→platform branches re-parse the untrimmed recipient text
  `detectAddressType()` (line 153) trims whitespace before calling `DashAddress.parse`, so a pasted address with a trailing newline is recognised and the send button is enabled. The shielded→shielded branch (lines 221) and shielded→platform branch (line 240) then re-parse `recipientAddress` without trimming, so the same input hits a `Recipient is not …` error at submit time. The Core branch (line 205) and Shielded→Core branch (line 261) already trim — these two should match.

Comment on lines +268 to +271
let address_signer: &'static VTableSigner =
std::mem::transmute::<&VTableSigner, &'static VTableSigner>(
&*(signer_address_handle as *const VTableSigner),
);
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: Use the established usize round-trip pattern instead of transmuting the signer borrow to 'static

block_on_worker requires F: 'static, and the new shield path satisfies this with mem::transmute::<&VTableSigner, &'static VTableSigner>(...). It is sound today only because block_on_worker (runtime.rs) parks on the spawned future to completion — any future change that lets it return early (timeout, cancellation, shutdown select!) silently turns this into a use-after-free. Other call sites in this crate (e.g. identity_top_up.rs:117-122) solve the same Send + 'static constraint by round-tripping the signer pointer through usize and re-materializing the &VTableSigner inside the future, which captures only Send + 'static data and avoids the lifetime fiction entirely. Aligning the shield path to that pattern would remove a sharp edge from the FFI surface at zero behavioural cost.

source: ['claude']

🤖 Fix this 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`:
- [SUGGESTION] lines 268-271: Use the established usize round-trip pattern instead of transmuting the signer borrow to 'static
  `block_on_worker` requires `F: 'static`, and the new shield path satisfies this with `mem::transmute::<&VTableSigner, &'static VTableSigner>(...)`. It is sound today only because `block_on_worker` (`runtime.rs`) parks on the spawned future to completion — any future change that lets it return early (timeout, cancellation, shutdown select!) silently turns this into a use-after-free. Other call sites in this crate (e.g. `identity_top_up.rs:117-122`) solve the same `Send + 'static` constraint by round-tripping the signer pointer through `usize` and re-materializing the `&VTableSigner` *inside* the future, which captures only `Send + 'static` data and avoids the lifetime fiction entirely. Aligning the shield path to that pattern would remove a sharp edge from the FFI surface at zero behavioural cost.

Comment on lines +117 to +145
// Fetch the current address nonces from Platform. Each
// input address has a per-address nonce that the next
// state transition must use as `last_used + 1`.
// `AddressInfo::fetch_many` returns the last-used nonce
// (and current balance) per address; we increment it.
// Without this the broadcast was rejected by drive-abci
// because every shield transition tried to use nonce 0.
use dash_sdk::platform::FetchMany;
use dash_sdk::query_types::AddressInfo;
use std::collections::BTreeSet;

let address_set: BTreeSet<PlatformAddress> = inputs.keys().copied().collect();
let infos = AddressInfo::fetch_many(&self.sdk, address_set)
.await
.map_err(|e| {
PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}"))
})?;

let mut inputs_with_nonce: BTreeMap<PlatformAddress, (u32, Credits)> = BTreeMap::new();
for (addr, credits) in inputs {
let info = infos
.get(&addr)
.and_then(|opt| opt.as_ref())
.ok_or_else(|| {
PlatformWalletError::ShieldedBuildError(format!(
"input address not found on platform: {:?}",
addr
))
})?;
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: Reimplements rs-sdk's canonical address-nonce fetch instead of reusing it

rs-sdk has fetch_inputs_with_nonce, nonce_inc, and ensure_address_balance in packages/rs-sdk/src/platform/transition/address_inputs.rs:12-40 that encapsulate exactly this fetch-and-increment dance plus a hard balance check. They are pub(crate) today, so platform-wallet can't reach them directly, but a single-line visibility change would let this code re-use the canonical helpers. As written, the new shield path will silently drift from the SDK's behaviour — for example the SDK enforces a balance check that this implementation only warn!s on.

source: ['claude']

🤖 Fix this 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`:
- [SUGGESTION] lines 117-145: Reimplements rs-sdk's canonical address-nonce fetch instead of reusing it
  rs-sdk has `fetch_inputs_with_nonce`, `nonce_inc`, and `ensure_address_balance` in `packages/rs-sdk/src/platform/transition/address_inputs.rs:12-40` that encapsulate exactly this fetch-and-increment dance plus a hard balance check. They are `pub(crate)` today, so platform-wallet can't reach them directly, but a single-line visibility change would let this code re-use the canonical helpers. As written, the new shield path will silently drift from the SDK's behaviour — for example the SDK enforces a balance check that this implementation only `warn!`s on.

Comment on lines +469 to +610
pub async fn shielded_shield_from_account<S, P>(
&self,
account_index: u32,
amount: u64,
signer: &S,
prover: P,
) -> Result<(), PlatformWalletError>
where
S: dpp::identity::signer::Signer<dpp::address_funds::PlatformAddress> + Send + Sync,
P: dpp::shielded::builder::OrchardProver,
{
// The shield transition uses `DeductFromInput(0)` as its fee
// strategy. drive-abci interprets that as "after each input
// address has had its `claim` deducted, take the fee out of
// input 0's *remaining* balance" (see
// `deduct_fee_from_outputs_or_remaining_balance_of_inputs_v0`
// in rs-dpp). "Input 0" is the smallest-key entry of the
// BTreeMap we hand to the builder. Therefore:
//
// * we must NOT claim each input's full balance — claiming
// `balance` leaves `remaining = 0`, and the fee
// deduction has nothing to bite into.
// * we must reserve at least `FEE_RESERVE_CREDITS` of
// unclaimed balance specifically on input 0 (the
// BTreeMap-smallest address).
//
// Empty-mempool fees on Type 15 transitions land at ~20M
// credits (~0.0002 DASH). Reserve 1e9 credits (0.01 DASH) —
// 50× headroom, still trivial relative to typical balances.
const FEE_RESERVE_CREDITS: u64 = 1_000_000_000;

// Build the inputs map under the wallet-manager read lock,
// then drop the lock before re-entering shielded so the
// guards don't nest unnecessarily.
let inputs: std::collections::BTreeMap<
dpp::address_funds::PlatformAddress,
dpp::fee::Credits,
> = {
let wm = self.wallet_manager.read().await;
let info = wm
.get_wallet_info(&self.wallet_id)
.ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?;
let account = info
.core_wallet
.platform_payment_managed_account_at_index(account_index)
.ok_or_else(|| {
PlatformWalletError::AddressOperation(format!(
"no platform payment account at index {account_index}"
))
})?;

// Collect (address, balance) for every funded address,
// sorted by address bytes — that determines BTreeMap
// key order downstream and therefore which input ends
// up at index 0.
let mut candidates: Vec<(dpp::address_funds::PlatformAddress, u64)> = account
.addresses
.addresses
.values()
.filter_map(|addr_info| {
let p2pkh =
key_wallet::PlatformP2PKHAddress::from_address(&addr_info.address).ok()?;
let balance = account.address_credit_balance(&p2pkh);
if balance == 0 {
None
} else {
Some((
dpp::address_funds::PlatformAddress::P2pkh(p2pkh.to_bytes()),
balance,
))
}
})
.collect();
candidates.sort_by_key(|(addr, _)| *addr);

// The address that will be the bundle's `input_0` must
// have balance > FEE_RESERVE so we can claim at least 1
// credit while leaving the reserve untouched. Skip any
// leading dust address that can't satisfy that — the
// next address up will become input 0 instead. (If
// every funded address is below the reserve, fall back
// to the smallest one so we still produce a valid
// builder input map; the network will reject it cleanly
// if the fee can't be covered.)
let viable_input_0 = candidates
.iter()
.position(|(_, balance)| *balance > FEE_RESERVE_CREDITS)
.unwrap_or(0);
let usable: &[(dpp::address_funds::PlatformAddress, u64)] =
&candidates[viable_input_0..];

let total_usable: u64 = usable.iter().map(|(_, b)| b).sum();
let needed = amount.saturating_add(FEE_RESERVE_CREDITS);
if total_usable < needed {
return Err(PlatformWalletError::ShieldedInsufficientBalance {
available: total_usable,
required: needed,
});
}

// Walk usable inputs in BTreeMap order, claiming only
// what's needed to cover `amount`. The fee reserve is
// taken off input 0's max claim so its post-claim
// remaining stays ≥ FEE_RESERVE_CREDITS for the
// network's `DeductFromInput(0)` step.
let mut chosen: std::collections::BTreeMap<
dpp::address_funds::PlatformAddress,
dpp::fee::Credits,
> = std::collections::BTreeMap::new();
let mut accumulated_claim: u64 = 0;
for (i, (addr, balance)) in usable.iter().enumerate() {
if accumulated_claim >= amount {
break;
}
let max_claim = if i == 0 {
balance.saturating_sub(FEE_RESERVE_CREDITS)
} else {
*balance
};
let still_need = amount - accumulated_claim;
let claim = max_claim.min(still_need);
if claim > 0 {
chosen.insert(*addr, claim);
accumulated_claim = accumulated_claim.saturating_add(claim);
}
}

if accumulated_claim < amount {
return Err(PlatformWalletError::ShieldedInsufficientBalance {
available: accumulated_claim,
required: amount,
});
}
chosen
};

let guard = self.shielded.read().await;
let shielded = guard
.as_ref()
.ok_or(PlatformWalletError::ShieldedNotBound)?;
shielded.shield(inputs, amount, signer, &prover).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.

🟡 Suggestion: shielded_shield_from_account selection logic has no Rust unit coverage

shielded_shield_from_account carries non-trivial selection rules that directly determine whether shield broadcasts succeed: skipping leading addresses below FEE_RESERVE_CREDITS, reserving fee headroom only on input 0, walking BTreeMap order, and accumulating to amount. None of this is covered by a focused Rust test, so a future refactor can reintroduce the original viable_input_0-style failure without tripping CI. Worth a deterministic unit test against a synthetic managed account covering: dust-first-address case, exact-reserve case, and amount-equal-to-total case.

source: ['codex']

🤖 Fix this 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/platform_wallet.rs`:
- [SUGGESTION] lines 469-610: shielded_shield_from_account selection logic has no Rust unit coverage
  `shielded_shield_from_account` carries non-trivial selection rules that directly determine whether shield broadcasts succeed: skipping leading addresses below `FEE_RESERVE_CREDITS`, reserving fee headroom only on input 0, walking BTreeMap order, and accumulating to `amount`. None of this is covered by a focused Rust test, so a future refactor can reintroduce the original `viable_input_0`-style failure without tripping CI. Worth a deterministic unit test against a synthetic managed account covering: dust-first-address case, exact-reserve case, and amount-equal-to-total case.

Comment on lines 114 to +168
) -> Result<(), PlatformWalletError> {
let recipient_addr = self.default_orchard_address()?;

// Build nonce map: The DPP builder takes (AddressNonce, Credits) pairs.
// For now we use nonce=0 as a placeholder -- the actual nonce should be
// fetched from the platform. In production, callers may use the SDK's
// ShieldFunds trait directly which fetches nonces automatically.
//
// TODO: Add proper nonce fetching, either here or require callers to
// provide inputs_with_nonce directly.
let inputs_with_nonce: BTreeMap<PlatformAddress, (u32, Credits)> = inputs
.into_iter()
.map(|(addr, credits)| (addr, (0u32, credits)))
.collect();
// Fetch the current address nonces from Platform. Each
// input address has a per-address nonce that the next
// state transition must use as `last_used + 1`.
// `AddressInfo::fetch_many` returns the last-used nonce
// (and current balance) per address; we increment it.
// Without this the broadcast was rejected by drive-abci
// because every shield transition tried to use nonce 0.
use dash_sdk::platform::FetchMany;
use dash_sdk::query_types::AddressInfo;
use std::collections::BTreeSet;

let address_set: BTreeSet<PlatformAddress> = inputs.keys().copied().collect();
let infos = AddressInfo::fetch_many(&self.sdk, address_set)
.await
.map_err(|e| {
PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}"))
})?;

let mut inputs_with_nonce: BTreeMap<PlatformAddress, (u32, Credits)> = BTreeMap::new();
for (addr, credits) in inputs {
let info = infos
.get(&addr)
.and_then(|opt| opt.as_ref())
.ok_or_else(|| {
PlatformWalletError::ShieldedBuildError(format!(
"input address not found on platform: {:?}",
addr
))
})?;
// Surface a per-input diagnostic so the host can see what
// we're claiming vs what Platform actually reports —
// mismatches are the typical root cause of
// `AddressesNotEnoughFundsError` on shield broadcast.
if info.balance < credits {
warn!(
address = ?addr,
claimed_credits = credits,
platform_balance = info.balance,
platform_nonce = info.nonce,
"Shield input claims more credits than Platform reports — broadcast will likely fail"
);
} else {
info!(
address = ?addr,
claimed_credits = credits,
platform_balance = info.balance,
platform_nonce = info.nonce,
"Shield input"
);
}
inputs_with_nonce.insert(addr, (info.nonce + 1, credits));
}
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: Concurrent shields on the same wallet TOCTOU on the fetched address nonce

Nonces are fetched via AddressInfo::fetch_many, incremented locally, then handed to the builder. Two concurrent calls to ShieldedWallet::shield for the same wallet (e.g. user double-taps Send, or app retries while the first is still proving) both observe the same info.nonce, both build with info.nonce + 1, and the second to land at drive-abci is rejected with a nonce conflict. Not exploitable, but produces an opaque user-facing failure after a ~30 s proof. Either serialise shield-class operations on a per-wallet mutex inside ShieldedWallet, or document at the FFI boundary that hosts must enforce single-flight.

source: ['claude']

🤖 Fix this 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`:
- [SUGGESTION] lines 108-168: Concurrent shields on the same wallet TOCTOU on the fetched address nonce
  Nonces are fetched via `AddressInfo::fetch_many`, incremented locally, then handed to the builder. Two concurrent calls to `ShieldedWallet::shield` for the same wallet (e.g. user double-taps Send, or app retries while the first is still proving) both observe the same `info.nonce`, both build with `info.nonce + 1`, and the second to land at drive-abci is rejected with a nonce conflict. Not exploitable, but produces an opaque user-facing failure after a ~30 s proof. Either serialise shield-class operations on a per-wallet mutex inside `ShieldedWallet`, or document at the FFI boundary that hosts must enforce single-flight.

Comment on lines +70 to +89
/// each entry rendered as `<base58_addr>=(nonce <n>, <c> credits)`.
fn format_addresses_with_info(
map: &std::collections::BTreeMap<
dpp::address_funds::PlatformAddress,
(dpp::prelude::AddressNonce, dpp::fee::Credits),
>,
) -> String {
map.iter()
.map(|(addr, (nonce, credits))| {
let hex_hash = match addr {
dpp::address_funds::PlatformAddress::P2pkh(h) => {
format!("p2pkh:{}", hex::encode(h))
}
dpp::address_funds::PlatformAddress::P2sh(h) => format!("p2sh:{}", hex::encode(h)),
};
format!("{hex_hash}=(nonce {nonce}, {credits} credits)")
})
.collect::<Vec<_>>()
.join(", ")
}
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.

💬 Nitpick: format_addresses_with_info doc claims base58 but body emits hex

The doc-comment says "each entry rendered as <base58_addr>=(nonce <n>, <c> credits)", but the body matches on PlatformAddress::P2pkh/P2sh and emits p2pkh:<hex> / p2sh:<hex> via hex::encode. Either update the comment to say hex (matches what the function actually does), or render via to_bech32m_string so the diagnostic matches the address shown in the wallet UI — the latter is more useful when grepping logs for a specific address.

source: ['claude']

Comment on lines +41 to +53
/// Build the Halo 2 proving key now if it hasn't been built yet.
///
/// First-call latency is ~30 seconds; subsequent calls return
/// immediately. Hosts should fire this on a background thread at
/// app startup so the first shielded send doesn't block the user.
/// Safe to call repeatedly and from any thread.
///
/// Independent of any manager — the cache is a process-global
/// `OnceLock`.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_shielded_warm_up_prover() {
CachedOrchardProver::new().warm_up();
}
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.

💬 Nitpick: warm_up_prover header says 'fire-and-forget' but the FFI call is synchronous and blocks ~30 s

The file header at lines 22-25 describes platform_wallet_shielded_warm_up_prover as a fire-and-forget global entry point hosts can call at startup. The function itself runs CachedOrchardProver::new().warm_up() synchronously on the calling thread and blocks ~30 s on first call. The Swift wrapper hides this via Task.detached(.background), but any other host that takes the doc at face value will block its UI thread. Either move the work onto a tokio task via runtime().spawn(...) so the call genuinely returns immediately, or amend the doc to say it blocks for ~30 s on first call.

source: ['claude']

- unshield FFI now takes the bech32m string and parses Rust-side
  via `PlatformAddress::from_bech32m_string`, with a network
  check. The previous byte-based path passed the 21-byte bech32m
  payload (type byte 0xb0/0x80) into bincode `from_bytes`, which
  expects the storage variant tag 0x00/0x01 and rejected real
  user-entered addresses (thepastaclaw c8873f6312ef).
- shield: nonce increment now `checked_add(1)` so a u32 wrap
  surfaces as `ShieldedBuildError` instead of replaying with
  nonce 0 after a 30 s proof (cb50b774985e).
- shield input selection: when no candidate clears
  FEE_RESERVE_CREDITS, fail fast with `ShieldedInsufficientBalance`
  instead of producing a known-boundary bundle (2b28ee4ac2f4).
- SendViewModel: trim recipient in the shielded→shielded and
  shielded→platform branches (68c36dcd4fe0). Forward the trimmed
  bech32m string to `shieldedUnshield` directly — the Swift side
  no longer extracts payload bytes.
- format_addresses_with_info now renders via `to_bech32m_string`
  and takes the wallet's network — diagnostics match what the UI
  shows so log greps line up (6b82603320bd).
- platform_wallet_shielded_warm_up_prover dispatches the build
  via `runtime().spawn_blocking(...)` so it actually returns
  immediately as the doc claims (a575d0f7eb0f).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 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/platform_wallet.rs`:
- Around line 377-459: The three new methods (shielded_transfer_to,
shielded_unshield_to, shielded_withdraw_to) call ShieldedWallet::{transfer,
unshield, withdraw} which still rely on the shared spend helper that errors out
with "Spending operations require a ShieldedStore that provides MerklePath
witnesses. Not yet implemented."; fix by wiring/implementing a ShieldedStore
that returns MerklePath witnesses (or update the shared spend helper to support
a witness-less code path), ensuring the store used by ShieldedWallet (the guard
in self.shielded) implements the witness provider used during note selection so
the calls from shielded_transfer_to / shielded_unshield_to /
shielded_withdraw_to succeed at runtime.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift`:
- Around line 92-118: The amount parsers currently accept any positive Double
and truncate to UInt64, but canSend only checks amountDuffs so tiny values
become zero after scaling or the wrong unit is validated for shielded flows;
update amountDuffs and amountCredits to parse the Double, compute the scaled
UInt64 and only return it if the scaled integer is > 0 (so UInt64(double *
scale) must be > 0), then replace the canSend check to use the active unit based
on detectedFlow (use amountDuffs for Core flows and amountCredits for
Platform/shielded flows) — reference the existing computed properties
amountDuffs, amountCredits, amount (shim) and the canSend and detectedFlow logic
when making the change.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: db812a17-166a-4e0c-a217-a3f4b8cf1387

📥 Commits

Reviewing files that changed from the base of the PR and between a8d9b14 and 6e4931c.

📒 Files selected for processing (5)
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift

Comment on lines +377 to +459
pub async fn shielded_transfer_to<P: dpp::shielded::builder::OrchardProver>(
&self,
recipient_raw_43: &[u8; 43],
amount: u64,
prover: P,
) -> Result<(), PlatformWalletError> {
let guard = self.shielded.read().await;
let shielded = guard
.as_ref()
.ok_or(PlatformWalletError::ShieldedNotBound)?;
let recipient = Option::<grovedb_commitment_tree::PaymentAddress>::from(
grovedb_commitment_tree::PaymentAddress::from_raw_address_bytes(recipient_raw_43),
)
.ok_or_else(|| {
PlatformWalletError::ShieldedBuildError(
"invalid Orchard payment address bytes".to_string(),
)
})?;
shielded.transfer(&recipient, amount, &prover).await
}

/// Unshield: spend shielded notes and send `amount` credits to
/// the platform address `to_platform_addr_bech32m` (a bech32m
/// string like `"dash1…"` / `"tdash1…"`). Parsed via
/// `PlatformAddress::from_bech32m_string` and verified against
/// the wallet's network.
#[cfg(feature = "shielded")]
pub async fn shielded_unshield_to<P: dpp::shielded::builder::OrchardProver>(
&self,
to_platform_addr_bech32m: &str,
amount: u64,
prover: P,
) -> Result<(), PlatformWalletError> {
let guard = self.shielded.read().await;
let shielded = guard
.as_ref()
.ok_or(PlatformWalletError::ShieldedNotBound)?;
let (to, addr_network) =
dpp::address_funds::PlatformAddress::from_bech32m_string(to_platform_addr_bech32m)
.map_err(|e| {
PlatformWalletError::ShieldedBuildError(format!(
"invalid platform address: {e}"
))
})?;
if addr_network != self.sdk.network {
return Err(PlatformWalletError::ShieldedBuildError(format!(
"platform address network mismatch: address {addr_network:?}, wallet {:?}",
self.sdk.network
)));
}
shielded.unshield(&to, amount, &prover).await
}

/// Withdraw: spend shielded notes and send `amount` credits to
/// the Core L1 address `to_core_address` (Base58Check string).
/// `core_fee_per_byte` is the L1 fee rate (duffs/byte).
#[cfg(feature = "shielded")]
pub async fn shielded_withdraw_to<P: dpp::shielded::builder::OrchardProver>(
&self,
to_core_address: &str,
amount: u64,
core_fee_per_byte: u32,
prover: P,
) -> Result<(), PlatformWalletError> {
let guard = self.shielded.read().await;
let shielded = guard
.as_ref()
.ok_or(PlatformWalletError::ShieldedNotBound)?;
let network = self.sdk.network;
let parsed = to_core_address
.parse::<dashcore::Address<dashcore::address::NetworkUnchecked>>()
.map_err(|e| {
PlatformWalletError::ShieldedBuildError(format!("invalid core address: {e}"))
})?
.require_network(network)
.map_err(|e| {
PlatformWalletError::ShieldedBuildError(format!(
"core address network mismatch: {e}"
))
})?;
shielded
.withdraw(&parsed, amount, core_fee_per_byte, &prover)
.await
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.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

These new spending APIs still route into an unimplemented witness path.

shielded_transfer_to, shielded_unshield_to, and shielded_withdraw_to all end up in ShieldedWallet::{transfer,unshield,withdraw}, but the shared spend helper still bails with "Spending operations require a ShieldedStore that provides MerklePath witnesses. Not yet implemented." once a note is selected. As written, three of the four newly wired shielded send flows cannot succeed at runtime.

🤖 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/rs-platform-wallet/src/wallet/platform_wallet.rs` around lines 377 -
459, The three new methods (shielded_transfer_to, shielded_unshield_to,
shielded_withdraw_to) call ShieldedWallet::{transfer, unshield, withdraw} which
still rely on the shared spend helper that errors out with "Spending operations
require a ShieldedStore that provides MerklePath witnesses. Not yet
implemented."; fix by wiring/implementing a ShieldedStore that returns
MerklePath witnesses (or update the shared spend helper to support a
witness-less code path), ensuring the store used by ShieldedWallet (the guard in
self.shielded) implements the witness provider used during note selection so the
calls from shielded_transfer_to / shielded_unshield_to / shielded_withdraw_to
succeed at runtime.

Comment on lines +92 to 118
/// Parsed amount expressed in **L1 duffs** (1 DASH = 1e8). Right
/// for Core sends; *wrong* for Platform / shielded sends, which
/// use the credits scale (1 DASH = 1e11) instead. Use [`amountCredits`]
/// for those paths — picking duffs underpays them by 1000×.
var amountDuffs: UInt64? {
guard let double = Double(amountString), double > 0 else { return nil }
return UInt64(double * 100_000_000)
}

/// Parsed amount expressed in Platform / shielded **credits**
/// (1 DASH = 1e11). Used for any flow that touches the credits
/// ledger (`platformToShielded`, `shieldedToShielded`,
/// `shieldedToPlatform`, `shieldedToCore`).
var amountCredits: UInt64? {
guard let double = Double(amountString), double > 0 else { return nil }
return UInt64(double * 100_000_000_000)
}

/// Backwards-compatibility shim — the original `amount` property
/// always returned duffs, so any leftover call site that hasn't
/// switched to the unit-explicit pair stays correct for Core
/// flows.
var amount: UInt64? { amountDuffs }

var canSend: Bool {
detectedFlow != nil && amount != nil && !isSending
detectedFlow != nil && amountDuffs != nil && !isSending
}
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make amount validation use the active unit after scaling.

These parsers accept any positive Double and then truncate, while canSend only checks amountDuffs != nil. That lets sub-unit values through until the backend sees 0, and it also validates shielded flows against the wrong unit. Please validate the scaled integer for the active flow (duffs for Core, credits for shielded/platform) and require it to be > 0 before enabling send.

🤖 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift`
around lines 92 - 118, The amount parsers currently accept any positive Double
and truncate to UInt64, but canSend only checks amountDuffs so tiny values
become zero after scaling or the wrong unit is validated for shielded flows;
update amountDuffs and amountCredits to parse the Double, compute the scaled
UInt64 and only return it if the scaled integer is > 0 (so UInt64(double *
scale) must be > 0), then replace the canSend check to use the active unit based
on detectedFlow (use amountDuffs for Core flows and amountCredits for
Platform/shielded flows) — reference the existing computed properties
amountDuffs, amountCredits, amount (shim) and the canSend and detectedFlow logic
when making the change.

Comment on lines +270 to +287
case .platformToShielded:
// Platform → Shielded (Type 15): spend credits from
// the wallet's first Platform Payment account into
// the bound shielded pool. Credits scale.
guard let amountCredits else {
error = "Invalid amount"
return
}
_ = platformState
_ = shieldedService
_ = wallet
_ = modelContext
_ = sdk
error = "Shielded sending is being rebuilt — see follow-up PR"
return
let signer = KeychainSigner(modelContainer: modelContext.container)
try await walletManager.shieldedShield(
walletId: wallet.walletId,
accountIndex: 0,
amount: amountCredits,
addressSigner: signer
)
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.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

This flow ignores the entered Orchard recipient and always self-shields.

walletManager.shieldedShield has no recipient parameter, and the Rust side shields into the bound wallet’s default Orchard address. So if the user types someone else’s Orchard address here, the app still reports success even though nothing was sent to that recipient. Either constrain this path to self-shield only, or block it unless the entered address matches the wallet’s own shielded address.

QuantumExplorer and others added 3 commits May 6, 2026 14:33
…ieldedStore

`extract_spends_and_anchor` returned `ShieldedBuildError("Spending
operations require a ShieldedStore that provides MerklePath
witnesses. Not yet implemented.")` for every note, so shielded
transfer / unshield / withdraw failed at runtime even when the
store had a real commitment tree. The persistent tree's
`ClientPersistentCommitmentTree::witness(position, depth) ->
Option<MerklePath>` was already available — the trait was just
sitting on a `Vec<u8>` placeholder.

Change `ShieldedStore::witness()` to return
`Result<Option<MerklePath>, _>` directly, wire
`FileBackedShieldedStore::witness` through
`tree.witness(Position::from(position), 0)` (depth 0 matches the
`tree_anchor()` that the same builder consumes), and have
`extract_spends_and_anchor` build real `SpendableNote { note,
merkle_path }` entries.

Side effects (deliberate):
- `InMemoryShieldedStore::witness` keeps its existing `Err`; that
  store has no tree state, only a flat `Vec<[u8; 32]>` of
  commitments. Spend paths require a real store.
- Trait module-doc was updated: the "no orchard types" claim was
  already partially false (notes deserialize to `orchard::Note` at
  the call site) and is now plainly false.

Tests: 11 existing shielded unit tests pass; clippy clean; iOS
xcframework + SwiftExampleApp rebuild succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`dbPath(for:)` was keyed only on network, so two wallets on the
same network bound `bind_shielded` to the *same* SQLite file.
`FileBackedShieldedStore`'s notes table has no `wallet_id`
column, so `store.get_unspent_notes()` returned every wallet's
notes — wallet B saw wallet A's shielded balance under its own
name even though B's seed (and FVK) is unrelated.

User reproduced this with two wallets on regtest, distinct
mnemonics: a freshly created Wallet2 with empty Core/Platform
balances reported the same 0.6 DASH shielded balance as the
funded Reg wallet.

Include the wallet id hex in the dbPath. Each wallet now has
its own commitment-tree file and will re-sync from genesis on
first bind. Per project memory ("pre-release: schema migrations
aren't a concern; dev DBs rebuild"), the resulting one-time
re-sync is acceptable. Long-term the right fix is to add a
`wallet_id` column to the notes table inside `FileBackedShieldedStore`
so wallets can share the tree but filter their own notes; that's
a bigger change tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… detail

`ShieldedService` is a singleton bound by
`rebindWalletScopedServices()` to `walletManager.firstWallet`.
The detail-view code path never re-bound it, so opening any
wallet other than `firstWallet` showed `firstWallet`'s shielded
balance under the wrong wallet's name. The previous per-wallet
dbPath fix correctly isolated each wallet's notes in Rust, but
the published `shieldedBalance` on the UI side stayed pinned to
the first-bound wallet.

`ShieldedService` now stashes `walletManager` / `resolver` /
`network` on first `bind(...)` and exposes
`switchTo(walletId:)` that reuses them — cheap and idempotent
(the Rust-side `bind_shielded` already replaces its slot).
`WalletDetailView` calls it from `.onAppear` and
`.onChange(of: wallet.walletId)`, and grew the
`@EnvironmentObject var shieldedService` it was missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift (2)

187-208: 💤 Low value

Consider clearing stashed network/resolver in reset() for symmetry.

reset() (lines 241-258) nils out walletManager and walletId but leaves the new network and resolver fields populated. It's not a correctness bug today since switchTo(walletId:) guards on walletManager being non-nil before using them, but the asymmetry will be a footgun if a future change re-checks any of these fields independently of walletManager.

♻️ Proposed tweak
     func reset() {
         syncStateCancellable?.cancel()
         syncEventCancellable?.cancel()
         walletManager = nil
         walletId = nil
+        network = nil
+        resolver = nil
         isSyncing = false
🤖 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift`
around lines 187 - 208, The reset() method currently nils walletManager and
walletId but leaves network and resolver set, creating asymmetry with
switchTo(walletId:) which relies on those being cleared only when walletManager
is nil; update reset() to also clear the network and resolver properties (nil
out network and resolver) so that reset() fully clears state and remains
symmetric with the guard in switchTo(walletId:), ensuring
bind(walletManager:walletId:network:resolver:) cannot be called with stale
network/resolver values.

290-308: 💤 Low value

Stale per-network DB files from prior installs are left behind.

The path scheme changed from shielded_tree_<network>.sqlite to shielded_tree_<network>_<walletHex>.sqlite. Existing users upgrading will keep the old per-network file orphaned in the documents directory forever (it's no longer referenced by any wallet). Low-impact disk leak but worth either a one-time cleanup pass or a brief note in the comment so it isn't forgotten.

🤖 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift`
around lines 290 - 308, Existing per-network DB files named
"shielded_tree_<network>.sqlite" are orphaned after switching to
dbPath(for:network:walletId:) which names files
"shielded_tree_<network>_<walletHex>.sqlite"; add a one-time cleanup to remove
legacy files (or at least document it) by detecting the old filename pattern and
deleting any matching file before/when creating the per-wallet DB. Implement
this cleanup in the same initialization flow that opens/creates the shielded DB
(e.g., in FileBackedShieldedStore init or just inside dbPath caller) so you
remove "shielded_tree_\(network.networkName).sqlite" if present, and then
proceed to return the new per-wallet path.
packages/rs-platform-wallet/src/wallet/shielded/file_store.rs (1)

163-175: ⚡ Quick win

Update the module docs to reflect that witness generation is live.

This implementation makes the header note at Lines 13-15 stale. Leaving "not implemented yet" in the file docs will send future debugging in the wrong direction.

🤖 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/rs-platform-wallet/src/wallet/shielded/file_store.rs` around lines
163 - 175, Update the module-level documentation to remove the "not implemented
yet" note and instead state that witness generation is implemented and live;
mention that the FileShieldedStore::witness method locks the tree (tree.lock),
converts the position with Position::from, and calls tree.witness with
checkpoint_depth = 0 (producing a grovedb_commitment_tree::MerklePath) and that
errors are wrapped in FileShieldedStoreError, so future readers know how
witnesses are produced and where to look for failures.
🤖 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.

Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/shielded/file_store.rs`:
- Around line 163-175: Update the module-level documentation to remove the "not
implemented yet" note and instead state that witness generation is implemented
and live; mention that the FileShieldedStore::witness method locks the tree
(tree.lock), converts the position with Position::from, and calls tree.witness
with checkpoint_depth = 0 (producing a grovedb_commitment_tree::MerklePath) and
that errors are wrapped in FileShieldedStoreError, so future readers know how
witnesses are produced and where to look for failures.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift`:
- Around line 187-208: The reset() method currently nils walletManager and
walletId but leaves network and resolver set, creating asymmetry with
switchTo(walletId:) which relies on those being cleared only when walletManager
is nil; update reset() to also clear the network and resolver properties (nil
out network and resolver) so that reset() fully clears state and remains
symmetric with the guard in switchTo(walletId:), ensuring
bind(walletManager:walletId:network:resolver:) cannot be called with stale
network/resolver values.
- Around line 290-308: Existing per-network DB files named
"shielded_tree_<network>.sqlite" are orphaned after switching to
dbPath(for:network:walletId:) which names files
"shielded_tree_<network>_<walletHex>.sqlite"; add a one-time cleanup to remove
legacy files (or at least document it) by detecting the old filename pattern and
deleting any matching file before/when creating the per-wallet DB. Implement
this cleanup in the same initialization flow that opens/creates the shielded DB
(e.g., in FileBackedShieldedStore init or just inside dbPath caller) so you
remove "shielded_tree_\(network.networkName).sqlite" if present, and then
proceed to return the new per-wallet path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6c749d03-a814-4258-bdef-f71935e6ec37

📥 Commits

Reviewing files that changed from the base of the PR and between 6e4931c and 3ffce1a.

📒 Files selected for processing (5)
  • packages/rs-platform-wallet/src/wallet/shielded/file_store.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/rs-platform-wallet/src/wallet/shielded/store.rs
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift

QuantumExplorer and others added 2 commits May 6, 2026 16:16
…n B)

Refactor shielded internals so a single PlatformWallet can hold
multiple ZIP-32 Orchard accounts that share the network's
commitment tree but keep their decrypted notes / nullifiers /
sync watermarks scoped per-(wallet_id, account_index).

This replaces the per-wallet shielded SQLite path that was
shipped earlier — that change isolated wallets at the cost of a
duplicate tree per wallet, and didn't help with same-wallet
multi-account at all. The on-chain commitment stream is
chain-wide, so the tree should be too.

## What changes

**`ShieldedStore` trait** (rs-platform-wallet):
- New `SubwalletId { wallet_id: [u8; 32], account_index: u32 }`.
- Note + sync-state methods (`save_note`, `get_unspent_notes`,
  `mark_spent`, `last_synced_note_index`,
  `nullifier_checkpoint`, …) take `id: SubwalletId`. Tree
  methods (`append_commitment`, `checkpoint_tree`,
  `tree_anchor`, `witness`) stay scope-free.
- `InMemoryShieldedStore` and `FileBackedShieldedStore` now hold
  a `BTreeMap<SubwalletId, SubwalletState>` and lazily allocate
  per-subwallet entries.

**`ShieldedWallet`**:
- Holds `accounts: BTreeMap<u32, AccountState>` (per-account
  keyset). New constructors `from_keysets`, `from_seed_accounts`;
  `add_account_from_seed` for live add. New `account_indices`,
  `keys_for(account)`, `default_address(account)`,
  `balance(account)`, `balances`, `balance_total`. Per-wallet
  `wallet_id` field threaded through every store call as
  `SubwalletId`.

**Sync** (`shielded/sync.rs`):
- One sync pass covers every bound account: fetch raw chunks
  via `sync_shielded_notes` once with the lowest-keyed
  account's IVK, then locally trial-decrypt each chunk with
  every other account's IVK via `dash_sdk::platform::shielded::
  try_decrypt_note`. Append each cmx to the shared tree once
  with `marked = (any account decrypted this position)`.
- `SyncNotesResult` and `ShieldedSyncSummary` carry per-account
  maps; `total_new_notes`, `total_newly_spent`, `balance_total`
  helpers fold them for the flat FFI surface.

**Operations** (`shielded/operations.rs`):
- `transfer`, `unshield`, `withdraw`, `shield`, `shield_from_asset_lock`
  all take `account: u32` and route through the corresponding
  `OrchardKeySet` and per-subwallet note set. Spends never
  cross account boundaries.

**`PlatformWallet`**:
- `bind_shielded(seed, accounts: &[u32], db_path)` derives all
  listed accounts at once. New `shielded_add_account(seed,
  account)` for live add (with a docstring caveat that
  historical retroactive marking requires a tree wipe + resync).
- `shielded_default_address(account)`, `shielded_balances()`,
  `shielded_account_indices()`, plus the four spend helpers
  (`shielded_transfer_to`, `shielded_unshield_to`,
  `shielded_withdraw_to`, `shielded_shield_from_account`) all
  take `account: u32`.
- `shielded_shield_from_account` now takes both
  `shielded_account` and `payment_account` — they're distinct
  concepts (Orchard recipient account vs Platform Payment funding
  account) that previously shared one `account_index` parameter.

**FFI** (`rs-platform-wallet-ffi`):
- `platform_wallet_manager_bind_shielded` takes
  `accounts_ptr: *const u32, accounts_len: usize` (1..=64).
- All four spend entry points + `shielded_default_address` take
  `account: u32`. `shielded_shield` takes both
  `shielded_account` and `payment_account`.
- `ShieldedSyncWalletResultFFI::ok` flattens per-account sums.

**Swift SDK + example app**:
- `bindShielded` takes `accounts: [UInt32] = [0]`; passes the
  C buffer through.
- All shielded send wrappers take `account: UInt32 = 0`.
- `shieldedDefaultAddress(walletId:account:)` per-account.
- `ShieldedService.dbPath(for:network:)` reverts to per-network
  (the per-(wallet,network) workaround is no longer needed —
  notes are scoped at the column level inside the store).

## Persistence (deferred)

This commit ships the multi-account refactor with notes still
held only in memory (`Vec<ShieldedNote>` on `SubwalletState`).
Cold start = re-sync from genesis, same as before. SwiftData
persistence (`PersistentShieldedNote` keyed by
`(walletId, accountIndex, position)` driven through the
existing changeset model) is the planned next step but is its
own substantial slice — splitting it out keeps this commit
reviewable.

## Tests

11 existing shielded unit tests pass. New
`test_save_and_retrieve_notes`, `test_mark_spent`,
`test_sync_state_per_subwallet` cover SubwalletId scoping in
the in-memory store. iOS xcframework + SwiftExampleApp rebuild
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bind

Adds the Rust-side persistence wiring for the multi-account
shielded refactor. Sync passes and spend operations now emit
`ShieldedChangeSet` deltas to the wallet's persister, and
`bind_shielded` rehydrates the in-memory `SubwalletState` from
the persister's `ClientStartState` snapshot before kicking off
the first sync.

This is the Rust half of the deferred persistence slice; the
FFI callback that surfaces these changesets to the host
(SwiftData on iOS) and the matching Swift handler land in
follow-up commits in this same PR.

## What changes

**`changeset/`**:
- `ShieldedChangeSet` — per-`SubwalletId` `notes_saved`,
  `nullifiers_spent`, `synced_indices`, `nullifier_checkpoints`.
  Implements `Merge` (LWW on watermarks; append on note vecs).
  Carried as a new `Option<ShieldedChangeSet>` field on
  `PlatformWalletChangeSet` (feature-gated `shielded`).
- `ShieldedSyncStartState` — restore snapshot keyed by
  `SubwalletId`. Lives on `ClientStartState.shielded`.
- Existing destructure sites in `apply.rs`, `manager/load.rs`,
  `manager/wallet_lifecycle.rs`, and `platform_wallet.rs` updated
  to drop the new field with a `#[cfg(feature = "shielded")]` arm.

**`wallet/shielded/mod.rs`**:
- `ShieldedWallet` grows an optional `WalletPersister` handle and a
  `set_persister(...)` setter.
- New `queue_shielded_changeset(cs)` helper that wraps a
  `ShieldedChangeSet` in a `PlatformWalletChangeSet` and pushes
  it to the persister. No-op when no persister is attached.
- New `restore_from_snapshot(&ShieldedSyncStartState)` consumes
  per-subwallet entries that match `(self.wallet_id, account)`
  for any bound account, save_note's their notes, marks spent
  ones, and replays the sync watermarks.

**`wallet/shielded/sync.rs`**:
- `sync_notes` accumulates a `ShieldedChangeSet` as it saves
  decrypted notes / advances watermarks, then queues it on the
  persister at the end of the pass (after dropping the store
  write lock so the persister callback isn't nested under it).
- `check_nullifiers` does the same for spent marks +
  nullifier checkpoints.

**`wallet/shielded/operations.rs`**:
- `mark_notes_spent` queues a changeset for each freshly-marked
  nullifier so spend events propagate to durable storage
  immediately rather than waiting for the next nullifier-sync
  pass to rediscover them.

**`wallet/platform_wallet.rs`**:
- `bind_shielded` attaches the wallet's persister to the
  `ShieldedWallet`, then calls `restore_from_snapshot` against
  `self.persister.load()?.shielded` so the freshly-bound wallet
  starts pre-populated with whatever the host already has on
  disk for `(self.wallet_id, account)` for each requested account.

## Tests

11 existing shielded unit tests still pass. Clippy clean. The
load-side end-to-end flow ("host writes → cold start →
restore_from_snapshot → spend works") is exercised once the FFI
+ SwiftData sides land in the next commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Re-review at 3ffce1a confirms the prior blocking unshield address-format bug is fixed and most earlier suggestions (checked_add nonce, fee-reserve early return, trim-before-parse, bech32m diagnostic, fire-and-forget warm-up) are addressed. One new blocking issue stands: shielded spend paths (unshield/transfer/withdraw) call mark_notes_spent immediately after broadcast() — which only submits, not confirms — so a rejected/dropped transition strands notes locally. Six remaining suggestions/nitpicks: the &'static signer transmute (convergent across all reviewers), TOCTOU on per-address nonces in concurrent shields, TOCTOU on note selection in concurrent spends, missing Rust unit coverage on shielded_shield_from_account, drift from rs-sdk's canonical address-nonce helper, an amount==0 edge case, and a mut→const SignerHandle ergonomic.

Reviewed commit: 3ffce1a

🔴 1 blocking | 🟡 5 suggestion(s) | 💬 2 nitpick(s)

2 additional findings

🔴 blocking: Shielded spends mark notes spent on broadcast acceptance, before chain confirmation

packages/rs-platform-wallet/src/wallet/shielded/operations.rs (lines 355-362)

unshield, transfer, and withdraw all call mark_notes_spent(&selected_notes) immediately after state_transition.broadcast(...) returns Ok (operations.rs:355-362, 425-431, 498-504). BroadcastStateTransition::broadcast (rs-sdk/src/platform/transition/broadcast.rs:36-93) only submits the transition — wait_for_response and broadcast_and_wait are separately exposed for confirmation. A transition that is mempool-accepted and later rejected, dropped, or replaced will silently flip those notes to spent in the local ShieldedStore, which has no reconciliation path to clear a false spend. The user then sees the funds permanently unavailable locally even though the chain never consumed them. Fix is to either (a) use broadcast_and_wait here so notes are only marked once the proof result is observed, or (b) add a mark_notes_pending step under a write lock before broadcast and only promote to spent on observed inclusion (with a clearing path on rejection). The same write-after-broadcast shape interacts with finding #4 below — both are root-caused in the same fetch→broadcast→mutate sequence.

🟡 suggestion: Note selection → broadcast → mark_spent is non-atomic across concurrent spends

packages/rs-platform-wallet/src/wallet/shielded/operations.rs (lines 312-511)

unshield, transfer, and withdraw each (1) take a read lock on the store to select unspent notes, (2) build+broadcast the transition, then (3) take a write lock to mark notes spent. The read lock is released before broadcast, so two concurrent calls (user-initiated send + retry, or two flows from the UI) can both observe the same notes as unspent. The first transition wins; the second's nullifiers are already on chain, drive-abci rejects the duplicate, and the user sees a generic broadcast error after 30 s of proof work. Same shape and remedy as the shield-side TOCTOU above — single-flight at the wallet level (or a tentative mark_in_flight step under a write lock before broadcast) prevents the failure. This also dovetails with finding #1: a mark_in_flight / mark_pending step would naturally provide the missing reconciliation hook.

🤖 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] lines 355-362: Shielded spends mark notes spent on broadcast acceptance, before chain confirmation
  `unshield`, `transfer`, and `withdraw` all call `mark_notes_spent(&selected_notes)` immediately after `state_transition.broadcast(...)` returns `Ok` (operations.rs:355-362, 425-431, 498-504). `BroadcastStateTransition::broadcast` (rs-sdk/src/platform/transition/broadcast.rs:36-93) only submits the transition — `wait_for_response` and `broadcast_and_wait` are separately exposed for confirmation. A transition that is mempool-accepted and later rejected, dropped, or replaced will silently flip those notes to spent in the local `ShieldedStore`, which has no reconciliation path to clear a false spend. The user then sees the funds permanently unavailable locally even though the chain never consumed them. Fix is to either (a) use `broadcast_and_wait` here so notes are only marked once the proof result is observed, or (b) add a `mark_notes_pending` step under a write lock before broadcast and only promote to spent on observed inclusion (with a clearing path on rejection). The same write-after-broadcast shape interacts with finding #4 below — both are root-caused in the same fetch→broadcast→mutate sequence.
- [SUGGESTION] lines 117-180: Concurrent shields on the same wallet TOCTOU on the fetched address nonces
  `ShieldedWallet::shield` reads each input's last-used nonce via `AddressInfo::fetch_many`, locally increments with `checked_add(1)`, and hands the result to `build_shield_transition`. The `shielded` slot uses `RwLock` and only a read guard is taken, so two overlapping `shield` invocations for the same wallet (UI double-tap, retry while the first proof is still building, two host threads racing) both observe the same `info.nonce`, both build with `info.nonce + 1`, and the second to land at drive-abci is rejected with a nonce conflict — but only after each has spent ~30 s on a Halo 2 proof. Not a memory-safety or consensus issue, but the user-visible failure is opaque and expensive. Either serialise shield-class operations on a per-wallet `tokio::sync::Mutex` taken across fetch+build+broadcast, or document at the FFI boundary (`shielded_send.rs:251-299`) that hosts must enforce single-flight per wallet.
- [SUGGESTION] lines 312-511: Note selection → broadcast → mark_spent is non-atomic across concurrent spends
  `unshield`, `transfer`, and `withdraw` each (1) take a read lock on the store to select unspent notes, (2) build+broadcast the transition, then (3) take a write lock to mark notes spent. The read lock is released before broadcast, so two concurrent calls (user-initiated send + retry, or two flows from the UI) can both observe the same notes as unspent. The first transition wins; the second's nullifiers are already on chain, drive-abci rejects the duplicate, and the user sees a generic broadcast error after 30 s of proof work. Same shape and remedy as the shield-side TOCTOU above — single-flight at the wallet level (or a tentative `mark_in_flight` step under a write lock before broadcast) prevents the failure. This also dovetails with finding #1: a `mark_in_flight` / `mark_pending` step would naturally provide the missing reconciliation hook.
- [SUGGESTION] lines 117-180: Reimplements rs-sdk's canonical address-nonce fetch instead of reusing it
  rs-sdk has `fetch_inputs_with_nonce`, `nonce_inc`, and `ensure_address_balance` in `packages/rs-sdk/src/platform/transition/address_inputs.rs` that encapsulate exactly this fetch-and-increment dance plus a hard balance check. They are `pub(crate)` today, so `platform-wallet` can't reach them directly, but a single-line visibility change would let this code re-use the canonical helpers. As written, the new shield path will silently drift from the SDK's behaviour — for example the SDK enforces a balance check while this implementation only `warn!`s on `info.balance < credits` (operations.rs:150-157).

In `packages/rs-platform-wallet-ffi/src/shielded_send.rs`:
- [SUGGESTION] lines 269-291: Use the established usize round-trip pattern instead of transmuting the signer borrow to &'static
  `block_on_worker` requires `F: Send + 'static` (runtime.rs:49-56), and the new shield path satisfies that with `mem::transmute::<&VTableSigner, &'static VTableSigner>(...)` at lines 276-279. It is sound today only because `block_on_worker` is `rt.block_on(async move { rt.spawn(future).await.expect(...) })` — the calling thread parks until the spawned task completes, so the host-owned `SignerHandle` outlives the borrow. Any future change that lets `block_on_worker` return early — a shutdown `select!`, a timeout, a cancellation token, or replacing `.expect` with a `?`-style return — silently turns this into a use-after-free across the FFI boundary, since Swift's `KeychainSigner.deinit` is free to destroy the handle as soon as the FFI call returns. The same crate already solves the identical constraint without the lifetime fiction: `identity_top_up.rs:113-126` round-trips the pointer through `usize` and re-materializes `&VTableSigner` *inside* the spawned future (capturing only `Send + 'static` data). Aligning the shield path with that precedent removes the `unsafe { transmute }` from the FFI surface at zero behavioural cost.

In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [SUGGESTION] lines 480-627: shielded_shield_from_account selection rules have no Rust unit coverage
  The selector carries non-trivial behaviour that directly determines whether shield broadcasts succeed: skipping leading addresses with `balance <= FEE_RESERVE_CREDITS` (only `>` is viable), reserving fee headroom only on input 0 via `balance.saturating_sub(FEE_RESERVE_CREDITS)`, walking BTreeMap key order so input 0 is the smallest-key entry, and accumulating until claim ≥ amount. None of this is covered by a focused Rust test, so a future refactor could regress any of these invariants — including reintroducing the original `viable_input_0` fall-through bug or the input-0 reserve regression — without tripping CI. Worth deterministic unit coverage against a synthetic `PlatformWalletInfo`/account: dust-first-address case, exact-reserve case (`balance == FEE_RESERVE`), single-address insufficient-with-reserve case, amount-equal-to-`(total_usable - FEE_RESERVE)`, and amount=0 (see #7).

Comment on lines +269 to +291
// SAFETY: the caller retains ownership of the signer handle
// and guarantees it outlives this call. We block until the
// worker future completes, so the `'static` lifetime we paint
// on the borrow does not actually outlive the host's handle.
// `VTableSigner` is `Send + Sync` per its `unsafe impl` in
// rs-sdk-ffi, so `&'static VTableSigner` is automatically
// `Send + 'static` — exactly what `block_on_worker` needs.
let address_signer: &'static VTableSigner =
std::mem::transmute::<&VTableSigner, &'static VTableSigner>(
&*(signer_address_handle as *const VTableSigner),
);

// Run the proof on a worker thread (8 MB stack). Halo 2 circuit
// synthesis recurses past the ~512 KB iOS dispatch-thread stack
// and crashes with EXC_BAD_ACCESS at the first
// `synthesize(... measure(pass))` call when polled on the
// calling thread.
let result = block_on_worker(async move {
let prover = CachedOrchardProver::new();
wallet
.shielded_shield_from_account(account_index, amount, address_signer, &prover)
.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.

🟡 Suggestion: Use the established usize round-trip pattern instead of transmuting the signer borrow to &'static

block_on_worker requires F: Send + 'static (runtime.rs:49-56), and the new shield path satisfies that with mem::transmute::<&VTableSigner, &'static VTableSigner>(...) at lines 276-279. It is sound today only because block_on_worker is rt.block_on(async move { rt.spawn(future).await.expect(...) }) — the calling thread parks until the spawned task completes, so the host-owned SignerHandle outlives the borrow. Any future change that lets block_on_worker return early — a shutdown select!, a timeout, a cancellation token, or replacing .expect with a ?-style return — silently turns this into a use-after-free across the FFI boundary, since Swift's KeychainSigner.deinit is free to destroy the handle as soon as the FFI call returns. The same crate already solves the identical constraint without the lifetime fiction: identity_top_up.rs:113-126 round-trips the pointer through usize and re-materializes &VTableSigner inside the spawned future (capturing only Send + 'static data). Aligning the shield path with that precedent removes the unsafe { transmute } from the FFI surface at zero behavioural cost.

💡 Suggested change
Suggested change
// SAFETY: the caller retains ownership of the signer handle
// and guarantees it outlives this call. We block until the
// worker future completes, so the `'static` lifetime we paint
// on the borrow does not actually outlive the host's handle.
// `VTableSigner` is `Send + Sync` per its `unsafe impl` in
// rs-sdk-ffi, so `&'static VTableSigner` is automatically
// `Send + 'static` — exactly what `block_on_worker` needs.
let address_signer: &'static VTableSigner =
std::mem::transmute::<&VTableSigner, &'static VTableSigner>(
&*(signer_address_handle as *const VTableSigner),
);
// Run the proof on a worker thread (8 MB stack). Halo 2 circuit
// synthesis recurses past the ~512 KB iOS dispatch-thread stack
// and crashes with EXC_BAD_ACCESS at the first
// `synthesize(... measure(pass))` call when polled on the
// calling thread.
let result = block_on_worker(async move {
let prover = CachedOrchardProver::new();
wallet
.shielded_shield_from_account(account_index, amount, address_signer, &prover)
.await
});
// Round-trip the signer pointer through `usize` so the spawned
// future's capture is `Send + 'static` (the raw pointer is `!Send`,
// but `usize` is). The underlying `Inner::Callback { ctx, vtable }`
// is `Send + Sync` — see the unsafe impls in `rs-sdk-ffi/src/signer.rs`.
let signer_addr = signer_address_handle as usize;
// Run the proof on a worker thread (8 MB stack). Halo 2 circuit
// synthesis recurses past the ~512 KB iOS dispatch-thread stack
// and crashes with EXC_BAD_ACCESS at the first
// `synthesize(... measure(pass))` call when polled on the
// calling thread.
let result = block_on_worker(async move {
let address_signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) };
let prover = CachedOrchardProver::new();
wallet
.shielded_shield_from_account(account_index, amount, address_signer, &prover)
.await
});

source: ['claude', 'codex']

🤖 Fix this 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`:
- [SUGGESTION] lines 269-291: Use the established usize round-trip pattern instead of transmuting the signer borrow to &'static
  `block_on_worker` requires `F: Send + 'static` (runtime.rs:49-56), and the new shield path satisfies that with `mem::transmute::<&VTableSigner, &'static VTableSigner>(...)` at lines 276-279. It is sound today only because `block_on_worker` is `rt.block_on(async move { rt.spawn(future).await.expect(...) })` — the calling thread parks until the spawned task completes, so the host-owned `SignerHandle` outlives the borrow. Any future change that lets `block_on_worker` return early — a shutdown `select!`, a timeout, a cancellation token, or replacing `.expect` with a `?`-style return — silently turns this into a use-after-free across the FFI boundary, since Swift's `KeychainSigner.deinit` is free to destroy the handle as soon as the FFI call returns. The same crate already solves the identical constraint without the lifetime fiction: `identity_top_up.rs:113-126` round-trips the pointer through `usize` and re-materializes `&VTableSigner` *inside* the spawned future (capturing only `Send + 'static` data). Aligning the shield path with that precedent removes the `unsafe { transmute }` from the FFI surface at zero behavioural cost.

Comment on lines +117 to +180
// Fetch the current address nonces from Platform. Each
// input address has a per-address nonce that the next
// state transition must use as `last_used + 1`.
// `AddressInfo::fetch_many` returns the last-used nonce
// (and current balance) per address; we increment it.
// Without this the broadcast was rejected by drive-abci
// because every shield transition tried to use nonce 0.
use dash_sdk::platform::FetchMany;
use dash_sdk::query_types::AddressInfo;
use std::collections::BTreeSet;

let address_set: BTreeSet<PlatformAddress> = inputs.keys().copied().collect();
let infos = AddressInfo::fetch_many(&self.sdk, address_set)
.await
.map_err(|e| {
PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}"))
})?;

let mut inputs_with_nonce: BTreeMap<PlatformAddress, (u32, Credits)> = BTreeMap::new();
for (addr, credits) in inputs {
let info = infos
.get(&addr)
.and_then(|opt| opt.as_ref())
.ok_or_else(|| {
PlatformWalletError::ShieldedBuildError(format!(
"input address not found on platform: {:?}",
addr
))
})?;
// Surface a per-input diagnostic so the host can see what
// we're claiming vs what Platform actually reports —
// mismatches are the typical root cause of
// `AddressesNotEnoughFundsError` on shield broadcast.
if info.balance < credits {
warn!(
address = ?addr,
claimed_credits = credits,
platform_balance = info.balance,
platform_nonce = info.nonce,
"Shield input claims more credits than Platform reports — broadcast will likely fail"
);
} else {
info!(
address = ?addr,
claimed_credits = credits,
platform_balance = info.balance,
platform_nonce = info.nonce,
"Shield input"
);
}
// `AddressNonce` is `u32`; `info.nonce + 1` would panic in
// debug and wrap in release once an address reaches the
// ceiling. drive-abci treats `u32::MAX` as exhausted, so a
// wrap submits nonce 0 and gets rejected as a replay
// *after* the wallet has already spent ~30 s building the
// Halo 2 proof. Bail loudly here instead.
let next_nonce = info.nonce.checked_add(1).ok_or_else(|| {
PlatformWalletError::ShieldedBuildError(format!(
"input address nonce exhausted on platform: {:?}",
addr
))
})?;
inputs_with_nonce.insert(addr, (next_nonce, credits));
}
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: Concurrent shields on the same wallet TOCTOU on the fetched address nonces

ShieldedWallet::shield reads each input's last-used nonce via AddressInfo::fetch_many, locally increments with checked_add(1), and hands the result to build_shield_transition. The shielded slot uses RwLock and only a read guard is taken, so two overlapping shield invocations for the same wallet (UI double-tap, retry while the first proof is still building, two host threads racing) both observe the same info.nonce, both build with info.nonce + 1, and the second to land at drive-abci is rejected with a nonce conflict — but only after each has spent ~30 s on a Halo 2 proof. Not a memory-safety or consensus issue, but the user-visible failure is opaque and expensive. Either serialise shield-class operations on a per-wallet tokio::sync::Mutex taken across fetch+build+broadcast, or document at the FFI boundary (shielded_send.rs:251-299) that hosts must enforce single-flight per wallet.

source: ['claude']

🤖 Fix this 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`:
- [SUGGESTION] lines 117-180: Concurrent shields on the same wallet TOCTOU on the fetched address nonces
  `ShieldedWallet::shield` reads each input's last-used nonce via `AddressInfo::fetch_many`, locally increments with `checked_add(1)`, and hands the result to `build_shield_transition`. The `shielded` slot uses `RwLock` and only a read guard is taken, so two overlapping `shield` invocations for the same wallet (UI double-tap, retry while the first proof is still building, two host threads racing) both observe the same `info.nonce`, both build with `info.nonce + 1`, and the second to land at drive-abci is rejected with a nonce conflict — but only after each has spent ~30 s on a Halo 2 proof. Not a memory-safety or consensus issue, but the user-visible failure is opaque and expensive. Either serialise shield-class operations on a per-wallet `tokio::sync::Mutex` taken across fetch+build+broadcast, or document at the FFI boundary (`shielded_send.rs:251-299`) that hosts must enforce single-flight per wallet.

Comment on lines +480 to +627
pub async fn shielded_shield_from_account<S, P>(
&self,
account_index: u32,
amount: u64,
signer: &S,
prover: P,
) -> Result<(), PlatformWalletError>
where
S: dpp::identity::signer::Signer<dpp::address_funds::PlatformAddress> + Send + Sync,
P: dpp::shielded::builder::OrchardProver,
{
// The shield transition uses `DeductFromInput(0)` as its fee
// strategy. drive-abci interprets that as "after each input
// address has had its `claim` deducted, take the fee out of
// input 0's *remaining* balance" (see
// `deduct_fee_from_outputs_or_remaining_balance_of_inputs_v0`
// in rs-dpp). "Input 0" is the smallest-key entry of the
// BTreeMap we hand to the builder. Therefore:
//
// * we must NOT claim each input's full balance — claiming
// `balance` leaves `remaining = 0`, and the fee
// deduction has nothing to bite into.
// * we must reserve at least `FEE_RESERVE_CREDITS` of
// unclaimed balance specifically on input 0 (the
// BTreeMap-smallest address).
//
// Empty-mempool fees on Type 15 transitions land at ~20M
// credits (~0.0002 DASH). Reserve 1e9 credits (0.01 DASH) —
// 50× headroom, still trivial relative to typical balances.
const FEE_RESERVE_CREDITS: u64 = 1_000_000_000;

// Build the inputs map under the wallet-manager read lock,
// then drop the lock before re-entering shielded so the
// guards don't nest unnecessarily.
let inputs: std::collections::BTreeMap<
dpp::address_funds::PlatformAddress,
dpp::fee::Credits,
> = {
let wm = self.wallet_manager.read().await;
let info = wm
.get_wallet_info(&self.wallet_id)
.ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?;
let account = info
.core_wallet
.platform_payment_managed_account_at_index(account_index)
.ok_or_else(|| {
PlatformWalletError::AddressOperation(format!(
"no platform payment account at index {account_index}"
))
})?;

// Collect (address, balance) for every funded address,
// sorted by address bytes — that determines BTreeMap
// key order downstream and therefore which input ends
// up at index 0.
let mut candidates: Vec<(dpp::address_funds::PlatformAddress, u64)> = account
.addresses
.addresses
.values()
.filter_map(|addr_info| {
let p2pkh =
key_wallet::PlatformP2PKHAddress::from_address(&addr_info.address).ok()?;
let balance = account.address_credit_balance(&p2pkh);
if balance == 0 {
None
} else {
Some((
dpp::address_funds::PlatformAddress::P2pkh(p2pkh.to_bytes()),
balance,
))
}
})
.collect();
candidates.sort_by_key(|(addr, _)| *addr);

// The address that will be the bundle's `input_0` must
// have balance > FEE_RESERVE so we can claim at least 1
// credit while leaving the reserve untouched. Skip any
// leading dust address that can't satisfy that — the
// next address up will become input 0 instead. If
// every funded address is below the reserve, fail fast:
// the network would reject the broadcast on the
// boundary anyway, only after we've spent ~30 s
// building the Halo 2 proof.
let Some(viable_input_0) = candidates
.iter()
.position(|(_, balance)| *balance > FEE_RESERVE_CREDITS)
else {
let total: u64 = candidates.iter().map(|(_, b)| b).sum();
return Err(PlatformWalletError::ShieldedInsufficientBalance {
available: total,
required: amount.saturating_add(FEE_RESERVE_CREDITS),
});
};
let usable: &[(dpp::address_funds::PlatformAddress, u64)] =
&candidates[viable_input_0..];

let total_usable: u64 = usable.iter().map(|(_, b)| b).sum();
let needed = amount.saturating_add(FEE_RESERVE_CREDITS);
if total_usable < needed {
return Err(PlatformWalletError::ShieldedInsufficientBalance {
available: total_usable,
required: needed,
});
}

// Walk usable inputs in BTreeMap order, claiming only
// what's needed to cover `amount`. The fee reserve is
// taken off input 0's max claim so its post-claim
// remaining stays ≥ FEE_RESERVE_CREDITS for the
// network's `DeductFromInput(0)` step.
let mut chosen: std::collections::BTreeMap<
dpp::address_funds::PlatformAddress,
dpp::fee::Credits,
> = std::collections::BTreeMap::new();
let mut accumulated_claim: u64 = 0;
for (i, (addr, balance)) in usable.iter().enumerate() {
if accumulated_claim >= amount {
break;
}
let max_claim = if i == 0 {
balance.saturating_sub(FEE_RESERVE_CREDITS)
} else {
*balance
};
let still_need = amount - accumulated_claim;
let claim = max_claim.min(still_need);
if claim > 0 {
chosen.insert(*addr, claim);
accumulated_claim = accumulated_claim.saturating_add(claim);
}
}

if accumulated_claim < amount {
return Err(PlatformWalletError::ShieldedInsufficientBalance {
available: accumulated_claim,
required: amount,
});
}
chosen
};

let guard = self.shielded.read().await;
let shielded = guard
.as_ref()
.ok_or(PlatformWalletError::ShieldedNotBound)?;
shielded.shield(inputs, amount, signer, &prover).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.

🟡 Suggestion: shielded_shield_from_account selection rules have no Rust unit coverage

The selector carries non-trivial behaviour that directly determines whether shield broadcasts succeed: skipping leading addresses with balance <= FEE_RESERVE_CREDITS (only > is viable), reserving fee headroom only on input 0 via balance.saturating_sub(FEE_RESERVE_CREDITS), walking BTreeMap key order so input 0 is the smallest-key entry, and accumulating until claim ≥ amount. None of this is covered by a focused Rust test, so a future refactor could regress any of these invariants — including reintroducing the original viable_input_0 fall-through bug or the input-0 reserve regression — without tripping CI. Worth deterministic unit coverage against a synthetic PlatformWalletInfo/account: dust-first-address case, exact-reserve case (balance == FEE_RESERVE), single-address insufficient-with-reserve case, amount-equal-to-(total_usable - FEE_RESERVE), and amount=0 (see #7).

source: ['claude']

🤖 Fix this 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/platform_wallet.rs`:
- [SUGGESTION] lines 480-627: shielded_shield_from_account selection rules have no Rust unit coverage
  The selector carries non-trivial behaviour that directly determines whether shield broadcasts succeed: skipping leading addresses with `balance <= FEE_RESERVE_CREDITS` (only `>` is viable), reserving fee headroom only on input 0 via `balance.saturating_sub(FEE_RESERVE_CREDITS)`, walking BTreeMap key order so input 0 is the smallest-key entry, and accumulating until claim ≥ amount. None of this is covered by a focused Rust test, so a future refactor could regress any of these invariants — including reintroducing the original `viable_input_0` fall-through bug or the input-0 reserve regression — without tripping CI. Worth deterministic unit coverage against a synthetic `PlatformWalletInfo`/account: dust-first-address case, exact-reserve case (`balance == FEE_RESERVE`), single-address insufficient-with-reserve case, amount-equal-to-`(total_usable - FEE_RESERVE)`, and amount=0 (see #7).

Comment on lines +117 to +180
// Fetch the current address nonces from Platform. Each
// input address has a per-address nonce that the next
// state transition must use as `last_used + 1`.
// `AddressInfo::fetch_many` returns the last-used nonce
// (and current balance) per address; we increment it.
// Without this the broadcast was rejected by drive-abci
// because every shield transition tried to use nonce 0.
use dash_sdk::platform::FetchMany;
use dash_sdk::query_types::AddressInfo;
use std::collections::BTreeSet;

let address_set: BTreeSet<PlatformAddress> = inputs.keys().copied().collect();
let infos = AddressInfo::fetch_many(&self.sdk, address_set)
.await
.map_err(|e| {
PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}"))
})?;

let mut inputs_with_nonce: BTreeMap<PlatformAddress, (u32, Credits)> = BTreeMap::new();
for (addr, credits) in inputs {
let info = infos
.get(&addr)
.and_then(|opt| opt.as_ref())
.ok_or_else(|| {
PlatformWalletError::ShieldedBuildError(format!(
"input address not found on platform: {:?}",
addr
))
})?;
// Surface a per-input diagnostic so the host can see what
// we're claiming vs what Platform actually reports —
// mismatches are the typical root cause of
// `AddressesNotEnoughFundsError` on shield broadcast.
if info.balance < credits {
warn!(
address = ?addr,
claimed_credits = credits,
platform_balance = info.balance,
platform_nonce = info.nonce,
"Shield input claims more credits than Platform reports — broadcast will likely fail"
);
} else {
info!(
address = ?addr,
claimed_credits = credits,
platform_balance = info.balance,
platform_nonce = info.nonce,
"Shield input"
);
}
// `AddressNonce` is `u32`; `info.nonce + 1` would panic in
// debug and wrap in release once an address reaches the
// ceiling. drive-abci treats `u32::MAX` as exhausted, so a
// wrap submits nonce 0 and gets rejected as a replay
// *after* the wallet has already spent ~30 s building the
// Halo 2 proof. Bail loudly here instead.
let next_nonce = info.nonce.checked_add(1).ok_or_else(|| {
PlatformWalletError::ShieldedBuildError(format!(
"input address nonce exhausted on platform: {:?}",
addr
))
})?;
inputs_with_nonce.insert(addr, (next_nonce, credits));
}
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: Reimplements rs-sdk's canonical address-nonce fetch instead of reusing it

rs-sdk has fetch_inputs_with_nonce, nonce_inc, and ensure_address_balance in packages/rs-sdk/src/platform/transition/address_inputs.rs that encapsulate exactly this fetch-and-increment dance plus a hard balance check. They are pub(crate) today, so platform-wallet can't reach them directly, but a single-line visibility change would let this code re-use the canonical helpers. As written, the new shield path will silently drift from the SDK's behaviour — for example the SDK enforces a balance check while this implementation only warn!s on info.balance < credits (operations.rs:150-157).

source: ['claude']

🤖 Fix this 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`:
- [SUGGESTION] lines 117-180: Reimplements rs-sdk's canonical address-nonce fetch instead of reusing it
  rs-sdk has `fetch_inputs_with_nonce`, `nonce_inc`, and `ensure_address_balance` in `packages/rs-sdk/src/platform/transition/address_inputs.rs` that encapsulate exactly this fetch-and-increment dance plus a hard balance check. They are `pub(crate)` today, so `platform-wallet` can't reach them directly, but a single-line visibility change would let this code re-use the canonical helpers. As written, the new shield path will silently drift from the SDK's behaviour — for example the SDK enforces a balance check while this implementation only `warn!`s on `info.balance < credits` (operations.rs:150-157).

Comment on lines +595 to +618
let mut accumulated_claim: u64 = 0;
for (i, (addr, balance)) in usable.iter().enumerate() {
if accumulated_claim >= amount {
break;
}
let max_claim = if i == 0 {
balance.saturating_sub(FEE_RESERVE_CREDITS)
} else {
*balance
};
let still_need = amount - accumulated_claim;
let claim = max_claim.min(still_need);
if claim > 0 {
chosen.insert(*addr, claim);
accumulated_claim = accumulated_claim.saturating_add(claim);
}
}

if accumulated_claim < amount {
return Err(PlatformWalletError::ShieldedInsufficientBalance {
available: accumulated_claim,
required: amount,
});
}
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.

💬 Nitpick: amount == 0 produces an empty inputs map and an opaque downstream failure

With amount = 0 and total_usable >= FEE_RESERVE_CREDITS, both pre-loop checks pass; the loop body's first if accumulated_claim >= amount { break; } fires immediately, leaving chosen empty. The post-loop accumulated_claim < amount check is 0 < 0 == false, so the empty map is handed to shielded.shield(...), which AddressInfo::fetch_manys an empty set and then surfaces an opaque downstream failure after the lock dance. Cheaper to early-return ShieldedBuildError("amount must be > 0") (or treat amount=0 as a no-op) at the top of shielded_shield_from_account. Edge case in practice — the UI guards against zero — but worth a defensive check at the library boundary.

source: ['claude']

Comment on lines +250 to +259
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_shielded_shield(
handle: Handle,
wallet_id_bytes: *const u8,
account_index: u32,
amount: u64,
signer_address_handle: *mut SignerHandle,
) -> PlatformWalletFFIResult {
check_ptr!(wallet_id_bytes);
check_ptr!(signer_address_handle);
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.

*💬 Nitpick: signer_address_handle is read-only — prefer const SignerHandle

The header explicitly states the caller retains ownership and the function does not destroy the handle, and the body only reads through it. Taking *mut SignerHandle invites callers to think the function may mutate or take ownership; *const SignerHandle matches the actual contract. Pure FFI ergonomics — no behavioural change.

source: ['claude']

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants