Skip to content

feat: identity registration with asset-lock proofs#3634

Open
shumkov wants to merge 68 commits into
v3.1-devfrom
feat/swift/funding-with-asset-lock
Open

feat: identity registration with asset-lock proofs#3634
shumkov wants to merge 68 commits into
v3.1-devfrom
feat/swift/funding-with-asset-lock

Conversation

@shumkov
Copy link
Copy Markdown
Collaborator

@shumkov shumkov commented May 12, 2026

Issue being fixed or feature implemented

SwiftExampleApp could only create a Platform identity from an existing asset-lock proof — there was no path to register an identity directly from a Core (SPV) wallet balance. This PR adds that flow end-to-end, with ExternalSignable wallets (no root xpriv on the Rust side) and an automatic IS-lock → ChainLock fallback when InstantSend doesn't show up.

Status: validated end-to-end on testnet through Phase 2. Identity successfully registered from Core funds with both IS-lock (3.5 s after broadcast) and ChainLock paths confirmed working. Phase 3 (stuck-asset-lock catch-up, CL-height retry hardening, and 23 post-review fixes) builds cleanly but the latest review-fix sweep has not yet been re-validated live on testnet.

What was done?

End-to-end Core-funded identity registration with no private-key material crossing the FFI boundary, plus follow-on fixes that took it from "compiles" to "works on testnet" and then "recovers from every edge case we found."

Phase 1 — core feature (initial 5 commits):

  • rs-dpp + rs-sdk + rs-sdk-ffi (992be090): StateTransition::sign_with_signer<S: key_wallet::signer::Signer>; renamed try_from_identity_with_signertry_from_identity_with_signer_and_private_key + new try_from_identity_with_signers<IS, AS> sibling; mirror split for top-up; ProtocolError::ExternalSignerError(String); put_to_platform_with_signer / broadcast_request_for_new_identity_with_signer / top_up_identity_with_signer on the SDK side. Rename ripple across rs-sdk-ffi, wasm-sdk, rs-drive-abci, strategy-tests.
  • rs-platform-wallet-ffi: MnemonicResolverCoreSigner trampoline (203067259): implements key_wallet::signer::Signer by deferring to a Swift-side MnemonicResolver vtable. Each Core ECDSA call atomically fetches mnemonic → derives key → signs digest → zeroes buffers. No private-key bytes ever leave Swift. Typed MnemonicResolverSignerError.
  • rs-platform-wallet refactor (f1a7d1c2): IdentityFunding enum (FromWalletBalance, FromExistingAssetLock, UseAssetLock) replaces the dual IdentityFundingMethod/TopUpFundingMethod pair. L1/L2 merge: single register_identity_with_signer (signer + proof + path + keys_map) + single register_identity_with_funding (funding-source orchestration). IS→CL fallback (180 s) and H3 cleanup-on-success centralized. build_asset_lock_transaction<S: Signer> / create_funded_asset_lock_proof<S: Signer> return (_, DerivationPath) — credit-output privkey no longer leaves the wallet.
  • swift-sdk wrappers (9d5e506a): registerIdentityWithFunding(amountDuffs:identityIndex:identityPubkeys:signer:) with internal MnemonicResolver() and withExtendedLifetime so ARC can't release the resolver mid-FFI. ManagedAssetLockManager.buildTransaction / createFundedProof take an external MnemonicResolver. KeychainManager delete-query fix (was using kSecValueData as a filter → errSecDuplicateItem).
  • SwiftExampleApp (8a57e882): CreateIdentityView Core-account branch + plan doc.

Phase 2 — end-to-end unblock (after Phase 1 testnet attempt):

  • 885a1be3fix(platform-wallet-ffi): always enable masternode sync for SPV
    Real root cause for the "tx never IS-locks" symptom. In trusted-SDK mode the app set masternodeSyncEnabled=false, which disabled dash-spv's ChainLockManager + InstantSendManager. The SPV client connected to masternode peers and received CLSig/ISLock P2P messages, but with no manager subscribed, MessageDispatcher dropped them. Removed the FFI knob entirely; hardcoded enable_masternodes = true. Asset-lock proofs are a published platform-wallet feature; the IS/CL subscription is a non-optional dependency.
  • 4184a425chore: bump rust-dashcore to 5297d61a for chainlock wallet handling
    Picks up feat(key-wallet): add chainlock handling to the wallet rust-dashcore#756 which adds WalletInterface::process_chain_lock, promotes records InBlock → InChainLockedBlock on chainlock arrival, and emits a new WalletEvent::TransactionsChainlocked variant. Without this the chainlock fallback branch of wait_for_proof could never resolve — records were stuck at InBlock(_) forever. Match-arm coverage added in core_bridge + balance_handler for the new variant.
  • 3d16a31afix(SwiftExampleApp): bump identity funding floor to v1 minimum for 3 keys
    Platform rejected v1 identity-creates funded at the v0 floor (200_000 duffs) because v1's calculate_min_required_fee_v1 adds per-key creation cost. With defaultKeyCount = 3 the real floor is 221_500 duffs (2_000_000 base + 3 × 6_500_000 per-key, all in credits, ÷ 1000 credits/duff). Bumped minIdentityFundingDuffs to 221_500 and defaultCoreFundingDuffs to 250_000.
  • 34d702d3docs(swift-sdk): mark SPV event-routing follow-up resolved — plan doc resolution note.

Phase 3 — stuck-asset-lock catch-up + retry hardening + post-review cleanup:

The Phase 2 testnet run worked for fresh registrations but exposed a recoverability gap: an asset lock that was broadcast before the app was killed (or that received its IS-lock / chainlock during a session the app was not running) sat at Broadcast forever after relaunch. Resolving that surfaced a chain of issues, each of which became a commit:

  • f65e2e4351 fix(platform-wallet): persist chain-lock context promotions to Swift via bridge — Rust-side WalletEvent::TransactionsChainlocked was emitted but the changeset bridge wasn't projecting the per-record InBlock → InChainLockedBlock flip into the PlatformWalletChangeSet Swift consumes. Added chain_lock_promotions to the core changeset and wired the projection.
  • d404cd0caf feat(platform-wallet-ffi): restore tx records for unresolved asset locks at load + 5aa9e9ad5f feat(swift-sdk): project tx records for unresolved asset locks into restore entry — at app launch, Swift now hands the funding-tx record (with persisted BlockInfo context) back to Rust as part of the wallet restore entry, so the in-memory transactions() map has something for the chainlock cascade to promote. Without this, restored asset locks at Broadcast had no in-memory record for apply_chain_lock to find, so the cascade silently no-op'd.
  • 67f5962012 feat(platform-wallet): background catch-up for stuck asset locks — new fire-and-forget FFI asset_lock_manager_catch_up_blocking + Swift PlatformWalletManager.catchUpStuckAssetLocks that walks every PersistentAssetLock with statusRaw < 2 and spawns Task.detached calls into resume_asset_lock, parked on wait_for_proof. The chain-lock cascade then fires on the next CLSig without any user interaction.

Phase 3.5 — what the in-flight working tree adds on top (this session, not yet split into commits):

After Phase 3's four commits a second round of testnet inspection turned up several deeper issues. Captured here for context; full commit will follow review:

  • The actual root cause for "stuck asset lock chore(wallet-lib): upgrade webpack to v.5 #10": PlatformWalletInfo is a wrapper around upstream's ManagedWalletInfo and delegated ~20 WalletInfoInterface methods (network, wallet_id, balance, sync heights, etc.) but silently missed apply_chain_lock and last_applied_chain_lock. Both fell through to the trait's default no-op. Upstream's spawn_chainlock_wallet_dispatch task was calling wallet.write().await.apply_chain_lock(...) for every validated CL, but it was hitting the no-op — metadata.last_applied_chain_lock stayed None, records never got promoted, the cascade never reached wait_for_proof. Two-line fix: delegate both methods.
  • CL-height-too-low retry path on identity registration. A wallet that observes a fresh ChainLock locally can race Platform's view of the same height; submitting too early returns InvalidAssetLockProofCoreChainHeightError (consensus code 10506). New submit_with_cl_height_retry helper in registration.rs retries every 15s for 210s (matching mainnet's create-empty-blocks-interval = 3m), bumping PutSettings::user_fee_increase per retry. Tenderdash's mempool caches rejected ST hashes for ~24h on mainnet/testnet (keep-invalid-txs-in-cache = true in dashmate's tenderdash/config.toml.dot), so retries must produce distinct signable bytes to bypass the cache. Critical follow-up: the original wiring was a no-op because broadcast_request_for_new_identity_with_signer hardcoded user_fee_increase = 0 (packages/rs-sdk/src/platform/transition/broadcast_identity.rs:171) — the threading was missing through the trait method. Symmetric fix to both private-key and signer variants now propagates PutSettings::user_fee_increase end-to-end.
  • Wallet trust hierarchy made explicit. The retry path no longer queries Platform's self-reported core_chain_locked_height anywhere. That metadata is unproven and a malicious DAPI node could stall us indefinitely. The wallet's last_applied_chain_lock (SPV-verified BLS signature, cryptographic) is the only signal trusted. Removed get_platform_core_chain_locked_height and its call sites; submission is now optimistic and reacts to Platform's deterministic 10506 rejection.
  • Wallet-side CL fallback in wait_for_proof — if the per-record context didn't promote (race between SPV's apply_chain_lock and the catch-up task entering wait_for_proof), the fallback uses wallet.last_applied_chain_lock.block_height >= record.height() to build a Chain proof directly. Gated on a chain-id match (wallet.network() == sdk.network) so a misconfigured wallet (network drift / restore from wrong-network backup) can't synthesize a proof on the wrong chain.
  • AssetLockStatus::Consumed terminal variant. The previous remove_asset_lock deleted the row from both Rust's in-memory tracked_asset_locks and Swift's PersistentAssetLock table. The deletion broke historical UI lookups: the "Transactions" list couldn't map a consumed funding tx back to its locked amount. New semantics: Rust drops from memory (terminal — no more proof work to do); Swift retains the row at statusRaw = 4 "Consumed" for the lifetime of the wallet. Catch-up scanner and "ready to fund" UI continue to filter < InstantSendLocked so Consumed entries don't generate noise. Renamed the function remove_asset_lockconsume_asset_lock to match.
  • PlatformWalletError::FinalityTimeout(OutPoint) — was FinalityTimeout(Txid). The full outpoint now flows from wait_for_proof through resolve_funding_with_is_timeout_fallback directly, eliminating a BTreeMap-iteration-order-dependent find_tracked_unproven_lock helper that could pick the wrong unproven lock when multiple were tracked at the same (funding_type, identity_index).
  • Wallet's last-applied chain-lock exposed via FFI. New has_last_applied_chain_lock / last_applied_chain_lock_height / last_applied_chain_lock_block_hash[32] fields on CoreWalletStateFFI; Swift CoreWalletStateSnapshot.lastAppliedChainLockHeight: UInt32? and lastAppliedChainLockBlockHash: Data?. Currently in-memory only — not persisted across app restarts.
  • Transaction-list UI for asset-lock rows. Asset-lock txs were reading as "Internal transaction to myself" with +0.00000000 DASH in red. The Rust classifier already labeled them TransactionType::AssetLock but the Swift row picked an icon from direction (which was Internal) and an amount from transaction.netAmount (which is ~0 because the credit output is a self-owned address). Fixed: purple lock icon (lock.fill), displayDirection returns "Asset Lock" / "Asset Unlock" instead of "Internal", row joins PersistentAssetLock.amountDuffs by txid (summing across vouts) and renders the actual L1 DASH burned; falls back to "Asset Lock (amount unknown)" if the linked row is missing. Same treatment in TransactionDetailView.
  • Multi-reviewer code audit + 20 of 23 findings addressed. Spawned ce-correctness-reviewer / ce-adversarial-reviewer / ce-maintainability-reviewer / rust-quality-engineer / silent-failure-hunter against the working tree. Findings ranged from "load-bearing CRITICAL" (the user_fee_increase no-op) down to LOW cleanups (stray TODO comment). The 3 deliberately skipped are documented in code comments (FundingResolution refactor: out of scope; saturating_add(1): defensive only inside the budget; #[non_exhaustive] on AssetLockStatus: would force wildcard arms and lose the compile-time signal for new variants).
  • Fix for Found-008 (Found-008: LockNotifyHandler::notify_waiters() drops lock events arriving in wait_for_proof's check/await gap (concurrent asset-lock builds stall on FinalityTimeout) #3641): LockNotifyHandler missed-wakeup race in wait_for_proof. The tokio::sync::Notify API has a well-known foot-gun: notify_waiters() only wakes currently-registered waiters and does NOT store a permit. The wait_for_proof loop checks state, then calls lock_notify.notified().await — an IS/CL event arriving in that gap is silently dropped, and the next event never comes for that specific txid, so the wait stalls until FinalityTimeout. Fix uses the canonical Notified::enable() pattern (Option C in my comment on Found-008: LockNotifyHandler::notify_waiters() drops lock events arriving in wait_for_proof's check/await gap (concurrent asset-lock builds stall on FinalityTimeout) #3641 — different from the issue body's Option A/B): arm the future BEFORE the state check, so any subsequent notify_waiters() is captured by the pinned future. ~10 lines per loop site (applied to both wait_for_proof and wait_for_chain_lock), preserves the multi-waiter semantics that notify_one would break, no API change to LockNotifyHandler. Closes Found-008: LockNotifyHandler::notify_waiters() drops lock events arriving in wait_for_proof's check/await gap (concurrent asset-lock builds stall on FinalityTimeout) #3641 (Found-008 / AL-001). The AL-001 e2e regression test in PR test(rs-platform-wallet): e2e suite, Found-025 fix + triage pins #3549 will pin the fix once that PR merges.

How Has This Been Tested?

  • cargo test --workspace green for rs-dpp / rs-sdk / rs-platform-wallet (124/124 in the lib tests).
  • Testnet end-to-end identity registration confirmed working (Phase 2 validation): IS-lock arrived 3.5 s after broadcast; live event tracing showed every wiring channel firing (PlatformEventManager::on_sync_eventLockNotifyHandlerwait_for_proof poll → context flip → InstantAssetLockProof emitted → Platform IdentityCreateTransition accepted).
  • Stuck-asset-lock catch-up confirmed working in the working-tree session: slot chore(wallet-lib): upgrade webpack to v.5 #10 (broadcast pre-restart, on-chain txlock=true, 538 confirmations) advanced from statusRaw=1 to statusRaw=3 with a populated proofBytes after the apply_chain_lock delegation fix landed — exactly the cascade described above.
  • iOS sim build green after every commit and after every review fix (./build_ios.sh --target sim --profile dev).
  • The Phase 3.5 review-fix sweep (CL-height retry path actually threading user_fee_increase, Consumed status persisting, the new FinalityTimeout(OutPoint) shape) has not been re-exercised on testnet yet — the build is green but a follow-up identity registration on a fresh wallet is needed to confirm the retry path now bypasses Tenderdash's hash cache as intended.

Breaking Changes

  • Removed masternode_sync_enabled parameter from the platform_wallet_manager_spv_start FFI signature and the corresponding masternodeSyncEnabled field on PlatformSpvStartConfig. Callers must drop the argument. Rationale: asset-lock proof acquisition requires it, so the flag was unsafe to expose. Internal-FFI ABI; the Swift SDK in this repo is the only consumer.
  • At the Rust public-API level, the prior try_from_identity_with_signer / try_from_identity methods were renamed to _and_private_key / _with_private_key variants alongside new _with_signer(s) siblings (all internal callers updated in the same commit).
  • BroadcastRequestForNewIdentity trait signature change: both broadcast_request_for_new_identity_with_private_key and broadcast_request_for_new_identity_with_signer gained a user_fee_increase: UserFeeIncrease parameter. This is the critical fix that makes the retry path actually function — the old hardcoded 0 produced identical ST hashes across retries and got dedup'd at Tenderdash. External implementors of the trait need to thread the parameter through to IdentityCreateTransition::try_from_identity_with_signer{,s}{,_and_private_key}.
  • AssetLockStatus gained a Consumed variant. Exhaustive matches in downstream consumers must add an arm. We intentionally did NOT mark the enum #[non_exhaustive] — every cross-crate match site uses exhaustive arms by design so the compiler catches the next variant addition the same way it caught this one.
  • PlatformWalletError::FinalityTimeout(Txid)FinalityTimeout(OutPoint). The variant payload type changed; consumers pattern-matching the variant need to destructure an OutPoint instead of a Txid. .txid field still accessible via the outpoint.
  • tracked_asset_locks map semantics changed. Was: removed on consumption. Now: terminal entries are dropped from the in-memory map but the Swift PersistentAssetLock row is retained with statusRaw=4. Load-path filters Consumed entries so they're never re-added to memory. Consumers reading the in-memory map see only still-actionable locks.
  • rust-dashcore rev bump (531308695297d61a) introduces WalletEvent::TransactionsChainlocked which forces match-arm coverage in downstream consumers of the WalletEvent enum.

Checklist:

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

Summary by CodeRabbit

  • New Features

    • Resumable identity registrations with persisted asset-locks, background catch‑up, progress UI, and single‑flight registration coordinator
    • Support for external signer flows (host-backed signing) for transactions, asset-locks, and identity operations
  • Improvements

    • ChainLock fallback for InstantSend timeouts and broader ChainLock handling (more reliable finality)
    • SPV startup now enables masternode sync by default for correct asset‑lock proofs
    • Improved asset‑lock/transaction display, “Consumed” state visibility, and storage views

Review Change Stack

shumkov and others added 5 commits May 13, 2026 00:18
Adds the missing external-Signer pathway for asset-lock-funded
IdentityCreate / IdentityTopUp state transitions. Previously these
required raw `&PrivateKey` bytes for the asset-lock-proof signature,
making the flow impossible on watch-only / ExternalSignable wallets
where no private keys live host-side.

Additive (no breaking changes to existing callers):

- `StateTransition::sign_with_signer<S: key_wallet::signer::Signer>` —
  sibling to `sign_by_private_key`. Atomic per-call derive+sign+zero
  via the supplied signer. Byte-parity proven against the legacy
  path (test pins on-wire compatibility).
- `IdentityCreateTransitionV0::try_from_identity_with_signers` and
  `IdentityTopUpTransitionV0::try_from_identity_with_signer` — new
  signer-based factories alongside the renamed legacy
  `_with_signer_and_private_key` / `_with_private_key` siblings.
- `PutIdentity::put_to_platform_with_signer`,
  `BroadcastNewIdentity::broadcast_request_for_new_identity_with_signer`,
  `TopUpIdentity::top_up_identity_with_signer` — rs-sdk wrappers,
  gated on `core_key_wallet` feature.
- `ProtocolError::ExternalSignerError(String)` — typed variant so
  callers can distinguish signer-side failures from generic protocol
  errors (recovery-id mismatch invariant violations etc.).

The legacy `try_from_identity_with_signer` was renamed to
`try_from_identity_with_signer_and_private_key` (and the top-up
counterpart `try_from_identity` to `try_from_identity_with_private_key`)
so callers can read the contract at a glance. Call sites in rs-sdk,
rs-sdk-ffi, wasm-sdk, drive-abci, and strategy-tests propagated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New Rust struct implementing `key_wallet::signer::Signer` (Core ECDSA)
by wrapping the existing `MnemonicResolverHandle` callback into iOS
Keychain. Per signing call: resolve mnemonic via the resolver vtable,
derive Core priv key at the requested derivation path, sign the
32-byte digest, zero all intermediate buffers via `Zeroizing<>`,
return `(secp256k1::ecdsa::Signature, secp256k1::PublicKey)`.

No private keys ever cross the FFI boundary — only signatures and
public keys. Lifetime of the resolver handle is the caller's
responsibility (documented at the constructor); current call sites
keep it alive on the FFI-frame stack.

Wraps and reuses the same primitive that the existing
`dash_sdk_sign_with_mnemonic_resolver_and_path` FFI uses for
Platform-address signing, so the Core-side and Platform-side signers
share one architectural pattern and one mnemonic-resolution path.

Typed `MnemonicResolverSignerError` enum with 9 variants gives
callers structured failure classification (NullHandle, NotFound,
BufferTooSmall, ResolverFailed(i32), InvalidUtf8, InvalidMnemonic,
DerivationFailed, InvalidScalar, …) instead of stringified blobs.

5 round-trip unit tests cover the happy path, error surfacing,
pubkey-vs-signature consistency, null/missing-handle handling, and
`SignerMethod::Digest`-only capability.

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

Collapses the dual register/top-up paths (legacy-vs-funded) into a
single L1 (signer-only) + L2 (funding+cleanup) pair, and wires
ExternalSignable wallets end-to-end:

- types/funding.rs: `IdentityFunding` enum (`FromWalletBalance`,
  `FromExistingAssetLock`, `UseAssetLock { proof, derivation_path }`)
  replaces `IdentityFundingMethod`/`TopUpFundingMethod`.
- asset_lock/build.rs: `build_asset_lock_transaction<S: Signer>` and
  `create_funded_asset_lock_proof<S: Signer>` now take a Core signer
  and return `(_, DerivationPath)` — credit-output private key no
  longer leaves the wallet.
- identity/network/registration.rs:
  - L1 `register_identity_with_signer(keys_map, proof, path, …)`
  - L2 `register_identity_with_funding(IdentityFunding, …)` —
    builds asset lock, awaits IS-lock with 180s timeout, falls back
    to chainlock proof on timeout, removes the tracked asset lock
    after a successful registration (H3 cleanup).
  - `resolve_funding_with_is_timeout_fallback` helper centralises
    the IS→CL transition.
- identity/network/top_up.rs: mirror split for top-up.
- error.rs: `is_instant_lock_timeout` discriminator.

FFI (`rs-platform-wallet-ffi`):
- `identity_registration_funded_with_signer` now drives
  `register_identity_with_funding(FromWalletBalance{…})` and accepts
  a `MnemonicResolverHandle` for Core ECDSA signing.
- `asset_lock/build.rs`, `asset_lock/sync.rs`, `core_wallet/broadcast.rs`
  pass the resolver-backed signer through every path that previously
  required a root extended privkey.

Result: ExternalSignable wallets can register/top-up identities
without ever materialising the root xpriv or credit-output key on
the Rust side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the new MnemonicResolverCoreSigner FFI through ManagedPlatformWallet
so identity registration, asset-lock proof creation, and Core sends all
sign via a resolver vtable rather than passing private-key bytes across
the FFI boundary.

- ManagedPlatformWallet: `registerIdentityWithFunding(amountDuffs:
  identityIndex:identityPubkeys:signer:)` creates an internal
  `MnemonicResolver()` and uses `withExtendedLifetime((signer,
  coreSigner))` around the FFI call so ARC can't release the resolver
  while Rust still holds its handle.
- ManagedAssetLockManager: `buildTransaction` and `createFundedProof`
  now take an external `MnemonicResolver` parameter and return a
  `derivationPath: String` instead of a `privateKey: Data`. The
  consume-phase signing happens in the next FFI call (`resume` doesn't
  need a signer at all).
- ManagedCoreWallet.sendToAddresses: creates and lifetime-extends an
  internal `MnemonicResolver` for each call — keys never leave Swift,
  Core ECDSA happens atomically inside the vtable.
- KeychainManager: split the duplicate-key insert into an explicit
  attribute-only `SecItemDelete` followed by `SecItemAdd`. Previously
  the delete query included `kSecValueData`, which Keychain interprets
  as a value filter, so the entry survived and `SecItemAdd` failed
  with `errSecDuplicateItem`. Kept the original
  `identity_privkey.<derivationPath>` account naming — wallet-id
  isolation was out of scope.

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

- CreateIdentityView gains a Core-account branch alongside the
  existing asset-lock-proof flow. When the user picks a Core wallet
  account with a sufficient balance, the view validates the funding
  amount, calls `ManagedPlatformWallet.registerIdentityWithFunding(
  amountDuffs:identityIndex:identityPubkeys:signer:)`, and lets the
  Rust side build → broadcast → await IS-lock → fall back to
  chainlock → register → clean up. The credit-output private key
  never crosses the FFI; the wallet's MnemonicResolver signs each
  Core ECDSA digest atomically.
- Plan document (CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md, Draft 9)
  captures the full spec, the 7-iteration design history,
  adversarial review outcomes, and the open P0 follow-up about SPV
  event routing (chainlock signatures not yet propagating to the
  wallet tx-record context — tracked separately).

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

coderabbitai Bot commented May 12, 2026

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

<review_stack_artifact>

</review_stack_artifact>

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/swift/funding-with-asset-lock

@github-actions github-actions Bot added this to the v3.1.0 milestone May 12, 2026
@shumkov shumkov changed the title feat(swift): Core-funded identity registration with asset-lock proofs feat(swift): identity registration with asset-lock proofs May 12, 2026
@shumkov shumkov changed the title feat(swift): identity registration with asset-lock proofs feat: identity registration with asset-lock proofs May 12, 2026
@shumkov shumkov changed the title feat: identity registration with asset-lock proofs feat(swift): identity registration with asset-lock proofs May 12, 2026
shumkov and others added 20 commits May 13, 2026 03:23
`AssetLockManager::wait_for_proof` resolves an asset-lock proof by
reading `CLSig` / `ISLock` P2P messages through `ChainLockManager` +
`InstantSendManager`. Both managers are only constructed by
`dash-spv` when `ClientConfig::enable_masternodes == true` (see
`dash-spv/src/client/lifecycle.rs`). With the flag off, the SPV
client connects to masternode peers and receives the wire messages,
but no manager is subscribed to them, so `MessageDispatcher` drops
the bytes. Result: no IS-lock / chain-lock events ever reach our
`LockNotifyHandler`, `wait_for_proof` sleeps the full 300 s deadline,
and identity registration fails with `FinalityTimeout`.

SwiftExampleApp was conflating "SDK in trusted mode" with "no
masternode sync needed", so `masternodeSyncEnabled = !trusted_mode`
silently disabled the IS/CL P2P subscription whenever the app used
the trusted SDK path. The two concerns are independent — trusted
mode is about who validates LLMQ quorum signatures, not about
whether dash-spv listens for them.

Asset-lock-funded identity registration is a published feature of
the platform-wallet crate; the IS/CL subscription is a non-optional
dependency. Encode that contract in the FFI by removing the
`masternode_sync_enabled` knob entirely and hardcoding
`config.enable_masternodes = true`. Callers that only need
trusted-mode Platform queries (no asset locks) are unaffected aside
from a slightly larger SPV footprint.

- packages/rs-platform-wallet-ffi/src/spv.rs:
  Drop `masternode_sync_enabled` parameter from
  `platform_wallet_manager_spv_start`; hardcode
  `config.enable_masternodes = true` with a comment pointing at the
  upstream contract.
- packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift:
  Drop `masternodeSyncEnabled` from `PlatformSpvStartConfig` and
  from the `platform_wallet_manager_spv_start` call.
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift:
  Drop the call-site `masternodeSyncEnabled:` argument. The
  in-app `@State` flag still drives UI display gating; only the
  SPV-config propagation is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up dashpay/rust-dashcore#756 which adds chainlock-driven
transaction finalization in the wallet layer. Previously,
`WalletInterface` had no `process_chain_lock` method and
`dash-spv`'s `SyncEvent::ChainLockReceived` was emitted but never
consumed, so wallet records were stuck at `TransactionContext::
InBlock(_)` forever even when the network produced a chainlock for
the containing block. The new pin promotes records `InBlock →
InChainLockedBlock` on chainlock arrival and emits a new
`WalletEvent::TransactionsChainlocked` variant carrying the
chainlock proof and per-account net-new finalized txids.

For our `wait_for_proof` poll loop this means the chainlock branch
(`record.context.is_chain_locked()`) actually flips when peers
deliver the chainlock — the iter-4 IS→CL fallback path now resolves
correctly instead of timing out at the secondary 180 s deadline.

The new `WalletEvent` variant forces match-arm coverage in two
sites:

- packages/rs-platform-wallet/src/changeset/core_bridge.rs
  `build_core_changeset` returns `CoreChangeSet::default()` for
  the new variant. The wallet has already mutated the in-memory
  record by the time the event fires (upstream is "mutate-then-
  emit"), and the poll loop reads `record.context.is_chain_locked()`
  directly, so no additional persister projection is needed today.
  A future enhancement could persist `WalletMetadata::
  last_applied_chain_lock` for crash recovery, but that's out of
  scope here.

- packages/rs-platform-wallet/src/wallet/core/balance_handler.rs
  `BalanceUpdateHandler::on_wallet_event` returns early for the
  new variant. Chainlocks promote finality (`InBlock →
  InChainLockedBlock`) without changing UTXO state, so there's no
  balance update to deliver.

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

Platform rejected identity-create transitions whose asset-lock
output funded the protocol-v0 floor of 200,000 duffs, because v1's
`IdentityCreateTransition::calculate_min_required_fee_v1` adds the
per-key creation cost on top of the asset-lock base. With our
`defaultKeyCount = 3` (master + high + transfer) the required
floor is:

    identity_create_base_cost        2_000_000 credits
  + asset_lock_base × CREDITS_PER_DUFF (200_000 * 1000) 200_000_000
  + identity_key_in_creation_cost × 3  (6_500_000 * 3)  19_500_000
  = 221_500_000 credits / 1000          = 221_500 duffs

Exactly matches the testnet rejection: "needs 221500000 credits to
start processing". Bump `minIdentityFundingDuffs` to 221_500 and
`defaultCoreFundingDuffs` to 250_000 (12.5% headroom so the new
identity has a non-zero initial credit balance after the processing
fee is deducted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end Core-funded identity registration validated on testnet.
The 70-line investigation history collapses to a 3-bullet
resolution note pointing at the commit SHAs that landed the fix:

- 885a1be — masternode sync hardcoded for SPV
- 4184a42 — rust-dashcore bump (#756 chainlock handling)
- 3d16a31 — funding floor bump to v1 minimum

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for iter 3's stage-aware registration progress bar and
iter 5's resume picker: tracked asset locks now round-trip through
SwiftData via a new FFI callback, so an in-flight identity
registration's progress is visible to SwiftUI views via @query and
survives app restarts.

Rust FFI:
- Add `AssetLockEntryFFI` (`asset_lock_persistence.rs`) — flat C
  mirror of `AssetLockEntry` with consensus-encoded tx + bincode-
  encoded proof carried by reference for the callback window.
- Add `on_persist_asset_locks_fn` to `PersistenceCallbacks`; wire
  the dispatcher in `FFIPersister::store()` so every changeset
  flush forwards asset-lock upserts + removed-outpoint tombstones
  to Swift.
- Extend `WalletRestoreEntryFFI` with `tracked_asset_locks` +
  `tracked_asset_locks_count`. `build_unused_asset_locks` decodes
  the persisted rows back into `BTreeMap<account_index,
  BTreeMap<OutPoint, TrackedAssetLock>>` on wallet load so a
  registration interrupted by an app kill resumes from the latest
  status without rebroadcasting.

SwiftData model:
- `PersistentAssetLock` keyed by `outPointHex` (`<txid_hex>:<vout>`),
  with `walletId` indexed for per-wallet scans. Mirrors the FFI
  shape 1:1.
- Registered in `DashModelContainer.modelTypes`.
- Encode/decode helpers (`encodeOutPoint` / `decodeOutPointHex`)
  bridge the 36-byte raw form Rust uses to the display-order hex
  string SwiftData stores.

Swift persister:
- `PlatformWalletPersistenceHandler.persistAssetLocks` performs
  insert-or-update by `outPointHex` and deletes by removed
  outpoints, both inside the bracketed begin/end save round.
- `loadCachedAssetLocks` / `buildAssetLockRestoreBuffer` populate
  the new FFI slice on the load path; the `LoadAllocation` owns
  the heap buffers until the matching free callback fires.
- `persistAssetLocksCallback` C trampoline snapshots every entry
  into owned `Data` before invoking the handler so Rust's
  `_storage` Vec can release the buffers as soon as the
  trampoline returns.

Storage explorer:
- New "Asset Locks" row in `StorageExplorerView`, list +
  detail views in `StorageModelListViews` /
  `StorageRecordDetailViews`. SwiftData-backed; proves the
  persister round-trip end-to-end before iter 3 part 2 starts
  consuming the same rows for the progress bar.

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

Replaces iter-1's single in-flight spinner with a 5-step stage-aware
progress UI that survives view dismissal and supports multiple
concurrent registrations.

Services:
- `IdentityRegistrationController` (`@MainActor`, ObservableObject)
  owns the per-slot registration phase: .idle → .preparingKeys →
  .inFlight → .completed(id) | .failed(message). Single-flighted
  inside `submit` so a re-submit on an active controller is a no-op.
- `RegistrationCoordinator` (hosted on `PlatformWalletManager` via
  an associated-object extension — keeps example-app types out of
  the SDK module while preserving the plan's call-site convention)
  maps `(walletId, identityIndex) → IdentityRegistrationController`,
  auto-purges `.completed` rows ~30s after success, keeps `.failed`
  rows until manually dismissed, and exposes
  `hasInFlightRegistrations` for the network-toggle gate.

Views:
- `RegistrationProgressView` derives the current step from
  `controller.phase` (steps 1, 4, 5) combined with a live `@Query`
  on `PersistentAssetLock` filtered by `(walletId, identityIndex)`
  (steps 2/3, driven by `statusRaw`). 5-row list with
  done/active/pending/failed states and inline error message on
  failure.
- `PendingRegistrationsList` + `PendingRegistrationRow` surface the
  coordinator's active controllers in `IdentitiesContentView`.
  Dismissed-but-still-running flows remain reachable via tap;
  `.failed` rows can be dismissed via swipe action.

Wiring:
- `CreateIdentityView.submitCoreFunded` binds the FFI call into
  `coordinator.startRegistration(...)` and observes the controller's
  phase transitions via a small AsyncStream poller (no Combine —
  `AnyCancellable` isn't Sendable from `AsyncStream`'s
  `@Sendable` builder closure). Local `createdIdentityId` /
  `submitError` / `isCreating` mirrors update from the observer so
  the existing success / error UI keeps working when the user stays
  on the sheet.
- `OptionsView`'s network picker `.disabled(_:)` includes
  `hasInFlightRegistrations` so switching networks mid-flight
  doesn't tear down the FFI manager (the adversarial-review
  concern from the plan). A small footer explains why the picker
  is grayed out. Both gates use a dedicated sub-view /
  ViewModifier observing the coordinator as `@ObservedObject` so
  the reactive update fires on phase transitions.

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

Iterative changes on the identity-creation UX, checkpointed mid-debug.

Working:
- Progress section refactored to 5 steps: Building → Broadcasting →
  Wait IS → Wait CL → Registering identity. `RegistrationProgressSection`
  is embeddable (no nested-`Form`); `RegistrationProgressView` is the
  standalone navigation destination.
- `TimelineView(.periodic)` drives the Broadcasting → Wait-IS →
  Wait-CL transition within `statusRaw == 1` using elapsed time as the
  anchor. Step 4 (Wait CL) renders as `.skipped` when the IS branch
  finalised the lock.
- Success state moved to `RegistrationProgressView.terminalSection`
  with a single "View" button (no separate "Done"). Tapping calls back
  through `onViewIdentity` to the parent and dismisses the sheet; the
  parent's `.navigationDestination(item:)` pushes `IdentityDetailView`.
- `IdentityStorageDetailView`: top-level "View Identity" link to the
  operational identity detail.
- `AssetLockStorageDetailView`: separate "Identity" section with a
  single-row `NavigationLink` to the linked identity (Base58 id),
  visible only for `IdentityRegistration` / `IdentityTopUp` funding
  types.

Known broken: `CreateIdentityView`'s Source Wallet `Picker` is
disabled / not responding to taps on the simulator. Likely caused by
the new `.navigationDestination(isPresented:)` modifier or its
interaction with the parent's `.navigationDestination(item:)`. The
log shows `<0x...> Gesture: System gesture gate timed out`, meaning
the main thread fails to respond to the tap. Wrapping the parent's
nav target in an `Identifiable` shim (`CreatedIdentityNavTarget`) was
attempted but didn't help. Committing as a checkpoint so the work
isn't lost; the picker regression is the next thing to debug.

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

Iter 3 polish round, salvaged from the WIP commit that hit a Picker
hit-test regression on iOS 26.

CreateIdentityView:
- Inline progress: the Form swaps in
  `RegistrationProgressSection` + a terminal `Done` banner when
  `activeController` is set, replacing the input sections in place.
  The "Done" button on success now also calls
  `walletManager.registrationCoordinator.dismiss(...)` so the
  "Pending Registrations" row on the Identities tab clears
  immediately rather than waiting ~30 s for the retention sweep.
- Dropped the in-flight `.fullScreenCover` / `.navigationDestination`
  experiments. Both modifiers broke `Picker` hit-testing inside the
  sheet on iOS 26 (Source Wallet "Select…" rendered but didn't
  respond to taps). Reverting to inline rendering keeps the picker
  interactive without losing the new-screen feel — the Form's
  sections are swapped wholesale on submit.

IdentitiesContentView:
- Dropped `navigateToCreatedIdentity` state + the
  `.navigationDestination(item:)` modifier that paired with
  CreateIdentityView's now-removed `onViewIdentity` callback.

RegistrationProgressView:
- Standalone `Done` button (the success state reachable from
  Pending Registrations) drops the controller from the coordinator
  before popping, matching the inline path.
- Reverted to a plain `Done` button (was a "View Identity" link
  briefly during the new-screen iteration); `View Identity` only
  makes sense in the sheet flow and that flow is gone.

AssetLockStorageDetailView:
- Identity is now its own `Section("Identity")` with the linked
  identity rendered as a copyable static `Text`. Pushing
  `IdentityDetailView` from this nested
  Settings → Storage → Asset Locks → Asset Lock path hung the main
  thread on iOS 26 even after the HStack/contentShape workaround
  the rest of the codebase uses elsewhere; punting on navigation
  here keeps the page usable. The operational identity view is
  still reachable from the Identities tab.
- Predicate relaxed: candidate identities are queried by
  `identityIndex` alone, then post-filtered in Swift preferring a
  strict `(walletId, identityIndex)` match and falling back to a
  single orphaned (wallet == nil) identity. The previous strict
  predicate silently hid legacy identities whose `wallet`
  relationship was never persisted.
- For partial / unconsumed asset locks (no linked identity), the
  section shows a status fallback ("In progress" / "Pending
  (unused)") so the entry isn't blank.

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

Adds `platform_wallet_resume_identity_with_existing_asset_lock_signer`
sibling to the wallet-balance funded variant. Takes an `OutPointFFI`
instead of a duff amount and dispatches via
`IdentityFunding::FromExistingAssetLock`, reusing the same
`register_identity_with_funding` helper (so the resume / IS->CL
fallback logic stays in one place on the Rust side).

Extracts a private `decode_identity_pubkeys` helper shared by both
funded-with-signer entry points; the only difference between fresh-
build and resume paths is which `IdentityFunding` variant is
constructed.

Swift surface: `ManagedPlatformWallet.resumeIdentityWithAssetLock(
outPointTxid:outPointVout:identityIndex:identityPubkeys:signer:)`
mirrors `registerIdentityWithFunding`'s shape exactly — same
`Task.detached` + `MnemonicResolver` lifecycle + `withExtendedLifetime`
+ `withPubkeyFFIArray` pattern. Caller passes the 32-byte raw txid
(little-endian wire order, matching `OutPointFFI.txid`) and the vout;
the wrapper packs them into the FFI struct.

Iter 5 plumbing — the picker UI lands in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the existing `.unusedAssetLock` FundingSelection case to the
`resumeIdentityWithAssetLock` FFI added in the previous commit. The
form now:

- Surfaces a list of tracked asset locks for the current wallet that
  are at status >= InstantSendLocked AND have no `PersistentIdentity`
  at the same `(walletId, identityIndex)`. The anti-join is post-fetch
  in Swift (SwiftData `#Predicate` can't express "no matching row in
  another model" cleanly).
- Renders each row inline (Section + tappable Button rows) — no
  navigation push, no `.fullScreenCover` / `.navigationDestination`
  modifiers that broke `Picker` hit-testing on iOS 26 in earlier iters.
- Pins the identity-registration index to the lock's slot when a row
  is picked; the `identityIndexSection` becomes read-only on this
  path so the user can confirm but not override.
- Drops the amount section when resuming (the lock's funded amount is
  fixed).
- Calls `walletManager.registrationCoordinator.startRegistration(...)`
  with a body that invokes `resumeIdentityWithAssetLock(outPointTxid:,
  outPointVout:, identityIndex:, identityPubkeys:, signer:)`. The
  existing 5-step progress UI binds to the same `PersistentAssetLock`
  row and reflects the resume (typically jumping from step 2 straight
  to step 5 since the lock is already final).

Plan doc status line flipped to iter 1+2+3+4+5 done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both pieces were delivered as part of iter 3/4: AssetLockStorageListView /
AssetLockStorageDetailView cover the storage-side drill-down, and
WalletMemoryDetailView.trackedAssetLocksSection covers the per-wallet
live FFI snapshot. No code changes required — only updating the plan
doc status line and adding citations to the existing implementation
sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts the resumable-asset-lock filter from CreateIdentityView into a
pure static `resumableLocks(in:usedIndices:walletId:)` generic over a
new `AssetLockResumeRow` protocol so unit tests can exercise the
business logic without spinning up a SwiftData ModelContainer. View
keeps its private `resumableAssetLocks(for:)` entry point as a
one-line wrapper that supplies the live `@Query` results.

Eight test cases cover the three pieces of logic that can silently
regress:
- walletId match (cross-wallet bleed)
- statusRaw >= 2 floor (Built/Broadcast rejected, ISLock/CLock
  accepted, forward-compatible for any future status >= 2)
- anti-join against the per-wallet used-slot set (including the
  Int32 -> UInt32 bitPattern bridge for the negative-index edge)

Drive-by fix: KeyManagerTests:178 was calling
`KeyFormatter.toWIF(_, isTestnet:)` but the SDK changed the signature
to `network:` in #3050 (Feb 2026); the test target couldn't build.
Updated the call so `xcodebuild test` works again.

All 8 new tests pass on iPhone 17 Pro sim (iOS 26.4.1).

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

When the user kills the app mid-registration after the asset lock
finalizes but before identity registration completes, the Pending
Registrations row (driven by the in-memory RegistrationCoordinator)
is wiped on restart. The orphan lock still lives in SwiftData but
the user has no surface signal to find it.

Adds a SwiftData-backed "Resumable Registrations" section to the
Identities tab that auto-surfaces every PersistentAssetLock at
statusRaw >= 2 with no matching PersistentIdentity at the same
(walletId, identityIndex) slot. Tapping Resume opens
CreateIdentityView pre-configured for the .unusedAssetLock funding
path with that specific lock pinned.

Re-uses the resumableLocks(...) pure filter extracted in f4ada01
and generalizes the per-wallet used-slot set across all wallets.

Two new unit tests pin the cross-wallet form of the anti-join.

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

After a crash mid-registration, the user previously only saw the
orphan lock once SPV delivered the InstantSendLock and the persister
flipped statusRaw from 1 -> 2. Until that moment (seconds-to-minutes
on testnet) the Resumable Registrations section was empty and the
user had no signal that anything was in flight.

Lowers the cross-wallet visibility floor from statusRaw >= 2 to
statusRaw >= 1 (Broadcast). The row's trailing affordance now
stages on status:
- 1 Broadcast: spinner + "Waiting for InstantSendLock..."
- 2 InstantSendLocked / 3 ChainLocked: Resume button

SwiftData @query is reactive, so when SPV delivers the IS lock and
the persister updates the row to (2) the trailing view re-renders
into the Resume button automatically.

The per-wallet picker in CreateIdentityView keeps its stricter
statusRaw >= 2 floor: a Resume button must be tappable, and a
Broadcast lock has no usable proof to fund Platform yet. The
asymmetry is pinned by a new regression test
(testPickerFloorStaysStricterThanSectionFloor) so a future "unify
the floors" refactor fails loudly.

Tests: 13/13 green (was 11/11), 2 new cases pin Broadcast
visibility and the two-floor invariant; the existing
testCrossWalletFilterEnforcesStatusFloor was updated to assert the
new floor (Built rejected, Broadcast accepted).

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

Path A — the ".unusedAssetLock" funding-source option in the Create
Identity sheet — is now redundant. The Identities-tab "Resumable
Registrations" section (Path B) surfaces every orphan asset lock
with a Resume button that pre-fills CreateIdentityView, so a
duplicate in-form picker was extra taps for the same outcome plus a
misleading "Funding source" framing (resuming an existing lock isn't
funding — it's resumption).

Changes:
- CreateIdentityView's funding-source picker drops the "Fund from
  unused Asset Lock" option; the footer points to the Identities tab.
- The sub-picker (assetLockPickerSection / assetLockRow), the
  per-wallet resumableAssetLocks(for:) view method, and the
  resumableLocks(in:usedIndices:walletId:) static helper are deleted
  — no remaining callers. -175 LoC.
- Body adds a "Resume mode" branch: when preselectedAssetLock is
  set (Path B), the form collapses to a read-only summary
  (resumeSummarySection) + submit button. Wallet + lock + slot are
  fixed by the tapped row, so the picker chrome would only be
  noise.
- IdentitiesContentView.crossWalletResumableLocks marked nonisolated
  — it's pure, so calling it from tests no longer trips the
  main-actor warning.
- Tests rewritten: the picker's >= 2 floor and its standalone
  invariants are gone with the picker. The cross-wallet helper
  retains 8 tests pinning the status floor (Built rejected,
  Broadcast accepted), the per-wallet anti-join, the cross-wallet
  scoping, the Int32 -> UInt32 bridge, and empty inputs.

8/8 tests green on iPhone 17 Pro sim (iOS 26.4.1); app build clean
(no new warnings).

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

During a normal in-session registration, the asset lock reaches
statusRaw >= 1 well before the persister writes a PersistentIdentity
row. The Resumable Registrations section's anti-join only excluded
identity-claimed slots, so the same lock was visible in BOTH "Pending
Registrations" (in-memory, coordinator-driven) and "Resumable
Registrations" (SwiftData-backed) for the ~tens of seconds between
asset-lock broadcast and identity-row write. Tapping Resume on the
second surface raced a duplicate FFI call against the original.

Fix in three layers:

1. RegistrationCoordinator.startRegistration now guards on the
   existing controller's phase. Re-entry on .preparingKeys / .inFlight
   / .completed returns the existing controller without disrupting it
   (was: reset to .preparingKeys and re-submit). The original guard
   inside IdentityRegistrationController.submit was bypassed because
   enterPreparingKeys() unconditionally overwrote the phase BEFORE
   submit's guard ran.

2. IdentityRegistrationController.submit hardens its phase guard to
   match: defensive single-flight at the controller layer (.inFlight
   and .completed rejected). .failed remains allowed so the coordinator's
   retry path stays alive.

3. ResumableRegistrationsList is extracted as a coordinator-observing
   subview (@ObservedObject) so the section's filter input is the
   UNION of identity-claimed slots and in-flight controller slots.
   New IdentityRegistrationController.Phase.isActive predicate
   centralizes the "this phase holds its slot" rule so the rule can't
   drift between the Pending and Resumable surfaces.

Also: tighten canSubmit's .unusedAssetLock gate to require the lock
row still exists (not just an id) — closes a small confusing-error
path when the row gets deleted between Path B init and submit.

Tests: 10/10 green. Two new cases — testInFlightSlotIsExcludedFromResumableSurface
pins the union semantics, testControllerPhaseIsActivePredicate
exhaustively pins the Phase.isActive predicate.

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

The AssetLockStatus discriminants (0/1/2/3 -> Built/Broadcast/
InstantSendLocked/ChainLocked) are protocol constants from the Rust
side. Until this commit four separate views each carried their own
copy of the case-block label mapping, and the >= 2 "is fundable"
threshold lived inline at every usage site. Consolidates into a
single PersistentAssetLock extension in the example app:

- statusLabel: protocol-discriminant -> human-readable string.
- canFundIdentity: statusRaw >= 2 (resume button gates on this).
- isVisibleAsResumable: statusRaw >= 1 (Resumable section surfaces this).
- shortOutPointDisplay: txid-prefix-plus-vout format used by every row.

The fundability/visibility predicates live on the AssetLockResumeRow
protocol so test fakes get them for free without an explicit
PersistentAssetLock instance.

Also:
- Delete unused `relativeDateString` and `assetLockStatusLabel` statics
  from CreateIdentityView (leftovers from the in-form picker removed in
  f466b7c).
- Remove now-duplicate `statusLabel(_:)` helpers from
  StorageRecordDetailViews, StorageModelListViews, and IdentitiesContentView.
- Remove the duplicated `shortOutPoint(_:)` helper that lived in both
  CreateIdentityView and IdentitiesContentView.
- Sync the iter 5 prose in CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md to
  reflect (a) the >= 1 visibility floor + active-controller anti-join
  and (b) the Identities-tab resume surface that replaced the original
  in-form picker.

Tests: 10/10 green, no behavior change.

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

Three small cleanups from the review pass, each a separate small win:

1. rs-platform-wallet-ffi: `Txid::from_slice` -> `Txid::from_byte_array`.
   `OutPointFFI::txid` is statically `[u8; 32]` so the slice variant's
   error branch was unreachable. The new call matches the convention
   used across `rs-drive-abci` and `rs-platform-wallet-ffi/src/persistence.rs`.

2. SwiftExampleAppTests: pin the outpoint hex round-trip
   (`PersistentAssetLock.encodeOutPoint` <-> `CreateIdentityView.parseOutPointHex`)
   plus a defensive test that malformed hex inputs return nil instead
   of producing all-zero bytes. Either side flipping endianness or
   silently producing zeros would address a different outpoint at
   the FFI layer and surface as an opaque Platform proof-verification
   failure. `parseOutPointHex` bumped from `private static` to `static`
   so @testable can reach it.

3. ManagedPlatformWallet.resumeIdentityWithAssetLock: add a comment
   pinning the `withExtendedLifetime` invariant. The Rust FFI uses
   `block_on_worker` synchronously inside the closure, so the
   resolver pair is alive for the whole call; a future refactor
   that introduces an unawaited Task inside the closure would drop
   the resolver mid-flight. Comment makes the invariant explicit.

Tests: 12/12 green (was 10/10), 2 new round-trip cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Unit tests pin the filter / predicate / round-trip invariants;
runtime composition (SwiftData @query reactivity, coordinator
@published mutations, view re-renders, SPV event routing) needs
manual testnet validation. Six scenarios cover the happy path, the
🔴 double-tap-during-in-flight guard, crash recovery from both
pre-IS-lock (status 1) and post-IS-lock (status 2/3) states, the
failed-retry flow, and the `.completed` retention window.

Also documents which upstream PR #3549 issues are tangential to our
UAT (#1 / #5: different code paths; #2: mitigated by the
persister's proofBytes capture at the IS-lock arrival moment; #3 /
#4: doc fixes only).

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

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs (1)

240-242: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fix broken test imports and stale doc links that reference removed symbols.

The test module imports dash_sdk_mnemonic_resolver_create and dash_sdk_mnemonic_resolver_destroy from crate::derive_and_persist_callbacks (lines 240-242), but these functions were removed from that module and are now in rs_sdk_ffi. This breaks compilation of the test suite. The make_resolver() helper at line 281 uses these broken imports directly.

Additionally, the module documentation contains four broken intra-doc links:

  • Lines 4, 30: [MnemonicResolverHandle](crate::MnemonicResolverHandle) — the type is imported from rs_sdk_ffi
  • Line 23: [MnemonicResolver](crate::MnemonicResolverHandle) — wrong crate path
  • Line 94: [crate::dash_sdk_mnemonic_resolver_create] — function is in rs_sdk_ffi, not crate
Proposed fixes
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::derive_and_persist_callbacks::{
-        dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy,
-    };
+    use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy};
     use std::ffi::CString;

Update doc link paths from crate:: to rs_sdk_ffi:: for MnemonicResolverHandle (lines 4, 30) and dash_sdk_mnemonic_resolver_create (line 94). Fix line 23 link text to match the target type.

🤖 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-ffi/src/sign_with_mnemonic_resolver.rs` around
lines 240 - 242, The test imports and intra-doc links reference removed symbols
in crate::derive_and_persist_callbacks and crate:: paths; change the use
declarations to import dash_sdk_mnemonic_resolver_create and
dash_sdk_mnemonic_resolver_destroy from rs_sdk_ffi (so make_resolver() uses the
correct functions), and update the doc links to point to
rs_sdk_ffi::MnemonicResolverHandle (both occurrences), correct the link text for
MnemonicResolver to the actual type name, and change the
dash_sdk_mnemonic_resolver_create doc link to
rs_sdk_ffi::dash_sdk_mnemonic_resolver_create so all references resolve to the
relocated symbols.
♻️ Duplicate comments (1)
packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs (1)

303-337: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Mnemonic-derived private scalar still leaks into SecretKey memory after signing.

SecretKey::from_slice(secret_bytes.as_ref()) copies the 32 scalar bytes into a fresh SecretKey([u8; 32]). Dropping secret_bytes (Zeroizing) only wipes the original buffer; the copy owned by secret is left intact (no Drop zeroizes it), so the comment on lines 319-324 is not correct and the module-level claim that "no private key bytes survive past the trait-method boundary" is violated for both sign_ecdsa and public_key. secp256k1::SecretKey exposes non_secure_erase() specifically for this — call it before returning.

🔒 Proposed fix
     async fn sign_ecdsa(
         &self,
         path: &DerivationPath,
         sighash: [u8; 32],
     ) -> Result<(secp256k1::ecdsa::Signature, secp256k1::PublicKey), Self::Error> {
         let secret_bytes = self.derive_priv(path)?;
         let secp = Secp256k1::new();
-        let secret = secp256k1::SecretKey::from_slice(secret_bytes.as_ref())
+        let mut secret = secp256k1::SecretKey::from_slice(secret_bytes.as_ref())
             .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?;
         let msg = secp256k1::Message::from_digest(sighash);
         let signature = secp.sign_ecdsa(&msg, &secret);
         let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret);
-        // `secp256k1::SecretKey` is `Copy` (a thin wrapper over a
-        // 32-byte buffer) and doesn't itself zero on drop — but the
-        // backing buffer here came from `secret_bytes`
-        // (a `Zeroizing<[u8; 32]>`), which IS wiped when it falls
-        // out of scope below. The `secret` binding is forgotten by
-        // letting it go out of scope; no explicit `drop` needed.
-        let _ = secret;
+        // `secp256k1::SecretKey` owns its own [u8; 32] copy of the
+        // scalar; `secret_bytes` zeroization does not reach it. Wipe
+        // the copy explicitly before returning.
+        secret.non_secure_erase();
         Ok((signature, pubkey))
     }
 
     async fn public_key(&self, path: &DerivationPath) -> Result<secp256k1::PublicKey, Self::Error> {
         let secret_bytes = self.derive_priv(path)?;
         let secp = Secp256k1::new();
-        let secret = secp256k1::SecretKey::from_slice(secret_bytes.as_ref())
+        let mut secret = secp256k1::SecretKey::from_slice(secret_bytes.as_ref())
             .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?;
         let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret);
-        let _ = secret;
+        secret.non_secure_erase();
         Ok(pubkey)
     }

Confirm the non_secure_erase() API is available on the SecretKey re-exported by key_wallet::dashcore::secp256k1 in the version pinned by this workspace:

#!/bin/bash
# Find the secp256k1 version key-wallet/dashcore pull in, and confirm
# SecretKey::non_secure_erase exists in that version's source.
fd -t f Cargo.lock | head -n 5
rg -nP '^name = "secp256k1"' -A2 Cargo.lock | head -n 60
rg -nP '\bnon_secure_erase\b' -C2
🤖 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-sdk-ffi/src/mnemonic_resolver_core_signer.rs` around lines 303 -
337, The secret scalar copy created by SecretKey::from_slice in the functions
sign_ecdsa and public_key remains in memory because secp256k1::SecretKey does
not zero on drop; call SecretKey::non_secure_erase() on the secret binding (the
variable named secret) just before returning from both sign_ecdsa and public_key
(i.e., after creating pubkey/signature and before Ok(...)) so the copied scalar
is wiped; keep the existing error mapping from derive_priv and from_slice
unchanged and ensure secret_bytes (Zeroizing) still drops as before. Also verify
that SecretKey::non_secure_erase exists in the re-exported secp256k1 version
before committing.
🧹 Nitpick comments (1)
packages/rs-sdk/src/platform/transition/top_up_identity.rs (1)

59-69: 💤 Low value

Avoid silently relying on PutSettings: Copy when threading settings twice.

Both impls do settings.and_then(|s| s.user_fee_increase).unwrap_or_default() at line 59 and line 88, then pass settings again to broadcast_and_wait at line 69 and line 100. This only compiles because Option<PutSettings> is currently Copy. The moment any non-Copy field is added to PutSettings (or if RequestSettings ever loses Copy), both call sites break in a non-obvious way. Borrowing via as_ref() makes the intent explicit and decouples this code from PutSettings's Copy derivation.

♻️ Proposed defensive refactor
     async fn top_up_identity_with_private_key(
         &self,
         sdk: &Sdk,
         asset_lock_proof: AssetLockProof,
         asset_lock_proof_private_key: &PrivateKey,
         settings: Option<PutSettings>,
     ) -> Result<u64, Error> {
-        let user_fee_increase = settings.and_then(|s| s.user_fee_increase).unwrap_or_default();
+        let user_fee_increase = settings
+            .as_ref()
+            .and_then(|s| s.user_fee_increase)
+            .unwrap_or_default();
     async fn top_up_identity_with_signer<AS>(
         ...
     ) -> Result<u64, Error>
     where
         AS: dpp::key_wallet::signer::Signer + Send + Sync,
     {
-        let user_fee_increase = settings.and_then(|s| s.user_fee_increase).unwrap_or_default();
+        let user_fee_increase = settings
+            .as_ref()
+            .and_then(|s| s.user_fee_increase)
+            .unwrap_or_default();

Also applies to: 88-100

🤖 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-sdk/src/platform/transition/top_up_identity.rs` around lines 59 -
69, The code currently relies on Option<PutSettings> being Copy by calling
settings.and_then(...) twice and passing settings by value to
broadcast_and_wait; change the extraction to borrow the option when reading
user_fee_increase (use settings.as_ref().and_then(|s|
s.user_fee_increase).unwrap_or_default()) and then pass an owned Option to
broadcast_and_wait by cloning when needed (e.g., settings.clone() or
settings.as_ref().cloned()) so that
IdentityTopUpTransition::try_from_identity_with_private_key and
broadcast_and_wait calls no longer depend on PutSettings being Copy; apply the
same fix to both occurrences around the user_fee_increase extraction and the
broadcast_and_wait call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet-ffi/src/lib.rs`:
- Line 13: The module asset_lock_persistence is declared but not re-exported
like the other sibling modules; either add a re-export (pub use
asset_lock_persistence::*;) to the existing re-export block that contains the
other pub use ...::* entries so the crate API remains consistent, or if you
intentionally want to keep its symbols namespaced, add a brief comment next to
the pub mod asset_lock_persistence declaration explaining that omission (e.g.,
“intentionally not re-exported to preserve namespace”) so reviewers understand
it is deliberate.

---

Outside diff comments:
In `@packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`:
- Around line 240-242: The test imports and intra-doc links reference removed
symbols in crate::derive_and_persist_callbacks and crate:: paths; change the use
declarations to import dash_sdk_mnemonic_resolver_create and
dash_sdk_mnemonic_resolver_destroy from rs_sdk_ffi (so make_resolver() uses the
correct functions), and update the doc links to point to
rs_sdk_ffi::MnemonicResolverHandle (both occurrences), correct the link text for
MnemonicResolver to the actual type name, and change the
dash_sdk_mnemonic_resolver_create doc link to
rs_sdk_ffi::dash_sdk_mnemonic_resolver_create so all references resolve to the
relocated symbols.

---

Duplicate comments:
In `@packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`:
- Around line 303-337: The secret scalar copy created by SecretKey::from_slice
in the functions sign_ecdsa and public_key remains in memory because
secp256k1::SecretKey does not zero on drop; call SecretKey::non_secure_erase()
on the secret binding (the variable named secret) just before returning from
both sign_ecdsa and public_key (i.e., after creating pubkey/signature and before
Ok(...)) so the copied scalar is wiped; keep the existing error mapping from
derive_priv and from_slice unchanged and ensure secret_bytes (Zeroizing) still
drops as before. Also verify that SecretKey::non_secure_erase exists in the
re-exported secp256k1 version before committing.

---

Nitpick comments:
In `@packages/rs-sdk/src/platform/transition/top_up_identity.rs`:
- Around line 59-69: The code currently relies on Option<PutSettings> being Copy
by calling settings.and_then(...) twice and passing settings by value to
broadcast_and_wait; change the extraction to borrow the option when reading
user_fee_increase (use settings.as_ref().and_then(|s|
s.user_fee_increase).unwrap_or_default()) and then pass an owned Option to
broadcast_and_wait by cloning when needed (e.g., settings.clone() or
settings.as_ref().cloned()) so that
IdentityTopUpTransition::try_from_identity_with_private_key and
broadcast_and_wait calls no longer depend on PutSettings being Copy; apply the
same fix to both occurrences around the user_fee_increase extraction and the
broadcast_and_wait call.
🪄 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: f995f992-27c3-43c9-ae8d-20ce0d575fd5

📥 Commits

Reviewing files that changed from the base of the PR and between 65e417d and e01fc9d.

📒 Files selected for processing (18)
  • packages/rs-platform-wallet-ffi/src/asset_lock/build.rs
  • packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
  • packages/rs-platform-wallet-ffi/src/derive_and_persist_callbacks.rs
  • packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs
  • packages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rs
  • packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet-ffi/src/shielded_sync.rs
  • packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/registration.rs
  • packages/rs-sdk-ffi/src/identity/topup.rs
  • packages/rs-sdk-ffi/src/lib.rs
  • packages/rs-sdk-ffi/src/mnemonic_resolver.rs
  • packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs
  • packages/rs-sdk/src/platform/transition/broadcast_identity.rs
  • packages/rs-sdk/src/platform/transition/put_identity.rs
  • packages/rs-sdk/src/platform/transition/top_up_identity.rs
  • packages/wasm-sdk/src/state_transitions/identity.rs
✅ Files skipped from review due to trivial changes (3)
  • packages/rs-platform-wallet-ffi/src/shielded_sync.rs
  • packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs
  • packages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/wasm-sdk/src/state_transitions/identity.rs
  • packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
  • packages/rs-sdk/src/platform/transition/put_identity.rs
  • packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs
  • packages/rs-platform-wallet-ffi/src/asset_lock/build.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/registration.rs

Comment thread packages/rs-platform-wallet-ffi/src/lib.rs
…after signer move

Both deps existed solely for `MnemonicResolverCoreSigner`'s
`#[async_trait] impl Signer for …` and its
`#[derive(thiserror::Error)] enum MnemonicResolverSignerError`.
The signer moved to `rs-sdk-ffi` in `e5aa1a40dc`; zero remaining
usages in `rs-platform-wallet-ffi`'s source tree. Drop both.

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

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

I verified the cited paths against SHA 67e053296e5b311fd95fe3cbb4db5e819d7bb802. Four findings are real on this head: three blockers in the Swift/Rust asset-lock and signer paths, plus one restore limitation for nonzero BIP44 accounts. The fee-bump retry path also trusts an unverified server rejection before increasing user_fee_increase, so this PR still needs changes before merge.

Reviewed commit: 67e0532

🔴 3 blocking | 🟡 1 suggestion(s)

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

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [BLOCKING] lines 360-425: `catchUpStuckAssetLocks` can destroy handles that detached catch-up tasks still need
  `catchUpStuckAssetLocks()` releases every previously retained `ManagedAssetLockManager` at line 365, but the detached work only keeps the raw `handle` captured at line 397. `ManagedAssetLockManager.deinit` calls `asset_lock_manager_destroy()`, and the Rust FFI resolves `asset_lock_manager_catch_up_blocking()` through `HandleStorage::with_item(...)`, which keeps the handle-table read lock for the entire synchronous `runtime().block_on(manager.resume_asset_lock(...))` call. A second catch-up pass can therefore block the main actor while `destroy()` waits for that read lock, then remove the handle before later tasks from the first batch run, causing them to fail with `ErrorInvalidHandle`. The comments claim the prior batch has already completed or timed out, but the method never tracks or enforces that invariant.

In `packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs`:
- [BLOCKING] lines 60-79: `consume_asset_lock()` computes the consumed snapshot but never persists it
  The method documentation says successful consumption must remove the lock from the in-memory map and upsert the persisted row with `status = Consumed`, but the implementation only builds an `AssetLockChangeSet` and returns it. Unlike the other asset-lock mutation paths, it never calls `queue_asset_lock_changeset()`, and both registration/top-up callers ignore the successful return value and only log `Err`. The result is that the new `Consumed` state exists only in process memory: after restart, Swift reloads the stale pre-consumption row and Rust treats the lock as actionable again.

In `packages/rs-platform-wallet/src/wallet/identity/network/registration.rs`:
- [BLOCKING] lines 137-186: The ChainLock-height retry loop trusts unverified 10506 responses before increasing user fees
  `submit_with_cl_height_retry()` treats any `InvalidAssetLockProofCoreChainHeightError` extracted by `as_asset_lock_proof_cl_height_too_low()` as authoritative and immediately bumps `PutSettings.user_fee_increase` before re-signing. That signal is not locally verified. `wait_for_response()` in `rs-sdk` accepts `wait_for_state_transition_result` error payloads directly from the DAPI server, deserializes the embedded consensus error, and returns it without any proof or independent check. A malicious or compromised endpoint can therefore fabricate a 10506 rejection once, force the wallet onto a higher-fee retry, and let a later attempt succeed with inflated fees. The retry budget is bounded, but the trust boundary is still wrong because an untrusted network peer is allowed to trigger fee escalation.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] lines 2859-2964: Asset-lock restore still hardcodes `account_index = 0` even though Rust uses that field to route recovery
  Both restore builders write `account_index = 0`: the tracked-lock snapshot at lines 2859-2864 and the unresolved funding-transaction buffer at lines 2955-2964. On the Rust side that field is load-bearing. `build_unused_asset_locks()` stores tracked locks under `spec.account_index`, `recover_asset_lock_blocking()` looks up the funding transaction in `standard_bip44_accounts.get(&account_index)`, and `restore_unresolved_asset_lock_tx_records()` drops a row if the matching account does not exist. The public Swift asset-lock APIs already accept arbitrary `accountIndex`, so a lock funded from a nonzero BIP44 account will not recover correctly after restart.

Comment on lines +360 to +425
// Release the previous batch's manager wrappers now that we
// know their tasks have either completed or timed out (any
// task still running past the 300s timeout is misbehaving
// and the bound is on the Rust side anyway). Without this
// the array would grow unboundedly across foregroundings.
retainedAssetLockManagers.removeAll(keepingCapacity: true)
for wallet in wallets {
let walletId = wallet.walletId
let locks = persistenceHandler.loadCachedAssetLocks(walletId: walletId)
let pending = locks.filter { $0.statusRaw < 2 }
if pending.isEmpty { continue }

// Snapshot the asset-lock manager handle ON the main
// actor (where `wallet` lives). The `ManagedAssetLockManager`
// class isn't `Sendable` (its `deinit` calls
// `asset_lock_manager_destroy`), so the detached Task
// captures the bare `Handle` value (an `Int64`) and
// calls the FFI directly. Lifetime: stash the manager
// wrapper on `retainedAssetLockManagers` so its `deinit`
// (which would invalidate the handle) waits for the
// tasks to complete; the wrapper is dropped on the next
// call to `catchUpStuckAssetLocks` or on manager
// shutdown, whichever comes first.
let assetLockManager: ManagedAssetLockManager
do {
assetLockManager = try wallet.assetLockManager()
} catch {
self.lastError = error
continue
}
// The previous batch's manager wrappers (if any) are
// dropped here — their tasks have either completed
// (success path persisted via the changeset) or hit the
// 300s timeout long ago. The replacement keeps the
// current batch's handles alive for the duration of the
// new tasks.
retainedAssetLockManagers.append(assetLockManager)
let handle = assetLockManager.handle

// Cap concurrency to avoid saturating iOS's cooperative
// thread pool. Each catch-up `block_on` parks a worker
// thread for up to 300s; N stuck locks at launch (after a
// multi-identity registration interrupted by an app kill)
// would otherwise spawn N parallel parked threads,
// starving every other `Task` in the app (UI updates,
// SwiftData writes, network calls).
//
// `MAX_CONCURRENT_CATCH_UPS = 4` is conservative for a
// 4-8 worker pool typical on iPhones. The real bottleneck
// is per-lock SPV chainlock arrival, not the catch-up
// throughput — running 4 in parallel vs 50 changes nothing
// about how fast each individual lock resolves.
let outpoints: [(txid: Data, vout: UInt32)] = pending.compactMap {
PlatformWalletManager.decodeOutPointForCatchUp($0.outPointHex)
}
guard !outpoints.isEmpty else { continue }
Task.detached(priority: .background) {
await withTaskGroup(of: Void.self) { group in
let maxConcurrent = 4
var nextIndex = 0
// Seed the group with up to `maxConcurrent` tasks.
while nextIndex < outpoints.count && nextIndex < maxConcurrent {
let (txid, vout) = outpoints[nextIndex]
group.addTask {
Self.runCatchUp(handle: handle, txid: txid, vout: vout)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: catchUpStuckAssetLocks can destroy handles that detached catch-up tasks still need

catchUpStuckAssetLocks() releases every previously retained ManagedAssetLockManager at line 365, but the detached work only keeps the raw handle captured at line 397. ManagedAssetLockManager.deinit calls asset_lock_manager_destroy(), and the Rust FFI resolves asset_lock_manager_catch_up_blocking() through HandleStorage::with_item(...), which keeps the handle-table read lock for the entire synchronous runtime().block_on(manager.resume_asset_lock(...)) call. A second catch-up pass can therefore block the main actor while destroy() waits for that read lock, then remove the handle before later tasks from the first batch run, causing them to fail with ErrorInvalidHandle. The comments claim the prior batch has already completed or timed out, but the method never tracks or enforces that invariant.

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/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [BLOCKING] lines 360-425: `catchUpStuckAssetLocks` can destroy handles that detached catch-up tasks still need
  `catchUpStuckAssetLocks()` releases every previously retained `ManagedAssetLockManager` at line 365, but the detached work only keeps the raw `handle` captured at line 397. `ManagedAssetLockManager.deinit` calls `asset_lock_manager_destroy()`, and the Rust FFI resolves `asset_lock_manager_catch_up_blocking()` through `HandleStorage::with_item(...)`, which keeps the handle-table read lock for the entire synchronous `runtime().block_on(manager.resume_asset_lock(...))` call. A second catch-up pass can therefore block the main actor while `destroy()` waits for that read lock, then remove the handle before later tasks from the first batch run, causing them to fail with `ErrorInvalidHandle`. The comments claim the prior batch has already completed or timed out, but the method never tracks or enforces that invariant.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 3abd581a01. ManagedAssetLockManager is now final + @unchecked Sendable (immutable let handle, deinit runs exactly once via ARC) and each catch-up task captures the wrapper directly instead of the raw Handle. The wrapper's deinit (which destroys the FFI handle) cannot fire until the task drops its retain. The retainedAssetLockManagers workaround property is gone. Concurrent invocations of catchUpStuckAssetLocks no longer race on each other's handles.

Comment on lines +60 to +79
pub(crate) async fn consume_asset_lock(
&self,
out_point: &OutPoint,
) -> Result<AssetLockChangeSet, PlatformWalletError> {
let mut wm = self.wallet_manager.write().await;
let info = wm
.get_wallet_info_mut(&self.wallet_id)
.ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?;
let mut cs = AssetLockChangeSet::default();
if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) {
if info.tracked_asset_locks.remove(out_point).is_some() {
cs.removed.insert(*out_point);
}
if let Some(mut entry) = info.tracked_asset_locks.remove(out_point) {
entry.status = AssetLockStatus::Consumed;
entry.proof = None; // one-shot — never relevant after consumption
cs.asset_locks.insert(*out_point, (&entry).into());
} else {
tracing::warn!(
outpoint = %out_point,
"consume_asset_lock: outpoint not tracked — already consumed or never present"
);
}
cs
Ok(cs)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: consume_asset_lock() computes the consumed snapshot but never persists it

The method documentation says successful consumption must remove the lock from the in-memory map and upsert the persisted row with status = Consumed, but the implementation only builds an AssetLockChangeSet and returns it. Unlike the other asset-lock mutation paths, it never calls queue_asset_lock_changeset(), and both registration/top-up callers ignore the successful return value and only log Err. The result is that the new Consumed state exists only in process memory: after restart, Swift reloads the stale pre-consumption row and Rust treats the lock as actionable again.

💡 Suggested change
Suggested change
pub(crate) async fn consume_asset_lock(
&self,
out_point: &OutPoint,
) -> Result<AssetLockChangeSet, PlatformWalletError> {
let mut wm = self.wallet_manager.write().await;
let info = wm
.get_wallet_info_mut(&self.wallet_id)
.ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?;
let mut cs = AssetLockChangeSet::default();
if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) {
if info.tracked_asset_locks.remove(out_point).is_some() {
cs.removed.insert(*out_point);
}
if let Some(mut entry) = info.tracked_asset_locks.remove(out_point) {
entry.status = AssetLockStatus::Consumed;
entry.proof = None; // one-shot — never relevant after consumption
cs.asset_locks.insert(*out_point, (&entry).into());
} else {
tracing::warn!(
outpoint = %out_point,
"consume_asset_lock: outpoint not tracked — already consumed or never present"
);
}
cs
Ok(cs)
pub(crate) async fn consume_asset_lock(
&self,
out_point: &OutPoint,
) -> Result<AssetLockChangeSet, PlatformWalletError> {
let cs = {
let mut wm = self.wallet_manager.write().await;
let info = wm
.get_wallet_info_mut(&self.wallet_id)
.ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?;
let mut cs = AssetLockChangeSet::default();
if let Some(mut entry) = info.tracked_asset_locks.remove(out_point) {
entry.status = AssetLockStatus::Consumed;
entry.proof = None;
cs.asset_locks.insert(*out_point, (&entry).into());
} else {
tracing::warn!(
outpoint = %out_point,
"consume_asset_lock: outpoint not tracked — already consumed or never present"
);
}
cs
};
self.queue_asset_lock_changeset(cs.clone());
Ok(cs)
}

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/asset_lock/sync/tracking.rs`:
- [BLOCKING] lines 60-79: `consume_asset_lock()` computes the consumed snapshot but never persists it
  The method documentation says successful consumption must remove the lock from the in-memory map and upsert the persisted row with `status = Consumed`, but the implementation only builds an `AssetLockChangeSet` and returns it. Unlike the other asset-lock mutation paths, it never calls `queue_asset_lock_changeset()`, and both registration/top-up callers ignore the successful return value and only log `Err`. The result is that the new `Consumed` state exists only in process memory: after restart, Swift reloads the stale pre-consumption row and Rust treats the lock as actionable again.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8a8545c945. consume_asset_lock now queues the Consumed changeset internally (write lock released before queueing). Doc-comment explains why this method queues internally while sibling mutators defer to in-module callers (queue_asset_lock_changeset is pub(super)-scoped; registration lives outside the asset_lock module). Test gap surfaced in the commit message — full AssetLockManager scaffolding doesn't exist yet; end-to-end registration flow exercises this path.

Comment on lines +137 to +186
async fn submit_with_cl_height_retry<F, Fut, R>(
mut settings: Option<PutSettings>,
submit: F,
) -> Result<R, dash_sdk::Error>
where
F: Fn(Option<PutSettings>) -> Fut,
Fut: std::future::Future<Output = Result<R, dash_sdk::Error>>,
{
let started = tokio::time::Instant::now();
let deadline = started + CL_HEIGHT_RETRY_BUDGET;
let mut attempt: u32 = 0;
loop {
attempt += 1;
match submit(settings).await {
Ok(r) => return Ok(r),
Err(e) => {
let Some(detail) = as_asset_lock_proof_cl_height_too_low(&e) else {
return Err(e);
};
let elapsed = started.elapsed();
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
tracing::error!(
"Platform rejected ChainLock proof: CL height too low \
(proof claimed height={}, Platform tip={}, attempt {}, \
elapsed {}s) — retry budget of {}s exhausted; surfacing \
error. Platform's reported tip is stuck — likely a lagging \
or misbehaving DAPI node.",
detail.proof_core_chain_locked_height(),
detail.current_core_chain_locked_height(),
attempt,
elapsed.as_secs(),
CL_HEIGHT_RETRY_BUDGET.as_secs(),
);
return Err(e);
}
let sleep_for = remaining.min(CL_HEIGHT_RETRY_DELAY);
tracing::warn!(
"Platform rejected ChainLock proof: CL height too low \
(proof claimed height={}, Platform tip={}, attempt {}, \
elapsed {}s); bumping user_fee_increase and waiting {}s \
before retry",
detail.proof_core_chain_locked_height(),
detail.current_core_chain_locked_height(),
attempt,
elapsed.as_secs(),
sleep_for.as_secs(),
);
settings = Some(bump_user_fee_increase(settings.unwrap_or_default()));
tokio::time::sleep(sleep_for).await;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: The ChainLock-height retry loop trusts unverified 10506 responses before increasing user fees

submit_with_cl_height_retry() treats any InvalidAssetLockProofCoreChainHeightError extracted by as_asset_lock_proof_cl_height_too_low() as authoritative and immediately bumps PutSettings.user_fee_increase before re-signing. That signal is not locally verified. wait_for_response() in rs-sdk accepts wait_for_state_transition_result error payloads directly from the DAPI server, deserializes the embedded consensus error, and returns it without any proof or independent check. A malicious or compromised endpoint can therefore fabricate a 10506 rejection once, force the wallet onto a higher-fee retry, and let a later attempt succeed with inflated fees. The retry budget is bounded, but the trust boundary is still wrong because an untrusted network peer is allowed to trigger fee escalation.

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/identity/network/registration.rs`:
- [BLOCKING] lines 137-186: The ChainLock-height retry loop trusts unverified 10506 responses before increasing user fees
  `submit_with_cl_height_retry()` treats any `InvalidAssetLockProofCoreChainHeightError` extracted by `as_asset_lock_proof_cl_height_too_low()` as authoritative and immediately bumps `PutSettings.user_fee_increase` before re-signing. That signal is not locally verified. `wait_for_response()` in `rs-sdk` accepts `wait_for_state_transition_result` error payloads directly from the DAPI server, deserializes the embedded consensus error, and returns it without any proof or independent check. A malicious or compromised endpoint can therefore fabricate a 10506 rejection once, force the wallet onto a higher-fee retry, and let a later attempt succeed with inflated fees. The retry budget is bounded, but the trust boundary is still wrong because an untrusted network peer is allowed to trigger fee escalation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Accepting as a known trust boundary, not a blocker. The retry loop treats the 10506 as authoritative because a node that fabricates rejections is a malicious DAPI node — the right defense is DAPI-client-level (don't submit to it again), not retry-loop-level. Bounded retry budget caps grief impact (~14 bumps max in 210s), and the attack is unprofitable because identity fees flow to Platform validators, not DAPI nodes. Verifiable consensus errors (quorum signature or validator attestation) would be the principled fix — tracked as future work; doing it in-place here would either re-implement DAPI client trust or require an SDK API change neither of which belong in this PR.

Doc comment sharpened in 3b5d841503 to spell this out for future readers.

Comment on lines +2859 to +2964
// `accountIndex` isn't stored on the SwiftData model
// (Rust derives it from the funding path), so default to
// 0. The Rust load path doesn't read this field for
// anything load-bearing — it's a breadcrumb for the
// FFI persist path going forward.
entry.account_index = 0
entry.funding_type = UInt8(clamping: record.fundingTypeRaw)
entry.identity_index = UInt32(bitPattern: record.identityIndexRaw)
entry.amount_duffs = UInt64(bitPattern: record.amountDuffs)
entry.status = UInt8(clamping: record.statusRaw)
entry.proof_bytes = proofPtr
entry.proof_bytes_len = UInt(proofLen)
buf[written] = entry
written += 1
}
if written == 0 {
buf.deallocate()
return (nil, 0)
}
allocation.assetLockArrays.append((buf, written))
return (buf, written)
}

/// Build the per-wallet `UnresolvedAssetLockTxRecordFFI` array
/// for the load callback. One entry per `PersistentAssetLock` row
/// at `statusRaw < 2` (Built / Broadcast) whose funding tx has a
/// matching `PersistentTransaction` row. Returns `(nil, 0)` when
/// there are no eligible rows.
///
/// The Rust side reads each row and re-inserts the decoded
/// transaction into the matching BIP44 account's in-memory
/// `transactions()` map so the next chain-lock event can promote
/// it via `apply_chain_lock`. See
/// `restore_unresolved_asset_lock_tx_records` for the Rust-side
/// contract.
///
/// Rows with no matching `PersistentTransaction` (e.g. an
/// orphaned asset-lock row whose tx never made it into the
/// transaction table) are skipped — the Rust side has no way to
/// reconstruct the funding tx without its consensus bytes, so
/// projecting an empty row would just bloat the FFI surface.
private func buildUnresolvedAssetLockTxRecordBuffer(
walletId: Data,
allocation: LoadAllocation
) -> (UnsafeMutablePointer<UnresolvedAssetLockTxRecordFFI>?, Int) {
// Filter to `statusRaw < 2` so already-IS-locked /
// already-chain-locked rows don't end up in the array —
// those locks have their proof bytes persisted on the
// `PersistentAssetLock` row and the Rust side doesn't need
// the funding tx in the in-memory map to use them.
let descriptor = FetchDescriptor<PersistentAssetLock>(
predicate: #Predicate { entry in
entry.walletId == walletId && entry.statusRaw < 2
}
)
guard let locks = try? backgroundContext.fetch(descriptor), !locks.isEmpty else {
return (nil, 0)
}

// Pre-query the matching `PersistentTransaction` rows.
// `PersistentAssetLock.outPointHex` carries the txid in
// display order; `PersistentTransaction.txid` is wire order
// — the same flip `decodeOutPointHex` already performs.
let buf = UnsafeMutablePointer<UnresolvedAssetLockTxRecordFFI>.allocate(
capacity: locks.count
)
var written = 0
for lock in locks {
guard let outpoint = decodeOutPointHex(lock.outPointHex) else {
continue
}
let txid = outpoint.prefix(32)
let txidData = Data(txid)
let txDescriptor = FetchDescriptor<PersistentTransaction>(
predicate: #Predicate { $0.txid == txidData }
)
guard let txRow = try? backgroundContext.fetch(txDescriptor).first else {
// No matching tx — Rust can't reconstruct the
// funding body without its consensus bytes. Skip.
continue
}
let txBytes = txRow.transactionData
guard !txBytes.isEmpty else {
// A stub row whose real upsert never arrived;
// skip rather than emit an undecodable buffer.
continue
}

// Allocate the consensus-bytes buffer. Lifetime is
// owned by `allocation.scalarBuffers`, freed by
// `LoadAllocation.release()` after Rust returns.
let txBuf = UnsafeMutablePointer<UInt8>.allocate(capacity: txBytes.count)
txBytes.copyBytes(to: txBuf, count: txBytes.count)
allocation.scalarBuffers.append((txBuf, txBytes.count))

var entry = UnresolvedAssetLockTxRecordFFI()
// `accountIndex` isn't stored on `PersistentAssetLock`
// (the existing `buildAssetLockRestoreBuffer` makes the
// same assumption). In production iOS the funding
// account is always BIP44 index 0 — the same default
// used by `recover_asset_lock_blocking`. The Rust side
// looks up `standard_bip44_accounts.get(&account_index)`
// so a wrong value here would silently drop the
// restore; documented as a known limit until per-row
// `accountIndex` lands on the SwiftData model.
entry.account_index = 0
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Asset-lock restore still hardcodes account_index = 0 even though Rust uses that field to route recovery

Both restore builders write account_index = 0: the tracked-lock snapshot at lines 2859-2864 and the unresolved funding-transaction buffer at lines 2955-2964. On the Rust side that field is load-bearing. build_unused_asset_locks() stores tracked locks under spec.account_index, recover_asset_lock_blocking() looks up the funding transaction in standard_bip44_accounts.get(&account_index), and restore_unresolved_asset_lock_tx_records() drops a row if the matching account does not exist. The public Swift asset-lock APIs already accept arbitrary accountIndex, so a lock funded from a nonzero BIP44 account will not recover correctly after restart.

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/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] lines 2859-2964: Asset-lock restore still hardcodes `account_index = 0` even though Rust uses that field to route recovery
  Both restore builders write `account_index = 0`: the tracked-lock snapshot at lines 2859-2864 and the unresolved funding-transaction buffer at lines 2955-2964. On the Rust side that field is load-bearing. `build_unused_asset_locks()` stores tracked locks under `spec.account_index`, `recover_asset_lock_blocking()` looks up the funding transaction in `standard_bip44_accounts.get(&account_index)`, and `restore_unresolved_asset_lock_tx_records()` drops a row if the matching account does not exist. The public Swift asset-lock APIs already accept arbitrary `accountIndex`, so a lock funded from a nonzero BIP44 account will not recover correctly after restart.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 81acae6726. Added accountIndexRaw: Int32 = 0 column to PersistentAssetLock and plumbed account_index through both the inbound FFI callback (onPersistAssetLocks) and the load-time snapshot. Both restore-marshalling sites (buildAssetLockRestoreBuffer for asset-lock entries, buildUnresolvedAssetLockTxRecords for tx records) now use UInt32(bitPattern: record.accountIndexRaw) instead of hardcoded 0. SwiftData lightweight migration keeps pre-feature rows safe (default 0 = the bytes the hardcoded restore was using = correct for the BIP44-account-0 common case); any subsequent upsert on a pre-feature row populates the column with the real value from AssetLockEntryFFI.account_index.

…string match for isAssetLock/isAssetUnlock

`PersistentTransaction.isAssetLock` / `isAssetUnlock` were matching
`transactionType == "AssetLock"` against the Rust side's
`format!("{:?}", tr.transaction_type)` output — a `Debug` derive
whose stability is not contractual. A future variant rename on the
Rust side would silently break every UI badge that branches on
this. The "Asset Lock Transaction" fallback in the same predicate
was dead code: nothing in `tx_record_to_ffi` ever emits the longer
form.

Add a typed `u8` discriminant alongside the human-readable string:

  Rust  : `TransactionRecordFFI.transaction_type_kind: u8`,
          populated by an exhaustive `transaction_type_to_u8`
          mapper (no wildcard arm — a new `TransactionType` variant
          becomes a Rust compile error rather than a silent FFI
          regression).
  Swift : `PersistentTransaction.transactionTypeKind: UInt8`
          (default `0xFF` sentinel for pre-feature rows), with a
          parallel `TransactionTypeKind` enum mirroring the Rust
          discriminants. `isAssetLock` / `isAssetUnlock` now match
          on the byte; the Debug string stays as
          `transactionType` for human display but is no longer a
          discriminant.

Migration: rows that predate this commit carry `0xFF` until SPV's
next upsert round touches them, at which point the real
discriminant is written. The accessors treat `0xFF` as "unknown"
and return false — same outcome as if the row never matched the
Debug string, no UI regression on the asset-lock-resume flow this
PR cares about.

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

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

I verified the flagged areas against commit 710de74adc5031e67e35ac4cff55b40ed9f592f2. Three findings are blocking and reproducible from the current source: the Swift catch-up path can invalidate live FFI handles, the new chain-lock metadata persistence still misses a restart case the proof fallback depends on, and successful registration/top-up never persists the terminal Consumed asset-lock state. Two additional FFI mismatches are real but lower severity.

Reviewed commit: 710de74

🔴 3 blocking | 🟡 2 suggestion(s)

1 additional finding

🟡 suggestion: Funded registration FFI path discards `IdentityPubkeyFFI` contract bounds

packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs (lines 86-95)

Swift populates contract_bounds_kind, contract_bounds_id, and contract_bounds_document_type when it builds IdentityPubkeyFFI, and the older signer registration path decodes those fields with decode_contract_bounds(...) before constructing IdentityPublicKeyV0. This newer funded-with-signer decoder hard-codes contract_bounds: None, so the same Swift public key is interpreted differently depending on which FFI entrypoint is used. Any bounded key is silently converted into an unbounded one on this path.

💡 Suggested change
        let pubkey_bytes: Vec<u8> =
            slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec();
        let contract_bounds = crate::identity_registration_with_signer::decode_contract_bounds(
            row,
            purpose,
            i,
            "identity_pubkeys",
        )?;
        keys_map.insert(
            row.key_id,
            IdentityPublicKey::V0(IdentityPublicKeyV0 {
                id: row.key_id,
                purpose,
                security_level,
                contract_bounds,
                key_type,
                read_only: row.read_only,
                data: BinaryData::new(pubkey_bytes),
                disabled_at: None,
            }),
        );
🤖 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/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [BLOCKING] lines 365-439: Repeated catch-up calls can free asset-lock manager handles while detached tasks still use them
  `catchUpStuckAssetLocks` drops `retainedAssetLockManagers` at the start of every invocation, but the actual work runs in `Task.detached` closures that capture only the raw `handle`. `ManagedAssetLockManager.deinit` destroys that handle, so a second call to `catchUpStuckAssetLocks` within the 300-second catch-up window can deinitialize the previous batch's wrappers while its detached tasks are still inside `asset_lock_manager_catch_up_blocking`. The method comment explicitly advertises foreground/reconnect retries, so this is not a theoretical path: it creates a real use-after-free / invalid-handle race during recovery.

In `packages/rs-platform-wallet/src/changeset/core_bridge.rs`:
- [BLOCKING] lines 198-224: `last_applied_chain_lock` is not persisted when a chain lock advances metadata without promoting records
  The new proof fallback in `wallet/asset_lock/sync/proof.rs` builds a ChainLock proof from persisted `metadata.last_applied_chain_lock` when a restarted wallet reloads an `InBlock` record after the promotion event already passed. This bridge only persists `last_applied_chain_lock` from `WalletEvent::TransactionsChainlocked`, and the code comment confirms upstream only emits that event when `per_account` is non-empty. In the no-promotion case, the wallet's in-memory metadata advances but nothing is persisted, so after a restart the fallback still sees stale or missing chain-lock metadata and the asset lock can remain stuck until some later chain lock happens to promote another record.

In `packages/rs-platform-wallet/src/wallet/identity/network/registration.rs`:
- [BLOCKING] lines 567-579: Successful registration/top-up drops the consumed asset-lock changeset instead of persisting it
  `consume_asset_lock` now returns an `AssetLockChangeSet` whose documented purpose is to upsert the persisted row with `status = Consumed` while removing the in-memory entry. Both success paths ignore that returned changeset and only log errors, so Swift never receives the `Consumed` upsert. The result is that a successful registration or top-up leaves persistence at the prior status (`Built`/`Broadcast`/`InstantSendLocked`/`ChainLocked`), which breaks the historical lookup behavior described in `tracking.rs` and allows already-spent locks to come back on reload because only persisted `Consumed` rows are filtered out.

In `packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs`:
- [SUGGESTION] lines 86-95: Funded registration FFI path discards `IdentityPubkeyFFI` contract bounds
  Swift populates `contract_bounds_kind`, `contract_bounds_id`, and `contract_bounds_document_type` when it builds `IdentityPubkeyFFI`, and the older signer registration path decodes those fields with `decode_contract_bounds(...)` before constructing `IdentityPublicKeyV0`. This newer funded-with-signer decoder hard-codes `contract_bounds: None`, so the same Swift public key is interpreted differently depending on which FFI entrypoint is used. Any bounded key is silently converted into an unbounded one on this path.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] lines 2955-2965: Restore path hard-codes unresolved asset-lock transactions to BIP44 account 0
  The restore payload now includes `account_index`, and the Rust loader uses that field to reinsert each unresolved funding transaction into `standard_bip44_accounts[account_index]`. Swift always serializes `entry.account_index = 0`, and the inline comment acknowledges that the wrong value causes the Rust side to drop the record. The SDK surface already allows asset locks to be funded from arbitrary BIP44 account indexes, so restarting a wallet with unresolved asset locks from a non-zero funding account will route those transactions to the wrong account or drop them outright, breaking the intended restart recovery path for those wallets.

Comment on lines +365 to +439
retainedAssetLockManagers.removeAll(keepingCapacity: true)
for wallet in wallets {
let walletId = wallet.walletId
let locks = persistenceHandler.loadCachedAssetLocks(walletId: walletId)
let pending = locks.filter { $0.statusRaw < 2 }
if pending.isEmpty { continue }

// Snapshot the asset-lock manager handle ON the main
// actor (where `wallet` lives). The `ManagedAssetLockManager`
// class isn't `Sendable` (its `deinit` calls
// `asset_lock_manager_destroy`), so the detached Task
// captures the bare `Handle` value (an `Int64`) and
// calls the FFI directly. Lifetime: stash the manager
// wrapper on `retainedAssetLockManagers` so its `deinit`
// (which would invalidate the handle) waits for the
// tasks to complete; the wrapper is dropped on the next
// call to `catchUpStuckAssetLocks` or on manager
// shutdown, whichever comes first.
let assetLockManager: ManagedAssetLockManager
do {
assetLockManager = try wallet.assetLockManager()
} catch {
self.lastError = error
continue
}
// The previous batch's manager wrappers (if any) are
// dropped here — their tasks have either completed
// (success path persisted via the changeset) or hit the
// 300s timeout long ago. The replacement keeps the
// current batch's handles alive for the duration of the
// new tasks.
retainedAssetLockManagers.append(assetLockManager)
let handle = assetLockManager.handle

// Cap concurrency to avoid saturating iOS's cooperative
// thread pool. Each catch-up `block_on` parks a worker
// thread for up to 300s; N stuck locks at launch (after a
// multi-identity registration interrupted by an app kill)
// would otherwise spawn N parallel parked threads,
// starving every other `Task` in the app (UI updates,
// SwiftData writes, network calls).
//
// `MAX_CONCURRENT_CATCH_UPS = 4` is conservative for a
// 4-8 worker pool typical on iPhones. The real bottleneck
// is per-lock SPV chainlock arrival, not the catch-up
// throughput — running 4 in parallel vs 50 changes nothing
// about how fast each individual lock resolves.
let outpoints: [(txid: Data, vout: UInt32)] = pending.compactMap {
PlatformWalletManager.decodeOutPointForCatchUp($0.outPointHex)
}
guard !outpoints.isEmpty else { continue }
Task.detached(priority: .background) {
await withTaskGroup(of: Void.self) { group in
let maxConcurrent = 4
var nextIndex = 0
// Seed the group with up to `maxConcurrent` tasks.
while nextIndex < outpoints.count && nextIndex < maxConcurrent {
let (txid, vout) = outpoints[nextIndex]
group.addTask {
Self.runCatchUp(handle: handle, txid: txid, vout: vout)
}
nextIndex += 1
}
// As each finishes, queue the next pending entry.
while await group.next() != nil {
if nextIndex < outpoints.count {
let (txid, vout) = outpoints[nextIndex]
group.addTask {
Self.runCatchUp(handle: handle, txid: txid, vout: vout)
}
nextIndex += 1
}
}
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Repeated catch-up calls can free asset-lock manager handles while detached tasks still use them

catchUpStuckAssetLocks drops retainedAssetLockManagers at the start of every invocation, but the actual work runs in Task.detached closures that capture only the raw handle. ManagedAssetLockManager.deinit destroys that handle, so a second call to catchUpStuckAssetLocks within the 300-second catch-up window can deinitialize the previous batch's wrappers while its detached tasks are still inside asset_lock_manager_catch_up_blocking. The method comment explicitly advertises foreground/reconnect retries, so this is not a theoretical path: it creates a real use-after-free / invalid-handle race during recovery.

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/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [BLOCKING] lines 365-439: Repeated catch-up calls can free asset-lock manager handles while detached tasks still use them
  `catchUpStuckAssetLocks` drops `retainedAssetLockManagers` at the start of every invocation, but the actual work runs in `Task.detached` closures that capture only the raw `handle`. `ManagedAssetLockManager.deinit` destroys that handle, so a second call to `catchUpStuckAssetLocks` within the 300-second catch-up window can deinitialize the previous batch's wrappers while its detached tasks are still inside `asset_lock_manager_catch_up_blocking`. The method comment explicitly advertises foreground/reconnect retries, so this is not a theoretical path: it creates a real use-after-free / invalid-handle race during recovery.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 3abd581a01. ManagedAssetLockManager is now final + @unchecked Sendable (immutable let handle, deinit runs exactly once via ARC) and each catch-up task captures the wrapper directly instead of the raw Handle. The wrapper's deinit (which destroys the FFI handle) cannot fire until the task drops its retain. The retainedAssetLockManagers workaround property is gone. Concurrent invocations of catchUpStuckAssetLocks no longer race on each other's handles.

Comment on lines +198 to +224
WalletEvent::TransactionsChainlocked { chain_lock, .. } => {
// The wallet has already promoted the matching records from
// `InBlock` to `InChainLockedBlock` by the time this event
// fires (upstream `WalletManager::process_chain_lock` mutates
// the in-memory map before emitting); our poll loop reads
// `record.context.is_chain_locked()` directly so we don't
// mirror per-record promotions here.
//
// What we DO persist is the wallet's global
// `metadata.last_applied_chain_lock` advance. Without this
// roundtrip, the metadata starts as `None` on every restart
// and the asset-lock-resume CL-from-metadata fallback in
// `proof.rs` can't fire until SPV re-applies a fresh
// ChainLock — wasted latency that the persister-roundtrip
// collapses to ~zero. SPV persists its own `best_chainlock`
// independently; this is the symmetric wallet-side
// persistence, not a re-application.
//
// Caveat: the upstream `apply_chain_lock` only emits this
// event when `per_account` is non-empty (records were
// promoted). A CL that advanced the wallet's metadata but
// had nothing to promote is invisible here — accepted
// limitation, addressed in practice because the very
// first CL that does promote records (typical on an
// actively-syncing wallet) persists the current height.
CoreChangeSet {
last_applied_chain_lock: Some(chain_lock.clone()),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: last_applied_chain_lock is not persisted when a chain lock advances metadata without promoting records

The new proof fallback in wallet/asset_lock/sync/proof.rs builds a ChainLock proof from persisted metadata.last_applied_chain_lock when a restarted wallet reloads an InBlock record after the promotion event already passed. This bridge only persists last_applied_chain_lock from WalletEvent::TransactionsChainlocked, and the code comment confirms upstream only emits that event when per_account is non-empty. In the no-promotion case, the wallet's in-memory metadata advances but nothing is persisted, so after a restart the fallback still sees stale or missing chain-lock metadata and the asset lock can remain stuck until some later chain lock happens to promote another record.

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/changeset/core_bridge.rs`:
- [BLOCKING] lines 198-224: `last_applied_chain_lock` is not persisted when a chain lock advances metadata without promoting records
  The new proof fallback in `wallet/asset_lock/sync/proof.rs` builds a ChainLock proof from persisted `metadata.last_applied_chain_lock` when a restarted wallet reloads an `InBlock` record after the promotion event already passed. This bridge only persists `last_applied_chain_lock` from `WalletEvent::TransactionsChainlocked`, and the code comment confirms upstream only emits that event when `per_account` is non-empty. In the no-promotion case, the wallet's in-memory metadata advances but nothing is persisted, so after a restart the fallback still sees stale or missing chain-lock metadata and the asset lock can remain stuck until some later chain lock happens to promote another record.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed end-to-end in dfb8caeff4 plus upstream dashpay/rust-dashcore#769. Upstream now emits a single WalletEvent::ChainLockProcessed { chain_lock, locked_transactions } every time last_applied_chain_lock advances, regardless of whether any record was promoted — closing the "metadata-advanced but per-account empty" gap. Platform's core_bridge.rs consumes the renamed event and projects the chainlock into CoreChangeSet::last_applied_chain_lock unconditionally. Same commit folds in two related upstream API changes (the new ApplyChainLockOutcome return type and DerivedAddress::public_key: PublicKey).

Comment on lines +567 to +579
if let Some(out_point) = tracked_out_point {
// Cleanup failure here can only mean WalletNotFound
// (the wallet handle that just registered an identity
// vanished). Surface as a warn — the identity DID
// register successfully on Platform, so propagating the
// error to the caller would be misleading.
if let Err(e) = self.asset_locks.consume_asset_lock(&out_point).await {
tracing::warn!(
outpoint = %out_point,
error = %e,
"consume_asset_lock failed after successful Platform submit"
);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Successful registration/top-up drops the consumed asset-lock changeset instead of persisting it

consume_asset_lock now returns an AssetLockChangeSet whose documented purpose is to upsert the persisted row with status = Consumed while removing the in-memory entry. Both success paths ignore that returned changeset and only log errors, so Swift never receives the Consumed upsert. The result is that a successful registration or top-up leaves persistence at the prior status (Built/Broadcast/InstantSendLocked/ChainLocked), which breaks the historical lookup behavior described in tracking.rs and allows already-spent locks to come back on reload because only persisted Consumed rows are filtered out.

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/identity/network/registration.rs`:
- [BLOCKING] lines 567-579: Successful registration/top-up drops the consumed asset-lock changeset instead of persisting it
  `consume_asset_lock` now returns an `AssetLockChangeSet` whose documented purpose is to upsert the persisted row with `status = Consumed` while removing the in-memory entry. Both success paths ignore that returned changeset and only log errors, so Swift never receives the `Consumed` upsert. The result is that a successful registration or top-up leaves persistence at the prior status (`Built`/`Broadcast`/`InstantSendLocked`/`ChainLocked`), which breaks the historical lookup behavior described in `tracking.rs` and allows already-spent locks to come back on reload because only persisted `Consumed` rows are filtered out.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8a8545c945. consume_asset_lock now queues the Consumed changeset internally (write lock released before queueing). Doc-comment explains why this method queues internally while sibling mutators defer to in-module callers (queue_asset_lock_changeset is pub(super)-scoped; registration lives outside the asset_lock module). Test gap surfaced in the commit message — full AssetLockManager scaffolding doesn't exist yet; end-to-end registration flow exercises this path.

Comment on lines +2955 to +2965
var entry = UnresolvedAssetLockTxRecordFFI()
// `accountIndex` isn't stored on `PersistentAssetLock`
// (the existing `buildAssetLockRestoreBuffer` makes the
// same assumption). In production iOS the funding
// account is always BIP44 index 0 — the same default
// used by `recover_asset_lock_blocking`. The Rust side
// looks up `standard_bip44_accounts.get(&account_index)`
// so a wrong value here would silently drop the
// restore; documented as a known limit until per-row
// `accountIndex` lands on the SwiftData model.
entry.account_index = 0
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Restore path hard-codes unresolved asset-lock transactions to BIP44 account 0

The restore payload now includes account_index, and the Rust loader uses that field to reinsert each unresolved funding transaction into standard_bip44_accounts[account_index]. Swift always serializes entry.account_index = 0, and the inline comment acknowledges that the wrong value causes the Rust side to drop the record. The SDK surface already allows asset locks to be funded from arbitrary BIP44 account indexes, so restarting a wallet with unresolved asset locks from a non-zero funding account will route those transactions to the wrong account or drop them outright, breaking the intended restart recovery path for those wallets.

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/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] lines 2955-2965: Restore path hard-codes unresolved asset-lock transactions to BIP44 account 0
  The restore payload now includes `account_index`, and the Rust loader uses that field to reinsert each unresolved funding transaction into `standard_bip44_accounts[account_index]`. Swift always serializes `entry.account_index = 0`, and the inline comment acknowledges that the wrong value causes the Rust side to drop the record. The SDK surface already allows asset locks to be funded from arbitrary BIP44 account indexes, so restarting a wallet with unresolved asset locks from a non-zero funding account will route those transactions to the wrong account or drop them outright, breaking the intended restart recovery path for those wallets.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 81acae6726. Added accountIndexRaw: Int32 = 0 column to PersistentAssetLock and plumbed account_index through both the inbound FFI callback (onPersistAssetLocks) and the load-time snapshot. Both restore-marshalling sites (buildAssetLockRestoreBuffer for asset-lock entries, buildUnresolvedAssetLockTxRecords for tx records) now use UInt32(bitPattern: record.accountIndexRaw) instead of hardcoded 0. SwiftData lightweight migration keeps pre-feature rows safe (default 0 = the bytes the hardcoded restore was using = correct for the BIP44-account-0 common case); any subsequent upsert on a pre-feature row populates the column with the real value from AssetLockEntryFFI.account_index.

…et-ffi

Catches the lockfile up to 67e0532 which removed both deps from
`packages/rs-platform-wallet-ffi/Cargo.toml` when the signer code
moved to `rs-sdk-ffi`. The manifest no longer references either
crate; the lockfile had stale entries.

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

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

The PR’s retry and recovery work is directionally correct, but six verified regressions remain in the new asset-lock and Swift FFI paths. Four of them break resumable asset-lock flows, one drops key metadata at the funded-registration boundary, and one introduces an unsafe Swift-to-Rust string lifetime bug.

Reviewed commit: 259d78a

🔴 6 blocking

🤖 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/identity/network/registration.rs`:
- [BLOCKING] lines 496-506: `UseAssetLock` retries always fail after Platform rejects an Instant proof
  `register_identity_with_funding` now retries every `InvalidInstantAssetLockProofSignatureError` by calling `self.asset_locks.upgrade_to_chain_lock_proof(&out_point, ...)`, and the top-up path does the same at lines 691-700. That helper only works for entries present in `tracked_asset_locks`; it returns `Asset lock ... is not tracked` otherwise. `IdentityFunding::UseAssetLock` explicitly sets `tracked_out_point: None` because the caller owns the lock lifecycle, so an externally supplied Instant proof can no longer survive a stale-IS rejection from Platform. Instead of surfacing the original SDK error, the wallet forces an internal retry path that cannot succeed for this funding variant.
- [BLOCKING] lines 567-578: Successful identity operations drop the `Consumed` asset-lock changeset
  `consume_asset_lock()` no longer persists by itself. It removes the in-memory entry and returns an `AssetLockChangeSet` whose `asset_locks` payload is the only thing that marks the persisted row as `Consumed`. Both success paths ignore that return value here and in the top-up branch at lines 736-747, so the asset lock disappears from memory but never transitions to `statusRaw = 4` in storage. After a restart, the same lock is reloaded as still actionable, which breaks the crash-recovery semantics this PR is adding.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [BLOCKING] lines 360-365: Re-entering catch-up can destroy FFI handles that detached tasks are still using
  `catchUpStuckAssetLocks` starts detached tasks that call `asset_lock_manager_catch_up_blocking(..., 300)`, then a later invocation immediately runs `retainedAssetLockManagers.removeAll(...)`. Dropping those wrappers calls `asset_lock_manager_destroy` in `ManagedAssetLockManager.deinit`, even though the previous batch may still be blocked inside Rust. The method is public and documented for app-foreground and reconnect retries, so a second call within the five-minute window can invalidate live handles and abort the earlier catch-up batch with the `errorInvalidHandle` path that the code already logs as a programmer error.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] lines 2860-2865: Asset-lock restore rewrites every funding account index to zero
  The persisted restore buffer hardcodes `entry.account_index = 0` for tracked asset locks here and again for unresolved funding transactions at lines 2956-2965. That is not just a breadcrumb: Rust uses `account_index` to look up the source BIP44 account during recovery, proof waiting, and ChainLock promotion. The Swift SDK’s public `buildTransaction`, `createFundedProof`, and `sendToAddresses` APIs all accept nonzero account indices, so any client that uses them will have its resumable asset-lock state silently rehydrated under the wrong account after restart.

In `packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs`:
- [BLOCKING] lines 86-99: Funded identity-registration FFI drops `contract_bounds` from Swift pubkeys
  This decoder builds `IdentityPublicKeyV0` with `contract_bounds: None` for every row, even though the Swift caller marshals `contract_bounds_kind`, `contract_bounds_id`, and `contract_bounds_document_type` for each key. The older signer-based registration path already decodes those fields through `decode_contract_bounds`, but the new asset-lock-funded path does not. Encryption and decryption keys therefore lose their declared bounds at the FFI boundary and can be registered with different semantics from what Swift sent.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift`:
- [BLOCKING] lines 108-131: `sendToAddresses` passes Rust C-string pointers backed by temporary Objective-C objects
  `sendToAddresses` builds `let cStrings = recipients.map { ($0.address as NSString).utf8String }` and then hands those pointers to Rust, where `core_wallet_send_to_addresses` immediately dereferences them with `CStr::from_ptr`. The array retains only raw pointers, not the bridged `NSString` instances that own the UTF-8 buffers, so the Rust side can read freed or corrupted memory if those temporaries are released before or during the call. This is an unsafe Swift-to-C lifetime bug on a transaction-building path.

Comment on lines +496 to +506
Err(e) if is_instant_lock_proof_invalid(&e) => {
let out_point = proof_out_point;
tracing::warn!(
"IS-lock proof rejected by Platform for identity registration (tx {}), \
retrying with ChainLock proof",
out_point.txid
);
let chain_proof = self
.asset_locks
.upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT)
.await?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: UseAssetLock retries always fail after Platform rejects an Instant proof

register_identity_with_funding now retries every InvalidInstantAssetLockProofSignatureError by calling self.asset_locks.upgrade_to_chain_lock_proof(&out_point, ...), and the top-up path does the same at lines 691-700. That helper only works for entries present in tracked_asset_locks; it returns Asset lock ... is not tracked otherwise. IdentityFunding::UseAssetLock explicitly sets tracked_out_point: None because the caller owns the lock lifecycle, so an externally supplied Instant proof can no longer survive a stale-IS rejection from Platform. Instead of surfacing the original SDK error, the wallet forces an internal retry path that cannot succeed for this funding variant.

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/identity/network/registration.rs`:
- [BLOCKING] lines 496-506: `UseAssetLock` retries always fail after Platform rejects an Instant proof
  `register_identity_with_funding` now retries every `InvalidInstantAssetLockProofSignatureError` by calling `self.asset_locks.upgrade_to_chain_lock_proof(&out_point, ...)`, and the top-up path does the same at lines 691-700. That helper only works for entries present in `tracked_asset_locks`; it returns `Asset lock ... is not tracked` otherwise. `IdentityFunding::UseAssetLock` explicitly sets `tracked_out_point: None` because the caller owns the lock lifecycle, so an externally supplied Instant proof can no longer survive a stale-IS rejection from Platform. Instead of surfacing the original SDK error, the wallet forces an internal retry path that cannot succeed for this funding variant.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed IdentityFunding::UseAssetLock entirely in 0fb17cce3b. Audit confirmed zero production callers in the workspace — the variant's own docstring acknowledged it was for "callers that manage asset locks via a sibling component (evo-tool's tasks, integration tests, etc.)" and none of those consumers live here.

Both reviewer findings (N1 = the IS-timeout fallback, N2 = the Platform IS-rejection retry) collapse with the variant's removal. The IS→CL machinery requires a tracked lock to drive upgrade_to_chain_lock_proof; UseAssetLock's tracked_out_point: None deliberately bypassed tracking, so the fallback was unreachable by design — exactly what the reviewer described.

Future consumers needing an external proof should register it through AssetLockManager first (creating a tracked entry) and then submit via FromExistingAssetLock. That path has working lifecycle + fallback.

Comment on lines +567 to +578
if let Some(out_point) = tracked_out_point {
// Cleanup failure here can only mean WalletNotFound
// (the wallet handle that just registered an identity
// vanished). Surface as a warn — the identity DID
// register successfully on Platform, so propagating the
// error to the caller would be misleading.
if let Err(e) = self.asset_locks.consume_asset_lock(&out_point).await {
tracing::warn!(
outpoint = %out_point,
error = %e,
"consume_asset_lock failed after successful Platform submit"
);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Successful identity operations drop the Consumed asset-lock changeset

consume_asset_lock() no longer persists by itself. It removes the in-memory entry and returns an AssetLockChangeSet whose asset_locks payload is the only thing that marks the persisted row as Consumed. Both success paths ignore that return value here and in the top-up branch at lines 736-747, so the asset lock disappears from memory but never transitions to statusRaw = 4 in storage. After a restart, the same lock is reloaded as still actionable, which breaks the crash-recovery semantics this PR is adding.

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/identity/network/registration.rs`:
- [BLOCKING] lines 567-578: Successful identity operations drop the `Consumed` asset-lock changeset
  `consume_asset_lock()` no longer persists by itself. It removes the in-memory entry and returns an `AssetLockChangeSet` whose `asset_locks` payload is the only thing that marks the persisted row as `Consumed`. Both success paths ignore that return value here and in the top-up branch at lines 736-747, so the asset lock disappears from memory but never transitions to `statusRaw = 4` in storage. After a restart, the same lock is reloaded as still actionable, which breaks the crash-recovery semantics this PR is adding.

Comment on lines +360 to +365
// Release the previous batch's manager wrappers now that we
// know their tasks have either completed or timed out (any
// task still running past the 300s timeout is misbehaving
// and the bound is on the Rust side anyway). Without this
// the array would grow unboundedly across foregroundings.
retainedAssetLockManagers.removeAll(keepingCapacity: true)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Re-entering catch-up can destroy FFI handles that detached tasks are still using

catchUpStuckAssetLocks starts detached tasks that call asset_lock_manager_catch_up_blocking(..., 300), then a later invocation immediately runs retainedAssetLockManagers.removeAll(...). Dropping those wrappers calls asset_lock_manager_destroy in ManagedAssetLockManager.deinit, even though the previous batch may still be blocked inside Rust. The method is public and documented for app-foreground and reconnect retries, so a second call within the five-minute window can invalidate live handles and abort the earlier catch-up batch with the errorInvalidHandle path that the code already logs as a programmer error.

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/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [BLOCKING] lines 360-365: Re-entering catch-up can destroy FFI handles that detached tasks are still using
  `catchUpStuckAssetLocks` starts detached tasks that call `asset_lock_manager_catch_up_blocking(..., 300)`, then a later invocation immediately runs `retainedAssetLockManagers.removeAll(...)`. Dropping those wrappers calls `asset_lock_manager_destroy` in `ManagedAssetLockManager.deinit`, even though the previous batch may still be blocked inside Rust. The method is public and documented for app-foreground and reconnect retries, so a second call within the five-minute window can invalidate live handles and abort the earlier catch-up batch with the `errorInvalidHandle` path that the code already logs as a programmer error.

Comment on lines +2860 to +2865
// `accountIndex` isn't stored on the SwiftData model
// (Rust derives it from the funding path), so default to
// 0. The Rust load path doesn't read this field for
// anything load-bearing — it's a breadcrumb for the
// FFI persist path going forward.
entry.account_index = 0
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Asset-lock restore rewrites every funding account index to zero

The persisted restore buffer hardcodes entry.account_index = 0 for tracked asset locks here and again for unresolved funding transactions at lines 2956-2965. That is not just a breadcrumb: Rust uses account_index to look up the source BIP44 account during recovery, proof waiting, and ChainLock promotion. The Swift SDK’s public buildTransaction, createFundedProof, and sendToAddresses APIs all accept nonzero account indices, so any client that uses them will have its resumable asset-lock state silently rehydrated under the wrong account after restart.

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/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] lines 2860-2865: Asset-lock restore rewrites every funding account index to zero
  The persisted restore buffer hardcodes `entry.account_index = 0` for tracked asset locks here and again for unresolved funding transactions at lines 2956-2965. That is not just a breadcrumb: Rust uses `account_index` to look up the source BIP44 account during recovery, proof waiting, and ChainLock promotion. The Swift SDK’s public `buildTransaction`, `createFundedProof`, and `sendToAddresses` APIs all accept nonzero account indices, so any client that uses them will have its resumable asset-lock state silently rehydrated under the wrong account after restart.

Comment on lines 86 to 99
@@ -72,19 +99,197 @@ pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer(
}),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Funded identity-registration FFI drops contract_bounds from Swift pubkeys

This decoder builds IdentityPublicKeyV0 with contract_bounds: None for every row, even though the Swift caller marshals contract_bounds_kind, contract_bounds_id, and contract_bounds_document_type for each key. The older signer-based registration path already decodes those fields through decode_contract_bounds, but the new asset-lock-funded path does not. Encryption and decryption keys therefore lose their declared bounds at the FFI boundary and can be registered with different semantics from what Swift sent.

💡 Suggested change
Suggested change
let pubkey_bytes: Vec<u8> =
slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec();
let contract_bounds =
crate::identity_registration_with_signer::decode_contract_bounds(
row,
purpose,
i,
"identity_pubkeys",
)?;
keys_map.insert(
row.key_id,
IdentityPublicKey::V0(IdentityPublicKeyV0 {
id: row.key_id,
purpose,
security_level,
contract_bounds,
key_type,
read_only: row.read_only,
data: BinaryData::new(pubkey_bytes),
disabled_at: None,
}),
);

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-ffi/src/identity_registration_funded_with_signer.rs`:
- [BLOCKING] lines 86-99: Funded identity-registration FFI drops `contract_bounds` from Swift pubkeys
  This decoder builds `IdentityPublicKeyV0` with `contract_bounds: None` for every row, even though the Swift caller marshals `contract_bounds_kind`, `contract_bounds_id`, and `contract_bounds_document_type` for each key. The older signer-based registration path already decodes those fields through `decode_contract_bounds`, but the new asset-lock-funded path does not. Encryption and decryption keys therefore lose their declared bounds at the FFI boundary and can be registered with different semantics from what Swift sent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in da7938a013. The funded-registration decoder now calls the existing decode_contract_bounds helper from identity_registration_with_signer, so the kind / id / document_type triple round-trips correctly and Encryption/Decryption purposes get rejected at the FFI boundary when kind == 0 (instead of silently registering an unusable key).

Comment on lines +109 to +131
let cStrings = recipients.map { ($0.address as NSString).utf8String }
let amounts = recipients.map { $0.amountDuffs }

// Resolver-backed signer owns mnemonic access for the lifetime
// of this call. Each Core ECDSA signature happens atomically
// inside the resolver vtable (mnemonic fetched, key derived,
// digest signed, buffers zeroed) — no priv key leaves Swift.
let resolver = MnemonicResolver()

try cStrings.withUnsafeBufferPointer { addrBuf in
try amounts.withUnsafeBufferPointer { amountBuf in
try core_wallet_send_to_addresses(
handle,
accountType.rawValue,
accountIndex,
addrBuf.baseAddress,
amountBuf.baseAddress,
UInt(recipients.count),
&txBytesPtr,
&txLen
).check()
try withExtendedLifetime(resolver) {
try core_wallet_send_to_addresses(
handle,
accountType.rawValue,
accountIndex,
addrBuf.baseAddress,
amountBuf.baseAddress,
UInt(recipients.count),
resolver.handle,
&txBytesPtr,
&txLen
).check()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: sendToAddresses passes Rust C-string pointers backed by temporary Objective-C objects

sendToAddresses builds let cStrings = recipients.map { ($0.address as NSString).utf8String } and then hands those pointers to Rust, where core_wallet_send_to_addresses immediately dereferences them with CStr::from_ptr. The array retains only raw pointers, not the bridged NSString instances that own the UTF-8 buffers, so the Rust side can read freed or corrupted memory if those temporaries are released before or during the call. This is an unsafe Swift-to-C lifetime bug on a transaction-building path.

💡 Suggested change
Suggested change
let cStrings = recipients.map { ($0.address as NSString).utf8String }
let amounts = recipients.map { $0.amountDuffs }
// Resolver-backed signer owns mnemonic access for the lifetime
// of this call. Each Core ECDSA signature happens atomically
// inside the resolver vtable (mnemonic fetched, key derived,
// digest signed, buffers zeroed) — no priv key leaves Swift.
let resolver = MnemonicResolver()
try cStrings.withUnsafeBufferPointer { addrBuf in
try amounts.withUnsafeBufferPointer { amountBuf in
try core_wallet_send_to_addresses(
handle,
accountType.rawValue,
accountIndex,
addrBuf.baseAddress,
amountBuf.baseAddress,
UInt(recipients.count),
&txBytesPtr,
&txLen
).check()
try withExtendedLifetime(resolver) {
try core_wallet_send_to_addresses(
handle,
accountType.rawValue,
accountIndex,
addrBuf.baseAddress,
amountBuf.baseAddress,
UInt(recipients.count),
resolver.handle,
&txBytesPtr,
&txLen
).check()
let addressStorage: [UnsafeMutablePointer<CChar>?] = recipients.map { strdup($0.address) }
defer { addressStorage.forEach { if let p = $0 { free(p) } } }
let amounts = recipients.map { $0.amountDuffs }
try addressStorage.withUnsafeBufferPointer { addrBuf in
let addressesPtr: UnsafePointer<UnsafePointer<CChar>?>? = addrBuf.baseAddress.map {
UnsafeRawPointer($0).assumingMemoryBound(to: UnsafePointer<CChar>?.self)
}
try amounts.withUnsafeBufferPointer { amountBuf in
try withExtendedLifetime(resolver) {
try core_wallet_send_to_addresses(
handle,
accountType.rawValue,
accountIndex,
addressesPtr,
amountBuf.baseAddress,
UInt(recipients.count),
resolver.handle,
&txBytesPtr,
&txLen
).check()
}
}
}

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/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift`:
- [BLOCKING] lines 108-131: `sendToAddresses` passes Rust C-string pointers backed by temporary Objective-C objects
  `sendToAddresses` builds `let cStrings = recipients.map { ($0.address as NSString).utf8String }` and then hands those pointers to Rust, where `core_wallet_send_to_addresses` immediately dereferences them with `CStr::from_ptr`. The array retains only raw pointers, not the bridged `NSString` instances that own the UTF-8 buffers, so the Rust side can read freed or corrupted memory if those temporaries are released before or during the call. This is an unsafe Swift-to-C lifetime bug on a transaction-building path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in da7938a013. sendToAddresses now owns the C-string storage via strdup + defer free instead of pointing at bridged NSString temporaries. Eliminates the use-after-free Apple's utf8String docs explicitly warn about.

shumkov and others added 9 commits May 15, 2026 19:00
`sign_ecdsa` and `public_key` on `MnemonicResolverCoreSigner` called
`secp256k1::SecretKey::from_slice(secret_bytes.as_ref())`, which copies
the 32-byte scalar into a fresh `SecretKey` value. The `Zeroizing<[u8; 32]>`
wrapper around `secret_bytes` only wipes the original buffer — the
SecretKey-owned copy stayed resident until the binding went out of scope
and never had its memory cleared. The prior comment claiming the buffers
alias was wrong (`from_slice` is a copy, not a borrow).

Fix: call `SecretKey::non_secure_erase()` before the `secret` binding
drops in both methods. This is the standard secp256k1 0.30 primitive
(at `key.rs:1022`) that overwrites the scalar with `[1u8; 32]`. The
"non_secure" caveat is that LLVM may elide the write under aggressive
optimization, but it remains the right idiom shipped by the secp256k1
crate authors for exactly this use case.

Externally observable behavior unchanged: existing `sign_ecdsa` /
`public_key` tests pass — signature and pubkey derivation happen before
the wipe.

PR review thread:
#3634 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous shape stored `ManagedAssetLockManager` wrappers in a
`retainedAssetLockManagers` array and released the previous batch at
the start of every `catchUpStuckAssetLocks` invocation. Detached
catch-up tasks captured only the bare `Handle` (Int64), so a second
call within the 5-minute `asset_lock_manager_catch_up_blocking`
window — which the public API explicitly supports for
app-foreground / network-reconnect retries — would drop the prior
batch's wrappers while their tasks were still mid-FFI-call. The
wrapper's `deinit` invokes `asset_lock_manager_destroy`, freeing the
handle out from under the running task. Behavior on a freed handle
is undefined: best case `errorInvalidHandle`, worst case a crash if
Rust dereferences the slot.

Fix: mark `ManagedAssetLockManager` `final` + `@unchecked Sendable`
(safe — immutable `let handle`, no shared mutable state, ARC
guarantees `deinit` runs exactly once) and have each `group.addTask`
capture the wrapper directly. The task's retain on the wrapper now
keeps the handle valid for the entire FFI call, independent of any
follow-up `catchUpStuckAssetLocks` invocation. The
`retainedAssetLockManagers` property, its `removeAll`, and its
`append` are deleted — no longer needed.

Verified with `./build_ios.sh --target sim` (BUILD SUCCEEDED).

Addresses #3634 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`consume_asset_lock` built an `AssetLockChangeSet` carrying
`status = Consumed` but never queued it. Callers in
`wallet/identity/network/registration.rs` (registration / top-up
success paths) couldn't queue it either — `queue_asset_lock_changeset`
is `pub(super)` to the `asset_lock` module and registration lives
outside that subtree. Result: a successful registration left the
persisted asset-lock row at its prior status (`Built` / `Broadcast` /
`InstantSendLocked` / `ChainLocked`); on restart Swift reloaded the
stale row and the resume flow could re-process an already-spent lock.

Queue the changeset internally before returning. The return value is
retained for tests / future internal callers; existing callers that
ignore it now get persistence as a side effect.

Doc-comment now spells out the visibility constraint (why this
mutator queues internally while `track_asset_lock` and
`advance_asset_lock_status` defer queueing to their in-module
callers). Write lock on the wallet manager is released before
`queue_asset_lock_changeset` runs so the synchronous persister call
can't deadlock against other writers.

PR review threads:
- #3634 (comment)
- #3634 (comment)

Test gap (explicit per project TDD discipline): no regression test
shipped with this commit. `AssetLockManager` construction needs an
`Arc<dash_sdk::Sdk>`, a populated `WalletManager`, a broadcaster, and
a `WalletPersister` — ~200 lines of scaffolding the asset_lock module
doesn't have yet. The end-to-end registration flow exercises this
path on every successful identity creation; a focused regression test
should land alongside whatever future PR adds asset-lock unit-test
scaffolding.

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

Reviewer flagged that the CL-height retry loop trusts unverified 10506
responses from DAPI, allowing a malicious node to grief by fabricating
rejections and forcing fee bumps.

Verified: the trust boundary is intentional and lives one layer up at
the DAPI client (rotation / blacklisting). A node that fabricates
rejections is a malicious DAPI node; defending in this function would
either re-implement DAPI client trust or require an SDK API change for
verifiable consensus errors — both out of scope for this PR. The
attack is unprofitable: identity fees flow to Platform validators, not
DAPI nodes, so the worst case is bounded grief (wasted credits and
slower registration), capped further by CL_HEIGHT_RETRY_BUDGET (max
~14 bumps in 210s). Cryptographically verifiable consensus errors
(quorum signature on rejection, or validator attestation) would be
the principled fix and is tracked as future work.

Doc comment sharpened to spell this out so future readers see the
threat was considered and intentionally placed at the right layer.

PR review thread:
#3634 (comment)

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

1. `AssetLockStatus.consumed = 4` added to the Swift mirror enum in
   `ManagedAssetLockManager.swift`. The Rust-side variant has existed
   for a while; the Swift mirror was stale. Without this case
   `AssetLockStatus(rawValue: lock.status) ?? .built` at line 109
   silently downgrades a Consumed lock from FFI to Built.

2. `PersistentAssetLock.statusRaw` doc comment updated to list 4 =
   Consumed.

3. `canFundIdentity` predicate in `PersistentAssetLockDisplay.swift`
   tightened from `statusRaw >= 2` to `statusRaw == 2 || statusRaw == 3`.
   Before this commit it returned `true` for Consumed (4 >= 2),
   which would enable a Resume button on an already-spent lock. The
   bug was latent before R2/R4 (`8a8545c945`) because Consumed was
   only an in-memory state never persisted; that commit started
   writing Consumed rows to SwiftData, so the imprecise predicate
   now matters.

`isVisibleAsResumable` and `crossWalletResumableLocks`'s `statusRaw >= 1`
floor are NOT changed in this commit; the anti-join against
`PersistentIdentity` rows in `crossWalletResumableLocks` already hides
Consumed locks in practice (every successful registration writes both
rows at the same `(walletId, identityIndex)` slot). Tightening that
floor would also require updating the deliberately-future-compatible
`testForwardCompatibleStatusFloor` test and is out of scope for the R5
finding.

PR review thread:
#3634 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`PersistentAssetLock.encodeOutPoint` did `raw.load(as: UInt32.self)` on
a `Data.suffix(4)` byte view. `Data` doesn't guarantee 4-byte
alignment in its underlying storage, so on ARM64 — which traps on
misaligned loads — this could crash whenever the source `Data`
happened to land on an odd offset. Byte-copy through a local aligned
`UInt32`, matching the same pattern used in `PlatformWalletManager.runCatchUp`
and elsewhere in the SDK for FFI byte-array decoding.

PR review thread:
#3634 (comment)

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

Two modules were declared `pub mod` but never appeared in the
re-export block, breaking the consistency of the crate's flat
namespace pattern. Downstream consumers had to reach into the
submodule path to use them.

- `asset_lock_persistence::*` — flagged by reviewer
- `identity_registration::*` — same oversight, found while
  cross-checking the mod list against the re-export list

No name collisions across the crate's public surface
(grep-confirmed via duplicate-name scan of all `pub fn / struct /
enum / type / const` declarations).

PR review thread:
#3634 (comment)

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

Bump rust-dashcore rev to cfb01fa7 (dashpay/rust-dashcore#769) and
consume the new `WalletEvent::ChainLockProcessed` event in the
core-bridge.

Upstream replaces `WalletEvent::TransactionsChainlocked` with a single
`ChainLockProcessed { wallet_id, chain_lock, locked_transactions }`
that fires every time the wallet's `last_applied_chain_lock`
advances, regardless of whether any record was promoted. The earlier
`TransactionsChainlocked` only fired when `per_account` was
non-empty, leaving a gap: a chainlock that advanced the wallet's
metadata but had nothing to promote was invisible to this bridge,
so a session-restart could load stale chainlock metadata and the
asset-lock-resume CL-from-metadata fast-path in `proof.rs` could
miss on the first poll iteration.

R6 fix proper, closing
#3634 (comment).

Also folds in upstream API changes pulled by the dep bump:
- `WalletInfoInterface::apply_chain_lock` now returns
  `ApplyChainLockOutcome { locked_transactions, metadata_advanced }`
  instead of `BTreeMap`. Updated `PlatformWalletInfo`'s impl to
  surface the new outcome plus a `metadata_advanced` trace field.
- `DerivedAddress::public_key` is now `dashcore::PublicKey` (was
  `[u8; 33]`, per dashpay/rust-dashcore#765). Use
  `.inner.serialize()` at the `CoreAddressEntryFFI` construction
  site to keep the 33-byte compressed FFI shape.

The `core_bridge` doc comment is retrained: the prior "caveat,
addressed in practice when SPV promotes" disclaimer is replaced
with a one-line note that the upstream event now fires
unconditionally on advance. `balance_handler`'s match arm renamed
to `ChainLockProcessed`; behavior unchanged (still returns early —
chainlocks don't move balance).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaced these gaps while running the R6 + R2/R4 + S9 manual test:

- SwiftData schema cheat sheet was stale:
  - `ZPERSISTENTASSETLOCK.ZSTATUSRAW` was missing `4=Consumed`, the
    terminal status this PR's R2/R4 fix started writing.
  - `ZPERSISTENTWALLET` was missing `ZLASTAPPLIEDCHAINLOCKBYTES`, the
    bincode-encoded ChainLock that powers the asset-lock-resume
    fast path (R6).
  - `ZPERSISTENTTRANSACTION` was missing `ZTRANSACTIONTYPEKIND`, the
    typed discriminant byte (added in `710de74adc`); skill now
    explicitly recommends it over the parallel human-string
    `ZTRANSACTIONTYPE` which is a `#[derive(Debug)]` repr and not a
    stable contract.

- New workflow J: use pre-existing buggy data as before/after
  evidence. Don't always wipe — within-store contrast (20 rows at the
  old stuck status vs 1 new row at the post-fix status) is a stronger
  proof than a clean-slate happy path and eliminates "did I really
  install the new build?" doubts.

- New pitfall: `idb connect` succeeds but `describe-all` returns a
  single root with empty bounds — companion is connected but
  desynced. Hit this twice today. Same fix as the hang case
  (`pkill -f idb_companion && idb connect $UDID`) but distinct
  symptom worth listing separately.

- New "Install the latest build before driving the UI" section in
  the Setup checklist. Without `xcrun simctl install booted`, idb
  taps hit the old binary and verification is meaningless.

- New pitfall: SwiftUI Menu/UIMenu popover children only appear in
  the accessibility tree AFTER the parent button is tapped — always
  re-`describe-all` after opening a popover.

- The `tap_label` example now uses a heredoc form, eliminating an
  internal inconsistency CodeRabbit flagged (R5 review comment on
  `SKILL.md:82`): the script and the env-var-propagation pitfall
  were contradicting each other. Heredoc + tmp-file pattern keeps
  the env-var pass and avoids the pipe.

No code change.

PR review thread (S10):
#3634 (comment)

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

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Re-review at 9a82020. Multiple prior blockers (catch-up handle retention, SecretKey wipe, Consumed-status persistence, last_applied_chain_lock advance, missed-wakeup race, user_fee_increase threading) are resolved at HEAD. Two real blocking bugs remain: the funded-with-signer FFI silently drops contract_bounds from Swift pubkeys (sibling path decodes them), and the post-submission IS→CL upgrade is unreachable for IdentityFunding::UseAssetLock because upgrade_to_chain_lock_proof requires the outpoint to be in tracked_asset_locks. Two lower-severity FFI-boundary issues persist (sendToAddresses C-string lifetime, asset-lock restore hardcoding account_index=0).

Reviewed commit: 9a82020

🔴 1 blocking | 🟡 2 suggestion(s)

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

In `packages/rs-platform-wallet/src/wallet/identity/network/registration.rs`:
- [BLOCKING] lines 515-540: IS→CL upgrade after Platform rejection is unreachable for IdentityFunding::UseAssetLock
  On `InvalidInstantAssetLockProofSignatureError`, this branch unconditionally calls `self.asset_locks.upgrade_to_chain_lock_proof(&proof_out_point, CL_FALLBACK_TIMEOUT)`. That helper short-circuits at `packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs:168-176` with `PlatformWalletError::AssetLockProofWait("Asset lock {} is not tracked")` whenever the outpoint is absent from `tracked_asset_locks`. The `IdentityFunding::UseAssetLock` arm at line 339 deliberately sets `tracked_out_point: None` (caller owns the lock lifecycle) and never inserts into `tracked_asset_locks`. Net effect: when a caller supplies an externally-managed IS proof that Platform rejects (e.g. quorum rotation), the wallet swallows the original SDK error and surfaces a misleading "is not tracked" failure — the very stale-IS case this fallback was added to handle. The same bug is mirrored in `top_up_identity_with_funding` at lines 710-731. Fix: gate the IS→CL upgrade on `tracked_out_point.is_some()` and re-raise the original `PlatformWalletError::Sdk(e)` for the `UseAssetLock` case, or extend `upgrade_to_chain_lock_proof` to accept an explicit account index for the caller-managed path.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] lines 2855-2965: Asset-lock restore buffers hardcode account_index = 0 even though the Rust loader uses it as a lookup key
  Both `buildAssetLockRestoreBuffer` (line 2865) and `buildUnresolvedAssetLockTxRecordBuffer` (line 2965) serialize `entry.account_index = 0`. The inline comment at line 2862-2864 ("The Rust load path doesn't read this field for anything load-bearing") is incorrect: in `packages/rs-platform-wallet-ffi/src/persistence.rs:2324-2336`, `spec.account_index` is stored into `TrackedAssetLock.account_index` AND used as the key in the per-account `BTreeMap`. Likewise `restore_unresolved_asset_lock_tx_records` at `persistence.rs:2739` reinserts funding transactions into `standard_bip44_accounts.get_mut(&rec.account_index)` and drops the row when no matching account exists. The public Swift asset-lock APIs (`buildTransaction`, `createFundedProof`, `sendToAddresses`) all accept arbitrary `accountIndex`, so any wallet funded from a nonzero BIP44 account silently loses crash-recovery for in-flight asset locks at restart. Today's iOS app happens to use account 0 only, which keeps this latent — but `PersistentAssetLock` needs a real `accountIndex` column (mirroring `identityIndexRaw`) before nonzero BIP44 accounts are supported.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift`:
- [SUGGESTION] lines 108-134: sendToAddresses passes C-string pointers backed by bridged-NSString temporaries
  `let cStrings = recipients.map { ($0.address as NSString).utf8String }` produces an array of `UnsafePointer<CChar>?` whose backing UTF-8 buffers are owned by autoreleased `NSString` temporaries created inside the closure — the array retains the pointers, not the NSString owners. Rust immediately dereferences each with `CStr::from_ptr` in `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`. In practice within a single synchronous non-`@autoreleasepool` function the temporaries live until function exit (no pool drain between `map` and the FFI call), so no concrete misbehavior is reproducible at this SHA. But this is the documented Swift→C anti-pattern: Apple's NSString docs warn that `utf8String` returns memory "automatically freed just as a returned object would be released," any future refactor introducing an `await`, `withCheckedThrowingContinuation`, or nested `@autoreleasepool` between buffer construction and the FFI call invalidates the pointers, and every other FFI buffer in this file already uses owned `strdup`/`free` allocations. Worth tightening because this is a transaction-signing path.

Comment on lines +515 to 540
Err(e) if is_instant_lock_proof_invalid(&e) => {
let out_point = proof_out_point;
tracing::warn!(
"IS-lock proof rejected by Platform for identity registration (tx {}), \
retrying with ChainLock proof",
out_point.txid
);
let chain_proof = self
.asset_locks
.upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT)
.await?;
submit_with_cl_height_retry(settings, |s| {
placeholder.put_to_platform_and_wait_for_response_with_signer(
&self.sdk,
chain_proof.clone(),
&path,
asset_lock_signer,
identity_signer,
s,
)
})
.await
.map_err(PlatformWalletError::Sdk)?
}
Err(e) => return Err(PlatformWalletError::Sdk(e)),
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: IS→CL upgrade after Platform rejection is unreachable for IdentityFunding::UseAssetLock

On InvalidInstantAssetLockProofSignatureError, this branch unconditionally calls self.asset_locks.upgrade_to_chain_lock_proof(&proof_out_point, CL_FALLBACK_TIMEOUT). That helper short-circuits at packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs:168-176 with PlatformWalletError::AssetLockProofWait("Asset lock {} is not tracked") whenever the outpoint is absent from tracked_asset_locks. The IdentityFunding::UseAssetLock arm at line 339 deliberately sets tracked_out_point: None (caller owns the lock lifecycle) and never inserts into tracked_asset_locks. Net effect: when a caller supplies an externally-managed IS proof that Platform rejects (e.g. quorum rotation), the wallet swallows the original SDK error and surfaces a misleading "is not tracked" failure — the very stale-IS case this fallback was added to handle. The same bug is mirrored in top_up_identity_with_funding at lines 710-731. Fix: gate the IS→CL upgrade on tracked_out_point.is_some() and re-raise the original PlatformWalletError::Sdk(e) for the UseAssetLock case, or extend upgrade_to_chain_lock_proof to accept an explicit account index for the caller-managed path.

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/identity/network/registration.rs`:
- [BLOCKING] lines 515-540: IS→CL upgrade after Platform rejection is unreachable for IdentityFunding::UseAssetLock
  On `InvalidInstantAssetLockProofSignatureError`, this branch unconditionally calls `self.asset_locks.upgrade_to_chain_lock_proof(&proof_out_point, CL_FALLBACK_TIMEOUT)`. That helper short-circuits at `packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs:168-176` with `PlatformWalletError::AssetLockProofWait("Asset lock {} is not tracked")` whenever the outpoint is absent from `tracked_asset_locks`. The `IdentityFunding::UseAssetLock` arm at line 339 deliberately sets `tracked_out_point: None` (caller owns the lock lifecycle) and never inserts into `tracked_asset_locks`. Net effect: when a caller supplies an externally-managed IS proof that Platform rejects (e.g. quorum rotation), the wallet swallows the original SDK error and surfaces a misleading "is not tracked" failure — the very stale-IS case this fallback was added to handle. The same bug is mirrored in `top_up_identity_with_funding` at lines 710-731. Fix: gate the IS→CL upgrade on `tracked_out_point.is_some()` and re-raise the original `PlatformWalletError::Sdk(e)` for the `UseAssetLock` case, or extend `upgrade_to_chain_lock_proof` to accept an explicit account index for the caller-managed path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed IdentityFunding::UseAssetLock entirely in 0fb17cce3b. Audit confirmed zero production callers in the workspace — the variant's own docstring acknowledged it was for "callers that manage asset locks via a sibling component (evo-tool's tasks, integration tests, etc.)" and none of those consumers live here.

Both reviewer findings (N1 = the IS-timeout fallback, N2 = the Platform IS-rejection retry) collapse with the variant's removal. The IS→CL machinery requires a tracked lock to drive upgrade_to_chain_lock_proof; UseAssetLock's tracked_out_point: None deliberately bypassed tracking, so the fallback was unreachable by design — exactly what the reviewer described.

Future consumers needing an external proof should register it through AssetLockManager first (creating a tracked entry) and then submit via FromExistingAssetLock. That path has working lifecycle + fallback.

Comment on lines +2855 to +2965

var entry = AssetLockEntryFFI()
copyBytes(outPoint, into: &entry.out_point)
entry.transaction_bytes = txPtr
entry.transaction_bytes_len = UInt(txLen)
// `accountIndex` isn't stored on the SwiftData model
// (Rust derives it from the funding path), so default to
// 0. The Rust load path doesn't read this field for
// anything load-bearing — it's a breadcrumb for the
// FFI persist path going forward.
entry.account_index = 0
entry.funding_type = UInt8(clamping: record.fundingTypeRaw)
entry.identity_index = UInt32(bitPattern: record.identityIndexRaw)
entry.amount_duffs = UInt64(bitPattern: record.amountDuffs)
entry.status = UInt8(clamping: record.statusRaw)
entry.proof_bytes = proofPtr
entry.proof_bytes_len = UInt(proofLen)
buf[written] = entry
written += 1
}
if written == 0 {
buf.deallocate()
return (nil, 0)
}
allocation.assetLockArrays.append((buf, written))
return (buf, written)
}

/// Build the per-wallet `UnresolvedAssetLockTxRecordFFI` array
/// for the load callback. One entry per `PersistentAssetLock` row
/// at `statusRaw < 2` (Built / Broadcast) whose funding tx has a
/// matching `PersistentTransaction` row. Returns `(nil, 0)` when
/// there are no eligible rows.
///
/// The Rust side reads each row and re-inserts the decoded
/// transaction into the matching BIP44 account's in-memory
/// `transactions()` map so the next chain-lock event can promote
/// it via `apply_chain_lock`. See
/// `restore_unresolved_asset_lock_tx_records` for the Rust-side
/// contract.
///
/// Rows with no matching `PersistentTransaction` (e.g. an
/// orphaned asset-lock row whose tx never made it into the
/// transaction table) are skipped — the Rust side has no way to
/// reconstruct the funding tx without its consensus bytes, so
/// projecting an empty row would just bloat the FFI surface.
private func buildUnresolvedAssetLockTxRecordBuffer(
walletId: Data,
allocation: LoadAllocation
) -> (UnsafeMutablePointer<UnresolvedAssetLockTxRecordFFI>?, Int) {
// Filter to `statusRaw < 2` so already-IS-locked /
// already-chain-locked rows don't end up in the array —
// those locks have their proof bytes persisted on the
// `PersistentAssetLock` row and the Rust side doesn't need
// the funding tx in the in-memory map to use them.
let descriptor = FetchDescriptor<PersistentAssetLock>(
predicate: #Predicate { entry in
entry.walletId == walletId && entry.statusRaw < 2
}
)
guard let locks = try? backgroundContext.fetch(descriptor), !locks.isEmpty else {
return (nil, 0)
}

// Pre-query the matching `PersistentTransaction` rows.
// `PersistentAssetLock.outPointHex` carries the txid in
// display order; `PersistentTransaction.txid` is wire order
// — the same flip `decodeOutPointHex` already performs.
let buf = UnsafeMutablePointer<UnresolvedAssetLockTxRecordFFI>.allocate(
capacity: locks.count
)
var written = 0
for lock in locks {
guard let outpoint = decodeOutPointHex(lock.outPointHex) else {
continue
}
let txid = outpoint.prefix(32)
let txidData = Data(txid)
let txDescriptor = FetchDescriptor<PersistentTransaction>(
predicate: #Predicate { $0.txid == txidData }
)
guard let txRow = try? backgroundContext.fetch(txDescriptor).first else {
// No matching tx — Rust can't reconstruct the
// funding body without its consensus bytes. Skip.
continue
}
let txBytes = txRow.transactionData
guard !txBytes.isEmpty else {
// A stub row whose real upsert never arrived;
// skip rather than emit an undecodable buffer.
continue
}

// Allocate the consensus-bytes buffer. Lifetime is
// owned by `allocation.scalarBuffers`, freed by
// `LoadAllocation.release()` after Rust returns.
let txBuf = UnsafeMutablePointer<UInt8>.allocate(capacity: txBytes.count)
txBytes.copyBytes(to: txBuf, count: txBytes.count)
allocation.scalarBuffers.append((txBuf, txBytes.count))

var entry = UnresolvedAssetLockTxRecordFFI()
// `accountIndex` isn't stored on `PersistentAssetLock`
// (the existing `buildAssetLockRestoreBuffer` makes the
// same assumption). In production iOS the funding
// account is always BIP44 index 0 — the same default
// used by `recover_asset_lock_blocking`. The Rust side
// looks up `standard_bip44_accounts.get(&account_index)`
// so a wrong value here would silently drop the
// restore; documented as a known limit until per-row
// `accountIndex` lands on the SwiftData model.
entry.account_index = 0
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Asset-lock restore buffers hardcode account_index = 0 even though the Rust loader uses it as a lookup key

Both buildAssetLockRestoreBuffer (line 2865) and buildUnresolvedAssetLockTxRecordBuffer (line 2965) serialize entry.account_index = 0. The inline comment at line 2862-2864 ("The Rust load path doesn't read this field for anything load-bearing") is incorrect: in packages/rs-platform-wallet-ffi/src/persistence.rs:2324-2336, spec.account_index is stored into TrackedAssetLock.account_index AND used as the key in the per-account BTreeMap. Likewise restore_unresolved_asset_lock_tx_records at persistence.rs:2739 reinserts funding transactions into standard_bip44_accounts.get_mut(&rec.account_index) and drops the row when no matching account exists. The public Swift asset-lock APIs (buildTransaction, createFundedProof, sendToAddresses) all accept arbitrary accountIndex, so any wallet funded from a nonzero BIP44 account silently loses crash-recovery for in-flight asset locks at restart. Today's iOS app happens to use account 0 only, which keeps this latent — but PersistentAssetLock needs a real accountIndex column (mirroring identityIndexRaw) before nonzero BIP44 accounts are supported.

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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] lines 2855-2965: Asset-lock restore buffers hardcode account_index = 0 even though the Rust loader uses it as a lookup key
  Both `buildAssetLockRestoreBuffer` (line 2865) and `buildUnresolvedAssetLockTxRecordBuffer` (line 2965) serialize `entry.account_index = 0`. The inline comment at line 2862-2864 ("The Rust load path doesn't read this field for anything load-bearing") is incorrect: in `packages/rs-platform-wallet-ffi/src/persistence.rs:2324-2336`, `spec.account_index` is stored into `TrackedAssetLock.account_index` AND used as the key in the per-account `BTreeMap`. Likewise `restore_unresolved_asset_lock_tx_records` at `persistence.rs:2739` reinserts funding transactions into `standard_bip44_accounts.get_mut(&rec.account_index)` and drops the row when no matching account exists. The public Swift asset-lock APIs (`buildTransaction`, `createFundedProof`, `sendToAddresses`) all accept arbitrary `accountIndex`, so any wallet funded from a nonzero BIP44 account silently loses crash-recovery for in-flight asset locks at restart. Today's iOS app happens to use account 0 only, which keeps this latent — but `PersistentAssetLock` needs a real `accountIndex` column (mirroring `identityIndexRaw`) before nonzero BIP44 accounts are supported.

Comment on lines 109 to 134
let cStrings = recipients.map { ($0.address as NSString).utf8String }
let amounts = recipients.map { $0.amountDuffs }

// Resolver-backed signer owns mnemonic access for the lifetime
// of this call. Each Core ECDSA signature happens atomically
// inside the resolver vtable (mnemonic fetched, key derived,
// digest signed, buffers zeroed) — no priv key leaves Swift.
let resolver = MnemonicResolver()

try cStrings.withUnsafeBufferPointer { addrBuf in
try amounts.withUnsafeBufferPointer { amountBuf in
try core_wallet_send_to_addresses(
handle,
accountType.rawValue,
accountIndex,
addrBuf.baseAddress,
amountBuf.baseAddress,
UInt(recipients.count),
&txBytesPtr,
&txLen
).check()
try withExtendedLifetime(resolver) {
try core_wallet_send_to_addresses(
handle,
accountType.rawValue,
accountIndex,
addrBuf.baseAddress,
amountBuf.baseAddress,
UInt(recipients.count),
resolver.handle,
&txBytesPtr,
&txLen
).check()
}
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: sendToAddresses passes C-string pointers backed by bridged-NSString temporaries

let cStrings = recipients.map { ($0.address as NSString).utf8String } produces an array of UnsafePointer<CChar>? whose backing UTF-8 buffers are owned by autoreleased NSString temporaries created inside the closure — the array retains the pointers, not the NSString owners. Rust immediately dereferences each with CStr::from_ptr in packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs. In practice within a single synchronous non-@autoreleasepool function the temporaries live until function exit (no pool drain between map and the FFI call), so no concrete misbehavior is reproducible at this SHA. But this is the documented Swift→C anti-pattern: Apple's NSString docs warn that utf8String returns memory "automatically freed just as a returned object would be released," any future refactor introducing an await, withCheckedThrowingContinuation, or nested @autoreleasepool between buffer construction and the FFI call invalidates the pointers, and every other FFI buffer in this file already uses owned strdup/free allocations. Worth tightening because this is a transaction-signing path.

💡 Suggested change
Suggested change
let cStrings = recipients.map { ($0.address as NSString).utf8String }
let amounts = recipients.map { $0.amountDuffs }
// Resolver-backed signer owns mnemonic access for the lifetime
// of this call. Each Core ECDSA signature happens atomically
// inside the resolver vtable (mnemonic fetched, key derived,
// digest signed, buffers zeroed) — no priv key leaves Swift.
let resolver = MnemonicResolver()
try cStrings.withUnsafeBufferPointer { addrBuf in
try amounts.withUnsafeBufferPointer { amountBuf in
try core_wallet_send_to_addresses(
handle,
accountType.rawValue,
accountIndex,
addrBuf.baseAddress,
amountBuf.baseAddress,
UInt(recipients.count),
&txBytesPtr,
&txLen
).check()
try withExtendedLifetime(resolver) {
try core_wallet_send_to_addresses(
handle,
accountType.rawValue,
accountIndex,
addrBuf.baseAddress,
amountBuf.baseAddress,
UInt(recipients.count),
resolver.handle,
&txBytesPtr,
&txLen
).check()
}
}
}
let addressStorage: [UnsafeMutablePointer<CChar>?] = recipients.map { strdup($0.address) }
defer { addressStorage.forEach { if let p = $0 { free(p) } } }
let amounts = recipients.map { $0.amountDuffs }
try addressStorage.withUnsafeBufferPointer { addrBuf in
let addressesPtr: UnsafePointer<UnsafePointer<CChar>?>? = addrBuf.baseAddress.map {
UnsafeRawPointer($0).assumingMemoryBound(to: UnsafePointer<CChar>?.self)
}
try amounts.withUnsafeBufferPointer { amountBuf in
try withExtendedLifetime(resolver) {
try core_wallet_send_to_addresses(
handle,
accountType.rawValue,
accountIndex,
addressesPtr,
amountBuf.baseAddress,
UInt(recipients.count),
resolver.handle,
&txBytesPtr,
&txLen
).check()
}
}
}

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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift`:
- [SUGGESTION] lines 108-134: sendToAddresses passes C-string pointers backed by bridged-NSString temporaries
  `let cStrings = recipients.map { ($0.address as NSString).utf8String }` produces an array of `UnsafePointer<CChar>?` whose backing UTF-8 buffers are owned by autoreleased `NSString` temporaries created inside the closure — the array retains the pointers, not the NSString owners. Rust immediately dereferences each with `CStr::from_ptr` in `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`. In practice within a single synchronous non-`@autoreleasepool` function the temporaries live until function exit (no pool drain between `map` and the FFI call), so no concrete misbehavior is reproducible at this SHA. But this is the documented Swift→C anti-pattern: Apple's NSString docs warn that `utf8String` returns memory "automatically freed just as a returned object would be released," any future refactor introducing an `await`, `withCheckedThrowingContinuation`, or nested `@autoreleasepool` between buffer construction and the FFI call invalidates the pointers, and every other FFI buffer in this file already uses owned `strdup`/`free` allocations. Worth tightening because this is a transaction-signing path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in da7938a013. sendToAddresses now owns the C-string storage via strdup + defer free instead of pointing at bridged NSString temporaries. Eliminates the use-after-free Apple's utf8String docs explicitly warn about.

shumkov and others added 6 commits May 18, 2026 18:59
Reviewer (CodeRabbit, R1) flagged that the public method name
`StateTransition::sign_with_signer` suggests a general-purpose signing
operation while in practice it only produces a meaningful signature
for the four asset-lock-signed variants (`IdentityCreate`,
`IdentityTopUp`, `AddressFundingFromAssetLock`, `ShieldFromAssetLock`).
The proposed fix was a runtime guard that rejects other variants.

After thinking through the architecture, the right answer is rename,
not restrict. The method was introduced one PR ago (992be09,
"signer-based asset-lock identity creation + top-up") as a sibling
of `sign_by_private_key` — the external-custody version of the same
primitive. Both produce byte-identical wrapper signatures over the
same digest when given the same key; they differ only in where the
key bytes live (host memory vs inside an HSM / hardware wallet /
secure enclave / remote signing service). Both intentionally do no
validation of variant or key/transition compatibility — they're
primitives, misuse is a caller responsibility.

What MADE the method look more general than its sibling is its
parameter type: `key_wallet::signer::Signer` operates on BIP32
derivation paths, which only carry semantic meaning for variants
whose wrapper signature is itself a Core-key signature (= the four
asset-lock-signed variants today). Identity-signed variants identify
keys by `IdentityPublicKey` and reach external signers through a
different trait (`Signer<IdentityPublicKey>`) consumed by
`sign_external`. The two families' external-signer entry points
exist for exactly that reason.

Rename to `sign_with_core_signer` so the method's name surfaces the
implicit Core-wallet/BIP32 scope that the parameter type already
imposes. Rustdoc rewritten to spell out:

  - Position in the primitive family (sibling table vs
    `sign_by_private_key`).
  - Byte-parity guarantee with `sign_by_private_key`, pinned by the
    `sign_with_core_signer_matches_sign_by_private_key_byte_for_byte`
    test.
  - Scope semantics (what passing a BIP32 path actually means; why
    identity-signed variants are conceptually wrong consumers but
    not statically blocked, exactly like `sign_by_private_key`).
  - Pointer to `sign_external` as the right entry point for
    identity-signed external-signer flows.

Production callers (2) and the byte-parity test updated to the new
name. No behavior change; no breaking change beyond the symbol rename
(version-bumped `!` per Conventional Commits because the public API
symbol changes).

PR review thread:
#3634 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Batch of four small, independent fixes from PR review:

S2 — ManagedAssetLockManager.swift (3 sites: buildTransaction,
createFundedProof, resume): the `pathPtr.map { String(cString: $0) }
?? ""` pattern swallowed an FFI contract violation. If
`asset_lock_manager_*` returned `Success` with a NULL path pointer,
downstream signing would fail with an opaque "key not found" / "derive
failed" error. Replaced with a `guard let pathPtr, pathPtr.pointee != 0
else throw …` that names the actual contract violation. Reviewer
thread: #3634 (comment)

S3 — ManagedPlatformWallet.swift (2 sites:
registerIdentityWithFunding, resumeIdentityWithAssetLock): added a
`guard outManagedHandle != NULL_HANDLE else throw …` after
`try result.check()` so a hypothetical Success-without-out-handle from
the FFI doesn't construct a `ManagedIdentity(handle: NULL_HANDLE)` that
would crash in `ManagedIdentity.deinit` calling
`managed_identity_destroy(NULL)` or any downstream FFI accessor that
dereferences the slot. Reviewer thread:
#3634 (comment)

S5 — PlatformWalletPersistenceHandler.swift (restore asset-lock
marshalling): replaced `UInt8(clamping: record.fundingTypeRaw /
.statusRaw)` with `UInt8(exactly:)` + NSLog + `continue` on out-of-u8
input. A corrupt persisted row (negative / >255 value, e.g. from
schema migration drift or external DB tampering) no longer silently
coerces to a valid-looking enum value (negative → 0 = Built /
IdentityRegistration; >255 → 255 = sentinel) and restore the wrong
asset-lock state. Bad rows are dropped with a log line carrying the
outpoint and the failing value so an operator can locate and repair
them. Reviewer thread:
#3634 (comment)

S7 — PendingRegistrationsList.swift: `ForEach(active, id:
\.identityIndex)` keyed rows by the per-wallet identity slot, but
controllers are keyed by `(walletId, identityIndex)` in the
coordinator. Two wallets registering identities at the same slot
would collide on the SwiftUI diff and one row would replace / collapse
the other. Switched to a composite `registrationRowID` extension
(`"<walletId-hex>-<identityIndex>"`) that matches the coordinator's
actual key shape. Reviewer thread:
#3634 (comment)

S8 (RegistrationProgressView's `instantLockTimeout: 300.0`)
deliberately NOT changed — the existing value mirrors the Rust IS
wait (`wait_for_proof` is called with `Duration::from_secs(300)`),
which is the right number for this UI step. The 180s
`CL_FALLBACK_TIMEOUT` is a separate post-IS-timeout budget for the
chain-lock fallback path, not the IS wait itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Rust restore path uses `account_index` to reinsert the unresolved
funding tx into `standard_bip44_accounts[account_index]`. The Swift
persister was hardcoding `entry.account_index = 0` at both restore
sites (`buildAssetLockRestoreBuffer` for asset-lock entries,
`buildUnresolvedAssetLockTxRecords` for tx records) because
`PersistentAssetLock` didn't carry the account index. The SDK surface
(`ManagedAssetLockManager.buildTransaction` and friends) already accepts
`accountIndex: UInt32` from callers, so any wallet that funded an asset
lock from a non-zero account had its restore silently dropped — the
Rust lookup `standard_bip44_accounts.get(&0)` would miss when the real
account was, say, 3.

Schema add: `accountIndexRaw: Int32 = 0` on `PersistentAssetLock`.
Default 0 keeps SwiftData's lightweight migration safe for pre-feature
rows — same bytes that the hardcoded restore path was using, so
behavior is preserved bit-exactly for the BIP44-account-0 common case.
Any new upsert (status transition, next register / top-up flow) on a
pre-feature row replaces the column with the real value from the FFI's
`AssetLockEntryFFI.account_index`.

Plumbing in `PlatformWalletPersistenceHandler`:
- `AssetLockEntrySnapshot.accountIndexRaw: Int32` field added.
- Inbound FFI callback (`onPersistAssetLocks` thunk) now copies
  `e.account_index` into the snapshot.
- `persistAssetLocks` upsert + insert paths write it onto the
  `PersistentAssetLock` row.
- `loadCachedAssetLocksOnQueue` includes it in the load-time snapshot.
- Both restore-marshalling sites now use
  `UInt32(bitPattern: record.accountIndexRaw)` instead of `0`.

PR review thread (4 re-runs from the same reviewer, all addressed by
this commit):
#3634 (comment)

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

# Conflicts:
#	Cargo.lock
#	Cargo.toml
Two new review findings (N1, N2) flagged that the IS→CL fallback paths
added in this PR are unreachable for `IdentityFunding::UseAssetLock`:

  - On a 300s IS-timeout from `wait_for_proof`, the resolver tries to
    upgrade via `upgrade_to_chain_lock_proof(out_point, ...)`.
  - On a Platform `InvalidInstantAssetLockProofSignatureError`, the
    submission layer does the same.

`upgrade_to_chain_lock_proof` short-circuits with
`PlatformWalletError::AssetLockProofWait("Asset lock {} is not tracked")`
when the outpoint isn't in `tracked_asset_locks`. `UseAssetLock`
deliberately sets `tracked_out_point: None` (caller-owned lifecycle)
and never inserts into the tracked map. Net effect: when an
externally-supplied IS proof gets stale-rejected by Platform, the
wallet swallows the original SDK error and surfaces a misleading
"is not tracked" failure — masking exactly the case the fallback was
added to handle. Same bug mirrored in `top_up_identity_with_funding`.

Reviewer threads:
- #3634 (comment) (N1)
- #3634 (comment) (N2)

Resolution: delete the variant. Audit confirms zero production
callers across the workspace — `grep -rn UseAssetLock packages/` finds
4 references, all in the variant's own definition or the now-removed
match arm and its doc comments. The variant's own docstring
acknowledged: "In practice this variant is used by callers that
manage asset locks via a sibling component (evo-tool's tasks,
integration tests, etc.). The Swift app's normal flow goes through
`FromWalletBalance` or `FromExistingAssetLock`." None of those sibling
consumers live in this workspace, and a future consumer needing an
external proof should register it through `AssetLockManager` first
then use `FromExistingAssetLock` (which DOES have lifecycle tracking
and therefore working fallback paths).

API impact (`!`): `IdentityFunding` loses a variant. Match-exhaustive
consumers downstream must update. Workspace audit found zero such
consumers; the FFI shape exposed to Swift only references
`FromWalletBalance` and `FromExistingAssetLock`.

Doc comments on `ResolvedFunding.tracked_out_point` and the
post-success cleanup block updated: `tracked_out_point` is now always
`Some` in practice — the `Option` is retained as future-proofing for
variants that might genuinely lack wallet-owned lifecycle, but if
such a variant lands the IS→CL fallback machinery would need a
matching extension (probably an `account_index` parameter on
`upgrade_to_chain_lock_proof` so the helper can drive the wait
without consulting `tracked_asset_locks`).

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

Two unrelated review findings, both real correctness bugs, batched
because they're each one file and touch zero overlapping surface.

N3 — `identity_registration_funded_with_signer.rs` (Rust FFI)

  The asset-lock-funded registration entry point (the new path added
  by this PR for Swift/iOS) decoded Swift's `IdentityPubkeyFFI` rows
  into `IdentityPublicKeyV0 { contract_bounds: None, ... }` — hardcoded
  None for every key, even though the Swift side marshalled
  `contract_bounds_kind`, `contract_bounds_id`, and
  `contract_bounds_document_type`. Encryption / Decryption keys, which
  Drive requires to carry contract bounds, would have been registered
  unbounded and silently unusable.

  Fix: call the existing `decode_contract_bounds` helper (already
  `pub(crate)` and used by the older signer-only registration path).
  Same enforcement applies — `kind == 0` is rejected for
  Encryption/Decryption purposes with a clean FFI error.

  Thread: #3634 (comment)

N4 — `ManagedCoreWallet.swift` (Swift side)

  `sendToAddresses` built the C-string array as:
    `recipients.map { ($0.address as NSString).utf8String }`
  `.utf8String` returns a pointer into the bridged NSString's internal
  buffer. Per Apple docs that pointer has a lifetime "shorter than
  the string object and will certainly not have a longer lifetime."
  The `.map` closure's bridged NSString is a temporary — once the
  closure returns, the NSString can be released, leaving Rust's
  `CStr::from_ptr` dereferencing freed (or autorelease-pool-recycled)
  memory. Use-after-free; the autorelease pool's timing was the only
  thing hiding it.

  Fix: own the C-string storage explicitly with `strdup` + `defer
  free`. Promote owned `UnsafeMutablePointer<CChar>?` to
  `UnsafePointer<CChar>?` for the FFI's `*const *const c_char`
  signature without any `assumingMemoryBound` re-interpretation.

  Thread: #3634 (comment)
  Same bug, suggestion-level dup at
  #3634 (comment)

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

codecov Bot commented May 18, 2026

Codecov Report

❌ Patch coverage is 52.57353% with 129 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.84%. Comparing base (cd93b2f) to head (1e3264d).
⚠️ Report is 8 commits behind head on v3.1-dev.

Files with missing lines Patch % Lines
...entity/identity_create_transition/v0/v0_methods.rs 46.15% 28 Missing ⚠️
.../identity/identity_topup_transition/methods/mod.rs 24.32% 28 Missing ⚠️
...identity/identity_create_transition/methods/mod.rs 31.57% 26 Missing ⚠️
...dentity/identity_topup_transition/v0/v0_methods.rs 4.00% 24 Missing ⚠️
packages/rs-dpp/src/state_transition/mod.rs 74.44% 23 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           v3.1-dev    #3634      +/-   ##
============================================
- Coverage     88.06%   87.84%   -0.23%     
============================================
  Files          2521     2537      +16     
  Lines        308995   311316    +2321     
============================================
+ Hits         272122   273472    +1350     
- Misses        36873    37844     +971     
Components Coverage Δ
dpp 87.83% <46.69%> (-0.18%) ⬇️
drive 87.03% <ø> (-0.03%) ⬇️
drive-abci 90.05% <100.00%> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.17% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 53.13% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

shumkov and others added 3 commits May 18, 2026 23:01
CI's `cargo fmt --check` step on the post-merge workspace flagged
unformatted lines introduced across the asset-lock-funding work that
landed without local fmt runs. No behavior change — purely whitespace /
line-wrap normalization across 15 files in dpp + platform-wallet +
platform-wallet-ffi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two lints surfacing under `cargo clippy --workspace --all-features
--locked -- --no-deps -D warnings` (the CI shape):

1. `packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/v0/v0_methods.rs:12`
   — unused import of `AssetLockProved`. Pre-existing on v3.1-dev,
   pulled into this branch by the v3.1-dev merge.

2. `packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs:99-105`
   — `match Option { Some(x) => Some(f(x)), None => None }` is
   exactly `Option::map`. Refactored.

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

# Conflicts:
#	packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
@github-actions
Copy link
Copy Markdown
Contributor

✅ 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: "6c73d31a78e30c5532b2b6f9adc4af611a03bfa9597576eb0758597b161c7001"
)

Xcode manual integration:

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

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Verified at 1e3264d. All prior blocking findings remain resolved — contract_bounds decoding on the funded-signer FFI path, removal of the unreachable UseAssetLock variant, Consumed-status persistence via self-queued changeset, accountIndexRaw threading through restore buffers, strdup-owned C-strings in sendToAddresses, per-task ARC retention of the catch-up FFI handle, user_fee_increase threading, and the pinned Notified::enable() pattern for the missed-wakeup race. One real logic gap remains in the Swift Resumable Registrations filter (terminal status=4 Consumed locks leak into the UI after local identity delete, showing a perpetual spinner). Three suggestion-severity items: a duplicate FFI pubkey decoder (already drifted once), a defense-in-depth zeroization gap in derive_priv, and missing test coverage for the new CL-height retry fee-bump loop.

Reviewed commit: 1e3264d

🟡 4 suggestion(s)

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

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift`:
- [SUGGESTION] line 37: isVisibleAsResumable accepts Consumed (4), leaking terminal locks into Resumable Registrations after a local identity delete
  `isVisibleAsResumable: statusRaw >= 1` has no upper bound, and `IdentitiesContentView.crossWalletResumableLocks` (IdentitiesContentView.swift:304) uses the same `statusRaw >= 1` floor. The new `AssetLockStatus::Consumed = 4` discriminant introduced by this PR is terminal — the inline doc at lines 26–28 even says "Consumed (4) was already used to fund an identity and cannot be reused; the persisted row survives only for historical lookup" — so it should not surface in the resume UI.

In the happy path this is masked by the `(walletId, identityIndex)` anti-join because `PersistentIdentity` claims the slot. But IdentitiesContentView exposes a 'Delete a single identity locally' action (IdentitiesContentView.swift:331+) whose doc-comment explicitly says it does NOT touch the funding `PersistentAssetLock`. After such a delete the slot frees up, the Consumed lock re-enters the cross-wallet list, and the row falls into the `else` branch of `trailingAffordance` (IdentitiesContentView.swift:535–551) because `canFundIdentity` (correctly) rejects status 4 — producing a perpetual `ProgressView` labelled "Waiting for InstantSendLock or ChainLock…" for a lock that can never be resumed. Tapping through would also error out: `resume_asset_lock` rejects Consumed entries with `AssetLockProofWait("Asset lock {} is already Consumed — nothing to resume")`.

The new test `testForwardCompatibleStatusFloor` (CreateIdentityResumableTests.swift:93–100) actively pins the wrong behavior — it asserts `statusRaw=4` surfaces, with a comment treating 4 as a hypothetical future value. Now that 4 IS Consumed, both the filter and the test need updating. Add the same bound to `crossWalletResumableLocks` for consistency.

In `packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs`:
- [SUGGESTION] lines 54-110: Duplicate unsafe IdentityPubkeyFFI decoder — sibling registration path already diverged once on contract_bounds
  `decode_identity_pubkeys()` here reconstructs `IdentityPublicKeyV0` with the same field-by-field logic that already exists in `packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs:349-379`. The earlier `contract_bounds` regression occurred precisely because one decoder evolved while the other did not — both now call `decode_contract_bounds(...)` but the surrounding row→key construction (KeyType/Purpose/SecurityLevel validation, null-buffer check, `IdentityPublicKeyV0` field mapping) is still duplicated across two unsafe FFI entrypoints. Because semantic drift here silently changes the on-chain meaning of Swift-supplied keys, the safer shape is a single shared decoder helper for `IdentityPubkeyFFI` rows that both entrypoints call, so future field additions cannot land in only one path. The error-surfacing styles differ (`Result<_, PlatformWalletFFIResult>` here vs. `unwrap_result_or_return!` there), but that can be normalized to the `Result` form and `unwrap_result_or_return!`-ed at the single remaining call site.

In `packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`:
- [SUGGESTION] lines 277-288: derive_priv leaves BIP-32 ExtendedPrivKey SecretKey scalars unwiped in memory
  `derive_priv` builds `master: ExtendedPrivKey` and `derived: ExtendedPrivKey`, each owning a `secp256k1::SecretKey` containing a 32-byte private scalar. `SecretKey` does NOT zeroize on drop in the `secp256k1` crate — that is exactly why `sign_ecdsa`/`public_key` later call `non_secure_erase()` on their reconstructed `secret` before drop. The module doc-comment (lines 39–44) explicitly promises that "Every intermediate that carries key material … is wrapped in Zeroizing and dropped before the method returns," but neither ExtendedPrivKey is wiped — both go out of scope still holding live scalars. The canonical copy is already `Zeroizing<[u8;32]>` and returned to the caller, so this is defense-in-depth divergence rather than a fresh exposure, but it weakens the documented invariant. Calling `non_secure_erase()` on `derived.private_key` and `master.private_key` before they drop (or shadowing them through a local SecretKey you erase explicitly) brings the implementation back in line with the doc.

In `packages/rs-platform-wallet/src/wallet/identity/network/registration.rs`:
- [SUGGESTION] lines 156-215: submit_with_cl_height_retry has no dedicated test pinning the fee-bump behavior
  `submit_with_cl_height_retry()` is load-bearing for crash-free identity registration/top-up under consensus code 10506, and this PR series already regressed it once (`user_fee_increase` was threaded incorrectly before being fixed). The existing tests only pin the timeout discriminator; there is no deterministic unit test that feeds a stub submit closure returning repeated `InvalidAssetLockProofCoreChainHeightError`s and asserts (a) successive attempts receive incremented `PutSettings::user_fee_increase` (hash-cache avoidance depends on this), and (b) the original error surfaces once `CL_HEIGHT_RETRY_BUDGET` is exhausted. The retry logic is entirely local and time-driven, so a paused-Tokio test would be cheap and would directly protect Tenderdash hash-cache avoidance behavior from a future silent regression.

/// should see it (as "Waiting for InstantSendLock…") so an
/// in-flight crash-recovery situation has visible continuity
/// through the IS-lock arrival.
var isVisibleAsResumable: Bool { statusRaw >= 1 }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: isVisibleAsResumable accepts Consumed (4), leaking terminal locks into Resumable Registrations after a local identity delete

isVisibleAsResumable: statusRaw >= 1 has no upper bound, and IdentitiesContentView.crossWalletResumableLocks (IdentitiesContentView.swift:304) uses the same statusRaw >= 1 floor. The new AssetLockStatus::Consumed = 4 discriminant introduced by this PR is terminal — the inline doc at lines 26–28 even says "Consumed (4) was already used to fund an identity and cannot be reused; the persisted row survives only for historical lookup" — so it should not surface in the resume UI.

In the happy path this is masked by the (walletId, identityIndex) anti-join because PersistentIdentity claims the slot. But IdentitiesContentView exposes a 'Delete a single identity locally' action (IdentitiesContentView.swift:331+) whose doc-comment explicitly says it does NOT touch the funding PersistentAssetLock. After such a delete the slot frees up, the Consumed lock re-enters the cross-wallet list, and the row falls into the else branch of trailingAffordance (IdentitiesContentView.swift:535–551) because canFundIdentity (correctly) rejects status 4 — producing a perpetual ProgressView labelled "Waiting for InstantSendLock or ChainLock…" for a lock that can never be resumed. Tapping through would also error out: resume_asset_lock rejects Consumed entries with AssetLockProofWait("Asset lock {} is already Consumed — nothing to resume").

The new test testForwardCompatibleStatusFloor (CreateIdentityResumableTests.swift:93–100) actively pins the wrong behavior — it asserts statusRaw=4 surfaces, with a comment treating 4 as a hypothetical future value. Now that 4 IS Consumed, both the filter and the test need updating. Add the same bound to crossWalletResumableLocks for consistency.

💡 Suggested change
Suggested change
var isVisibleAsResumable: Bool { statusRaw >= 1 }
var isVisibleAsResumable: Bool { statusRaw >= 1 && statusRaw <= 3 }

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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift`:
- [SUGGESTION] line 37: isVisibleAsResumable accepts Consumed (4), leaking terminal locks into Resumable Registrations after a local identity delete
  `isVisibleAsResumable: statusRaw >= 1` has no upper bound, and `IdentitiesContentView.crossWalletResumableLocks` (IdentitiesContentView.swift:304) uses the same `statusRaw >= 1` floor. The new `AssetLockStatus::Consumed = 4` discriminant introduced by this PR is terminal — the inline doc at lines 26–28 even says "Consumed (4) was already used to fund an identity and cannot be reused; the persisted row survives only for historical lookup" — so it should not surface in the resume UI.

In the happy path this is masked by the `(walletId, identityIndex)` anti-join because `PersistentIdentity` claims the slot. But IdentitiesContentView exposes a 'Delete a single identity locally' action (IdentitiesContentView.swift:331+) whose doc-comment explicitly says it does NOT touch the funding `PersistentAssetLock`. After such a delete the slot frees up, the Consumed lock re-enters the cross-wallet list, and the row falls into the `else` branch of `trailingAffordance` (IdentitiesContentView.swift:535–551) because `canFundIdentity` (correctly) rejects status 4 — producing a perpetual `ProgressView` labelled "Waiting for InstantSendLock or ChainLock…" for a lock that can never be resumed. Tapping through would also error out: `resume_asset_lock` rejects Consumed entries with `AssetLockProofWait("Asset lock {} is already Consumed — nothing to resume")`.

The new test `testForwardCompatibleStatusFloor` (CreateIdentityResumableTests.swift:93–100) actively pins the wrong behavior — it asserts `statusRaw=4` surfaces, with a comment treating 4 as a hypothetical future value. Now that 4 IS Consumed, both the filter and the test need updating. Add the same bound to `crossWalletResumableLocks` for consistency.

Comment on lines +54 to +110
unsafe fn decode_identity_pubkeys(
identity_pubkeys: *const IdentityPubkeyFFI,
identity_pubkeys_count: usize,
) -> Result<BTreeMap<u32, IdentityPublicKey>, PlatformWalletFFIResult> {
let pubkey_rows: &[IdentityPubkeyFFI] =
slice::from_raw_parts(identity_pubkeys, identity_pubkeys_count);
let mut keys_map: BTreeMap<u32, IdentityPublicKey> = BTreeMap::new();
for (i, row) in pubkey_rows.iter().enumerate() {
let key_type = KeyType::try_from(row.key_type).map_err(|e| {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!("identity_pubkeys[{i}].key_type invalid: {e}"),
)
})?;
let purpose = Purpose::try_from(row.purpose).map_err(|e| {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!("identity_pubkeys[{i}].purpose invalid: {e}"),
)
})?;
let security_level = SecurityLevel::try_from(row.security_level).map_err(|e| {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!("identity_pubkeys[{i}].security_level invalid: {e}"),
)
})?;
if row.pubkey_bytes.is_null() || row.pubkey_len == 0 {
return Err(PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorNullPointer,
format!("identity_pubkeys[{i}].pubkey_bytes is null or empty"),
));
}
let pubkey_bytes: Vec<u8> =
slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec();
// ContractBounds round-trip: decode the kind/id/document_type
// tuple the Swift side marshalled, with the same enforcement
// the signer-only registration path uses (Encryption /
// Decryption purposes must carry bounds; kind 0 for those is
// rejected with a clean FFI error rather than producing a key
// Drive silently can't use).
let contract_bounds = decode_contract_bounds(row, purpose, i, "identity_pubkeys")?;
keys_map.insert(
row.key_id,
IdentityPublicKey::V0(IdentityPublicKeyV0 {
id: row.key_id,
purpose,
security_level,
contract_bounds,
key_type,
read_only: row.read_only,
data: BinaryData::new(pubkey_bytes),
disabled_at: None,
}),
);
}
Ok(keys_map)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Duplicate unsafe IdentityPubkeyFFI decoder — sibling registration path already diverged once on contract_bounds

decode_identity_pubkeys() here reconstructs IdentityPublicKeyV0 with the same field-by-field logic that already exists in packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs:349-379. The earlier contract_bounds regression occurred precisely because one decoder evolved while the other did not — both now call decode_contract_bounds(...) but the surrounding row→key construction (KeyType/Purpose/SecurityLevel validation, null-buffer check, IdentityPublicKeyV0 field mapping) is still duplicated across two unsafe FFI entrypoints. Because semantic drift here silently changes the on-chain meaning of Swift-supplied keys, the safer shape is a single shared decoder helper for IdentityPubkeyFFI rows that both entrypoints call, so future field additions cannot land in only one path. The error-surfacing styles differ (Result<_, PlatformWalletFFIResult> here vs. unwrap_result_or_return! there), but that can be normalized to the Result form and unwrap_result_or_return!-ed at the single remaining call site.

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-ffi/src/identity_registration_funded_with_signer.rs`:
- [SUGGESTION] lines 54-110: Duplicate unsafe IdentityPubkeyFFI decoder — sibling registration path already diverged once on contract_bounds
  `decode_identity_pubkeys()` here reconstructs `IdentityPublicKeyV0` with the same field-by-field logic that already exists in `packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs:349-379`. The earlier `contract_bounds` regression occurred precisely because one decoder evolved while the other did not — both now call `decode_contract_bounds(...)` but the surrounding row→key construction (KeyType/Purpose/SecurityLevel validation, null-buffer check, `IdentityPublicKeyV0` field mapping) is still duplicated across two unsafe FFI entrypoints. Because semantic drift here silently changes the on-chain meaning of Swift-supplied keys, the safer shape is a single shared decoder helper for `IdentityPubkeyFFI` rows that both entrypoints call, so future field additions cannot land in only one path. The error-surfacing styles differ (`Result<_, PlatformWalletFFIResult>` here vs. `unwrap_result_or_return!` there), but that can be normalized to the `Result` form and `unwrap_result_or_return!`-ed at the single remaining call site.

Comment on lines +277 to +288
let master = ExtendedPrivKey::new_master(self.network, seed.as_ref())
.map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")))?;
let derived = master
.derive_priv(&secp, path)
.map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?;

// `secret_bytes()` returns a plain `[u8; 32]`; wrap in
// `Zeroizing` so the caller (and any panic-unwind path)
// wipes it on drop.
let bytes = Zeroizing::new(derived.private_key.secret_bytes());
Ok(bytes)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: derive_priv leaves BIP-32 ExtendedPrivKey SecretKey scalars unwiped in memory

derive_priv builds master: ExtendedPrivKey and derived: ExtendedPrivKey, each owning a secp256k1::SecretKey containing a 32-byte private scalar. SecretKey does NOT zeroize on drop in the secp256k1 crate — that is exactly why sign_ecdsa/public_key later call non_secure_erase() on their reconstructed secret before drop. The module doc-comment (lines 39–44) explicitly promises that "Every intermediate that carries key material … is wrapped in Zeroizing and dropped before the method returns," but neither ExtendedPrivKey is wiped — both go out of scope still holding live scalars. The canonical copy is already Zeroizing<[u8;32]> and returned to the caller, so this is defense-in-depth divergence rather than a fresh exposure, but it weakens the documented invariant. Calling non_secure_erase() on derived.private_key and master.private_key before they drop (or shadowing them through a local SecretKey you erase explicitly) brings the implementation back in line with the doc.

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-sdk-ffi/src/mnemonic_resolver_core_signer.rs`:
- [SUGGESTION] lines 277-288: derive_priv leaves BIP-32 ExtendedPrivKey SecretKey scalars unwiped in memory
  `derive_priv` builds `master: ExtendedPrivKey` and `derived: ExtendedPrivKey`, each owning a `secp256k1::SecretKey` containing a 32-byte private scalar. `SecretKey` does NOT zeroize on drop in the `secp256k1` crate — that is exactly why `sign_ecdsa`/`public_key` later call `non_secure_erase()` on their reconstructed `secret` before drop. The module doc-comment (lines 39–44) explicitly promises that "Every intermediate that carries key material … is wrapped in Zeroizing and dropped before the method returns," but neither ExtendedPrivKey is wiped — both go out of scope still holding live scalars. The canonical copy is already `Zeroizing<[u8;32]>` and returned to the caller, so this is defense-in-depth divergence rather than a fresh exposure, but it weakens the documented invariant. Calling `non_secure_erase()` on `derived.private_key` and `master.private_key` before they drop (or shadowing them through a local SecretKey you erase explicitly) brings the implementation back in line with the doc.

Comment on lines +156 to +215
async fn submit_with_cl_height_retry<F, Fut, R>(
mut settings: Option<PutSettings>,
submit: F,
) -> Result<R, dash_sdk::Error>
where
F: Fn(Option<PutSettings>) -> Fut,
Fut: std::future::Future<Output = Result<R, dash_sdk::Error>>,
{
let started = tokio::time::Instant::now();
let deadline = started + CL_HEIGHT_RETRY_BUDGET;
let mut attempt: u32 = 0;
loop {
attempt += 1;
match submit(settings).await {
Ok(r) => return Ok(r),
Err(e) => {
let Some(detail) = as_asset_lock_proof_cl_height_too_low(&e) else {
return Err(e);
};
let elapsed = started.elapsed();
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
tracing::error!(
"Platform rejected ChainLock proof: CL height too low \
(proof claimed height={}, Platform tip={}, attempt {}, \
elapsed {}s) — retry budget of {}s exhausted; surfacing \
error. Platform's reported tip is stuck — likely a lagging \
or misbehaving DAPI node.",
detail.proof_core_chain_locked_height(),
detail.current_core_chain_locked_height(),
attempt,
elapsed.as_secs(),
CL_HEIGHT_RETRY_BUDGET.as_secs(),
);
return Err(e);
}
let sleep_for = remaining.min(CL_HEIGHT_RETRY_DELAY);
tracing::warn!(
"Platform rejected ChainLock proof: CL height too low \
(proof claimed height={}, Platform tip={}, attempt {}, \
elapsed {}s); bumping user_fee_increase and waiting {}s \
before retry",
detail.proof_core_chain_locked_height(),
detail.current_core_chain_locked_height(),
attempt,
elapsed.as_secs(),
sleep_for.as_secs(),
);
settings = Some(bump_user_fee_increase(settings.unwrap_or_default()));
tokio::time::sleep(sleep_for).await;
}
}
}
}

/// Bump `user_fee_increase` by 1 (saturating at `u16::MAX`).
fn bump_user_fee_increase(mut settings: PutSettings) -> PutSettings {
settings.user_fee_increase = Some(settings.user_fee_increase.unwrap_or(0).saturating_add(1));
settings
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: submit_with_cl_height_retry has no dedicated test pinning the fee-bump behavior

submit_with_cl_height_retry() is load-bearing for crash-free identity registration/top-up under consensus code 10506, and this PR series already regressed it once (user_fee_increase was threaded incorrectly before being fixed). The existing tests only pin the timeout discriminator; there is no deterministic unit test that feeds a stub submit closure returning repeated InvalidAssetLockProofCoreChainHeightErrors and asserts (a) successive attempts receive incremented PutSettings::user_fee_increase (hash-cache avoidance depends on this), and (b) the original error surfaces once CL_HEIGHT_RETRY_BUDGET is exhausted. The retry logic is entirely local and time-driven, so a paused-Tokio test would be cheap and would directly protect Tenderdash hash-cache avoidance behavior from a future silent regression.

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/identity/network/registration.rs`:
- [SUGGESTION] lines 156-215: submit_with_cl_height_retry has no dedicated test pinning the fee-bump behavior
  `submit_with_cl_height_retry()` is load-bearing for crash-free identity registration/top-up under consensus code 10506, and this PR series already regressed it once (`user_fee_increase` was threaded incorrectly before being fixed). The existing tests only pin the timeout discriminator; there is no deterministic unit test that feeds a stub submit closure returning repeated `InvalidAssetLockProofCoreChainHeightError`s and asserts (a) successive attempts receive incremented `PutSettings::user_fee_increase` (hash-cache avoidance depends on this), and (b) the original error surfaces once `CL_HEIGHT_RETRY_BUDGET` is exhausted. The retry logic is entirely local and time-driven, so a paused-Tokio test would be cheap and would directly protect Tenderdash hash-cache avoidance behavior from a future silent regression.

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

Labels

None yet

Projects

None yet

2 participants