bsv-sdk 0.3.1 → 0.3.2

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.
@@ -271,6 +271,36 @@ module BSV
271
271
  p2pkh_unlock(signature_der, pubkey_bytes)
272
272
  end
273
273
 
274
+ # Construct an OP_CAT locking script.
275
+ #
276
+ # The script concatenates two stack items and compares the result
277
+ # against the expected data. The spender must push two values whose
278
+ # concatenation equals +expected_data+.
279
+ #
280
+ # @param expected_data [String] binary string — the expected result of
281
+ # concatenating the two unlocking values
282
+ # @return [Script]
283
+ def self.op_cat_lock(expected_data)
284
+ buf = [Opcodes::OP_CAT].pack('C')
285
+ buf << encode_push_data(expected_data.b)
286
+ buf << [Opcodes::OP_EQUAL].pack('C')
287
+ new(buf)
288
+ end
289
+
290
+ # Construct an OP_CAT unlocking script.
291
+ #
292
+ # Pushes two data items onto the stack. The locking script's OP_CAT
293
+ # will concatenate them and compare against the expected value.
294
+ #
295
+ # @param data1 [String] binary string — first item (pushed first, deeper on stack)
296
+ # @param data2 [String] binary string — second item (pushed second, top of stack)
297
+ # @return [Script]
298
+ def self.op_cat_unlock(data1, data2)
299
+ buf = encode_push_data(data1.b)
300
+ buf << encode_push_data(data2.b)
301
+ new(buf)
302
+ end
303
+
274
304
  # --- Serialisation ---
275
305
 
276
306
  # @return [String] a copy of the raw script bytes
@@ -420,6 +450,19 @@ module BSV
420
450
  end
421
451
  end
422
452
 
453
+ # Whether this is an OP_CAT puzzle script.
454
+ #
455
+ # Pattern: +OP_CAT <expected_data> OP_EQUAL+
456
+ #
457
+ # @return [Boolean]
458
+ def op_cat?
459
+ c = chunks
460
+ c.length == 3 &&
461
+ c[0].opcode == Opcodes::OP_CAT &&
462
+ c[1].data? &&
463
+ c[2].opcode == Opcodes::OP_EQUAL
464
+ end
465
+
423
466
  # Whether this is a bare multisig script.
424
467
  #
425
468
  # Pattern: +OP_M <pubkey1> ... <pubkeyN> OP_N OP_CHECKMULTISIG+
@@ -450,6 +493,7 @@ module BSV
450
493
  elsif multisig? then 'multisig'
451
494
  elsif pushdrop? then 'pushdrop'
452
495
  elsif rpuzzle? then 'rpuzzle'
496
+ elsif op_cat? then 'opcat'
453
497
  else 'nonstandard'
454
498
  end
455
499
  end
@@ -648,6 +692,11 @@ module BSV
648
692
 
649
693
  len = raw.getbyte(pos)
650
694
  pos += 1
695
+ if pos + len > raw.bytesize
696
+ raise ArgumentError,
697
+ "truncated script: OP_PUSHDATA1 needs #{len} data bytes at offset #{pos}, got #{raw.bytesize - pos}"
698
+ end
699
+
651
700
  data = raw.byteslice(pos, len)
652
701
  pos += len
653
702
  result << Chunk.new(opcode: opcode, data: data)
@@ -656,6 +705,11 @@ module BSV
656
705
 
657
706
  len = raw.byteslice(pos, 2).unpack1('v')
658
707
  pos += 2
708
+ if pos + len > raw.bytesize
709
+ raise ArgumentError,
710
+ "truncated script: OP_PUSHDATA2 needs #{len} data bytes at offset #{pos}, got #{raw.bytesize - pos}"
711
+ end
712
+
659
713
  data = raw.byteslice(pos, len)
660
714
  pos += len
661
715
  result << Chunk.new(opcode: opcode, data: data)
@@ -664,6 +718,11 @@ module BSV
664
718
 
665
719
  len = raw.byteslice(pos, 4).unpack1('V')
666
720
  pos += 4
721
+ if pos + len > raw.bytesize
722
+ raise ArgumentError,
723
+ "truncated script: OP_PUSHDATA4 needs #{len} data bytes at offset #{pos}, got #{raw.bytesize - pos}"
724
+ end
725
+
667
726
  data = raw.byteslice(pos, len)
668
727
  pos += len
669
728
  result << Chunk.new(opcode: opcode, data: data)
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module BSV
8
+ module Transaction
9
+ module FeeModels
10
+ # Dynamic fee model that fetches the live mining fee rate from an ARC
11
+ # policy endpoint.
12
+ #
13
+ # The fetched rate is cached for a configurable TTL (default 5 minutes)
14
+ # so repeated calls to {#compute_fee} do not repeatedly query the API.
15
+ # If a fetch fails, the model falls back to a configurable default rate.
16
+ #
17
+ # @example
18
+ # model = BSV::Transaction::FeeModels::LivePolicy.new(
19
+ # arc_url: 'https://arc.gorillapool.io',
20
+ # fallback_rate: 50
21
+ # )
22
+ # fee = model.compute_fee(transaction)
23
+ class LivePolicy < FeeModel
24
+ DEFAULT_CACHE_TTL = 300 # 5 minutes in seconds
25
+
26
+ # @return [String] the ARC base URL
27
+ attr_reader :arc_url
28
+
29
+ # @return [Integer] fallback sat/kB when fetch fails
30
+ attr_reader :fallback_rate
31
+
32
+ # @return [Integer] cache TTL in seconds
33
+ attr_reader :cache_ttl
34
+
35
+ # @param arc_url [String] ARC base URL (e.g. 'https://arc.gorillapool.io')
36
+ # @param fallback_rate [Integer] sat/kB to use when fetch fails (default: 50)
37
+ # @param cache_ttl [Integer] seconds to cache a fetched rate (default: 300)
38
+ # @param api_key [String, nil] optional Bearer token for ARC authentication
39
+ # @param http_client [#request, nil] injectable HTTP client for testing
40
+ def initialize(arc_url:, fallback_rate: 50, cache_ttl: DEFAULT_CACHE_TTL, api_key: nil, http_client: nil)
41
+ super()
42
+ @arc_url = arc_url.chomp('/')
43
+ @fallback_rate = fallback_rate
44
+ @cache_ttl = cache_ttl
45
+ @api_key = api_key
46
+ @http_client = http_client
47
+ @cached_rate = nil
48
+ @cached_at = nil
49
+ @mutex = Mutex.new
50
+ end
51
+
52
+ # Compute the fee for a transaction using the latest ARC rate.
53
+ #
54
+ # @param transaction [Transaction] the transaction to compute the fee for
55
+ # @return [Integer] the fee in satoshis
56
+ def compute_fee(transaction)
57
+ rate = current_rate
58
+ size = transaction.estimated_size
59
+ (size / 1000.0 * rate).ceil
60
+ end
61
+
62
+ # Return the current sat/kB rate, fetching from ARC if the cache
63
+ # has expired.
64
+ #
65
+ # @return [Integer] satoshis per kilobyte
66
+ def current_rate
67
+ @mutex.synchronize do
68
+ return @cached_rate if cache_valid?
69
+
70
+ rate = fetch_rate
71
+ if rate
72
+ @cached_rate = rate
73
+ @cached_at = Time.now
74
+ rate
75
+ elsif @cached_rate
76
+ @cached_rate
77
+ else
78
+ @fallback_rate
79
+ end
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def cache_valid?
86
+ @cached_rate && @cached_at && (Time.now - @cached_at) < @cache_ttl
87
+ end
88
+
89
+ def fetch_rate
90
+ uri = URI("#{@arc_url}/v1/policy")
91
+ request = Net::HTTP::Get.new(uri)
92
+ request['Accept'] = 'application/json'
93
+ request['Authorization'] = "Bearer #{@api_key}" if @api_key
94
+
95
+ response = execute(uri, request)
96
+ return unless (200..299).cover?(response.code.to_i)
97
+
98
+ payload = JSON.parse(response.body)
99
+ # Some endpoints wrap the policy in a 'data' key
100
+ payload = payload['data'] if payload.is_a?(Hash) && payload.key?('data') && payload['data'].is_a?(Hash)
101
+
102
+ extract_rate(payload)
103
+ rescue StandardError
104
+ nil
105
+ end
106
+
107
+ def extract_rate(payload)
108
+ policy = payload.is_a?(Hash) ? payload['policy'] : nil
109
+ return unless policy.is_a?(Hash)
110
+
111
+ # Primary: policy.fees.miningFee { satoshis: x, bytes: y }
112
+ fees = policy['fees']
113
+ mining_fee = fees.is_a?(Hash) ? fees['miningFee'] : nil
114
+ mining_fee ||= policy['miningFee']
115
+
116
+ if mining_fee.is_a?(Hash)
117
+ satoshis = mining_fee['satoshis']
118
+ bytes = mining_fee['bytes']
119
+ return [1, (satoshis.to_f / bytes * 1000).round].max if satoshis.is_a?(Numeric) && bytes.is_a?(Numeric) && bytes.positive?
120
+ end
121
+
122
+ # Fallback: direct sat/kB keys
123
+ %w[satPerKb sat_per_kb satoshisPerKb].each do |key|
124
+ value = policy[key]
125
+ return [1, value.round].max if value.is_a?(Numeric) && value.positive?
126
+ end
127
+
128
+ nil
129
+ end
130
+
131
+ def execute(uri, request)
132
+ if @http_client
133
+ @http_client.request(uri, request)
134
+ else
135
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
136
+ http.request(request)
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -5,6 +5,7 @@ module BSV
5
5
  # Namespace for fee model implementations.
6
6
  module FeeModels
7
7
  autoload :SatoshisPerKilobyte, 'bsv/transaction/fee_models/satoshis_per_kilobyte'
8
+ autoload :LivePolicy, 'bsv/transaction/fee_models/live_policy'
8
9
  end
9
10
  end
10
11
  end
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.3.1'
4
+ VERSION = '0.3.2'
5
5
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BSV
4
4
  module WalletInterface
5
- VERSION = '0.1.1'
5
+ VERSION = '0.1.2'
6
6
  end
7
7
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require 'securerandom'
4
4
  require 'base64'
5
+ require 'net/http'
6
+ require 'json'
7
+ require 'uri'
5
8
 
6
9
  module BSV
7
10
  module Wallet
@@ -36,11 +39,13 @@ module BSV
36
39
  # @param storage [StorageAdapter] persistence adapter (default: MemoryStore)
37
40
  # @param network [String] 'mainnet' (default) or 'testnet'
38
41
  # @param chain_provider [ChainProvider] blockchain data provider (default: NullChainProvider)
39
- def initialize(key, storage: MemoryStore.new, network: 'mainnet', chain_provider: NullChainProvider.new)
42
+ # @param http_client [#request, nil] injectable HTTP client for certificate issuance
43
+ def initialize(key, storage: MemoryStore.new, network: 'mainnet', chain_provider: NullChainProvider.new, http_client: nil)
40
44
  super(key)
41
45
  @storage = storage
42
46
  @network = network
43
47
  @chain_provider = chain_provider
48
+ @http_client = http_client
44
49
  @pending = {}
45
50
  end
46
51
 
@@ -254,18 +259,11 @@ module BSV
254
259
  def acquire_certificate(args, _originator: nil)
255
260
  validate_acquire_certificate!(args)
256
261
 
257
- raise UnsupportedActionError, 'acquire_certificate with issuance protocol' if args[:acquisition_protocol] == 'issuance'
258
-
259
- cert = {
260
- type: args[:type],
261
- subject: @key_deriver.identity_key,
262
- serial_number: args[:serial_number],
263
- certifier: args[:certifier],
264
- revocation_outpoint: args[:revocation_outpoint],
265
- signature: args[:signature],
266
- fields: args[:fields],
267
- keyring: args[:keyring_for_subject]
268
- }
262
+ cert = if args[:acquisition_protocol] == 'issuance'
263
+ acquire_via_issuance(args)
264
+ else
265
+ acquire_via_direct(args)
266
+ end
269
267
 
270
268
  @storage.store_certificate(cert)
271
269
  cert_without_keyring(cert)
@@ -721,12 +719,69 @@ module BSV
721
719
  protocol = args[:acquisition_protocol]
722
720
  raise InvalidParameterError.new('acquisition_protocol', '"direct" or "issuance"') unless %w[direct issuance].include?(protocol)
723
721
 
724
- return unless protocol == 'direct'
722
+ if protocol == 'direct'
723
+ raise InvalidParameterError.new('serial_number', 'present for direct acquisition') unless args[:serial_number]
724
+ raise InvalidParameterError.new('revocation_outpoint', 'present for direct acquisition') unless args[:revocation_outpoint]
725
+ raise InvalidParameterError.new('signature', 'present for direct acquisition') unless args[:signature]
726
+ raise InvalidParameterError.new('keyring_for_subject', 'a Hash for direct acquisition') unless args[:keyring_for_subject].is_a?(Hash)
727
+ elsif protocol == 'issuance'
728
+ raise InvalidParameterError.new('certifier_url', 'present for issuance acquisition') unless args[:certifier_url].is_a?(String)
729
+ end
730
+ end
725
731
 
726
- raise InvalidParameterError.new('serial_number', 'present for direct acquisition') unless args[:serial_number]
727
- raise InvalidParameterError.new('revocation_outpoint', 'present for direct acquisition') unless args[:revocation_outpoint]
728
- raise InvalidParameterError.new('signature', 'present for direct acquisition') unless args[:signature]
729
- raise InvalidParameterError.new('keyring_for_subject', 'a Hash for direct acquisition') unless args[:keyring_for_subject].is_a?(Hash)
732
+ def acquire_via_direct(args)
733
+ {
734
+ type: args[:type],
735
+ subject: @key_deriver.identity_key,
736
+ serial_number: args[:serial_number],
737
+ certifier: args[:certifier],
738
+ revocation_outpoint: args[:revocation_outpoint],
739
+ signature: args[:signature],
740
+ fields: args[:fields],
741
+ keyring: args[:keyring_for_subject]
742
+ }
743
+ end
744
+
745
+ def acquire_via_issuance(args)
746
+ uri = URI(args[:certifier_url])
747
+ request = Net::HTTP::Post.new(uri)
748
+ request['Content-Type'] = 'application/json'
749
+ request.body = JSON.generate({
750
+ type: args[:type],
751
+ subject: @key_deriver.identity_key,
752
+ certifier: args[:certifier],
753
+ fields: args[:fields]
754
+ })
755
+
756
+ response = execute_http(uri, request)
757
+ code = response.code.to_i
758
+
759
+ raise WalletError, "Certificate issuance failed: HTTP #{code}" unless (200..299).cover?(code)
760
+
761
+ body = JSON.parse(response.body)
762
+
763
+ {
764
+ type: body['type'] || args[:type],
765
+ subject: @key_deriver.identity_key,
766
+ serial_number: body['serialNumber'],
767
+ certifier: args[:certifier],
768
+ revocation_outpoint: body['revocationOutpoint'],
769
+ signature: body['signature'],
770
+ fields: body['fields'] || args[:fields],
771
+ keyring: body['keyringForSubject']
772
+ }
773
+ rescue JSON::ParserError
774
+ raise WalletError, 'Certificate issuance failed: invalid JSON response'
775
+ end
776
+
777
+ def execute_http(uri, request)
778
+ if @http_client
779
+ @http_client.request(uri, request)
780
+ else
781
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
782
+ http.request(request)
783
+ end
784
+ end
730
785
  end
731
786
 
732
787
  def find_stored_certificate(cert_arg)
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ module Wire
6
+ # Consumes a binary byte string using BRC-100 wire protocol decoding conventions.
7
+ #
8
+ # All multi-byte integers use little-endian order unless stated otherwise.
9
+ # VarInts follow Bitcoin encoding; the 9-byte MaxUint64 sentinel decodes as -1.
10
+ class Reader
11
+ # The 64-bit sentinel value that encodes -1 in a signed VarInt.
12
+ MAX_UINT64 = 0xFFFF_FFFF_FFFF_FFFF
13
+
14
+ def initialize(data)
15
+ @data = data.b
16
+ @offset = 0
17
+ end
18
+
19
+ # Current read position (bytes consumed so far).
20
+ attr_reader :offset
21
+
22
+ # Reads a single unsigned byte (0–255).
23
+ #
24
+ # @return [Integer]
25
+ def read_byte
26
+ require_bytes(1)
27
+ byte = @data.getbyte(@offset)
28
+ @offset += 1
29
+ byte
30
+ end
31
+
32
+ # Reads a signed 8-bit integer (-128–127).
33
+ #
34
+ # @return [Integer]
35
+ def read_int8
36
+ require_bytes(1)
37
+ val = @data.byteslice(@offset, 1).unpack1('c')
38
+ @offset += 1
39
+ val
40
+ end
41
+
42
+ # Reads an unsigned Bitcoin VarInt.
43
+ #
44
+ # @return [Integer]
45
+ def read_varint
46
+ require_bytes(1)
47
+ first = @data.getbyte(@offset)
48
+
49
+ case first
50
+ when 0..0xFC
51
+ @offset += 1
52
+ first
53
+ when 0xFD
54
+ require_bytes(3)
55
+ val = @data.byteslice(@offset + 1, 2).unpack1('v')
56
+ @offset += 3
57
+ val
58
+ when 0xFE
59
+ require_bytes(5)
60
+ val = @data.byteslice(@offset + 1, 4).unpack1('V')
61
+ @offset += 5
62
+ val
63
+ else # 0xFF
64
+ require_bytes(9)
65
+ val = @data.byteslice(@offset + 1, 8).unpack1('Q<')
66
+ @offset += 9
67
+ val
68
+ end
69
+ end
70
+
71
+ # Reads a signed VarInt, returning -1 for the MaxUint64 sentinel.
72
+ #
73
+ # @return [Integer] non-negative value or -1
74
+ def read_signed_varint
75
+ val = read_varint
76
+ val == MAX_UINT64 ? -1 : val
77
+ end
78
+
79
+ # Reads exactly n raw bytes.
80
+ #
81
+ # @param n [Integer]
82
+ # @return [String] binary string
83
+ def read_bytes(n)
84
+ require_bytes(n)
85
+ slice = @data.byteslice(@offset, n)
86
+ @offset += n
87
+ slice
88
+ end
89
+
90
+ # Reads all remaining bytes from the current offset.
91
+ #
92
+ # @return [String] binary string
93
+ def read_remaining
94
+ slice = @data.byteslice(@offset, @data.bytesize - @offset) || ''.b
95
+ @offset = @data.bytesize
96
+ slice
97
+ end
98
+
99
+ # Reads a VarInt-prefixed byte array.
100
+ #
101
+ # @return [String] binary string
102
+ def read_byte_array
103
+ len = read_varint
104
+ read_bytes(len)
105
+ end
106
+
107
+ # Reads an optional VarInt-prefixed byte array; returns nil for the -1 sentinel.
108
+ #
109
+ # @return [String, nil]
110
+ def read_optional_byte_array
111
+ len = read_signed_varint
112
+ return nil if len == -1
113
+
114
+ read_bytes(len)
115
+ end
116
+
117
+ # Reads a VarInt-prefixed UTF-8 string.
118
+ #
119
+ # @return [String]
120
+ def read_utf8_string
121
+ len = read_varint
122
+ read_bytes(len).force_encoding('UTF-8')
123
+ end
124
+
125
+ # Reads an optional VarInt-prefixed UTF-8 string; returns nil for the -1 sentinel.
126
+ #
127
+ # @return [String, nil]
128
+ def read_optional_utf8_string
129
+ len = read_signed_varint
130
+ return nil if len == -1
131
+
132
+ read_bytes(len).force_encoding('UTF-8')
133
+ end
134
+
135
+ # Reads a signed Int8 optional boolean: 1=true, 0=false, -1=nil.
136
+ #
137
+ # @return [Boolean, nil]
138
+ def read_optional_bool
139
+ val = read_int8
140
+ return nil if val == -1
141
+
142
+ val == 1
143
+ end
144
+
145
+ # Reads an outpoint: 32 bytes (txid hex in display order) + VarInt index.
146
+ #
147
+ # @return [Array(String, Integer)] [txid_hex, index]
148
+ def read_outpoint
149
+ txid_bytes = read_bytes(32)
150
+ txid_hex = txid_bytes.unpack1('H*')
151
+ index = read_varint
152
+ [txid_hex, index]
153
+ end
154
+
155
+ # Reads a counterparty value using the first-byte dispatch scheme.
156
+ #
157
+ # @return [String, nil] 'self', 'anyone', nil, or 66-char hex pubkey
158
+ def read_counterparty
159
+ flag = read_byte
160
+ case flag
161
+ when 11
162
+ 'self'
163
+ when 12
164
+ 'anyone'
165
+ when 0
166
+ nil
167
+ else
168
+ # First byte is part of the 33-byte compressed pubkey; read 32 more
169
+ remaining = read_bytes(32)
170
+ ([flag].pack('C') + remaining).unpack1('H*')
171
+ end
172
+ end
173
+
174
+ # Reads a protocol ID: UInt8 security level + VarInt-prefixed UTF-8 name.
175
+ #
176
+ # @return [Array(Integer, String)] [level, name]
177
+ def read_protocol_id
178
+ level = read_byte
179
+ name = read_utf8_string
180
+ [level, name]
181
+ end
182
+
183
+ # Reads an optional string array: VarInt count + strings, or nil for the -1 sentinel.
184
+ #
185
+ # @return [Array<String>, nil]
186
+ def read_string_array
187
+ count = read_signed_varint
188
+ return nil if count == -1
189
+
190
+ count.times.map { read_utf8_string }
191
+ end
192
+
193
+ # Reads an optional string→string map: VarInt count + key/value pairs, or nil.
194
+ #
195
+ # @return [Hash, nil]
196
+ def read_map
197
+ count = read_signed_varint
198
+ return nil if count == -1
199
+
200
+ count.times.each_with_object({}) do |_, hash|
201
+ key = read_utf8_string
202
+ val = read_utf8_string
203
+ hash[key] = val
204
+ end
205
+ end
206
+
207
+ # Reads privileged parameters: optional bool + Int8-length reason string.
208
+ #
209
+ # @return [Array(Boolean|nil, String|nil)] [privileged, privileged_reason]
210
+ def read_privileged
211
+ privileged = read_optional_bool
212
+ reason_len = read_int8
213
+ privileged_reason = if reason_len == -1
214
+ nil
215
+ elsif reason_len.negative?
216
+ raise ArgumentError, "invalid privileged_reason length: #{reason_len}"
217
+ else
218
+ read_bytes(reason_len).force_encoding('UTF-8')
219
+ end
220
+ [privileged, privileged_reason]
221
+ end
222
+
223
+ private
224
+
225
+ def require_bytes(n)
226
+ raise ArgumentError, "negative byte count: #{n}" if n.negative?
227
+
228
+ remaining = @data.bytesize - @offset
229
+ return if remaining >= n
230
+
231
+ raise ArgumentError,
232
+ "truncated wire data: need #{n} bytes at offset #{@offset}, " \
233
+ "got #{remaining}"
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end