Skip to content

Commit 57a0b11

Browse files
authored
fix(ledger): validate reference scripts, fees, and block limits
Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
1 parent ec8104f commit 57a0b11

11 files changed

Lines changed: 1428 additions & 59 deletions

ledger/common/ref_scripts.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright 2026 Blink Labs Software
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package common
16+
17+
import (
18+
"errors"
19+
"fmt"
20+
"math"
21+
)
22+
23+
type transactionInputKey struct {
24+
id Blake2b256
25+
index uint32
26+
}
27+
28+
type consumedReferenceScriptInput struct {
29+
input TransactionInput
30+
isReference bool
31+
}
32+
33+
func newTransactionInputKey(input TransactionInput) transactionInputKey {
34+
return transactionInputKey{id: input.Id(), index: input.Index()}
35+
}
36+
37+
// ConsumedReferenceScriptSize returns the total original encoded size of the
38+
// reference scripts at a transaction body's regular and reference inputs. An
39+
// input present in both sets is counted once. Distinct inputs are counted
40+
// separately even when they contain identical scripts.
41+
func ConsumedReferenceScriptSize(
42+
tx TransactionBody,
43+
utxoState UtxoState,
44+
) (uint64, error) {
45+
if tx == nil {
46+
return 0, errors.New("transaction body is nil")
47+
}
48+
inputs := make(
49+
map[transactionInputKey]consumedReferenceScriptInput,
50+
len(tx.Inputs())+len(tx.ReferenceInputs()),
51+
)
52+
for _, input := range tx.Inputs() {
53+
if input != nil {
54+
inputs[newTransactionInputKey(input)] = consumedReferenceScriptInput{
55+
input: input,
56+
}
57+
}
58+
}
59+
for _, input := range tx.ReferenceInputs() {
60+
if input != nil {
61+
inputs[newTransactionInputKey(input)] = consumedReferenceScriptInput{
62+
input: input,
63+
isReference: true,
64+
}
65+
}
66+
}
67+
if len(inputs) == 0 {
68+
return 0, nil
69+
}
70+
if utxoState == nil {
71+
return 0, errors.New(
72+
"ledger state is required to resolve consumed reference scripts",
73+
)
74+
}
75+
var total uint64
76+
for _, consumedInput := range inputs {
77+
utxo, err := utxoState.UtxoById(consumedInput.input)
78+
if err != nil {
79+
if !consumedInput.isReference {
80+
continue
81+
}
82+
return 0, fmt.Errorf(
83+
"resolve consumed reference-script input %s: %w",
84+
consumedInput.input,
85+
err,
86+
)
87+
}
88+
if utxo.Output == nil || utxo.Output.ScriptRef() == nil {
89+
continue
90+
}
91+
scriptSize := uint64(len(utxo.Output.ScriptRef().RawScriptBytes()))
92+
if total > math.MaxUint64-scriptSize {
93+
return 0, errors.New("consumed reference-script size overflow")
94+
}
95+
total += scriptSize
96+
}
97+
return total, nil
98+
}
99+
100+
// TransactionReferenceScriptSizeFunc measures the consumed reference scripts
101+
// of one top-level transaction. Dijkstra supplies a function that also counts
102+
// each sub-transaction body.
103+
type TransactionReferenceScriptSizeFunc func(
104+
Transaction,
105+
UtxoState,
106+
) (uint64, error)
107+
108+
type blockUtxoState struct {
109+
base UtxoState
110+
produced map[transactionInputKey]Utxo
111+
consumed map[transactionInputKey]struct{}
112+
}
113+
114+
func (s *blockUtxoState) UtxoById(input TransactionInput) (Utxo, error) {
115+
key := newTransactionInputKey(input)
116+
if utxo, ok := s.produced[key]; ok {
117+
return utxo, nil
118+
}
119+
if _, ok := s.consumed[key]; ok {
120+
return Utxo{}, fmt.Errorf("utxo not found: %s", input)
121+
}
122+
if s.base == nil {
123+
return Utxo{}, errors.New(
124+
"ledger state is required to resolve consumed reference scripts",
125+
)
126+
}
127+
return s.base.UtxoById(input)
128+
}
129+
130+
func (s *blockUtxoState) apply(tx Transaction) {
131+
for _, input := range tx.Consumed() {
132+
key := newTransactionInputKey(input)
133+
delete(s.produced, key)
134+
s.consumed[key] = struct{}{}
135+
}
136+
for _, utxo := range tx.Produced() {
137+
if utxo.Id == nil {
138+
continue
139+
}
140+
key := newTransactionInputKey(utxo.Id)
141+
delete(s.consumed, key)
142+
s.produced[key] = utxo
143+
}
144+
}
145+
146+
// ConsumedReferenceScriptSizePerBlock measures transactions in block order.
147+
// When includeProduced is true, each transaction sees the UTxO changes made by
148+
// preceding transactions, matching the PV11+ Conway block rule.
149+
func ConsumedReferenceScriptSizePerBlock(
150+
block Block,
151+
utxoState UtxoState,
152+
includeProduced bool,
153+
sizeTx TransactionReferenceScriptSizeFunc,
154+
) (uint64, error) {
155+
if block == nil {
156+
return 0, errors.New("block is nil")
157+
}
158+
if sizeTx == nil {
159+
sizeTx = func(tx Transaction, state UtxoState) (uint64, error) {
160+
return ConsumedReferenceScriptSize(tx, state)
161+
}
162+
}
163+
state := &blockUtxoState{
164+
base: utxoState,
165+
produced: make(map[transactionInputKey]Utxo),
166+
consumed: make(map[transactionInputKey]struct{}),
167+
}
168+
var total uint64
169+
for _, tx := range block.Transactions() {
170+
txState := utxoState
171+
if includeProduced {
172+
txState = state
173+
}
174+
txSize, err := sizeTx(tx, txState)
175+
if err != nil {
176+
return 0, err
177+
}
178+
if total > math.MaxUint64-txSize {
179+
return 0, errors.New("block consumed reference-script size overflow")
180+
}
181+
total += txSize
182+
if includeProduced {
183+
state.apply(tx)
184+
}
185+
}
186+
return total, nil
187+
}

ledger/conway/ref_scripts.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright 2026 Blink Labs Software
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package conway
16+
17+
import (
18+
"errors"
19+
"fmt"
20+
"math"
21+
"math/big"
22+
23+
"github.com/blinklabs-io/gouroboros/cbor"
24+
"github.com/blinklabs-io/gouroboros/ledger/common"
25+
)
26+
27+
const (
28+
// MaxRefScriptSizePerTx is the Conway transaction limit from the ledger
29+
// specification. Dijkstra moves this value into protocol parameters.
30+
MaxRefScriptSizePerTx uint64 = 200 * 1024
31+
// MaxRefScriptSizePerBlock is the Conway block limit from the ledger
32+
// specification. Dijkstra moves this value into protocol parameters.
33+
MaxRefScriptSizePerBlock uint64 = 1024 * 1024
34+
// RefScriptCostStride is the fixed Conway reference-script fee tier size.
35+
RefScriptCostStride uint64 = 25_600
36+
)
37+
38+
func UtxoValidateRefScriptSizePerTx(
39+
tx common.Transaction,
40+
slot uint64,
41+
ls common.LedgerState,
42+
pp common.ProtocolParameters,
43+
) error {
44+
if _, ok := pp.(*ConwayProtocolParameters); !ok {
45+
return errors.New("pparams are not expected type")
46+
}
47+
if !tx.IsValid() {
48+
return nil
49+
}
50+
totalSize, err := common.ConsumedReferenceScriptSize(tx, ls)
51+
if err != nil {
52+
return err
53+
}
54+
if totalSize > MaxRefScriptSizePerTx {
55+
return common.RefScriptSizePerTxTooLargeError{
56+
TxSize: totalSize,
57+
MaxSize: MaxRefScriptSizePerTx,
58+
}
59+
}
60+
return nil
61+
}
62+
63+
// ValidateRefScriptSizePerBlock checks the Conway block limit. The optional
64+
// state parameter keeps the original two-argument calling shape usable for
65+
// publishing-only blocks while allowing consumed scripts to be resolved.
66+
func ValidateRefScriptSizePerBlock(
67+
block *ConwayBlock,
68+
pp common.ProtocolParameters,
69+
utxoStates ...common.UtxoState,
70+
) error {
71+
conwayPparams, ok := pp.(*ConwayProtocolParameters)
72+
if !ok {
73+
return errors.New("pparams are not expected type")
74+
}
75+
if len(utxoStates) > 1 {
76+
return errors.New("expected at most one ledger state")
77+
}
78+
var utxoState common.UtxoState
79+
if len(utxoStates) == 1 {
80+
utxoState = utxoStates[0]
81+
}
82+
totalSize, err := common.ConsumedReferenceScriptSizePerBlock(
83+
block,
84+
utxoState,
85+
conwayPparams.ProtocolVersion.Major >= common.ProtocolVersionVanRossem,
86+
nil,
87+
)
88+
if err != nil {
89+
return err
90+
}
91+
if totalSize > MaxRefScriptSizePerBlock {
92+
return common.RefScriptSizePerBlockTooLargeError{
93+
BlockSize: totalSize,
94+
MaxSize: MaxRefScriptSizePerBlock,
95+
}
96+
}
97+
return nil
98+
}
99+
100+
// CalculateRefScriptFee calculates the tiered reference-script fee and floors
101+
// the exact rational result once, after all tiers have been accumulated.
102+
func CalculateRefScriptFee(
103+
scriptSize uint64,
104+
baseCost *cbor.Rat,
105+
stride uint64,
106+
multiplier *cbor.Rat,
107+
) (uint64, error) {
108+
if scriptSize == 0 {
109+
return 0, nil
110+
}
111+
if baseCost == nil || baseCost.Rat == nil || baseCost.Sign() < 0 {
112+
return 0, errors.New("invalid reference-script base cost")
113+
}
114+
if stride == 0 {
115+
return 0, errors.New("reference-script cost stride must be greater than zero")
116+
}
117+
if multiplier == nil || multiplier.Rat == nil || multiplier.Sign() <= 0 {
118+
return 0, errors.New("invalid reference-script cost multiplier")
119+
}
120+
price := new(big.Rat).Set(baseCost.Rat)
121+
total := new(big.Rat)
122+
remaining := scriptSize
123+
for remaining > 0 {
124+
tierSize := min(remaining, stride)
125+
tierCost := new(big.Rat).Mul(
126+
price,
127+
new(big.Rat).SetInt(new(big.Int).SetUint64(tierSize)),
128+
)
129+
total.Add(total, tierCost)
130+
remaining -= tierSize
131+
price.Mul(price, multiplier.Rat)
132+
}
133+
fee := new(big.Int).Quo(total.Num(), total.Denom())
134+
if !fee.IsUint64() {
135+
return 0, fmt.Errorf("reference-script fee overflow: %s", fee)
136+
}
137+
return fee.Uint64(), nil
138+
}
139+
140+
// MinFeeTxWithRefScriptSize adds the Conway tiered reference-script fee to
141+
// the size-based transaction fee. Callers that already resolved the UTxO set
142+
// can use this function without repeating the lookup.
143+
func MinFeeTxWithRefScriptSize(
144+
tx common.Transaction,
145+
pparams common.ProtocolParameters,
146+
scriptSize uint64,
147+
) (uint64, error) {
148+
conwayPparams, ok := pparams.(*ConwayProtocolParameters)
149+
if !ok {
150+
return 0, errors.New("pparams are not expected type")
151+
}
152+
baseFee, err := MinFeeTx(tx, pparams)
153+
if err != nil {
154+
return 0, err
155+
}
156+
refScriptFee, err := CalculateRefScriptFee(
157+
scriptSize,
158+
conwayPparams.MinFeeRefScriptCostPerByte,
159+
RefScriptCostStride,
160+
&cbor.Rat{Rat: big.NewRat(6, 5)},
161+
)
162+
if err != nil {
163+
return 0, err
164+
}
165+
if baseFee > math.MaxUint64-refScriptFee {
166+
return 0, errors.New("minimum transaction fee overflow")
167+
}
168+
return baseFee + refScriptFee, nil
169+
}
170+
171+
// MinFeeTxWithUtxo calculates the Conway minimum fee using the same consumed
172+
// reference-script set as the transaction and block size limits.
173+
func MinFeeTxWithUtxo(
174+
tx common.Transaction,
175+
pparams common.ProtocolParameters,
176+
utxoState common.UtxoState,
177+
) (uint64, error) {
178+
scriptSize, err := common.ConsumedReferenceScriptSize(tx, utxoState)
179+
if err != nil {
180+
return 0, err
181+
}
182+
return MinFeeTxWithRefScriptSize(tx, pparams, scriptSize)
183+
}

0 commit comments

Comments
 (0)