Beersy
BRC-155

Pull-Based Receive Discovery

A wallet can hand out a fresh receive address to anyone, but if that sender is a custodial exchange, swap service, or anyone who just sends money without telling the wallet, there is no way for the wallet to notice the payment arrived. Watching every possible address the wallet could ever generate is slow, leaks privacy, and can still miss late payments. This lets a wallet reliably notice payments to addresses it gave out, without the sender's cooperation and without scanning forever.

BSVanon9 min read

Reference for an AI

Everything an assistant needs to answer questions about BRC-155 accurately, including what it depends on.

Summary

Why
A wallet needs to detect unsolicited on-chain payments sent to addresses it handed out, without relying on the sender to notify it or scanning an unbounded set of possible addresses.
What
BRC-155 defines how a wallet tracks its handed-out receive addresses and periodically checks them for new deposits, verifying and absorbing any it finds.
How
A wallet keeps a list of addresses it has generated with a running counter, calls an untrusted scan on each active address to get hints of activity, and feeds any new through an SPV-verifying internalize step before marking it seen, while stale empty addresses are pruned and a fresh device can rebuild the…

What this lets you do

  • Hand out fresh receive addresses and track them
  • Discover deposits to those addresses without sender cooperation
  • Prune stale addresses without losing late payments
  • Deep-scan retired addresses for rare re-payments
  • Recover the full address list on a new device from a synced counter

Written by claude-sonnet-5 from the specification text. Where the two differ, the original is correct.

addr1addr2addr3wallet

The specification

Abstract

This standard specifies how a wallet discovers unsolicited incoming payments to addresses it has previously handed out, without cooperation from the sender and without scanning an unbounded derivation space. It defines a tracked list of explicitly handed-out receive addresses, a discovery procedure that internalizes each new deposit exactly once ( verification is performed within the internalize step), a pruning procedure that retires stale addresses while never orphaning funds, and a deterministic recovery procedure for a fresh device. A reference implementation with a fully injected engine, network, and persistence layer accompanies this document.

Motivation

Every existing BSV receive mechanism is cooperative (push):

  • BRC-29 has the sender derive the recipient key and relay the transaction.
  • BRC-50 has an application submit a finished transaction into the recipient's wallet.
  • internalizeAction (BRC-100) has the recipient handed a completed, proven transaction to absorb.

Each requires the payer, or software acting for the payer, to actively deliver the payment to the recipient's wallet. None cover the pull case: a non-cooperating source — a custodial exchange processing a withdrawal, a swap/on-ramp service, or any party that simply holds one of the recipient's addresses — broadcasts a plain payment on-chain and notifies no one. The recipient's wallet must notice it.

The naive approaches are both poor. Reusing a single address destroys privacy and still requires monitoring. Scanning the full derivation space (BIP-44-style gap scanning) on every poll is O(all derivations), leaks every candidate address to the queried indexer, and still risks missing a late payment beyond the gap limit. This standard takes a third approach: monitor only the explicit, finite set of addresses actually handed out, which is both tractable (O(handed-out)) and complete (a handed-out address is never outside the watched set). Pruning (below) further bounds the actively-scanned set to addresses that currently hold value plus recently handed-out empties, so per-poll cost does not grow with the wallet's lifetime payment count.

Terminology

  • Handed-out address — a receive address the wallet has generated and disclosed to some party (in a request, an invoice, a QR code, or directly).
  • Discovery state — the ordered list of handed-out address entries plus a monotonic counter (nextIndex).
  • Hint — an untrusted signal from a block explorer that an address may have on-chain activity.
  • Internalize — the trusted step that fetches the deposit as a proven transaction, SPV-verifies it, and records it as spendable wallet state.

Specification

Data model

The discovery state is:

State = { nextIndex: uint, entries: Entry[] }
Entry = {
  index:      uint,                       // derivation index; the address's stable identity
  address:    string,
  keyID:      string,                     // derivation locator for the internalize step
  createdAt:  uint,                       // ms epoch, for staleness
  status:     "active" | "pruned",
  everFunded: boolean,                    // advisory, derived from the untrusted hint — not proof of receipt
  seen:       string[],                   // outpoints ("txid.vout") already internalized at this address
  amount?:    uint,                       // optional invoice context
  label?:     string,
  expiresAt?: uint                        // ms epoch; may retire the address before the age threshold
}

index is the address's durable identity. Because derivation is deterministic in index, every handed-out address re-derives from the and its index on any device.

Injected dependencies

A conformant implementation is defined over four capabilities so that it is independent of any particular wallet engine, network client, or store:

  • derive(index) -> { address, keyID } — deterministic receive-key derivation (e.g. BRC-42). MUST be a pure function of the identity key and index.
  • scan(address) -> { utxos: [{ txid, vout, satoshis }], hasHistory } — an untrusted hint source (typically a block explorer). MAY report unconfirmed (0-conf) outputs. See Trust model.
  • internalize({ address, keyID, index, utxo }) -> void — obtain the deposit in form and validate it per SPV rules: Merkle/ proofs verified for mined ancestors against the wallet's own header chain. An unconfirmed deposit MAY be accepted provisionally per engine policy and, if later reorged out, reversed via unsee. Record the accepted deposit as spendable and durably converge. MUST be idempotent per .
  • load() / save(state) — durable persistence for the state (optional; an implementation MAY be in-memory).

Plus tuning parameters: now(), a staleness threshold pruneAgeMs, and a recovery gapLimit.

Operations

  • nextAddress({ amount?, label?, expiresAt? }) — allocate index = nextIndex, derive, append an active entry, increment the counter, and return { index, address, keyID }. Fresh by default; reuse is permitted but never forced.
  • discover(onError) — for each active entry, scan it and, for each returned outpoint not already in seen, internalize it and then record the outpoint in seen. Marking seen only after a successful internalize makes a mid-flight failure retryable and never credits unproven funds. Returns the receipts internalized this pass. A scan/internalize failure on one address is isolated and reported, never blocking the rest.
  • prune(onError) — retire stale addresses that hold no live value. "Stale" = createdAt older than pruneAgeMs, or past expiresAt. Staleness MUST be confirmed by a fresh scan at prune time, not a cached flag. An address that still holds live UTXOs is internalized (absorbed) and kept active, so pruning can never strand a late deposit it just observed. An address that is merely drained — prior on-chain history but no current UTXOs — is retired like a never-funded one; keeping it active on the strength of history alone would watch every once-paid address forever and let discovery grow without bound. Retiring only flips status to pruned (entries are never deleted), so a rare re-payment to a retired address remains recoverable via deepScan.
  • deepScan({ onProgress, onError }) — the explicit, warned, rate-limited scan over pruned addresses. A pruned address found funded is internalized and resurrected to active. onProgress is invoked before each address so the caller owns throttling and user warnings.
  • deepScanRange() — list the pruned addresses, for a caller that drives its own deep scan or shows the list.
  • unsee(index, outpoint) — reverse a seen mark after a 0-conf deposit is reorged away (learned from the engine's proof monitoring), so the next discover re-checks and, if it reappears, re-internalizes. The engine remains the source of truth for spendability, so no phantom is created either way.
  • recover({ highestIndex, force, onProgress }) — rebuild on a fresh device. With the synced counter (highestIndex = nextIndex, which MUST be a non-negative integer), rescan the full handed-out range 0..N-1 and re-add every address, funded or not, so a late payment to an address that was empty at restore time is still caught by a subsequent discover. Without the counter, fall back to a best-effort gap-scan that finds only already-funded addresses and stops after gapLimit consecutive empty indices. recover rebuilds from scratchseen marks, invoice metadata, and status are not reconstructable from the chain — so an implementation SHOULD refuse or warn when the state is non-empty (this reference refuses unless force is set). After recovery, receipts returned by a subsequent discover are deposits internalized this pass, not first-time-credit notifications; the engine dedups the actual credit.
  • counter() — the single integer (nextIndex) worth syncing so recover can go full-range.
  • snapshot() — a read-only copy of the state.

Callbacks and liveness

onError and onProgress are fire-and-forget telemetry. An implementation MUST invoke them defensively so that a throwing callback cannot break or abort a mutation, and a callback MAY await (so the caller can throttle a deep scan). A callback MUST NOT invoke a mutating operation on the same instance: mutating operations are serialized, so a re-entrant call queues behind the very operation that is awaiting the callback and deadlocks.

Because mutating operations are serialized, a long-running discover, deepScan, or recover holds the queue for its full duration, and an address hand-out may block behind it. Implementations that require low hand-out latency during long scans SHOULD scope transactions more finely (for example, per entry).

Trust model

scan is only an untrusted address-index hint. It never credits funds and is not itself SPV. All crediting occurs in internalize, which MUST obtain the deposit in BEEF form and validate it per SPV rules — Merkle/BUMP proofs for mined ancestors against the wallet's own header chain — before recording it (an unconfirmed deposit accepted provisionally is reversible via unsee). Consequences:

  • A lying or malicious explorer cannot fabricate a credit — an unprovable "hint" fails at internalize.
  • A censoring or eclipsed explorer can withhold a hint indefinitely — for as long as it controls the client's view of the chain, not merely until the next scan.
  • Untrusted input can downgrade discovery, though never fabricate or lose a credit: because prune re-scans through the same source, a persistently lying-empty hint can cause an active address to be pruned (leaving the watched set), after which a real deposit to it is recoverable only via the manual deepScan.

Implementations SHOULD therefore treat scan as replaceable and MAY use multiple independent sources, a self-hosted indexer, or an lookup.

Durability, atomicity, and read isolation

Every mutating operation MUST be atomic with respect to durable state: apply changes to a private copy, persist, and adopt the persisted copy as live state only after the save succeeds. A failure at any point — the save, a derive/scan, or a callback — MUST leave the live state unchanged (the private copy is discarded). Combined with an idempotent internalize, a failed-then-retried operation credits each deposit exactly once, with no phantom credit and none silently skipped.

External readers (snapshot, counter, deepScanRange) MUST observe only committed state, never an in-flight mutation. The state MUST be copy-isolated from the persistence adapter on both load and save so that a by-reference adapter cannot alias live state.

Concurrency and multi-device allocation

Within a single instance, mutating operations MUST be serialized so that two overlapping nextAddress calls cannot read the same nextIndex and hand out the same address.

Across instances, collision-freedom of the counter holds only under a single durable writer. A deployment with multiple independent writers sharing one identity MUST choose one of:

  1. Explicit single-writer — one device owns allocation; others request addresses from it.
  2. Device-scoped index ranges — each writer draws from a disjoint arithmetic range (e.g. index = offset_d + k·stride), keeping recovery deterministic per device.
  3. Compare-and-set / lease on the shared counter.

A collision here is a privacy/merge defect, not a loss of funds: both writers still internalize correctly.

Privacy

Querying handed-out addresses at a public explorer correlates them to the user at that provider. Mitigations (out of scope of this standard): a self-hosted indexer, an overlay lookup, or query batching. Fresh-by-default addressing limits cross-payment linkage at the cost of a longer list.

Implementations

receive-discovery — a pure, dependency-injected reference implementation of this standard, written in TypeScript and released under the Open BSV License. The engine, network, and persistence are injected, so the full state machine (hand-out, discover, prune, deep-scan, recover, reorg, transactional persistence, concurrency) is exercised hermetically with no engine and no network.

Test vectors

The reference implementation ships twenty-five hermetic tests that serve as executable test vectors, covering: deterministic sequential allocation and serialized concurrent allocation; discover with once-only internalize and dedup across passes; internalize-failure retry with no phantom credit; per-address scan-failure isolation; prune with fresh re-check, expiry, absorb-on-funded, and drained-address retirement; deep-scan resurrection; full-range and gap-scan recovery, the non-empty-state guard, and the non-negative-integer guard; reorg reversal and pruned-entry resurrection; malformed-state rejection; and transactional persistence — same-instance save-failure retry, copy-isolation against a by-reference adapter, and read isolation against a mid-save dirty read.

References

  • BRC-9, BRC-67 — Simplified Payment Verification
  • BRC-29 — Simple Authenticated BSV Payment Protocol
  • BRC-42 — (BKDS)
  • BRC-50 — Submitting Received Payments to a Wallet
  • BRC-62 — Background Evaluation Extended Format (BEEF) Transactions
  • BRC-95 — Transactions
  • BRC-100 — (internalizeAction)
  • BIP-44 — gap-limit address scanning (contrast)
Was this helpful?

Search Beersy

Search standards by number, title, author or topic