fix(ledger): apply the collateral rules only to phase-2 transactions - #2205
Conversation
|
Warning Review limit reachedNext included review available in 59 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (5)
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 |
|
Acceptance on the real chain. The wedged node was restarted on its own data directory, so it had only to get past the block that stopped it: Block 15148509 now applies and the node is following the chain again. That confirms the change resolves the observed divergence. It does not confirm the condition is the one cardano-ledger uses — the caveat in the description stands, and a reviewer who knows that code should check it against the reference rather than against this block. |
There was a problem hiding this comment.
All reported issues were addressed across 2 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
@coderabbitai review |
|
wolf31o2
left a comment
There was a problem hiding this comment.
Answering the "what I have not confirmed" section: the reading is right, and it is redeemers.
cardano-ledger Alonzo.Rules.Utxo.feesOK, Part 2:
unless (null $ tx ^. witsTxL . rdmrsTxWitsL . unRedeemersL) $
validateCollateral pp txBody utxoCollateralBabbage's feesOK gates validateTotalCollateral with the identical guard, and Conway inherits Babbage. The gate is a non-empty redeemer map, not the presence of Plutus scripts in the witness set -- so gating on redeemers is exactly what the reference does, and the reference-input reasoning in the description is the right reason for it.
One remaining member of the same class inline. Branch is on fab2538, four commits behind main.
| // is not in the witness set, and gating on that would skip the check for | ||
| // exactly the transactions that most need it. Every phase-2 execution has a | ||
| // redeemer regardless of where its script came from. | ||
| if !transactionRunsPhase2Scripts(tx) { |
There was a problem hiding this comment.
Two notes on the class this belongs to.
One member is still ungated. In the reference, validateCollateral / validateTotalCollateral is a group, and all of it sits behind the redeemer guard. gouroboros already gates three of them on len(WsRedeemers.Redeemers) == 0 -- UtxoValidateInsufficientCollateral, UtxoValidateCollateralContainsNonAda, UtxoValidateNoCollateralInputs, in both alonzo and babbage. babbage.UtxoValidateCollateralEqBalance is not gated: it returns early only when TotalCollateral() is nil or zero. But it is Part 6 of validateTotalCollateral, inside the same guard. A transaction with declared collateral, a total_collateral field and no redeemers is still held to it, which is the same false-rejection shape this PR is closing. Suspected, not observed on chain -- but it is the one place left where the class is incomplete.
babbage.UtxoValidateTooManyCollateralInputs is correctly outside the guard: validateTooManyCollateralInputs is called from the UTXO transition, not from feesOK. No change wanted there.
The guard now covers two different rules. This helper does more than validateScriptsNotPaidUTxO, which only tests vKeyLocked. It also requires a matching vkey witness for each collateral input, and that requirement comes from UTXOW's witsVKeyNeeded (Alonzo includes collateral inputs), which is not redeemer-gated in the reference. So skipping it here is a missed rejection rather than a false one -- the safe direction, and not worth blocking on, but worth knowing the guard is broader than the reference's.
There was a problem hiding this comment.
Both correct. Fixed in 5252120.
The ungated member. babbage.UtxoValidateCollateralEqBalance now returns early on !common.TransactionRunsPhase2Scripts(tx) (ledger/babbage/rules.go:653), matching the guard the other three already carry.
It uses the interface-level helper rather than tmpTx.WitnessSet.WsRedeemers deliberately: Conway and Dijkstra both delegate into this one function, and the helper counts sub-transaction redeemers, so Dijkstra gets the same answer its own collateral rules get from redeemerCount(tx).
I audited the class per era rather than assuming the copies agree:
| era | CollateralEqBalance |
TooManyCollateralInputs |
|---|---|---|
| alonzo | not defined, not wired — no total_collateral field in the Alonzo body |
not defined |
| babbage | was ungated, now gated | ungated, untouched |
| conway | thin delegate to babbage — inherits the gate | ungated, untouched |
| dijkstra | wires conway.UtxoValidateCollateralEqBalance — inherits the gate |
ungated, untouched |
So babbage was the only place the rule has a body, and the other two eras route through it. The other three members were already gated in all four eras (alonzo/babbage on len(...Redeemers) == 0, conway on WsRedeemers.Len() == 0, dijkstra on redeemerCount(tx) == 0).
UtxoValidateTooManyCollateralInputs is unchanged in every era — the diff does not mention it.
Two existing fixtures had to change: TestUtxoValidateCollateralEqBalance in both babbage and conway built transactions with no redeemers, so under the new gate they would have passed while testing nothing. Each gained a redeemer.
The broader guard. Agreed, and it is now written down rather than left implicit — see the doc comment on ValidateCollateralVKeyWitnesses in ledger/common/witness.go. It names both halves (validateScriptsNotPaidUTxO, inside the guard; UTXOW's witsVKeyNeeded, not gated in the reference), states that gating both makes it a missed rejection rather than a false one, and gives the reason that matters — it can only accept something the reference rejects, so it cannot wedge a node on a canonical block. It also records that splitting the witsVKeyNeeded half back out to run ungated is the correct end state, left out of scope here.
Also rebased onto current main (88145ad).
| ), | ||
| }, | ||
| WitnessSet: wits, | ||
| } |
There was a problem hiding this comment.
Accept-case, reject-case and the sub-transaction direction are all here, and the accept-case fails without the change -- this is the right shape. Two smaller points: it drives babbage.UtxoValidateCollateralVKeyWitnesses directly rather than resolving it from babbage.UtxoValidationRules, so nothing pins that the rule is still in the production slice; and the fixture transaction has no TxTotalCollateral, so it would not notice the UtxoValidateCollateralEqBalance gap noted on witness.go.
There was a problem hiding this comment.
Both fixed in aac1220.
Production wiring is now pinned. The cases resolve the rule out of babbage.UtxoValidationRules by function identity and fail if it is not there, instead of calling the exported function directly:
func productionRule(t *testing.T, name string, want common.UtxoValidationRuleFunc) common.UtxoValidationRuleFunc {
wantId := reflect.ValueOf(want).Pointer()
for _, rule := range babbage.UtxoValidationRules {
if reflect.ValueOf(rule).Pointer() == wantId {
return rule
}
}
t.Fatalf("%s is not wired into babbage.UtxoValidationRules", name)
return nil
}The TxTotalCollateral gap is covered. New TestCollateralEqBalanceOnlyForPhase2 uses a fixture that sets TxTotalCollateral to 1 ADA against a 100 ADA collateral input with no collateral return — a genuine mismatch, so the rule rejects it whenever it actually runs. Three cases: no redeemers accepts, redeemers reject, and redeemers only in a sub-transaction reject.
Each case was proven to fail without its fix. The mutations:
- Delete the redeemer guard from
babbage.UtxoValidateCollateralEqBalance→
TestCollateralEqBalanceOnlyForPhase2/no_phase-2_scriptsfails withincorrect total collateral field: provided 100000000, total collateral 1000000. This is the case that would have caught the gap you noted. - Remove
UtxoValidateCollateralVKeyWitnessesandUtxoValidateCollateralEqBalancefromUtxoValidationRules(leaving the functions defined) →
both tests fail with... is not wired into babbage.UtxoValidationRules. Under the old shape they both still passed, which is exactly the hole. - Make
TransactionRunsPhase2Scriptsread only the top-level witness set →
the sub-transaction case of both tests fails.
Full suite green and make lint at 0 issues on aac1220.
Collateral pays for phase-2 script execution that fails, so a transaction that runs no phase-2 scripts has nothing for it to cover. The collateral witness rules were applied to any transaction that declared collateral, so declaring collateral it did not need made an otherwise valid transaction invalid. Preview transaction 9ce59ee0dc6abee0 at slot 15148509 does exactly that: two vkey witnesses, one native script, no Plutus scripts and no redeemers, and a collateral input at an enterprise-script address. The chain accepted it; a node holding it to the key-locked rule rejected a canonical block and stopped following the chain. Gate on the presence of redeemers rather than on Plutus scripts in the witness set. A script supplied by a reference input is not in the witness set, so gating on that would skip the check for exactly the transactions that most need it, while every phase-2 execution carries a redeemer wherever its script came from. The test covers both directions: script-locked collateral is accepted without phase-2 scripts and still rejected with them, so the guard cannot become a way to skip the rule. Reported as blinklabs-io/dingo#3896. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cXreCqyqbGJuDtvgYyqQN Signed-off-by: Chris Guiney <chris@guiney.net>
…al rules The phase-2 gate read only the top-level witness set. A Dijkstra transaction can carry its redeemers in a sub-transaction, so such a transaction would have reported no phase-2 execution and skipped the collateral rules entirely -- the one direction this guard must never fail in, since it turns a narrowing into a way to bypass the check. Aggregate over SubTransactionWitnessSetsFromTransaction as the other Dijkstra collateral rules already do. Raised by Cubic on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cXreCqyqbGJuDtvgYyqQN Signed-off-by: Chris Guiney <chris@guiney.net>
UtxoValidateCollateralEqBalance is Part 6 of the reference's validateTotalCollateral, which feesOK runs only when the redeemer map is non-empty, the same guard already applied to the other three members of the group. It returned early only on a nil or zero total_collateral, so a transaction declaring collateral and a total_collateral field but running no phase-2 scripts was still held to it. That is the same false-rejection shape as the key-locked rule. The guard uses common.TransactionRunsPhase2Scripts rather than the Babbage-typed witness set because Conway and Dijkstra delegate to this function, and a Dijkstra transaction can carry its redeemers in a sub-transaction, which the helper counts. Babbage is the only era that implements the rule; Alonzo has no total_collateral field and does not wire it. The Babbage and Conway TestUtxoValidateCollateralEqBalance fixtures carried no redeemers, so they gained one to keep exercising the rule. Also records in ValidateCollateralVKeyWitnesses that its guard is broader than the reference's: the helper covers both validateScriptsNotPaidUTxO, which is inside feesOK's redeemer guard, and UTXOW's witsVKeyNeeded, which is not. Gating both makes a no-redeemer transaction with an unwitnessed collateral input a missed rejection rather than a false one, which cannot wedge a node on a canonical block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cXreCqyqbGJuDtvgYyqQN Signed-off-by: Chris Guiney <chris@guiney.net>
The phase-2 collateral cases called the rule functions directly, so a rule dropped from babbage.UtxoValidationRules would still have passed them. They now resolve the rule out of that slice by function identity and fail if it is not there, which is what the node actually runs. Adds the total_collateral cases the previous fixture could not reach: it set no TxTotalCollateral, so UtxoValidateCollateralEqBalance short-circuited before its guard mattered. The new fixture declares a total_collateral that does not match the collateral balance, so the rule rejects it whenever it runs, and the accept case fails without the phase-2 gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cXreCqyqbGJuDtvgYyqQN Signed-off-by: Chris Guiney <chris@guiney.net>
22c1275 to
aac1220
Compare
|
Addressed @wolf31o2's review. Head is now The ungated member of the class. Audited per era rather than assuming the copies agree — this family is duplicated and has drifted before:
The other three members were already gated in all four eras. The broader guard, documented. No behaviour change. The doc comment on Tests. The phase-2 cases now resolve the rule out of Each new case was proven to fail without its fix:
Two pre-existing fixtures changed: Validation on Not re-run: the Preview replay from the earlier acceptance comment. That evidence still covers the key-locked rule, which is unchanged here. The Cubic's sub-transaction finding was re-verified against this head and remains fixed; replied on the thread. |
| // the reference rejects rather than rejecting one the reference accepts, so it | ||
| // cannot wedge a node on a canonical block. Splitting the witsVKeyNeeded half | ||
| // back out to run ungated is the correct end state; it is not done here because | ||
| // this change is scoped to the false-rejection fix. |
There was a problem hiding this comment.
Non-blocking, for a follow-up issue: this paragraph checks out against cardano-ledger master.
getAlonzoWitsVKeyNeeded is getShelleyWitsVKeyNeeded certState utxo txBody \Set.union` reqSignerHashes, and Shelley's inputAuthorsfolds overspendableInputsTxBodyF, which Alonzo defines as allInputsTxBodyF = inputs `Set.union` collateralInputs (eras/alonzo/impl/src/Cardano/Ledger/Alonzo/TxBody.hs:243-248). That set feeds UTXOW, not feesOK`, so the reference does require a vkey witness per key-locked collateral input regardless of redeemers.
So the gap is real: after this change a no-redeemer transaction with an unwitnessed key-locked collateral input is accepted here. Safe direction, and gouroboros does not check spend-input authors at all today, so the asymmetry is narrower than it looks. Worth an issue so the split does not stay only in this comment.
There was a problem hiding this comment.
Filed as #2224, with your cardano-ledger derivation recorded — getAlonzoWitsVKeyNeeded unioning reqSignerHashes onto getShelleyWitsVKeyNeeded, and inputAuthors folding over Alonzo's allInputsTxBodyF = inputs ∪ collateralInputs, feeding UTXOW rather than feesOK.
It states the consequence as you framed it: a missed rejection rather than a false one, and narrower than it looks because gouroboros does not check spend-input authors at all today, so collateral is not uniquely unchecked.
Agreed that it should not live only in a comment. The helper's doc comment now records the current state and points at the split as the end state; the issue carries the reference derivation and the fix.
Reported as blinklabs-io/dingo#3896.
Collateral pays for phase-2 script execution that fails. A transaction that runs
no phase-2 scripts has nothing for it to cover, but the collateral witness rules
were applied to any transaction that declared collateral — so declaring
collateral it did not need made an otherwise valid transaction invalid.
How it surfaced
A from-genesis Preview replay wedged at slot 15148509 (epoch 175):
114 occurrences, node no longer following the chain.
The transaction:
Its collateral input really is script-locked — that part of the check is right:
Koios confirms the transaction is on chain at block height 670981 with no
collateral consumed, so it was phase-2 valid and the collateral was never
touched.
The change
ValidateCollateralVKeyWitnessespreviously returned early only when nocollateral was declared. It now also returns when the transaction runs no
phase-2 scripts.
The condition is the presence of redeemers, not of Plutus scripts in the
witness set. A script supplied by a reference input is not in the witness set,
so gating on that would skip the check for exactly the transactions that most
need it. Every phase-2 execution carries a redeemer wherever its script came
from.
What I have not confirmed
I have not verified this against cardano-ledger, and that is worth a
reviewer's attention before merge.
What is certain is that the current behaviour is wrong: the block is canonical
and a node applying this rule cannot follow the chain. What I am inferring is
the precise condition the ledger uses — whether it gates on phase-2 scripts
being present, on a non-empty redeemer set, or on something else again.
Redeemers seemed the most defensible reading, and it is strictly narrower than
removing the check: a transaction that does run phase-2 scripts is still held to
the rule, which the second test asserts. But if the reference implementation
gates differently, this should follow it rather than the reading that happens to
make this block pass.
Tests
TestCollateralKeyLockedOnlyForPhase2uses the real transaction's shape and thereal collateral address, and covers both directions: script-locked collateral is
accepted without phase-2 scripts, and still rejected with them, so the guard
cannot become a way to skip the rule. Without the change the first case fails
with
collateral input must be key-locked.go build ./...,go vet ./ledger/...andgo test ./...(39 packages) pass.🤖 Generated with Claude Code
https://claude.ai/code/session_019cXreCqyqbGJuDtvgYyqQN
Summary by cubic
Applies the collateral witness rules only to transactions that run phase-2 scripts, fixing a node that stopped following the chain when a transaction declared unnecessary collateral.
The fix extends beyond the original key-locked rule: the
total_collateralbalance check is also gated, and redeemers found in sub-transaction witness sets count, so Dijkstra transactions cannot bypass the rules.Details
Written for commit aac1220. Summary will update on new commits.