From 0a1b66d6efa96317668535ff94431c20c25a58df Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 11 Jul 2026 20:14:35 +0700 Subject: [PATCH 1/6] feat(platform-wallet-ffi): managed identity top-up from asset lock Add two managed FFI exports for topping up an existing identity from a Core asset lock, both delegating to the upstream orchestrator IdentityWallet::top_up_identity_with_funding (funding resolution, IS->CL fallback, balance persist -- no reimplementation): - platform_wallet_top_up_identity_with_funding_signer (AssetLockFunding::FromWalletBalance) builds and broadcasts a new lock from the wallet's balance. - platform_wallet_topup_identity_with_existing_asset_lock_signer (AssetLockFunding::FromExistingAssetLock) consumes an already-tracked lock -- the crash-recovery path for a top-up interrupted between Core confirmation and Platform submission. The IdentityTopUp transition is signed entirely by the asset lock's Core-side key (no identity-key signer), so both take only a MnemonicResolver core signer. Guard the sub-floor funds trap: an amount between dust and Platform's processing-start minimum (50000 duffs) builds a real lock Core accepts but Platform rejects, stranding the funds. The funding export rejects amount_duffs < MIN_TOP_UP_DUFFS before any broadcast. Add null-pointer + sub-floor guard unit tests for both exports, and the design spec under docs/ios-managed-topup/. Refs #4092 Co-Authored-By: Claude Opus 4.8 --- docs/ios-managed-topup/SPEC.md | 375 ++++++++++++++++++ ...dentity_registration_funded_with_signer.rs | 165 ++++++++ .../src/identity_top_up.rs | 231 ++++++++++- 3 files changed, 755 insertions(+), 16 deletions(-) create mode 100644 docs/ios-managed-topup/SPEC.md diff --git a/docs/ios-managed-topup/SPEC.md b/docs/ios-managed-topup/SPEC.md new file mode 100644 index 00000000000..c20bf4ca3cc --- /dev/null +++ b/docs/ios-managed-topup/SPEC.md @@ -0,0 +1,375 @@ +# Spec — Wire iOS managed identity top-up from asset lock (#4092) + +Branch: `feat/ios-managed-identity-topup` (worktree `platform.worktrees/ios-topup`), based on `v4.1-dev` (`f7d7c8d348`). +Target: standalone PR against `v4.1-dev`. + +## 1. Problem + +Topping up an existing identity from a Core asset lock has **three fragmented paths** today +(see issue #4092 for the full table), and iOS has **no one-call managed path**: + +- The DPP-SDK primitive `dash_sdk_identity_topup_with_instant_lock` is IS-only, takes a + caller-built InstantSend proof + a **raw asset-lock private key**, and — though wired through the + Swift SDK chain `topUpIdentity → identityTopUp` — has **zero app callers**. The example-app + handler `executeIdentityTopUp` is a `notImplemented` stub. +- The managed **from-wallet-balance** export `platform_wallet_top_up_identity_with_funding_signer` + exists only in the Kotlin-SDK PR #3999 (Android-only). +- The managed **from-existing-asset-lock** export exists only on the DIP-15 invitations branch. + +The correct "from asset lock" abstraction is the **managed orchestrator** +`IdentityWallet::top_up_identity_with_funding`, which is **already on `v4.1-dev`** +(`packages/rs-platform-wallet/src/wallet/identity/network/registration.rs:388`) and does the full +lifecycle: resolve/build the asset lock, **IS→CL fallback**, retries, persist the new balance. + +The only missing pieces on `v4.1-dev` are (a) the thin FFI export over that orchestrator, and +(b) the Swift SDK wrapper + example-app UI to call it. + +## 2. Chosen approach + +Extract #3999's already-written FFI export into `v4.1-dev` verbatim, then mirror the **existing, +proven** iOS registration-from-funding path for top-up. Nothing is reimplemented; the orchestrator, +the `MnemonicResolver` core-signer plumbing, and the cbindgen→xcframework header flow all already +exist and are exercised by `registerIdentityWithFunding` on iOS today. + +### 2.1 Rust FFI exports (canonical, owned by this PR) + +Two managed exports, so top-up has the same **register + resume** pair registration already has: + +- **`platform_wallet_top_up_identity_with_funding_signer`** (`FromWalletBalance`) — the primary path, + build a new lock from wallet balance. +- **`platform_wallet_topup_identity_with_existing_asset_lock_signer`** (`FromExistingAssetLock`) — the + crash-recovery / stuck-lock path, consume an already-tracked lock. Extracted **verbatim** from the + DIP-15 branch (`feat/dip15-dashpay-invitations`, `identity_registration_funded_with_signer.rs:268`, + where it is the invitation-reclaim primitive); it wraps + `top_up_identity_with_funding(&identity_id, AssetLockFunding::FromExistingAssetLock { out_point }, + &asset_lock_signer, None)` and returns the new balance via `out_new_balance`. Signature: + `(wallet_handle, out_point: *const OutPointFFI, identity_id: *const [u8;32], + core_signer_handle: *mut MnemonicResolverHandle, out_new_balance: *mut u64)`. Place it in + `identity_registration_funded_with_signer.rs` **exactly where DIP-15 has it** (next to the + registration resume twin `platform_wallet_resume_identity_with_existing_asset_lock_signer`, whose + `OutPointFFI` import + marshalling it reuses) — so #4041's later rebase dedups cleanly too. + +#### FromWalletBalance export + +Add `platform_wallet_top_up_identity_with_funding_signer` to +`packages/rs-platform-wallet-ffi/src/identity_top_up.rs` — **exactly where #3999 places it**, next to +the existing `platform_wallet_top_up_from_addresses_with_signer`. Take the **whole `7a1d04792f` +version of the file** so the merge is byte-exact (the sibling `top_up_from_addresses_with_signer` +function is already identical between #3999 and `v4.1-dev` HEAD — zero diff — so this is a clean +wholesale adoption). The delta is: a module-doc-header rewrite, **one merged import line** (adding +`MnemonicResolverCoreSigner, MnemonicResolverHandle` to the existing +`rs_sdk_ffi::{SignerHandle, VTableSigner}`) **plus one new** `use platform_wallet::AssetLockFunding;`, +and the ~100-line function (`Identifier` is already imported). All dependencies already compile on +`v4.1-dev` — the registration twin `identity_registration_funded_with_signer.rs` uses the identical set. + +Signature (verbatim from #3999): + +```rust +pub unsafe extern "C" fn platform_wallet_top_up_identity_with_funding_signer( + wallet_handle: Handle, + identity_id: *const [u8; 32], + amount_duffs: u64, + account_index: u32, + core_signer_handle: *mut MnemonicResolverHandle, + out_new_balance: *mut u64, +) -> PlatformWalletFFIResult +``` + +Body: guard the pointers + `amount_duffs != 0`; round-trip `core_signer_handle` through `usize` +(so the spawned future's capture is `Send`); `PLATFORM_WALLET_STORAGE.with_item(...)` → +`block_on_worker(async { identity_wallet.top_up_identity_with_funding(&identity_id, +AssetLockFunding::FromWalletBalance { amount_duffs, account_index }, &asset_lock_signer, None).await })`; +write `*out_new_balance`. Note vs. registration: **no** identity-key signer and **no** pubkeys array — +the `IdentityTopUp` transition is signed entirely by the asset lock's Core-side key, so only +`core_signer_handle` is needed. + +Add the `MIN_TOP_UP_DUFFS` guard (§2.5) to this export's parameter checks. + +#### FromExistingAssetLock export + +Append `platform_wallet_topup_identity_with_existing_asset_lock_signer` (body above) to +`identity_registration_funded_with_signer.rs`, taking the DIP-15 function body. Its symbols are present +on v4.1-dev **except `Identifier`** — the registration resume twin never takes an `identity_id` input, +so the file has no `Identifier` import. **Add `use dpp::prelude::Identifier;`** or the verbatim body +fails `error[E0433]`. Everything else (`OutPointFFI`, `AssetLockFunding`, `MnemonicResolverCoreSigner`, +`MnemonicResolverHandle`, `PLATFORM_WALLET_STORAGE`, `block_on_worker`, `check_ptr!`, +`unwrap_option_or_return!`, `dashcore::{OutPoint, Txid}`, `Handle`, `PlatformWalletFFIResult`) is +already imported. It has no amount parameter (the lock's value is fixed), so the §2.5 floor does not +apply to it. + +**Rewrite the doc header.** DIP-15's header describes the invitation-voucher/OP_RETURN-burn reclaim +use case, which is misleading on generic v4.1-dev top-up code. Replace it with a self-contained +description of the stuck-lock / crash-recovery top-up (per the timeless-comments rule — no voucher, +no invitation narrative). + +Header auto-surfaces for both: `platform-wallet-ffi` is in `build_ios.sh`'s `INCLUDED_CRATES`; cbindgen +regenerates the header at build time (not committed) and the umbrella `DashSDKFFI.h` picks it up. +**No manual C-header work.** + +### 2.2 Swift SDK wrappers + +Add two methods to `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift` +— the register/resume pair for top-up, mirroring `registerIdentityWithFunding` (`:3549`) + +`resumeIdentityWithAssetLock` (`:3656`) but **simpler** (no `KeychainSigner`, no pubkeys, return a +balance instead of a `ManagedIdentity`): + +```swift +public func topUpIdentityWithFunding( // FromWalletBalance + identityId: Data, // 32 bytes + amountDuffs: UInt64, + accountIndex: UInt32 +) async throws -> UInt64 // new credit balance + +public func resumeTopUpWithAssetLock( // FromExistingAssetLock (crash recovery) + identityId: Data, // 32 bytes + outPoint: Data // 36 bytes: 32-byte txid + u32 vout +) async throws -> UInt64 // new credit balance +``` + +`resumeTopUpWithAssetLock` mirrors `resumeIdentityWithAssetLock` (`:3656`) exactly — same `OutPointFFI` +marshalling (`:3705`: `var outPoint = OutPointFFI(txid: txidTuple, vout: ...)`), same +`withExtendedLifetime(coreSigner)` + `Task.detached`, calling the new +`platform_wallet_topup_identity_with_existing_asset_lock_signer` and returning `outNewBalance`. + +`topUpIdentityWithFunding` implementation, mirroring the registration scaffolding: +- `let coreSigner = MnemonicResolver()` (default `WalletStorage()`), as registration does at `:3570`. +- Marshal `identityId` (32 bytes) into a pointer to a 32-byte tuple for the `*const [u8;32]` param + **by copying the sibling `topUpFromAddresses` wrapper in the same file** + (`ManagedPlatformWallet.swift:502-551`): build a zeroed 32-byte tuple, fill from + `identityId.prefix(32)`, pass via `withUnsafePointer(to: &idTuple) { idPtr in ... }`. This is the + exact `*const [u8;32]` **input** precedent (sharper than registration's `*mut` out-param). +- `var outNewBalance: UInt64 = 0`. +- Run the blocking FFI call in `Task.detached(priority: .userInitiated)` wrapped in + `withExtendedLifetime(coreSigner)` (the registration doc at `:3586` warns `_ = coreSigner` is unsafe + under `-O`) — the `MnemonicResolver` ctx is `passUnretained`, so it MUST outlive the call. +- `try result.check()`; return `outNewBalance`. + +### 2.3 Example-app UI — implement `executeIdentityTopUp` + +Wire the stub at `TransitionDetailView.swift:611` following the managed pattern of +`executeIdentityUpdate` (`:644`): +1. Resolve `ownerIdentity` from `selectedIdentityId`; resolve its wallet via + `ownerIdentity.wallet?.walletId` → `walletManager.wallet(for: walletId)`. +2. Read the funding **amount** (and optionally **accountIndex**) from `formInputs`. +3. `let newBalance = try await wallet.topUpIdentityWithFunding(identityId:amountDuffs:accountIndex:)`. +4. Update the local `PersistentIdentity` balance + `modelContext.save()` (as + `executeIdentityCreditTransfer` does at `:855`), return a `[String: Any]` result dict. + +Catalog change (`StateTransitionDefinitions.swift:49`): replace the single `assetLockProof` textarea +input (which the stub never read) with an **amount** field and an optional **`accountIndex`** field +(default `"0"`). The identity picker is already provided by `needsIdentitySelection`. + +**Amount is Core-side duffs, NOT platform credits.** Do *not* mirror `identityCreditTransfer`'s +`"Amount (credits)"` field (`StateTransitionDefinitions.swift:100`) — that would mislabel the unit by +the credit-per-duff scale. Mirror the funding-amount UX of `CreateIdentityView` (DASH-denominated text +field with `duffsPerDash` conversion), or at minimum label the field **"Amount (duffs)"**, and feed it +into `amount_duffs`. Enforce the minimum-amount floor (§2.5) before submit — the same shape as +`CreateIdentityView`'s `currentMinFundingDuffs` gate. + +Account selection is a plain numeric field defaulting to 0 (minimal, correct for the QA app). The +richer balance-validated BIP44-funding-account `Picker` from `CreateIdentityView` (`:617-636`) is +**not** copied — it's purely additive Swift-side work later (the FFI takes a raw `account_index: u32` +regardless), so deferring forces no rework. See §4. + +**Resume UI (crash-recovery path).** Add a minimal explicit entry to invoke `resumeTopUpWithAssetLock` +— an outpoint (txid hex + vout) input that recovers a stuck lock into the selected identity, styled +like the existing explicit-input QA flows (the two-step `TopUpIdentityFromAddressesView` already takes +raw hex inputs). Add a **confirmation step** ("top up identity X with lock Y?") before invoking — +resume directs a tracked lock at whatever identity is selected, and while cross-wallet consumption is +structurally impossible (the lock must be tracked by *this* wallet), a stray lock could still land on +the wrong *self-owned* identity, which is not undoable. The elaborate auto-detect-tracked-lock +coordinator that registration wraps around `resumeIdentityWithAssetLock` is **not** copied — the SDK +wrapper + explicit-outpoint UI is enough to exercise and prove funds recovery in the QA app; +auto-detection is a follow-up (§7). + +**Already-consumed error classification.** A double-resume (lock already burned on Platform) surfaces +as an opaque `PlatformWalletError::Sdk(...)` consensus rejection, not a friendly message — the same +class the DIP-15 reclaim had to special-case with an `isAlreadyConsumed("already completely used")` +classifier. The resume UI should detect and message this ("asset lock already consumed") rather than +dumping the raw SDK error. (A typed FFI error code is a follow-up; a narrow string-match classifier +matches the existing DIP-15 precedent.) + +### 2.4 Decision — RETIRE `dash_sdk_identity_topup_with_instant_lock` + +**Decided: RETIRE.** The managed path supersedes it and the app has zero callers, so remove the +raw-private-key surface rather than keep it. Blast radius (verified — fully self-contained, no +Kotlin/Android/Java/rs-sdk callers): + +- **Rust:** delete `packages/rs-sdk-ffi/src/identity/topup.rs` entirely — it contains only + `dash_sdk_identity_topup_with_instant_lock` (`:21`) and `..._and_wait` (`:94`). In + `packages/rs-sdk-ffi/src/identity/mod.rs`, remove **both** the `mod topup;` declaration (`:16`) + **and** the full 3-line re-export block (`:47-49`: `pub use topup::{ ... };`). **Keep** the rs-sdk + `TopUpIdentity` trait (`top_up_identity::TopUpIdentity`) — it's only `use`d locally by topup.rs, not + defined there, and the managed orchestrator uses it too. topup.rs's helper imports + (`create_instant_asset_lock_proof`, `parse_private_key`, `convert_put_settings`) are heavily used + elsewhere — deleting the file orphans nothing. +- **Swift:** delete `identityTopUp(...)` (`StateTransitionExtensions.swift:329`, incl. the C call at + `:352`) and its sole convenience caller `topUpIdentity(...)` (`:2596`). No other callers. +- Header regenerates automatically (symbols vanish from the cbindgen output). + +The `identityTopUp` **catalog key** stays — it now routes to the new managed `executeIdentityTopUp`. + +### 2.5 Minimum top-up amount floor (funds-safety — requires a decision) + +`amount_duffs == 0` is guarded in the export, but that is **not** the real floor. Platform enforces a +consensus-side minimum for `IdentityTopUp`: +`required_asset_lock_duff_balance_for_processing_start_for_identity_top_up = 50_000` duffs +(`dpp_state_transition_versions/v3.rs:21`, checked in +`identity_topup_transition/state_transition_estimated_fee_validation.rs:35`). An amount **between dust +(~a few hundred duffs) and ~50_000 + fees** does the dangerous thing: it **builds and broadcasts a +real asset lock (spending Core UTXOs), which Core accepts, then Platform rejects** — leaving the user's +DASH committed in a tracked asset lock that can never complete this top-up. This is the same footgun +class as the DIP-15 invitations sub-floor defect (`MIN_INVITATION_DUFFS`). + +**Decided: UI gate + export guard (defense in depth).** +- **Export guard:** in `platform_wallet_top_up_identity_with_funding_signer`, replace the + `amount_duffs == 0` check with `amount_duffs < MIN_TOP_UP_DUFFS → ErrorInvalidParameter`. Define + `const MIN_TOP_UP_DUFFS: u64 = 50_000;` in the FFI with a comment pointing at the consensus source + (`required_asset_lock_duff_balance_for_processing_start_for_identity_top_up`). This guards **all** + callers of the export, including Android. (The `FromExistingAssetLock` export has no amount param and + is unaffected.) +- **UI gate:** in the example app, disable submit + show a "minimum …" hint until the entered amount + ≥ the floor (mirroring `CreateIdentityView.currentMinFundingDuffs`), so no sub-floor lock is ever + broadcast. UI default should carry fee headroom above the bare 50_000 (exact default an + implementation detail; the hard floor is the consensus 50_000). + +### 2.6 Crash-recovery / stuck-lock resume for top-up (funds-safety — requires a decision) + +The orchestrator's `AssetLockFunding` enum already has a `FromExistingAssetLock { out_point }` variant +for resuming a lock that confirmed on Core but never reached Platform (app killed / network drop +between broadcast and submit). Registration exposes this symmetrically — +`platform_wallet_resume_identity_with_existing_asset_lock_signer` → Swift `resumeIdentityWithAssetLock` +(`ManagedPlatformWallet.swift:3656`), explicitly the "crash recovery" path. This PR wires **only** +`FromWalletBalance` for top-up, so an interrupted top-up strands the confirmed Core lock with **no +in-app recovery**. (The DIP-15 invitations branch already carries a `FromExistingAssetLock` top-up +export, so the primitive exists.) + +**Decided: include now.** Wire the `FromExistingAssetLock` top-up export +(`platform_wallet_topup_identity_with_existing_asset_lock_signer`, §2.1), the Swift +`resumeTopUpWithAssetLock` wrapper (§2.2), and a minimal explicit-outpoint resume UI (§2.3). This gives +top-up the same register+resume symmetry registration has and closes the stuck-lock funds-safety gap +in this PR. (Auto-detection of tracked stuck locks — registration's coordinator — remains a follow-up, +§7.) + +## 3. Interface / data flow + +``` +Example-app executeIdentityTopUp (TransitionDetailView.swift) + selectedIdentityId + formInputs[amount] (+ accountIndex) + │ resolve wallet via walletManager.wallet(for: ownerIdentity.wallet.walletId) + ▼ +Swift SDK ManagedPlatformWallet.topUpIdentityWithFunding(identityId, amountDuffs, accountIndex) + │ MnemonicResolver() core signer; Task.detached + withExtendedLifetime + ▼ +C ABI platform_wallet_top_up_identity_with_funding_signer(wallet, id, amt, acct, coreSigner, &out) + │ (cbindgen-generated header, auto-surfaced) + ▼ +Rust FFI PLATFORM_WALLET_STORAGE.with_item + block_on_worker + ▼ +Orchestrator IdentityWallet::top_up_identity_with_funding( + id, AssetLockFunding::FromWalletBalance{amount_duffs, account_index}, coreSigner, None) + │ build asset lock from wallet balance → IS→CL fallback → submit IdentityTopUp → persist balance + ▼ returns u64 new balance → out_new_balance → Swift UInt64 → UI balance update +``` + +## 4. Alternatives considered / rejected + +- **Wire the IS-only `dash_sdk_identity_topup_with_instant_lock` into the UI.** Rejected: pushes + asset-lock creation, proof acquisition, and raw-private-key handling onto the UI; no IS→CL fallback; + contradicts the managed security model (raw key never crosses FFI in the managed path). +- **Build a dedicated `TopUpFromCoreView` mirroring `CreateIdentityView`** (BIP44 funding-account + picker + a registration-style coordinator that survives sheet dismissal). Rejected for this PR as + over-scoped: the generic `TransitionDetailView` already handles async execution + result/error + presentation, and a numeric account field is adequate for the QA app. Can be a follow-up if a + production-grade UX is wanted. +- **Reimplement the orchestrator in the FFI layer.** Rejected — the orchestrator is already upstream; + the export must be a thin delegate (acceptance criterion). +- **Add the export to `identity_registration_funded_with_signer.rs`.** Rejected in favor of + `identity_top_up.rs` (where #3999 has it) to keep the eventual #3999 dedup a clean file-level match. + +## 5. Failure modes + +- **Insufficient Core UTXO balance** in the chosen account → orchestrator errors; surfaced via + `PlatformWalletFFIResult` → Swift throw → UI error panel. (Same as registration.) +- **Sub-floor amount (dust < amount < ~50_000 duffs + fees)** → the asset lock is **accepted Core-side + and broadcast**, then **rejected Platform-side**, leaving funds committed in a stuck tracked lock. + This is the funds-safety gap addressed by the §2.5 floor — NOT a clean Core-side failure. (Contrary + to registration's UI, which *does* gate on a computed `currentMinFundingDuffs` floor in + `CreateIdentityView` — registration is not floor-free at the UI layer.) +- **Interrupted top-up (app killed between Core confirmation and Platform submit)** → recover via the + §2.6 resume path (`resumeTopUpWithAssetLock`). +- **Resume of an untracked / consumed / foreign-wallet outpoint** → the orchestrator rejects cleanly + *before* any broadcast: untracked → `"not tracked"`; already-consumed locally → `"already Consumed — + nothing to resume"`; consumed on Platform → deterministic consensus rejection on resubmit. No + double-spend / double-credit / cross-wallet consumption is possible. The consumed-on-Platform case + is opaque (`Sdk(...)`) and should be classified in the resume UI (§2.3). +- **IS timeout** → orchestrator's IS→CL fallback handles it (the whole reason to use the managed path). +- **`MnemonicResolver` lifetime** → mandatory `withExtendedLifetime`; a dropped resolver mid-call is a + use-after-free. Directly mirrored from registration. +- **Wrong/unset identity index** → orchestrator returns `IdentityIndexNotSet`; propagated as an error. +- **Local balance bookkeeping failure after Platform accepted** → orchestrator logs, does not fail the + call (Platform already accepted). UI shows the returned balance. + +## 6. Test / verification plan + +Honest scoping: the rs-platform-wallet-ffi test suite is **entirely in-process** (contact requests, +handle lifecycle, null-pointer guards); **none** of it exercises the funded registration/top-up path, +because that needs a live Core+Platform network (asset locks, IS/CL). So "mirror the registration +coverage" realistically means: + +1. **Rust guard unit tests** (hermetic, mirrorable from the existing null-pointer tests): for the + `FromWalletBalance` export, assert `ErrorInvalidParameter` on `amount_duffs < MIN_TOP_UP_DUFFS` + (including the old `== 0` and a sub-floor value like `49_999`), and the null-pointer error on null + `identity_id` / `core_signer_handle` / `out_new_balance`. For the `FromExistingAssetLock` export, + assert the null-pointer errors on null `out_point` / `identity_id` / `core_signer_handle` / + `out_new_balance`. Cheap, real, regression-proof. +2. **Compile/link gates:** `cargo check -p platform-wallet-ffi`; then `./build_ios.sh` regenerates the + header and the SwiftExampleApp builds against the new symbol (this is the real "does it surface" + proof). +3. **Funded happy-path + IS→CL fallback: testnet UAT via SwiftExampleApp** (device/simulator). Top up a + funded identity by N duffs; assert the credit balance rises by ~N (minus fee). Consistent with how + the funded registration path and the DashPay funded flows are verified in this repo. Not a hermetic + unit test — stated explicitly, not implied. **Also exercise a near/sub-floor amount** (e.g. just + under the §2.5 floor) to confirm the UI floor blocks it *before* any asset lock is broadcast (so no + funds are stranded) — this pins the §2.5 mitigation, not just the `== 0` guard. +4. **Resume-path UAT (testnet):** deliberately interrupt a `FromWalletBalance` top-up after the Core + lock confirms but before Platform accepts (or reuse a lock the app already tracks), then recover it + via `resumeTopUpWithAssetLock` and assert the balance rises. Proves the stuck-lock recovery. +5. **Retire regression:** `cargo check -p rs-sdk-ffi` and the SwiftExampleApp build must still pass + after deleting `topup.rs` + the Swift `identityTopUp`/`topUpIdentity` wrappers — confirms nothing + else referenced them. +6. **Regression:** the existing two-step address route + (`topUpAddressFromAssetLock` → `topUpIdentityFromAddresses`) must still build and work — it is + independent of the new exports. + +## 7. Out of scope / follow-ups + +- **#3999 dedup:** after this merges, #3999 rebases and **drops its copy** of + `platform_wallet_top_up_identity_with_funding_signer` (`identity_top_up.rs` +110), keeping only its + Android JNI/Kotlin wiring. This PR owns the canonical export. Note in the PR description. +- **#4041 dedup:** the DIP-15 invitations PR similarly drops its copy of + `platform_wallet_topup_identity_with_existing_asset_lock_signer` once this lands. Note in both PRs. +- Dedicated production-grade `TopUpFromCoreView` with a balance-validated funding-account picker + a + registration-style coordinator, **and auto-detection of tracked stuck locks** to offer resume + proactively (this PR ships only the explicit-outpoint resume entry). + +**Ops note:** the *local* `v4.1-dev` ref is stale (points at `9f9092cc91`, v4.0.0); this branch sits +exactly on `origin/v4.1-dev` (`f7d7c8d348`) with zero commits. Diff/verify against `origin/v4.1-dev`, +not the local ref, or you'll see an 80k-line phantom diff. All spec line numbers match worktree HEAD. + +## 8. Acceptance criteria (from the issue) + +- [ ] `platform_wallet_top_up_identity_with_funding_signer` (`FromWalletBalance`) **and** + `platform_wallet_topup_identity_with_existing_asset_lock_signer` (`FromExistingAssetLock`) on + `v4.1-dev`, both delegating to `top_up_identity_with_funding` (no reimplementation). +- [ ] iOS tops up an existing identity from a Core asset lock in one managed call (build lock from + wallet balance, IS→CL fallback) via wired UI — `executeIdentityTopUp` no longer `notImplemented`. +- [ ] iOS can recover a stuck/tracked lock into an identity via `resumeTopUpWithAssetLock` (explicit + outpoint UI). +- [ ] Sub-floor amounts are rejected before broadcast (export `MIN_TOP_UP_DUFFS` guard + UI gate, §2.5). +- [ ] `dash_sdk_identity_topup_with_instant_lock`(+`_and_wait`) and its Swift wrappers are removed + (§2.4); rs-sdk-ffi + SwiftExampleApp still build. +- [ ] iOS + Android share the same FFI exports. +- [ ] Tests cover happy path + IS→CL fallback + resume + sub-floor rejection (per §6 scoping); two-step + address route still works. +- [ ] #3999 and #4041 dedup follow-ups noted (§7). diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs index 5815898af57..c11c01d6f53 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs @@ -17,6 +17,7 @@ use dashcore::hashes::Hash; use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::Identifier; use platform_wallet::AssetLockFunding; use rs_sdk_ffi::{SignerHandle, VTableSigner}; @@ -241,3 +242,167 @@ pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_loc *out_identity_handle = handle; PlatformWalletFFIResult::ok() } + +/// Top up an EXISTING identity from an already-tracked Core asset lock. +/// +/// The crash-recovery counterpart to +/// [`crate::platform_wallet_top_up_identity_with_funding_signer`] (which +/// builds a *new* lock from wallet balance): this consumes a lock that +/// already confirmed on Core but whose `IdentityTopUp` never reached +/// Platform (app killed / network drop between broadcast and submit), +/// completing the top-up against `identity_id` from the stored outpoint. +/// Sister to +/// [`platform_wallet_resume_identity_with_existing_asset_lock_signer`], +/// which resumes the lock as a NEW-identity registration instead. +/// +/// The `FromExistingAssetLock` resume + IS→CL fallback logic lives in +/// `top_up_identity_with_funding`; this FFI is a thin marshaler. No +/// per-identity-key signer is needed (a top-up creates no keys); only the +/// Core-side asset-lock signature, produced by the wallet's own resolver. +/// +/// # Safety +/// - `out_point` must be a valid, non-null `*const OutPointFFI`; the caller +/// retains ownership. +/// - `identity_id` must point to 32 readable bytes. +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`; the caller retains ownership. +/// - `out_new_balance` must be a valid `*mut u64`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_topup_identity_with_existing_asset_lock_signer( + wallet_handle: Handle, + out_point: *const OutPointFFI, + identity_id: *const [u8; 32], + core_signer_handle: *mut MnemonicResolverHandle, + out_new_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_point); + check_ptr!(identity_id); + check_ptr!(core_signer_handle); + check_ptr!(out_new_balance); + // FFI-safe sentinel before any fallible work. + *out_new_balance = 0; + + let out_point_ffi = *out_point; + let reclaim_outpoint = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array(out_point_ffi.txid), + vout: out_point_ffi.vout, + }; + let identity_id = Identifier::from(*identity_id); + + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.sdk().network; + block_on_worker(async move { + // SAFETY: see the fn-level safety doc — the handle is pinned alive + // for the duration of this FFI call. + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + identity_wallet + .top_up_identity_with_funding( + &identity_id, + AssetLockFunding::FromExistingAssetLock { + out_point: reclaim_outpoint, + }, + &asset_lock_signer, + None, + ) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let new_balance = unwrap_result_or_return!(result); + *out_new_balance = new_balance; + PlatformWalletFFIResult::ok() +} + +#[cfg(test)] +mod topup_existing_lock_guard_tests { + use super::*; + use crate::error::PlatformWalletFFIResultCode; + + /// Non-null but never-dereferenced core-signer pointer: the null guards + /// under test return before the handle is used. + fn dangling_core_signer() -> *mut MnemonicResolverHandle { + std::ptr::NonNull::::dangling().as_ptr() + } + + fn zero_out_point() -> OutPointFFI { + OutPointFFI { + txid: [0u8; 32], + vout: 0, + } + } + + #[test] + fn rejects_null_out_point() { + let id = [0u8; 32]; + let mut balance = 0u64; + let res = unsafe { + platform_wallet_topup_identity_with_existing_asset_lock_signer( + 0, + std::ptr::null(), + &id, + dangling_core_signer(), + &mut balance, + ) + }; + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + #[test] + fn rejects_null_identity_id() { + let op = zero_out_point(); + let mut balance = 0u64; + let res = unsafe { + platform_wallet_topup_identity_with_existing_asset_lock_signer( + 0, + &op, + std::ptr::null(), + dangling_core_signer(), + &mut balance, + ) + }; + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + #[test] + fn rejects_null_core_signer() { + let op = zero_out_point(); + let id = [0u8; 32]; + let mut balance = 0u64; + let res = unsafe { + platform_wallet_topup_identity_with_existing_asset_lock_signer( + 0, + &op, + &id, + std::ptr::null_mut(), + &mut balance, + ) + }; + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + #[test] + fn rejects_null_out_balance() { + let op = zero_out_point(); + let id = [0u8; 32]; + let res = unsafe { + platform_wallet_topup_identity_with_existing_asset_lock_signer( + 0, + &op, + &id, + dangling_core_signer(), + std::ptr::null_mut(), + ) + }; + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/identity_top_up.rs b/packages/rs-platform-wallet-ffi/src/identity_top_up.rs index 70d90bf488b..889477788dc 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_top_up.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_top_up.rs @@ -1,20 +1,24 @@ -//! Identity top-up driven by an external `Signer` -//! handle. +//! Identity top-up for an *existing* identity — two funding sources: //! -//! Mirrors [`crate::identity_registration_with_signer`] (registration) -//! but for an *existing* identity. The single entry point — -//! [`platform_wallet_top_up_from_addresses_with_signer`] — wraps the -//! composite -//! [`PlatformWallet::top_up_from_addresses`](platform_wallet::PlatformWallet::top_up_from_addresses) -//! and reuses the same address-input shape (`IdentityFundingInputFFI`) -//! the registration FFI exposes. +//! - [`platform_wallet_top_up_from_addresses_with_signer`] wraps the +//! composite +//! [`PlatformWallet::top_up_from_addresses`](platform_wallet::PlatformWallet::top_up_from_addresses), +//! spending already-funded Platform-payment addresses (driven by an +//! external `Signer` handle) and reusing the same +//! address-input shape (`IdentityFundingInputFFI`) the registration FFI +//! exposes. +//! - [`platform_wallet_top_up_identity_with_funding_signer`] wraps +//! `IdentityWallet::top_up_identity_with_funding`, building and +//! broadcasting a **new Core asset lock** (same mechanism as identity +//! registration), driven by a Core-side `MnemonicResolverHandle`. //! -//! Top-up state-transitions are signed entirely with the Platform -//! address inputs' private keys (the SDK uses `BalanceTransfer` to -//! credit the existing identity), so this FFI takes a single -//! `SignerHandle` — `signer_address_handle` — used as -//! `Signer`. No identity-key signer is needed -//! (existing identity, no IdentityCreate to sign). +//! The address path's top-up state-transitions are signed entirely with +//! the Platform address inputs' private keys (the SDK uses +//! `BalanceTransfer` to credit the existing identity), so that FFI takes a +//! single `SignerHandle` — `signer_address_handle` — used as +//! `Signer`. Neither path needs an identity-key signer +//! (existing identity, no IdentityCreate to sign); the asset-lock path is +//! signed by the lock's Core-side key via the `MnemonicResolver`. //! //! On success the function writes the post-transition credit balance //! back through `out_new_balance`. The local `ManagedIdentity` @@ -29,7 +33,8 @@ use std::slice; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use dpp::prelude::Identifier; -use rs_sdk_ffi::{SignerHandle, VTableSigner}; +use platform_wallet::AssetLockFunding; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; use crate::check_ptr; use crate::error::*; @@ -136,3 +141,197 @@ pub unsafe extern "C" fn platform_wallet_top_up_from_addresses_with_signer( *out_new_balance = new_balance; PlatformWalletFFIResult::ok() } + +/// Minimum asset-lock funding for a Core-funded identity top-up, in duffs. +/// +/// Platform will not start processing an `IdentityTopUp` whose asset lock +/// funds less than +/// `required_asset_lock_duff_balance_for_processing_start_for_identity_top_up` +/// (currently 50_000 duffs). Below that, a lock built and broadcast here is +/// accepted by Core (spending real UTXOs) but rejected by Platform, stranding +/// the funds in a lock that can never complete the top-up. Reject sub-floor +/// amounts up front so no such lock is ever broadcast. +const MIN_TOP_UP_DUFFS: u64 = 50_000; + +/// Top up an existing identity's credit balance by building and +/// broadcasting a **new Core asset lock** (the same funding mechanism as +/// identity registration), distinct from +/// [`platform_wallet_top_up_from_addresses_with_signer`] which spends +/// already-funded Platform-payment addresses. +/// +/// Wraps +/// [`IdentityWallet::top_up_identity_with_funding`](platform_wallet::wallet::identity::network::registration) +/// with [`AssetLockFunding::FromWalletBalance`] — the same L2 orchestrator +/// (funding resolution, IS→CL fallback, asset-lock cleanup) that +/// [`platform_wallet_register_identity_with_funding_signer`] drives for +/// registration. `account_index` selects which BIP44 *standard* account +/// the asset-lock UTXOs are drawn from (only BIP44 standard accounts are +/// supported today, matching registration). +/// +/// Unlike registration this takes NO identity-key signer: the +/// `IdentityTopUp` state-transition is signed entirely by the asset lock's +/// Core-side key, so only `core_signer_handle` (a +/// `*mut MnemonicResolverHandle`, reusing the Keychain-resolver vtable) is +/// required. On success `out_new_balance` receives the post-transition +/// credit balance Platform returns; the local `ManagedIdentity` balance is +/// updated + queued for persistence inside the library call. +/// +/// `amount_duffs` must be at least [`MIN_TOP_UP_DUFFS`]; a smaller amount is +/// rejected with `ErrorInvalidParameter` before any lock is broadcast. +/// +/// # Safety +/// - `wallet_handle` must come from the platform-wallet handle registry. +/// - `identity_id` must point at a 32-byte identity id buffer for the +/// duration of the call. +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle` produced by +/// [`crate::dash_sdk_mnemonic_resolver_create`]. The caller retains +/// ownership; this function does NOT destroy it. +/// - `out_new_balance` must be writable for the duration of the call. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_top_up_identity_with_funding_signer( + wallet_handle: Handle, + identity_id: *const [u8; 32], + amount_duffs: u64, + account_index: u32, + core_signer_handle: *mut MnemonicResolverHandle, + out_new_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(identity_id); + check_ptr!(core_signer_handle); + check_ptr!(out_new_balance); + if amount_duffs < MIN_TOP_UP_DUFFS { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "`amount_duffs` is below the minimum top-up asset-lock balance", + ); + } + + let identity_id_bytes: [u8; 32] = *identity_id; + let identity_id = Identifier::from_bytes(&identity_id_bytes).unwrap_or_default(); + + // Round-trip the handle through `usize` so the spawned future's + // capture is `Send + 'static` — same pattern as the registration + // FFI (raw pointers are `!Send`, `usize` isn't). + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + // Capture the network the asset-lock signer should derive under, + // pulled from the wallet (mirrors the registration FFI). + let network = wallet.sdk().network; + block_on_worker(async move { + // SAFETY: see the fn-level safety doc — the handle is pinned + // alive for the duration of this FFI call. + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + identity_wallet + .top_up_identity_with_funding( + &identity_id, + AssetLockFunding::FromWalletBalance { + amount_duffs, + account_index, + }, + &asset_lock_signer, + None, + ) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let new_balance = unwrap_result_or_return!(result); + *out_new_balance = new_balance; + PlatformWalletFFIResult::ok() +} + +#[cfg(test)] +mod top_up_with_funding_guard_tests { + use super::*; + use crate::error::PlatformWalletFFIResultCode; + + /// A non-null but never-dereferenced core-signer pointer. Every guard + /// under test returns before the handle is used, so a dangling pointer + /// is sufficient (and never unsound here). + fn dangling_core_signer() -> *mut MnemonicResolverHandle { + std::ptr::NonNull::::dangling().as_ptr() + } + + #[test] + fn rejects_null_identity_id() { + let mut balance = 0u64; + let res = unsafe { + platform_wallet_top_up_identity_with_funding_signer( + 0, + std::ptr::null(), + MIN_TOP_UP_DUFFS, + 0, + dangling_core_signer(), + &mut balance, + ) + }; + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + #[test] + fn rejects_null_core_signer() { + let id = [0u8; 32]; + let mut balance = 0u64; + let res = unsafe { + platform_wallet_top_up_identity_with_funding_signer( + 0, + &id, + MIN_TOP_UP_DUFFS, + 0, + std::ptr::null_mut(), + &mut balance, + ) + }; + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + #[test] + fn rejects_null_out_balance() { + let id = [0u8; 32]; + let res = unsafe { + platform_wallet_top_up_identity_with_funding_signer( + 0, + &id, + MIN_TOP_UP_DUFFS, + 0, + dangling_core_signer(), + std::ptr::null_mut(), + ) + }; + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + #[test] + fn rejects_sub_floor_amount() { + let id = [0u8; 32]; + let mut balance = 0u64; + for amount in [0u64, MIN_TOP_UP_DUFFS - 1] { + let res = unsafe { + platform_wallet_top_up_identity_with_funding_signer( + 0, + &id, + amount, + 0, + dangling_core_signer(), + &mut balance, + ) + }; + assert_eq!( + res.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "amount {amount} below MIN_TOP_UP_DUFFS should be rejected" + ); + } + } +} From 3b03d45edfb0da34de2ba3a37ef256edc95fdbf7 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 11 Jul 2026 20:14:57 +0700 Subject: [PATCH 2/6] refactor: retire raw-key IS-only identity top-up primitive dash_sdk_identity_topup_with_instant_lock (+ _and_wait) required the caller to build an InstantSend proof and pass a raw 32-byte asset-lock private key across FFI into a non-zeroizable Swift Data. The managed platform_wallet_top_up_identity_with_funding_signer path supersedes it (key material stays behind the Keychain resolver, never crossing FFI as plaintext) and the app has zero callers. Delete the rs-sdk-ffi exports (topup.rs and its re-exports) and the Swift identityTopUp / topUpIdentity wrappers. The rs-sdk TopUpIdentity trait is kept -- the managed orchestrator uses it. Refs #4092 Co-Authored-By: Claude Opus 4.8 --- packages/rs-sdk-ffi/src/identity/mod.rs | 4 - packages/rs-sdk-ffi/src/identity/topup.rs | 172 ------------------ .../FFI/StateTransitionExtensions.swift | 99 ---------- 3 files changed, 275 deletions(-) delete mode 100644 packages/rs-sdk-ffi/src/identity/topup.rs diff --git a/packages/rs-sdk-ffi/src/identity/mod.rs b/packages/rs-sdk-ffi/src/identity/mod.rs index 6cb38336ce6..2fd096fc864 100644 --- a/packages/rs-sdk-ffi/src/identity/mod.rs +++ b/packages/rs-sdk-ffi/src/identity/mod.rs @@ -13,7 +13,6 @@ mod put; mod queries; mod test_transfer; mod top_up_from_addresses; -mod topup; mod transfer; mod transfer_to_addresses; mod withdraw; @@ -44,9 +43,6 @@ pub use top_up_from_addresses::{ dash_sdk_identity_top_up_from_addresses, dash_sdk_identity_top_up_from_addresses_result_free, DashSDKIdentityTopUpFromAddressesResult, }; -pub use topup::{ - dash_sdk_identity_topup_with_instant_lock, dash_sdk_identity_topup_with_instant_lock_and_wait, -}; pub use transfer::{ dash_sdk_identity_transfer_credits, dash_sdk_transfer_credits_result_free, DashSDKTransferCreditsResult, diff --git a/packages/rs-sdk-ffi/src/identity/topup.rs b/packages/rs-sdk-ffi/src/identity/topup.rs deleted file mode 100644 index 68486234ddd..00000000000 --- a/packages/rs-sdk-ffi/src/identity/topup.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Identity top-up operations - -use dash_sdk::dpp::prelude::Identity; -use dash_sdk::platform::Fetch; - -use crate::identity::helpers::{ - convert_put_settings, create_instant_asset_lock_proof, parse_private_key, -}; -use crate::sdk::SDKWrapper; -use crate::types::{DashSDKPutSettings, DashSDKResultDataType, IdentityHandle, SDKHandle}; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; - -/// Top up an identity with credits using instant lock proof -/// -/// # Safety -/// - `sdk_handle`, `identity_handle`, `instant_lock_bytes`, `transaction_bytes`, and `private_key` must be valid, non-null pointers. -/// - Buffer pointers must reference at least the specified lengths. -/// - `put_settings` may be null; if non-null it must be valid for the duration of the call. -/// - On success, returns serialized data; any heap memory inside the result must be freed using SDK routines. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_identity_topup_with_instant_lock( - sdk_handle: *mut SDKHandle, - identity_handle: *const IdentityHandle, - instant_lock_bytes: *const u8, - instant_lock_len: usize, - transaction_bytes: *const u8, - transaction_len: usize, - output_index: u32, - private_key: *const [u8; 32], - put_settings: *const DashSDKPutSettings, -) -> DashSDKResult { - // Validate parameters - if sdk_handle.is_null() - || identity_handle.is_null() - || instant_lock_bytes.is_null() - || transaction_bytes.is_null() - || private_key.is_null() - { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "One or more required parameters is null".to_string(), - )); - } - - let wrapper = &mut *(sdk_handle as *mut SDKWrapper); - let identity = &*(identity_handle as *const Identity); - - let result: Result, FFIError> = wrapper.runtime.block_on(async { - // Create instant asset lock proof - let asset_lock_proof = create_instant_asset_lock_proof( - instant_lock_bytes, - instant_lock_len, - transaction_bytes, - transaction_len, - output_index, - )?; - - // Parse private key - let private_key = parse_private_key(private_key)?; - - // Convert settings - let settings = convert_put_settings(put_settings); - - // Use TopUp trait to top up identity - use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; - - let new_balance = identity - .top_up_identity_with_private_key( - &wrapper.sdk, - asset_lock_proof, - &private_key, - settings, - ) - .await - .map_err(|e| FFIError::InternalError(format!("Failed to top up identity: {}", e)))?; - - // Return the new balance as a string since we don't have the state transition anymore - Ok(new_balance.to_string().into_bytes()) - }); - - match result { - Ok(serialized_data) => DashSDKResult::success_binary(serialized_data), - Err(e) => DashSDKResult::error(e.into()), - } -} - -/// Top up an identity with credits using instant lock proof and wait for confirmation -/// -/// # Safety -/// - Same requirements as `dash_sdk_identity_topup_with_instant_lock`. -/// - The function may block while waiting for confirmation; input pointers must remain valid throughout. -/// - On success, returns a heap-allocated handle which must be destroyed with the SDK's destroy function. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_identity_topup_with_instant_lock_and_wait( - sdk_handle: *mut SDKHandle, - identity_handle: *const IdentityHandle, - instant_lock_bytes: *const u8, - instant_lock_len: usize, - transaction_bytes: *const u8, - transaction_len: usize, - output_index: u32, - private_key: *const [u8; 32], - put_settings: *const DashSDKPutSettings, -) -> DashSDKResult { - // Validate parameters - if sdk_handle.is_null() - || identity_handle.is_null() - || instant_lock_bytes.is_null() - || transaction_bytes.is_null() - || private_key.is_null() - { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "One or more required parameters is null".to_string(), - )); - } - - let wrapper = &mut *(sdk_handle as *mut SDKWrapper); - let identity = &*(identity_handle as *const Identity); - - let result: Result = wrapper.runtime.block_on(async { - // Create instant asset lock proof - let asset_lock_proof = create_instant_asset_lock_proof( - instant_lock_bytes, - instant_lock_len, - transaction_bytes, - transaction_len, - output_index, - )?; - - // Parse private key - let private_key = parse_private_key(private_key)?; - - // Convert settings - let settings = convert_put_settings(put_settings); - - // Use TopUp trait to top up identity and wait for response - use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; - - let _new_balance = identity - .top_up_identity_with_private_key( - &wrapper.sdk, - asset_lock_proof, - &private_key, - settings, - ) - .await - .map_err(|e| FFIError::InternalError(format!("Failed to top up identity: {}", e)))?; - - // Fetch the updated identity after top up - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - let updated_identity = Identity::fetch(&wrapper.sdk, identity.id()) - .await - .map_err(FFIError::from)? - .ok_or_else(|| { - FFIError::InternalError("Failed to fetch updated identity".to_string()) - })?; - - Ok(updated_identity) - }); - - match result { - Ok(topped_up_identity) => { - let handle = Box::into_raw(Box::new(topped_up_identity)) as *mut IdentityHandle; - DashSDKResult::success_handle( - handle as *mut std::os::raw::c_void, - DashSDKResultDataType::ResultIdentityHandle, - ) - } - Err(e) => DashSDKResult::error(e.into()), - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift index 0559999e6c9..aacf891dd5e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift @@ -325,80 +325,6 @@ extension SDK { } } - /// Top up an identity with instant lock - public func identityTopUp( - identity: OpaquePointer, - instantLock: Data, - transaction: Data, - outputIndex: UInt32, - privateKey: Data - ) async throws -> UInt64 { - let idBox = SendableOpaque(identity) - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { [weak self] in - guard let self = self, let handle = self.handle else { - continuation.resume(throwing: SDKError.invalidState("SDK not initialized")) - return - } - - guard privateKey.count == 32 else { - continuation.resume(throwing: SDKError.invalidParameter("Private key must be 32 bytes")) - return - } - - let result = instantLock.withUnsafeBytes { instantLockBytes in - transaction.withUnsafeBytes { txBytes in - privateKey.withUnsafeBytes { keyBytes in - dash_sdk_identity_topup_with_instant_lock( - handle, - idBox.p, - instantLockBytes.bindMemory(to: UInt8.self).baseAddress!, - UInt(instantLock.count), - txBytes.bindMemory(to: UInt8.self).baseAddress!, - UInt(transaction.count), - outputIndex, - keyBytes.bindMemory(to: UInt8.self).baseAddress!.withMemoryRebound(to: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8).self, capacity: 1) { $0 }, - nil // Default put settings - ) - } - } - } - - - if result.error == nil { - if result.data_type.rawValue == 3, // ResultIdentityHandle - let toppedUpIdentityHandle = result.data { - // Get identity info from the handle to retrieve the new balance - let idPtr = OpaquePointer(toppedUpIdentityHandle) - let infoPtr = dash_sdk_identity_get_info(idPtr) - - if let info = infoPtr { - let balance = info.pointee.balance - - // Free the identity info structure - dash_sdk_identity_info_free(info) - - // Destroy the topped up identity handle - dash_sdk_identity_destroy(idPtr) - - continuation.resume(returning: balance) - } else { - // Destroy the identity handle - dash_sdk_identity_destroy(idPtr) - continuation.resume(throwing: SDKError.internalError("Failed to get identity info after topup")) - } - } else { - continuation.resume(throwing: SDKError.internalError("Invalid result type")) - } - } else { - let errorString = result.error?.pointee.message != nil ? - String(cString: result.error!.pointee.message) : "Unknown error" - continuation.resume(throwing: SDKError.internalError(errorString)) - } - } - } - } - /// Transfer credits between identities public func identityTransferCredits( fromIdentity: OpaquePointer, @@ -2592,31 +2518,6 @@ extension SDK { ) } - /// Top up identity with instant lock (convenience method with DPPIdentity) - public func topUpIdentity( - _ identity: DPPIdentity, - instantLock: Data, - transaction: Data, - outputIndex: UInt32, - privateKey: Data - ) async throws -> UInt64 { - // Convert DPPIdentity to handle - let identityHandle = try identityToHandle(identity) - defer { - // Clean up the handle when done - dash_sdk_identity_destroy(identityHandle) - } - - // Call the lower-level method - return try await identityTopUp( - identity: identityHandle, - instantLock: instantLock, - transaction: transaction, - outputIndex: outputIndex, - privateKey: privateKey - ) - } - /// Withdraw credits from identity (convenience method with DPPIdentity) public func withdrawFromIdentity( _ identity: DPPIdentity, From 4e745599c66d31cfec5c72b468101830e59fa32e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 11 Jul 2026 20:15:08 +0700 Subject: [PATCH 3/6] feat(swift-sdk): wire iOS managed identity top-up from asset lock Add ManagedPlatformWallet.topUpIdentityWithFunding and resumeTopUpWithAssetLock, mirroring the registration funding/resume pair, over the new managed FFI exports. Implement the example-app executeIdentityTopUp handler (previously a notImplemented stub) and a new executeIdentityTopUpResume recovery handler, with catalog + menu entries. The amount is Core-side duffs; the UI gates on the same 50000-duff floor the FFI enforces so a sub-floor amount never reaches the network. The resume handler reverses the display-order txid to wire order (matching OutPointFFI, as CreateIdentityView.parseOutPointHex does) and classifies the opaque "already consumed" rejection into a friendly message. Refs #4092 Co-Authored-By: Claude Opus 4.8 --- .../Models/StateTransitionDefinitions.swift | 45 ++++- .../ManagedPlatformWallet.swift | 155 ++++++++++++++++++ .../Views/TransitionCategoryView.swift | 1 + .../Views/TransitionDetailView.swift | 109 +++++++++++- 4 files changed, 302 insertions(+), 8 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Models/StateTransitionDefinitions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Models/StateTransitionDefinitions.swift index 7b5c4fb627a..c757d731995 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Models/StateTransitionDefinitions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Models/StateTransitionDefinitions.swift @@ -49,15 +49,48 @@ public struct TransitionDefinitions { "identityTopUp": TransitionDefinition( key: "identityTopUp", label: "Identity Top Up", - description: "Add credits to an existing identity", + description: "Add credits to an existing identity from a new Core asset lock", inputs: [ TransitionInput( - name: "assetLockProof", - type: "textarea", - label: "Asset Lock Proof", + name: "amount", + type: "number", + label: "Amount (duffs)", required: true, - placeholder: "Enter asset lock proof (hex encoded)", - help: "The asset lock proof that provides additional credits" + placeholder: "100000", + help: "Core-side funding amount in duffs (minimum 50000). A new asset lock is built from the wallet's balance." + ), + TransitionInput( + name: "accountIndex", + type: "number", + label: "Funding Account Index", + required: false, + placeholder: "0", + help: "BIP44 standard account supplying the funding UTXOs", + defaultValue: "0" + ) + ] + ), + + "identityTopUpResume": TransitionDefinition( + key: "identityTopUpResume", + label: "Identity Top Up (Resume Stuck Lock)", + description: "Recover a stuck top-up by consuming an already-tracked Core asset lock", + inputs: [ + TransitionInput( + name: "outPointTxid", + type: "text", + label: "Asset Lock Txid (hex)", + required: true, + placeholder: "64-hex-char transaction id", + help: "Txid of the tracked asset lock to consume, in display-order hex" + ), + TransitionInput( + name: "outPointVout", + type: "number", + label: "Asset Lock Output Index", + required: false, + placeholder: "0", + defaultValue: "0" ) ] ), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index f5dec496d55..33100c2b1d3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3742,4 +3742,159 @@ extension ManagedPlatformWallet { return (identityId, ManagedIdentity(handle: outManagedHandle)) }.value } + + /// Top up an existing identity by building and broadcasting a **new + /// Core asset lock** from the wallet's own balance — the top-up twin of + /// [`registerIdentityWithFunding(amountDuffs:accountIndex:identityIndex:identityPubkeys:signer:)`]. + /// + /// Simpler than registration: an `IdentityTopUp` creates no identity + /// keys, so there is no per-identity-key `KeychainSigner` and no pubkey + /// array — the transition is signed entirely by the asset lock's + /// Core-side key via a `MnemonicResolver`. `accountIndex` selects which + /// BIP44 *standard* account supplies the funding UTXOs (same constraint + /// as registration). + /// + /// `amountDuffs` must meet the Rust-side minimum top-up asset-lock + /// balance; a smaller amount is rejected before any lock is broadcast + /// (callers should also gate on the minimum in the UI so a sub-floor + /// amount never reaches here). Returns the identity's post-transition + /// credit balance; the local `ManagedIdentity` balance is updated inside + /// the FFI call. + public func topUpIdentityWithFunding( + identityId: Data, + amountDuffs: UInt64, + accountIndex: UInt32 + ) async throws -> UInt64 { + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "identityId must be 32 bytes, got \(identityId.count)" + ) + } + let handle = self.handle + // Core-side asset-lock signer. Same `MnemonicResolver` lifetime + + // vtable rationale as `registerIdentityWithFunding`: the + // credit-output private key is fetched per-call from Keychain, + // signed, and zeroed — no private key ever lives in Rust memory + // across operations. + let coreSigner = MnemonicResolver() + return try await Task.detached(priority: .userInitiated) { () -> UInt64 in + var idTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + withUnsafeMutableBytes(of: &idTuple) { raw in + for (i, byte) in identityId.prefix(32).enumerated() { + raw[i] = byte + } + } + + var newBalance: UInt64 = 0 + // `withExtendedLifetime` pins `coreSigner` across the + // synchronous FFI call (Rust uses `block_on_worker`). Keep the + // call inline — an unawaited Task inside would let the resolver + // drop mid-flight and dangle its trampoline ctx pointer. + let result = withExtendedLifetime(coreSigner) { + withUnsafePointer(to: &idTuple) { idPtr in + platform_wallet_top_up_identity_with_funding_signer( + handle, + idPtr, + amountDuffs, + accountIndex, + coreSigner.handle, + &newBalance + ) + } + } + try result.check() + return newBalance + }.value + } + + /// Recover a stuck top-up by consuming an already-tracked Core asset + /// lock — the top-up twin of + /// [`resumeIdentityWithAssetLock(outPointTxid:outPointVout:identityIndex:identityPubkeys:signer:)`]. + /// + /// Use case is crash recovery: a prior `topUpIdentityWithFunding` + /// confirmed its lock on Core but the `IdentityTopUp` never reached + /// Platform (app killed / network drop). This picks up that lock by + /// outpoint and completes the top-up against `identityId`. + /// + /// `outPointTxid` is the 32-byte raw txid (little-endian wire order, + /// same shape as `OutPointFFI.txid`; the caller decodes from + /// display-order hex first). Returns the post-transition credit balance. + /// + /// If the lock was already consumed on Platform (double-resume), the FFI + /// surfaces an opaque consensus rejection — the caller should classify + /// and message it ("asset lock already consumed") rather than showing + /// the raw error. + public func resumeTopUpWithAssetLock( + identityId: Data, + outPointTxid: Data, + outPointVout: UInt32 + ) async throws -> UInt64 { + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "identityId must be 32 bytes, got \(identityId.count)" + ) + } + guard outPointTxid.count == 32 else { + throw PlatformWalletError.invalidParameter( + "outPointTxid must be exactly 32 bytes (was \(outPointTxid.count))" + ) + } + let handle = self.handle + // Same `MnemonicResolver` rationale as `topUpIdentityWithFunding`. + let coreSigner = MnemonicResolver() + return try await Task.detached(priority: .userInitiated) { () -> UInt64 in + var idTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + withUnsafeMutableBytes(of: &idTuple) { raw in + for (i, byte) in identityId.prefix(32).enumerated() { + raw[i] = byte + } + } + var txidTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + outPointTxid.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &txidTuple) { dst in + dst.copyMemory(from: src) + } + } + var outPoint = OutPointFFI(txid: txidTuple, vout: outPointVout) + + var newBalance: UInt64 = 0 + let result = withExtendedLifetime(coreSigner) { + withUnsafePointer(to: &idTuple) { idPtr in + platform_wallet_topup_identity_with_existing_asset_lock_signer( + handle, + &outPoint, + idPtr, + coreSigner.handle, + &newBalance + ) + } + } + try result.check() + return newBalance + }.value + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift index 7ab03ad21c3..925c8bbba0f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift @@ -14,6 +14,7 @@ struct TransitionCategoryView: View { return [ ("identityCreate", "Create Identity", "Create a new identity with initial credits"), ("identityTopUp", "Top Up Identity", "Add credits to an existing identity"), + ("identityTopUpResume", "Top Up Identity (Resume)", "Recover a stuck top-up from a tracked asset lock"), ("identityUpdate", "Update Identity", "Update identity properties and keys"), ("identityCreditTransfer", "Transfer Credits", "Transfer credits between identities"), ("identityCreditWithdrawal", "Withdraw Credits", "Withdraw credits to a Dash address") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index 0fbf1fda1b4..ba8e77bf4ea 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -493,6 +493,9 @@ struct TransitionDetailView: View { case "identityTopUp": return try await executeIdentityTopUp(sdk: sdk) + case "identityTopUpResume": + return try await executeIdentityTopUpResume(sdk: sdk) + case "identityUpdate": return try await executeIdentityUpdate(sdk: sdk) @@ -608,13 +611,115 @@ struct TransitionDetailView: View { ] } + /// Minimum Core-side funding for a managed top-up, in duffs. Mirrors the + /// Rust `MIN_TOP_UP_DUFFS` guard so the UI blocks a sub-floor amount + /// *before* any asset lock is broadcast — a lock below Platform's + /// processing-start minimum is accepted by Core but rejected by Platform, + /// stranding the funds in a lock that can't complete the top-up. + private static let minTopUpDuffs: UInt64 = 50_000 + + /// Top up the selected identity by building a new Core asset lock from + /// the owning wallet's balance (managed path — the credit-output key + /// stays behind the Keychain resolver and never crosses FFI as bytes). + /// Mirrors `executeIdentityUpdate`'s wallet resolution and + /// `executeIdentityCreditTransfer`'s local balance update. + @MainActor private func executeIdentityTopUp(sdk: SDK) async throws -> Any { guard !selectedIdentityId.isEmpty, - identities.contains(where: { $0.identityIdBase58 == selectedIdentityId }) else { + let ownerIdentity = identities.first(where: { $0.identityIdBase58 == selectedIdentityId }) else { throw SDKError.invalidParameter("No identity selected") } + guard let walletId = ownerIdentity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + throw SDKError.invalidParameter( + "Identity has no wallet linkage; cannot fund the top-up" + ) + } + guard let amountString = formInputs["amount"], + let amountDuffs = UInt64(amountString.trimmingCharacters(in: .whitespaces)) else { + throw SDKError.invalidParameter("Invalid amount (duffs)") + } + guard amountDuffs >= Self.minTopUpDuffs else { + throw SDKError.invalidParameter( + "Amount must be at least \(Self.minTopUpDuffs) duffs; a smaller top-up would be rejected by Platform and strand the funds" + ) + } + let accountIndex = UInt32( + formInputs["accountIndex"]?.trimmingCharacters(in: .whitespaces) ?? "0" + ) ?? 0 + + let newBalance = try await wallet.topUpIdentityWithFunding( + identityId: ownerIdentity.identityId, + amountDuffs: amountDuffs, + accountIndex: accountIndex + ) - throw SDKError.notImplemented("Identity top-up requires proper Identity handle conversion") + PersistentIdentity.updateBalance( + in: modelContext, identityId: ownerIdentity.identityId, balance: newBalance + ) + try? modelContext.save() + + return [ + "identityId": ownerIdentity.identityIdBase58, + "newBalance": newBalance, + "fundedDuffs": amountDuffs, + "accountIndex": accountIndex, + "message": "Identity topped up successfully", + ] + } + + /// Recover a stuck top-up by consuming an already-tracked Core asset lock + /// by outpoint (crash-recovery path). Same managed signing as + /// `executeIdentityTopUp`. The txid is entered in display order and + /// reversed to raw wire order here, matching `OutPointFFI.txid` (same + /// convention as `CreateIdentityView.parseOutPointHex`). + @MainActor + private func executeIdentityTopUpResume(sdk: SDK) async throws -> Any { + guard !selectedIdentityId.isEmpty, + let ownerIdentity = identities.first(where: { $0.identityIdBase58 == selectedIdentityId }) else { + throw SDKError.invalidParameter("No identity selected") + } + guard let walletId = ownerIdentity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + throw SDKError.invalidParameter( + "Identity has no wallet linkage; cannot resume the top-up" + ) + } + let txidHex = (formInputs["outPointTxid"] ?? "").trimmingCharacters(in: .whitespaces) + guard txidHex.count == 64, let txidForward = Data(hexString: txidHex) else { + throw SDKError.invalidParameter("Asset lock txid must be 64 hex characters (32 bytes)") + } + let txidRaw = Data(txidForward.reversed()) + let vout = UInt32( + formInputs["outPointVout"]?.trimmingCharacters(in: .whitespaces) ?? "0" + ) ?? 0 + + do { + let newBalance = try await wallet.resumeTopUpWithAssetLock( + identityId: ownerIdentity.identityId, + outPointTxid: txidRaw, + outPointVout: vout + ) + PersistentIdentity.updateBalance( + in: modelContext, identityId: ownerIdentity.identityId, balance: newBalance + ) + try? modelContext.save() + return [ + "identityId": ownerIdentity.identityIdBase58, + "newBalance": newBalance, + "message": "Stuck top-up recovered successfully", + ] + } catch { + // Classify the opaque "already consumed" consensus rejection into a + // friendly message (mirrors the DIP-15 reclaim classifier) rather + // than surfacing the raw SDK error. + let desc = String(describing: error).lowercased() + if (desc.contains("already") && desc.contains("consumed")) + || desc.contains("already completely used") { + throw SDKError.invalidParameter("Asset lock already consumed — nothing to resume") + } + throw error + } } /// Generic-builder IdentityUpdate handler. From 24328aacc0a80019386fff760ad3d81200a15e68 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 11 Jul 2026 20:48:38 +0700 Subject: [PATCH 4/6] fix: correct top-up floor to 50500 duffs + address review feedback - MIN_TOP_UP_DUFFS 50000 -> 50500. The active v1 fee calc (IdentityTopUpTransition::calculate_min_required_fee_v1) adds identity_topup_base_cost (500000 credits = 500 duffs) on top of the 50000-duff asset-lock floor, so the real consensus minimum is 50500 duffs (enforced on-chain by tx_out_credit_value < required_balance). Amounts 50000-50499 previously passed the guard, built and broadcast a real Core lock, and were then stranded by Platform -- the exact funds-safety trap the guard exists to prevent. Bumped in the FFI const, the Swift UI gate, the catalog help, and the spec. - Add the `*out_new_balance = 0` sentinel before fallible work in the funding export, matching its FromExistingAssetLock sibling, so an early-return leaves a defined value. - Gate the example-app resume flow behind a confirmation dialog summarizing the target identity + outpoint. A tracked asset lock is not bound to an identity; resume directs it at whatever identity is selected, and a stray lock landing on the wrong self-owned identity is not undoable. Addresses PR #4093 review (thepastaclaw, CodeRabbit). Refs #4092 Co-Authored-By: Claude Opus 4.8 --- docs/ios-managed-topup/SPEC.md | 33 +++++++++-------- .../src/identity_top_up.rs | 23 ++++++++---- .../Models/StateTransitionDefinitions.swift | 2 +- .../Views/TransitionDetailView.swift | 36 +++++++++++++++++-- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/docs/ios-managed-topup/SPEC.md b/docs/ios-managed-topup/SPEC.md index c20bf4ca3cc..7eaf083fb4e 100644 --- a/docs/ios-managed-topup/SPEC.md +++ b/docs/ios-managed-topup/SPEC.md @@ -212,26 +212,29 @@ The `identityTopUp` **catalog key** stays — it now routes to the new managed ` ### 2.5 Minimum top-up amount floor (funds-safety — requires a decision) `amount_duffs == 0` is guarded in the export, but that is **not** the real floor. Platform enforces a -consensus-side minimum for `IdentityTopUp`: -`required_asset_lock_duff_balance_for_processing_start_for_identity_top_up = 50_000` duffs -(`dpp_state_transition_versions/v3.rs:21`, checked in -`identity_topup_transition/state_transition_estimated_fee_validation.rs:35`). An amount **between dust -(~a few hundred duffs) and ~50_000 + fees** does the dangerous thing: it **builds and broadcasts a -real asset lock (spending Core UTXOs), which Core accepts, then Platform rejects** — leaving the user's -DASH committed in a tracked asset lock that can never complete this top-up. This is the same footgun -class as the DIP-15 invitations sub-floor defect (`MIN_INVITATION_DUFFS`). +consensus-side minimum for `IdentityTopUp` via `IdentityTopUpTransition::calculate_min_required_fee`. +Under the **active fee version (v1)** — `calculate_min_required_fee_on_identity_top_up_transition: 1` +in `dpp_state_transition_versions/v3.rs:31`, used by `STATE_TRANSITION_VERSIONS_V3` (protocol v11+) — +that minimum is `identity_topup_base_cost` (500_000 credits = **500 duffs**) **plus** +`required_asset_lock_duff_balance_for_processing_start_for_identity_top_up` (**50_000 duffs**) = +**50_500 duffs** (`identity_topup_transition/state_transition_estimated_fee_validation.rs:39-50`; +`CREDITS_PER_DUFF = 1000`). Enforced on-chain by `tx_out_credit_value < required_balance` in +`rs-drive-abci/.../identity_top_up/transform_into_action/v0/mod.rs:59`. An amount **between dust and +50_500 duffs** does the dangerous thing: it **builds and broadcasts a real asset lock (spending Core +UTXOs), which Core accepts, then Platform rejects** — leaving the user's DASH committed in a tracked +asset lock that can never complete this top-up. This is the same footgun class as the DIP-15 +invitations sub-floor defect (`MIN_INVITATION_DUFFS`). (The bare 50_000 value is only the fee-v0 +minimum; the v0→v1 fee-calc bump adds the base cost.) **Decided: UI gate + export guard (defense in depth).** - **Export guard:** in `platform_wallet_top_up_identity_with_funding_signer`, replace the `amount_duffs == 0` check with `amount_duffs < MIN_TOP_UP_DUFFS → ErrorInvalidParameter`. Define - `const MIN_TOP_UP_DUFFS: u64 = 50_000;` in the FFI with a comment pointing at the consensus source - (`required_asset_lock_duff_balance_for_processing_start_for_identity_top_up`). This guards **all** - callers of the export, including Android. (The `FromExistingAssetLock` export has no amount param and - is unaffected.) + `const MIN_TOP_UP_DUFFS: u64 = 50_500;` in the FFI (the active v1 fee minimum — base cost + asset-lock + floor; see above). This guards **all** callers of the export, including Android. (The + `FromExistingAssetLock` export has no amount param and is unaffected.) - **UI gate:** in the example app, disable submit + show a "minimum …" hint until the entered amount ≥ the floor (mirroring `CreateIdentityView.currentMinFundingDuffs`), so no sub-floor lock is ever - broadcast. UI default should carry fee headroom above the bare 50_000 (exact default an - implementation detail; the hard floor is the consensus 50_000). + broadcast. The hard floor is the consensus 50_500 duffs. ### 2.6 Crash-recovery / stuck-lock resume for top-up (funds-safety — requires a decision) @@ -291,7 +294,7 @@ Orchestrator IdentityWallet::top_up_identity_with_funding( - **Insufficient Core UTXO balance** in the chosen account → orchestrator errors; surfaced via `PlatformWalletFFIResult` → Swift throw → UI error panel. (Same as registration.) -- **Sub-floor amount (dust < amount < ~50_000 duffs + fees)** → the asset lock is **accepted Core-side +- **Sub-floor amount (dust < amount < 50_500 duffs)** → the asset lock is **accepted Core-side and broadcast**, then **rejected Platform-side**, leaving funds committed in a stuck tracked lock. This is the funds-safety gap addressed by the §2.5 floor — NOT a clean Core-side failure. (Contrary to registration's UI, which *does* gate on a computed `currentMinFundingDuffs` floor in diff --git a/packages/rs-platform-wallet-ffi/src/identity_top_up.rs b/packages/rs-platform-wallet-ffi/src/identity_top_up.rs index 889477788dc..fb227870ecc 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_top_up.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_top_up.rs @@ -144,14 +144,20 @@ pub unsafe extern "C" fn platform_wallet_top_up_from_addresses_with_signer( /// Minimum asset-lock funding for a Core-funded identity top-up, in duffs. /// -/// Platform will not start processing an `IdentityTopUp` whose asset lock -/// funds less than +/// Platform rejects an `IdentityTopUp` whose asset-lock output value (in +/// credits) is below `IdentityTopUpTransition::calculate_min_required_fee`. +/// Under the active fee version (v1) that minimum is +/// `identity_topup_base_cost` (500_000 credits = 500 duffs) **plus** /// `required_asset_lock_duff_balance_for_processing_start_for_identity_top_up` -/// (currently 50_000 duffs). Below that, a lock built and broadcast here is -/// accepted by Core (spending real UTXOs) but rejected by Platform, stranding -/// the funds in a lock that can never complete the top-up. Reject sub-floor -/// amounts up front so no such lock is ever broadcast. -const MIN_TOP_UP_DUFFS: u64 = 50_000; +/// (50_000 duffs) — i.e. 50_500 duffs. Below that, a lock built and broadcast +/// here is accepted by Core (spending real UTXOs) but rejected by Platform, +/// stranding the funds in a lock that can never complete the top-up. Reject +/// sub-floor amounts up front so no such lock is ever broadcast. +/// +/// (The bare 50_000 asset-lock floor alone was the fee-v0 minimum; the active +/// v1 calc — `STATE_TRANSITION_VERSIONS_V3`, protocol v11+ — adds the base +/// cost, so this constant must include it.) +const MIN_TOP_UP_DUFFS: u64 = 50_500; /// Top up an existing identity's credit balance by building and /// broadcasting a **new Core asset lock** (the same funding mechanism as @@ -201,6 +207,9 @@ pub unsafe extern "C" fn platform_wallet_top_up_identity_with_funding_signer( check_ptr!(identity_id); check_ptr!(core_signer_handle); check_ptr!(out_new_balance); + // FFI-safe sentinel before any fallible work, matching the sibling + // `platform_wallet_topup_identity_with_existing_asset_lock_signer`. + *out_new_balance = 0; if amount_duffs < MIN_TOP_UP_DUFFS { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Models/StateTransitionDefinitions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Models/StateTransitionDefinitions.swift index c757d731995..9e94eea237d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Models/StateTransitionDefinitions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Models/StateTransitionDefinitions.swift @@ -57,7 +57,7 @@ public struct TransitionDefinitions { label: "Amount (duffs)", required: true, placeholder: "100000", - help: "Core-side funding amount in duffs (minimum 50000). A new asset lock is built from the wallet's balance." + help: "Core-side funding amount in duffs (minimum 50500). A new asset lock is built from the wallet's balance." ), TransitionInput( name: "accountIndex", diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index ba8e77bf4ea..7f0b7cc1d3d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -27,6 +27,11 @@ struct TransitionDetailView: View { @State private var showResult = false @State private var resultText = "" @State private var isError = false + /// Gates the resume-from-tracked-lock confirmation. A tracked asset lock + /// isn't bound to an identity — resume directs it at whatever identity is + /// selected, and a stray lock landing on the wrong (self-owned) identity + /// is not undoable — so this flow requires an explicit confirm. + @State private var showResumeConfirm = false // Dynamic form inputs @State private var formInputs: [String: String] = [:] @@ -200,6 +205,23 @@ struct TransitionDetailView: View { .foregroundColor(.white) .cornerRadius(10) .disabled(!enabled) + .confirmationDialog( + "Resume top-up?", + isPresented: $showResumeConfirm, + titleVisibility: .visible + ) { + Button("Top Up") { + Task { await performTransition() } + } + Button("Cancel", role: .cancel) {} + } message: { + let txid = (formInputs["outPointTxid"] ?? "").trimmingCharacters(in: .whitespaces) + let vout = (formInputs["outPointVout"] ?? "0").trimmingCharacters(in: .whitespaces) + Text( + "Consume asset lock \(txid.isEmpty ? "?" : txid):\(vout) into identity " + + "\(selectedIdentityId)? This credits the selected identity and cannot be undone." + ) + } } private var resultView: some View { @@ -456,6 +478,13 @@ struct TransitionDetailView: View { // MARK: - Transition Execution private func executeTransition() { + // Resume directs a tracked asset lock at whatever identity is selected; + // a stray lock landing on the wrong (self-owned) identity is not + // undoable, so require explicit confirmation before firing. + if transitionKey == "identityTopUpResume" { + showResumeConfirm = true + return + } Task { await performTransition() } @@ -613,10 +642,11 @@ struct TransitionDetailView: View { /// Minimum Core-side funding for a managed top-up, in duffs. Mirrors the /// Rust `MIN_TOP_UP_DUFFS` guard so the UI blocks a sub-floor amount - /// *before* any asset lock is broadcast — a lock below Platform's - /// processing-start minimum is accepted by Core but rejected by Platform, + /// *before* any asset lock is broadcast — a lock below Platform's minimum + /// required fee (active v1 calc: 500-duff base cost + 50_000-duff asset-lock + /// floor = 50_500 duffs) is accepted by Core but rejected by Platform, /// stranding the funds in a lock that can't complete the top-up. - private static let minTopUpDuffs: UInt64 = 50_000 + private static let minTopUpDuffs: UInt64 = 50_500 /// Top up the selected identity by building a new Core asset lock from /// the owning wallet's balance (managed path — the credit-output key From e489cdfda46ae72156e72ed3ba9b2dc7fd85108a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 12:19:56 +0700 Subject: [PATCH 5/6] docs: drop internal design spec from the PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove docs/ios-managed-topup/SPEC.md — it was the research/review design doc, not intended to ship in the repo. Net effect on the PR: the spec is no longer added (add + remove cancels in the base...head diff). Refs #4092 Co-Authored-By: Claude Opus 4.8 --- docs/ios-managed-topup/SPEC.md | 378 --------------------------------- 1 file changed, 378 deletions(-) delete mode 100644 docs/ios-managed-topup/SPEC.md diff --git a/docs/ios-managed-topup/SPEC.md b/docs/ios-managed-topup/SPEC.md deleted file mode 100644 index 7eaf083fb4e..00000000000 --- a/docs/ios-managed-topup/SPEC.md +++ /dev/null @@ -1,378 +0,0 @@ -# Spec — Wire iOS managed identity top-up from asset lock (#4092) - -Branch: `feat/ios-managed-identity-topup` (worktree `platform.worktrees/ios-topup`), based on `v4.1-dev` (`f7d7c8d348`). -Target: standalone PR against `v4.1-dev`. - -## 1. Problem - -Topping up an existing identity from a Core asset lock has **three fragmented paths** today -(see issue #4092 for the full table), and iOS has **no one-call managed path**: - -- The DPP-SDK primitive `dash_sdk_identity_topup_with_instant_lock` is IS-only, takes a - caller-built InstantSend proof + a **raw asset-lock private key**, and — though wired through the - Swift SDK chain `topUpIdentity → identityTopUp` — has **zero app callers**. The example-app - handler `executeIdentityTopUp` is a `notImplemented` stub. -- The managed **from-wallet-balance** export `platform_wallet_top_up_identity_with_funding_signer` - exists only in the Kotlin-SDK PR #3999 (Android-only). -- The managed **from-existing-asset-lock** export exists only on the DIP-15 invitations branch. - -The correct "from asset lock" abstraction is the **managed orchestrator** -`IdentityWallet::top_up_identity_with_funding`, which is **already on `v4.1-dev`** -(`packages/rs-platform-wallet/src/wallet/identity/network/registration.rs:388`) and does the full -lifecycle: resolve/build the asset lock, **IS→CL fallback**, retries, persist the new balance. - -The only missing pieces on `v4.1-dev` are (a) the thin FFI export over that orchestrator, and -(b) the Swift SDK wrapper + example-app UI to call it. - -## 2. Chosen approach - -Extract #3999's already-written FFI export into `v4.1-dev` verbatim, then mirror the **existing, -proven** iOS registration-from-funding path for top-up. Nothing is reimplemented; the orchestrator, -the `MnemonicResolver` core-signer plumbing, and the cbindgen→xcframework header flow all already -exist and are exercised by `registerIdentityWithFunding` on iOS today. - -### 2.1 Rust FFI exports (canonical, owned by this PR) - -Two managed exports, so top-up has the same **register + resume** pair registration already has: - -- **`platform_wallet_top_up_identity_with_funding_signer`** (`FromWalletBalance`) — the primary path, - build a new lock from wallet balance. -- **`platform_wallet_topup_identity_with_existing_asset_lock_signer`** (`FromExistingAssetLock`) — the - crash-recovery / stuck-lock path, consume an already-tracked lock. Extracted **verbatim** from the - DIP-15 branch (`feat/dip15-dashpay-invitations`, `identity_registration_funded_with_signer.rs:268`, - where it is the invitation-reclaim primitive); it wraps - `top_up_identity_with_funding(&identity_id, AssetLockFunding::FromExistingAssetLock { out_point }, - &asset_lock_signer, None)` and returns the new balance via `out_new_balance`. Signature: - `(wallet_handle, out_point: *const OutPointFFI, identity_id: *const [u8;32], - core_signer_handle: *mut MnemonicResolverHandle, out_new_balance: *mut u64)`. Place it in - `identity_registration_funded_with_signer.rs` **exactly where DIP-15 has it** (next to the - registration resume twin `platform_wallet_resume_identity_with_existing_asset_lock_signer`, whose - `OutPointFFI` import + marshalling it reuses) — so #4041's later rebase dedups cleanly too. - -#### FromWalletBalance export - -Add `platform_wallet_top_up_identity_with_funding_signer` to -`packages/rs-platform-wallet-ffi/src/identity_top_up.rs` — **exactly where #3999 places it**, next to -the existing `platform_wallet_top_up_from_addresses_with_signer`. Take the **whole `7a1d04792f` -version of the file** so the merge is byte-exact (the sibling `top_up_from_addresses_with_signer` -function is already identical between #3999 and `v4.1-dev` HEAD — zero diff — so this is a clean -wholesale adoption). The delta is: a module-doc-header rewrite, **one merged import line** (adding -`MnemonicResolverCoreSigner, MnemonicResolverHandle` to the existing -`rs_sdk_ffi::{SignerHandle, VTableSigner}`) **plus one new** `use platform_wallet::AssetLockFunding;`, -and the ~100-line function (`Identifier` is already imported). All dependencies already compile on -`v4.1-dev` — the registration twin `identity_registration_funded_with_signer.rs` uses the identical set. - -Signature (verbatim from #3999): - -```rust -pub unsafe extern "C" fn platform_wallet_top_up_identity_with_funding_signer( - wallet_handle: Handle, - identity_id: *const [u8; 32], - amount_duffs: u64, - account_index: u32, - core_signer_handle: *mut MnemonicResolverHandle, - out_new_balance: *mut u64, -) -> PlatformWalletFFIResult -``` - -Body: guard the pointers + `amount_duffs != 0`; round-trip `core_signer_handle` through `usize` -(so the spawned future's capture is `Send`); `PLATFORM_WALLET_STORAGE.with_item(...)` → -`block_on_worker(async { identity_wallet.top_up_identity_with_funding(&identity_id, -AssetLockFunding::FromWalletBalance { amount_duffs, account_index }, &asset_lock_signer, None).await })`; -write `*out_new_balance`. Note vs. registration: **no** identity-key signer and **no** pubkeys array — -the `IdentityTopUp` transition is signed entirely by the asset lock's Core-side key, so only -`core_signer_handle` is needed. - -Add the `MIN_TOP_UP_DUFFS` guard (§2.5) to this export's parameter checks. - -#### FromExistingAssetLock export - -Append `platform_wallet_topup_identity_with_existing_asset_lock_signer` (body above) to -`identity_registration_funded_with_signer.rs`, taking the DIP-15 function body. Its symbols are present -on v4.1-dev **except `Identifier`** — the registration resume twin never takes an `identity_id` input, -so the file has no `Identifier` import. **Add `use dpp::prelude::Identifier;`** or the verbatim body -fails `error[E0433]`. Everything else (`OutPointFFI`, `AssetLockFunding`, `MnemonicResolverCoreSigner`, -`MnemonicResolverHandle`, `PLATFORM_WALLET_STORAGE`, `block_on_worker`, `check_ptr!`, -`unwrap_option_or_return!`, `dashcore::{OutPoint, Txid}`, `Handle`, `PlatformWalletFFIResult`) is -already imported. It has no amount parameter (the lock's value is fixed), so the §2.5 floor does not -apply to it. - -**Rewrite the doc header.** DIP-15's header describes the invitation-voucher/OP_RETURN-burn reclaim -use case, which is misleading on generic v4.1-dev top-up code. Replace it with a self-contained -description of the stuck-lock / crash-recovery top-up (per the timeless-comments rule — no voucher, -no invitation narrative). - -Header auto-surfaces for both: `platform-wallet-ffi` is in `build_ios.sh`'s `INCLUDED_CRATES`; cbindgen -regenerates the header at build time (not committed) and the umbrella `DashSDKFFI.h` picks it up. -**No manual C-header work.** - -### 2.2 Swift SDK wrappers - -Add two methods to `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift` -— the register/resume pair for top-up, mirroring `registerIdentityWithFunding` (`:3549`) + -`resumeIdentityWithAssetLock` (`:3656`) but **simpler** (no `KeychainSigner`, no pubkeys, return a -balance instead of a `ManagedIdentity`): - -```swift -public func topUpIdentityWithFunding( // FromWalletBalance - identityId: Data, // 32 bytes - amountDuffs: UInt64, - accountIndex: UInt32 -) async throws -> UInt64 // new credit balance - -public func resumeTopUpWithAssetLock( // FromExistingAssetLock (crash recovery) - identityId: Data, // 32 bytes - outPoint: Data // 36 bytes: 32-byte txid + u32 vout -) async throws -> UInt64 // new credit balance -``` - -`resumeTopUpWithAssetLock` mirrors `resumeIdentityWithAssetLock` (`:3656`) exactly — same `OutPointFFI` -marshalling (`:3705`: `var outPoint = OutPointFFI(txid: txidTuple, vout: ...)`), same -`withExtendedLifetime(coreSigner)` + `Task.detached`, calling the new -`platform_wallet_topup_identity_with_existing_asset_lock_signer` and returning `outNewBalance`. - -`topUpIdentityWithFunding` implementation, mirroring the registration scaffolding: -- `let coreSigner = MnemonicResolver()` (default `WalletStorage()`), as registration does at `:3570`. -- Marshal `identityId` (32 bytes) into a pointer to a 32-byte tuple for the `*const [u8;32]` param - **by copying the sibling `topUpFromAddresses` wrapper in the same file** - (`ManagedPlatformWallet.swift:502-551`): build a zeroed 32-byte tuple, fill from - `identityId.prefix(32)`, pass via `withUnsafePointer(to: &idTuple) { idPtr in ... }`. This is the - exact `*const [u8;32]` **input** precedent (sharper than registration's `*mut` out-param). -- `var outNewBalance: UInt64 = 0`. -- Run the blocking FFI call in `Task.detached(priority: .userInitiated)` wrapped in - `withExtendedLifetime(coreSigner)` (the registration doc at `:3586` warns `_ = coreSigner` is unsafe - under `-O`) — the `MnemonicResolver` ctx is `passUnretained`, so it MUST outlive the call. -- `try result.check()`; return `outNewBalance`. - -### 2.3 Example-app UI — implement `executeIdentityTopUp` - -Wire the stub at `TransitionDetailView.swift:611` following the managed pattern of -`executeIdentityUpdate` (`:644`): -1. Resolve `ownerIdentity` from `selectedIdentityId`; resolve its wallet via - `ownerIdentity.wallet?.walletId` → `walletManager.wallet(for: walletId)`. -2. Read the funding **amount** (and optionally **accountIndex**) from `formInputs`. -3. `let newBalance = try await wallet.topUpIdentityWithFunding(identityId:amountDuffs:accountIndex:)`. -4. Update the local `PersistentIdentity` balance + `modelContext.save()` (as - `executeIdentityCreditTransfer` does at `:855`), return a `[String: Any]` result dict. - -Catalog change (`StateTransitionDefinitions.swift:49`): replace the single `assetLockProof` textarea -input (which the stub never read) with an **amount** field and an optional **`accountIndex`** field -(default `"0"`). The identity picker is already provided by `needsIdentitySelection`. - -**Amount is Core-side duffs, NOT platform credits.** Do *not* mirror `identityCreditTransfer`'s -`"Amount (credits)"` field (`StateTransitionDefinitions.swift:100`) — that would mislabel the unit by -the credit-per-duff scale. Mirror the funding-amount UX of `CreateIdentityView` (DASH-denominated text -field with `duffsPerDash` conversion), or at minimum label the field **"Amount (duffs)"**, and feed it -into `amount_duffs`. Enforce the minimum-amount floor (§2.5) before submit — the same shape as -`CreateIdentityView`'s `currentMinFundingDuffs` gate. - -Account selection is a plain numeric field defaulting to 0 (minimal, correct for the QA app). The -richer balance-validated BIP44-funding-account `Picker` from `CreateIdentityView` (`:617-636`) is -**not** copied — it's purely additive Swift-side work later (the FFI takes a raw `account_index: u32` -regardless), so deferring forces no rework. See §4. - -**Resume UI (crash-recovery path).** Add a minimal explicit entry to invoke `resumeTopUpWithAssetLock` -— an outpoint (txid hex + vout) input that recovers a stuck lock into the selected identity, styled -like the existing explicit-input QA flows (the two-step `TopUpIdentityFromAddressesView` already takes -raw hex inputs). Add a **confirmation step** ("top up identity X with lock Y?") before invoking — -resume directs a tracked lock at whatever identity is selected, and while cross-wallet consumption is -structurally impossible (the lock must be tracked by *this* wallet), a stray lock could still land on -the wrong *self-owned* identity, which is not undoable. The elaborate auto-detect-tracked-lock -coordinator that registration wraps around `resumeIdentityWithAssetLock` is **not** copied — the SDK -wrapper + explicit-outpoint UI is enough to exercise and prove funds recovery in the QA app; -auto-detection is a follow-up (§7). - -**Already-consumed error classification.** A double-resume (lock already burned on Platform) surfaces -as an opaque `PlatformWalletError::Sdk(...)` consensus rejection, not a friendly message — the same -class the DIP-15 reclaim had to special-case with an `isAlreadyConsumed("already completely used")` -classifier. The resume UI should detect and message this ("asset lock already consumed") rather than -dumping the raw SDK error. (A typed FFI error code is a follow-up; a narrow string-match classifier -matches the existing DIP-15 precedent.) - -### 2.4 Decision — RETIRE `dash_sdk_identity_topup_with_instant_lock` - -**Decided: RETIRE.** The managed path supersedes it and the app has zero callers, so remove the -raw-private-key surface rather than keep it. Blast radius (verified — fully self-contained, no -Kotlin/Android/Java/rs-sdk callers): - -- **Rust:** delete `packages/rs-sdk-ffi/src/identity/topup.rs` entirely — it contains only - `dash_sdk_identity_topup_with_instant_lock` (`:21`) and `..._and_wait` (`:94`). In - `packages/rs-sdk-ffi/src/identity/mod.rs`, remove **both** the `mod topup;` declaration (`:16`) - **and** the full 3-line re-export block (`:47-49`: `pub use topup::{ ... };`). **Keep** the rs-sdk - `TopUpIdentity` trait (`top_up_identity::TopUpIdentity`) — it's only `use`d locally by topup.rs, not - defined there, and the managed orchestrator uses it too. topup.rs's helper imports - (`create_instant_asset_lock_proof`, `parse_private_key`, `convert_put_settings`) are heavily used - elsewhere — deleting the file orphans nothing. -- **Swift:** delete `identityTopUp(...)` (`StateTransitionExtensions.swift:329`, incl. the C call at - `:352`) and its sole convenience caller `topUpIdentity(...)` (`:2596`). No other callers. -- Header regenerates automatically (symbols vanish from the cbindgen output). - -The `identityTopUp` **catalog key** stays — it now routes to the new managed `executeIdentityTopUp`. - -### 2.5 Minimum top-up amount floor (funds-safety — requires a decision) - -`amount_duffs == 0` is guarded in the export, but that is **not** the real floor. Platform enforces a -consensus-side minimum for `IdentityTopUp` via `IdentityTopUpTransition::calculate_min_required_fee`. -Under the **active fee version (v1)** — `calculate_min_required_fee_on_identity_top_up_transition: 1` -in `dpp_state_transition_versions/v3.rs:31`, used by `STATE_TRANSITION_VERSIONS_V3` (protocol v11+) — -that minimum is `identity_topup_base_cost` (500_000 credits = **500 duffs**) **plus** -`required_asset_lock_duff_balance_for_processing_start_for_identity_top_up` (**50_000 duffs**) = -**50_500 duffs** (`identity_topup_transition/state_transition_estimated_fee_validation.rs:39-50`; -`CREDITS_PER_DUFF = 1000`). Enforced on-chain by `tx_out_credit_value < required_balance` in -`rs-drive-abci/.../identity_top_up/transform_into_action/v0/mod.rs:59`. An amount **between dust and -50_500 duffs** does the dangerous thing: it **builds and broadcasts a real asset lock (spending Core -UTXOs), which Core accepts, then Platform rejects** — leaving the user's DASH committed in a tracked -asset lock that can never complete this top-up. This is the same footgun class as the DIP-15 -invitations sub-floor defect (`MIN_INVITATION_DUFFS`). (The bare 50_000 value is only the fee-v0 -minimum; the v0→v1 fee-calc bump adds the base cost.) - -**Decided: UI gate + export guard (defense in depth).** -- **Export guard:** in `platform_wallet_top_up_identity_with_funding_signer`, replace the - `amount_duffs == 0` check with `amount_duffs < MIN_TOP_UP_DUFFS → ErrorInvalidParameter`. Define - `const MIN_TOP_UP_DUFFS: u64 = 50_500;` in the FFI (the active v1 fee minimum — base cost + asset-lock - floor; see above). This guards **all** callers of the export, including Android. (The - `FromExistingAssetLock` export has no amount param and is unaffected.) -- **UI gate:** in the example app, disable submit + show a "minimum …" hint until the entered amount - ≥ the floor (mirroring `CreateIdentityView.currentMinFundingDuffs`), so no sub-floor lock is ever - broadcast. The hard floor is the consensus 50_500 duffs. - -### 2.6 Crash-recovery / stuck-lock resume for top-up (funds-safety — requires a decision) - -The orchestrator's `AssetLockFunding` enum already has a `FromExistingAssetLock { out_point }` variant -for resuming a lock that confirmed on Core but never reached Platform (app killed / network drop -between broadcast and submit). Registration exposes this symmetrically — -`platform_wallet_resume_identity_with_existing_asset_lock_signer` → Swift `resumeIdentityWithAssetLock` -(`ManagedPlatformWallet.swift:3656`), explicitly the "crash recovery" path. This PR wires **only** -`FromWalletBalance` for top-up, so an interrupted top-up strands the confirmed Core lock with **no -in-app recovery**. (The DIP-15 invitations branch already carries a `FromExistingAssetLock` top-up -export, so the primitive exists.) - -**Decided: include now.** Wire the `FromExistingAssetLock` top-up export -(`platform_wallet_topup_identity_with_existing_asset_lock_signer`, §2.1), the Swift -`resumeTopUpWithAssetLock` wrapper (§2.2), and a minimal explicit-outpoint resume UI (§2.3). This gives -top-up the same register+resume symmetry registration has and closes the stuck-lock funds-safety gap -in this PR. (Auto-detection of tracked stuck locks — registration's coordinator — remains a follow-up, -§7.) - -## 3. Interface / data flow - -``` -Example-app executeIdentityTopUp (TransitionDetailView.swift) - selectedIdentityId + formInputs[amount] (+ accountIndex) - │ resolve wallet via walletManager.wallet(for: ownerIdentity.wallet.walletId) - ▼ -Swift SDK ManagedPlatformWallet.topUpIdentityWithFunding(identityId, amountDuffs, accountIndex) - │ MnemonicResolver() core signer; Task.detached + withExtendedLifetime - ▼ -C ABI platform_wallet_top_up_identity_with_funding_signer(wallet, id, amt, acct, coreSigner, &out) - │ (cbindgen-generated header, auto-surfaced) - ▼ -Rust FFI PLATFORM_WALLET_STORAGE.with_item + block_on_worker - ▼ -Orchestrator IdentityWallet::top_up_identity_with_funding( - id, AssetLockFunding::FromWalletBalance{amount_duffs, account_index}, coreSigner, None) - │ build asset lock from wallet balance → IS→CL fallback → submit IdentityTopUp → persist balance - ▼ returns u64 new balance → out_new_balance → Swift UInt64 → UI balance update -``` - -## 4. Alternatives considered / rejected - -- **Wire the IS-only `dash_sdk_identity_topup_with_instant_lock` into the UI.** Rejected: pushes - asset-lock creation, proof acquisition, and raw-private-key handling onto the UI; no IS→CL fallback; - contradicts the managed security model (raw key never crosses FFI in the managed path). -- **Build a dedicated `TopUpFromCoreView` mirroring `CreateIdentityView`** (BIP44 funding-account - picker + a registration-style coordinator that survives sheet dismissal). Rejected for this PR as - over-scoped: the generic `TransitionDetailView` already handles async execution + result/error - presentation, and a numeric account field is adequate for the QA app. Can be a follow-up if a - production-grade UX is wanted. -- **Reimplement the orchestrator in the FFI layer.** Rejected — the orchestrator is already upstream; - the export must be a thin delegate (acceptance criterion). -- **Add the export to `identity_registration_funded_with_signer.rs`.** Rejected in favor of - `identity_top_up.rs` (where #3999 has it) to keep the eventual #3999 dedup a clean file-level match. - -## 5. Failure modes - -- **Insufficient Core UTXO balance** in the chosen account → orchestrator errors; surfaced via - `PlatformWalletFFIResult` → Swift throw → UI error panel. (Same as registration.) -- **Sub-floor amount (dust < amount < 50_500 duffs)** → the asset lock is **accepted Core-side - and broadcast**, then **rejected Platform-side**, leaving funds committed in a stuck tracked lock. - This is the funds-safety gap addressed by the §2.5 floor — NOT a clean Core-side failure. (Contrary - to registration's UI, which *does* gate on a computed `currentMinFundingDuffs` floor in - `CreateIdentityView` — registration is not floor-free at the UI layer.) -- **Interrupted top-up (app killed between Core confirmation and Platform submit)** → recover via the - §2.6 resume path (`resumeTopUpWithAssetLock`). -- **Resume of an untracked / consumed / foreign-wallet outpoint** → the orchestrator rejects cleanly - *before* any broadcast: untracked → `"not tracked"`; already-consumed locally → `"already Consumed — - nothing to resume"`; consumed on Platform → deterministic consensus rejection on resubmit. No - double-spend / double-credit / cross-wallet consumption is possible. The consumed-on-Platform case - is opaque (`Sdk(...)`) and should be classified in the resume UI (§2.3). -- **IS timeout** → orchestrator's IS→CL fallback handles it (the whole reason to use the managed path). -- **`MnemonicResolver` lifetime** → mandatory `withExtendedLifetime`; a dropped resolver mid-call is a - use-after-free. Directly mirrored from registration. -- **Wrong/unset identity index** → orchestrator returns `IdentityIndexNotSet`; propagated as an error. -- **Local balance bookkeeping failure after Platform accepted** → orchestrator logs, does not fail the - call (Platform already accepted). UI shows the returned balance. - -## 6. Test / verification plan - -Honest scoping: the rs-platform-wallet-ffi test suite is **entirely in-process** (contact requests, -handle lifecycle, null-pointer guards); **none** of it exercises the funded registration/top-up path, -because that needs a live Core+Platform network (asset locks, IS/CL). So "mirror the registration -coverage" realistically means: - -1. **Rust guard unit tests** (hermetic, mirrorable from the existing null-pointer tests): for the - `FromWalletBalance` export, assert `ErrorInvalidParameter` on `amount_duffs < MIN_TOP_UP_DUFFS` - (including the old `== 0` and a sub-floor value like `49_999`), and the null-pointer error on null - `identity_id` / `core_signer_handle` / `out_new_balance`. For the `FromExistingAssetLock` export, - assert the null-pointer errors on null `out_point` / `identity_id` / `core_signer_handle` / - `out_new_balance`. Cheap, real, regression-proof. -2. **Compile/link gates:** `cargo check -p platform-wallet-ffi`; then `./build_ios.sh` regenerates the - header and the SwiftExampleApp builds against the new symbol (this is the real "does it surface" - proof). -3. **Funded happy-path + IS→CL fallback: testnet UAT via SwiftExampleApp** (device/simulator). Top up a - funded identity by N duffs; assert the credit balance rises by ~N (minus fee). Consistent with how - the funded registration path and the DashPay funded flows are verified in this repo. Not a hermetic - unit test — stated explicitly, not implied. **Also exercise a near/sub-floor amount** (e.g. just - under the §2.5 floor) to confirm the UI floor blocks it *before* any asset lock is broadcast (so no - funds are stranded) — this pins the §2.5 mitigation, not just the `== 0` guard. -4. **Resume-path UAT (testnet):** deliberately interrupt a `FromWalletBalance` top-up after the Core - lock confirms but before Platform accepts (or reuse a lock the app already tracks), then recover it - via `resumeTopUpWithAssetLock` and assert the balance rises. Proves the stuck-lock recovery. -5. **Retire regression:** `cargo check -p rs-sdk-ffi` and the SwiftExampleApp build must still pass - after deleting `topup.rs` + the Swift `identityTopUp`/`topUpIdentity` wrappers — confirms nothing - else referenced them. -6. **Regression:** the existing two-step address route - (`topUpAddressFromAssetLock` → `topUpIdentityFromAddresses`) must still build and work — it is - independent of the new exports. - -## 7. Out of scope / follow-ups - -- **#3999 dedup:** after this merges, #3999 rebases and **drops its copy** of - `platform_wallet_top_up_identity_with_funding_signer` (`identity_top_up.rs` +110), keeping only its - Android JNI/Kotlin wiring. This PR owns the canonical export. Note in the PR description. -- **#4041 dedup:** the DIP-15 invitations PR similarly drops its copy of - `platform_wallet_topup_identity_with_existing_asset_lock_signer` once this lands. Note in both PRs. -- Dedicated production-grade `TopUpFromCoreView` with a balance-validated funding-account picker + a - registration-style coordinator, **and auto-detection of tracked stuck locks** to offer resume - proactively (this PR ships only the explicit-outpoint resume entry). - -**Ops note:** the *local* `v4.1-dev` ref is stale (points at `9f9092cc91`, v4.0.0); this branch sits -exactly on `origin/v4.1-dev` (`f7d7c8d348`) with zero commits. Diff/verify against `origin/v4.1-dev`, -not the local ref, or you'll see an 80k-line phantom diff. All spec line numbers match worktree HEAD. - -## 8. Acceptance criteria (from the issue) - -- [ ] `platform_wallet_top_up_identity_with_funding_signer` (`FromWalletBalance`) **and** - `platform_wallet_topup_identity_with_existing_asset_lock_signer` (`FromExistingAssetLock`) on - `v4.1-dev`, both delegating to `top_up_identity_with_funding` (no reimplementation). -- [ ] iOS tops up an existing identity from a Core asset lock in one managed call (build lock from - wallet balance, IS→CL fallback) via wired UI — `executeIdentityTopUp` no longer `notImplemented`. -- [ ] iOS can recover a stuck/tracked lock into an identity via `resumeTopUpWithAssetLock` (explicit - outpoint UI). -- [ ] Sub-floor amounts are rejected before broadcast (export `MIN_TOP_UP_DUFFS` guard + UI gate, §2.5). -- [ ] `dash_sdk_identity_topup_with_instant_lock`(+`_and_wait`) and its Swift wrappers are removed - (§2.4); rs-sdk-ffi + SwiftExampleApp still build. -- [ ] iOS + Android share the same FFI exports. -- [ ] Tests cover happy path + IS→CL fallback + resume + sub-floor rejection (per §6 scoping); two-step - address route still works. -- [ ] #3999 and #4041 dedup follow-ups noted (§7). From f44914240db0cf9bf6970a153605e852e8580aaf Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 12:26:37 +0700 Subject: [PATCH 6/6] docs(swift-example-app): update QA plan for managed identity top-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ID-13 ("Top up identity, builder path") was marked retired/notImplemented stub. This feature un-retires it: the builder path now runs a managed Core-funded top-up (executeIdentityTopUp -> topUpIdentityWithFunding -> platform_wallet_top_up_identity_with_funding_signer), building a new Core asset lock from wallet balance -- distinct from ID-05/ID-06, which spend already-funded Platform addresses. Flip it to 🧪 (builder-reachable), Tier=Common, and note the 50,500-duff floor and the removed raw-key path. Add ID-16 for the crash-recovery resume flow (executeIdentityTopUpResume -> resumeTopUpWithAssetLock -> platform_wallet_topup_identity_with_existing_asset_lock_signer), incl. the confirmation dialog and the clean-failure behavior for untracked/consumed/foreign outpoints. Extend the §6 Identity span to ID-01..16. Funded e2e for both is not yet run (testnet UAT pending) -- noted in the rows, not claimed as verified. Refs #4092 Co-Authored-By: Claude Opus 4.8 --- packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index df198d01e2e..91bb95cf9c0 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -155,9 +155,10 @@ The app is a full multi-wallet client: `PlatformWalletManager` holds N wallets c | ID-10 | Withdraw credits → Dash L1 address | Cross | Common | ✅ | withdrawal | `IdentityDetailView` → **Withdraw Credits** (sheet, `WithdrawCreditsView`) → `wallet.withdrawCredits` → `platform_wallet_withdraw_credits_with_signer` (keychain-signed). Destination L1 address typed in + validated against the wallet's network; amount validated against balance. Identity credit balance drops by amount + fee; L1 payout is pooled and processed asynchronously by the network (no immediate txid). Requires the identity to have a TRANSFER/CRITICAL key — newly-derived identities get one (keyId 3); older identities may need one added first via `ID-07`. (Also reachable via the *Settings → Platform State Transitions → Identity Credit Withdrawal* builder → `dash_sdk_identity_withdraw` with a test signer.) | | ID-11 | Transfer credits → Platform addresses | Platform | Common | ✅ | | `AddressQueriesView` → TransferIdentityToAddresses → `dash_sdk_identity_transfer_credits_to_addresses`. | | ID-12 | Update identity — disable key | Platform | Thorough | ✅ | | `KeyDetailView` (drill into a key from `KeysListView`) → **Key Status → Disable Key** → confirm (permanent / irreversible) → `wallet.updateIdentity(disablePublicKeyIds:)` → `platform_wallet_update_identity_with_signer` (keychain-signed). The button is gated to match consensus: it's hidden/disabled for master-level keys, the last enabled authentication key, and the last enabled transfer key (each shows an inline reason), and already-disabled keys show a read-only "Disabled" row. On success the identity's keys are re-fetched so the disabled badge appears, then the view pops back. A swipe-to-Disable shortcut on each eligible row in `KeysListView` routes into the same confirm + submit (reaches keys whose row tap opens `PrivateKeyView` instead of the detail). (Also reachable via *Settings → Platform State Transitions → Identity Update* (disable path) → `executeIdentityUpdate` with a test signer.) | -| ID-13 | Top up identity (builder path) | Cross | — | ➖ | | Retired — builder entry is a stub (`notImplemented`); identity top-up is covered by `ID-05`/`ID-06`. Kept here to document the stub; not seeded to the QA catalog. | +| ID-13 | Top up identity (Core-funded, builder path) | Cross | Common | 🧪 | | *Settings → Platform State Transitions → Top Up Identity* (`TransitionDetailView` `executeIdentityTopUp`) → `wallet.topUpIdentityWithFunding` → `platform_wallet_top_up_identity_with_funding_signer`. Builds + broadcasts a **new Core asset lock** from the wallet's balance (IS→CL fallback), unlike `ID-05`/`ID-06` which spend already-funded Platform addresses. Amount is Core **duffs**, gated `≥ 50,500` (Platform's min required fee for an IdentityTopUp) in both the UI and the FFI. Needs a **Funded Core wallet**. *(Replaces the retired `notImplemented` stub; the raw-key `dash_sdk_identity_topup_with_instant_lock` path was removed. Funded e2e — balance rises by ~the funded amount — not yet run; testnet UAT pending.)* | | ID-14 | Credit transfer between two on-device identities (A → B) | Platform | Thorough | ✅ | multiwallet | `IdentityDetailView` → **Transfer Credits** (`ID-04`), recipient = wallet B's identity (via `RecipientPickerView` — local / paste id / DPNS). Switch to B; verify its credit balance rose and A's dropped. Fully local round-trip. | | ID-15 | Same identity restored into two wallets (duplicate seed) | Platform | Uncommon | ✅ | multiwallet | Importing the same mnemonic as a second wallet derives the **same** identity; verify state stays consistent and balances are not double-counted or conflicting across the two wallets. | +| ID-16 | Resume stuck top-up (tracked asset lock) | Cross | Uncommon | 🧪 | | *Settings → Platform State Transitions → Top Up Identity (Resume)* (`TransitionDetailView` `executeIdentityTopUpResume`) → **confirmation dialog** (target identity + outpoint) → `wallet.resumeTopUpWithAssetLock` → `platform_wallet_topup_identity_with_existing_asset_lock_signer`. Crash-recovery for a `ID-13` top-up whose Core asset lock confirmed but whose IdentityTopUp never reached Platform: consumes the **already-tracked** lock by outpoint (txid hex in display order + vout). Enter the outpoint, confirm the dialog, and the selected identity's balance rises. Cancelling the dialog leaves the transition untouched; resuming an untracked / already-consumed / foreign-wallet outpoint fails cleanly with **no fund movement**. *(Funded e2e pending; testnet UAT.)* | ### 4.3 Platform Addresses (DIP-17 credit addresses) — `Domain=Address` @@ -341,7 +342,7 @@ Each row's **primary home** is its §4 section, but a few rows are cross-cutting **By category (§4 section):** - **Core / Wallet** — `CORE-01..23` -- **Identity** — `ID-01..15`, `SH-11` +- **Identity** — `ID-01..16`, `SH-11` - **Address** (DIP-17 platform addresses) — `ADDR-01..04`, `ADDR-06..09`, `ID-06`, `ID-08`, `ID-11` - **DPNS** — `DPNS-01..08` - **Voting** — `VOTE-01..07`, `DPNS-05`