bsv-sdk 0.2.0 → 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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/lib/bsv/network/broadcast_response.rb +1 -2
  4. data/lib/bsv/primitives/bsm.rb +2 -6
  5. data/lib/bsv/primitives/curve.rb +1 -2
  6. data/lib/bsv/primitives/encrypted_message.rb +100 -0
  7. data/lib/bsv/primitives/extended_key.rb +1 -2
  8. data/lib/bsv/primitives/key_shares.rb +83 -0
  9. data/lib/bsv/primitives/mnemonic.rb +1 -3
  10. data/lib/bsv/primitives/point_in_finite_field.rb +72 -0
  11. data/lib/bsv/primitives/polynomial.rb +95 -0
  12. data/lib/bsv/primitives/private_key.rb +101 -5
  13. data/lib/bsv/primitives/signed_message.rb +104 -0
  14. data/lib/bsv/primitives/symmetric_key.rb +128 -0
  15. data/lib/bsv/primitives.rb +18 -12
  16. data/lib/bsv/script/interpreter/interpreter.rb +1 -3
  17. data/lib/bsv/script/interpreter/operations/bitwise.rb +1 -3
  18. data/lib/bsv/script/interpreter/operations/crypto.rb +3 -9
  19. data/lib/bsv/script/interpreter/operations/flow_control.rb +2 -6
  20. data/lib/bsv/script/interpreter/operations/splice.rb +1 -3
  21. data/lib/bsv/script/interpreter/script_number.rb +2 -7
  22. data/lib/bsv/script/script.rb +256 -1
  23. data/lib/bsv/transaction/beef.rb +8 -11
  24. data/lib/bsv/transaction/transaction.rb +131 -59
  25. data/lib/bsv/transaction/transaction_input.rb +1 -2
  26. data/lib/bsv/transaction/transaction_output.rb +1 -2
  27. data/lib/bsv/transaction/var_int.rb +4 -16
  28. data/lib/bsv/transaction.rb +14 -14
  29. data/lib/bsv/version.rb +1 -1
  30. data/lib/bsv/wallet_interface/errors/invalid_hmac_error.rb +11 -0
  31. data/lib/bsv/wallet_interface/errors/invalid_parameter_error.rb +14 -0
  32. data/lib/bsv/wallet_interface/errors/invalid_signature_error.rb +11 -0
  33. data/lib/bsv/wallet_interface/errors/unsupported_action_error.rb +11 -0
  34. data/lib/bsv/wallet_interface/errors/wallet_error.rb +14 -0
  35. data/lib/bsv/wallet_interface/interface.rb +384 -0
  36. data/lib/bsv/wallet_interface/key_deriver.rb +142 -0
  37. data/lib/bsv/wallet_interface/memory_store.rb +115 -0
  38. data/lib/bsv/wallet_interface/proto_wallet.rb +361 -0
  39. data/lib/bsv/wallet_interface/storage_adapter.rb +51 -0
  40. data/lib/bsv/wallet_interface/validators.rb +126 -0
  41. data/lib/bsv/wallet_interface/version.rb +7 -0
  42. data/lib/bsv/wallet_interface/wallet_client.rb +486 -0
  43. data/lib/bsv/wallet_interface.rb +25 -0
  44. data/lib/bsv-wallet.rb +4 -0
  45. metadata +24 -3
  46. /data/{LICENCE → LICENSE} +0 -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
@@ -391,22 +638,30 @@ module BSV
391
638
  pos += 1
392
639
 
393
640
  if opcode.positive? && opcode <= 0x4b
641
+ raise ArgumentError, "truncated script: need #{opcode} data bytes at offset #{pos}" if pos + opcode > raw.bytesize
642
+
394
643
  data = raw.byteslice(pos, opcode)
395
644
  pos += opcode
396
645
  result << Chunk.new(opcode: opcode, data: data)
397
646
  elsif opcode == Opcodes::OP_PUSHDATA1
647
+ raise ArgumentError, "truncated script: OP_PUSHDATA1 missing length byte at offset #{pos}" if pos >= raw.bytesize
648
+
398
649
  len = raw.getbyte(pos)
399
650
  pos += 1
400
651
  data = raw.byteslice(pos, len)
401
652
  pos += len
402
653
  result << Chunk.new(opcode: opcode, data: data)
403
654
  elsif opcode == Opcodes::OP_PUSHDATA2
655
+ raise ArgumentError, "truncated script: OP_PUSHDATA2 needs 2 length bytes at offset #{pos}" if pos + 2 > raw.bytesize
656
+
404
657
  len = raw.byteslice(pos, 2).unpack1('v')
405
658
  pos += 2
406
659
  data = raw.byteslice(pos, len)
407
660
  pos += len
408
661
  result << Chunk.new(opcode: opcode, data: data)
409
662
  elsif opcode == Opcodes::OP_PUSHDATA4
663
+ raise ArgumentError, "truncated script: OP_PUSHDATA4 needs 4 length bytes at offset #{pos}" if pos + 4 > raw.bytesize
664
+
410
665
  len = raw.byteslice(pos, 4).unpack1('V')
411
666
  pos += 4
412
667
  data = raw.byteslice(pos, len)
@@ -63,7 +63,7 @@ module BSV
63
63
 
64
64
  # The transaction ID for this entry.
65
65
  #
66
- # @return [String, nil] 32-byte txid in internal byte order
66
+ # @return [String, nil] 32-byte txid in display byte order
67
67
  def txid
68
68
  case @format
69
69
  when FORMAT_TXID_ONLY
@@ -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
 
@@ -210,7 +207,7 @@ module BSV
210
207
 
211
208
  # Find a transaction in the bundle by its transaction ID.
212
209
  #
213
- # @param txid [String] 32-byte txid in internal byte order
210
+ # @param txid [String] 32-byte txid in display byte order
214
211
  # @return [Transaction, nil] the matching transaction, or nil
215
212
  def find_transaction(txid)
216
213
  @transactions.each do |beef_tx|
@@ -221,7 +218,7 @@ module BSV
221
218
 
222
219
  # Find the merkle path (BUMP) for a transaction by its txid.
223
220
  #
224
- # @param txid [String] 32-byte txid in internal byte order
221
+ # @param txid [String] 32-byte txid in display byte order
225
222
  # @return [MerklePath, nil] the merkle path, or nil if not found
226
223
  def find_bump(txid)
227
224
  bt = @transactions.find { |entry| entry.txid == txid && entry.format == FORMAT_RAW_TX_AND_BUMP }
@@ -232,7 +229,7 @@ module BSV
232
229
 
233
230
  # Find a transaction with all source_transactions wired for signing.
234
231
  #
235
- # @param txid [String] 32-byte txid in internal byte order
232
+ # @param txid [String] 32-byte txid in display byte order
236
233
  # @return [Transaction, nil] the transaction with wired inputs, or nil
237
234
  def find_transaction_for_signing(txid)
238
235
  tx = find_transaction(txid)
@@ -245,7 +242,7 @@ module BSV
245
242
  # Find a transaction and recursively wire its ancestry (source transactions
246
243
  # and merkle paths) for atomic proof validation.
247
244
  #
248
- # @param txid [String] 32-byte txid in internal byte order
245
+ # @param txid [String] 32-byte txid in display byte order
249
246
  # @return [Transaction, nil] the transaction with full proof tree, or nil
250
247
  def find_atomic_transaction(txid)
251
248
  tx = find_transaction(txid)
@@ -381,7 +378,7 @@ module BSV
381
378
 
382
379
  # Convert a transaction entry to TXID-only format.
383
380
  #
384
- # @param txid [String] 32-byte txid in internal byte order
381
+ # @param txid [String] 32-byte txid in display byte order
385
382
  # @return [BeefTx, nil] the converted entry, or nil if not found
386
383
  def make_txid_only(txid)
387
384
  idx = @transactions.index { |bt| bt.txid == txid }
@@ -557,7 +554,7 @@ module BSV
557
554
 
558
555
  # Wire inputs to ancestors already in the map (BEEF is dependency-ordered)
559
556
  beef_tx.transaction.inputs.each do |input|
560
- # prev_tx_id is internal byte order; txid keys are display order (reversed)
557
+ # prev_tx_id is wire byte order; txid keys are display byte order (reversed)
561
558
  source = tx_map[input.prev_tx_id.reverse]
562
559
  input.source_transaction = source if source
563
560
  end