bsv-wallet-postgres 0.6.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 -123
- 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 -502
- 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,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,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
|