Skip to content

Commit d4099d2

Browse files
authored
Merge pull request #463 from sgbett/feat/455-align-status-taxonomy
feat!: align action status taxonomy with reference SDK; fail loudly on broadcast misconfiguration
2 parents 5602883 + cb17ba2 commit d4099d2

File tree

17 files changed

+753
-203
lines changed

17 files changed

+753
-203
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Plan: HLR #455 — Align action status with reference SDK; fail loudly on broadcast misconfiguration
2+
3+
## Context
4+
5+
`bsv-wallet`'s `create_action` and `internalize_action` collapse distinct states into `'completed'`, leading consumers to believe transactions are on-chain when they may not be. This caused phantom-payment bugs in x402-rack (#148, #158) and x402-doom (#196).
6+
7+
The TS reference SDK (wallet-toolbox) distinguishes `'nosend'`, `'unsent'`, `'sending'`, `'unmined'`, `'unproven'`, `'completed'`, `'failed'`. `'completed'` means a merkle proof is on file. Ruby currently uses `'completed'` for "broadcast succeeded" (wrong) and "no broadcaster, nothing on chain" (also wrong, silently).
8+
9+
## Current status-setting sites
10+
11+
| Site | Current | Fix |
12+
|------|---------|-----|
13+
| `auto_fund_and_create` `no_send: true` (wallet_client.rb:814) | `'nosend'` | ✓ keep |
14+
| `auto_fund_and_create` default (wallet_client.rb:834) | `'pending'` (pre-queue) | ✓ keep |
15+
| `finalize_action` (wallet_client.rb:1441-1448) | `'nosend'` or `'pending'` | ✓ keep |
16+
| `InlineQueue#broadcast_and_promote` success (inline_queue.rb:100) | `'completed'` |`'unproven'` |
17+
| `InlineQueue#broadcast_and_promote` failure (inline_queue.rb:89) | `'failed'` | ✓ keep |
18+
| `InlineQueue#promote_without_broadcast` default (inline_queue.rb:127) | `'completed'` silently |**raise `WalletError`** (no broadcaster + sync broadcast requested) |
19+
| `InlineQueue#promote_without_broadcast` delayed (inline_queue.rb:127) | `'unproven'` |`'unproven'` (sync fallback path per #380) |
20+
| `SolidQueueAdapter#process_job` success (solid_queue_adapter.rb:~292) | `'completed'` |`'unproven'` |
21+
| `SolidQueueAdapter#process_job` failure | `'failed'` | ✓ keep |
22+
| `promote_no_send` success (wallet_client.rb:1416) | `'unproven'` | ✓ keep |
23+
| `internalize_action` via `store_action` (wallet_client.rb:306) | `'completed'` unconditional |`'completed'` if BEEF has merkle proof for subject tx, else `'unproven'` |
24+
| `store_action` default (wallet_client.rb:1484) | `'completed'` | ✓ keep (tests only use default) |
25+
26+
## Tasks
27+
28+
### Task 1: Add validation in `create_action`
29+
30+
**Modify** `gem/bsv-wallet/lib/bsv/wallet_interface/wallet_client.rb`.
31+
32+
Add a new private method `validate_broadcast_configuration!(args)` called at the top of `create_action` (after `validate_create_action!`):
33+
34+
```ruby
35+
def validate_broadcast_configuration!(args)
36+
no_send = args.dig(:options, :no_send)
37+
return if no_send
38+
return if @broadcaster
39+
40+
raise WalletError,
41+
'create_action requires a broadcaster for on-chain broadcast. ' \
42+
'Pass broadcaster: BSV::Network::ARC.default to WalletClient.new, ' \
43+
'or options: { no_send: true } to build a transaction without broadcasting.'
44+
end
45+
```
46+
47+
This fails loudly at the call site — not after state has been written to storage.
48+
49+
### Task 2: Fix `InlineQueue` success status
50+
51+
**Modify** `gem/bsv-wallet/lib/bsv/wallet_interface/inline_queue.rb`.
52+
53+
- `broadcast_and_promote` success: change `promote(..., txid)` default from `'completed'` to `'unproven'`. Pass explicit `status: 'unproven'`.
54+
- `promote_without_broadcast`: remove the silent-`'completed'` branch. This method is now reached only via the `accept_delayed_broadcast: true` + no-broadcaster fallback (per #380 design). Status stays `'unproven'`. If somehow called without `accept_delayed_broadcast`, raise `WalletError` — a defensive guard since Task 1 should catch this upstream.
55+
- Update docstrings: `'completed'``'unproven'` (on-chain but unproven); `'completed'` only after proof monitoring promotes it (future work).
56+
57+
### Task 3: Fix `SolidQueueAdapter` success status
58+
59+
**Modify** `gem/bsv-wallet-postgres/lib/bsv/wallet_postgres/solid_queue_adapter.rb`.
60+
61+
- `process_job` success path: `promote(..., txid)` should use `status: 'unproven'` instead of `'completed'`.
62+
- Update docstring.
63+
64+
### Task 4: Fix `internalize_action` to check merkle proof
65+
66+
**Modify** `gem/bsv-wallet/lib/bsv/wallet_interface/wallet_client.rb` `internalize_action` (line ~267) and/or `store_proofs_from_beef` / `extract_subject_transaction` / `find_by_subject_txid`.
67+
68+
- Determine whether the BEEF contains a merkle proof (BUMP) for the subject transaction.
69+
- If yes → status = `'completed'`
70+
- If no → status = `'unproven'`
71+
- Pass explicit status to `store_action`
72+
73+
Mirrors TS `storage/methods/internalizeAction.ts:301`:
74+
```typescript
75+
const status: TransactionStatus = provenTx ? 'completed' : 'unproven'
76+
```
77+
78+
### Task 5: Update specs
79+
80+
**Modify** existing specs to reflect the new status values. Files identified:
81+
- `gem/bsv-wallet/spec/bsv/wallet_interface/wallet_client_spec.rb` (2 sites)
82+
- `gem/bsv-wallet/spec/bsv/wallet_interface/wallet_client_auto_fund_spec.rb` (4 sites)
83+
- `gem/bsv-wallet/spec/bsv/wallet_interface/inline_queue_spec.rb` (5 sites)
84+
- `gem/bsv-wallet/spec/bsv/wallet_interface/broadcast_rollback_spec.rb`
85+
- `gem/bsv-wallet/spec/bsv/wallet_interface/auto_funding_spec.rb`
86+
- `gem/bsv-wallet/spec/bsv/wallet_interface/wire/serializer_spec.rb`
87+
- `gem/bsv-wallet-postgres/spec/bsv/wallet_postgres/solid_queue_adapter_spec.rb`
88+
89+
Strategy: global search-and-replace of `'completed'``'unproven'` in test expectations where the asserted state is post-broadcast, then review each hit manually to catch exceptions (e.g. `internalize_action` tests with proven BEEFs should stay `'completed'`).
90+
91+
**Add** new specs:
92+
- `create_action` raises `WalletError` when no broadcaster AND `no_send: false` (default)
93+
- `create_action` succeeds with `no_send: true` and no broadcaster → status `'nosend'`
94+
- `create_action` with broadcaster → status `'unproven'` on success
95+
- `internalize_action` with proven BEEF → `'completed'`
96+
- `internalize_action` with unproven BEEF → `'unproven'`
97+
- `SolidQueueAdapter` worker success → status `'unproven'`
98+
99+
### Task 6: Documentation
100+
101+
- **CHANGELOG.md** for `bsv-wallet`: document as breaking change for 0.8.0 with migration notes
102+
- **gem/bsv-wallet/README.md**: update status meanings table
103+
- **docs/gems/wallet.md**: add "Status values" section
104+
- **CLAUDE.md**: no changes needed (general development guidance is already correct)
105+
106+
### Task 7: Downstream coordination
107+
108+
- **bsv-attest**: verify `Attest.publish` callers don't assert `'completed'` — confirmed no assertions, no change needed.
109+
- **bsv-wallet-postgres**: no schema change (status is TEXT). Bump dependency floor to match new wallet version.
110+
- **x402-rack, x402-doom**: will need guidance — `'unproven'` is the new "success" state for fresh broadcasts. Document this in the bsv-wallet 0.8.0 release notes.
111+
112+
## Critical files
113+
114+
| File | Action |
115+
|------|--------|
116+
| `gem/bsv-wallet/lib/bsv/wallet_interface/wallet_client.rb` | Modify — add `validate_broadcast_configuration!`, fix internalize status logic |
117+
| `gem/bsv-wallet/lib/bsv/wallet_interface/inline_queue.rb` | Modify — success status + remove silent fallback |
118+
| `gem/bsv-wallet-postgres/lib/bsv/wallet_postgres/solid_queue_adapter.rb` | Modify — success status |
119+
| `gem/bsv-wallet/CHANGELOG.md` | Update for breaking change |
120+
| `gem/bsv-wallet/README.md` | Update status table |
121+
| `docs/gems/wallet.md` | Add status values section |
122+
123+
## Reference files (read-only)
124+
125+
| File | Why |
126+
|------|-----|
127+
| `wallet-toolbox/src/storage/methods/processAction.ts` | Status state machine reference |
128+
| `wallet-toolbox/src/storage/methods/internalizeAction.ts:301` | Proven vs unproven decision |
129+
| `wallet-toolbox/src/signer/methods/processAction.ts:147-155` | SendWithResult status mapping |
130+
131+
## Verification
132+
133+
```bash
134+
cd /opt/ruby/bsv-ruby-sdk
135+
bundle exec rake spec:wallet # wallet specs
136+
bundle exec rake spec:wallet_postgres # postgres specs (SolidQueueAdapter)
137+
bundle exec rake # full suite
138+
bundle exec rubocop # lint
139+
```
140+
141+
Manual verification:
142+
1. Construct `WalletClient.new(key)` (no broadcaster) and call `create_action` with default options → expect `WalletError`
143+
2. Same + `options: { no_send: true }` → expect success with status `'nosend'`
144+
3. Construct `WalletClient.new(key, broadcaster: ARC.default)` and call `create_action` → expect status `'unproven'` on success
145+
4. Internalize a BEEF with merkle proof → action status `'completed'`
146+
5. Internalize a BEEF without merkle proof → action status `'unproven'`
147+
148+
## Sequencing
149+
150+
```
151+
Task 1 (create_action guard) ──→ Task 5 (specs update)
152+
Task 2 (InlineQueue) ──→ Task 5
153+
Task 3 (SolidQueueAdapter) ──→ Task 5
154+
Task 4 (internalize_action) ──→ Task 5
155+
Task 6 (docs) ── independent
156+
Task 7 (downstream floor) ── last, requires wallet version bump
157+
```
158+
159+
Tasks 1-4 can be done in parallel (touch different code paths). Task 5 must wait for all of them. Tasks 6-7 are independent bookkeeping.

docs/gems/wallet.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,30 @@ wallet.internalize_action({
148148
})
149149
```
150150

151+
## Status meanings
152+
153+
Each action stored by the wallet carries a `status` field that reflects its
154+
current lifecycle state:
155+
156+
| Status | Meaning |
157+
|--------|---------|
158+
| `'nosend'` | Transaction built but not broadcast (caller opted into `options: { no_send: true }`) |
159+
| `'sending'` | Broadcast queued for background processing (async adapter); worker has not yet attempted broadcast |
160+
| `'unproven'` | Broadcast succeeded; awaiting merkle proof |
161+
| `'completed'` | Merkle proof received and stored |
162+
| `'failed'` | Broadcast attempted and rejected by the network |
163+
164+
> **Note for consumers:** Querying `list_actions(status: 'completed')` returns
165+
> fewer results under the current taxonomy until a proof-watcher is implemented
166+
> (out of scope for the current release). Most fresh broadcasts will be in
167+
> `'unproven'` state until their merkle proof arrives. This aligns with the TS
168+
> reference SDK's semantics. To find all successfully-broadcast actions, query
169+
> for both `'unproven'` and `'completed'`, or rely on `'failed'` to detect
170+
> broadcast rejection.
171+
172+
This taxonomy matches the [wallet-toolbox](https://github.com/bitcoin-sv/wallet-toolbox)
173+
reference implementation (BRC-100).
174+
151175
## Balance and UTXOs
152176

153177
```ruby

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ def async?
8282
true
8383
end
8484

85+
# Returns +true+ — +SolidQueueAdapter+ requires a broadcaster at
86+
# construction time (+ArgumentError+ is raised if +nil+ is passed), so
87+
# broadcast is always available.
88+
#
89+
# @return [Boolean]
90+
def broadcast_enabled?
91+
!@broadcaster.nil?
92+
end
93+
8594
# Persists a transaction to the broadcast job queue and returns immediately.
8695
#
8796
# Inserts a row into +wallet_broadcast_jobs+ with status +unsent+. If a row
@@ -280,16 +289,18 @@ def process_job(job)
280289
# Promotes UTXO state after a successful broadcast.
281290
#
282291
# Marks inputs as +:spent+, change as +:spendable+, and updates the action
283-
# status to +completed+. When outpoints are +nil+ (finalize path), UTXO
284-
# transitions are skipped.
292+
# status to +unproven+. The transaction has been accepted by the network but
293+
# is not yet proven on-chain — status advances to +completed+ once a merkle
294+
# proof arrives. When outpoints are +nil+ (finalize path), UTXO transitions
295+
# are skipped.
285296
#
286297
# @param input_outpoints [Array<String>, nil]
287298
# @param change_outpoints [Array<String>, nil]
288299
# @param txid [String, nil]
289300
def promote(input_outpoints, change_outpoints, txid)
290301
Array(input_outpoints).each { |op| @storage.update_output_state(op, :spent) }
291302
Array(change_outpoints).each { |op| @storage.update_output_state(op, :spendable) }
292-
@storage.update_action_status(txid, 'completed') if txid
303+
@storage.update_action_status(txid, 'unproven') if txid
293304
end
294305

295306
# Rolls back wallet state after a failed broadcast.

gem/bsv-wallet-postgres/spec/bsv/wallet_postgres/solid_queue_adapter_spec.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,9 @@ def action_status(txid)
259259
expect(output_state('chg:0').to_s).to eq('spendable')
260260
end
261261

262-
it 'updates action status to completed' do
263-
expect(action_status(txid_a)).to eq('completed')
262+
# Post-HLR #455: 'unproven' until a merkle proof lands via internalize_action
263+
it 'updates action status to unproven' do
264+
expect(action_status(txid_a)).to eq('unproven')
264265
end
265266
end
266267

@@ -329,8 +330,9 @@ def action_status(txid)
329330
adapter.drain
330331
end
331332

332-
it 'updates action status to completed' do
333-
expect(action_status(txid_a)).to eq('completed')
333+
# Post-HLR #455: 'unproven' until a merkle proof lands via internalize_action
334+
it 'updates action status to unproven' do
335+
expect(action_status(txid_a)).to eq('unproven')
334336
end
335337

336338
it 'marks job as completed' do

gem/bsv-wallet/CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,47 @@ All notable changes to the `bsv-wallet` gem are documented here.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
66
and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## 0.9.0 (unreleased)
9+
10+
### Changed — **Breaking**
11+
12+
#### Action status taxonomy aligned with BRC-100 reference SDK (HLR #455)
13+
14+
This release realigns the action status values with the wallet-toolbox reference
15+
implementation. The meaning of `'completed'` has changed — **consumers must
16+
update any code that checks for `'completed'` as the post-broadcast success
17+
state**.
18+
19+
| Status | Old meaning | New meaning |
20+
|--------|-------------|-------------|
21+
| `'nosend'` | No change | Transaction built but not broadcast (`no_send: true`) |
22+
| `'sending'` | No change | Async queue accepted; worker has not yet attempted broadcast |
23+
| `'unproven'` | *(new)* | Broadcast succeeded; awaiting merkle proof |
24+
| `'completed'` | Broadcast succeeded | **Merkle proof received and stored** |
25+
| `'failed'` | No change | Broadcast rejected or transaction invalid |
26+
27+
**Migration:**
28+
29+
- Code querying `list_actions(status: 'completed')` will return fewer results
30+
until a proof-watcher is implemented (out of scope for this release). To find
31+
successfully-broadcast actions that have not yet received a proof, query
32+
`status: 'unproven'` instead.
33+
- `create_action` and `sign_action` now raise `BSV::Wallet::WalletError` when
34+
no broadcaster is configured and `options: { no_send: true }` is not set.
35+
Previously these calls succeeded silently. To resolve: pass
36+
`broadcaster: BSV::Network::ARC.default` to `WalletClient.new`, or pass
37+
`options: { no_send: true }` to `create_action` to build without
38+
broadcasting.
39+
- `internalize_action` now sets status to `'completed'` only when the supplied
40+
BEEF contains a merkle proof for the subject transaction. Plain BEEF (raw
41+
transaction, no BUMP) results in status `'unproven'`.
42+
- Wallets configured with a `SolidQueueAdapter` satisfy the broadcaster
43+
requirement if the adapter carries an embedded `broadcaster:` — the
44+
`WalletClient` itself does not need one.
45+
46+
**Related upstream incidents:** x402-rack #148, x402-rack #158, x402-doom #196.
47+
**Tracking issue:** #455.
48+
849
## 0.8.0 — 2026-04-15
950

1051
### Added

gem/bsv-wallet/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,39 @@ broadcasting, and state promotion/rollback automatically.
5555
| `FileStore` | bsv-wallet | Development (default) |
5656
| `PostgresStore` | [bsv-wallet-postgres](https://rubygems.org/gems/bsv-wallet-postgres) | Production |
5757

58+
## Status values
59+
60+
Each action stored by the wallet carries one of the following status values:
61+
62+
| Status | Meaning |
63+
|--------|---------|
64+
| `'nosend'` | Transaction built but not broadcast (caller opted into `options: { no_send: true }`) |
65+
| `'sending'` | Broadcast queued for background processing (async adapter); worker has not yet attempted broadcast |
66+
| `'unproven'` | Broadcast succeeded; awaiting merkle proof |
67+
| `'completed'` | Merkle proof received and stored |
68+
| `'failed'` | Broadcast attempted and rejected by the network |
69+
70+
## Broadcaster requirement
71+
72+
A broadcaster is required for `create_action` to succeed unless
73+
`options: { no_send: true }` is passed per call. Three ways to satisfy this:
74+
75+
1. Pass `broadcaster:` to `WalletClient.new`:
76+
```ruby
77+
wallet = BSV::Wallet::WalletClient.new(key, broadcaster: BSV::Network::ARC.default)
78+
```
79+
2. Pass a `broadcast_queue:` whose adapter has an embedded `broadcaster:`:
80+
```ruby
81+
adapter = BSV::Wallet::SolidQueueAdapter.new(db: db, storage: store, broadcaster: arc)
82+
wallet = BSV::Wallet::WalletClient.new(key, storage: store, broadcast_queue: adapter)
83+
```
84+
3. Pass `options: { no_send: true }` to skip broadcast for a specific call:
85+
```ruby
86+
wallet.create_action({ description: '...', outputs: [...], options: { no_send: true } })
87+
```
88+
89+
Without one of these, `create_action` raises `BSV::Wallet::WalletError`.
90+
5891
## Documentation
5992

6093
- [Full documentation](https://sgbett.github.io/bsv-ruby-sdk/)

gem/bsv-wallet/lib/bsv/wallet_interface/broadcast_queue.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,21 @@ def async?
6868
false
6969
end
7070

71+
# Returns +false+ by default — adapters without a broadcaster cannot
72+
# broadcast on-chain.
73+
#
74+
# Override in adapters that hold a broadcaster reference so that
75+
# +WalletClient+ can determine broadcast availability from the queue
76+
# alone. This is the correct delegation point because users may pass a
77+
# broadcaster-equipped queue (e.g.
78+
# +SolidQueueAdapter.new(broadcaster: arc)+) without also passing
79+
# +broadcaster:+ directly to +WalletClient+.
80+
#
81+
# @return [Boolean]
82+
def broadcast_enabled?
83+
false
84+
end
85+
7186
# Returns the broadcast status for a previously enqueued transaction.
7287
#
7388
# @param _txid [String] hex transaction identifier

0 commit comments

Comments
 (0)