feat(swift-sdk): wallet memory explorer + persistor UTXO/sync load#3576
feat(swift-sdk): wallet memory explorer + persistor UTXO/sync load#3576QuantumExplorer wants to merge 9 commits intov3.1-devfrom
Conversation
Mirrors `PlatformWalletManager` in the SwiftExampleApp explorer end-to-end and extends `load_from_persistor` to repopulate per-account UTXOs and core sync metadata so a restored wallet boots with non-zero balances. Memory explorer (read-only diagnostics): - 15 new `*_blocking` accessors on `PlatformWalletManager` covering the manager-level tunables (address-sync / identity-sync config, list of registered wallet ids), per-wallet state (core / identity / platform- address provider), per-wallet floating state (tracked asset locks, InstantSend lock txids), per-account drill-down (metadata, address pools with encoded address + derived public-key bytes, UTXOs), and the identity manager bucket layout. - Matching `#[repr(C)]` FFI surface in `manager_diagnostics.rs` with paired `_free` fns for every heap-owning shape. - Swift wrappers in `PlatformWalletManagerDiagnostics.swift`. - Explorer split into Funds vs Keys account sections with kind badges; Balance / UTXOs / Total UTXOs are suppressed on keys-only rows where they would always read zero by construction; address-pool rows now surface the encoded address plus derived public-key bytes. Persistor restore (`build_wallet_start_state` + `manager::load`): - `WalletRestoreEntryFFI` gains `birth_height`, `synced_height`, `last_processed_height`, `last_synced` (zero treated as "unknown" so `from_wallet`'s seeded default survives for fresh wallets), and a per-wallet `utxos: [UtxoRestoreEntryFFI]` array routed into the matching `ManagedCoreFundsAccount.utxos` map by `AccountType` tag. - After insertion the loader calls `wallet_info.update_balance()` and `manager::load_from_persistor` mirrors the recomputed inner balance into the lock-free `Arc<WalletBalance>` the UI reads (`WalletBalance::set` is `pub(crate)`). - Restored wallets are now `WalletType::ExternalSignable` instead of `WatchOnly` — signing requests route back through the host signer surface (Keychain-backed mnemonic), not error out. - Swift `loadWalletList` populates the new fields from `PersistentWallet` metadata and unspent `PersistentTxo` rows (`isSpent == false`, walking each row's `account` relationship for the routing tags). Upstream rust-dashcore alignment (no behavior change beyond compile fix): - Field-to-method renames on `ManagedAccountRef` (`managed_account_type`, `monitor_revision`); variant-aware reads via `as_funds()` for `balance` / `utxos`; `transactions_iter()` for the ref-enum tx walk (always empty when `keep_txs_in_memory` is off — tx history is event-driven). - `ManagedCoreAccount` → `ManagedCoreFundsAccount`; `accounts.insert(...)` → `accounts.insert_funds(...)` for DashPay funds-bearing accounts. - Drop the per-account `is_watch_only` and `custom_name` snapshots (upstream removed both fields). - Drop the standalone "PlatformWalletInfo Metadata" explorer section + its FFI / Swift wrapper / Rust accessor — every field either duplicated `CoreWalletStateSnapshot` or had no active populator. SwiftExampleApp cleanup: - `PersistentWallet` balance fields (`balanceConfirmed/Unconfirmed/ Immature/Locked`) are no longer surfaced — canonical source is `walletManager.accountBalances(for:)`. The persister callback no longer writes them either; `let b = cs.balance` removed. - Wallet-detail Total / breakdown logic and `SendTransactionView` switched to the in-memory account-balance source. - SPV progress label multiplies by 100 to render as a percent. Bumps rust-dashcore pin to `e2e8fcf8` on `v0.42-platform-nightly` for the `ManagedCoreFundsAccount` / `ManagedCoreKeysAccount` split and the upstream-removed `is_watch_only` / `first_loaded_at` / `total_transactions` fields. 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:
📝 WalkthroughWalkthroughAdds atomic diagnostic snapshot APIs and FFI diagnostics module, extends ABI types (including keys_used/keys_total and UTXO restore rows), hardens persistence/restore with validation and rollback, hydrates restored balances, and updates Swift bridge, models, and UI to consume per-account snapshots and diagnostics. ChangesDiagnostics Snapshot + Persistence + Swift Bridge
Sequence Diagram(s)sequenceDiagram
participant UI as Swift UI
participant SwiftMgr as PlatformWalletManager (Swift)
participant FFI as DashSDKFFI (C)
participant RustMgr as PlatformWalletManager (Rust)
participant DB as Persistor/DB
UI->>SwiftMgr: request diagnostic snapshot (e.g., wallet detail)
SwiftMgr->>FFI: call platform_wallet_core_wallet_state / other FFI
FFI->>RustMgr: invoke core_wallet_state_blocking(&wallet_id)
RustMgr->>RustMgr: build atomic snapshot (accounts, provider, identity)
RustMgr->>DB: (optional) load persisted UTXO/restore data during startup/restore
RustMgr-->>FFI: return snapshot struct or flat buffers
FFI-->>SwiftMgr: marshal C types to Swift, return pointer/count
SwiftMgr-->>UI: mapped Swift value types (Data, ints, arrays)
UI->>SwiftMgr: on completion call free helpers (defer) to release buffers
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🔍 Review in progress — actively reviewing now (commit a74717e) |
|
✅ 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: "da56575678905734e1b259cbb654289a663ce95149fe84e6181ff71b7fb8375c"
)Xcode manual integration:
|
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs (1)
467-502:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftMake external contact registration atomic.
wallet.add_account(...)now happens beforeinsert_funds(...); if the second insert fails, the wallet is left half-registered and retries can hit duplicate-account errors. Please ensure both collections are updated together or roll back the first insert on failure.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs` around lines 467 - 502, The current sequence calls wallet.add_account(...) before info.core_wallet.accounts.insert_funds(...), which can leave the wallet half-registered if insert_funds fails; make the operation atomic by performing the insert_funds first and then calling wallet.add_account, and if wallet.add_account returns an error, roll back the funds-side insert by removing the ManagedCoreFundsAccount (use the corresponding removal API on info.core_wallet.accounts, e.g. remove_funds or equivalent keyed by the ManagedCoreFundsAccount identity); alternatively, wrap both calls in a scoped transaction if such a facility exists so both succeed or both are undone. Ensure you reference and use ManagedCoreFundsAccount::from_account, info.core_wallet.accounts.insert_funds, info.core_wallet.accounts.remove_funds (or the correct removal method), and wallet.add_account when implementing the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift`:
- Around line 395-414: In accountTransactions(for:balance:pageOffset:pageLimit:)
ensure pageOffset and pageLimit are validated as non-negative before converting
to UInt; e.g., clamp or guard against negative values (returning [] or setting
to 0) and then pass the safe UInt values into
platform_wallet_account_transactions when calling it (the call that currently
uses UInt(pageOffset) and UInt(pageLimit)). Update the code around the
platform_wallet_account_transactions invocation to use the validated
UInt(pageOffsetSafe) and UInt(pageLimitSafe).
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 2078-2081: The assignment in PlatformWalletPersistenceHandler that
sets entry.last_processed_height = w.syncedHeight incorrectly synthesizes a
processing watermark; change this to not synthesize from w.syncedHeight (e.g.,
set entry.last_processed_height to 0 or the explicit "unknown" sentinel) unless
you have a real persisted last-processed value to use; update the logic around
the entry and Wallet (w) handling so last_processed_height is only written from
a true persisted last-processed watermark and not derived from syncedHeight.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift`:
- Around line 677-703: The various computed properties (coreConfirmed,
coreUnconfirmed, coreImmature, coreLocked, totalCoreBalance and
balanceBreakdown()) repeatedly call walletManager.accountBalances(for:) causing
multiple FFI hits; modify CoreContentView to capture a single snapshot once
(e.g. store walletManager.accountBalances(for: wallet.walletId) in a private
let/var snapshot like coreBalancesSnapshot) and have the computed properties and
balanceBreakdown() read from that snapshot instead of re-invoking
walletManager.accountBalances(for:), ensuring the snapshot is created once per
body evaluation; update the other identical block (the one referenced at lines
~756-775) similarly.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift`:
- Around line 164-171: The computed property coreBalance currently calls
walletManager.accountBalances(for:) repeatedly during rendering (used by summary
rows, availableSources, and balance(for:)), causing blocking FFI calls; hoist
that lookup out of the render path by taking a single snapshot of
walletManager.accountBalances(for: wallet.walletId) once before computing the
view body (e.g., capture into a local constant or `@State/`@StateObject updated
when walletId changes), then pass that snapshot into the helper methods
(availableSources, balance(for:), and any summary row code) so they read from
the cached balances instead of re-invoking walletManager.accountBalances(for:).
---
Outside diff comments:
In `@packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs`:
- Around line 467-502: The current sequence calls wallet.add_account(...) before
info.core_wallet.accounts.insert_funds(...), which can leave the wallet
half-registered if insert_funds fails; make the operation atomic by performing
the insert_funds first and then calling wallet.add_account, and if
wallet.add_account returns an error, roll back the funds-side insert by removing
the ManagedCoreFundsAccount (use the corresponding removal API on
info.core_wallet.accounts, e.g. remove_funds or equivalent keyed by the
ManagedCoreFundsAccount identity); alternatively, wrap both calls in a scoped
transaction if such a facility exists so both succeed or both are undone. Ensure
you reference and use ManagedCoreFundsAccount::from_account,
info.core_wallet.accounts.insert_funds, info.core_wallet.accounts.remove_funds
(or the correct removal method), and wallet.add_account when implementing the
change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a7064c01-b85e-42f3-9510-6f348c9eaa68
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (27)
Cargo.tomlpackages/rs-platform-wallet-ffi/src/core_wallet_types.rspackages/rs-platform-wallet-ffi/src/lib.rspackages/rs-platform-wallet-ffi/src/manager_diagnostics.rspackages/rs-platform-wallet-ffi/src/persistence.rspackages/rs-platform-wallet-ffi/src/wallet.rspackages/rs-platform-wallet-ffi/src/wallet_restore_types.rspackages/rs-platform-wallet/src/changeset/core_bridge.rspackages/rs-platform-wallet/src/manager/accessors.rspackages/rs-platform-wallet/src/manager/identity_sync.rspackages/rs-platform-wallet/src/manager/load.rspackages/rs-platform-wallet/src/manager/mod.rspackages/rs-platform-wallet/src/manager/wallet_lifecycle.rspackages/rs-platform-wallet/src/wallet/identity/network/contacts.rspackages/rs-platform-wallet/src/wallet/platform_addresses/provider.rspackages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rspackages/rs-platform-wallet/src/wallet/platform_wallet_traits.rspackages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift
💤 Files with no reviewable changes (1)
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
- accountTransactions: guard non-negative pageOffset/pageLimit before the Int → UInt cast so misuse returns [] instead of trapping. - loadWalletList: stop synthesizing entry.last_processed_height from PersistentWallet.syncedHeight. The two can diverge and overstating processed progress would let SPV skip blocks on restore. Send 0 (Rust treats as unknown and falls back to the from_wallet seed) until a real watermark column exists. - WalletRowView: snapshot accountBalances(for:) once per body render via a CoreBalanceTotals tuple instead of recomputing through four separate computed properties + balanceBreakdown(). Cuts ~12 FFI roundtrips per row down to one. - SendTransactionView: same hoist — capture coreBalance at the top of body and thread through availableSources / balance(for:); helpers now take it as a parameter. 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
Three blocking issues on the persistor-restore path: legacy IdentityAuthentication account rows abort the entire wallet restore via ?-propagation (codex-general), malformed cached platform-address rows can hand Rust uninitialized FFI memory because the published count exceeds the number of writes (codex-general + codex-ffi-engineer), and load_from_persistor mutates wallet_manager before validating expected_wallet_id so error paths leave orphan registrations behind. Several worthwhile suggestions about hardcoded last_processed_height = 0, silent UTXO/key-discriminant drops, and the inconsistent Box/Vec allocator pairing in diagnostic free fns. No consensus-critical paths are touched. 5 nitpicks dropped as triv/speculative.
Reviewed commit: 7e802b4
🔴 3 blocking | 🟡 3 suggestion(s) | 💬 2 nitpick(s)
3 additional findings
🔴 blocking: Uninitialized AddressBalanceEntryFFI slots crossing into Rust when persisted hash != 20 bytes
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift (lines 2010-2065)
The buffer is sized to cachedBalances.count and indexed via for (j, cached) in cachedBalances.enumerated(). The guard hash.count == 20 else { continue } skip keeps j advancing on the input array but never writes buf[j]. entry.platform_address_balances_count is still set to UInt(cachedBalances.count) at line 2065, so Rust's slice::from_raw_parts(entry.platform_address_balances, entry.platform_address_balances_count) (persistence.rs:1359-1364) reads the uninitialized slots as valid AddressBalanceEntryFFI — undefined behaviour the moment a single malformed SwiftData row exists. The fix is to compact: track a written counter, write buf[written], and publish written (or nil + 0 if no rows survived) instead of cachedBalances.count. The same written-counter pattern is already used correctly in buildUtxoRestoreBuffer immediately below.
🔴 blocking: load_from_persistor leaves orphan wallets in `wallet_manager` on mid-load errors
packages/rs-platform-wallet/src/manager/load.rs (lines 85-137)
Each wallet is registered into self.wallet_manager (line 87) before two distinct fail-able checks run: the wallet_id != expected_wallet_id validation at line 95-101, and platform().initialize_from_persisted(...) at line 119-129. Either failure returns Err while the entry is still live in wallet_manager, and may also be missing from self.wallets (which is only inserted at line 136). That breaks the manager's invariant that wallet_manager and self.wallets describe the same set, and a user-driven retry will now collide on duplicate registration / partial state instead of cleanly re-running. Stage the work transactionally — validate expected_wallet_id and run initialize_from_persisted against a temporary structure first, or roll back the insert_wallet on every error path before returning.
🟡 suggestion: Invalid persisted public-key discriminants coerced into valid DPP enums instead of skipped
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift (lines 2269-2278)
UInt8(pk.keyType) ?? 0 (and the analogous purpose / securityLevel lines) silently rewrite parse failures to 0, which is a valid discriminant for all three enums (ECDSA_SECP256K1 / AUTHENTICATION / MASTER). Rust's build_identity_public_keys at persistence.rs:1525-1533 is intentionally designed to skip rows whose try_from fails — your ?? 0 defeats that safeguard and turns malformed SwiftData rows into 'valid ECDSA master keys' carrying the original byte payload, which is data corruption rather than a no-op skip. The inline comment claims this avoids dropping the row entirely, but a corrupt row crossing the FFI boundary as a misclassified key is a worse outcome than dropping. Use UInt8.max (or any value the try_from impls reject) so Rust's filter handles it.
🤖 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/persistence.rs`:
- [BLOCKING] lines 1168-1181: Legacy IdentityAuthentication account rows abort the entire wallet restore
`account_type_from_spec` at line 1652-1658 explicitly returns `Err` for `AccountTypeTagFFI::IdentityAuthenticationEcdsa | IdentityAuthenticationBls` because the upstream `AccountType` variants were removed. The new account-loading loop calls `account_type_from_spec(spec)?` at line 1169 and propagates that error, which aborts `build_wallet_start_state` and consequently `FFIPersister::load()` for the whole client — every wallet, not just the offending one. The UTXO loop a few lines below already treats the identical failure mode as recoverable snapshot drift via `let Ok(account_type) = account_type_from_spec(&spec) else { continue; };`. Persisted wallets carrying these (still-ABI-valid) tags will fail to restore on every launch until the row is purged. The account loader needs the same skip-and-continue treatment.
- [SUGGESTION] lines 1239-1323: UTXO restore silently drops rows on four distinct failure modes — under-reports balance with no signal
The new restore loop `continue`s on tag-not-mappable, bad txid bytes, unrenderable script, and account-not-in-wallet. Because `wallet_info.update_balance()` only sees successfully routed rows, any skipped persisted TXO quietly reduces the restored balance even though the snapshot claimed it existed. The four cases have very different operational meanings (corruption vs legitimate snapshot drift vs legitimate keys-only routing) but currently look identical to operators. Given this PR's stated goal is making cold-start balances accurate from `PersistentTxo` rows, a small schema drift can recreate the original 'restored wallet shows zero balance' symptom silently. At minimum, emit a `tracing::warn!` per dropped row tagged by reason and return a `(routed, dropped)` counter to the caller so the surface above can detect divergence.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] lines 2010-2065: Uninitialized AddressBalanceEntryFFI slots crossing into Rust when persisted hash != 20 bytes
The buffer is sized to `cachedBalances.count` and indexed via `for (j, cached) in cachedBalances.enumerated()`. The `guard hash.count == 20 else { continue }` skip keeps `j` advancing on the input array but never writes `buf[j]`. `entry.platform_address_balances_count` is still set to `UInt(cachedBalances.count)` at line 2065, so Rust's `slice::from_raw_parts(entry.platform_address_balances, entry.platform_address_balances_count)` (persistence.rs:1359-1364) reads the uninitialized slots as valid `AddressBalanceEntryFFI` — undefined behaviour the moment a single malformed SwiftData row exists. The fix is to compact: track a `written` counter, write `buf[written]`, and publish `written` (or `nil` + `0` if no rows survived) instead of `cachedBalances.count`. The same `written`-counter pattern is already used correctly in `buildUtxoRestoreBuffer` immediately below.
- [SUGGESTION] lines 2071-2082: `last_processed_height = 0` hardcoded — restored core state regresses against `synced_height`
Swift always sends `entry.last_processed_height = 0` and the Rust loader treats `0` as 'unknown' (persistence.rs:1201-1203), so `from_wallet`'s `birth_height.saturating_sub(1)` survives. After a launch this leaves `metadata.last_processed_height ≈ birth_height - 1` while `metadata.synced_height` is correctly preserved at the persisted watermark. Two concrete consequences: (1) `ManagedWalletInfo::update_balance()` consults `last_processed_height` to classify coinbase maturity, so restored mined wallets will mis-bucket matured coinbase outputs as immature until SPV advances the watermark; (2) any caller that uses the gap between the two heights for resume / replay decisions reads a wildly stale value. The PR comment justifies this as deferred work pending a real persisted column. As a stop-gap, `entry.last_processed_height = w.syncedHeight` would keep the two aligned for non-pruning wallets, matching the runtime invariant. The proper fix is a dedicated `lastProcessedHeight` column on `PersistentWallet`.
- [SUGGESTION] lines 2269-2278: Invalid persisted public-key discriminants coerced into valid DPP enums instead of skipped
`UInt8(pk.keyType) ?? 0` (and the analogous `purpose` / `securityLevel` lines) silently rewrite parse failures to `0`, which is a *valid* discriminant for all three enums (ECDSA_SECP256K1 / AUTHENTICATION / MASTER). Rust's `build_identity_public_keys` at persistence.rs:1525-1533 is intentionally designed to skip rows whose `try_from` fails — your `?? 0` defeats that safeguard and turns malformed SwiftData rows into 'valid ECDSA master keys' carrying the original byte payload, which is data corruption rather than a no-op skip. The inline comment claims this avoids dropping the row entirely, but a corrupt row crossing the FFI boundary as a misclassified key is a worse outcome than dropping. Use `UInt8.max` (or any value the `try_from` impls reject) so Rust's filter handles it.
In `packages/rs-platform-wallet/src/manager/load.rs`:
- [BLOCKING] lines 85-137: load_from_persistor leaves orphan wallets in `wallet_manager` on mid-load errors
Each wallet is registered into `self.wallet_manager` (line 87) before two distinct fail-able checks run: the `wallet_id != expected_wallet_id` validation at line 95-101, and `platform().initialize_from_persisted(...)` at line 119-129. Either failure returns Err while the entry is still live in `wallet_manager`, and may also be missing from `self.wallets` (which is only inserted at line 136). That breaks the manager's invariant that `wallet_manager` and `self.wallets` describe the same set, and a user-driven retry will now collide on duplicate registration / partial state instead of cleanly re-running. Stage the work transactionally — validate `expected_wallet_id` and run `initialize_from_persisted` against a temporary structure first, or roll back the `insert_wallet` on every error path before returning.
Blocking:
- persistence.rs account loader: skip-and-continue on legacy
IdentityAuthentication{Ecdsa,Bls} tags instead of `?`-propagating
the err. Previously a single such row aborted load() for every
wallet on every launch — same failure mode the UTXO loop already
handled. tracing::warn the skip so operators can detect it.
- PlatformWalletPersistenceHandler: address-balance buffer was sized
to cachedBalances.count but indexed by enumerate(), leaving uninit
slots when a row had hash.count != 20. Rust read the published
count and saw uninit memory — UB. Switched to a `written` counter
+ per-row write so the published count matches actual writes.
- load_from_persistor: rolled back insert_wallet on the recomputed-id
mismatch and the initialize_from_persisted failure paths so a
mid-load error doesn't leave an orphan registration in
wallet_manager that breaks the manager's invariant with self.wallets.
Suggestions:
- Reverted entry.last_processed_height to w.syncedHeight (was 0). Per
the upstream impl, update_balance feeds last_processed_height into
ManagedCoreFundsAccount::update_balance for coinbase-maturity
classification — leaving it at birth_height-1 mis-buckets matured
outputs. For non-pruning SPV wallets the two heights advance in
lockstep, so syncedHeight is the correct stop-gap until a dedicated
PersistentWallet column lands.
- UTXO loader: per-skip tracing::warn with the reason, plus a single
rollup with routed/dropped counters split by category
(account_type / bad_txid / bad_script / no_account).
- IdentityKeyRestoreFFI: invalid persisted discriminants now fall
back to UInt8.max instead of 0. The prior `?? 0` silently coerced
parse failures into valid ECDSA / AUTHENTICATION / MASTER values;
UInt8.max forces Rust's KeyType/Purpose/SecurityLevel try_from to
reject so build_identity_public_keys drops the row cleanly.
Nitpicks:
- Aligned manager_diagnostics.rs free fns on Box::from_raw +
slice_from_raw_parts_mut (was Vec::from_raw_parts) so dealloc
matches the producer's Box::into_raw(into_boxed_slice()) shape.
- Fixed comment claiming update_balance uses last_processed_height —
it actually reads synced_height (the parameter naming on
ManagedCoreFundsAccount::update_balance is historical).
tracing dep added to platform-wallet-ffi/Cargo.toml.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 2162-2171: The fetch predicate for PersistentTxo currently only
selects rows where $0.walletId == walletId, which skips legacy rows with an
empty or nil walletId; update the FetchDescriptor<PersistentTxo> predicate used
when creating descriptor so it also includes legacy rows (e.g. $0.walletId ==
walletId || $0.walletId == "" || $0.walletId == nil) and then continue to filter
spent and account != nil as before (descriptor, backgroundContext.fetch,
routable). Ensure any empty/nil walletId rows are treated/mapped to the current
walletId before further processing so they are included in balance/UTXO restore.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 3394d3ab-03ab-44f7-a346-603702d134e0
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (8)
packages/rs-platform-wallet-ffi/Cargo.tomlpackages/rs-platform-wallet-ffi/src/manager_diagnostics.rspackages/rs-platform-wallet-ffi/src/persistence.rspackages/rs-platform-wallet/src/manager/load.rspackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift
✅ Files skipped from review due to trivial changes (1)
- packages/rs-platform-wallet-ffi/Cargo.toml
🚧 Files skipped from review as they are similar to previous changes (3)
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift
- packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift
- packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs
The PersistentTxo.walletId column was added later and defaults to Data(); rows that haven't been touched by the inbound-tx path since the schema bump still carry an empty walletId (the persister has existing backfill logic at the inbound site). The previous fetch predicate (walletId == walletId) excluded those rows, so a freshly- restored wallet under-reported its UTXO/balance until SPV touched each affected TXO. Widen the predicate to include `walletId.isEmpty` rows in the same fetch, then filter Swift-side via the parent account's wallet relationship so a sibling wallet's legacy rows don't leak in. 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
Round-2 fixes addressed every prior finding cleanly (legacy account-tag skip, AddressBalanceEntryFFI compaction, per-wallet rollback, last_processed_height alignment, key-discriminant fallback, UTXO-drop telemetry, manager_diagnostics dealloc parity). Two blocking issues remain in the restore path: load_from_persistor only rolls back the current iteration so a later-wallet failure leaves earlier wallets registered (poisoning retries), and a stale platform-address balance row aborts the entire restore at persistence.rs:1442. Quality-level follow-ups concern an asymmetric register_wallet rollback gap, a positional 4-tuple on a public accessor, an FFI repr(u8) enum hardening pattern, and minor diagnostic surface nits.
Reviewed commit: 46d42fb
🔴 2 blocking | 🟡 2 suggestion(s) | 💬 3 nitpick(s)
3 additional findings
🔴 blocking: load_from_persistor commits earlier wallets before the whole batch is known-good
packages/rs-platform-wallet/src/manager/load.rs (lines 45-156)
The new rollback only removes the wallet currently being processed (lines 109-110, 142-143). If the snapshot contains multiple wallets and wallet N fails its id check or initialize_from_persisted, wallets 0..N-1 are already inserted into both self.wallet_manager and self.wallets, but the function still returns Err. The caller now holds a half-loaded manager with no signal as to which wallets made it in, and a clean retry of load_from_persistor collides on WalletManager::insert_wallet (returning WalletAlreadyExists) for every previously-loaded wallet — effectively poisoning the manager until the process is rebuilt. For a startup hydration API this needs to be transactional at the manager level: stage all reconstructed (Wallet, PlatformWalletInfo, PlatformWallet) tuples in local Vecs, validate each one, and only commit them to wallet_manager and self.wallets once every iteration has succeeded — or, on any later-iteration failure, unwind every previously-inserted wallet from this call before returning Err.
🔴 blocking: Stale platform-address balance row aborts the entire wallet restore
packages/rs-platform-wallet-ffi/src/persistence.rs (lines 1435-1447)
per_account is built only from wallet.accounts.platform_payment_accounts (lines 1410-1419), but the cached platform_address_balances slice exported from Swift includes every PersistentPlatformAddress for the wallet regardless of whether the corresponding PersistentAccount was restorable from xpubs. If a single snapshot-drift row references a platform-payment account that is missing from the reconstructed wallet (deleted, not yet hydrated, or stale cache), get_mut(...).ok_or_else(...)? at line 1442 aborts build_wallet_start_state and therefore the whole FFIPersister::load() for the wallet. The cache is recoverable by the next platform-address sync, so this should mirror the UTXO restore path above (lines 1364-1371): skip-and-warn on unroutable rows with a per-skip and rollup tracing line, not fail the restore.
🟡 suggestion: register_wallet has the same orphan-registration pattern that load_from_persistor was just fixed for
packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs (lines 173-294)
The PR fix in manager/load.rs rolls back wm.insert_wallet on both id-mismatch and initialize_from_persisted failure. register_wallet here has the structurally identical shape: line 175 inserts into wallet_manager, then load_persisted (line 274) and initialize_from_persisted (lines 281-291) propagate Err via ? with the wallet still registered in wallet_manager and absent from self.wallets. A user retry after a transient persister or platform-address restore failure then hits WalletManager::insert_wallet returning WalletAlreadyExists. Not introduced by this PR, but the fix for the failure mode the prior reviewer identified is incomplete without addressing this site too — apply the same remove_wallet rollback before each ? return after line 175.
🤖 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/manager/load.rs`:
- [BLOCKING] lines 45-156: load_from_persistor commits earlier wallets before the whole batch is known-good
The new rollback only removes the wallet currently being processed (lines 109-110, 142-143). If the snapshot contains multiple wallets and wallet N fails its id check or `initialize_from_persisted`, wallets 0..N-1 are already inserted into both `self.wallet_manager` and `self.wallets`, but the function still returns `Err`. The caller now holds a half-loaded manager with no signal as to which wallets made it in, and a clean retry of `load_from_persistor` collides on `WalletManager::insert_wallet` (returning `WalletAlreadyExists`) for every previously-loaded wallet — effectively poisoning the manager until the process is rebuilt. For a startup hydration API this needs to be transactional at the manager level: stage all reconstructed (Wallet, PlatformWalletInfo, PlatformWallet) tuples in local Vecs, validate each one, and only commit them to `wallet_manager` and `self.wallets` once every iteration has succeeded — or, on any later-iteration failure, unwind every previously-inserted wallet from this call before returning Err.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] lines 1435-1447: Stale platform-address balance row aborts the entire wallet restore
`per_account` is built only from `wallet.accounts.platform_payment_accounts` (lines 1410-1419), but the cached `platform_address_balances` slice exported from Swift includes every `PersistentPlatformAddress` for the wallet regardless of whether the corresponding `PersistentAccount` was restorable from xpubs. If a single snapshot-drift row references a platform-payment account that is missing from the reconstructed wallet (deleted, not yet hydrated, or stale cache), `get_mut(...).ok_or_else(...)?` at line 1442 aborts `build_wallet_start_state` and therefore the whole `FFIPersister::load()` for the wallet. The cache is recoverable by the next platform-address sync, so this should mirror the UTXO restore path above (lines 1364-1371): skip-and-warn on unroutable rows with a per-skip and rollup tracing line, not fail the restore.
In `packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs`:
- [SUGGESTION] lines 173-294: register_wallet has the same orphan-registration pattern that load_from_persistor was just fixed for
The PR fix in `manager/load.rs` rolls back `wm.insert_wallet` on both id-mismatch and `initialize_from_persisted` failure. `register_wallet` here has the structurally identical shape: line 175 inserts into `wallet_manager`, then `load_persisted` (line 274) and `initialize_from_persisted` (lines 281-291) propagate Err via `?` with the wallet still registered in `wallet_manager` and absent from `self.wallets`. A user retry after a transient persister or platform-address restore failure then hits `WalletManager::insert_wallet` returning `WalletAlreadyExists`. Not introduced by this PR, but the fix for the failure mode the prior reviewer identified is incomplete without addressing this site too — apply the same `remove_wallet` rollback before each `?` return after line 175.
In `packages/rs-platform-wallet/src/manager/accessors.rs`:
- [SUGGESTION] lines 250-294: account_balances_blocking returns a positional 4-tuple — name the fields
`pub fn account_balances_blocking(&self, wallet_id: &WalletId) -> Vec<(AccountType, WalletCoreBalance, u32, u32)>` is a public accessor that grew tuple slots for `keys_used`/`keys_total`, with the FFI consumer destructuring positionally at `wallet.rs:154`. Tuples of 3+ meaningfully-different fields are a maintenance hazard — adding the next field (e.g. `pool_count`, `last_used_height`) means walking every callsite to fix the destructuring, and the field meanings are invisible at the type level. The rest of this file already uses named-field snapshot structs (`CoreWalletStateSnapshot`, `AccountAddressPoolSnapshot`). Define an `AccountBalanceSnapshot { account_type, balance, keys_used, keys_total }` to match the file's own convention before more callers grow.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Round-4 delta is a single Swift-side migration shim widening the SwiftData TXO fetch to also include legacy empty-walletId rows (routed via the parent account → wallet relationship). The shim is correct but introduces a per-wallet O(legacy_rows × wallets) refetch. All 7 prior findings from the 46d42fb review are still valid at head 4085463 — none of the referenced files were touched, so the two blocking restore-path issues (load_from_persistor multi-wallet rollback gap; stale platform-address row aborting whole restore) and the five lower-severity items remain unresolved.
Reviewed commit: 4085463
🔴 2 blocking | 🟡 2 suggestion(s) | 💬 4 nitpick(s)
3 additional findings
🔴 blocking: load_from_persistor commits earlier wallets before the whole batch is known-good
packages/rs-platform-wallet/src/manager/load.rs (lines 45-156)
Carried forward from prior review — STILL VALID at 4085463. The per-iteration rollback at lines 109-110 and 142-143 only removes the wallet currently being processed. If the snapshot contains multiple wallets and wallet N fails its id check (line 104) or initialize_from_persisted (line 137), wallets 0..N-1 are already committed to both self.wallet_manager (line 96) and self.wallets (line 155) but the function still returns Err. The caller now holds a half-loaded manager with no signal as to which wallets made it in, and a clean retry of load_from_persistor collides on WalletManager::insert_wallet (returning WalletAlreadyExists) for every previously-loaded wallet — effectively poisoning the manager until the process is rebuilt. Because this is invoked from Swift via FFI, the half-registered state is observable across the boundary with no boundary-side reset path. Fix transactionally at the manager level: stage all reconstructed (Wallet, PlatformWalletInfo, PlatformWallet) tuples in local Vecs, validate every one, and only commit them to wallet_manager and self.wallets once every iteration has succeeded — or, on any later-iteration failure, unwind every previously-inserted wallet from this call before returning Err.
🔴 blocking: Stale platform-address balance row aborts the entire wallet restore
packages/rs-platform-wallet-ffi/src/persistence.rs (lines 1435-1447)
Carried forward from prior review — STILL VALID at 4085463. per_account is built only from wallet.accounts.platform_payment_accounts (lines 1410-1419), but the cached platform_address_balances slice exported from Swift includes every PersistentPlatformAddress for the wallet regardless of whether the corresponding PersistentAccount was restorable from xpubs. If a single snapshot-drift row references a platform-payment account that is missing from the reconstructed wallet (deleted, not yet hydrated, or stale cache), per_account.get_mut(...).ok_or_else(...)? at line 1442 aborts build_wallet_start_state and therefore the whole FFIPersister::load() for the wallet. The cache is recoverable on the next platform-address sync, so this should mirror the UTXO restore path immediately above (lines 1373-1389): skip-and-warn on unroutable rows with a per-skip and rollup tracing line (e.g. dropped_unknown_account), not fail the restore.
🟡 suggestion: register_wallet has the same orphan-registration pattern that load_from_persistor was just fixed for
packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs (lines 173-294)
Carried forward from prior review — STILL VALID at 4085463. The PR fix in manager/load.rs rolls back wm.insert_wallet on both id-mismatch and initialize_from_persisted failure. register_wallet here has the structurally identical shape: line 175 inserts into wallet_manager, then load_persisted (line 274) and initialize_from_persisted (lines 281-291) propagate Err via ? with the wallet still registered in wallet_manager and absent from self.wallets. A user retry after a transient persister or platform-address restore failure then hits WalletManager::insert_wallet returning WalletAlreadyExists. Since register_wallet is invoked from Swift via FFI, the orphan registration is observable across the boundary with no recovery surface on the Swift side. Apply the same wm.remove_wallet(&wallet_id) rollback before each ? return after line 175.
🤖 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/manager/load.rs`:
- [BLOCKING] lines 45-156: load_from_persistor commits earlier wallets before the whole batch is known-good
Carried forward from prior review — STILL VALID at 4085463. The per-iteration rollback at lines 109-110 and 142-143 only removes the wallet currently being processed. If the snapshot contains multiple wallets and wallet N fails its id check (line 104) or initialize_from_persisted (line 137), wallets 0..N-1 are already committed to both self.wallet_manager (line 96) and self.wallets (line 155) but the function still returns Err. The caller now holds a half-loaded manager with no signal as to which wallets made it in, and a clean retry of load_from_persistor collides on WalletManager::insert_wallet (returning WalletAlreadyExists) for every previously-loaded wallet — effectively poisoning the manager until the process is rebuilt. Because this is invoked from Swift via FFI, the half-registered state is observable across the boundary with no boundary-side reset path. Fix transactionally at the manager level: stage all reconstructed (Wallet, PlatformWalletInfo, PlatformWallet) tuples in local Vecs, validate every one, and only commit them to wallet_manager and self.wallets once every iteration has succeeded — or, on any later-iteration failure, unwind every previously-inserted wallet from this call before returning Err.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] lines 1435-1447: Stale platform-address balance row aborts the entire wallet restore
Carried forward from prior review — STILL VALID at 4085463. per_account is built only from wallet.accounts.platform_payment_accounts (lines 1410-1419), but the cached platform_address_balances slice exported from Swift includes every PersistentPlatformAddress for the wallet regardless of whether the corresponding PersistentAccount was restorable from xpubs. If a single snapshot-drift row references a platform-payment account that is missing from the reconstructed wallet (deleted, not yet hydrated, or stale cache), per_account.get_mut(...).ok_or_else(...)? at line 1442 aborts build_wallet_start_state and therefore the whole FFIPersister::load() for the wallet. The cache is recoverable on the next platform-address sync, so this should mirror the UTXO restore path immediately above (lines 1373-1389): skip-and-warn on unroutable rows with a per-skip and rollup tracing line (e.g. dropped_unknown_account), not fail the restore.
In `packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs`:
- [SUGGESTION] lines 173-294: register_wallet has the same orphan-registration pattern that load_from_persistor was just fixed for
Carried forward from prior review — STILL VALID at 4085463. The PR fix in manager/load.rs rolls back wm.insert_wallet on both id-mismatch and initialize_from_persisted failure. register_wallet here has the structurally identical shape: line 175 inserts into wallet_manager, then load_persisted (line 274) and initialize_from_persisted (lines 281-291) propagate Err via ? with the wallet still registered in wallet_manager and absent from self.wallets. A user retry after a transient persister or platform-address restore failure then hits WalletManager::insert_wallet returning WalletAlreadyExists. Since register_wallet is invoked from Swift via FFI, the orphan registration is observable across the boundary with no recovery surface on the Swift side. Apply the same wm.remove_wallet(&wallet_id) rollback before each ? return after line 175.
In `packages/rs-platform-wallet/src/manager/accessors.rs`:
- [SUGGESTION] lines 250-294: account_balances_blocking returns a positional 4-tuple — name the fields
Carried forward from prior review — STILL VALID at 4085463. pub fn account_balances_blocking(&self, wallet_id: &WalletId) -> Vec<(AccountType, WalletCoreBalance, u32, u32)> is a public accessor that grew tuple slots for keys_used/keys_total, with the FFI consumer destructuring positionally. Tuples of 3+ meaningfully-different fields are a maintenance hazard — adding the next field (e.g. pool_count, last_used_height) means walking every callsite to fix the destructuring, and the field meanings are invisible at the type level. The rest of this file already uses named-field snapshot structs (CoreWalletStateSnapshot, AccountAddressPoolSnapshot). Define an AccountBalanceSnapshot { account_type, balance, keys_used, keys_total } to match the file's own convention before more callers grow.
Blocking:
- load_from_persistor: batch is now transactional. Tracks every
successful insert into both wallet_manager and self.wallets in
per-call vectors, breaks to a single rollback path on any
iteration's failure, and unwinds every prior commit before
returning Err. Previously a wallet-N failure left wallets 0..N-1
registered, poisoning every retry on WalletAlreadyExists.
- persistence.rs platform-address restore: skip-and-warn on rows
whose referenced account isn't in the reconstructed wallet
(snapshot drift / not-yet-hydrated / stale cache). Also skips
unsupported address_type values rather than aborting. Per-skip
tracing::warn + a rollup line. Mirrors the UTXO restore path.
Suggestions:
- register_wallet: same orphan rollback pattern applied to the two ?
returns after insert_wallet (load_persisted, initialize_from_persisted).
- accessors.rs: account_balances_blocking now returns
Vec<AccountBalanceRow { account_type, balance, keys_used, keys_total }>
instead of a positional 4-tuple. The FFI consumer reads named fields.
Nitpicks:
- diagnostic_sync_height_u32: u32::try_from(u64).unwrap_or(u32::MAX)
saturates instead of silently wrapping; corrupt heights now
surface visibly in the diagnostic panel.
- platform_address_sync_config_blocking: dropped last_event_wallet_count
(it aliased watch_list_size — both read wallets.len() — and the
explorer rendered them as two independent observations). FFI
struct, Swift wrapper, and explorer KVRow trimmed in lockstep.
- loadWalletList: hoist the unspent-PersistentTxo fetch out of the
per-wallet loop into a single bucketed pass keyed by walletId
(legacy empty-walletId rows resolve via account.wallet.walletId).
Prefetches the account/wallet relationship via
relationshipKeyPathsForPrefetching so legacy-row routing doesn't
trigger SwiftData faults per row. buildUtxoRestoreBuffer takes
pre-bucketed rows and is now pure marshalling.
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
At 5270cd5a141d459ffc8131c71209a4b2a696f939, the previously reported transactional restore and stale-row issues are fixed, but two restore-boundary correctness problems remain. One is an FFI soundness bug on persisted account tags, and the other is a silent SwiftData failure path that can still restore wallets with zero core-chain funds while reporting success.
Reviewed commit: 5270cd5
🔴 2 blocking
1 additional finding
🔴 blocking: Restore FFI structs materialize unvalidated Swift tag bytes as Rust enums
packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs (lines 89-91)
AccountSpecFFI stores type_tag: AccountTypeTagFFI and standard_tag: StandardAccountTypeTagFFI directly, and UtxoRestoreEntryFFI repeats the same layout at lines 232-234. On the Swift side those fields are written as raw bytes (UInt8(truncatingIfNeeded: ...) / UInt8), and Rust later reads slices of these structs from foreign memory and immediately matches on spec.type_tag and spec.standard_tag in account_type_from_spec (packages/rs-platform-wallet-ffi/src/persistence.rs:1700). In Rust, an out-of-range discriminant in a repr(u8) enum is undefined behavior before the match executes, so a corrupted persisted row, a forward-versioned tag, or any malformed host buffer turns the restore path into UB instead of a recoverable validation error. This FFI layer already uses the safe pattern elsewhere by transporting discriminants as u8 and converting explicitly; these restore structs need the same treatment.
🤖 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/wallet_restore_types.rs`:
- [BLOCKING] lines 89-91: Restore FFI structs materialize unvalidated Swift tag bytes as Rust enums
`AccountSpecFFI` stores `type_tag: AccountTypeTagFFI` and `standard_tag: StandardAccountTypeTagFFI` directly, and `UtxoRestoreEntryFFI` repeats the same layout at lines 232-234. On the Swift side those fields are written as raw bytes (`UInt8(truncatingIfNeeded: ...)` / `UInt8`), and Rust later reads slices of these structs from foreign memory and immediately matches on `spec.type_tag` and `spec.standard_tag` in `account_type_from_spec` (`packages/rs-platform-wallet-ffi/src/persistence.rs:1700`). In Rust, an out-of-range discriminant in a `repr(u8)` enum is undefined behavior before the `match` executes, so a corrupted persisted row, a forward-versioned tag, or any malformed host buffer turns the restore path into UB instead of a recoverable validation error. This FFI layer already uses the safe pattern elsewhere by transporting discriminants as `u8` and converting explicitly; these restore structs need the same treatment.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] lines 1978-2004: Unspent-TXO fetch errors are swallowed and restore proceeds as if every wallet had no UTXOs
`loadWalletList()` wraps the authoritative unspent-TXO preload in `if let unspent = try? backgroundContext.fetch(unspentDescriptor)`, and `loadWalletListCallback()` always returns success (`0`) to Rust. If SwiftData throws here, `unspentBuckets` remains empty and the function still emits `WalletRestoreEntryFFI` records for every wallet. Rust then sees `entry.utxos_count == 0` for those wallets, `build_wallet_start_state()` routes no persisted UTXOs, and `wallet_info.update_balance()` is never called because it only runs when at least one UTXO was restored (`packages/rs-platform-wallet-ffi/src/persistence.rs:1407`). The result is a successful restore with zero core-chain funds and no surfaced error, which reintroduces the same restored-zero-balance failure mode this path is meant to eliminate. The callback contract already supports non-zero failure returns, so this fetch failure must abort the load instead of silently degrading to an empty snapshot.
Two FFI / restore-path soundness issues: 1. AccountSpecFFI / UtxoRestoreEntryFFI: type_tag and standard_tag are now plain `u8` on the FFI surface (were `repr(u8)` enum fields). Reading a foreign byte directly into a `repr(u8)` enum slot is UB for out-of-range values, *before* the match runs — corrupt SwiftData rows or forward-versioned tags would have been undefined behaviour rather than a recoverable validation error. New `AccountTypeTagFFI::try_from_u8` and `StandardAccountTypeTagFFI::try_from_u8` validate the byte; account_type_from_spec (in both persistence.rs and the manager_diagnostics duplicate) calls them up front and returns PersistenceError::Backend on out-of-range bytes. Producer sites cast as `u8`. Swift side already wrote the fields as plain bytes, so no Swift change required. 2. loadWalletList: SwiftData fetch failures (PersistentWallet, unspent PersistentTxo) now propagate to Rust via a new `errored: Bool` tuple element. The callback returns 1 on errored, so the Rust loader aborts instead of silently degrading to an empty restore (which previously would have surfaced as "successful 0-balance restore" — exactly the failure mode the UTXO-load path was added to eliminate). Each fetch failure is NSLog'd with the underlying error for debugability. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift (2)
1975-1980:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winRelease the preallocated load buffer on early failure.
entriesPtris allocated before the unspent-TXO fetch. If Line 2003 throws,loadWalletList()returns witherrored = truebut never releasesallocation, so the entries array leaks on each failed restore attempt. Move the allocation below the fallible fetches or explicitly release it before returning.Also applies to: 2001-2009
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift` around lines 1975 - 1980, The preallocation of entriesPtr via UnsafeMutablePointer<WalletRestoreEntryFFI>.allocate (assigned into LoadAllocation.entries) occurs before fallible operations and can leak if an early throw/return happens; to fix, move the allocation of entriesPtr (and setting allocation.entries/allocation.entriesCount) to after the unspent-TXO fetches/safe points in loadWalletList(), or if you must keep it earlier, ensure you deallocate/free allocation.entries (and reset allocation.entriesCount) before any early return/throw path so LoadAllocation is always released; look for the LoadAllocation variable, entriesPtr allocation, WalletRestoreEntryFFI usage and the errored return paths around loadWalletList() and apply the deallocation or relocation accordingly.
2051-2053:⚠️ Potential issue | 🟠 MajorValidate persisted account tags before narrowing to
UInt8, and release allocations on error paths.Lines 2052 and 2265 use
UInt8(truncatingIfNeeded: acc.accountType)whereaccountTypeisUInt32. Truncation silently wraps out-of-range values into the 0–255 range; a corrupt row withaccountType > 255will wrap to a potentially valid tag instead of being rejected by Rust'stry_from_u8validation. UseUInt8(exactly:)and skip or fail when the cast overflows.Additionally, if the unspent-TXO fetch fails at line 2003–2009, the function returns before storing
allocationinloadAllocations, causing the preallocatedentriesPtrbuffer to leak becauseLoadAllocation.release()is never called on the orphaned object. Ensure early error paths either release the allocation explicitly or store it inloadAllocationsbefore any fallible operation.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift` around lines 2051 - 2053, The code narrows acc.accountType (UInt32) to spec.type_tag using truncating cast which can silently wrap invalid values; change to use UInt8(exactly:) and handle the nil (overflow) case by skipping/failing the row so invalid accountType >255 is rejected (update the AccountSpecFFI population where spec.type_tag is set). Also ensure any preallocated LoadAllocation (entriesPtr) is released on early error paths: if the unspent-TXO fetch (the block around the fetch that may return early) can fail, either call LoadAllocation.release() on the orphaned allocation before returning or store the allocation into loadAllocations immediately before performing any fallible operation so it’s not leaked (refer to LoadAllocation.release(), loadAllocations, and the unspent-TXO fetch code).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/rs-platform-wallet-ffi/src/persistence.rs`:
- Around line 1173-1190: The current code swallows every PersistenceError from
account_type_from_spec; change the Err handling to only treat the specific
legacy removed tags as recoverable. Replace the let Ok(...) =
account_type_from_spec(spec) else { ... continue } with a match on
account_type_from_spec(spec) that on Ok(account_type) proceeds, on Err(e)
inspects either the error kind or spec.type_tag (the legacy discriminants like
IdentityAuthenticationEcdsa and IdentityAuthenticationBls) and logs+continues
only for those legacy tags, otherwise propagate the error (return Err(e) or use
?). Apply the same change to the UTXO loop handling at the other block (lines
around the second account_type_from_spec usage) so only legacy-tag cases are
skipped and all other PersistenceError::Backend/validation failures are
surfaced.
---
Outside diff comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 1975-1980: The preallocation of entriesPtr via
UnsafeMutablePointer<WalletRestoreEntryFFI>.allocate (assigned into
LoadAllocation.entries) occurs before fallible operations and can leak if an
early throw/return happens; to fix, move the allocation of entriesPtr (and
setting allocation.entries/allocation.entriesCount) to after the unspent-TXO
fetches/safe points in loadWalletList(), or if you must keep it earlier, ensure
you deallocate/free allocation.entries (and reset allocation.entriesCount)
before any early return/throw path so LoadAllocation is always released; look
for the LoadAllocation variable, entriesPtr allocation, WalletRestoreEntryFFI
usage and the errored return paths around loadWalletList() and apply the
deallocation or relocation accordingly.
- Around line 2051-2053: The code narrows acc.accountType (UInt32) to
spec.type_tag using truncating cast which can silently wrap invalid values;
change to use UInt8(exactly:) and handle the nil (overflow) case by
skipping/failing the row so invalid accountType >255 is rejected (update the
AccountSpecFFI population where spec.type_tag is set). Also ensure any
preallocated LoadAllocation (entriesPtr) is released on early error paths: if
the unspent-TXO fetch (the block around the fetch that may return early) can
fail, either call LoadAllocation.release() on the orphaned allocation before
returning or store the allocation into loadAllocations immediately before
performing any fallible operation so it’s not leaked (refer to
LoadAllocation.release(), loadAllocations, and the unspent-TXO fetch code).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b8cccd7b-0724-4bd2-8038-96efc6f2083b
📒 Files selected for processing (4)
packages/rs-platform-wallet-ffi/src/manager_diagnostics.rspackages/rs-platform-wallet-ffi/src/persistence.rspackages/rs-platform-wallet-ffi/src/wallet_restore_types.rspackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
✅ Files skipped from review due to trivial changes (1)
- packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs
1. persistence.rs: distinguish "legacy IdentityAuthentication{Ecdsa,Bls}
tag" (skip-and-continue) from "out-of-range type_tag byte"
(propagate Err). The previous `let Ok(...) else continue` swallowed
*all* PersistenceErrors from account_type_from_spec, including the
new try_from_u8 validation failures, hiding corruption as silent
under-restoration. Both the account loader and the UTXO loop now
match on the result and only swallow the legacy tag bytes (15/16);
everything else propagates.
2. PlatformWalletPersistenceHandler: move the LoadAllocation +
entriesPtr allocation to AFTER the unspent-PersistentTxo fetch so
an early fetch failure (return errored=true) doesn't leak the
entries buffer.
3. PlatformWalletPersistenceHandler: replace
`UInt8(truncatingIfNeeded: acc.accountType)` with
`UInt8(exactly:)` + skip-on-overflow at both the account and UTXO
spec construction sites. Truncation would have silently wrapped
corrupt 0x100+ accountType values into the 0–255 range, defeating
Rust's try_from_u8 validation. The account-spec writer also gained
a `written` counter (same compaction pattern as the
AddressBalanceEntryFFI / UtxoRestoreEntryFFI writers) so a skipped
row doesn't leave an uninitialized FFI slot in the published slice.
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
At head 6472d011b6393c863964d3ccd9160a168562d737, the restore-path hardening is mostly in place: the Rust side now validates raw account-tag bytes, SwiftData fetch failures propagate correctly, and the earlier transactional rollback issues are addressed. One restore-boundary defect remains in the Swift marshalling path: malformed persisted account tags are skipped locally and still reported to Rust as a successful restore, which can produce partially restored or empty wallets without surfacing an error.
Reviewed commit: 6472d01
🔴 1 blocking
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] lines 2066-2072: Corrupt persisted account tags are dropped silently and still reported as a successful restore
`loadWalletList()` filters `restorable` wallets before validating any account tags, then skips rows whose `accountType` does not fit in `UInt8` and continues building the wallet entry. The callback still returns success unless a SwiftData fetch failed, so Rust accepts the entry with `accounts_count == 0` or with a truncated account set. `build_wallet_start_state()` treats that as a valid snapshot and constructs a watch-only wallet from the reduced account list, and `PlatformWalletManager.loadFromPersistor()` still asks SwiftData for the same wallet ids via `restorableWalletIds()`. The result is a restore that appears successful even though corrupted persisted rows were discarded and funds-bearing accounts or their UTXOs may be missing. This code needs to fail the load callback when a persisted account tag is invalid instead of degrading to a partial restore.
| guard let typeTagByte = UInt8(exactly: acc.accountType) else { | ||
| NSLog( | ||
| "[persistor-load:swift] skipping account row: accountType %u out of UInt8 range", | ||
| acc.accountType | ||
| ) | ||
| continue | ||
| } |
There was a problem hiding this comment.
🔴 Blocking: Corrupt persisted account tags are dropped silently and still reported as a successful restore
loadWalletList() filters restorable wallets before validating any account tags, then skips rows whose accountType does not fit in UInt8 and continues building the wallet entry. The callback still returns success unless a SwiftData fetch failed, so Rust accepts the entry with accounts_count == 0 or with a truncated account set. build_wallet_start_state() treats that as a valid snapshot and constructs a watch-only wallet from the reduced account list, and PlatformWalletManager.loadFromPersistor() still asks SwiftData for the same wallet ids via restorableWalletIds(). The result is a restore that appears successful even though corrupted persisted rows were discarded and funds-bearing accounts or their UTXOs may be missing. This code needs to fail the load callback when a persisted account tag is invalid instead of degrading to a partial restore.
source: ['codex-general']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] lines 2066-2072: Corrupt persisted account tags are dropped silently and still reported as a successful restore
`loadWalletList()` filters `restorable` wallets before validating any account tags, then skips rows whose `accountType` does not fit in `UInt8` and continues building the wallet entry. The callback still returns success unless a SwiftData fetch failed, so Rust accepts the entry with `accounts_count == 0` or with a truncated account set. `build_wallet_start_state()` treats that as a valid snapshot and constructs a watch-only wallet from the reduced account list, and `PlatformWalletManager.loadFromPersistor()` still asks SwiftData for the same wallet ids via `restorableWalletIds()`. The result is a restore that appears successful even though corrupted persisted rows were discarded and funds-bearing accounts or their UTXOs may be missing. This code needs to fail the load callback when a persisted account tag is invalid instead of degrading to a partial restore.
Round-7 (thepastaclaw, codex-general): both spec-construction sites were `continue`ing past rows whose `accountType` (UInt32) didn't fit in `u8`, then handing Rust an entry with a truncated account / UTXO set while still reporting success. Result was a "successful restore" with silently-dropped funds-bearing accounts. Both writers now signal `errored = true` on `UInt8(exactly:)` failure: - account-spec writer: deallocates its scratch buffer + calls `allocation.release()` and returns from `loadWalletList` with `(nil, 0, true)` so the load callback returns 1 and Rust aborts. - `buildUtxoRestoreBuffer`: gained a third tuple element (`errored: Bool`); on error, deallocates its buffer and returns `(nil, 0, true)`. Caller in `loadWalletList` propagates by calling `allocation.release()` and returning errored. The persisted snapshot is corrupt in either case — refusing to drop rows surfaces it as a hard fail rather than letting half-loaded state through to the Rust manager. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/rs-platform-wallet-ffi/src/persistence.rs (1)
1188-1204: 💤 Low valueOptionally extract the
is_legacy_tagpredicate into a private helper.The identical two-line check:
spec.type_tag == AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 || spec.type_tag == AccountTypeTagFFI::IdentityAuthenticationBls as u8is duplicated verbatim at both the account-loader and the UTXO-loader callsites. If a third
AccountTypeis deprecated and needs the same "skip-and-continue" treatment, both callers must be updated consistently.♻️ Proposed refactor
Add a module-private helper below
account_type_from_spec:+/// Returns `true` for the ABI-only `IdentityAuthentication{Ecdsa,Bls}` tag bytes +/// whose upstream `AccountType` variants were removed (TODO(events)). These are +/// the only tags that `account_type_from_spec` deliberately returns `Err` for +/// while still being valid discriminants — callers use this predicate to +/// distinguish "recoverable drift" from "real corruption / out-of-range byte". +fn is_legacy_removed_account_tag(type_tag: u8) -> bool { + type_tag == AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 + || type_tag == AccountTypeTagFFI::IdentityAuthenticationBls as u8 +}Then in the account loader (line 1191–1194):
- let is_legacy_tag = spec.type_tag - == AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 - || spec.type_tag == AccountTypeTagFFI::IdentityAuthenticationBls as u8; - if is_legacy_tag { + if is_legacy_removed_account_tag(spec.type_tag) {And in the UTXO loader (line 1294–1297):
- let is_legacy_tag = u.type_tag - == AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 - || u.type_tag == AccountTypeTagFFI::IdentityAuthenticationBls as u8; - if is_legacy_tag { + if is_legacy_removed_account_tag(u.type_tag) {Also applies to: 1291-1308
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/rs-platform-wallet-ffi/src/persistence.rs` around lines 1188 - 1204, Extract the duplicated predicate into a module-private helper (e.g., fn is_legacy_identity_tag(type_tag: u8) -> bool) placed near or below account_type_from_spec, implement it to return true when type_tag equals AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 or AccountTypeTagFFI::IdentityAuthenticationBls as u8, then replace the duplicate inline checks in the account-loader and UTXO-loader error branches to call is_legacy_identity_tag(spec.type_tag) and keep the existing warn+continue behavior when it returns true.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/rs-platform-wallet-ffi/src/persistence.rs`:
- Around line 1188-1204: Extract the duplicated predicate into a module-private
helper (e.g., fn is_legacy_identity_tag(type_tag: u8) -> bool) placed near or
below account_type_from_spec, implement it to return true when type_tag equals
AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 or
AccountTypeTagFFI::IdentityAuthenticationBls as u8, then replace the duplicate
inline checks in the account-loader and UTXO-loader error branches to call
is_legacy_identity_tag(spec.type_tag) and keep the existing warn+continue
behavior when it returns true.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2932d441-e93c-4f4b-822e-51b0a82e7f4a
📒 Files selected for processing (2)
packages/rs-platform-wallet-ffi/src/persistence.rspackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
Round-8 (coderabbit nit, 💤 low value but reasonable). Lifts the duplicated `type_tag == IdentityAuthenticationEcdsa as u8 || type_tag == IdentityAuthenticationBls as u8` check at the account loader and UTXO loader sites into a module-private `is_legacy_removed_account_tag(u8) -> bool` helper. If a third AccountType is ever deprecated, only the helper changes — both loader sites pick it up automatically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue being fixed or feature implemented
Two threads of work that landed together:
WalletMemoryExplorer in SwiftExampleApp had to mirror
PlatformWalletManagerend-to-end so iOS engineers can see exactly what's in memory afterload_from_persistorruns (sync watermark, identity buckets, address pools, UTXOs, asset locks…). This is the diagnostic surface that unblocked the next item.load_from_persistorstopped at recomputing theWalletfrom per-account xpubs — UTXOs and sync metadata weren't being repopulated, so a restored wallet booted with a 0 DASH balance even thoughPersistentTxorows existed in SwiftData. That problem is fixed here.A handful of upstream rust-dashcore changes also landed in lockstep —
ManagedCoreAccountwas split intoManagedCoreFundsAccount/ManagedCoreKeysAccount,is_watch_only/custom_name/first_loaded_at/total_transactionswere dropped, andkeep_txs_in_memorybecame a non-default Cargo feature. Platform-wallet was realigned against those.What was done?
Memory explorer (read-only diagnostics)
*_blockingaccessors onPlatformWalletManagerfor the manager-level tunables (address-sync / identity-sync config, registered wallet ids), per-wallet state (core / identity / platform-address provider), per-wallet floating state (tracked asset locks, InstantSend lock txids), per-account drill-down (metadata, address pools with encoded address + derived public-key bytes, UTXOs), and the identity manager bucket layout.manager_diagnostics.rsFFI surface with paired_freefns for every heap-owning shape; newPlatformWalletManagerDiagnostics.swiftwrappers.CoreWalletStateSnapshotor had no active populator. The Swift wrapper and Rust accessor were dropped in lockstep.Persistor restore —
WalletRestoreEntryFFI+build_wallet_start_state+manager::loadbirth_height,synced_height,last_processed_height,last_synced. Zero is treated as "unknown" soManagedWalletInfo::from_wallet's seededbirth_height - 1default survives for fresh wallets.utxos: [UtxoRestoreEntryFFI]array. Each row carries the same account-tag blockAccountSpecFFIuses, soaccount_type_from_spechandles routing into the matchingManagedCoreFundsAccount.utxos. Keys-only / PlatformPayment variants and snapshot-drift rows are silently skipped — re-sync recovers them.wallet_info.update_balance()(recomputes per-account confirmed/unconfirmed/immature/locked from UTXOs againstmetadata.last_processed_height, sums into the wallet rollup).manager::load::load_from_persistorthen mirrors the recomputed inner balance into the lock-freeArc<WalletBalance>the UI reads —WalletBalance::setispub(crate)to platform-wallet, which is why this step has to live there rather than the FFI loader.WalletType::ExternalSignableinstead ofWatchOnly. The mnemonic / seed lives in the iOS Keychain; signing requests route back through the host signer surface rather than erroring out.loadWalletListpopulates the new fields fromPersistentWallet.{birthHeight, syncedHeight, lastSynced}and walks unspentPersistentTxorows (isSpent == false) for UTXOs, copying tags off each row'saccountrelationship. The compositeoutpointfield onPersistentTxois 36 bytes (32-byte txid + LE u32 vout), so the FFI handoff usesrecord.txid(which preferstransaction.txidand falls back tooutpoint.prefix(32)).Upstream rust-dashcore alignment
e2e8fcf852130383b5922d3c2d907dda334296eeonv0.42-platform-nightly.ManagedAccountRef(managed_account_type(),monitor_revision()); variant-aware reads viaas_funds()forbalance/utxos;transactions_iter()for the ref-enum tx walk (returns empty whenkeep_txs_in_memoryis off — tx history is event-driven now).ManagedCoreAccount→ManagedCoreFundsAccount;accounts.insert(...)→accounts.insert_funds(...)for DashPay funds-bearing inserts.is_watch_only/custom_namesnapshots removed (upstream dropped both fields). Watch-only is wallet-level now (read offWallet.wallet_type).WalletInfoInterfaceno longer declaresfirst_loaded_at/set_first_loaded_at; the platform delegating impls were removed.SwiftExampleApp cleanup (touched along the way)
PersistentWalletbalance fields (balanceConfirmed/Unconfirmed/Immature/Locked) are no longer rendered — the canonical source iswalletManager.accountBalances(for:). The persister callback no longer writes them either.SendTransactionViewwere switched to the in-memory account-balance source.KeyWallet/ManagedAccount.swift: dropped theisWatchOnlygetter; the matching C functionmanaged_core_account_get_is_watch_onlyno longer exists upstream.How Has This Been Tested?
cargo check -p platform-wallet-ffi— clean../build_ios.sh --target sim— clean. Verified end-to-end against a recovered testnet wallet on iPhone 17 Pro Simulator (iOS 26.3):PersistentTxorows → 18 routed into the BIP44 #0 funds account on load.Keysbadge, no Balance/UTXOs sections, address pools fully populated.Breaking Changes
None outside of FFI surface evolution that's already kept in sync with the rust-dashcore upstream split. Restored wallets going from
WatchOnlytoExternalSignableis the most user-visible behavior change but only affects in-memory state; persistence layout is unchanged.Checklist:
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Refactor