From 9f0b5d63813ac3a444565370831e2fa510961632 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 11 May 2026 11:25:35 +0200 Subject: [PATCH 1/8] feat(swift-sdk): platform-to-platform send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Send Dash" flow in SwiftExampleApp for sending platform-held credits to another platform address. - ManagedPlatformAddressWallet: new `transfer(accountIndex, outputs, changeAddress, signer)` bridge over the existing `platform_address_wallet_transfer` FFI. Picks the smallest set of inputs (largest-balance first) that covers the recipients plus a fee-buffer cushion, appends a change output back to a fresh unused HD address (so the protocol's "output ≠ input" rule holds), and uses `ReduceOutput(change_index)` so the recipient gets the exact amount and the change absorbs the on-chain fee. - ManagedPlatformAddressWallet: also exposes `restoreSyncState(...)` over the existing `platform_address_wallet_restore_sync_state` FFI so callers can clear the watermark and force a full BLAST rescan (needed to hydrate `account.address_credit_balance` after wallet restore before the in-memory state is queried by `auto_select_inputs`). - SendViewModel: new `.platformToPlatform` SendFlow case + amount-in- credits accessor; the `.platform` source now appears in the source list when destination is also `.platform`; `executeSend` maps the bech32m wire byte to the FFI discriminant and dispatches through the new wrapper. - SendTransactionView: looks up `ManagedPlatformAddressWallet` for the current wallet, resets the sync watermark + runs a fresh BLAST sync, picks the next unused HD address as the change destination (mirroring ReceiveAddressView's selection rule), and threads everything through `executeSend`. Fee display also uses the unit appropriate to the source (credits for platform, duffs for core). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ManagedPlatformAddressWallet.swift | 284 ++++++++++++++++++ .../Core/ViewModels/SendViewModel.swift | 77 ++++- .../Core/Views/SendTransactionView.swift | 45 ++- 3 files changed, 401 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 8343e2dba9a..65441d79499 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -21,6 +21,29 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { platform_address_wallet_destroy(handle).discard() } + /// Reset the incremental sync watermark. + /// + /// Passing `(0, 0, 0)` clears the provider's last-sync state so the + /// next `sync_balances` call does a full trunk/branch scan instead + /// of an incremental recent-query. Useful when the in-memory + /// `account.address_credit_balance` map is empty after a wallet + /// restore: the persisted provider state is in place but balances + /// only flow into the account via the `on_address_found` callback + /// during a sync, which incremental mode skips when nothing has + /// changed. + public func restoreSyncState( + syncHeight: UInt64, + syncTimestamp: UInt64, + lastKnownRecentBlock: UInt64 + ) throws { + try platform_address_wallet_restore_sync_state( + handle, + syncHeight, + syncTimestamp, + lastKnownRecentBlock + ).check() + } + // MARK: - Balance queries /// Platform address with its credit balance. @@ -64,4 +87,265 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { ) } } + + // MARK: - Transfer + + /// One recipient row for `transfer(...)`. + public struct TransferOutput: Sendable { + /// `0 = P2PKH`, `1 = P2SH`. Mirrors the Rust-side `PlatformAddress` discriminant. + public let addressType: UInt8 + /// 20-byte address hash. + public let hash: Data + /// Credits to send to this address. + public let credits: UInt64 + + public init(addressType: UInt8, hash: Data, credits: UInt64) { + self.addressType = addressType + self.hash = hash + self.credits = credits + } + } + + /// Updated balance for an address after a transfer. + public struct UpdatedBalance: Sendable { + public let addressType: UInt8 + public let hash: Data + public let balance: UInt64 + public let nonce: UInt32 + public let accountIndex: UInt32 + public let addressIndex: UInt32 + } + + /// Transfer credits between platform addresses. + /// + /// The `AddressFundsTransferTransition` protocol requires + /// `sum(inputs) == sum(outputs)` and forbids any output address from + /// also appearing as an input. This wrapper handles both invariants: + /// - Picks the smallest set of inputs (largest-balance first) that + /// covers `sum(outputs) + feeBuffer`. + /// - Routes the change to `changeAddress` if supplied — typically a + /// fresh, unused HD address pulled from the SwiftData address + /// pool (lowest `addressIndex` with `isUsed == false`, the same + /// selection rule the Receive screen uses). When `changeAddress` + /// is `nil` the wrapper falls back to reserving the smallest + /// non-recipient balance-bearing address — workable but it + /// accumulates change onto an existing address. + /// + /// Fee strategy is `ReduceOutput(change_index)` so each recipient + /// gets exactly its requested amount and the change output absorbs + /// the on-chain fee. + /// + /// The signer must be able to sign for the selected inputs — i.e. + /// the `KeychainSigner` resolves their derivation paths via + /// SwiftData + the wallet mnemonic (the `0xFF` branch in + /// `KeychainSigner.swift`). + @discardableResult + public func transfer( + accountIndex: UInt32, + outputs: [TransferOutput], + changeAddress: TransferOutput? = nil, + signer: KeychainSigner + ) async throws -> [UpdatedBalance] { + guard !outputs.isEmpty else { + throw PlatformWalletError.invalidParameter("outputs is empty") + } + + // Sum recipient amounts. + var totalRecipientCredits: UInt64 = 0 + for out in outputs { + guard out.hash.count == 20 else { + throw PlatformWalletError.walletOperation( + "TransferOutput.hash must be exactly 20 bytes (got \(out.hash.count))" + ) + } + totalRecipientCredits = totalRecipientCredits.addingReportingOverflow(out.credits).0 + } + + // Read available balances. We pick inputs from balance-bearing + // addresses; the change destination must differ from both the + // recipient set and the chosen inputs. + let feeBuffer: UInt64 = 100_000_000 // 0.001 DASH cushion + let recipientHashes = Set(outputs.map { $0.hash }) + + // Validate explicit change-address up front if the caller supplied one. + if let cc = changeAddress { + guard cc.hash.count == 20 else { + throw PlatformWalletError.walletOperation( + "changeAddress.hash must be exactly 20 bytes (got \(cc.hash.count))" + ) + } + guard !recipientHashes.contains(cc.hash) else { + throw PlatformWalletError.walletOperation( + "changeAddress collides with a recipient address." + ) + } + } + + let balanced = try addressesWithBalances() + .filter { $0.balance > 0 } + .filter { !recipientHashes.contains($0.hash) } + .sorted { $0.balance > $1.balance } + + // Pick the smallest set of inputs (largest-first) that covers + // recipients + fee buffer. When the caller hasn't supplied a + // dedicated change address, leave the last balance-bearing + // candidate aside so we can use it as change. + let reserveOneForChange = (changeAddress == nil) + var selectedInputs: [AddressBalance] = [] + var totalInputs: UInt64 = 0 + for b in balanced { + if reserveOneForChange && selectedInputs.count == balanced.count - 1 { break } + // Skip if this address is the explicit change destination. + if let cc = changeAddress, b.hash == cc.hash { continue } + selectedInputs.append(b) + totalInputs += b.balance + if totalInputs >= totalRecipientCredits + feeBuffer { break } + } + guard totalInputs >= totalRecipientCredits + feeBuffer else { + throw PlatformWalletError.walletOperation( + "Insufficient platform balance: have \(totalInputs) credits across \(selectedInputs.count) input(s), need at least \(totalRecipientCredits + feeBuffer)" + ) + } + let selectedHashes = Set(selectedInputs.map { $0.hash }) + + // Resolve the change destination: caller-supplied address wins + // (fresh HD address from the unused pool); otherwise reserve a + // balance-bearing address that's neither input nor recipient. + let resolvedChange: (addressType: UInt8, hash: Data) + if let cc = changeAddress { + guard !selectedHashes.contains(cc.hash) else { + throw PlatformWalletError.walletOperation( + "changeAddress collides with a selected input address." + ) + } + resolvedChange = (cc.addressType, cc.hash) + } else { + guard let fallback = balanced.first(where: { !selectedHashes.contains($0.hash) }) else { + throw PlatformWalletError.walletOperation( + "Could not find a wallet address distinct from inputs to use as the change destination — pass a fresh HD address via `changeAddress`." + ) + } + resolvedChange = (fallback.addressType, fallback.hash) + } + + // Marshal explicit inputs. + var ffiInputs: [ExplicitInputFFI] = [] + ffiInputs.reserveCapacity(selectedInputs.count) + for inp in selectedInputs { + let inputTuple = Self.hashTuple(from: inp.hash) + ffiInputs.append( + ExplicitInputFFI( + address: PlatformAddressFFI(address_type: inp.addressType, hash: inputTuple), + balance: inp.balance + ) + ) + } + + // Marshal recipient outputs. + var ffiOutputs: [AddressBalanceEntryFFI] = [] + ffiOutputs.reserveCapacity(outputs.count + 1) + for out in outputs { + let outTuple = Self.hashTuple(from: out.hash) + ffiOutputs.append( + AddressBalanceEntryFFI( + address: PlatformAddressFFI(address_type: out.addressType, hash: outTuple), + balance: out.credits, + nonce: 0, + account_index: 0, + address_index: 0 + ) + ) + } + + // Append the change output to the resolved change address — + // guaranteed to be distinct from every input and recipient + // (the protocol rejects outputs that also appear as inputs). + let changeAmount = totalInputs - totalRecipientCredits + let changeTuple = Self.hashTuple(from: resolvedChange.hash) + ffiOutputs.append( + AddressBalanceEntryFFI( + address: PlatformAddressFFI(address_type: resolvedChange.addressType, hash: changeTuple), + balance: changeAmount, + nonce: 0, + account_index: 0, + address_index: 0 + ) + ) + + // Fee strategy: take the fee out of the change output (last index) + // so recipients get their requested amounts unchanged. + let changeIndex = UInt16(ffiOutputs.count - 1) + let feeStrategy: [FeeStrategyStepFFI] = [ + FeeStrategyStepFFI(step_type: 1, index: changeIndex) // 1 = ReduceOutput + ] + + let handle = self.handle + let signerHandle = signer.handle + let inRows = ffiInputs + let outRows = ffiOutputs + let feeRows = feeStrategy + + return try await Task.detached(priority: .userInitiated) { () -> [UpdatedBalance] in + _ = signer + var changeset = PlatformAddressChangeSetFFI(updated: nil, updated_count: 0) + let result = inRows.withUnsafeBufferPointer { + inBp -> PlatformWalletFFIResult in + outRows.withUnsafeBufferPointer { outBp in + feeRows.withUnsafeBufferPointer { feeBp in + platform_address_wallet_transfer( + handle, + accountIndex, + INPUT_SELECTION_TYPE_EXPLICIT, + inBp.baseAddress, + UInt(inBp.count), + nil, + 0, + outBp.baseAddress, + UInt(outBp.count), + feeBp.baseAddress, + UInt(feeBp.count), + signerHandle, + &changeset + ) + } + } + } + try result.check() + + defer { platform_address_wallet_free_changeset(&changeset) } + + guard let updatedPtr = changeset.updated, changeset.updated_count > 0 else { + return [] + } + return (0.. ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) { + let b = [UInt8](data) + precondition(b.count == 20, "hashTuple requires a 20-byte Data") + return ( + b[0], b[1], b[2], b[3], b[4], + b[5], b[6], b[7], b[8], b[9], + b[10], b[11], b[12], b[13], b[14], + b[15], b[16], b[17], b[18], b[19] + ) + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index d07dc7a7d06..7be13790139 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -5,6 +5,7 @@ import SwiftDashSDK /// Available send flow types based on source and destination. enum SendFlow: Equatable { case coreToCore // Standard L1 payment + case platformToPlatform // Platform-address → platform-address transfer case platformToShielded // Shield credits case shieldedToShielded // Private transfer case shieldedToPlatform // Unshield @@ -13,6 +14,7 @@ enum SendFlow: Equatable { var displayName: String { switch self { case .coreToCore: return "Core Payment" + case .platformToPlatform: return "Platform Transfer" case .platformToShielded: return "Shield Credits" case .shieldedToShielded: return "Shielded Transfer" case .shieldedToPlatform: return "Unshield" @@ -23,6 +25,7 @@ enum SendFlow: Equatable { var iconName: String { switch self { case .coreToCore: return "arrow.right" + case .platformToPlatform: return "arrow.right" case .platformToShielded: return "lock.shield" case .shieldedToShielded: return "arrow.left.arrow.right" case .shieldedToPlatform: return "lock.open" @@ -33,6 +36,7 @@ enum SendFlow: Equatable { var estimatedFee: UInt64 { switch self { case .coreToCore: return 500_000 // ~0.005 DASH + case .platformToPlatform: return 100_000_000 // ~0.001 DASH in credits case .platformToShielded: return 200_000 case .shieldedToShielded: return 300_000 case .shieldedToPlatform: return 300_000 @@ -94,6 +98,13 @@ class SendViewModel: ObservableObject { return UInt64(double * 100_000_000) } + /// Amount in platform credits (1 DASH = 1e11 credits). + /// Used by platform-credit flows; `amount` (above) is duffs (1e8) for core. + var amountCredits: UInt64? { + guard let double = Double(amountString), double > 0 else { return nil } + return UInt64(double * 100_000_000_000) + } + var canSend: Bool { detectedFlow != nil && amount != nil && !isSending } @@ -113,6 +124,7 @@ class SendViewModel: ObservableObject { if shieldedBalance > 0 { sources.append(.shielded) } if platformBalance > 0 { sources.append(.platform) } case .platform: + if platformBalance > 0 { sources.append(.platform) } if shieldedBalance > 0 { sources.append(.shielded) } case .unknown: break @@ -146,6 +158,8 @@ class SendViewModel: ObservableObject { detectedFlow = .shieldedToShielded case (.orchard, .platform): detectedFlow = .platformToShielded + case (.platform, .platform): + detectedFlow = .platformToPlatform case (.platform, .shielded): detectedFlow = .shieldedToPlatform default: @@ -162,9 +176,13 @@ class SendViewModel: ObservableObject { platformState: AppState, wallet: PersistentWallet, coreWallet: ManagedCoreWallet?, + platformAddressWallet: ManagedPlatformAddressWallet?, + signer: KeychainSigner?, + senderAccountIndex: UInt32, + changeAddressRow: PersistentPlatformAddress?, modelContext: ModelContext ) async { - guard let flow = detectedFlow, let amount = amount else { return } + guard let flow = detectedFlow else { return } isSending = true error = nil @@ -178,12 +196,69 @@ class SendViewModel: ObservableObject { error = "Core wallet not available" return } + guard let amount = amount else { return } let address = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) let _ = try core.sendToAddresses( recipients: [(address: address, amountDuffs: amount)] ) successMessage = "Payment sent" + case .platformToPlatform: + guard let addressWallet = platformAddressWallet else { + error = "Platform address wallet not available" + return + } + guard let signer = signer else { + error = "Signer not available" + return + } + guard case .platform(let payload) = detectedAddressType else { + error = "Recipient is not a platform address" + return + } + guard payload.count == 21 else { + error = "Platform address must be 21 bytes (got \(payload.count))" + return + } + guard let credits = amountCredits else { + error = "Invalid amount" + return + } + // Map bech32m wire byte → FFI storage discriminant. + // See rs-dpp/src/address_funds/platform_address.rs:41-47. + let bech32mByte = payload[0] + let ffiAddressType: UInt8 + switch bech32mByte { + case 0xb0, 0x00: ffiAddressType = 0 // P2PKH + case 0x80, 0x01: ffiAddressType = 1 // P2SH + default: + error = "Unknown platform address type byte 0x\(String(bech32mByte, radix: 16))" + return + } + let hash = payload.subdata(in: 1..<21) + let output = ManagedPlatformAddressWallet.TransferOutput( + addressType: ffiAddressType, + hash: hash, + credits: credits + ) + // If the view passed a fresh unused HD address from the + // pool, use it as the dedicated change destination — + // matches the Receive screen's lowest-unused selection. + let changeOutput: ManagedPlatformAddressWallet.TransferOutput? = changeAddressRow.map { + ManagedPlatformAddressWallet.TransferOutput( + addressType: $0.addressType, + hash: $0.addressHash, + credits: 0 // value gets overwritten by the wrapper + ) + } + _ = try await addressWallet.transfer( + accountIndex: senderAccountIndex, + outputs: [output], + changeAddress: changeOutput, + signer: signer + ) + successMessage = "Platform transfer sent" + case .platformToShielded, .shieldedToShielded, .shieldedToPlatform, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 80ffe41816a..b73757ae52e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -140,7 +140,7 @@ struct SendTransactionView: View { HStack { Text("Estimated Fee:") Spacer() - Text("~\(formatBalance(fee))") + Text("~\(formatBalance(fee, unit: unit(for: viewModel.selectedSource)))") .foregroundColor(.secondary) } } @@ -164,15 +164,51 @@ struct SendTransactionView: View { // the Rust manager holds all wallets // and this view's `wallet` may not // be the one that was last created. - let coreWallet = try? walletManager - .wallet(for: wallet.walletId)? - .coreWallet() + let managed = walletManager.wallet(for: wallet.walletId) + let coreWallet = try? managed?.coreWallet() + let platformAddressWallet = try? managed?.platformAddressWallet() + // The in-memory account.address_credit_balance map + // is only hydrated by BLAST sync via the provider's + // on_address_found callback (provider.rs:578). + // After wallet restore the provider has a watermark + // set, which forces sync into incremental ("recent + // query") mode that skips known addresses — so the + // callback never fires and the account stays empty. + // Reset the watermark to force a full trunk/branch + // scan that re-emits on_address_found for every + // known address. + try? platformAddressWallet?.restoreSyncState( + syncHeight: 0, syncTimestamp: 0, lastKnownRecentBlock: 0 + ) + try? await walletManager.syncPlatformAddressNow() + // Pick the account holding the platform + // balance. Most wallets have a single + // PlatformPayment account (index 0); + // fallback handles that case too. + let senderAccountIndex = addressBalances + .first(where: { $0.balance > 0 })? + .accountIndex ?? 0 + // Mirror ReceiveAddressView's selection: + // the lowest-indexed HD address that has + // never been used. Used as the change + // destination so the transition doesn't + // collide with any input address. + let changeAddressRow = addressBalances + .filter { !$0.isUsed && $0.balance == 0 } + .min(by: { $0.addressIndex < $1.addressIndex }) + let signer = KeychainSigner( + modelContainer: modelContext.container + ) await viewModel.executeSend( sdk: sdk, shieldedService: shieldedService, platformState: platformState, wallet: wallet, coreWallet: coreWallet, + platformAddressWallet: platformAddressWallet, + signer: signer, + senderAccountIndex: senderAccountIndex, + changeAddressRow: changeAddressRow, modelContext: modelContext ) } @@ -273,6 +309,7 @@ struct SendTransactionView: View { private func flowColor(for flow: SendFlow) -> Color { switch flow { case .coreToCore: return .green + case .platformToPlatform: return .blue case .platformToShielded: return .purple case .shieldedToShielded: return .purple case .shieldedToPlatform: return .blue From 1ed514ae07b2f842271ad5c576cdf6de9344fd5c Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 11 May 2026 12:58:19 +0200 Subject: [PATCH 2/8] chore(swift-sdk): tighten send-flow API + error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up cleanups to the platform-to-platform send introduced in the preceding commit. No on-chain behaviour change; only the API surface and error paths get tighter. - Dedicated `ManagedPlatformAddressWallet.ChangeAddress` struct (`addressType + hash`) replaces the previous `TransferOutput(..., credits: 0)` smell at the change-address argument. - Output-credits sum now checks `addingReportingOverflow().overflow` and throws `PlatformWalletError.invalidParameter` instead of silently wrapping past `UInt64.max`. - Lift the 100M-credit fee cushion to a private static constant `feeBuffer` with a docstring explaining the heuristic. - `DashAddress.parse` drops `0x00` from the platform-address accept set — the bech32m wire bytes per DPP are exactly `0xb0` (P2PKH) and `0x80` (P2SH); `0x00` is the GroveDB *storage* byte and shouldn't appear in a `tdash1…` string. - `SendViewModel.executeSend` now fails fast with a user-readable "P2SH platform addresses aren't supported yet" message instead of letting the Rust FFI surface a raw "Unsupported address type" (rs-platform-wallet-ffi/src/platform_address_types.rs:42 rejects non-P2PKH). - `SendTransactionView` no longer swallows pre-send sync errors with `try?`; both `restoreSyncState` and `syncPlatformAddressNow` now surface through `viewModel.error` and abort the send. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ManagedPlatformAddressWallet.swift | 43 ++++++++++++++++--- .../Core/Models/DashAddress.swift | 10 +++-- .../Core/ViewModels/SendViewModel.swift | 22 +++++++--- .../Core/Views/SendTransactionView.swift | 27 ++++++++++-- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 65441d79499..8c92a9528a1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -106,6 +106,21 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { } } + /// Destination address for the change output the wrapper appends to + /// every transfer. Carries no `credits` field — the wrapper computes + /// the change amount itself as `sum(inputs) - sum(outputs)`. + public struct ChangeAddress: Sendable { + /// `0 = P2PKH`, `1 = P2SH`. Mirrors the Rust-side `PlatformAddress` discriminant. + public let addressType: UInt8 + /// 20-byte address hash. + public let hash: Data + + public init(addressType: UInt8, hash: Data) { + self.addressType = addressType + self.hash = hash + } + } + /// Updated balance for an address after a transfer. public struct UpdatedBalance: Sendable { public let addressType: UInt8 @@ -139,18 +154,27 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// the `KeychainSigner` resolves their derivation paths via /// SwiftData + the wallet mnemonic (the `0xFF` branch in /// `KeychainSigner.swift`). + /// Cushion held back so the change output stays positive after the on-chain + /// fee is deducted (the transfer uses `ReduceOutput(change_index)`). + /// Observed fee for a 1-input/2-output transition is ~6.5M credits; + /// this is intentionally an order of magnitude larger so estimation + /// drift doesn't force an "insufficient" failure in normal use. + private static let feeBuffer: UInt64 = 100_000_000 + @discardableResult public func transfer( accountIndex: UInt32, outputs: [TransferOutput], - changeAddress: TransferOutput? = nil, + changeAddress: ChangeAddress? = nil, signer: KeychainSigner ) async throws -> [UpdatedBalance] { guard !outputs.isEmpty else { throw PlatformWalletError.invalidParameter("outputs is empty") } - // Sum recipient amounts. + // Sum recipient amounts. Reject overflow rather than silently + // wrapping (would let a caller smuggle bogus amounts past the + // protocol's sum check). var totalRecipientCredits: UInt64 = 0 for out in outputs { guard out.hash.count == 20 else { @@ -158,13 +182,18 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { "TransferOutput.hash must be exactly 20 bytes (got \(out.hash.count))" ) } - totalRecipientCredits = totalRecipientCredits.addingReportingOverflow(out.credits).0 + let sum = totalRecipientCredits.addingReportingOverflow(out.credits) + if sum.overflow { + throw PlatformWalletError.invalidParameter( + "Output credits sum overflowed UInt64" + ) + } + totalRecipientCredits = sum.partialValue } // Read available balances. We pick inputs from balance-bearing // addresses; the change destination must differ from both the // recipient set and the chosen inputs. - let feeBuffer: UInt64 = 100_000_000 // 0.001 DASH cushion let recipientHashes = Set(outputs.map { $0.hash }) // Validate explicit change-address up front if the caller supplied one. @@ -199,11 +228,11 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { if let cc = changeAddress, b.hash == cc.hash { continue } selectedInputs.append(b) totalInputs += b.balance - if totalInputs >= totalRecipientCredits + feeBuffer { break } + if totalInputs >= totalRecipientCredits + Self.feeBuffer { break } } - guard totalInputs >= totalRecipientCredits + feeBuffer else { + guard totalInputs >= totalRecipientCredits + Self.feeBuffer else { throw PlatformWalletError.walletOperation( - "Insufficient platform balance: have \(totalInputs) credits across \(selectedInputs.count) input(s), need at least \(totalRecipientCredits + feeBuffer)" + "Insufficient platform balance: have \(totalInputs) credits across \(selectedInputs.count) input(s), need at least \(totalRecipientCredits + Self.feeBuffer)" ) } let selectedHashes = Set(selectedInputs.map { $0.hash }) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift index 388600199d4..9e0d7987dec 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift @@ -21,13 +21,17 @@ struct DashAddress { // Check HRP validity // Platform and Orchard share the same HRP: "dash" (mainnet) / "tdash" (testnet/regtest) - // Distinguished by type byte: 0x00/0xb0/0x80 = platform, 0x10 = orchard + // Distinguished by type byte: 0xb0/0x80 = platform, 0x10 = orchard let validHrp = (network == .mainnet) ? "dash" : "tdash" if hrp == validHrp && data.count == 21 { - // Platform address: type byte 0x00 (P2PKH) or 0xb0/0x80 + 20-byte hash + // Platform address bech32m wire bytes per + // rs-dpp/src/address_funds/platform_address.rs:41-47: + // 0xb0 = P2PKH, 0x80 = P2SH. + // 0x00/0x01 are the *storage* bytes (GroveDB keys) and must + // never appear in a `tdash1…`/`dash1…` string. let typeByte = data[0] - if typeByte == 0x00 || typeByte == 0xb0 || typeByte == 0x80 { + if typeByte == 0xb0 || typeByte == 0x80 { return DashAddress(type: .platform(data), displayString: input) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 7be13790139..2f6b8c3f0d9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -229,12 +229,21 @@ class SendViewModel: ObservableObject { let bech32mByte = payload[0] let ffiAddressType: UInt8 switch bech32mByte { - case 0xb0, 0x00: ffiAddressType = 0 // P2PKH - case 0x80, 0x01: ffiAddressType = 1 // P2SH + case 0xb0: ffiAddressType = 0 // P2PKH + case 0x80: ffiAddressType = 1 // P2SH default: error = "Unknown platform address type byte 0x\(String(bech32mByte, radix: 16))" return } + // The Rust FFI's `PlatformAddressFFI → PlatformAddress` + // conversion (rs-platform-wallet-ffi/src/platform_address_types.rs:42) + // only accepts P2PKH; sending to a P2SH platform address + // would surface a raw "Unsupported address type" string + // from Rust. Fail fast with a user-readable message. + guard ffiAddressType == 0 else { + error = "P2SH platform addresses aren't supported yet. Use a P2PKH recipient." + return + } let hash = payload.subdata(in: 1..<21) let output = ManagedPlatformAddressWallet.TransferOutput( addressType: ffiAddressType, @@ -244,17 +253,16 @@ class SendViewModel: ObservableObject { // If the view passed a fresh unused HD address from the // pool, use it as the dedicated change destination — // matches the Receive screen's lowest-unused selection. - let changeOutput: ManagedPlatformAddressWallet.TransferOutput? = changeAddressRow.map { - ManagedPlatformAddressWallet.TransferOutput( + let change: ManagedPlatformAddressWallet.ChangeAddress? = changeAddressRow.map { + ManagedPlatformAddressWallet.ChangeAddress( addressType: $0.addressType, - hash: $0.addressHash, - credits: 0 // value gets overwritten by the wrapper + hash: $0.addressHash ) } _ = try await addressWallet.transfer( accountIndex: senderAccountIndex, outputs: [output], - changeAddress: changeOutput, + changeAddress: change, signer: signer ) successMessage = "Platform transfer sent" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index b73757ae52e..f7cd8d2fc6c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -177,10 +177,29 @@ struct SendTransactionView: View { // Reset the watermark to force a full trunk/branch // scan that re-emits on_address_found for every // known address. - try? platformAddressWallet?.restoreSyncState( - syncHeight: 0, syncTimestamp: 0, lastKnownRecentBlock: 0 - ) - try? await walletManager.syncPlatformAddressNow() + // + // Both calls run before we hand off to + // `executeSend`; surface failures via the + // viewModel's existing error-alert binding + // and abort the send so we don't fall + // through to a misleading "available 0 + // credits" downstream. + do { + try platformAddressWallet?.restoreSyncState( + syncHeight: 0, syncTimestamp: 0, lastKnownRecentBlock: 0 + ) + } catch { + viewModel.error = + "Couldn't reset sync state: \(error.localizedDescription)" + return + } + do { + try await walletManager.syncPlatformAddressNow() + } catch { + viewModel.error = + "Couldn't refresh platform balance before sending: \(error.localizedDescription)" + return + } // Pick the account holding the platform // balance. Most wallets have a single // PlatformPayment account (index 0); From b31da1668d92f763ceeb97703754cf35dcc52097 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 11 May 2026 13:20:08 +0200 Subject: [PATCH 3/8] fix(rs-platform-wallet): hydrate account.address_credit_balance on restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlatformAddressWallet::initialize_from_persisted` previously only rebuilt the platform-address provider; the per-address balances stayed parked in `provider.per_wallet[..].found` and never reached the per-account `address_credit_balance` map. `auto_select_inputs` reads exclusively from that map (transfer.rs:178-203), so callers were seeing "available 0 credits" on every cold-start until a BLAST sync re-fired `on_address_found` for every known address. Push the persisted balances onto each matching ManagedPlatformAccount under a short write lock BEFORE constructing the provider (Tokio's RwLock has no read→write upgrade, and `from_persisted` takes its own read lock). `None` for the key-source matches the `apply.rs` precedent: the gap-limit pool has already been re-derived from `account_state.addresses` by the time `from_persisted` returns, so we just want to set balances, not re-extend the pool. Adds a `pub(crate) fn found()` accessor on `PerAccountPlatformAddressState` so the hydration block in `wallet.rs` can iterate the persisted entries without making the field public. No FFI signatures change. End-to-end test: cold-start the SwiftExampleApp with a wallet that has known persisted platform-address balances, open Send Dash without waiting for a BLAST round — Send From → Platform should populate immediately and the transfer should succeed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/platform_addresses/provider.rs | 9 +++++ .../src/wallet/platform_addresses/wallet.rs | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs index d5836be9ff1..3dc9a675656 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -115,6 +115,15 @@ impl PerAccountPlatformAddressState { self.addresses.insert(address_index, address); self.found.insert(address, funds); } + + /// Read-only view of the persisted `(address, funds)` entries. + /// + /// Used by `PlatformAddressWallet::initialize_from_persisted` to + /// push the persisted balances onto each `ManagedPlatformAccount` + /// before the provider takes over. + pub(crate) fn found(&self) -> &BTreeMap { + &self.found + } } /// Per-wallet account map — keys are DIP-17 account indexes (hardened diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index 0c08fc8a425..f7d83a2fff3 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -87,10 +87,47 @@ impl PlatformAddressWallet { /// [`PlatformPaymentAddressProvider::from_persisted`] so xpubs, /// `found`, and `absent` are restored verbatim while `addresses` /// and `pending` are rebuilt from the live `AddressPool`. + /// + /// Also pushes each persisted balance back onto the matching + /// `ManagedPlatformAccount` via `set_address_credit_balance` so + /// the transfer/withdrawal `auto_select_inputs` paths see a + /// non-zero balance immediately after restore — without this, + /// they'd report "available 0 credits" until a fresh BLAST sync + /// round fired `on_address_found` for every known address. + /// Mirrors the `set_address_credit_balance(.., None)` shape in + /// [apply.rs](crate::wallet::apply): `None` for the key-source + /// argument because the gap-limit pool is already restored from + /// `account_state.addresses` inside `from_persisted`. pub async fn initialize_from_persisted( &self, persisted: crate::PlatformAddressSyncStartState, ) -> Result<(), PlatformWalletError> { + // Hydrate `account.address_credit_balance` BEFORE constructing + // the provider. `from_persisted` holds a read lock on + // `wallet_manager` for its duration, and Tokio's `RwLock` has + // no read→write upgrade — doing the write-lock dance first + // keeps both paths simple and avoids exposing a new public + // accessor on the provider. + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + for (account_index, account_state) in &persisted.per_account { + if let Some(account) = info + .core_wallet + .platform_payment_managed_account_at_index_mut(*account_index) + { + for (p2pkh, funds) in account_state.found() { + account.set_address_credit_balance( + *p2pkh, + funds.balance, + None, + ); + } + } + } + } + } + let mut per_wallet = std::collections::BTreeMap::new(); per_wallet.insert(self.wallet_id, persisted.per_account); let provider = PlatformPaymentAddressProvider::from_persisted( From e9181df57280a667453543bfc4292bcd7de37f0c Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 11 May 2026 13:22:10 +0200 Subject: [PATCH 4/8] chore(swift-sdk): drop pre-send watermark/sync workaround The Rust hydration fix in the preceding commit pushes persisted balances onto each `ManagedPlatformAccount` during `initialize_from_persisted`, so the SwiftExampleApp no longer needs to reset the sync watermark and force a full BLAST rescan before every send to make `auto_select_inputs` see them. - Remove the `restoreSyncState(0,0,0)` + `syncPlatformAddressNow()` blocks (and their do/catch error-surfacing) from `SendTransactionView`'s Send button task. The send is now instantaneous on cold start instead of paying a multi-second sync round. - Drop the now-unused `ManagedPlatformAddressWallet.restoreSyncState(...)` wrapper. The underlying `platform_address_wallet_restore_sync_state` FFI stays; only the Swift bridge over it goes. Re-add the wrapper from a single call site if a future consumer needs it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ManagedPlatformAddressWallet.swift | 23 ------------- .../Core/Views/SendTransactionView.swift | 33 ------------------- 2 files changed, 56 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 8c92a9528a1..d7abeeb0d91 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -21,29 +21,6 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { platform_address_wallet_destroy(handle).discard() } - /// Reset the incremental sync watermark. - /// - /// Passing `(0, 0, 0)` clears the provider's last-sync state so the - /// next `sync_balances` call does a full trunk/branch scan instead - /// of an incremental recent-query. Useful when the in-memory - /// `account.address_credit_balance` map is empty after a wallet - /// restore: the persisted provider state is in place but balances - /// only flow into the account via the `on_address_found` callback - /// during a sync, which incremental mode skips when nothing has - /// changed. - public func restoreSyncState( - syncHeight: UInt64, - syncTimestamp: UInt64, - lastKnownRecentBlock: UInt64 - ) throws { - try platform_address_wallet_restore_sync_state( - handle, - syncHeight, - syncTimestamp, - lastKnownRecentBlock - ).check() - } - // MARK: - Balance queries /// Platform address with its credit balance. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index f7cd8d2fc6c..a9500138e9f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -167,39 +167,6 @@ struct SendTransactionView: View { let managed = walletManager.wallet(for: wallet.walletId) let coreWallet = try? managed?.coreWallet() let platformAddressWallet = try? managed?.platformAddressWallet() - // The in-memory account.address_credit_balance map - // is only hydrated by BLAST sync via the provider's - // on_address_found callback (provider.rs:578). - // After wallet restore the provider has a watermark - // set, which forces sync into incremental ("recent - // query") mode that skips known addresses — so the - // callback never fires and the account stays empty. - // Reset the watermark to force a full trunk/branch - // scan that re-emits on_address_found for every - // known address. - // - // Both calls run before we hand off to - // `executeSend`; surface failures via the - // viewModel's existing error-alert binding - // and abort the send so we don't fall - // through to a misleading "available 0 - // credits" downstream. - do { - try platformAddressWallet?.restoreSyncState( - syncHeight: 0, syncTimestamp: 0, lastKnownRecentBlock: 0 - ) - } catch { - viewModel.error = - "Couldn't reset sync state: \(error.localizedDescription)" - return - } - do { - try await walletManager.syncPlatformAddressNow() - } catch { - viewModel.error = - "Couldn't refresh platform balance before sending: \(error.localizedDescription)" - return - } // Pick the account holding the platform // balance. Most wallets have a single // PlatformPayment account (index 0); From 4d4c13b85dd75d41e754811745248fb62de3bc12 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 11 May 2026 16:51:04 +0200 Subject: [PATCH 5/8] fix(swift-sdk): persist transfer balance changes + reject duplicate outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two Codex-review findings on the platform-to-platform send. P1 — Apply UpdatedBalance to SwiftData after a successful transfer. `platform_address_wallet_transfer` returns the post-broadcast balances/nonces for every input + change address but, unlike `sync_balances`, the Rust path does not call the persister itself. The SendViewModel previously discarded the wrapper's `[UpdatedBalance]` return value, so PersistentPlatformAddress rows stayed frozen at pre-send values until the next BLAST round. That left the UI displaying spent funds, let the change-address picker re-pick the same fresh address on the next send (the previous one now has a balance Rust knows about but SwiftData doesn't), and meant a cold restart would rehydrate the wallet from stale rows via the Batch 2 hydration path. Capture the returned rows in `executeSend` and apply `balance`, `nonce`, `isUsed = true`, `lastUpdated = Date()` to the matching PersistentPlatformAddress rows, then `try modelContext.save()`. Mirrors PlatformWalletPersistenceHandler.persistAddressBalances (PlatformWalletPersistenceHandler.swift:88-114). Save failures surface via the existing `viewModel.error` alert. P2 — Reject duplicate recipient addresses in the wrapper. Rust's `parse_outputs` (platform_address_types.rs:189-204) inserts outputs into a `BTreeMap`, so duplicate recipient addresses silently overwrite earlier entries. Swift's wrapper sums every output toward the change-amount calculation, so duplicates would leave the resulting state transition misbalanced (Rust broadcasts only the last per-address entry; Swift compensated for the larger sum). No current Swift call site can trigger this (SendViewModel passes a single recipient), but it's an unsafe public-API surface — guard against it up front. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ManagedPlatformAddressWallet.swift | 15 +++++++ .../Core/ViewModels/SendViewModel.swift | 43 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index d7abeeb0d91..1240e4630d9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -149,6 +149,21 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { throw PlatformWalletError.invalidParameter("outputs is empty") } + // Reject duplicate recipient addresses. Rust's parse_outputs + // (platform_address_types.rs:189-204) inserts into a + // `BTreeMap` keyed by address, so a + // duplicate would silently overwrite earlier entries — Swift + // would still sum every output toward the change calculation, + // leaving the transition misbalanced. We key on `hash` only: + // P2PKH/P2SH share the same 20-byte hash space, so the same + // hash with different `addressType` is still ambiguous. + let outputHashes = Set(outputs.map { $0.hash }) + guard outputHashes.count == outputs.count else { + throw PlatformWalletError.invalidParameter( + "Duplicate recipient addresses in outputs" + ) + } + // Sum recipient amounts. Reject overflow rather than silently // wrapping (would let a caller smuggle bogus amounts past the // protocol's sum check). diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 2f6b8c3f0d9..721a386b00a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -259,12 +259,53 @@ class SendViewModel: ObservableObject { hash: $0.addressHash ) } - _ = try await addressWallet.transfer( + let updated = try await addressWallet.transfer( accountIndex: senderAccountIndex, outputs: [output], changeAddress: change, signer: signer ) + + // Apply the post-broadcast balances/nonces returned by + // Rust to SwiftData so the UI doesn't display stale + // values until the next BLAST sync. The Rust-side + // `transfer` updates its in-memory state but does not + // call the persister, so without this the + // PersistentPlatformAddress rows that drive the Send + // screen's @Query stay frozen at their pre-send + // values — change-address selection could re-pick the + // same fresh address on the next send, and a cold + // restart would rehydrate the wallet from the stale + // rows. + // + // Mirrors PlatformWalletPersistenceHandler.persistAddressBalances + // (PlatformWalletPersistenceHandler.swift:88-114): fetch + // each row by `addressHash`, set the volatile fields, + // stamp `lastUpdated`. Every entry Rust returned was + // touched by the transition, so `isUsed = true` + // unconditionally. Rows that aren't found are + // silently skipped — the same defensive shape the + // BLAST handler uses. + for entry in updated { + let entryHash = entry.hash + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.addressHash == entryHash } + ) + guard let row = try? modelContext.fetch(descriptor).first else { + continue + } + row.balance = entry.balance + row.nonce = entry.nonce + row.isUsed = true + row.lastUpdated = Date() + } + do { + try modelContext.save() + } catch { + self.error = "Couldn't persist post-transfer balances: \(error.localizedDescription)" + return + } + successMessage = "Platform transfer sent" case .platformToShielded, From 4f28a5b80d8e34a39d21852790e244f08de6d640 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 11 May 2026 17:13:53 +0200 Subject: [PATCH 6/8] fix(rs-platform-wallet): persist transfer changeset after a successful broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlatformAddressWallet::transfer` updates the in-memory ManagedPlatformAccount and returns a PlatformAddressChangeSet, but unlike its sibling `sync_balances` it never calls `self.persister.store(cs)`. The asymmetry leaves SwiftData stale until the next BLAST round: after each send the platform address rows still show the pre-send balances/nonces, the Batch 2 cold-start hydration trusts those rows on next launch, and the wrapper's next `auto_select_inputs` declares a stale input balance the protocol then rejects with "Insufficient address balance". Mirror sync.rs:79-83 — drop the wallet-manager write lock first to match the lock discipline there, then push the changeset through the persister with the same log-on-error shape. `cs.clone()` because the caller still consumes `cs` (FFI builds PlatformAddressChangeSetFFI from the return); `sync.rs` can move `cs` because its return doesn't include the changeset. New `use crate::changeset::Merge` import so `cs.is_empty()` resolves (same import sync.rs uses). With this fix the Swift-side P1 loop in `SendViewModel.executeSend` becomes redundant (writes the same data the Rust callback already delivered); leaving it in place for now because the writes are idempotent and a follow-up commit can prune it. Companion gaps in `withdrawal.rs` and `fund_from_asset_lock.rs` carry the same shape and are deferred — neither path is exercised by the current app. Credits the Codex P1 finding for surfacing the underlying layering bug behind the stale-state symptom. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/platform_addresses/transfer.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs index 8af37949e3b..e9e0d81e958 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs @@ -8,6 +8,7 @@ use dpp::version::PlatformVersion; use dpp::version::LATEST_PLATFORM_VERSION; use key_wallet::PlatformP2PKHAddress; +use crate::changeset::Merge; use crate::wallet::PlatformAddressWallet; use crate::{PlatformAddressChangeSet, PlatformWalletError}; use dash_sdk::platform::transition::transfer_address_funds::TransferAddressFunds; @@ -136,6 +137,26 @@ impl PlatformAddressWallet { } } } + drop(wm); + + // Mirror `sync.rs:79-83`: push the post-broadcast balances + // through the persister so SwiftData (and any other consumer + // of the persister callback) stays in sync with the in-memory + // account state we just updated above. Without this the + // SwiftExampleApp's `PersistentPlatformAddress` rows stay + // frozen at pre-send values until the next BLAST sync — + // causing the Batch 2 cold-start hydration to load stale + // balances and the wrapper's next `auto_select_inputs` to + // declare a stale input balance the protocol then rejects. + // + // Log-on-error rather than propagate: the on-chain transition + // already succeeded, and a persistence hiccup shouldn't mask + // that. A subsequent BLAST round would reconcile. + if !cs.is_empty() { + if let Err(e) = self.persister.store(cs.clone().into()) { + tracing::error!("Failed to persist transfer changeset: {}", e); + } + } Ok(cs) } From f48497bf2dc89d33af45a497d549c6d9fce80986 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 11 May 2026 17:33:01 +0200 Subject: [PATCH 7/8] chore: scrub session-specific terminology from comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persister-call comment in transfer.rs referenced "Batch 2 cold-start hydration" — internal review terminology that means nothing to a reader who hasn't been in the conversation. Rewrite to reference the actual function (`initialize_from_persisted`) and the generic external-store contract. Also update the Swift balance-write loop's comment: it justified itself with "the Rust-side transfer does not call the persister", which stopped being true the moment that gap got fixed. The loop is now redundant-but-idempotent belt-and-suspenders; say so. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/platform_addresses/transfer.rs | 20 +++++------ .../Core/ViewModels/SendViewModel.swift | 34 ++++++++----------- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs index e9e0d81e958..3140ac4586f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs @@ -139,19 +139,19 @@ impl PlatformAddressWallet { } drop(wm); - // Mirror `sync.rs:79-83`: push the post-broadcast balances - // through the persister so SwiftData (and any other consumer - // of the persister callback) stays in sync with the in-memory - // account state we just updated above. Without this the - // SwiftExampleApp's `PersistentPlatformAddress` rows stay - // frozen at pre-send values until the next BLAST sync — - // causing the Batch 2 cold-start hydration to load stale - // balances and the wrapper's next `auto_select_inputs` to - // declare a stale input balance the protocol then rejects. + // Mirror `sync.rs`: push the post-broadcast balances through + // the persister so any external store stays in sync with the + // in-memory account state we just updated above. Without + // this, persisted rows for these addresses stay frozen at + // pre-send values until the next BLAST sync, and + // `initialize_from_persisted` on the next process start would + // seed `account.address_credit_balance` from those stale rows + // — leaving `auto_select_inputs` to declare an input balance + // the protocol then rejects. // // Log-on-error rather than propagate: the on-chain transition // already succeeded, and a persistence hiccup shouldn't mask - // that. A subsequent BLAST round would reconcile. + // that. A subsequent sync reconciles. if !cs.is_empty() { if let Err(e) = self.persister.store(cs.clone().into()) { tracing::error!("Failed to persist transfer changeset: {}", e); diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 721a386b00a..9712b2ed752 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -266,26 +266,22 @@ class SendViewModel: ObservableObject { signer: signer ) - // Apply the post-broadcast balances/nonces returned by - // Rust to SwiftData so the UI doesn't display stale - // values until the next BLAST sync. The Rust-side - // `transfer` updates its in-memory state but does not - // call the persister, so without this the - // PersistentPlatformAddress rows that drive the Send - // screen's @Query stay frozen at their pre-send - // values — change-address selection could re-pick the - // same fresh address on the next send, and a cold - // restart would rehydrate the wallet from the stale - // rows. + // Belt-and-suspenders: apply the post-broadcast + // balances/nonces returned by `transfer` to SwiftData + // directly. The Rust side already pushes the same + // changeset through the persister, so this loop is + // idempotent (same hash → same balance/nonce), but + // doing it here too keeps the @Query-bound + // PersistentPlatformAddress rows fresh even if the + // persister callback ordering ever changes. // - // Mirrors PlatformWalletPersistenceHandler.persistAddressBalances - // (PlatformWalletPersistenceHandler.swift:88-114): fetch - // each row by `addressHash`, set the volatile fields, - // stamp `lastUpdated`. Every entry Rust returned was - // touched by the transition, so `isUsed = true` - // unconditionally. Rows that aren't found are - // silently skipped — the same defensive shape the - // BLAST handler uses. + // Mirrors PlatformWalletPersistenceHandler.persistAddressBalances: + // fetch each row by `addressHash`, update the + // volatile fields, stamp `lastUpdated`. Every entry + // returned was touched by the transition, so + // `isUsed = true` unconditionally. Rows that aren't + // found are silently skipped — same defensive shape + // the BLAST handler uses. for entry in updated { let entryHash = entry.hash let descriptor = FetchDescriptor( From 16769c9cbb58c5de2c2042b24ca3d463229c62a1 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 11 May 2026 17:47:50 +0200 Subject: [PATCH 8/8] chore(swift-sdk): address PR review comments (HRP, Decimal parsing, change-account scope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small Swift-side cleanups from review feedback. No FFI changes, no framework rebuild. - `DashAddress.encodePlatform(...)` now emits `dash`/`tdash` HRPs to match what `parse(...)` accepts. Previously emitted `dashevo`/`tdashevo`, so a locally-generated platform address would round-trip back as `.unknown`. Function currently has zero callers in the repo so the on-the-wire effect is nil today, but the latent bug is removed for the next consumer. - `SendViewModel.amount` and `amountCredits` now route through the existing `parseTokenAmount(_:decimals:)` helper (TokenAmountFormatting.swift), which uses `Decimal` arithmetic and strict decimal grammar. The previous `Double * 1e8` / `Double * 1e11` pipeline could submit a credit count off by 1 on edge inputs that aren't exactly representable in binary float (e.g. `0.0001`). Same fix applied to both accessors because both shared the same shape. - `SendTransactionView.changeAddressRow` filter now requires `$0.accountIndex == senderAccountIndex`, so the unused HD address picked as the change destination always comes from the sender's platform-payment account. Single-account wallets behave identically; multi-account wallets stop misrouting change. (The sibling input-selection scoping bug — same multi-account class — still needs the FFI-level fix called out in the PR description.) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/Core/Models/DashAddress.swift | 9 +++++++-- .../Core/ViewModels/SendViewModel.swift | 15 +++++++++------ .../Core/Views/SendTransactionView.swift | 11 +++++++++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift index 9e0d7987dec..4e6afa228d1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift @@ -69,10 +69,15 @@ struct DashAddress { return Bech32m.encode(hrp: hrp, data: payload) } - /// Encode 21-byte platform address to bech32m display string + /// Encode 21-byte platform address to bech32m display string. + /// + /// HRP matches what `parse(...)` accepts (`dash` / `tdash`) so a + /// round-trip through `encodePlatform` → `parse` resolves as + /// `.platform(...)`. The type byte at `rawBytes[0]` (0xb0 / 0x80) + /// is what distinguishes platform from Orchard, not the HRP. static func encodePlatform(rawBytes: Data, network: Network) -> String? { guard rawBytes.count == 21 else { return nil } - let hrp = (network == .mainnet) ? "dashevo" : "tdashevo" + let hrp = (network == .mainnet) ? "dash" : "tdash" return Bech32m.encode(hrp: hrp, data: rawBytes) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 9712b2ed752..38c2058bd9e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -93,16 +93,19 @@ class SendViewModel: ObservableObject { self.network = network } + /// Amount in duffs (1 DASH = 1e8). Used by core/L1 flows. + /// Backed by `Decimal` parsing — typing 0.0001 deterministically + /// yields exactly 10_000 duffs, not 9_999 or 10_001 depending on + /// binary-float rounding. var amount: UInt64? { - guard let double = Double(amountString), double > 0 else { return nil } - return UInt64(double * 100_000_000) + parseTokenAmount(amountString, decimals: 8) } - /// Amount in platform credits (1 DASH = 1e11 credits). - /// Used by platform-credit flows; `amount` (above) is duffs (1e8) for core. + /// Amount in platform credits (1 DASH = 1e11 credits). Used by + /// platform-credit flows. Same `Decimal`-backed parsing as + /// `amount`; the divisor difference is just the `decimals` arg. var amountCredits: UInt64? { - guard let double = Double(amountString), double > 0 else { return nil } - return UInt64(double * 100_000_000_000) + parseTokenAmount(amountString, decimals: 11) } var canSend: Bool { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index a9500138e9f..cfe28625b08 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -178,9 +178,16 @@ struct SendTransactionView: View { // the lowest-indexed HD address that has // never been used. Used as the change // destination so the transition doesn't - // collide with any input address. + // collide with any input address. Scoped + // to `senderAccountIndex` so multi-account + // wallets don't land change on a different + // platform-payment account than the inputs. let changeAddressRow = addressBalances - .filter { !$0.isUsed && $0.balance == 0 } + .filter { + $0.accountIndex == senderAccountIndex + && !$0.isUsed + && $0.balance == 0 + } .min(by: { $0.addressIndex < $1.addressIndex }) let signer = KeychainSigner( modelContainer: modelContext.container