Skip to content

feat: external signable wallets and tx building with signer#3639

Draft
ZocoLini wants to merge 15 commits into
v3.1-devfrom
feat/external-signable-wallets
Draft

feat: external signable wallets and tx building with signer#3639
ZocoLini wants to merge 15 commits into
v3.1-devfrom
feat/external-signable-wallets

Conversation

@ZocoLini
Copy link
Copy Markdown
Collaborator

I need some PRs to get merge first:
dashpay/rust-dashcore#759
#3634

Checklist:

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

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

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

Additive (no breaking changes to existing callers):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

coderabbitai Bot commented May 13, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7182986b-a747-4012-8db3-b279f15da92e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/external-signable-wallets

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added this to the v3.1.0 milestone May 13, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

📖 Book Preview built successfully.

Download the preview from the workflow artifacts.
To view locally: download the artifact, unzip, and open index.html.

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.
@ZocoLini ZocoLini force-pushed the feat/external-signable-wallets branch from b9ad462 to dc7dafb Compare May 13, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants