Part of #455.
Summary
Add validate_broadcast_configuration! to WalletClient and introduce BroadcastQueue#broadcast_enabled? so the new guard works with queue-embedded broadcasters (the documented SolidQueue pattern).
When create_action is called with synchronous broadcast semantics (the default — accept_delayed_broadcast: false, no no_send, no send_with) AND no broadcaster is available anywhere in the wiring (neither directly on WalletClient nor inside @broadcast_queue), raise BSV::Wallet::WalletError before any storage writes occur.
This closes the silent-failure path that caused the x402-rack phantom-payment incident (#148).
Design decisions (resolved)
-
Delegate broadcast availability to the queue. Add broadcast_enabled? to the BroadcastQueue module (default false). Override in InlineQueue and SolidQueueAdapter to return !!@broadcaster. WalletClient#broadcast_enabled? (currently lines ~98-100) delegates to @broadcast_queue.broadcast_enabled?. validate_broadcast_configuration! uses the queue's check, not @broadcaster directly.
- Why:
WalletClient.new(..., broadcast_queue: SolidQueueAdapter.new(..., broadcaster: arc)) passes broadcaster: nil to WalletClient while the queue has a real broadcaster. This is the documented SolidQueue integration pattern (gem/bsv-wallet-postgres/README.md:44-48). Delegating to the queue keeps this pattern working.
-
Also validate at sign_action. Run validate_broadcast_configuration!(merged_args) at the top of sign_action (after its own validation), not only at create_action.
- Why:
sign_action merges its own options over the create-time args. A caller can omit no_send at create time (passes validation), receive a signable tx, then flip no_send: false at sign time with no broadcaster configured. Validating only at create_action entry misses this flip case. Same fail-loud principle applies: refuse before storage is written.
Scope
gem/bsv-wallet/lib/bsv/wallet_interface/wallet_client.rb
create_action (line ~122)
sign_action (line ~183)
broadcast_enabled? (line ~98)
- New private method
validate_broadcast_configuration!(args)
gem/bsv-wallet/lib/bsv/wallet_interface/broadcast_queue.rb — add broadcast_enabled? (default false)
gem/bsv-wallet/lib/bsv/wallet_interface/inline_queue.rb — override broadcast_enabled? → !!@broadcaster
gem/bsv-wallet-postgres/lib/bsv/wallet_postgres/solid_queue_adapter.rb — override broadcast_enabled? → !!@broadcaster
Error message
WalletError: create_action requires a broadcaster for on-chain broadcast.
Pass broadcaster: BSV::Network::ARC.default to WalletClient.new,
or options: { no_send: true } to build a transaction without broadcasting.
(Same message from sign_action entry; consistent wording aids diagnosis.)
Acceptance criteria
Edge cases
args[:options] absent entirely (nil, not {}) — dig(:options, :no_send) handles cleanly.
accept_delayed_broadcast: true with no broadcaster AND no_send: false — still raises. Delayed broadcast without a broadcaster was the silent-fallback case; per HLR that is now blocked.
send_with: [...] with no broadcaster — the existing raise at line ~135 still fires. Validation passes first if no_send: true is set; otherwise the new validation raises before reaching line 135. Either error is acceptable.
broadcaster: nil explicitly on WalletClient.new but queue has a broadcaster — must pass.
Test scenarios
raises WalletError when no broadcaster and no no_send option
raises WalletError when no broadcaster and accept_delayed_broadcast: true and no no_send
does not raise when no_send: true with no broadcaster
does not raise when broadcaster present on WalletClient
does not raise when broadcast_queue has embedded broadcaster but WalletClient @broadcaster is nil
raises before any storage writes occur (verify storage is untouched after rescue)
sign_action: raises when flipping no_send false with no broadcaster
sign_action: does not raise when no_send true remains true through sign
BroadcastQueue.new.broadcast_enabled? returns false
InlineQueue.new(...).broadcast_enabled? reflects @broadcaster presence
SolidQueueAdapter.new(...).broadcast_enabled? reflects @broadcaster presence
Files modified
gem/bsv-wallet/lib/bsv/wallet_interface/wallet_client.rb
gem/bsv-wallet/lib/bsv/wallet_interface/broadcast_queue.rb
gem/bsv-wallet/lib/bsv/wallet_interface/inline_queue.rb
gem/bsv-wallet-postgres/lib/bsv/wallet_postgres/solid_queue_adapter.rb
- Corresponding specs (Task 5 handles the spec edits; this task adds the new guard-specific specs)
Sequencing
Land first. Task 2 overrides broadcast_enabled? on InlineQueue; this task introduces the interface.
Part of #455.
Summary
Add
validate_broadcast_configuration!toWalletClientand introduceBroadcastQueue#broadcast_enabled?so the new guard works with queue-embedded broadcasters (the documented SolidQueue pattern).When
create_actionis called with synchronous broadcast semantics (the default —accept_delayed_broadcast: false, nono_send, nosend_with) AND no broadcaster is available anywhere in the wiring (neither directly onWalletClientnor inside@broadcast_queue), raiseBSV::Wallet::WalletErrorbefore any storage writes occur.This closes the silent-failure path that caused the x402-rack phantom-payment incident (#148).
Design decisions (resolved)
Delegate broadcast availability to the queue. Add
broadcast_enabled?to theBroadcastQueuemodule (defaultfalse). Override inInlineQueueandSolidQueueAdapterto return!!@broadcaster.WalletClient#broadcast_enabled?(currently lines ~98-100) delegates to@broadcast_queue.broadcast_enabled?.validate_broadcast_configuration!uses the queue's check, not@broadcasterdirectly.WalletClient.new(..., broadcast_queue: SolidQueueAdapter.new(..., broadcaster: arc))passesbroadcaster: niltoWalletClientwhile the queue has a real broadcaster. This is the documented SolidQueue integration pattern (gem/bsv-wallet-postgres/README.md:44-48). Delegating to the queue keeps this pattern working.Also validate at
sign_action. Runvalidate_broadcast_configuration!(merged_args)at the top ofsign_action(after its own validation), not only atcreate_action.sign_actionmerges its own options over the create-time args. A caller can omitno_sendat create time (passes validation), receive a signable tx, then flipno_send: falseat sign time with no broadcaster configured. Validating only atcreate_actionentry misses this flip case. Same fail-loud principle applies: refuse before storage is written.Scope
gem/bsv-wallet/lib/bsv/wallet_interface/wallet_client.rbcreate_action(line ~122)sign_action(line ~183)broadcast_enabled?(line ~98)validate_broadcast_configuration!(args)gem/bsv-wallet/lib/bsv/wallet_interface/broadcast_queue.rb— addbroadcast_enabled?(defaultfalse)gem/bsv-wallet/lib/bsv/wallet_interface/inline_queue.rb— overridebroadcast_enabled?→!!@broadcastergem/bsv-wallet-postgres/lib/bsv/wallet_postgres/solid_queue_adapter.rb— overridebroadcast_enabled?→!!@broadcasterError message
(Same message from
sign_actionentry; consistent wording aids diagnosis.)Acceptance criteria
WalletClient.new(key, storage: store).create_action(valid_args)raisesWalletErrorwith the documented message.WalletClient.new(key, storage: store).create_action(valid_args.merge(options: { no_send: true }))succeeds (no raise).WalletClient.new(key, storage: store, broadcaster: arc).create_action(valid_args)does not raise.WalletClient.new(key, storage: store, broadcast_queue: solid_queue_with_broadcaster).create_action(valid_args)does not raise.sign_actionwithno_send: truemerged in at sign time does not raise (even if no broadcaster).sign_actionflipping tono_send: falseat sign time with no broadcaster raisesWalletError.store_transaction,store_action,lock_utxos— no storage side effects on misconfig (verified viastorage.find_actionsreturning empty after rescue).send_with-only path continues to raise at line ~135 with its specific message (unchanged).BroadcastQueue#broadcast_enabled?defaults tofalse; subclass overrides work.WalletClient#broadcast_enabled?delegates to the queue.Edge cases
args[:options]absent entirely (nil, not{}) —dig(:options, :no_send)handles cleanly.accept_delayed_broadcast: truewith no broadcaster ANDno_send: false— still raises. Delayed broadcast without a broadcaster was the silent-fallback case; per HLR that is now blocked.send_with: [...]with no broadcaster — the existing raise at line ~135 still fires. Validation passes first ifno_send: trueis set; otherwise the new validation raises before reaching line 135. Either error is acceptable.broadcaster: nilexplicitly onWalletClient.newbut queue has a broadcaster — must pass.Test scenarios
raises WalletError when no broadcaster and no no_send optionraises WalletError when no broadcaster and accept_delayed_broadcast: true and no no_senddoes not raise when no_send: true with no broadcasterdoes not raise when broadcaster present on WalletClientdoes not raise when broadcast_queue has embedded broadcaster but WalletClient @broadcaster is nilraises before any storage writes occur (verify storage is untouched after rescue)sign_action: raises when flipping no_send false with no broadcastersign_action: does not raise when no_send true remains true through signBroadcastQueue.new.broadcast_enabled? returns falseInlineQueue.new(...).broadcast_enabled? reflects @broadcaster presenceSolidQueueAdapter.new(...).broadcast_enabled? reflects @broadcaster presenceFiles modified
gem/bsv-wallet/lib/bsv/wallet_interface/wallet_client.rbgem/bsv-wallet/lib/bsv/wallet_interface/broadcast_queue.rbgem/bsv-wallet/lib/bsv/wallet_interface/inline_queue.rbgem/bsv-wallet-postgres/lib/bsv/wallet_postgres/solid_queue_adapter.rbSequencing
Land first. Task 2 overrides
broadcast_enabled?onInlineQueue; this task introduces the interface.