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,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ extension :pg_enum
6
+
7
+ # --- New enum ---
8
+ # Ternary: root (identity key, no derivation), outbound (payment to
9
+ # others, no derivation), NULL (derived, has keys).
10
+ create_enum(:output_type, %w[root outbound])
11
+
12
+ # --- 1. blocks ---
13
+ alter_table(:blocks) do
14
+ add_constraint(:height_range) { height >= 0 }
15
+ add_constraint(:merkle_root_length) { length(merkle_root) =~ 32 }
16
+ add_constraint(:block_hash_length, 'block_hash IS NULL OR length(block_hash) = 32')
17
+ end
18
+
19
+ # --- 2. tx_proofs ---
20
+ alter_table(:tx_proofs) do
21
+ set_column_not_null :raw_tx
22
+ add_constraint(:wtxid_length) { length(wtxid) =~ 32 }
23
+ add_constraint(:raw_tx_min_length) { length(raw_tx) >= 20 }
24
+ end
25
+
26
+ # --- 3. actions ---
27
+ alter_table(:actions) do
28
+ drop_column :satoshis
29
+ set_column_not_null :description
30
+ add_constraint(:wtxid_length, 'wtxid IS NULL OR length(wtxid) = 32')
31
+ add_constraint(:description_length, 'length(description) BETWEEN 5 AND 50')
32
+ add_constraint(:nlocktime_range, 'NOT outgoing OR (nlocktime IS NOT NULL AND nlocktime >= 0)')
33
+ add_constraint(:wtxid_raw_tx_parity, '(wtxid IS NULL) = (raw_tx IS NULL)')
34
+ end
35
+
36
+ # Convert reference from text to uuid
37
+ run 'ALTER TABLE actions ALTER COLUMN reference SET NOT NULL'
38
+ run 'ALTER TABLE actions ALTER COLUMN reference TYPE uuid USING reference::uuid'
39
+ run "ALTER TABLE actions ALTER COLUMN reference SET DEFAULT gen_random_uuid()"
40
+
41
+ # --- 3. broadcasts ---
42
+ alter_table(:broadcasts) do
43
+ add_constraint(:block_hash_length, 'block_hash IS NULL OR length(block_hash) = 32')
44
+ add_constraint(:block_height_range, 'block_height IS NULL OR block_height >= 0')
45
+ end
46
+
47
+ # --- 4. baskets ---
48
+ alter_table(:baskets) do
49
+ drop_column :deleted_at
50
+ add_constraint(:name_length, 'length(name) BETWEEN 1 AND 300')
51
+ add_constraint(:name_not_default, "name != 'default'")
52
+ add_constraint(:target_count_range, 'target_count IS NULL OR target_count >= 0')
53
+ add_constraint(:target_value_range, 'target_value IS NULL OR target_value >= 0')
54
+ end
55
+ # Replace partial unique with plain unique
56
+ run 'DROP INDEX IF EXISTS baskets_name_index'
57
+ alter_table(:baskets) do
58
+ add_unique_constraint :name, name: :baskets_name_unique
59
+ end
60
+
61
+ # --- 5. outputs ---
62
+ # The immutable log. Derivation data lives here — it's a fact about the
63
+ # output, recorded when the key is derived. Spendable is pure membership.
64
+ alter_table(:outputs) do
65
+ set_column_not_null :locking_script
66
+ add_column :output_type, :output_type
67
+ add_constraint(:satoshis_range) { satoshis >= 0 }
68
+ add_constraint(:vout_range) { vout >= 0 }
69
+ add_constraint(:locking_script_min) { length(locking_script) >= 1 }
70
+ # Typed outputs (root, outbound) use identity key or belong to
71
+ # others — no derivation fields allowed
72
+ add_constraint(:typed_no_prefix, 'output_type IS NULL OR derivation_prefix IS NULL')
73
+ add_constraint(:typed_no_suffix, 'output_type IS NULL OR derivation_suffix IS NULL')
74
+ add_constraint(:typed_no_sender, 'output_type IS NULL OR sender_identity_key IS NULL')
75
+ # Derived outputs (NULL type) must have all derivation fields
76
+ add_constraint(:derived_needs_prefix, 'output_type IS NOT NULL OR derivation_prefix IS NOT NULL')
77
+ add_constraint(:derived_needs_suffix, 'output_type IS NOT NULL OR derivation_suffix IS NOT NULL')
78
+ add_constraint(:derived_needs_sender, 'output_type IS NOT NULL OR sender_identity_key IS NOT NULL')
79
+ end
80
+
81
+ # --- 6. spendable ---
82
+ # Pure set membership — a row's existence IS the wallet.
83
+ alter_table(:spendable) do
84
+ set_column_not_null :action_id
85
+ end
86
+
87
+ # Outbound outputs are payments to others — they must never have a
88
+ # spendable row. Cross-table CHECK constraints aren't possible in
89
+ # PostgreSQL, so a trigger enforces this invariant.
90
+ run <<~SQL
91
+ CREATE FUNCTION prevent_outbound_spendable() RETURNS trigger AS $$
92
+ BEGIN
93
+ IF EXISTS (SELECT 1 FROM outputs WHERE id = NEW.output_id AND output_type = 'outbound') THEN
94
+ RAISE EXCEPTION 'spendable row forbidden for outbound output %', NEW.output_id
95
+ USING ERRCODE = 'check_violation';
96
+ END IF;
97
+ RETURN NEW;
98
+ END;
99
+ $$ LANGUAGE plpgsql;
100
+ SQL
101
+ run <<~SQL
102
+ CREATE TRIGGER check_outbound_spendable
103
+ BEFORE INSERT ON spendable
104
+ FOR EACH ROW
105
+ EXECUTE FUNCTION prevent_outbound_spendable();
106
+ SQL
107
+
108
+ # --- 7. output_details ---
109
+ # change column stays — cosmetic flag for display, not structural
110
+ alter_table(:output_details) do
111
+ set_column_not_null :action_id
112
+ end
113
+
114
+ # --- 8. output_baskets ---
115
+ alter_table(:output_baskets) do
116
+ set_column_not_null :action_id
117
+ end
118
+
119
+ # --- 9. inputs ---
120
+ alter_table(:inputs) do
121
+ add_constraint(:vin_range) { vin >= 0 }
122
+ add_constraint(:nsequence_range, 'nsequence BETWEEN 0 AND 4294967295')
123
+ end
124
+
125
+ # --- 10. labels ---
126
+ alter_table(:labels) do
127
+ drop_column :deleted_at
128
+ add_constraint(:label_length, 'length(label) BETWEEN 1 AND 300')
129
+ end
130
+ run 'DROP INDEX IF EXISTS labels_label_index'
131
+ alter_table(:labels) do
132
+ add_unique_constraint :label, name: :labels_label_unique
133
+ end
134
+
135
+ # --- 11. action_labels ---
136
+ alter_table(:action_labels) do
137
+ drop_column :deleted_at
138
+ # Replace FK without cascade with cascading FK
139
+ drop_foreign_key [:action_id]
140
+ add_foreign_key [:action_id], :actions, on_delete: :cascade
141
+ end
142
+
143
+ # --- 12. tags ---
144
+ alter_table(:tags) do
145
+ drop_column :deleted_at
146
+ add_constraint(:tag_length, 'length(tag) BETWEEN 1 AND 300')
147
+ end
148
+ run 'DROP INDEX IF EXISTS tags_tag_index'
149
+ alter_table(:tags) do
150
+ add_unique_constraint :tag, name: :tags_tag_unique
151
+ end
152
+
153
+ # --- 13. output_tags ---
154
+ alter_table(:output_tags) do
155
+ drop_column :deleted_at
156
+ end
157
+
158
+ # --- 14. certificates ---
159
+ alter_table(:certificates) do
160
+ drop_column :deleted_at
161
+ end
162
+
163
+ # --- 15. tx_reqs ---
164
+ alter_table(:tx_reqs) do
165
+ add_constraint(:wtxid_length) { length(wtxid) =~ 32 }
166
+ add_constraint(:status_values, "status IN ('unmined', 'completed', 'failed')")
167
+ add_constraint(:attempts_range) { attempts >= 0 }
168
+ end
169
+ end
170
+
171
+ down do
172
+ extension :pg_enum
173
+
174
+ # --- 15. tx_reqs ---
175
+ alter_table(:tx_reqs) do
176
+ drop_constraint :wtxid_length
177
+ drop_constraint :status_values
178
+ drop_constraint :attempts_range
179
+ end
180
+
181
+ # --- 14. certificates ---
182
+ alter_table(:certificates) do
183
+ add_column :deleted_at, :timestamptz
184
+ end
185
+
186
+ # --- 13. output_tags ---
187
+ alter_table(:output_tags) do
188
+ add_column :deleted_at, :timestamptz
189
+ end
190
+
191
+ # --- 12. tags ---
192
+ alter_table(:tags) do
193
+ drop_constraint :tag_length
194
+ drop_constraint :tags_tag_unique, type: :unique
195
+ add_column :deleted_at, :timestamptz
196
+ end
197
+ run "CREATE UNIQUE INDEX tags_tag_index ON tags (tag) WHERE deleted_at IS NULL"
198
+
199
+ # --- 11. action_labels ---
200
+ alter_table(:action_labels) do
201
+ drop_foreign_key [:action_id]
202
+ add_foreign_key [:action_id], :actions
203
+ add_column :deleted_at, :timestamptz
204
+ end
205
+
206
+ # --- 10. labels ---
207
+ alter_table(:labels) do
208
+ drop_constraint :label_length
209
+ drop_constraint :labels_label_unique, type: :unique
210
+ add_column :deleted_at, :timestamptz
211
+ end
212
+ run "CREATE UNIQUE INDEX labels_label_index ON labels (label) WHERE deleted_at IS NULL"
213
+
214
+ # --- 9. inputs ---
215
+ alter_table(:inputs) do
216
+ drop_constraint :vin_range
217
+ drop_constraint :nsequence_range
218
+ end
219
+
220
+ # --- 8. output_baskets ---
221
+ alter_table(:output_baskets) do
222
+ set_column_allow_null :action_id
223
+ end
224
+
225
+ # --- 7. output_details ---
226
+ alter_table(:output_details) do
227
+ set_column_allow_null :action_id
228
+ end
229
+
230
+ # --- 6. spendable ---
231
+ run 'DROP TRIGGER IF EXISTS check_outbound_spendable ON spendable'
232
+ run 'DROP FUNCTION IF EXISTS prevent_outbound_spendable()'
233
+ alter_table(:spendable) do
234
+ set_column_allow_null :action_id
235
+ end
236
+
237
+ # --- 5. outputs ---
238
+ alter_table(:outputs) do
239
+ drop_constraint :typed_no_prefix
240
+ drop_constraint :typed_no_suffix
241
+ drop_constraint :typed_no_sender
242
+ drop_constraint :derived_needs_prefix
243
+ drop_constraint :derived_needs_suffix
244
+ drop_constraint :derived_needs_sender
245
+ drop_constraint :satoshis_range
246
+ drop_constraint :vout_range
247
+ drop_constraint :locking_script_min
248
+ drop_column :output_type
249
+ set_column_allow_null :locking_script
250
+ end
251
+
252
+ # --- 4. baskets ---
253
+ alter_table(:baskets) do
254
+ drop_constraint :name_length
255
+ drop_constraint :name_not_default
256
+ drop_constraint :target_count_range
257
+ drop_constraint :target_value_range
258
+ drop_constraint :baskets_name_unique, type: :unique
259
+ add_column :deleted_at, :timestamptz
260
+ end
261
+ run "CREATE UNIQUE INDEX baskets_name_index ON baskets (name) WHERE deleted_at IS NULL"
262
+
263
+ # --- 3. broadcasts ---
264
+ alter_table(:broadcasts) do
265
+ drop_constraint :block_hash_length
266
+ drop_constraint :block_height_range
267
+ end
268
+
269
+ # --- 3. actions ---
270
+ run "ALTER TABLE actions ALTER COLUMN reference TYPE text USING reference::text"
271
+ run "ALTER TABLE actions ALTER COLUMN reference DROP NOT NULL"
272
+ alter_table(:actions) do
273
+ drop_constraint :wtxid_length
274
+ drop_constraint :description_length
275
+ drop_constraint :nlocktime_range
276
+ drop_constraint :wtxid_raw_tx_parity
277
+ set_column_allow_null :description
278
+ add_column :satoshis, :bigint
279
+ end
280
+
281
+ # --- 2. tx_proofs ---
282
+ alter_table(:tx_proofs) do
283
+ drop_constraint :wtxid_length
284
+ drop_constraint :raw_tx_min_length
285
+ set_column_allow_null :raw_tx
286
+ end
287
+
288
+ # --- 1. blocks ---
289
+ alter_table(:blocks) do
290
+ drop_constraint :height_range
291
+ drop_constraint :merkle_root_length
292
+ drop_constraint :block_hash_length
293
+ end
294
+
295
+ drop_enum(:output_type)
296
+ end
297
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ drop_table(:tx_reqs)
6
+ end
7
+
8
+ down do
9
+ create_table(:tx_reqs) do
10
+ column :id, :bigint, primary_key: true, identity: :always
11
+ foreign_key :tx_proof_id, :tx_proofs, type: :bigint
12
+ column :wtxid, :bytea, null: false, unique: true
13
+ column :status, :text, null: false, default: 'unmined'
14
+ column :attempts, :integer, null: false, default: 0
15
+ column :notified, :boolean, null: false, default: false
16
+ column :history, :text
17
+ column :notify, :text
18
+ column :batch, :text
19
+ column :raw_tx, :bytea
20
+ column :input_beef, :bytea
21
+ column :created_at, :timestamptz, null: false, default: Sequel.function(:now)
22
+ column :updated_at, :timestamptz, null: false, default: Sequel.function(:now)
23
+
24
+ index :status
25
+
26
+ # Constraints from migration 003
27
+ constraint(:wtxid_length) { length(wtxid) =~ 32 }
28
+ constraint(:status_values, "status IN ('unmined', 'completed', 'failed')")
29
+ constraint(:attempts_range) { attempts >= 0 }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ module Postgres
6
+ class Action < Sequel::Model
7
+ include DisplayTxid
8
+ include BSV::Wallet::Fetchable
9
+
10
+ plugin :timestamps, update_on_create: true
11
+
12
+ many_to_one :tx_proof, class: 'BSV::Wallet::Postgres::TxProof'
13
+ one_to_one :broadcast_entry, class: 'BSV::Wallet::Postgres::Broadcast', key: :action_id
14
+ one_to_many :outputs, class: 'BSV::Wallet::Postgres::Output'
15
+ one_to_many :inputs, class: 'BSV::Wallet::Postgres::Input'
16
+ many_to_many :labels, class: 'BSV::Wallet::Postgres::Label',
17
+ join_table: :action_labels,
18
+ left_key: :action_id, right_key: :label_id
19
+
20
+ # Derive BRC-100 status from structural state.
21
+ # No status column — the database structure IS the state.
22
+ #
23
+ # @return [Symbol]
24
+ def derived_status
25
+ return :unsigned if wtxid.nil?
26
+ return :completed if tx_proof_id
27
+ return :nosend if values[:broadcast] == 'none'
28
+ return :unproven unless outputs_dataset.empty?
29
+ return :failed if broadcast_entry&.tx_status == 'REJECTED'
30
+ return :sending if broadcast_entry
31
+ :unprocessed
32
+ end
33
+
34
+ # -- Fetchable contract --
35
+
36
+ def fetch_command
37
+ :get_tx_status
38
+ end
39
+
40
+ def fetch_args
41
+ { txid: dtxid }
42
+ end
43
+
44
+ def needs_fetch?
45
+ outgoing && !wtxid.nil? && tx_proof_id.nil?
46
+ end
47
+
48
+ # Create a TxProof from the network response when proof data is present.
49
+ # No-op when the transaction is not yet mined (no merkle_path/block_height).
50
+ #
51
+ # @param response [BSV::Network::ProtocolResponse] normalized response
52
+ def write!(response)
53
+ data = response.data
54
+ return unless data.is_a?(Hash) && data[:merkle_path] && data[:block_height]
55
+
56
+ proof_store = ProofStore.new
57
+ proof_id = proof_store.save_proof(
58
+ wtxid: wtxid,
59
+ proof: {
60
+ height: data[:block_height],
61
+ block_hash: decode_hex(data[:block_hash]),
62
+ merkle_path: decode_hex(data[:merkle_path]),
63
+ raw_tx: raw_tx
64
+ }
65
+ )
66
+ update(tx_proof_id: proof_id)
67
+ end
68
+
69
+ private
70
+
71
+ def decode_hex(hex)
72
+ return unless hex
73
+ return hex if hex.encoding == Encoding::BINARY
74
+
75
+ [hex].pack('H*')
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ module Postgres
6
+ class ActionLabel < Sequel::Model
7
+ plugin :timestamps, update_on_create: true
8
+
9
+ many_to_one :action, class: 'BSV::Wallet::Postgres::Action'
10
+ many_to_one :label, class: 'BSV::Wallet::Postgres::Label'
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ module Postgres
6
+ # Bridges the BroadcastQueue's arc_client interface (which passes raw_tx
7
+ # binary) with the SDK's ARC protocol (which expects Transaction objects).
8
+ #
9
+ # @example
10
+ # provider = BSV::Network::Providers::GorillaPool.mainnet
11
+ # adapter = ArcAdapter.new(provider)
12
+ # broadcast_queue = BroadcastQueue.new(db: db, arc_client: adapter)
13
+ class ArcAdapter
14
+ def initialize(provider)
15
+ @provider = provider
16
+ end
17
+
18
+ def call(method, *args, **kwargs)
19
+ case method
20
+ when :broadcast
21
+ tx = BSV::Transaction::Transaction.from_binary(args.first)
22
+ @provider.call(:broadcast, tx)
23
+ when :get_tx_status
24
+ @provider.call(:get_tx_status, **kwargs)
25
+ else
26
+ @provider.call(method, *args, **kwargs)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ module Postgres
6
+ class Basket < Sequel::Model
7
+ plugin :timestamps, update_on_create: true
8
+
9
+ one_to_many :output_baskets, class: 'BSV::Wallet::Postgres::OutputBasket'
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ module Postgres
6
+ class Block < Sequel::Model
7
+ plugin :timestamps, update_on_create: true
8
+
9
+ one_to_many :tx_proofs, class: 'BSV::Wallet::Postgres::TxProof'
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ module Postgres
6
+ class Broadcast < Sequel::Model
7
+ include BSV::Wallet::Pushable
8
+ include BSV::Wallet::Fetchable
9
+
10
+ plugin :timestamps, update_on_create: true
11
+
12
+ many_to_one :action, class: 'BSV::Wallet::Postgres::Action'
13
+
14
+ # Broadcasts with these statuses are considered terminal — no further polling.
15
+ TERMINAL_STATUSES = %w[
16
+ SEEN_ON_NETWORK MINED IMMUTABLE
17
+ REJECTED DOUBLE_SPEND_ATTEMPTED
18
+ ].freeze
19
+
20
+ # Minimum age (seconds) before a broadcast is eligible for status polling.
21
+ FETCH_STALENESS = 30
22
+
23
+ # --- Pushable contract ---
24
+
25
+ def push_command
26
+ :broadcast
27
+ end
28
+
29
+ def push_payload
30
+ action.raw_tx
31
+ end
32
+
33
+ def needs_push?
34
+ broadcast_at.nil? && action&.raw_tx
35
+ end
36
+
37
+ # --- Fetchable contract ---
38
+
39
+ def fetch_command
40
+ :get_tx_status
41
+ end
42
+
43
+ def fetch_args
44
+ { txid: action.dtxid }
45
+ end
46
+
47
+ def needs_fetch?
48
+ return false unless broadcast_at
49
+ return false if TERMINAL_STATUSES.include?(tx_status)
50
+
51
+ broadcast_at < Time.now - FETCH_STALENESS
52
+ end
53
+
54
+ # --- Shared write ---
55
+
56
+ # Update columns from a normalized Services response.
57
+ #
58
+ # @param response [BSV::Network::ProtocolResponse] normalized response
59
+ def write!(response)
60
+ data = response.data
61
+ return unless data.is_a?(Hash)
62
+
63
+ fields = {}
64
+ fields[:broadcast_at] = Time.now if broadcast_at.nil?
65
+ fields[:tx_status] = data[:tx_status] if data[:tx_status]
66
+ fields[:arc_status] = data[:status] if data[:status]
67
+ fields[:block_hash] = decode_hex(data[:block_hash]) if data[:block_hash]
68
+ fields[:block_height] = data[:block_height] if data[:block_height]
69
+ fields[:merkle_path] = decode_hex(data[:merkle_path]) if data[:merkle_path]
70
+ fields[:extra_info] = data[:extra_info] if data[:extra_info]
71
+ fields[:competing_txs] = Sequel.pg_array(data[:competing_txs]) if data[:competing_txs]
72
+
73
+ update(fields) unless fields.empty?
74
+ end
75
+
76
+ private
77
+
78
+ def decode_hex(value)
79
+ return unless value
80
+ return Sequel.blob(value) if value.encoding == Encoding::BINARY
81
+
82
+ Sequel.blob([value].pack('H*'))
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rack'
5
+
6
+ module BSV
7
+ module Wallet
8
+ module Postgres
9
+ # Rack app that receives ARC TransactionStatus webhook POSTs
10
+ # and delegates to a BroadcastQueue instance.
11
+ #
12
+ # Mount however the host application prefers:
13
+ # - Standalone: rackup with config.ru
14
+ # - Rails: mount BroadcastCallback.new(broadcast_queue:) => '/arc/callback'
15
+ class BroadcastCallback
16
+ def initialize(broadcast_queue:)
17
+ @broadcast_queue = broadcast_queue
18
+ end
19
+
20
+ def call(env)
21
+ request = Rack::Request.new(env)
22
+ body = JSON.parse(request.body.read, symbolize_names: true)
23
+ event = decode_event(body)
24
+ @broadcast_queue.handle_event(event)
25
+ [200, { 'content-type' => 'text/plain' }, ['OK']]
26
+ rescue JSON::ParserError
27
+ [400, { 'content-type' => 'text/plain' }, ['Bad Request']]
28
+ end
29
+
30
+ private
31
+
32
+ def decode_event(body)
33
+ BSV::Primitives::Hex.validate_dtxid_hex!(body[:txid], name: 'ARC callback txid') if body[:txid]
34
+ {
35
+ wtxid: decode_hex(body[:txid])&.reverse,
36
+ tx_status: body[:txStatus],
37
+ status: body[:status],
38
+ block_hash: decode_hex(body[:blockHash]),
39
+ block_height: body[:blockHeight],
40
+ merkle_path: decode_hex(body[:merklePath]),
41
+ extra_info: body[:extraInfo],
42
+ competing_txs: body[:competingTxs]
43
+ }
44
+ end
45
+
46
+ def decode_hex(hex)
47
+ return unless hex
48
+
49
+ [hex].pack('H*')
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end