The gap
STAMP_POOL_MAX_PURCHASES_PER_HOUR was deliberately placed inside _purchase_stamp rather than in the replenish loop, so that no code path could miss it — the scheduled check, the immediate replenishment after an acquire, and anything added later all pass through it.
The pool's top-up path does not. app/services/stamp_pool.py:976 calls swarm_api.extend_postage_stamp directly:
await swarm_api.extend_postage_stamp(batch_id, amount)
Extending a batch spends BZZ exactly as buying one does. It is the only spending path in the gateway with no bound of any kind:
| path |
bound |
POST /stamps/ |
per-request ceiling + daily budget (#102) |
PATCH /stamps/{id}/extend |
per-request ceiling + daily budget (#102) |
POST /pool/acquire |
daily allowance per origin |
| pool purchase |
STAMP_POOL_MAX_PURCHASES_PER_HOUR |
| pool top-up |
none |
How bad is it
Not urgent. This path is driven by the scheduler, not by callers — nothing external triggers it, so it cannot be provoked. The amount comes from STAMP_POOL_TOPUP_HOURS and the batch count is small, so ordinary operation costs little.
What it is exposed to is a configuration or logic error: a topup interval that fires more often than intended, a TTL calculation that thinks every batch needs extending on every pass, or a retry loop around a failing extend. The purchase ceiling exists because exactly that happened — 82 batches bought against a target of five (#271), with the bulk never explained. A ceiling does not need the cause, which was the argument for adding it. The same argument applies here.
Suggested fix
Route the top-up through the same accounting as _purchase_stamp, or give _extend_stamp its own hourly ceiling and report refusals in GET /api/v1/pool/status the way purchase refusals already are.
Whichever way, the property worth preserving is the one the purchase ceiling was designed around: it should be impossible to add a new spending path that skips the bound, rather than something a future author has to remember.
Related: #271 (the incident the purchase ceiling came from), #102 (bounds on the caller-facing spending paths).
The gap
STAMP_POOL_MAX_PURCHASES_PER_HOURwas deliberately placed inside_purchase_stamprather than in the replenish loop, so that no code path could miss it — the scheduled check, the immediate replenishment after an acquire, and anything added later all pass through it.The pool's top-up path does not.
app/services/stamp_pool.py:976callsswarm_api.extend_postage_stampdirectly:Extending a batch spends BZZ exactly as buying one does. It is the only spending path in the gateway with no bound of any kind:
POST /stamps/PATCH /stamps/{id}/extendPOST /pool/acquireSTAMP_POOL_MAX_PURCHASES_PER_HOURHow bad is it
Not urgent. This path is driven by the scheduler, not by callers — nothing external triggers it, so it cannot be provoked. The amount comes from
STAMP_POOL_TOPUP_HOURSand the batch count is small, so ordinary operation costs little.What it is exposed to is a configuration or logic error: a topup interval that fires more often than intended, a TTL calculation that thinks every batch needs extending on every pass, or a retry loop around a failing extend. The purchase ceiling exists because exactly that happened — 82 batches bought against a target of five (#271), with the bulk never explained. A ceiling does not need the cause, which was the argument for adding it. The same argument applies here.
Suggested fix
Route the top-up through the same accounting as
_purchase_stamp, or give_extend_stampits own hourly ceiling and report refusals inGET /api/v1/pool/statusthe way purchase refusals already are.Whichever way, the property worth preserving is the one the purchase ceiling was designed around: it should be impossible to add a new spending path that skips the bound, rather than something a future author has to remember.
Related: #271 (the incident the purchase ceiling came from), #102 (bounds on the caller-facing spending paths).