Skip to content

Commit 4be53f3

Browse files
authored
Merge pull request #42 from sgbett/feature/19-additional-sighash-types
feat(transaction): support all SIGHASH types (NONE, SINGLE, ANYONE_CAN_PAY)
2 parents 190a1cd + a6313bd commit 4be53f3

File tree

4 files changed

+364
-12
lines changed

4 files changed

+364
-12
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# Additional SIGHASH Types — Task #19
2+
3+
## Context
4+
5+
Issue #19, sub-task of HLR #11. Extends BIP-143 (FORKID) sighash computation to support NONE, SINGLE, ANYONE_CAN_PAY, and their combinations. Currently only `SIGHASH_ALL|FORKID` (0x41) is implemented.
6+
7+
Go SDK: `transaction/signaturehash.go` lines 58-149
8+
9+
---
10+
11+
## File Structure
12+
13+
```
14+
lib/bsv/transaction/sighash.rb # MODIFIED — add convenience constants
15+
lib/bsv/transaction/transaction.rb # MODIFIED — conditional hash logic in sighash_preimage
16+
spec/bsv/transaction/transaction_spec.rb # MODIFIED — add sighash tests
17+
```
18+
19+
No new files needed.
20+
21+
---
22+
23+
## Implementation
24+
25+
### 1. Add convenience constants to `Sighash` module
26+
27+
`lib/bsv/transaction/sighash.rb`:
28+
29+
```ruby
30+
module Sighash
31+
ALL = 0x01
32+
NONE = 0x02
33+
SINGLE = 0x03
34+
ANYONE_CAN_PAY = 0x80
35+
FORK_ID = 0x40
36+
MASK = 0x1f
37+
38+
ALL_FORK_ID = ALL | FORK_ID # 0x41
39+
NONE_FORK_ID = NONE | FORK_ID # 0x42
40+
SINGLE_FORK_ID = SINGLE | FORK_ID # 0x43
41+
42+
ALL_FORK_ID_ANYONE_CAN_PAY = ALL_FORK_ID | ANYONE_CAN_PAY # 0xC1
43+
NONE_FORK_ID_ANYONE_CAN_PAY = NONE_FORK_ID | ANYONE_CAN_PAY # 0xC2
44+
SINGLE_FORK_ID_ANYONE_CAN_PAY = SINGLE_FORK_ID | ANYONE_CAN_PAY # 0xC3
45+
end
46+
```
47+
48+
### 2. Modify `sighash_preimage` and private hash methods
49+
50+
`lib/bsv/transaction/transaction.rb`:
51+
52+
Pass `sighash_type` to the three hash methods and apply BIP-143 conditional logic (matching Go SDK `CalcInputPreimage` lines 79-97):
53+
54+
**hashPrevouts** (field 2):
55+
- Default: SHA256d of all input outpoints
56+
- If `ANYONE_CAN_PAY` is set: 32 zero bytes
57+
58+
**hashSequence** (field 3):
59+
- Default: SHA256d of all input sequences
60+
- If `ANYONE_CAN_PAY` is set, OR base type is `NONE` or `SINGLE`: 32 zero bytes
61+
62+
**hashOutputs** (field 8):
63+
- If base type is `ALL`: SHA256d of all outputs (current behaviour)
64+
- If base type is `NONE`: 32 zero bytes
65+
- If base type is `SINGLE`:
66+
- If `input_index < outputs.length`: SHA256d of output at `input_index` only
67+
- Else: 32 zero bytes (consensus edge case)
68+
69+
Base type is extracted via `sighash_type & MASK` (lower 5 bits).
70+
71+
Refactored method signatures:
72+
73+
```ruby
74+
def sighash_preimage(input_index, sighash_type = Sighash::ALL_FORK_ID)
75+
raise ArgumentError, '...' unless sighash_type & Sighash::FORK_ID != 0
76+
77+
input = @inputs[input_index]
78+
base_type = sighash_type & Sighash::MASK
79+
anyone = sighash_type & Sighash::ANYONE_CAN_PAY != 0
80+
81+
buf = [@version].pack('V')
82+
buf << hash_prevouts(anyone)
83+
buf << hash_sequence(anyone, base_type)
84+
buf << input.outpoint_binary
85+
# ... scriptCode, value, nSequence unchanged ...
86+
buf << hash_outputs(base_type, input_index)
87+
buf << [@lock_time].pack('V')
88+
buf << [sighash_type].pack('V')
89+
buf
90+
end
91+
92+
private
93+
94+
ZERO_HASH = ("\x00".b * 32).freeze
95+
96+
def hash_prevouts(anyone_can_pay)
97+
return ZERO_HASH if anyone_can_pay
98+
99+
buf = @inputs.map(&:outpoint_binary).join
100+
BSV::Primitives::Digest.sha256d(buf)
101+
end
102+
103+
def hash_sequence(anyone_can_pay, base_type)
104+
return ZERO_HASH if anyone_can_pay || base_type == Sighash::SINGLE || base_type == Sighash::NONE
105+
106+
buf = @inputs.map { |i| [i.sequence].pack('V') }.join
107+
BSV::Primitives::Digest.sha256d(buf)
108+
end
109+
110+
def hash_outputs(base_type, input_index)
111+
case base_type
112+
when Sighash::ALL
113+
buf = @outputs.map(&:to_binary).join
114+
BSV::Primitives::Digest.sha256d(buf)
115+
when Sighash::SINGLE
116+
if input_index < @outputs.length
117+
BSV::Primitives::Digest.sha256d(@outputs[input_index].to_binary)
118+
else
119+
ZERO_HASH
120+
end
121+
else # NONE
122+
ZERO_HASH
123+
end
124+
end
125+
```
126+
127+
---
128+
129+
## Test Strategy
130+
131+
The Go SDK test vectors only cover `SIGHASH_ALL|FORKID`. For the new types, use **structural verification**: build a transaction with known inputs/outputs, compute preimages for each sighash type, and verify the specific fields (hashPrevouts/hashSequence/hashOutputs) match expected values.
132+
133+
### Test setup
134+
135+
Reuse the existing test transaction (vector 1: 1 input, 2 outputs) already in the spec. Extract its known hashPrevouts, hashSequence, hashOutputs from the existing ALL|FORKID preimage, then verify:
136+
137+
### Tests to add
138+
139+
**NONE|FORKID (0x42):**
140+
- hashPrevouts = same as ALL (all outpoints)
141+
- hashSequence = 32 zero bytes
142+
- hashOutputs = 32 zero bytes
143+
- Fields 1, 4-7, 9-10 unchanged
144+
145+
**SINGLE|FORKID (0x43):**
146+
- hashPrevouts = same as ALL (all outpoints)
147+
- hashSequence = 32 zero bytes
148+
- hashOutputs = SHA256d(output at input_index only)
149+
- Fields 1, 4-7, 9-10 unchanged
150+
151+
**ALL|FORKID|ANYONE_CAN_PAY (0xC1):**
152+
- hashPrevouts = 32 zero bytes
153+
- hashSequence = 32 zero bytes
154+
- hashOutputs = same as ALL (all outputs)
155+
- Fields 1, 4-7, 9-10 unchanged
156+
157+
**NONE|FORKID|ANYONE_CAN_PAY (0xC2):**
158+
- hashPrevouts = 32 zero bytes
159+
- hashSequence = 32 zero bytes
160+
- hashOutputs = 32 zero bytes
161+
162+
**SINGLE|FORKID|ANYONE_CAN_PAY (0xC3):**
163+
- hashPrevouts = 32 zero bytes
164+
- hashSequence = 32 zero bytes
165+
- hashOutputs = SHA256d(output at input_index only)
166+
167+
**SINGLE edge case:**
168+
- input_index >= outputs.length → hashOutputs = 32 zero bytes
169+
170+
**Existing tests:**
171+
- Verify all 3 existing `SIGHASH_ALL|FORKID` vectors still pass (regression)
172+
173+
**Constant tests:**
174+
- Verify new MASK constant = 0x1f
175+
- Verify combined constant values (0x42, 0x43, 0xC1, 0xC2, 0xC3)
176+
177+
**Non-FORKID rejection:**
178+
- Still raises ArgumentError for types without FORK_ID bit
179+
180+
---
181+
182+
## Commit
183+
184+
Single commit: `feat(transaction): support all SIGHASH types (NONE, SINGLE, ANYONE_CAN_PAY)`
185+
186+
---
187+
188+
## Verification
189+
190+
```bash
191+
bundle exec rspec spec/bsv/transaction/transaction_spec.rb
192+
bundle exec rubocop lib/bsv/transaction/sighash.rb lib/bsv/transaction/transaction.rb
193+
bundle exec rake
194+
```

lib/bsv/transaction/sighash.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ module Sighash
88
SINGLE = 0x03
99
ANYONE_CAN_PAY = 0x80
1010
FORK_ID = 0x40
11-
ALL_FORK_ID = ALL | FORK_ID # 0x41
11+
MASK = 0x1f
12+
13+
ALL_FORK_ID = ALL | FORK_ID # 0x41
14+
NONE_FORK_ID = NONE | FORK_ID # 0x42
15+
SINGLE_FORK_ID = SINGLE | FORK_ID # 0x43
16+
17+
ALL_FORK_ID_ANYONE_CAN_PAY = ALL_FORK_ID | ANYONE_CAN_PAY # 0xC1
18+
NONE_FORK_ID_ANYONE_CAN_PAY = NONE_FORK_ID | ANYONE_CAN_PAY # 0xC2
19+
SINGLE_FORK_ID_ANYONE_CAN_PAY = SINGLE_FORK_ID | ANYONE_CAN_PAY # 0xC3
1220
end
1321
end
1422
end

lib/bsv/transaction/transaction.rb

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,17 @@ def sighash_preimage(input_index, sighash_type = Sighash::ALL_FORK_ID)
120120
raise ArgumentError, 'only SIGHASH_FORKID types are supported' unless sighash_type & Sighash::FORK_ID != 0
121121

122122
input = @inputs[input_index]
123+
base_type = sighash_type & Sighash::MASK
124+
anyone = sighash_type.anybits?(Sighash::ANYONE_CAN_PAY)
123125

124126
# 1. nVersion (4 LE)
125127
buf = [@version].pack('V')
126128

127-
# 2. hashPrevouts — SHA256d of all outpoints
128-
buf << hash_prevouts
129+
# 2. hashPrevouts
130+
buf << hash_prevouts(anyone)
129131

130-
# 3. hashSequence — SHA256d of all sequences
131-
buf << hash_sequence
132+
# 3. hashSequence
133+
buf << hash_sequence(anyone, base_type)
132134

133135
# 4. outpoint of this input (32 + 4)
134136
buf << input.outpoint_binary
@@ -144,8 +146,8 @@ def sighash_preimage(input_index, sighash_type = Sighash::ALL_FORK_ID)
144146
# 7. nSequence of this input (4 LE)
145147
buf << [input.sequence].pack('V')
146148

147-
# 8. hashOutputs — SHA256d of all outputs
148-
buf << hash_outputs
149+
# 8. hashOutputs
150+
buf << hash_outputs(base_type, input_index)
149151

150152
# 9. nLockTime (4 LE)
151153
buf << [@lock_time].pack('V')
@@ -197,19 +199,35 @@ def estimated_fee(satoshis_per_byte: 0.5)
197199

198200
private
199201

200-
def hash_prevouts
202+
ZERO_HASH = "\x00".b * 32
203+
private_constant :ZERO_HASH
204+
205+
def hash_prevouts(anyone_can_pay)
206+
return ZERO_HASH if anyone_can_pay
207+
201208
buf = @inputs.map(&:outpoint_binary).join
202209
BSV::Primitives::Digest.sha256d(buf)
203210
end
204211

205-
def hash_sequence
212+
def hash_sequence(anyone_can_pay, base_type)
213+
return ZERO_HASH if anyone_can_pay || base_type == Sighash::SINGLE || base_type == Sighash::NONE
214+
206215
buf = @inputs.map { |i| [i.sequence].pack('V') }.join
207216
BSV::Primitives::Digest.sha256d(buf)
208217
end
209218

210-
def hash_outputs
211-
buf = @outputs.map(&:to_binary).join
212-
BSV::Primitives::Digest.sha256d(buf)
219+
def hash_outputs(base_type, input_index)
220+
case base_type
221+
when Sighash::ALL
222+
buf = @outputs.map(&:to_binary).join
223+
BSV::Primitives::Digest.sha256d(buf)
224+
when Sighash::SINGLE
225+
return ZERO_HASH if input_index >= @outputs.length
226+
227+
BSV::Primitives::Digest.sha256d(@outputs[input_index].to_binary)
228+
else # NONE
229+
ZERO_HASH
230+
end
213231
end
214232

215233
def estimated_size

0 commit comments

Comments
 (0)