Skip to content

Commit 96e9c5c

Browse files
sgbettclaude
andcommitted
test(wallet): add comprehensive UTXO pool specs with round-1 regression coverage (#491)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b7570b4 commit 96e9c5c

File tree

5 files changed

+900
-0
lines changed

5 files changed

+900
-0
lines changed
Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require 'bsv-wallet'
5+
require 'securerandom'
6+
7+
RSpec.describe 'BSV::Wallet::LocalPool' do
8+
let(:store) { BSV::Wallet::MemoryStore.new }
9+
10+
# Build a pool with sensible defaults. Replenisher is nil unless set explicitly.
11+
def build_pool(name: 'test', target_count: 5, target_satoshis: 10_000, low_water_mark: 2)
12+
BSV::Wallet::LocalPool.new(
13+
name: name,
14+
storage: store,
15+
wallet_client: nil,
16+
target_count: target_count,
17+
target_satoshis: target_satoshis,
18+
low_water_mark: low_water_mark
19+
)
20+
end
21+
22+
# Seeds a spendable output directly into the store for the pool basket.
23+
def seed_pool_output(outpoint:, satoshis: 10_000, basket: 'pool:test')
24+
store.store_output(
25+
outpoint: outpoint,
26+
satoshis: satoshis,
27+
basket: basket,
28+
state: :spendable,
29+
derivation_prefix: SecureRandom.hex(16),
30+
derivation_suffix: SecureRandom.hex(16),
31+
sender_identity_key: "02#{'ab' * 32}"
32+
)
33+
end
34+
35+
# -----------------------------------------------------------------------
36+
# Attributes and factory basics
37+
# -----------------------------------------------------------------------
38+
39+
describe 'attributes' do
40+
it 'derives the basket name as pool:<name>' do
41+
pool = build_pool(name: 'doom')
42+
expect(pool.basket).to eq('pool:doom')
43+
end
44+
45+
it 'exposes the name' do
46+
pool = build_pool(name: 'payments')
47+
expect(pool.name).to eq('payments')
48+
end
49+
50+
it 'exposes the storage adapter (bug #8 regression)' do
51+
pool = build_pool
52+
expect(pool.storage).to eq(store)
53+
end
54+
55+
it 'exposes target_count' do
56+
pool = build_pool(target_count: 10)
57+
expect(pool.target_count).to eq(10)
58+
end
59+
60+
it 'exposes target_satoshis' do
61+
pool = build_pool(target_satoshis: 5_000)
62+
expect(pool.target_satoshis).to eq(5_000)
63+
end
64+
end
65+
66+
# -----------------------------------------------------------------------
67+
# acquire
68+
# -----------------------------------------------------------------------
69+
70+
describe '#acquire' do
71+
let(:pool) { build_pool }
72+
73+
before do
74+
seed_pool_output(outpoint: "#{'aa' * 32}.0")
75+
end
76+
77+
it 'returns the outpoint string of the acquired output' do
78+
result = pool.acquire
79+
expect(result).to be_a(String)
80+
expect(result).to match(/\A[0-9a-f]{64}\.\d+\z/)
81+
end
82+
83+
it 'acquired output is no longer in find_spendable_outputs' do
84+
outpoint = pool.acquire
85+
spendable = store.find_spendable_outputs(basket: 'pool:test')
86+
expect(spendable.map { |o| o[:outpoint] }).not_to include(outpoint)
87+
end
88+
89+
it 'multiple sequential acquires return distinct outpoints' do
90+
seed_pool_output(outpoint: "#{'bb' * 32}.0")
91+
first = pool.acquire
92+
second = pool.acquire
93+
expect(first).not_to eq(second)
94+
end
95+
96+
it 'raises PoolDepletedError when the pool is empty' do
97+
store.update_output_state("#{'aa' * 32}.0", :spent)
98+
expect { pool.acquire }.to raise_error(BSV::Wallet::PoolDepletedError)
99+
end
100+
101+
it 'raises PoolDepletedError when all outputs are already pending' do
102+
outpoint = "#{'aa' * 32}.0"
103+
store.update_output_state(outpoint, :pending, pending_reference: 'held-externally')
104+
expect { pool.acquire }.to raise_error(BSV::Wallet::PoolDepletedError)
105+
end
106+
107+
# Bug #1 regression: pool-acquired locks must use no_send: true so they
108+
# are exempt from release_stale_pending! sweeps.
109+
it 'acquires with no_send: true so the lock survives release_stale_pending! (bug #1)' do
110+
outpoint = pool.acquire
111+
112+
# Zero-timeout forces any non-exempt lock to be released immediately.
113+
released = store.release_stale_pending!(timeout: 0)
114+
expect(released).to eq(0)
115+
116+
# The output must still be locked (not spendable).
117+
spendable = store.find_spendable_outputs(basket: 'pool:test')
118+
expect(spendable.map { |o| o[:outpoint] }).not_to include(outpoint)
119+
end
120+
121+
it 'the acquired output carries no_send: true in storage' do
122+
outpoint = pool.acquire
123+
raw = store.find_outputs({ outpoint: outpoint, include_spent: true, limit: 1, offset: 0 })
124+
expect(raw.first[:no_send]).to be true
125+
end
126+
end
127+
128+
# -----------------------------------------------------------------------
129+
# Bug #3 regression: signal at <= low_water_mark
130+
# -----------------------------------------------------------------------
131+
132+
describe '#acquire signals replenisher at exactly the low-water mark (bug #3)' do
133+
let(:replenisher) { double('replenisher', signal: nil, stop: nil) } # rubocop:disable RSpec/VerifiedDoubles
134+
135+
# Pool of 2 with low_water_mark of 2: after first acquire, 1 output remains
136+
# which is <= 2, so the replenisher must be signalled.
137+
it 'signals when remaining count equals low_water_mark' do
138+
pool = build_pool(target_count: 3, low_water_mark: 2)
139+
pool.replenisher = replenisher
140+
141+
seed_pool_output(outpoint: "#{'cc' * 32}.0")
142+
seed_pool_output(outpoint: "#{'dd' * 32}.0")
143+
144+
# After the first acquire there is 1 output left, which is <= low_water_mark (2).
145+
pool.acquire
146+
expect(replenisher).to have_received(:signal).at_least(:once)
147+
end
148+
149+
# Pool of 1 with low_water_mark of 1: after acquire, 0 remain which is still <= 1.
150+
it 'signals when pool drops to zero at low_water_mark of 1 (pool-of-1 edge case)' do
151+
pool = build_pool(target_count: 1, low_water_mark: 1)
152+
pool.replenisher = replenisher
153+
154+
seed_pool_output(outpoint: "#{'ee' * 32}.0")
155+
156+
pool.acquire
157+
expect(replenisher).to have_received(:signal).at_least(:once)
158+
end
159+
160+
it 'does not signal when remaining count is above low_water_mark' do
161+
pool = build_pool(target_count: 10, low_water_mark: 2)
162+
pool.replenisher = replenisher
163+
164+
# Seed 5 outputs; after one acquire 4 remain, which is > 2.
165+
5.times { |i| seed_pool_output(outpoint: "#{"f#{i}" * 32}.0") }
166+
167+
pool.acquire
168+
expect(replenisher).not_to have_received(:signal)
169+
end
170+
end
171+
172+
# -----------------------------------------------------------------------
173+
# release
174+
# -----------------------------------------------------------------------
175+
176+
describe '#release' do
177+
let(:pool) { build_pool }
178+
179+
before { seed_pool_output(outpoint: "#{'aa' * 32}.0") }
180+
181+
it 'returns the output to spendable state' do
182+
outpoint = pool.acquire
183+
pool.release(outpoint)
184+
spendable = store.find_spendable_outputs(basket: 'pool:test')
185+
expect(spendable.map { |o| o[:outpoint] }).to include(outpoint)
186+
end
187+
188+
it 'a released output can be re-acquired' do
189+
outpoint = pool.acquire
190+
pool.release(outpoint)
191+
re_acquired = pool.acquire
192+
expect(re_acquired).to eq(outpoint)
193+
end
194+
end
195+
196+
# -----------------------------------------------------------------------
197+
# status
198+
# -----------------------------------------------------------------------
199+
200+
describe '#status' do
201+
it 'returns :healthy state when available count is above low_water_mark' do
202+
pool = build_pool(target_count: 5, low_water_mark: 2)
203+
3.times { |i| seed_pool_output(outpoint: "#{"a#{i}" * 32}.0") }
204+
result = pool.status
205+
expect(result[:state]).to eq(:healthy)
206+
end
207+
208+
it 'returns :depleted state when pool is empty and no replenisher is running' do
209+
pool = build_pool(target_count: 5, low_water_mark: 2)
210+
# No outputs seeded — pool is empty.
211+
result = pool.status
212+
expect(result[:state]).to eq(:depleted)
213+
end
214+
215+
it 'includes correct available count' do
216+
pool = build_pool
217+
seed_pool_output(outpoint: "#{'aa' * 32}.0")
218+
seed_pool_output(outpoint: "#{'bb' * 32}.0")
219+
expect(pool.status[:available]).to eq(2)
220+
end
221+
222+
it 'includes target count' do
223+
pool = build_pool(target_count: 7)
224+
expect(pool.status[:target]).to eq(7)
225+
end
226+
227+
it 'includes satoshis_committed as the sum of spendable satoshis' do
228+
pool = build_pool
229+
seed_pool_output(outpoint: "#{'aa' * 32}.0", satoshis: 10_000)
230+
seed_pool_output(outpoint: "#{'bb' * 32}.0", satoshis: 20_000)
231+
expect(pool.status[:satoshis_committed]).to eq(30_000)
232+
end
233+
234+
it 'returns :replenishing after acquire triggers replenisher signal' do
235+
replenisher = double('replenisher', signal: nil, stop: nil) # rubocop:disable RSpec/VerifiedDoubles
236+
pool = build_pool(target_count: 2, low_water_mark: 2)
237+
pool.replenisher = replenisher
238+
239+
seed_pool_output(outpoint: "#{'aa' * 32}.0")
240+
seed_pool_output(outpoint: "#{'bb' * 32}.0")
241+
242+
# After acquire the pool drops to 1 which is <= 2, triggering replenishment.
243+
pool.acquire
244+
expect(pool.status[:state]).to eq(:replenishing)
245+
end
246+
end
247+
248+
# -----------------------------------------------------------------------
249+
# Basket isolation
250+
# -----------------------------------------------------------------------
251+
252+
describe 'basket isolation' do
253+
it 'pool outputs are not returned by find_spendable_outputs for the default basket' do
254+
seed_pool_output(outpoint: "#{'aa' * 32}.0", basket: 'pool:test')
255+
default_outputs = store.find_spendable_outputs(basket: 'default')
256+
expect(default_outputs.map { |o| o[:outpoint] }).not_to include("#{'aa' * 32}.0")
257+
end
258+
end
259+
260+
# -----------------------------------------------------------------------
261+
# shutdown
262+
# -----------------------------------------------------------------------
263+
264+
describe '#shutdown' do
265+
let(:pool) { build_pool }
266+
267+
it 'sets status to :shutdown' do
268+
pool.shutdown
269+
expect(pool.status[:state]).to eq(:shutdown)
270+
end
271+
272+
it 'is idempotent (calling shutdown twice does not raise)' do
273+
pool.shutdown
274+
expect { pool.shutdown }.not_to raise_error
275+
end
276+
277+
it 'raises PoolDepletedError on acquire after shutdown' do
278+
pool.shutdown
279+
expect { pool.acquire }.to raise_error(BSV::Wallet::PoolDepletedError)
280+
end
281+
282+
it 'stops the replenisher if one is set' do
283+
replenisher = double('replenisher', stop: nil) # rubocop:disable RSpec/VerifiedDoubles
284+
pool.replenisher = replenisher
285+
pool.shutdown
286+
expect(replenisher).to have_received(:stop)
287+
end
288+
end
289+
290+
# -----------------------------------------------------------------------
291+
# Concurrent acquire (barrier pattern)
292+
# -----------------------------------------------------------------------
293+
294+
describe 'concurrent acquire' do
295+
it '2 threads competing for 1 output: exactly 1 succeeds, the other gets PoolDepletedError' do
296+
pool = build_pool
297+
seed_pool_output(outpoint: "#{'aa' * 32}.0")
298+
299+
results = Array.new(2)
300+
barrier = Queue.new
301+
acquired = []
302+
303+
threads = 2.times.map do |i|
304+
Thread.new do
305+
barrier.pop # wait until both threads are ready
306+
307+
begin
308+
results[i] = pool.acquire
309+
acquired << results[i]
310+
rescue BSV::Wallet::PoolDepletedError
311+
results[i] = :depleted
312+
end
313+
end
314+
end
315+
316+
2.times { barrier.push(:go) }
317+
threads.each(&:join)
318+
319+
depleted_count = results.count(:depleted)
320+
success_count = results.count { |r| r != :depleted }
321+
322+
expect(success_count).to eq(1), "Expected exactly 1 acquire to succeed; got #{results.inspect}"
323+
expect(depleted_count).to eq(1)
324+
end
325+
326+
it '5 threads competing for 5 outputs: all 5 get distinct outpoints' do
327+
pool = build_pool(target_count: 5)
328+
5.times { |i| seed_pool_output(outpoint: "#{SecureRandom.hex(32)}.#{i}") }
329+
330+
results = Array.new(5)
331+
barrier = Queue.new
332+
333+
threads = 5.times.map do |i|
334+
Thread.new do
335+
barrier.pop
336+
results[i] = pool.acquire
337+
rescue BSV::Wallet::PoolDepletedError
338+
results[i] = :depleted
339+
end
340+
end
341+
342+
5.times { barrier.push(:go) }
343+
threads.each(&:join)
344+
345+
expect(results).not_to include(:depleted)
346+
expect(results.uniq.length).to eq(5), "Expected all 5 distinct outpoints; got #{results.inspect}"
347+
end
348+
end
349+
350+
# -----------------------------------------------------------------------
351+
# WalletClient#utxo_pool factory integration
352+
# -----------------------------------------------------------------------
353+
354+
describe 'WalletClient#utxo_pool factory' do
355+
let(:private_key) { BSV::Primitives::PrivateKey.generate }
356+
let(:storage) { BSV::Wallet::MemoryStore.new }
357+
let(:broadcaster) { double('broadcaster') } # rubocop:disable RSpec/VerifiedDoubles
358+
let(:wallet) do
359+
BSV::Wallet::WalletClient.new(private_key, storage: storage, broadcaster: broadcaster)
360+
end
361+
let(:pool) { wallet.utxo_pool(name: 'test') }
362+
363+
after { pool.shutdown }
364+
365+
before do
366+
allow(broadcaster).to receive(:broadcast).and_return(
367+
BSV::Network::BroadcastResponse.new(txid: 'stub', tx_status: 'SEEN_ON_NETWORK')
368+
)
369+
end
370+
371+
it 'returns a LocalPool instance' do
372+
expect(pool).to be_a(BSV::Wallet::LocalPool)
373+
end
374+
375+
it 'pool basket is pool:<name>' do
376+
named_pool = wallet.utxo_pool(name: 'payments')
377+
named_pool.shutdown
378+
expect(named_pool.basket).to eq('pool:payments')
379+
end
380+
381+
it 'pool has a replenisher attached (not nil)' do
382+
replenisher = pool.instance_variable_get(:@replenisher)
383+
expect(replenisher).not_to be_nil
384+
end
385+
386+
it 'pool.storage returns the wallet storage adapter (bug #8 regression)' do
387+
expect(pool.storage).to eq(storage)
388+
end
389+
390+
it 'shutdown marks the replenisher as stopped' do
391+
replenisher = pool.instance_variable_get(:@replenisher)
392+
pool.shutdown
393+
running = replenisher.instance_variable_get(:@running)
394+
expect(running).to be false
395+
end
396+
end
397+
end

0 commit comments

Comments
 (0)