Skip to content

round: tighten single-output quote echo amount validation (#378) - #453

Closed
ellemouton wants to merge 1 commit into
mainfrom
fix/378-single-output-quote-underpay
Closed

round: tighten single-output quote echo amount validation (#378)#453
ellemouton wants to merge 1 commit into
mainfrom
fix/378-single-output-quote-underpay

Conversation

@ellemouton

Copy link
Copy Markdown
Member

Closes #378.

Summary

round.validateQuoteEchoes set implicitChange := totalOutputs == 1 and skipped the non-change amount echo check for both VTXO and leave entries on any single-output intent. The wallet flow only blocks change == 0 && len(recipients) > 1; a single-recipient directed send with exact coin selection ships one VTXORequest{IsChange: false, Amount: r.Amount} which slips through. Downstream callers quoteVTXOAmount / quoteLeaveAmounts treat the quote amount as authoritative, so a malicious echo propagates into commitment validation — the operator could underpay a fixed recipient.

Fix

Replaced the unconditional implicit-change skip with the precise rule

entry.AmountSat == intent.Amount − quote.OperatorFeeSat

Applied symmetrically to VTXO and leave channels. OperatorFeeSat is already capped by env.MaxOperatorFee (negative rejected, zero/unset fails closed) so the only honest deviation is bounded by the fee cap rather than unbounded shaving.

Test plan

  • new TestEvaluateQuoteEchoRejectsSingleVTXOUnderpayment
  • new TestEvaluateQuoteEchoRejectsSingleLeaveUnderpayment
  • new TestEvaluateQuoteEchoRejectsSingleVTXOOverpayment
  • new TestEvaluateQuoteEchoRejectsSingleVTXOMissingFeeDeduction
  • new TestEvaluateQuoteEchoAcceptsSingleVTXOImplicitChangeFee (honest-path)
  • new TestEvaluateQuoteEchoAcceptsSingleLeaveImplicitChangeFee (honest-path)
  • all 4 rejection tests fail when the production change is reverted
  • TestActorBuffersEarlyQuote updated — old fixture sent AmountSat: int64(v.Amount) (no fee deduction), which only passed because the old code skipped the check; updated fixture aligns with honest server behavior
  • make lint-native — 0 issues
  • go test ./round/... ./wallet/... -count=1 — pass

Relationship to #379

Complementary. #379 (fee cap can be bypassed via change underpayment) adds a global realised-fee gate; #378 (this PR) tightens the per-output echo validator on the single-output implicit-change path. The two compose: #378 rejects underpayment at the echo step; #379 catches change-shaving the echo validator still permits.

@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 security vulnerability (issue #378) where single-output intents, treated as implicit change, bypassed amount validation. The validateQuoteEchoes function in round/transitions.go has been updated to strictly enforce that the echoed amount for single-output intents must equal the intent target minus the operator fee. The PR also includes comprehensive regression tests and updates existing tests to reflect this requirement. Review feedback suggests using valid P2WPKH scripts in test data, replacing magic numbers with existing variables for better maintainability, and refactoring the validation logic to use if/else structures instead of continue statements for improved readability.

Comment thread round/quote_echo_test.go Outdated
// buildSingleLeaveIntents returns a deterministic intent carrying a
// single non-change LeaveRequest. Mirrors a single-VTXO offboard.
func buildSingleLeaveIntents() Intents {
leavePkScript := []byte{0x00, 0x14, 0xCA, 0xFE, 0xBA, 0xBE}

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

The leavePkScript is not a valid P2WPKH script. A P2WPKH script should be 22 bytes long (OP_0 <20-byte-hash>), but this is only 6 bytes. While the test may pass because the same invalid script is used for comparison, it's better to use valid data in tests to avoid confusion and ensure correctness.

Suggested change
leavePkScript := []byte{0x00, 0x14, 0xCA, 0xFE, 0xBA, 0xBE}
leavePkScript := append([]byte{0x00, 0x14}, make([]byte, 20)...)

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.

Fixed in f96b570 — replaced the 6-byte stub with a valid 22-byte P2WPKH script (append([]byte{0x00, 0x14}, bytes.Repeat([]byte{0xab}, 20)...)).

Comment thread round/quote_echo_test.go Outdated

intents := buildSingleLeaveIntents()
quote := quoteFromIntents(t, intents, 2_500)
quote.LeaveQuotes[0].AmountSat -= 2_500

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

To avoid using a magic number and improve maintainability, you can use the OperatorFeeSat from the quote object, which is already set to the correct value.

Suggested change
quote.LeaveQuotes[0].AmountSat -= 2_500
quote.LeaveQuotes[0].AmountSat -= quote.OperatorFeeSat

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.

Fixed in f96b570 — replaced the 2_500 magic number with quote.OperatorFeeSat.

Comment thread round/transitions.go Outdated
Comment on lines 953 to 980
// Multi-output intent: only the explicit IsChange=true
// slot may deviate from its intent target.
if !implicitChange {
if vtxoReq.IsChange {
continue
}
if entry.AmountSat != int64(vtxoReq.Amount) {
return fmt.Sprintf("vtxo[%d] "+
"non-change amount %d != "+
"intent target %d", i,
entry.AmountSat,
int64(vtxoReq.Amount)), false
}

continue
}

// Single-output implicit-change intent: the only honest
// deviation is (Amount − OperatorFeeSat). Anything else
// is a fee-shave attack on the lone output.
expected := int64(vtxoReq.Amount) - quote.OperatorFeeSat
if entry.AmountSat != expected {
return fmt.Sprintf("vtxo[%d] implicit-change "+
"amount %d != intent target %d - "+
"operator fee %d (= %d)", i,
entry.AmountSat, int64(vtxoReq.Amount),
quote.OperatorFeeSat, expected), false
}

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

The logic for validating VTXO amounts can be simplified for better readability and maintainability by using an if/else structure to handle the implicitChange case directly. This avoids the nested ifs and continue statements.

		if implicitChange {
			// Single-output implicit-change intent: the only honest
			// deviation is (Amount − OperatorFeeSat). Anything else
			// is a fee-shave attack on the lone output.
			expected := int64(vtxoReq.Amount) - quote.OperatorFeeSat
			if entry.AmountSat != expected {
				return fmt.Sprintf("vtxo[%d] implicit-change "+
					"amount %d != intent target %d - "+
					"operator fee %d (= %d)", i,
					entry.AmountSat, int64(vtxoReq.Amount),
					quote.OperatorFeeSat, expected), false
			}
		} else {
			// Multi-output intent: only the explicit IsChange=true
			// slot may deviate from its intent target.
			if !vtxoReq.IsChange && entry.AmountSat != int64(vtxoReq.Amount) {
				return fmt.Sprintf("vtxo[%d] "+
					"non-change amount %d != "+
					"intent target %d", i,
					entry.AmountSat,
					int64(vtxoReq.Amount)), false
			}
		}

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.

Refactored in f96b570 — the VTXO validator now uses if implicitChange { ... } else if !vtxoReq.IsChange { ... }, removing the nested ifs and continue statements. Verified semantically equivalent.

Comment thread round/transitions.go Outdated
Comment on lines 996 to 1022
// Multi-output intent: only the explicit IsChange=true
// slot may deviate from its intent target.
if !implicitChange {
if leaveReq.IsChange {
continue
}
if entry.AmountSat != leaveReq.Output.Value {
return fmt.Sprintf("leave[%d] "+
"non-change amount %d != "+
"intent target %d", i,
entry.AmountSat,
leaveReq.Output.Value), false
}

continue
}

// Single-output implicit-change intent: the only honest
// deviation is (Amount − OperatorFeeSat).
expected := leaveReq.Output.Value - quote.OperatorFeeSat
if entry.AmountSat != expected {
return fmt.Sprintf("leave[%d] implicit-change "+
"amount %d != intent target %d - "+
"operator fee %d (= %d)", i,
entry.AmountSat, leaveReq.Output.Value,
quote.OperatorFeeSat, expected), false
}

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

Similar to the VTXO validation, this logic for validating leave amounts can be simplified for better readability and maintainability by using an if/else structure to handle the implicitChange case directly.

		if implicitChange {
			// Single-output implicit-change intent: the only honest
			// deviation is (Amount − OperatorFeeSat).
			expected := leaveReq.Output.Value - quote.OperatorFeeSat
			if entry.AmountSat != expected {
				return fmt.Sprintf("leave[%d] implicit-change "+
					"amount %d != intent target %d - "+
					"operator fee %d (= %d)", i,
					entry.AmountSat, leaveReq.Output.Value,
					quote.OperatorFeeSat, expected), false
			}
		} else {
			// Multi-output intent: only the explicit IsChange=true
			// slot may deviate from its intent target.
			if !leaveReq.IsChange && entry.AmountSat != leaveReq.Output.Value {
				return fmt.Sprintf("leave[%d] "+
					"non-change amount %d != "+
					"intent target %d", i,
					entry.AmountSat,
					leaveReq.Output.Value), false
			}
		}

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.

Refactored in f96b570 — applied the same if implicitChange { ... } else if !leaveReq.IsChange { ... } restructure to the leave-amount validator. Verified semantically equivalent.

validateQuoteEchoes previously skipped the non-change amount-equality
check entirely whenever the combined VTXORequests + LeaveRequests
count was one. The shortcut was added to mirror the server's
implicit-change relaxation (the lone slot absorbs the residual) but
was too broad: it admitted any echoed amount on that slot, including
zero. A malicious or compromised operator endpoint could shave
arbitrary value from any single-output flow -- the worst case being
a single-recipient directed send with coin-selection-exact change=0,
where the lone slot is a third-party recipient marked IsChange=false
and downstream commitment validation treats the quote's AmountSat as
authoritative.

The server's residual on the implicit-change slot is exactly
(intent target - OperatorFeeSat). Enforce that equality on the lone
slot for both VTXO and leave channels. The OperatorFeeSat itself is
already bounded by env.MaxOperatorFee at line 840, so the only honest
deviation reduces to a capped fee deduction -- not unbounded shaving.

Closes #378.
@ellemouton
ellemouton force-pushed the fix/378-single-output-quote-underpay branch from 5efcb94 to f96b570 Compare May 15, 2026 12:01
@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 added a commit that referenced this pull request May 22, 2026
…s-2026-05-15

multi: consolidated security fixes (May 15)
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] Single-output quote echo can underpay fixed recipients

1 participant