bsv-sdk 0.2.1 → 0.3.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/lib/bsv/network/broadcast_response.rb +1 -2
  3. data/lib/bsv/primitives/bsm.rb +2 -6
  4. data/lib/bsv/primitives/curve.rb +1 -2
  5. data/lib/bsv/primitives/encrypted_message.rb +100 -0
  6. data/lib/bsv/primitives/extended_key.rb +1 -2
  7. data/lib/bsv/primitives/key_shares.rb +83 -0
  8. data/lib/bsv/primitives/mnemonic.rb +1 -3
  9. data/lib/bsv/primitives/point_in_finite_field.rb +72 -0
  10. data/lib/bsv/primitives/polynomial.rb +95 -0
  11. data/lib/bsv/primitives/private_key.rb +100 -3
  12. data/lib/bsv/primitives/signed_message.rb +104 -0
  13. data/lib/bsv/primitives/symmetric_key.rb +128 -0
  14. data/lib/bsv/primitives.rb +18 -12
  15. data/lib/bsv/script/interpreter/interpreter.rb +1 -3
  16. data/lib/bsv/script/interpreter/operations/bitwise.rb +1 -3
  17. data/lib/bsv/script/interpreter/operations/crypto.rb +3 -9
  18. data/lib/bsv/script/interpreter/operations/flow_control.rb +2 -6
  19. data/lib/bsv/script/interpreter/operations/splice.rb +1 -3
  20. data/lib/bsv/script/interpreter/script_number.rb +2 -7
  21. data/lib/bsv/script/script.rb +252 -1
  22. data/lib/bsv/transaction/beef.rb +1 -4
  23. data/lib/bsv/transaction/transaction.rb +123 -45
  24. data/lib/bsv/transaction/transaction_input.rb +1 -2
  25. data/lib/bsv/transaction/transaction_output.rb +1 -2
  26. data/lib/bsv/transaction/var_int.rb +4 -16
  27. data/lib/bsv/transaction.rb +14 -14
  28. data/lib/bsv/version.rb +1 -1
  29. data/lib/bsv/wallet_interface/errors/invalid_hmac_error.rb +11 -0
  30. data/lib/bsv/wallet_interface/errors/invalid_parameter_error.rb +14 -0
  31. data/lib/bsv/wallet_interface/errors/invalid_signature_error.rb +11 -0
  32. data/lib/bsv/wallet_interface/errors/unsupported_action_error.rb +11 -0
  33. data/lib/bsv/wallet_interface/errors/wallet_error.rb +14 -0
  34. data/lib/bsv/wallet_interface/interface.rb +384 -0
  35. data/lib/bsv/wallet_interface/key_deriver.rb +142 -0
  36. data/lib/bsv/wallet_interface/memory_store.rb +115 -0
  37. data/lib/bsv/wallet_interface/proto_wallet.rb +361 -0
  38. data/lib/bsv/wallet_interface/storage_adapter.rb +51 -0
  39. data/lib/bsv/wallet_interface/validators.rb +126 -0
  40. data/lib/bsv/wallet_interface/version.rb +7 -0
  41. data/lib/bsv/wallet_interface/wallet_client.rb +486 -0
  42. data/lib/bsv/wallet_interface.rb +25 -0
  43. data/lib/bsv-wallet.rb +4 -0
  44. metadata +24 -3
  45. /data/{LICENCE → LICENSE} +0 -0
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+
6
+ module BSV
7
+ module Primitives
8
+ # AES-256-GCM symmetric encryption.
9
+ #
10
+ # Provides authenticated encryption matching the interface used by the
11
+ # TS, Go, and Python reference SDKs. The wire format is:
12
+ #
13
+ # |--- 32-byte IV ---|--- ciphertext ---|--- 16-byte auth tag ---|
14
+ #
15
+ # All three reference SDKs use a 32-byte IV (non-standard but
16
+ # cross-SDK compatible) and 16-byte authentication tag.
17
+ #
18
+ # @example Round-trip encryption
19
+ # key = BSV::Primitives::SymmetricKey.from_random
20
+ # encrypted = key.encrypt('hello world')
21
+ # key.decrypt(encrypted) #=> "hello world"
22
+ class SymmetricKey
23
+ IV_SIZE = 32
24
+ TAG_SIZE = 16
25
+ KEY_SIZE = 32
26
+
27
+ # @param key_bytes [String] 32-byte binary key (shorter keys are left-zero-padded)
28
+ # @raise [ArgumentError] if key is empty or longer than 32 bytes
29
+ def initialize(key_bytes)
30
+ key_bytes = key_bytes.b
31
+ raise ArgumentError, 'key must not be empty' if key_bytes.empty?
32
+ raise ArgumentError, "key must be at most #{KEY_SIZE} bytes, got #{key_bytes.bytesize}" if key_bytes.bytesize > KEY_SIZE
33
+
34
+ @key = if key_bytes.bytesize < KEY_SIZE
35
+ ("\x00".b * (KEY_SIZE - key_bytes.bytesize)) + key_bytes
36
+ else
37
+ key_bytes
38
+ end
39
+ end
40
+
41
+ # Generate a random symmetric key.
42
+ #
43
+ # @return [SymmetricKey]
44
+ def self.from_random
45
+ new(SecureRandom.random_bytes(KEY_SIZE))
46
+ end
47
+
48
+ # Derive a symmetric key from an ECDH shared secret.
49
+ #
50
+ # Computes the shared point between the two parties and uses the
51
+ # X-coordinate as the key material. The X-coordinate may be 31 or
52
+ # 32 bytes; shorter values are left-zero-padded automatically.
53
+ #
54
+ # @example Alice and Bob derive the same key
55
+ # alice_key = SymmetricKey.from_ecdh(alice_priv, bob_pub)
56
+ # bob_key = SymmetricKey.from_ecdh(bob_priv, alice_pub)
57
+ # alice_key.to_bytes == bob_key.to_bytes #=> true
58
+ #
59
+ # @param private_key [PrivateKey] one party's private key
60
+ # @param public_key [PublicKey] the other party's public key
61
+ # @return [SymmetricKey]
62
+ def self.from_ecdh(private_key, public_key)
63
+ shared = private_key.derive_shared_secret(public_key)
64
+ # X-coordinate = bytes 1..32 of the compressed point (skip the 02/03 prefix)
65
+ x_bytes = shared.compressed.byteslice(1, 32)
66
+ new(x_bytes)
67
+ end
68
+
69
+ # Encrypt a message with AES-256-GCM.
70
+ #
71
+ # Generates a random 32-byte IV per call. Returns the concatenation
72
+ # of IV, ciphertext, and 16-byte authentication tag.
73
+ #
74
+ # @param plaintext [String] the message to encrypt
75
+ # @return [String] binary string: IV (32) + ciphertext + auth tag (16)
76
+ def encrypt(plaintext)
77
+ iv = SecureRandom.random_bytes(IV_SIZE)
78
+
79
+ cipher = OpenSSL::Cipher.new('aes-256-gcm')
80
+ cipher.encrypt
81
+ cipher.key = @key
82
+ cipher.iv_len = IV_SIZE
83
+ cipher.iv = iv
84
+ cipher.auth_data = ''.b
85
+
86
+ plaintext_bytes = plaintext.b
87
+ ciphertext = plaintext_bytes.empty? ? cipher.final : cipher.update(plaintext_bytes) + cipher.final
88
+ tag = cipher.auth_tag(TAG_SIZE)
89
+
90
+ iv + ciphertext + tag
91
+ end
92
+
93
+ # Decrypt an AES-256-GCM encrypted message.
94
+ #
95
+ # Expects the wire format: IV (32) + ciphertext + auth tag (16).
96
+ #
97
+ # @param data [String] the encrypted message
98
+ # @return [String] the decrypted plaintext (binary)
99
+ # @raise [ArgumentError] if the data is too short
100
+ # @raise [OpenSSL::Cipher::CipherError] if authentication fails (wrong key or tampered data)
101
+ def decrypt(data)
102
+ data = data.b
103
+ raise ArgumentError, "ciphertext too short: #{data.bytesize} bytes (minimum #{IV_SIZE + TAG_SIZE})" if data.bytesize < IV_SIZE + TAG_SIZE
104
+
105
+ iv = data.byteslice(0, IV_SIZE)
106
+ tag = data.byteslice(-TAG_SIZE, TAG_SIZE)
107
+ ciphertext = data.byteslice(IV_SIZE, data.bytesize - IV_SIZE - TAG_SIZE)
108
+
109
+ decipher = OpenSSL::Cipher.new('aes-256-gcm')
110
+ decipher.decrypt
111
+ decipher.key = @key
112
+ decipher.iv_len = IV_SIZE
113
+ decipher.iv = iv
114
+ decipher.auth_tag = tag
115
+ decipher.auth_data = ''.b
116
+
117
+ ciphertext.empty? ? decipher.final : decipher.update(ciphertext) + decipher.final
118
+ end
119
+
120
+ # Return the raw key bytes.
121
+ #
122
+ # @return [String] 32-byte binary key
123
+ def to_bytes
124
+ @key.dup
125
+ end
126
+ end
127
+ end
128
+ end
@@ -7,17 +7,23 @@ module BSV
7
7
  # HD key derivation (BIP-32), and mnemonic phrase generation (BIP-39).
8
8
  # All cryptography uses Ruby's stdlib +openssl+ — no external gems.
9
9
  module Primitives
10
- autoload :Curve, 'bsv/primitives/curve'
11
- autoload :Digest, 'bsv/primitives/digest'
12
- autoload :Base58, 'bsv/primitives/base58'
13
- autoload :Signature, 'bsv/primitives/signature'
14
- autoload :ECDSA, 'bsv/primitives/ecdsa'
15
- autoload :ECIES, 'bsv/primitives/ecies'
16
- autoload :BSM, 'bsv/primitives/bsm'
17
- autoload :Schnorr, 'bsv/primitives/schnorr'
18
- autoload :PublicKey, 'bsv/primitives/public_key'
19
- autoload :PrivateKey, 'bsv/primitives/private_key'
20
- autoload :ExtendedKey, 'bsv/primitives/extended_key'
21
- autoload :Mnemonic, 'bsv/primitives/mnemonic'
10
+ autoload :Curve, 'bsv/primitives/curve'
11
+ autoload :Digest, 'bsv/primitives/digest'
12
+ autoload :Base58, 'bsv/primitives/base58'
13
+ autoload :Signature, 'bsv/primitives/signature'
14
+ autoload :ECDSA, 'bsv/primitives/ecdsa'
15
+ autoload :ECIES, 'bsv/primitives/ecies'
16
+ autoload :BSM, 'bsv/primitives/bsm'
17
+ autoload :Schnorr, 'bsv/primitives/schnorr'
18
+ autoload :PublicKey, 'bsv/primitives/public_key'
19
+ autoload :PrivateKey, 'bsv/primitives/private_key'
20
+ autoload :ExtendedKey, 'bsv/primitives/extended_key'
21
+ autoload :Mnemonic, 'bsv/primitives/mnemonic'
22
+ autoload :SymmetricKey, 'bsv/primitives/symmetric_key'
23
+ autoload :SignedMessage, 'bsv/primitives/signed_message'
24
+ autoload :EncryptedMessage, 'bsv/primitives/encrypted_message'
25
+ autoload :PointInFiniteField, 'bsv/primitives/point_in_finite_field'
26
+ autoload :Polynomial, 'bsv/primitives/polynomial'
27
+ autoload :KeyShares, 'bsv/primitives/key_shares'
22
28
  end
23
29
  end
@@ -96,9 +96,7 @@ module BSV
96
96
  break if @early_return && script_idx == 1
97
97
 
98
98
  # Between scripts: verify conditionals balanced
99
- unless @cond_stack.empty?
100
- raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'unbalanced conditional')
101
- end
99
+ raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'unbalanced conditional') unless @cond_stack.empty?
102
100
 
103
101
  # Clear alt stack between scripts
104
102
  @astack.clear
@@ -55,9 +55,7 @@ module BSV
55
55
  def pop_equal_length_pair
56
56
  a = @dstack.pop_bytes
57
57
  b = @dstack.pop_bytes
58
- if a.bytesize != b.bytesize
59
- raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'byte arrays are not the same length')
60
- end
58
+ raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'byte arrays are not the same length') if a.bytesize != b.bytesize
61
59
 
62
60
  [a, b]
63
61
  end
@@ -52,9 +52,7 @@ module BSV
52
52
  result = verify_checksig(full_sig, pubkey_bytes)
53
53
 
54
54
  # NULLFAIL: non-empty signature that failed verification
55
- unless result
56
- raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification')
57
- end
55
+ raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification') unless result
58
56
 
59
57
  @dstack.push_bool(true)
60
58
  end
@@ -85,18 +83,14 @@ module BSV
85
83
 
86
84
  # Dummy element (off-by-one bug compatibility)
87
85
  dummy = @dstack.pop_bytes
88
- unless dummy.empty?
89
- raise ScriptError.new(ScriptErrorCode::SIG_NULLDUMMY, 'CHECKMULTISIG dummy element must be empty')
90
- end
86
+ raise ScriptError.new(ScriptErrorCode::SIG_NULLDUMMY, 'CHECKMULTISIG dummy element must be empty') unless dummy.empty?
91
87
 
92
88
  success = multisig_match?(signatures, pubkeys)
93
89
 
94
90
  # NULLFAIL: if failed, all signatures must be empty
95
91
  unless success
96
92
  signatures.each do |sig|
97
- unless sig.empty?
98
- raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification')
99
- end
93
+ raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification') unless sig.empty?
100
94
  end
101
95
  end
102
96
 
@@ -30,9 +30,7 @@ module BSV
30
30
 
31
31
  # OP_ELSE: toggle conditional branch (only one ELSE per IF after genesis)
32
32
  def op_else
33
- if @cond_stack.empty?
34
- raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ELSE without matching OP_IF')
35
- end
33
+ raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ELSE without matching OP_IF') if @cond_stack.empty?
36
34
 
37
35
  # After genesis: only one ELSE per IF
38
36
  raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'duplicate OP_ELSE') if @else_stack.pop
@@ -47,9 +45,7 @@ module BSV
47
45
 
48
46
  # OP_ENDIF: close conditional block
49
47
  def op_endif
50
- if @cond_stack.empty?
51
- raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ENDIF without matching OP_IF')
52
- end
48
+ raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ENDIF without matching OP_IF') if @cond_stack.empty?
53
49
 
54
50
  @cond_stack.pop
55
51
  @else_stack.pop
@@ -44,9 +44,7 @@ module BSV
44
44
  size = @dstack.pop_int.to_i32
45
45
  data = @dstack.pop_bytes
46
46
 
47
- if size.negative?
48
- raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'OP_NUM2BIN: size is negative')
49
- end
47
+ raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'OP_NUM2BIN: size is negative') if size.negative?
50
48
 
51
49
  minimal = ScriptNumber.minimally_encode(data)
52
50
 
@@ -44,10 +44,7 @@ module BSV
44
44
  return new(0) if bytes.empty?
45
45
 
46
46
  if bytes.bytesize > max_length
47
- raise ScriptError.new(
48
- ScriptErrorCode::NUMBER_TOO_BIG,
49
- "script number overflow: #{bytes.bytesize} > #{max_length}"
50
- )
47
+ raise ScriptError.new ScriptErrorCode::NUMBER_TOO_BIG, "script number overflow: #{bytes.bytesize} > #{max_length}"
51
48
  end
52
49
 
53
50
  check_minimal_encoding!(bytes) if require_minimal
@@ -202,9 +199,7 @@ module BSV
202
199
  return if msb.anybits?(0x7f)
203
200
 
204
201
  # Single byte that is pure sign/zero (0x00 or 0x80) — not minimal
205
- if bytes.bytesize == 1
206
- raise ScriptError.new(ScriptErrorCode::MINIMAL_DATA, 'non-minimal script number encoding')
207
- end
202
+ raise ScriptError.new(ScriptErrorCode::MINIMAL_DATA, 'non-minimal script number encoding') if bytes.bytesize == 1
208
203
 
209
204
  # Padding is justified if second-to-last byte has high bit set
210
205
  return if bytes.getbyte(bytes.bytesize - 2) & 0x80 != 0
@@ -177,6 +177,100 @@ module BSV
177
177
  new(buf)
178
178
  end
179
179
 
180
+ # Construct a PushDrop locking script.
181
+ #
182
+ # Pushes arbitrary data fields onto the stack, then drops them all
183
+ # before the locking condition executes. Used for token protocols
184
+ # where data must be embedded in spendable outputs.
185
+ #
186
+ # Structure: +[field0] [field1] ... [fieldN] [OP_2DROP...] [OP_DROP?] [lock_script]+
187
+ #
188
+ # @param fields [Array<String>] data payloads to embed (binary strings)
189
+ # @param lock_script [Script] the underlying locking condition (e.g. P2PKH)
190
+ # @return [Script]
191
+ # @raise [ArgumentError] if fields is empty or lock_script is not a Script
192
+ def self.pushdrop_lock(fields, lock_script)
193
+ raise ArgumentError, 'fields must not be empty' if fields.empty?
194
+ raise ArgumentError, 'lock_script must be a Script' unless lock_script.is_a?(Script)
195
+
196
+ chunks = fields.map { |f| encode_minimally(f.b) }
197
+
198
+ remaining = fields.length
199
+ while remaining > 1
200
+ chunks << Chunk.new(opcode: Opcodes::OP_2DROP)
201
+ remaining -= 2
202
+ end
203
+ chunks << Chunk.new(opcode: Opcodes::OP_DROP) if remaining == 1
204
+
205
+ chunks.concat(lock_script.chunks)
206
+ from_chunks(chunks)
207
+ end
208
+
209
+ # Construct a PushDrop unlocking script.
210
+ #
211
+ # Pass-through wrapper — the data fields are dropped during execution,
212
+ # so the unlocking script just needs to satisfy the underlying lock.
213
+ #
214
+ # @param unlock_script [Script] unlocking script for the underlying condition
215
+ # @return [Script]
216
+ def self.pushdrop_unlock(unlock_script)
217
+ unlock_script
218
+ end
219
+
220
+ # Hash type to opcode mapping for RPuzzle scripts.
221
+ RPUZZLE_HASH_OPS = {
222
+ raw: nil,
223
+ sha1: Opcodes::OP_SHA1,
224
+ ripemd160: Opcodes::OP_RIPEMD160,
225
+ sha256: Opcodes::OP_SHA256,
226
+ hash160: Opcodes::OP_HASH160,
227
+ hash256: Opcodes::OP_HASH256
228
+ }.freeze
229
+
230
+ # Reverse lookup: opcode → hash type symbol (excludes :raw).
231
+ RPUZZLE_OP_TO_TYPE = RPUZZLE_HASH_OPS.reject { |k, _| k == :raw }.invert.freeze
232
+
233
+ # The fixed opcode prefix shared by all RPuzzle locking scripts.
234
+ # OP_OVER OP_3 OP_SPLIT OP_NIP OP_1 OP_SPLIT OP_SWAP OP_SPLIT OP_DROP
235
+ RPUZZLE_PREFIX = [
236
+ Opcodes::OP_OVER, Opcodes::OP_3, Opcodes::OP_SPLIT,
237
+ Opcodes::OP_NIP, Opcodes::OP_1, Opcodes::OP_SPLIT,
238
+ Opcodes::OP_SWAP, Opcodes::OP_SPLIT, Opcodes::OP_DROP
239
+ ].freeze
240
+
241
+ # Construct an RPuzzle locking script.
242
+ #
243
+ # RPuzzle enables hash-puzzle-based spending where the spender proves
244
+ # knowledge of the ECDSA K-value (nonce) that produced a signature's
245
+ # R component.
246
+ #
247
+ # @param hash_value [String] the R-value or hash of R-value to lock against
248
+ # @param hash_type [Symbol] one of +:raw+, +:sha1+, +:ripemd160+,
249
+ # +:sha256+, +:hash160+, +:hash256+
250
+ # @return [Script]
251
+ # @raise [ArgumentError] if hash_type is invalid
252
+ def self.rpuzzle_lock(hash_value, hash_type: :hash160)
253
+ raise ArgumentError, "unknown hash_type: #{hash_type}" unless RPUZZLE_HASH_OPS.key?(hash_type)
254
+
255
+ buf = RPUZZLE_PREFIX.pack('C*')
256
+ hash_op = RPUZZLE_HASH_OPS[hash_type]
257
+ buf << [hash_op].pack('C') if hash_op
258
+ buf << encode_push_data(hash_value.b)
259
+ buf << [Opcodes::OP_EQUALVERIFY, Opcodes::OP_CHECKSIG].pack('CC')
260
+ new(buf)
261
+ end
262
+
263
+ # Construct an RPuzzle unlocking script.
264
+ #
265
+ # Same wire format as P2PKH: signature + public key.
266
+ #
267
+ # @param signature_der [String] DER-encoded signature with sighash byte
268
+ # @param pubkey_bytes [String] compressed or uncompressed public key bytes
269
+ # @return [Script]
270
+ def self.rpuzzle_unlock(signature_der, pubkey_bytes)
271
+ p2pkh_unlock(signature_der, pubkey_bytes)
272
+ end
273
+
180
274
  # --- Serialisation ---
181
275
 
182
276
  # @return [String] a copy of the raw script bytes
@@ -256,6 +350,76 @@ module BSV
256
350
  ([0x04, 0x06, 0x07].include?(version) && pubkey.bytesize == 65)
257
351
  end
258
352
 
353
+ # Whether this is a PushDrop script.
354
+ #
355
+ # Detects scripts with one or more data pushes followed by a
356
+ # OP_DROP/OP_2DROP chain and a recognisable locking condition.
357
+ #
358
+ # @return [Boolean]
359
+ def pushdrop?
360
+ c = chunks
361
+ return false if c.length < 3
362
+
363
+ # Find the first DROP/2DROP — everything before is data fields
364
+ drop_start = c.index { |ch| [Opcodes::OP_DROP, Opcodes::OP_2DROP].include?(ch.opcode) }
365
+ return false unless drop_start&.positive?
366
+
367
+ # All chunks before first drop must be data pushes or minimal push opcodes
368
+ field_chunks = c[0...drop_start]
369
+ return false unless field_chunks.all? { |ch| ch.data? || minimal_push_opcode?(ch.opcode) }
370
+
371
+ # Count fields and verify the drop sequence
372
+ num_fields = field_chunks.length
373
+ expected_drops = []
374
+ remaining = num_fields
375
+ while remaining > 1
376
+ expected_drops << Opcodes::OP_2DROP
377
+ remaining -= 2
378
+ end
379
+ expected_drops << Opcodes::OP_DROP if remaining == 1
380
+
381
+ drop_end = drop_start + expected_drops.length
382
+ return false if drop_end > c.length
383
+
384
+ actual_drops = c[drop_start...drop_end].map(&:opcode)
385
+ return false unless actual_drops == expected_drops
386
+
387
+ # Must have at least one chunk after the drops (the lock script)
388
+ drop_end < c.length
389
+ end
390
+
391
+ # Whether this is an RPuzzle script.
392
+ #
393
+ # Detects the fixed R-value extraction prefix followed by an optional
394
+ # hash opcode, a data push, OP_EQUALVERIFY, and OP_CHECKSIG.
395
+ #
396
+ # @return [Boolean]
397
+ def rpuzzle?
398
+ c = chunks
399
+ # Minimum: 9 prefix + hash_data + OP_EQUALVERIFY + OP_CHECKSIG = 12
400
+ # With hash op: 13
401
+ return false unless c.length >= 12
402
+
403
+ # Verify the 9-opcode prefix
404
+ RPUZZLE_PREFIX.each_with_index do |op, i|
405
+ return false unless c[i].opcode == op
406
+ end
407
+
408
+ # After prefix: optional hash op, then data push, OP_EQUALVERIFY, OP_CHECKSIG
409
+ return false unless c[-1].opcode == Opcodes::OP_CHECKSIG
410
+ return false unless c[-2].opcode == Opcodes::OP_EQUALVERIFY
411
+ return false unless c[-3].data?
412
+
413
+ # Either exactly 12 chunks (raw) or 13 chunks (with hash op)
414
+ if c.length == 12
415
+ true
416
+ elsif c.length == 13
417
+ RPUZZLE_HASH_OPS.values.compact.include?(c[9].opcode)
418
+ else
419
+ false
420
+ end
421
+ end
422
+
259
423
  # Whether this is a bare multisig script.
260
424
  #
261
425
  # Pattern: +OP_M <pubkey1> ... <pubkeyN> OP_N OP_CHECKMULTISIG+
@@ -275,7 +439,8 @@ module BSV
275
439
  # Classify the script as a standard type.
276
440
  #
277
441
  # @return [String] one of +"empty"+, +"pubkeyhash"+, +"pubkey"+,
278
- # +"scripthash"+, +"nulldata"+, +"multisig"+, or +"nonstandard"+
442
+ # +"scripthash"+, +"nulldata"+, +"multisig"+, +"pushdrop"+,
443
+ # +"rpuzzle"+, or +"nonstandard"+
279
444
  def type
280
445
  if @bytes.empty? then 'empty'
281
446
  elsif p2pkh? then 'pubkeyhash'
@@ -283,6 +448,8 @@ module BSV
283
448
  elsif p2sh? then 'scripthash'
284
449
  elsif op_return? then 'nulldata'
285
450
  elsif multisig? then 'multisig'
451
+ elsif pushdrop? then 'pushdrop'
452
+ elsif rpuzzle? then 'rpuzzle'
286
453
  else 'nonstandard'
287
454
  end
288
455
  end
@@ -317,6 +484,49 @@ module BSV
317
484
  Script.new(@bytes.byteslice(start..)).chunks.select(&:data?).map(&:data)
318
485
  end
319
486
 
487
+ # Extract the hash value from an RPuzzle script.
488
+ #
489
+ # @return [String, nil] the locked hash/R-value, or +nil+ if not RPuzzle
490
+ def rpuzzle_hash
491
+ return unless rpuzzle?
492
+
493
+ chunks[-3].data
494
+ end
495
+
496
+ # Detect the hash type used in an RPuzzle script.
497
+ #
498
+ # @return [Symbol, nil] the hash type (e.g. +:hash160+, +:raw+), or +nil+ if not RPuzzle
499
+ def rpuzzle_hash_type
500
+ return unless rpuzzle?
501
+
502
+ chunks.length == 12 ? :raw : RPUZZLE_OP_TO_TYPE[chunks[9].opcode]
503
+ end
504
+
505
+ # Extract the embedded data fields from a PushDrop script.
506
+ #
507
+ # @return [Array<String>, nil] array of field data, or +nil+ if not PushDrop
508
+ def pushdrop_fields
509
+ return unless pushdrop?
510
+
511
+ c = chunks
512
+ drop_start = c.index { |ch| [Opcodes::OP_DROP, Opcodes::OP_2DROP].include?(ch.opcode) }
513
+ c[0...drop_start].map { |ch| decode_minimal_push(ch) }
514
+ end
515
+
516
+ # Extract the underlying lock script from a PushDrop script.
517
+ #
518
+ # @return [Script, nil] the lock script portion, or +nil+ if not PushDrop
519
+ def pushdrop_lock_script
520
+ return unless pushdrop?
521
+
522
+ c = chunks
523
+ drop_start = c.index { |ch| [Opcodes::OP_DROP, Opcodes::OP_2DROP].include?(ch.opcode) }
524
+ num_fields = drop_start
525
+ num_drops = (num_fields / 2) + (num_fields.odd? ? 1 : 0)
526
+ lock_start = drop_start + num_drops
527
+ self.class.from_chunks(c[lock_start..])
528
+ end
529
+
320
530
  # Derive Bitcoin addresses from this script.
321
531
  #
322
532
  # Currently supports P2PKH scripts only.
@@ -366,6 +576,26 @@ module BSV
366
576
  end
367
577
  end
368
578
 
579
+ def encode_minimally(data)
580
+ len = data.bytesize
581
+
582
+ if len.zero? || (len == 1 && data.getbyte(0).zero?)
583
+ Chunk.new(opcode: Opcodes::OP_0)
584
+ elsif len == 1 && data.getbyte(0).between?(1, 16)
585
+ Chunk.new(opcode: 0x50 + data.getbyte(0))
586
+ elsif len == 1 && data.getbyte(0) == 0x81
587
+ Chunk.new(opcode: Opcodes::OP_1NEGATE)
588
+ elsif len <= 0x4b
589
+ Chunk.new(opcode: len, data: data)
590
+ elsif len <= 0xff
591
+ Chunk.new(opcode: Opcodes::OP_PUSHDATA1, data: data)
592
+ elsif len <= 0xffff
593
+ Chunk.new(opcode: Opcodes::OP_PUSHDATA2, data: data)
594
+ else
595
+ Chunk.new(opcode: Opcodes::OP_PUSHDATA4, data: data)
596
+ end
597
+ end
598
+
369
599
  def resolve_opcode(token)
370
600
  return nil unless token.start_with?('OP_')
371
601
 
@@ -381,6 +611,23 @@ module BSV
381
611
  opcode == Opcodes::OP_0 || opcode.between?(Opcodes::OP_1, Opcodes::OP_16)
382
612
  end
383
613
 
614
+ def minimal_push_opcode?(opcode)
615
+ opcode == Opcodes::OP_0 ||
616
+ opcode == Opcodes::OP_1NEGATE ||
617
+ opcode.between?(Opcodes::OP_1, Opcodes::OP_16)
618
+ end
619
+
620
+ def decode_minimal_push(chunk)
621
+ return chunk.data if chunk.data?
622
+
623
+ case chunk.opcode
624
+ when Opcodes::OP_0 then ''.b
625
+ when Opcodes::OP_1NEGATE then "\x81".b
626
+ when Opcodes::OP_1..Opcodes::OP_16
627
+ [chunk.opcode - 0x50].pack('C')
628
+ end
629
+ end
630
+
384
631
  def parse_chunks
385
632
  result = []
386
633
  pos = 0
@@ -392,11 +639,13 @@ module BSV
392
639
 
393
640
  if opcode.positive? && opcode <= 0x4b
394
641
  raise ArgumentError, "truncated script: need #{opcode} data bytes at offset #{pos}" if pos + opcode > raw.bytesize
642
+
395
643
  data = raw.byteslice(pos, opcode)
396
644
  pos += opcode
397
645
  result << Chunk.new(opcode: opcode, data: data)
398
646
  elsif opcode == Opcodes::OP_PUSHDATA1
399
647
  raise ArgumentError, "truncated script: OP_PUSHDATA1 missing length byte at offset #{pos}" if pos >= raw.bytesize
648
+
400
649
  len = raw.getbyte(pos)
401
650
  pos += 1
402
651
  data = raw.byteslice(pos, len)
@@ -404,6 +653,7 @@ module BSV
404
653
  result << Chunk.new(opcode: opcode, data: data)
405
654
  elsif opcode == Opcodes::OP_PUSHDATA2
406
655
  raise ArgumentError, "truncated script: OP_PUSHDATA2 needs 2 length bytes at offset #{pos}" if pos + 2 > raw.bytesize
656
+
407
657
  len = raw.byteslice(pos, 2).unpack1('v')
408
658
  pos += 2
409
659
  data = raw.byteslice(pos, len)
@@ -411,6 +661,7 @@ module BSV
411
661
  result << Chunk.new(opcode: opcode, data: data)
412
662
  elsif opcode == Opcodes::OP_PUSHDATA4
413
663
  raise ArgumentError, "truncated script: OP_PUSHDATA4 needs 4 length bytes at offset #{pos}" if pos + 4 > raw.bytesize
664
+
414
665
  len = raw.byteslice(pos, 4).unpack1('V')
415
666
  pos += 4
416
667
  data = raw.byteslice(pos, len)
@@ -106,10 +106,7 @@ module BSV
106
106
  # @param data [String] raw BEEF binary
107
107
  # @return [Beef] the parsed BEEF bundle
108
108
  def self.from_binary(data)
109
- if data.bytesize < 4
110
- raise ArgumentError,
111
- "truncated BEEF: need at least 4 bytes for version, got #{data.bytesize}"
112
- end
109
+ raise ArgumentError, "truncated BEEF: need at least 4 bytes for version, got #{data.bytesize}" if data.bytesize < 4
113
110
 
114
111
  offset = 0
115
112