feat: external signable wallets and tx building with signer#3639
feat: external signable wallets and tx building with signer#3639ZocoLini wants to merge 15 commits into
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>
`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>
…int (#3623) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ 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 |
|
📖 Book Preview built successfully. Download the preview from the workflow artifacts. Updated at 2026-05-13T17:57:17.078Z |
After the colleague's MnemonicResolverCoreSigner landed every signing path is signer-driven, so freshly-derived wallets no longer need to keep the root xpriv in-process. `create_wallet_from_mnemonic` and `create_wallet_from_seed_bytes` now derive accounts via `Wallet::from_mnemonic`/`from_seed_bytes` and immediately downgrade to an ExternalSignable wallet with the same wallet_id + account xpubs. The seed stays in the host's secure storage and the resolver vtable remains the only way to sign.
b9ad462 to
dc7dafb
Compare
I need some PRs to get merge first:
dashpay/rust-dashcore#759
#3634
Checklist:
For repository code-owners and collaborators only