bsv-sdk 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c8eb40d6b6cfbf03769704b141d681d92229d780abf1efe4d9e32a01db4c90d
4
- data.tar.gz: 43950d736297f149ad6fff69d88ebe342111dc1c9259b3fb5b0b78ea90492e47
3
+ metadata.gz: 392488d3baa5c87f80eda541ca38b55ef0c4c55aa0848acdee995e6f4d1b79b1
4
+ data.tar.gz: 10bc2f546ec61020eac37158792de00328db64150d1f394375e4287cf32c5ab2
5
5
  SHA512:
6
- metadata.gz: b0a2a209b899e5f5b4527c91c880fe3c0c3113c155ea5ce34d87496a956656cbe06e78a0b02b03b17da2355c97e91e597352c2fceda438185802f1665fdd4c0a
7
- data.tar.gz: ec8d4fef6344e1f121dba4516c80d36f6089133d70ae355d69ac361b62b2aa712fec2e0a84e397db5545efc434e606004bf7ff5d3fddb573cfef5cdd4c46c052
6
+ metadata.gz: 973064e412dba805c930504c065fac014bfda41ef0306129e7035516b8c4f3f4450b1935667531ef2711a44ef8aad5bddecd146d36f3d35cb6fd2505bbc5be17
7
+ data.tar.gz: 46f59effd056e78c6a93d3fadcd4858550fc405a97032201e7d8daa2fdbcef60eb13fd87e15597b0250c4a7acebfa1773d76f095ea349baadf56ef27f2a3fb44
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to the `bsv-sdk` gem are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.11.0 — 2026-04-12
9
+
10
+ ### Added
11
+ - `Base58Check.check_encode` accepts `prefix:` parameter; `check_decode` accepts `prefix_length:` (F1.1)
12
+ - `Base58.decode("")` raises `ArgumentError` instead of returning empty bytes (F1.2)
13
+ - `Digest.hash256` alias for `sha256d` (F1.7)
14
+ - `Script.p2pkh_lock` accepts Base58Check address strings as well as raw 20-byte hashes (F3.18)
15
+ - `TransactionInput#sequence` and `TransactionOutput#locking_script` now writable via `attr_accessor` (F4.10/F4.11)
16
+ - Coinbase 100-block maturity check in `MerklePath#verify` — intentionally diverges from TS SDK bug (F5.11)
17
+ - ECIES Electrum `no_key:` encrypt and `sender_public_key:` decrypt with uncompressed key detection (F6.7)
18
+ - `BSV::Messages` namespace re-exporting `SignedMessage` and `EncryptedMessage` (F6.16)
19
+
20
+ ### Fixed
21
+ - `PointInFiniteField` zero-coordinate Base58 round-trip (BN(0) now encodes as `"\x00"`)
22
+ - `p2pkh_lock` encoding check uses bytesize only — accepts 20-byte hashes regardless of string encoding
23
+ - `Base58Check.check_decode` raises on `prefix_length` exceeding payload
24
+ - ECIES key-format ambiguity documented (inherited TS SDK design)
25
+
26
+ ### Changed
27
+ - Numeric fee `.ceil` behaviour documented with inline comment (F4.6)
28
+
8
29
  ## 0.10.1 — 2026-04-12
9
30
 
10
31
  ### Added
@@ -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
@@ -61,9 +61,9 @@ module BSV
61
61
  #
62
62
  # @param string [String] Base58-encoded string
63
63
  # @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
65
65
  def decode(string)
66
- return ''.b if string.empty?
66
+ raise ArgumentError, 'cannot decode empty string' if string.empty?
67
67
 
68
68
  # Count leading '1' characters (representing zero bytes)
69
69
  leading_ones = 0
@@ -89,19 +89,30 @@ module BSV
89
89
 
90
90
  # Encode binary data with a 4-byte double-SHA-256 checksum appended.
91
91
  #
92
+ # When +prefix+ is given, it is prepended to the payload before checksumming.
93
+ # The checksum covers the full +prefix + payload+ concatenation.
94
+ #
92
95
  # @param payload [String] binary data to encode
96
+ # @param prefix [String, nil] optional version prefix to prepend (binary string)
93
97
  # @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)
97
102
  end
98
103
 
99
104
  # Decode a Base58Check string and verify its checksum.
100
105
  #
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
+ #
101
111
  # @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
103
114
  # @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)
105
116
  data = decode(string)
106
117
  raise ChecksumError, 'input too short for checksum' if data.length < 4
107
118
 
@@ -110,7 +121,11 @@ module BSV
110
121
  expected = Digest.sha256d(payload)[0, 4]
111
122
  raise ChecksumError, 'checksum mismatch' unless checksum == expected
112
123
 
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..] }
114
129
  end
115
130
  end
116
131
  end
@@ -38,6 +38,9 @@ module BSV
38
38
  sha256(sha256(data))
39
39
  end
40
40
 
41
+ alias hash256 sha256d
42
+ module_function :hash256
43
+
41
44
  # Compute SHA-512 digest.
42
45
  #
43
46
  # @param data [String] binary data to hash
@@ -33,8 +33,9 @@ module BSV
33
33
  # @param message [String] the plaintext message
34
34
  # @param public_key [PublicKey] the recipient's public key
35
35
  # @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)
38
39
  message = message.b if message.encoding != Encoding::ASCII_8BIT
39
40
 
40
41
  ephemeral = private_key || PrivateKey.generate
@@ -48,7 +49,11 @@ module BSV
48
49
  cipher.iv = iv
49
50
  ciphertext = message.empty? ? cipher.final : cipher.update(message) + cipher.final
50
51
 
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
52
57
  mac = Digest.hmac_sha256(key_m, payload)
53
58
 
54
59
  payload + mac
@@ -58,29 +63,64 @@ module BSV
58
63
  #
59
64
  # Verifies the HMAC before attempting decryption (encrypt-then-MAC).
60
65
  #
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
+ #
61
73
  # @param data [String] the encrypted payload (BIE1 format)
62
74
  # @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)
63
76
  # @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
65
78
  # @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)
67
80
  data = data.b if data.encoding != Encoding::ASCII_8BIT
68
81
 
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
70
84
 
71
85
  magic = data[0, 4]
72
86
  raise ArgumentError, 'invalid magic: expected BIE1' unless magic == MAGIC
73
87
 
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?
77
116
 
78
- ephemeral_pub = PublicKey.from_bytes(ephemeral_pub_bytes)
117
+ mac = data[-tag_length, tag_length]
118
+ ciphertext = data[offset...-tag_length]
79
119
 
80
120
  iv, key_e, key_m = derive_keys(private_key, ephemeral_pub)
81
121
 
82
122
  # Verify HMAC before decryption (encrypt-then-MAC)
83
- payload = data[0...-32]
123
+ payload = data[0...-tag_length]
84
124
  expected_mac = Digest.hmac_sha256(key_m, payload)
85
125
 
86
126
  raise DecryptionError, 'HMAC verification failed' unless secure_compare(mac, expected_mac)
@@ -65,7 +65,10 @@ module BSV
65
65
  # Convert an OpenSSL::BN to a big-endian binary string, stripping the
66
66
  # sign byte that OpenSSL::BN#to_s(2) sometimes prepends.
67
67
  def bn_to_bytes(bn)
68
- bn.to_s(2)
68
+ bytes = bn.to_s(2)
69
+ # BN(0).to_s(2) returns "" — encode as a single zero byte so
70
+ # Base58 round-trip works (Base58.decode raises on empty strings).
71
+ bytes.empty? ? "\x00".b : bytes
69
72
  end
70
73
  end
71
74
  end
@@ -144,11 +144,19 @@ module BSV
144
144
 
145
145
  # Construct a Pay-to-Public-Key-Hash (P2PKH) locking script.
146
146
  #
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
148
154
  # @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)
152
160
 
153
161
  buf = [
154
162
  Opcodes::OP_DUP,
@@ -159,6 +167,40 @@ module BSV
159
167
  new(buf)
160
168
  end
161
169
 
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 string is treated as a raw binary hash regardless of
175
+ # encoding — callers commonly pass `"\x00" * 20` which is UTF-8.
176
+ # A valid Base58Check address is always longer than 20 characters
177
+ # (25 raw bytes → ~34 Base58 characters), so this is unambiguous.
178
+ return arg.b if arg.bytesize == 20
179
+
180
+ # Otherwise treat as a Base58Check address string.
181
+ decoded = BSV::Primitives::Base58.check_decode(arg, prefix_length: 1)
182
+ prefix = decoded[:prefix]
183
+ data = decoded[:data]
184
+
185
+ p2sh_prefix = "\x05".b
186
+ mainnet_prefix = "\x00".b
187
+ testnet_prefix = "\x6f".b # accepted for testnet interop; no mainnet-only restriction
188
+
189
+ if prefix == p2sh_prefix
190
+ raise ArgumentError,
191
+ 'P2SH addresses are not supported on BSV; ' \
192
+ 'use p2pkh_lock with a P2PKH address or 20-byte hash'
193
+ end
194
+
195
+ raise ArgumentError, "unrecognised address prefix: 0x#{prefix.unpack1('H*')}" \
196
+ unless prefix == mainnet_prefix || prefix == testnet_prefix
197
+
198
+ raise ArgumentError, 'decoded hash must be 20 bytes' unless data.bytesize == 20
199
+
200
+ data
201
+ end
202
+ private_class_method :resolve_pubkey_hash
203
+
162
204
  # Construct a P2PKH unlocking script.
163
205
  #
164
206
  # @param signature_der [String] DER-encoded signature with sighash byte appended
@@ -303,10 +303,29 @@ module BSV
303
303
  # Computes the merkle root from the path and txid, then checks it
304
304
  # against the blockchain via the provided chain tracker.
305
305
  #
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
+ #
306
315
  # @param txid_hex [String] hex-encoded transaction ID (display order)
307
316
  # @param chain_tracker [ChainTracker] chain tracker to verify the root against
308
317
  # @return [Boolean] true if the computed root matches the block at this height
309
318
  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
+
310
329
  root_hex = compute_root_hex(txid_hex)
311
330
  chain_tracker.valid_root_for_height?(root_hex, @block_height)
312
331
  end
@@ -801,7 +801,7 @@ module BSV
801
801
  when FeeModel
802
802
  model_or_fee.compute_fee(self)
803
803
  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
805
805
  else
806
806
  raise ArgumentError, "expected FeeModel, Numeric, or nil; got #{model_or_fee.class}"
807
807
  end
@@ -15,7 +15,7 @@ module BSV
15
15
  attr_reader :prev_tx_out_index
16
16
 
17
17
  # @return [Integer] sequence number (default: 0xFFFFFFFF)
18
- attr_reader :sequence
18
+ attr_accessor :sequence
19
19
 
20
20
  # @return [Script::Script, nil] the unlocking script (set after signing)
21
21
  attr_accessor :unlocking_script
@@ -12,7 +12,7 @@ module BSV
12
12
  attr_accessor :satoshis
13
13
 
14
14
  # @return [Script::Script] the locking script (spending conditions)
15
- attr_reader :locking_script
15
+ attr_accessor :locking_script
16
16
 
17
17
  # @return [Boolean] whether this output receives change
18
18
  attr_accessor :change
data/lib/bsv/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BSV
4
- VERSION = '0.10.1'
4
+ VERSION = '0.11.0'
5
5
  end
data/lib/bsv-sdk.rb CHANGED
@@ -13,4 +13,5 @@ module BSV
13
13
  autoload :Identity, 'bsv/identity'
14
14
  autoload :Registry, 'bsv/registry'
15
15
  autoload :MCP, 'bsv/mcp'
16
+ autoload :Messages, 'bsv/messages'
16
17
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bsv-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-04-12 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: mcp
@@ -57,6 +57,7 @@ files:
57
57
  - lib/bsv/mcp/tools/fetch_utxos.rb
58
58
  - lib/bsv/mcp/tools/generate_key.rb
59
59
  - lib/bsv/mcp/tools/helpers.rb
60
+ - lib/bsv/messages.rb
60
61
  - lib/bsv/network.rb
61
62
  - lib/bsv/network/arc.rb
62
63
  - lib/bsv/network/broadcast_error.rb
@@ -164,7 +165,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
164
165
  - !ruby/object:Gem::Version
165
166
  version: '0'
166
167
  requirements: []
167
- rubygems_version: 3.6.2
168
+ rubygems_version: 4.0.10
168
169
  specification_version: 4
169
170
  summary: Ruby SDK for the BSV Blockchain
170
171
  test_files: []