fix(platform-wallet): auto_select_inputs honors Σ inputs == Σ outputs#3554
fix(platform-wallet): auto_select_inputs honors Σ inputs == Σ outputs#3554Claudius-Maginificent wants to merge 77 commits into
Conversation
`auto_select_inputs` in `wallet/platform_addresses/transfer.rs` was
inserting each selected address with its FULL balance as the input's
`Credits` value, then returning as soon as accumulated covered
`output + fee`. With a bank holding ~500B credits and a 50M output, the
SDK got `inputs = {bank: 499_985_086_740}, outputs = {target: 50_000_000}`
and the protocol rejected it because address-funds-transfer enforces
`Σ inputs.credits == Σ outputs.credits` (strict equality, verified at
`rs-dpp/.../address_funds_transfer_transition/v0/state_transition_validation.rs`,
asserted on-chain by
`rs-drive-abci/.../address_funds_transfer/tests.rs::test_input_balance_decreased_correctly`,
which checks `new_balance == initial_balance - transfer_amount - fee`).
The protocol's actual semantics:
- `inputs[addr].credits` = consumed amount from `addr`
- `outputs[addr]` = credited amount to `addr`
- `Σ inputs.credits == Σ outputs.credits`
- Fee is deducted from the targeted input's REMAINING balance (post-
consumption) per `AddressFundsFeeStrategy`. `DeductFromInput(0)`
reduces the *remaining balance* by the fee — never the inputs map's
`Credits` value.
Fix: extract the selection loop into a pure module-scope helper
`select_inputs(candidates, outputs, total_output, fee_strategy,
platform_version)` that:
1. Walks candidates in DIP-17 order, tentatively appending each to a
`Vec<(address, balance)>` to drive the per-iteration fee estimate.
2. Stops when `accumulated >= total_output + estimated_fee` (the
accumulated balance must cover the fee from the last input's
remaining balance).
3. Builds the returned map front-to-back, consuming each input in
insertion order until exactly `total_output` is reached. Inputs
added solely to satisfy the per-input fee margin are excluded
from the final map — preserving Σ inputs.credits == total_output
without violating `min_input_amount`.
Side benefits:
- The pure helper is unit-testable without constructing a full
`PlatformWalletManager` + `PlatformAddressWallet`. Five tests cover
the fix:
- `single_input_oversized_balance_trims_to_output_amount`
- `two_input_selection_trims_only_the_last`
- `fee_only_tail_input_does_not_inflate_input_sum` (regression for
the Σ-inputs-greater-than-Σ-outputs case raised in Copilot review)
- `insufficient_balance_errors`
- `no_candidates_errors`
- The full per-`PlatformAddressWallet` async method `auto_select_inputs`
now just gathers `(address, balance)` candidates and calls
`select_inputs`, which keeps the testability win without changing
public API.
Doc note in `auto_select_inputs_for_withdrawal` clarifies the
asymmetry: withdrawal validates `Σ inputs > output_amount` (strictly
greater, surplus = fee), so its drain-everything strategy is correct
by design — NOT the same bug as the transfer selector. No code
change there.
Verification:
- `cargo check --tests -p platform-wallet` OK
- `cargo clippy --tests -p platform-wallet -- -D warnings` OK
- `cargo fmt -p platform-wallet` OK
- `cargo test -p platform-wallet --lib` 115/115
Co-Authored-By: Claudius the Magnificent <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughRestricts auto input-selection to a single fee-strategy shape and replaces the greedy selector with a deterministic, tested pure selector; plus small refactors and test/adaptations and a package-filter addition. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~40 minutes 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 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 |
|
✅ Review complete (commit 30e612b) |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- Around line 341-365: When building the final `selected` map after `accumulated
>= required`, ensure the address referenced by the fee strategy
`DeductFromInput(index)` is reserved the estimated fee before returning: locate
the `chosen` prefix and the loop that constructs `selected` using
`remaining`/`total_output`, compute the required fee headroom for the
fee-bearing input (from `fee_strategy`/`DeductFromInput`) and reduce that
input's available amount by that fee (i.e., instead of consuming up to
`remaining`, cap consumption so the fee-bearing input keeps at least
`estimated_fee`), and if that causes the input to be insufficient, continue
selecting additional candidates or return an error; update uses of `selected`,
`remaining`, `accumulated`, and `required` accordingly so the returned map
guarantees the fee-bearing input still has the reserved fee.
🪄 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: 56d75786-5d1c-44b8-a304-2e962f372120
📒 Files selected for processing (1)
packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs
…arget CodeRabbit caught a critical bug on PR #3554's `select_inputs`: the helper ensured `Σ inputs.credits == Σ outputs.credits` (the protocol's structural invariant) but did NOT ensure that the address targeted by `DeductFromInput(0)` had post-consumption remaining balance >= the estimated fee. Worked example from CodeRabbit: candidates = [(addr_a, 20M), (addr_b, 50M)] // addr_a < addr_b lex total_output = 30M fee_strategy = [DeductFromInput(0)] Old result = {addr_a: 20M, addr_b: 10M} // Σ matches; addr_a drained Drive applies DeductFromInput(0) over inputs sorted by key (BTreeMap order), hitting addr_a — whose remaining balance is 0 — so `min(fee, 0) = 0`, `fee_fully_covered = false`, validator rejects with AddressesNotEnoughFundsError. The Wave-8 single-input live e2e accidentally avoided this because the fee target had ~1B credits left over after consumption — multi-input auto-selected transfers would have hit it on first contact. This rewrite: - Phase 1 (unchanged): pick smallest DIP-17-ordered prefix covering total_output + estimated_fee. - Phase 2: identify the fee target = lex-smallest address in the prefix (= `BTreeMap` index 0, what `DeductFromInput(0)` will hit per `rs-dpp/src/address_funds/fee_strategy/.../v0/mod.rs`). - Phase 3: consume the *minimum* allowed amount from the fee target (`max(min_input_amount, total_output − Σ other balances)`) so it retains the most remaining balance for fee deduction. Error out with a descriptive AddressOperation if even that minimum leaves less than `estimated_fee` remaining. - Phase 4: distribute the rest of `total_output` across the other prefix entries in DIP-17 order. - Phase 5: defensive invariant checks. `min_input_amount` is fetched from `platform_version.dpp.state_transitions.address_funds.min_input_amount` (currently 100k across v1/v2/v3 of platform-version). For non-`[DeductFromInput(0)]` fee strategies the helper falls back to the previous "consume from front" distribution that only enforces the Σ invariant — none of the wallet's call sites use anything else today. Tests: - updated `two_input_selection_trims_only_the_last` → `two_input_selection_keeps_fee_headroom_at_index_zero` to assert the new distribution AND the headroom invariant. - updated `fee_only_tail_input_does_not_inflate_input_sum`'s expected outputs (the tail is no longer dropped — it absorbs the consumption the fee target sheds). - added `fee_target_keeps_remaining_for_fee_deduction` (CodeRabbit's exact scenario, with the headroom invariant as the load-bearing assertion). - added `fee_headroom_violation_errors` (lex-smallest address too small to retain headroom → descriptive error rather than transition the validator will reject). - `single_input_oversized_balance_trims_to_output_amount`, `insufficient_balance_errors`, `no_candidates_errors` pass unchanged. `cargo test -p platform-wallet --lib` → 117 / 117 green `cargo clippy -p platform-wallet --tests -- -D warnings` → clean `cargo fmt -p platform-wallet --check` → clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- Around line 441-461: The distribution loop that sets each consumed = min(bal,
remaining) can insert inputs below min_input_amount (e.g., consumed <
min_input_amount); after that loop (the one that inserts entries into prefix and
uses variables fee_target_consumed, remaining, consumed, fee_target_addr),
validate all non-zero consumed values against min_input_amount and either (a)
rebalance by moving consumption from fee_target_consumed or other large entries
to bump small entries up to min_input_amount while preserving total consumption,
or (b) return an error indicating distribution impossible; implement the
simplest correct choice for your flow (prefer returning an error if safe
redistribution is complex) and ensure the function returns early on failure so
subsequent debug_asserts aren’t relied on.
- Around line 349-353: The code currently silently falls back to front-trimming
for any AddressFundsFeeStrategy other than the single-item [DeductFromInput(0)]
inside transfer()/where InputSelection::Auto is handled; change this to reject
unsupported auto-selection fee strategies by returning a clear error instead of
performing the unsafe fallback. Locate the branch handling InputSelection::Auto
in transfer.rs (the block that examines fee_strategy and falls back to
front-trimming) and add a guard that checks the strategy sequence—if it is not
exactly the single DeductFromInput(0) pattern, return an Err (with a descriptive
enum/variant or mapped error) indicating unsupported fee strategy for
auto-selection so callers cannot produce inputs that sum to outputs but will
fail on-chain once fees are applied. Ensure the new error flows out of
transfer() consistently with existing error types.
- Around line 363-385: The loop that builds the DIP-17-ordered prefix (variables
prefix, accumulated, covered) currently breaks out as soon as accumulated >=
required, which prevents trying larger prefixes when Phase 3 (fee target
feasibility using fee_target_min/fee_target_max and DeductFromInput(0)
semantics) fails; change the logic so that when a covering prefix is found you
run Phase 3 checks but do not return/error on Phase 3 failure—continue the for
(address, balance) in candidates iteration (calling estimate_fee_for_inputs_pub
as before) to grow the prefix and try later candidates until either Phase 3
succeeds or all candidates are exhausted, only then set covered/error
accordingly.
🪄 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: 857ac9b6-556f-4771-a503-d3c0069a9ecb
📒 Files selected for processing (1)
packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs
…ee-headroom bug Adds `pre_fix_buggy_selector_output_is_rejected_by_protocol_fee_deduction` to the `select_inputs` test module. Reconstructs the exact `inputs` map the pre-fix `auto_select_inputs` would have returned for CodeRabbit's example (candidates (20M, 50M), total_output 30M, `DeductFromInput(0)`), runs the post-consumption remaining balances through the live dpp fee-deduction code path, and asserts `fee_fully_covered == false` — i.e. the protocol rejects it with `AddressesNotEnoughFundsError`. Distinct from `fee_target_keeps_remaining_for_fee_deduction`, which asserts the new selector's output meets the headroom invariant. This reproduction proves the bug at the protocol layer rather than merely asserting "the new output looks different" — it would have stayed red without the fix in 9ea9e70. Verification: - cargo check --tests -p platform-wallet OK - cargo clippy --tests -p platform-wallet -- -D warnings OK - cargo fmt -p platform-wallet OK - cargo test -p platform-wallet --lib 118/118 Co-Authored-By: Claudius the Magnificent <noreply@anthropic.com>
…descending Internal-only change to `auto_select_inputs`. Candidates were previously collected in DIP-17 derivation index order; now they sort by balance descending before being handed to `select_inputs`. Mirrors the dash-evo-tool allocator (`src/ui/wallets/send_screen.rs:155-157`). Effects: - Single largest balance covering `total_output + estimated_fee` => 1-input result, no multi-input case, no lex-smallest fee headroom logic firing. Common path simplified. - Multi-input cases (when the largest alone isn't enough) still go through the headroom-respecting distribution introduced in 9ea9e70 — unchanged, still correct. - No public API change. `transfer()`, `auto_select_inputs`, `select_inputs` signatures all identical. Adds `descending_order_picks_single_largest_when_sufficient` to the existing test module to lock in the common-path behavior. Other tests pass candidates directly to `select_inputs` and are order-agnostic by design — unchanged. The `fee_headroom_violation_errors` error message now includes the fee-target address, its balance, required headroom, and remaining-after-consumption to ease debugging. Verification: - cargo check --tests -p platform-wallet OK - cargo clippy --tests -p platform-wallet -- -D warnings OK - cargo fmt -p platform-wallet OK - cargo test -p platform-wallet --lib 119/119 Co-Authored-By: Claudius the Magnificent <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs (2)
442-453: Fallback path for non-[DeductFromInput(0)]strategies still risks on-chain rejection.The docstring at lines 371-375 acknowledges this limitation, but the code silently proceeds with a distribution that only guarantees
Σ inputs == Σ outputswithout reserving fee headroom on the actual fee-bearing input. Iftransfer()is ever called with a different fee strategy (e.g.,DeductFromInput(1)or multi-step strategies), the returned inputs map could still fail on-chain when the targeted input lacks remaining balance for fee deduction.A previous review suggested returning an error for unsupported strategies. The current approach documents the limitation but doesn't prevent misuse. Consider whether rejecting unsupported strategies is preferable to silent fallback with potential on-chain failure.
Alternative: Reject unsupported strategies explicitly
if !single_deduct_from_input_zero { - let mut selected: BTreeMap<PlatformAddress, Credits> = BTreeMap::new(); - let mut remaining = total_output; - for (addr, bal) in prefix.iter() { - if remaining == 0 { - break; - } - let consumed = (*bal).min(remaining); - selected.insert(*addr, consumed); - remaining = remaining.saturating_sub(consumed); - } - return Ok(selected); + return Err(PlatformWalletError::AddressOperation( + "Auto input selection currently supports only [DeductFromInput(0)] fee strategy. \ + Other strategies require explicit input selection.".to_string(), + )); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs` around lines 442 - 453, The fallback branch that builds `selected` when `!single_deduct_from_input_zero` must not silently return a transfer that may fail on-chain; instead make `transfer()` validate the fee strategy up-front and return an explicit error for unsupported strategies (e.g., when the strategy is not `FeeStrategy::DeductFromInput(0)` or `single_deduct_from_input_zero` is false). Replace the current loop-return branch that constructs `selected` with an Err variant (create or reuse a `TransferError::UnsupportedFeeStrategy` or similar), and ensure callers handle that error; keep the `prefix`-consumption logic only for the supported `single_deduct_from_input_zero` path.
405-409: Phase 1 early break may cause false "fee headroom" failures when a larger prefix would succeed.A previous review noted that because
DeductFromInput(0)targets the lex-smallest address (not the first in iteration order), a later candidate joining the prefix can become the new fee target and make an otherwise-infeasible selection work. The current code breaks at the first covering prefix without checking Phase 3 feasibility.With the descending-balance sort, this scenario is less common (the first candidates are the largest balances), but it can still occur when:
- The largest-balance address is also lex-smallest
- That address has just enough to cover
total_outputbut nottotal_output + fee- Adding a lex-smaller address would shift the fee-target role to a smaller-balance address with better headroom characteristics
Consider continuing to accumulate candidates until Phase 3 succeeds or all candidates are exhausted, rather than erroring immediately on the first Phase 3 failure.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs` around lines 405 - 409, The early break on the first prefix that meets accumulated >= required causes false failures because Phase 3 (fee-target reassignment via DeductFromInput(0)) may succeed for a larger prefix; in the loop that checks accumulated, required and sets covered=true then break, remove the immediate break and instead, after accumulated >= required, invoke the Phase 3 feasibility check (the same logic that uses DeductFromInput(0) / the fee-target selection) and only set covered=true and stop accumulating if that Phase 3 check succeeds; if Phase 3 fails, continue accumulating more candidates and only error or mark covered=false after all candidates are exhausted. Ensure you update the variables used in the Phase 3 check to reflect the extended prefix and keep the existing semantics for setting covered and returning an error when no prefix passes Phase 3.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- Around line 509-521: The loop that distributes `remaining` across `prefix` may
insert entries below `min_input_amount` because it uses `consumed =
bal.min(remaining)` without validating per-input minimum; update the Phase 4
loop (the block iterating `for (addr, bal) in prefix.iter()`) to only insert a
`consumed` value if `consumed >= min_input_amount`, otherwise do not insert that
address and instead add the small remainder to the `fee_target_addr` (or
accumulate it to be merged into the fee target while preserving any fee
headroom) so no non-fee-target input can be below `min_input_amount`; retain
existing `remaining`/`selected` semantics and keep the Phase 5 `debug_assert`
intact.
---
Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- Around line 442-453: The fallback branch that builds `selected` when
`!single_deduct_from_input_zero` must not silently return a transfer that may
fail on-chain; instead make `transfer()` validate the fee strategy up-front and
return an explicit error for unsupported strategies (e.g., when the strategy is
not `FeeStrategy::DeductFromInput(0)` or `single_deduct_from_input_zero` is
false). Replace the current loop-return branch that constructs `selected` with
an Err variant (create or reuse a `TransferError::UnsupportedFeeStrategy` or
similar), and ensure callers handle that error; keep the `prefix`-consumption
logic only for the supported `single_deduct_from_input_zero` path.
- Around line 405-409: The early break on the first prefix that meets
accumulated >= required causes false failures because Phase 3 (fee-target
reassignment via DeductFromInput(0)) may succeed for a larger prefix; in the
loop that checks accumulated, required and sets covered=true then break, remove
the immediate break and instead, after accumulated >= required, invoke the Phase
3 feasibility check (the same logic that uses DeductFromInput(0) / the
fee-target selection) and only set covered=true and stop accumulating if that
Phase 3 check succeeds; if Phase 3 fails, continue accumulating more candidates
and only error or mark covered=false after all candidates are exhausted. Ensure
you update the variables used in the Phase 3 check to reflect the extended
prefix and keep the existing semantics for setting covered and returning an
error when no prefix passes Phase 3.
🪄 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: 3b42e9a8-3597-407f-9ca0-e9a419a38732
📒 Files selected for processing (1)
packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR fixes the original Σ inputs == Σ outputs bug, but introduces three new protocol violations. The selector reasons about fee headroom in DIP-17 insertion order while the chain applies DeductFromInput(i) over BTreeMap key order — combined with dropping/draining tail inputs, this leaves the actual fee-bearing input with no remaining balance. The trim can also produce inputs below min_input_amount (100_000). The new tests assert only the structural invariant and would not catch any of these regressions.
Reviewed commit: aaf8be7
🔴 2 blocking | 🟡 1 suggestion(s) | 💬 2 nitpick(s)
🤖 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/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- [BLOCKING] lines 321-365: Selector reserves fee headroom in insertion order, but the protocol charges DeductFromInput(0) against the BTreeMap-first input
`select_inputs` keeps `chosen` in insertion (DIP-17) order and consumes from front to back, fully draining every input except the last to reach exactly `total_output`. It returns a `BTreeMap`, however, and the on-chain validator resolves `DeductFromInput(index)` via `remaining_balances.iter().nth(index)` (verified at `rs-dpp/.../state_transition_estimated_fee_validation.rs:48-69`), i.e. against BTreeMap key order — the lex-smallest selected address.
When the BTreeMap-first address is not the same as the insertion-order tail, all fee headroom ends up on the wrong input. The new test `two_input_selection_trims_only_the_last` demonstrates the failure mode directly: `addr_a = [0x01;20]` (BTreeMap key 0) is consumed for its full 20M balance — remaining = 0; `addr_b` keeps 40M of headroom. With `DeductFromInput(0)` the protocol charges the fee to `addr_a`, which has 0 left, so `fee_fully_covered = false` and the transition is rejected with `AddressesNotEnoughFundsError` (`rs-drive-abci/.../validate_fees_of_event/v0/mod.rs:209-224`).
The `fee_only_tail_input_does_not_inflate_input_sum` test exposes the same root cause via dropping rather than draining: `addr_a` has `total_output + 1` and is consumed for `total_output`, leaving 1 credit of remaining balance on the only returned input — far below any realistic transfer fee. The aggregate guarantee `Σ remaining ≥ fee` is irrelevant because the protocol charges the fee from one specific input, not the aggregate.
The helper must guarantee that the input the protocol will actually charge (BTreeMap-first when `fee_strategy = [DeductFromInput(0)]`) retains at least `estimated_fee` of remaining balance after consumption. Practical options: (a) compute the BTreeMap-first selected address up front and direct the trim residue to it, leaving headroom there; (b) restrict consumption on the fee-target input to `balance − estimated_fee`. The unit tests need to assert remaining balances after consumption, not just `Σ values == total_output`, to catch this class of bug.
- [BLOCKING] lines 351-365: Trimmed tail input can fall below `min_input_amount`, causing structural rejection
The trim sets `consumed = (*bal).min(remaining)` per input, with no lower bound. The transfer validator rejects any input below `platform_version.dpp.state_transitions.address_funds.min_input_amount` (currently 100_000) with `InputBelowMinimumError` (`rs-dpp/.../address_funds_transfer_transition/v0/state_transition_validation.rs:146-167`).
A perfectly normal multi-input scenario triggers this: candidates A=5M, B=100M with `total_output = 5_050_000` yields `{A: 5_000_000, B: 50_000}`, and 50_000 < 100_000. Or two 100k inputs funding a 150k output: `{first: 100_000, second: 50_000}` — also rejected. None of the new tests exercise this boundary.
Fix options: filter candidates with `balance >= min_input_amount` at the call site and either skip a tail that would trim below the minimum (rolling residue back into a prior input) or refuse to add a candidate whose post-trim consumption would violate the limit. The helper aims to encode protocol correctness, so this should be enforced inside `select_inputs` rather than left to the validator.
- [SUGGESTION] lines 412-539: Tests assert the input-sum invariant only, not full state-transition validation
`single_input_oversized_balance_trims_to_output_amount`, `two_input_selection_trims_only_the_last`, and `fee_only_tail_input_does_not_inflate_input_sum` all assert only that `Σ values == total_output`. They never assemble the result into an `AddressFundsTransferTransition` and run it through `validate_structure` + the fee-validation pipeline. As a result they pass on inputs maps that the on-chain validator deterministically rejects (see the two blocking findings above). The dpp validators are pure and don't need a live node — wiring an integration assertion into these tests would catch both classes of regression and is the right substitute for the still-pending testnet rerun referenced in the PR description.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The DeductFromInput(0) headroom bug from the prior review is fixed: the selector now targets the BTreeMap-first input via lex-smallest selection, and the new pre_fix_buggy_selector_output_is_rejected_by_protocol_fee_deduction test exercises dpp's actual fee-deduction path. Two prior blockers remain reachable on the current head, however: Phase 4's distribution across non-fee-target inputs has no min_input_amount floor, and the public-API fallback for fee strategies other than [DeductFromInput(0)] produces input maps the validator deterministically rejects.
Reviewed commit: 60f7850
🔴 2 blocking | 🟡 1 suggestion(s) | 💬 2 nitpick(s)
🤖 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/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- [BLOCKING] lines 504-521: Non-fee-target inputs can still be trimmed below `min_input_amount`
Phase 3 pins `fee_target_min ≥ min_input_amount` (line 484), but Phase 4 then distributes `total_output − fee_target_consumed` across the non-fee-target prefix entries with `let consumed = (*bal).min(remaining)` and inserts any positive value (lines 516-520) — there is no per-input lower bound. The validator at `packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funds_transfer_transition/v0/state_transition_validation.rs:157-167` iterates `self.inputs.values()` and rejects ANY input below `min_input_amount` (100_000) with `InputBelowMinimumError`.
A construction that survives Phase 1 and Phase 3 but trips the validator:
- candidates after balance-desc sort: `[(addr_X=0x01, 1_000_000), (addr_Y=0x02, 30_000)]`
- `total_output = 950_000`, `min_input_amount = 100_000`
- Phase 1 needs both inputs (e.g. `fee_for_1 = 60_000` → 1_000_000 < 1_010_000; `fee_for_2 = 80_000` → 1_030_000 ≥ 1_030_000 ✓)
- `fee_target = addr_X`, `fee_target_max = 920_000`, `other_total = 30_000`, `fee_target_min = max(100_000, 920_000) = 920_000` — 920_000 ≤ 920_000 ✓
- Phase 4: `remaining = 30_000` → `addr_Y` inserted as 30_000 < 100_000 → rejected.
Fix options: filter candidates with `balance < min_input_amount` at the call site; refuse to insert a non-fee-target consumption that would land below the minimum (rolling residue back to the fee target, which already has remaining headroom); or bail out with a descriptive error. The existing `fee_headroom_violation_errors` test exercises only the fee-target min-input path; an analogous test for the tail input would catch this.
- [BLOCKING] lines 442-454: Fallback path is reachable via public API and produces protocol-invalid input maps
`transfer()` (line 31) is `pub` and accepts an arbitrary `fee_strategy: AddressFundsFeeStrategy` from any caller. With `InputSelection::Auto`, this routes through `select_inputs()`, which only implements protocol-correct logic for the exact shape `[DeductFromInput(0)]` (line 437-440). For every other strategy, lines 442-453 fall back to a front-consume distribution that guarantees only `Σ inputs == total_output` and ignores both fee-target headroom and `min_input_amount`.
Reachable failure: with `fee_strategy = [DeductFromInput(1)]`, candidates `[(addr_b, 20M), (addr_a, 50M)]` where `addr_a < addr_b`, and `total_output = 30M`, the fallback returns `{addr_b: 20M, addr_a: 10M}`. The protocol resolves `DeductFromInput(i)` against BTreeMap key order (`packages/rs-dpp/src/address_funds/fee_strategy/.../v0/mod.rs`), so index 1 points at `addr_b`, which is fully drained — fee deduction fails exactly like the original bug. `ReduceOutput(...)` strategies can produce structurally invalid trailing inputs for the same reason.
The doc on lines 371-375 acknowledges this as 'must be revisited if [strategy] changes', but the public API surface is wide open today. Either constrain the strategy at the entry point, return an explicit `Err` for unsupported shapes, or extend the fee-target/min-input logic to general strategies. Returning a known-suspect map silently is the riskier option — it forces a future caller to stumble into the same protocol rejection that motivated this PR.
- [SUGGESTION] lines 484-545: `total_output < min_input_amount` falls through to misleading 'Internal selection error'
When `total_output < min_input_amount` (e.g. caller asks to transfer 50_000 credits with min_input=100_000), the 1-input path computes `fee_target_min = max(min_input_amount, total_output) = 100_000 > total_output`, so `selected = {addr: 100_000}` and `input_sum = 100_000 ≠ total_output`. Phase 4's loop runs once with `remaining = total_output.saturating_sub(100_000) = 0`, then the flow trips the `debug_assert_eq!` at line 527 in debug builds and falls through to the line-538 'Internal selection error' branch in release.
The protocol disallows any transfer with `total_output < min_input_amount` (no input set can satisfy both `Σ inputs == total_output` and per-input `≥ min_input_amount`). This deserves an early, descriptive error like 'Transfer amount X below minimum Y' rather than the internal-error path that's documented as 'should never trip'. Add an early check at the top of `select_inputs` (or in the `transfer` entry-point on `outputs.values().sum()`).
…egy, retry on Phase 3 fail Addresses the second wave of review findings on PR #3554: 1. [BLOCKING] Phase 4 distribution no longer produces inputs below `min_input_amount`. `auto_select_inputs` now filters candidates with `balance < min_input_amount` upfront — they cannot legally appear in the inputs map. In Phase 4, when a non-fee-target tail entry would consume less than `min_input_amount`, the residue rolls back into the fee target's consumption (which has surplus headroom by construction). Returns a descriptive error if rollback would violate the fee-target headroom invariant. 2. [BLOCKING] `transfer()` rejects unsupported `fee_strategy` shapes for `InputSelection::Auto`. Auto-select currently only implements protocol-correct logic for `[DeductFromInput(0)]`; any other strategy returns `PlatformWalletError::AddressOperation` with a clear message redirecting callers to `InputSelection::Explicit`. Explicit paths still accept arbitrary strategies (caller's responsibility). 3. [BLOCKING] When Phase 3 (`fee_target_min > fee_target_max`) fails in `select_inputs`, the algorithm now extends the prefix with the next candidate and retries instead of erroring out. Larger prefixes may yield a different lex-smallest fee target with sufficient headroom. Errors out only when candidates are exhausted and no covering prefix is feasible. 4. [SUGGESTION] `select_inputs` returns an early descriptive error when `total_output < min_input_amount` — the protocol forbids this regardless of input shape, so an explicit error beats the internal "should never trip" branch that some callers were reaching. 5. [SUGGESTION] Existing selector tests now also build a minimal `AddressFundsTransferTransitionV0` and run `validate_structure`, asserting protocol-level validity in addition to the `Σ inputs == total_output` invariant. Catches future regressions without needing a live node. Coderabbit findings DUuz (#3554), DUu1 (#3554), E5L5 (#3554), thepastaclaw findings F9fo, GMHz, GMH5, GMH_, F9fv addressed. Outdated F9fk references the renamed test from before 9ea9e70. Nitpicks F9fz/GMID/F9f5/GMIH deferred (unreachable / low value). Verification: - cargo check --tests -p platform-wallet OK - cargo clippy --tests -p platform-wallet -- -D warnings OK - cargo fmt -p platform-wallet OK - cargo test -p platform-wallet --lib 121/121 Co-Authored-By: Claudius the Magnificent <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs (1)
607-631: Phase 5 invariant checks are partially release-protected.The
debug_assertstatements at lines 611-624 verify critical protocol invariants:
Σ inputs == total_output— also checked in release at line 626 ✓- Fee target is BTreeMap index-0 (lex-smallest)
- Fee target retains ≥
estimated_feeremaining balance- Every input ≥
min_input_amountInvariants 2-4 are only asserted in debug builds. If any of these fail in release (due to a future regression in Phase 1-4 logic), the transition would be submitted and rejected by the protocol layer rather than caught here.
Given the algorithm's structure and test coverage, these should never trip. However, promoting invariant 3 (the fee headroom check) to a release-mode error would provide defense-in-depth for the exact bug this PR fixes.
♻️ Optional: Add release-mode check for fee headroom
if input_sum != total_output { return Err(PlatformWalletError::AddressOperation(format!( "Internal selection error: Σ inputs ({}) != total_output ({})", input_sum, total_output ))); } + + let fee_target_remaining = fee_target_balance.saturating_sub(fee_target_consumed); + if fee_target_remaining < estimated_fee { + return Err(PlatformWalletError::AddressOperation(format!( + "Internal selection error: fee target {} remaining {} < estimated fee {}", + format_address(&fee_target_addr), + fee_target_remaining, + estimated_fee, + ))); + } Ok(selected)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs` around lines 607 - 631, The fee-headroom check currently uses debug_assert! on fee_target_balance.saturating_sub(fee_target_consumed) >= estimated_fee, which is only active in debug builds; replace that debug-only assertion with a release-mode runtime check inside the same scope (where selected, fee_target_balance, fee_target_consumed and estimated_fee are available) so that if the condition fails you return Err(PlatformWalletError::AddressOperation(...)) with a clear message including the computed headroom and required estimated_fee; keep the other debug_asserts as-is and ensure the new check mirrors the existing error style used for input_sum != total_output.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- Around line 607-631: The fee-headroom check currently uses debug_assert! on
fee_target_balance.saturating_sub(fee_target_consumed) >= estimated_fee, which
is only active in debug builds; replace that debug-only assertion with a
release-mode runtime check inside the same scope (where selected,
fee_target_balance, fee_target_consumed and estimated_fee are available) so that
if the condition fails you return
Err(PlatformWalletError::AddressOperation(...)) with a clear message including
the computed headroom and required estimated_fee; keep the other debug_asserts
as-is and ensure the new check mirrors the existing error style used for
input_sum != total_output.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e663ab0e-42e2-48de-b207-cd8e4fa9740b
📒 Files selected for processing (1)
packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs
…allet changes Adds `rs-platform-wallet` as a filter entry in `.github/package-filters/rs-packages-no-workflows.yml`. Without this, crate-only changes under `packages/rs-platform-wallet/` evaluate to `rs-packages = '[]'` and the `rs-workspace-tests` job in `.github/workflows/tests.yml` gates off — meaning the crate's unit tests never run in CI when only that crate is touched. This gap surfaced on PR #3554 itself: five commits, 121 unit tests, none of them executed by `Rust workspace tests` (all reported as SKIPPED). Local `cargo test -p platform-wallet --lib` was the only validation. Reviewers seeing "all green" could miss that the actual Rust validation was skipped. The filter entry mirrors the existing pattern: list the crate path and inherit the SDK alias (`*sdk`) so transitive SDK changes also trigger workspace tests for the wallet, matching how `wasm-sdk` and `rs-sdk-ffi` are wired. Co-Authored-By: Claudius the Magnificent <noreply@anthropic.com>
…allet-auto-select-inputs
…now run) The CI filter addition in 79c2b28 made `Rust workspace tests` run on `rs-platform-wallet` for the first time in a while, surfacing three pre-existing breaks that the silently-skipped pipeline had been accumulating: 1. `src/changeset/core_bridge.rs` (`build_core_changeset`) — `field_reassign_with_default` lint. `let mut cs = CoreChangeSet::default(); cs.new_utxos = ...; cs.spent_utxos = ...;` replaced with a struct literal carrying the derived values plus `..CoreChangeSet::default()` for forward-compat fields. 2. `src/wallet/apply.rs:316` — `let_unit_value` lint. `WalletInfoInterface::update_balance` returns `()`; the `let _ = ...` discards a unit value. Calling the method directly is the intended shape. 3. `tests/spv_sync.rs:74-78` — stale field access. The integration test still walked `core.chain.synced_height` even though `CoreChangeSet` was flattened (see existing rustdoc on `synced_height` direct field). Replaced with `core.synced_height` directly. None of these are bugs — clippy hardening and a stale test field that `cargo test --lib` never compiled. Verified: - `cargo clippy --workspace --tests -- -D warnings` clean - `cargo clippy -p platform-wallet --tests -- -D warnings` clean - `cargo test -p platform-wallet --lib` 121/121 Co-Authored-By: Claudius the Magnificent <noreply@anthropic.com>
… work Apply claudius:coding-best-practices rules: length cap (<=2 preferred, 3 mediocre), present-state only (no Wave/PR-number history), two-tier (strict for internal, liberal for public API rustdoc). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…o-select Extends transfer() / auto_select_inputs to accept [ReduceOutput(0)] in addition to [DeductFromInput(0)]. Output 0 absorbs the fee, so input selection skips the fee-headroom reservation. Σ inputs == Σ outputs invariant preserved via last- input trim. 5 new tests in auto_select_tests cover happy path, multi-input trim, multi- output isolation, output-too-small error, and structural validation. Resolves PR #3549 thread r-aCky's production prerequisite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## v3.1-dev #3554 +/- ##
============================================
- Coverage 87.17% 87.15% -0.03%
============================================
Files 2601 2606 +5
Lines 318220 319221 +1001
============================================
+ Hits 277409 278216 +807
- Misses 40811 41005 +194
🚀 New features to boost your workflow:
|
…allet-auto-select-inputs # Conflicts: # packages/rs-platform-wallet/src/changeset/core_bridge.rs
…educeOutput Phase 4 Annotates `select_inputs_reduce_output`'s Phase 4 fee-headroom check to document the known dpp-layer bug (platform #3040) where `estimate_min_fee` models only the static state_transition_min_fees floor and excludes storage + processing costs. For small `output[0]`, the auto-selector greenlights selections that then fail on-chain with AddressesNotEnoughFundsError. Comment-only — no behaviour change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…allet-auto-select-inputs
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
I verified the cited source on head 30e612bdd3a8445ddd16f9f78c89613414ac8a51. The selector and restore work are largely sound, but four suggestion-level issues remain: one stale public API contract, one duplicated emptiness predicate that can drop future persisted state, one new typed error surface that still collapses at the FFI boundary, and one subtle restore path that still lacks direct regression coverage.
Reviewed commit: 30e612b
🟡 4 suggestion(s)
4 additional findings
🟡 suggestion: `InputSelection::Auto` still documents the old selector behavior
packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs (lines 37-40)
The public doc comment still says auto-selection consumes addresses from lowest derivation index upward until outputs plus estimated fees are covered, but that is no longer the contract implemented by this PR. transfer() now only allows [DeductFromInput(0)] and [ReduceOutput(0)] for Auto, build_auto_select_candidates() filters out sub-minimum balances and addresses that also appear in outputs, and selection proceeds from the surviving candidates in balance-descending order. Leaving the old contract in the public enum docs will mislead downstream callers about spend order, eligibility, supported fee strategies, and the new typed empty-candidate failures.
💡 Suggested change
/// Automatically select inputs from the account, consuming addresses
/// in balance-descending order until the required amount plus
/// estimated fees is covered. Addresses with balance below the
/// protocol's per-input minimum (`min_input_amount`) are skipped,
/// and any address that also appears as a destination output is
/// excluded. Supported fee strategies: `[DeductFromInput(0)]` and
/// `[ReduceOutput(0)]`; other strategies must use `Explicit`.
Auto,
🟡 suggestion: `is_empty_no_records()` has drifted from the authoritative `CoreChangeSet::is_empty()` contract
packages/rs-platform-wallet/src/changeset/core_bridge.rs (lines 319-326)
spawn_wallet_event_adapter() uses core.is_empty_no_records() as the gate before calling persister.store(...), but this helper no longer matches CoreChangeSet::is_empty(): it ignores addresses_derived even though CoreChangeSet::is_empty() treats that field as persisted state. Current event shapes happen to also populate records or last_processed_height, so this does not drop a live event today, but it leaves a real correctness trap at the Rust→Swift persistence boundary: a future derived-address-only core changeset would be discarded before persistence or callbacks run.
💡 Suggested change
fn is_empty_no_records(&self) -> bool {
self.records.is_empty()
&& self.spent_utxos.is_empty()
&& self.new_utxos.is_empty()
&& self.instant_locks_for_non_final_records.is_empty()
&& self.last_processed_height.is_none()
&& self.synced_height.is_none()
&& self.addresses_derived.is_empty()
}
🟡 suggestion: New typed auto-selection failures still collapse to `ErrorUnknown` across the FFI boundary
packages/rs-platform-wallet-ffi/src/error.rs (lines 157-160)
This PR introduced structured Rust-side failures (OnlyOutputAddressesFunded and OnlyDustInputs) so callers can distinguish recovery paths without parsing strings, and those variants are reachable from the exported transfer path. The FFI conversion still maps every PlatformWalletError to PlatformWalletFFIResultCode::ErrorUnknown, and Swift reconstructs its domain error from that numeric code in PlatformWalletResult.swift:144-164. As a result, the new typed contract stops at the Rust boundary and Swift/iOS callers are pushed back to brittle message inspection.
🟡 suggestion: `initialize_from_persisted` adds restart-critical balance hydration with no direct regression test
packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs (lines 101-129)
initialize_from_persisted() now does more than rebuild the provider: it first pushes each persisted balance back into ManagedPlatformAccount with set_address_credit_balance(...) before calling PlatformPaymentAddressProvider::from_persisted(). That ordering is important because the transfer and withdrawal auto-selectors read account.address_credit_balance() immediately after restore; without this hydration they would report zero spendable funds until a fresh BLAST round rediscovered each known address. I did not find a unit or integration test that restores a non-empty PlatformAddressSyncStartState and asserts that balances are usable immediately after startup, so this new behavior can regress silently.
🤖 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/rs-platform-wallet/src/wallet/platform_addresses/mod.rs`:
- [SUGGESTION] lines 37-40: `InputSelection::Auto` still documents the old selector behavior
The public doc comment still says auto-selection consumes addresses from lowest derivation index upward until outputs plus estimated fees are covered, but that is no longer the contract implemented by this PR. `transfer()` now only allows `[DeductFromInput(0)]` and `[ReduceOutput(0)]` for `Auto`, `build_auto_select_candidates()` filters out sub-minimum balances and addresses that also appear in `outputs`, and selection proceeds from the surviving candidates in balance-descending order. Leaving the old contract in the public enum docs will mislead downstream callers about spend order, eligibility, supported fee strategies, and the new typed empty-candidate failures.
In `packages/rs-platform-wallet/src/changeset/core_bridge.rs`:
- [SUGGESTION] lines 319-326: `is_empty_no_records()` has drifted from the authoritative `CoreChangeSet::is_empty()` contract
`spawn_wallet_event_adapter()` uses `core.is_empty_no_records()` as the gate before calling `persister.store(...)`, but this helper no longer matches `CoreChangeSet::is_empty()`: it ignores `addresses_derived` even though `CoreChangeSet::is_empty()` treats that field as persisted state. Current event shapes happen to also populate `records` or `last_processed_height`, so this does not drop a live event today, but it leaves a real correctness trap at the Rust→Swift persistence boundary: a future derived-address-only core changeset would be discarded before persistence or callbacks run.
In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] lines 157-160: New typed auto-selection failures still collapse to `ErrorUnknown` across the FFI boundary
This PR introduced structured Rust-side failures (`OnlyOutputAddressesFunded` and `OnlyDustInputs`) so callers can distinguish recovery paths without parsing strings, and those variants are reachable from the exported transfer path. The FFI conversion still maps every `PlatformWalletError` to `PlatformWalletFFIResultCode::ErrorUnknown`, and Swift reconstructs its domain error from that numeric code in `PlatformWalletResult.swift:144-164`. As a result, the new typed contract stops at the Rust boundary and Swift/iOS callers are pushed back to brittle message inspection.
In `packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs`:
- [SUGGESTION] lines 101-129: `initialize_from_persisted` adds restart-critical balance hydration with no direct regression test
`initialize_from_persisted()` now does more than rebuild the provider: it first pushes each persisted balance back into `ManagedPlatformAccount` with `set_address_credit_balance(...)` before calling `PlatformPaymentAddressProvider::from_persisted()`. That ordering is important because the transfer and withdrawal auto-selectors read `account.address_credit_balance()` immediately after restore; without this hydration they would report zero spendable funds until a fresh BLAST round rediscovered each known address. I did not find a unit or integration test that restores a non-empty `PlatformAddressSyncStartState` and asserts that balances are usable immediately after startup, so this new behavior can regress silently.
There was a problem hiding this comment.
Code Review
All four prior findings reconciled and STILL VALID at head 436d38b. The latest delta is a v3.1-dev merge that touched is_empty_no_records() to add last_applied_chain_lock but did not address the long-standing addresses_derived asymmetry. No new findings. All four are suggestion-level; no blocking issues.
Reviewed commit: 436d38b
🟡 4 suggestion(s)
Prior finding reconciliation
- STILL VALID: 4 prior findings carried forward below.
- FIXED: 0
- OUTDATED: 0
- INTENTIONALLY DEFERRED: 0
New findings in latest delta
None. The latest delta is a v3.1-dev merge; reviewers did not verify any new issues beyond the carried-forward findings.
🟡 suggestion: `InputSelection::Auto` docstring still describes the pre-PR selection contract
packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs (lines 37-40)
Verified at HEAD 436d38b: lines 37-40 still say Auto consumes addresses "from lowest derivation index to highest until the required amount plus estimated fees is covered." InputSelection is pub, so this is API-surface documentation. The implementation diverges on five externally observable axes:
transfer()only accepts fee strategies[DeductFromInput(0)]and[ReduceOutput(0)]underAuto; other shapes return a typed error.build_auto_select_candidatessorts candidates balance-descending, not by derivation index.- Addresses with balance below
min_input_amountare filtered out. - Any address that also appears as a destination output is excluded (the protocol forbids the same address being both input and output of a single transition).
- Empty-candidate failures surface as the typed
PlatformWalletError::OnlyOutputAddressesFundedorOnlyDustInputspayloads.
Downstream Rust callers reading the docstring get the wrong mental model for spend order, eligibility, accepted fee strategies, and the structured failure payload they need to handle.
💡 Suggested change
/// Automatically select inputs from the account, consuming addresses
/// in balance-descending order until the required amount plus
/// estimated fees is covered. Addresses with balance below the
/// protocol's per-input minimum (`min_input_amount`) are skipped,
/// and any address that is also a destination output is excluded
/// (the protocol forbids the same address being both input and
/// output of a single transition); when this filtering empties the
/// candidate set, `transfer()` returns the typed
/// [`PlatformWalletError::OnlyOutputAddressesFunded`] or
/// [`PlatformWalletError::OnlyDustInputs`] payload so callers can
/// offer rotation vs. consolidation guidance.
///
/// Supported fee strategies: `[DeductFromInput(0)]` and
/// `[ReduceOutput(0)]`. Other strategies must use `Explicit`.
Auto,
🟡 suggestion: `is_empty_no_records()` still diverges from `CoreChangeSet::is_empty()` on `addresses_derived`
packages/rs-platform-wallet/src/changeset/core_bridge.rs (lines 349-357)
Verified at HEAD 436d38b. CoreChangeSet::is_empty() (changeset.rs:227-236) ends with && self.addresses_derived.is_empty() && self.last_applied_chain_lock.is_none(). The fast-path adapter helper at core_bridge.rs:349-357 now includes last_applied_chain_lock but still omits addresses_derived.
The latest v3.1-dev merge modified this predicate (adding last_applied_chain_lock) without aligning the long-standing addresses_derived divergence — strong evidence that the maintainer recognizes these two predicates must stay in sync but missed this clause. The helper is the sole gate on whether persister.store(...) runs at the Rust→Swift FFI boundary; addresses_derived is exactly the payload later marshalled into AccountAddressPoolFFI and delivered to Swift through on_persist_account_address_pools_fn.
Under current event projections (TransactionDetected populates records, BlockProcessed sets last_processed_height, ChainLockProcessed sets last_applied_chain_lock) this is not a live dropped-callback bug, but a future derived-address-only event variant or projection refactor would silently bypass persistence while CoreChangeSet itself treats it as persisted state. Mirror the authoritative predicate to keep the two definitions in lockstep.
💡 Suggested change
fn is_empty_no_records(&self) -> bool {
self.records.is_empty()
&& self.spent_utxos.is_empty()
&& self.new_utxos.is_empty()
&& self.instant_locks_for_non_final_records.is_empty()
&& self.last_processed_height.is_none()
&& self.synced_height.is_none()
&& self.last_applied_chain_lock.is_none()
&& self.addresses_derived.is_empty()
}
🟡 suggestion: Typed `OnlyOutputAddressesFunded` / `OnlyDustInputs` failures collapse to `ErrorUnknown` across the FFI boundary
packages/rs-platform-wallet-ffi/src/error.rs (lines 157-161)
Verified at HEAD 436d38b. The blanket impl From<PlatformWalletError> for PlatformWalletFFIResult at error.rs:157-161 still maps every variant to PlatformWalletFFIResultCode::ErrorUnknown and forwards only error.to_string().
This PR deliberately split the prior NoSelectableInputs struct variant into OnlyOutputAddressesFunded { funded_outputs, min_input_amount } and OnlyDustInputs { sub_min_count, sub_min_aggregate, min_input_amount } (rs-platform-wallet/src/error.rs:77-103) precisely so callers can branch on the failure shape (rotate to a fresh receive address vs. consolidate dust) without parsing message strings. Both variants are reachable through the exported platform_address_wallet_transfer() path (transfer.rs:23-71), but Swift's PlatformWalletError.init(result:) (PlatformWalletResult.swift:144-166) dispatches purely on result.code, so iOS callers crossing the FFI are pushed back to substring-matching the message — exactly the brittle pattern the typed split was designed to eliminate.
Add dedicated result codes (e.g., ErrorOutputCollision / ErrorDustInputs, or a single ErrorNoSelectableInputs) and a match-arm conversion so the typed contract crosses the language boundary intact.
🟡 suggestion: `initialize_from_persisted` restart-critical balance hydration still has no direct regression test
packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs (lines 101-140)
Verified at HEAD 436d38b. The hydration block at wallet.rs:111-125 first acquires a write lock on wallet_manager, walks every persisted (account_index, per-address funds) entry and pushes each funds.balance into ManagedPlatformAccount via set_address_credit_balance(*p2pkh, funds.balance, None), then drops the write lock and calls PlatformPaymentAddressProvider::from_persisted(...).
The ordering is load-bearing because auto_select_inputs reads account.address_credit_balance() immediately after restore. Without this hydration, restored wallets would report OnlyDustInputs or insufficient-balance to the caller until a fresh BLAST round rediscovered every known address — a silent denial-of-service for the transfer/withdrawal API at startup.
The behavior is invoked from three sites (platform_wallet.rs:463, manager/load.rs:148, manager/wallet_lifecycle.rs:295), but no unit/integration test builds a non-empty PlatformAddressSyncStartState, calls initialize_from_persisted, and asserts account.address_credit_balance(&p2pkh) == funds.balance for each persisted address. A small #[tokio::test] would pin the invariant and document the write-lock-drop-before-read-lock ordering for future maintainers.
🤖 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/rs-platform-wallet/src/wallet/platform_addresses/mod.rs`:
- [SUGGESTION] lines 37-40: `InputSelection::Auto` docstring still describes the pre-PR selection contract
Verified at HEAD 436d38bd: lines 37-40 still say `Auto` consumes addresses "from lowest derivation index to highest until the required amount plus estimated fees is covered." `InputSelection` is `pub`, so this is API-surface documentation. The implementation diverges on five externally observable axes:
1. `transfer()` only accepts fee strategies `[DeductFromInput(0)]` and `[ReduceOutput(0)]` under `Auto`; other shapes return a typed error.
2. `build_auto_select_candidates` sorts candidates **balance-descending**, not by derivation index.
3. Addresses with balance below `min_input_amount` are filtered out.
4. Any address that also appears as a destination output is excluded (the protocol forbids the same address being both input and output of a single transition).
5. Empty-candidate failures surface as the typed `PlatformWalletError::OnlyOutputAddressesFunded` or `OnlyDustInputs` payloads.
Downstream Rust callers reading the docstring get the wrong mental model for spend order, eligibility, accepted fee strategies, and the structured failure payload they need to handle.
In `packages/rs-platform-wallet/src/changeset/core_bridge.rs`:
- [SUGGESTION] lines 349-357: `is_empty_no_records()` still diverges from `CoreChangeSet::is_empty()` on `addresses_derived`
Verified at HEAD 436d38bd. `CoreChangeSet::is_empty()` (changeset.rs:227-236) ends with `&& self.addresses_derived.is_empty() && self.last_applied_chain_lock.is_none()`. The fast-path adapter helper at core_bridge.rs:349-357 now includes `last_applied_chain_lock` but still omits `addresses_derived`.
The latest v3.1-dev merge **modified** this predicate (adding `last_applied_chain_lock`) without aligning the long-standing `addresses_derived` divergence — strong evidence that the maintainer recognizes these two predicates must stay in sync but missed this clause. The helper is the sole gate on whether `persister.store(...)` runs at the Rust→Swift FFI boundary; `addresses_derived` is exactly the payload later marshalled into `AccountAddressPoolFFI` and delivered to Swift through `on_persist_account_address_pools_fn`.
Under current event projections (`TransactionDetected` populates `records`, `BlockProcessed` sets `last_processed_height`, `ChainLockProcessed` sets `last_applied_chain_lock`) this is not a live dropped-callback bug, but a future derived-address-only event variant or projection refactor would silently bypass persistence while `CoreChangeSet` itself treats it as persisted state. Mirror the authoritative predicate to keep the two definitions in lockstep.
In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] lines 157-161: Typed `OnlyOutputAddressesFunded` / `OnlyDustInputs` failures collapse to `ErrorUnknown` across the FFI boundary
Verified at HEAD 436d38bd. The blanket `impl From<PlatformWalletError> for PlatformWalletFFIResult` at error.rs:157-161 still maps every variant to `PlatformWalletFFIResultCode::ErrorUnknown` and forwards only `error.to_string()`.
This PR deliberately split the prior `NoSelectableInputs` struct variant into `OnlyOutputAddressesFunded { funded_outputs, min_input_amount }` and `OnlyDustInputs { sub_min_count, sub_min_aggregate, min_input_amount }` (rs-platform-wallet/src/error.rs:77-103) precisely so callers can branch on the failure shape (rotate to a fresh receive address vs. consolidate dust) without parsing message strings. Both variants are reachable through the exported `platform_address_wallet_transfer()` path (transfer.rs:23-71), but Swift's `PlatformWalletError.init(result:)` (PlatformWalletResult.swift:144-166) dispatches purely on `result.code`, so iOS callers crossing the FFI are pushed back to substring-matching the message — exactly the brittle pattern the typed split was designed to eliminate.
Add dedicated result codes (e.g., `ErrorOutputCollision` / `ErrorDustInputs`, or a single `ErrorNoSelectableInputs`) and a `match`-arm conversion so the typed contract crosses the language boundary intact.
In `packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs`:
- [SUGGESTION] lines 101-140: `initialize_from_persisted` restart-critical balance hydration still has no direct regression test
Verified at HEAD 436d38bd. The hydration block at wallet.rs:111-125 first acquires a write lock on `wallet_manager`, walks every persisted `(account_index, per-address funds)` entry and pushes each `funds.balance` into `ManagedPlatformAccount` via `set_address_credit_balance(*p2pkh, funds.balance, None)`, then drops the write lock and calls `PlatformPaymentAddressProvider::from_persisted(...)`.
The ordering is load-bearing because `auto_select_inputs` reads `account.address_credit_balance()` immediately after restore. Without this hydration, restored wallets would report `OnlyDustInputs` or insufficient-balance to the caller until a fresh BLAST round rediscovered every known address — a silent denial-of-service for the transfer/withdrawal API at startup.
The behavior is invoked from three sites (`platform_wallet.rs:463`, `manager/load.rs:148`, `manager/wallet_lifecycle.rs:295`), but no unit/integration test builds a non-empty `PlatformAddressSyncStartState`, calls `initialize_from_persisted`, and asserts `account.address_credit_balance(&p2pkh) == funds.balance` for each persisted address. A small `#[tokio::test]` would pin the invariant and document the write-lock-drop-before-read-lock ordering for future maintainers.
…allet-auto-select-inputs # Conflicts: # .github/package-filters/rs-packages-no-workflows.yml
|
✅ 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: "899f25467dd1297085ee8d76f24002a751a5aa2a83d332a58002e83dd88bc5e4"
)Xcode manual integration:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
I verified the carried-forward findings against the checked-out head d5d2b3d271c1278b8016651773fa5951ca9a23e9 and all four still apply. There are no CodeRabbit comments to validate and I did not find any additional issues beyond these existing API-contract, persistence-boundary, FFI-surface, and regression-coverage gaps.
Reviewed commit: d5d2b3d2
🟡 4 suggestion(s)
Carried-forward prior findings
All four prior findings from 436d38bd are still valid on this head:
- [suggestion]
InputSelection::Autostill documents the old selector contract- Location:
packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs:37-40 - This public enum advertises auto-selection as consuming addresses from lowest derivation index upward, but the current implementation does not do that.
transfer()now rejects every auto-selection fee strategy except[DeductFromInput(0)]and[ReduceOutput(0)],build_auto_select_candidates()drops balances belowmin_input_amount, excludes addresses that also appear inoutputs, sorts candidates by balance descending, and can return the typedOnlyOutputAddressesFunded/OnlyDustInputserrors. Leaving the old description here misstates the public API and will send downstream callers toward the wrong fee-strategy and recovery assumptions. - Suggested fix:
- Location:
/// Automatically select inputs from the account, consuming addresses
/// in balance-descending order until the required amount plus
/// estimated fees is covered. Addresses with balance below the
/// protocol's per-input minimum (`min_input_amount`) are skipped,
/// and any address that also appears as a destination output is
/// excluded (the protocol forbids the same address being both input
/// and output of a single transition); when this filtering empties
/// the candidate set, `transfer()` returns the typed
/// [`PlatformWalletError::OnlyOutputAddressesFunded`] or
/// [`PlatformWalletError::OnlyDustInputs`] payload so callers can
/// offer rotation vs. consolidation guidance.
///
/// Supported fee strategies: `[DeductFromInput(0)]` and
/// `[ReduceOutput(0)]`. Other strategies must use `Explicit`.
Auto,- [suggestion]
is_empty_no_records()uses a different emptiness contract thanCoreChangeSet::is_empty()- Location:
packages/rs-platform-wallet/src/changeset/core_bridge.rs:349-357 - The event adapter drops a
CoreChangeSetentirely whenis_empty_no_records()returns true, but this helper omitsaddresses_derivedeven though the canonicalCoreChangeSet::is_empty()treatsaddresses_derivedas persisted state. That leaves the Rust-to-persistence boundary with two conflicting definitions of "empty". Today this is masked because the current event projections that carryaddresses_derivedalso populate other fields, but a derived-address-only event or refactor would be silently discarded beforepersister.store(...)runs, even thoughCoreChangeSetitself considers that batch non-empty. - Suggested fix:
- Location:
fn is_empty_no_records(&self) -> bool {
self.records.is_empty()
&& self.spent_utxos.is_empty()
&& self.new_utxos.is_empty()
&& self.instant_locks_for_non_final_records.is_empty()
&& self.last_processed_height.is_none()
&& self.synced_height.is_none()
&& self.last_applied_chain_lock.is_none()
&& self.addresses_derived.is_empty()
}-
[suggestion] The new auto-selection failure variants are flattened away at the FFI boundary
- Location:
packages/rs-platform-wallet-ffi/src/error.rs:157-160 - Rust now exposes
OnlyOutputAddressesFundedandOnlyDustInputsas distinctPlatformWalletErrorvariants so callers can choose different recovery paths, but the blanketFrom<PlatformWalletError>conversion still maps every wallet error toPlatformWalletFFIResultCode::ErrorUnknown. Swift constructs its typed errors fromresult.code, so the new distinction is not observable outside Rust and FFI consumers are forced back to parsing strings. If these variants are part of the intended contract change, the exported result-code surface needs matching typed cases.
- Location:
-
[suggestion]
initialize_from_persistedstill lacks a direct regression test for balance hydration- Location:
packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs:101-132 - This restore path now depends on a specific ordering: it writes each persisted
funds.balanceback intoManagedPlatformAccountbefore rebuilding the provider, and the transfer/withdrawal auto-selectors immediately readaccount.address_credit_balance()after restore. I did not find a unit or integration test that constructs a non-emptyPlatformAddressSyncStartState, callsinitialize_from_persisted, and asserts those balances are available without waiting for another BLAST sync. That makes a restart-visible regression easy to miss if this hydration step is refactored later.
- Location:
New findings in latest delta
None beyond the carried-forward issues above.
QA-001: `transfer_with_change_address` computed `change_amount = input_sum
− user_output_sum` and unconditionally inserted it. The protocol enforces
a per-output minimum (`OutputBelowMinimumError`, code 10810) via
`platform_version.dpp.state_transitions.address_funds.min_output_amount`,
so a residual in the `(0, min_output_amount)` band would ship a transition
the chain rejects.
Plumb `platform_version` through `augment_outputs_with_change`, add a
typed `PlatformWalletError::ChangeBelowMinimumOutput { change_amount,
min_output_amount }` variant, and reject the band before broadcast. Adds
the missing `(0, min_output_amount)` band test alongside the existing
residual=0 / residual=55M coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CMT-001: the public `InputSelection::Auto` rustdoc described the
pre-PR contract ("consume addresses from lowest derivation index
upward"). The current implementation orders candidates
balance-descending, drops dust below `min_input_amount`, excludes
output-collision addresses, restricts fee strategies to
`[DeductFromInput(0)]` / `[ReduceOutput(0)]`, and surfaces typed
`OnlyOutputAddressesFunded` / `OnlyDustInputs` errors. Rewrite the
docstring to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CMT-002: the adapter-side shortcut treated a changeset carrying only derived addresses as empty and dropped it before reaching the persister, while the canonical `CoreChangeSet::is_empty()` counts `addresses_derived` as persisted state. Align the two definitions so no future derived-address-only flow silently loses its event. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ding QA-003: both `select_inputs_deduct_from_input` and `select_inputs_reduce_output` rely on a balance-descending invariant (`select_inputs_reduce_output`'s Phase-2 trim and Phase-3 donor-lift treat the last entry as the smallest; `select_inputs_deduct_from_input` grows the smallest covering prefix). Production callers pre-sort via `build_auto_select_candidates`, but direct test / future callers would silently miscompute. Re-sort defensively at the top of each selector and document the invariant; updates two tests whose fixtures relied on the caller's accidental order. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QA-004: `select_inputs_deduct_from_input` sized `estimated_fee` against `prefix.len()` in Phase 1, but the Phase-4 residue-fold can leave `selected.len() < prefix.len()` when sub-minimum tail entries collapse into the fee target. The post-residue headroom recheck then over-bounded the fee target's max consumption and could falsely reject feasible selections as "Cannot satisfy fee headroom". Recompute `estimated_fee` against the actual selected input count and re-derive `fee_target_consumed` from `total_output − Σ other selected`. Adds a 3-entry/2-selected regression test alongside the existing sub-minimum redistribution coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QA-005: when both "funded-but-also-output" and "dust" diagnostics applied, `detect_no_selectable_inputs` returned `OnlyOutputAddressesFunded` without any breadcrumb to the simultaneous dust shape. A UI rotating the receive address on the first error would hit `OnlyDustInputs` on the second try with no prior warning. Add `sub_min_count` and `sub_min_aggregate` fields to the variant; the detector now populates them in the combined case so the caller can surface both shapes in one go. Output-collision still wins the primary classification (more actionable fix), the dust info is auxiliary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QA-006: `transfer_with_change_address` accepts the input map verbatim from the caller under `Explicit` / `ExplicitWithNonces`. The shared `saturating_sum_credits` helper silently saturated to `u64::MAX` on a bogus FFI sum, muddying the downstream protocol diagnostic. Introduce `checked_sum_credits` (try-fold + `Credits::checked_add`) and route the two caller-supplied input sums through it. Saturating helper stays for wallet-derived sums where the supply invariant is trusted. Adds a typed `PlatformWalletError::InputSumOverflow` variant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_addr QA-002: outputs were a `BTreeMap<PlatformAddress, Credits>`, so under `[ReduceOutput(0)]` "output 0" was always the lex-smallest key — never first-inserted. A change address that happened to be lex-smaller than every user output would silently absorb the fee instead of the caller-declared target. Σ invariants held; UX expectations did not. Switch the public `PlatformAddressWallet::transfer` and `transfer_with_change_address` outputs parameter to `indexmap::IndexMap<PlatformAddress, Credits>` so caller insertion order is preserved (useful for debugging and UI), and document the lex-key constraint DPP imposes at the chain layer. Reject the `[ReduceOutput(0)] + Some(change_addr)` case where `change_addr` is lex-smaller than every user output, since that is the only path where the surprise can ship a transition the caller didn't intend. Mirror the change at the FFI boundary: `parse_outputs` returns `IndexMap`, with the FFI call-site array order surviving the boundary. BREAKING CHANGE: `PlatformAddressWallet::transfer` and `transfer_with_change_address` now take `indexmap::IndexMap` for the outputs / user_outputs parameter instead of `std::collections::BTreeMap`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CMT-003: `From<PlatformWalletError>` collapsed every variant to `ErrorUnknown`, so Swift callers building typed errors from `result.code` lost the distinction between `OnlyOutputAddressesFunded` and `OnlyDustInputs` introduced upstream. That defeats the entire reason the typed Rust variants exist. Add `ErrorOnlyOutputAddressesFunded` (= 13) and `ErrorOnlyDustInputs` (= 14) to `PlatformWalletFFIResultCode`, route the `From` impl, and mirror in Swift: new `PlatformWalletResultCode` cases, `init(ffi:)` arms via the cbindgen `PLATFORM_WALLET_FFI_RESULT_CODE_*` constants, and typed `PlatformWalletError` cases. Adds a table-driven Rust test covering both new variants plus the catch-all fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QA-007 (defer): annotate the auto-select snapshot site with the pre-existing read-snapshot vs broadcast race; the new headroom math leans harder on the snapshot but fixing the race is out of this PR's scope. CMT-004 (defer): annotate `initialize_from_persisted` with a TODO noting the missing direct regression test for restart-time balance hydration; track separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cosmetic only — `cargo fmt --all` over the touched file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Carried-forward prior findings: none. Three prior issues are fixed in 295762375685bb1df67b6f961acf77b99abbdf4b, and the remaining balance-hydration regression-test gap is explicitly deferred with a TODO rather than resolved. New findings in the latest delta: one blocking Swift transfer bug and two Rust/FFI correctness issues remain.
🔴 1 blocking | 🟡 2 suggestion(s)
3 additional finding(s) omitted (not in diff).
Review details
🤖 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] lines 281-300: Swift still targets the change output by insertion index even though Rust/DPP reduce outputs by lexicographic key order
This wrapper appends change last and then sends `ReduceOutput(change_index)` using `ffiOutputs.count - 1`, which assumes the chain will treat the last appended output as the fee target. That assumption is false in the current Rust implementation: `PlatformAddressWallet::transfer()` converts the caller-facing `IndexMap` into a `BTreeMap`, and its own docs now state that output index 0 is determined by lexicographic address order, not insertion order. A change address that sorts before any recipient will therefore become DPP's reduced output, so the fee is deducted from the wrong entry and a recipient can receive less than the amount Swift promised. Rust added a guard for this in `transfer_with_change_address`, but this Swift path bypasses that API and reintroduces the same bug at the FFI boundary.
In `packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- [SUGGESTION] lines 121-154: Transfer persistence records external recipient addresses as wallet-owned state
`transfer_address_funds` proves and returns address info for the full `inputs ∪ outputs` set, not just wallet-owned inputs. This loop writes every returned `P2pkh` into the local account with `set_address_credit_balance`, and `ManagedPlatformAccount::contains_platform_address()` treats any address present in `address_balances` as belonging to the account. When the proved address is not actually in the wallet's derived-address pool, the code still persists it with `address_index = 0`; on restore, `insert_persisted_entry()` inserts that row back into the account state as if it were an owned address. A single transfer to an external address can therefore poison later balance/account views and persisted wallet state with addresses the wallet cannot sign for.
In `packages/rs-platform-wallet-ffi/src/platform_address_types.rs`:
- [SUGGESTION] lines 203-205: `parse_outputs` silently coalesces duplicate destination addresses instead of rejecting them
The FFI parser still feeds each output through `map.insert(addr, entry.balance)`, so non-Rust callers can pass the same destination address multiple times and only the last balance survives. The Rust API cannot express duplicate keys, which makes this parser the only place to reject them before the wallet builds a different output set than the caller supplied. That mismatch is now more dangerous because callers may compute change or validate `Σ inputs == Σ outputs` from the original array while the wallet broadcasts the deduplicated map.
Reviewed commit: 29576237
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
I verified the carried-forward findings against the checked-out head 94bbb6453d7dce30e828d14214827d4f9f5db2aa. All three still apply in the current source: the Swift explicit-change wrapper still targets ReduceOutput by insertion index, transfer persistence still records non-derived recipient addresses into persisted wallet-owned state, and the FFI output parser still silently overwrites duplicate destination rows. Earlier issues around InputSelection::Auto documentation, CoreChangeSet::is_empty_no_records(), and typed FFI error routing are fixed on this SHA; the restore-hydration regression test is still intentionally deferred and documented with TODO(CMT-004).
Reviewed commit: 94bbb645
🔴 1 blocking | 🟡 2 suggestion(s)
These are carried-forward findings revalidated on the current head; GitHub could not map them cleanly to fresh inline diff comments, so I’m posting the details here.
1. Blocking: Swift explicit-change transfers still target the fee reduction by insertion index instead of DPP output order
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift:281-316
This wrapper appends the change output last and then sends ReduceOutput(ffiOutputs.count - 1), but the Rust wallet does not preserve insertion indexes for fee targeting. platform_address_wallet_transfer parses the array and calls PlatformAddressWallet::transfer(), which converts outputs to a BTreeMap; the documented contract there is that output indexes are interpreted in lexicographic PlatformAddress order, not Swift insertion order. If the chosen change address sorts before a recipient, DPP will reduce a different output than Swift intended, so the broadcast transition can underpay a recipient even though Swift presented the fee as coming from change. Rust added a guard for this case in transfer_with_change_address(), but this FFI entry point still bypasses that protection.
2. Suggestion: Transfer persistence still stores external recipient addresses as if they belonged to the wallet account
packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs:121-154
This loop persists every address returned by transfer_address_funds, even though that result set includes recipient outputs as well as wallet-owned inputs. For any returned P2pkh that is not in the account's derived address pool, the code still calls set_address_credit_balance(...) and then falls back to address_index = 0 before pushing a persisted PlatformAddressBalanceEntry. On restore, insert_persisted_entry() reinserts that row into the provider's (address_index -> address) map and found balances, and initialize_from_persisted() rehydrates those persisted balances back into ManagedPlatformAccount. A transfer to an external address therefore leaks untrusted recipient data into privileged wallet state and can corrupt the restored account/address mapping.
3. Suggestion: parse_outputs still silently coalesces duplicate destination addresses
packages/rs-platform-wallet-ffi/src/platform_address_types.rs:203-205
The FFI boundary accepts an ordered output array, but this parser inserts each row into an IndexMap with map.insert(addr, entry.balance). If a non-Rust caller supplies the same destination address twice, the earlier amount is overwritten and Rust signs a different transfer than the caller constructed. Because Rust-native callers cannot express duplicate keys, this parser is the right place to reject the ambiguity instead of silently changing the spend request across the language boundary.
Suggested replacement:
for entry in std::slice::from_raw_parts(ptr, count) {
let addr = PlatformAddress::try_from(entry.address)?;
if map.contains_key(&addr) {
return Err("Duplicate output address");
}
map.insert(addr, entry.balance);
}
Prior findings resolved/deferred
InputSelection::Autodocumentation did not describe the actual supported fee strategies — Fixed inpackages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs: the public docs and runtime check now state that auto-selection only supports[DeductFromInput(0)]and[ReduceOutput(0)].CoreChangeSet::is_empty_no_records()could drop chain-lock-only or other metadata-only rounds — Fixed inpackages/rs-platform-wallet/src/changeset/core_bridge.rs:is_empty_no_records()now checksinstant_locks_for_non_final_records,last_applied_chain_lock, andaddresses_derived, so metadata-only persistence rounds are no longer discarded.- FFI collapsed
OnlyOutputAddressesFundedandOnlyDustInputsinto the generic unknown error path — Fixed inpackages/rs-platform-wallet-ffi/src/error.rsandpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift: Rust now emits dedicated result codes 13 and 14, and Swift maps them to typed error cases. - Missing regression test for restore-time platform-address balance hydration — Intentionally deferred on this SHA.
packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rscarriesTODO(CMT-004)documenting thatinitialize_from_persistedstill lacks a direct regression test for restart-time balance hydration.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
I revalidated the reported wallet/FFI issues against the checked-out head ceb4fc87035cef64acadeaec6f2d22af2274d7cf. All three Codex findings are real on this SHA, and the Swift transfer issue remains blocking because the wrapper still targets fee reduction by array position while the Rust/DPP path interprets ReduceOutput after lexicographic output canonicalization.
🔴 1 blocking | 🟡 2 suggestion(s)
3 additional finding(s) omitted (not in diff).
|
Details for the carried-forward findings in my
|
Issue
auto_select_inputsinpackages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rswas inserting each selected address with its full balance as the input'sCreditsvalue, then returning as soon as accumulated coveredoutput + fee. The address-funds-transfer protocol enforcesΣ inputs.credits == Σ outputs.credits(strict equality), so a bank with ~500B credits funding a 50M output produced:Verified at
rs-dpp/.../address_funds_transfer_transition/v0/state_transition_validation.rsand asserted on-chain byrs-drive-abci/.../address_funds_transfer/tests.rs::test_input_balance_decreased_correctly(new_balance == initial_balance - transfer_amount - fee).Protocol semantics (now respected)
inputs[addr].credits= consumed amount fromaddroutputs[addr]= credited amount toaddrΣ inputs.credits == Σ outputs.credits(strict equality)AddressFundsFeeStrategy.DeductFromInput(0)reduces the remaining balance by the fee — never the inputs map'sCreditsvalueconsumed >= min_input_amount(currently 100_000)What changed
auto_select_inputsnow respects the protocol contract:Σ inputs.credits == Σ outputs.credits(strict equality) — selection trims so the inputs map sums to exactlytotal_output.DeductFromInput(0)target — the lex-smallest selected input retains at leastestimated_feeof remaining balance.min_input_amountenforcement — candidates below the floor are filtered upfront; Phase-4 distribution rolls sub-minimum residue into the fee target.dash-evo-tool's allocator; collapses many cases to a single input.InputSelection::Autofee strategies supported:[DeductFromInput(0)]and[ReduceOutput(0)]. Any other shape returns a clear error redirecting toInputSelection::Explicit.checked_add/saturating_subonCreditsthroughout the selector path; overflow produces typed errors instead of wrapping.fee_target_min > fee_target_max, the algorithm extends the prefix with the next candidate and retries instead of erroring out.CI gap also closed in this PR:
rs-platform-walletwas missing from.github/package-filters/rs-packages-no-workflows.yml, so Rust workspace tests had been skipping silently. The filter entry mirrors the existing pattern (path +*sdkalias). The previously-hidden Rust 1.92 clippy lints (incore_bridge.rs,wallet/apply.rs:316,tests/spv_sync.rs) are cleared in the same PR because they were blocking once CI started exercising the crate.Commit history (initial 5-commit review wave for blame)
aaf8be74— initialΣ inputs == Σ outputsfix. Extracted the selection loop into a pure module-scope helperselect_inputsthat walks candidates and trims the result so the inputs map sums to exactlytotal_output.9ea9e703— fee-headroom guarantee atDeductFromInput(0)target (CodeRabbit critical). The original fix proved aggregate balance covered the fee but not that the specific fee-bearing input had remaining headroom. Now identifies the prospective fee target (lex-smallest of selected) and reserves at leastestimated_feeof remaining balance on it.687b1f86— protocol-level reproduction test. Reconstructs the OLD buggy selector output for the CodeRabbit example, feeds the post-consumptioninput_current_balancesthroughdpp::address_funds::fee_strategy::deduct_fee_from_outputs_or_remaining_balance_of_inputs, and asserts!fee_fully_covered. Proves the rejection at the protocol layer rather than asserting "the new output looks different."60f7850a— sort candidates by balance descending (mirrorsdash-evo-tool's allocator). Reduces the frequency of multi-input cases — when the largest single balance coverstotal_output + fee, the result is a 1-input map and the lex-smallest fee-target headroom logic doesn't fire at all. Bonus:fee_headroom_violation_errorsnow produces a debuggable error message.9ff937ff— second review wave (4 blocking, 1 suggestion):min_input_amountenforcement.auto_select_inputsfilters candidates <min_input_amountupfront; Phase 4 distribution rolls any sub-minimum tail residue back into the fee target's consumption rather than producing anInputBelowMinimumError-prone tail.fee_strategyrestriction intransfer().InputSelection::Autonow rejects any shape other than[DeductFromInput(0)]with a clear redirect toInputSelection::Explicit. The previous fallback path was publicly reachable but only protocol-correct for that single shape.fee_target_min > fee_target_max, the algorithm extends the prefix with the next candidate and retries instead of erroring out — larger prefixes can yield a different lex-smallest fee target with sufficient headroom.total_output < min_input_amounterror (replaces the internal-error fallthrough).assert_selection_validateshelper builds anAddressFundsTransferTransitionV0from each selector test's output and runsvalidate_structure. Catches future protocol-level regressions without depending on testnet.CI / infrastructure (3 commits)
79c2b285—ci(rs-packages-filter): trigger Rust workspace tests on rs-platform-wallet changes. The path filter at.github/package-filters/rs-packages-no-workflows.ymldidn't listrs-platform-wallet, so any crate-only change there evaluatedrs-packages = '[]'andRust workspace testssilently skipped. This PR's prior 5 commits had never been validated by Rust CI — only by localcargo test. The filter entry mirrors the existing pattern (path +*sdkalias for transitive triggers).d610502— mergev3.1-dev(9bd37f203a).3c4f9199— Rust 1.92 clippy hardening that the previously-skipped pipeline had been quietly accumulating:field_reassign_with_defaultincore_bridge.rs::build_core_changeset→ struct literal initlet_unit_valueinwallet/apply.rs:316(WalletInfoInterface::update_balancereturns()) → drop thelet _ =core.chain.synced_heightaccess intests/spv_sync.rs→ flattened tocore.synced_height(struct shape changed upstream; the test never recompiled because workspace tests were skipping)Tests (138 lib tests, all passing)
auto_select_testsmodule — 13 tests:single_input_oversized_balance_trims_to_output_amountdescending_order_picks_single_largest_when_sufficientpre_fix_buggy_selector_output_is_rejected_by_protocol_fee_deduction— protocol-level reproduction (asserts!fee_fully_covered)fee_headroom_violation_errorsnon_fee_target_below_min_input_redistributesauto_select_inputs_excludes_output_addresses— confirms self-funded-transition filtertotal_output_below_min_input_amount_errorsno_candidates_errorsreduce_output_happy_path_single_input— confirms[ReduceOutput(0)]strategy parity with[DeductFromInput(0)]detect_no_selectable_inputs_combines_both_cases— pins theNoSelectableInputsvariant fields (funded_outputs,sub_min_count,sub_min_aggregate)augment_outputs_with_change_adds_residual_outputaugment_outputs_with_change_rejects_duplicate_addressaugment_outputs_with_change_rejects_no_surplusSeveral assert structural validity via
assert_selection_validates→AddressFundsTransferTransitionV0::validate_structure.Note on withdrawal selector
The
auto_select_inputs_for_withdrawalrustdoc clarifies the asymmetry: withdrawal validatesΣ inputs > output_amount(strictly greater, surplus = fee), so its drain-everything strategy is correct by design. Not the same bug; no code change.Verification
cargo fmt -p platform-wallet --check✓cargo clippy --workspace --tests -- -D warnings✓cargo test -p platform-wallet --lib— 138/138 passingTest plan
deduct_fee_from_outputs_or_remaining_balance_of_inputstransfer(),auto_select_inputs,select_inputsunchanged. Backward-compatible additions only: newWalletError::NoSelectableInputsstruct variant (replacing the prior generic insufficient-balance message) andInputSelection::Autonow accepts[ReduceOutput(0)]in addition to[DeductFromInput(0)]. Downstream exhaustive matches onWalletErrorneed updating.Provenance
Originally surfaced and fixed during work on PR #3549 (
rs-platform-wallete2e harness). Split out so the production-code fix can ship independently of the long-running e2e branch. Subsequent commits address review feedback from CodeRabbit andthepastaclawreviewers and close a CI coverage gap that was hiding pre-existing breaks onv3.1-dev.🤖 Generated with Claude Code
Triage wave (11 commits, 2026-05-21)
Applied 9 of 11 triaged findings from a fresh grumpy-review + comment-verification pass at HEAD
d5d2b3d271. 2 deferred as out-of-scope TODOs.098484f34faugment_outputs_with_changenow rejectschange_amount < min_output_amountvia typedChangeBelowMinimumOutput;platform_versionplumbed;(0, min_output_amount)band test added8ee78a4f60InputSelection::Autorustdoc rewritten to current contract (balance-desc, dust filter, output-address exclusion, supported fee strategies, typed errors)3aeed87bfais_empty_no_records()now includesaddresses_derived, matching canonicalCoreChangeSet::is_empty()6c3fb02b0cselect_inputs_reduce_outputandselect_inputs_deduct_from_input; invariant documentede2140bd475selected.len()instead ofprefix.len(); eliminates false "insufficient funds" on objectively spendable walletsb85b6a9a90OnlyOutputAddressesFundednow carriessub_min_count+sub_min_aggregateto preserve dust diagnostics under the combined casecfe3c33247checked_sum_credits+InputSumOverflowfor caller-supplied input maps intransfer_with_change_address; trusted call sites still saturate9854e38674outputsnow typed asIndexMap<PlatformAddress, Credits>so caller controls "output 0" insertion order;[ReduceOutput(0)] + Some(change_addr)rejects whenchange_addris lex-smaller than every user output9c136cd912ErrorOnlyOutputAddressesFunded = 13andErrorOnlyDustInputs = 14; SwiftPlatformWalletResultCode+ typedPlatformWalletErrormirrored; table-driven Rust test pins routing7c7a2ec5852957623756cargo fmt --allcleanupTriage decisions
Verification
cargo fmt --all --check✓cargo clippy -p platform-wallet -p platform-wallet-ffi --all-targets -- -D warnings✓cargo test -p platform-wallet --lib— 144 passingcargo test -p platform-wallet-ffi --lib— 75 passing (incl. newtyped_errors_route_to_dedicated_codes)Known follow-ups (out of PR scope)
AddressFundsTransferTransitionoutputs are stillBTreeMapat the chain layer; the IndexMap is a wallet-only affordance. A doc comment on the DPP transition struct cross-referencing the wallet semantics would help future readers — separate PR.parse_outputsoverwrites duplicate-address entries silently. Pre-existing behavior, but worth a sanity rejection in a follow-up.🤖 Co-authored by Claudius the Magnificent AI Agent