bsv-wallet-postgres 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9afef26a167d09125962a45afc6d24d0233de215847c34233346fc1dbba11f34
4
- data.tar.gz: 453a4d2d63f1735beae93aeb4e32ef37c9a15ab09141865a7789b736824b4507
3
+ metadata.gz: cc93b503d2d084ed5b1221f97803f66b171141f2c8a855b7c247b0a54b434fc3
4
+ data.tar.gz: 55b88e4b7a82168df0da8b38ace070fbc50d76249e08e4aa35aaa49bb09a199f
5
5
  SHA512:
6
- metadata.gz: 600ceec193391521686eae2a52201e905cd9d9ac495a07060747eb7e3b97b5d8bf1172774d1b67f16d15328d94e51e20eab2c1afd13c7e7042221f053fd99537
7
- data.tar.gz: 60925f55c22d87445398817f1368096df57b639b2e439b60e09a40ecc716c6d70abaeee49294b8bef9f30d92bf41d88906a9e45000be8ca03d6c07214733bb64
6
+ metadata.gz: d124ec0511c1ed69ff41a4c9e3a681d794a869a89c1439286b6bac32dc173576e63f22df86f20665a8b5d216fd2057b9245d5b1756581afe4eab6f71deea39e2
7
+ data.tar.gz: 62842664bdd3ad2ba4f065f0e081065baec92c30bb7ad53cfed71dd1f91135f7e8cb3a9dbea73abe9895a4ef691da5b32ccaf744b6dcc0d3a52caa4b075aedc1
@@ -5,6 +5,34 @@ All notable changes to the `bsv-wallet-postgres` gem are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.2.0 — 2026-04-12
9
+
10
+ ### Added
11
+
12
+ - **Migration 004** — adds `satoshis`, `pending_since`, `pending_reference`,
13
+ `no_send` columns and a partial index on `(state, basket)` for spendable
14
+ rows (#353)
15
+ - **`find_spendable_outputs(basket:, min_satoshis:, sort_order:)`** — query
16
+ spendable outputs with backward-compatible COALESCE for legacy rows (#354)
17
+ - **`update_output_state(outpoint, new_state, ...)`** — transition output
18
+ state with JSONB data synchronisation (#354)
19
+ - **`lock_utxos(outpoints, reference:, no_send:)`** — atomic
20
+ `UPDATE ... WHERE state = 'spendable' RETURNING` pattern for concurrent
21
+ safety (#355)
22
+ - **`release_stale_pending!(timeout:)`** — recover stuck pending outputs,
23
+ exempting `no_send` locks (#355)
24
+ - **PostgresStore settings methods** — `store_setting` / `find_setting`
25
+
26
+ ### Fixed
27
+
28
+ - **Spendable boolean sync** — `update_output_state`, `lock_utxos`, and
29
+ `release_stale_pending!` now keep the legacy `spendable` column in sync
30
+ with the `state` column; `filter_outputs` uses dual-column WHERE clause
31
+
32
+ ### Changed
33
+
34
+ - Directory restructure — source moved to `gem/bsv-wallet-postgres/`
35
+
8
36
  ## 0.1.0 — 2026-04-09
9
37
 
10
38
  Initial release of `bsv-wallet-postgres`, a PostgreSQL-backed
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add output lifecycle state column to wallet_outputs.
4
+ #
5
+ # Introduces a nullable +state+ text column alongside the existing boolean
6
+ # +spendable+ column. The two fields are kept in sync by the application
7
+ # layer (+PostgresStore#update_output_state+).
8
+ #
9
+ # === State values
10
+ #
11
+ # * +spendable+ — the output is available for coin selection
12
+ # * +pending+ — the output has been selected but the spending transaction
13
+ # has not yet been broadcast or confirmed
14
+ # * +spent+ — the output has been consumed by a confirmed transaction
15
+ #
16
+ # === Backward compatibility
17
+ #
18
+ # Rows written before this migration have +state = NULL+. The application
19
+ # treats +state IS NULL AND spendable = TRUE+ as equivalent to
20
+ # +state = 'spendable'+ so that existing data continues to behave
21
+ # correctly without a backfill.
22
+ #
23
+ # The migration is intentionally non-destructive: +state+ is nullable
24
+ # so the migration itself applies instantly on large tables (no rewrite,
25
+ # no default scan). A backfill can be run offline if required.
26
+ Sequel.migration do
27
+ change do
28
+ alter_table(:wallet_outputs) do
29
+ add_column :state, String, null: true
30
+ add_index :state
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add wallet settings table to BSV::Wallet::PostgresStore.
4
+ #
5
+ # Provides key-value persistence for wallet configuration (e.g. change
6
+ # output parameters). Keys are short strings (e.g. 'change_params');
7
+ # values are JSON-serialised strings so the schema never needs to change
8
+ # when setting shapes evolve.
9
+ #
10
+ # The table uses +key+ as its primary key so +store_setting+ is a simple
11
+ # upsert (insert-or-replace on conflict).
12
+ Sequel.migration do
13
+ change do
14
+ create_table(:wallet_settings) do
15
+ String :key, primary_key: true
16
+ String :value, text: true, null: false
17
+ DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
18
+ end
19
+ end
20
+ end
@@ -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
@@ -109,7 +109,14 @@ module BSV
109
109
  @db[:wallet_outputs]
110
110
  .insert_conflict(
111
111
  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
+ }
113
120
  )
114
121
  .insert(row)
115
122
  output_data
@@ -128,6 +135,154 @@ module BSV
128
135
  @db[:wallet_outputs].where(outpoint: outpoint).delete.positive?
129
136
  end
130
137
 
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
+
131
286
  # --- Certificates ---
132
287
 
133
288
  def store_certificate(cert_data)
@@ -181,6 +336,18 @@ module BSV
181
336
  @db[:wallet_transactions].where(txid: txid).get(:tx_hex)
182
337
  end
183
338
 
339
+ # --- Settings ---
340
+
341
+ def store_setting(key, value)
342
+ @db[:wallet_settings]
343
+ .insert_conflict(target: :key, update: { value: value })
344
+ .insert(key: key, value: value)
345
+ end
346
+
347
+ def find_setting(key)
348
+ @db[:wallet_settings].where(key: key).get(:value)
349
+ end
350
+
184
351
  private
185
352
 
186
353
  # --- Row builders ---
@@ -195,11 +362,14 @@ module BSV
195
362
 
196
363
  def output_row(data)
197
364
  spendable = data[:spendable] != false # nil treated as spendable, like MemoryStore
365
+ state = data[:state]&.to_s
198
366
  {
199
367
  outpoint: data[:outpoint],
200
368
  basket: data[:basket],
201
369
  tags: Sequel.pg_array(Array(data[:tags]), :text),
202
370
  spendable: spendable,
371
+ state: state,
372
+ satoshis: data[:satoshis],
203
373
  data: Sequel.pg_jsonb(data.to_h)
204
374
  }
205
375
  end
@@ -224,7 +394,7 @@ module BSV
224
394
  ds = ds.where(outpoint: query[:outpoint]) if query[:outpoint]
225
395
  ds = ds.where(basket: query[:basket]) if query[:basket]
226
396
  ds = apply_array_filter(ds, :tags, query[:tags], query[:tag_query_mode])
227
- 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]
228
398
  ds
229
399
  end
230
400
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BSV
4
4
  module WalletPostgres
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bsv-wallet-postgres
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-04-09 00:00:00.000000000 Z
10
+ date: 2026-04-11 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bsv-wallet
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.3.4
18
+ version: 0.4.0
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: '1.0'
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: 0.3.4
28
+ version: 0.4.0
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '1.0'
@@ -63,11 +63,14 @@ executables: []
63
63
  extensions: []
64
64
  extra_rdoc_files: []
65
65
  files:
66
- - CHANGELOG-wallet-postgres.md
66
+ - CHANGELOG.md
67
67
  - LICENSE
68
68
  - lib/bsv-wallet-postgres.rb
69
69
  - lib/bsv/wallet_postgres.rb
70
70
  - lib/bsv/wallet_postgres/migrations/001_create_wallet_tables.rb
71
+ - lib/bsv/wallet_postgres/migrations/002_add_output_state.rb
72
+ - lib/bsv/wallet_postgres/migrations/003_add_wallet_settings.rb
73
+ - lib/bsv/wallet_postgres/migrations/004_add_pending_metadata.rb
71
74
  - lib/bsv/wallet_postgres/postgres_store.rb
72
75
  - lib/bsv/wallet_postgres/version.rb
73
76
  homepage: https://github.com/sgbett/bsv-ruby-sdk
@@ -76,7 +79,7 @@ licenses:
76
79
  metadata:
77
80
  homepage_uri: https://github.com/sgbett/bsv-ruby-sdk
78
81
  source_code_uri: https://github.com/sgbett/bsv-ruby-sdk
79
- changelog_uri: https://github.com/sgbett/bsv-ruby-sdk/blob/master/CHANGELOG-wallet-postgres.md
82
+ changelog_uri: https://github.com/sgbett/bsv-ruby-sdk/blob/master/gem/bsv-wallet-postgres/CHANGELOG.md
80
83
  rubygems_mfa_required: 'true'
81
84
  rdoc_options: []
82
85
  require_paths: