diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index ddc1d05d76..d0b1c1b79f 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -16,6 +16,9 @@ "core_wallet_abandon_signed_transaction_v2": "packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs", "core_wallet_broadcast_signed_transaction_v2": "packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs", "core_wallet_get_balance": "packages/rs-platform-wallet-ffi/src/core_wallet/wallet.rs", + "core_wallet_signed_payment_broadcast": "packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs", + "core_wallet_signed_payment_finalize": "packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs", + "core_wallet_signed_payment_release": "packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs", "core_wallet_tx_builder_finalize": "packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs", "dash_sdk_sign_async_completion": "packages/rs-sdk-ffi/src/signer.rs", "dpns_name_array_free": "packages/rs-platform-wallet-ffi/src/dpns.rs", @@ -161,6 +164,97 @@ } ] }, + { + "id": "core.deferred_signed_payment", + "title": "Deferred BIP70/BIP270 send: sign now, broadcast or release on merchant ack", + "area": "correctness", + "shared_apis": [ + "core_wallet_signed_payment_finalize", + "core_wallet_signed_payment_broadcast", + "core_wallet_signed_payment_release" + ], + "required_persistence_capabilities": [], + "hosts": { + "swift": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + }, + "kotlin": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + } + }, + "verification": [ + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SignedCoreTransactionTests.swift", + "id": "testShouldReleaseTokenOnceOnCloseAndIgnoreASecondClose", + "command": "swift test --package-path packages/swift-sdk --filter SignedCoreTransactionTests", + "covers_restart": false + }, + { + "host": "swift", + "kind": "integration", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/DeferredSignedPaymentIntegrationTests.swift", + "id": "testBuildBroadcastConsumesTokenExactlyOnce", + "command": "RUN_INTEGRATION_TESTS=1 swift test --package-path packages/swift-sdk --filter DeferredSignedPaymentIntegrationTests", + "covers_restart": false + }, + { + "host": "swift", + "kind": "integration", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/DeferredSignedPaymentIntegrationTests.swift", + "id": "testReleaseFreesTheReservedInputs", + "command": "RUN_INTEGRATION_TESTS=1 swift test --package-path packages/swift-sdk --filter DeferredSignedPaymentIntegrationTests", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt", + "id": "fromRegisterBlobDecodesFieldsAndIsAnOwningCloseable", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt", + "id": "cleanerBackstopRunsTheReleaseActionExactlyOnce", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs", + "id": "build_then_broadcast_sends_registered_bytes", + "command": "cargo test -p platform-wallet build_then_broadcast_sends_registered_bytes", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs", + "id": "build_then_release_frees_the_reservation", + "command": "cargo test -p platform-wallet build_then_release_frees_the_reservation", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs", + "id": "double_broadcast_is_a_stale_token_error", + "command": "cargo test -p platform-wallet double_broadcast_is_a_stale_token_error", + "covers_restart": false + } + ] + }, { "id": "tokens.full_u64_domain", "title": "Token amounts and costs preserve the full u64 domain", diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index 11057ae22a..7f7b2a0b8b 100644 --- a/packages/kotlin-sdk/PARITY_SUMMARY.md +++ b/packages/kotlin-sdk/PARITY_SUMMARY.md @@ -2,23 +2,23 @@ # Kotlin/Swift executable parity summary Audit baseline: `PR #3999 @ 6dbc72a54df72d26eb9c4a014b425d2b95134e4e` -Capabilities tracked: **23** +Capabilities tracked: **24** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 13 | 8 | 1 | 1 | -| Swift | Example app | 4 | 12 | 1 | 6 | -| Kotlin | SDK | 11 | 12 | 0 | 0 | -| Kotlin | Example app | 5 | 12 | 0 | 6 | +| Swift | SDK | 14 | 8 | 1 | 1 | +| Swift | Example app | 4 | 12 | 1 | 7 | +| Kotlin | SDK | 12 | 12 | 0 | 0 | +| Kotlin | Example app | 5 | 12 | 0 | 7 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 0 | 7 | 16 | -| Kotlin | 4 | 6 | 13 | +| Swift | 0 | 7 | 17 | +| Kotlin | 4 | 6 | 14 | ## Capability status @@ -26,6 +26,7 @@ Capabilities tracked: **23** | --- | --- | --- | | `persistence.platform_address_identity` | partial / not-applicable / required | supported / not-applicable / tested | | `core.atomic_send` | supported / supported / not_applicable | supported / supported / not_applicable | +| `core.deferred_signed_payment` | supported / not-applicable / not_applicable | supported / not-applicable / not_applicable | | `tokens.full_u64_domain` | partial / partial / required | supported / supported / tested | | `shielded.seedless_restart` | supported / partial / required | partial / partial / required | | `dashpay.invitations` | supported / partial / required | supported / partial / required | diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift index 64f0eb4b5b..7d1047d866 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift @@ -70,6 +70,87 @@ public final class FinalizedCoreTransaction { func takeForAbandon() throws -> Handle { try takeForBroadcast() } } +/// A built, signed Core transaction whose funding UTXOs are reserved, awaiting +/// a deferred `ManagedPlatformWallet.broadcastSigned` or +/// `ManagedPlatformWallet.releaseReservation` — the split-out result of +/// `ManagedPlatformWallet.buildSignedPayment` for BIP70/BIP270 (CTX/DashSpend) +/// flows that must sign now, POST the raw bytes to a merchant server, and +/// broadcast only on the server's ack. +/// +/// **Owns the reservation token.** The native registration mints the token +/// before this object exists, so a discarded payment would orphan the token — +/// and with it the funding reservation — until key-wallet's TTL. `close()` +/// releases it exactly once, and `deinit` is the backstop for a caller that +/// never calls `close()`. Release is idempotent native-side and tokens are +/// process-unique (never reused), so releasing a token already consumed by +/// `broadcastSigned` / `releaseReservation` — or releasing twice — is a +/// harmless no-op. +/// +/// Process-death note: the reservation is in-memory only. An app crash between +/// the build and the broadcast drops the registry entry and the reservation +/// together on restart, and the UTXOs become spendable again. +/// +/// Kotlin parity: `ManagedPlatformWallet.SignedCoreTransaction` +/// (`ManagedPlatformWallet.kt`). +public final class SignedCoreTransaction { + /// The transaction id (lowercase hex) the broadcast will return, computed + /// Rust-side from the signed bytes so it matches exactly. + public let txidHex: String + /// The consensus-serialized signed transaction, to hand to the merchant + /// server. + public let rawTxBytes: Data + /// The fee this build charged, in duffs. + public let feeDuffs: UInt64 + /// The opaque token driving `broadcastSigned` / `releaseReservation`. Valid + /// only for the wallet generation it was minted against, and only until one + /// of those calls consumes it (or `close()` / `deinit` releases it). + public let reservationToken: UInt64 + + /// Non-atomic, matching `FinalizedCoreTransaction`'s single-owner handle + /// precedent: a payment is driven by one owner, and the native release is + /// itself idempotent. + private var tokenOwned = true + private let releaseToken: (UInt64) -> Void + + /// - Parameter releaseToken: the release action, defaulting to the native + /// one. Injectable so tests can count releases without reaching into the + /// process-global registry. + init( + txidHex: String, + rawTxBytes: Data, + feeDuffs: UInt64, + reservationToken: UInt64, + releaseToken: @escaping (UInt64) -> Void = { + core_wallet_signed_payment_release($0).discard() + } + ) { + self.txidHex = txidHex + self.rawTxBytes = rawTxBytes + self.feeDuffs = feeDuffs + self.reservationToken = reservationToken + self.releaseToken = releaseToken + } + + /// Release the funding reservation if this payment was neither broadcast + /// nor released, and drop the token. Idempotent: a second `close()` is a + /// no-op here, and calling it after `broadcastSigned` / + /// `releaseReservation` is safe because the native release of an + /// already-consumed token is a silent no-op. + public func close() { + guard tokenOwned else { return } + tokenOwned = false + releaseToken(reservationToken) + } + + /// Backstop for an abandoned payment, so a caller that never calls + /// `close()` cannot strand its funding reservation until the TTL. + deinit { + if tokenOwned { + releaseToken(reservationToken) + } + } +} + /// key-wallet transaction builder over FFI. Add outputs and options, then call /// `finalizeAtomic` before broadcasting via `ManagedCoreWallet`. public final class CoreTransactionBuilder { @@ -289,4 +370,98 @@ public final class CoreTransactionBuilder { } return try FinalizedCoreTransaction(handle: transactionHandle) } + + /// Consume this configured builder and, in ONE atomic native operation, + /// select + reserve + sign the inputs and register the built transaction + /// for deferred (BIP70/BIP270) submission. The deferred counterpart to + /// `finalizeAtomic`: selection and insertion into the account reservation + /// set commit as a single unit under the wallet-manager lock, so a + /// concurrent deferred build — or a deferred build racing an immediate + /// send — cannot double-select an input. + /// + /// Consumes the builder on every path, including failures; this instance + /// must not be reused afterwards. + /// + /// Kotlin parity: `CoreTransactionBuilder.finalizeSignedPayment` + /// (`CoreTransactionBuilder.kt`). + public func finalizeSignedPayment( + wallet: ManagedPlatformWallet, + accountType: AccountType, + accountIndex: UInt32 + ) throws -> SignedCoreTransaction { + guard !consumed else { + throw PlatformWalletError.unknown("CoreTransactionBuilder already consumed") + } + var token: UInt64 = 0 + var feeDuffs: UInt64 = 0 + var txidPtr: UnsafeMutablePointer? = nil + var tx = FFICoreTransaction(tx_bytes: nil, tx_len: 0, fee: 0) + var bytesPtr: UnsafePointer? = nil + var bytesLen: UInt = 0 + + let resolver = MnemonicResolver() + let result = withExtendedLifetime(resolver) { + core_wallet_signed_payment_finalize( + handle, + wallet.handle, + accountType.ffi, + accountIndex, + resolver.handle, + &token, + &feeDuffs, + &txidPtr, + &tx, + &bytesPtr, + &bytesLen + ) + } + consumed = true + try result.check() + + defer { + if let txidPtr { + core_wallet_free_address(txidPtr) + } + core_wallet_transaction_free(&tx) + } + + // Every guard below trips after the native register already minted the + // token and transferred reservation ownership to it, so throwing without + // releasing would strand the reservation until key-wallet's TTL with no + // token left to release it. Release defensively first, mirroring the + // owner-guarded release on the rest of the deferred path + // (dashpay/platform#4185). + func releaseMintedToken() { + if token != 0 { + core_wallet_signed_payment_release(token).discard() + } + } + + guard token != 0 else { + throw PlatformWalletError.unknown( + "deferred finalizer returned success with a zero reservation token" + ) + } + guard let txidCString = txidPtr else { + releaseMintedToken() + throw PlatformWalletError.nullPointer( + "core_wallet_signed_payment_finalize returned a NULL txid pointer" + ) + } + guard let rawBytes = bytesPtr, bytesLen > 0 else { + releaseMintedToken() + throw PlatformWalletError.unknown( + "deferred finalizer returned success but the tx buffer was empty" + ) + } + + // The borrowed view into `tx`'s buffer is copied here, before the + // deferred `core_wallet_transaction_free` frees it. + return SignedCoreTransaction( + txidHex: String(cString: txidCString), + rawTxBytes: Data(bytes: rawBytes, count: Int(bytesLen)), + feeDuffs: feeDuffs, + reservationToken: token + ) + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift index 012cfea638..66798e6a0d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -361,6 +361,33 @@ public class ManagedCoreWallet { } } + /// Broadcast the deferred (BIP70/BIP270) payment behind `token` and return + /// its txid. The token is consumed atomically before the send, so a repeated + /// or concurrent broadcast gets an error rather than a second send. + /// + /// An unusable token surfaces as one of three sibling errors instead: + /// `.staleReservationToken` (34) — the reservation may already have aged out + /// of key-wallet's TTL, rebuild the payment; `.reservationTokenConsumed` + /// (35) — unknown, already broadcast, or already released; + /// `.reservationWalletMismatch` (36) — minted against a different wallet + /// generation. `.notFound` (98) means the token's wallet was removed from + /// the manager entirely. None of them touched the network and none is + /// retryable in place. + /// + /// Kotlin parity: `ManagedCoreWallet.broadcastSignedPayment` + /// (`ManagedCoreWallet.kt`). + func broadcastSignedPayment(token: UInt64) throws -> String { + var txidPtr: UnsafeMutablePointer? = nil + try core_wallet_signed_payment_broadcast(handle, token, &txidPtr).check() + guard let ptr = txidPtr else { + throw PlatformWalletError.nullPointer( + "core_wallet_signed_payment_broadcast returned a NULL txid pointer" + ) + } + defer { core_wallet_free_address(ptr) } + return String(cString: ptr) + } + /// Consume without sending and release its reservation immediately. public func abandonTransaction(_ tx: FinalizedCoreTransaction) throws { try core_wallet_abandon_signed_transaction_v2( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 4f5226ac82..3c861030d0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -4193,3 +4193,122 @@ extension ManagedPlatformWallet { }.value } } + +// MARK: - Deferred (BIP70/BIP270) signed payments + +extension ManagedPlatformWallet { + + /// Build and sign a Core payment to `recipients` WITHOUT broadcasting, + /// reserving the funding UTXOs and returning a `SignedCoreTransaction` + /// whose `reservationToken` later drives `broadcastSigned` (merchant server + /// acked) or `releaseReservation` (abandoned / server nacked). + /// + /// The BIP70/BIP270 counterpart to the immediate build-and-send path: those + /// protocols sign, POST the raw bytes to a merchant server, and broadcast + /// only on ack, which a single build-sign-broadcast call cannot express. + /// The whole build is one atomic native operation — select + reserve + sign + /// + register under the wallet-manager lock — so once this returns the + /// reservation holds the inputs and the token operates on them later. + /// + /// The returned object OWNS the token: `close()` (or its `deinit` backstop) + /// releases it, so a payment that is neither broadcast nor released is + /// never orphaned. Consuming the token via `broadcastSigned` / + /// `releaseReservation` makes that release a native no-op. + /// + /// Process-death note: the reservation is in-memory. An app crash between + /// this call and `broadcastSigned` drops it on restart and the UTXOs become + /// spendable again. + /// + /// Kotlin parity: `ManagedPlatformWallet.buildSignedPayment` + /// (`ManagedPlatformWallet.kt`). + public func buildSignedPayment( + recipients: [(address: String, amountDuffs: UInt64)], + network: Network, + accountType: CoreTransactionBuilder.AccountType = .bip44, + accountIndex: UInt32 = 0 + ) throws -> SignedCoreTransaction { + guard !recipients.isEmpty else { + throw PlatformWalletError.invalidParameter("recipients must not be empty") + } + guard recipients.allSatisfy({ $0.amountDuffs > 0 }) else { + throw PlatformWalletError.invalidParameter("every recipient amount must be positive") + } + + let builder = try CoreTransactionBuilder(network: network) + for recipient in recipients { + try builder.addOutput(address: recipient.address, amountDuffs: recipient.amountDuffs) + } + return try builder.finalizeSignedPayment( + wallet: self, + accountType: accountType, + accountIndex: accountIndex + ) + } + + /// Broadcast the deferred payment behind `token` (from + /// `buildSignedPayment`) and return its txid — the "merchant server acked" + /// arm. Consumes the token: rather than double-broadcasting, an unusable + /// token throws one of the three sibling errors — + /// `.staleReservationToken` (aged out), `.reservationTokenConsumed` + /// (unknown / already consumed), `.reservationWalletMismatch` (a different + /// wallet generation) — or `.notFound` when the wallet was removed. None is + /// retryable in place; rebuild the payment. + /// + /// Callers holding a `SignedCoreTransaction` should prefer the object + /// overload: with the bare token, the source object must stay alive until + /// this call returns, or its `deinit` backstop can release the reservation + /// mid-broadcast. + /// + /// Kotlin parity: `ManagedPlatformWallet.broadcastSigned(token)` + /// (`ManagedPlatformWallet.kt`). + public func broadcastSigned(token: UInt64) throws -> String { + try coreWallet().broadcastSignedPayment(token: token) + } + + /// Broadcast `payment` and return its txid — the object-owning form of + /// `broadcastSigned`. Prefer it over passing the bare + /// `SignedCoreTransaction.reservationToken`: the token's lifetime is + /// coupled to the object's, so a caller that extracts the `UInt64` and + /// drops the object races the `deinit` backstop and can find the + /// reservation released mid-broadcast. This overload keeps the object alive + /// across the whole native call and disarms the backstop once the token is + /// consumed. + /// + /// On a throw the payment stays armed, and its backstop later runs the + /// (idempotent, harmless) release. + /// + /// Kotlin parity: `ManagedPlatformWallet.broadcastSigned(payment)` + /// (`ManagedPlatformWallet.kt`). + public func broadcastSigned(_ payment: SignedCoreTransaction) throws -> String { + try withExtendedLifetime(payment) { + let txid = try broadcastSigned(token: payment.reservationToken) + payment.close() + return txid + } + } + + /// Release the funding reservation behind `token` (from + /// `buildSignedPayment`) — the "payment abandoned / merchant server nacked" + /// arm — returning the reserved UTXOs to spendable. Idempotent: releasing + /// an unknown / already-broadcast / already-released token is a silent + /// no-op, so it is always safe to call defensively. + /// + /// Kotlin parity: `ManagedPlatformWallet.releaseReservation(token)` + /// (`ManagedPlatformWallet.kt`). + public func releaseReservation(token: UInt64) throws { + try core_wallet_signed_payment_release(token).check() + } + + /// Release `payment`'s funding reservation — the object-owning form of + /// `releaseReservation`; see `broadcastSigned(_:)` for why it is preferred + /// over the bare-token form. + /// + /// Kotlin parity: `ManagedPlatformWallet.releaseReservation(payment)` + /// (`ManagedPlatformWallet.kt`). + public func releaseReservation(_ payment: SignedCoreTransaction) throws { + try withExtendedLifetime(payment) { + try releaseReservation(token: payment.reservationToken) + payment.close() + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/DeferredSignedPaymentIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/DeferredSignedPaymentIntegrationTests.swift new file mode 100644 index 0000000000..f8e83b1f88 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/DeferredSignedPaymentIntegrationTests.swift @@ -0,0 +1,164 @@ +import XCTest +@testable import SwiftDashSDK + +/// End-to-end coverage of the deferred (BIP70/BIP270) finalize / broadcast / +/// release C ABI against the funded local devnet. +/// +/// This is the layer neither sibling suite reaches. `SignedCoreTransactionTests` +/// runs entirely above the boundary (it injects the release action and never +/// calls the native finalizer), and the Rust `signed_payment_registry` tests run +/// entirely below it (they never marshal anything through the C ABI). What only +/// these tests can catch: the eleven-argument out-parameter marshalling of +/// `core_wallet_signed_payment_finalize`, ownership of the returned txid string, +/// the copy of the borrowed bytes view before its backing `FFICoreTransaction` +/// is freed, the error-code mapping of the deferred-token failures onto the +/// typed `PlatformWalletError` arms, and the consume-once / release-frees-inputs +/// behaviour of a reservation actually held by a live wallet. +final class DeferredSignedPaymentIntegrationTests: IntegrationTestCase { + private let fundingDash: Double = 0.5 + private var fundingDuffs: UInt64 { + UInt64(fundingDash * 1e8) + } + + private let paymentAmount: UInt64 = 100_000 // 0.001 DASH + + func testBuildBroadcastConsumesTokenExactlyOnce() async throws { + try await env.walletManager.startSpv(config: env.spvConfig) + let alice = try await env.makeTestWallet(name: "deferred-alice") + let bob = try await env.makeTestWallet(name: "deferred-bob") + + let aliceAddress = try alice.getCoreWallet().nextReceiveAddress() + _ = try await env.fund(address: aliceAddress, dash: fundingDash) + try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90) + + let bobAddress = try bob.getCoreWallet().nextReceiveAddress() + let network = try alice.getCoreWallet().network() + let payment = try alice.getPlatformWallet().buildSignedPayment( + recipients: [(address: bobAddress, amountDuffs: paymentAmount)], + network: network + ) + + // Every field below crossed the boundary through a separate out + // parameter, so each one is its own marshalling assertion. + XCTAssertNotEqual(payment.reservationToken, 0, "finalize must mint a reservation token") + XCTAssertGreaterThan(payment.feeDuffs, 0, "a signed build always charges a fee") + XCTAssertFalse( + payment.rawTxBytes.isEmpty, + "the borrowed bytes view must be copied out before out_tx is freed" + ) + XCTAssertEqual(payment.txidHex.count, 64, "txid must be 32 bytes of hex") + let hexDigits = Set("0123456789abcdef") + XCTAssertTrue( + payment.txidHex.allSatisfy { hexDigits.contains($0) }, + "txid must be lowercase hex, got \(payment.txidHex)" + ) + + let beforeTxids = try await readTxids() + let txid = try alice.getPlatformWallet().broadcastSigned(payment) + // Rust computed `txidHex` from the signed bytes at finalize time and the + // broadcast reports what it actually sent; that these agree is what + // proves the merchant server was handed the bytes that got broadcast. + XCTAssertEqual(txid, payment.txidHex, "broadcast txid must match the one finalize returned") + + guard let newTxid = try await waitForNewTxid(notIn: beforeTxids) else { + XCTFail("deferred payment PersistentTransaction row never appeared") + return + } + _ = try await env.mine(1, including: newTxid) + + try await Wait.until( + "bob credited \(paymentAmount) duffs by the deferred payment", + timeout: 60, + pollInterval: 0.01 + ) { + try bob.getPlatformWallet().balance().spendable == paymentAmount + } + + // The token is consumed atomically before the send, so the second + // broadcast is refused rather than sending again. Proves code 35 maps to + // the typed arm across the ABI, not just in the hermetic mirror. + XCTAssertThrowsError( + try alice.getPlatformWallet().broadcastSigned(token: payment.reservationToken) + ) { error in + guard case PlatformWalletError.reservationTokenConsumed = error else { + XCTFail("re-broadcast must throw .reservationTokenConsumed, got \(error)") + return + } + } + + // Releasing a consumed token is a silent native no-op, so a defensive + // release on the merchant-acked path must not throw. + XCTAssertNoThrow(try alice.getPlatformWallet().releaseReservation(payment)) + } + + func testReleaseFreesTheReservedInputs() async throws { + try await env.walletManager.startSpv(config: env.spvConfig) + let alice = try await env.makeTestWallet(name: "deferred-release-alice") + + let aliceAddress = try alice.getCoreWallet().nextReceiveAddress() + _ = try await env.fund(address: aliceAddress, dash: fundingDash) + try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90) + + let network = try alice.getCoreWallet().network() + // Nothing here is ever broadcast, so the destination is immaterial; + // paying alice's own receive address keeps this to one funded wallet. + let destination = aliceAddress + + let held = try alice.getPlatformWallet().buildSignedPayment( + recipients: [(address: destination, amountDuffs: paymentAmount)], + network: network + ) + XCTAssertNotEqual(held.reservationToken, 0, "finalize must mint a reservation token") + + // The single funding UTXO is now reserved, so a second build has nothing + // to select. Coin selection reports that as either "not enough funds" or + // "no selectable inputs" depending on which check gives up first — both + // mean the same thing here, so accept either rather than pinning the + // selector's internal ordering. + XCTAssertThrowsError( + try alice.getPlatformWallet().buildSignedPayment( + recipients: [(address: destination, amountDuffs: paymentAmount)], + network: network + ) + ) { error in + guard let walletError = error as? PlatformWalletError else { + XCTFail("build against a fully reserved wallet threw \(error)") + return + } + switch walletError { + case .coreInsufficientFunds, .noSelectableInputs: + break + default: + XCTFail("build against a fully reserved wallet threw \(walletError)") + } + } + + // The abandoned / merchant-nacked arm: returns the inputs to spendable. + try alice.getPlatformWallet().releaseReservation(held) + + let rebuilt = try alice.getPlatformWallet().buildSignedPayment( + recipients: [(address: destination, amountDuffs: paymentAmount)], + network: network + ) + XCTAssertNotEqual( + rebuilt.reservationToken, 0, + "the released UTXO must be selectable again" + ) + XCTAssertNotEqual( + rebuilt.reservationToken, held.reservationToken, + "tokens are process-unique and never reused" + ) + // Leave no outstanding reservation behind for tearDown's resetState. + try alice.getPlatformWallet().releaseReservation(rebuilt) + + // A released token is as unusable as a consumed one. + XCTAssertThrowsError( + try alice.getPlatformWallet().broadcastSigned(token: held.reservationToken) + ) { error in + guard case PlatformWalletError.reservationTokenConsumed = error else { + XCTFail("broadcasting a released token must throw .reservationTokenConsumed, got \(error)") + return + } + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SignedCoreTransactionTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SignedCoreTransactionTests.swift new file mode 100644 index 0000000000..0571fd5c9b --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SignedCoreTransactionTests.swift @@ -0,0 +1,116 @@ +// SignedCoreTransactionTests.swift +// SwiftDashSDKTests +// +// Unit tests for `SignedCoreTransaction`'s reservation-token ownership. + +import XCTest +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the Swift side of the deferred (BIP70/BIP270) signed-payment +/// flow exported by dashpay/platform#4308. +/// +/// What is Swift's to get right is token OWNERSHIP: the native registration +/// mints the reservation token before `SignedCoreTransaction` exists, so the +/// object must release it exactly once — on `close()`, or on `deinit` for a +/// payment that was abandoned without either a broadcast or an explicit close. +/// The lifecycle semantics themselves (atomic consume-before-send, idempotent +/// release, tokens bound to their originating wallet generation) live in Rust +/// and are covered there. +/// +/// These tests are hermetic: no wallet, no network. The release action is +/// injected through the internal `releaseToken` seam so the count can be +/// observed without reaching into the process-global registry. Kotlin parity: +/// `SignedCoreTransactionTest`. +final class SignedCoreTransactionTests: XCTestCase { + + /// Records every token handed to the injected release action. + private final class ReleaseRecorder { + private(set) var released: [UInt64] = [] + var count: Int { released.count } + + func record(_ token: UInt64) { + released.append(token) + } + } + + private func makePayment( + token: UInt64 = 0xDEAD_BEEF, + recorder: ReleaseRecorder + ) -> SignedCoreTransaction { + SignedCoreTransaction( + txidHex: "b0c1d2e3f4a5968778695a4b3c2d1e0fb0c1d2e3f4a5968778695a4b3c2d1e0f", + rawTxBytes: Data([0x01, 0x02, 0x03, 0x04]), + feeDuffs: 2_260, + reservationToken: token, + releaseToken: { recorder.record($0) } + ) + } + + func testShouldRoundTripPropertiesThroughInit() { + let recorder = ReleaseRecorder() + let payment = makePayment(token: 42, recorder: recorder) + + XCTAssertEqual( + payment.txidHex, + "b0c1d2e3f4a5968778695a4b3c2d1e0fb0c1d2e3f4a5968778695a4b3c2d1e0f" + ) + XCTAssertEqual(payment.rawTxBytes, Data([0x01, 0x02, 0x03, 0x04])) + XCTAssertEqual(payment.feeDuffs, 2_260) + XCTAssertEqual(payment.reservationToken, 42) + XCTAssertEqual(recorder.count, 0, "construction must not release the token") + + payment.close() + } + + func testShouldReleaseTokenOnceOnCloseAndIgnoreASecondClose() { + let recorder = ReleaseRecorder() + let payment = makePayment(token: 7, recorder: recorder) + + payment.close() + XCTAssertEqual(recorder.released, [7]) + + payment.close() + XCTAssertEqual(recorder.released, [7], "a second close must not release again") + } + + func testShouldReleaseTokenOnceViaDeinitWhenNeverClosed() { + let recorder = ReleaseRecorder() + var payment: SignedCoreTransaction? = makePayment(token: 99, recorder: recorder) + XCTAssertNotNil(payment) + XCTAssertEqual(recorder.count, 0, "a live payment must keep its token") + + payment = nil + XCTAssertEqual( + recorder.released, [99], + "an abandoned payment must release its reservation from deinit" + ) + } + + func testShouldNotDoubleReleaseWhenCloseIsFollowedByDeinit() { + let recorder = ReleaseRecorder() + var payment: SignedCoreTransaction? = makePayment(token: 5, recorder: recorder) + + payment?.close() + XCTAssertEqual(recorder.released, [5]) + + payment = nil + XCTAssertEqual(recorder.released, [5], "deinit must not re-release a closed payment") + } + + /// Symbol-binding smoke test against the REAL release path: releasing a + /// token the registry never minted is a silent native no-op, so the default + /// seam must neither throw nor crash. This is what links + /// `core_wallet_signed_payment_release`. + func testShouldTreatReleaseOfAnUnknownTokenAsASilentNoOp() { + XCTAssertNoThrow(try core_wallet_signed_payment_release(0xFFFF_FFFF_FFF0).check()) + + let payment = SignedCoreTransaction( + txidHex: "00", + rawTxBytes: Data(), + feeDuffs: 0, + reservationToken: 0xFFFF_FFFF_FFF1 + ) + payment.close() + } +}