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/transfer.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs index 8af37949e3b..3140ac4586f 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`: 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 sync reconciles. + if !cs.is_empty() { + if let Err(e) = self.persister.store(cs.clone().into()) { + tracing::error!("Failed to persist transfer changeset: {}", e); + } + } Ok(cs) } 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( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 8343e2dba9a..1240e4630d9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -64,4 +64,309 @@ 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 + } + } + + /// 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 + 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`). + /// 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: ChangeAddress? = nil, + signer: KeychainSigner + ) async throws -> [UpdatedBalance] { + guard !outputs.isEmpty else { + 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). + 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))" + ) + } + 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 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 + Self.feeBuffer { break } + } + guard totalInputs >= totalRecipientCredits + Self.feeBuffer else { + throw PlatformWalletError.walletOperation( + "Insufficient platform balance: have \(totalInputs) credits across \(selectedInputs.count) input(s), need at least \(totalRecipientCredits + Self.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/Models/DashAddress.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift index 388600199d4..4e6afa228d1 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) } } @@ -65,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 d07dc7a7d06..38c2058bd9e 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 @@ -89,9 +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. Same `Decimal`-backed parsing as + /// `amount`; the divisor difference is just the `decimals` arg. + var amountCredits: UInt64? { + parseTokenAmount(amountString, decimals: 11) } var canSend: Bool { @@ -113,6 +127,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 +161,8 @@ class SendViewModel: ObservableObject { detectedFlow = .shieldedToShielded case (.orchard, .platform): detectedFlow = .platformToShielded + case (.platform, .platform): + detectedFlow = .platformToPlatform case (.platform, .shielded): detectedFlow = .shieldedToPlatform default: @@ -162,9 +179,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 +199,114 @@ 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: 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, + 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 change: ManagedPlatformAddressWallet.ChangeAddress? = changeAddressRow.map { + ManagedPlatformAddressWallet.ChangeAddress( + addressType: $0.addressType, + hash: $0.addressHash + ) + } + let updated = try await addressWallet.transfer( + accountIndex: senderAccountIndex, + outputs: [output], + changeAddress: change, + signer: signer + ) + + // 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: + // 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( + 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, .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..cfe28625b08 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,44 @@ 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() + // 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. 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.accountIndex == senderAccountIndex + && !$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 +302,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