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
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BSV
|
|
4
|
+
module Wallet
|
|
5
|
+
module Postgres
|
|
6
|
+
# Concrete PostgreSQL implementation of Interface::Store.
|
|
7
|
+
#
|
|
8
|
+
# Layer 2a — orchestrates Layer 2b models into the phase-based
|
|
9
|
+
# action lifecycle. Contains no BRC-100 business logic.
|
|
10
|
+
#
|
|
11
|
+
# All methods receive and return plain hashes — no Sequel::Model
|
|
12
|
+
# objects leak through the interface boundary.
|
|
13
|
+
class Store
|
|
14
|
+
include BSV::Wallet::Interface::Store
|
|
15
|
+
|
|
16
|
+
def initialize(db: nil)
|
|
17
|
+
@db = db || BSV::Wallet::Postgres.db
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# --- Action Lifecycle ---
|
|
21
|
+
|
|
22
|
+
def create_action(action:, inputs: [])
|
|
23
|
+
@db.transaction do
|
|
24
|
+
record = Action.create(
|
|
25
|
+
description: action[:description],
|
|
26
|
+
broadcast: action[:broadcast]&.to_s || 'delayed',
|
|
27
|
+
nlocktime: action[:nlocktime],
|
|
28
|
+
version: action[:version],
|
|
29
|
+
outgoing: action.fetch(:outgoing, true),
|
|
30
|
+
input_beef: action[:input_beef]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if inputs.any?
|
|
34
|
+
locked = 0
|
|
35
|
+
inputs.each do |inp|
|
|
36
|
+
result = @db[:inputs].insert_conflict(target: :output_id).insert(
|
|
37
|
+
action_id: record.id,
|
|
38
|
+
output_id: inp[:output_id],
|
|
39
|
+
vin: inp[:vin],
|
|
40
|
+
nsequence: inp[:nsequence] || 4_294_967_295,
|
|
41
|
+
description: inp[:description]
|
|
42
|
+
)
|
|
43
|
+
locked += 1 if result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if locked < inputs.size
|
|
47
|
+
raise Sequel::Rollback
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
action_to_hash(record)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sign_action(action_id:, wtxid:, raw_tx:, change_outputs: [])
|
|
56
|
+
BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'sign_action wtxid')
|
|
57
|
+
BSV.logger&.debug { "[Store] sign_action: action_id=#{action_id} dtxid=#{wtxid.reverse.unpack1('H*')}" }
|
|
58
|
+
@db.transaction do
|
|
59
|
+
Action.where(id: action_id).update(
|
|
60
|
+
wtxid: Sequel.blob(wtxid),
|
|
61
|
+
raw_tx: Sequel.blob(raw_tx)
|
|
62
|
+
)
|
|
63
|
+
TxProof.dataset.insert_conflict(target: :wtxid, update: { raw_tx: Sequel.blob(raw_tx) })
|
|
64
|
+
.insert(wtxid: Sequel.blob(wtxid), raw_tx: Sequel.blob(raw_tx))
|
|
65
|
+
|
|
66
|
+
# Write change output rows atomically with signing. Output rows
|
|
67
|
+
# record derivation data (spending authority) but NO spendable
|
|
68
|
+
# rows — promotion to spendable happens after broadcast acceptance
|
|
69
|
+
# or in the no_send path, same as any other output.
|
|
70
|
+
change_outputs.each do |chg|
|
|
71
|
+
output = Output.create(
|
|
72
|
+
action_id: action_id,
|
|
73
|
+
satoshis: chg[:satoshis],
|
|
74
|
+
vout: chg[:vout],
|
|
75
|
+
locking_script: chg[:locking_script],
|
|
76
|
+
derivation_prefix: chg[:derivation_prefix],
|
|
77
|
+
derivation_suffix: chg[:derivation_suffix],
|
|
78
|
+
sender_identity_key: chg[:sender_identity_key]
|
|
79
|
+
)
|
|
80
|
+
OutputDetail.create(
|
|
81
|
+
output_id: output.id,
|
|
82
|
+
action_id: action_id,
|
|
83
|
+
change: true
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def promote_action(action_id:, outputs:)
|
|
90
|
+
@db.transaction do
|
|
91
|
+
outputs.map do |out|
|
|
92
|
+
output = Output.create(
|
|
93
|
+
action_id: action_id,
|
|
94
|
+
satoshis: out[:satoshis],
|
|
95
|
+
vout: out[:vout],
|
|
96
|
+
locking_script: out[:locking_script],
|
|
97
|
+
output_type: out[:output_type],
|
|
98
|
+
derivation_prefix: out[:derivation_prefix],
|
|
99
|
+
derivation_suffix: out[:derivation_suffix],
|
|
100
|
+
sender_identity_key: out[:sender_identity_key]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Only wallet-owned outputs get a spendable row.
|
|
104
|
+
# Derived outputs (NULL type with derivation fields) and root
|
|
105
|
+
# outputs are wallet-owned. Outbound outputs are payments to
|
|
106
|
+
# others — never spendable.
|
|
107
|
+
wallet_owned = out[:derivation_prefix] || out[:output_type] == 'root'
|
|
108
|
+
if wallet_owned
|
|
109
|
+
Spendable.create(output_id: output.id, action_id: action_id)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if out[:basket] && out[:basket] != 'default'
|
|
113
|
+
basket_id = find_or_create_basket(name: out[:basket])
|
|
114
|
+
OutputBasket.create(output_id: output.id, basket_id: basket_id, action_id: action_id)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if out[:description] || out[:custom_instructions]
|
|
118
|
+
OutputDetail.create(
|
|
119
|
+
output_id: output.id,
|
|
120
|
+
action_id: action_id,
|
|
121
|
+
description: out[:description],
|
|
122
|
+
custom_instructions: out[:custom_instructions]
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if out[:tags]&.any?
|
|
127
|
+
tag_ids = find_or_create_tags(names: out[:tags])
|
|
128
|
+
tag_ids.each { |tid| OutputTag.create(output_id: output.id, tag_id: tid) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
output.id
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def link_proof(action_id:, tx_proof_id:)
|
|
137
|
+
Action.where(id: action_id).update(tx_proof_id: tx_proof_id)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def abort_action(action_id:)
|
|
141
|
+
# Allow deletion of actions that haven't been broadcast.
|
|
142
|
+
# After the deferred signing rework, actions may have an unsigned
|
|
143
|
+
# raw_tx and wtxid before broadcast — the guard checks for absence
|
|
144
|
+
# of a broadcast entry rather than absence of wtxid.
|
|
145
|
+
broadcast_exists = Broadcast.where(
|
|
146
|
+
Sequel[:broadcasts][:action_id] => Sequel[:actions][:id]
|
|
147
|
+
).select(1)
|
|
148
|
+
|
|
149
|
+
Action.where(id: action_id).exclude(broadcast_exists.exists).delete
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# --- Queries ---
|
|
153
|
+
|
|
154
|
+
def find_action(id: nil, wtxid: nil, reference: nil)
|
|
155
|
+
BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'find_action wtxid') if wtxid
|
|
156
|
+
record = if id then Action[id]
|
|
157
|
+
elsif wtxid then Action.first(wtxid: Sequel.blob(wtxid))
|
|
158
|
+
elsif reference then Action.first(reference: reference)
|
|
159
|
+
end
|
|
160
|
+
return unless record
|
|
161
|
+
|
|
162
|
+
action_to_hash(record)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def query_actions(labels:, label_query_mode: :any, limit: 10, offset: 0,
|
|
166
|
+
include_labels: false, include_inputs: false,
|
|
167
|
+
include_input_locking_scripts: false,
|
|
168
|
+
include_input_unlocking_scripts: false,
|
|
169
|
+
include_outputs: false, include_output_locking_scripts: false)
|
|
170
|
+
label_ids = Label.where(label: labels).select_map(:id)
|
|
171
|
+
return { total: 0, actions: [] } if label_ids.empty?
|
|
172
|
+
|
|
173
|
+
base = Action
|
|
174
|
+
.join(:action_labels, action_id: :id)
|
|
175
|
+
.where(Sequel[:action_labels][:label_id] => label_ids)
|
|
176
|
+
.select_all(:actions)
|
|
177
|
+
|
|
178
|
+
if label_query_mode == :all
|
|
179
|
+
base = base
|
|
180
|
+
.group(Sequel[:actions][:id])
|
|
181
|
+
.having { count(Sequel.function(:distinct, Sequel[:action_labels][:label_id])) >= label_ids.size }
|
|
182
|
+
else
|
|
183
|
+
base = base.distinct
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
total = base.count
|
|
187
|
+
records = base
|
|
188
|
+
.order(Sequel.desc(Sequel[:actions][:created_at]))
|
|
189
|
+
.limit(limit).offset(offset).all
|
|
190
|
+
|
|
191
|
+
actions = records.map do |row|
|
|
192
|
+
# row may be a hash from the join — reload as model
|
|
193
|
+
a = row.is_a?(Action) ? row : Action[row[:id]]
|
|
194
|
+
action_to_hash(a,
|
|
195
|
+
include_labels: include_labels,
|
|
196
|
+
include_inputs: include_inputs,
|
|
197
|
+
include_input_locking_scripts: include_input_locking_scripts,
|
|
198
|
+
include_outputs: include_outputs,
|
|
199
|
+
include_output_locking_scripts: include_output_locking_scripts)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
{ total: total, actions: actions }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def query_outputs(basket:, tags: nil, tag_query_mode: :any,
|
|
206
|
+
limit: 10, offset: 0,
|
|
207
|
+
include_locking_scripts: false,
|
|
208
|
+
include_custom_instructions: false,
|
|
209
|
+
include_tags: false, include_labels: false)
|
|
210
|
+
base = Output.spendable.in_basket(basket)
|
|
211
|
+
|
|
212
|
+
if tags&.any?
|
|
213
|
+
tag_ids = Tag.where(tag: tags).select_map(:id)
|
|
214
|
+
unless tag_ids.empty?
|
|
215
|
+
tag_ds = OutputTag.dataset
|
|
216
|
+
.where(tag_id: tag_ids)
|
|
217
|
+
.where(Sequel[:output_tags][:output_id] => Sequel[:outputs][:id])
|
|
218
|
+
.select(1)
|
|
219
|
+
|
|
220
|
+
if tag_query_mode == :all
|
|
221
|
+
base = base.where(
|
|
222
|
+
tag_ds
|
|
223
|
+
.group(Sequel[:output_tags][:output_id])
|
|
224
|
+
.having { count(Sequel.function(:distinct, Sequel[:output_tags][:tag_id])) >= tag_ids.size }
|
|
225
|
+
.exists
|
|
226
|
+
)
|
|
227
|
+
else
|
|
228
|
+
base = base.where(tag_ds.exists)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
total = base.count
|
|
234
|
+
records = base
|
|
235
|
+
.order(Sequel.desc(:created_at))
|
|
236
|
+
.limit(limit).offset(offset).all
|
|
237
|
+
|
|
238
|
+
outputs = records.map do |o|
|
|
239
|
+
output_to_hash(o,
|
|
240
|
+
include_locking_scripts: include_locking_scripts,
|
|
241
|
+
include_custom_instructions: include_custom_instructions,
|
|
242
|
+
include_tags: include_tags,
|
|
243
|
+
include_labels: include_labels)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
{ total: total, outputs: outputs }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# --- Outputs ---
|
|
250
|
+
|
|
251
|
+
def relinquish_output(output_id:)
|
|
252
|
+
@db.transaction do
|
|
253
|
+
Spendable.where(output_id: output_id).delete
|
|
254
|
+
OutputBasket.where(output_id: output_id).delete
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# --- Labels, Tags, Baskets ---
|
|
259
|
+
|
|
260
|
+
def find_or_create_labels(names:)
|
|
261
|
+
names.map do |name|
|
|
262
|
+
label = Label.first(label: name)
|
|
263
|
+
label ||= Label.create(label: name)
|
|
264
|
+
label.id
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def find_or_create_tags(names:)
|
|
269
|
+
names.map do |name|
|
|
270
|
+
tag = Tag.first(tag: name)
|
|
271
|
+
tag ||= Tag.create(tag: name)
|
|
272
|
+
tag.id
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def find_or_create_basket(name:)
|
|
277
|
+
basket = Basket.first(name: name)
|
|
278
|
+
basket ||= Basket.create(name: name)
|
|
279
|
+
basket.id
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def label_action(action_id:, label_ids:)
|
|
283
|
+
label_ids.each do |lid|
|
|
284
|
+
existing = ActionLabel.first(action_id: action_id, label_id: lid)
|
|
285
|
+
ActionLabel.create(action_id: action_id, label_id: lid) unless existing
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# --- Certificates ---
|
|
290
|
+
|
|
291
|
+
def save_certificate(certificate)
|
|
292
|
+
@db.transaction do
|
|
293
|
+
cert = Certificate.create(
|
|
294
|
+
type: certificate[:type],
|
|
295
|
+
subject: certificate[:subject],
|
|
296
|
+
serial_number: certificate[:serial_number],
|
|
297
|
+
certifier: certificate[:certifier],
|
|
298
|
+
verifier: certificate[:verifier],
|
|
299
|
+
revocation_outpoint: certificate[:revocation_outpoint],
|
|
300
|
+
signature: certificate[:signature]
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
certificate[:fields]&.each do |name, value|
|
|
304
|
+
CertificateField.create(
|
|
305
|
+
certificate_id: cert.id,
|
|
306
|
+
name: name.to_s,
|
|
307
|
+
value: value.to_s,
|
|
308
|
+
master_key: certificate.dig(:keyring, name.to_s)
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
certificate_to_hash(cert)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def query_certificates(certifiers:, types:, limit: 10, offset: 0)
|
|
317
|
+
base = Certificate
|
|
318
|
+
.where(certifier: certifiers, type: types)
|
|
319
|
+
|
|
320
|
+
total = base.count
|
|
321
|
+
records = base
|
|
322
|
+
.order(Sequel.desc(:created_at))
|
|
323
|
+
.limit(limit).offset(offset).all
|
|
324
|
+
|
|
325
|
+
{
|
|
326
|
+
total: total,
|
|
327
|
+
certificates: records.map { |c| certificate_to_hash(c) }
|
|
328
|
+
}
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def delete_certificate(type:, serial_number:, certifier:)
|
|
332
|
+
Certificate
|
|
333
|
+
.where(type: type, serial_number: serial_number, certifier: certifier)
|
|
334
|
+
.delete
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# --- Settings ---
|
|
338
|
+
|
|
339
|
+
def get_setting(key:)
|
|
340
|
+
Setting.get(key)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def set_setting(key:, value:)
|
|
344
|
+
Setting.set(key, value)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# --- Input Resolution ---
|
|
348
|
+
|
|
349
|
+
def resolve_inputs_for_signing(action_id:)
|
|
350
|
+
rows = @db[:inputs]
|
|
351
|
+
.join(:outputs, id: :output_id)
|
|
352
|
+
.join(Sequel[:actions].as(:source_actions), id: Sequel[:outputs][:action_id])
|
|
353
|
+
.where(Sequel[:inputs][:action_id] => action_id)
|
|
354
|
+
.order(Sequel[:inputs][:vin])
|
|
355
|
+
.select(
|
|
356
|
+
Sequel[:inputs][:vin],
|
|
357
|
+
Sequel[:inputs][:nsequence].as(:sequence),
|
|
358
|
+
Sequel[:source_actions][:wtxid].as(:source_wtxid),
|
|
359
|
+
Sequel[:outputs][:vout].as(:source_vout),
|
|
360
|
+
Sequel[:outputs][:satoshis].as(:source_satoshis),
|
|
361
|
+
Sequel[:outputs][:locking_script].as(:source_locking_script),
|
|
362
|
+
Sequel[:outputs][:derivation_prefix],
|
|
363
|
+
Sequel[:outputs][:derivation_suffix],
|
|
364
|
+
Sequel[:outputs][:sender_identity_key]
|
|
365
|
+
)
|
|
366
|
+
.all
|
|
367
|
+
|
|
368
|
+
result = rows.map do |row|
|
|
369
|
+
if row[:source_wtxid].nil?
|
|
370
|
+
raise "Source action has nil wtxid for input vin #{row[:vin]} of action #{action_id}"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
BSV::Primitives::Hex.validate_wtxid!(row[:source_wtxid], name: "resolve_inputs source vin=#{row[:vin]}")
|
|
374
|
+
|
|
375
|
+
{
|
|
376
|
+
vin: row[:vin],
|
|
377
|
+
sequence: row[:sequence],
|
|
378
|
+
source_wtxid: row[:source_wtxid],
|
|
379
|
+
source_vout: row[:source_vout],
|
|
380
|
+
source_satoshis: row[:source_satoshis],
|
|
381
|
+
source_locking_script: row[:source_locking_script],
|
|
382
|
+
derivation_prefix: row[:derivation_prefix],
|
|
383
|
+
derivation_suffix: row[:derivation_suffix],
|
|
384
|
+
sender_identity_key: row[:sender_identity_key]
|
|
385
|
+
}
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
BSV.logger&.debug do
|
|
389
|
+
dtxids = result.first(5).map { |r| r[:source_wtxid].reverse.unpack1('H*') }
|
|
390
|
+
suffix = result.size > 5 ? " (+#{result.size - 5} more)" : ''
|
|
391
|
+
"[Store] resolve_inputs_for_signing: action_id=#{action_id} inputs=#{result.size} sources=#{dtxids.join(',')}#{suffix}"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
result
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# --- Change Output Queries ---
|
|
398
|
+
|
|
399
|
+
def query_change_output_vouts(action_id:)
|
|
400
|
+
Output.where(action_id: action_id)
|
|
401
|
+
.where(
|
|
402
|
+
OutputDetail.dataset
|
|
403
|
+
.where(Sequel[:output_details][:output_id] => Sequel[:outputs][:id])
|
|
404
|
+
.where(change: true)
|
|
405
|
+
.select(1)
|
|
406
|
+
.exists
|
|
407
|
+
)
|
|
408
|
+
.select_map(:vout)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def promote_change_to_spendable(action_id:)
|
|
412
|
+
change_outputs = Output.where(action_id: action_id)
|
|
413
|
+
.where(
|
|
414
|
+
OutputDetail.dataset
|
|
415
|
+
.where(Sequel[:output_details][:output_id] => Sequel[:outputs][:id])
|
|
416
|
+
.where(change: true)
|
|
417
|
+
.select(1)
|
|
418
|
+
.exists
|
|
419
|
+
)
|
|
420
|
+
.exclude(
|
|
421
|
+
Spendable.where(Sequel[:spendable][:output_id] => Sequel[:outputs][:id])
|
|
422
|
+
.select(1).exists
|
|
423
|
+
)
|
|
424
|
+
.all
|
|
425
|
+
change_outputs.each do |output|
|
|
426
|
+
Spendable.create(output_id: output.id, action_id: action_id)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# --- UTXO Selection ---
|
|
431
|
+
|
|
432
|
+
def find_spendable(satoshis:, basket: nil, exclude: [])
|
|
433
|
+
ds = Output.spendable
|
|
434
|
+
ds = ds.in_basket(basket) if basket
|
|
435
|
+
ds = ds.exclude(Sequel[:outputs][:id] => exclude) if exclude.any?
|
|
436
|
+
ds = ds.order(Sequel.desc(:satoshis))
|
|
437
|
+
|
|
438
|
+
candidates = []
|
|
439
|
+
total = 0
|
|
440
|
+
ds.each do |output|
|
|
441
|
+
candidates << {
|
|
442
|
+
id: output.id,
|
|
443
|
+
satoshis: output.satoshis,
|
|
444
|
+
vout: output.vout,
|
|
445
|
+
action_id: output.action_id,
|
|
446
|
+
locking_script: output.locking_script,
|
|
447
|
+
derivation_prefix: output.derivation_prefix,
|
|
448
|
+
derivation_suffix: output.derivation_suffix,
|
|
449
|
+
sender_identity_key: output.sender_identity_key
|
|
450
|
+
}
|
|
451
|
+
total += output.satoshis
|
|
452
|
+
break if total >= satoshis
|
|
453
|
+
end
|
|
454
|
+
candidates
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# --- Reaper ---
|
|
458
|
+
|
|
459
|
+
def reap_stale_actions(threshold:)
|
|
460
|
+
cutoff = Time.now - threshold
|
|
461
|
+
output_exists = Output.where(Sequel[:outputs][:action_id] => Sequel[:actions][:id]).select(1)
|
|
462
|
+
|
|
463
|
+
Action
|
|
464
|
+
.where { created_at < cutoff }
|
|
465
|
+
.where(Sequel.~(broadcast: 'none'))
|
|
466
|
+
.where(Sequel.lit('wtxid IS NOT NULL'))
|
|
467
|
+
.exclude(output_exists.exists)
|
|
468
|
+
.delete
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
private
|
|
472
|
+
|
|
473
|
+
def action_to_hash(record, include_labels: false, include_inputs: false,
|
|
474
|
+
include_input_locking_scripts: false,
|
|
475
|
+
include_outputs: false, include_output_locking_scripts: false, **)
|
|
476
|
+
h = {
|
|
477
|
+
id: record.id,
|
|
478
|
+
wtxid: record.wtxid,
|
|
479
|
+
raw_tx: record.raw_tx,
|
|
480
|
+
reference: record.reference,
|
|
481
|
+
status: record.derived_status,
|
|
482
|
+
outgoing: record.outgoing,
|
|
483
|
+
description: record.description,
|
|
484
|
+
version: record.version,
|
|
485
|
+
nlocktime: record.nlocktime,
|
|
486
|
+
broadcast: record.values[:broadcast],
|
|
487
|
+
created_at: record.created_at
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if include_labels
|
|
491
|
+
h[:labels] = record.labels.map(&:label)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
if include_inputs
|
|
495
|
+
h[:inputs] = record.inputs.map do |inp|
|
|
496
|
+
ih = {
|
|
497
|
+
output_id: inp.output_id,
|
|
498
|
+
vin: inp.vin,
|
|
499
|
+
nsequence: inp.nsequence,
|
|
500
|
+
description: inp.description
|
|
501
|
+
}
|
|
502
|
+
if include_input_locking_scripts && inp.output
|
|
503
|
+
ih[:source_locking_script] = inp.output.locking_script
|
|
504
|
+
ih[:source_satoshis] = inp.output.satoshis
|
|
505
|
+
end
|
|
506
|
+
ih
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
if include_outputs
|
|
511
|
+
h[:outputs] = record.outputs.map do |out|
|
|
512
|
+
oh = {
|
|
513
|
+
id: out.id,
|
|
514
|
+
satoshis: out.satoshis,
|
|
515
|
+
vout: out.vout,
|
|
516
|
+
spendable: out.spendable?
|
|
517
|
+
}
|
|
518
|
+
oh[:locking_script] = out.locking_script if include_output_locking_scripts
|
|
519
|
+
if out.detail
|
|
520
|
+
oh[:description] = out.detail.description
|
|
521
|
+
oh[:custom_instructions] = out.detail.custom_instructions
|
|
522
|
+
end
|
|
523
|
+
oh[:basket] = out.basket&.name
|
|
524
|
+
oh[:tags] = out.tags.map(&:tag)
|
|
525
|
+
oh
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
h
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def output_to_hash(record, include_locking_scripts: false,
|
|
533
|
+
include_custom_instructions: false,
|
|
534
|
+
include_tags: false, include_labels: false, **)
|
|
535
|
+
h = {
|
|
536
|
+
id: record.id,
|
|
537
|
+
satoshis: record.satoshis,
|
|
538
|
+
vout: record.vout,
|
|
539
|
+
spendable: true
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
h[:locking_script] = record.locking_script if include_locking_scripts
|
|
543
|
+
|
|
544
|
+
if include_custom_instructions && record.detail
|
|
545
|
+
h[:custom_instructions] = record.detail.custom_instructions
|
|
546
|
+
h[:description] = record.detail.description
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
if include_tags
|
|
550
|
+
h[:tags] = record.tags.map(&:tag)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
if include_labels && record.action
|
|
554
|
+
h[:labels] = record.action.labels.map(&:label)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
h[:basket] = record.basket&.name
|
|
558
|
+
h
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def certificate_to_hash(record)
|
|
562
|
+
fields = {}
|
|
563
|
+
record.certificate_fields.each { |f| fields[f.name] = f.value }
|
|
564
|
+
|
|
565
|
+
{
|
|
566
|
+
id: record.id,
|
|
567
|
+
type: record.type,
|
|
568
|
+
subject: record.subject,
|
|
569
|
+
serial_number: record.serial_number,
|
|
570
|
+
certifier: record.certifier,
|
|
571
|
+
verifier: record.verifier,
|
|
572
|
+
revocation_outpoint: record.revocation_outpoint,
|
|
573
|
+
signature: record.signature,
|
|
574
|
+
fields: fields
|
|
575
|
+
}
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BSV
|
|
4
|
+
module Wallet
|
|
5
|
+
module Postgres
|
|
6
|
+
class Tag < Sequel::Model
|
|
7
|
+
plugin :timestamps, update_on_create: true
|
|
8
|
+
|
|
9
|
+
many_to_many :outputs, class: 'BSV::Wallet::Postgres::Output',
|
|
10
|
+
join_table: :output_tags,
|
|
11
|
+
left_key: :tag_id, right_key: :output_id
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BSV
|
|
4
|
+
module Wallet
|
|
5
|
+
module Postgres
|
|
6
|
+
class TxProof < Sequel::Model
|
|
7
|
+
include DisplayTxid
|
|
8
|
+
plugin :timestamps, update_on_create: true
|
|
9
|
+
|
|
10
|
+
many_to_one :block, class: 'BSV::Wallet::Postgres::Block'
|
|
11
|
+
one_to_many :actions, class: 'BSV::Wallet::Postgres::Action'
|
|
12
|
+
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BSV
|
|
4
|
+
module Wallet
|
|
5
|
+
module Postgres
|
|
6
|
+
# Tier 1 UTXO selection — delegates to Store#find_spendable.
|
|
7
|
+
#
|
|
8
|
+
# No reservation at this tier. Locking happens in Store#create_action
|
|
9
|
+
# via the input row INSERT ON CONFLICT.
|
|
10
|
+
class UTXOPool
|
|
11
|
+
include BSV::Wallet::Interface::UTXOPool
|
|
12
|
+
|
|
13
|
+
MAX_UTXO_COUNT = 500
|
|
14
|
+
MIN_UTXO_SATS = 1000
|
|
15
|
+
MAX_CHANGE_PER_TX = 8
|
|
16
|
+
|
|
17
|
+
def initialize(store:, max_utxo_count: MAX_UTXO_COUNT,
|
|
18
|
+
min_utxo_sats: MIN_UTXO_SATS,
|
|
19
|
+
max_change_per_tx: MAX_CHANGE_PER_TX)
|
|
20
|
+
raise ArgumentError, 'max_utxo_count must be >= 1' unless max_utxo_count >= 1
|
|
21
|
+
raise ArgumentError, 'min_utxo_sats must be positive' unless min_utxo_sats.positive?
|
|
22
|
+
raise ArgumentError, 'max_change_per_tx must be >= 1' unless max_change_per_tx >= 1
|
|
23
|
+
|
|
24
|
+
@store = store
|
|
25
|
+
@max_utxo_count = max_utxo_count
|
|
26
|
+
@min_utxo_sats = min_utxo_sats
|
|
27
|
+
@max_change_per_tx = max_change_per_tx
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def select(satoshis:, exclude: [])
|
|
31
|
+
candidates = @store.find_spendable(satoshis: satoshis, exclude: exclude)
|
|
32
|
+
total = candidates.sum { |c| c[:satoshis] }
|
|
33
|
+
raise BSV::Wallet::PoolDepletedError, 'default' if total < satoshis
|
|
34
|
+
|
|
35
|
+
candidates
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def release(outputs:)
|
|
39
|
+
# No-op for tier 1 — CASCADE handles it
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def balance
|
|
43
|
+
(Output.spendable.sum(:satoshis) || 0).to_i
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def spendable_count
|
|
47
|
+
Output.spendable.count
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def change_output_count
|
|
51
|
+
target = [@max_utxo_count, balance / @min_utxo_sats].min
|
|
52
|
+
deficit = target - spendable_count
|
|
53
|
+
deficit.clamp(1, @max_change_per_tx)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|