Use case
Downstream consumers that already manage an ARC Broadcaster instance at the wallet-toolbox layer (rate limits, deployment ID, custom auth headers, retry policy, telemetry) and want the runar SDK to broadcast through the same instance rather than a hardcoded second one inside WalletProvider.
Today WalletProvider hardcodes an internal ARC URL + a fresh ureq client. If wallet-toolbox uses one broadcaster (e.g. TAAL) and runar uses another (e.g. ARC GorillaPool), the two layers can broadcast parent + child transactions through different broadcasters within the same session. The child can land at an ARC instance that hasn't seen the parent yet — ARC stamps the child SEEN_IN_ORPHAN_MEMPOOL (sticky for 5+ blocks); the child eventually drops. This isn't a corner case; it's the default whenever the two layers' broadcaster configurations diverge.
This issue proposes adopting the canonical Broadcaster trait pattern that already exists in the ecosystem — accepting an injected broadcaster on WalletProvider, so both layers share one instance with one configuration.
How this surfaced
Surfaced during a mainnet broadcast where wallet-toolbox and the SDK used different broadcaster instances and parent visibility diverged. Wallet-toolbox successfully broadcast a parent transaction through its default broadcaster; the SDK then broadcast a child transaction (spending the parent's change) through WalletProvider's hardcoded ARC URL. The child landed at an ARC instance that hadn't yet seen the parent — ARC stamped it SEEN_IN_ORPHAN_MEMPOOL and held it. Repro happens any time the two configurations point at distinct ARC deployments, which is the default whenever a downstream consumer touches either layer's broadcaster config without touching the other.
Current behavior
packages/runar-rs/src/sdk/wallet.rs constructs ARC requests inline using a hardcoded URL on WalletProvider:
let arc_endpoint = format!("{}/v1/tx", self.arc_url);
ureq::post(&arc_endpoint)
.set("Content-Type", "application/octet-stream")
.send_bytes(&raw_bytes)
No injection point exists. The WalletProvider constructor takes an arc_url: String and builds its own ureq client; there is no way to hand it a pre-configured broadcaster owned by the calling layer.
The canonical ecosystem pattern already exists. bsv-rust-sdk/src/transaction/broadcaster.rs:33-56:
#[derive(Debug, Clone, Default)]
pub struct BroadcastFailure {
pub status: u32,
pub code: String,
pub description: String,
pub txid: Option<String>,
pub competing_txs: Option<Vec<String>>,
pub more: Option<serde_json::Value>,
}
#[async_trait]
pub trait Broadcaster: Send + Sync {
async fn broadcast(&self, tx: &Transaction) -> Result<BroadcastResponse, BroadcastFailure>;
}
TS @bsv/sdk Broadcaster.ts:24-51 mirrors this with BroadcastResponse | BroadcastFailure as a union return. Both are well-established public traits. wallet-toolbox accepts Broadcaster injection at construction time (see e.g. WalletStorageManager setup). Runar's WalletProvider is the odd-one-out that hardcodes its own.
Proposed shape (design question)
Two viable paths:
Shape A — full canonical adoption (preferred long-term)
WalletProvider::with_broadcaster(broadcaster: Box<dyn Broadcaster>) constructor (or builder method). The trait reference is bsv-rust-sdk::Broadcaster. Internally, WalletProvider::broadcast delegates to the injected broadcaster:
pub fn with_broadcaster<W>(wallet: W, broadcaster: Box<dyn Broadcaster>) -> Self { ... }
// Provider impl:
async fn broadcast(&mut self, tx: &BsvTransaction) -> Result<String, BroadcastFailure> {
self.broadcaster.broadcast(tx).await.map(|r| r.txid)
}
This subsumes PR-04's R1 reshape entirely — the canonical BroadcastFailure shape is inherited from the trait, and callers can pattern-match on code / description / competing_txs programmatically instead of regex-parsing an error string.
Cost: changes Provider::broadcast's return type from Result<String, String> to Result<String, BroadcastFailure> — a trait-signature break. Existing callers that destructure Err(s) would need to migrate to Err(BroadcastFailure { description, .. }). Cross-language symmetry would warrant a parallel TS twin (TS already returns the analogous union shape; the runar TS WalletProvider would need the same injection point).
Shape B — additive constructor only
Add the constructor + injection point but keep the existing Provider::broadcast signature. WalletProvider::broadcast delegates to the injected broadcaster and flattens any structured failure into the existing Err(String) shape on the way out. Loses the structured-error benefit of Shape A but is non-breaking at the trait surface.
Both shapes solve the parent-visibility class of failures (single broadcaster across both layers). Shape A also closes the silent-error-shape gap. Shape B is purely additive.
This issue is the design call. The proof-of-concept work for PR-04 chose the local-minimal path (no trait change) to close the silent-failure bug without forcing this design decision; the structural fix tracked here.
Alternatives considered
| Shape |
Strengths |
Weaknesses |
A — full canonical (bsv-rust-sdk::Broadcaster trait) |
Direct ecosystem parity; structured failure shape inherited; subsumes PR-04 R1's reshape question. |
Trait-signature break — all WalletProvider::broadcast callers must migrate. Cross-language TS twin needed. |
| B — additive constructor, no trait change |
Non-breaking. Solves the orphan-mempool class via shared broadcaster. |
Doesn't close the structured-error gap; PR-04 R1's reshape question still open. |
| Status quo + chain-truth probe (see ISSUE-03) |
Zero churn. Catches the symptom reactively. |
Doesn't eliminate the cause; every consumer pays a per-broadcast probe round-trip. |
| Keep hardcoded URL + document deployment-matching |
Zero code change. |
Punts the problem to the consumer's deployment topology; the next consumer rediscovers the trap. |
Backwards compatibility
Whichever shape lands, the design should be opt-in:
- Existing
WalletProvider::new(wallet, arc_url) continues to work — the new constructor sits beside it as a builder option.
- The injected
Broadcaster is the upgrade path; the hardcoded ARC URL is the default. New consumers can adopt the injection at their own pace.
- For Shape A specifically, the trait-signature change is the migration cost. Suggest staging behind a feature flag or major-version bump.
Related
Use case
Downstream consumers that already manage an ARC
Broadcasterinstance at the wallet-toolbox layer (rate limits, deployment ID, custom auth headers, retry policy, telemetry) and want the runar SDK to broadcast through the same instance rather than a hardcoded second one insideWalletProvider.Today
WalletProviderhardcodes an internal ARC URL + a freshureqclient. If wallet-toolbox uses one broadcaster (e.g. TAAL) and runar uses another (e.g. ARC GorillaPool), the two layers can broadcast parent + child transactions through different broadcasters within the same session. The child can land at an ARC instance that hasn't seen the parent yet — ARC stamps the childSEEN_IN_ORPHAN_MEMPOOL(sticky for 5+ blocks); the child eventually drops. This isn't a corner case; it's the default whenever the two layers' broadcaster configurations diverge.This issue proposes adopting the canonical
Broadcastertrait pattern that already exists in the ecosystem — accepting an injected broadcaster onWalletProvider, so both layers share one instance with one configuration.How this surfaced
Surfaced during a mainnet broadcast where wallet-toolbox and the SDK used different broadcaster instances and parent visibility diverged. Wallet-toolbox successfully broadcast a parent transaction through its default broadcaster; the SDK then broadcast a child transaction (spending the parent's change) through
WalletProvider's hardcoded ARC URL. The child landed at an ARC instance that hadn't yet seen the parent — ARC stamped itSEEN_IN_ORPHAN_MEMPOOLand held it. Repro happens any time the two configurations point at distinct ARC deployments, which is the default whenever a downstream consumer touches either layer's broadcaster config without touching the other.Current behavior
packages/runar-rs/src/sdk/wallet.rsconstructs ARC requests inline using a hardcoded URL onWalletProvider:No injection point exists. The
WalletProviderconstructor takes anarc_url: Stringand builds its ownureqclient; there is no way to hand it a pre-configured broadcaster owned by the calling layer.The canonical ecosystem pattern already exists.
bsv-rust-sdk/src/transaction/broadcaster.rs:33-56:TS
@bsv/sdkBroadcaster.ts:24-51mirrors this withBroadcastResponse | BroadcastFailureas a union return. Both are well-established public traits. wallet-toolbox acceptsBroadcasterinjection at construction time (see e.g.WalletStorageManagersetup). Runar'sWalletProvideris the odd-one-out that hardcodes its own.Proposed shape (design question)
Two viable paths:
Shape A — full canonical adoption (preferred long-term)
WalletProvider::with_broadcaster(broadcaster: Box<dyn Broadcaster>)constructor (or builder method). The trait reference isbsv-rust-sdk::Broadcaster. Internally,WalletProvider::broadcastdelegates to the injected broadcaster:This subsumes PR-04's R1 reshape entirely — the canonical
BroadcastFailureshape is inherited from the trait, and callers can pattern-match oncode/description/competing_txsprogrammatically instead of regex-parsing an error string.Cost: changes
Provider::broadcast's return type fromResult<String, String>toResult<String, BroadcastFailure>— a trait-signature break. Existing callers that destructureErr(s)would need to migrate toErr(BroadcastFailure { description, .. }). Cross-language symmetry would warrant a parallel TS twin (TS already returns the analogous union shape; the runar TSWalletProviderwould need the same injection point).Shape B — additive constructor only
Add the constructor + injection point but keep the existing
Provider::broadcastsignature.WalletProvider::broadcastdelegates to the injected broadcaster and flattens any structured failure into the existingErr(String)shape on the way out. Loses the structured-error benefit of Shape A but is non-breaking at the trait surface.Both shapes solve the parent-visibility class of failures (single broadcaster across both layers). Shape A also closes the silent-error-shape gap. Shape B is purely additive.
This issue is the design call. The proof-of-concept work for PR-04 chose the local-minimal path (no trait change) to close the silent-failure bug without forcing this design decision; the structural fix tracked here.
Alternatives considered
bsv-rust-sdk::Broadcastertrait)WalletProvider::broadcastcallers must migrate. Cross-language TS twin needed.Backwards compatibility
Whichever shape lands, the design should be opt-in:
WalletProvider::new(wallet, arc_url)continues to work — the new constructor sits beside it as a builder option.Broadcasteris the upgrade path; the hardcoded ARC URL is the default. New consumers can adopt the injection at their own pace.Related
runar broadcast and get_transaction: R1 there is the local-minimal fix for the silent-Ok bug. This issue is the architectural follow-up; adopting Shape A would subsume R1's reshape.verify_inputs_on_chainchain-truth probe: treats the orphan-mempool symptom reactively. If this issue lands as Shape A (shared broadcaster), ISSUE-03 becomes largely optional.