bsv-wallet 0.1.1 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6224c4f914180b05509cd15454a22709ebc899b72e6ba3c77fdace1a8005e125
4
- data.tar.gz: 5bbcb406a15c15d02c2181b54cebfdb6b6e27bfd3f56ecf90adc7e3d5e1e2cce
3
+ metadata.gz: 42266cef3c2585982df666f123097ea4fb4edd9f6619aacf37ed50628148ae1f
4
+ data.tar.gz: cec92d7893bba6a8b25439f5fd9c4d203ae7fce0cc2f63370adfc1a01d6bed61
5
5
  SHA512:
6
- metadata.gz: b76c12653644732d2283fcc452a2a5c3a509d776cb510c261fcb13f9a396dba40de40d2dae2bfa321dc1d79df0e8b702c0b62b7b546b4f31588f9e4f14c7f161
7
- data.tar.gz: 502dde3dfd72aea6089939284e39c8d00d1e4d7b52d1dd260de68c37a39c156d0bad88a4cfbd16bbb1d5ec5211110e75c2c836763cffdb10b433685d63301539
6
+ metadata.gz: b45b5c376c8c2b20ec7afffd1f57a15e89d19509ad6afecce8da8f3cdd69b9f7d8af20b5157ca380b8d0a65d9db5dc9a6aed2b8ac1518542775207534e576825
7
+ data.tar.gz: aae7eaec036fd2bd620551645e5a111bccfa39ac948ccb6541f24dc5e187acd1c9458a2576b4bca3adc5e31c0ec73d83af270cee8861d940af9952da429fe36c
@@ -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
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'logger'
6
+
7
+ module BSV
8
+ module Wallet
9
+ # JSON file-backed storage adapter.
10
+ #
11
+ # Persists actions, outputs, and certificates as JSON files in a
12
+ # configurable directory (default: +~/.bsv-wallet/+). Data survives
13
+ # process restarts.
14
+ #
15
+ # Inherits all filtering and pagination logic from {MemoryStore} and
16
+ # adds load-on-init / save-on-mutation.
17
+ #
18
+ # @example Default location
19
+ # store = BSV::Wallet::FileStore.new
20
+ # # Data written to ~/.bsv-wallet/
21
+ #
22
+ # @example Custom directory
23
+ # store = BSV::Wallet::FileStore.new(dir: '/var/lib/my-app/wallet')
24
+ class FileStore < MemoryStore
25
+ DEFAULT_DIR = File.expand_path('~/.bsv-wallet')
26
+
27
+ # @param dir [String] directory for JSON files
28
+ # (default: +~/.bsv-wallet/+ or +BSV_WALLET_DIR+ env var)
29
+ # @param dir [String] directory for JSON files
30
+ # @param logger [Logger, nil] logger for permission warnings (default: Logger to STDERR)
31
+ def initialize(dir: nil, logger: nil)
32
+ super()
33
+ @dir = dir || ENV.fetch('BSV_WALLET_DIR', DEFAULT_DIR)
34
+ @logger = logger || Logger.new($stderr, progname: 'bsv-wallet')
35
+ FileUtils.mkdir_p(@dir, mode: 0o700)
36
+ check_permissions
37
+ load_from_disk
38
+ end
39
+
40
+ # @return [String] the storage directory path
41
+ attr_reader :dir
42
+
43
+ # --- Mutations: delegate to super, then persist ---
44
+
45
+ def store_action(action_data)
46
+ result = super
47
+ save_actions
48
+ result
49
+ end
50
+
51
+ def store_output(output_data)
52
+ result = super
53
+ save_outputs
54
+ result
55
+ end
56
+
57
+ def delete_output(outpoint)
58
+ result = super
59
+ save_outputs if result
60
+ result
61
+ end
62
+
63
+ def store_certificate(cert_data)
64
+ result = super
65
+ save_certificates
66
+ result
67
+ end
68
+
69
+ def delete_certificate(type:, serial_number:, certifier:)
70
+ result = super
71
+ save_certificates if result
72
+ result
73
+ end
74
+
75
+ private
76
+
77
+ def check_permissions
78
+ dir_mode = File.stat(@dir).mode & 0o777
79
+ if dir_mode != 0o700
80
+ @logger.warn("Wallet directory #{@dir} has permissions #{format('%04o', dir_mode)} (expected 0700). " \
81
+ 'Other users may be able to access wallet data.')
82
+ end
83
+
84
+ [actions_path, outputs_path, certificates_path].each do |path|
85
+ next unless File.exist?(path)
86
+
87
+ file_mode = File.stat(path).mode & 0o777
88
+ next if file_mode == 0o600
89
+
90
+ @logger.warn("Wallet file #{path} has permissions #{format('%04o', file_mode)} (expected 0600). " \
91
+ 'Other users may be able to read wallet data.')
92
+ end
93
+ end
94
+
95
+ def actions_path
96
+ File.join(@dir, 'actions.json')
97
+ end
98
+
99
+ def outputs_path
100
+ File.join(@dir, 'outputs.json')
101
+ end
102
+
103
+ def certificates_path
104
+ File.join(@dir, 'certificates.json')
105
+ end
106
+
107
+ def load_from_disk
108
+ @actions = load_file(actions_path)
109
+ @outputs = load_file(outputs_path)
110
+ @certificates = load_file(certificates_path)
111
+ end
112
+
113
+ def load_file(path)
114
+ return [] unless File.exist?(path)
115
+
116
+ data = JSON.parse(File.read(path))
117
+ return [] unless data.is_a?(Array)
118
+
119
+ # Symbolise top-level keys for consistency with MemoryStore
120
+ data.map { |entry| symbolise_keys(entry) }
121
+ rescue JSON::ParserError
122
+ []
123
+ end
124
+
125
+ def save_actions
126
+ write_file(actions_path, @actions)
127
+ end
128
+
129
+ def save_outputs
130
+ write_file(outputs_path, @outputs)
131
+ end
132
+
133
+ def save_certificates
134
+ write_file(certificates_path, @certificates)
135
+ end
136
+
137
+ def write_file(path, data)
138
+ json = JSON.pretty_generate(stringify_keys_deep(data))
139
+ tmp = "#{path}.tmp"
140
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(json) }
141
+ File.rename(tmp, path)
142
+ end
143
+
144
+ def symbolise_keys(hash)
145
+ return hash unless hash.is_a?(Hash)
146
+
147
+ hash.each_with_object({}) do |(k, v), result|
148
+ result[k.to_sym] = case v
149
+ when Hash then symbolise_keys(v)
150
+ when Array then v.map { |e| e.is_a?(Hash) ? symbolise_keys(e) : e }
151
+ else v
152
+ end
153
+ end
154
+ end
155
+
156
+ def stringify_keys_deep(obj)
157
+ case obj
158
+ when Hash
159
+ obj.each_with_object({}) do |(k, v), result|
160
+ result[k.to_s] = stringify_keys_deep(v)
161
+ end
162
+ when Array
163
+ obj.map { |e| stringify_keys_deep(e) }
164
+ else
165
+ obj
166
+ end
167
+ end
168
+ end
169
+ end
170
+ 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.2.0'
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,24 @@ 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
- # @param storage [StorageAdapter] persistence adapter (default: MemoryStore)
31
- def initialize(key, storage: MemoryStore.new)
39
+ # @param storage [StorageAdapter] persistence adapter (default: FileStore).
40
+ # Use +storage: MemoryStore.new+ for tests.
41
+ # @param network [String] 'mainnet' (default) or 'testnet'
42
+ # @param chain_provider [ChainProvider] blockchain data provider (default: NullChainProvider)
43
+ # @param http_client [#request, nil] injectable HTTP client for certificate issuance
44
+ def initialize(key, storage: FileStore.new, network: 'mainnet', chain_provider: NullChainProvider.new, http_client: nil)
32
45
  super(key)
33
46
  @storage = storage
47
+ @network = network
48
+ @chain_provider = chain_provider
49
+ @http_client = http_client
34
50
  @pending = {}
35
51
  end
36
52
 
@@ -166,6 +182,217 @@ module BSV
166
182
  { accepted: true }
167
183
  end
168
184
 
185
+ # --- Blockchain & Network Data ---
186
+
187
+ # Returns the current blockchain height from the chain provider.
188
+ #
189
+ # @param _args [Hash] unused (empty hash)
190
+ # @return [Hash] { height: Integer }
191
+ def get_height(_args = {}, _originator: nil)
192
+ { height: @chain_provider.get_height }
193
+ end
194
+
195
+ # Returns the block header at the given height from the chain provider.
196
+ #
197
+ # @param args [Hash]
198
+ # @option args [Integer] :height block height
199
+ # @return [Hash] { header: String } 80-byte hex-encoded block header
200
+ def get_header_for_height(args, _originator: nil)
201
+ raise InvalidParameterError.new('height', 'a positive Integer') unless args[:height].is_a?(Integer) && args[:height].positive?
202
+
203
+ { header: @chain_provider.get_header(args[:height]) }
204
+ end
205
+
206
+ # Returns the network this wallet is configured for.
207
+ #
208
+ # @param _args [Hash] unused (empty hash)
209
+ # @return [Hash] { network: String } 'mainnet' or 'testnet'
210
+ def get_network(_args = {}, _originator: nil)
211
+ { network: @network }
212
+ end
213
+
214
+ # Returns the wallet version string.
215
+ #
216
+ # @param _args [Hash] unused (empty hash)
217
+ # @return [Hash] { version: String } in vendor-major.minor.patch format
218
+ def get_version(_args = {}, _originator: nil)
219
+ { version: "bsv-wallet-#{BSV::WalletInterface::VERSION}" }
220
+ end
221
+
222
+ # --- Authentication ---
223
+
224
+ # Checks whether the user is authenticated.
225
+ # For local wallets with a private key, this is always true.
226
+ #
227
+ # @param _args [Hash] unused (empty hash)
228
+ # @return [Hash] { authenticated: Boolean }
229
+ def is_authenticated(_args = {}, _originator: nil)
230
+ { authenticated: true }
231
+ end
232
+
233
+ # Waits until the user is authenticated.
234
+ # For local wallets, returns immediately.
235
+ #
236
+ # @param _args [Hash] unused (empty hash)
237
+ # @return [Hash] { authenticated: true }
238
+ def wait_for_authentication(_args = {}, _originator: nil)
239
+ { authenticated: true }
240
+ end
241
+
242
+ # --- Identity and Certificate Management ---
243
+
244
+ # Acquires an identity certificate via direct storage.
245
+ #
246
+ # The 'issuance' protocol (which requires HTTP to a certifier URL) is
247
+ # not yet supported and raises {UnsupportedActionError}.
248
+ #
249
+ # @param args [Hash]
250
+ # @option args [String] :type certificate type (base64)
251
+ # @option args [String] :certifier certifier public key hex
252
+ # @option args [String] :acquisition_protocol 'direct' or 'issuance'
253
+ # @option args [Hash] :fields certificate fields (field_name => value)
254
+ # @option args [String] :serial_number serial number (required for direct)
255
+ # @option args [String] :revocation_outpoint outpoint string (required for direct)
256
+ # @option args [String] :signature certifier signature hex (required for direct)
257
+ # @option args [String] :keyring_revealer pubkey hex or 'certifier' (required for direct)
258
+ # @option args [Hash] :keyring_for_subject field_name => base64 key (required for direct)
259
+ # @return [Hash] the stored certificate
260
+ def acquire_certificate(args, _originator: nil)
261
+ validate_acquire_certificate!(args)
262
+
263
+ cert = if args[:acquisition_protocol] == 'issuance'
264
+ acquire_via_issuance(args)
265
+ else
266
+ acquire_via_direct(args)
267
+ end
268
+
269
+ @storage.store_certificate(cert)
270
+ cert_without_keyring(cert)
271
+ end
272
+
273
+ # Lists identity certificates filtered by certifier and type.
274
+ #
275
+ # @param args [Hash]
276
+ # @option args [Array<String>] :certifiers certifier public keys
277
+ # @option args [Array<String>] :types certificate types
278
+ # @option args [Integer] :limit max results (default 10)
279
+ # @option args [Integer] :offset number to skip (default 0)
280
+ # @return [Hash] { total_certificates:, certificates: [...] }
281
+ def list_certificates(args, _originator: nil)
282
+ raise InvalidParameterError.new('certifiers', 'a non-empty Array') unless args[:certifiers].is_a?(Array) && !args[:certifiers].empty?
283
+ raise InvalidParameterError.new('types', 'a non-empty Array') unless args[:types].is_a?(Array) && !args[:types].empty?
284
+
285
+ query = {
286
+ certifiers: args[:certifiers],
287
+ types: args[:types],
288
+ limit: args[:limit] || 10,
289
+ offset: args[:offset] || 0
290
+ }
291
+ total = @storage.count_certificates(query)
292
+ certs = @storage.find_certificates(query)
293
+ { total_certificates: total, certificates: certs.map { |c| cert_without_keyring(c) } }
294
+ end
295
+
296
+ # Proves select fields of an identity certificate to a verifier.
297
+ #
298
+ # Encrypts each requested field's keyring entry for the verifier using
299
+ # protocol-derived encryption (BRC-2), allowing the verifier to decrypt
300
+ # only the revealed fields.
301
+ #
302
+ # @param args [Hash]
303
+ # @option args [Hash] :certificate the certificate to prove
304
+ # @option args [Array<String>] :fields_to_reveal field names to reveal
305
+ # @option args [String] :verifier verifier public key hex
306
+ # @return [Hash] { keyring_for_verifier: { field_name => Array<Integer> } }
307
+ def prove_certificate(args, _originator: nil)
308
+ cert_arg = args[:certificate]
309
+ fields_to_reveal = args[:fields_to_reveal]
310
+ verifier = args[:verifier]
311
+
312
+ raise InvalidParameterError.new('certificate', 'a Hash') unless cert_arg.is_a?(Hash)
313
+ raise InvalidParameterError.new('fields_to_reveal', 'a non-empty Array') unless fields_to_reveal.is_a?(Array) && !fields_to_reveal.empty?
314
+
315
+ Validators.validate_pub_key_hex!(verifier, 'verifier')
316
+
317
+ # Look up the full certificate (with keyring) from storage
318
+ stored = find_stored_certificate(cert_arg)
319
+ raise WalletError, 'Certificate not found in wallet' unless stored
320
+ raise WalletError, 'Certificate has no keyring' unless stored[:keyring]
321
+
322
+ keyring_for_verifier = {}
323
+ fields_to_reveal.each do |field_name|
324
+ key_value = stored[:keyring][field_name] || stored[:keyring][field_name.to_sym]
325
+ raise WalletError, "Keyring entry not found for field '#{field_name}'" unless key_value
326
+
327
+ # Encrypt the keyring entry for the verifier
328
+ encrypted = encrypt({
329
+ plaintext: key_value.bytes,
330
+ protocol_id: [2, 'certificate field revelation'],
331
+ key_id: "#{cert_arg[:type]} #{cert_arg[:serial_number]} #{field_name}",
332
+ counterparty: verifier
333
+ })
334
+ keyring_for_verifier[field_name] = encrypted[:ciphertext]
335
+ end
336
+
337
+ { keyring_for_verifier: keyring_for_verifier }
338
+ end
339
+
340
+ # Removes a certificate from the wallet.
341
+ #
342
+ # @param args [Hash]
343
+ # @option args [String] :type certificate type
344
+ # @option args [String] :serial_number serial number
345
+ # @option args [String] :certifier certifier public key hex
346
+ # @return [Hash] { relinquished: true }
347
+ def relinquish_certificate(args, _originator: nil)
348
+ deleted = @storage.delete_certificate(
349
+ type: args[:type],
350
+ serial_number: args[:serial_number],
351
+ certifier: args[:certifier]
352
+ )
353
+ raise WalletError, 'Certificate not found' unless deleted
354
+
355
+ { relinquished: true }
356
+ end
357
+
358
+ # Discovers certificates issued to a given identity key.
359
+ #
360
+ # For a local wallet, searches stored certificates where the subject
361
+ # matches the given identity key.
362
+ #
363
+ # @param args [Hash]
364
+ # @option args [String] :identity_key public key hex to search
365
+ # @option args [Integer] :limit max results (default 10)
366
+ # @option args [Integer] :offset number to skip (default 0)
367
+ # @return [Hash] { total_certificates:, certificates: [...] }
368
+ def discover_by_identity_key(args, _originator: nil)
369
+ Validators.validate_pub_key_hex!(args[:identity_key], 'identity_key')
370
+
371
+ query = { subject: args[:identity_key], limit: args[:limit] || 10, offset: args[:offset] || 0 }
372
+ total = @storage.count_certificates(query)
373
+ certs = @storage.find_certificates(query)
374
+ { total_certificates: total, certificates: certs.map { |c| cert_without_keyring(c) } }
375
+ end
376
+
377
+ # Discovers certificates matching specific attribute values.
378
+ #
379
+ # Searches stored certificates where field values match the given
380
+ # attributes. Only searches certificates belonging to this wallet.
381
+ #
382
+ # @param args [Hash]
383
+ # @option args [Hash] :attributes field_name => value pairs to match
384
+ # @option args [Integer] :limit max results (default 10)
385
+ # @option args [Integer] :offset number to skip (default 0)
386
+ # @return [Hash] { total_certificates:, certificates: [...] }
387
+ def discover_by_attributes(args, _originator: nil)
388
+ raise InvalidParameterError.new('attributes', 'a non-empty Hash') unless args[:attributes].is_a?(Hash) && !args[:attributes].empty?
389
+
390
+ query = { attributes: args[:attributes], limit: args[:limit] || 10, offset: args[:offset] || 0 }
391
+ total = @storage.count_certificates(query)
392
+ certs = @storage.find_certificates(query)
393
+ { total_certificates: total, certificates: certs.map { |c| cert_without_keyring(c) } }
394
+ end
395
+
169
396
  private
170
397
 
171
398
  # --- Validation ---
@@ -481,6 +708,97 @@ module BSV
481
708
  spendable: true
482
709
  })
483
710
  end
711
+
712
+ # --- Certificate helpers ---
713
+
714
+ def validate_acquire_certificate!(args)
715
+ raise InvalidParameterError.new('type', 'a String') unless args[:type].is_a?(String)
716
+
717
+ Validators.validate_pub_key_hex!(args[:certifier], 'certifier')
718
+ raise InvalidParameterError.new('fields', 'a Hash') unless args[:fields].is_a?(Hash)
719
+
720
+ protocol = args[:acquisition_protocol]
721
+ raise InvalidParameterError.new('acquisition_protocol', '"direct" or "issuance"') unless %w[direct issuance].include?(protocol)
722
+
723
+ if protocol == 'direct'
724
+ raise InvalidParameterError.new('serial_number', 'present for direct acquisition') unless args[:serial_number]
725
+ raise InvalidParameterError.new('revocation_outpoint', 'present for direct acquisition') unless args[:revocation_outpoint]
726
+ raise InvalidParameterError.new('signature', 'present for direct acquisition') unless args[:signature]
727
+ raise InvalidParameterError.new('keyring_for_subject', 'a Hash for direct acquisition') unless args[:keyring_for_subject].is_a?(Hash)
728
+ elsif protocol == 'issuance'
729
+ raise InvalidParameterError.new('certifier_url', 'present for issuance acquisition') unless args[:certifier_url].is_a?(String)
730
+ end
731
+ end
732
+
733
+ def acquire_via_direct(args)
734
+ {
735
+ type: args[:type],
736
+ subject: @key_deriver.identity_key,
737
+ serial_number: args[:serial_number],
738
+ certifier: args[:certifier],
739
+ revocation_outpoint: args[:revocation_outpoint],
740
+ signature: args[:signature],
741
+ fields: args[:fields],
742
+ keyring: args[:keyring_for_subject]
743
+ }
744
+ end
745
+
746
+ def acquire_via_issuance(args)
747
+ uri = URI(args[:certifier_url])
748
+ request = Net::HTTP::Post.new(uri)
749
+ request['Content-Type'] = 'application/json'
750
+ request.body = JSON.generate({
751
+ type: args[:type],
752
+ subject: @key_deriver.identity_key,
753
+ certifier: args[:certifier],
754
+ fields: args[:fields]
755
+ })
756
+
757
+ response = execute_http(uri, request)
758
+ code = response.code.to_i
759
+
760
+ raise WalletError, "Certificate issuance failed: HTTP #{code}" unless (200..299).cover?(code)
761
+
762
+ body = JSON.parse(response.body)
763
+
764
+ {
765
+ type: body['type'] || args[:type],
766
+ subject: @key_deriver.identity_key,
767
+ serial_number: body['serialNumber'],
768
+ certifier: args[:certifier],
769
+ revocation_outpoint: body['revocationOutpoint'],
770
+ signature: body['signature'],
771
+ fields: body['fields'] || args[:fields],
772
+ keyring: body['keyringForSubject']
773
+ }
774
+ rescue JSON::ParserError
775
+ raise WalletError, 'Certificate issuance failed: invalid JSON response'
776
+ end
777
+
778
+ def execute_http(uri, request)
779
+ if @http_client
780
+ @http_client.request(uri, request)
781
+ else
782
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
783
+ http.request(request)
784
+ end
785
+ end
786
+ end
787
+
788
+ def find_stored_certificate(cert_arg)
789
+ results = @storage.find_certificates({
790
+ certifiers: [cert_arg[:certifier]],
791
+ types: [cert_arg[:type]],
792
+ limit: 10_000
793
+ })
794
+ results.find { |c| c[:serial_number] == cert_arg[:serial_number] }
795
+ end
796
+
797
+ def cert_without_keyring(cert)
798
+ result = cert.dup
799
+ result.delete(:keyring)
800
+ result
801
+ end
484
802
  end
485
803
  end
486
804
  end