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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -115
  3. data/LICENSE +23 -80
  4. data/db/migrations/001_create_schema.rb +261 -0
  5. data/db/migrations/002_action_id_cascade.rb +66 -0
  6. data/db/migrations/003_schema_constraints.rb +297 -0
  7. data/db/migrations/004_drop_tx_reqs.rb +32 -0
  8. data/lib/bsv/wallet/postgres/action.rb +80 -0
  9. data/lib/bsv/wallet/postgres/action_label.rb +14 -0
  10. data/lib/bsv/wallet/postgres/arc_adapter.rb +32 -0
  11. data/lib/bsv/wallet/postgres/basket.rb +13 -0
  12. data/lib/bsv/wallet/postgres/block.rb +13 -0
  13. data/lib/bsv/wallet/postgres/broadcast.rb +87 -0
  14. data/lib/bsv/wallet/postgres/broadcast_callback.rb +54 -0
  15. data/lib/bsv/wallet/postgres/broadcast_queue.rb +98 -0
  16. data/lib/bsv/wallet/postgres/certificate.rb +13 -0
  17. data/lib/bsv/wallet/postgres/certificate_field.rb +13 -0
  18. data/lib/bsv/wallet/postgres/display_txid.rb +25 -0
  19. data/lib/bsv/wallet/postgres/input.rb +14 -0
  20. data/lib/bsv/wallet/postgres/label.rb +15 -0
  21. data/lib/bsv/wallet/postgres/output.rb +64 -0
  22. data/lib/bsv/wallet/postgres/output_basket.rb +15 -0
  23. data/lib/bsv/wallet/postgres/output_detail.rb +12 -0
  24. data/lib/bsv/wallet/postgres/output_tag.rb +14 -0
  25. data/lib/bsv/wallet/postgres/proof_store.rb +109 -0
  26. data/lib/bsv/wallet/postgres/setting.rb +32 -0
  27. data/lib/bsv/wallet/postgres/spendable.rb +12 -0
  28. data/lib/bsv/wallet/postgres/store.rb +580 -0
  29. data/lib/bsv/wallet/postgres/tag.rb +15 -0
  30. data/lib/bsv/wallet/postgres/tx_proof.rb +16 -0
  31. data/lib/bsv/wallet/postgres/utxo_pool.rb +58 -0
  32. data/lib/bsv/wallet/postgres/version.rb +9 -0
  33. data/lib/bsv/wallet/postgres.rb +77 -0
  34. data/lib/bsv-wallet-postgres.rb +1 -1
  35. metadata +49 -35
  36. data/lib/bsv/wallet_postgres/migrations/001_create_wallet_tables.rb +0 -58
  37. data/lib/bsv/wallet_postgres/migrations/002_add_output_state.rb +0 -33
  38. data/lib/bsv/wallet_postgres/migrations/003_add_wallet_settings.rb +0 -20
  39. data/lib/bsv/wallet_postgres/migrations/004_add_pending_metadata.rb +0 -69
  40. data/lib/bsv/wallet_postgres/migrations/005_add_txid_unique_index.rb +0 -27
  41. data/lib/bsv/wallet_postgres/migrations/006_create_broadcast_jobs.rb +0 -68
  42. data/lib/bsv/wallet_postgres/postgres_store.rb +0 -482
  43. data/lib/bsv/wallet_postgres/solid_queue_adapter.rb +0 -328
  44. data/lib/bsv/wallet_postgres/version.rb +0 -7
  45. 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ module Postgres
6
+ VERSION = '0.100.0'
7
+ end
8
+ end
9
+ end