@@ -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