bsv-wallet 0.9.1 → 0.11.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 +4 -4
- data/CHANGELOG.md +58 -0
- data/lib/bsv/wallet/broadcast_queue/inline.rb +202 -0
- data/lib/bsv/wallet/broadcast_queue.rb +28 -0
- data/lib/bsv/wallet/client/brc100/authentication.rb +28 -0
- data/lib/bsv/wallet/client/brc100/crypto.rb +314 -0
- data/lib/bsv/wallet/client/brc100/identity.rb +239 -0
- data/lib/bsv/wallet/client/brc100/network.rb +72 -0
- data/lib/bsv/wallet/client/brc100/transaction.rb +980 -0
- data/lib/bsv/{wallet_interface → wallet/client}/certificate_signature.rb +5 -5
- data/lib/bsv/wallet/client/fee_model.rb +13 -0
- data/lib/bsv/{wallet_interface → wallet/client}/validators.rb +56 -5
- data/lib/bsv/wallet/client.rb +255 -0
- data/lib/bsv/wallet/errors/insufficient_funds_error.rb +15 -0
- data/lib/bsv/wallet/errors/pool_depleted_error.rb +12 -0
- data/lib/bsv/wallet/interface/brc100.rb +153 -0
- data/lib/bsv/wallet/interface/broadcast_queue.rb +41 -0
- data/lib/bsv/wallet/interface/proof_store.rb +21 -0
- data/lib/bsv/wallet/interface/store.rb +109 -0
- data/lib/bsv/wallet/interface/utxo_pool.rb +31 -0
- data/lib/bsv/wallet/interface.rb +17 -0
- data/lib/bsv/{wallet_interface → wallet/proof_store}/local_proof_store.rb +4 -4
- data/lib/bsv/wallet/store/file.rb +279 -0
- data/lib/bsv/wallet/store/memory.rb +382 -0
- data/lib/bsv/wallet/store.rb +19 -0
- data/lib/bsv/{wallet_interface → wallet}/substrates/http_wallet_json.rb +1 -1
- data/lib/bsv/{wallet_interface → wallet}/substrates/wallet_wire_transceiver.rb +1 -1
- data/lib/bsv/{wallet_interface → wallet}/substrates.rb +3 -3
- data/lib/bsv/wallet/testing/shared_examples_for_storage_adapter.rb +778 -0
- data/lib/bsv/wallet/testing/shared_examples_for_wallet_operations.rb +3701 -0
- data/lib/bsv/wallet/testing/store_conformance.rb +15 -0
- data/lib/bsv/wallet/utxo_pool/local_pool.rb +172 -0
- data/lib/bsv/wallet/utxo_pool/replenishment_worker.rb +183 -0
- data/lib/bsv/{wallet_interface → wallet}/version.rb +1 -1
- data/lib/bsv/{wallet_interface → wallet}/wire.rb +3 -3
- data/lib/bsv/wallet.rb +40 -0
- data/lib/bsv-wallet.rb +1 -1
- metadata +48 -36
- data/lib/bsv/wallet_interface/broadcast_queue.rb +0 -116
- data/lib/bsv/wallet_interface/chain_provider.rb +0 -51
- data/lib/bsv/wallet_interface/fee_model.rb +0 -21
- data/lib/bsv/wallet_interface/file_store.rb +0 -272
- data/lib/bsv/wallet_interface/inline_queue.rb +0 -200
- data/lib/bsv/wallet_interface/interface.rb +0 -384
- data/lib/bsv/wallet_interface/memory_store.rb +0 -364
- data/lib/bsv/wallet_interface/null_chain_provider.rb +0 -30
- data/lib/bsv/wallet_interface/proof_store.rb +0 -32
- data/lib/bsv/wallet_interface/proto_wallet.rb +0 -361
- data/lib/bsv/wallet_interface/storage_adapter.rb +0 -170
- data/lib/bsv/wallet_interface/wallet_client.rb +0 -1827
- data/lib/bsv/wallet_interface/whats_on_chain_provider.rb +0 -62
- data/lib/bsv/wallet_interface.rb +0 -39
- /data/lib/bsv/{wallet_interface → wallet/client}/change_generator.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet/client}/coin_selector.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet/client}/fee_estimator.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet/client}/key_deriver.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet}/errors/invalid_hmac_error.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet}/errors/invalid_parameter_error.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet}/errors/invalid_signature_error.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet}/errors/unsupported_action_error.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet}/errors/wallet_error.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet}/substrates/http_wallet_wire.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet}/wire/reader.rb +0 -0
- /data/lib/bsv/{wallet_interface → wallet}/wire/serializer.rb +0 -0
- /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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43a1c78810a0351eb11cf29575e9d2a9e8ffca2d5e844f8122bc28dc5dfffb02
|
|
4
|
+
data.tar.gz: 358f2f7514b5d44f1896ec3ca6898cfb2e195142639711e787f545d27eda6f03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c82bcf291d49346fd574d8bf442b690575f4b42d3c66e94a8262a32ceab596831d7c9b8c69a3617e5540f29e93417014f16979aa0e345eff0b48577de6ddf10e
|
|
7
|
+
data.tar.gz: 8a5adfbe6bb90ea76d8478d181f3a453d5a9c4a15ad3a1f7eb833ef9d1b2862b5305d2dcaab016f4b10d83f5f091338f1e66c9ba3fdf0858f25597304d345308
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,64 @@ 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.11.0 — 2026-04-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `chain_data_source:` constructor param on `Client` — injectable chain
|
|
12
|
+
data provider for local blockchain queries without a remote substrate
|
|
13
|
+
- `sync_utxos` restored — discovers on-chain UTXOs via the chain data
|
|
14
|
+
source and imports them into storage as `:spendable` with
|
|
15
|
+
`derivation_type: :identity`. Deduplicates transaction fetches and
|
|
16
|
+
validates UTXO API values against transaction outputs (#599)
|
|
17
|
+
- `get_height` restored with local chain data fallback — returns
|
|
18
|
+
`{ height: Integer }` without requiring a substrate (#597)
|
|
19
|
+
- `get_header_for_height` restored with local chain data fallback —
|
|
20
|
+
returns WoC block header JSON (#598)
|
|
21
|
+
- BEEF SPV merkle root verification restored in `internalize_action`
|
|
22
|
+
when a chain data source with `valid_root_for_height?` is available;
|
|
23
|
+
error messages now distinguish SPV rejection from structural
|
|
24
|
+
invalidity (#600)
|
|
25
|
+
- Store conformance shared examples extracted to
|
|
26
|
+
`lib/bsv/wallet/testing/` — downstream adapter gems can now
|
|
27
|
+
`require 'bsv/wallet/testing/store_conformance'` for interface and
|
|
28
|
+
wallet-level test coverage (#591)
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- `MemoryStore` guarded in `Client.new` — raises `ArgumentError` unless
|
|
32
|
+
`allow_memory_store: true` is passed, preventing accidental data loss
|
|
33
|
+
in production
|
|
34
|
+
- Wallet-level dual-store specs refactored to use shared examples via
|
|
35
|
+
`it_behaves_like`; no test coverage lost (1281 examples preserved)
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- `sync_utxos` deduplicates UTXO response entries before processing
|
|
39
|
+
and caches fetched transactions by txid to minimise WoC API calls
|
|
40
|
+
- `internalize_action` chain tracker guard uses explicit nil check for
|
|
41
|
+
clarity
|
|
42
|
+
- Consistent error message wording across `get_height` and
|
|
43
|
+
`get_header_for_height`
|
|
44
|
+
|
|
45
|
+
## 0.10.0 — 2026-04-21
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
- BRC-100 abstract contract modules and `Client` with composition architecture
|
|
49
|
+
- UTXO pool system: `UTXOPool` interface, `LocalPool` implementation, `ReplenishmentWorker`, and `utxo_pool` factory on Client
|
|
50
|
+
- BRC-122 two-zone basket validation
|
|
51
|
+
- `update_output_basket` on Store interface
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
- `ProtoWallet` and `WalletClient` deleted, replaced with `Client` using concern modules (`Transaction`, `Crypto`, `Identity`, `Network`, `Authentication`)
|
|
55
|
+
- File paths renamed from `wallet_interface` to `wallet`
|
|
56
|
+
- `Store` and `BroadcastQueue` collaborators namespaced under `Client`
|
|
57
|
+
- Contracts moved under `Interface` namespace
|
|
58
|
+
- Legacy `ChainProvider` classes removed
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
- `internalize_payment` and `internalize_basket` now set `state: :spendable` explicitly on stored outputs
|
|
62
|
+
- Derivation metadata now persisted in `store_tracked_outputs`
|
|
63
|
+
- Cold start normalisation and eager validation fixes
|
|
64
|
+
- SimpleCov coverage gate now requires `COVERAGE=true` explicitly
|
|
65
|
+
|
|
8
66
|
## 0.9.1 — 2026-04-16
|
|
9
67
|
|
|
10
68
|
### Changed
|
|
@@ -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
|