polycratia

The gap limit is a compatibility contract, not a scanner setting

· 7 min read

An HD wallet scanner quits twenty unused addresses after the last one that received money. An address handed out past that window still belongs to your key, your own indexer will watch it happily, and no standard wallet will ever reach it. That makes the gap limit a constraint on what you are allowed to issue, not a parameter you tune on your own side of the system.

An address that is yours and unreachable#

Deriving a deposit address from an account extended public key is arithmetic: an HMAC, a scalar multiplication, a point addition, then an encoding. Nothing in that chain has an opinion about whether anyone will ever find the result.

python
from chain_addresses import ExtendedPublicKey, get_encoder

account = ExtendedPublicKey.parse(account_xpub)
far = account.derive("0/500")

get_encoder("bitcoin-p2wpkh").encode(far.public_key)   # bc1q...

That address is valid. It is a real P2WPKH output for a key under your account, and if a customer sends coins to it, the coins are yours in every sense that matters cryptographically.

Discovery is a different question, and convention answers it rather than the curve. A conforming wallet walks the branch forward from the last index it has seen receive a deposit, then gives up after a run of consecutive unused addresses. BIP44 fixed that run at twenty. So if 0/3 is the newest index with money on it, a scanner looks at 0/4 through 0/23 and stops. Index 500 is not rejected: it is simply never visited.

The money is not lost. It is unfindable by the standard procedure, which in practice sits close enough to lost that the distinction only gets interesting during an incident. Recovery means rescanning with a raised limit, on software that lets you raise it, by someone who knows that is what happened.

Your indexer is not the thing that has to find it#

The usual objection is reasonable: I run my own indexer, I subscribe to each address explicitly as I issue it, I never scan for anything. The gap limit is a wallet UX detail and I am not a wallet.

That holds for every path where your service does the looking. The paths that matter are the ones where it does not:

  • The seed is restored into a hardware or desktop wallet to sweep the account, because the service is being wound down, migrated, or rebuilt.
  • Someone reconstructs the account from the xpub with off-the-shelf tooling to check that the balance you report is the balance on chain.
  • A recovery tool is run years later by a person who never saw your schema and has nothing but the mnemonic.

In all three, the software honouring the default of twenty is not yours, and you cannot patch it. Your address table is the only artefact in the world that knows index 500 was handed out. If the table is intact, you did not need the standard discovery rule. If the table is the thing that failed, the standard discovery rule is the entire recovery procedure, and every address past the frontier sits outside it.

Read that way, the gap limit stops being a scanner setting and becomes a contract with software you will never run. That changes where enforcement belongs. Not in a dashboard that alerts when the count of outstanding addresses grows, because by the time the alert fires the addresses are already in customers' hands. It belongs at the moment of issuance, the last point where the state is still repairable for free.

Making the refusal the default#

In chain-addresses the cursor and the limit live together in one small object, and handing out an address is the only operation that can fail.

python
from chain_addresses import AddressLedger, DEFAULT_GAP_LIMIT, GapLimitError

book = AddressLedger(account, "bitcoin-p2wpkh", branch=0)
book.gap_limit == DEFAULT_GAP_LIMIT      # 20, the BIP44 convention

for _ in range(book.remaining):
    book.issue()

book.next_index    # 20
book.frontier      # 20, the first index a conforming scanner would miss
book.remaining     # 0

try:
    book.issue()
except GapLimitError:
    ...               # nothing left to hand out

Two properties carry the whole argument. frontier is the first index a scanner honouring the limit would never reach, measured from the last index known to have been used. remaining is how many addresses may still go out before that point. When remaining hits zero, issue raises instead of returning, and the error tells you which index was refused, how many unused addresses it would have left behind, and the two things that can change the answer: mark a deposit as used, or raise the limit.

The second property is the one people find uncomfortable. Issuing spends the budget, and only arriving deposits refill it. Nothing a caller does can create room.

python
book.mark_used(19)

book.outstanding   # 0
book.frontier      # 40
book.remaining     # 20

A deposit on the newest address reopens the whole window, because the run of unused addresses a scanner would have to cross is now empty. A deposit on an older index moves nothing: the gap it has to walk is unchanged. mark_used also accepts an index that this process never issued, which is what a restore from another system looks like. The cursor jumps past it and continues.

Issuing is not reading#

The refusal is only tolerable because inspection is free. peek returns the address issue would hand out, address_at derives any index at all, and neither touches the bookkeeping.

python
book = AddressLedger(account, "bitcoin-p2wpkh", branch=0)

book.peek()            # what issue() would return
book.address_at(500)   # path "0/500", derived, not handed out
book.next_index        # still 0

This matters more than it looks. A support tool showing a customer's address, a reconciliation script re-deriving what the database claims, a rescan comparing indices against chain state: all of them want addresses and none of them should spend the budget. address_at is also exactly the call a recovery investigation needs: show me index 500, confirm it is the address in the row, confirm the balance sitting on it. Derivation being a pure function is what makes that possible without any state at all.

So the one call with consequences is the one that can fail, and its caller has to be something capable of handling a failure. That rules out a request handler obliged to return a fresh address on demand, which is the real reason an address ends up being an assignment you store per customer rather than a response you compute per request.

State survives the process in the obvious way. state returns something JSON-serialisable, restore rebuilds the ledger from it, and what is durable is the cursor and the last used index rather than the addresses themselves. A restored ledger whose stored next_index already sits past a now-lower limit reports zero room rather than negative room, and refuses to issue. For a state that was created under a policy you have since tightened, that is the correct behaviour.

What I would do differently#

In earlier custodial and on-chain payment work, the address index was a database sequence and the gap limit was a sentence in an operations document. That arrangement is fine right up until the day the deposit watcher falls behind, because a sequence has no notion of a frontier and will keep incrementing cheerfully while the window closes behind it. The failure is silent on both sides: no error at issuance, no error at scan time, just addresses accumulating past the point where the standard procedure can see them.

Two things I would set up differently from the start. First, the last used index is a first-class value written by the deposit watcher, not something a nightly job recomputes, because it is the only input that refills the budget. Second, a raised gap_limit is a recorded decision attached to the account, not a configuration value someone edits, because raising it does not change the contract. It changes which software can honour it, and the wallet performing the eventual recovery will probably still use twenty.

Derivation is free and discovery is not. The only place that difference can be enforced is the call that moves the cursor.

react

$ new-project --brief

or email hey@polycratia.com