bsv-wallet-postgres 0.5.0 → 0.100.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 +17 -115
- data/LICENSE +23 -80
- data/db/migrations/001_create_schema.rb +261 -0
- data/db/migrations/002_action_id_cascade.rb +66 -0
- data/db/migrations/003_schema_constraints.rb +297 -0
- data/db/migrations/004_drop_tx_reqs.rb +32 -0
- data/lib/bsv/wallet/postgres/action.rb +80 -0
- data/lib/bsv/wallet/postgres/action_label.rb +14 -0
- data/lib/bsv/wallet/postgres/arc_adapter.rb +32 -0
- data/lib/bsv/wallet/postgres/basket.rb +13 -0
- data/lib/bsv/wallet/postgres/block.rb +13 -0
- data/lib/bsv/wallet/postgres/broadcast.rb +87 -0
- data/lib/bsv/wallet/postgres/broadcast_callback.rb +54 -0
- data/lib/bsv/wallet/postgres/broadcast_queue.rb +98 -0
- data/lib/bsv/wallet/postgres/certificate.rb +13 -0
- data/lib/bsv/wallet/postgres/certificate_field.rb +13 -0
- data/lib/bsv/wallet/postgres/display_txid.rb +25 -0
- data/lib/bsv/wallet/postgres/input.rb +14 -0
- data/lib/bsv/wallet/postgres/label.rb +15 -0
- data/lib/bsv/wallet/postgres/output.rb +64 -0
- data/lib/bsv/wallet/postgres/output_basket.rb +15 -0
- data/lib/bsv/wallet/postgres/output_detail.rb +12 -0
- data/lib/bsv/wallet/postgres/output_tag.rb +14 -0
- data/lib/bsv/wallet/postgres/proof_store.rb +109 -0
- data/lib/bsv/wallet/postgres/setting.rb +32 -0
- data/lib/bsv/wallet/postgres/spendable.rb +12 -0
- data/lib/bsv/wallet/postgres/store.rb +580 -0
- data/lib/bsv/wallet/postgres/tag.rb +15 -0
- data/lib/bsv/wallet/postgres/tx_proof.rb +16 -0
- data/lib/bsv/wallet/postgres/utxo_pool.rb +58 -0
- data/lib/bsv/wallet/postgres/version.rb +9 -0
- data/lib/bsv/wallet/postgres.rb +77 -0
- data/lib/bsv-wallet-postgres.rb +1 -1
- metadata +49 -35
- data/lib/bsv/wallet_postgres/migrations/001_create_wallet_tables.rb +0 -58
- data/lib/bsv/wallet_postgres/migrations/002_add_output_state.rb +0 -33
- data/lib/bsv/wallet_postgres/migrations/003_add_wallet_settings.rb +0 -20
- data/lib/bsv/wallet_postgres/migrations/004_add_pending_metadata.rb +0 -69
- data/lib/bsv/wallet_postgres/migrations/005_add_txid_unique_index.rb +0 -27
- data/lib/bsv/wallet_postgres/migrations/006_create_broadcast_jobs.rb +0 -68
- data/lib/bsv/wallet_postgres/postgres_store.rb +0 -482
- data/lib/bsv/wallet_postgres/solid_queue_adapter.rb +0 -328
- data/lib/bsv/wallet_postgres/version.rb +0 -7
- data/lib/bsv/wallet_postgres.rb +0 -13
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'sequel'
|
|
4
|
-
|
|
5
|
-
module BSV
|
|
6
|
-
module Wallet
|
|
7
|
-
# PostgreSQL-backed asynchronous broadcast queue adapter.
|
|
8
|
-
#
|
|
9
|
-
# Persists outbound transactions to the +wallet_broadcast_jobs+ table and
|
|
10
|
-
# processes them in a background worker thread. Designed for multi-process
|
|
11
|
-
# deployments where +InlineQueue+'s synchronous broadcast is unacceptable.
|
|
12
|
-
#
|
|
13
|
-
# The worker uses +SELECT ... FOR UPDATE SKIP LOCKED+ so multiple processes
|
|
14
|
-
# can run +SolidQueueAdapter+ against the same database safely — only one
|
|
15
|
-
# worker will claim a given job.
|
|
16
|
-
#
|
|
17
|
-
# === Lifecycle
|
|
18
|
-
#
|
|
19
|
-
# adapter = BSV::Wallet::SolidQueueAdapter.new(
|
|
20
|
-
# db: sequel_db,
|
|
21
|
-
# storage: postgres_store,
|
|
22
|
-
# broadcaster: arc_broadcaster
|
|
23
|
-
# )
|
|
24
|
-
# adapter.start # spawns background worker thread
|
|
25
|
-
# # ... process transactions ...
|
|
26
|
-
# adapter.drain # stop + join (blocks until current poll cycle completes)
|
|
27
|
-
#
|
|
28
|
-
# === Recovery
|
|
29
|
-
#
|
|
30
|
-
# On restart, the worker's first poll naturally finds stale +sending+ jobs
|
|
31
|
-
# (those whose +locked_at+ is older than +stale_threshold+ seconds) and
|
|
32
|
-
# re-broadcasts them. No special recovery code is needed.
|
|
33
|
-
#
|
|
34
|
-
# === Drain warning
|
|
35
|
-
#
|
|
36
|
-
# +drain+ blocks until the current poll cycle completes. If a job is
|
|
37
|
-
# mid-broadcast this may take several seconds. Jobs enqueued after
|
|
38
|
-
# +@running = false+ but before the worker thread exits will remain
|
|
39
|
-
# +unsent+ until the next +start+.
|
|
40
|
-
class SolidQueueAdapter
|
|
41
|
-
include BSV::Wallet::BroadcastQueue
|
|
42
|
-
|
|
43
|
-
# Default number of seconds between poll cycles.
|
|
44
|
-
DEFAULT_POLL_INTERVAL = 8
|
|
45
|
-
|
|
46
|
-
# Default number of seconds before a +sending+ job is considered stale
|
|
47
|
-
# and eligible for retry. Configurable for testability.
|
|
48
|
-
STALE_THRESHOLD = 300
|
|
49
|
-
|
|
50
|
-
# Maximum number of broadcast attempts before a job is abandoned.
|
|
51
|
-
# After this many failures the job remains +failed+ permanently.
|
|
52
|
-
MAX_ATTEMPTS = 5
|
|
53
|
-
|
|
54
|
-
# @param db [Sequel::Database] a Sequel database handle (shared with PostgresStore)
|
|
55
|
-
# @param storage [StorageAdapter] wallet storage adapter (must not be MemoryStore)
|
|
56
|
-
# @param broadcaster [#broadcast] broadcaster object
|
|
57
|
-
# @param poll_interval [Integer] seconds between worker poll cycles
|
|
58
|
-
# @param stale_threshold [Integer] seconds before a +sending+ job is retried
|
|
59
|
-
# @raise [ArgumentError] if +storage+ is a +BSV::Wallet::MemoryStore+
|
|
60
|
-
# @raise [ArgumentError] if +broadcaster+ is +nil+
|
|
61
|
-
def initialize(db:, storage:, broadcaster:, poll_interval: DEFAULT_POLL_INTERVAL, stale_threshold: STALE_THRESHOLD)
|
|
62
|
-
if storage.is_a?(BSV::Wallet::MemoryStore)
|
|
63
|
-
raise ArgumentError, 'SolidQueueAdapter requires a persistent storage adapter — MemoryStore is not supported'
|
|
64
|
-
end
|
|
65
|
-
raise ArgumentError, 'SolidQueueAdapter requires a broadcaster' if broadcaster.nil?
|
|
66
|
-
|
|
67
|
-
@db = db
|
|
68
|
-
@storage = storage
|
|
69
|
-
@broadcaster = broadcaster
|
|
70
|
-
@poll_interval = poll_interval
|
|
71
|
-
@stale_threshold = stale_threshold
|
|
72
|
-
|
|
73
|
-
@mutex = Mutex.new
|
|
74
|
-
@running = false
|
|
75
|
-
@worker_thread = nil
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Returns +true+ — this adapter executes broadcast asynchronously.
|
|
79
|
-
#
|
|
80
|
-
# @return [Boolean]
|
|
81
|
-
def async?
|
|
82
|
-
true
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# Returns +true+ — +SolidQueueAdapter+ requires a broadcaster at
|
|
86
|
-
# construction time (+ArgumentError+ is raised if +nil+ is passed), so
|
|
87
|
-
# broadcast is always available.
|
|
88
|
-
#
|
|
89
|
-
# @return [Boolean]
|
|
90
|
-
def broadcast_enabled?
|
|
91
|
-
!@broadcaster.nil?
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Persists a transaction to the broadcast job queue and returns immediately.
|
|
95
|
-
#
|
|
96
|
-
# Inserts a row into +wallet_broadcast_jobs+ with status +unsent+. If a row
|
|
97
|
-
# already exists for the same +txid+ (e.g. after a crash and restart), the
|
|
98
|
-
# +UniqueConstraintViolation+ is rescued and the existing status is returned
|
|
99
|
-
# instead.
|
|
100
|
-
#
|
|
101
|
-
# @param payload [Hash] broadcast payload (see +BroadcastQueue+ module docs)
|
|
102
|
-
# @return [Hash] +{ txid: String, broadcast_status: 'sending' }+
|
|
103
|
-
def enqueue(payload)
|
|
104
|
-
txid = payload[:txid]
|
|
105
|
-
beef_binary = payload[:beef_binary]
|
|
106
|
-
input_outpoints = payload[:input_outpoints]
|
|
107
|
-
change_outpoints = payload[:change_outpoints]
|
|
108
|
-
fund_ref = payload[:fund_ref]
|
|
109
|
-
|
|
110
|
-
row = {
|
|
111
|
-
txid: txid,
|
|
112
|
-
beef_hex: beef_binary.unpack1('H*'),
|
|
113
|
-
input_outpoints: input_outpoints ? Sequel.pg_array(input_outpoints, :text) : nil,
|
|
114
|
-
change_outpoints: change_outpoints ? Sequel.pg_array(change_outpoints, :text) : nil,
|
|
115
|
-
fund_ref: fund_ref,
|
|
116
|
-
status: 'unsent'
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
begin
|
|
120
|
-
@db[:wallet_broadcast_jobs].insert(row)
|
|
121
|
-
rescue Sequel::UniqueConstraintViolation
|
|
122
|
-
existing_status = @db[:wallet_broadcast_jobs].where(txid: txid).get(:status)
|
|
123
|
-
return { txid: txid, broadcast_status: existing_status || 'sending' }
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
{ txid: txid, broadcast_status: 'sending' }
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# Returns the broadcast status for a previously enqueued transaction.
|
|
130
|
-
#
|
|
131
|
-
# Reads directly from the jobs table — the authoritative source of truth
|
|
132
|
-
# for async broadcast status.
|
|
133
|
-
#
|
|
134
|
-
# @param txid [String] hex transaction identifier
|
|
135
|
-
# @return [String, nil] status string or +nil+ if no job exists
|
|
136
|
-
def status(txid)
|
|
137
|
-
@db[:wallet_broadcast_jobs].where(txid: txid).get(:status)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Spawns the background worker thread.
|
|
141
|
-
#
|
|
142
|
-
# Safe to call multiple times — returns immediately if already running.
|
|
143
|
-
# The check-and-set is atomic under the mutex to prevent two concurrent
|
|
144
|
-
# +start+ calls from spawning duplicate worker threads.
|
|
145
|
-
#
|
|
146
|
-
# @return [void]
|
|
147
|
-
def start
|
|
148
|
-
@mutex.synchronize do
|
|
149
|
-
return if @running || @worker_thread&.alive?
|
|
150
|
-
|
|
151
|
-
@running = true
|
|
152
|
-
end
|
|
153
|
-
@worker_thread = Thread.new { worker_loop }
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Signals the worker to stop after the current poll cycle.
|
|
157
|
-
#
|
|
158
|
-
# Non-blocking — returns immediately without waiting for the thread.
|
|
159
|
-
#
|
|
160
|
-
# @return [void]
|
|
161
|
-
def stop
|
|
162
|
-
@mutex.synchronize { @running = false }
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
# Stops the worker and blocks until the current poll cycle completes.
|
|
166
|
-
#
|
|
167
|
-
# Safe to call when +start+ has not been called (+@worker_thread+ is nil).
|
|
168
|
-
#
|
|
169
|
-
# @return [void]
|
|
170
|
-
def drain
|
|
171
|
-
stop
|
|
172
|
-
@worker_thread&.join
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
private
|
|
176
|
-
|
|
177
|
-
# Main loop for the background worker thread.
|
|
178
|
-
#
|
|
179
|
-
# Calls +poll_once+ in a loop, sleeping +poll_interval+ seconds between
|
|
180
|
-
# cycles. Unexpected errors from +poll_once+ are logged to stderr and the
|
|
181
|
-
# loop continues — the worker thread must not die silently.
|
|
182
|
-
def worker_loop
|
|
183
|
-
while running?
|
|
184
|
-
begin
|
|
185
|
-
poll_once
|
|
186
|
-
rescue StandardError => e
|
|
187
|
-
warn "[bsv-wallet] SolidQueueAdapter worker error: #{e.message}"
|
|
188
|
-
end
|
|
189
|
-
sleep(@poll_interval) if running?
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
# Thread-safe check of the running flag.
|
|
194
|
-
#
|
|
195
|
-
# @return [Boolean]
|
|
196
|
-
def running?
|
|
197
|
-
@mutex.synchronize { @running }
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# Claims and processes a single pending broadcast job.
|
|
201
|
-
#
|
|
202
|
-
# Uses +SELECT ... FOR UPDATE SKIP LOCKED+ so concurrent workers do not
|
|
203
|
-
# double-process the same job. Stale +sending+ jobs (those whose +locked_at+
|
|
204
|
-
# is older than +stale_threshold+ seconds) are also eligible for retry,
|
|
205
|
-
# up to +MAX_ATTEMPTS+.
|
|
206
|
-
#
|
|
207
|
-
# NOTE: The entire +process_job+ call (including the network broadcast)
|
|
208
|
-
# runs inside this transaction, holding the row lock and a database
|
|
209
|
-
# connection for the duration. This is acceptable for a single worker
|
|
210
|
-
# thread with an 8-second poll interval but would need restructuring
|
|
211
|
-
# for high-throughput multi-worker deployments.
|
|
212
|
-
#
|
|
213
|
-
# @return [void]
|
|
214
|
-
def poll_once
|
|
215
|
-
@db.transaction do
|
|
216
|
-
job = @db[:wallet_broadcast_jobs]
|
|
217
|
-
.where(Sequel.lit(
|
|
218
|
-
"status = 'unsent' OR " \
|
|
219
|
-
"(status = 'sending' AND locked_at < NOW() - " \
|
|
220
|
-
"#{@stale_threshold.to_i} * interval '1 second' " \
|
|
221
|
-
"AND attempts < #{MAX_ATTEMPTS})"
|
|
222
|
-
))
|
|
223
|
-
.order(:created_at)
|
|
224
|
-
.limit(1)
|
|
225
|
-
.for_update
|
|
226
|
-
.skip_locked
|
|
227
|
-
.first
|
|
228
|
-
return unless job
|
|
229
|
-
|
|
230
|
-
process_job(job)
|
|
231
|
-
end
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
# Marks a job as +sending+, broadcasts the transaction, and promotes or
|
|
235
|
-
# rolls back wallet state based on the outcome.
|
|
236
|
-
#
|
|
237
|
-
# Deserialization failures (corrupt +beef_hex+) are caught and mark the
|
|
238
|
-
# job as +failed+ immediately to prevent infinite retry loops.
|
|
239
|
-
#
|
|
240
|
-
# @param job [Hash] row from +wallet_broadcast_jobs+
|
|
241
|
-
# @return [void]
|
|
242
|
-
def process_job(job)
|
|
243
|
-
@db[:wallet_broadcast_jobs].where(id: job[:id]).update(
|
|
244
|
-
status: 'sending',
|
|
245
|
-
locked_at: Sequel.lit('NOW()'),
|
|
246
|
-
attempts: Sequel.lit('attempts + 1'),
|
|
247
|
-
updated_at: Sequel.lit('NOW()')
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
begin
|
|
251
|
-
tx = BSV::Transaction::Transaction.from_beef_hex(job[:beef_hex])
|
|
252
|
-
rescue StandardError => e
|
|
253
|
-
@db[:wallet_broadcast_jobs].where(id: job[:id]).update(
|
|
254
|
-
status: 'failed',
|
|
255
|
-
last_error: "Deserialization failed: #{e.message}",
|
|
256
|
-
updated_at: Sequel.lit('NOW()')
|
|
257
|
-
)
|
|
258
|
-
return
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
input_outpoints = job[:input_outpoints]&.to_a
|
|
262
|
-
change_outpoints = job[:change_outpoints]&.to_a
|
|
263
|
-
txid = job[:txid]
|
|
264
|
-
fund_ref = job[:fund_ref]
|
|
265
|
-
|
|
266
|
-
begin
|
|
267
|
-
@broadcaster.broadcast(tx)
|
|
268
|
-
rescue StandardError => e
|
|
269
|
-
if input_outpoints && !input_outpoints.empty?
|
|
270
|
-
rollback(input_outpoints, change_outpoints, txid, fund_ref)
|
|
271
|
-
elsif txid
|
|
272
|
-
@storage.update_action_status(txid, 'failed')
|
|
273
|
-
end
|
|
274
|
-
@db[:wallet_broadcast_jobs].where(id: job[:id]).update(
|
|
275
|
-
status: 'failed',
|
|
276
|
-
last_error: e.message,
|
|
277
|
-
updated_at: Sequel.lit('NOW()')
|
|
278
|
-
)
|
|
279
|
-
return
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
promote(input_outpoints, change_outpoints, txid)
|
|
283
|
-
@db[:wallet_broadcast_jobs].where(id: job[:id]).update(
|
|
284
|
-
status: 'completed',
|
|
285
|
-
updated_at: Sequel.lit('NOW()')
|
|
286
|
-
)
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
# Promotes UTXO state after a successful broadcast.
|
|
290
|
-
#
|
|
291
|
-
# Marks inputs as +:spent+, change as +:spendable+, and updates the action
|
|
292
|
-
# status to +unproven+. The transaction has been accepted by the network but
|
|
293
|
-
# is not yet proven on-chain — status advances to +completed+ once a merkle
|
|
294
|
-
# proof arrives. When outpoints are +nil+ (finalize path), UTXO transitions
|
|
295
|
-
# are skipped.
|
|
296
|
-
#
|
|
297
|
-
# @param input_outpoints [Array<String>, nil]
|
|
298
|
-
# @param change_outpoints [Array<String>, nil]
|
|
299
|
-
# @param txid [String, nil]
|
|
300
|
-
def promote(input_outpoints, change_outpoints, txid)
|
|
301
|
-
Array(input_outpoints).each { |op| @storage.update_output_state(op, :spent) }
|
|
302
|
-
Array(change_outpoints).each { |op| @storage.update_output_state(op, :spendable) }
|
|
303
|
-
@storage.update_action_status(txid, 'unproven') if txid
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Rolls back wallet state after a failed broadcast.
|
|
307
|
-
#
|
|
308
|
-
# Releases locked inputs (only those matching +fund_ref+), deletes phantom
|
|
309
|
-
# change outputs, and marks the action as +failed+.
|
|
310
|
-
#
|
|
311
|
-
# @param input_outpoints [Array<String>]
|
|
312
|
-
# @param change_outpoints [Array<String>]
|
|
313
|
-
# @param txid [String, nil]
|
|
314
|
-
# @param fund_ref [String] fund reference used when locking inputs
|
|
315
|
-
def rollback(input_outpoints, change_outpoints, txid, fund_ref)
|
|
316
|
-
Array(input_outpoints).each do |op|
|
|
317
|
-
outputs = @storage.find_outputs({ outpoint: op, include_spent: true, limit: 1, offset: 0 })
|
|
318
|
-
next if outputs.empty?
|
|
319
|
-
next unless outputs.first[:pending_reference] == fund_ref
|
|
320
|
-
|
|
321
|
-
@storage.update_output_state(op, :spendable)
|
|
322
|
-
end
|
|
323
|
-
Array(change_outpoints).each { |op| @storage.delete_output(op) }
|
|
324
|
-
@storage.update_action_status(txid, 'failed') if txid
|
|
325
|
-
end
|
|
326
|
-
end
|
|
327
|
-
end
|
|
328
|
-
end
|
data/lib/bsv/wallet_postgres.rb
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module BSV
|
|
4
|
-
# Top-level module for the bsv-wallet-postgres gem.
|
|
5
|
-
module WalletPostgres
|
|
6
|
-
autoload :VERSION, 'bsv/wallet_postgres/version'
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
module Wallet
|
|
10
|
-
autoload :PostgresStore, 'bsv/wallet_postgres/postgres_store'
|
|
11
|
-
autoload :SolidQueueAdapter, 'bsv/wallet_postgres/solid_queue_adapter'
|
|
12
|
-
end
|
|
13
|
-
end
|