Skip to content

Commit 19e4a11

Browse files
authored
Merge pull request #357 from sgbett/feat/351-postgres-auto-fund
feat(wallet-postgres): implement auto-fund storage methods
2 parents 43261a7 + 7a06b34 commit 19e4a11

File tree

4 files changed

+693
-2
lines changed

4 files changed

+693
-2
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
# Add pending lock metadata columns and satoshis to wallet_outputs.
4+
#
5+
# Introduces dedicated columns to support the auto-fund UTXO management
6+
# methods (+find_spendable_outputs+, +lock_utxos+, +update_output_state+,
7+
# +release_stale_pending!+) without requiring expensive JSONB extraction
8+
# on every query.
9+
#
10+
# === Columns added
11+
#
12+
# * +satoshis+ — bigint, nullable. Mirrors the value stored inside
13+
# the JSONB +data+ blob so that +find_spendable_outputs+
14+
# can +ORDER BY satoshis+ without casting. New writes
15+
# populate this column; existing rows leave it NULL
16+
# and queries fall back to +COALESCE(satoshis, (data->>'satoshis')::bigint, 0)+.
17+
#
18+
# * +pending_since+ — timestamp (UTC), nullable. Set to +NOW()+ when a
19+
# UTXO is locked via +lock_utxos+. Used by
20+
# +release_stale_pending!+ to identify stale locks.
21+
#
22+
# * +pending_reference+ — text, nullable. Caller-supplied label passed to
23+
# +lock_utxos+. Carried through for observability.
24+
#
25+
# * +no_send+ — boolean, default +false+. When +true+ the lock is
26+
# exempt from automatic stale recovery by
27+
# +release_stale_pending!+.
28+
#
29+
# === Index added
30+
#
31+
# A partial index on +(state, basket)+ filtered to rows where
32+
# +state IS NULL OR state = 'spendable'+ accelerates the hot path of
33+
# +find_spendable_outputs+: the vast majority of rows in a live wallet
34+
# are spendable, not pending or spent.
35+
#
36+
# === Backward compatibility
37+
#
38+
# All new columns are nullable (or carry a safe default). No existing rows
39+
# are modified. The application layer handles NULL values via +COALESCE+ or
40+
# explicit IS NULL checks.
41+
Sequel.migration do
42+
up do
43+
alter_table(:wallet_outputs) do
44+
add_column :satoshis, :bigint, null: true
45+
add_column :pending_since, :timestamptz, null: true
46+
add_column :pending_reference, String, null: true
47+
add_column :no_send, :boolean, null: false, default: false
48+
end
49+
50+
# Partial index on spendable rows only — keeps the index small and
51+
# fast for the typical coin-selection scan.
52+
run <<~SQL
53+
CREATE INDEX wallet_outputs_spendable_basket_idx
54+
ON wallet_outputs (state, basket)
55+
WHERE (state IS NULL OR state = 'spendable');
56+
SQL
57+
end
58+
59+
down do
60+
run 'DROP INDEX IF EXISTS wallet_outputs_spendable_basket_idx;'
61+
62+
alter_table(:wallet_outputs) do
63+
drop_column :no_send
64+
drop_column :pending_reference
65+
drop_column :pending_since
66+
drop_column :satoshis
67+
end
68+
end
69+
end

gem/bsv-wallet-postgres/lib/bsv/wallet_postgres/postgres_store.rb

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,14 @@ def store_output(output_data)
109109
@db[:wallet_outputs]
110110
.insert_conflict(
111111
target: :outpoint,
112-
update: { basket: row[:basket], tags: row[:tags], spendable: row[:spendable], data: row[:data] }
112+
update: {
113+
basket: row[:basket],
114+
tags: row[:tags],
115+
spendable: row[:spendable],
116+
state: row[:state],
117+
satoshis: row[:satoshis],
118+
data: row[:data]
119+
}
113120
)
114121
.insert(row)
115122
output_data
@@ -128,6 +135,154 @@ def delete_output(outpoint)
128135
@db[:wallet_outputs].where(outpoint: outpoint).delete.positive?
129136
end
130137

138+
# Returns outputs whose effective state is +:spendable+.
139+
#
140+
# Legacy rows with +state = NULL+ are treated as spendable when the
141+
# +spendable+ boolean is true (or absent), matching MemoryStore's
142+
# effective_state logic.
143+
#
144+
# @param basket [String, nil] restrict to this basket when provided
145+
# @param min_satoshis [Integer, nil] exclude outputs below this value
146+
# @param sort_order [Symbol] +:asc+ or +:desc+ (default +:desc+, largest first)
147+
# @return [Array<Hash>]
148+
def find_spendable_outputs(basket: nil, min_satoshis: nil, sort_order: :desc)
149+
ds = @db[:wallet_outputs]
150+
.where(Sequel.lit('(state = ? OR (state IS NULL AND spendable = TRUE))', 'spendable'))
151+
ds = ds.where(basket: basket) if basket
152+
if min_satoshis
153+
ds = ds.where(
154+
Sequel.lit('COALESCE(satoshis, (data->>?)::bigint, 0) >= ?', 'satoshis', min_satoshis)
155+
)
156+
end
157+
satoshis_expr = Sequel.lit('COALESCE(satoshis, (data->>?)::bigint, 0)', 'satoshis')
158+
ds = ds.order(sort_order == :asc ? Sequel.asc(satoshis_expr) : Sequel.desc(satoshis_expr))
159+
ds.all.map { |r| symbolise_keys(r[:data]) }
160+
end
161+
162+
# Transitions the state of an existing output.
163+
#
164+
# When +new_state+ is +:pending+, sets +pending_since+, +pending_reference+,
165+
# and +no_send+, and merges those values into the JSONB +data+ blob.
166+
#
167+
# When transitioning away from +:pending+, clears the pending metadata
168+
# columns and removes the corresponding keys from the JSONB blob.
169+
#
170+
# @param outpoint [String] the outpoint identifier
171+
# @param new_state [Symbol] +:spendable+, +:pending+, or +:spent+
172+
# @param pending_reference [String, nil] caller-supplied label for a pending lock
173+
# @param no_send [Boolean, nil] true if the lock belongs to a no_send transaction
174+
# @raise [BSV::Wallet::WalletError] if the outpoint is not found
175+
# @return [Hash] the updated output hash
176+
def update_output_state(outpoint, new_state, pending_reference: nil, no_send: nil)
177+
state_str = new_state.to_s
178+
179+
# Keep legacy spendable boolean in sync so filter_outputs and other
180+
# queries that haven't migrated to the state column still work.
181+
spendable_bool = new_state == :spendable
182+
183+
if new_state == :pending
184+
updates = {
185+
state: state_str,
186+
spendable: spendable_bool,
187+
pending_since: Sequel.lit('NOW()'),
188+
pending_reference: pending_reference,
189+
no_send: no_send ? true : false,
190+
data: Sequel.lit(
191+
"data || jsonb_build_object('state', ?, 'pending_since', NOW()::text, 'pending_reference', ?, 'no_send', ?)",
192+
state_str, pending_reference, no_send ? true : false
193+
)
194+
}
195+
else
196+
updates = {
197+
state: state_str,
198+
spendable: spendable_bool,
199+
pending_since: nil,
200+
pending_reference: nil,
201+
no_send: false
202+
}
203+
# Remove pending keys from JSONB blob, update state
204+
updates[:data] = Sequel.lit(
205+
"(data - 'pending_since' - 'pending_reference' - 'no_send') || jsonb_build_object('state', ?)",
206+
state_str
207+
)
208+
end
209+
210+
ds = @db[:wallet_outputs].where(outpoint: outpoint)
211+
rows_updated = ds.update(updates)
212+
raise WalletError, "Output not found: #{outpoint}" if rows_updated.zero?
213+
214+
row = ds.first
215+
symbolise_keys(row[:data])
216+
end
217+
218+
# Atomically marks a set of outpoints as +:pending+.
219+
#
220+
# Uses +UPDATE ... WHERE state = 'spendable' ... RETURNING outpoint+ so that
221+
# the check-and-set is atomic at the database level. A concurrent caller that
222+
# wins the race will have already changed the state to 'pending', so the
223+
# second caller's WHERE clause will not match and will return nothing. No
224+
# explicit row-level locking is needed — the UPDATE itself takes the lock.
225+
#
226+
# Legacy rows with +state = NULL AND spendable = TRUE+ are also eligible.
227+
#
228+
# @param outpoints [Array<String>] outpoint identifiers to lock
229+
# @param reference [String] caller-supplied pending reference
230+
# @param no_send [Boolean] true if this is a no_send lock
231+
# @return [Array<String>] outpoints that were actually locked
232+
def lock_utxos(outpoints, reference:, no_send: false)
233+
return [] if outpoints.empty?
234+
235+
rows = @db[:wallet_outputs]
236+
.where(outpoint: outpoints)
237+
.where(Sequel.lit('(state = ? OR (state IS NULL AND spendable = TRUE))', 'spendable'))
238+
.returning(:outpoint)
239+
.update(
240+
state: 'pending',
241+
spendable: false,
242+
pending_since: Sequel.lit('NOW()'),
243+
pending_reference: reference,
244+
no_send: no_send ? true : false,
245+
data: Sequel.lit(
246+
"data || jsonb_build_object('state', 'pending', 'pending_since', NOW()::text, " \
247+
"'pending_reference', ?, 'no_send', ?)",
248+
reference, no_send ? true : false
249+
)
250+
)
251+
252+
rows.map { |r| r[:outpoint] }
253+
end
254+
255+
# Releases stale pending locks back to +:spendable+.
256+
#
257+
# Any output in +:pending+ state whose +pending_since+ is older than
258+
# +timeout+ seconds is reset to +spendable+ and its pending metadata is
259+
# cleared. Outputs with +no_send = true+ are exempt and remain pending.
260+
# Outputs with +pending_since = NULL+ are also skipped — they are treated
261+
# as freshly locked (NULL means "just acquired but no timestamp yet").
262+
#
263+
# @param timeout [Integer] age in seconds before a lock is considered stale (default 300)
264+
# @return [Integer] number of outputs released
265+
def release_stale_pending!(timeout: 300)
266+
rows = @db[:wallet_outputs]
267+
.where(state: 'pending')
268+
.where(Sequel.lit('no_send IS NOT TRUE'))
269+
.where(Sequel.lit('pending_since IS NOT NULL'))
270+
.where(Sequel.lit('pending_since < (NOW() - INTERVAL ?)', "#{timeout} seconds"))
271+
.returning(:outpoint)
272+
.update(
273+
state: 'spendable',
274+
spendable: true,
275+
pending_since: nil,
276+
pending_reference: nil,
277+
no_send: false,
278+
data: Sequel.lit(
279+
"(data - 'pending_since' - 'pending_reference' - 'no_send') || jsonb_build_object('state', 'spendable')"
280+
)
281+
)
282+
283+
rows.length
284+
end
285+
131286
# --- Certificates ---
132287

133288
def store_certificate(cert_data)
@@ -207,11 +362,14 @@ def action_row(data)
207362

208363
def output_row(data)
209364
spendable = data[:spendable] != false # nil treated as spendable, like MemoryStore
365+
state = data[:state]&.to_s
210366
{
211367
outpoint: data[:outpoint],
212368
basket: data[:basket],
213369
tags: Sequel.pg_array(Array(data[:tags]), :text),
214370
spendable: spendable,
371+
state: state,
372+
satoshis: data[:satoshis],
215373
data: Sequel.pg_jsonb(data.to_h)
216374
}
217375
end
@@ -236,7 +394,7 @@ def filter_outputs(ds, query)
236394
ds = ds.where(outpoint: query[:outpoint]) if query[:outpoint]
237395
ds = ds.where(basket: query[:basket]) if query[:basket]
238396
ds = apply_array_filter(ds, :tags, query[:tags], query[:tag_query_mode])
239-
ds = ds.where(spendable: true) unless query[:include_spent]
397+
ds = ds.where(Sequel.lit('(state = ? OR (state IS NULL AND spendable = TRUE))', 'spendable')) unless query[:include_spent]
240398
ds
241399
end
242400

0 commit comments

Comments
 (0)