Skip to content

feat(swift-sdk): partial-amount platform address withdrawal wrapper#4139

Merged
QuantumExplorer merged 1 commit into
v4.1-devfrom
feat/swift-sdk-partial-withdrawal-wrapper
Jul 16, 2026
Merged

feat(swift-sdk): partial-amount platform address withdrawal wrapper#4139
QuantumExplorer merged 1 commit into
v4.1-devfrom
feat/swift-sdk-partial-withdrawal-wrapper

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

Adds a withdraw(accountIndex:coreAddress:inputs:signer:) overload to ManagedPlatformAddressWallet using INPUT_SELECTION_TYPE_EXPLICIT: each WithdrawalInput names an address and the exact credits to withdraw, so the payout equals the requested amount (the transition fee is deducted from the fee-source input's remaining balance, per the default DeductFromInput(0) strategy).

Complements the existing AUTO overload, which always withdraws the full non-dust balance. Swift-only — the FFI entry point already ships in DashSDKFFI.

Why now

dashwallet-ios has required this API since its partial Platform → Transparent transfer landed (dashwallet-ios #817, and the Send redesign #819 builds on it) — building the app's swift-sdk-integration branch against v4.1-dev without this commit fails with missing-member errors on withdraw(…inputs:) / WithdrawalInput. This upstreams the local commit so plain v4.1-dev builds the app again.

Test plan

  • Swift-only wrapper over the shipped FFI; exercised end-to-end by dashwallet-ios partial Platform → Transparent withdrawals on testnet (payout equals the requested amount, fee deducted from the source address's remaining balance).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added explicit-input withdrawals for managed platform address wallets.
    • Added support for specifying withdrawal inputs, destination address, and fee rate.
    • Added validation for destination addresses and withdrawal input details.
    • Existing automatic input selection withdrawals remain available.

Add a withdraw(accountIndex:coreAddress:inputs:signer:) overload to
ManagedPlatformAddressWallet using INPUT_SELECTION_TYPE_EXPLICIT: each
WithdrawalInput names an address and the exact credits to withdraw, so
the payout equals the requested amount (the transition fee is deducted
from the fee-source input's remaining balance, per the default
DeductFromInput(0) strategy). Complements the existing AUTO overload,
which always withdraws the full non-dust balance. Swift-only — the FFI
entry point already ships in DashSDKFFI.

Used by dashwallet-ios for partial Platform → Transparent internal
transfers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thepastaclaw

thepastaclaw commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit 5043b86)
Canonical validated blockers: 2

@github-actions github-actions Bot added this to the v4.1.0 milestone Jul 16, 2026
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Explicit withdrawal flow

Layer / File(s) Summary
Validated explicit withdrawal execution
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift
Adds WithdrawalInput and an explicit-input withdraw overload that validates inputs, invokes the platform wallet FFI, and returns updated balances.

Estimated code review effort: 3 (Moderate) | ~15–30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ManagedPlatformAddressWallet
  participant KeychainSigner
  participant platform_address_wallet_withdraw_to_address
  ManagedPlatformAddressWallet->>ManagedPlatformAddressWallet: Validate coreAddress and WithdrawalInput values
  ManagedPlatformAddressWallet->>KeychainSigner: Pin signer for FFI call
  ManagedPlatformAddressWallet->>platform_address_wallet_withdraw_to_address: Submit ExplicitInputFFI rows and fee rate
  platform_address_wallet_withdraw_to_address-->>ManagedPlatformAddressWallet: Return changeset
  ManagedPlatformAddressWallet->>ManagedPlatformAddressWallet: Check, free, and decode UpdatedBalance entries
Loading

Possibly related PRs

  • dashpay/platform#3923: Introduced the Rust FFI withdrawal endpoint and Core address validation used by this Swift integration.

Suggested reviewers: shumkov, llbartekll, zocolini

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main Swift SDK change: a partial-amount platform address withdrawal wrapper.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/swift-sdk-partial-withdrawal-wrapper

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@QuantumExplorer
QuantumExplorer merged commit ecbdf97 into v4.1-dev Jul 16, 2026
18 of 19 checks passed
@QuantumExplorer
QuantumExplorer deleted the feat/swift-sdk-partial-withdrawal-wrapper branch July 16, 2026 15:07

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift (1)

557-573: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reject duplicate input addresses and validate the credits sum.

Similarly to how outputs are pre-checked in transfer, duplicate input addresses should be rejected up-front to prevent silent overwrites when the Rust side parses them into a map (e.g., BTreeMap). Additionally, checking for UInt64 overflow when summing the input credits ensures the total is safely bounded before offloading the work to the detached task.

♻️ Proposed validations
-        for input in inputs {
+        let inputHashes = Set(inputs.map { $0.hash })
+        guard inputHashes.count == inputs.count else {
+            throw PlatformWalletError.invalidParameter(
+                "Duplicate source addresses in inputs"
+            )
+        }
+        
+        var totalInputCredits: UInt64 = 0
+        for input in inputs {
             guard input.addressType == 0 else {
                 throw PlatformWalletError.invalidParameter(
                     "only P2PKH (addressType 0) withdrawal inputs are supported"
                 )
             }
             guard input.hash.count == 20 else {
                 throw PlatformWalletError.invalidParameter(
                     "withdrawal input hash must be 20 bytes, got \(input.hash.count)"
                 )
             }
             guard input.credits > 0 else {
                 throw PlatformWalletError.invalidParameter(
                     "withdrawal input credits must be > 0"
                 )
             }
+            
+            let sum = totalInputCredits.addingReportingOverflow(input.credits)
+            if sum.overflow {
+                throw PlatformWalletError.invalidParameter(
+                    "Input credits sum overflowed UInt64"
+                )
+            }
+            totalInputCredits = sum.partialValue
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift`
around lines 557 - 573, Update the input pre-validation loop in the withdrawal
flow to reject duplicate input addresses before creating the detached task,
using the same address uniqueness approach as transfer output validation. While
validating inputs, accumulate credits with checked UInt64 addition and throw
PlatformWalletError.invalidParameter on overflow; preserve the existing
addressType, hash-length, and positive-credit checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift`:
- Around line 557-573: Update the input pre-validation loop in the withdrawal
flow to reject duplicate input addresses before creating the detached task,
using the same address uniqueness approach as transfer output validation. While
validating inputs, accumulate credits with checked UInt64 addition and throw
PlatformWalletError.invalidParameter on overflow; preserve the existing
addressType, hash-length, and positive-credit checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4a581e95-12bc-4f5c-b3f0-cd3e33beadaa

📥 Commits

Reviewing files that changed from the base of the PR and between dc1d645 and 5043b86.

📒 Files selected for processing (1)
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preliminary review — Codex only

The new partial-withdrawal API has two in-scope blockers. Caller-supplied explicit inputs are not authorized against the managed wallet/account before the shared signer resolves keys, and the documented first-input fee source is reinterpreted using Rust's canonical address order.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (failed), default — general (failed), gpt-5.6-sol — general (failed), gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (failed), default — security-auditor (failed), gpt-5.6-sol — security-auditor (failed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — ffi-engineer (failed), default — ffi-engineer (failed), gpt-5.6-sol — ffi-engineer (failed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift:583-613: Explicit withdrawal inputs are not bound to the managed wallet or account
  This managed-wallet overload accepts arbitrary address hashes and forwards them as `InputSelection::Explicit` without checking that they belong to `self` or `accountIndex`. The Rust explicit branch does not use `wallet_id` or `account_index` for authorization; the SDK only fetches the supplied addresses' balances and then asks the signer to sign every map key. `KeychainSigner` resolves a platform key by `addressHash` across the shared `ModelContainer`, takes the matched row's `walletId` and derivation path, and loads that wallet's mnemonic. Because one manager/container holds multiple wallets, code using wallet A's handle can supply a persisted wallet B hash and have the normal signer authorize a withdrawal to an arbitrary Core address. Post-broadcast reconciliation merely skips the foreign address and cannot prevent the spend. Validate every explicit input against this wallet and account before signing, preferably in the Rust wallet/FFI layer, and bind signer resolution to the expected wallet ID.
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift:593-610: Default fee strategy targets the canonical first address, not inputs[0]
  The API tells callers to leave fee headroom on the first Swift input, but passing `nil, 0` becomes `DeductFromInput(0)` only after `parse_explicit_inputs` has inserted the array into a `BTreeMap<PlatformAddress, Credits>`. DPP resolves index 0 against that map's derived `Ord` order, so it means the lexicographically smallest address hash rather than `inputs[0]`. If a later, smaller-sorting input is withdrawn in full, fee validation fails because that unintended target has no remainder; if it has headroom, the fee is charged to the wrong address. This is the same insertion-order-versus-canonical-index class previously fixed for transfer outputs. Compute the canonical rank of the caller's intended fee source and pass an explicit `FeeStrategyStepFFI`, with a regression test whose Swift order differs from hash order.

Comment on lines +583 to +613
let ffiInputs = inputRows.map { input -> ExplicitInputFFI in
ExplicitInputFFI(
address: PlatformAddressFFI(
address_type: input.addressType,
hash: Self.hashTuple(from: input.hash)
),
balance: input.credits
)
}
var changeset = PlatformAddressChangeSetFFI(updated: nil, updated_count: 0)
// Fee strategy nil/0 → the Rust default `DeductFromInput(0)`:
// the first input is the fee source. Callers order inputs so
// index 0 carries the fee headroom.
let result = withExtendedLifetime(signer) {
address.withCString { addrCStr in
ffiInputs.withUnsafeBufferPointer { inputBp in
platform_address_wallet_withdraw_to_address(
handle,
accountIndex,
INPUT_SELECTION_TYPE_EXPLICIT,
inputBp.baseAddress,
UInt(inputBp.count),
nil,
0,
addrCStr,
feePerByte,
nil,
0,
signerHandle,
&changeset
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Explicit withdrawal inputs are not bound to the managed wallet or account

This managed-wallet overload accepts arbitrary address hashes and forwards them as InputSelection::Explicit without checking that they belong to self or accountIndex. The Rust explicit branch does not use wallet_id or account_index for authorization; the SDK only fetches the supplied addresses' balances and then asks the signer to sign every map key. KeychainSigner resolves a platform key by addressHash across the shared ModelContainer, takes the matched row's walletId and derivation path, and loads that wallet's mnemonic. Because one manager/container holds multiple wallets, code using wallet A's handle can supply a persisted wallet B hash and have the normal signer authorize a withdrawal to an arbitrary Core address. Post-broadcast reconciliation merely skips the foreign address and cannot prevent the spend. Validate every explicit input against this wallet and account before signing, preferably in the Rust wallet/FFI layer, and bind signer resolution to the expected wallet ID.

source: ['codex']

Comment on lines +593 to +610
// Fee strategy nil/0 → the Rust default `DeductFromInput(0)`:
// the first input is the fee source. Callers order inputs so
// index 0 carries the fee headroom.
let result = withExtendedLifetime(signer) {
address.withCString { addrCStr in
ffiInputs.withUnsafeBufferPointer { inputBp in
platform_address_wallet_withdraw_to_address(
handle,
accountIndex,
INPUT_SELECTION_TYPE_EXPLICIT,
inputBp.baseAddress,
UInt(inputBp.count),
nil,
0,
addrCStr,
feePerByte,
nil,
0,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Default fee strategy targets the canonical first address, not inputs[0]

The API tells callers to leave fee headroom on the first Swift input, but passing nil, 0 becomes DeductFromInput(0) only after parse_explicit_inputs has inserted the array into a BTreeMap<PlatformAddress, Credits>. DPP resolves index 0 against that map's derived Ord order, so it means the lexicographically smallest address hash rather than inputs[0]. If a later, smaller-sorting input is withdrawn in full, fee validation fails because that unintended target has no remainder; if it has headroom, the fee is charged to the wrong address. This is the same insertion-order-versus-canonical-index class previously fixed for transfer outputs. Compute the canonical rank of the caller's intended fee source and pass an explicit FeeStrategyStepFFI, with a regression test whose Swift order differs from hash order.

source: ['codex']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants