feat(swift-sdk,platform-wallet): wire shielded send end-to-end (all 4 transitions)#3603
feat(swift-sdk,platform-wallet): wire shielded send end-to-end (all 4 transitions)#3603QuantumExplorer wants to merge 12 commits intov3.1-devfrom
Conversation
…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.
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (11)
📝 WalkthroughWalkthroughAdds 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. ChangesShielded Wallet FFI → Core → Swift Flow
Shield Construction & Address Restoration
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Review GateCommit:
|
|
✅ 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:
|
…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.
… 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>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (5)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift (2)
407-438: 💤 Low valueConsider validating
toCoreAddressis non-empty.Other methods explicitly reject empty inputs; an empty
toCoreAddresshere would be passed straight towithCStringand 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 valueTighter validation on
toPlatformAddresswould catch host-side mistakes earlier.The doc says the address is "bincode-encoded
PlatformAddress—0x00 ‖ 20-byte hashfor 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 valueHoist the
usestatements to module scope.The three
useimports (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
canSendkeys offamountDuffseven for credits-based flows.Today
amountDuffs != nil⇔amountCredits != nilbecause both parsers gate on the sameDouble > 0predicate, so this is correct in practice. It will silently break the moment one parser gains stricter validation (e.g., theDecimalswitch suggested elsewhere, or an upper-bound check). Consider keying off the right unit perdetectedFlowso 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 winConsider parsing amounts via
Decimalto avoid float rounding.
Double(amountString) * 100_000_000_000is 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"⇒Double≈1.2299999999999999⇒* 1e11 ≈ 122999999999.99998⇒UInt64(...)⇒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
NSDecimalNumberHandlerif you want banker's rounding rather thanuint64Value'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
📒 Files selected for processing (11)
packages/rs-platform-wallet-ffi/src/lib.rspackages/rs-platform-wallet-ffi/src/shielded_send.rspackages/rs-platform-wallet/src/error.rspackages/rs-platform-wallet/src/wallet/platform_addresses/provider.rspackages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rspackages/rs-platform-wallet/src/wallet/platform_wallet.rspackages/rs-platform-wallet/src/wallet/shielded/operations.rspackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift
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>
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
| 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 | ||
| ) |
There was a problem hiding this comment.
🔴 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)); |
There was a problem hiding this comment.
🟡 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
| 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.
| let viable_input_0 = candidates | ||
| .iter() | ||
| .position(|(_, balance)| *balance > FEE_RESERVE_CREDITS) | ||
| .unwrap_or(0); |
There was a problem hiding this comment.
🟡 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.
| 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" |
There was a problem hiding this comment.
🟡 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.
| let address_signer: &'static VTableSigner = | ||
| std::mem::transmute::<&VTableSigner, &'static VTableSigner>( | ||
| &*(signer_address_handle as *const VTableSigner), | ||
| ); |
There was a problem hiding this comment.
🟡 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.
| // 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 | ||
| )) | ||
| })?; |
There was a problem hiding this comment.
🟡 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.
| 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 | ||
| } |
There was a problem hiding this comment.
🟡 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.
| ) -> 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)); | ||
| } |
There was a problem hiding this comment.
🟡 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.
| /// 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(", ") | ||
| } |
There was a problem hiding this comment.
💬 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']
| /// 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(); | ||
| } |
There was a problem hiding this comment.
💬 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>
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
packages/rs-platform-wallet-ffi/src/shielded_send.rspackages/rs-platform-wallet/src/wallet/platform_wallet.rspackages/rs-platform-wallet/src/wallet/shielded/operations.rspackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
| 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 |
There was a problem hiding this comment.
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.
| /// 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 | ||
| } |
There was a problem hiding this comment.
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.
| 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 | ||
| ) |
There was a problem hiding this comment.
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.
…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>
There was a problem hiding this comment.
🧹 Nitpick comments (3)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift (2)
187-208: 💤 Low valueConsider clearing stashed
network/resolverinreset()for symmetry.
reset()(lines 241-258) nils outwalletManagerandwalletIdbut leaves the newnetworkandresolverfields populated. It's not a correctness bug today sinceswitchTo(walletId:)guards onwalletManagerbeing non-nil before using them, but the asymmetry will be a footgun if a future change re-checks any of these fields independently ofwalletManager.♻️ 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 valueStale per-network DB files from prior installs are left behind.
The path scheme changed from
shielded_tree_<network>.sqlitetoshielded_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 winUpdate 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
📒 Files selected for processing (5)
packages/rs-platform-wallet/src/wallet/shielded/file_store.rspackages/rs-platform-wallet/src/wallet/shielded/operations.rspackages/rs-platform-wallet/src/wallet/shielded/store.rspackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift
…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>
thepastaclaw
left a comment
There was a problem hiding this comment.
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).
| // 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 | ||
| }); |
There was a problem hiding this comment.
🟡 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
| // 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.
| // 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)); | ||
| } |
There was a problem hiding this comment.
🟡 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.
| 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 | ||
| } |
There was a problem hiding this comment.
🟡 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).
| // 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)); | ||
| } |
There was a problem hiding this comment.
🟡 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).
| 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, | ||
| }); | ||
| } |
There was a problem hiding this comment.
💬 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']
| #[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); |
There was a problem hiding this comment.
*💬 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']
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/shieldalready exist on the Rustside. Three of them needed only the bound shielded wallet's
cached
SpendAuthorizingKey(no host signer); the fourth(
shield, Type 15) needed a hostSigner<PlatformAddress>plusa 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::ShieldedNotBoundto distinguish"wallet has no shielded sub-wallet" from build / broadcast
failures.
New
PlatformWalletwrappers (feature-gatedshielded):shielded_transfer_to(recipient_raw_43, amount, prover)— Type 16shielded_unshield_to(to_platform_addr_bytes, amount, prover)— Type 17shielded_withdraw_to(to_core_address, amount, core_fee_per_byte, prover)— Type 19shielded_shield_from_account(account_index, amount, signer, prover)— Type 15Each takes the prover by value because
OrchardProverisimpl'd on
&CachedOrchardProver. Theshield_from_accounthelper auto-selects input addresses from the named Platform
Payment account in ascending derivation order, covering
amount + 0.01 DASHfee buffer (on-chain fee comes offinput 0 via
DeductFromInput(0)).ShieldedWallet::shieldnow fetches per-input nonces fromPlatform via
AddressInfo::fetch_manyand increments thembefore handing to
build_shield_transition. Removes thelong-standing
nonce=0placeholder + TODO.rs-platform-wallet-ffi
New module
shielded_send(feature-gatedshielded):platform_wallet_shielded_warm_up_prover()— fire-and-forgetglobal, no manager handle.
platform_wallet_shielded_prover_is_ready()— bool getterfor a UI affordance.
platform_wallet_manager_shielded_transfer / unshield / withdraw— manager-handle FFIs that resolve the wallet,instantiate a
CachedOrchardProver, and forward to thewallet wrappers via
runtime().block_on(...).platform_wallet_manager_shielded_shield(handle, wallet_id, account_index, amount, signer_address_handle)— additionallytakes a
*mut SignerHandle(Swift'sKeychainSigner.handle)cast to
&VTableSigner. Same shapeplatform_address_wallet_transferuses;VTableSigneralready implements bothSigner<PlatformAddress>andSigner<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.
shieldedShieldkeeps theKeychainSigneralive acrossthe detached work the same way
topUpFromAddressesdoes.Static helpers
PlatformWalletManager.warmUpShieldedProver()and
PlatformWalletManager.isShieldedProverReady.swift-example-app
SendViewModel.executeSendgains awalletManagerparameter and replaces all four shielded placeholder
branches with the real FFI calls. The
.platformToShieldedbranch constructs a
KeychainSignerfrom the modelContextthe same way
TopUpIdentityView/RegisterNameView/FriendsViewalready do.SwiftExampleAppApp.bootstrapfireswarmUpShieldedProver()on a background task at app startso the first user-initiated shielded send doesn't pay the
build cost inline.
Send matrix after this PR
Type 18 (
shield_from_asset_lock— direct Core L1 → Shieldedwithout 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 warningsclean.bash build_ios.sh --target sim --profile devgreen.in CI — needs a running dashmate stack and a wallet with
Platform credits — but the underlying spend builders + sign
nonce-fetch glue is straightforward, and the
KeychainSigner/VTableSigner/Signer<PlatformAddress>path is the same one
platform_address_wallet_transferandtopUpFromAddressesalready use in production.Breaking Changes
None.
SendViewModel.executeSendgains a requiredwalletManagerparameter, but the only call site is in-tree
(
SendTransactionView) and is updated in the same commit set.Checklist:
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Performance
Reliability