bsv-wallet 0.5.1 → 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 +46 -0
- data/lib/bsv/wallet_interface/broadcast_queue.rb +101 -0
- data/lib/bsv/wallet_interface/fee_estimator.rb +26 -10
- data/lib/bsv/wallet_interface/file_store.rb +12 -0
- data/lib/bsv/wallet_interface/inline_queue.rb +171 -0
- data/lib/bsv/wallet_interface/key_deriver.rb +2 -0
- data/lib/bsv/wallet_interface/memory_store.rb +53 -1
- data/lib/bsv/wallet_interface/storage_adapter.rb +20 -0
- data/lib/bsv/wallet_interface/version.rb +1 -1
- data/lib/bsv/wallet_interface/wallet_client.rb +286 -59
- 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,52 @@ 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
|
+
|
|
26
|
+
## 0.6.0 — 2026-04-12
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- Broadcast-before-promote semantics for `create_action` — transactions are
|
|
30
|
+
broadcast via a configurable `broadcaster:` before promoting state, with
|
|
31
|
+
automatic rollback on failure to prevent phantom UTXOs (#369, #371)
|
|
32
|
+
- `send_with` for batched broadcast of previously `no_send` transactions,
|
|
33
|
+
with per-transaction rollback on failure (#373)
|
|
34
|
+
- `accept_delayed_broadcast` option accepted as a stub (defaults to `false`;
|
|
35
|
+
background broadcasting deferred to a follow-up) (#374)
|
|
36
|
+
- `delete_action` and `update_action_status` on `StorageAdapter`, implemented
|
|
37
|
+
in MemoryStore, FileStore, and PostgresStore (#370)
|
|
38
|
+
- Broadcast results mapped to `ReviewActionResultStatus` (`success`,
|
|
39
|
+
`doubleSpend`, `invalidTx`, `serviceError`) and returned in the result
|
|
40
|
+
hash (#372)
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- EF version marker overhead corrected from 2 to 6 bytes in fee estimation
|
|
44
|
+
- Fee estimation now includes EF overhead for ARC compatibility
|
|
45
|
+
- `FeeEstimator` default raised from 1 sat/kB to 100 sat/kB
|
|
46
|
+
- `internalize_payment` now stores outputs in the default basket
|
|
47
|
+
- `derivation_type` comparison uses string comparison for JSON round-trip
|
|
48
|
+
safety (#367)
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
- Extracted `DEFAULT_SATS_PER_KB` constant for fee estimation
|
|
52
|
+
- Specs reorganised into per-gem directories (#363)
|
|
53
|
+
|
|
8
54
|
## 0.5.1 — 2026-04-11
|
|
9
55
|
|
|
10
56
|
### Fixed
|
|
@@ -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
|
|
@@ -8,40 +8,56 @@ module BSV
|
|
|
8
8
|
# fee estimation given only input and output counts. Once a real {Transaction} is
|
|
9
9
|
# available, delegates directly to the underlying SDK fee model via {#estimate_for_tx}.
|
|
10
10
|
#
|
|
11
|
-
# The default rate is
|
|
12
|
-
#
|
|
11
|
+
# The default rate is 100 sat/kB, matching the ARC mining policy endpoint
|
|
12
|
+
# and the SDK's own {SatoshisPerKilobyte} default.
|
|
13
13
|
#
|
|
14
14
|
# @example Pre-construction estimate
|
|
15
15
|
# estimator = BSV::Wallet::FeeEstimator.new
|
|
16
16
|
# fee = estimator.estimate(p2pkh_inputs: 2, p2pkh_outputs: 3)
|
|
17
|
-
# # =>
|
|
17
|
+
# # => 41 (at 100 sat/kB, ceil(408/1000 * 100) = 41)
|
|
18
18
|
#
|
|
19
19
|
# @example Custom rate
|
|
20
20
|
# estimator = BSV::Wallet::FeeEstimator.new(sats_per_kb: 50)
|
|
21
21
|
# fee = estimator.estimate(p2pkh_inputs: 1, p2pkh_outputs: 1)
|
|
22
22
|
# # => 10 (ceil(192/1000 * 50) = 10)
|
|
23
23
|
class FeeEstimator
|
|
24
|
-
# Estimated size in bytes of an unsigned P2PKH input.
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
# Estimated size in bytes of an unsigned P2PKH input in raw format.
|
|
25
|
+
P2PKH_RAW_INPUT_SIZE = BSV::Transaction::Transaction::UNSIGNED_P2PKH_INPUT_SIZE
|
|
26
|
+
|
|
27
|
+
# Extended Format (BRC-30/EF) adds source_satoshis (8 bytes) +
|
|
28
|
+
# varint(25) (1 byte) + P2PKH locking script (25 bytes) = 34 bytes
|
|
29
|
+
# per input. ARC validates fees against the EF size, not raw.
|
|
30
|
+
EF_INPUT_OVERHEAD = 34
|
|
31
|
+
|
|
32
|
+
# Total estimated input size including EF overhead. This is the size
|
|
33
|
+
# ARC sees and charges fees against.
|
|
34
|
+
P2PKH_INPUT_SIZE = P2PKH_RAW_INPUT_SIZE + EF_INPUT_OVERHEAD
|
|
27
35
|
|
|
28
36
|
# Estimated size in bytes of a P2PKH output (8 satoshis + varint(25) + 25-byte script).
|
|
29
37
|
P2PKH_OUTPUT_SIZE = 34
|
|
30
38
|
|
|
31
|
-
#
|
|
32
|
-
|
|
39
|
+
# EF version marker: 6 bytes (\x00\x00\x00\x00\x00\xEF) follows
|
|
40
|
+
# the 4-byte version field, adding 6 bytes of fixed overhead.
|
|
41
|
+
EF_VERSION_OVERHEAD = 6
|
|
42
|
+
|
|
43
|
+
# Fixed overhead in bytes for version (4) + EF marker (6) + lock_time (4).
|
|
44
|
+
FIXED_OVERHEAD = 14
|
|
33
45
|
|
|
34
46
|
# Approximate overhead including typical 1-byte varints for input/output
|
|
35
47
|
# counts. Retained for backward compatibility with code that referenced
|
|
36
48
|
# +FeeModel::OVERHEAD+.
|
|
37
49
|
OVERHEAD = 10
|
|
38
50
|
|
|
51
|
+
# Default fee rate in satoshis per kilobyte, matching ARC's mining
|
|
52
|
+
# policy endpoint (/v1/policy → miningFee 100/1000).
|
|
53
|
+
DEFAULT_SATS_PER_KB = 100
|
|
54
|
+
|
|
39
55
|
# @return [Integer] the satoshis-per-kilobyte rate used for estimation
|
|
40
56
|
attr_reader :sats_per_kb
|
|
41
57
|
|
|
42
|
-
# @param sats_per_kb [Integer] fee rate in satoshis per kilobyte
|
|
58
|
+
# @param sats_per_kb [Integer] fee rate in satoshis per kilobyte
|
|
43
59
|
# @raise [ArgumentError] if sats_per_kb is zero or negative
|
|
44
|
-
def initialize(sats_per_kb:
|
|
60
|
+
def initialize(sats_per_kb: DEFAULT_SATS_PER_KB)
|
|
45
61
|
raise ArgumentError, 'sats_per_kb must be greater than zero' unless sats_per_kb.positive?
|
|
46
62
|
|
|
47
63
|
@sats_per_kb = sats_per_kb
|
|
@@ -48,6 +48,18 @@ module BSV
|
|
|
48
48
|
result
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
def update_action_status(txid, new_status)
|
|
52
|
+
result = super
|
|
53
|
+
save_actions
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def delete_action(txid)
|
|
58
|
+
result = super
|
|
59
|
+
save_actions if result
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
|
|
51
63
|
def store_output(output_data)
|
|
52
64
|
result = super
|
|
53
65
|
save_outputs
|
|
@@ -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)
|
|
@@ -30,6 +58,22 @@ module BSV
|
|
|
30
58
|
action_data
|
|
31
59
|
end
|
|
32
60
|
|
|
61
|
+
def update_action_status(txid, new_status)
|
|
62
|
+
action = @actions.find { |a| a[:txid] == txid }
|
|
63
|
+
raise WalletError, "Action not found: #{txid}" unless action
|
|
64
|
+
|
|
65
|
+
action[:status] = new_status
|
|
66
|
+
action
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def delete_action(txid)
|
|
70
|
+
idx = @actions.index { |a| a[:txid] == txid }
|
|
71
|
+
return false unless idx
|
|
72
|
+
|
|
73
|
+
@actions.delete_at(idx)
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
33
77
|
def find_actions(query)
|
|
34
78
|
apply_pagination(filter_actions(query), query)
|
|
35
79
|
end
|
|
@@ -239,6 +283,14 @@ module BSV
|
|
|
239
283
|
|
|
240
284
|
private
|
|
241
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
|
+
|
|
242
294
|
def filter_actions(query)
|
|
243
295
|
results = @actions
|
|
244
296
|
return results unless query[:labels]
|
|
@@ -67,6 +67,26 @@ module BSV
|
|
|
67
67
|
raise NotImplementedError, "#{self.class}#find_transaction not implemented"
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# Updates the status field of a stored action identified by txid.
|
|
71
|
+
#
|
|
72
|
+
# @param _txid [String] the transaction identifier of the action to update
|
|
73
|
+
# @param _new_status [String] the new status value (e.g. 'failed', 'completed')
|
|
74
|
+
# @return [Hash] the updated action hash
|
|
75
|
+
# @raise [BSV::Wallet::WalletError] if no action with the given txid is found
|
|
76
|
+
# @raise [NotImplementedError]
|
|
77
|
+
def update_action_status(_txid, _new_status)
|
|
78
|
+
raise NotImplementedError, "#{self.class}#update_action_status not implemented"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Removes a stored action by txid.
|
|
82
|
+
#
|
|
83
|
+
# @param _txid [String] the transaction identifier of the action to remove
|
|
84
|
+
# @return [Boolean] true if the action was deleted, false if not found
|
|
85
|
+
# @raise [NotImplementedError]
|
|
86
|
+
def delete_action(_txid)
|
|
87
|
+
raise NotImplementedError, "#{self.class}#delete_action not implemented"
|
|
88
|
+
end
|
|
89
|
+
|
|
70
90
|
# Transitions the state of an existing output.
|
|
71
91
|
#
|
|
72
92
|
# When +new_state+ is +:pending+, pass a +pending_reference:+ string to
|
|
@@ -39,6 +39,12 @@ module BSV
|
|
|
39
39
|
# @return [ProofStore] the merkle proof persistence store
|
|
40
40
|
attr_reader :proof_store
|
|
41
41
|
|
|
42
|
+
# @return [#broadcast, nil] the optional broadcaster (responds to #broadcast(tx))
|
|
43
|
+
attr_reader :broadcaster
|
|
44
|
+
|
|
45
|
+
# @return [BroadcastQueue] the broadcast queue used to dispatch transactions
|
|
46
|
+
attr_reader :broadcast_queue
|
|
47
|
+
|
|
42
48
|
# @param key [BSV::Primitives::PrivateKey, String, KeyDeriver] signing key
|
|
43
49
|
# @param storage [StorageAdapter] persistence adapter (default: FileStore).
|
|
44
50
|
# Use +storage: MemoryStore.new+ for tests.
|
|
@@ -46,6 +52,8 @@ module BSV
|
|
|
46
52
|
# @param chain_provider [ChainProvider] blockchain data provider (default: NullChainProvider)
|
|
47
53
|
# @param proof_store [ProofStore, nil] merkle proof store (default: LocalProofStore backed by storage)
|
|
48
54
|
# @param http_client [#request, nil] injectable HTTP client for certificate issuance
|
|
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
|
|
49
57
|
def initialize(
|
|
50
58
|
key,
|
|
51
59
|
storage: FileStore.new,
|
|
@@ -55,7 +63,9 @@ module BSV
|
|
|
55
63
|
http_client: nil,
|
|
56
64
|
fee_estimator: nil,
|
|
57
65
|
coin_selector: nil,
|
|
58
|
-
change_generator: nil
|
|
66
|
+
change_generator: nil,
|
|
67
|
+
broadcaster: nil,
|
|
68
|
+
broadcast_queue: nil
|
|
59
69
|
)
|
|
60
70
|
super(key)
|
|
61
71
|
@storage = storage
|
|
@@ -63,10 +73,21 @@ module BSV
|
|
|
63
73
|
@chain_provider = chain_provider
|
|
64
74
|
@proof_store = proof_store || LocalProofStore.new(storage)
|
|
65
75
|
@http_client = http_client
|
|
76
|
+
@broadcaster = broadcaster
|
|
66
77
|
@pending = {}
|
|
78
|
+
@pending_by_txid = {}
|
|
67
79
|
@injected_fee_estimator = fee_estimator
|
|
68
80
|
@injected_coin_selector = coin_selector
|
|
69
81
|
@injected_change_generator = change_generator
|
|
82
|
+
@broadcast_queue = broadcast_queue || InlineQueue.new(
|
|
83
|
+
storage: @storage,
|
|
84
|
+
broadcaster: @broadcaster
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns true when a broadcaster has been configured.
|
|
89
|
+
def broadcast_enabled?
|
|
90
|
+
!@broadcaster.nil?
|
|
70
91
|
end
|
|
71
92
|
|
|
72
93
|
# --- Transaction Operations ---
|
|
@@ -92,11 +113,29 @@ module BSV
|
|
|
92
113
|
def create_action(args, _originator: nil)
|
|
93
114
|
validate_create_action!(args)
|
|
94
115
|
|
|
116
|
+
send_with_txids = Array(args.dig(:options, :send_with))
|
|
117
|
+
|
|
95
118
|
outputs = args[:outputs] || []
|
|
96
119
|
inputs = args[:inputs]
|
|
97
120
|
|
|
121
|
+
# When send_with is provided but no new transaction body is specified,
|
|
122
|
+
# broadcast only the batched no_send transactions.
|
|
123
|
+
if !send_with_txids.empty? && outputs.empty? && (inputs.nil? || inputs.empty?)
|
|
124
|
+
raise WalletError, 'A broadcaster is required to use send_with' unless broadcast_enabled?
|
|
125
|
+
|
|
126
|
+
return { send_with_results: broadcast_send_with(send_with_txids) }
|
|
127
|
+
end
|
|
128
|
+
|
|
98
129
|
if (inputs.nil? || inputs.empty?) && !outputs.empty? && (args[:auto_fund] || spendable_pool_eligible?)
|
|
99
|
-
|
|
130
|
+
result = auto_fund_and_create(args, outputs)
|
|
131
|
+
# If send_with was also specified, batch-broadcast those alongside the
|
|
132
|
+
# current transaction's implicit broadcast.
|
|
133
|
+
unless send_with_txids.empty?
|
|
134
|
+
raise WalletError, 'A broadcaster is required to use send_with' unless broadcast_enabled?
|
|
135
|
+
|
|
136
|
+
result[:send_with_results] = broadcast_send_with(send_with_txids)
|
|
137
|
+
end
|
|
138
|
+
return result
|
|
100
139
|
end
|
|
101
140
|
|
|
102
141
|
beef = parse_input_beef(args[:input_beef])
|
|
@@ -125,7 +164,15 @@ module BSV
|
|
|
125
164
|
tx = pending[:tx]
|
|
126
165
|
apply_spends(tx, args[:spends])
|
|
127
166
|
@pending.delete(reference)
|
|
128
|
-
|
|
167
|
+
|
|
168
|
+
# Merge sign_action's own options over the original create_action args so
|
|
169
|
+
# callers can supply accept_delayed_broadcast at sign time.
|
|
170
|
+
merged_args = if args[:options]
|
|
171
|
+
pending[:args].merge(options: (pending[:args][:options] || {}).merge(args[:options]))
|
|
172
|
+
else
|
|
173
|
+
pending[:args]
|
|
174
|
+
end
|
|
175
|
+
finalize_action(tx, merged_args)
|
|
129
176
|
end
|
|
130
177
|
|
|
131
178
|
# Aborts a pending signable transaction.
|
|
@@ -143,11 +190,14 @@ module BSV
|
|
|
143
190
|
raise WalletError, 'Transaction not found for the given reference' unless @pending.key?(reference)
|
|
144
191
|
|
|
145
192
|
pending_entry = @pending.delete(reference)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
193
|
+
txid = pending_entry[:tx]&.txid_hex
|
|
194
|
+
@pending_by_txid.delete(txid) if txid
|
|
195
|
+
rollback_pending_action(
|
|
196
|
+
pending_entry[:locked_outpoints],
|
|
197
|
+
pending_entry[:change_outpoints],
|
|
198
|
+
txid,
|
|
199
|
+
reference
|
|
200
|
+
)
|
|
151
201
|
{ aborted: true }
|
|
152
202
|
end
|
|
153
203
|
|
|
@@ -347,7 +397,7 @@ module BSV
|
|
|
347
397
|
# @return [Integer] total auto-spendable satoshis
|
|
348
398
|
def spendable_balance(basket: nil)
|
|
349
399
|
@storage.find_spendable_outputs(basket: basket)
|
|
350
|
-
.select { |o| (o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) || o[:derivation_type] ==
|
|
400
|
+
.select { |o| (o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) || o[:derivation_type]&.to_s == 'identity' }
|
|
351
401
|
.sum { |o| o[:satoshis].to_i }
|
|
352
402
|
end
|
|
353
403
|
|
|
@@ -613,7 +663,7 @@ module BSV
|
|
|
613
663
|
all_spendable = @storage.find_spendable_outputs(basket: 'default')
|
|
614
664
|
available = all_spendable.select do |o|
|
|
615
665
|
(o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) ||
|
|
616
|
-
o[:derivation_type] ==
|
|
666
|
+
o[:derivation_type]&.to_s == 'identity'
|
|
617
667
|
end
|
|
618
668
|
|
|
619
669
|
selection = auto_fund_select(available, target, caller_outputs.size)
|
|
@@ -648,46 +698,50 @@ module BSV
|
|
|
648
698
|
@storage.store_transaction(txid, tx_hex)
|
|
649
699
|
|
|
650
700
|
if no_send
|
|
651
|
-
#
|
|
652
|
-
#
|
|
653
|
-
store_change_outputs(
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
# the TS SDK where noSend outputs have spendable: false.
|
|
658
|
-
change_outpoints = []
|
|
659
|
-
change_outputs.each do |spec|
|
|
660
|
-
idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
|
|
661
|
-
next unless idx
|
|
662
|
-
|
|
663
|
-
op = "#{txid}.#{idx}"
|
|
664
|
-
change_outpoints << op
|
|
665
|
-
@storage.update_output_state(op, :pending, pending_reference: fund_ref, no_send: true)
|
|
666
|
-
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
|
+
)
|
|
667
707
|
|
|
668
708
|
store_action(tx, args, status: 'nosend')
|
|
669
709
|
store_tracked_outputs(txid, tx, caller_outputs)
|
|
670
710
|
|
|
671
|
-
# Register in @pending so abort_action can release
|
|
672
|
-
# remove change outputs.
|
|
711
|
+
# Register in @pending so abort_action (or send_with) can release
|
|
712
|
+
# inputs and remove change outputs. Secondary index by txid allows
|
|
713
|
+
# send_with callers to look up pending entries by txid.
|
|
673
714
|
@pending[fund_ref] = {
|
|
674
715
|
tx: tx, args: args,
|
|
675
716
|
locked_outpoints: selected_outpoints,
|
|
676
717
|
change_outpoints: change_outpoints
|
|
677
718
|
}
|
|
719
|
+
@pending_by_txid[txid] = fund_ref
|
|
678
720
|
|
|
679
721
|
beef_binary = tx.to_beef
|
|
680
722
|
{ txid: txid, tx: beef_binary.unpack('C*'), reference: fund_ref, no_send_change: change_outpoints }
|
|
681
723
|
else
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
724
|
+
# Store everything in pending state first — inputs are already locked
|
|
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.
|
|
728
|
+
store_action(tx, args, status: 'pending')
|
|
729
|
+
change_outpoints = store_change_outputs(
|
|
730
|
+
txid, tx, change_outputs, tx_hex,
|
|
731
|
+
state: :pending, pending_reference: fund_ref
|
|
732
|
+
)
|
|
685
733
|
|
|
686
|
-
|
|
687
|
-
selected_outpoints.each { |op| @storage.update_output_state(op, :spent) }
|
|
734
|
+
store_tracked_outputs(txid, tx, caller_outputs)
|
|
688
735
|
|
|
689
736
|
beef_binary = tx.to_beef
|
|
690
|
-
|
|
737
|
+
|
|
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
|
+
)
|
|
691
745
|
end
|
|
692
746
|
rescue StandardError
|
|
693
747
|
# Release the pending lock so the UTXOs are available for retry.
|
|
@@ -803,7 +857,7 @@ module BSV
|
|
|
803
857
|
|
|
804
858
|
wire_source_from_storage(input, utxo[:outpoint])
|
|
805
859
|
|
|
806
|
-
priv = if utxo[:derivation_type] ==
|
|
860
|
+
priv = if utxo[:derivation_type]&.to_s == 'identity'
|
|
807
861
|
@key_deriver.root_key
|
|
808
862
|
else
|
|
809
863
|
@key_deriver.derive_private_key(
|
|
@@ -837,40 +891,55 @@ module BSV
|
|
|
837
891
|
tx.add_output(output)
|
|
838
892
|
end
|
|
839
893
|
|
|
840
|
-
# Persists change outputs in storage
|
|
894
|
+
# Persists change outputs in storage with the specified state.
|
|
841
895
|
#
|
|
842
896
|
# Each output is stored with full BRC-29 derivation metadata so the
|
|
843
|
-
# wallet can derive the spending key later
|
|
844
|
-
#
|
|
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.
|
|
845
901
|
#
|
|
846
902
|
# @param txid [String] hex txid of the signed transaction
|
|
847
903
|
# @param tx [BSV::Transaction::Transaction]
|
|
848
904
|
# @param change_specs [Array<Hash>] change descriptors from {ChangeGenerator}
|
|
849
905
|
# @param tx_hex [String] serialised transaction hex (used as source)
|
|
850
|
-
|
|
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 = []
|
|
851
912
|
change_specs.each do |spec|
|
|
852
913
|
actual_idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
|
|
853
914
|
next unless actual_idx
|
|
854
915
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
860
922
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
|
873
941
|
end
|
|
942
|
+
entry
|
|
874
943
|
end
|
|
875
944
|
|
|
876
945
|
# Lazy accessors for auto-fund components. Created on first use so the
|
|
@@ -897,7 +966,10 @@ module BSV
|
|
|
897
966
|
Validators.validate_description!(args[:description])
|
|
898
967
|
inputs_present = args[:inputs] && !args[:inputs].empty?
|
|
899
968
|
outputs_present = args[:outputs] && !args[:outputs].empty?
|
|
900
|
-
|
|
969
|
+
send_with_present = args.dig(:options, :send_with) && !Array(args.dig(:options, :send_with)).empty?
|
|
970
|
+
unless inputs_present || outputs_present || send_with_present
|
|
971
|
+
raise InvalidParameterError.new('inputs/outputs', 'at least one input or output')
|
|
972
|
+
end
|
|
901
973
|
|
|
902
974
|
validate_action_inputs!(args[:inputs]) if args[:inputs]
|
|
903
975
|
validate_action_outputs!(args[:outputs]) if args[:outputs]
|
|
@@ -1121,10 +1193,150 @@ module BSV
|
|
|
1121
1193
|
end
|
|
1122
1194
|
end
|
|
1123
1195
|
|
|
1196
|
+
# Rolls back a pending auto-funded action.
|
|
1197
|
+
#
|
|
1198
|
+
# Releases locked input UTXOs back to +:spendable+, deletes phantom change
|
|
1199
|
+
# outputs, and optionally updates the action status. Used by both
|
|
1200
|
+
# broadcast failure and +abort_action+ so UTXO state is always consistent.
|
|
1201
|
+
#
|
|
1202
|
+
# @param input_outpoints [Array<String>] outpoints locked as inputs
|
|
1203
|
+
# @param change_outpoints [Array<String>] change outputs to delete
|
|
1204
|
+
# @param txid [String, nil] txid of the action to update (may be nil)
|
|
1205
|
+
# @param ref [String] fund reference used when locking inputs
|
|
1206
|
+
# @param action_status [String, nil] new action status; nil skips the update
|
|
1207
|
+
def rollback_pending_action(input_outpoints, change_outpoints, txid, ref, action_status: nil)
|
|
1208
|
+
release_pending_utxos(input_outpoints, ref)
|
|
1209
|
+
Array(change_outpoints).each { |op| @storage.delete_output(op) }
|
|
1210
|
+
@storage.update_action_status(txid, action_status) if txid && action_status
|
|
1211
|
+
end
|
|
1212
|
+
|
|
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.
|
|
1218
|
+
#
|
|
1219
|
+
# @param tx [Transaction] signed transaction to broadcast
|
|
1220
|
+
# @param txid [String] hex transaction id
|
|
1221
|
+
# @param input_outpoints [Array<String>] outpoints locked as inputs
|
|
1222
|
+
# @param change_outpoints [Array<String>] change output outpoints
|
|
1223
|
+
# @param fund_ref [String] fund reference used when locking
|
|
1224
|
+
# @param beef_binary [String] raw BEEF bytes
|
|
1225
|
+
# @return [Hash] result hash from +BroadcastQueue#enqueue+
|
|
1226
|
+
def broadcast_and_promote(tx, txid, input_outpoints, change_outpoints, fund_ref, beef_binary)
|
|
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
|
+
)
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
# Batch-broadcasts a list of previously no_send transactions.
|
|
1237
|
+
#
|
|
1238
|
+
# Each txid must correspond to a pending no_send entry registered in
|
|
1239
|
+
# +@pending_by_txid+. Transactions are broadcast individually so that one
|
|
1240
|
+
# failure does not block the others. On per-tx success the inputs are
|
|
1241
|
+
# promoted to +:spent+ and change outputs to +:spendable+. On failure the
|
|
1242
|
+
# pending state is rolled back via +rollback_pending_action+.
|
|
1243
|
+
#
|
|
1244
|
+
# @param txids [Array<String>] txids of no_send transactions to broadcast
|
|
1245
|
+
# @return [Array<Hash>] per-tx results matching TS SDK +SendWithResult[]+ shape:
|
|
1246
|
+
# +{ txid: String, status: 'unproven' | 'failed' }+
|
|
1247
|
+
def broadcast_send_with(txids)
|
|
1248
|
+
txids.map { |txid| broadcast_single_no_send(txid) }
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
# Broadcasts one no_send transaction and promotes or rolls back its state.
|
|
1252
|
+
#
|
|
1253
|
+
# @param txid [String] hex txid of the no_send transaction
|
|
1254
|
+
# @return [Hash] +{ txid:, status: }+ result entry
|
|
1255
|
+
def broadcast_single_no_send(txid)
|
|
1256
|
+
fund_ref = @pending_by_txid[txid]
|
|
1257
|
+
return { txid: txid, status: 'failed', error: 'Transaction not found in pending no_send store' } unless fund_ref
|
|
1258
|
+
|
|
1259
|
+
pending_entry = @pending[fund_ref]
|
|
1260
|
+
unless pending_entry
|
|
1261
|
+
@pending_by_txid.delete(txid)
|
|
1262
|
+
return { txid: txid, status: 'failed', error: 'Pending entry expired or already processed' }
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
# Use the original signed transaction from the pending entry — it has
|
|
1266
|
+
# source_satoshis and source_locking_script wired on each input, which
|
|
1267
|
+
# the broadcaster needs for Extended Format (EF/BRC-30) submission.
|
|
1268
|
+
# Reconstructing from stored hex would lose that metadata.
|
|
1269
|
+
tx = pending_entry[:tx]
|
|
1270
|
+
unless tx
|
|
1271
|
+
tx_hex = @storage.find_transaction(txid)
|
|
1272
|
+
return { txid: txid, status: 'failed', error: 'Transaction not found' } unless tx_hex
|
|
1273
|
+
|
|
1274
|
+
tx = BSV::Transaction::Transaction.from_hex(tx_hex)
|
|
1275
|
+
end
|
|
1276
|
+
promote_no_send(tx, txid, fund_ref, pending_entry)
|
|
1277
|
+
rescue StandardError => e
|
|
1278
|
+
# Catch unexpected errors outside the broadcast rescue block.
|
|
1279
|
+
{ txid: txid, status: 'failed', error: e.message }
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
# Attempts to broadcast and promote a single no_send transaction.
|
|
1283
|
+
# Action status is set to 'unproven' (matching TS SDK SendWithResult)
|
|
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.
|
|
1289
|
+
def promote_no_send(tx, txid, fund_ref, pending_entry)
|
|
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
|
|
1304
|
+
|
|
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.
|
|
1308
|
+
pending_entry[:locked_outpoints].each { |op| @storage.update_output_state(op, :spent) }
|
|
1309
|
+
pending_entry[:change_outpoints].each { |op| @storage.update_output_state(op, :spendable) }
|
|
1310
|
+
@storage.update_action_status(txid, 'unproven')
|
|
1311
|
+
@pending.delete(fund_ref)
|
|
1312
|
+
@pending_by_txid.delete(txid)
|
|
1313
|
+
|
|
1314
|
+
{ txid: txid, status: 'unproven' }
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
# Maps a broadcast exception to a {ReviewActionResultStatus} string.
|
|
1318
|
+
#
|
|
1319
|
+
# Delegates to +BroadcastQueue.status_for_error+ for a single source of
|
|
1320
|
+
# truth across all queue adapters.
|
|
1321
|
+
#
|
|
1322
|
+
# @param error [StandardError] the exception raised during broadcast
|
|
1323
|
+
# @return [String] one of 'doubleSpend', 'invalidTx', 'serviceError'
|
|
1324
|
+
def broadcast_status_for(error)
|
|
1325
|
+
BroadcastQueue.status_for_error(error)
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1124
1328
|
def finalize_action(tx, args)
|
|
1125
1329
|
tx.sign_all if tx.inputs.any?(&:unlocking_script_template)
|
|
1126
1330
|
txid = tx.txid_hex
|
|
1127
|
-
|
|
1331
|
+
|
|
1332
|
+
no_send = args.dig(:options, :no_send)
|
|
1333
|
+
delayed = args.dig(:options, :accept_delayed_broadcast)
|
|
1334
|
+
|
|
1335
|
+
status = if no_send
|
|
1336
|
+
'nosend'
|
|
1337
|
+
else
|
|
1338
|
+
'pending'
|
|
1339
|
+
end
|
|
1128
1340
|
|
|
1129
1341
|
@storage.store_transaction(txid, tx.to_hex)
|
|
1130
1342
|
store_action(tx, args, status: status)
|
|
@@ -1132,7 +1344,21 @@ module BSV
|
|
|
1132
1344
|
|
|
1133
1345
|
beef_binary = tx.to_beef
|
|
1134
1346
|
result = { txid: txid, tx: beef_binary.unpack('C*') }
|
|
1135
|
-
|
|
1347
|
+
|
|
1348
|
+
if no_send
|
|
1349
|
+
result[:no_send_change] = []
|
|
1350
|
+
else
|
|
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
|
+
)
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1136
1362
|
result
|
|
1137
1363
|
end
|
|
1138
1364
|
|
|
@@ -1281,6 +1507,7 @@ module BSV
|
|
|
1281
1507
|
outpoint: "#{txid}.#{output_index}",
|
|
1282
1508
|
satoshis: tx_output.satoshis,
|
|
1283
1509
|
locking_script: tx_output.locking_script.to_hex,
|
|
1510
|
+
basket: 'default',
|
|
1284
1511
|
spendable: true,
|
|
1285
1512
|
sender_identity_key: sender_key,
|
|
1286
1513
|
derivation_prefix: prefix,
|
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: []
|