bsv-wallet 0.6.0 → 0.8.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 +40 -0
- data/lib/bsv/wallet_interface/broadcast_queue.rb +101 -0
- data/lib/bsv/wallet_interface/inline_queue.rb +171 -0
- data/lib/bsv/wallet_interface/memory_store.rb +37 -1
- data/lib/bsv/wallet_interface/substrates/http_wallet_json.rb +268 -0
- data/lib/bsv/wallet_interface/substrates/http_wallet_wire.rb +85 -0
- data/lib/bsv/wallet_interface/substrates/wallet_wire_transceiver.rb +168 -0
- data/lib/bsv/wallet_interface/substrates.rb +16 -0
- data/lib/bsv/wallet_interface/version.rb +2 -2
- data/lib/bsv/wallet_interface/wallet_client.rb +298 -170
- data/lib/bsv/wallet_interface.rb +5 -4
- metadata +11 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c71d9a7e4b68e5d47c4d95e3a8bcd6c23c698a92ca3faa4809a9a7abf1171b8e
|
|
4
|
+
data.tar.gz: f61618ef74ba89eae855dc710244f5a9208bff34a2244f4bb2ab6425d6774fea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9c9d6badbfaac1bb19ed0bf5745ccfe21aba5372822e692be72f1065df56175481277e8f1b41daf4c17dd0acc758e43a2dcf73d4e8c96147d982dafd7b7a7ca3
|
|
7
|
+
data.tar.gz: 161733d4e9c277c55d4e3b1d89eab89bf156d066575c3f924e066c636d55f65606cd1e4df7afdbbdf0ff8cea615d5d6c09c30aafc63773294370c22659a3bf85
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ 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.8.0 — 2026-04-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- BRC-100 substrates: `HTTPWalletJSON` for JSON-over-HTTP, `HTTPWalletWire`
|
|
12
|
+
for binary transport, and `WalletWireTransceiver` Interface adapter (#449–#451)
|
|
13
|
+
- `WalletClient` accepts `substrate:` constructor param for remote wallet
|
|
14
|
+
delegation — all Interface methods delegate to the substrate when set (#452)
|
|
15
|
+
- `list_actions` and `list_outputs` honour `include_labels`, `include_inputs`,
|
|
16
|
+
and `include_outputs` flags (#448)
|
|
17
|
+
- `acquire_certificate` uses `AuthFetch` for BRC-104 authenticated certificate
|
|
18
|
+
issuance (#453)
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- `prove_certificate` now uses correct protocol ID (`'certificate field encryption'`)
|
|
22
|
+
and key ID format (`"#{serial_number} #{field_name}"`) matching TS/Go SDKs —
|
|
23
|
+
previously incompatible cross-SDK (#424)
|
|
24
|
+
- Code review findings addressed for substrates and include flags
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- `BSV::WalletInterface` module removed — `VERSION` now lives in `BSV::Wallet::VERSION`
|
|
28
|
+
where all other wallet constants already reside
|
|
29
|
+
|
|
30
|
+
## 0.7.0 — 2026-04-12
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- Pluggable `BroadcastQueue` interface module — duck-typed, follows the `StorageAdapter` pattern
|
|
34
|
+
- `InlineQueue` synchronous default adapter — consolidates broadcast and no-broadcaster paths
|
|
35
|
+
- `WalletClient` accepts `broadcast_queue:` constructor parameter (auto-creates `InlineQueue` when not provided)
|
|
36
|
+
- `BroadcastQueue.status_for_error` shared helper for consistent broadcast error mapping
|
|
37
|
+
- Integration specs for broadcast/rollback flows (20 specs)
|
|
38
|
+
- MemoryStore production warning — logs to stderr when `RACK_ENV`/`RAILS_ENV` is `production` or `staging`
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
- TOCTOU window on change outputs — stored as `:pending` directly, eliminating race with concurrent `auto_fund`
|
|
42
|
+
- Broadcast promotion failure no longer deletes confirmed on-chain outputs — only broadcast failure triggers rollback
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
- `accept_delayed_broadcast: true` no longer logs "not yet implemented" warning — handled by the queue adapter
|
|
46
|
+
- MemoryStore demoted to test/development only (production use triggers a suppressible warning)
|
|
47
|
+
|
|
8
48
|
## 0.6.0 — 2026-04-12
|
|
9
49
|
|
|
10
50
|
### Added
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BSV
|
|
4
|
+
module Wallet
|
|
5
|
+
# Duck-typed broadcast queue interface for wallet transaction dispatch.
|
|
6
|
+
#
|
|
7
|
+
# Include this module in broadcast queue adapters and override all instance
|
|
8
|
+
# methods that raise +NotImplementedError+. The +async?+ method may be
|
|
9
|
+
# overridden to return +true+ for adapters that defer execution to a
|
|
10
|
+
# background worker.
|
|
11
|
+
#
|
|
12
|
+
# == Payload contract
|
|
13
|
+
#
|
|
14
|
+
# The +enqueue+ method receives a +Hash+ with the following keys:
|
|
15
|
+
#
|
|
16
|
+
# {
|
|
17
|
+
# tx: BSV::Transaction, # signed transaction object
|
|
18
|
+
# txid: String, # hex txid
|
|
19
|
+
# beef_binary: String, # raw BEEF bytes
|
|
20
|
+
# input_outpoints: Array<String>, # locked input outpoints (nil on finalize path)
|
|
21
|
+
# change_outpoints: Array<String>, # change outpoints (nil on finalize path)
|
|
22
|
+
# fund_ref: String, # fund reference for rollback (nil on finalize path)
|
|
23
|
+
# accept_delayed_broadcast: Boolean # from caller options
|
|
24
|
+
# }
|
|
25
|
+
#
|
|
26
|
+
# When +input_outpoints+ is +nil+, the caller is on the finalize path and
|
|
27
|
+
# the adapter must skip UTXO state transitions.
|
|
28
|
+
#
|
|
29
|
+
# == Example
|
|
30
|
+
#
|
|
31
|
+
# class MyQueue
|
|
32
|
+
# include BSV::Wallet::BroadcastQueue
|
|
33
|
+
#
|
|
34
|
+
# def async?
|
|
35
|
+
# true
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# def enqueue(payload)
|
|
39
|
+
# MyWorker.perform_later(payload[:txid])
|
|
40
|
+
# { txid: payload[:txid] }
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# def status(txid)
|
|
44
|
+
# MyWorker.status_for(txid)
|
|
45
|
+
# end
|
|
46
|
+
# end
|
|
47
|
+
module BroadcastQueue
|
|
48
|
+
# Enqueues a transaction for broadcast and state promotion.
|
|
49
|
+
#
|
|
50
|
+
# For synchronous adapters this executes immediately and returns the
|
|
51
|
+
# result. For asynchronous adapters this persists the job and returns
|
|
52
|
+
# a partial result; the caller should treat the action as pending.
|
|
53
|
+
#
|
|
54
|
+
# @param _payload [Hash] broadcast payload (see module-level doc for keys)
|
|
55
|
+
# @return [Hash] result hash containing at minimum +:txid+
|
|
56
|
+
# @raise [NotImplementedError] unless overridden by the including class
|
|
57
|
+
def enqueue(_payload)
|
|
58
|
+
raise NotImplementedError, "#{self.class}#enqueue not implemented"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns +false+ by default, indicating synchronous execution.
|
|
62
|
+
#
|
|
63
|
+
# Override and return +true+ in adapters that defer broadcast to a
|
|
64
|
+
# background worker (e.g. SolidQueue, Sidekiq).
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def async?
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the broadcast status for a previously enqueued transaction.
|
|
72
|
+
#
|
|
73
|
+
# @param _txid [String] hex transaction identifier
|
|
74
|
+
# @return [String, nil] the current status string, or +nil+ if unknown
|
|
75
|
+
# @raise [NotImplementedError] unless overridden by the including class
|
|
76
|
+
def status(_txid)
|
|
77
|
+
raise NotImplementedError, "#{self.class}#status not implemented"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Maps a broadcast exception to a {ReviewActionResultStatus} string.
|
|
81
|
+
#
|
|
82
|
+
# This shared helper is extracted from +WalletClient#broadcast_status_for+
|
|
83
|
+
# so all queue adapters can produce consistent status strings without
|
|
84
|
+
# duplicating the mapping logic.
|
|
85
|
+
#
|
|
86
|
+
# @param error [StandardError] the exception raised during broadcast
|
|
87
|
+
# @return [String] one of +'doubleSpend'+, +'invalidTx'+, +'serviceError'+
|
|
88
|
+
def self.status_for_error(error)
|
|
89
|
+
return 'serviceError' unless error.is_a?(BSV::Network::BroadcastError)
|
|
90
|
+
|
|
91
|
+
arc_status = error.arc_status.to_s.upcase
|
|
92
|
+
return 'doubleSpend' if arc_status == 'DOUBLE_SPEND_ATTEMPTED'
|
|
93
|
+
|
|
94
|
+
invalid_statuses = %w[REJECTED INVALID MALFORMED MINED_IN_STALE_BLOCK]
|
|
95
|
+
return 'invalidTx' if invalid_statuses.include?(arc_status) || arc_status.include?('ORPHAN')
|
|
96
|
+
|
|
97
|
+
'serviceError'
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BSV
|
|
4
|
+
module Wallet
|
|
5
|
+
# Synchronous broadcast queue adapter — the default for +WalletClient+.
|
|
6
|
+
#
|
|
7
|
+
# +InlineQueue+ replicates the current wallet broadcast behaviour exactly:
|
|
8
|
+
#
|
|
9
|
+
# * With a broadcaster: calls +broadcaster.broadcast+, promotes UTXO state on
|
|
10
|
+
# success, rolls back on failure.
|
|
11
|
+
# * Without a broadcaster: promotes immediately and returns BEEF for the
|
|
12
|
+
# caller to broadcast manually (backwards-compatible fallback).
|
|
13
|
+
#
|
|
14
|
+
# Because this adapter executes synchronously, +async?+ returns +false+ and
|
|
15
|
+
# the caller can rely on the returned hash containing the final result.
|
|
16
|
+
class InlineQueue
|
|
17
|
+
include BSV::Wallet::BroadcastQueue
|
|
18
|
+
|
|
19
|
+
# @param broadcaster [#broadcast, nil] broadcaster object; +nil+ disables broadcasting
|
|
20
|
+
# @param storage [StorageAdapter] wallet storage adapter
|
|
21
|
+
def initialize(storage:, broadcaster: nil)
|
|
22
|
+
@broadcaster = broadcaster
|
|
23
|
+
@storage = storage
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns +false+ — this adapter executes synchronously.
|
|
27
|
+
#
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
def async?
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the broadcast status for a previously enqueued transaction.
|
|
34
|
+
#
|
|
35
|
+
# Delegates to storage and returns the action status field, or +nil+ if
|
|
36
|
+
# the action is not found.
|
|
37
|
+
#
|
|
38
|
+
# @param txid [String] hex transaction identifier
|
|
39
|
+
# @return [String, nil]
|
|
40
|
+
def status(txid)
|
|
41
|
+
actions = @storage.find_actions({ txid: txid, limit: 1, offset: 0 })
|
|
42
|
+
actions.first&.dig(:status)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Broadcasts and promotes (or just promotes) a transaction synchronously.
|
|
46
|
+
#
|
|
47
|
+
# Dispatches to +broadcast_and_promote+ when a broadcaster is configured,
|
|
48
|
+
# or +promote_without_broadcast+ when none is present.
|
|
49
|
+
#
|
|
50
|
+
# @param payload [Hash] broadcast payload (see +BroadcastQueue+ module docs)
|
|
51
|
+
# @return [Hash] result hash containing at minimum +:txid+ and +:tx+
|
|
52
|
+
def enqueue(payload)
|
|
53
|
+
if @broadcaster
|
|
54
|
+
broadcast_and_promote(payload)
|
|
55
|
+
else
|
|
56
|
+
promote_without_broadcast(payload)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Broadcasts the transaction and promotes storage state on success.
|
|
63
|
+
#
|
|
64
|
+
# On broadcast failure with outpoints present, rolls back all pending
|
|
65
|
+
# state (releases locked inputs, deletes change outputs, marks action
|
|
66
|
+
# failed). On failure without outpoints (finalize path), only updates
|
|
67
|
+
# the action status.
|
|
68
|
+
#
|
|
69
|
+
# INVARIANT: Only broadcast failure triggers rollback. If broadcast
|
|
70
|
+
# succeeds but promotion raises, the error propagates — confirmed
|
|
71
|
+
# on-chain outputs must never be deleted.
|
|
72
|
+
#
|
|
73
|
+
# @param payload [Hash] broadcast payload
|
|
74
|
+
# @return [Hash] result hash
|
|
75
|
+
def broadcast_and_promote(payload)
|
|
76
|
+
tx = payload[:tx]
|
|
77
|
+
txid = payload[:txid]
|
|
78
|
+
beef_binary = payload[:beef_binary]
|
|
79
|
+
input_outpoints = payload[:input_outpoints]
|
|
80
|
+
change_outpoints = payload[:change_outpoints]
|
|
81
|
+
fund_ref = payload[:fund_ref]
|
|
82
|
+
|
|
83
|
+
begin
|
|
84
|
+
broadcast_result = @broadcaster.broadcast(tx)
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
if input_outpoints
|
|
87
|
+
rollback(input_outpoints, change_outpoints, txid, fund_ref)
|
|
88
|
+
elsif txid
|
|
89
|
+
@storage.update_action_status(txid, 'failed')
|
|
90
|
+
end
|
|
91
|
+
return {
|
|
92
|
+
txid: txid,
|
|
93
|
+
tx: beef_binary.unpack('C*'),
|
|
94
|
+
broadcast_error: e.message,
|
|
95
|
+
broadcast_status: BroadcastQueue.status_for_error(e)
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Broadcast succeeded — promote all pending state to final.
|
|
100
|
+
promote(input_outpoints, change_outpoints, txid)
|
|
101
|
+
|
|
102
|
+
result = {
|
|
103
|
+
txid: txid,
|
|
104
|
+
tx: beef_binary.unpack('C*'),
|
|
105
|
+
broadcast_result: broadcast_result,
|
|
106
|
+
broadcast_status: 'success'
|
|
107
|
+
}
|
|
108
|
+
result[:competing_txs] = broadcast_result.competing_txs if broadcast_result.respond_to?(:competing_txs) && broadcast_result.competing_txs
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Promotes UTXO state without broadcasting — backwards-compatible fallback
|
|
113
|
+
# used when no broadcaster is configured.
|
|
114
|
+
#
|
|
115
|
+
# If +accept_delayed_broadcast+ is set the action status is +unproven+;
|
|
116
|
+
# otherwise it is +completed+.
|
|
117
|
+
#
|
|
118
|
+
# @param payload [Hash] broadcast payload
|
|
119
|
+
# @return [Hash] result hash containing +:txid+ and +:tx+
|
|
120
|
+
def promote_without_broadcast(payload)
|
|
121
|
+
txid = payload[:txid]
|
|
122
|
+
beef_binary = payload[:beef_binary]
|
|
123
|
+
input_outpoints = payload[:input_outpoints]
|
|
124
|
+
change_outpoints = payload[:change_outpoints]
|
|
125
|
+
delayed = payload[:accept_delayed_broadcast]
|
|
126
|
+
|
|
127
|
+
final_status = delayed ? 'unproven' : 'completed'
|
|
128
|
+
promote(input_outpoints, change_outpoints, txid, status: final_status)
|
|
129
|
+
|
|
130
|
+
{ txid: txid, tx: beef_binary.unpack('C*') }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Promotes UTXO state: marks inputs as +:spent+, change as +:spendable+,
|
|
134
|
+
# and updates the action status.
|
|
135
|
+
#
|
|
136
|
+
# When +outpoints+ arguments are +nil+ (finalize path), UTXO transitions
|
|
137
|
+
# are skipped and only the action status is updated.
|
|
138
|
+
#
|
|
139
|
+
# @param input_outpoints [Array<String>, nil]
|
|
140
|
+
# @param change_outpoints [Array<String>, nil]
|
|
141
|
+
# @param txid [String, nil]
|
|
142
|
+
# @param status [String]
|
|
143
|
+
def promote(input_outpoints, change_outpoints, txid, status: 'completed')
|
|
144
|
+
Array(input_outpoints).each { |op| @storage.update_output_state(op, :spent) }
|
|
145
|
+
Array(change_outpoints).each { |op| @storage.update_output_state(op, :spendable) }
|
|
146
|
+
@storage.update_action_status(txid, status) if txid
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Rolls back a pending auto-funded action.
|
|
150
|
+
#
|
|
151
|
+
# Releases locked inputs (only those matching +fund_ref+), deletes phantom
|
|
152
|
+
# change outputs, and marks the action as +failed+.
|
|
153
|
+
#
|
|
154
|
+
# @param input_outpoints [Array<String>] outpoints locked as inputs
|
|
155
|
+
# @param change_outpoints [Array<String>] change outputs to delete
|
|
156
|
+
# @param txid [String, nil] action txid
|
|
157
|
+
# @param fund_ref [String] fund reference used when locking inputs
|
|
158
|
+
def rollback(input_outpoints, change_outpoints, txid, fund_ref)
|
|
159
|
+
Array(input_outpoints).each do |op|
|
|
160
|
+
outputs = @storage.find_outputs({ outpoint: op, include_spent: true, limit: 1, offset: 0 })
|
|
161
|
+
next if outputs.empty?
|
|
162
|
+
next unless outputs.first[:pending_reference] == fund_ref
|
|
163
|
+
|
|
164
|
+
@storage.update_output_state(op, :spendable)
|
|
165
|
+
end
|
|
166
|
+
Array(change_outpoints).each { |op| @storage.delete_output(op) }
|
|
167
|
+
@storage.update_action_status(txid, 'failed') if txid
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module BSV
|
|
4
4
|
module Wallet
|
|
5
|
-
# In-memory storage adapter for testing and
|
|
5
|
+
# In-memory storage adapter intended for testing and development only.
|
|
6
6
|
#
|
|
7
7
|
# Stores actions, outputs, and certificates in plain Ruby arrays.
|
|
8
|
+
# All data is lost when the process exits — do not use in production.
|
|
9
|
+
# Use PostgresStore (or another persistent adapter) for production wallets.
|
|
8
10
|
#
|
|
9
11
|
# Thread safety: a +Mutex+ serialises all state-mutating operations so that
|
|
10
12
|
# concurrent threads cannot select and mark the same UTXO as pending.
|
|
@@ -12,9 +14,29 @@ module BSV
|
|
|
12
14
|
#
|
|
13
15
|
# NOTE: FileStore is NOT process-safe — concurrent processes share no
|
|
14
16
|
# in-memory lock and may read stale state from disk.
|
|
17
|
+
#
|
|
18
|
+
# == Production Warning
|
|
19
|
+
#
|
|
20
|
+
# When +RACK_ENV+, +RAILS_ENV+, or +APP_ENV+ is set to +production+ or
|
|
21
|
+
# +staging+, a warning is emitted to stderr. Suppress it with either:
|
|
22
|
+
#
|
|
23
|
+
# BSV_MEMORY_STORE_OK=1 # environment variable
|
|
24
|
+
# MemoryStore.warn_in_production = false # Ruby flag (e.g. in test setup)
|
|
15
25
|
class MemoryStore
|
|
16
26
|
include StorageAdapter
|
|
17
27
|
|
|
28
|
+
# Controls whether the production-environment warning is emitted.
|
|
29
|
+
# Set to +false+ in test suites to silence the warning globally.
|
|
30
|
+
#
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def self.warn_in_production?
|
|
33
|
+
@warn_in_production != false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
attr_writer :warn_in_production
|
|
38
|
+
end
|
|
39
|
+
|
|
18
40
|
def initialize
|
|
19
41
|
@actions = []
|
|
20
42
|
@outputs = []
|
|
@@ -23,6 +45,12 @@ module BSV
|
|
|
23
45
|
@transactions = {}
|
|
24
46
|
@settings = {}
|
|
25
47
|
@mutex = Mutex.new
|
|
48
|
+
|
|
49
|
+
return unless self.class.warn_in_production? && production_env?
|
|
50
|
+
|
|
51
|
+
warn '[bsv-wallet] MemoryStore is intended for testing only. ' \
|
|
52
|
+
'Use PostgresStore for production wallets. ' \
|
|
53
|
+
'Set BSV_MEMORY_STORE_OK=1 to silence this warning.'
|
|
26
54
|
end
|
|
27
55
|
|
|
28
56
|
def store_action(action_data)
|
|
@@ -255,6 +283,14 @@ module BSV
|
|
|
255
283
|
|
|
256
284
|
private
|
|
257
285
|
|
|
286
|
+
# Returns true when the process is running in a production or staging
|
|
287
|
+
# environment and the operator has not explicitly acknowledged MemoryStore
|
|
288
|
+
# usage via the +BSV_MEMORY_STORE_OK+ environment variable.
|
|
289
|
+
def production_env?
|
|
290
|
+
env = ENV['RACK_ENV'] || ENV['RAILS_ENV'] || ENV.fetch('APP_ENV', nil)
|
|
291
|
+
env && %w[production staging].include?(env) && !ENV['BSV_MEMORY_STORE_OK']
|
|
292
|
+
end
|
|
293
|
+
|
|
258
294
|
def filter_actions(query)
|
|
259
295
|
results = @actions
|
|
260
296
|
return results unless query[:labels]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module BSV
|
|
8
|
+
module Wallet
|
|
9
|
+
module Substrates
|
|
10
|
+
# BRC-100 wallet substrate that delegates all Interface methods to a remote
|
|
11
|
+
# wallet server via JSON-over-HTTP (POST #{base_url}/#{camelCaseMethodName}).
|
|
12
|
+
#
|
|
13
|
+
# Key conversion is handled by {BSV::WireFormat}: Ruby snake_case symbol keys
|
|
14
|
+
# in args are converted to camelCase strings before the request, and the
|
|
15
|
+
# camelCase JSON response is converted back to snake_case symbol keys.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# wallet = BSV::Wallet::Substrates::HTTPWalletJSON.new('http://localhost:3321',
|
|
19
|
+
# originator: 'myapp.example.com')
|
|
20
|
+
# result = wallet.get_public_key({ identity_key: true })
|
|
21
|
+
# # => { public_key: '02abc...' }
|
|
22
|
+
class HTTPWalletJSON
|
|
23
|
+
include BSV::Wallet::Interface
|
|
24
|
+
|
|
25
|
+
# Maps the 28 BRC-100 Interface method symbols to their camelCase HTTP endpoint names.
|
|
26
|
+
# Derived from Wire::Serializer::CALL_CODES keys via BSV::WireFormat.snake_to_camel.
|
|
27
|
+
METHOD_NAMES = BSV::Wallet::Wire::Serializer::CALL_CODES.keys.to_h do |sym|
|
|
28
|
+
[sym, BSV::WireFormat.snake_to_camel(sym.to_s)]
|
|
29
|
+
end.freeze
|
|
30
|
+
|
|
31
|
+
# @param base_url [String] base URL of the remote wallet server (e.g. 'http://localhost:3321')
|
|
32
|
+
# @param originator [String, nil] FQDN of the originating application (sent as Origin/Originator headers)
|
|
33
|
+
# @param http_client [Object, nil] injectable HTTP client for testing; must respond to
|
|
34
|
+
# `start(uri, &block)` returning a Net::HTTP-compatible response
|
|
35
|
+
def initialize(base_url, originator: nil, http_client: nil)
|
|
36
|
+
@base_url = base_url
|
|
37
|
+
@originator = originator
|
|
38
|
+
@http_client = http_client
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Per-call originator is accepted for Interface conformance but not forwarded.
|
|
42
|
+
# The Origin header uses the constructor-level @originator for the lifetime of
|
|
43
|
+
# the connection — matching the TS SDK's HTTPWalletJSON, which also ignores per-call
|
|
44
|
+
# originator. WalletWireTransceiver supports per-call originator because the wire
|
|
45
|
+
# frame encodes it per-message; HTTP substrates identify by connection, not by call.
|
|
46
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
|
47
|
+
|
|
48
|
+
def create_action(args, originator: nil)
|
|
49
|
+
call(METHOD_NAMES[:create_action], args)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def sign_action(args, originator: nil)
|
|
53
|
+
call(METHOD_NAMES[:sign_action], args)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def abort_action(args, originator: nil)
|
|
57
|
+
call(METHOD_NAMES[:abort_action], args)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def list_actions(args, originator: nil)
|
|
61
|
+
call(METHOD_NAMES[:list_actions], args)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def internalize_action(args, originator: nil)
|
|
65
|
+
call(METHOD_NAMES[:internalize_action], args)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def list_outputs(args, originator: nil)
|
|
69
|
+
call(METHOD_NAMES[:list_outputs], args)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def relinquish_output(args, originator: nil)
|
|
73
|
+
call(METHOD_NAMES[:relinquish_output], args)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def get_public_key(args, originator: nil)
|
|
77
|
+
call(METHOD_NAMES[:get_public_key], args)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reveal_counterparty_key_linkage(args, originator: nil)
|
|
81
|
+
call(METHOD_NAMES[:reveal_counterparty_key_linkage], args)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def reveal_specific_key_linkage(args, originator: nil)
|
|
85
|
+
call(METHOD_NAMES[:reveal_specific_key_linkage], args)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def encrypt(args, originator: nil)
|
|
89
|
+
call(METHOD_NAMES[:encrypt], args)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def decrypt(args, originator: nil)
|
|
93
|
+
call(METHOD_NAMES[:decrypt], args)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def create_hmac(args, originator: nil)
|
|
97
|
+
call(METHOD_NAMES[:create_hmac], args)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def verify_hmac(args, originator: nil)
|
|
101
|
+
call(METHOD_NAMES[:verify_hmac], args)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def create_signature(args, originator: nil)
|
|
105
|
+
call(METHOD_NAMES[:create_signature], args)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def verify_signature(args, originator: nil)
|
|
109
|
+
call(METHOD_NAMES[:verify_signature], args)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def acquire_certificate(args, originator: nil)
|
|
113
|
+
call(METHOD_NAMES[:acquire_certificate], args)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def list_certificates(args, originator: nil)
|
|
117
|
+
call(METHOD_NAMES[:list_certificates], args)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def prove_certificate(args, originator: nil)
|
|
121
|
+
call(METHOD_NAMES[:prove_certificate], args)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def relinquish_certificate(args, originator: nil)
|
|
125
|
+
call(METHOD_NAMES[:relinquish_certificate], args)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def discover_by_identity_key(args, originator: nil)
|
|
129
|
+
call(METHOD_NAMES[:discover_by_identity_key], args)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def discover_by_attributes(args, originator: nil)
|
|
133
|
+
call(METHOD_NAMES[:discover_by_attributes], args)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def is_authenticated(args = {}, originator: nil)
|
|
137
|
+
call(METHOD_NAMES[:is_authenticated], args)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def wait_for_authentication(args = {}, originator: nil)
|
|
141
|
+
call(METHOD_NAMES[:wait_for_authentication], args)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def get_height(args = {}, originator: nil)
|
|
145
|
+
call(METHOD_NAMES[:get_height], args)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def get_header_for_height(args, originator: nil)
|
|
149
|
+
call(METHOD_NAMES[:get_header_for_height], args)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def get_network(args = {}, originator: nil)
|
|
153
|
+
call(METHOD_NAMES[:get_network], args)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def get_version(args = {}, originator: nil)
|
|
157
|
+
call(METHOD_NAMES[:get_version], args)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# Posts args to the remote wallet endpoint for the given camelCase method name.
|
|
165
|
+
#
|
|
166
|
+
# Outbound: converts snake_case symbol keys to camelCase strings via WireFormat.to_wire.
|
|
167
|
+
# Inbound: converts camelCase string keys to snake_case symbols via WireFormat.from_wire.
|
|
168
|
+
# Errors: non-2xx response raises the appropriate WalletError subclass.
|
|
169
|
+
#
|
|
170
|
+
# @param method_name [String] camelCase endpoint name (e.g. 'getPublicKey')
|
|
171
|
+
# @param args [Hash] method arguments (snake_case symbol keys)
|
|
172
|
+
# @return [Hash] response with snake_case symbol keys
|
|
173
|
+
def call(method_name, args)
|
|
174
|
+
args ||= {}
|
|
175
|
+
wire_args = BSV::WireFormat.to_wire(args)
|
|
176
|
+
body = JSON.generate(wire_args)
|
|
177
|
+
|
|
178
|
+
uri = build_uri(method_name)
|
|
179
|
+
headers = build_headers
|
|
180
|
+
|
|
181
|
+
response = execute_request(uri, body, headers)
|
|
182
|
+
|
|
183
|
+
handle_response(response, method_name, args)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def build_uri(method_name)
|
|
187
|
+
URI.parse("#{@base_url}/#{method_name}")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_headers
|
|
191
|
+
h = {
|
|
192
|
+
'Content-Type' => 'application/json',
|
|
193
|
+
'Accept' => 'application/json'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if @originator
|
|
197
|
+
origin_value = to_origin_header(@originator)
|
|
198
|
+
h['Origin'] = origin_value
|
|
199
|
+
h['Originator'] = origin_value
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
h
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Converts an originator domain to a full Origin header value.
|
|
206
|
+
# Prepends 'http://' if no scheme is present (matching TS SDK toOriginHeader).
|
|
207
|
+
def to_origin_header(originator)
|
|
208
|
+
return originator if originator.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
|
|
209
|
+
|
|
210
|
+
"http://#{originator}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def execute_request(uri, body, headers)
|
|
214
|
+
if @http_client
|
|
215
|
+
@http_client.post(uri, body, headers)
|
|
216
|
+
else
|
|
217
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
|
218
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
|
219
|
+
request.body = body
|
|
220
|
+
http.request(request)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def handle_response(response, method_name, args)
|
|
226
|
+
code = response.code.to_i
|
|
227
|
+
|
|
228
|
+
unless (200..299).cover?(code)
|
|
229
|
+
data = begin
|
|
230
|
+
parse_json_body(response.body)
|
|
231
|
+
rescue JSON::ParserError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
raise_error_response(code, data, method_name, args)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
data = parse_json_body(response.body)
|
|
238
|
+
return {} if data.nil?
|
|
239
|
+
|
|
240
|
+
data.is_a?(Hash) ? BSV::WireFormat.from_wire(data) : data
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def parse_json_body(body)
|
|
244
|
+
return nil if body.nil? || body.empty?
|
|
245
|
+
|
|
246
|
+
JSON.parse(body)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def raise_error_response(code, data, method_name, _args)
|
|
250
|
+
if code == 400 && data.is_a?(Hash) && data['isError']
|
|
251
|
+
case data['code']
|
|
252
|
+
when 5
|
|
253
|
+
raise BSV::Wallet::WalletError.new(data['message'] || 'Review actions required', 5)
|
|
254
|
+
when 6
|
|
255
|
+
raise BSV::Wallet::InvalidParameterError, data['parameter'] || 'unknown'
|
|
256
|
+
when 7
|
|
257
|
+
raise BSV::Wallet::InsufficientFundsError, data['message']
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
message = (data.is_a?(Hash) && data['message']) ||
|
|
262
|
+
"HTTP #{code} error calling #{method_name}"
|
|
263
|
+
raise BSV::Wallet::WalletError, message
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|