Skip to content
Merged
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ POOL_ADMIN_ADDRESSES=
# STAMP_POOL_RESERVE_MEDIUM=1 # Depth-20 stamps to keep in reserve
# STAMP_POOL_RESERVE_LARGE=0 # Depth-22 stamps to keep in reserve
# STAMP_POOL_CHECK_INTERVAL_SECONDS=900 # Check pool every N seconds (default: 15 min)

# Hard ceiling on batches bought per rolling hour, across all depths and all
# code paths. A blast radius, not a tuning knob: a staging node once bought 82
# against a target of 5. Raise only after understanding why it was hit.
STAMP_POOL_MAX_PURCHASES_PER_HOUR=10
# STAMP_POOL_MIN_TTL_HOURS=24 # Top up if TTL below this (hours)
# STAMP_POOL_TOPUP_HOURS=168 # How much TTL to add when topping up (1 week)
# STAMP_POOL_LOW_RESERVE_THRESHOLD=1 # Alert when reserve drops to this level
Expand Down Expand Up @@ -190,3 +195,21 @@ STAMP_OWNERSHIP_ALLOW_UNTRACKED=false
# python scripts/generate_notary_key.py
#
# Security: Keep NOTARY_PRIVATE_KEY secret. Anyone with this key can sign as your notary.

# Daily allowance of pooled stamps, per calling origin AND per size.
#
# The limit applies to each size separately. A depth-20 batch costs eight times
# a depth-17 one, so a shared count would let a caller spend eight times its
# budget simply by asking for a larger size.
# POOL_DAILY_ALLOWANCES="https://app.example=50,https://dev.app.example=20"
#
# Origin is attribution, not authentication: a browser cannot forge another
# site's, so this stops other WEBSITES spending your postage — but any
# non-browser client can claim any origin. The budget is what protects you.
POOL_DAILY_ALLOWANCES=
# Allowance for origins not listed above, including callers sending no Origin
# (CLIs, SDKs, server-to-server). -1 = unlimited (the behaviour before this
# existed), 0 = refuse. Left at -1 so deploying changes nothing until the
# allowances are deliberately configured.
POOL_DEFAULT_DAILY_ALLOWANCE=-1
POOL_ALLOWANCE_STATE_FILE=data/pool_allowance.json
6 changes: 6 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ jobs:
STAMP_POOL_RESERVE_SMALL=${{ vars.STAMP_POOL_RESERVE_SMALL || '1' }}
STAMP_POOL_RESERVE_MEDIUM=${{ vars.STAMP_POOL_RESERVE_MEDIUM || '1' }}
STAMP_POOL_RESERVE_LARGE=${{ vars.STAMP_POOL_RESERVE_LARGE || '0' }}
STAMP_POOL_MAX_PURCHASES_PER_HOUR=${{ vars.STAMP_POOL_MAX_PURCHASES_PER_HOUR || '10' }}
POOL_DAILY_ALLOWANCES=${{ vars.POOL_DAILY_ALLOWANCES }}
POOL_DEFAULT_DAILY_ALLOWANCE=${{ vars.POOL_DEFAULT_DAILY_ALLOWANCE || '-1' }}
STAMP_POOL_CHECK_INTERVAL_SECONDS=${{ vars.STAMP_POOL_CHECK_INTERVAL_SECONDS || '900' }}
STAMP_POOL_MIN_TTL_HOURS=${{ vars.STAMP_POOL_MIN_TTL_HOURS || '24' }}
STAMP_POOL_TOPUP_HOURS=${{ vars.STAMP_POOL_TOPUP_HOURS || '168' }}
Expand Down Expand Up @@ -115,6 +118,9 @@ jobs:
STAMP_POOL_RESERVE_SMALL=${{ vars.STAMP_POOL_RESERVE_SMALL || '1' }}
STAMP_POOL_RESERVE_MEDIUM=${{ vars.STAMP_POOL_RESERVE_MEDIUM || '1' }}
STAMP_POOL_RESERVE_LARGE=${{ vars.STAMP_POOL_RESERVE_LARGE || '0' }}
STAMP_POOL_MAX_PURCHASES_PER_HOUR=${{ vars.STAMP_POOL_MAX_PURCHASES_PER_HOUR || '10' }}
POOL_DAILY_ALLOWANCES=${{ vars.POOL_DAILY_ALLOWANCES }}
POOL_DEFAULT_DAILY_ALLOWANCE=${{ vars.POOL_DEFAULT_DAILY_ALLOWANCE || '-1' }}
STAMP_POOL_CHECK_INTERVAL_SECONDS=${{ vars.STAMP_POOL_CHECK_INTERVAL_SECONDS || '900' }}
STAMP_POOL_MIN_TTL_HOURS=${{ vars.STAMP_POOL_MIN_TTL_HOURS || '24' }}
STAMP_POOL_TOPUP_HOURS=${{ vars.STAMP_POOL_TOPUP_HOURS || '168' }}
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,10 @@ Every batch a caller can obtain is registered to them — pool acquire, direct p

#### Stamp Pool (Low-Latency Provisioning)
- `GET /api/v1/pool/status`: Get pool status and reserve levels
- `POST /api/v1/pool/acquire`: Acquire stamp from pool instantly (<5 seconds vs >1 minute)
- `POST /api/v1/pool/acquire`: Acquire stamp from pool instantly (<5 seconds vs >1 minute). Subject to a **daily allowance per origin and per size** (`POOL_DAILY_ALLOWANCES`, e.g. `https://app.example=50`; unlisted origins and callers sending no `Origin` use `POOL_DEFAULT_DAILY_ALLOWANCE`, `-1` = unlimited). Returns `429` with code `DAILY_STAMP_ALLOWANCE_EXHAUSTED` and a message written to be shown to a person, plus `resets_at` and the direct-purchase alternative. Consumed only once a batch is actually handed over, so an empty pool costs the caller nothing. **Origin is attribution, not authentication** — a browser cannot forge another site's, so this stops other websites spending the operator's postage, but any non-browser client can claim any origin; the budget is the control. Counters persist across restarts, or a crash loop would grant a fresh allowance each time.
- `GET /api/v1/pool/available`: List available stamps in pool
> **Purchase ceiling**: the pool refuses to buy more than `STAMP_POOL_MAX_PURCHASES_PER_HOUR` (default 10) batches in a rolling hour, across every depth and every code path — the scheduled check, the immediate replenishment after an acquire, and anything added later. Enforced inside `_purchase_stamp` rather than in the replenish loop so no caller can miss it, and counted when Bee accepts the batch rather than when it becomes usable, since that is when the money is spent. Refusals are reported in `GET /api/v1/pool/status` errors. This exists because a staging node once bought 82 batches against a target of 5 (#271) and the bulk of it was never explained — a ceiling does not need the cause.

- `POST /api/v1/pool/check`: Schedule manual pool maintenance. **Operator-only — it spends BZZ.** Requires an EIP-191 signature over `swarm-connect-pool-check:<unix_ts>` from an address in `POOL_ADMIN_ADDRESSES` (headers `X-Debug-Timestamp` + `X-Debug-Signature`), a deliberately separate allow-list from `DEBUG_ALLOWED_ADDRESSES` so a diagnostics signature cannot authorise spending. 404 when the list is empty (the default). Returns `202` and schedules the work — poll `GET /api/v1/pool/status` for the outcome, since a purchase takes ~16s and awaiting it held the caller's connection (#292).

#### Notary Signing (Provenance)
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ swarm_connect/

### Setup and Installation

> Running this on a server rather than locally? See the
> [Deployment Guide](docs/deployment-guide.md) for requirements, funding,
> firewalling and the failure modes worth knowing about.




```bash
# Create virtual environment
python3 -m venv venv
Expand Down
58 changes: 57 additions & 1 deletion app/api/endpoints/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from app.services.stamp_pool import stamp_pool_manager, PoolStampStatus
from app.services.stamp_ownership import stamp_ownership_manager
from app.services.metrics import pool_acquires_total
from app.services.pool_allowance import pool_allowance_tracker
from app.services.signed_auth import POOL_CHECK_PREFIX, authorize_signed_request
from app.api.models.stamp import SIZE_PRESETS

Expand Down Expand Up @@ -190,13 +191,64 @@ async def acquire_stamp(
detail="Stamp pool feature is not enabled on this gateway. Use POST /api/v1/stamps/ to purchase stamps directly."
)

# Determine requested depth
# Daily allowance for this origin. Checked before any batch is taken, and
# only consumed once one has actually been handed over — a failed acquire
# must not spend somebody's budget.
#
# Origin is attribution, not authentication: a browser cannot forge another
# site's, but anything that is not a browser can claim any origin it likes.
# The budget is what protects the pool; the origin only selects which budget.
origin = http_request.headers.get("origin")
# Resolve the size first: the budget is per size, because a depth-20 batch
# costs eight times a depth-17 one and a shared count would let a caller
# spend eight times its allowance by asking for a larger one.
if request.depth is not None:
requested_depth = request.depth
elif request.size is not None:
requested_depth = SIZE_PRESETS.get(request.size, 17)
else:
requested_depth = 17 # Default to small
requested_size = depth_to_size_name(requested_depth)

allowed_by_budget, budget = pool_allowance_tracker.check(origin, requested_size)
if not allowed_by_budget:
logger.info(
"Pool allowance exhausted for origin %s (%s/%s today)",
budget["origin"], budget["used"], budget["allowance"],
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={
"code": "DAILY_STAMP_ALLOWANCE_EXHAUSTED",
# Written to be shown to a person, not just logged. The caller is
# a browser app whose user has no idea what a postage batch is.
# Written to be shown to a person. The caller is a browser app
# whose user has never heard of a postage batch, so it says what
# they can do rather than only what failed.
#
# It deliberately does NOT offer to sell pool access: that is not
# payable yet. Adding the payment dependency to this endpoint
# returns 402 to any caller sending no payment header, which is
# every current caller. Paid pool access is tracked in #67 and is
# blocked on dataprovenance-app#126.
"message": (
f"The daily free allowance of {budget['allowance']} {requested_size} stamps for this "
f"application has been used up. It resets at {budget['resets_at']}. "
"To continue now, pay with x402: POST /api/v1/stamps/ with an "
"X-PAYMENT header buys a stamp outright. It takes about a minute "
"to become usable, unlike a pooled one."
),
"size": requested_size,
"allowance": budget["allowance"],
"used": budget["used"],
"resets_at": budget["resets_at"],
"alternative": {
"endpoint": "POST /api/v1/stamps/",
"payment": "x402",
"note": "Paid, and usable after about a minute.",
},
},
)

# Try to get exact match first
stamp = stamp_pool_manager.get_available_stamp(requested_depth)
Expand Down Expand Up @@ -254,6 +306,10 @@ async def acquire_stamp(
source="pool_acquire"
)

# Consumed only now: the batch has been released to the caller, so the
# allowance has genuinely been spent.
pool_allowance_tracker.consume(origin, requested_size)

# Trigger immediate replenishment if pool is below target
# This runs in the background and doesn't affect the response
replenishment_triggered = stamp_pool_manager.trigger_replenishment_if_needed(released.depth)
Expand Down
55 changes: 55 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,41 @@ class Settings(BaseSettings):

# Pool monitoring settings
STAMP_POOL_CHECK_INTERVAL_SECONDS: int = 900 # How often to check pool (15 minutes)
# Hard ceiling on how many batches the pool may buy in a rolling hour,
# across every depth and every code path.
#
# This is not a tuning knob, it is a blast radius. A staging node once bought
# 82 batches against a target of 5 and spent ~8.9 BZZ; one mechanism was found
# and fixed, and it accounts for five per restart, not seventy. The rest was
# never identified (#271). A ceiling makes the cause irrelevant: whatever the
# defect, it cannot spend past this.
#
# 10/hour is far above any legitimate need — a target of five, fully drained
# and rebought, is five — and far below what an unbounded loop costs.
STAMP_POOL_MAX_PURCHASES_PER_HOUR: int = 10

# Daily allowance of pooled batches per calling origin.
# Format: "https://app.example=50,https://dev.app.example=20"
#
# The pool pre-buys batches and pays to keep them alive, and /pool/acquire had
# no gate at all — 3,866 acquire calls in a day drove 40 replacement purchases
# on staging. The main consumer is a static browser app with no backend and no
# identity of its own, so there is no address to allow-list and no key it could
# sign with; an origin with a budget is the control that fits.
#
# Origin is attribution, not authentication. A browser will not let one site
# forge another's, so this does stop other WEBSITES spending your postage. Any
# non-browser client can claim any origin, so the BUDGET is what protects you —
# a forged origin consumes that origin's allowance and no more.
POOL_DAILY_ALLOWANCES: str = ""
# Allowance for origins not listed above, including callers that send no
# Origin at all: CLIs, SDKs, server-to-server.
#
# -1 means unlimited, which is the behaviour before this existed, and is the
# default so that deploying changes nothing until allowances are deliberately
# configured. A limit that arrives unannounced breaks callers.
POOL_DEFAULT_DAILY_ALLOWANCE: int = -1
POOL_ALLOWANCE_STATE_FILE: str = "data/pool_allowance.json"
STAMP_POOL_MIN_TTL_HOURS: int = 24 # Top up if TTL below this
STAMP_POOL_TOPUP_HOURS: int = 168 # How much TTL to add (1 week)
STAMP_POOL_LOW_RESERVE_THRESHOLD: int = 1 # Alert when reserve drops to this level
Expand Down Expand Up @@ -237,6 +272,26 @@ def get_debug_allowed_addresses(self) -> List[str]:
return []
return [a.strip().lower() for a in self.DEBUG_ALLOWED_ADDRESSES.split(",") if a.strip()]

def get_pool_daily_allowances(self) -> dict:
"""Parse POOL_DAILY_ALLOWANCES into {normalised_origin: limit}."""
out = {}
if not self.POOL_DAILY_ALLOWANCES:
return out
from urllib.parse import urlparse
for entry in self.POOL_DAILY_ALLOWANCES.split(","):
entry = entry.strip()
if not entry or "=" not in entry:
continue
origin, _, raw = entry.rpartition("=")
try:
limit = int(raw.strip())
except ValueError:
continue
p = urlparse(origin.strip())
if p.scheme and p.hostname:
out[f"{p.scheme.lower()}://{p.hostname.lower()}"] = limit
return out

def get_pool_admin_addresses(self) -> List[str]:
"""Parse the pool-admin allow-list into lowercased 0x addresses."""
if not self.POOL_ADMIN_ADDRESSES:
Expand Down
Loading
Loading