bsv-sdk 0.20.0 → 0.23.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/lib/bsv/mcp/tools/broadcast_p2pkh.rb +5 -3
  4. data/lib/bsv/network/protocols/arc.rb +4 -30
  5. data/lib/bsv/network/protocols/arcade.rb +163 -0
  6. data/lib/bsv/network/protocols/chaintracks.rb +27 -4
  7. data/lib/bsv/network/protocols/jungle_bus.rb +34 -0
  8. data/lib/bsv/network/protocols/woc_rest.rb +28 -1
  9. data/lib/bsv/network/protocols.rb +1 -0
  10. data/lib/bsv/network/providers/gorilla_pool.rb +18 -18
  11. data/lib/bsv/network/util.rb +44 -0
  12. data/lib/bsv/network.rb +1 -0
  13. data/lib/bsv/transaction/chain_tracker.rb +66 -13
  14. data/lib/bsv/transaction/chain_trackers.rb +0 -10
  15. data/lib/bsv/transaction/fee_models/live_policy.rb +10 -8
  16. data/lib/bsv/version.rb +1 -1
  17. data/lib/bsv/wallet/errors.rb +65 -21
  18. data/lib/bsv/wallet/proto_wallet/validators.rb +7 -49
  19. data/lib/bsv/wallet/proto_wallet.rb +14 -1
  20. data/lib/bsv/wallet/serializer/abort_action.rb +38 -0
  21. data/lib/bsv/wallet/serializer/acquire_certificate.rb +171 -0
  22. data/lib/bsv/wallet/serializer/certificate.rb +184 -0
  23. data/lib/bsv/wallet/serializer/common.rb +207 -0
  24. data/lib/bsv/wallet/serializer/create_action_args.rb +259 -0
  25. data/lib/bsv/wallet/serializer/create_action_result.rb +85 -0
  26. data/lib/bsv/wallet/serializer/create_hmac.rb +67 -0
  27. data/lib/bsv/wallet/serializer/create_signature.rb +90 -0
  28. data/lib/bsv/wallet/serializer/decrypt.rb +60 -0
  29. data/lib/bsv/wallet/serializer/discover_by_attributes.rb +61 -0
  30. data/lib/bsv/wallet/serializer/discover_by_identity_key.rb +49 -0
  31. data/lib/bsv/wallet/serializer/discover_certificates_result.rb +39 -0
  32. data/lib/bsv/wallet/serializer/encrypt.rb +60 -0
  33. data/lib/bsv/wallet/serializer/get_header_for_height.rb +71 -0
  34. data/lib/bsv/wallet/serializer/get_height.rb +46 -0
  35. data/lib/bsv/wallet/serializer/get_network.rb +65 -0
  36. data/lib/bsv/wallet/serializer/get_public_key.rb +86 -0
  37. data/lib/bsv/wallet/serializer/get_version.rb +44 -0
  38. data/lib/bsv/wallet/serializer/internalize_action.rb +151 -0
  39. data/lib/bsv/wallet/serializer/list_actions.rb +348 -0
  40. data/lib/bsv/wallet/serializer/list_certificates.rb +124 -0
  41. data/lib/bsv/wallet/serializer/list_outputs.rb +167 -0
  42. data/lib/bsv/wallet/serializer/prove_certificate.rb +146 -0
  43. data/lib/bsv/wallet/serializer/relinquish_certificate.rb +56 -0
  44. data/lib/bsv/wallet/serializer/relinquish_output.rb +44 -0
  45. data/lib/bsv/wallet/serializer/reveal_counterparty_key_linkage.rb +108 -0
  46. data/lib/bsv/wallet/serializer/reveal_specific_key_linkage.rb +116 -0
  47. data/lib/bsv/wallet/serializer/sign_action_args.rb +94 -0
  48. data/lib/bsv/wallet/serializer/sign_action_result.rb +49 -0
  49. data/lib/bsv/wallet/serializer/status.rb +85 -0
  50. data/lib/bsv/wallet/serializer/verify_hmac.rb +67 -0
  51. data/lib/bsv/wallet/serializer/verify_signature.rb +101 -0
  52. data/lib/bsv/wallet/serializer.rb +180 -0
  53. data/lib/bsv/wallet/substrates/http_wallet_json.rb +129 -0
  54. data/lib/bsv/wallet/substrates/http_wallet_wire.rb +99 -0
  55. data/lib/bsv/wallet/wallet_wire.rb +20 -0
  56. data/lib/bsv/wallet/wallet_wire_processor.rb +61 -0
  57. data/lib/bsv/wallet/wallet_wire_transceiver.rb +61 -0
  58. data/lib/bsv/wallet/wire/calls.rb +79 -0
  59. data/lib/bsv/wallet/wire/frame.rb +181 -0
  60. data/lib/bsv/wallet/wire/reader_writer.rb +402 -0
  61. data/lib/bsv/wallet/wire/validation.rb +213 -0
  62. data/lib/bsv/wallet/wire.rb +13 -0
  63. data/lib/bsv/wallet.rb +17 -0
  64. metadata +46 -2
  65. data/lib/bsv/transaction/chain_trackers/chaintracks.rb +0 -83
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ module Wire
6
+ # BRC-103 request and result frame codec.
7
+ #
8
+ # Port of go-sdk/wallet/serializer/frame.go. Two frame types:
9
+ #
10
+ # Request frame (client → wallet):
11
+ # [1 byte: call][1 byte: originator_len][originator_len bytes: UTF-8][remaining: params]
12
+ #
13
+ # Result frame (wallet → client):
14
+ # [1 byte: error_code]
15
+ # On success (0x00): [remaining bytes: payload]
16
+ # On error (non-zero):
17
+ # [VarInt: message_len][message bytes]
18
+ # [VarInt: stack_len][stack bytes]
19
+ module Frame
20
+ # Maximum originator byte length enforced at write time.
21
+ # Matches the BRC-100 +OriginatorDomainNameStringUnder250Bytes+ branded
22
+ # type used by +Wire::Validation.originator_domain!+.
23
+ MAX_ORIGINATOR_BYTES = 250
24
+
25
+ module_function
26
+
27
+ # Encode a request frame.
28
+ #
29
+ # @param call [Integer] call byte (1..28)
30
+ # @param originator [String] originator domain (0..250 bytes UTF-8)
31
+ # @param params [String, nil] binary params payload
32
+ # @return [String] binary frame (ASCII-8BIT encoding)
33
+ # @raise [ArgumentError] if originator exceeds 250 bytes
34
+ def write_request(call:, originator:, params: nil)
35
+ originator_bytes = originator.to_s.b
36
+ if originator_bytes.bytesize > MAX_ORIGINATOR_BYTES
37
+ raise ArgumentError,
38
+ "originator must be at most #{MAX_ORIGINATOR_BYTES} bytes, " \
39
+ "got #{originator_bytes.bytesize}"
40
+ end
41
+
42
+ buf = String.new(encoding: 'BINARY')
43
+ buf << [call, originator_bytes.bytesize].pack('CC')
44
+ buf << originator_bytes
45
+ buf << params.b if params && !params.empty?
46
+ buf
47
+ end
48
+
49
+ # Decode a request frame.
50
+ #
51
+ # @param bytes [String] binary frame
52
+ # @return [Hash] { call: Integer, originator: String, params: String }
53
+ # @raise [ArgumentError] if the frame is truncated or malformed
54
+ def read_request(bytes)
55
+ data = bytes.b
56
+ raise ArgumentError, 'frame too short: need at least 2 bytes' if data.bytesize < 2
57
+
58
+ call = data.getbyte(0)
59
+ originator_len = data.getbyte(1)
60
+
61
+ if data.bytesize < 2 + originator_len
62
+ raise ArgumentError,
63
+ "frame truncated: need #{2 + originator_len} bytes for originator, " \
64
+ "got #{data.bytesize}"
65
+ end
66
+
67
+ originator = data.byteslice(2, originator_len).force_encoding('UTF-8')
68
+ raise ArgumentError, 'frame originator is not valid UTF-8' unless originator.valid_encoding?
69
+
70
+ params = data.byteslice(2 + originator_len, data.bytesize - 2 - originator_len) || ''.b
71
+
72
+ { call: call, originator: originator, params: params }
73
+ end
74
+
75
+ # Encode a success result frame.
76
+ #
77
+ # @param payload [String, nil] binary payload
78
+ # @return [String] binary frame
79
+ def write_result(payload: nil)
80
+ buf = String.new(encoding: 'BINARY')
81
+ buf << "\x00"
82
+ buf << payload.b if payload && !payload.empty?
83
+ buf
84
+ end
85
+
86
+ # Encode an error result frame.
87
+ #
88
+ # @param error [BSV::Wallet::Error] the error to encode
89
+ # @return [String] binary frame
90
+ def write_error(error:)
91
+ wire = error.to_wire
92
+ msg_bytes = wire[:message].to_s.b
93
+ stack_bytes = wire[:stack].to_s.b
94
+
95
+ buf = String.new(encoding: 'BINARY')
96
+ buf << [wire[:code]].pack('C')
97
+ buf << encode_varint(msg_bytes.bytesize)
98
+ buf << msg_bytes
99
+ buf << encode_varint(stack_bytes.bytesize)
100
+ buf << stack_bytes
101
+ buf
102
+ end
103
+
104
+ # Decode a result frame.
105
+ #
106
+ # @param bytes [String] binary frame
107
+ # @return [String] binary payload on success
108
+ # @raise [BSV::Wallet::Error] the appropriate subclass on error
109
+ # @raise [ArgumentError] if the frame is truncated or malformed
110
+ def read_result(bytes)
111
+ data = bytes.b
112
+ raise ArgumentError, 'result frame is empty' if data.empty?
113
+
114
+ code = data.getbyte(0)
115
+
116
+ return data.byteslice(1, data.bytesize - 1) || ''.b if code.zero?
117
+
118
+ offset = 1
119
+ msg_len, vi = decode_varint(data, offset)
120
+ offset += vi
121
+
122
+ raise ArgumentError, 'result frame truncated: message' if data.bytesize < offset + msg_len
123
+
124
+ message = data.byteslice(offset, msg_len).force_encoding('UTF-8')
125
+ raise ArgumentError, 'result frame error message is not valid UTF-8' unless message.valid_encoding?
126
+
127
+ offset += msg_len
128
+
129
+ stack_len, vi = decode_varint(data, offset)
130
+ offset += vi
131
+
132
+ raise ArgumentError, 'result frame truncated: stack' if data.bytesize < offset + stack_len
133
+
134
+ stack = data.byteslice(offset, stack_len).force_encoding('UTF-8')
135
+ raise ArgumentError, 'result frame stack is not valid UTF-8' unless stack.valid_encoding?
136
+
137
+ raise BSV::Wallet.error_from_wire(code, message, stack)
138
+ end
139
+
140
+ # @param n [Integer] unsigned integer
141
+ # @return [String] Bitcoin varint encoding
142
+ def encode_varint(n)
143
+ if n < 0xFD
144
+ [n].pack('C')
145
+ elsif n <= 0xFFFF
146
+ [0xFD, n].pack('Cv')
147
+ elsif n <= 0xFFFFFFFF
148
+ [0xFE, n].pack('CV')
149
+ else
150
+ [0xFF, n].pack('CQ<')
151
+ end
152
+ end
153
+
154
+ # @param data [String] binary data
155
+ # @param offset [Integer] byte offset
156
+ # @return [Array(Integer, Integer)] decoded value, bytes consumed
157
+ def decode_varint(data, offset = 0)
158
+ raise ArgumentError, "varint: need 1 byte at #{offset}" if offset >= data.bytesize
159
+
160
+ first = data.getbyte(offset)
161
+ case first
162
+ when 0..0xFC
163
+ [first, 1]
164
+ when 0xFD
165
+ raise ArgumentError, "varint: need 3 bytes at #{offset}" if data.bytesize < offset + 3
166
+
167
+ [data.byteslice(offset + 1, 2).unpack1('v'), 3]
168
+ when 0xFE
169
+ raise ArgumentError, "varint: need 5 bytes at #{offset}" if data.bytesize < offset + 5
170
+
171
+ [data.byteslice(offset + 1, 4).unpack1('V'), 5]
172
+ when 0xFF
173
+ raise ArgumentError, "varint: need 9 bytes at #{offset}" if data.bytesize < offset + 9
174
+
175
+ [data.byteslice(offset + 1, 8).unpack1('Q<'), 9]
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module BSV
6
+ module Wallet
7
+ module Wire
8
+ # Binary read/write helpers with BRC-103 idioms.
9
+ #
10
+ # Thin wrappers over StringIO providing the specific encodings used across
11
+ # BRC-103 wire frames: varint strings, optional bools, satoshi LE uint64,
12
+ # and outpoints. Reuses BSV::Transaction::VarInt for the varint codec.
13
+
14
+ # Writer accumulates bytes into a binary string buffer.
15
+ class Writer
16
+ attr_reader :buf
17
+
18
+ def initialize
19
+ @buf = String.new(encoding: 'BINARY')
20
+ end
21
+
22
+ # Write a raw byte.
23
+ def write_byte(byte)
24
+ @buf << [byte].pack('C')
25
+ end
26
+
27
+ # Write raw bytes (binary string).
28
+ def write_bytes(bytes)
29
+ @buf << bytes.b
30
+ end
31
+
32
+ # Write a Bitcoin varint.
33
+ def write_varint(n)
34
+ @buf << BSV::Transaction::VarInt.encode(n)
35
+ end
36
+
37
+ # Write a UTF-8 string prefixed by its byte length as a varint.
38
+ def write_str_with_varint_len(str)
39
+ bytes = str.to_s.b
40
+ write_varint(bytes.bytesize)
41
+ write_bytes(bytes)
42
+ end
43
+
44
+ # Write an optional boolean as a single byte (Go/BRC-103 convention).
45
+ # nil → 0xFF, false → 0x00, true → 0x01
46
+ def write_optional_bool(value)
47
+ byte = case value
48
+ when nil then 0xFF
49
+ when false then 0x00
50
+ else 0x01
51
+ end
52
+ write_byte(byte)
53
+ end
54
+
55
+ # Write a satoshi amount as 8-byte little-endian uint64.
56
+ def write_satoshis(n)
57
+ @buf << [n].pack('Q<')
58
+ end
59
+
60
+ # Write an outpoint: 32-byte display-order txid followed by varint vout.
61
+ #
62
+ # Go encodeOutpoint calls WriteBytesReverse(Txid[:]) which writes the
63
+ # wire-order (chainhash) bytes reversed — i.e. display order on the wire.
64
+ # The vout is a varint, not a fixed 4-byte LE integer.
65
+ #
66
+ # @param txid_hex [String] 64-char display-order hex txid
67
+ # @param vout [Integer] output index
68
+ def write_outpoint(txid_hex, vout)
69
+ BSV::Primitives::Hex.validate_dtxid_hex!(txid_hex)
70
+ @buf << [txid_hex].pack('H*')
71
+ write_varint(vout)
72
+ end
73
+
74
+ # Write the NegativeOne sentinel (MaxUint64 as a 9-byte varint: 9 × 0xFF).
75
+ # Used as a nil sentinel for optional fields in Go-compatible encoding.
76
+ def write_negative_one
77
+ write_varint(0xFFFF_FFFF_FFFF_FFFF)
78
+ end
79
+
80
+ # Write a varint length-prefixed byte array (WriteIntBytes in Go).
81
+ # @param bytes [String, nil] binary string; nil or empty → write varint 0
82
+ def write_int_bytes(bytes)
83
+ raw = bytes ? bytes.b : ''.b
84
+ write_varint(raw.bytesize)
85
+ write_bytes(raw)
86
+ end
87
+
88
+ # Write an optional uint32: nil → NegativeOne; else varint.
89
+ # @param n [Integer, nil]
90
+ def write_optional_uint32(n)
91
+ if n.nil?
92
+ write_negative_one
93
+ else
94
+ write_varint(n)
95
+ end
96
+ end
97
+
98
+ # Write an optional string: nil or empty → NegativeOne; else varint len + bytes.
99
+ # Matches Go WriteOptionalString.
100
+ # @param str [String, nil]
101
+ def write_optional_string(str)
102
+ if str.nil? || str.empty?
103
+ write_negative_one
104
+ else
105
+ write_str_with_varint_len(str)
106
+ end
107
+ end
108
+
109
+ # Write an array of strings: nil → NegativeOne; else varint count + each as optional string.
110
+ # Matches Go WriteStringSlice.
111
+ # @param arr [Array<String>, nil]
112
+ def write_string_slice(arr)
113
+ if arr.nil?
114
+ write_negative_one
115
+ else
116
+ write_varint(arr.length)
117
+ arr.each { |s| write_optional_string(s) }
118
+ end
119
+ end
120
+
121
+ # Write a string map (Hash<String,String>) sorted by key.
122
+ # Matches Go WriteStringMap.
123
+ # @param map [Hash, nil]
124
+ def write_string_map(map)
125
+ m = map || {}
126
+ write_varint(m.length)
127
+ m.keys.sort.each do |k|
128
+ write_str_with_varint_len(k)
129
+ write_str_with_varint_len(m[k])
130
+ end
131
+ end
132
+
133
+ # Write a binary value encoded as a Base64 string on the wire.
134
+ # Decodes the Base64 string and writes the raw bytes prefixed by varint length.
135
+ # @param base64_str [String] standard Base64-encoded string
136
+ def write_int_from_base64(base64_str)
137
+ raw = Base64.strict_decode64(base64_str)
138
+ write_int_bytes(raw)
139
+ end
140
+
141
+ # Write the privileged flag and reason (Go encodePrivilegedParams).
142
+ # privileged: nil → NegativeOneByte (0xFF), false → 0x00, true → 0x01.
143
+ # reason: nil or empty → NegativeOneByte; else varint len + bytes.
144
+ def write_privileged_params(privileged, reason)
145
+ # Go OptionalBool: nil=0xFF, false=0x00, true=0x01
146
+ byte = case privileged
147
+ when nil then 0xFF
148
+ when false then 0x00
149
+ else 0x01
150
+ end
151
+ write_byte(byte)
152
+ if reason.nil? || reason.empty?
153
+ write_byte(0xFF)
154
+ else
155
+ write_str_with_varint_len(reason)
156
+ end
157
+ end
158
+
159
+ # Write a txid slice (array of 32-byte txids in wire order).
160
+ # nil → NegativeOne; else varint count + 32 raw bytes per txid.
161
+ # Txids are passed as 64-char display-order hex; written as-is (no byte reversal)
162
+ # to match Go WriteTxidSlice which writes txID[:] (wire-order bytes directly).
163
+ # @param txids [Array<String>, nil] 64-char display-order hex strings
164
+ def write_txid_slice(txids)
165
+ if txids.nil?
166
+ write_negative_one
167
+ else
168
+ write_varint(txids.length)
169
+ txids.each { |hex| write_bytes([hex].pack('H*')) }
170
+ end
171
+ end
172
+
173
+ # Write optional bytes with a 1-byte flag prefix (Go BytesOptionWithFlag).
174
+ # nil/empty → 0x00; else → 0x01 + varint_len + bytes (or fixed_size bytes).
175
+ # @param bytes [String, nil] binary data
176
+ # @param fixed_size [Integer, nil] if set, omit the varint length prefix
177
+ def write_optional_bytes_with_flag(bytes, fixed_size: nil)
178
+ raw = bytes ? bytes.b : ''.b
179
+ if raw.empty?
180
+ write_byte(0)
181
+ else
182
+ write_byte(1)
183
+ if fixed_size
184
+ write_bytes(raw)
185
+ else
186
+ write_int_bytes(raw)
187
+ end
188
+ end
189
+ end
190
+
191
+ # Write a varint-len string (always present, 0-length for nil/empty).
192
+ # Matches Go WriteString which always writes the length prefix.
193
+ # @param str [String, nil]
194
+ def write_string(str)
195
+ write_str_with_varint_len(str.to_s)
196
+ end
197
+ end
198
+
199
+ # Reader reads sequentially from a binary string.
200
+ class Reader
201
+ # @param data [String] binary data
202
+ def initialize(data)
203
+ @data = data.b
204
+ @pos = 0
205
+ end
206
+
207
+ # Read a single byte.
208
+ # @return [Integer]
209
+ def read_byte
210
+ raise ArgumentError, 'unexpected end of data reading byte' if @pos >= @data.bytesize
211
+
212
+ byte = @data.getbyte(@pos)
213
+ @pos += 1
214
+ byte
215
+ end
216
+
217
+ # Look at the next byte without consuming it.
218
+ # @return [Integer]
219
+ def peek_byte
220
+ raise ArgumentError, 'unexpected end of data peeking byte' if @pos >= @data.bytesize
221
+
222
+ @data.getbyte(@pos)
223
+ end
224
+
225
+ # Read +n+ raw bytes.
226
+ # @return [String] binary string
227
+ def read_bytes(n)
228
+ raise ArgumentError, "need #{n} bytes at offset #{@pos}, got #{remaining}" if remaining < n
229
+
230
+ slice = @data.byteslice(@pos, n)
231
+ @pos += n
232
+ slice
233
+ end
234
+
235
+ # Read a Bitcoin varint.
236
+ # @return [Integer]
237
+ def read_varint
238
+ value, consumed = BSV::Transaction::VarInt.decode(@data, @pos)
239
+ @pos += consumed
240
+ value
241
+ end
242
+
243
+ # Read a varint-prefixed UTF-8 string.
244
+ # @return [String]
245
+ # @raise [ArgumentError] if the bytes are not valid UTF-8
246
+ def read_str_with_varint_len
247
+ len = read_varint
248
+ str = read_bytes(len).force_encoding('UTF-8')
249
+ raise ArgumentError, 'varint-prefixed string is not valid UTF-8' unless str.valid_encoding?
250
+
251
+ str
252
+ end
253
+
254
+ # Read an optional boolean byte (Go/BRC-103 convention).
255
+ # 0xFF → nil, 0x00 → false, 0x01 → true
256
+ # @return [Boolean, nil]
257
+ def read_optional_bool
258
+ byte = read_byte
259
+ return nil if byte == 0xFF
260
+
261
+ byte == 0x01
262
+ end
263
+
264
+ # Read 8-byte little-endian uint64 satoshi amount.
265
+ # @return [Integer]
266
+ def read_satoshis
267
+ read_bytes(8).unpack1('Q<')
268
+ end
269
+
270
+ # Read an outpoint: 32-byte display-order txid + varint vout.
271
+ # @return [Hash] { txid_hex: String, vout: Integer }
272
+ def read_outpoint
273
+ txid_bytes = read_bytes(32)
274
+ vout = read_varint
275
+ { txid_hex: txid_bytes.unpack1('H*'), vout: vout }
276
+ end
277
+
278
+ # Remaining bytes.
279
+ def remaining
280
+ @data.bytesize - @pos
281
+ end
282
+
283
+ # Read all remaining bytes.
284
+ # @return [String] binary string
285
+ def read_remaining
286
+ slice = @data.byteslice(@pos, @data.bytesize - @pos) || ''.b
287
+ @pos = @data.bytesize
288
+ slice
289
+ end
290
+
291
+ # Whether the next varint is the NegativeOne sentinel (0xFFFF…).
292
+ # Peeks at the next byte without consuming it.
293
+ def next_negative_one?
294
+ @pos < @data.bytesize && @data.getbyte(@pos) == 0xFF
295
+ end
296
+
297
+ # Read a varint-length-prefixed byte array (ReadIntBytes in Go).
298
+ # @return [String] binary string
299
+ def read_int_bytes
300
+ len = read_varint
301
+ return ''.b if len == 0xFFFF_FFFF_FFFF_FFFF || len.zero?
302
+
303
+ read_bytes(len)
304
+ end
305
+
306
+ # Read an optional uint32: NegativeOne sentinel → nil; else varint → Integer.
307
+ # @return [Integer, nil]
308
+ def read_optional_uint32
309
+ val = read_varint
310
+ val == 0xFFFF_FFFF_FFFF_FFFF ? nil : val
311
+ end
312
+
313
+ # Read an optional string: NegativeOne sentinel → nil; else varint len + bytes.
314
+ # @return [String, nil]
315
+ def read_optional_string
316
+ val = read_varint
317
+ return nil if val == 0xFFFF_FFFF_FFFF_FFFF
318
+
319
+ read_bytes(val).force_encoding('UTF-8')
320
+ end
321
+
322
+ # Read an array of strings encoded as varint count + each optional string.
323
+ # NegativeOne sentinel count → nil.
324
+ # @return [Array<String>, nil]
325
+ def read_string_slice
326
+ count = read_varint
327
+ return nil if count == 0xFFFF_FFFF_FFFF_FFFF
328
+
329
+ count.times.map { read_optional_string || '' }
330
+ end
331
+
332
+ # Read a string map: varint count + key/value pairs (each varint-len-prefixed).
333
+ # @return [Hash<String,String>]
334
+ def read_string_map
335
+ count = read_varint
336
+ count.times.each_with_object({}) do |_, h|
337
+ k = read_str_with_varint_len
338
+ v = read_str_with_varint_len
339
+ h[k] = v
340
+ end
341
+ end
342
+
343
+ # Read a binary value and return it Base64-encoded.
344
+ # @return [String] Base64-encoded string
345
+ def read_base64_int
346
+ raw = read_int_bytes
347
+ Base64.strict_encode64(raw)
348
+ end
349
+
350
+ # Read privileged params (Go decodePrivilegedParams).
351
+ # @return [Array(Boolean|nil, String|nil)]
352
+ def read_privileged_params
353
+ privileged = read_optional_bool
354
+ first_byte = read_byte
355
+ if first_byte == 0xFF
356
+ [privileged, nil]
357
+ else
358
+ # Back up one byte and read as varint-prefixed string
359
+ @pos -= 1
360
+ reason = read_str_with_varint_len
361
+ [privileged, reason]
362
+ end
363
+ end
364
+
365
+ # Read a txid slice: NegativeOne → nil; else varint count + 32 bytes per txid.
366
+ # Go stores txids in wire order (txID[:]) — returned as hex without reversal.
367
+ # @return [Array<String>, nil]
368
+ def read_txid_slice
369
+ count = read_varint
370
+ return nil if count == 0xFFFF_FFFF_FFFF_FFFF
371
+
372
+ count.times.map { read_bytes(32).unpack1('H*') }
373
+ end
374
+
375
+ # Read optional bytes with a 1-byte flag prefix (Go BytesOptionWithFlag).
376
+ # 0x00 → nil; 0x01 → read varint_len + bytes (or fixed_size bytes).
377
+ # @param fixed_size [Integer, nil] if set, read exactly this many bytes (no varint)
378
+ # @return [String, nil] binary string or nil
379
+ def read_optional_bytes_with_flag(fixed_size: nil)
380
+ flag = read_byte
381
+ return nil if flag.zero?
382
+
383
+ if fixed_size
384
+ read_bytes(fixed_size)
385
+ else
386
+ read_int_bytes
387
+ end
388
+ end
389
+
390
+ # Read a varint-len string (always present, 0-length = empty string).
391
+ # Matches Go ReadString which returns "" for length 0 or NegativeOne.
392
+ # @return [String]
393
+ def read_string
394
+ len = read_varint
395
+ return '' if len.zero? || len == 0xFFFF_FFFF_FFFF_FFFF
396
+
397
+ read_bytes(len).force_encoding('UTF-8')
398
+ end
399
+ end
400
+ end
401
+ end
402
+ end