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..fb227870ecc 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,206 @@ 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 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` +/// (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 +/// 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); + // 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, + "`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" + ); + } + } +} 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, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Models/StateTransitionDefinitions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Models/StateTransitionDefinitions.swift index 7b5c4fb627a..9e94eea237d 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 50500). 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..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() } @@ -493,6 +522,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 +640,116 @@ 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 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_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 + /// 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 + ) + + 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 - throw SDKError.notImplemented("Identity top-up requires proper Identity handle conversion") + 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. 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`