Skip to content

fix(planner): stop transfer-merge spiral in recordsFee fee estimate#16

Merged
niconiconi merged 1 commit into
mainfrom
fix/transfer-merge-spiral-estimate
Apr 24, 2026
Merged

fix(planner): stop transfer-merge spiral in recordsFee fee estimate#16
niconiconi merged 1 commit into
mainfrom
fix/transfer-merge-spiral-estimate

Conversation

@niconiconi

Copy link
Copy Markdown
Contributor

Context

Mirror fix to OCash PR #385, which fixed the same bug in the frontend's copy of this function.

Bug

estimateRecords (used to produce feeSummary / maxSummary / okWithMerge) inflates feeCount dramatically:

  • User has 32 × 0.1 shielded UTXOs
  • Requests a 0.01 transfer
  • Reported: feeCount = 16, mergeCount = 15 (cascade-merge full balance)
  • Correct: feeCount = 1 (a single 0.1 UTXO covers required amount + fee)

Root cause

src/planner/planner.ts:143-145 — in recordsFee, the action === 'transfer' non-max branch set cost but forgot outputAmount:

if (maxUint256 === expectedOutput) {
  cost = total;
  outputAmount = total - fee;   // ← set here
} else {
  cost = expectedIsWithFee ? expectedOutput : expectedOutput + fee;
  // outputAmount never assigned → stays at 0n (initializer)
}

estimateRecords at line 249 then evaluates:

const isExceedPay = input.expectedIsWithFee
  ? payInfo.cost >= input.expectedOutput
  : payInfo.outputAmount >= input.expectedOutput;

For expectedIsWithFee = false (default), the check is 0n >= expectedOutput, always false → forEach keeps pushing records → all UTXOs accumulate → recordsFee re-simulates with cascade merge → feeCount = (N_utxos - 2)/2 + 1.

Scope of impact

On the SDK side, only fee summary display is affected. Planner.plan uses selectTransferInputs (greedy by amount, capped at 3) for actual input selection, so on-chain transactions were correct. But downstream consumers reading feeSummary.feeCount / mergeCount (UI tx-count displays, okWithMerge gating) got wrong values.

The frontend copy at client/app/src/hook/useCommitmentEstimated.ts had the same bug and there DID drive submissionutxoTransfer.ts reads feeEstimated.payInfo.records and feeCount directly and submits that many on-chain txs. A user on BSC Testnet burned 0.0064 BNB (16 × relayer fee) on a single 0.01 transfer. That's fixed in OCash PR #385.

Fix

} else {
  cost = expectedIsWithFee ? expectedOutput : expectedOutput + fee;
  outputAmount = expectedIsWithFee ? expectedOutput - fee : expectedOutput;
  if (outputAmount < 0n) outputAmount = 0n;
}
if (total < cost) {
  cost = 0n;
  outputAmount = 0n;
}

Tests

Added regression test in tests/planner.test.ts covering the bug scenario (32 × 0.1 UTXOs, transfer 0.01). Pre-fix output: feeCount=16. Post-fix: feeCount=1.

All 5 tests in the file pass with the fix.

Test plan

  • pnpm test -- tests/planner.test.ts passes
  • Regression test fails without the fix (verified by temporarily reverting)
  • Existing UI consumers of feeSummary.feeCount continue to render correctly

🤖 Generated with Claude Code

Non-max transfer branch of recordsFee never assigned outputAmount; it
stayed at the 0n initializer. estimateRecords then treated every UTXO as
"not yet sufficient" (outputAmount >= expectedOutput is always false),
pushing all records into payRecords and re-simulating with the 3-input
cascade merge. feeSummary.feeCount ballooned (e.g. 32×0.1 transferring
0.01 reported feeCount=16, not 1).

Planner.plan uses selectTransferInputs (greedy by amount, bounded by 3)
for the actual input selection, so on-chain submissions were not
affected on the SDK side. The bug corrupted only the fee summary
displayed via okWithMerge / feeSummary.

The frontend's own copy of this function at
client/app/src/hook/useCommitmentEstimated.ts had the same bug and there
did drive actual tx submission — see OCash PR #385 for that fix.

Fix: set outputAmount = expectedOutput (or expectedOutput - fee when
expectedIsWithFee) in the specified-amount branch, mirroring the max
branch; also zero outputAmount when the insufficient-balance guard
clears cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request addresses a bug in the recordsFee function where outputAmount was not assigned in the non-max branch, leading to incorrect fee calculations and unnecessary UTXO merging. A regression test was added to verify the fix. Feedback suggests resetting cost to 0n when outputAmount is negative to ensure consistency and correct flag calculation.

Comment thread src/planner/planner.ts
} else {
cost = expectedIsWithFee ? expectedOutput : expectedOutput + fee;
outputAmount = expectedIsWithFee ? expectedOutput - fee : expectedOutput;
if (outputAmount < 0n) outputAmount = 0n;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

For consistency with the maxUint256 branch (lines 139-142) and to ensure the okWithMerge flag is correctly calculated as false when fees exceed the requested amount, cost should also be reset to 0n when outputAmount is negative.

Suggested change
if (outputAmount < 0n) outputAmount = 0n;
if (outputAmount < 0n) {
outputAmount = 0n;
cost = 0n;
}

@niconiconi niconiconi merged commit 4474a61 into main Apr 24, 2026
1 check passed
niconiconi added a commit that referenced this pull request Apr 24, 2026
Bug-fix prerelease collecting PR #14 / #15 / #16 and the NaN-cid
follow-up. See CHANGELOG.md for details.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants