bsv-sdk 0.4.0 → 0.5.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: 5d32b117209fc6509e5020811725c5b5bb5e776c57fbd0f3cb0afa8cf3cc12ca
4
- data.tar.gz: 2fa913c8e3fe270b53a1748e2ff4bdb8055945b625a9e3075eb293b78d581fe4
3
+ metadata.gz: e6f6657530a3e07510c21eeb919dd276a99b680a825b1d63c8e3c05731f36107
4
+ data.tar.gz: 0e44275e673ea30e56d4996d1f21e98e8bb22803815da60ab1f32fefcc61b327
5
5
  SHA512:
6
- metadata.gz: 82e8ca0cf9e266d7c80fe8be42be40450e5a3495db083f90aa098aaf7ff0186ecc4a63b3a1efd9b7447738d857b930f655be291ad098a83ef7a6d0095df0b4d0
7
- data.tar.gz: 103b3246361a57c3b1161c789c097c147b6c7070781094587f59bdee6caf3141bb5b22934a89e1bfdd4e514b4f45eb2edd1ae218fc92f23e2d520a3a56f72113
6
+ metadata.gz: 1e3bbb1cf676054dbf64cfcb94d3edd33e290f9d64734ec828ed1673b97f65ddb25b83e087e6e592ca036d1699a4ded46c974491f6a828b975b2c739e57e214e
7
+ data.tar.gz: 0de23da3bb0755d7dcf515f75638286479828c6dac0957532aedbfe28abad6bf8efee8c13b8092e5d05826559fdb16f7c102b84c242885b089022f90b638de05
data/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-04-04
9
+
10
+ ### Added
11
+
12
+ #### Overlay
13
+
14
+ - **SHIP/SLAP overlay services** — `BSV::Overlay` module for topic-based transaction broadcasting and service discovery.
15
+ - `TopicBroadcaster` (aliased as `SHIPBroadcaster`) — broadcasts tagged BEEF to topic-interested hosts with configurable acknowledgement modes (all/any/specific hosts) and STEAK response parsing.
16
+ - `LookupResolver` — discovers competent hosts via SLAP trackers, queries in parallel, aggregates and deduplicates results. TTL-based host caching.
17
+ - `HostReputationTracker` — EWMA latency scoring with exponential backoff, DNS error escalation, thread-safe. Optional persistence via injectable store adapter.
18
+ - `AdminTokenTemplate` — decode/lock/unlock for SHIP/SLAP advertisement PushDrop tokens with BRC-42 wallet key derivation.
19
+ - Abstract base classes (`LookupFacilitator`, `BroadcastFacilitator`) with default HTTPS implementations — all dependencies injectable via constructor.
20
+ - SSRF protection for SLAP-discovered domains (private/loopback IP rejection).
21
+
22
+ #### Identity
23
+
24
+ - **Identity client** — `BSV::Identity` module for certificate-based identity resolution and publication.
25
+ - `Client` — resolve identities by key or attributes, publicly reveal certificate fields on-chain, revoke revelations. All overlay dependencies injectable.
26
+ - `IdentityParser` — converts identity certificates to `DisplayableIdentity`, handling all 9 known types (xCert, discordCert, phoneCert, emailCert, identiCert, registrant, coolCert, anyone, self) plus generic field-name heuristic fallback.
27
+ - Types: `DisplayableIdentity`, `IdentityCertificate`, `CertifierInfo`, `ClientOptions` with cross-SDK constant alignment.
28
+ - Certificate verifier injectable with safe-by-default (raises `NotImplementedError`).
29
+
30
+ #### Script
31
+
32
+ - **PushDropTemplate** — reusable wallet-aware PushDrop template with BRC-42 key derivation, optional ECDSA field signing, and P2PKH lock/unlock. Used by Identity client, reusable for ContactsManager and other PushDrop-based features.
33
+
34
+ ### Fixed
35
+
36
+ - `ProtoWallet` parameter name mismatch: `_originator:` → `originator:` to match the `WalletInterface` contract.
37
+
8
38
  ## [0.4.0] - 2026-04-01
9
39
 
10
40
  ### Added
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module BSV
6
+ module Identity
7
+ # Client for resolving and publishing identity information on the BSV overlay network.
8
+ #
9
+ # Wraps wallet discovery and overlay broadcasting to provide a high-level
10
+ # interface for the BSV Identity protocol:
11
+ #
12
+ # - Resolve identities by key or attributes via {#resolve_by_identity_key} and
13
+ # {#resolve_by_attributes}.
14
+ # - Publicly reveal certificate fields on-chain via {#publicly_reveal_attributes}.
15
+ # - Revoke an existing revelation via {#revoke_certificate_revelation}.
16
+ #
17
+ # All overlay dependencies (broadcaster, resolver) are injectable for testing.
18
+ #
19
+ # @example Resolve an identity
20
+ # client = BSV::Identity::Client.new(wallet: my_wallet)
21
+ # identities = client.resolve_by_identity_key(identity_key: pubkey_hex)
22
+ #
23
+ # @example Publicly reveal certificate fields
24
+ # client = BSV::Identity::Client.new(wallet: my_wallet, broadcaster: my_broadcaster)
25
+ # result = client.publicly_reveal_attributes(certificate, fields_to_reveal: ['email'])
26
+ class Client
27
+ # Default certificate verifier — raises NotImplementedError because certificate
28
+ # verification depends on BSV::Auth::Certificate, which is a separate HLR.
29
+ DEFAULT_VERIFIER = lambda do |_certificate|
30
+ raise NotImplementedError,
31
+ 'Certificate verification requires BSV::Auth::Certificate (not yet implemented)'
32
+ end
33
+
34
+ # @param wallet [#discover_by_identity_key, #discover_by_attributes,
35
+ # #prove_certificate, #create_action, #get_network] BRC-100 wallet interface
36
+ # @param options [ClientOptions, nil] identity protocol options (default: ClientOptions::DEFAULT)
37
+ # @param originator [String, nil] optional FQDN of the originating application
38
+ # @param certificate_verifier [#call, nil] callable that verifies a certificate;
39
+ # defaults to a lambda that raises NotImplementedError
40
+ # @param broadcaster [BSV::Overlay::TopicBroadcaster, nil] injectable broadcaster;
41
+ # built from the wallet's network preset when nil
42
+ # @param resolver [BSV::Overlay::LookupResolver, nil] injectable lookup resolver;
43
+ # built from the wallet's network preset when nil
44
+ def initialize(
45
+ wallet:,
46
+ options: nil,
47
+ originator: nil,
48
+ certificate_verifier: nil,
49
+ broadcaster: nil,
50
+ resolver: nil
51
+ )
52
+ @wallet = wallet
53
+ @options = options || default_options
54
+ @originator = originator
55
+ @certificate_verifier = certificate_verifier || DEFAULT_VERIFIER
56
+ @broadcaster = broadcaster
57
+ @resolver = resolver
58
+ end
59
+
60
+ # Resolves displayable identities issued to a given identity key.
61
+ #
62
+ # Delegates to the wallet's +discover_by_identity_key+ and maps each
63
+ # returned certificate through {IdentityParser.parse}.
64
+ #
65
+ # @param identity_key [String] compressed public key hex
66
+ # @param limit [Integer, nil] maximum number of certificates to return
67
+ # @param offset [Integer, nil] number of certificates to skip
68
+ # @return [Array<DisplayableIdentity>]
69
+ def resolve_by_identity_key(identity_key:, limit: nil, offset: nil)
70
+ args = { identity_key: identity_key }
71
+ args[:limit] = limit unless limit.nil?
72
+ args[:offset] = offset unless offset.nil?
73
+
74
+ result = @wallet.discover_by_identity_key(args, originator: @originator)
75
+ parse_certificates(result)
76
+ end
77
+
78
+ # Resolves displayable identities matching specific certificate attribute values.
79
+ #
80
+ # Delegates to the wallet's +discover_by_attributes+ and maps each
81
+ # returned certificate through {IdentityParser.parse}.
82
+ #
83
+ # @param attributes [Hash] field name/value pairs to match
84
+ # @param limit [Integer, nil] maximum number of certificates to return
85
+ # @param offset [Integer, nil] number of certificates to skip
86
+ # @return [Array<DisplayableIdentity>]
87
+ def resolve_by_attributes(attributes:, limit: nil, offset: nil)
88
+ args = { attributes: attributes }
89
+ args[:limit] = limit unless limit.nil?
90
+ args[:offset] = offset unless offset.nil?
91
+
92
+ result = @wallet.discover_by_attributes(args, originator: @originator)
93
+ parse_certificates(result)
94
+ end
95
+
96
+ # Publicly reveals selected fields from a certificate by creating an
97
+ # on-chain identity token and broadcasting it to the overlay network.
98
+ #
99
+ # The certificate is first optionally verified via the injected verifier,
100
+ # then the wallet proves the selected fields to the "anyone" verifier
101
+ # (PrivateKey(1) public key). A PushDrop locking script is constructed from
102
+ # the certificate JSON plus keyring, and the resulting transaction is
103
+ # broadcast to +tm_identity+.
104
+ #
105
+ # @param certificate [Hash] wallet certificate hash
106
+ # @param fields_to_reveal [Array<String>] field names to include in the revelation
107
+ # @return [BSV::Overlay::OverlayBroadcastResult]
108
+ # @raise [ArgumentError] if the certificate has no fields or fields_to_reveal is empty
109
+ # @raise [RuntimeError] if certificate verification fails or create_action returns no tx
110
+ def publicly_reveal_attributes(certificate, fields_to_reveal:)
111
+ fields = certificate[:fields] || certificate['fields'] || {}
112
+ raise ArgumentError, 'Public reveal failed: Certificate has no fields to reveal!' if fields.empty?
113
+ raise ArgumentError, 'Public reveal failed: You must reveal at least one field!' if fields_to_reveal.empty?
114
+
115
+ verify_certificate(certificate)
116
+
117
+ # Prove the certificate to the "anyone" verifier (PrivateKey(1) public key)
118
+ anyone_pubkey = BSV::Script::PushDropTemplate::GENERATOR_PUBKEY_HEX
119
+ prove_result = @wallet.prove_certificate(
120
+ { certificate: certificate, fields_to_reveal: fields_to_reveal, verifier: anyone_pubkey },
121
+ originator: @originator
122
+ )
123
+ keyring = prove_result[:keyring_for_verifier]
124
+
125
+ # Build the PushDrop payload with ONLY the revealed fields — never
126
+ # broadcast the full certificate (encrypted values for unrevealed
127
+ # fields must not be written on-chain).
128
+ revealed_fields = fields_to_reveal.each_with_object({}) do |name, h|
129
+ h[name] = fields[name.to_s] || fields[name.to_sym]
130
+ end
131
+
132
+ payload = JSON.generate(
133
+ type: certificate[:type] || certificate['type'],
134
+ serialNumber: certificate[:serial_number] || certificate['serial_number'] || certificate[:serialNumber] || certificate['serialNumber'],
135
+ subject: certificate[:subject] || certificate['subject'],
136
+ certifier: certificate[:certifier] || certificate['certifier'],
137
+ revocationOutpoint: certificate[:revocation_outpoint] || certificate['revocation_outpoint'] || certificate[:revocationOutpoint] || certificate['revocationOutpoint'],
138
+ fields: revealed_fields,
139
+ keyring: keyring
140
+ )
141
+
142
+ # Construct the locking script via PushDropTemplate
143
+ template = BSV::Script::PushDropTemplate.new(wallet: @wallet, originator: @originator)
144
+ locking_script = template.lock(
145
+ fields: [payload],
146
+ protocol_id: @options.protocol_id,
147
+ key_id: @options.key_id,
148
+ counterparty: 'anyone'
149
+ )
150
+
151
+ # Create the transaction
152
+ create_result = @wallet.create_action(
153
+ {
154
+ description: 'Create a new Identity Token',
155
+ outputs: [
156
+ {
157
+ satoshis: @options.token_amount,
158
+ locking_script: locking_script.to_hex,
159
+ output_description: 'Identity Token'
160
+ }
161
+ ],
162
+ options: { randomize_outputs: false }
163
+ },
164
+ originator: @originator
165
+ )
166
+
167
+ raise 'Public reveal failed: failed to create action!' if create_result[:tx].nil?
168
+
169
+ tx = BSV::Transaction::Transaction.from_beef(create_result[:tx])
170
+ broadcaster_for_action.broadcast(tx)
171
+ end
172
+
173
+ # Revokes a publicly revealed certificate by spending the identity token.
174
+ #
175
+ # Queries the +ls_identity+ lookup service for the revelation output identified
176
+ # by +serial_number+, then creates a spending transaction via the wallet and
177
+ # broadcasts it to +tm_identity+.
178
+ #
179
+ # @param serial_number [String] Base64 serial number of the certificate revelation to revoke
180
+ # @return [void]
181
+ # @raise [RuntimeError] if the revelation cannot be found or the transaction cannot be created
182
+ def revoke_certificate_revelation(serial_number)
183
+ question = BSV::Overlay::LookupQuestion.new(
184
+ service: Constants::SERVICE,
185
+ query: { serial_number: serial_number }
186
+ )
187
+ answer = resolver_for_action.query(question)
188
+
189
+ raise 'Revoke failed: could not find revelation output' unless answer.type == 'output-list'
190
+ raise 'Revoke failed: no outputs found for serial number' if answer.outputs.empty?
191
+
192
+ output = answer.outputs.first
193
+ beef_bytes = output['beef'] || output[:beef]
194
+ raise 'Revoke failed: overlay response missing BEEF data' unless beef_bytes
195
+
196
+ raw_idx = output['outputIndex'] || output[:output_index]
197
+ raise 'Revoke failed: overlay response missing outputIndex' if raw_idx.nil?
198
+
199
+ output_idx = raw_idx.to_i
200
+ raise 'Revoke failed: invalid outputIndex from overlay' if output_idx.negative?
201
+
202
+ beef = BSV::Transaction::Beef.from_binary(beef_bytes)
203
+ tx = beef.transactions.last&.transaction
204
+ raise 'Revoke failed: no transaction found in BEEF' unless tx
205
+ raise 'Revoke failed: outputIndex out of range' if output_idx >= tx.outputs.length
206
+ txid = tx.txid_hex
207
+ outpoint = "#{txid}.#{output_idx}"
208
+
209
+ # Create a spending transaction; use unlocking_script_length so the wallet
210
+ # produces a signable transaction that can then be signed and broadcast.
211
+ create_result = @wallet.create_action(
212
+ {
213
+ description: 'Spend certificate revelation token',
214
+ input_beef: beef_bytes,
215
+ inputs: [
216
+ {
217
+ input_description: 'Revelation token',
218
+ outpoint: outpoint,
219
+ unlocking_script_length: BSV::Script::PushDropTemplate::Unlocker::ESTIMATED_LENGTH
220
+ }
221
+ ],
222
+ options: { randomize_outputs: false, no_send: true }
223
+ },
224
+ originator: @originator
225
+ )
226
+
227
+ raise 'Revoke failed: failed to create signable transaction' if create_result[:signable_transaction].nil?
228
+
229
+ signable = create_result[:signable_transaction]
230
+ partial_tx = BSV::Transaction::Transaction.from_beef(signable[:tx])
231
+
232
+ # Unlock via PushDrop
233
+ template = BSV::Script::PushDropTemplate.new(wallet: @wallet, originator: @originator)
234
+ unlocker = template.unlock(
235
+ protocol_id: @options.protocol_id,
236
+ key_id: @options.key_id,
237
+ counterparty: 'anyone'
238
+ )
239
+ # Sign the first (and only) input in the spending transaction.
240
+ # output_idx is the index in the *source* tx; the spending tx input is always 0.
241
+ spending_input_idx = 0
242
+ unlocking_script = unlocker.sign(partial_tx, spending_input_idx)
243
+
244
+ sign_result = @wallet.sign_action(
245
+ {
246
+ reference: signable[:reference],
247
+ spends: { spending_input_idx => { unlocking_script: unlocking_script.to_hex } },
248
+ options: { no_send: true }
249
+ },
250
+ originator: @originator
251
+ )
252
+
253
+ raise 'Revoke failed: failed to sign transaction' if sign_result[:tx].nil?
254
+
255
+ signed_tx = BSV::Transaction::Transaction.from_beef(sign_result[:tx])
256
+ broadcaster_for_action.broadcast(signed_tx)
257
+ end
258
+
259
+ private
260
+
261
+ # Maps an array of raw certificate hashes to DisplayableIdentity objects.
262
+ #
263
+ # Each certificate hash (as returned by the wallet's discovery methods) is
264
+ # wrapped in an IdentityCertificate and parsed via IdentityParser.
265
+ #
266
+ # @param result [Hash, nil] wallet discovery result with :certificates key
267
+ # @return [Array<DisplayableIdentity>]
268
+ def parse_certificates(result)
269
+ certs = result && result[:certificates]
270
+ return [] if certs.nil? || certs.empty?
271
+
272
+ certs.map { |cert| IdentityParser.parse(wrap_certificate(cert)) }
273
+ end
274
+
275
+ # Wraps a raw certificate hash in an IdentityCertificate, extracting
276
+ # decrypted_fields and certifier_info where present.
277
+ #
278
+ # @param cert [Hash] raw certificate hash from the wallet
279
+ # @return [IdentityCertificate]
280
+ def wrap_certificate(cert)
281
+ decrypted = cert[:decrypted_fields] || cert['decrypted_fields'] || {}
282
+ certifier_h = cert[:certifier_info] || cert['certifier_info'] || {}
283
+
284
+ certifier_info = if certifier_h && !certifier_h.empty?
285
+ CertifierInfo.new(
286
+ name: certifier_h[:name] || certifier_h['name'] || '',
287
+ icon_url: certifier_h[:icon_url] || certifier_h['icon_url']
288
+ )
289
+ end
290
+
291
+ IdentityCertificate.new(
292
+ certificate: cert,
293
+ decrypted_fields: decrypted,
294
+ certifier_info: certifier_info
295
+ )
296
+ end
297
+
298
+ # Calls the injected certificate verifier, wrapping any errors in a
299
+ # standardised RuntimeError message.
300
+ #
301
+ # @param certificate [Hash]
302
+ # @raise [RuntimeError] if verification fails
303
+ def verify_certificate(certificate)
304
+ @certificate_verifier.call(certificate)
305
+ rescue NotImplementedError
306
+ raise
307
+ rescue StandardError => e
308
+ raise "Public reveal failed: Certificate verification failed! (#{e.message})"
309
+ end
310
+
311
+ # Returns the broadcaster to use for overlay actions, building one from
312
+ # the wallet's network when no injectable broadcaster was provided.
313
+ #
314
+ # @return [BSV::Overlay::TopicBroadcaster]
315
+ def broadcaster_for_action
316
+ return @broadcaster if @broadcaster
317
+
318
+ network = wallet_network
319
+ BSV::Overlay::TopicBroadcaster.new(
320
+ [Constants::TOPIC],
321
+ network_preset: network
322
+ )
323
+ end
324
+
325
+ # Returns the lookup resolver to use for overlay queries, building one from
326
+ # the wallet's network when no injectable resolver was provided.
327
+ #
328
+ # @return [BSV::Overlay::LookupResolver]
329
+ def resolver_for_action
330
+ return @resolver if @resolver
331
+
332
+ network = wallet_network
333
+ BSV::Overlay::LookupResolver.new(network_preset: network)
334
+ end
335
+
336
+ # Queries the wallet for the current network and converts the string to a symbol.
337
+ #
338
+ # @return [Symbol] :mainnet or :testnet
339
+ def wallet_network
340
+ result = @wallet.get_network({}, originator: @originator)
341
+ net_str = result[:network] || result['network'] || 'mainnet'
342
+ net_str.to_sym
343
+ end
344
+
345
+ # Returns the default ClientOptions.
346
+ #
347
+ # @return [ClientOptions]
348
+ def default_options
349
+ ClientOptions::DEFAULT
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Identity
5
+ # Protocol constants and well-known values for the BSV Identity system.
6
+ module Constants
7
+ # Overlay topic name for identity transactions.
8
+ TOPIC = 'tm_identity'
9
+
10
+ # Lookup service identifier for identity queries.
11
+ SERVICE = 'ls_identity'
12
+
13
+ # Maps symbolic names to their Base64-encoded 32-byte certificate type identifiers.
14
+ #
15
+ # Values are byte-exact matches with the TS SDK (ts-sdk/src/identity/types/index.ts)
16
+ # and Go SDK (go-sdk/identity/types.go).
17
+ KNOWN_IDENTITY_TYPES = {
18
+ x_cert: 'vdDWvftf1H+5+ZprUw123kjHlywH+v20aPQTuXgMpNc=',
19
+ discord_cert: '2TgqRC35B1zehGmB21xveZNc7i5iqHc0uxMb+1NMPW4=',
20
+ phone_cert: 'mffUklUzxbHr65xLohn0hRL0Tq2GjW1GYF/OPfzqJ6A=',
21
+ email_cert: 'exOl3KM0dIJ04EW5pZgbZmPag6MdJXd3/a1enmUU/BA=',
22
+ identi_cert: 'z40BOInXkI8m7f/wBrv4MJ09bZfzZbTj2fJqCtONqCY=',
23
+ registrant: 'YoPsbfR6YQczjzPdHCoGC7nJsOdPQR50+SYqcWpJ0y0=',
24
+ cool_cert: 'AGfk/WrT1eBDXpz3mcw386Zww2HmqcIn3uY6x4Af1eo=',
25
+ anyone: 'mfkOMfLDQmrr3SBxBQ5WeE+6Hy3VJRFq6w4A5Ljtlis=',
26
+ self: 'Hkge6X5JRxt1cWXtHLCrSTg6dCVTxjQJJ48iOYd7n3g='
27
+ }.freeze
28
+
29
+ # Fallback identity used when no verified identity information is available.
30
+ DEFAULT_IDENTITY = DisplayableIdentity.new(
31
+ name: 'Unknown Identity',
32
+ avatar_url: 'XUUB8bbn9fEthk15Ge3zTQXypUShfC94vFjp65v7u5CQ8qkpxzst',
33
+ abbreviated_key: '',
34
+ identity_key: '',
35
+ badge_icon_url: 'XUUV39HVPkpmMzYNTx7rpKzJvXfeiVyQWg2vfSpjBAuhunTCA9uG',
36
+ badge_label: 'Not verified by anyone you trust.',
37
+ badge_click_url: 'https://projectbabbage.com/docs/unknown-identity'
38
+ ).freeze
39
+ end
40
+ end
41
+ end