feat: identity registration with asset-lock proofs#3634
Conversation
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>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThis PR refactors identity registration and top-up to external-signer flows by establishing a shared mnemonic-resolver FFI layer in ChangesMnemonic Resolver FFI and Core Signer
🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
`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>
Review GateCommit:
|
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift (1)
165-165:⚠️ Potential issue | 🟠 Major | ⚡ Quick winSame delete-then-add bug still present in
storePrivateKeyNonisolatedandstoreKeyData.The fix at lines 627-645 acknowledges that including
kSecValueData/kSecAttrGenericin aSecItemDeletequery causes stale rows to survive when the new payload differs, ultimately leading toerrSecDuplicateItemon the subsequentSecItemAdd. The same pattern is still present in two sibling functions and will fail identically under the same conditions:
storePrivateKeyNonisolated(line 165):SecItemDelete(query as CFDictionary)is called with the fullquerythat includeskSecValueData(line 143) andkSecAttrGeneric(line 156). This path is reachable viastorePrivateKey(line 108) and is still the legacy identity-id+keyIndex writer.storeKeyData(line 381):SecItemDelete(query as CFDictionary)is called with aquerythat includeskSecValueData(line 372). This is the backing store forstoreSpecialKey(line 287).Apply the same attribute-only delete query pattern in both functions so the fix is consistent across the writer surface.
🛡️ Proposed fix for both sibling functions
@@ storePrivateKeyNonisolated @@ - // Delete any existing item first - SecItemDelete(query as CFDictionary) + // Delete any existing item first. Use an attribute-only + // query so kSecValueData/kSecAttrGeneric don't act as + // value filters and leave stale rows behind on update. + var deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: keyIdentifier + ] + if let accessGroup = accessGroup { + deleteQuery[kSecAttrAccessGroup as String] = accessGroup + } + SecItemDelete(deleteQuery as CFDictionary)@@ storeKeyData @@ - SecItemDelete(query as CFDictionary) + var deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: identifier + ] + if let accessGroup = accessGroup { + deleteQuery[kSecAttrAccessGroup as String] = accessGroup + } + SecItemDelete(deleteQuery as CFDictionary)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift` at line 165, The SecItemDelete call in storePrivateKeyNonisolated and storeKeyData currently uses the full query that contains kSecValueData (and kSecAttrGeneric in storePrivateKeyNonisolated), which can leave stale rows and cause errSecDuplicateItem on SecItemAdd; change both deletes to use an attribute-only delete query (include kSecClass and the identifying attributes such as kSecAttrAccount, kSecAttrService, kSecAttrLabel, kSecAttrAccessible, etc., but omit kSecValueData and kSecAttrGeneric) so the existing item is removed based on its attributes before calling SecItemAdd, matching the pattern used in the earlier fix for the other writer.packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift (1)
199-235:⚠️ Potential issue | 🟠 Major | ⚡ Quick winPlatform-payment success currently has no terminal UI path.
submitPlatformPaymentsetscreatedIdentityId, but the view only renders completion/failure UI throughactiveController(terminalSection). For Platform-payment,activeControlleris never set, so a successful create returns users to the same form with no success confirmation or completion action.Suggested minimal fix
@@ try await MainActor.run { try persistCreatedIdentity( identityId: created.identityId, network: network ) @@ try modelContext.save() self.createdIdentityId = created.identityId self.isCreating = false + dismiss() }Also applies to: 624-629, 844-906
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift` around lines 199 - 235, The view never shows a terminal UI when submitPlatformPayment succeeds because successful platform payments set createdIdentityId but never set activeController; update the view logic (around the conditional rendering that checks activeController / preselectedAssetLock) to also treat a non-nil createdIdentityId as a terminal state by rendering terminalSection(for:) (or a small completion-only section) when createdIdentityId != nil, or alternatively set activeController to a completion-state controller inside submitPlatformPayment after success; reference submitPlatformPayment, createdIdentityId, activeController, RegistrationProgressSection, and terminalSection(for:) to locate code to change and ensure the submit button path for platform payments either shows the same success banner and actions or dismisses the sheet appropriately.
🧹 Nitpick comments (12)
packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md (4)
1056-1071: ⚡ Quick winUpdate open questions with implementation outcomes.
Several questions in this section were resolved during implementation:
- Line 1058 asks about 100,000 duffs default, but line 1054 documents that
defaultCoreFundingDuffswas set to 250,000 duffs in commit3d16a31a.- Line 1059-1060 asks about asset-lock minimum, but line 1054 shows
minIdentityFundingDuffswas set to 221,500 duffs.📝 Suggested update
## Open questions -- **Default funding amount**: 100,000 duffs (0.001 DASH)? -- **Asset-lock minimum constant**: name + value, verify < - testnet faucet typical payout (per adversarial review W8). +- **Default funding amount**: ✅ Resolved - set to 250,000 duffs in commit 3d16a31a (iter SPV follow-up). +- **Asset-lock minimum constant**: ✅ Resolved - `minIdentityFundingDuffs` set to 221,500 duffs (identity_create_base_cost + asset_lock_base * CREDITS_PER_DUFF + identity_key_in_creation_cost * 3 for defaultKeyCount=3). - **Key count**: stick with `defaultKeyCount = 3` (1 master + 2 high), or expose a picker?🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md` around lines 1056 - 1071, Update the "Open questions" section to reflect resolved decisions: replace the question about default funding amount with a statement that defaultCoreFundingDuffs was set to 250,000 duffs (commit 3d16a31a), and replace the asset-lock minimum question with a note that minIdentityFundingDuffs was set to 221,500 duffs; remove or mark as resolved the items referencing those values (lines mentioning 100,000 duffs and asset-lock minimum) and keep the remaining open questions (key count and AssetLockStatus extension) unchanged.
147-149: ⚡ Quick winUpdate iteration number reference for clarity.
Line 147 states "Resolved in iter 2" but the SwiftData mirror work was actually delivered in iteration 3 (which was renumbered from the original iter 2 after the mid-iter-1 signer discovery). Line 583 confirms this: "### Iter 3 — SwiftData mirror + persister callback (was iter 2)".
📝 Suggested clarification
-**Resolved in iter 2.** From iter 2 onward, a `PersistentAssetLock` +**Resolved in iter 3 (originally planned as iter 2).** From iter 3 onward, a `PersistentAssetLock` SwiftData model mirrors `TrackedAssetLock` via a new FFI persister callback.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md` around lines 147 - 149, Update the iteration reference from "iter 2" to "iter 3" in the sentence that currently reads "Resolved in iter 2" so it matches the later note "### Iter 3 — SwiftData mirror + persister callback (was iter 2)"; ensure the wording still indicates that the PersistentAssetLock SwiftData model mirrors TrackedAssetLock via the FFI persister callback and that this work was delivered in iteration 3.
992-992: 💤 Low valueClarify "bug repro" scenario intent.
Scenario 2 is labeled "🔴 bug repro" but describes the correct expected behavior ("must NOT appear in Resumable Registrations"). Is this testing a regression (preventing a past bug from returning), or was this a bug discovered during UAT?
📝 Suggested clarification
Consider either:
- If it's a regression test: "Regression guard — Start a fresh registration..."
- If it's a discovered bug: Add a note indicating whether the bug was fixed: "🔴 bug fixed — ..." or "🔴 known issue — ..."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md` at line 992, Update the wording for Scenario 2 (currently labeled "🔴 bug repro") to clearly state intent: if this is preventing a previously fixed regression, rename to "Regression guard — Start a fresh registration..." ; if it documents a discovered/fixed issue, use "🔴 bug fixed — ..." or "🔴 known issue — ..." and add a short note whether the bug was fixed; ensure the description still states that the in-flight slot must not appear in Resumable Registrations and reference the related code check (Phase.isActive + identity-slot union in ResumableRegistrationsList, commit 02a15497c6) so readers know the behavioral contract being validated.
417-417: ⚡ Quick winUpdate cross-reference to SPV follow-up section.
The reference "See § Iter 5 / SPV event-routing follow-up" points to a section that is actually titled "### SPV event-routing follow-up — RESOLVED (2026-05-13)" at lines 1048-1054, not within iter 5.
📝 Suggested fix
-Diagnosed as either testnet masternode silence or a regression in dash-spv → wallet integration (likely from recent rust-dashcore bumps). **Iter 4's auto IS→CL fallback was triggered but ALSO timed out** because the chain-lock event never propagated to our poll either. See § Iter 5 / SPV event-routing follow-up. +Diagnosed as either testnet masternode silence or a regression in dash-spv → wallet integration (likely from recent rust-dashcore bumps). **Iter 4's auto IS→CL fallback was triggered but ALSO timed out** because the chain-lock event never propagated to our poll either. See § SPV event-routing follow-up.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md` at line 417, Update the cross-reference text "See § Iter 5 / SPV event-routing follow-up" so it points to the actual section heading "SPV event-routing follow-up — RESOLVED (2026-05-13)" used later in the document; locate the string in the Step 13 paragraph and replace it with a clear reference matching the exact heading text ("See § SPV event-routing follow-up — RESOLVED (2026-05-13)"), or alternatively change the reference to "See § SPV event-routing follow-up" if you prefer a shorter match that exactly equals the section title "SPV event-routing follow-up — RESOLVED (2026-05-13)" to ensure the cross-reference resolves correctly..claude/skills/simulator-control/SKILL.md (2)
66-81: ⚡ Quick winConsider filtering stderr to prevent JSON parse failures.
The current implementation pipes both stdout and stderr (
2>&1) into Python'sjson.loads(). Ifidb ui describe-allemits any warnings or errors to stderr, the JSON parsing will fail.🛡️ Suggested hardening
tap_label() { local label="$1" local udid=$(xcrun simctl list devices booted | awk -F'[()]' '/Booted/ {print $2}') - LABEL="$label" UDID="$udid" "$HOME/.local/bin/idb" ui describe-all --udid "$udid" 2>&1 \ + LABEL="$label" UDID="$udid" "$HOME/.local/bin/idb" ui describe-all --udid "$udid" 2>/dev/null \ | python3 -c " import json, os, subprocess, sys items = json.loads(sys.stdin.read())Or capture stderr separately if diagnostics are needed:
local output=$("$HOME/.local/bin/idb" ui describe-all --udid "$udid" 2>/tmp/idb_err.log) LABEL="$label" UDID="$udid" echo "$output" | python3 -c "..."🤖 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 @.claude/skills/simulator-control/SKILL.md around lines 66 - 81, The piped command currently redirects stderr into stdout (the "2>&1" around the call to "$HOME/.local/bin/idb" ui describe-all) so the inline Python (json.loads on sys.stdin) can receive non-JSON diagnostics and fail; change the shell pipeline to separate or filter stderr before passing JSON to the python snippet: remove or modify the "2>&1" redirection on the idb ui describe-all call and instead capture only its stdout (or redirect stderr to a log/pipe) so the python3 -c block that calls json.loads(...) receives clean JSON; update references around LABEL/UDID and the subprocess.run(...) invocation accordingly to still use the UDID env var and perform the tap.
150-152: 💤 Low valueConsider disambiguating when multiple "Resume" buttons exist.
Line 152 taps the first "Resume" button in the accessibility tree. If the UI contains multiple resumable registrations, this may tap an unintended row.
The comment acknowledges this limitation. For more precise automation, consider filtering by both label and frame.y coordinate range, or waiting for the specific outpoint substring to appear in the accessibility tree before tapping its associated Resume button (using the pattern from Workflow C, lines 157-163).
🤖 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 @.claude/skills/simulator-control/SKILL.md around lines 150 - 152, The tap_label "Resume" call is ambiguous when multiple Resume buttons exist; instead locate the specific Resume button associated with the intended registration by first waiting for the unique outpoint substring to appear in the accessibility tree (as done in Workflow C lines 157-163) or by filtering candidate nodes by their frame.y coordinate range, then tap that specific node rather than calling tap_label "Resume" blindly; update the sequence around tap_label "Identities" / tap_label "Resume" to perform the wait-and-find (or y-range filter) and then invoke the tap on the resolved node.packages/rs-platform-wallet/src/wallet/asset_lock/build.rs (1)
110-134: 💤 Low valueConsider validating exactly one credit-output key is returned.
The code handles the zero-keys case with an error but silently drops any extra keys beyond the first via
drain().next(). While the builder contract should guarantee a single key per funding output, making this invariant explicit would improve clarity.💡 Optional validation
let path = match result.keys { AssetLockCreditKeys::Public(mut keys) => { + if keys.len() != 1 { + return Err(PlatformWalletError::AssetLockTransaction( + format!("Builder returned {} credit-output keys; expected exactly 1", keys.len()) + )); + } let (_pubkey, path) = keys.drain(..).next().ok_or_else(|| {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/rs-platform-wallet/src/wallet/asset_lock/build.rs` around lines 110 - 134, The code currently takes the first key from result.keys (AssetLockCreditKeys::Public) and ignores any extras; change the match arm to explicitly validate there is exactly one credit-output key: inspect the collection (e.g., check keys.len() or pattern-match on a single-element Vec) and return a PlatformWalletError::AssetLockTransaction if it is empty or contains >1 entries, then return the single path; keep the existing error for the Private variant and still return Ok((result.transaction, path)) on success.packages/rs-platform-wallet/src/wallet/core/broadcast.rs (1)
39-41: 💤 Low valueDoc link target looks wrong.
The intra-doc link points at
crate::wallet::asset_lock::buildforMnemonicResolverCoreSigner, but per the PR description that type lives inplatform-wallet-ffi, not in this crate'sasset_lock::buildmodule. Consider dropping the path link (since the type isn't reachable from this crate) or rewording to plain prose referring toplatform-wallet-ffi.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/rs-platform-wallet/src/wallet/core/broadcast.rs` around lines 39 - 41, The intra-doc link for MnemonicResolverCoreSigner is incorrect: it points to crate::wallet::asset_lock::build but the type lives in platform-wallet-ffi; update the doc comment (the lines referencing `MnemonicResolverCoreSigner` and `crate::wallet::asset_lock::build`) to remove or change the intra-doc path — either drop the link and refer to the type in plain prose ("MnemonicResolverCoreSigner from platform-wallet-ffi") or point users to the external crate; ensure you do not keep the unreachable `crate::wallet::asset_lock::build` link.packages/rs-platform-wallet/src/wallet/identity/network/registration.rs (3)
419-440: 💤 Low valueTwo sequential 180s waits on the IS→CL fallback path may double the user-visible delay.
After
upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT)succeeds,resume_asset_lock(&out_point, CL_FALLBACK_TIMEOUT)is called purely to re-derive the credit-outputpath. The inline comment correctly notes this should "short-circuit to the existing-proof branch" — but theCL_FALLBACK_TIMEOUT(180 s) is passed as the wait budget anyway, so if the short-circuit assumption ever breaks (e.g. a regression inresume_asset_lock's state machine) users could wait up to 360 s total before observing the failure. Consider passingDuration::ZERO/a small bound here to make the "no wait expected" invariant load-bearing, or split out a path-only helper.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/rs-platform-wallet/src/wallet/identity/network/registration.rs` around lines 419 - 440, The code calls asset_locks.upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT) and then immediately calls asset_locks.resume_asset_lock(&out_point, CL_FALLBACK_TIMEOUT) just to re-derive the credit-output path, which can double user-visible wait time if the short-circuit assumption breaks; change resume_asset_lock usage so it does not wait: either call a new "path-only" helper on asset_locks that derives the path without any SPV wait, or pass a zero/very-small timeout (e.g. Duration::ZERO) instead of CL_FALLBACK_TIMEOUT when calling resume_asset_lock to make the "no wait expected" invariant load-bearing, leaving upgrade_to_chain_lock_proof and ResolvedFunding construction unchanged.
224-275: ⚡ Quick winL1 doc claims a defensive check that isn't actually here.
The doc says "The first key (id=0) MUST be a MASTER + AUTHENTICATION key — this is enforced here defensively so a malformed map fails fast", but the body only constructs
IdentityV0fromkeys_mapand submits. The actual MASTER/AUTHENTICATION enforcement lives in the L2 orchestrator (register_identity_with_funding, lines 374-393), so callers that invoke the L1 primitive directly (the doc explicitly advertises this for evo-tool/integration tests) get no validation. Either move the check down into the L1 primitive (recommended, since the comment at lines 442-448 says L2 deliberately bypasses L1 only to avoid the by-valuekeys_map) or reword the doc to make clear the check is L2-only.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/rs-platform-wallet/src/wallet/identity/network/registration.rs` around lines 224 - 275, The docstring incorrectly claims a defensive check for the first key but none exists; update register_identity_with_signer to enforce that keys_map contains an entry with id 0 and that that IdentityPublicKey has the required MASTER key type and AUTHENTICATION purpose (e.g., check keys_map.get(&0) and validate its key_type/purpose fields), returning an appropriate dash_sdk::Error if missing/invalid; alternatively, if you prefer not to add logic here, change the docstring to state that validation is performed only by register_identity_with_funding so callers of register_identity_with_signer must ensure keys_map is well-formed.
288-310: ⚡ Quick winClarify the Copy derive on
PutSettingsand consider extractinguser_fee_increasefor defensive clarity.
settings.and_then(|s| s.user_fee_increase)movessettings: Option<PutSettings>, which works here only becausePutSettingsexplicitly derivesCopy. The second argument then passessettingsagain. While this compiles correctly, usingsettings.as_ref().and_then(|s| s.user_fee_increase)avoids relying on implicit copies and is more defensive to future changes:🛠️ Suggested fix
- identity - .top_up_identity_with_signer( - &self.sdk, - asset_lock_proof, - asset_lock_proof_path, - asset_lock_signer, - settings.and_then(|s| s.user_fee_increase), - settings, - ) - .await + let user_fee_increase = settings.as_ref().and_then(|s| s.user_fee_increase); + identity + .top_up_identity_with_signer( + &self.sdk, + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + user_fee_increase, + settings, + ) + .await🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/rs-platform-wallet/src/wallet/identity/network/registration.rs` around lines 288 - 310, The current call in top_up_identity_with_signer relies on PutSettings being Copy by using settings.and_then(|s| s.user_fee_increase), which is fragile; change the second argument to use a reference so we don't move settings: pass settings.as_ref().and_then(|s| s.user_fee_increase) when calling identity.top_up_identity_with_signer, keeping the original settings variable for the later settings parameter and avoiding depending on PutSettings::Copy.packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs (1)
337-369: 💤 Low valueConsistency: add
unsafe impl Send + SyncforUnresolvedAssetLockTxRecordFFIand tightentx_bytesmutability.Two small consistency items on the new struct:
Every other restore FFI type in this file (
AccountSpecFFI,IdentityKeyRestoreFFI,IdentityRestoreEntryFFI,UtxoRestoreEntryFFI,WalletRestoreEntryFFI) carries an explicitunsafe impl Send + Sync.UnresolvedAssetLockTxRecordFFIis the only new addition without it — because it holds*mut u8, it falls back to!Send + !Sync. It compiles today only because the slice is consumed inline in the load callback and never crosses a thread boundary; a future caller that wants to ship the slice through aSendfuture or thread pool will hit a compile error inconsistent with sibling types.
tx_bytes: *mut u8is read-only on the Rust side (decoded viaconsensus::deserialize). Sibling read-only buffers use*const u8(e.g.UtxoRestoreEntryFFI.script_pubkey,IdentityKeyRestoreFFI.data).*const u8better documents the intended access pattern.♻️ Proposed consistency fixes
pub struct UnresolvedAssetLockTxRecordFFI { pub account_index: u32, - pub tx_bytes: *mut u8, + pub tx_bytes: *const u8, pub tx_bytes_len: usize, @@ unsafe impl Send for UtxoRestoreEntryFFI {} unsafe impl Sync for UtxoRestoreEntryFFI {} +unsafe impl Send for UnresolvedAssetLockTxRecordFFI {} +unsafe impl Sync for UnresolvedAssetLockTxRecordFFI {}Swift call sites that allocate
tx_bytesviaUnsafeMutablePointer<UInt8>will need to convert/UnsafePointer(...)at construction; the matching change inpersistence.rs::restore_unresolved_asset_lock_tx_recordsis implicit sinceslice_from_rawalready takes*const u8(the current*mut u8is coerced).Also applies to: 440-452
🤖 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/wallet_restore_types.rs` around lines 337 - 369, UnresolvedAssetLockTxRecordFFI currently uses tx_bytes: *mut u8 and lacks thread-safety markers; change tx_bytes to *const u8 (to reflect read-only access during consensus::deserialize) and add an explicit unsafe impl Send + Sync for UnresolvedAssetLockTxRecordFFI so it matches sibling FFI types (e.g., AccountSpecFFI, UtxoRestoreEntryFFI) and allows the struct to be sent across threads; update any construction sites to cast MutablePointer to const as needed.
🤖 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-dpp/src/state_transition/mod.rs`:
- Around line 1293-1369: The method sign_with_signer currently allows signing
any transition with a signature field; restrict it to only the asset-lock
transition variants by adding an early guard that inspects self's enum variant
(the transition enum that implements sign_with_signer) and returns a
ProtocolError::Generic (or other appropriate ProtocolError) if self is not an
asset-lock variant; place this check at the top of sign_with_signer before
computing signable_bytes, and keep the rest of the flow (including calling
signer.sign_ecdsa, constructing RecoverableSignature, and calling set_signature)
unchanged so only asset-lock transitions can reach set_signature and update
signature_public_key_id/state.
In `@packages/rs-platform-wallet/src/wallet/identity/network/registration.rs`:
- Around line 742-774: The current find_tracked_unproven_lock implementation
uses iter().find(...) which returns an arbitrary match instead of the
most-recently-tracked entry promised by the docs; update the function to collect
matching entries from info.tracked_asset_locks (filtering by funding_type,
identity_index, status and proof.is_none()) and then choose the entry with the
latest timestamp (e.g., lock.tracked_at or lock.created_at) using max_by_key (or
fold) before mapping to OutPoint, or alternatively update the doc comment to
explicitly state it returns any matching unproven lock; change the logic in
find_tracked_unproven_lock (and reference tracked_asset_locks and
AssetLockStatus::Built | AssetLockStatus::Broadcast) accordingly.
In
`@packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift`:
- Around line 144-146: The code in PersistentAssetLock uses raw.load(as:
UInt32.self) on voutBytes which can crash on platforms without alignment
guarantees; replace that load with an unaligned-safe byte-copy decode (same
pattern used in ManagedAssetLockManager/PlatformWalletManager) by copying the 4
voutBytes into a local UInt32 via withUnsafeBytes or Data.copyBytes into a
UInt32 buffer and then calling littleEndian to obtain vout.
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift`:
- Around line 135-136: The code currently maps pathPtr to an empty string and
returns BuildResult(transaction: txData, derivationPath: path) even when the C
string is missing; update ManagedAssetLockManager (the functions constructing
BuildResult) to treat a nil pathPtr as an immediate failure rather than
defaulting to "", e.g. detect pathPtr == nil and return/throw an error (or a
failing BuildResult) with a clear message before creating
BuildResult(transaction:txData,...); apply the same change to the other
occurrences noted (the analogous blocks around the 168-172 and 216-220 diffs) so
callers don’t receive empty derivationPath values.
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift`:
- Around line 2417-2421: The code currently returns ManagedIdentity(handle:
outManagedHandle) even when the FFI succeeded but the out handle is NULL_HANDLE;
update the post-FFI logic in the closure that builds the (identityId,
ManagedIdentity) tuple to validate outManagedHandle (compare against
NULL_HANDLE) and throw a descriptive error if it is NULL before constructing
ManagedIdentity; apply the same validation to the other occurrence that builds
ManagedIdentity at the later block around the second spot noted (the one around
lines 2527-2530), so neither path can return a ManagedIdentity wrapping
NULL_HANDLE.
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- Around line 360-366: The current logic clears retainedAssetLockManagers at the
start of the batch which can drop wrappers while their attached catch-up tasks
(asset_lock_manager_catch_up_blocking) are still running; instead, change to
track each started task (e.g., store a Task/Promise or DispatchGroup per wallet
using the retainedAssetLockManagers map) and only remove the corresponding
manager entry when that task completes or a per-task joined timeout expires.
Update the code paths around the loop that iterates wallets (the block that
currently calls retainedAssetLockManagers.removeAll(keepingCapacity: true) and
then launches catch-up work) to register the task, await or observe its
completion, and then remove that specific manager key rather than clearing the
whole collection; ensure the cleanup logic used elsewhere (the block around
lines ~405-428) follows the same per-task join-and-remove pattern.
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 2855-2859: The code currently uses UInt8(clamping:) for
entry.funding_type and entry.status which can silently map out-of-range or
negative persisted integers into valid enum values; instead perform an
exact/validated conversion: check record.fundingTypeRaw and record.statusRaw are
within 0...255 (or use UInt8(exactly:) and test for nil), and if either
conversion fails, log the error (including record identifiers) and skip/return
the invalid row rather than clamping; update the assignment of
entry.funding_type and entry.status to use the validated UInt8 values
(references: entry.funding_type, entry.status, record.fundingTypeRaw,
record.statusRaw in PlatformWalletPersistenceHandler.swift).
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingRegistrationsList.swift`:
- Around line 17-19: The ForEach is currently keyed by identityIndex which is
not globally unique across wallets; change the row identity to a composite key
of (walletId, identityIndex). Add or use a unique identifier on the registration
controller (e.g., a computed property like rowId or walletAndIndex that returns
"\(walletId)-\(identityIndex)"), then update the ForEach to use id: \.rowId (or
the new property) so PendingRegistrationRow(controller: controller) is keyed by
the combined walletId and identityIndex.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift`:
- Around line 49-55: The instantLockTimeout constant in RegistrationProgressView
is set to 300.0 but the registration flow contract uses a 180-second InstantSend
timeout; update the static let instantLockTimeout: TimeInterval in
RegistrationProgressView to 180.0 so the UI transitions from InstantSend wait to
ChainLock fallback at the correct 180s threshold.
---
Outside diff comments:
In `@packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift`:
- Line 165: The SecItemDelete call in storePrivateKeyNonisolated and
storeKeyData currently uses the full query that contains kSecValueData (and
kSecAttrGeneric in storePrivateKeyNonisolated), which can leave stale rows and
cause errSecDuplicateItem on SecItemAdd; change both deletes to use an
attribute-only delete query (include kSecClass and the identifying attributes
such as kSecAttrAccount, kSecAttrService, kSecAttrLabel, kSecAttrAccessible,
etc., but omit kSecValueData and kSecAttrGeneric) so the existing item is
removed based on its attributes before calling SecItemAdd, matching the pattern
used in the earlier fix for the other writer.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift`:
- Around line 199-235: The view never shows a terminal UI when
submitPlatformPayment succeeds because successful platform payments set
createdIdentityId but never set activeController; update the view logic (around
the conditional rendering that checks activeController / preselectedAssetLock)
to also treat a non-nil createdIdentityId as a terminal state by rendering
terminalSection(for:) (or a small completion-only section) when
createdIdentityId != nil, or alternatively set activeController to a
completion-state controller inside submitPlatformPayment after success;
reference submitPlatformPayment, createdIdentityId, activeController,
RegistrationProgressSection, and terminalSection(for:) to locate code to change
and ensure the submit button path for platform payments either shows the same
success banner and actions or dismisses the sheet appropriately.
---
Nitpick comments:
In @.claude/skills/simulator-control/SKILL.md:
- Around line 66-81: The piped command currently redirects stderr into stdout
(the "2>&1" around the call to "$HOME/.local/bin/idb" ui describe-all) so the
inline Python (json.loads on sys.stdin) can receive non-JSON diagnostics and
fail; change the shell pipeline to separate or filter stderr before passing JSON
to the python snippet: remove or modify the "2>&1" redirection on the idb ui
describe-all call and instead capture only its stdout (or redirect stderr to a
log/pipe) so the python3 -c block that calls json.loads(...) receives clean
JSON; update references around LABEL/UDID and the subprocess.run(...) invocation
accordingly to still use the UDID env var and perform the tap.
- Around line 150-152: The tap_label "Resume" call is ambiguous when multiple
Resume buttons exist; instead locate the specific Resume button associated with
the intended registration by first waiting for the unique outpoint substring to
appear in the accessibility tree (as done in Workflow C lines 157-163) or by
filtering candidate nodes by their frame.y coordinate range, then tap that
specific node rather than calling tap_label "Resume" blindly; update the
sequence around tap_label "Identities" / tap_label "Resume" to perform the
wait-and-find (or y-range filter) and then invoke the tap on the resolved node.
In `@packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs`:
- Around line 337-369: UnresolvedAssetLockTxRecordFFI currently uses tx_bytes:
*mut u8 and lacks thread-safety markers; change tx_bytes to *const u8 (to
reflect read-only access during consensus::deserialize) and add an explicit
unsafe impl Send + Sync for UnresolvedAssetLockTxRecordFFI so it matches sibling
FFI types (e.g., AccountSpecFFI, UtxoRestoreEntryFFI) and allows the struct to
be sent across threads; update any construction sites to cast MutablePointer to
const as needed.
In `@packages/rs-platform-wallet/src/wallet/asset_lock/build.rs`:
- Around line 110-134: The code currently takes the first key from result.keys
(AssetLockCreditKeys::Public) and ignores any extras; change the match arm to
explicitly validate there is exactly one credit-output key: inspect the
collection (e.g., check keys.len() or pattern-match on a single-element Vec) and
return a PlatformWalletError::AssetLockTransaction if it is empty or contains >1
entries, then return the single path; keep the existing error for the Private
variant and still return Ok((result.transaction, path)) on success.
In `@packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- Around line 39-41: The intra-doc link for MnemonicResolverCoreSigner is
incorrect: it points to crate::wallet::asset_lock::build but the type lives in
platform-wallet-ffi; update the doc comment (the lines referencing
`MnemonicResolverCoreSigner` and `crate::wallet::asset_lock::build`) to remove
or change the intra-doc path — either drop the link and refer to the type in
plain prose ("MnemonicResolverCoreSigner from platform-wallet-ffi") or point
users to the external crate; ensure you do not keep the unreachable
`crate::wallet::asset_lock::build` link.
In `@packages/rs-platform-wallet/src/wallet/identity/network/registration.rs`:
- Around line 419-440: The code calls
asset_locks.upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT) and
then immediately calls asset_locks.resume_asset_lock(&out_point,
CL_FALLBACK_TIMEOUT) just to re-derive the credit-output path, which can double
user-visible wait time if the short-circuit assumption breaks; change
resume_asset_lock usage so it does not wait: either call a new "path-only"
helper on asset_locks that derives the path without any SPV wait, or pass a
zero/very-small timeout (e.g. Duration::ZERO) instead of CL_FALLBACK_TIMEOUT
when calling resume_asset_lock to make the "no wait expected" invariant
load-bearing, leaving upgrade_to_chain_lock_proof and ResolvedFunding
construction unchanged.
- Around line 224-275: The docstring incorrectly claims a defensive check for
the first key but none exists; update register_identity_with_signer to enforce
that keys_map contains an entry with id 0 and that that IdentityPublicKey has
the required MASTER key type and AUTHENTICATION purpose (e.g., check
keys_map.get(&0) and validate its key_type/purpose fields), returning an
appropriate dash_sdk::Error if missing/invalid; alternatively, if you prefer not
to add logic here, change the docstring to state that validation is performed
only by register_identity_with_funding so callers of
register_identity_with_signer must ensure keys_map is well-formed.
- Around line 288-310: The current call in top_up_identity_with_signer relies on
PutSettings being Copy by using settings.and_then(|s| s.user_fee_increase),
which is fragile; change the second argument to use a reference so we don't move
settings: pass settings.as_ref().and_then(|s| s.user_fee_increase) when calling
identity.top_up_identity_with_signer, keeping the original settings variable for
the later settings parameter and avoiding depending on PutSettings::Copy.
In `@packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md`:
- Around line 1056-1071: Update the "Open questions" section to reflect resolved
decisions: replace the question about default funding amount with a statement
that defaultCoreFundingDuffs was set to 250,000 duffs (commit 3d16a31a), and
replace the asset-lock minimum question with a note that minIdentityFundingDuffs
was set to 221,500 duffs; remove or mark as resolved the items referencing those
values (lines mentioning 100,000 duffs and asset-lock minimum) and keep the
remaining open questions (key count and AssetLockStatus extension) unchanged.
- Around line 147-149: Update the iteration reference from "iter 2" to "iter 3"
in the sentence that currently reads "Resolved in iter 2" so it matches the
later note "### Iter 3 — SwiftData mirror + persister callback (was iter 2)";
ensure the wording still indicates that the PersistentAssetLock SwiftData model
mirrors TrackedAssetLock via the FFI persister callback and that this work was
delivered in iteration 3.
- Line 992: Update the wording for Scenario 2 (currently labeled "🔴 bug repro")
to clearly state intent: if this is preventing a previously fixed regression,
rename to "Regression guard — Start a fresh registration..." ; if it documents a
discovered/fixed issue, use "🔴 bug fixed — ..." or "🔴 known issue — ..." and
add a short note whether the bug was fixed; ensure the description still states
that the in-flight slot must not appear in Resumable Registrations and reference
the related code check (Phase.isActive + identity-slot union in
ResumableRegistrationsList, commit 02a15497c6) so readers know the behavioral
contract being validated.
- Line 417: Update the cross-reference text "See § Iter 5 / SPV event-routing
follow-up" so it points to the actual section heading "SPV event-routing
follow-up — RESOLVED (2026-05-13)" used later in the document; locate the string
in the Step 13 paragraph and replace it with a clear reference matching the
exact heading text ("See § SPV event-routing follow-up — RESOLVED
(2026-05-13)"), or alternatively change the reference to "See § SPV
event-routing follow-up" if you prefer a shorter match that exactly equals the
section title "SPV event-routing follow-up — RESOLVED (2026-05-13)" to ensure
the cross-reference resolves correctly.
🪄 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: 7da94f0d-3eec-4969-bbcf-bb7966120fea
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (76)
.claude/skills/simulator-control/SKILL.mdCargo.tomlpackages/rs-dpp/src/errors/protocol_error.rspackages/rs-dpp/src/state_transition/mod.rspackages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/methods/mod.rspackages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/methods/v0/mod.rspackages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/v0/v0_methods.rspackages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/methods/mod.rspackages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/methods/v0/mod.rspackages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rspackages/rs-drive-abci/src/execution/check_tx/v0/mod.rspackages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rspackages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rspackages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rspackages/rs-drive-abci/tests/strategy_tests/strategy.rspackages/rs-platform-wallet-ffi/Cargo.tomlpackages/rs-platform-wallet-ffi/src/asset_lock/build.rspackages/rs-platform-wallet-ffi/src/asset_lock/sync.rspackages/rs-platform-wallet-ffi/src/asset_lock_persistence.rspackages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rspackages/rs-platform-wallet-ffi/src/core_wallet_types.rspackages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rspackages/rs-platform-wallet-ffi/src/lib.rspackages/rs-platform-wallet-ffi/src/mnemonic_resolver_core_signer.rspackages/rs-platform-wallet-ffi/src/persistence.rspackages/rs-platform-wallet-ffi/src/spv.rspackages/rs-platform-wallet-ffi/src/wallet_restore_types.rspackages/rs-platform-wallet/Cargo.tomlpackages/rs-platform-wallet/src/changeset/changeset.rspackages/rs-platform-wallet/src/changeset/core_bridge.rspackages/rs-platform-wallet/src/error.rspackages/rs-platform-wallet/src/lib.rspackages/rs-platform-wallet/src/wallet/asset_lock/build.rspackages/rs-platform-wallet/src/wallet/asset_lock/manager.rspackages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rspackages/rs-platform-wallet/src/wallet/core/balance_handler.rspackages/rs-platform-wallet/src/wallet/core/broadcast.rspackages/rs-platform-wallet/src/wallet/core/wallet.rspackages/rs-platform-wallet/src/wallet/identity/mod.rspackages/rs-platform-wallet/src/wallet/identity/network/registration.rspackages/rs-platform-wallet/src/wallet/identity/network/top_up.rspackages/rs-platform-wallet/src/wallet/identity/types/funding.rspackages/rs-platform-wallet/src/wallet/identity/types/mod.rspackages/rs-sdk-ffi/src/identity/put.rspackages/rs-sdk-ffi/src/identity/topup.rspackages/rs-sdk/src/platform/transition/broadcast_identity.rspackages/rs-sdk/src/platform/transition/put_identity.rspackages/rs-sdk/src/platform/transition/top_up_identity.rspackages/strategy-tests/src/transitions.rspackages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swiftpackages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.mdpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+RegistrationCoordinator.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingRegistrationsList.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swiftpackages/wasm-sdk/src/state_transitions/identity.rs
| pub async fn sign_with_signer<S: ::key_wallet::signer::Signer>( | ||
| &mut self, | ||
| path: &::key_wallet::bip32::DerivationPath, | ||
| signer: &S, | ||
| ) -> Result<(), ProtocolError> { | ||
| use dashcore::secp256k1::ecdsa::{RecoverableSignature, RecoveryId}; | ||
| use dashcore::secp256k1::{Message, Secp256k1}; | ||
| use dashcore::signer::{double_sha, CompactSignature}; | ||
|
|
||
| let data = self.signable_bytes()?; | ||
| // Pre-image transform matches `dashcore::signer::sign`: double-SHA256 | ||
| // of the signable bytes is the actual ECDSA message digest. | ||
| let data_hash = double_sha(&data); | ||
| let digest: [u8; 32] = data_hash | ||
| .as_slice() | ||
| .try_into() | ||
| .map_err(|_| ProtocolError::Generic("double_sha did not return 32 bytes".to_string()))?; | ||
|
|
||
| let (signature, public_key) = signer | ||
| .sign_ecdsa(path, digest) | ||
| .await | ||
| .map_err(|e| ProtocolError::ExternalSignerError(format!("signer failed: {}", e)))?; | ||
|
|
||
| // The signer returns a non-recoverable signature. The legacy path | ||
| // stores a 65-byte recoverable compact signature, so we brute-force | ||
| // the recovery id (0..3) by reconstructing a `RecoverableSignature` | ||
| // and comparing the recovered public key with the one the signer | ||
| // returned. secp256k1 normalises both `sign_ecdsa` and | ||
| // `sign_ecdsa_recoverable` outputs to low-s form, so the 64-byte | ||
| // `r||s` payload is bit-identical to what `dashcore::signer::sign` | ||
| // produces. | ||
| let compact_64 = signature.serialize_compact(); | ||
| let secp = Secp256k1::new(); | ||
| let msg = Message::from_digest(digest); | ||
|
|
||
| let mut found: Option<RecoverableSignature> = None; | ||
| for id in 0..4i32 { | ||
| let recid = match RecoveryId::try_from(id) { | ||
| Ok(r) => r, | ||
| Err(_) => continue, | ||
| }; | ||
| let candidate = match RecoverableSignature::from_compact(&compact_64, recid) { | ||
| Ok(s) => s, | ||
| Err(_) => continue, | ||
| }; | ||
| if let Ok(recovered) = secp.recover_ecdsa(&msg, &candidate) { | ||
| if recovered == public_key { | ||
| found = Some(candidate); | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| let recoverable = found.ok_or_else(|| { | ||
| // Invariant violation by a non-conformant signer: the | ||
| // signature returned does not correspond to the public | ||
| // key the signer claims. Surface as ExternalSignerError | ||
| // (NOT Generic) so callers can distinguish signer-side | ||
| // failures from protocol-level invariants. | ||
| ProtocolError::ExternalSignerError( | ||
| "signer returned a signature whose recovery id does not match the returned public key".to_string(), | ||
| ) | ||
| })?; | ||
|
|
||
| // Compressed-pubkey convention matches `dashcore::signer::sign`, which | ||
| // always passes `true` regardless of the underlying key encoding. The | ||
| // signer's `sign_ecdsa` returns the compressed `secp256k1::PublicKey`, | ||
| // so this is consistent. | ||
| let compact_65 = recoverable.to_compact_signature(true); | ||
|
|
||
| if !self.set_signature(compact_65.to_vec().into()) { | ||
| return Err(ProtocolError::InvalidVerificationWrongNumberOfElements { | ||
| needed: self.required_number_of_private_keys(), | ||
| using: 1, | ||
| msg: "failed to set ECDSA signature", | ||
| }); | ||
| } | ||
| Ok(()) |
There was a problem hiding this comment.
Restrict sign_with_signer to asset-lock transitions.
This public method currently succeeds for any variant that has a signature field. On identity-signed transitions like DataContractCreate, Batch, or IdentityCreditTransfer, that skips the normal key-purpose/security checks and never updates signature_public_key_id, so callers can manufacture an invalid signed transition and still get Ok(()). Please hard-fail here unless self is one of the asset-lock variants this API is meant for.
Suggested guard
pub async fn sign_with_signer<S: ::key_wallet::signer::Signer>(
&mut self,
path: &::key_wallet::bip32::DerivationPath,
signer: &S,
) -> Result<(), ProtocolError> {
+ match self {
+ StateTransition::IdentityCreate(_)
+ | StateTransition::IdentityTopUp(_)
+ | StateTransition::AddressFundingFromAssetLock(_)
+ | StateTransition::ShieldFromAssetLock(_) => {}
+ other => {
+ return Err(ProtocolError::CorruptedCodeExecution(format!(
+ "{} cannot be signed via the asset-lock signer path",
+ other.name()
+ )));
+ }
+ }
+
let data = self.signable_bytes()?;🤖 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-dpp/src/state_transition/mod.rs` around lines 1293 - 1369, The
method sign_with_signer currently allows signing any transition with a signature
field; restrict it to only the asset-lock transition variants by adding an early
guard that inspects self's enum variant (the transition enum that implements
sign_with_signer) and returns a ProtocolError::Generic (or other appropriate
ProtocolError) if self is not an asset-lock variant; place this check at the top
of sign_with_signer before computing signable_bytes, and keep the rest of the
flow (including calling signer.sign_ecdsa, constructing RecoverableSignature,
and calling set_signature) unchanged so only asset-lock transitions can reach
set_signature and update signature_public_key_id/state.
| let vout = voutBytes.withUnsafeBytes { raw in | ||
| raw.load(as: UInt32.self).littleEndian | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift | sed -n '130,160p'Repository: dashpay/platform
Length of output: 1114
🏁 Script executed:
cat -n packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift | head -50Repository: dashpay/platform
Length of output: 2836
🏁 Script executed:
rg -A 5 -B 5 "voutBytes" packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swiftRepository: dashpay/platform
Length of output: 623
🏁 Script executed:
find packages/swift-sdk -name "Package.swift" -o -name "package.swift" | head -5Repository: dashpay/platform
Length of output: 137
🏁 Script executed:
cat packages/swift-sdk/Package.swift | grep -A 20 "swiftLanguageVersions\|platforms"Repository: dashpay/platform
Length of output: 631
🏁 Script executed:
rg "loadUnaligned" packages/swift-sdk --type swift -cRepository: dashpay/platform
Length of output: 42
🏁 Script executed:
rg -B 3 -A 3 "withUnsafeBytes.*load\(as:" packages/swift-sdk --type swift | head -40Repository: dashpay/platform
Length of output: 42
🏁 Script executed:
rg -B 2 -A 3 "prefix\(|suffix\(" packages/swift-sdk/Sources/SwiftDashSDK --type swift -A 2 | grep -A 2 "withUnsafeBytes\|\.load\|UInt" | head -50Repository: dashpay/platform
Length of output: 2495
🏁 Script executed:
rg "\.copyMemory\|\.copyBytes" packages/swift-sdk --type swift -B 2 -A 2Repository: dashpay/platform
Length of output: 42
🏁 Script executed:
python3 << 'EOF'
# Check Swift's Data and UnsafeRawBufferPointer alignment behavior
# Swift Data.suffix() may not guarantee alignment for load(as:)
print("""
Swift Memory Alignment Analysis:
================================
1. Data.suffix(4) returns a Data slice - a VIEW into the original Data.
When you call withUnsafeBytes on a Data suffix, the closure receives
an UnsafeRawBufferPointer whose baseAddress is offset into the original
Data storage.
2. UnsafeRawBufferPointer.load(as: T) assumes the baseAddress is properly
aligned for T. For UInt32, that means the address must be 4-byte aligned.
3. If rawBytes is at offset 32 (odd multiple of 4), then suffix(4) gives
a pointer at offset 32. A 4-byte load from offset 32 is aligned.
BUT if the original Data object itself is not aligned to 4 bytes in
storage (common for Data created from arbitrary byte sources), the
pointer at ANY offset may be unaligned.
4. Swift Data does NOT guarantee alignment in its internal storage.
A Data object's baseAddress may be at any byte boundary.
5. Therefore, using raw.load(as: UInt32.self) on a Data-derived
UnsafeRawBufferPointer is unsafe and can trigger a trap on ARM64
(which requires aligned loads).
6. iOS 18+ and macOS 15+ both support Swift 6 and have loadUnaligned
available (since Swift 5.3), but the safest portable approach is
byte copying to a local variable then loading that.
Conclusion: The review comment is CORRECT. The code is unsafe.
""")
EOFRepository: dashpay/platform
Length of output: 1401
🏁 Script executed:
rg "littleEndian\|bigEndian" packages/swift-sdk/Sources/SwiftDashSDK --type swift -B 2 -A 2 | head -80Repository: dashpay/platform
Length of output: 42
Use an unaligned-safe decode for vout to avoid runtime traps.
Line 145 uses raw.load(as: UInt32.self) on Data bytes, which lacks alignment guarantees and can crash on ARM64. The codebase uses safe byte-copy decoding consistently elsewhere (e.g., ManagedAssetLockManager, PlatformWalletManager); apply the same pattern here.
Suggested fix
- let vout = voutBytes.withUnsafeBytes { raw in
- raw.load(as: UInt32.self).littleEndian
- }
+ let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in
+ var value: UInt32 = 0
+ withUnsafeMutableBytes(of: &value) { dst in
+ dst.copyBytes(from: raw.prefix(MemoryLayout<UInt32>.size))
+ }
+ return UInt32(littleEndian: value)
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let vout = voutBytes.withUnsafeBytes { raw in | |
| raw.load(as: UInt32.self).littleEndian | |
| } | |
| let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in | |
| var value: UInt32 = 0 | |
| withUnsafeMutableBytes(of: &value) { dst in | |
| dst.copyBytes(from: raw.prefix(MemoryLayout<UInt32>.size)) | |
| } | |
| return UInt32(littleEndian: value) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift`
around lines 144 - 146, The code in PersistentAssetLock uses raw.load(as:
UInt32.self) on voutBytes which can crash on platforms without alignment
guarantees; replace that load with an unaligned-safe byte-copy decode (same
pattern used in ManagedAssetLockManager/PlatformWalletManager) by copying the 4
voutBytes into a local UInt32 via withUnsafeBytes or Data.copyBytes into a
UInt32 buffer and then calling littleEndian to obtain vout.
| let path = pathPtr.map { String(cString: $0) } ?? "" | ||
| return BuildResult(transaction: txData, derivationPath: path) |
There was a problem hiding this comment.
Fail fast when derivation path is missing instead of defaulting to "".
These methods currently treat a missing pathPtr as success and return an empty derivation path, which can cause later signing failures with less actionable errors.
Suggested fix
- let path = pathPtr.map { String(cString: $0) } ?? ""
+ guard let pathPtr, pathPtr.pointee != 0 else {
+ throw PlatformWalletError.unknown("FFI returned success but derivation path was empty")
+ }
+ let path = String(cString: pathPtr)
return BuildResult(transaction: txData, derivationPath: path)- let path = pathPtr.map { String(cString: $0) } ?? ""
+ guard let pathPtr, pathPtr.pointee != 0 else {
+ throw PlatformWalletError.unknown("FFI returned success but derivation path was empty")
+ }
+ let path = String(cString: pathPtr)
return FundedProofResult(- let path = pathPtr.map { String(cString: $0) } ?? ""
+ guard let pathPtr, pathPtr.pointee != 0 else {
+ throw PlatformWalletError.unknown("FFI returned success but derivation path was empty")
+ }
+ let path = String(cString: pathPtr)
return ResumeResult(Also applies to: 168-172, 216-220
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift`
around lines 135 - 136, The code currently maps pathPtr to an empty string and
returns BuildResult(transaction: txData, derivationPath: path) even when the C
string is missing; update ManagedAssetLockManager (the functions constructing
BuildResult) to treat a nil pathPtr as an immediate failure rather than
defaulting to "", e.g. detect pathPtr == nil and return/throw an error (or a
failing BuildResult) with a clear message before creating
BuildResult(transaction:txData,...); apply the same change to the other
occurrences noted (the analogous blocks around the 168-172 and 216-220 diffs) so
callers don’t receive empty derivationPath values.
| try result.check() | ||
| // Copy the 32-byte tuple into a Data via withUnsafeBytes. | ||
| let identityId = Swift.withUnsafeBytes(of: idTuple) { Data($0) } | ||
| return (identityId, ManagedIdentity(handle: outManagedHandle)) | ||
| }.value |
There was a problem hiding this comment.
Validate outManagedHandle before constructing ManagedIdentity.
Both paths can currently return a ManagedIdentity wrapping NULL_HANDLE if FFI succeeds but omits the out handle, which pushes failure to a later, harder-to-debug point.
Suggested fix
try result.check()
+ guard outManagedHandle != NULL_HANDLE else {
+ throw PlatformWalletError.walletOperation("identity handle not returned")
+ }
let identityId = Swift.withUnsafeBytes(of: idTuple) { Data($0) }
return (identityId, ManagedIdentity(handle: outManagedHandle))Also applies to: 2527-2530
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift`
around lines 2417 - 2421, The code currently returns ManagedIdentity(handle:
outManagedHandle) even when the FFI succeeded but the out handle is NULL_HANDLE;
update the post-FFI logic in the closure that builds the (identityId,
ManagedIdentity) tuple to validate outManagedHandle (compare against
NULL_HANDLE) and throw a descriptive error if it is NULL before constructing
ManagedIdentity; apply the same validation to the other occurrence that builds
ManagedIdentity at the later block around the second spot noted (the one around
lines 2527-2530), so neither path can return a ManagedIdentity wrapping
NULL_HANDLE.
| // 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 { |
There was a problem hiding this comment.
Don’t release retained managers by batch replacement while catch-up tasks may still run.
retainedAssetLockManagers.removeAll(...) can drop wrappers from a prior invocation even if their detached tasks are still in-flight, which can invalidate handle during asset_lock_manager_catch_up_blocking.
Track per-task completion and release each retained manager only after its corresponding task finishes (or after an explicit joined timeout), rather than clearing the whole previous batch on method entry.
Also applies to: 405-428
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`
around lines 360 - 366, The current logic clears retainedAssetLockManagers at
the start of the batch which can drop wrappers while their attached catch-up
tasks (asset_lock_manager_catch_up_blocking) are still running; instead, change
to track each started task (e.g., store a Task/Promise or DispatchGroup per
wallet using the retainedAssetLockManagers map) and only remove the
corresponding manager entry when that task completes or a per-task joined
timeout expires. Update the code paths around the loop that iterates wallets
(the block that currently calls
retainedAssetLockManagers.removeAll(keepingCapacity: true) and then launches
catch-up work) to register the task, await or observe its completion, and then
remove that specific manager key rather than clearing the whole collection;
ensure the cleanup logic used elsewhere (the block around lines ~405-428)
follows the same per-task join-and-remove pattern.
| 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 |
There was a problem hiding this comment.
Don’t clamp funding_type / status during restore marshalling.
UInt8(clamping:) can silently change corrupt/out-of-range persisted values into valid-looking enums (e.g., negative → 0), which can restore the wrong asset-lock state. Prefer exact conversion and skip/log invalid rows.
Suggested fix
- entry.funding_type = UInt8(clamping: record.fundingTypeRaw)
+ guard let fundingType = UInt8(exactly: record.fundingTypeRaw) else {
+ NSLog("[persistor-load:swift] dropping asset-lock row with invalid fundingTypeRaw: %d", record.fundingTypeRaw)
+ continue
+ }
+ entry.funding_type = fundingType
entry.identity_index = UInt32(bitPattern: record.identityIndexRaw)
entry.amount_duffs = UInt64(bitPattern: record.amountDuffs)
- entry.status = UInt8(clamping: record.statusRaw)
+ guard let status = UInt8(exactly: record.statusRaw) else {
+ NSLog("[persistor-load:swift] dropping asset-lock row with invalid statusRaw: %d", record.statusRaw)
+ continue
+ }
+ entry.status = status📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 | |
| guard let fundingType = UInt8(exactly: record.fundingTypeRaw) else { | |
| NSLog("[persistor-load:swift] dropping asset-lock row with invalid fundingTypeRaw: %d", record.fundingTypeRaw) | |
| continue | |
| } | |
| entry.funding_type = fundingType | |
| entry.identity_index = UInt32(bitPattern: record.identityIndexRaw) | |
| entry.amount_duffs = UInt64(bitPattern: record.amountDuffs) | |
| guard let status = UInt8(exactly: record.statusRaw) else { | |
| NSLog("[persistor-load:swift] dropping asset-lock row with invalid statusRaw: %d", record.statusRaw) | |
| continue | |
| } | |
| entry.status = status | |
| entry.proof_bytes = proofPtr |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`
around lines 2855 - 2859, The code currently uses UInt8(clamping:) for
entry.funding_type and entry.status which can silently map out-of-range or
negative persisted integers into valid enum values; instead perform an
exact/validated conversion: check record.fundingTypeRaw and record.statusRaw are
within 0...255 (or use UInt8(exactly:) and test for nil), and if either
conversion fails, log the error (including record identifiers) and skip/return
the invalid row rather than clamping; update the assignment of
entry.funding_type and entry.status to use the validated UInt8 values
(references: entry.funding_type, entry.status, record.fundingTypeRaw,
record.statusRaw in PlatformWalletPersistenceHandler.swift).
| ForEach(active, id: \.identityIndex) { controller in | ||
| PendingRegistrationRow(controller: controller) | ||
| } |
There was a problem hiding this comment.
Use a globally unique row id in ForEach.
Rows are keyed by identityIndex only, but registrations are keyed by (walletId, identityIndex). Two wallets can share the same index, which can collapse/replace rows in the list.
Suggested fix
- ForEach(active, id: \.identityIndex) { controller in
+ ForEach(
+ active,
+ id: \.walletId
+ ) { controller in
PendingRegistrationRow(controller: controller)
}+// Better: explicit composite id to avoid relying on walletId-only uniqueness.
+ForEach(active, id: \.self) { controller in ... } // if controller is Hashable+// Or add a computed stable key:
+// "\(controller.walletId.toHexString())-\(controller.identityIndex)"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ForEach(active, id: \.identityIndex) { controller in | |
| PendingRegistrationRow(controller: controller) | |
| } | |
| ForEach( | |
| active, | |
| id: \.walletId | |
| ) { controller in | |
| PendingRegistrationRow(controller: controller) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingRegistrationsList.swift`
around lines 17 - 19, The ForEach is currently keyed by identityIndex which is
not globally unique across wallets; change the row identity to a composite key
of (walletId, identityIndex). Add or use a unique identifier on the registration
controller (e.g., a computed property like rowId or walletAndIndex that returns
"\(walletId)-\(identityIndex)"), then update the ForEach to use id: \.rowId (or
the new property) so PendingRegistrationRow(controller: controller) is keyed by
the combined walletId and identityIndex.
| /// Cutoff (seconds since `Broadcast`) where the Rust side falls | ||
| /// back from InstantSend to ChainLock. Mirrors | ||
| /// `AssetLockManager`'s 300 s IS wait. If `statusRaw == 1` is | ||
| /// still the state after this window, the wallet is in the CL | ||
| /// fallback window (180 s); we mark step 4 done and step 5 | ||
| /// active to communicate the shift. | ||
| private static let instantLockTimeout: TimeInterval = 300.0 |
There was a problem hiding this comment.
Align InstantSend timeout heuristic with the registration flow contract.
Line 55 sets instantLockTimeout to 300.0, but this registration flow is defined with a 180-second InstantSend timeout before ChainLock fallback. Using 300s will keep the UI in the IS-wait step longer than actual behavior.
Suggested fix
- private static let instantLockTimeout: TimeInterval = 300.0
+ private static let instantLockTimeout: TimeInterval = 180.0📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /// Cutoff (seconds since `Broadcast`) where the Rust side falls | |
| /// back from InstantSend to ChainLock. Mirrors | |
| /// `AssetLockManager`'s 300 s IS wait. If `statusRaw == 1` is | |
| /// still the state after this window, the wallet is in the CL | |
| /// fallback window (180 s); we mark step 4 done and step 5 | |
| /// active to communicate the shift. | |
| private static let instantLockTimeout: TimeInterval = 300.0 | |
| /// Cutoff (seconds since `Broadcast`) where the Rust side falls | |
| /// back from InstantSend to ChainLock. Mirrors | |
| /// `AssetLockManager`'s 300 s IS wait. If `statusRaw == 1` is | |
| /// still the state after this window, the wallet is in the CL | |
| /// fallback window (180 s); we mark step 4 done and step 5 | |
| /// active to communicate the shift. | |
| private static let instantLockTimeout: TimeInterval = 180.0 |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift`
around lines 49 - 55, The instantLockTimeout constant in
RegistrationProgressView is set to 300.0 but the registration flow contract uses
a 180-second InstantSend timeout; update the static let instantLockTimeout:
TimeInterval in RegistrationProgressView to 180.0 so the UI transitions from
InstantSend wait to ChainLock fallback at the correct 180s threshold.
…letInfo `PlatformWalletInfo`'s `WalletInfoInterface` impl was relying on the trait's default `apply_chain_lock` (no-op returning empty per-account map) and `last_applied_chain_lock` (`None`). The chain-lock dispatch task spawned by `dash_spv::client::sync_coordinator::run` calls `wallet.write().await.apply_chain_lock(...)` on every validated `ChainLockReceived` event — so every CLSig was hitting the no-op default. Promotion of `InBlock` records to `InChainLockedBlock` never ran, `metadata.last_applied_chain_lock` stayed `None`, and the asset-lock resume flow couldn't observe finality through either the event cascade or the metadata-fallback path. This is the actual root cause behind "stuck asset lock #10": everything downstream (the `chain_lock_promotions` bridge, the metadata-based proof fallback) was correct in isolation but useless because the foundational delegation was missing. Delegate both methods to `self.core_wallet`, which holds the upstream `ManagedWalletInfo` whose impls do the real work. Added a `debug!` log on `apply_chain_lock` invocation so future regressions in the dispatch path are observable without a debugger. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A successful identity registration / top-up has historically been the implicit end-of-life signal for a tracked asset lock — the entry was removed from the in-memory `tracked_asset_locks` map AND tombstoned on the persister via `AssetLockChangeSet::removed`, deleting the `PersistentAssetLock` row. The UI then lost its only handle from a funding-tx row back to the original locked amount. Split the two semantics: - **In-memory** (`tracked_asset_locks`) — entries still drop on consumption; a one-shot proof has no further use and keeping it around just costs heap. - **Persisted** (`PersistentAssetLock`) — the row is retained with `status = Consumed (4)` for historical UI lookups. The `AssetLockChangeSet` carries the entry through `asset_locks` (upsert), NOT `removed` (delete). Rename `remove_asset_lock` → `consume_asset_lock`, returning a `Result` so the wallet-not-found path surfaces instead of being silently swallowed. Two callers in `registration.rs` updated to log the warn-level path; an exhaustive match arm in `recovery.rs` fast-fails any caller that ever reaches `resume_asset_lock` on a Consumed entry (shouldn't happen — load-path filters them out — but the match has to be exhaustive). The variant is intentionally NOT `#[non_exhaustive]`: every FFI / serializer / status-mapping site (`asset_lock_persistence.rs`, `accessors.rs`, FFI `asset_lock/manager.rs`, FFI `persistence.rs`) matches exhaustively on `AssetLockStatus` precisely so a new variant generates compile errors at every translation boundary. Wildcarding those arms would silently lose that signal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n_lock fallback The asset-lock resume path was relying on a per-record `InBlock` → `InChainLockedBlock` promotion landing in SwiftData before `wait_for_proof` could finalize a Chain proof. After a wallet restart the in-memory `transactions()` map is empty by design, so a CLSig that fires for a previously-mined funding tx has nothing to promote — the per-record path silently misses and the resume stalls indefinitely waiting on a context flip that will never come. Add a fallback to `wait_for_proof`: when the persister's record is still at `InBlock` (or its context is otherwise non-final), check the wallet's *global* `metadata.last_applied_chain_lock`. If that SPV-verified ChainLock's `block_height` already covers the record's `height()`, the CL has cryptographically finalized the record's block — same guarantee as the per-record `InChainLockedBlock` arm — and we can construct a `ChainAssetLockProof` directly. Chain-id check: refuse the fallback if the wallet's declared network doesn't match the SDK's. A persisted `last_applied_chain_lock` from the wrong network (cross-network drift / corrupt restore) would otherwise produce a proof Platform rejects with 10506, burning the submission layer's full retry budget on impossible-to-satisfy bumps. Adjacent refactors enabling the fallback: - `PlatformWalletError::FinalityTimeout` now carries the full `OutPoint` instead of just the `Txid`. The IS→CL upgrade path needs the vout to disambiguate when multiple unproven locks share a `(funding_type, identity_index)` key, and looking it back up by walking the tracked-asset-lock map is BTreeMap-order, non- deterministic. Callers (proof.rs, recovery.rs, registration.rs) are updated. - `IdentityWallet::out_point_from_proof` is now total over `AssetLockProof` (returns `OutPoint`, not `Option<OutPoint>`). Both variants always produce an outpoint — Instant via `tx.txid() + output_index`, Chain via the proof's carried `out_point` field. The `Option` was vestigial; callers (the two IS-lock-rejection arms in registration.rs) used `.ok_or_else` with an impossible "proof carried no outpoint" error message. - `LockNotifyHandler::on_sync_event` adds `debug!` logs naming the event variant + height so operators can verify SPV→wallet wake-ups without a debugger. - `wait_for_proof` adds per-iteration `debug!` instrumentation for the same reason: the prior failure mode was completely silent (loop spinning on `notified.await` with no observable evidence the persister fallback was being consulted). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-height lag (10506) When the wallet submits a ChainLock asset-lock proof, Platform validates the proof's `core_chain_locked_height` against its own observed Core tip. If Platform is briefly behind the wallet's SPV-verified tip (a routine race against `create-empty-blocks-interval` — 3min on mainnet), the submission is rejected with `InvalidAssetLockProofCoreChainHeightError` (consensus code 10506). Previously this surfaced as a hard error, even though it's recoverable: the same proof will be accepted as soon as Platform catches up. We just need to resubmit — but Tenderdash caches rejected ST hashes for ~24h on mainnet/testnet (`keep-invalid-txs-in-cache = true` in dashmate's template), so an identical-bytes resubmit is silently dropped before reaching CheckTx. Solution: retry with a bumped `user_fee_increase`. The fee bump changes the ST's signable bytes, which changes its hash, which bypasses the mempool cache. Each attempt logs both the proof's claimed height and Platform's currently observed Core tip so persistent lag (>3.5min) attributes to the specific DAPI node we hit and not to a generic timeout. - `error::as_asset_lock_proof_cl_height_too_low` — extractor for the 10506 consensus error from `dash_sdk::Error`. Covers both `StateTransitionBroadcastError` and `Protocol(ProtocolError::ConsensusError)` wrapping paths. Re-audit when SDK gains new variants that carry consensus errors. - `registration::submit_with_cl_height_retry` — generic retry loop with a 210s budget (3min Platform interval + 30s safety margin) and 15s inter-attempt sleeps. Not cancellation-safe; documented inline. - Wired into both `register_identity_with_funding` and `top_up_identity_with_funding`. SDK side plumbs `PutSettings::user_fee_increase` through the two "with_signer" / "with_private_key" submission paths that previously hardcoded `0`. The DPP `IdentityCreateTransitionV0` builder is refactored to construct the struct via record-init (with `identity_id` from `asset_lock_proof.create_identifier()`) instead of mutating an unset default — same end state, but the new shape makes the `user_fee_increase` threading visible at the construction site. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…outcomes Two observability gaps surfaced while diagnosing the asset-lock resume flow: 1. `dash_sdk_enable_logging`'s EnvFilter directive listed `dash_sdk`/`rs_sdk`/`rs_sdk_ffi`/`dapi_grpc`/h2/tower/hyper/tonic but NOT `platform_wallet` or `platform_wallet_ffi`. Tracing macros in those crates fell through to the default level (warn) — so the resume / wait_for_proof / catch-up paths only logged on failure. Diagnosing a stuck cascade required adding ad-hoc prints each time. 2. `asset_lock_manager_catch_up_blocking` discarded `resume_asset_lock`'s `Result` and returned `ok()` unconditionally. Swift saw success regardless of whether the proof acquisition succeeded, timed out, or hit an invalid-handle path. The lock would stay at statusRaw=1 with no diagnostic surface. Add the two crates to the filter; map the `resume_asset_lock` result through to the FFI return code (Ok → ok, Err → ErrorWalletOperation with the error message); log the entry, success, and failure cases at info/warn so the Xcode console shows the full lifecycle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ents `PersistentTransaction`: surface asset-lock / asset-unlock detection (`isAssetLock`, `isAssetUnlock`) and a `displayDirection` helper so the Transactions list and detail view can render "Asset Lock" / "Asset Unlock" labels instead of the misleading raw `Internal` direction (the L1 DASH isn't going "to myself" — it's converting to L2 platform credits). `PlatformWalletManager.catchUpStuckAssetLocks`: cap concurrent catch-up tasks at 4. Each `asset_lock_manager_catch_up_blocking` parks a worker thread for up to 300s; N stuck locks at launch would otherwise spawn N parallel parked threads and starve every other `Task` in the app (UI updates, SwiftData writes, network calls). The bottleneck is per-lock SPV chainlock arrival, not catch-up throughput — running 4 in parallel vs 50 changes nothing about how fast each individual lock resolves. Example-app views (`IdentitiesContentView`, `TransactionListView`, `TransactionDetailView`, `StorageModelListViews`, etc.): consume the new asset-lock display surface, render the Consumed status added in the rust side, and tighten existing rendering paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…unds plan doc The asset-lock resume / identity-from-core-funds plan doc has been superseded by the actual implementation landing across the previous commits; the file no longer reflects current code. Drop it. `.claude/skills/simulator-control/SKILL.md` picks up small workflow tweaks accumulated during the asset-lock debug session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routine lockfile updates picked up while building — `itertools 0.10` → `0.13` for `bindgen`, `windows-sys 0.59` → `0.61` transitively. No direct dependency changes; no semantic effect on our code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This reverts commit f65e2e4. `chain_lock_promotions` was added to fix the `InBlock → InChainLockedBlock` transition not reaching SwiftData. It worked, but only covered ONE of the four transition surfaces that should keep `PersistentTransaction.context` truthful — leaving the parallel `Mempool → InstantSend` and `InstantSend → InChainLockedBlock` paths just as broken as they were before (acknowledged in the `TODO(events)` at `core_wallet_types.rs:222-230`). Keeping one half-fixed while the other stays broken is internally inconsistent: the "Chain Locked" UI badge in `TransactionListView` / `StorageRecordDetailViews` would fire correctly while the equivalent "InstantSend" branch (`PersistentTransaction.contextName` case 1) remained dead code. Either we commit to wiring all transitions through the bridge or we leave none of them. Independent of that consistency argument: the asset-lock resume flow doesn't need this bridge to function. The companion commit `3cb42211e3` (`build CL proof from metadata.last_applied_chain_lock fallback`) reads the wallet's global chain-lock metadata directly in `wait_for_proof`, bypassing the per-record context flip entirely. What `chain_lock_promotions` was nominally enabling — the `InChainLockedBlock` arm of `wait_for_proof`'s context match — was already redundant with the `_` fallback arm we now own. Revert returns the system to its pre-May-13 finalization-display state: `context = 2 (InBlock)` is the last stop in SwiftData for any tx that gets chain-locked after first being persisted. The "Chain Locked" branding is back to being dead code, matching the "InstantSend" branding that was always dead code. If we later want either of them live, we should wire the full event-bus pipeline through `TODO(events)` rather than keep this partial fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…egistration `RegistrationProgressView` had a symmetric design for the IS/CL proof-acquisition halves (steps 3 and 4 in the 1..5 progress list), but only step 4 carried a `.skipped` carve-out for the success path where its counterpart resolved first. The mirror case — step 3 should be `.skipped` when the lock came back ChainLock-locked without an InstantSend round — was missing, so any registration that completed via the CL path rendered a green "InstantSend proof received ✅" check even though no IS proof was ever observed. Two paths reach `statusRaw == 3`: 1. IS timed out at 300s and `upgrade_to_chain_lock_proof` ran the ChainLock fallback. This existed prior but was rare (only after a full 5-minute IS wait expired). 2. NEW: `wait_for_proof`'s `metadata.last_applied_chain_lock` fallback (the Option B path landed in `3cb42211e3`) builds a Chain proof directly without attempting IS. Common after a resume-from-restart where the wallet already holds a CL covering the funding tx's block height — which means the misleading "InstantSend ✅" badge is now the typical outcome on the happy path, not an edge case. Add `step3WasSkipped` mirroring `step4WasSkipped`, and extend the `stepState` carve-out so `idx == 3 && step3WasSkipped → .skipped`. The dashed "step 3 was skipped" rendering matches the existing "step 4 was skipped" rendering on the IS-success path, keeping the two halves visually consistent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…back wait too `step3WasSkipped` was previously gated on the terminal `statusRaw == 3` state. That covered the post-CL-fallback success case but missed the intermediate state where IS has already timed out and we're mid-CL-wait: statusRaw == 1 (still Broadcast row) + elapsed > instantLockTimeout (broadcastSubStep returns 4) In that state the helper banner already reads "InstantSend timed out; falling back to ChainLock finality (~2 min)" while step 3 silently renders a green ✅ — exactly the contradiction reported on the in-flight registration screen. Broaden the predicate to `statusRaw != 2`: step 3 was successful iff the lock is IS-locked, period. Every other "moved past step 3" state counts as skipped. The `idx < currentStep` gate in `stepState` ensures this only takes effect once the progress bar has actually moved past step 3 (so `statusRaw == 0/1` with `currentStep <= 3` isn't asked about step 3's left-behind state at all). Symmetric to how `step4WasSkipped` works on the IS-success path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…chain_lock across restarts The wallet's `metadata.last_applied_chain_lock` was purely in-memory: SPV's per-session apply_chain_lock would set it, but nothing wrote it to SwiftData. After every restart it started as `None`, leaving the asset-lock-resume CL-from-metadata fallback (`proof.rs::wait_for_proof`, landed in commit `3cb42211e3`) blind for the SPV catch-up window. The catch-up task would park on `lock_notify` and only complete once SPV reapplied a fresh ChainLock — wasted latency. Roundtrip the field across launches. Bincode-encode the `ChainLock` when the bridge sees `WalletEvent::TransactionsChainlocked`, write to a new `lastAppliedChainLockBytes: Data?` column on `PersistentWallet`, read back at load, decode, and stamp `wallet_info.metadata` before the wallet enters the manager. After this change, a catch-up task's first `wait_for_proof` poll resolves to a Chain proof immediately if the persisted CL's `block_height >= record.height`, with zero SPV dependency. SPV continues to persist its own `best_chainlock` to its metadata storage independently — this is the symmetric wallet-side persistence, not a duplicate of SPV state. Each layer owns its own crash-recovery story; no upstream change required. Wire path: Rust write proof flow Swift persist SwiftData ----------- ---------------- --------------- -------------------- CoreChangeSet ─→ WalletChangeSetFFI ─→ persistWalletChangeset ─→ PersistentWallet .last_applied_ .last_applied_ .lastApplied .lastAppliedChainLockBytes chain_lock chain_lock_bytes ChainLockBytes Rust load proof flow Swift load SwiftData ----------- ---------------- --------------- -------------------- build_wallet_ ←─ WalletRestoreEntryFFI ←─ loadWalletList ←─ PersistentWallet start_state .last_applied_ .lastApplied .lastAppliedChainLockBytes stamps chain_lock_bytes ChainLockBytes metadata Caveats documented inline: - Persist trigger fires only on `WalletEvent::TransactionsChainlocked`, which upstream emits only when `per_account` is non-empty (the chain lock had records to promote). A CL that advanced metadata but had nothing to promote is invisible to the bridge. Accepted: for the asset-lock-resume use case, the funding tx record is restored to the in-memory map by the existing `restore_unresolved_asset_lock_tx_records` path → first promoting CLSig fires the event → persistence happens → subsequent restarts benefit. - Decode failure on load is treated as miss: logged at `warn`, metadata stays `None`, the next fresh CLSig overwrites the column with a valid value. Window of degraded behavior = SPV catch-up latency, same as if the column had been empty. - Migration: pre-feature `PersistentWallet` rows load fine; SwiftData auto-adds the column as nullable, and the FFI surface treats null bytes / zero length as "no persisted CL". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ngle top_up The two-layer factoring was modelling a use case that doesn't exist in-tree: - L1 `register_identity_with_signer` — declared, documented, never called. The "external callers managing their own submission policy" rationale never materialised; the FFI surface `platform_wallet_register_identity_with_signer` calls `register_from_addresses`, a different method. The L2 `register_identity_with_funding` doesn't even call its own L1 — it inlines `put_to_platform_and_wait_for_response_with_signer` directly to avoid a `keys_map` deep-clone on the IS→CL retry path. - L1 `top_up_identity_with_signer` — one in-tree caller (L2 `top_up_identity_with_funding`), and the body is a 1-call pass-through to the SDK's `Identity::top_up_identity_with_signer` trait method. Inlining costs 5 lines per call site and removes one indirection. Delete both L1 primitives. L2's `top_up_identity_with_funding` calls `Identity::top_up_identity_with_signer` inline at the two sites that used to go through L1 (primary submit + IS→CL retry submit). Rewrite the module docstring to describe the actual single-method-per-operation shape, with the recovery layers (CL-height-too-low retry, IS-lock rejection, IS-lock build-time timeout) called out as the real architectural axis. Drop the L1/L2 references in adjacent comments. The L2 method names (`register_identity_with_funding`, `top_up_identity_with_funding`) are kept as-is — renaming would ripple through the FFI surface (`platform_wallet_register_identity_with_funding_signer`) and Swift wrappers, which is a separate decision. If we ever want a thin "primitive" again (e.g. for an external tool managing its own submission policy), the call sites are obvious: it's the body of `Identity::put_to_platform_and_wait_for_response_with_signer` / `Identity::top_up_identity_with_signer` already invoked inline. Net change: -148 / +59 lines, two `pub` methods retired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The PR fixes the main stuck-asset-lock flow, but three issues in the new recovery/signer paths still need attention. Two are runtime bugs in the asset-lock lifecycle, and one is a private-key memory-handling bug in the new FFI signer. I also dropped one interop report that does not reproduce on the current code path because consumed locks are removed from the in-memory list before Swift can decode them.
Reviewed commit: 97417be
🔴 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 358-397: Repeated catch-up passes can invalidate live asset-lock handles and block the main actor
`catchUpStuckAssetLocks` clears `retainedAssetLockManagers` at the start of every invocation, but the previous invocation has already launched detached tasks that keep using the raw `handle` through `asset_lock_manager_catch_up_blocking`. Releasing the wrapper runs `asset_lock_manager_destroy` in `ManagedAssetLockManager.deinit`, so a foreground or reconnect-triggered second pass can destroy handles that the first batch is still using. On the Rust side, `asset_lock_manager_catch_up_blocking` holds a read lock on the handle table for the full `runtime().block_on(manager.resume_asset_lock(...))` call, while destroy takes the write lock to remove the handle. That means the second pass can both turn the first batch into `errorInvalidHandle` and synchronously block the main actor until the earlier proof wait finishes or times out.
In `packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs`:
- [BLOCKING] lines 60-79: `consume_asset_lock` computes a consumed-state changeset but never persists it
`consume_asset_lock()` removes the entry from `tracked_asset_locks`, changes its status to `Consumed`, and returns an `AssetLockChangeSet`, but it never calls `queue_asset_lock_changeset()`. Both current callers in `identity/network/registration.rs` only check for `Err` and discard the successful return value, so the consumed state never reaches the Swift persister. That leaves the on-disk `PersistentAssetLock` row at its pre-consumption status, which contradicts this method's own contract and causes restart/history paths to reload the lock as still actionable instead of terminally consumed.
In `packages/rs-platform-wallet-ffi/src/mnemonic_resolver_core_signer.rs`:
- [BLOCKING] lines 301-334: The mnemonic-backed signer leaves a second copy of the private scalar in `SecretKey` memory
The zeroization comment here is wrong. `derive_priv()` returns `Zeroizing<[u8; 32]>`, but `secp256k1::SecretKey::from_slice(secret_bytes.as_ref())` copies those bytes into a separate `SecretKey` value. In the vendored `secp256k1 0.30.0` crate, `SecretKey` is a `Copy` wrapper over its own `[u8; 32]` buffer and exposes `non_secure_erase()` for manual wiping. As written, dropping `secret_bytes` only clears the original buffer; the copied scalar inside `secret` remains in process memory until reuse. That defeats the explicit goal of keeping the derived private key out of long-lived Rust memory in both `sign_ecdsa()` and `public_key()`.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] lines 2816-2821: The asset-lock restore format hardcodes BIP44 account 0 even though the public API accepts other account indices
Both restore builders write `entry.account_index = 0` instead of persisting the real funding account. The comments acknowledge this as a temporary limitation, but the SDK already exposes public asset-lock APIs with an `accountIndex` parameter, so this is not just dead code. On reload, Rust restores unresolved asset-lock transactions into `standard_bip44_accounts.get_mut(&rec.account_index)` and drops records whose account is missing, so any asset lock funded from a nonzero BIP44 account will fail to recover after restart. The same hardcoded value is repeated in `buildUnresolvedAssetLockTxRecordBuffer` at lines 2912-2921, so both the tracked-lock snapshot and the unresolved-tx replay path are affected.
| public func catchUpStuckAssetLocks(wallets: [ManagedPlatformWallet]) { | ||
| guard let persistenceHandler = persistenceHandler else { return } | ||
| // 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 |
There was a problem hiding this comment.
🔴 Blocking: Repeated catch-up passes can invalidate live asset-lock handles and block the main actor
catchUpStuckAssetLocks clears retainedAssetLockManagers at the start of every invocation, but the previous invocation has already launched detached tasks that keep using the raw handle through asset_lock_manager_catch_up_blocking. Releasing the wrapper runs asset_lock_manager_destroy in ManagedAssetLockManager.deinit, so a foreground or reconnect-triggered second pass can destroy handles that the first batch is still using. On the Rust side, asset_lock_manager_catch_up_blocking holds a read lock on the handle table for the full runtime().block_on(manager.resume_asset_lock(...)) call, while destroy takes the write lock to remove the handle. That means the second pass can both turn the first batch into errorInvalidHandle and synchronously block the main actor until the earlier proof wait finishes or times 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [BLOCKING] lines 358-397: Repeated catch-up passes can invalidate live asset-lock handles and block the main actor
`catchUpStuckAssetLocks` clears `retainedAssetLockManagers` at the start of every invocation, but the previous invocation has already launched detached tasks that keep using the raw `handle` through `asset_lock_manager_catch_up_blocking`. Releasing the wrapper runs `asset_lock_manager_destroy` in `ManagedAssetLockManager.deinit`, so a foreground or reconnect-triggered second pass can destroy handles that the first batch is still using. On the Rust side, `asset_lock_manager_catch_up_blocking` holds a read lock on the handle table for the full `runtime().block_on(manager.resume_asset_lock(...))` call, while destroy takes the write lock to remove the handle. That means the second pass can both turn the first batch into `errorInvalidHandle` and synchronously block the main actor until the earlier proof wait finishes or times out.
| 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) |
There was a problem hiding this comment.
🔴 Blocking: consume_asset_lock computes a consumed-state changeset but never persists it
consume_asset_lock() removes the entry from tracked_asset_locks, changes its status to Consumed, and returns an AssetLockChangeSet, but it never calls queue_asset_lock_changeset(). Both current callers in identity/network/registration.rs only check for Err and discard the successful return value, so the consumed state never reaches the Swift persister. That leaves the on-disk PersistentAssetLock row at its pre-consumption status, which contradicts this method's own contract and causes restart/history paths to reload the lock as still actionable instead of terminally consumed.
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 a consumed-state changeset but never persists it
`consume_asset_lock()` removes the entry from `tracked_asset_locks`, changes its status to `Consumed`, and returns an `AssetLockChangeSet`, but it never calls `queue_asset_lock_changeset()`. Both current callers in `identity/network/registration.rs` only check for `Err` and discard the successful return value, so the consumed state never reaches the Swift persister. That leaves the on-disk `PersistentAssetLock` row at its pre-consumption status, which contradicts this method's own contract and causes restart/history paths to reload the lock as still actionable instead of terminally consumed.
| 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(); | ||
| // `SecretKey::from_slice` validates the 32-byte scalar is a | ||
| // legitimate field element. The slice borrow is dropped at | ||
| // the end of this block; `secret_bytes` is then zeroed when | ||
| // it falls out of scope. | ||
| let 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; | ||
| 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()) | ||
| .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?; | ||
| let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret); | ||
| let _ = secret; | ||
| Ok(pubkey) |
There was a problem hiding this comment.
🔴 Blocking: The mnemonic-backed signer leaves a second copy of the private scalar in SecretKey memory
The zeroization comment here is wrong. derive_priv() returns Zeroizing<[u8; 32]>, but secp256k1::SecretKey::from_slice(secret_bytes.as_ref()) copies those bytes into a separate SecretKey value. In the vendored secp256k1 0.30.0 crate, SecretKey is a Copy wrapper over its own [u8; 32] buffer and exposes non_secure_erase() for manual wiping. As written, dropping secret_bytes only clears the original buffer; the copied scalar inside secret remains in process memory until reuse. That defeats the explicit goal of keeping the derived private key out of long-lived Rust memory in both sign_ecdsa() and public_key().
💡 Suggested change
| 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(); | |
| // `SecretKey::from_slice` validates the 32-byte scalar is a | |
| // legitimate field element. The slice borrow is dropped at | |
| // the end of this block; `secret_bytes` is then zeroed when | |
| // it falls out of scope. | |
| let 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; | |
| 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()) | |
| .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?; | |
| let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret); | |
| let _ = secret; | |
| Ok(pubkey) | |
| 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 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); | |
| 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 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); | |
| secret.non_secure_erase(); | |
| Ok(pubkey) | |
| } |
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/mnemonic_resolver_core_signer.rs`:
- [BLOCKING] lines 301-334: The mnemonic-backed signer leaves a second copy of the private scalar in `SecretKey` memory
The zeroization comment here is wrong. `derive_priv()` returns `Zeroizing<[u8; 32]>`, but `secp256k1::SecretKey::from_slice(secret_bytes.as_ref())` copies those bytes into a separate `SecretKey` value. In the vendored `secp256k1 0.30.0` crate, `SecretKey` is a `Copy` wrapper over its own `[u8; 32]` buffer and exposes `non_secure_erase()` for manual wiping. As written, dropping `secret_bytes` only clears the original buffer; the copied scalar inside `secret` remains in process memory until reuse. That defeats the explicit goal of keeping the derived private key out of long-lived Rust memory in both `sign_ecdsa()` and `public_key()`.
| // `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 |
There was a problem hiding this comment.
🟡 Suggestion: The asset-lock restore format hardcodes BIP44 account 0 even though the public API accepts other account indices
Both restore builders write entry.account_index = 0 instead of persisting the real funding account. The comments acknowledge this as a temporary limitation, but the SDK already exposes public asset-lock APIs with an accountIndex parameter, so this is not just dead code. On reload, Rust restores unresolved asset-lock transactions into standard_bip44_accounts.get_mut(&rec.account_index) and drops records whose account is missing, so any asset lock funded from a nonzero BIP44 account will fail to recover after restart. The same hardcoded value is repeated in buildUnresolvedAssetLockTxRecordBuffer at lines 2912-2921, so both the tracked-lock snapshot and the unresolved-tx replay path are affected.
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 2816-2821: The asset-lock restore format hardcodes BIP44 account 0 even though the public API accepts other account indices
Both restore builders write `entry.account_index = 0` instead of persisting the real funding account. The comments acknowledge this as a temporary limitation, but the SDK already exposes public asset-lock APIs with an `accountIndex` parameter, so this is not just dead code. On reload, Rust restores unresolved asset-lock transactions into `standard_bip44_accounts.get_mut(&rec.account_index)` and drops records whose account is missing, so any asset lock funded from a nonzero BIP44 account will fail to recover after restart. The same hardcoded value is repeated in `buildUnresolvedAssetLockTxRecordBuffer` at lines 2912-2921, so both the tracked-lock snapshot and the unresolved-tx replay path are affected.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
packages/rs-platform-wallet/src/wallet/apply.rs (1)
293-309: ⚡ Quick winAdd a regression test for the
Consumedbranch.This new branch is core to restart/recovery semantics, but current tests still only cover insert/tombstone flow. Please add a focused test that seeds a tracked lock, applies
status = Consumed, and asserts the in-memory map entry is removed.Suggested test shape
+ #[test] + fn apply_asset_lock_consumed_removes_from_in_memory_map() { + let mut wallet = build_test_wallet(); + let mut info = empty_info(&wallet); + let out_point = OutPoint::default(); + + // Seed as Built. + let mut seed = AssetLockChangeSet::default(); + seed.asset_locks.insert( + out_point, + AssetLockEntry { + out_point, + transaction: dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount_duffs: 5_000, + status: AssetLockStatus::Built, + proof: None, + }, + ); + let mut cs = PlatformWalletChangeSet::default(); + cs.asset_locks = Some(seed); + info.apply_changeset(&mut wallet, cs).expect("seed"); + assert!(info.tracked_asset_locks.contains_key(&out_point)); + + // Apply Consumed transition. + let mut consumed = AssetLockChangeSet::default(); + consumed.asset_locks.insert( + out_point, + AssetLockEntry { + out_point, + transaction: dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount_duffs: 5_000, + status: AssetLockStatus::Consumed, + proof: None, + }, + ); + let mut cs = PlatformWalletChangeSet::default(); + cs.asset_locks = Some(consumed); + info.apply_changeset(&mut wallet, cs).expect("consume"); + assert!(!info.tracked_asset_locks.contains_key(&out_point)); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/rs-platform-wallet/src/wallet/apply.rs` around lines 293 - 309, Add a regression test that seeds the in-memory tracked_asset_locks map with a TrackedAssetLock (keyed by out_point), then constructs an entry whose status is AssetLockStatus::Consumed and invokes the code path that processes that entry (the function that contains the shown branch handling entry.status). After invoking it assert that tracked_asset_locks does not contain the out_point (i.e. the entry was removed). Use the exact symbols TrackedAssetLock, tracked_asset_locks, AssetLockStatus::Consumed, out_point and entry to locate and exercise the branch.
🤖 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 @.claude/skills/simulator-control/SKILL.md:
- Around line 63-82: The tap_label function exports LABEL and UDID inline before
invoking a python -c string which can fail to receive those env vars in non-bash
shells; update tap_label to pass LABEL and UDID into the Python interpreter via
a heredoc (invoke python3 with a here-doc block) or explicitly export them in
the shell environment before calling idb so the Python code can read
os.environ['LABEL'] and os.environ['UDID'] reliably; locate the tap_label
function and the inline python3 -c invocation and replace it with a heredoc form
(or an explicit export of LABEL/UDID) so environment propagation is portable
across shells.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift`:
- Line 26: PersistentAssetLockDisplay's canFundIdentity currently returns true
for Consumed (statusRaw == 4); update the predicate to exclude Consumed so spent
locks can't fund identities. Locate the canFundIdentity computed property in
PersistentAssetLockDisplay and change its logic to require statusRaw >= 2 AND
statusRaw != 4 (i.e., allow ChainLocked and other non-consumed finalized states
but explicitly disallow Consumed); reference the canFundIdentity property and
statusRaw field when making the change.
---
Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/apply.rs`:
- Around line 293-309: Add a regression test that seeds the in-memory
tracked_asset_locks map with a TrackedAssetLock (keyed by out_point), then
constructs an entry whose status is AssetLockStatus::Consumed and invokes the
code path that processes that entry (the function that contains the shown branch
handling entry.status). After invoking it assert that tracked_asset_locks does
not contain the out_point (i.e. the entry was removed). Use the exact symbols
TrackedAssetLock, tracked_asset_locks, AssetLockStatus::Consumed, out_point and
entry to locate and exercise the branch.
🪄 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: a2891db7-1533-4806-b424-3a03b004291e
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (36)
.claude/skills/simulator-control/SKILL.mdpackages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/v0/v0_methods.rspackages/rs-platform-wallet-ffi/src/asset_lock/manager.rspackages/rs-platform-wallet-ffi/src/asset_lock/sync.rspackages/rs-platform-wallet-ffi/src/asset_lock_persistence.rspackages/rs-platform-wallet-ffi/src/core_wallet_types.rspackages/rs-platform-wallet-ffi/src/persistence.rspackages/rs-platform-wallet-ffi/src/wallet_restore_types.rspackages/rs-platform-wallet/src/changeset/changeset.rspackages/rs-platform-wallet/src/changeset/core_bridge.rspackages/rs-platform-wallet/src/error.rspackages/rs-platform-wallet/src/manager/accessors.rspackages/rs-platform-wallet/src/wallet/apply.rspackages/rs-platform-wallet/src/wallet/asset_lock/lock_notify_handler.rspackages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rspackages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rspackages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rspackages/rs-platform-wallet/src/wallet/asset_lock/tracked.rspackages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rspackages/rs-platform-wallet/src/wallet/identity/network/registration.rspackages/rs-platform-wallet/src/wallet/platform_wallet_traits.rspackages/rs-sdk-ffi/src/lib.rspackages/rs-sdk/src/platform/transition/broadcast_identity.rspackages/rs-sdk/src/platform/transition/put_identity.rspackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift
✅ Files skipped from review due to trivial changes (2)
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift
- packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs
🚧 Files skipped from review as they are similar to previous changes (10)
- packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs
- packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/v0/v0_methods.rs
- packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
- packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs
- packages/rs-platform-wallet-ffi/src/persistence.rs
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
- packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift
- packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs
| tap_label() { | ||
| local label="$1" | ||
| local udid=$(xcrun simctl list devices booted | awk -F'[()]' '/Booted/ {print $2}') | ||
| LABEL="$label" UDID="$udid" "$HOME/.local/bin/idb" ui describe-all --udid "$udid" 2>&1 \ | ||
| | python3 -c " | ||
| import json, os, subprocess, sys | ||
| items = json.loads(sys.stdin.read()) | ||
| label = os.environ['LABEL'] | ||
| # Exact match first, fall back to substring | ||
| match = next((it for it in items if it.get('AXLabel') == label and it.get('enabled')), None) | ||
| if not match: | ||
| match = next((it for it in items if label in (it.get('AXLabel') or '') and it.get('enabled')), None) | ||
| if not match: | ||
| print(f'no enabled element matching {label!r}', file=sys.stderr); sys.exit(1) | ||
| f = match['frame'] | ||
| x, y = int(f['x'] + f['width']/2), int(f['y'] + f['height']/2) | ||
| subprocess.run([os.path.expanduser('~/.local/bin/idb'), 'ui', 'tap', '--udid', os.environ['UDID'], str(x), str(y)], check=True) | ||
| print(f'tapped {match.get(\"AXLabel\")!r} ({match.get(\"type\")}) at ({x},{y})') | ||
| " | ||
| } |
There was a problem hiding this comment.
Environment variable propagation inconsistency with pitfall warning.
The tap_label function sets environment variables using LABEL="$label" UDID="$udid" command | python3 -c "..." (lines 66-81), but lines 258-264 warn that "Env vars don't propagate into python3 -c '...' the way they do into bash" and recommend using a heredoc form instead.
While this pattern works in bash, it may fail in other shells or configurations, causing the Python script to see empty environment variables and the function to fail silently.
🛡️ Proposed fix for shell portability
tap_label() {
local label="$1"
local udid=$(xcrun simctl list devices booted | awk -F'[()]' '/Booted/ {print $2}')
- LABEL="$label" UDID="$udid" "$HOME/.local/bin/idb" ui describe-all --udid "$udid" 2>&1 \
- | python3 -c "
+ export LABEL="$label"
+ export UDID="$udid"
+ "$HOME/.local/bin/idb" ui describe-all --udid "$udid" 2>&1 | python3 -c "
import json, os, subprocess, sys
items = json.loads(sys.stdin.read())
label = os.environ['LABEL']Or use the heredoc pattern recommended in the pitfalls section for maximum portability.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| tap_label() { | |
| local label="$1" | |
| local udid=$(xcrun simctl list devices booted | awk -F'[()]' '/Booted/ {print $2}') | |
| LABEL="$label" UDID="$udid" "$HOME/.local/bin/idb" ui describe-all --udid "$udid" 2>&1 \ | |
| | python3 -c " | |
| import json, os, subprocess, sys | |
| items = json.loads(sys.stdin.read()) | |
| label = os.environ['LABEL'] | |
| # Exact match first, fall back to substring | |
| match = next((it for it in items if it.get('AXLabel') == label and it.get('enabled')), None) | |
| if not match: | |
| match = next((it for it in items if label in (it.get('AXLabel') or '') and it.get('enabled')), None) | |
| if not match: | |
| print(f'no enabled element matching {label!r}', file=sys.stderr); sys.exit(1) | |
| f = match['frame'] | |
| x, y = int(f['x'] + f['width']/2), int(f['y'] + f['height']/2) | |
| subprocess.run([os.path.expanduser('~/.local/bin/idb'), 'ui', 'tap', '--udid', os.environ['UDID'], str(x), str(y)], check=True) | |
| print(f'tapped {match.get(\"AXLabel\")!r} ({match.get(\"type\")}) at ({x},{y})') | |
| " | |
| } | |
| tap_label() { | |
| local label="$1" | |
| local udid=$(xcrun simctl list devices booted | awk -F'[()]' '/Booted/ {print $2}') | |
| export LABEL="$label" | |
| export UDID="$udid" | |
| "$HOME/.local/bin/idb" ui describe-all --udid "$udid" 2>&1 | python3 -c " | |
| import json, os, subprocess, sys | |
| items = json.loads(sys.stdin.read()) | |
| label = os.environ['LABEL'] | |
| # Exact match first, fall back to substring | |
| match = next((it for it in items if it.get('AXLabel') == label and it.get('enabled')), None) | |
| if not match: | |
| match = next((it for it in items if label in (it.get('AXLabel') or '') and it.get('enabled')), None) | |
| if not match: | |
| print(f'no enabled element matching {label!r}', file=sys.stderr); sys.exit(1) | |
| f = match['frame'] | |
| x, y = int(f['x'] + f['width']/2), int(f['y'] + f['height']/2) | |
| subprocess.run([os.path.expanduser('~/.local/bin/idb'), 'ui', 'tap', '--udid', os.environ['UDID'], str(x), str(y)], check=True) | |
| print(f'tapped {match.get(\"AXLabel\")!r} ({match.get(\"type\")}) at ({x},{y})') | |
| " | |
| } |
🤖 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 @.claude/skills/simulator-control/SKILL.md around lines 63 - 82, The
tap_label function exports LABEL and UDID inline before invoking a python -c
string which can fail to receive those env vars in non-bash shells; update
tap_label to pass LABEL and UDID into the Python interpreter via a heredoc
(invoke python3 with a here-doc block) or explicitly export them in the shell
environment before calling idb so the Python code can read os.environ['LABEL']
and os.environ['UDID'] reliably; locate the tap_label function and the inline
python3 -c invocation and replace it with a heredoc form (or an explicit export
of LABEL/UDID) so environment propagation is portable across shells.
| /// now; the Resumable Registrations row's Resume button gates | ||
| /// on this. Built (0) and Broadcast (1) have no signed proof | ||
| /// yet — submitting them would fail at the Platform layer. | ||
| var canFundIdentity: Bool { statusRaw >= 2 } |
There was a problem hiding this comment.
Fix predicate to exclude Consumed status.
canFundIdentity currently returns true for Consumed locks (statusRaw == 4), but consumed locks have already been spent and cannot fund identities. The Resume button should not be enabled for these.
🐛 Proposed fix to exclude Consumed status
- var canFundIdentity: Bool { statusRaw >= 2 }
+ var canFundIdentity: Bool { statusRaw == 2 || statusRaw == 3 }Alternatively, if additional finalized states are added between ChainLocked and Consumed in the future:
- var canFundIdentity: Bool { statusRaw >= 2 }
+ var canFundIdentity: Bool { statusRaw >= 2 && statusRaw < 4 }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| var canFundIdentity: Bool { statusRaw >= 2 } | |
| var canFundIdentity: Bool { statusRaw == 2 || statusRaw == 3 } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift`
at line 26, PersistentAssetLockDisplay's canFundIdentity currently returns true
for Consumed (statusRaw == 4); update the predicate to exclude Consumed so spent
locks can't fund identities. Locate the canFundIdentity computed property in
PersistentAssetLockDisplay and change its logic to require statusRaw >= 2 AND
statusRaw != 4 (i.e., allow ChainLocked and other non-consumed finalized states
but explicitly disallow Consumed); reference the canFundIdentity property and
statusRaw field when making the change.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The current head fixes the new chain-lock watermark persistence path, but it does not resolve the three blocking issues carried forward from the earlier review. One additional restore-path limitation remains live as a non-blocking recovery gap: it is now documented in comments, but the runtime behavior is unchanged.
Reviewed commit: 65e417d
🔴 2 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 358-425: Repeated catch-up passes can destroy or stall asset-lock managers that detached tasks are still using
`catchUpStuckAssetLocks()` clears `retainedAssetLockManagers` at line 365 before it launches the next batch, but the previous batch's detached tasks still hold only the raw `handle` captured at lines 396-425. Releasing the wrapper runs `asset_lock_manager_destroy()` in `ManagedAssetLockManager.deinit`, and the Rust FFI side resolves that handle through `ASSET_LOCK_MANAGER_STORAGE.with_item(...)` for the full synchronous `runtime().block_on(manager.resume_asset_lock(...))` call. That creates two bad outcomes on a second foreground/reconnect pass: `deinit` can synchronously block the main actor waiting for the handle-table write lock, and any later tasks from the first batch can lose the handle once destroy finally removes it. The comments claiming prior tasks have already completed or timed out are not enforced anywhere in this method.
In `packages/rs-platform-wallet-ffi/src/mnemonic_resolver_core_signer.rs`:
- [BLOCKING] lines 301-334: The mnemonic-backed signer still leaves a second private-key copy in `SecretKey` memory
`derive_priv()` returns `Zeroizing<[u8; 32]>`, but both `sign_ecdsa()` and `public_key()` immediately copy those bytes into a separate `secp256k1::SecretKey` with `SecretKey::from_slice(...)`. Zeroizing `secret_bytes` only wipes the original buffer; it does not wipe the copied scalar owned by `secret`. The current comment at lines 317-322 is therefore incorrect, and the implementation still violates the stated FFI security goal that mnemonic-derived private key material not remain resident on the Rust side after the call.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] lines 2859-2964: The restore path still hardcodes asset-lock account index 0
Both restore builders still write `account_index = 0`: `buildAssetLockRestoreBuffer()` at line 2864 and `buildUnresolvedAssetLockTxRecordBuffer()` at line 2964. That value is load-bearing on the Rust side: tracked locks are keyed under `map.entry(spec.account_index)`, and unresolved funding transactions are restored through `standard_bip44_accounts.get_mut(&rec.account_index)` and dropped when that account does not exist. The new comments document this as a known limitation, but the public Swift SDK surface still exposes nonzero `accountIndex` on asset-lock creation APIs, so restart recovery for locks funded from other BIP44 accounts remains incorrect.
| public func catchUpStuckAssetLocks(wallets: [ManagedPlatformWallet]) { | ||
| guard let persistenceHandler = persistenceHandler else { return } | ||
| // 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) | ||
| } |
There was a problem hiding this comment.
🔴 Blocking: Repeated catch-up passes can destroy or stall asset-lock managers that detached tasks are still using
catchUpStuckAssetLocks() clears retainedAssetLockManagers at line 365 before it launches the next batch, but the previous batch's detached tasks still hold only the raw handle captured at lines 396-425. Releasing the wrapper runs asset_lock_manager_destroy() in ManagedAssetLockManager.deinit, and the Rust FFI side resolves that handle through ASSET_LOCK_MANAGER_STORAGE.with_item(...) for the full synchronous runtime().block_on(manager.resume_asset_lock(...)) call. That creates two bad outcomes on a second foreground/reconnect pass: deinit can synchronously block the main actor waiting for the handle-table write lock, and any later tasks from the first batch can lose the handle once destroy finally removes it. The comments claiming prior tasks have already completed or timed out are not enforced anywhere in this method.
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 358-425: Repeated catch-up passes can destroy or stall asset-lock managers that detached tasks are still using
`catchUpStuckAssetLocks()` clears `retainedAssetLockManagers` at line 365 before it launches the next batch, but the previous batch's detached tasks still hold only the raw `handle` captured at lines 396-425. Releasing the wrapper runs `asset_lock_manager_destroy()` in `ManagedAssetLockManager.deinit`, and the Rust FFI side resolves that handle through `ASSET_LOCK_MANAGER_STORAGE.with_item(...)` for the full synchronous `runtime().block_on(manager.resume_asset_lock(...))` call. That creates two bad outcomes on a second foreground/reconnect pass: `deinit` can synchronously block the main actor waiting for the handle-table write lock, and any later tasks from the first batch can lose the handle once destroy finally removes it. The comments claiming prior tasks have already completed or timed out are not enforced anywhere in this method.
| 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(); | ||
| // `SecretKey::from_slice` validates the 32-byte scalar is a | ||
| // legitimate field element. The slice borrow is dropped at | ||
| // the end of this block; `secret_bytes` is then zeroed when | ||
| // it falls out of scope. | ||
| let 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; | ||
| 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()) | ||
| .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?; | ||
| let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret); | ||
| let _ = secret; | ||
| Ok(pubkey) |
There was a problem hiding this comment.
🔴 Blocking: The mnemonic-backed signer still leaves a second private-key copy in SecretKey memory
derive_priv() returns Zeroizing<[u8; 32]>, but both sign_ecdsa() and public_key() immediately copy those bytes into a separate secp256k1::SecretKey with SecretKey::from_slice(...). Zeroizing secret_bytes only wipes the original buffer; it does not wipe the copied scalar owned by secret. The current comment at lines 317-322 is therefore incorrect, and the implementation still violates the stated FFI security goal that mnemonic-derived private key material not remain resident on the Rust side after the call.
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/mnemonic_resolver_core_signer.rs`:
- [BLOCKING] lines 301-334: The mnemonic-backed signer still leaves a second private-key copy in `SecretKey` memory
`derive_priv()` returns `Zeroizing<[u8; 32]>`, but both `sign_ecdsa()` and `public_key()` immediately copy those bytes into a separate `secp256k1::SecretKey` with `SecretKey::from_slice(...)`. Zeroizing `secret_bytes` only wipes the original buffer; it does not wipe the copied scalar owned by `secret`. The current comment at lines 317-322 is therefore incorrect, and the implementation still violates the stated FFI security goal that mnemonic-derived private key material not remain resident on the Rust side after the call.
| // `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 |
There was a problem hiding this comment.
🟡 Suggestion: The restore path still hardcodes asset-lock account index 0
Both restore builders still write account_index = 0: buildAssetLockRestoreBuffer() at line 2864 and buildUnresolvedAssetLockTxRecordBuffer() at line 2964. That value is load-bearing on the Rust side: tracked locks are keyed under map.entry(spec.account_index), and unresolved funding transactions are restored through standard_bip44_accounts.get_mut(&rec.account_index) and dropped when that account does not exist. The new comments document this as a known limitation, but the public Swift SDK surface still exposes nonzero accountIndex on asset-lock creation APIs, so restart recovery for locks funded from other BIP44 accounts remains incorrect.
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: The restore path still hardcodes asset-lock account index 0
Both restore builders still write `account_index = 0`: `buildAssetLockRestoreBuffer()` at line 2864 and `buildUnresolvedAssetLockTxRecordBuffer()` at line 2964. That value is load-bearing on the Rust side: tracked locks are keyed under `map.entry(spec.account_index)`, and unresolved funding transactions are restored through `standard_bip44_accounts.get_mut(&rec.account_index)` and dropped when that account does not exist. The new comments document this as a known limitation, but the public Swift SDK surface still exposes nonzero `accountIndex` on asset-lock creation APIs, so restart recovery for locks funded from other BIP44 accounts remains incorrect.
…CoreSigner from platform-wallet-ffi
Both types are SDK-protocol material — they implement
`key_wallet::signer::Signer` over a Swift-supplied mnemonic-resolver
vtable, with no wallet state, no `AssetLockManager`, no
`WalletManager`. The historical placement in `platform-wallet-ffi`
was incidental: that crate happened to host the first consumer.
Now `VTableSigner` (identity / platform-address signer) and
`MnemonicResolverCoreSigner` (asset-lock-derived Core ECDSA signer)
sit together as the SDK's complete FFI signer surface.
What moved
==========
- `mnemonic_resolver_core_signer.rs` (the signer + tests) →
`rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`
- `MnemonicResolverHandle`, `MnemonicResolverVTable`,
`MnemonicResolveCallback`, `MNEMONIC_RESOLVER_BUFFER_CAPACITY`,
`mnemonic_resolver_result` module → new
`rs-sdk-ffi/src/mnemonic_resolver.rs`
- The `dash_sdk_mnemonic_resolver_create` /
`_destroy` FFI entry points moved with the resolver, retaining
identical C ABI shape (Swift bindings unchanged).
What stays
==========
- `IdentityKeyPersisterHandle`, `PersistKeyArgs`,
`PersistKeyCallback`, `PERSIST_KEY_*` constants stay in
`platform-wallet-ffi/src/derive_and_persist_callbacks.rs`. The
Keychain-write semantics (`wallet_id` scoping, identity-index
slot tagging) are wallet-domain. Module renamed in spirit
(header now describes the persister-only role) without renaming
the `.rs` filename — keeping git history intact for the persister
bits.
Wiring
======
- `MnemonicResolverHandle.ctx` and `.vtable` fields promoted from
`pub(crate)` to `pub` so the cross-crate dispatch sites in
platform-wallet-ffi (`sign_with_mnemonic_resolver.rs`,
`derive_identity_key_at_slot.rs`, etc.) can still reach the
vtable. The struct is `#[repr(C)]` and its layout is part of
the C ABI either way; field visibility was an internal-Rust
nicety with no external invariant attached.
- 8 consumers in `platform-wallet-ffi` updated to import from
`rs_sdk_ffi::{MnemonicResolverHandle, …}` instead of
`crate::derive_and_persist_callbacks::{…}`.
- The signer file's `dashcore::secp256k1::*` import becomes
`key_wallet::dashcore::secp256k1::*` since rs-sdk-ffi doesn't
carry `dashcore` as a direct dep (key-wallet re-exports it).
- `parse_mnemonic_any_language` import shifts to the existing
`signer_simple::parse_mnemonic_any_language` already living in
rs-sdk-ffi (was duplicated identically in
platform-wallet-ffi/identity_keys_from_mnemonic.rs — that copy
stays for the rest of platform-wallet-ffi's own consumers).
Net diff
========
-660 lines from platform-wallet-ffi (one full file deletion +
210-line strip from derive_and_persist_callbacks.rs); +660 lines
in rs-sdk-ffi as the two new files. Pure relocation, no behavior
change. Every consumer compiles against the new import path; iOS
build succeeds with regenerated headers (8 mnemonic-resolver
declarations now live in `rs-sdk-ffi/rs-sdk-ffi.h`, the 21
references in `platform-wallet-ffi.h` are all consumer-side
function parameters that take `MnemonicResolverHandle*`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…instead of pre-extracting user_fee_increase
Callers of `BroadcastRequestForNewIdentity::broadcast_request_for_new_identity_with_*`
and `TopUpIdentity::top_up_identity_with_*` were doing this dance:
let user_fee_increase = settings
.as_ref()
.and_then(|s| s.user_fee_increase)
.unwrap_or_default();
identity.broadcast_request_for_new_identity_with_*(..., user_fee_increase, ...)
— pre-extracting a single field from `PutSettings` only to hand it
back to a function that's already getting the full `settings`
elsewhere (for `broadcast_and_wait`). The CL-height retry path in
platform-wallet (commit `2c3ba07db1`) added the same pre-extraction
at every callsite, and the wallet's own inlined `top_up_identity_with_signer`
call (commit `65e417d7cc`) inherited it too.
Drop the redundant param. Both traits now take `settings: Option<PutSettings>`
directly; the impl extracts `user_fee_increase` internally. Same
behaviour, less duplicate plumbing, and the callees become free to
read other `PutSettings` fields later without further signature
changes.
Files
=====
In-scope (asset-lock-funded identity flow):
- `broadcast_identity.rs` — trait + 2 impls: replace
`user_fee_increase: UserFeeIncrease` with `settings: Option<PutSettings>`
- `top_up_identity.rs` — trait + 2 impls: drop
`user_fee_increase: Option<UserFeeIncrease>` param (was redundant
with the `settings` param already present)
- `put_identity.rs` — `put_identity_with_asset_lock_and_private_key`
and `put_identity_with_asset_lock_and_signer` pass settings
straight through. The address-funded path
(`put_identity_with_address_funding`) is untouched — it calls a
DPP method that takes `user_fee_increase` directly and is outside
this PR's scope.
- `registration.rs` (platform-wallet) — wallet caller drops the
`s.as_ref().and_then(|x| x.user_fee_increase)` arg at the two
inlined `identity.top_up_identity_with_signer(...)` sites.
Forced by the trait signature change (not standalone cleanup):
- `rs-sdk-ffi/identity/topup.rs` — FFI consumer of
`TopUpIdentity::top_up_identity_with_private_key`
- `wasm-sdk/src/state_transitions/identity.rs` — wasm-sdk consumer
of the same trait
Out of scope (unrelated to asset-lock funding):
- `shield.rs`, `transfer_to_addresses.rs`, `transfer_address_funds.rs`,
`top_up_address.rs`, `top_up_identity_from_addresses.rs`,
`address_credit_withdrawal.rs` — all have the same pre-extraction
pattern, all call DPP methods directly (not the SDK traits we
changed), and none participate in asset-lock identity funding.
Left for a follow-up if the cleanup is wanted there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
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 winFix broken test imports and stale doc links that reference removed symbols.
The test module imports
dash_sdk_mnemonic_resolver_createanddash_sdk_mnemonic_resolver_destroyfromcrate::derive_and_persist_callbacks(lines 240-242), but these functions were removed from that module and are now inrs_sdk_ffi. This breaks compilation of the test suite. Themake_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 fromrs_sdk_ffi- Line 23:
[MnemonicResolver](crate::MnemonicResolverHandle)— wrong crate path- Line 94:
[crate::dash_sdk_mnemonic_resolver_create]— function is inrs_sdk_ffi, notcrateProposed 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::tors_sdk_ffi::forMnemonicResolverHandle(lines 4, 30) anddash_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 winMnemonic-derived private scalar still leaks into
SecretKeymemory after signing.
SecretKey::from_slice(secret_bytes.as_ref())copies the 32 scalar bytes into a freshSecretKey([u8; 32]). Droppingsecret_bytes(Zeroizing) only wipes the original buffer; the copy owned bysecretis left intact (noDropzeroizes 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 bothsign_ecdsaandpublic_key.secp256k1::SecretKeyexposesnon_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 theSecretKeyre-exported bykey_wallet::dashcore::secp256k1in 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 valueAvoid silently relying on
PutSettings: Copywhen threadingsettingstwice.Both impls do
settings.and_then(|s| s.user_fee_increase).unwrap_or_default()at line 59 and line 88, then passsettingsagain tobroadcast_and_waitat line 69 and line 100. This only compiles becauseOption<PutSettings>is currentlyCopy. The moment any non-Copyfield is added toPutSettings(or ifRequestSettingsever losesCopy), both call sites break in a non-obvious way. Borrowing viaas_ref()makes the intent explicit and decouples this code fromPutSettings'sCopyderivation.♻️ 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
📒 Files selected for processing (18)
packages/rs-platform-wallet-ffi/src/asset_lock/build.rspackages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rspackages/rs-platform-wallet-ffi/src/derive_and_persist_callbacks.rspackages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rspackages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rspackages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rspackages/rs-platform-wallet-ffi/src/lib.rspackages/rs-platform-wallet-ffi/src/shielded_sync.rspackages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rspackages/rs-platform-wallet/src/wallet/identity/network/registration.rspackages/rs-sdk-ffi/src/identity/topup.rspackages/rs-sdk-ffi/src/lib.rspackages/rs-sdk-ffi/src/mnemonic_resolver.rspackages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rspackages/rs-sdk/src/platform/transition/broadcast_identity.rspackages/rs-sdk/src/platform/transition/put_identity.rspackages/rs-sdk/src/platform/transition/top_up_identity.rspackages/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
| #![allow(clippy::large_enum_variant)] | ||
|
|
||
| pub mod asset_lock; | ||
| pub mod asset_lock_persistence; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 1. Inspect what asset_lock_persistence exports.
fd -t f asset_lock_persistence.rs packages/rs-platform-wallet-ffi
rg -nP '^\s*(pub\s+(unsafe\s+)?(extern\s+"C"\s+)?(fn|struct|enum|type|const|static)|pub\s+use)\b' packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs 2>/dev/null
# 2. Look for symbol-name collisions with asset_lock.
rg -nP '^\s*pub\s+(unsafe\s+)?(extern\s+"C"\s+)?fn\s+(\w+)' packages/rs-platform-wallet-ffi/src/asset_lock.rs \
packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs 2>/dev/null | sort
# 3. See whether downstream callers reach the module through the flat namespace.
rg -nP '\basset_lock_persistence::' packages/rs-platform-wallet-ffi/srcRepository: dashpay/platform
Length of output: 931
🏁 Script executed:
# Read the module declarations and re-export block
cat -n packages/rs-platform-wallet-ffi/src/lib.rs | sed -n '1,130p'Repository: dashpay/platform
Length of output: 4638
Add missing pub use asset_lock_persistence::*; or document why it's intentionally namespaced.
asset_lock_persistence is declared on line 13 but is not re-exported in the re-export block (lines 70–122), unlike every other public sibling module. If intentional (e.g., to keep symbols namespaced), add a comment explaining the rationale. Otherwise, add pub use asset_lock_persistence::*; to maintain consistency with the crate's pattern and flatten the namespace for downstream consumers.
🤖 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/lib.rs` at 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.
…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>
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
| // 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) | ||
| } |
There was a problem hiding this comment.
🔴 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.
| 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) |
There was a problem hiding this comment.
🔴 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
| 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.
| 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; |
There was a problem hiding this comment.
🔴 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.
| // `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 |
There was a problem hiding this comment.
🟡 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.
…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>
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):
992be090):StateTransition::sign_with_signer<S: key_wallet::signer::Signer>; renamedtry_from_identity_with_signer→try_from_identity_with_signer_and_private_key+ newtry_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_signeron the SDK side. Rename ripple acrossrs-sdk-ffi,wasm-sdk,rs-drive-abci, strategy-tests.203067259): implementskey_wallet::signer::Signerby deferring to a Swift-sideMnemonicResolvervtable. Each Core ECDSA call atomically fetches mnemonic → derives key → signs digest → zeroes buffers. No private-key bytes ever leave Swift. TypedMnemonicResolverSignerError.f1a7d1c2):IdentityFundingenum (FromWalletBalance,FromExistingAssetLock,UseAssetLock) replaces the dualIdentityFundingMethod/TopUpFundingMethodpair. L1/L2 merge: singleregister_identity_with_signer(signer + proof + path + keys_map) + singleregister_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.9d5e506a):registerIdentityWithFunding(amountDuffs:identityIndex:identityPubkeys:signer:)with internalMnemonicResolver()andwithExtendedLifetimeso ARC can't release the resolver mid-FFI.ManagedAssetLockManager.buildTransaction/createFundedProoftake an externalMnemonicResolver.KeychainManagerdelete-query fix (was usingkSecValueDataas a filter →errSecDuplicateItem).8a57e882):CreateIdentityViewCore-account branch + plan doc.Phase 2 — end-to-end unblock (after Phase 1 testnet attempt):
885a1be3—fix(platform-wallet-ffi): always enable masternode sync for SPVReal root cause for the "tx never IS-locks" symptom. In trusted-SDK mode the app set
masternodeSyncEnabled=false, which disableddash-spv'sChainLockManager+InstantSendManager. The SPV client connected to masternode peers and receivedCLSig/ISLockP2P messages, but with no manager subscribed,MessageDispatcherdropped them. Removed the FFI knob entirely; hardcodedenable_masternodes = true. Asset-lock proofs are a published platform-wallet feature; the IS/CL subscription is a non-optional dependency.4184a425—chore: bump rust-dashcore to 5297d61a for chainlock wallet handlingPicks up feat(key-wallet): add chainlock handling to the wallet rust-dashcore#756 which adds
WalletInterface::process_chain_lock, promotes recordsInBlock → InChainLockedBlockon chainlock arrival, and emits a newWalletEvent::TransactionsChainlockedvariant. Without this the chainlock fallback branch ofwait_for_proofcould never resolve — records were stuck atInBlock(_)forever. Match-arm coverage added incore_bridge+balance_handlerfor the new variant.3d16a31a—fix(SwiftExampleApp): bump identity funding floor to v1 minimum for 3 keysPlatform rejected v1 identity-creates funded at the v0 floor (
200_000duffs) because v1'scalculate_min_required_fee_v1adds per-key creation cost. WithdefaultKeyCount = 3the real floor is221_500duffs (2_000_000 base + 3 × 6_500_000 per-key, all in credits, ÷ 1000 credits/duff). BumpedminIdentityFundingDuffsto221_500anddefaultCoreFundingDuffsto250_000.34d702d3—docs(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
Broadcastforever 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-sideWalletEvent::TransactionsChainlockedwas emitted but the changeset bridge wasn't projecting the per-recordInBlock → InChainLockedBlockflip into thePlatformWalletChangeSetSwift consumes. Addedchain_lock_promotionsto 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 persistedBlockInfocontext) back to Rust as part of the wallet restore entry, so the in-memorytransactions()map has something for the chainlock cascade to promote. Without this, restored asset locks atBroadcasthad no in-memory record forapply_chain_lockto find, so the cascade silently no-op'd.67f5962012 feat(platform-wallet): background catch-up for stuck asset locks— new fire-and-forget FFIasset_lock_manager_catch_up_blocking+ SwiftPlatformWalletManager.catchUpStuckAssetLocksthat walks everyPersistentAssetLockwithstatusRaw < 2and spawnsTask.detachedcalls intoresume_asset_lock, parked onwait_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:
PlatformWalletInfois a wrapper around upstream'sManagedWalletInfoand delegated ~20WalletInfoInterfacemethods (network,wallet_id,balance, sync heights, etc.) but silently missedapply_chain_lockandlast_applied_chain_lock. Both fell through to the trait's default no-op. Upstream'sspawn_chainlock_wallet_dispatchtask was callingwallet.write().await.apply_chain_lock(...)for every validated CL, but it was hitting the no-op —metadata.last_applied_chain_lockstayedNone, records never got promoted, the cascade never reachedwait_for_proof. Two-line fix: delegate both methods.InvalidAssetLockProofCoreChainHeightError(consensus code 10506). Newsubmit_with_cl_height_retryhelper inregistration.rsretries every 15s for 210s (matching mainnet'screate-empty-blocks-interval = 3m), bumpingPutSettings::user_fee_increaseper retry. Tenderdash's mempool caches rejected ST hashes for ~24h on mainnet/testnet (keep-invalid-txs-in-cache = truein dashmate'stenderdash/config.toml.dot), so retries must produce distinct signable bytes to bypass the cache. Critical follow-up: the original wiring was a no-op becausebroadcast_request_for_new_identity_with_signerhardcodeduser_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 propagatesPutSettings::user_fee_increaseend-to-end.core_chain_locked_heightanywhere. That metadata is unproven and a malicious DAPI node could stall us indefinitely. The wallet'slast_applied_chain_lock(SPV-verified BLS signature, cryptographic) is the only signal trusted. Removedget_platform_core_chain_locked_heightand its call sites; submission is now optimistic and reacts to Platform's deterministic 10506 rejection.wait_for_proof— if the per-record context didn't promote (race between SPV'sapply_chain_lockand the catch-up task enteringwait_for_proof), the fallback useswallet.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::Consumedterminal variant. The previousremove_asset_lockdeleted the row from both Rust's in-memorytracked_asset_locksand Swift'sPersistentAssetLocktable. 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 atstatusRaw = 4 "Consumed"for the lifetime of the wallet. Catch-up scanner and "ready to fund" UI continue to filter< InstantSendLockedso Consumed entries don't generate noise. Renamed the functionremove_asset_lock→consume_asset_lockto match.PlatformWalletError::FinalityTimeout(OutPoint)— wasFinalityTimeout(Txid). The full outpoint now flows fromwait_for_proofthroughresolve_funding_with_is_timeout_fallbackdirectly, eliminating a BTreeMap-iteration-order-dependentfind_tracked_unproven_lockhelper that could pick the wrong unproven lock when multiple were tracked at the same(funding_type, identity_index).has_last_applied_chain_lock/last_applied_chain_lock_height/last_applied_chain_lock_block_hash[32]fields onCoreWalletStateFFI; SwiftCoreWalletStateSnapshot.lastAppliedChainLockHeight: UInt32?andlastAppliedChainLockBlockHash: Data?. Currently in-memory only — not persisted across app restarts.+0.00000000 DASHin red. The Rust classifier already labeled themTransactionType::AssetLockbut the Swift row picked an icon fromdirection(which wasInternal) and an amount fromtransaction.netAmount(which is ~0 because the credit output is a self-owned address). Fixed: purple lock icon (lock.fill),displayDirectionreturns "Asset Lock" / "Asset Unlock" instead of "Internal", row joinsPersistentAssetLock.amountDuffsby 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 inTransactionDetailView.ce-correctness-reviewer/ce-adversarial-reviewer/ce-maintainability-reviewer/rust-quality-engineer/silent-failure-hunteragainst the working tree. Findings ranged from "load-bearing CRITICAL" (theuser_fee_increaseno-op) down to LOW cleanups (stray TODO comment). The 3 deliberately skipped are documented in code comments (FundingResolutionrefactor: out of scope;saturating_add(1): defensive only inside the budget;#[non_exhaustive]onAssetLockStatus: would force wildcard arms and lose the compile-time signal for new variants).LockNotifyHandler::notify_waiters()drops lock events arriving inwait_for_proof's check/await gap (concurrent asset-lock builds stall on FinalityTimeout) #3641):LockNotifyHandlermissed-wakeup race inwait_for_proof. Thetokio::sync::NotifyAPI has a well-known foot-gun:notify_waiters()only wakes currently-registered waiters and does NOT store a permit. Thewait_for_proofloop checks state, then callslock_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 untilFinalityTimeout. Fix uses the canonicalNotified::enable()pattern (Option C in my comment on Found-008:LockNotifyHandler::notify_waiters()drops lock events arriving inwait_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 subsequentnotify_waiters()is captured by the pinned future. ~10 lines per loop site (applied to bothwait_for_proofandwait_for_chain_lock), preserves the multi-waiter semantics thatnotify_onewould break, no API change toLockNotifyHandler. Closes Found-008:LockNotifyHandler::notify_waiters()drops lock events arriving inwait_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(platform-wallet): e2e framework + 60-test suite, 5 upstream bug pins #3549 will pin the fix once that PR merges.How Has This Been Tested?
cargo test --workspacegreen for rs-dpp / rs-sdk / rs-platform-wallet (124/124 in the lib tests).PlatformEventManager::on_sync_event→LockNotifyHandler→wait_for_proofpoll → context flip →InstantAssetLockProofemitted → PlatformIdentityCreateTransitionaccepted).txlock=true, 538 confirmations) advanced fromstatusRaw=1tostatusRaw=3with a populatedproofBytesafter theapply_chain_lockdelegation fix landed — exactly the cascade described above../build_ios.sh --target sim --profile dev).user_fee_increase,Consumedstatus persisting, the newFinalityTimeout(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
masternode_sync_enabledparameter from theplatform_wallet_manager_spv_startFFI signature and the correspondingmasternodeSyncEnabledfield onPlatformSpvStartConfig. 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.try_from_identity_with_signer/try_from_identitymethods were renamed to_and_private_key/_with_private_keyvariants alongside new_with_signer(s)siblings (all internal callers updated in the same commit).BroadcastRequestForNewIdentitytrait signature change: bothbroadcast_request_for_new_identity_with_private_keyandbroadcast_request_for_new_identity_with_signergained auser_fee_increase: UserFeeIncreaseparameter. This is the critical fix that makes the retry path actually function — the old hardcoded0produced identical ST hashes across retries and got dedup'd at Tenderdash. External implementors of the trait need to thread the parameter through toIdentityCreateTransition::try_from_identity_with_signer{,s}{,_and_private_key}.AssetLockStatusgained aConsumedvariant. 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 anOutPointinstead of aTxid..txidfield still accessible via the outpoint.tracked_asset_locksmap semantics changed. Was: removed on consumption. Now: terminal entries are dropped from the in-memory map but the SwiftPersistentAssetLockrow is retained withstatusRaw=4. Load-path filtersConsumedentries so they're never re-added to memory. Consumers reading the in-memory map see only still-actionable locks.53130869→5297d61a) introducesWalletEvent::TransactionsChainlockedwhich forces match-arm coverage in downstream consumers of theWalletEventenum.Checklist:
Summary by CodeRabbit
Release Notes
New Features
Improvements