Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions docs/sdk/sdk-parity-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 8 additions & 7 deletions packages/kotlin-sdk/PARITY_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,31 @@
# 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

| Capability | Swift SDK / app / restart | Kotlin SDK / app / restart |
| --- | --- | --- |
| `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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<CChar>? = nil
var tx = FFICoreTransaction(tx_bytes: nil, tx_len: 0, fee: 0)
var bytesPtr: UnsafePointer<UInt8>? = 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
Comment thread
QuantumExplorer marked this conversation as resolved.
)
}
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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CChar>? = 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(
Expand Down
Loading
Loading