bsv-wallet 0.6.0 → 0.7.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 +18 -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/version.rb +1 -1
- data/lib/bsv/wallet_interface/wallet_client.rb +111 -133
- data/lib/bsv/wallet_interface.rb +2 -0
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 233e4c969fea0e8bb52110ffffe9d034a953854ccb1098c0a3dd3eb76bb2c034
|
|
4
|
+
data.tar.gz: e11ab6c70e949cbe62593f620d0367225e80ac84a144f94b07418d02e9383466
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 974ffa3042e2bb4fe3c9f7ec94d8503c55ed20e94d5a808a667700339fbb5151eec6b12c185238353ccfd40e62f197477081fdb7b6b9f9ce75da4598a4536b90
|
|
7
|
+
data.tar.gz: 0af0b5d238c57a1b640ea243b2bce0e5e33fd55932354689a62b1c05dfe3b41d776fdfcd5fe245fa3973de98632d6f0985bbfc4bed99b4d2527494dc16fc2f6f
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ 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.7.0 — 2026-04-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Pluggable `BroadcastQueue` interface module — duck-typed, follows the `StorageAdapter` pattern
|
|
12
|
+
- `InlineQueue` synchronous default adapter — consolidates broadcast and no-broadcaster paths
|
|
13
|
+
- `WalletClient` accepts `broadcast_queue:` constructor parameter (auto-creates `InlineQueue` when not provided)
|
|
14
|
+
- `BroadcastQueue.status_for_error` shared helper for consistent broadcast error mapping
|
|
15
|
+
- Integration specs for broadcast/rollback flows (20 specs)
|
|
16
|
+
- MemoryStore production warning — logs to stderr when `RACK_ENV`/`RAILS_ENV` is `production` or `staging`
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- TOCTOU window on change outputs — stored as `:pending` directly, eliminating race with concurrent `auto_fund`
|
|
20
|
+
- Broadcast promotion failure no longer deletes confirmed on-chain outputs — only broadcast failure triggers rollback
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- `accept_delayed_broadcast: true` no longer logs "not yet implemented" warning — handled by the queue adapter
|
|
24
|
+
- MemoryStore demoted to test/development only (production use triggers a suppressible warning)
|
|
25
|
+
|
|
8
26
|
## 0.6.0 — 2026-04-12
|
|
9
27
|
|
|
10
28
|
### 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]
|
|
@@ -42,6 +42,9 @@ module BSV
|
|
|
42
42
|
# @return [#broadcast, nil] the optional broadcaster (responds to #broadcast(tx))
|
|
43
43
|
attr_reader :broadcaster
|
|
44
44
|
|
|
45
|
+
# @return [BroadcastQueue] the broadcast queue used to dispatch transactions
|
|
46
|
+
attr_reader :broadcast_queue
|
|
47
|
+
|
|
45
48
|
# @param key [BSV::Primitives::PrivateKey, String, KeyDeriver] signing key
|
|
46
49
|
# @param storage [StorageAdapter] persistence adapter (default: FileStore).
|
|
47
50
|
# Use +storage: MemoryStore.new+ for tests.
|
|
@@ -50,6 +53,7 @@ module BSV
|
|
|
50
53
|
# @param proof_store [ProofStore, nil] merkle proof store (default: LocalProofStore backed by storage)
|
|
51
54
|
# @param http_client [#request, nil] injectable HTTP client for certificate issuance
|
|
52
55
|
# @param broadcaster [#broadcast, nil] optional broadcaster; any object responding to #broadcast(tx)
|
|
56
|
+
# @param broadcast_queue [BroadcastQueue, nil] optional broadcast queue; defaults to InlineQueue
|
|
53
57
|
def initialize(
|
|
54
58
|
key,
|
|
55
59
|
storage: FileStore.new,
|
|
@@ -60,7 +64,8 @@ module BSV
|
|
|
60
64
|
fee_estimator: nil,
|
|
61
65
|
coin_selector: nil,
|
|
62
66
|
change_generator: nil,
|
|
63
|
-
broadcaster: nil
|
|
67
|
+
broadcaster: nil,
|
|
68
|
+
broadcast_queue: nil
|
|
64
69
|
)
|
|
65
70
|
super(key)
|
|
66
71
|
@storage = storage
|
|
@@ -74,6 +79,10 @@ module BSV
|
|
|
74
79
|
@injected_fee_estimator = fee_estimator
|
|
75
80
|
@injected_coin_selector = coin_selector
|
|
76
81
|
@injected_change_generator = change_generator
|
|
82
|
+
@broadcast_queue = broadcast_queue || InlineQueue.new(
|
|
83
|
+
storage: @storage,
|
|
84
|
+
broadcaster: @broadcaster
|
|
85
|
+
)
|
|
77
86
|
end
|
|
78
87
|
|
|
79
88
|
# Returns true when a broadcaster has been configured.
|
|
@@ -689,22 +698,12 @@ module BSV
|
|
|
689
698
|
@storage.store_transaction(txid, tx_hex)
|
|
690
699
|
|
|
691
700
|
if no_send
|
|
692
|
-
#
|
|
693
|
-
#
|
|
694
|
-
store_change_outputs(
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
# the TS SDK where noSend outputs have spendable: false.
|
|
699
|
-
change_outpoints = []
|
|
700
|
-
change_outputs.each do |spec|
|
|
701
|
-
idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
|
|
702
|
-
next unless idx
|
|
703
|
-
|
|
704
|
-
op = "#{txid}.#{idx}"
|
|
705
|
-
change_outpoints << op
|
|
706
|
-
@storage.update_output_state(op, :pending, pending_reference: fund_ref, no_send: true)
|
|
707
|
-
end
|
|
701
|
+
# Store change outputs directly as :pending with no_send: true so a
|
|
702
|
+
# concurrent auto-fund cannot select them. No flip loop needed.
|
|
703
|
+
change_outpoints = store_change_outputs(
|
|
704
|
+
txid, tx, change_outputs, tx_hex,
|
|
705
|
+
state: :pending, pending_reference: fund_ref, no_send: true
|
|
706
|
+
)
|
|
708
707
|
|
|
709
708
|
store_action(tx, args, status: 'nosend')
|
|
710
709
|
store_tracked_outputs(txid, tx, caller_outputs)
|
|
@@ -724,46 +723,25 @@ module BSV
|
|
|
724
723
|
else
|
|
725
724
|
# Store everything in pending state first — inputs are already locked
|
|
726
725
|
# as :pending by lock_utxos above, so we match that discipline here.
|
|
726
|
+
# Change outputs are stored directly as :pending to eliminate the
|
|
727
|
+
# TOCTOU window where a concurrent auto-fund could select them.
|
|
727
728
|
store_action(tx, args, status: 'pending')
|
|
728
|
-
store_change_outputs(
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
change_outpoints = []
|
|
733
|
-
change_outputs.each do |spec|
|
|
734
|
-
idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
|
|
735
|
-
next unless idx
|
|
736
|
-
|
|
737
|
-
op = "#{txid}.#{idx}"
|
|
738
|
-
change_outpoints << op
|
|
739
|
-
@storage.update_output_state(op, :pending, pending_reference: fund_ref)
|
|
740
|
-
end
|
|
729
|
+
change_outpoints = store_change_outputs(
|
|
730
|
+
txid, tx, change_outputs, tx_hex,
|
|
731
|
+
state: :pending, pending_reference: fund_ref
|
|
732
|
+
)
|
|
741
733
|
|
|
742
734
|
store_tracked_outputs(txid, tx, caller_outputs)
|
|
743
735
|
|
|
744
736
|
beef_binary = tx.to_beef
|
|
745
737
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
selected_outpoints.each { |op| @storage.update_output_state(op, :spent) }
|
|
754
|
-
change_outpoints.each { |op| @storage.update_output_state(op, :spendable) }
|
|
755
|
-
|
|
756
|
-
final_status = if args.dig(:options, :accept_delayed_broadcast)
|
|
757
|
-
warn '[bsv-wallet] accept_delayed_broadcast: true is not yet implemented; ' \
|
|
758
|
-
'falling through to synchronous processing. ' \
|
|
759
|
-
'Background broadcasting is a planned future feature.'
|
|
760
|
-
'unproven'
|
|
761
|
-
else
|
|
762
|
-
'completed'
|
|
763
|
-
end
|
|
764
|
-
@storage.update_action_status(txid, final_status)
|
|
765
|
-
{ txid: txid, tx: beef_binary.unpack('C*') }
|
|
766
|
-
end
|
|
738
|
+
@broadcast_queue.enqueue(
|
|
739
|
+
tx: tx, txid: txid, beef_binary: beef_binary,
|
|
740
|
+
input_outpoints: selected_outpoints,
|
|
741
|
+
change_outpoints: change_outpoints,
|
|
742
|
+
fund_ref: fund_ref,
|
|
743
|
+
accept_delayed_broadcast: args.dig(:options, :accept_delayed_broadcast)
|
|
744
|
+
)
|
|
767
745
|
end
|
|
768
746
|
rescue StandardError
|
|
769
747
|
# Release the pending lock so the UTXOs are available for retry.
|
|
@@ -913,40 +891,55 @@ module BSV
|
|
|
913
891
|
tx.add_output(output)
|
|
914
892
|
end
|
|
915
893
|
|
|
916
|
-
# Persists change outputs in storage
|
|
894
|
+
# Persists change outputs in storage with the specified state.
|
|
917
895
|
#
|
|
918
896
|
# Each output is stored with full BRC-29 derivation metadata so the
|
|
919
|
-
# wallet can derive the spending key later
|
|
920
|
-
#
|
|
897
|
+
# wallet can derive the spending key later. The +:state+ parameter
|
|
898
|
+
# controls the initial state — pass +:pending+ with a +:pending_reference+
|
|
899
|
+
# to store outputs atomically in the correct state and avoid any TOCTOU
|
|
900
|
+
# window where a concurrent auto-fund could select them as spendable.
|
|
921
901
|
#
|
|
922
902
|
# @param txid [String] hex txid of the signed transaction
|
|
923
903
|
# @param tx [BSV::Transaction::Transaction]
|
|
924
904
|
# @param change_specs [Array<Hash>] change descriptors from {ChangeGenerator}
|
|
925
905
|
# @param tx_hex [String] serialised transaction hex (used as source)
|
|
926
|
-
|
|
906
|
+
# @param state [Symbol] initial output state (default: +:spendable+)
|
|
907
|
+
# @param pending_reference [String, nil] reference tag for pending outputs
|
|
908
|
+
# @param no_send [Boolean] when +true+, marks the output as no-send pending
|
|
909
|
+
# @return [Array<String>] list of stored outpoints ("txid.vout" format)
|
|
910
|
+
def store_change_outputs(txid, tx, change_specs, tx_hex, state: :spendable, pending_reference: nil, no_send: false)
|
|
911
|
+
outpoints = []
|
|
927
912
|
change_specs.each do |spec|
|
|
928
913
|
actual_idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
|
|
929
914
|
next unless actual_idx
|
|
930
915
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
916
|
+
entry = change_output_entry(txid, actual_idx, spec, tx_hex, state, pending_reference, no_send)
|
|
917
|
+
@storage.store_output(entry)
|
|
918
|
+
outpoints << "#{txid}.#{actual_idx}"
|
|
919
|
+
end
|
|
920
|
+
outpoints
|
|
921
|
+
end
|
|
936
922
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
923
|
+
def change_output_entry(txid, idx, spec, tx_hex, state, pending_reference, no_send)
|
|
924
|
+
locking_script_hex = spec[:locking_script].is_a?(BSV::Script::Script) ? spec[:locking_script].to_hex : spec[:locking_script]
|
|
925
|
+
entry = {
|
|
926
|
+
outpoint: "#{txid}.#{idx}",
|
|
927
|
+
satoshis: spec[:satoshis],
|
|
928
|
+
locking_script: locking_script_hex,
|
|
929
|
+
basket: 'default',
|
|
930
|
+
tags: [],
|
|
931
|
+
derivation_prefix: spec[:derivation_prefix],
|
|
932
|
+
derivation_suffix: spec[:derivation_suffix],
|
|
933
|
+
sender_identity_key: spec[:sender_identity_key],
|
|
934
|
+
state: state,
|
|
935
|
+
source_tx_hex: tx_hex
|
|
936
|
+
}
|
|
937
|
+
if state == :pending
|
|
938
|
+
entry[:pending_since] = Time.now.utc.iso8601
|
|
939
|
+
entry[:pending_reference] = pending_reference if pending_reference
|
|
940
|
+
entry[:no_send] = true if no_send
|
|
949
941
|
end
|
|
942
|
+
entry
|
|
950
943
|
end
|
|
951
944
|
|
|
952
945
|
# Lazy accessors for auto-fund components. Created on first use so the
|
|
@@ -1217,8 +1210,11 @@ module BSV
|
|
|
1217
1210
|
@storage.update_action_status(txid, action_status) if txid && action_status
|
|
1218
1211
|
end
|
|
1219
1212
|
|
|
1220
|
-
#
|
|
1221
|
-
#
|
|
1213
|
+
# Delegates to the broadcast queue for auto-fund promotion.
|
|
1214
|
+
#
|
|
1215
|
+
# Kept as a thin wrapper so +promote_no_send+ callers (send_with path)
|
|
1216
|
+
# remain unaffected. The queue handles broadcast, UTXO promotion, and
|
|
1217
|
+
# rollback.
|
|
1222
1218
|
#
|
|
1223
1219
|
# @param tx [Transaction] signed transaction to broadcast
|
|
1224
1220
|
# @param txid [String] hex transaction id
|
|
@@ -1226,21 +1222,15 @@ module BSV
|
|
|
1226
1222
|
# @param change_outpoints [Array<String>] change output outpoints
|
|
1227
1223
|
# @param fund_ref [String] fund reference used when locking
|
|
1228
1224
|
# @param beef_binary [String] raw BEEF bytes
|
|
1229
|
-
# @return [Hash] result hash
|
|
1225
|
+
# @return [Hash] result hash from +BroadcastQueue#enqueue+
|
|
1230
1226
|
def broadcast_and_promote(tx, txid, input_outpoints, change_outpoints, fund_ref, beef_binary)
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
result = { txid: txid, tx: beef_binary.unpack('C*'), broadcast_result: broadcast_result, broadcast_status: 'success' }
|
|
1239
|
-
result[:competing_txs] = broadcast_result.competing_txs if broadcast_result.competing_txs
|
|
1240
|
-
result
|
|
1241
|
-
rescue StandardError => e
|
|
1242
|
-
rollback_pending_action(input_outpoints, change_outpoints, txid, fund_ref, action_status: 'failed')
|
|
1243
|
-
{ txid: txid, tx: beef_binary.unpack('C*'), broadcast_error: e.message, broadcast_status: broadcast_status_for(e) }
|
|
1227
|
+
@broadcast_queue.enqueue(
|
|
1228
|
+
tx: tx, txid: txid, beef_binary: beef_binary,
|
|
1229
|
+
input_outpoints: input_outpoints,
|
|
1230
|
+
change_outpoints: change_outpoints,
|
|
1231
|
+
fund_ref: fund_ref,
|
|
1232
|
+
accept_delayed_broadcast: false
|
|
1233
|
+
)
|
|
1244
1234
|
end
|
|
1245
1235
|
|
|
1246
1236
|
# Batch-broadcasts a list of previously no_send transactions.
|
|
@@ -1292,10 +1282,29 @@ module BSV
|
|
|
1292
1282
|
# Attempts to broadcast and promote a single no_send transaction.
|
|
1293
1283
|
# Action status is set to 'unproven' (matching TS SDK SendWithResult)
|
|
1294
1284
|
# because the transaction has been submitted but not yet confirmed.
|
|
1285
|
+
#
|
|
1286
|
+
# INVARIANT: Only broadcast failure triggers rollback. If broadcast succeeds
|
|
1287
|
+
# but promotion raises, the error propagates — confirmed on-chain outputs must
|
|
1288
|
+
# never be deleted. See +broadcast_and_promote+ for the same invariant.
|
|
1295
1289
|
def promote_no_send(tx, txid, fund_ref, pending_entry)
|
|
1296
|
-
|
|
1290
|
+
begin
|
|
1291
|
+
@broadcaster.broadcast(tx)
|
|
1292
|
+
rescue StandardError => e
|
|
1293
|
+
rollback_pending_action(
|
|
1294
|
+
pending_entry[:locked_outpoints],
|
|
1295
|
+
pending_entry[:change_outpoints],
|
|
1296
|
+
txid,
|
|
1297
|
+
fund_ref,
|
|
1298
|
+
action_status: 'failed'
|
|
1299
|
+
)
|
|
1300
|
+
@pending.delete(fund_ref)
|
|
1301
|
+
@pending_by_txid.delete(txid)
|
|
1302
|
+
return { txid: txid, status: 'failed', error: e.message }
|
|
1303
|
+
end
|
|
1297
1304
|
|
|
1298
1305
|
# Broadcast succeeded — promote state and remove from pending indices.
|
|
1306
|
+
# Do NOT rescue here: if promotion fails, the transaction is confirmed on-chain
|
|
1307
|
+
# and outputs must not be deleted. Let the error propagate to the caller.
|
|
1299
1308
|
pending_entry[:locked_outpoints].each { |op| @storage.update_output_state(op, :spent) }
|
|
1300
1309
|
pending_entry[:change_outpoints].each { |op| @storage.update_output_state(op, :spendable) }
|
|
1301
1310
|
@storage.update_action_status(txid, 'unproven')
|
|
@@ -1303,34 +1312,17 @@ module BSV
|
|
|
1303
1312
|
@pending_by_txid.delete(txid)
|
|
1304
1313
|
|
|
1305
1314
|
{ txid: txid, status: 'unproven' }
|
|
1306
|
-
rescue StandardError => e
|
|
1307
|
-
rollback_pending_action(
|
|
1308
|
-
pending_entry[:locked_outpoints],
|
|
1309
|
-
pending_entry[:change_outpoints],
|
|
1310
|
-
txid,
|
|
1311
|
-
fund_ref,
|
|
1312
|
-
action_status: 'failed'
|
|
1313
|
-
)
|
|
1314
|
-
@pending.delete(fund_ref)
|
|
1315
|
-
@pending_by_txid.delete(txid)
|
|
1316
|
-
|
|
1317
|
-
{ txid: txid, status: 'failed', error: e.message }
|
|
1318
1315
|
end
|
|
1319
1316
|
|
|
1320
1317
|
# Maps a broadcast exception to a {ReviewActionResultStatus} string.
|
|
1321
1318
|
#
|
|
1319
|
+
# Delegates to +BroadcastQueue.status_for_error+ for a single source of
|
|
1320
|
+
# truth across all queue adapters.
|
|
1321
|
+
#
|
|
1322
1322
|
# @param error [StandardError] the exception raised during broadcast
|
|
1323
1323
|
# @return [String] one of 'doubleSpend', 'invalidTx', 'serviceError'
|
|
1324
1324
|
def broadcast_status_for(error)
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
arc_status = error.arc_status.to_s.upcase
|
|
1328
|
-
return 'doubleSpend' if arc_status == 'DOUBLE_SPEND_ATTEMPTED'
|
|
1329
|
-
|
|
1330
|
-
invalid_statuses = %w[REJECTED INVALID MALFORMED MINED_IN_STALE_BLOCK]
|
|
1331
|
-
return 'invalidTx' if invalid_statuses.include?(arc_status) || arc_status.include?('ORPHAN')
|
|
1332
|
-
|
|
1333
|
-
'serviceError'
|
|
1325
|
+
BroadcastQueue.status_for_error(error)
|
|
1334
1326
|
end
|
|
1335
1327
|
|
|
1336
1328
|
def finalize_action(tx, args)
|
|
@@ -1355,30 +1347,16 @@ module BSV
|
|
|
1355
1347
|
|
|
1356
1348
|
if no_send
|
|
1357
1349
|
result[:no_send_change] = []
|
|
1358
|
-
elsif broadcast_enabled?
|
|
1359
|
-
# Broadcast and promote or rollback — same discipline as auto_fund path.
|
|
1360
|
-
broadcast_result = nil
|
|
1361
|
-
begin
|
|
1362
|
-
broadcast_result = @broadcaster.broadcast(tx)
|
|
1363
|
-
@storage.update_action_status(txid, 'completed')
|
|
1364
|
-
result[:broadcast_result] = broadcast_result
|
|
1365
|
-
result[:broadcast_status] = 'success'
|
|
1366
|
-
rescue StandardError => e
|
|
1367
|
-
@storage.update_action_status(txid, 'failed')
|
|
1368
|
-
result[:broadcast_error] = e.message
|
|
1369
|
-
result[:broadcast_status] = broadcast_status_for(e)
|
|
1370
|
-
end
|
|
1371
1350
|
else
|
|
1372
|
-
#
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
@storage.update_action_status(txid, final_status)
|
|
1351
|
+
# The queue handles both broadcaster-present and no-broadcaster cases
|
|
1352
|
+
# internally, so no broadcast_enabled? check is needed here.
|
|
1353
|
+
result.merge!(
|
|
1354
|
+
@broadcast_queue.enqueue(
|
|
1355
|
+
tx: tx, txid: txid, beef_binary: beef_binary,
|
|
1356
|
+
input_outpoints: nil, change_outpoints: nil, fund_ref: nil,
|
|
1357
|
+
accept_delayed_broadcast: delayed
|
|
1358
|
+
)
|
|
1359
|
+
)
|
|
1382
1360
|
end
|
|
1383
1361
|
|
|
1384
1362
|
result
|
data/lib/bsv/wallet_interface.rb
CHANGED
|
@@ -12,6 +12,8 @@ module BSV
|
|
|
12
12
|
autoload :ProtoWallet, 'bsv/wallet_interface/proto_wallet'
|
|
13
13
|
autoload :Validators, 'bsv/wallet_interface/validators'
|
|
14
14
|
autoload :StorageAdapter, 'bsv/wallet_interface/storage_adapter'
|
|
15
|
+
autoload :BroadcastQueue, 'bsv/wallet_interface/broadcast_queue'
|
|
16
|
+
autoload :InlineQueue, 'bsv/wallet_interface/inline_queue'
|
|
15
17
|
autoload :MemoryStore, 'bsv/wallet_interface/memory_store'
|
|
16
18
|
autoload :FileStore, 'bsv/wallet_interface/file_store'
|
|
17
19
|
autoload :ProofStore, 'bsv/wallet_interface/proof_store'
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bsv-wallet
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Simon Bettison
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: base64
|
|
@@ -53,6 +53,7 @@ files:
|
|
|
53
53
|
- LICENSE
|
|
54
54
|
- lib/bsv-wallet.rb
|
|
55
55
|
- lib/bsv/wallet_interface.rb
|
|
56
|
+
- lib/bsv/wallet_interface/broadcast_queue.rb
|
|
56
57
|
- lib/bsv/wallet_interface/certificate_signature.rb
|
|
57
58
|
- lib/bsv/wallet_interface/chain_provider.rb
|
|
58
59
|
- lib/bsv/wallet_interface/change_generator.rb
|
|
@@ -65,6 +66,7 @@ files:
|
|
|
65
66
|
- lib/bsv/wallet_interface/fee_estimator.rb
|
|
66
67
|
- lib/bsv/wallet_interface/fee_model.rb
|
|
67
68
|
- lib/bsv/wallet_interface/file_store.rb
|
|
69
|
+
- lib/bsv/wallet_interface/inline_queue.rb
|
|
68
70
|
- lib/bsv/wallet_interface/interface.rb
|
|
69
71
|
- lib/bsv/wallet_interface/key_deriver.rb
|
|
70
72
|
- lib/bsv/wallet_interface/local_proof_store.rb
|
|
@@ -103,7 +105,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
103
105
|
- !ruby/object:Gem::Version
|
|
104
106
|
version: '0'
|
|
105
107
|
requirements: []
|
|
106
|
-
rubygems_version:
|
|
108
|
+
rubygems_version: 4.0.10
|
|
107
109
|
specification_version: 4
|
|
108
110
|
summary: BRC-100 Wallet Interface for the BSV Blockchain
|
|
109
111
|
test_files: []
|