fix(swift-sdk): sort transfer outputs lexicographically before ReduceOutput#3752
Conversation
…Output `ManagedPlatformAddressWallet.transfer()` targeted the fee-reduction step by Swift insertion index, telling Rust to `ReduceOutput(N)` where N was `ffiOutputs.count - 1` (the change row appended last). The Rust side collects those outputs into `BTreeMap<PlatformAddress, Credits>`, which canonicalizes them lexicographically by address — variant byte first (`P2pkh = 0 < P2sh = 1`), then the 20-byte hash. DPP's fee-deduction layer indexes that canonicalized list when resolving `ReduceOutput(N)`. When the resolved change address sorted before any recipient, the wrong row got decremented: the recipient was underpaid by the fee amount, and the change came back full. Hits randomly based on which address gets generated as change, so it passed integration tests most runs. Mirror the Rust ordering in Swift via a new `buildSortedFFIOutputs` helper: build `(addressType, hash, balance)` rows for all outputs + change, sort by `(addressType, hash)` using `Data.lexicographicallyPrecedes`, then look up the change row's actual position in the sorted list. Marshal into `AddressBalanceEntryFFI` only after the sort completes. Add 2 regression tests on the pure helper: - #3738 scenario (change `0x00…`, recipient `0xFF…`) — change at sorted index 0 - multi-recipient with change in middle, crossing the 0x7F/0x80 byte boundary to catch any signed-byte comparator regression Fixes #3738 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthrough
ChangesOutput Canonicalization Fix
Sequence DiagramsequenceDiagram
participant Transfer as transfer()
participant BuildSorted as buildSortedFFIOutputs()
participant Sort as Lexicographic Sort
participant FFI as AddressBalanceEntryFFI
participant FeeStrat as Fee Strategy
Transfer->>BuildSorted: recipients, change tuple
BuildSorted->>BuildSorted: merge into combined list
BuildSorted->>Sort: sort by address-type, then hash
Sort->>BuildSorted: sorted output items
BuildSorted->>FFI: convert each to FFI row
BuildSorted->>BuildSorted: track change position (changeIndex)
BuildSorted-->>Transfer: (ffiOutputs, changeIndex)
Transfer->>FeeStrat: ReduceOutput(changeIndex)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.
Inline comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift`:
- Around line 353-355: The current force-unwrapping when computing changeIdx can
trap; in ManagedPlatformAddressWallet (the changeIdx computation using
rows.firstIndex { $0.addressType == change.addressType && $0.hash == change.hash
}), replace the forced unwrap with a guarded lookup: use guard let idx =
rows.firstIndex(...) and then safely convert with UInt16(exactly: idx) (or check
idx <= UInt16.max); if either step fails, return or throw an appropriate wallet
error instead of crashing. Ensure the surrounding method returns the same error
type/path used elsewhere in this file for lookup/conversion failures.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: bd6fc088-6611-4edc-b094-f9460115c048
📒 Files selected for processing (2)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ManagedPlatformAddressWalletTests.swift
|
✅ DashSDKFFI.xcframework built for this PR.
SwiftPM (host the zip at a stable URL, then use): .binaryTarget(
name: "DashSDKFFI",
url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
checksum: "5cded6bdacb066426a90af8afad1f8a434aaf39bf5eb4aed8e5332facbef6ccf"
)Xcode manual integration:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR correctly aligns Swift's FeeStrategyStep::ReduceOutput index with Rust's BTreeMap<PlatformAddress, _> canonicalization by sorting outputs on (addressType ascending, hash lexicographic) — faithfully mirroring derive(Ord) on the Rust enum. Pre-existing dedup guards at ManagedPlatformAddressWallet.swift:160-165 and :198-202 ensure no duplicate hashes ever reach buildSortedFFIOutputs, so the only inline blocking finding from codex-general does not apply. No in-scope issues; only out-of-scope follow-ups worth tracking.
Out-of-scope follow-up suggestions (4)
These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.
- Canonical-ordering decision lives in Swift, against swift-sdk/CLAUDE.md —
buildSortedFFIOutputsmakes a structural decision (canonical row order + which index encodes ReduceOutput) in Swift that mirrors a Rust-private invariant:derive(Ord)onenum PlatformAddress { P2pkh([u8;20]), P2sh([u8;20]) }.packages/swift-sdk/CLAUDE.mdis explicit that decisions belong in Rust and Swift should only marshal. If anyone reorders the Rust variants, adds a new one, or changesPlatformAddressFFI's wire encoding so thataddress_typebytes no longer match theOrdordinals, this helper will silently desynchronize and re-introduce the fund-misdirection bug. The PR description acknowledges this and chooses Swift-side for urgency — defensible, but worth tracking.- Follow-up: Open a separate issue to expose a Rust FFI like
platform_address_wallet_transfer_with_changethat takes recipients + change and computes the canonical change index internally, removing the cross-language ordering duplication.
- Follow-up: Open a separate issue to expose a Rust FFI like
- Sort test coverage doesn't exercise the addressType axis — Both new tests in
ManagedPlatformAddressWalletTests.swiftuseaddressType: 0(P2PKH) for every row, so theaddressType != addressTypebranch of the comparator (P2pkh vs P2sh, which is the half tied to a Rust enum declaration order) isn't exercised. Not blocking — existing tests catch the original bug and the 0x7F/0x80 boundary risk — and the Rust FFI currently rejects non-zero address types anyway.- Follow-up: Add a unit test with a mixed-variant (P2PKH + P2SH) layout once P2SH support is wired through
TryFrom<PlatformAddressFFI>inpackages/rs-platform-wallet-ffi/src/platform_address_types.rs.
- Follow-up: Add a unit test with a mixed-variant (P2PKH + P2SH) layout once P2SH support is wired through
- Recipient/change collision guards key on 20-byte hash only, not full (addressType, hash) —
recipientHashes, selected-input filtering, and the explicit-change-address validation inManagedPlatformAddressWallet.swiftcompare onlyhash, so a P2PKH(hash) and P2SH(hash) pair would be flagged as colliding even though Rust'sBTreeMap<PlatformAddress, _>would treat them as distinct keys. Only P2PKH is currently accepted on the Rust side, so this has no current behavioral impact, but it will need to change in lockstep if P2SH is enabled.- Follow-up: Track this together with enabling P2SH support in
TryFrom<PlatformAddressFFI>; audit all Swift hash-only equality checks to compare(addressType, hash).
- Follow-up: Track this together with enabling P2SH support in
- Swift docs claim P2SH support that the Rust FFI parser rejects —
ManagedPlatformAddressWallet.TransferOutputandChangeAddressdocumentaddressTypeas0 = P2PKH, 1 = P2SH, butTryFrom<PlatformAddressFFI> for PlatformAddressinpackages/rs-platform-wallet-ffi/src/platform_address_types.rsreturns 'Unsupported address type' for anything but 0. Pre-existing interop gap, unaffected by this PR.- Follow-up: Either add P2SH support to the FFI's
TryFrom<PlatformAddressFFI>or narrow the Swift API and docs to the address types the Rust FFI actually accepts.
- Follow-up: Either add P2SH support to the FFI's
Issue being fixed or feature implemented
Fixes #3738 —
ManagedPlatformAddressWallet.transfer()was underpaying recipients whenever the resolved change address sorted lexicographically before any recipient address. HIGH severity, silent fund misdirection.What was done?
The Swift wrapper was targeting the fee-reduction step by insertion index:
But the Rust side collects outputs into
BTreeMap<PlatformAddress, Credits>, which canonicalizes them lexicographically by address — variant byte first (P2pkh = 0 < P2sh = 1), then the 20-byte hash. DPP's fee-deduction layer (rs-dpp/src/address_funds/fee_strategy/v0.rs) resolvesReduceOutput(N)against that canonicalized list, not Swift's insertion order. When the change address sorted before any recipient, the wrong row got decremented: the recipient was underpaid by the fee amount, and the change came back full. The bug hit randomly based on which address got generated as change, so it bypassed integration tests most runs.Fix: mirror the Rust ordering in Swift via a new
buildSortedFFIOutputshelper.(addressType, hash, balance)rows for all outputs + change.(addressType, hash)—addressTypeascending first, thenhashviaData.lexicographicallyPrecedes(unsigned byte-by-byte).AddressBalanceEntryFFI.The helper is
internal staticso the unit tests can hit it directly without spinning up FFI handles.Per discussion in #3738, this is the structurally complete fix for the Swift side. No additional FFI consumers are planned, so a Rust-side
transfer_with_change_addressbridge is out of scope.How Has This Been Tested?
Two regression tests on the pure helper (in
SwiftExampleApp/SwiftExampleAppTests/ManagedPlatformAddressWalletTests.swift, auto-discovered via the existingPBXFileSystemSynchronizedRootGroup):test_buildSortedFFIOutputs_changeSortsBeforeRecipient_indexIsZero— exact Swift:ReduceOutput(insertion-index)in ManagedPlatformAddressWallet.transfer underpays recipients when change sorts before recipient #3738 scenario. Recipient hash0xFF…, change hash0x00…. AssertschangeIndex == 0and both rows land in the correct sorted positions with balances preserved. Pre-fix this returnedchangeIndex == 1and Rust would have decremented the recipient.test_buildSortedFFIOutputs_multipleRecipients_changeInMiddle— three outputs, change sorts into the middle position. Recipients at0x10…/0xF0…, change at0x80…. AssertschangeIndex == 1and all three rows in the correct sorted positions. The0x80midpoint deliberately crosses the signed-byte boundary — if the comparator ever degrades to signed-byte semantics, this test fails immediately. Also defends against any off-by-one or last-position assumption regression.End-to-end build verification was blocked locally by a stale
DashSDKFFI.xcframework(recent FFI constants from PR #3651 aren't in the framework I had built on May 25) — unrelated to this change. The two pre-existing errors are inPlatformWalletResult.swift, not in the file modified here. swift-frontend reported zero errors inManagedPlatformAddressWallet.swiftduring the failed build, confirming my changes parse and type-check cleanly. Will need./build_ios.shto refresh the framework before CI/test run.Breaking Changes
None. The FFI ABI is unchanged; only the values Swift puts into the existing slots change.
Checklist:
For repository code-owners and collaborators only
Summary by CodeRabbit