bsv-wallet 0.1.1 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6224c4f914180b05509cd15454a22709ebc899b72e6ba3c77fdace1a8005e125
4
- data.tar.gz: 5bbcb406a15c15d02c2181b54cebfdb6b6e27bfd3f56ecf90adc7e3d5e1e2cce
3
+ metadata.gz: 4b8425ff5a48801f390149006396bac80983a36231e101ff9e96255b4c702f68
4
+ data.tar.gz: 5df7b47ab142634281a76a68de73cd30562a31c4864cddb1ebf5cfe332e60ac0
5
5
  SHA512:
6
- metadata.gz: b76c12653644732d2283fcc452a2a5c3a509d776cb510c261fcb13f9a396dba40de40d2dae2bfa321dc1d79df0e8b702c0b62b7b546b4f31588f9e4f14c7f161
7
- data.tar.gz: 502dde3dfd72aea6089939284e39c8d00d1e4d7b52d1dd260de68c37a39c156d0bad88a4cfbd16bbb1d5ec5211110e75c2c836763cffdb10b433685d63301539
6
+ metadata.gz: 7a8faf79916348e238b86b1006a78cf16ce4bcc0317170c7bd0ba80a2627615a129b6b2a694af884a2492379a29f354994581cd74e54adc62cd6138103906094
7
+ data.tar.gz: 9f3f53a96072ed5dd27d46265a54558b3bd8073811d7532458276d4d287f59c5b77714017d024f970b95686f7fca8c1f5fc0891ba32e493cb671287ac84ff74c
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ # Duck-typed interface for blockchain data providers.
6
+ #
7
+ # Include this module in chain provider adapters and override all methods.
8
+ # The default implementations raise NotImplementedError.
9
+ #
10
+ # @example Custom provider
11
+ # class MyChainProvider
12
+ # include BSV::Wallet::ChainProvider
13
+ #
14
+ # def get_height
15
+ # # query your node/API
16
+ # end
17
+ #
18
+ # def get_header(height)
19
+ # # return 80-byte hex block header
20
+ # end
21
+ # end
22
+ module ChainProvider
23
+ # Returns the current blockchain height.
24
+ # @return [Integer]
25
+ def get_height
26
+ raise NotImplementedError, "#{self.class}#get_height not implemented"
27
+ end
28
+
29
+ # Returns the block header at the given height.
30
+ # @param _height [Integer] block height
31
+ # @return [String] 80-byte hex-encoded block header
32
+ def get_header(_height)
33
+ raise NotImplementedError, "#{self.class}#get_header not implemented"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -55,10 +55,11 @@ module BSV
55
55
  end
56
56
 
57
57
  def find_certificates(query)
58
- results = @certificates
59
- results = results.select { |c| query[:certifiers].include?(c[:certifier]) } if query[:certifiers]
60
- results = results.select { |c| query[:types].include?(c[:type]) } if query[:types]
61
- apply_pagination(results, query)
58
+ apply_pagination(filter_certificates(query), query)
59
+ end
60
+
61
+ def count_certificates(query)
62
+ filter_certificates(query).length
62
63
  end
63
64
 
64
65
  def delete_certificate(type:, serial_number:, certifier:)
@@ -105,6 +106,20 @@ module BSV
105
106
  query[:include_spent] ? results : results.reject { |o| o[:spendable] == false }
106
107
  end
107
108
 
109
+ def filter_certificates(query)
110
+ results = @certificates
111
+ results = results.select { |c| query[:certifiers].include?(c[:certifier]) } if query[:certifiers]
112
+ results = results.select { |c| query[:types].include?(c[:type]) } if query[:types]
113
+ results = results.select { |c| c[:subject] == query[:subject] } if query[:subject]
114
+ if query[:attributes]
115
+ results = results.select do |c|
116
+ fields = c[:fields] || {}
117
+ query[:attributes].all? { |k, v| fields[k] == v || fields[k.to_sym] == v }
118
+ end
119
+ end
120
+ results
121
+ end
122
+
108
123
  def apply_pagination(results, query)
109
124
  offset = query[:offset] || 0
110
125
  limit = query[:limit] || 10
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ # Default chain provider that raises for all blockchain queries.
6
+ #
7
+ # Used when a WalletClient is constructed without a chain provider,
8
+ # allowing the wallet to function for transaction and crypto operations
9
+ # without requiring a blockchain connection.
10
+ class NullChainProvider
11
+ include ChainProvider
12
+
13
+ def get_height
14
+ raise UnsupportedActionError, 'get_height (no chain provider configured)'
15
+ end
16
+
17
+ def get_header(_height)
18
+ raise UnsupportedActionError, 'get_header_for_height (no chain provider configured)'
19
+ end
20
+ end
21
+ end
22
+ end
@@ -46,6 +46,10 @@ module BSV
46
46
  def count_outputs(_query)
47
47
  raise NotImplementedError, "#{self.class}#count_outputs not implemented"
48
48
  end
49
+
50
+ def count_certificates(_query)
51
+ raise NotImplementedError, "#{self.class}#count_certificates not implemented"
52
+ end
49
53
  end
50
54
  end
51
55
  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
@@ -26,11 +29,23 @@ module BSV
26
29
  # @return [StorageAdapter] the underlying persistence adapter
27
30
  attr_reader :storage
28
31
 
32
+ # @return [ChainProvider] the blockchain data provider
33
+ attr_reader :chain_provider
34
+
35
+ # @return [String] the network ('mainnet' or 'testnet')
36
+ attr_reader :network
37
+
29
38
  # @param key [BSV::Primitives::PrivateKey, String, KeyDeriver] signing key
30
39
  # @param storage [StorageAdapter] persistence adapter (default: MemoryStore)
31
- def initialize(key, storage: MemoryStore.new)
40
+ # @param network [String] 'mainnet' (default) or 'testnet'
41
+ # @param chain_provider [ChainProvider] blockchain data provider (default: NullChainProvider)
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)
32
44
  super(key)
33
45
  @storage = storage
46
+ @network = network
47
+ @chain_provider = chain_provider
48
+ @http_client = http_client
34
49
  @pending = {}
35
50
  end
36
51
 
@@ -166,6 +181,217 @@ module BSV
166
181
  { accepted: true }
167
182
  end
168
183
 
184
+ # --- Blockchain & Network Data ---
185
+
186
+ # Returns the current blockchain height from the chain provider.
187
+ #
188
+ # @param _args [Hash] unused (empty hash)
189
+ # @return [Hash] { height: Integer }
190
+ def get_height(_args = {}, _originator: nil)
191
+ { height: @chain_provider.get_height }
192
+ end
193
+
194
+ # Returns the block header at the given height from the chain provider.
195
+ #
196
+ # @param args [Hash]
197
+ # @option args [Integer] :height block height
198
+ # @return [Hash] { header: String } 80-byte hex-encoded block header
199
+ def get_header_for_height(args, _originator: nil)
200
+ raise InvalidParameterError.new('height', 'a positive Integer') unless args[:height].is_a?(Integer) && args[:height].positive?
201
+
202
+ { header: @chain_provider.get_header(args[:height]) }
203
+ end
204
+
205
+ # Returns the network this wallet is configured for.
206
+ #
207
+ # @param _args [Hash] unused (empty hash)
208
+ # @return [Hash] { network: String } 'mainnet' or 'testnet'
209
+ def get_network(_args = {}, _originator: nil)
210
+ { network: @network }
211
+ end
212
+
213
+ # Returns the wallet version string.
214
+ #
215
+ # @param _args [Hash] unused (empty hash)
216
+ # @return [Hash] { version: String } in vendor-major.minor.patch format
217
+ def get_version(_args = {}, _originator: nil)
218
+ { version: "bsv-wallet-#{BSV::WalletInterface::VERSION}" }
219
+ end
220
+
221
+ # --- Authentication ---
222
+
223
+ # Checks whether the user is authenticated.
224
+ # For local wallets with a private key, this is always true.
225
+ #
226
+ # @param _args [Hash] unused (empty hash)
227
+ # @return [Hash] { authenticated: Boolean }
228
+ def is_authenticated(_args = {}, _originator: nil)
229
+ { authenticated: true }
230
+ end
231
+
232
+ # Waits until the user is authenticated.
233
+ # For local wallets, returns immediately.
234
+ #
235
+ # @param _args [Hash] unused (empty hash)
236
+ # @return [Hash] { authenticated: true }
237
+ def wait_for_authentication(_args = {}, _originator: nil)
238
+ { authenticated: true }
239
+ end
240
+
241
+ # --- Identity and Certificate Management ---
242
+
243
+ # Acquires an identity certificate via direct storage.
244
+ #
245
+ # The 'issuance' protocol (which requires HTTP to a certifier URL) is
246
+ # not yet supported and raises {UnsupportedActionError}.
247
+ #
248
+ # @param args [Hash]
249
+ # @option args [String] :type certificate type (base64)
250
+ # @option args [String] :certifier certifier public key hex
251
+ # @option args [String] :acquisition_protocol 'direct' or 'issuance'
252
+ # @option args [Hash] :fields certificate fields (field_name => value)
253
+ # @option args [String] :serial_number serial number (required for direct)
254
+ # @option args [String] :revocation_outpoint outpoint string (required for direct)
255
+ # @option args [String] :signature certifier signature hex (required for direct)
256
+ # @option args [String] :keyring_revealer pubkey hex or 'certifier' (required for direct)
257
+ # @option args [Hash] :keyring_for_subject field_name => base64 key (required for direct)
258
+ # @return [Hash] the stored certificate
259
+ def acquire_certificate(args, _originator: nil)
260
+ validate_acquire_certificate!(args)
261
+
262
+ cert = if args[:acquisition_protocol] == 'issuance'
263
+ acquire_via_issuance(args)
264
+ else
265
+ acquire_via_direct(args)
266
+ end
267
+
268
+ @storage.store_certificate(cert)
269
+ cert_without_keyring(cert)
270
+ end
271
+
272
+ # Lists identity certificates filtered by certifier and type.
273
+ #
274
+ # @param args [Hash]
275
+ # @option args [Array<String>] :certifiers certifier public keys
276
+ # @option args [Array<String>] :types certificate types
277
+ # @option args [Integer] :limit max results (default 10)
278
+ # @option args [Integer] :offset number to skip (default 0)
279
+ # @return [Hash] { total_certificates:, certificates: [...] }
280
+ def list_certificates(args, _originator: nil)
281
+ raise InvalidParameterError.new('certifiers', 'a non-empty Array') unless args[:certifiers].is_a?(Array) && !args[:certifiers].empty?
282
+ raise InvalidParameterError.new('types', 'a non-empty Array') unless args[:types].is_a?(Array) && !args[:types].empty?
283
+
284
+ query = {
285
+ certifiers: args[:certifiers],
286
+ types: args[:types],
287
+ limit: args[:limit] || 10,
288
+ offset: args[:offset] || 0
289
+ }
290
+ total = @storage.count_certificates(query)
291
+ certs = @storage.find_certificates(query)
292
+ { total_certificates: total, certificates: certs.map { |c| cert_without_keyring(c) } }
293
+ end
294
+
295
+ # Proves select fields of an identity certificate to a verifier.
296
+ #
297
+ # Encrypts each requested field's keyring entry for the verifier using
298
+ # protocol-derived encryption (BRC-2), allowing the verifier to decrypt
299
+ # only the revealed fields.
300
+ #
301
+ # @param args [Hash]
302
+ # @option args [Hash] :certificate the certificate to prove
303
+ # @option args [Array<String>] :fields_to_reveal field names to reveal
304
+ # @option args [String] :verifier verifier public key hex
305
+ # @return [Hash] { keyring_for_verifier: { field_name => Array<Integer> } }
306
+ def prove_certificate(args, _originator: nil)
307
+ cert_arg = args[:certificate]
308
+ fields_to_reveal = args[:fields_to_reveal]
309
+ verifier = args[:verifier]
310
+
311
+ raise InvalidParameterError.new('certificate', 'a Hash') unless cert_arg.is_a?(Hash)
312
+ raise InvalidParameterError.new('fields_to_reveal', 'a non-empty Array') unless fields_to_reveal.is_a?(Array) && !fields_to_reveal.empty?
313
+
314
+ Validators.validate_pub_key_hex!(verifier, 'verifier')
315
+
316
+ # Look up the full certificate (with keyring) from storage
317
+ stored = find_stored_certificate(cert_arg)
318
+ raise WalletError, 'Certificate not found in wallet' unless stored
319
+ raise WalletError, 'Certificate has no keyring' unless stored[:keyring]
320
+
321
+ keyring_for_verifier = {}
322
+ fields_to_reveal.each do |field_name|
323
+ key_value = stored[:keyring][field_name] || stored[:keyring][field_name.to_sym]
324
+ raise WalletError, "Keyring entry not found for field '#{field_name}'" unless key_value
325
+
326
+ # Encrypt the keyring entry for the verifier
327
+ encrypted = encrypt({
328
+ plaintext: key_value.bytes,
329
+ protocol_id: [2, 'certificate field revelation'],
330
+ key_id: "#{cert_arg[:type]} #{cert_arg[:serial_number]} #{field_name}",
331
+ counterparty: verifier
332
+ })
333
+ keyring_for_verifier[field_name] = encrypted[:ciphertext]
334
+ end
335
+
336
+ { keyring_for_verifier: keyring_for_verifier }
337
+ end
338
+
339
+ # Removes a certificate from the wallet.
340
+ #
341
+ # @param args [Hash]
342
+ # @option args [String] :type certificate type
343
+ # @option args [String] :serial_number serial number
344
+ # @option args [String] :certifier certifier public key hex
345
+ # @return [Hash] { relinquished: true }
346
+ def relinquish_certificate(args, _originator: nil)
347
+ deleted = @storage.delete_certificate(
348
+ type: args[:type],
349
+ serial_number: args[:serial_number],
350
+ certifier: args[:certifier]
351
+ )
352
+ raise WalletError, 'Certificate not found' unless deleted
353
+
354
+ { relinquished: true }
355
+ end
356
+
357
+ # Discovers certificates issued to a given identity key.
358
+ #
359
+ # For a local wallet, searches stored certificates where the subject
360
+ # matches the given identity key.
361
+ #
362
+ # @param args [Hash]
363
+ # @option args [String] :identity_key public key hex to search
364
+ # @option args [Integer] :limit max results (default 10)
365
+ # @option args [Integer] :offset number to skip (default 0)
366
+ # @return [Hash] { total_certificates:, certificates: [...] }
367
+ def discover_by_identity_key(args, _originator: nil)
368
+ Validators.validate_pub_key_hex!(args[:identity_key], 'identity_key')
369
+
370
+ query = { subject: args[:identity_key], limit: args[:limit] || 10, offset: args[:offset] || 0 }
371
+ total = @storage.count_certificates(query)
372
+ certs = @storage.find_certificates(query)
373
+ { total_certificates: total, certificates: certs.map { |c| cert_without_keyring(c) } }
374
+ end
375
+
376
+ # Discovers certificates matching specific attribute values.
377
+ #
378
+ # Searches stored certificates where field values match the given
379
+ # attributes. Only searches certificates belonging to this wallet.
380
+ #
381
+ # @param args [Hash]
382
+ # @option args [Hash] :attributes field_name => value pairs to match
383
+ # @option args [Integer] :limit max results (default 10)
384
+ # @option args [Integer] :offset number to skip (default 0)
385
+ # @return [Hash] { total_certificates:, certificates: [...] }
386
+ def discover_by_attributes(args, _originator: nil)
387
+ raise InvalidParameterError.new('attributes', 'a non-empty Hash') unless args[:attributes].is_a?(Hash) && !args[:attributes].empty?
388
+
389
+ query = { attributes: args[:attributes], limit: args[:limit] || 10, offset: args[:offset] || 0 }
390
+ total = @storage.count_certificates(query)
391
+ certs = @storage.find_certificates(query)
392
+ { total_certificates: total, certificates: certs.map { |c| cert_without_keyring(c) } }
393
+ end
394
+
169
395
  private
170
396
 
171
397
  # --- Validation ---
@@ -481,6 +707,97 @@ module BSV
481
707
  spendable: true
482
708
  })
483
709
  end
710
+
711
+ # --- Certificate helpers ---
712
+
713
+ def validate_acquire_certificate!(args)
714
+ raise InvalidParameterError.new('type', 'a String') unless args[:type].is_a?(String)
715
+
716
+ Validators.validate_pub_key_hex!(args[:certifier], 'certifier')
717
+ raise InvalidParameterError.new('fields', 'a Hash') unless args[:fields].is_a?(Hash)
718
+
719
+ protocol = args[:acquisition_protocol]
720
+ raise InvalidParameterError.new('acquisition_protocol', '"direct" or "issuance"') unless %w[direct issuance].include?(protocol)
721
+
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
731
+
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
785
+ end
786
+
787
+ def find_stored_certificate(cert_arg)
788
+ results = @storage.find_certificates({
789
+ certifiers: [cert_arg[:certifier]],
790
+ types: [cert_arg[:type]],
791
+ limit: 10_000
792
+ })
793
+ results.find { |c| c[:serial_number] == cert_arg[:serial_number] }
794
+ end
795
+
796
+ def cert_without_keyring(cert)
797
+ result = cert.dup
798
+ result.delete(:keyring)
799
+ result
800
+ end
484
801
  end
485
802
  end
486
803
  end