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.
- checksums.yaml +4 -4
- data/lib/bsv/auth/auth_error.rb +8 -0
- data/lib/bsv/auth/nonce.rb +88 -0
- data/lib/bsv/auth/peer.rb +317 -0
- data/lib/bsv/auth/peer_session.rb +60 -0
- data/lib/bsv/auth/session_manager.rb +120 -0
- data/lib/bsv/auth/transport.rb +45 -0
- data/lib/bsv/auth.rb +22 -0
- data/lib/bsv/primitives/extended_key.rb +4 -1
- data/lib/bsv/script/script.rb +59 -0
- data/lib/bsv/transaction/fee_models/live_policy.rb +143 -0
- data/lib/bsv/transaction/fee_models.rb +1 -0
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv/wallet_interface/version.rb +1 -1
- data/lib/bsv/wallet_interface/wallet_client.rb +73 -18
- data/lib/bsv/wallet_interface/wire/reader.rb +238 -0
- data/lib/bsv/wallet_interface/wire/serializer.rb +1993 -0
- data/lib/bsv/wallet_interface/wire/writer.rb +214 -0
- data/lib/bsv/wallet_interface/wire.rb +19 -0
- data/lib/bsv/wallet_interface.rb +1 -0
- data/lib/bsv-sdk.rb +1 -0
- metadata +14 -2
data/lib/bsv/script/script.rb
CHANGED
|
@@ -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
|
data/lib/bsv/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|