Skip to content

round: enforce fee cap against realised quote economics (#379) - #452

Closed
ellemouton wants to merge 1 commit into
mainfrom
fix/379-quoted-fee-cap-change-underpay
Closed

round: enforce fee cap against realised quote economics (#379)#452
ellemouton wants to merge 1 commit into
mainfrom
fix/379-quoted-fee-cap-change-underpay

Conversation

@ellemouton

Copy link
Copy Markdown
Member

Closes #379.

Summary

round.evaluateQuote only checked the operator-attested quote.OperatorFeeSat against env.MaxOperatorFee. The echo validator intentionally permits amount deviation on the IsChange=true output, so a malicious operator could quote a small fee within the cap while shaving change by a much larger delta. The client would then sign a round whose realised economic fee exceeded the cap.

Fix

evaluateQuote now enforces the cap against the realised quote economics:

realisedQuoteFee = Σ(boarding ChainInfo.Amount + VTXOStore-looked-up forfeits)
                 − Σ(quote.VTXOQuotes.AmountSat + quote.LeaveQuotes.AmountSat)

Three additional rejections:

  • realisedQuoteFee > feeCap (the actual bypass)
  • realisedQuoteFee < 0 (defensive; would only over-reject)
  • realisedQuoteFee != quote.OperatorFeeSat (closes drift between on-chain realised fee and operator-declared fee used for confirmation-time accounting)

The declared-field check stays as belt-and-braces. ctx is plumbed through evaluateQuote and its three callers (initial quote, post-accept reseal, in-state reseal). Transient VTXOStore.GetVTXO failure surfaces as QuoteRejected with a diagnostic (fail-closed; the alternative re-opens the bypass).

Test plan

  • new TestEvaluateQuoteRejectsChangeUnderpaymentBypass — declared fee 1k sat (under 10k cap), but change shaved 60k→40k yields realised 25k; without fix accepts, with fix rejects
  • new TestEvaluateQuoteRejectsSingleOutputUnderpayment — no-change single-output case
  • new TestEvaluateQuoteRealisedFeeUsesForfeitStore — refresh-round (forfeit-only) inputs
  • new TestEvaluateQuoteRejectsRealisedFeeNegative — defensive case
  • new TestEvaluateQuoteRejectsChangeUnderpaymentBelowCap — below-cap-but-dishonest (realised != declared)
  • new TestEvaluateQuoteAcceptsExactlyAtCap — boundary
  • all 5 bypass tests fail without the fix
  • make lint-native — 0 issues
  • go test ./round/... -count=1 — pass

Relationship to #378

Complementary, not conflicting. #378 (single-output quote echo can underpay fixed recipients) tightens the echo validator on the single-output implicit-change path. #379 (this PR) adds a global realised-fee gate that covers single- and multi-output cases. The two fixes reinforce each other; no legitimate-quote regression introduced by their combination.

@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 implements realized-fee cap enforcement to prevent operators from bypassing fee limits by manipulating output amounts. It introduces the realisedQuoteFee function to calculate the actual economic fee and validates it against the fee cap and the operator's declared fee. Extensive tests were added to cover various attack vectors, such as change underpayment. Feedback was provided to refine the fee calculation by including zero-value outputs and explicitly rejecting negative output amounts to ensure robustness against manipulation.

Comment thread round/transitions.go
Comment on lines +966 to +977
for i := range quote.VTXOQuotes {
amt := quote.VTXOQuotes[i].AmountSat
if amt > 0 {
outputsSat += amt
}
}
for i := range quote.LeaveQuotes {
amt := quote.LeaveQuotes[i].AmountSat
if amt > 0 {
outputsSat += amt
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current implementation of summing output amounts incorrectly handles zero-value and negative-value outputs.

  • Zero-value outputs are valid in Bitcoin (e.g., for OP_RETURN) and should be included in the total output sum. By ignoring them with if amt > 0, the calculated realised fee will be higher than it should be, which could lead to incorrect rejections of valid quotes.
  • Negative-value outputs are invalid in a transaction, but a malicious operator could include them in a quote to manipulate the realised fee calculation. The current code ignores them. A better approach is to explicitly reject quotes with negative output amounts.

I suggest modifying the loops to include zero-value outputs in the sum and to return an error for negative-value outputs. This will make the fee calculation more robust and secure.

Suggested change
for i := range quote.VTXOQuotes {
amt := quote.VTXOQuotes[i].AmountSat
if amt > 0 {
outputsSat += amt
}
}
for i := range quote.LeaveQuotes {
amt := quote.LeaveQuotes[i].AmountSat
if amt > 0 {
outputsSat += amt
}
}
for i := range quote.VTXOQuotes {
amt := quote.VTXOQuotes[i].AmountSat
if amt < 0 {
return 0, fmt.Errorf("vtxo quote %d has negative amount %d", i, amt)
}
outputsSat += amt
}
for i := range quote.LeaveQuotes {
amt := quote.LeaveQuotes[i].AmountSat
if amt < 0 {
return 0, fmt.Errorf("leave quote %d has negative amount %d", i, amt)
}
outputsSat += amt
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied in 053dae4.

Verdict: applied in full. realisedQuoteFee no longer filters amt > 0; instead it sums all amounts and explicitly rejects negatives on both sides (boarding inputs and VTXO/leave outputs). The forfeit-input branch was already authoritative (sourced from VTXOStore / fallback hint) and doesn't take this code path.

On the Ark protocol question: zero-value VTXO/leave outputs are not a legitimate honest shape — VTXOs and leaves both flow through the dust/output checks elsewhere. So the previous filter was effectively dead defensive code for honest cases. Mathematically a zero contributes nothing to the sum either way; the real correctness fix the comment exposes is that the same filter was silently absorbing negatives, which would shift the realised fee in the operator's favor. The new code fails closed on negatives with a diagnostic instead.

New tests:

  • TestRealisedFeeIncludesZeroValueOutputs — quote with a zero-value leave output is summed into Σoutputs and the realised fee matches the declared fee.
  • TestRealisedFeeRejectsNegativeOutput — negative AmountSat is rejected by realisedQuoteFee and surfaced via QuoteRejected with a "negative" diagnostic.

Lint: 0 issues (make lint-native). Round suite: green (go test ./round/... -count=1).

The quote.OperatorFeeSat field is operator-attested; a malicious
operator could quote a small fee within env.MaxOperatorFee while
shaving the IsChange=true output by a much larger delta. The echo
validator intentionally permits change-output amount deviation, so
without a second gate the client would accept and sign a round
whose actual economic fee exceeds the cap.

Recompute the realised fee at evaluateQuote time as Σ(authoritative
inputs) − Σ(quoted outputs), with inputs sourced from the client's
own intent composition (boarding ChainInfo amounts and VTXOStore
forfeit values) and outputs from the quote's positional slices.
Reject when the realised value exceeds the cap, is negative, or
disagrees with the declared OperatorFeeSat (operator dishonesty
that would otherwise drift downstream fee accounting).

Closes #379.
@ellemouton
ellemouton force-pushed the fix/379-quoted-fee-cap-change-underpay branch from a3496ee to 053dae4 Compare May 15, 2026 12:10
@ellemouton
ellemouton marked this pull request as ready for review May 15, 2026 12:31
@ellemouton

Copy link
Copy Markdown
Member Author

Superseded by consolidated PR #459. Closing to reduce CI load.

@ellemouton ellemouton closed this May 15, 2026
ellemouton pushed a commit that referenced this pull request May 22, 2026
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.

[security][high] Quoted fee cap can be bypassed via change underpayment

1 participant