Skip to content

[arch] WalletProvider::with_broadcaster injection so SDK and wallet-toolbox share one Broadcaster #107

Description

@E-Jacko

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions