> For the complete documentation index, see [llms.txt](https://bsv.brc.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://bsv.brc.dev/wallet/0155.md).

# Pull-Based Receive Discovery

BSVanon (<140332993+BSVanon@users.noreply.github.com>)

## 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 (SPV 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 identity key 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 **BEEF** form and validate it per **SPV** rules: Merkle/BUMP 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 outpoint.
* `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 scratch** — `seen` 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 overlay 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`](https://github.com/BSVanon/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 P2PKH Payment Protocol
* BRC-42 — BSV Key Derivation Scheme (BKDS)
* BRC-50 — Submitting Received Payments to a Wallet
* BRC-62 — Background Evaluation Extended Format (BEEF) Transactions
* BRC-95 — Atomic BEEF Transactions
* BRC-100 — Wallet-to-Application Interface (`internalizeAction`)
* BIP-44 — gap-limit address scanning (contrast)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://bsv.brc.dev/wallet/0155.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
