Skip to content

Commit af9bd3e

Browse files
authored
fix(validator): guard satoshi totals against uint64 overflow (#62) (#101)
Reject when adding the next output/input value would wrap the total above maxSatoshis. Closes F-004.
1 parent 15397f9 commit af9bd3e

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

validator/validator.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,13 @@ func (v *Validator) checkOutputs(tx *sdkTx.Transaction) error {
214214
case isData && output.Satoshis != 0:
215215
return errors.Join(ErrTxOutputInvalid, ErrTxOutputNonZeroOpReturn)
216216
}
217+
// Overflow-safe accumulation: both total and output.Satoshis are
218+
// individually <= maxSatoshis here, so maxSatoshis - output.Satoshis
219+
// cannot underflow and the comparison catches any wrap before it
220+
// happens.
221+
if total > maxSatoshis-output.Satoshis {
222+
return errors.Join(ErrTxOutputInvalid, ErrTxOutputTotalSatoshisTooHigh)
223+
}
217224
total += output.Satoshis
218225
}
219226
if total > maxSatoshis {
@@ -237,6 +244,13 @@ func (v *Validator) checkInputs(tx *sdkTx.Transaction) error {
237244
if inputSatoshis > maxSatoshis {
238245
return errors.Join(ErrTxInputInvalid, ErrTxInputSatoshisTooHigh)
239246
}
247+
// Overflow-safe accumulation: both total and inputSatoshis are
248+
// individually <= maxSatoshis here, so maxSatoshis - inputSatoshis
249+
// cannot underflow and the comparison catches any wrap before it
250+
// happens.
251+
if total > maxSatoshis-inputSatoshis {
252+
return errors.Join(ErrTxInputInvalid, ErrTxInputTotalSatoshisTooHigh)
253+
}
240254
total += inputSatoshis
241255
}
242256
if total > maxSatoshis {

validator/validator_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package validator
22

33
import (
4+
"errors"
45
"testing"
56

7+
"github.com/bsv-blockchain/go-sdk/chainhash"
8+
"github.com/bsv-blockchain/go-sdk/script"
9+
sdkTx "github.com/bsv-blockchain/go-sdk/transaction"
10+
611
arcerrors "github.com/bsv-blockchain/arcade/errors"
712
)
813

@@ -103,3 +108,172 @@ func TestWrapPolicyError_UnlockingScripts(t *testing.T) {
103108
t.Errorf("expected StatusUnlockingScripts (461), got %d", arcErr.StatusCode)
104109
}
105110
}
111+
112+
// nonDataLockingScript returns a minimal non-data locking script suitable for
113+
// exercising the output validation paths. A single OP_TRUE (0x51) is treated
114+
// as a non-data, non-empty locking script by the SDK's IsData heuristic.
115+
func nonDataLockingScript() *script.Script {
116+
s := script.Script([]byte{0x51})
117+
return &s
118+
}
119+
120+
// nonZeroSourceTXID returns a pointer to a non-zero chainhash so an input is
121+
// not treated as a coinbase input by checkInputs.
122+
func nonZeroSourceTXID() *chainhash.Hash {
123+
h := chainhash.Hash{}
124+
h[0] = 0x01
125+
return &h
126+
}
127+
128+
// TestCheckOutputs_OverflowGuarded crafts a transaction with two non-data
129+
// outputs each at maxSatoshis. Each value individually passes the
130+
// "output.Satoshis > maxSatoshis" check, and their sum exceeds maxSatoshis;
131+
// the per-iteration guard must reject this before the unbounded total can
132+
// be relied upon.
133+
func TestCheckOutputs_OverflowGuarded(t *testing.T) {
134+
v := NewValidator(nil, nil)
135+
136+
tx := &sdkTx.Transaction{
137+
Outputs: []*sdkTx.TransactionOutput{
138+
{Satoshis: maxSatoshis, LockingScript: nonDataLockingScript()},
139+
{Satoshis: maxSatoshis, LockingScript: nonDataLockingScript()},
140+
},
141+
}
142+
143+
err := v.checkOutputs(tx)
144+
if err == nil {
145+
t.Fatal("expected error for total satoshis above maxSatoshis, got nil")
146+
}
147+
if !errors.Is(err, ErrTxOutputTotalSatoshisTooHigh) {
148+
t.Errorf("expected ErrTxOutputTotalSatoshisTooHigh, got %v", err)
149+
}
150+
if !errors.Is(err, ErrTxOutputInvalid) {
151+
t.Errorf("expected ErrTxOutputInvalid wrap, got %v", err)
152+
}
153+
}
154+
155+
// TestCheckOutputs_ManySmallOutputsCannotOverflow exercises the per-iteration
156+
// guard by accumulating many outputs whose total exceeds maxSatoshis. This is
157+
// the regression case for F-004: prior to the fix a sequence of values that
158+
// summed past 2^64 could wrap and slip past the post-loop check.
159+
func TestCheckOutputs_ManySmallOutputsCannotOverflow(t *testing.T) {
160+
v := NewValidator(nil, nil)
161+
162+
half := uint64(maxSatoshis / 2)
163+
tx := &sdkTx.Transaction{
164+
Outputs: []*sdkTx.TransactionOutput{
165+
{Satoshis: half, LockingScript: nonDataLockingScript()},
166+
{Satoshis: half, LockingScript: nonDataLockingScript()},
167+
{Satoshis: half, LockingScript: nonDataLockingScript()},
168+
},
169+
}
170+
171+
err := v.checkOutputs(tx)
172+
if err == nil {
173+
t.Fatal("expected error for cumulative output satoshis above maxSatoshis, got nil")
174+
}
175+
if !errors.Is(err, ErrTxOutputTotalSatoshisTooHigh) {
176+
t.Errorf("expected ErrTxOutputTotalSatoshisTooHigh, got %v", err)
177+
}
178+
}
179+
180+
// TestCheckOutputs_ValidPasses confirms a legitimate transaction is still
181+
// accepted by checkOutputs after the overflow guard is added.
182+
func TestCheckOutputs_ValidPasses(t *testing.T) {
183+
v := NewValidator(nil, nil)
184+
185+
tx := &sdkTx.Transaction{
186+
Outputs: []*sdkTx.TransactionOutput{
187+
{Satoshis: 1_000, LockingScript: nonDataLockingScript()},
188+
{Satoshis: 2_000, LockingScript: nonDataLockingScript()},
189+
},
190+
}
191+
192+
if err := v.checkOutputs(tx); err != nil {
193+
t.Fatalf("expected no error for valid outputs, got %v", err)
194+
}
195+
}
196+
197+
// TestCheckInputs_OverflowGuarded crafts a transaction whose input
198+
// SourceTxSatoshis values are each <= maxSatoshis but whose cumulative sum
199+
// exceeds maxSatoshis. Without the per-iteration guard a uint64 wrap could
200+
// allow the post-loop check to pass.
201+
func TestCheckInputs_OverflowGuarded(t *testing.T) {
202+
v := NewValidator(nil, nil)
203+
204+
makeInput := func(sats uint64) *sdkTx.TransactionInput {
205+
in := &sdkTx.TransactionInput{SourceTXID: nonZeroSourceTXID()}
206+
in.SetSourceTxOutput(&sdkTx.TransactionOutput{Satoshis: sats, LockingScript: nonDataLockingScript()})
207+
return in
208+
}
209+
210+
tx := &sdkTx.Transaction{
211+
Inputs: []*sdkTx.TransactionInput{
212+
makeInput(maxSatoshis),
213+
makeInput(maxSatoshis),
214+
},
215+
}
216+
217+
err := v.checkInputs(tx)
218+
if err == nil {
219+
t.Fatal("expected error for total input satoshis above maxSatoshis, got nil")
220+
}
221+
if !errors.Is(err, ErrTxInputTotalSatoshisTooHigh) {
222+
t.Errorf("expected ErrTxInputTotalSatoshisTooHigh, got %v", err)
223+
}
224+
if !errors.Is(err, ErrTxInputInvalid) {
225+
t.Errorf("expected ErrTxInputInvalid wrap, got %v", err)
226+
}
227+
}
228+
229+
// TestCheckInputs_ManySmallInputsCannotOverflow exercises the per-iteration
230+
// guard via accumulation across multiple inputs.
231+
func TestCheckInputs_ManySmallInputsCannotOverflow(t *testing.T) {
232+
v := NewValidator(nil, nil)
233+
234+
half := uint64(maxSatoshis / 2)
235+
makeInput := func(sats uint64) *sdkTx.TransactionInput {
236+
in := &sdkTx.TransactionInput{SourceTXID: nonZeroSourceTXID()}
237+
in.SetSourceTxOutput(&sdkTx.TransactionOutput{Satoshis: sats, LockingScript: nonDataLockingScript()})
238+
return in
239+
}
240+
241+
tx := &sdkTx.Transaction{
242+
Inputs: []*sdkTx.TransactionInput{
243+
makeInput(half),
244+
makeInput(half),
245+
makeInput(half),
246+
},
247+
}
248+
249+
err := v.checkInputs(tx)
250+
if err == nil {
251+
t.Fatal("expected error for cumulative input satoshis above maxSatoshis, got nil")
252+
}
253+
if !errors.Is(err, ErrTxInputTotalSatoshisTooHigh) {
254+
t.Errorf("expected ErrTxInputTotalSatoshisTooHigh, got %v", err)
255+
}
256+
}
257+
258+
// TestCheckInputs_ValidPasses confirms a legitimate transaction is still
259+
// accepted by checkInputs after the overflow guard is added.
260+
func TestCheckInputs_ValidPasses(t *testing.T) {
261+
v := NewValidator(nil, nil)
262+
263+
makeInput := func(sats uint64) *sdkTx.TransactionInput {
264+
in := &sdkTx.TransactionInput{SourceTXID: nonZeroSourceTXID()}
265+
in.SetSourceTxOutput(&sdkTx.TransactionOutput{Satoshis: sats, LockingScript: nonDataLockingScript()})
266+
return in
267+
}
268+
269+
tx := &sdkTx.Transaction{
270+
Inputs: []*sdkTx.TransactionInput{
271+
makeInput(1_000),
272+
makeInput(2_000),
273+
},
274+
}
275+
276+
if err := v.checkInputs(tx); err != nil {
277+
t.Fatalf("expected no error for valid inputs, got %v", err)
278+
}
279+
}

0 commit comments

Comments
 (0)