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 Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 Walkthrough<review_stack_artifact> </review_stack_artifact> ✨ Finishing Touches🧪 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>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Re-review at 9a82020. Multiple prior blockers (catch-up handle retention, SecretKey wipe, Consumed-status persistence, last_applied_chain_lock advance, missed-wakeup race, user_fee_increase threading) are resolved at HEAD. Two real blocking bugs remain: the funded-with-signer FFI silently drops contract_bounds from Swift pubkeys (sibling path decodes them), and the post-submission IS→CL upgrade is unreachable for IdentityFunding::UseAssetLock because upgrade_to_chain_lock_proof requires the outpoint to be in tracked_asset_locks. Two lower-severity FFI-boundary issues persist (sendToAddresses C-string lifetime, asset-lock restore hardcoding account_index=0).
Reviewed commit: 9a82020
🔴 1 blocking | 🟡 2 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet/src/wallet/identity/network/registration.rs`:
- [BLOCKING] lines 515-540: IS→CL upgrade after Platform rejection is unreachable for IdentityFunding::UseAssetLock
On `InvalidInstantAssetLockProofSignatureError`, this branch unconditionally calls `self.asset_locks.upgrade_to_chain_lock_proof(&proof_out_point, CL_FALLBACK_TIMEOUT)`. That helper short-circuits at `packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs:168-176` with `PlatformWalletError::AssetLockProofWait("Asset lock {} is not tracked")` whenever the outpoint is absent from `tracked_asset_locks`. The `IdentityFunding::UseAssetLock` arm at line 339 deliberately sets `tracked_out_point: None` (caller owns the lock lifecycle) and never inserts into `tracked_asset_locks`. Net effect: when a caller supplies an externally-managed IS proof that Platform rejects (e.g. quorum rotation), the wallet swallows the original SDK error and surfaces a misleading "is not tracked" failure — the very stale-IS case this fallback was added to handle. The same bug is mirrored in `top_up_identity_with_funding` at lines 710-731. Fix: gate the IS→CL upgrade on `tracked_out_point.is_some()` and re-raise the original `PlatformWalletError::Sdk(e)` for the `UseAssetLock` case, or extend `upgrade_to_chain_lock_proof` to accept an explicit account index for the caller-managed path.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] lines 2855-2965: Asset-lock restore buffers hardcode account_index = 0 even though the Rust loader uses it as a lookup key
Both `buildAssetLockRestoreBuffer` (line 2865) and `buildUnresolvedAssetLockTxRecordBuffer` (line 2965) serialize `entry.account_index = 0`. The inline comment at line 2862-2864 ("The Rust load path doesn't read this field for anything load-bearing") is incorrect: in `packages/rs-platform-wallet-ffi/src/persistence.rs:2324-2336`, `spec.account_index` is stored into `TrackedAssetLock.account_index` AND used as the key in the per-account `BTreeMap`. Likewise `restore_unresolved_asset_lock_tx_records` at `persistence.rs:2739` reinserts funding transactions into `standard_bip44_accounts.get_mut(&rec.account_index)` and drops the row when no matching account exists. The public Swift asset-lock APIs (`buildTransaction`, `createFundedProof`, `sendToAddresses`) all accept arbitrary `accountIndex`, so any wallet funded from a nonzero BIP44 account silently loses crash-recovery for in-flight asset locks at restart. Today's iOS app happens to use account 0 only, which keeps this latent — but `PersistentAssetLock` needs a real `accountIndex` column (mirroring `identityIndexRaw`) before nonzero BIP44 accounts are supported.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift`:
- [SUGGESTION] lines 108-134: sendToAddresses passes C-string pointers backed by bridged-NSString temporaries
`let cStrings = recipients.map { ($0.address as NSString).utf8String }` produces an array of `UnsafePointer<CChar>?` whose backing UTF-8 buffers are owned by autoreleased `NSString` temporaries created inside the closure — the array retains the pointers, not the NSString owners. Rust immediately dereferences each with `CStr::from_ptr` in `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`. In practice within a single synchronous non-`@autoreleasepool` function the temporaries live until function exit (no pool drain between `map` and the FFI call), so no concrete misbehavior is reproducible at this SHA. But this is the documented Swift→C anti-pattern: Apple's NSString docs warn that `utf8String` returns memory "automatically freed just as a returned object would be released," any future refactor introducing an `await`, `withCheckedThrowingContinuation`, or nested `@autoreleasepool` between buffer construction and the FFI call invalidates the pointers, and every other FFI buffer in this file already uses owned `strdup`/`free` allocations. Worth tightening because this is a transaction-signing path.
Reviewer (CodeRabbit, R1) flagged that the public method name `StateTransition::sign_with_signer` suggests a general-purpose signing operation while in practice it only produces a meaningful signature for the four asset-lock-signed variants (`IdentityCreate`, `IdentityTopUp`, `AddressFundingFromAssetLock`, `ShieldFromAssetLock`). The proposed fix was a runtime guard that rejects other variants. After thinking through the architecture, the right answer is rename, not restrict. The method was introduced one PR ago (992be09, "signer-based asset-lock identity creation + top-up") as a sibling of `sign_by_private_key` — the external-custody version of the same primitive. Both produce byte-identical wrapper signatures over the same digest when given the same key; they differ only in where the key bytes live (host memory vs inside an HSM / hardware wallet / secure enclave / remote signing service). Both intentionally do no validation of variant or key/transition compatibility — they're primitives, misuse is a caller responsibility. What MADE the method look more general than its sibling is its parameter type: `key_wallet::signer::Signer` operates on BIP32 derivation paths, which only carry semantic meaning for variants whose wrapper signature is itself a Core-key signature (= the four asset-lock-signed variants today). Identity-signed variants identify keys by `IdentityPublicKey` and reach external signers through a different trait (`Signer<IdentityPublicKey>`) consumed by `sign_external`. The two families' external-signer entry points exist for exactly that reason. Rename to `sign_with_core_signer` so the method's name surfaces the implicit Core-wallet/BIP32 scope that the parameter type already imposes. Rustdoc rewritten to spell out: - Position in the primitive family (sibling table vs `sign_by_private_key`). - Byte-parity guarantee with `sign_by_private_key`, pinned by the `sign_with_core_signer_matches_sign_by_private_key_byte_for_byte` test. - Scope semantics (what passing a BIP32 path actually means; why identity-signed variants are conceptually wrong consumers but not statically blocked, exactly like `sign_by_private_key`). - Pointer to `sign_external` as the right entry point for identity-signed external-signer flows. Production callers (2) and the byte-parity test updated to the new name. No behavior change; no breaking change beyond the symbol rename (version-bumped `!` per Conventional Commits because the public API symbol changes). PR review thread: #3634 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Batch of four small, independent fixes from PR review:
S2 — ManagedAssetLockManager.swift (3 sites: buildTransaction,
createFundedProof, resume): the `pathPtr.map { String(cString: $0) }
?? ""` pattern swallowed an FFI contract violation. If
`asset_lock_manager_*` returned `Success` with a NULL path pointer,
downstream signing would fail with an opaque "key not found" / "derive
failed" error. Replaced with a `guard let pathPtr, pathPtr.pointee != 0
else throw …` that names the actual contract violation. Reviewer
thread: #3634 (comment)
S3 — ManagedPlatformWallet.swift (2 sites:
registerIdentityWithFunding, resumeIdentityWithAssetLock): added a
`guard outManagedHandle != NULL_HANDLE else throw …` after
`try result.check()` so a hypothetical Success-without-out-handle from
the FFI doesn't construct a `ManagedIdentity(handle: NULL_HANDLE)` that
would crash in `ManagedIdentity.deinit` calling
`managed_identity_destroy(NULL)` or any downstream FFI accessor that
dereferences the slot. Reviewer thread:
#3634 (comment)
S5 — PlatformWalletPersistenceHandler.swift (restore asset-lock
marshalling): replaced `UInt8(clamping: record.fundingTypeRaw /
.statusRaw)` with `UInt8(exactly:)` + NSLog + `continue` on out-of-u8
input. A corrupt persisted row (negative / >255 value, e.g. from
schema migration drift or external DB tampering) no longer silently
coerces to a valid-looking enum value (negative → 0 = Built /
IdentityRegistration; >255 → 255 = sentinel) and restore the wrong
asset-lock state. Bad rows are dropped with a log line carrying the
outpoint and the failing value so an operator can locate and repair
them. Reviewer thread:
#3634 (comment)
S7 — PendingRegistrationsList.swift: `ForEach(active, id:
\.identityIndex)` keyed rows by the per-wallet identity slot, but
controllers are keyed by `(walletId, identityIndex)` in the
coordinator. Two wallets registering identities at the same slot
would collide on the SwiftUI diff and one row would replace / collapse
the other. Switched to a composite `registrationRowID` extension
(`"<walletId-hex>-<identityIndex>"`) that matches the coordinator's
actual key shape. Reviewer thread:
#3634 (comment)
S8 (RegistrationProgressView's `instantLockTimeout: 300.0`)
deliberately NOT changed — the existing value mirrors the Rust IS
wait (`wait_for_proof` is called with `Duration::from_secs(300)`),
which is the right number for this UI step. The 180s
`CL_FALLBACK_TIMEOUT` is a separate post-IS-timeout budget for the
chain-lock fallback path, not the IS wait itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Rust restore path uses `account_index` to reinsert the unresolved funding tx into `standard_bip44_accounts[account_index]`. The Swift persister was hardcoding `entry.account_index = 0` at both restore sites (`buildAssetLockRestoreBuffer` for asset-lock entries, `buildUnresolvedAssetLockTxRecords` for tx records) because `PersistentAssetLock` didn't carry the account index. The SDK surface (`ManagedAssetLockManager.buildTransaction` and friends) already accepts `accountIndex: UInt32` from callers, so any wallet that funded an asset lock from a non-zero account had its restore silently dropped — the Rust lookup `standard_bip44_accounts.get(&0)` would miss when the real account was, say, 3. Schema add: `accountIndexRaw: Int32 = 0` on `PersistentAssetLock`. Default 0 keeps SwiftData's lightweight migration safe for pre-feature rows — same bytes that the hardcoded restore path was using, so behavior is preserved bit-exactly for the BIP44-account-0 common case. Any new upsert (status transition, next register / top-up flow) on a pre-feature row replaces the column with the real value from the FFI's `AssetLockEntryFFI.account_index`. Plumbing in `PlatformWalletPersistenceHandler`: - `AssetLockEntrySnapshot.accountIndexRaw: Int32` field added. - Inbound FFI callback (`onPersistAssetLocks` thunk) now copies `e.account_index` into the snapshot. - `persistAssetLocks` upsert + insert paths write it onto the `PersistentAssetLock` row. - `loadCachedAssetLocksOnQueue` includes it in the load-time snapshot. - Both restore-marshalling sites now use `UInt32(bitPattern: record.accountIndexRaw)` instead of `0`. PR review thread (4 re-runs from the same reviewer, all addressed by this commit): #3634 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g-with-asset-lock # Conflicts: # Cargo.lock # Cargo.toml
Two new review findings (N1, N2) flagged that the IS→CL fallback paths
added in this PR are unreachable for `IdentityFunding::UseAssetLock`:
- On a 300s IS-timeout from `wait_for_proof`, the resolver tries to
upgrade via `upgrade_to_chain_lock_proof(out_point, ...)`.
- On a Platform `InvalidInstantAssetLockProofSignatureError`, the
submission layer does the same.
`upgrade_to_chain_lock_proof` short-circuits with
`PlatformWalletError::AssetLockProofWait("Asset lock {} is not tracked")`
when the outpoint isn't in `tracked_asset_locks`. `UseAssetLock`
deliberately sets `tracked_out_point: None` (caller-owned lifecycle)
and never inserts into the tracked map. Net effect: when an
externally-supplied IS proof gets stale-rejected by Platform, the
wallet swallows the original SDK error and surfaces a misleading
"is not tracked" failure — masking exactly the case the fallback was
added to handle. Same bug mirrored in `top_up_identity_with_funding`.
Reviewer threads:
- #3634 (comment) (N1)
- #3634 (comment) (N2)
Resolution: delete the variant. Audit confirms zero production
callers across the workspace — `grep -rn UseAssetLock packages/` finds
4 references, all in the variant's own definition or the now-removed
match arm and its doc comments. The variant's own docstring
acknowledged: "In practice this variant is used by callers that
manage asset locks via a sibling component (evo-tool's tasks,
integration tests, etc.). The Swift app's normal flow goes through
`FromWalletBalance` or `FromExistingAssetLock`." None of those sibling
consumers live in this workspace, and a future consumer needing an
external proof should register it through `AssetLockManager` first
then use `FromExistingAssetLock` (which DOES have lifecycle tracking
and therefore working fallback paths).
API impact (`!`): `IdentityFunding` loses a variant. Match-exhaustive
consumers downstream must update. Workspace audit found zero such
consumers; the FFI shape exposed to Swift only references
`FromWalletBalance` and `FromExistingAssetLock`.
Doc comments on `ResolvedFunding.tracked_out_point` and the
post-success cleanup block updated: `tracked_out_point` is now always
`Some` in practice — the `Option` is retained as future-proofing for
variants that might genuinely lack wallet-owned lifecycle, but if
such a variant lands the IS→CL fallback machinery would need a
matching extension (probably an `account_index` parameter on
`upgrade_to_chain_lock_proof` so the helper can drive the wait
without consulting `tracked_asset_locks`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s in sendToAddresses
Two unrelated review findings, both real correctness bugs, batched
because they're each one file and touch zero overlapping surface.
N3 — `identity_registration_funded_with_signer.rs` (Rust FFI)
The asset-lock-funded registration entry point (the new path added
by this PR for Swift/iOS) decoded Swift's `IdentityPubkeyFFI` rows
into `IdentityPublicKeyV0 { contract_bounds: None, ... }` — hardcoded
None for every key, even though the Swift side marshalled
`contract_bounds_kind`, `contract_bounds_id`, and
`contract_bounds_document_type`. Encryption / Decryption keys, which
Drive requires to carry contract bounds, would have been registered
unbounded and silently unusable.
Fix: call the existing `decode_contract_bounds` helper (already
`pub(crate)` and used by the older signer-only registration path).
Same enforcement applies — `kind == 0` is rejected for
Encryption/Decryption purposes with a clean FFI error.
Thread: #3634 (comment)
N4 — `ManagedCoreWallet.swift` (Swift side)
`sendToAddresses` built the C-string array as:
`recipients.map { ($0.address as NSString).utf8String }`
`.utf8String` returns a pointer into the bridged NSString's internal
buffer. Per Apple docs that pointer has a lifetime "shorter than
the string object and will certainly not have a longer lifetime."
The `.map` closure's bridged NSString is a temporary — once the
closure returns, the NSString can be released, leaving Rust's
`CStr::from_ptr` dereferencing freed (or autorelease-pool-recycled)
memory. Use-after-free; the autorelease pool's timing was the only
thing hiding it.
Fix: own the C-string storage explicitly with `strdup` + `defer
free`. Promote owned `UnsafeMutablePointer<CChar>?` to
`UnsafePointer<CChar>?` for the FFI's `*const *const c_char`
signature without any `assumingMemoryBound` re-interpretation.
Thread: #3634 (comment)
Same bug, suggestion-level dup at
#3634 (comment)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## v3.1-dev #3634 +/- ##
============================================
- Coverage 88.06% 87.84% -0.23%
============================================
Files 2521 2537 +16
Lines 308995 311315 +2320
============================================
+ Hits 272122 273472 +1350
- Misses 36873 37843 +970
🚀 New features to boost your workflow:
|
CI's `cargo fmt --check` step on the post-merge workspace flagged unformatted lines introduced across the asset-lock-funding work that landed without local fmt runs. No behavior change — purely whitespace / line-wrap normalization across 15 files in dpp + platform-wallet + platform-wallet-ffi. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two lints surfacing under `cargo clippy --workspace --all-features
--locked -- --no-deps -D warnings` (the CI shape):
1. `packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/v0/v0_methods.rs:12`
— unused import of `AssetLockProved`. Pre-existing on v3.1-dev,
pulled into this branch by the v3.1-dev merge.
2. `packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs:99-105`
— `match Option { Some(x) => Some(f(x)), None => None }` is
exactly `Option::map`. Refactored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g-with-asset-lock # Conflicts: # packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
|
✅ DashSDKFFI.xcframework built for this PR.
SwiftPM (host the zip at a stable URL, then use): .binaryTarget(
name: "DashSDKFFI",
url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
checksum: "d60065cf853539a5aa7b356a45c5bde0f4d08929c881b1a27e1ae31bc761c0f9"
)Xcode manual integration:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified at 1e3264d. All prior blocking findings remain resolved — contract_bounds decoding on the funded-signer FFI path, removal of the unreachable UseAssetLock variant, Consumed-status persistence via self-queued changeset, accountIndexRaw threading through restore buffers, strdup-owned C-strings in sendToAddresses, per-task ARC retention of the catch-up FFI handle, user_fee_increase threading, and the pinned Notified::enable() pattern for the missed-wakeup race. One real logic gap remains in the Swift Resumable Registrations filter (terminal status=4 Consumed locks leak into the UI after local identity delete, showing a perpetual spinner). Three suggestion-severity items: a duplicate FFI pubkey decoder (already drifted once), a defense-in-depth zeroization gap in derive_priv, and missing test coverage for the new CL-height retry fee-bump loop.
Reviewed commit: 1e3264d
🟡 4 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift`:
- [SUGGESTION] line 37: isVisibleAsResumable accepts Consumed (4), leaking terminal locks into Resumable Registrations after a local identity delete
`isVisibleAsResumable: statusRaw >= 1` has no upper bound, and `IdentitiesContentView.crossWalletResumableLocks` (IdentitiesContentView.swift:304) uses the same `statusRaw >= 1` floor. The new `AssetLockStatus::Consumed = 4` discriminant introduced by this PR is terminal — the inline doc at lines 26–28 even says "Consumed (4) was already used to fund an identity and cannot be reused; the persisted row survives only for historical lookup" — so it should not surface in the resume UI.
In the happy path this is masked by the `(walletId, identityIndex)` anti-join because `PersistentIdentity` claims the slot. But IdentitiesContentView exposes a 'Delete a single identity locally' action (IdentitiesContentView.swift:331+) whose doc-comment explicitly says it does NOT touch the funding `PersistentAssetLock`. After such a delete the slot frees up, the Consumed lock re-enters the cross-wallet list, and the row falls into the `else` branch of `trailingAffordance` (IdentitiesContentView.swift:535–551) because `canFundIdentity` (correctly) rejects status 4 — producing a perpetual `ProgressView` labelled "Waiting for InstantSendLock or ChainLock…" for a lock that can never be resumed. Tapping through would also error out: `resume_asset_lock` rejects Consumed entries with `AssetLockProofWait("Asset lock {} is already Consumed — nothing to resume")`.
The new test `testForwardCompatibleStatusFloor` (CreateIdentityResumableTests.swift:93–100) actively pins the wrong behavior — it asserts `statusRaw=4` surfaces, with a comment treating 4 as a hypothetical future value. Now that 4 IS Consumed, both the filter and the test need updating. Add the same bound to `crossWalletResumableLocks` for consistency.
In `packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs`:
- [SUGGESTION] lines 54-110: Duplicate unsafe IdentityPubkeyFFI decoder — sibling registration path already diverged once on contract_bounds
`decode_identity_pubkeys()` here reconstructs `IdentityPublicKeyV0` with the same field-by-field logic that already exists in `packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs:349-379`. The earlier `contract_bounds` regression occurred precisely because one decoder evolved while the other did not — both now call `decode_contract_bounds(...)` but the surrounding row→key construction (KeyType/Purpose/SecurityLevel validation, null-buffer check, `IdentityPublicKeyV0` field mapping) is still duplicated across two unsafe FFI entrypoints. Because semantic drift here silently changes the on-chain meaning of Swift-supplied keys, the safer shape is a single shared decoder helper for `IdentityPubkeyFFI` rows that both entrypoints call, so future field additions cannot land in only one path. The error-surfacing styles differ (`Result<_, PlatformWalletFFIResult>` here vs. `unwrap_result_or_return!` there), but that can be normalized to the `Result` form and `unwrap_result_or_return!`-ed at the single remaining call site.
In `packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`:
- [SUGGESTION] lines 277-288: derive_priv leaves BIP-32 ExtendedPrivKey SecretKey scalars unwiped in memory
`derive_priv` builds `master: ExtendedPrivKey` and `derived: ExtendedPrivKey`, each owning a `secp256k1::SecretKey` containing a 32-byte private scalar. `SecretKey` does NOT zeroize on drop in the `secp256k1` crate — that is exactly why `sign_ecdsa`/`public_key` later call `non_secure_erase()` on their reconstructed `secret` before drop. The module doc-comment (lines 39–44) explicitly promises that "Every intermediate that carries key material … is wrapped in Zeroizing and dropped before the method returns," but neither ExtendedPrivKey is wiped — both go out of scope still holding live scalars. The canonical copy is already `Zeroizing<[u8;32]>` and returned to the caller, so this is defense-in-depth divergence rather than a fresh exposure, but it weakens the documented invariant. Calling `non_secure_erase()` on `derived.private_key` and `master.private_key` before they drop (or shadowing them through a local SecretKey you erase explicitly) brings the implementation back in line with the doc.
In `packages/rs-platform-wallet/src/wallet/identity/network/registration.rs`:
- [SUGGESTION] lines 156-215: submit_with_cl_height_retry has no dedicated test pinning the fee-bump behavior
`submit_with_cl_height_retry()` is load-bearing for crash-free identity registration/top-up under consensus code 10506, and this PR series already regressed it once (`user_fee_increase` was threaded incorrectly before being fixed). The existing tests only pin the timeout discriminator; there is no deterministic unit test that feeds a stub submit closure returning repeated `InvalidAssetLockProofCoreChainHeightError`s and asserts (a) successive attempts receive incremented `PutSettings::user_fee_increase` (hash-cache avoidance depends on this), and (b) the original error surfaces once `CL_HEIGHT_RETRY_BUDGET` is exhausted. The retry logic is entirely local and time-driven, so a paused-Tokio test would be cheap and would directly protect Tenderdash hash-cache avoidance behavior from a future silent regression.
QuantumExplorer
left a comment
There was a problem hiding this comment.
Codex review:
I’m Codex. I found a few issues in this PR:
- [P1] Core-funded registration ignores the account selected in the UI.
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift accepts Standard/CoinJoin accounts as selectedCoreAccount and reads that selected account’s live balance, but the submit path calls registerIdentityWithFunding(...) without passing any account identity. The Swift API and FFI also only pass amountDuffs/identityIndex, and the Rust IdentityFunding::FromWalletBalance path then hardcodes account index 0 when calling create_funded_asset_lock_proof(...).
This means selecting BIP32, CoinJoin, or BIP44 account #1 can either fail even though the selected account has funds, or spend from BIP44 #0 instead of the account the UI showed. Please either plumb account type/index through the Swift/FFI/Rust path, or restrict this UI to the actual supported BIP44 #0 funding source.
- [P2] Wallet deletion leaves persisted asset locks behind.
PlatformWalletPersistenceHandler.deleteWalletData(walletId:) wipes identities, balances, txos, pending inputs, wallet rows, orphan transactions, and sync state, but it never deletes PersistentAssetLock rows. Those rows are scoped by walletId and are restored on wallet load via loadCachedAssetLocksOnQueue, so deleting and reimporting the same wallet can resurrect stale pending/resumable asset-lock state.
Please add a walletId-scoped PersistentAssetLock delete in the wallet deletion path and cover it in WalletDeletionTests.
- [P2] The new mnemonic resolver signer overstates key-material zeroization.
packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs says every key-carrying intermediate is Zeroizing and that no private key bytes survive the trait-method boundary, but master and derived ExtendedPrivKey values are plain locals. Existing code in signer_simple.rs already notes that ExtendedPrivKey does not zeroize on drop.
Please either scrub/avoid those ExtendedPrivKey copies or narrow the security claim so callers do not rely on a false “no private bytes survive” guarantee.
Checks I ran:
git diff --check origin/v3.1-dev...HEADcargo test -p rs-sdk-ffi mnemonic_resolver_core_signer --no-default-featurescargo test -p platform-wallet-ffi restore_unresolved_records --no-default-features
I did not run full CI or Swift/Xcode tests.
… signing pipelines
Reviewer (thepastaclaw, NEW3) flagged that
`key_wallet::bip32::ExtendedPrivKey` owns a `secp256k1::SecretKey`
field with no `Zeroize` / `Drop` impl, so the master + derived
`ExtendedPrivKey` values constructed inside `derive_priv` drop with
their scalars un-wiped. Two pipelines have the gap (audit-confirmed
identical shape):
1. `rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs` —
`MnemonicResolverCoreSigner::derive_priv` (asset-lock-proof /
Core signing path, consumed by `sign_with_core_signer`).
2. `rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs` —
`dash_sdk_sign_with_mnemonic_resolver_and_path` (platform-
address signing path, consumed by `Signer<PlatformAddress>`
impl on `VTableSigner`).
Both call `ExtendedPrivKey::new_master` and `derive_priv`, then take
`derived.private_key.secret_bytes()` into a `Zeroizing<[u8;32]>` and
let the `ExtendedPrivKey` values fall out of scope. The seed and
the derived 32-byte scalar are already wiped via `Zeroizing`; the
SecretKey scalars inside the two ExtendedPrivKey wrappers were not.
Fix: take both values `mut` and call
`secp256k1::SecretKey::non_secure_erase()` on each `private_key`
field before they drop. Same approach as the R7 fix at the sign-
site (also flagged by CodeRabbit).
Includes a `TODO(upstream)` block on each site noting that the
proper fix is a `Zeroize` / `ZeroizeOnDrop` impl in
`dashpay/rust-dashcore`'s `key-wallet/src/bip32.rs`. Until that
ships, the local explicit wipe is the right defense.
PR review thread:
#3634 (comment)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer (NEW1) flagged the edge case I called out in the S9 commit but explicitly deferred: the local-only delete-identity action (`IdentitiesContentView.swift` "Delete a single identity locally") removes the `PersistentIdentity` row WITHOUT touching the funding `PersistentAssetLock`. After that delete, the slot frees up in the anti-join, and the Consumed (statusRaw=4) lock re-surfaces on the Resumable Registrations list — as a perpetual-spinner row that can never be advanced, because `canFundIdentity` (correctly) rejects 4 and `resume_asset_lock` on the Rust side rejects Consumed entries with "already Consumed — nothing to resume". Three changes mirroring the reviewer's analysis: 1. `PersistentAssetLockDisplay.swift:37` — `isVisibleAsResumable` gains an upper bound: `statusRaw >= 1 && statusRaw <= 3`. Doc comment explains the load-bearing reason (the deferred-edge-case from S9). 2. `IdentitiesContentView.swift:299` — `crossWalletResumableLocks` gains the same `>= 1 && <= 3` bound. Doc comment expanded to spell out the local-delete failure mode the upper bound prevents. 3. `CreateIdentityResumableTests.swift:89` — renamed `testForwardCompatibleStatusFloor` → `testConsumedLocksAreHiddenFromResumableList`, flipped the assertion to `XCTAssertEqual(result, [])`. The previous test pinned the WRONG behavior (it asserted statusRaw=4 surfaces, under a doc comment treating 4 as a hypothetical future value). Now 4 is Consumed, terminal; the test now pins the right behavior. PR review thread: #3634 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ry contract Two findings from thepastaclaw's 2026-05-18 19:22 review batch (NEW1 + NEW3 were already addressed in 117f113 + 04e299f). NEW2 — Duplicate unsafe IdentityPubkeyFFI decoder Two registration entry points were reconstructing `IdentityPublicKeyV0` from `IdentityPubkeyFFI` rows with the same field-by-field logic: - `identity_registration_with_signer.rs:349-379` (address-funded) - `identity_registration_funded_with_signer.rs:54-110` (asset-lock-funded) This is exactly the drift surface where the contract_bounds regression landed (the funded path was added later and dropped `decode_contract_bounds`; caught by the reviewer as N3 / R3247674469, fixed in `da7938a013`). The structural fix is one decoder. Hoisted `decode_identity_pubkeys` to `identity_registration_with_signer.rs` next to its existing `decode_contract_bounds` helper (option B — share-with-sibling-helper), promoted to `pub(crate)`, both call sites now share it via `unwrap_result_or_return!`. Net: -68 / +63 lines. NEW4 — submit_with_cl_height_retry has no fee-bump test Reviewer flagged that the retry helper is load-bearing for crash-free identity registration under consensus 10506 — and the fee-bump behavior already regressed silently once in this PR series — but no deterministic test pins the contract. Added `submit_with_cl_height_retry_bumps_user_fee_and_surfaces_after_budget` under `#[tokio::test(start_paused = true)]`. The test feeds a stub closure returning a fabricated `InvalidAssetLockProofCoreChainHeightError` (constructor pinned alongside the matcher in `error.rs` so a future SDK refactor can't silently desynchronize them) and asserts: 1. Every retry's `PutSettings::user_fee_increase` is strictly greater than the previous attempt (Tenderdash 24h invalid-tx hash-cache avoidance depends on this). 2. The original 10506 error surfaces after `CL_HEIGHT_RETRY_BUDGET` elapses, rather than the helper looping forever or swallowing the error. Paused-Tokio: total wall-time sub-millisecond despite the helper's 210s budget. Added `tokio` dev-dep with `test-util` feature for `start_paused`. PR review threads: - #3634 (comment) - #3634 (comment) 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
Verified at 117f113. All prior blocking issues are resolved. Two convergent suggestion-level concerns persist: the IdentityPubkeyFFI row decoder is still duplicated across two unsafe FFI entrypoints (only decode_contract_bounds was extracted), and the load-bearing submit_with_cl_height_retry helper still lacks a deterministic test despite having regressed once in this PR series. One additional FFI hardening gap: core_wallet_send_to_addresses dereferences nullable inner C-string pointers without checking them — downgraded from Codex's 'blocking' to 'suggestion' since the realistic null source is strdup OOM and Swift is the only known caller, but worth fixing for boundary hygiene.
Reviewed commit: 117f113
🟡 2 suggestion(s)
1 additional finding
🟡 suggestion: core_wallet_send_to_addresses dereferences nullable inner C-string pointers without checking them
packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs (lines 77-83)
After the sendToAddresses lifetime fix on the Swift side, cStringStorage is [UnsafeMutablePointer<CChar>?] populated by strdup, which can return NULL on allocation failure. The Rust callee only checks that the outer addresses array pointer is non-null, then immediately calls CStr::from_ptr(addr_ptrs[i]).to_str() for every element. If any inner element is null (strdup OOM, or any other C-ABI caller passing nullable slots), that call is undefined behavior — CStr::from_ptr requires a non-null pointer to a valid NUL-terminated string. This is no longer the original temporary-lifetime bug, but it is still an unchecked nullable-pointer dereference on the Swift→Rust FFI boundary that should return a structured FFI error rather than UB. Add a per-element null check inside the loop returning PlatformWalletFFIResultCode::ErrorNullPointer with the offending index, matching the inner-pointer validation pattern other FFI entrypoints in this crate already use for pubkey_bytes.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs`:
- [SUGGESTION] lines 54-110: IdentityPubkeyFFI row decoder still duplicated across two unsafe FFI entrypoints
`decode_identity_pubkeys()` here and the inline loop at `identity_registration_with_signer.rs:349-379` both reconstruct `IdentityPublicKeyV0` from a `*const IdentityPubkeyFFI` row with the same field-by-field logic (KeyType/Purpose/SecurityLevel `try_from`, null/empty `pubkey_bytes` check, `BinaryData` wrap, `IdentityPublicKeyV0 { … }` struct literal). Only the `decode_contract_bounds` helper has been factored out — the surrounding decoder body is still two copies of unsafe pointer-deref code. The two sites also use different error-surfacing styles (`Result<_, PlatformWalletFFIResult>` here vs. `unwrap_result_or_return!` there). This PR series has already shipped one regression on exactly this drift channel (`contract_bounds: None` landed in only one of the two sites and silently changed on-chain identity key semantics). A shared `unsafe fn decode_identity_pubkeys(...) -> Result<BTreeMap<u32, IdentityPublicKey>, PlatformWalletFFIResult>` that both entrypoints unwrap through `unwrap_result_or_return!` or `?` closes the divergence vector for future field additions on the FFI surface.
In `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`:
- [SUGGESTION] lines 77-83: core_wallet_send_to_addresses dereferences nullable inner C-string pointers without checking them
After the `sendToAddresses` lifetime fix on the Swift side, `cStringStorage` is `[UnsafeMutablePointer<CChar>?]` populated by `strdup`, which can return NULL on allocation failure. The Rust callee only checks that the outer `addresses` array pointer is non-null, then immediately calls `CStr::from_ptr(addr_ptrs[i]).to_str()` for every element. If any inner element is null (strdup OOM, or any other C-ABI caller passing nullable slots), that call is undefined behavior — `CStr::from_ptr` requires a non-null pointer to a valid NUL-terminated string. This is no longer the original temporary-lifetime bug, but it is still an unchecked nullable-pointer dereference on the Swift→Rust FFI boundary that should return a structured FFI error rather than UB. Add a per-element null check inside the loop returning `PlatformWalletFFIResultCode::ErrorNullPointer` with the offending index, matching the inner-pointer validation pattern other FFI entrypoints in this crate already use for `pubkey_bytes`.
Codex P1 finding on #3634: the Swift `CreateIdentityView` funding picker lets the user pick which Core account to fund a new identity from, but the registration call ignored the choice — Rust's `create_funded_asset_lock_proof` was always invoked with `account_index = 0`, so picking BIP44 account #1 (or CoinJoin) would either fail (if #0 had no balance) or silently spend the wrong account's UTXOs. Plumbed `account_index: u32` end-to-end: - `IdentityFunding::FromWalletBalance` gains the field (and its doc comment now spells out the BIP44-standard-only constraint). - `resolve_funding_with_is_timeout_fallback` in `registration.rs` forwards the variant's `account_index` to `create_funded_asset_lock_proof` instead of the hardcoded `0`. - `top_up_identity` convenience wrapper takes `account_index` and forwards it into the variant (no other callers — top-up has no FFI surface yet). - `platform_wallet_register_identity_with_funding_signer` takes a new `account_index: u32` after `amount_duffs` and constructs the variant with both. - Swift `registerIdentityWithFunding` matches the new FFI shape. - `CreateIdentityView` passes `selectedCoreAccount.accountIndex` into the Swift wrapper at submit time. Scope: BIP44 standard only. The Rust side (`create_funded_asset_lock_proof`) doesn't support CoinJoin / BIP32 funding for identity registration today, so the Swift picker now hides those — `selectedCoreAccount` and `isFundingAccount` both gate on `accountType == 0 && standardTag == 0`. PlatformPayment (`accountType == 14`) is unaffected — it flows through `registerIdentityFromAddresses`, a separate path. Verification: `cargo check -p platform-wallet -p platform-wallet-ffi` clean, `cargo clippy --workspace --all-features --locked --tests -- --no-deps -D warnings` clean, `swift-sdk/build_ios.sh --target sim` emits `** BUILD SUCCEEDED **`. Ref: #3634 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated cleanups bundled because both unblock CI / accuracy on Codex's last review pass: 1. Module docstring on `rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs` previously said "every intermediate ... is wrapped in `Zeroizing`" — that's now incomplete. The actual mechanism is two-tiered after `04e299f869`: `Zeroizing` for byte buffers (mnemonic / seed / derived scalar) AND explicit `non_secure_erase()` for the `secp256k1::SecretKey` scalars inside the intermediate `ExtendedPrivKey` values (which have no `Drop` / `Zeroize` impl in key-wallet upstream). Doc now spells out both. Codex P2 third finding addressed at the wording level; the code wipe has been in since `04e299f869`. 2. `cargo fmt --all` over `registration.rs` (the `submit_with_cl_height_retry_bumps_user_fee...` test added in `067ed9da91` — rustfmt re-wraps the multi-line ConsensusError construction). Required for CI's "Check formatting" step, which failed on the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@QuantumExplorer (responding to the Codex review at #3634 (review)) All three findings addressedP1 (account_index hardcoded) →
P2 (
P2 (mnemonic resolver signer zeroization claim) →
Let me know if anything still looks off. |
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Cumulative review at e9e56f3. The latest delta (117f113..e9e56f3) resolves all prior code-quality findings: the IdentityPubkeyFFI decoder was centralized into a shared decode_identity_pubkeys helper, and submit_with_cl_height_retry now has a deterministic paused-tokio test pinning per-retry user_fee_increase bumps and budget-exhaustion error surfacing. The new account_index plumbing through IdentityFunding::FromWalletBalance and the PersistentAssetLock cleanup on wallet deletion are clean. One prior FFI-boundary suggestion is still valid because core_wallet/broadcast.rs is unchanged in this delta: core_wallet_send_to_addresses still dereferences nullable C-string pointers without per-element null checks.
🟡 1 suggestion(s)
1 additional finding(s) omitted (not in diff).
Findings not attached inline (unchanged lines / prior carried-forward findings)
-
**[suggestion] packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs —
core_wallet_send_to_addresseshas two unchecked UB paths on the FFI boundaryVerified at e9e56f3 — file unchanged in this delta. Two distinct UB paths remain in this entrypoint:
-
Outer pointers when
count == 0: Thecheck_ptr!(addresses)/check_ptr!(amounts)calls at lines 67-68 are gated behindif count > 0, but lines 74-75 then unconditionally callstd::slice::from_raw_parts(addresses, count)andstd::slice::from_raw_parts(amounts, count). Perfrom_raw_parts's contract, the pointer must be non-null and properly aligned even for zero-length slices — a caller passingcount == 0with NULL pointers triggers UB rather than a clean no-op. -
Inner C-string pointers: The loop at lines 77-78 calls
CStr::from_ptr(addr_ptrs[i]).to_str()for every element without a per-element null check.CStr::from_ptrrequires a non-null pointer to a valid NUL-terminated string. On the Swift sidecStringStorageis[UnsafeMutablePointer<CChar>?]populated bystrdup, which can return NULL on allocation failure; any null inner element is UB rather than a structured FFI error.
Other FFI entrypoints in this crate (e.g. the pubkey_bytes and decode_contract_bounds per-row null checks in identity_registration_with_signer.rs) consistently validate inner pointers before dereferencing — this entrypoint is the outlier. Add a null check on each addr_ptrs[i] before CStr::from_ptr, and either ungate the outer pointer checks or skip the from_raw_parts calls entirely when count == 0, returning PlatformWalletFFIResultCode::ErrorNullPointer with the offending index on null.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified all findings against the worktree at bd2aaad. The broadcast.rs FFI UB issue (count==0 with NULL pointers triggers UB via from_raw_parts; per-element NULL inner pointers dereferenced unchecked) is real and unchanged. Two additional Codex findings about the mnemonic-resolver signers are valid: both implementations only call non_secure_erase on the success path, so a derive_priv failure leaves the ExtendedPrivKey master scalar resident in memory, contradicting the newly-documented zeroization guarantee.
🟡 3 suggestion(s)
3 additional finding(s) omitted (not in diff).
Findings not attached inline (unchanged lines / prior carried-forward findings)
-
**[suggestion] packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs —
core_wallet_send_to_addresseshas two unchecked UB paths at the FFI boundaryVerified at bd2aaad — file unchanged in this push. Two distinct UB paths remain in this entrypoint:
-
Outer pointers when
count == 0: Thecheck_ptr!(addresses)/check_ptr!(amounts)guards at lines 67-68 are gated behindif count > 0, but lines 74-75 unconditionally callstd::slice::from_raw_parts(addresses, count)andfrom_raw_parts(amounts, count).from_raw_parts's safety contract requires the pointer to be non-null and properly aligned even for zero-length slices, so a caller passingcount == 0with NULL pointers triggers UB instead of a clean no-op. SwiftwithUnsafeBufferPointeron an empty array can return a NULLbaseAddress. -
Inner C-string pointers: The loop at line 78 calls
CStr::from_ptr(addr_ptrs[i]).to_str()for every element with no per-element null check. The Swift side populates the array withstrdup, which returns NULL on allocation failure; any null inner element is UB rather than a structured FFI error.
Other FFI entrypoints in this crate (e.g. the per-row checks in identity_registration_with_signer.rs) consistently validate inner pointers before dereferencing — this entrypoint is the outlier. Fix: add a per-element null check on addr_ptrs[i] before CStr::from_ptr, and either ungate the outer pointer checks or skip the from_raw_parts calls entirely when count == 0, returning PlatformWalletFFIResultCode::ErrorNullPointer with the offending index on null.
-
**[suggestion] packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs —
derive_privskips theExtendedPrivKeywipe on the derivation error pathThe newly-expanded zeroization doc-comment promises that no private-key bytes survive past this boundary, but
derive_privonly honors that on the success path.masteris fully initialized at line 294, thenmaster.derive_priv(&secp, path)at lines 296-298 propagates errors with?. On that error path,masterdrops immediately without ever reaching themaster.private_key.non_secure_erase()/derived.private_key.non_secure_erase()calls at lines 314-315. BecauseExtendedPrivKeyhas noDrop/Zeroizeimpl (as the comment notes), the master secret scalar is left resident in memory — exactly the residual-secret class the new documentation claims to have closed. The fix is to wipemaster.private_keybefore returning the derivation error. -
**[suggestion] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs — FFI mnemonic-resolver signer has the same success-only zeroization hole
The lower-level
dash_sdk_sign_with_mnemonic_resolver_and_pathmirrors the same pattern as the trait signer.masteris initialized at lines 203-206; ifmaster.derive_priv(&secp, &path)at lines 208-210 fails, the earlyreturn fail(SIGN_WITH_RESOLVER_ERR_DERIVATION)skips the explicit wipes at lines 222-223 and drops the initializedExtendedPrivKeywithout zeroizing its innerSecretKey. SinceExtendedPrivKeyhas noDrop/Zeroizeimpl, the master scalar is left in memory on this error path. Both resolver-backed signing implementations need the same fix: wipemaster.private_keybefore returning the derivation error.
Inline posting failed; posted the verified findings in the top-level review body.
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 + full test suite — triage pins, Found-*/PA-* guards, fail-closed persist, Stage-2 merge #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
New Features
Improvements