bsv-wallet 0.9.0 → 0.10.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/lib/bsv/wallet/broadcast_queue/inline.rb +202 -0
  4. data/lib/bsv/wallet/broadcast_queue.rb +28 -0
  5. data/lib/bsv/wallet/client/brc100/authentication.rb +28 -0
  6. data/lib/bsv/wallet/client/brc100/crypto.rb +314 -0
  7. data/lib/bsv/wallet/client/brc100/identity.rb +239 -0
  8. data/lib/bsv/wallet/client/brc100/network.rb +52 -0
  9. data/lib/bsv/wallet/client/brc100/transaction.rb +968 -0
  10. data/lib/bsv/{wallet_interface → wallet/client}/certificate_signature.rb +5 -5
  11. data/lib/bsv/wallet/client/fee_model.rb +13 -0
  12. data/lib/bsv/{wallet_interface → wallet/client}/validators.rb +56 -5
  13. data/lib/bsv/wallet/client.rb +181 -0
  14. data/lib/bsv/wallet/errors/insufficient_funds_error.rb +15 -0
  15. data/lib/bsv/wallet/errors/pool_depleted_error.rb +12 -0
  16. data/lib/bsv/wallet/interface/brc100.rb +153 -0
  17. data/lib/bsv/wallet/interface/broadcast_queue.rb +41 -0
  18. data/lib/bsv/wallet/interface/proof_store.rb +21 -0
  19. data/lib/bsv/wallet/interface/store.rb +109 -0
  20. data/lib/bsv/wallet/interface/utxo_pool.rb +31 -0
  21. data/lib/bsv/wallet/interface.rb +17 -0
  22. data/lib/bsv/{wallet_interface → wallet/proof_store}/local_proof_store.rb +4 -4
  23. data/lib/bsv/wallet/store/file.rb +279 -0
  24. data/lib/bsv/wallet/store/memory.rb +382 -0
  25. data/lib/bsv/wallet/store.rb +11 -0
  26. data/lib/bsv/{wallet_interface → wallet}/substrates/http_wallet_json.rb +1 -1
  27. data/lib/bsv/{wallet_interface → wallet}/substrates/wallet_wire_transceiver.rb +1 -1
  28. data/lib/bsv/{wallet_interface → wallet}/substrates.rb +3 -3
  29. data/lib/bsv/wallet/utxo_pool/local_pool.rb +172 -0
  30. data/lib/bsv/wallet/utxo_pool/replenishment_worker.rb +183 -0
  31. data/lib/bsv/{wallet_interface → wallet}/version.rb +1 -1
  32. data/lib/bsv/{wallet_interface → wallet}/wire.rb +3 -3
  33. data/lib/bsv/wallet.rb +40 -0
  34. data/lib/bsv-wallet.rb +1 -1
  35. metadata +47 -38
  36. data/lib/bsv/wallet_interface/broadcast_queue.rb +0 -116
  37. data/lib/bsv/wallet_interface/chain_provider.rb +0 -51
  38. data/lib/bsv/wallet_interface/fee_model.rb +0 -21
  39. data/lib/bsv/wallet_interface/file_store.rb +0 -272
  40. data/lib/bsv/wallet_interface/inline_queue.rb +0 -200
  41. data/lib/bsv/wallet_interface/interface.rb +0 -384
  42. data/lib/bsv/wallet_interface/memory_store.rb +0 -364
  43. data/lib/bsv/wallet_interface/null_chain_provider.rb +0 -30
  44. data/lib/bsv/wallet_interface/proof_store.rb +0 -32
  45. data/lib/bsv/wallet_interface/proto_wallet.rb +0 -361
  46. data/lib/bsv/wallet_interface/storage_adapter.rb +0 -170
  47. data/lib/bsv/wallet_interface/wallet_client.rb +0 -1828
  48. data/lib/bsv/wallet_interface/whats_on_chain_provider.rb +0 -62
  49. data/lib/bsv/wallet_interface.rb +0 -39
  50. /data/lib/bsv/{wallet_interface → wallet/client}/change_generator.rb +0 -0
  51. /data/lib/bsv/{wallet_interface → wallet/client}/coin_selector.rb +0 -0
  52. /data/lib/bsv/{wallet_interface → wallet/client}/fee_estimator.rb +0 -0
  53. /data/lib/bsv/{wallet_interface → wallet/client}/key_deriver.rb +0 -0
  54. /data/lib/bsv/{wallet_interface → wallet}/errors/invalid_hmac_error.rb +0 -0
  55. /data/lib/bsv/{wallet_interface → wallet}/errors/invalid_parameter_error.rb +0 -0
  56. /data/lib/bsv/{wallet_interface → wallet}/errors/invalid_signature_error.rb +0 -0
  57. /data/lib/bsv/{wallet_interface → wallet}/errors/unsupported_action_error.rb +0 -0
  58. /data/lib/bsv/{wallet_interface → wallet}/errors/wallet_error.rb +0 -0
  59. /data/lib/bsv/{wallet_interface → wallet}/substrates/http_wallet_wire.rb +0 -0
  60. /data/lib/bsv/{wallet_interface → wallet}/wire/reader.rb +0 -0
  61. /data/lib/bsv/{wallet_interface → wallet}/wire/serializer.rb +0 -0
  62. /data/lib/bsv/{wallet_interface → wallet}/wire/writer.rb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7959ebc47fc5d89d3d0db18f4136c925e228f6ea483553decffd4b9e7de9defc
4
- data.tar.gz: 2365a17193357b64d50c15a61aa12422b966b8f0155a6ab989b3bacb700a0929
3
+ metadata.gz: c9ebc3008fda1b4430f4e813abd4d9e4e87329a6845b247adab0d967b2c4fe5a
4
+ data.tar.gz: b05c60a9f6d73e85c2bd0d179abe77e9d80018112336ce20b92782fc35b91b6e
5
5
  SHA512:
6
- metadata.gz: 895d3c7a661e00b7d70dd833d12e4dd0e17ca2a8d0d8ba6a2c0ef622dfbac6bc0d6fb534a3fe06c14c0b1747981f1e0c9b8b61e9f164e3cc8fea222e5f9e70c1
7
- data.tar.gz: 3086ff6e236c57d903c947ac7c230148104993837b3d2f46ddef3a09a9b43da42bab1a5ea6f1c7b375b105cc7e8feffe2c8defbd83a325ec7ca0775dbd1f2ea1
6
+ metadata.gz: f1d6d32a26a674919fb659da57e6305b641dacbeb51eb0582af92f6e60396a73eed9ff373c48a90f95a4d5e1f86cefe9e10d69e71da515b8e08fc383fb8dc872
7
+ data.tar.gz: 36e41e56f39d3b6ee945605a2ce831a2a220975f92458dfe081b627dc6ce9419f1a8fc69735fc6bec18cbb6e09bf5c55bf387b43ac09416c49d1af166785458c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to the `bsv-wallet` gem are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.10.0 — 2026-04-21
9
+
10
+ ### Added
11
+ - BRC-100 abstract contract modules and `Client` with composition architecture
12
+ - UTXO pool system: `UTXOPool` interface, `LocalPool` implementation, `ReplenishmentWorker`, and `utxo_pool` factory on Client
13
+ - BRC-122 two-zone basket validation
14
+ - `update_output_basket` on Store interface
15
+
16
+ ### Changed
17
+ - `ProtoWallet` and `WalletClient` deleted, replaced with `Client` using concern modules (`Transaction`, `Crypto`, `Identity`, `Network`, `Authentication`)
18
+ - File paths renamed from `wallet_interface` to `wallet`
19
+ - `Store` and `BroadcastQueue` collaborators namespaced under `Client`
20
+ - Contracts moved under `Interface` namespace
21
+ - Legacy `ChainProvider` classes removed
22
+
23
+ ### Fixed
24
+ - `internalize_payment` and `internalize_basket` now set `state: :spendable` explicitly on stored outputs
25
+ - Derivation metadata now persisted in `store_tracked_outputs`
26
+ - Cold start normalisation and eager validation fixes
27
+ - SimpleCov coverage gate now requires `COVERAGE=true` explicitly
28
+
29
+ ## 0.9.1 — 2026-04-16
30
+
31
+ ### Changed
32
+
33
+ - Raised `bsv-sdk` dependency floor to `>= 0.12.1` to pick up BEEF ancestry correctness fixes ([bsv-sdk#467](https://github.com/sgbett/bsv-ruby-sdk/issues/467), [bsv-sdk#468](https://github.com/sgbett/bsv-ruby-sdk/issues/468), HLR #466). Consumer impact: recently-received UTXOs that previously could not be re-broadcast now can — the wallet's `create_action` flow relies on `Transaction.from_beef` producing fully-wired ancestry, which the old SDK didn't do.
34
+
35
+ ### Fixed
36
+
37
+ - Removed redundant `store_transaction` call in `internalize_action` — `store_proofs_from_beef` already persists every transaction in the BEEF bundle, so the subject tx was being written twice. No behaviour change; minor cleanup. (#469)
38
+
39
+ **Related:** [wallet-toolbox#149](https://github.com/bsv-blockchain/wallet-toolbox/issues/149) — same architectural gap reported upstream.
40
+
8
41
  ## 0.9.0 — 2026-04-16
9
42
 
10
43
  ### Changed — **Breaking**
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ module BroadcastQueue
6
+ # Synchronous broadcast queue adapter — the default for +Client+.
7
+ #
8
+ # +BroadcastQueue::Inline+ replicates the current wallet broadcast behaviour exactly:
9
+ #
10
+ # * With a broadcaster: calls +broadcaster.broadcast+, promotes UTXO state on
11
+ # success, rolls back on failure.
12
+ # * Without a broadcaster: promotes immediately and returns BEEF for the
13
+ # caller to broadcast manually (backwards-compatible fallback).
14
+ #
15
+ # Because this adapter executes synchronously, +async?+ returns +false+ and
16
+ # the caller can rely on the returned hash containing the final result.
17
+ class Inline
18
+ include BSV::Wallet::Interface::BroadcastQueue
19
+
20
+ # @param broadcaster [#broadcast, nil] broadcaster object; +nil+ disables broadcasting
21
+ # @param storage [BSV::Wallet::Store] wallet storage adapter
22
+ def initialize(storage:, broadcaster: nil)
23
+ @broadcaster = broadcaster
24
+ @storage = storage
25
+ end
26
+
27
+ # Returns +false+ — this adapter executes synchronously.
28
+ #
29
+ # @return [Boolean]
30
+ def async?
31
+ false
32
+ end
33
+
34
+ # Returns +true+ when a broadcaster has been configured.
35
+ #
36
+ # +Client+ delegates its own +broadcast_enabled?+ to this method
37
+ # so the check works correctly when the broadcaster is embedded in the
38
+ # queue rather than passed directly to the wallet.
39
+ #
40
+ # @return [Boolean]
41
+ def broadcast_enabled?
42
+ !@broadcaster.nil?
43
+ end
44
+
45
+ # Returns the broadcast status for a previously enqueued transaction.
46
+ #
47
+ # Delegates to storage and returns the action status field, or +nil+ if
48
+ # the action is not found.
49
+ #
50
+ # @param txid [String] hex transaction identifier
51
+ # @return [String, nil]
52
+ def status(txid)
53
+ actions = @storage.find_actions({ txid: txid, limit: 1, offset: 0 })
54
+ actions.first&.dig(:status)
55
+ end
56
+
57
+ # Broadcasts and promotes (or just promotes) a transaction synchronously.
58
+ #
59
+ # Dispatches to +broadcast_and_promote+ when a broadcaster is configured,
60
+ # or +promote_without_broadcast+ when none is present.
61
+ #
62
+ # @param payload [Hash] broadcast payload (see +BroadcastQueue+ module docs)
63
+ # @return [Hash] result hash containing at minimum +:txid+ and +:tx+
64
+ def enqueue(payload)
65
+ if @broadcaster
66
+ broadcast_and_promote(payload)
67
+ else
68
+ promote_without_broadcast(payload)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ # Broadcasts the transaction and promotes storage state on success.
75
+ #
76
+ # On broadcast failure with outpoints present, rolls back all pending
77
+ # state (releases locked inputs, deletes change outputs, marks action
78
+ # failed). On failure without outpoints (finalize path), only updates
79
+ # the action status.
80
+ #
81
+ # INVARIANT: Only broadcast failure triggers rollback. If broadcast
82
+ # succeeds but promotion raises, the error propagates — confirmed
83
+ # on-chain outputs must never be deleted.
84
+ #
85
+ # @param payload [Hash] broadcast payload
86
+ # @return [Hash] result hash
87
+ def broadcast_and_promote(payload)
88
+ tx = payload[:tx]
89
+ txid = payload[:txid]
90
+ beef_binary = payload[:beef_binary]
91
+ input_outpoints = payload[:input_outpoints]
92
+ change_outpoints = payload[:change_outpoints]
93
+ fund_ref = payload[:fund_ref]
94
+
95
+ begin
96
+ broadcast_result = @broadcaster.broadcast(tx)
97
+ rescue StandardError => e
98
+ if input_outpoints
99
+ rollback(input_outpoints, change_outpoints, txid, fund_ref)
100
+ elsif txid
101
+ @storage.update_action_status(txid, 'failed')
102
+ end
103
+ return {
104
+ txid: txid,
105
+ tx: beef_binary.unpack('C*'),
106
+ broadcast_error: e.message,
107
+ broadcast_status: BroadcastQueue.status_for_error(e)
108
+ }
109
+ end
110
+
111
+ # Broadcast succeeded — promote all pending state; set status to
112
+ # 'unproven' (transaction is on-chain but lacks a merkle proof).
113
+ # 'completed' is reserved for transactions confirmed by a proof-watcher.
114
+ promote(input_outpoints, change_outpoints, txid, status: 'unproven')
115
+
116
+ result = {
117
+ txid: txid,
118
+ tx: beef_binary.unpack('C*'),
119
+ broadcast_result: broadcast_result,
120
+ broadcast_status: 'success'
121
+ }
122
+ result[:competing_txs] = broadcast_result.competing_txs if broadcast_result.respond_to?(:competing_txs) && broadcast_result.competing_txs
123
+ result
124
+ end
125
+
126
+ # Promotes UTXO state without broadcasting.
127
+ #
128
+ # This path is reached when no broadcaster is configured. It is only
129
+ # valid when +accept_delayed_broadcast+ is set on the create_action
130
+ # call — the caller explicitly accepts that the transaction will be
131
+ # broadcast out-of-band. Action status is set to +unproven+.
132
+ #
133
+ # +completed+ is reserved for transactions that have received a merkle
134
+ # proof (set by +internalize_action+ or a future proof-watcher).
135
+ #
136
+ # Defensive guard: raises +WalletError+ if reached without
137
+ # +accept_delayed_broadcast+. The normal entry point for this guard is
138
+ # the +create_action+ validation added in Task 1 (#456), but this guard
139
+ # protects against other code paths that bypass it.
140
+ #
141
+ # @param payload [Hash] broadcast payload
142
+ # @return [Hash] result hash containing +:txid+ and +:tx+
143
+ # @raise [BSV::Wallet::WalletError] if +accept_delayed_broadcast+ is not set
144
+ def promote_without_broadcast(payload)
145
+ txid = payload[:txid]
146
+ beef_binary = payload[:beef_binary]
147
+ input_outpoints = payload[:input_outpoints]
148
+ change_outpoints = payload[:change_outpoints]
149
+ delayed = payload[:accept_delayed_broadcast]
150
+
151
+ unless delayed
152
+ raise BSV::Wallet::WalletError,
153
+ 'BroadcastQueue::Inline cannot promote without a broadcaster unless ' \
154
+ 'accept_delayed_broadcast is set. This indicates a bypass of ' \
155
+ 'the create_action guard — report as a bug.'
156
+ end
157
+
158
+ promote(input_outpoints, change_outpoints, txid, status: 'unproven')
159
+
160
+ { txid: txid, tx: beef_binary.unpack('C*') }
161
+ end
162
+
163
+ # Promotes UTXO state: marks inputs as +:spent+, change as +:spendable+,
164
+ # and updates the action status.
165
+ #
166
+ # When +outpoints+ arguments are +nil+ (finalize path), UTXO transitions
167
+ # are skipped and only the action status is updated.
168
+ #
169
+ # @param input_outpoints [Array<String>, nil]
170
+ # @param change_outpoints [Array<String>, nil]
171
+ # @param txid [String, nil]
172
+ # @param status [String]
173
+ def promote(input_outpoints, change_outpoints, txid, status: 'completed')
174
+ Array(input_outpoints).each { |op| @storage.update_output_state(op, :spent) }
175
+ Array(change_outpoints).each { |op| @storage.update_output_state(op, :spendable) }
176
+ @storage.update_action_status(txid, status) if txid
177
+ end
178
+
179
+ # Rolls back a pending auto-funded action.
180
+ #
181
+ # Releases locked inputs (only those matching +fund_ref+), deletes phantom
182
+ # change outputs, and marks the action as +failed+.
183
+ #
184
+ # @param input_outpoints [Array<String>] outpoints locked as inputs
185
+ # @param change_outpoints [Array<String>] change outputs to delete
186
+ # @param txid [String, nil] action txid
187
+ # @param fund_ref [String] fund reference used when locking inputs
188
+ def rollback(input_outpoints, change_outpoints, txid, fund_ref)
189
+ Array(input_outpoints).each do |op|
190
+ outputs = @storage.find_outputs({ outpoint: op, include_spent: true, limit: 1, offset: 0 })
191
+ next if outputs.empty?
192
+ next unless outputs.first[:pending_reference] == fund_ref
193
+
194
+ @storage.update_output_state(op, :spendable)
195
+ end
196
+ Array(change_outpoints).each { |op| @storage.delete_output(op) }
197
+ @storage.update_action_status(txid, 'failed') if txid
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ # Broadcast queue implementations. See {Interface::BroadcastQueue} for the contract.
6
+ module BroadcastQueue
7
+ autoload :Inline, 'bsv/wallet/broadcast_queue/inline'
8
+
9
+ # Maps a broadcast exception to a status string.
10
+ #
11
+ # Shared helper so all queue adapters produce consistent status strings.
12
+ #
13
+ # @param error [StandardError] the exception raised during broadcast
14
+ # @return [String] one of +'doubleSpend'+, +'invalidTx'+, +'serviceError'+
15
+ def self.status_for_error(error)
16
+ return 'serviceError' unless error.is_a?(BSV::Network::BroadcastError)
17
+
18
+ arc_status = error.arc_status.to_s.upcase
19
+ return 'doubleSpend' if arc_status == 'DOUBLE_SPEND_ATTEMPTED'
20
+
21
+ invalid_statuses = %w[REJECTED INVALID MALFORMED MINED_IN_STALE_BLOCK]
22
+ return 'invalidTx' if invalid_statuses.include?(arc_status) || arc_status.include?('ORPHAN')
23
+
24
+ 'serviceError'
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ class Client
6
+ # Authentication methods for {Client}.
7
+ module Authentication
8
+ # Checks whether the user is authenticated.
9
+ #
10
+ # @return [Hash] { authenticated: Boolean }
11
+ def is_authenticated(args = {}, originator: nil)
12
+ return @substrate.is_authenticated(args, originator: originator) if @substrate
13
+
14
+ { authenticated: true }
15
+ end
16
+
17
+ # Waits until the user is authenticated.
18
+ #
19
+ # @return [Hash] { authenticated: true }
20
+ def wait_for_authentication(args = {}, originator: nil)
21
+ return @substrate.wait_for_authentication(args, originator: originator) if @substrate
22
+
23
+ { authenticated: true }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module BSV
6
+ module Wallet
7
+ class Client
8
+ # Cryptographic operations for the BRC-100 wallet client.
9
+ #
10
+ # Provides the 9 crypto public methods defined by the BRC-100 interface:
11
+ # key derivation, symmetric encryption/decryption, HMAC creation/verification,
12
+ # ECDSA signing/verification, and key linkage revelation.
13
+ #
14
+ # All methods delegate to @substrate when present. Otherwise they operate
15
+ # against @key_deriver directly.
16
+ module Crypto
17
+ # Returns a derived or identity public key.
18
+ #
19
+ # @param args [Hash]
20
+ # @option args [Boolean] :identity_key return the identity key instead of deriving
21
+ # @option args [Array] :protocol_id [security_level, protocol_name]
22
+ # @option args [String] :key_id key identifier
23
+ # @option args [String] :counterparty public key hex, 'self', or 'anyone'
24
+ # @option args [Boolean] :for_self derive from own identity
25
+ # @return [Hash] { public_key: String } hex-encoded compressed public key
26
+ def get_public_key(args, originator: nil)
27
+ return @substrate.get_public_key(args, originator: originator) if @substrate
28
+
29
+ if args[:identity_key]
30
+ { public_key: @key_deriver.identity_key }
31
+ else
32
+ counterparty = args[:counterparty] || 'self'
33
+ pub = @key_deriver.derive_public_key(
34
+ args[:protocol_id],
35
+ args[:key_id],
36
+ counterparty,
37
+ for_self: args[:for_self] || false
38
+ )
39
+ { public_key: pub.to_hex }
40
+ end
41
+ end
42
+
43
+ # Encrypts plaintext using AES-256-GCM with a derived symmetric key.
44
+ #
45
+ # @param args [Hash]
46
+ # @option args [Array<Integer>] :plaintext byte array to encrypt
47
+ # @option args [Array] :protocol_id [security_level, protocol_name]
48
+ # @option args [String] :key_id key identifier
49
+ # @option args [String] :counterparty public key hex, 'self', or 'anyone'
50
+ # @return [Hash] { ciphertext: Array<Integer> }
51
+ def encrypt(args, originator: nil)
52
+ return @substrate.encrypt(args, originator: originator) if @substrate
53
+
54
+ sym_key = derive_sym_key(args)
55
+ ciphertext = sym_key.encrypt(bytes_to_string(args[:plaintext]))
56
+ { ciphertext: string_to_bytes(ciphertext) }
57
+ end
58
+
59
+ # Decrypts ciphertext using AES-256-GCM with a derived symmetric key.
60
+ #
61
+ # @param args [Hash]
62
+ # @option args [Array<Integer>] :ciphertext byte array to decrypt
63
+ # @option args [Array] :protocol_id [security_level, protocol_name]
64
+ # @option args [String] :key_id key identifier
65
+ # @option args [String] :counterparty public key hex, 'self', or 'anyone'
66
+ # @return [Hash] { plaintext: Array<Integer> }
67
+ def decrypt(args, originator: nil)
68
+ return @substrate.decrypt(args, originator: originator) if @substrate
69
+
70
+ sym_key = derive_sym_key(args)
71
+ plaintext = sym_key.decrypt(bytes_to_string(args[:ciphertext]))
72
+ { plaintext: string_to_bytes(plaintext) }
73
+ end
74
+
75
+ # Creates an HMAC-SHA256 using a derived symmetric key.
76
+ #
77
+ # @param args [Hash]
78
+ # @option args [Array<Integer>] :data byte array to authenticate
79
+ # @option args [Array] :protocol_id [security_level, protocol_name]
80
+ # @option args [String] :key_id key identifier
81
+ # @option args [String] :counterparty public key hex, 'self', or 'anyone'
82
+ # @return [Hash] { hmac: Array<Integer> }
83
+ def create_hmac(args, originator: nil)
84
+ return @substrate.create_hmac(args, originator: originator) if @substrate
85
+
86
+ sym_key = derive_sym_key(args)
87
+ hmac = BSV::Primitives::Digest.hmac_sha256(sym_key.to_bytes, bytes_to_string(args[:data]))
88
+ { hmac: string_to_bytes(hmac) }
89
+ end
90
+
91
+ # Verifies an HMAC-SHA256 using a derived symmetric key.
92
+ #
93
+ # @param args [Hash]
94
+ # @option args [Array<Integer>] :data byte array that was authenticated
95
+ # @option args [Array<Integer>] :hmac HMAC to verify
96
+ # @option args [Array] :protocol_id [security_level, protocol_name]
97
+ # @option args [String] :key_id key identifier
98
+ # @option args [String] :counterparty public key hex, 'self', or 'anyone'
99
+ # @return [Hash] { valid: true }
100
+ # @raise [InvalidHmacError] if the HMAC does not match
101
+ def verify_hmac(args, originator: nil)
102
+ return @substrate.verify_hmac(args, originator: originator) if @substrate
103
+
104
+ sym_key = derive_sym_key(args)
105
+ expected = BSV::Primitives::Digest.hmac_sha256(sym_key.to_bytes, bytes_to_string(args[:data]))
106
+ provided = bytes_to_string(args[:hmac])
107
+
108
+ raise InvalidHmacError unless secure_compare(expected, provided)
109
+
110
+ { valid: true }
111
+ end
112
+
113
+ # Creates an ECDSA signature using a derived private key.
114
+ #
115
+ # @param args [Hash]
116
+ # @option args [Array<Integer>] :data data to hash and sign
117
+ # @option args [Array<Integer>] :hash_to_directly_sign pre-computed 32-byte hash to sign
118
+ # @option args [Array] :protocol_id [security_level, protocol_name]
119
+ # @option args [String] :key_id key identifier
120
+ # @option args [String] :counterparty public key hex, 'self', or 'anyone'
121
+ # @return [Hash] { signature: Array<Integer> } DER-encoded signature as byte array
122
+ def create_signature(args, originator: nil)
123
+ return @substrate.create_signature(args, originator: originator) if @substrate
124
+
125
+ counterparty = args[:counterparty] || 'anyone'
126
+ priv_key = @key_deriver.derive_private_key(args[:protocol_id], args[:key_id], counterparty)
127
+
128
+ hash = if args[:hash_to_directly_sign]
129
+ bytes_to_string(args[:hash_to_directly_sign])
130
+ else
131
+ BSV::Primitives::Digest.sha256(bytes_to_string(args[:data]))
132
+ end
133
+
134
+ sig = priv_key.sign(hash)
135
+ { signature: string_to_bytes(sig.to_der) }
136
+ end
137
+
138
+ # Verifies an ECDSA signature using a derived public key.
139
+ #
140
+ # @param args [Hash]
141
+ # @option args [Array<Integer>] :data original data that was signed
142
+ # @option args [Array<Integer>] :hash_to_directly_verify pre-computed 32-byte hash
143
+ # @option args [Array<Integer>] :signature DER-encoded signature as byte array
144
+ # @option args [Array] :protocol_id [security_level, protocol_name]
145
+ # @option args [String] :key_id key identifier
146
+ # @option args [String] :counterparty public key hex, 'self', or 'anyone'
147
+ # @option args [Boolean] :for_self verify own derived key (default false)
148
+ # @return [Hash] { valid: true }
149
+ # @raise [InvalidSignatureError] if the signature does not verify
150
+ def verify_signature(args, originator: nil)
151
+ return @substrate.verify_signature(args, originator: originator) if @substrate
152
+
153
+ counterparty = args[:counterparty] || 'self'
154
+ for_self = args[:for_self] || false
155
+
156
+ pub_key = @key_deriver.derive_public_key(
157
+ args[:protocol_id],
158
+ args[:key_id],
159
+ counterparty,
160
+ for_self: for_self
161
+ )
162
+
163
+ hash = if args[:hash_to_directly_verify]
164
+ bytes_to_string(args[:hash_to_directly_verify])
165
+ else
166
+ BSV::Primitives::Digest.sha256(bytes_to_string(args[:data]))
167
+ end
168
+
169
+ sig = BSV::Primitives::Signature.from_der(bytes_to_string(args[:signature]))
170
+ valid = pub_key.verify(hash, sig)
171
+
172
+ raise InvalidSignatureError unless valid
173
+
174
+ { valid: true }
175
+ end
176
+
177
+ # Reveals counterparty key linkage to a verifier (BRC-69 Method 1).
178
+ #
179
+ # @param args [Hash]
180
+ # @option args [String] :counterparty counterparty public key hex (not 'self')
181
+ # @option args [String] :verifier verifier public key hex
182
+ # @return [Hash] with :prover, :verifier, :counterparty, :revelation_time,
183
+ # :encrypted_linkage, :encrypted_linkage_proof
184
+ def reveal_counterparty_key_linkage(args, originator: nil)
185
+ return @substrate.reveal_counterparty_key_linkage(args, originator: originator) if @substrate
186
+
187
+ counterparty = args[:counterparty]
188
+ verifier = args[:verifier]
189
+
190
+ raise InvalidParameterError.new('counterparty', 'a specific public key hex, not "anyone"') if counterparty == 'anyone'
191
+
192
+ Validators.validate_pub_key_hex!(verifier, 'verifier')
193
+
194
+ linkage = @key_deriver.reveal_counterparty_secret(counterparty)
195
+ revelation_time = Time.now.utc.iso8601
196
+
197
+ encrypted_linkage_result = encrypt({
198
+ plaintext: string_to_bytes(linkage),
199
+ protocol_id: [2, 'counterparty linkage revelation'],
200
+ key_id: revelation_time,
201
+ counterparty: verifier
202
+ })
203
+
204
+ counterparty_pub = BSV::Primitives::PublicKey.from_hex(counterparty)
205
+ linkage_point = BSV::Primitives::PublicKey.from_bytes(linkage)
206
+ schnorr_proof = BSV::Primitives::Schnorr.generate_proof(
207
+ @key_deriver.root_key,
208
+ @key_deriver.root_key.public_key,
209
+ counterparty_pub,
210
+ linkage_point
211
+ )
212
+
213
+ z_bytes = schnorr_proof.z.to_s(2)
214
+ z_bytes = ("\x00".b * (32 - z_bytes.length)) + z_bytes if z_bytes.length < 32
215
+ proof_bin = schnorr_proof.r.compressed + schnorr_proof.s_prime.compressed + z_bytes
216
+
217
+ encrypted_proof_result = encrypt({
218
+ plaintext: string_to_bytes(proof_bin),
219
+ protocol_id: [2, 'counterparty linkage revelation'],
220
+ key_id: revelation_time,
221
+ counterparty: verifier
222
+ })
223
+
224
+ {
225
+ prover: @key_deriver.identity_key,
226
+ verifier: verifier,
227
+ counterparty: counterparty,
228
+ revelation_time: revelation_time,
229
+ encrypted_linkage: encrypted_linkage_result[:ciphertext],
230
+ encrypted_linkage_proof: encrypted_proof_result[:ciphertext]
231
+ }
232
+ end
233
+
234
+ # Reveals specific key linkage for a particular interaction (BRC-69 Method 2).
235
+ #
236
+ # @param args [Hash]
237
+ # @option args [String] :counterparty counterparty public key hex
238
+ # @option args [String] :verifier verifier public key hex
239
+ # @option args [Array] :protocol_id [security_level, protocol_name]
240
+ # @option args [String] :key_id key identifier
241
+ # @return [Hash] with :prover, :verifier, :counterparty, :protocol_id, :key_id,
242
+ # :encrypted_linkage, :encrypted_linkage_proof, :proof_type
243
+ def reveal_specific_key_linkage(args, originator: nil)
244
+ return @substrate.reveal_specific_key_linkage(args, originator: originator) if @substrate
245
+
246
+ counterparty = args[:counterparty]
247
+ verifier = args[:verifier]
248
+ protocol_id = args[:protocol_id]
249
+ key_id = args[:key_id]
250
+
251
+ raise InvalidParameterError.new('counterparty', 'a specific public key hex, not "anyone"') if counterparty == 'anyone'
252
+
253
+ Validators.validate_pub_key_hex!(verifier, 'verifier')
254
+
255
+ linkage = @key_deriver.reveal_specific_secret(counterparty, protocol_id, key_id)
256
+
257
+ derived_protocol = "specific linkage revelation #{protocol_id[0]} #{protocol_id[1]}"
258
+
259
+ encrypted_linkage_result = encrypt({
260
+ plaintext: string_to_bytes(linkage),
261
+ protocol_id: [2, derived_protocol],
262
+ key_id: key_id,
263
+ counterparty: verifier
264
+ })
265
+
266
+ encrypted_proof_result = encrypt({
267
+ plaintext: [0],
268
+ protocol_id: [2, derived_protocol],
269
+ key_id: key_id,
270
+ counterparty: verifier
271
+ })
272
+
273
+ {
274
+ prover: @key_deriver.identity_key,
275
+ verifier: verifier,
276
+ counterparty: counterparty,
277
+ protocol_id: protocol_id,
278
+ key_id: key_id,
279
+ encrypted_linkage: encrypted_linkage_result[:ciphertext],
280
+ encrypted_linkage_proof: encrypted_proof_result[:ciphertext],
281
+ proof_type: 0
282
+ }
283
+ end
284
+
285
+ private
286
+
287
+ def derive_sym_key(args)
288
+ counterparty = args[:counterparty] || 'self'
289
+ @key_deriver.derive_symmetric_key(args[:protocol_id], args[:key_id], counterparty)
290
+ end
291
+
292
+ def bytes_to_string(bytes)
293
+ bytes.pack('C*')
294
+ end
295
+
296
+ def string_to_bytes(str)
297
+ str.unpack('C*')
298
+ end
299
+
300
+ def secure_compare(a, b)
301
+ return false unless a.bytesize == b.bytesize
302
+
303
+ if OpenSSL.respond_to?(:fixed_length_secure_compare)
304
+ OpenSSL.fixed_length_secure_compare(a, b)
305
+ else
306
+ result = 0
307
+ a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
308
+ result.zero?
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end