bsv-wallet-postgres 0.1.0 → 0.3.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: b1159b17c5c9a6d0f20be0c47648eab1fbb13f8131e4d9b48d5a13ff5edc8126
4
+ data.tar.gz: 4abd93ce4a43614e4c28b0601d031ce8ca51504dfd2270d6cf58856148dca1d8
5
5
  SHA512:
6
- metadata.gz: 600ceec193391521686eae2a52201e905cd9d9ac495a07060747eb7e3b97b5d8bf1172774d1b67f16d15328d94e51e20eab2c1afd13c7e7042221f053fd99537
7
- data.tar.gz: 60925f55c22d87445398817f1368096df57b639b2e439b60e09a40ecc716c6d70abaeee49294b8bef9f30d92bf41d88906a9e45000be8ca03d6c07214733bb64
6
+ metadata.gz: 846999a97dd79648e234d37863bbceeda51478d1926500d4e2176cbae5eb9646fbc240a67bb4c80a6e5554f49cc4ff51ac779c3b1d73c4d2e475fd335449ac1f
7
+ data.tar.gz: 5643c7caead597cf5327ada2b857a0faf375769f46010bc6545cb995130611c3e05b5c27985333f103e1b240573f4dbea4ce6be7259c23cc43d45448da1bcfb0
@@ -5,6 +5,40 @@ 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.3.0 — 2026-04-12
9
+
10
+ ### Added
11
+ - `update_action_status` and `delete_action` implementations for PostgresStore,
12
+ matching the new StorageAdapter contract introduced in bsv-wallet 0.6.0 (#370)
13
+
14
+ ## 0.2.0 — 2026-04-12
15
+
16
+ ### Added
17
+
18
+ - **Migration 004** — adds `satoshis`, `pending_since`, `pending_reference`,
19
+ `no_send` columns and a partial index on `(state, basket)` for spendable
20
+ rows (#353)
21
+ - **`find_spendable_outputs(basket:, min_satoshis:, sort_order:)`** — query
22
+ spendable outputs with backward-compatible COALESCE for legacy rows (#354)
23
+ - **`update_output_state(outpoint, new_state, ...)`** — transition output
24
+ state with JSONB data synchronisation (#354)
25
+ - **`lock_utxos(outpoints, reference:, no_send:)`** — atomic
26
+ `UPDATE ... WHERE state = 'spendable' RETURNING` pattern for concurrent
27
+ safety (#355)
28
+ - **`release_stale_pending!(timeout:)`** — recover stuck pending outputs,
29
+ exempting `no_send` locks (#355)
30
+ - **PostgresStore settings methods** — `store_setting` / `find_setting`
31
+
32
+ ### Fixed
33
+
34
+ - **Spendable boolean sync** — `update_output_state`, `lock_utxos`, and
35
+ `release_stale_pending!` now keep the legacy `spendable` column in sync
36
+ with the `state` column; `filter_outputs` uses dual-column WHERE clause
37
+
38
+ ### Changed
39
+
40
+ - Directory restructure — source moved to `gem/bsv-wallet-postgres/`
41
+
8
42
  ## 0.1.0 — 2026-04-09
9
43
 
10
44
  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
@@ -102,6 +102,23 @@ module BSV
102
102
  filter_actions(@db[:wallet_actions], query).count
103
103
  end
104
104
 
105
+ def update_action_status(txid, new_status)
106
+ ds = @db[:wallet_actions].where(txid: txid)
107
+ raise WalletError, "Action not found: #{txid}" if ds.empty?
108
+
109
+ ds.update(
110
+ data: Sequel.lit(
111
+ "data || jsonb_build_object('status', ?)",
112
+ new_status
113
+ )
114
+ )
115
+ symbolise_keys(ds.first[:data])
116
+ end
117
+
118
+ def delete_action(txid)
119
+ @db[:wallet_actions].where(txid: txid).delete.positive?
120
+ end
121
+
105
122
  # --- Outputs ---
106
123
 
107
124
  def store_output(output_data)
@@ -109,7 +126,14 @@ module BSV
109
126
  @db[:wallet_outputs]
110
127
  .insert_conflict(
111
128
  target: :outpoint,
112
- update: { basket: row[:basket], tags: row[:tags], spendable: row[:spendable], data: row[:data] }
129
+ update: {
130
+ basket: row[:basket],
131
+ tags: row[:tags],
132
+ spendable: row[:spendable],
133
+ state: row[:state],
134
+ satoshis: row[:satoshis],
135
+ data: row[:data]
136
+ }
113
137
  )
114
138
  .insert(row)
115
139
  output_data
@@ -128,6 +152,154 @@ module BSV
128
152
  @db[:wallet_outputs].where(outpoint: outpoint).delete.positive?
129
153
  end
130
154
 
155
+ # Returns outputs whose effective state is +:spendable+.
156
+ #
157
+ # Legacy rows with +state = NULL+ are treated as spendable when the
158
+ # +spendable+ boolean is true (or absent), matching MemoryStore's
159
+ # effective_state logic.
160
+ #
161
+ # @param basket [String, nil] restrict to this basket when provided
162
+ # @param min_satoshis [Integer, nil] exclude outputs below this value
163
+ # @param sort_order [Symbol] +:asc+ or +:desc+ (default +:desc+, largest first)
164
+ # @return [Array<Hash>]
165
+ def find_spendable_outputs(basket: nil, min_satoshis: nil, sort_order: :desc)
166
+ ds = @db[:wallet_outputs]
167
+ .where(Sequel.lit('(state = ? OR (state IS NULL AND spendable = TRUE))', 'spendable'))
168
+ ds = ds.where(basket: basket) if basket
169
+ if min_satoshis
170
+ ds = ds.where(
171
+ Sequel.lit('COALESCE(satoshis, (data->>?)::bigint, 0) >= ?', 'satoshis', min_satoshis)
172
+ )
173
+ end
174
+ satoshis_expr = Sequel.lit('COALESCE(satoshis, (data->>?)::bigint, 0)', 'satoshis')
175
+ ds = ds.order(sort_order == :asc ? Sequel.asc(satoshis_expr) : Sequel.desc(satoshis_expr))
176
+ ds.all.map { |r| symbolise_keys(r[:data]) }
177
+ end
178
+
179
+ # Transitions the state of an existing output.
180
+ #
181
+ # When +new_state+ is +:pending+, sets +pending_since+, +pending_reference+,
182
+ # and +no_send+, and merges those values into the JSONB +data+ blob.
183
+ #
184
+ # When transitioning away from +:pending+, clears the pending metadata
185
+ # columns and removes the corresponding keys from the JSONB blob.
186
+ #
187
+ # @param outpoint [String] the outpoint identifier
188
+ # @param new_state [Symbol] +:spendable+, +:pending+, or +:spent+
189
+ # @param pending_reference [String, nil] caller-supplied label for a pending lock
190
+ # @param no_send [Boolean, nil] true if the lock belongs to a no_send transaction
191
+ # @raise [BSV::Wallet::WalletError] if the outpoint is not found
192
+ # @return [Hash] the updated output hash
193
+ def update_output_state(outpoint, new_state, pending_reference: nil, no_send: nil)
194
+ state_str = new_state.to_s
195
+
196
+ # Keep legacy spendable boolean in sync so filter_outputs and other
197
+ # queries that haven't migrated to the state column still work.
198
+ spendable_bool = new_state == :spendable
199
+
200
+ if new_state == :pending
201
+ updates = {
202
+ state: state_str,
203
+ spendable: spendable_bool,
204
+ pending_since: Sequel.lit('NOW()'),
205
+ pending_reference: pending_reference,
206
+ no_send: no_send ? true : false,
207
+ data: Sequel.lit(
208
+ "data || jsonb_build_object('state', ?, 'pending_since', NOW()::text, 'pending_reference', ?, 'no_send', ?)",
209
+ state_str, pending_reference, no_send ? true : false
210
+ )
211
+ }
212
+ else
213
+ updates = {
214
+ state: state_str,
215
+ spendable: spendable_bool,
216
+ pending_since: nil,
217
+ pending_reference: nil,
218
+ no_send: false
219
+ }
220
+ # Remove pending keys from JSONB blob, update state
221
+ updates[:data] = Sequel.lit(
222
+ "(data - 'pending_since' - 'pending_reference' - 'no_send') || jsonb_build_object('state', ?)",
223
+ state_str
224
+ )
225
+ end
226
+
227
+ ds = @db[:wallet_outputs].where(outpoint: outpoint)
228
+ rows_updated = ds.update(updates)
229
+ raise WalletError, "Output not found: #{outpoint}" if rows_updated.zero?
230
+
231
+ row = ds.first
232
+ symbolise_keys(row[:data])
233
+ end
234
+
235
+ # Atomically marks a set of outpoints as +:pending+.
236
+ #
237
+ # Uses +UPDATE ... WHERE state = 'spendable' ... RETURNING outpoint+ so that
238
+ # the check-and-set is atomic at the database level. A concurrent caller that
239
+ # wins the race will have already changed the state to 'pending', so the
240
+ # second caller's WHERE clause will not match and will return nothing. No
241
+ # explicit row-level locking is needed — the UPDATE itself takes the lock.
242
+ #
243
+ # Legacy rows with +state = NULL AND spendable = TRUE+ are also eligible.
244
+ #
245
+ # @param outpoints [Array<String>] outpoint identifiers to lock
246
+ # @param reference [String] caller-supplied pending reference
247
+ # @param no_send [Boolean] true if this is a no_send lock
248
+ # @return [Array<String>] outpoints that were actually locked
249
+ def lock_utxos(outpoints, reference:, no_send: false)
250
+ return [] if outpoints.empty?
251
+
252
+ rows = @db[:wallet_outputs]
253
+ .where(outpoint: outpoints)
254
+ .where(Sequel.lit('(state = ? OR (state IS NULL AND spendable = TRUE))', 'spendable'))
255
+ .returning(:outpoint)
256
+ .update(
257
+ state: 'pending',
258
+ spendable: false,
259
+ pending_since: Sequel.lit('NOW()'),
260
+ pending_reference: reference,
261
+ no_send: no_send ? true : false,
262
+ data: Sequel.lit(
263
+ "data || jsonb_build_object('state', 'pending', 'pending_since', NOW()::text, " \
264
+ "'pending_reference', ?, 'no_send', ?)",
265
+ reference, no_send ? true : false
266
+ )
267
+ )
268
+
269
+ rows.map { |r| r[:outpoint] }
270
+ end
271
+
272
+ # Releases stale pending locks back to +:spendable+.
273
+ #
274
+ # Any output in +:pending+ state whose +pending_since+ is older than
275
+ # +timeout+ seconds is reset to +spendable+ and its pending metadata is
276
+ # cleared. Outputs with +no_send = true+ are exempt and remain pending.
277
+ # Outputs with +pending_since = NULL+ are also skipped — they are treated
278
+ # as freshly locked (NULL means "just acquired but no timestamp yet").
279
+ #
280
+ # @param timeout [Integer] age in seconds before a lock is considered stale (default 300)
281
+ # @return [Integer] number of outputs released
282
+ def release_stale_pending!(timeout: 300)
283
+ rows = @db[:wallet_outputs]
284
+ .where(state: 'pending')
285
+ .where(Sequel.lit('no_send IS NOT TRUE'))
286
+ .where(Sequel.lit('pending_since IS NOT NULL'))
287
+ .where(Sequel.lit('pending_since < (NOW() - INTERVAL ?)', "#{timeout} seconds"))
288
+ .returning(:outpoint)
289
+ .update(
290
+ state: 'spendable',
291
+ spendable: true,
292
+ pending_since: nil,
293
+ pending_reference: nil,
294
+ no_send: false,
295
+ data: Sequel.lit(
296
+ "(data - 'pending_since' - 'pending_reference' - 'no_send') || jsonb_build_object('state', 'spendable')"
297
+ )
298
+ )
299
+
300
+ rows.length
301
+ end
302
+
131
303
  # --- Certificates ---
132
304
 
133
305
  def store_certificate(cert_data)
@@ -181,6 +353,18 @@ module BSV
181
353
  @db[:wallet_transactions].where(txid: txid).get(:tx_hex)
182
354
  end
183
355
 
356
+ # --- Settings ---
357
+
358
+ def store_setting(key, value)
359
+ @db[:wallet_settings]
360
+ .insert_conflict(target: :key, update: { value: value })
361
+ .insert(key: key, value: value)
362
+ end
363
+
364
+ def find_setting(key)
365
+ @db[:wallet_settings].where(key: key).get(:value)
366
+ end
367
+
184
368
  private
185
369
 
186
370
  # --- Row builders ---
@@ -195,11 +379,14 @@ module BSV
195
379
 
196
380
  def output_row(data)
197
381
  spendable = data[:spendable] != false # nil treated as spendable, like MemoryStore
382
+ state = data[:state]&.to_s
198
383
  {
199
384
  outpoint: data[:outpoint],
200
385
  basket: data[:basket],
201
386
  tags: Sequel.pg_array(Array(data[:tags]), :text),
202
387
  spendable: spendable,
388
+ state: state,
389
+ satoshis: data[:satoshis],
203
390
  data: Sequel.pg_jsonb(data.to_h)
204
391
  }
205
392
  end
@@ -224,7 +411,7 @@ module BSV
224
411
  ds = ds.where(outpoint: query[:outpoint]) if query[:outpoint]
225
412
  ds = ds.where(basket: query[:basket]) if query[:basket]
226
413
  ds = apply_array_filter(ds, :tags, query[:tags], query[:tag_query_mode])
227
- ds = ds.where(spendable: true) unless query[:include_spent]
414
+ ds = ds.where(Sequel.lit('(state = ? OR (state IS NULL AND spendable = TRUE))', 'spendable')) unless query[:include_spent]
228
415
  ds
229
416
  end
230
417
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BSV
4
4
  module WalletPostgres
5
- VERSION = '0.1.0'
5
+ VERSION = '0.3.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.3.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: 1980-01-02 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.6.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.6.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:
@@ -92,7 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
95
  - !ruby/object:Gem::Version
93
96
  version: '0'
94
97
  requirements: []
95
- rubygems_version: 3.6.2
98
+ rubygems_version: 4.0.10
96
99
  specification_version: 4
97
100
  summary: PostgreSQL storage adapter for bsv-wallet
98
101
  test_files: []