Skip to content

Commit 3d64f65

Browse files
authored
Merge pull request #391 from sgbett/feat/377-sdk-cross-sdk-alignment-polish
feat: SDK cross-SDK alignment polish (F1.1, F1.2, F1.7, F3.18, F4.6, F4.10/F4.11, F5.11, F6.7, F6.16)
2 parents f7d2bed + 7398d61 commit 3d64f65

20 files changed

+549
-33
lines changed

.rubocop.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ AllCops:
99
Style/Documentation:
1010
Enabled: false
1111

12+
# `be > 0` is idiomatic RSpec for numeric comparisons; `be.positive?` is broken syntax.
13+
Style/NumericPredicate:
14+
Exclude:
15+
- 'gem/*/spec/**/*'
16+
1217
Metrics/BlockLength:
1318
Exclude:
1419
- 'gem/bsv-wallet/lib/bsv/wallet_interface/wire/**/*'

gem/bsv-sdk/lib/bsv-sdk.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ module BSV
1313
autoload :Identity, 'bsv/identity'
1414
autoload :Registry, 'bsv/registry'
1515
autoload :MCP, 'bsv/mcp'
16+
autoload :Messages, 'bsv/messages'
1617
end

gem/bsv-sdk/lib/bsv/messages.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
module BSV
4+
# Namespace providing TS SDK naming parity for messaging primitives.
5+
#
6+
# Re-exports {BSV::Primitives::SignedMessage} and {BSV::Primitives::EncryptedMessage}
7+
# under the +BSV::Messages+ namespace, matching the structure of the TypeScript SDK
8+
# (ts-sdk/src/messages/index.ts).
9+
#
10+
# The canonical implementations remain in +BSV::Primitives+; this module is a
11+
# lightweight re-export only.
12+
module Messages
13+
SignedMessage = BSV::Primitives::SignedMessage
14+
EncryptedMessage = BSV::Primitives::EncryptedMessage
15+
end
16+
end

gem/bsv-sdk/lib/bsv/primitives/base58.rb

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ def encode(bytes)
6161
#
6262
# @param string [String] Base58-encoded string
6363
# @return [String] decoded binary data
64-
# @raise [ArgumentError] if the string contains invalid Base58 characters
64+
# @raise [ArgumentError] if the string is empty or contains invalid Base58 characters
6565
def decode(string)
66-
return ''.b if string.empty?
66+
raise ArgumentError, 'cannot decode empty string' if string.empty?
6767

6868
# Count leading '1' characters (representing zero bytes)
6969
leading_ones = 0
@@ -89,19 +89,30 @@ def decode(string)
8989

9090
# Encode binary data with a 4-byte double-SHA-256 checksum appended.
9191
#
92+
# When +prefix+ is given, it is prepended to the payload before checksumming.
93+
# The checksum covers the full +prefix + payload+ concatenation.
94+
#
9295
# @param payload [String] binary data to encode
96+
# @param prefix [String, nil] optional version prefix to prepend (binary string)
9397
# @return [String] Base58Check-encoded string
94-
def check_encode(payload)
95-
checksum = Digest.sha256d(payload)[0, 4]
96-
encode(payload + checksum)
98+
def check_encode(payload, prefix: nil)
99+
full = (prefix || ''.b) + payload
100+
checksum = Digest.sha256d(full)[0, 4]
101+
encode(full + checksum)
97102
end
98103

99104
# Decode a Base58Check string and verify its checksum.
100105
#
106+
# When +prefix_length+ is greater than zero, the decoded payload is split
107+
# into a prefix and data portion. The returned value is then a Hash with
108+
# +:prefix+ and +:data+ keys. When +prefix_length+ is zero (default), the
109+
# raw payload is returned unchanged for backwards compatibility.
110+
#
101111
# @param string [String] Base58Check-encoded string
102-
# @return [String] decoded payload (without checksum)
112+
# @param prefix_length [Integer] number of leading bytes to treat as a prefix (default: 0)
113+
# @return [String, Hash] decoded payload, or +{ prefix:, data: }+ when prefix_length > 0
103114
# @raise [ChecksumError] if the checksum does not match or input is too short
104-
def check_decode(string)
115+
def check_decode(string, prefix_length: 0)
105116
data = decode(string)
106117
raise ChecksumError, 'input too short for checksum' if data.length < 4
107118

@@ -110,7 +121,11 @@ def check_decode(string)
110121
expected = Digest.sha256d(payload)[0, 4]
111122
raise ChecksumError, 'checksum mismatch' unless checksum == expected
112123

113-
payload
124+
return payload if prefix_length.zero?
125+
126+
raise ArgumentError, 'prefix_length exceeds payload' if prefix_length > payload.length
127+
128+
{ prefix: payload[0, prefix_length], data: payload[prefix_length..] }
114129
end
115130
end
116131
end

gem/bsv-sdk/lib/bsv/primitives/digest.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ def sha256d(data)
3838
sha256(sha256(data))
3939
end
4040

41+
alias hash256 sha256d
42+
module_function :hash256
43+
4144
# Compute SHA-512 digest.
4245
#
4346
# @param data [String] binary data to hash

gem/bsv-sdk/lib/bsv/primitives/ecies.rb

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ class DecryptionError < StandardError; end
3333
# @param message [String] the plaintext message
3434
# @param public_key [PublicKey] the recipient's public key
3535
# @param private_key [PrivateKey, nil] optional ephemeral key (random if omitted)
36-
# @return [String] encrypted payload: BIE1 magic + ephemeral pubkey + ciphertext + HMAC
37-
def encrypt(message, public_key, private_key: nil)
36+
# @param no_key [Boolean] when +true+, omit the ephemeral public key from the payload
37+
# @return [String] encrypted payload: BIE1 magic + [ephemeral pubkey] + ciphertext + HMAC
38+
def encrypt(message, public_key, private_key: nil, no_key: false)
3839
message = message.b if message.encoding != Encoding::ASCII_8BIT
3940

4041
ephemeral = private_key || PrivateKey.generate
@@ -48,7 +49,11 @@ def encrypt(message, public_key, private_key: nil)
4849
cipher.iv = iv
4950
ciphertext = message.empty? ? cipher.final : cipher.update(message) + cipher.final
5051

51-
payload = MAGIC + ephemeral_pub.compressed + ciphertext
52+
payload = if no_key
53+
MAGIC + ciphertext
54+
else
55+
MAGIC + ephemeral_pub.compressed + ciphertext
56+
end
5257
mac = Digest.hmac_sha256(key_m, payload)
5358

5459
payload + mac
@@ -58,29 +63,64 @@ def encrypt(message, public_key, private_key: nil)
5863
#
5964
# Verifies the HMAC before attempting decryption (encrypt-then-MAC).
6065
#
66+
# The ephemeral public key may be embedded in the payload (compressed or
67+
# uncompressed), or absent entirely (when the payload was encrypted with
68+
# +no_key: true+). When absent, +sender_public_key+ must be provided.
69+
#
70+
# If a key is found in the payload and +sender_public_key+ is also given,
71+
# the payload key takes precedence (matching TS SDK behaviour).
72+
#
6173
# @param data [String] the encrypted payload (BIE1 format)
6274
# @param private_key [PrivateKey] the recipient's private key
75+
# @param sender_public_key [PublicKey, nil] sender's public key (required when no key in payload)
6376
# @return [String] the decrypted plaintext
64-
# @raise [ArgumentError] if the data is too short or has invalid magic bytes
77+
# @raise [ArgumentError] if the data is too short, has invalid magic, or has no key and none provided
6578
# @raise [DecryptionError] if HMAC verification or AES decryption fails
66-
def decrypt(data, private_key)
79+
def decrypt(data, private_key, sender_public_key: nil)
6780
data = data.b if data.encoding != Encoding::ASCII_8BIT
6881

69-
raise ArgumentError, 'data too short' if data.bytesize < 85
82+
# Minimum: magic(4) + ciphertext(16) + HMAC(32) = 52 (no-key case)
83+
raise ArgumentError, 'data too short' if data.bytesize < 52
7084

7185
magic = data[0, 4]
7286
raise ArgumentError, 'invalid magic: expected BIE1' unless magic == MAGIC
7387

74-
ephemeral_pub_bytes = data[4, 33]
75-
mac = data[-32, 32]
76-
ciphertext = data[37...-32]
88+
# Determine ephemeral key presence and format by inspecting byte at offset 4.
89+
# Ambiguity note: a no-key payload whose ciphertext starts with 0x02/0x03/0x04
90+
# could be misinterpreted as containing an embedded key. The HMAC check below
91+
# will catch this (wrong shared secret → HMAC mismatch), but the resulting
92+
# error message will be misleading. This is a TS SDK design inheritance —
93+
# the wire format has no explicit key-presence flag.
94+
# Guard: only attempt to read a key if sufficient bytes remain beyond HMAC.
95+
tag_length = 32
96+
offset = 4
97+
ephemeral_pub = nil
98+
99+
remaining_after_offset = data.bytesize - offset - tag_length
100+
if remaining_after_offset >= 33
101+
first_byte = data.getbyte(offset)
102+
if [0x02, 0x03].include?(first_byte)
103+
# Compressed key: 33 bytes
104+
ephemeral_pub = PublicKey.from_bytes(data[offset, 33])
105+
offset += 33
106+
elsif first_byte == 0x04 && remaining_after_offset >= 65
107+
# Uncompressed key: 65 bytes
108+
ephemeral_pub = PublicKey.from_bytes(data[offset, 65])
109+
offset += 65
110+
end
111+
end
112+
113+
# If no key found in payload, fall back to provided sender_public_key
114+
ephemeral_pub ||= sender_public_key
115+
raise ArgumentError, 'sender_public_key required when no key in payload' if ephemeral_pub.nil?
77116

78-
ephemeral_pub = PublicKey.from_bytes(ephemeral_pub_bytes)
117+
mac = data[-tag_length, tag_length]
118+
ciphertext = data[offset...-tag_length]
79119

80120
iv, key_e, key_m = derive_keys(private_key, ephemeral_pub)
81121

82122
# Verify HMAC before decryption (encrypt-then-MAC)
83-
payload = data[0...-32]
123+
payload = data[0...-tag_length]
84124
expected_mac = Digest.hmac_sha256(key_m, payload)
85125

86126
raise DecryptionError, 'HMAC verification failed' unless secure_compare(mac, expected_mac)

gem/bsv-sdk/lib/bsv/script/script.rb

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,19 @@ def self.op_return(*data_items)
144144

145145
# Construct a Pay-to-Public-Key-Hash (P2PKH) locking script.
146146
#
147-
# @param pubkey_hash [String] 20-byte public key hash
147+
# Accepts either a raw 20-byte binary hash or a Base58Check address string.
148+
# When given an address string, the version prefix is validated: +0x00+
149+
# (mainnet) and +0x6f+ (testnet) are accepted; +0x05+ (P2SH) is rejected
150+
# with a clear error message.
151+
#
152+
# @param pubkey_hash_or_address [String] 20-byte binary pubkey hash, or a
153+
# Base58Check address string
148154
# @return [Script]
149-
# @raise [ArgumentError] if pubkey_hash is not 20 bytes
150-
def self.p2pkh_lock(pubkey_hash)
151-
raise ArgumentError, 'pubkey_hash must be 20 bytes' unless pubkey_hash.bytesize == 20
155+
# @raise [ArgumentError] if the argument is not a valid 20-byte hash, if the
156+
# address has an unrecognised prefix, or if a P2SH address is supplied
157+
# @raise [BSV::Primitives::Base58::ChecksumError] if the address checksum is invalid
158+
def self.p2pkh_lock(pubkey_hash_or_address)
159+
pubkey_hash = resolve_pubkey_hash(pubkey_hash_or_address)
152160

153161
buf = [
154162
Opcodes::OP_DUP,
@@ -159,6 +167,37 @@ def self.p2pkh_lock(pubkey_hash)
159167
new(buf)
160168
end
161169

170+
# @api private
171+
# Resolve a pubkey_hash argument that may be a raw binary hash or a
172+
# Base58Check address string.
173+
def self.resolve_pubkey_hash(arg)
174+
# A 20-byte ASCII-8BIT string is treated as a raw binary hash.
175+
return arg if arg.encoding == Encoding::ASCII_8BIT && arg.bytesize == 20
176+
177+
# Otherwise treat as a Base58Check address string.
178+
decoded = BSV::Primitives::Base58.check_decode(arg, prefix_length: 1)
179+
prefix = decoded[:prefix]
180+
data = decoded[:data]
181+
182+
p2sh_prefix = "\x05".b
183+
mainnet_prefix = "\x00".b
184+
testnet_prefix = "\x6f".b # accepted for testnet interop; no mainnet-only restriction
185+
186+
if prefix == p2sh_prefix
187+
raise ArgumentError,
188+
'P2SH addresses are not supported on BSV; ' \
189+
'use p2pkh_lock with a P2PKH address or 20-byte hash'
190+
end
191+
192+
raise ArgumentError, "unrecognised address prefix: 0x#{prefix.unpack1('H*')}" \
193+
unless prefix == mainnet_prefix || prefix == testnet_prefix
194+
195+
raise ArgumentError, 'decoded hash must be 20 bytes' unless data.bytesize == 20
196+
197+
data
198+
end
199+
private_class_method :resolve_pubkey_hash
200+
162201
# Construct a P2PKH unlocking script.
163202
#
164203
# @param signature_der [String] DER-encoded signature with sighash byte appended

gem/bsv-sdk/lib/bsv/transaction/merkle_path.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,29 @@ def compute_root_hex(txid_hex = nil)
303303
# Computes the merkle root from the path and txid, then checks it
304304
# against the blockchain via the provided chain tracker.
305305
#
306+
# For coinbase transactions (offset 0 in the merkle tree), an additional
307+
# maturity check is performed: the coinbase must have at least 100
308+
# confirmations before it is considered spendable/valid.
309+
#
310+
# NOTE: The TS SDK has an inverted coinbase maturity check at MerklePath.ts:378
311+
# (`this.blockHeight + 100 < height`), which rejects mature coinbase transactions
312+
# and accepts immature ones — the opposite of the intended behaviour. The correct
313+
# logic is: reject when `current_height - block_height < 100` (immature).
314+
#
306315
# @param txid_hex [String] hex-encoded transaction ID (display order)
307316
# @param chain_tracker [ChainTracker] chain tracker to verify the root against
308317
# @return [Boolean] true if the computed root matches the block at this height
309318
def verify(txid_hex, chain_tracker)
319+
txid_bytes = [txid_hex].pack('H*').reverse
320+
txid_leaf = @path[0].find { |l| l.hash == txid_bytes }
321+
322+
# Offset 0 in a block's merkle tree is always the coinbase transaction —
323+
# a Bitcoin protocol invariant. Apply the 100-block maturity check.
324+
if txid_leaf&.offset&.zero?
325+
current = chain_tracker.current_height
326+
return false if current - @block_height < 100
327+
end
328+
310329
root_hex = compute_root_hex(txid_hex)
311330
chain_tracker.valid_root_for_height?(root_hex, @block_height)
312331
end

gem/bsv-sdk/lib/bsv/transaction/transaction.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,7 @@ def compute_fee_sats(model_or_fee)
801801
when FeeModel
802802
model_or_fee.compute_fee(self)
803803
when Numeric
804-
model_or_fee.ceil
804+
model_or_fee.ceil # round up — fractional satoshis from callers are not valid; rounding up prevents underpayment
805805
else
806806
raise ArgumentError, "expected FeeModel, Numeric, or nil; got #{model_or_fee.class}"
807807
end

gem/bsv-sdk/lib/bsv/transaction/transaction_input.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class TransactionInput
1515
attr_reader :prev_tx_out_index
1616

1717
# @return [Integer] sequence number (default: 0xFFFFFFFF)
18-
attr_reader :sequence
18+
attr_accessor :sequence
1919

2020
# @return [Script::Script, nil] the unlocking script (set after signing)
2121
attr_accessor :unlocking_script

0 commit comments

Comments
 (0)