Skip to content
Open
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ POOL_ADMIN_ADDRESSES=
# X402_BZZ_USD_RATE=0.50 # Manual BZZ/USD rate (default: $0.50)
# X402_MARKUP_PERCENT=50.0 # Markup percentage on cost (default: 50%)
# X402_MIN_PRICE_USD=0.01 # Minimum charge per request in USD (default: $0.01)
# X402_MAX_STAMP_BZZ=5.0 # Max single stamp purchase in BZZ (default: 5.0)
# X402_MAX_STAMP_BZZ # Set below, under spending limits — it is enforced now
# X402_RATE_LIMIT_PER_IP=10 # Requests per minute for paying users (default: 10)
#
# Access control: IP-based allow/block lists
Expand Down Expand Up @@ -226,6 +226,17 @@ POOL_ALLOWANCE_STATE_FILE=data/pool_allowance.json
# takes.
X402_POOL_MARKUP_PERCENT=100

# Hard ceiling on what a single stamp purchase or extend may cost the gateway.
# Zero or negative disables it. Enforced on POST /stamps/ and
# PATCH /stamps/{id}/extend alike.
X402_MAX_STAMP_BZZ=5.0

# Daily BZZ a single caller (by client IP) may spend through those two
# endpoints. -1 disables the bound. Both endpoints spend the operator's money
# for whoever asks, and only the pool had a bound before this.
STAMP_DAILY_BZZ_PER_CALLER=0.5
STAMP_SPEND_BUDGET_STATE_FILE=data/stamp_spend_budget.json

# Whether a settled x402 payment may bypass the pool's daily allowance.
# On a testnet the currency is free from a faucet, so a payment there proves
# nothing and the bypass is withheld — the allowance still applies. Set this
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ jobs:
POOL_DEFAULT_DAILY_ALLOWANCE=${{ vars.POOL_DEFAULT_DAILY_ALLOWANCE || '-1' }}
X402_POOL_MARKUP_PERCENT=${{ vars.X402_POOL_MARKUP_PERCENT || '100' }}
X402_ALLOW_TESTNET_PAID_BYPASS=${{ vars.X402_ALLOW_TESTNET_PAID_BYPASS || 'false' }}
X402_MAX_STAMP_BZZ=${{ vars.X402_MAX_STAMP_BZZ || '5.0' }}
STAMP_DAILY_BZZ_PER_CALLER=${{ vars.STAMP_DAILY_BZZ_PER_CALLER || '0.5' }}
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 @@ -125,6 +127,8 @@ jobs:
POOL_DEFAULT_DAILY_ALLOWANCE=${{ vars.POOL_DEFAULT_DAILY_ALLOWANCE || '-1' }}
X402_POOL_MARKUP_PERCENT=${{ vars.X402_POOL_MARKUP_PERCENT || '100' }}
X402_ALLOW_TESTNET_PAID_BYPASS=${{ vars.X402_ALLOW_TESTNET_PAID_BYPASS || 'false' }}
X402_MAX_STAMP_BZZ=${{ vars.X402_MAX_STAMP_BZZ || '5.0' }}
STAMP_DAILY_BZZ_PER_CALLER=${{ vars.STAMP_DAILY_BZZ_PER_CALLER || '0.5' }}
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
23 changes: 21 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,11 @@ CORS (browser access):
- `GET /` (and `/health`): Health check. Always includes a `bee_node` section (from Bee `/topology` + `/status` + `/health` + `/addresses` + `/chainstate`, fetched concurrently, 15s cached): identity/build `overlay`, `version`, `api_version`, `bee_status`; connectivity `mode`, `connected_peers`, `population`, `depth`, `reachability`, `network_availability` (Available/Unavailable/Unknown — Bee sets this from outbound-dial results; Unavailable = OS network/host-unreachable on dials); reserve/radius `storage_radius`, `committed_depth`, `reserve_size`, `reserve_size_within_radius`, `pullsync_rate`, `batch_commitment`; chain sync `last_synced_block`, `chain_tip`, `chain_sync_lag_blocks`; plus `warming_up`, `healthy`, `warnings`. Any endpoint that fails yields `null` for its fields rather than losing the whole section. Overall `status` → `degraded` when `network_availability` is `Unavailable` (node can't reach the storer network → uploads may 201 without propagating) — advisory warnings (low peer count `< LOW_PEER_WARN_THRESHOLD`, chain lag `> CHAIN_LAG_WARN_BLOCKS`, non-ok Bee status) never flip `healthy` or `status`. x402 wallet section added when `X402_ENABLED`.

#### Stamp Management
- `POST /api/v1/stamps/`: Purchase new postage stamps (records purchase time for propagation tracking)
- `POST /api/v1/stamps/`: Purchase new postage stamps (records purchase time for propagation tracking). **Bounded** by `X402_MAX_STAMP_BZZ` per request and `STAMP_DAILY_BZZ_PER_CALLER` per caller per day — see below.
- `GET /api/v1/stamps/`: List stamps (default: local only). Supports `?global=true` for all stamps, `?wallet=0x...` for wallet-filtered view (x402)
- `GET /api/v1/stamps/{stamp_id}`: Retrieve specific stamp batch details including propagation timing
- `GET /api/v1/stamps/{stamp_id}/check`: Check stamp health for uploads (errors, warnings, can_upload status, propagation status)
- `PATCH /api/v1/stamps/{stamp_id}/extend`: Extend existing stamps with additional funds
- `PATCH /api/v1/stamps/{stamp_id}/extend`: Extend existing stamps with additional funds. Subject to the same two bounds. Note this route is **not** payment-gated: `is_protected_endpoint` matches on method and `PROTECTED_ENDPOINTS` lists only POST paths, so a PATCH never sees x402 or the free-tier rate limit. It also tops up any batch on the node, including ones the caller does not own.
- `POST /api/v1/stamps/for-owner` (Flow B #228/#230): create a postage batch owned by an arbitrary address via `GnosisChainClient.create_batch` (PostageStamp.createBatch on Gnosis), so the owner can sign its own stamps off-node. Body: `owner` (0x, never assumed = payer), `size`/`depth`, `duration_hours`, `immutable`. Returns `batchID` (64-hex, no 0x) + `txHash` + propagation info; records the batch in the ownership registry (`source="created_for_owner"`, informational — on-chain ownership is source of truth). **Spends the gateway's Gnosis funds**, so: OFF by default (`STAMP_PURCHASE_FOR_OTHERS_ENABLED`, router 404s when off); owner **allow-list** (`STAMP_FOR_OTHERS_REQUIRE_WHITELIST` + `_OWNER_WHITELIST`); hard caps `STAMP_FOR_OTHERS_MAX_DEPTH` / `_MAX_BZZ` / `_MAX_DURATION_HOURS` — ALL enforced before any on-chain spend. Plus a signer-wallet **preflight** (#231): refuses `503 SIGNER_INSUFFICIENT_FUNDS` if the gateway can't fund the batch (gas/xBZZ), checked after the caps and before createBatch. **x402 (#229):** mounted WITH the x402 dependency, so when `X402_ENABLED` the caller pays via the `/stamps/` protected prefix (priced from the actual depth/duration by reading the body in `_calculate_price_for_request`); free-tier creation is OFF by default (`STAMP_FOR_OTHERS_FREE_TIER_ENABLED`, else `402 FREE_TIER_DISABLED`). Payer (x402) ≠ owner (`body.owner`). Emits `gateway_for_owner_batches_total{status}` + `_bzz_spent_total` and audits each creation. See `docs/buy-batch-for-owner-guide.md`.

**Stamp list query parameters**:
Expand All @@ -160,6 +160,22 @@ CORS (browser access):
- `estimatedReadyAt`: ISO 8601 timestamp when stamp should be usable (null for external stamps)
- `propagationStatus`: `"ready"` / `"propagating"` / `"unknown"` (null if undetermined)

**Spending limits on the stamp endpoints** (`app/services/spend_budget.py`, #102):
Both `POST /stamps/` and `PATCH /stamps/{id}/extend` spend the gateway's BZZ for whoever asks, and neither had a bound — the pool got a daily allowance and these did not, which made them the cheaper way to spend the operator's money. Measured on staging, an anonymous free-tier request reached the point of the gateway costing a **243,074 BZZ** batch and was refused only because the wallet could not cover it: the balance was the limit.

Two bounds now apply, answering different questions:

- `X402_MAX_STAMP_BZZ` (default 5.0, zero disables) caps a **single request**, so no one call takes a large share of the wallet however it is shaped. This setting existed from the start and was referenced nowhere — a cap that appears in configuration and enforces nothing, which is worse than an absent one because it reads as protection during review.
- `STAMP_DAILY_BZZ_PER_CALLER` (default 0.5, `-1` disables) caps a **caller over a day**, so the first bound cannot simply be applied repeatedly.

The budget counts **BZZ, not batches**, unlike the pool allowance. The pool hands out fixed inventory so counting batches per size bounds the spend; these endpoints take a depth and a duration and cost `amount × 2^depth`, so a count would let a caller stay inside its allowance and still spend arbitrarily by asking for bigger batches.

The key is the **client IP**, not `Origin`. The callers here are CLIs, SDKs and the MCP plugin, which send no `Origin` at all and would collapse into one shared bucket. An IP is not an identity — shared behind NAT, cheap to change — and this is the same bargain `bandwidth_free_tier.py` already makes. It bounds casual and accidental spending, which is what actually happened twice, without pretending to prevent deliberate spending.

Both limits are enforced **before** the wallet balance check, so the refusal does not depend on how much money happens to be left. Charged only after the money is actually spent, so a purchase Bee refuses costs the caller nothing. A settled x402 payment bypasses the daily budget but **not** the per-request ceiling — the gateway fronts the BZZ either way — and the bypass is withheld on a test network for the same reason as the pool's.

`PLUR_PER_BZZ` and `plur_to_bzz` now live once, in `app/services/swarm_api.py`, and everything else imports them. There were five copies of the constant and two of the function — `app/x402/pricing.py`, `app/x402/preflight.py`, `app/services/gnosis_chain.py`, `app/api/endpoints/stamps_for_owner.py` and `swarm_api` itself. They all agreed, but nothing made them agree, and one drifting would have produced wrong money arithmetic in one place and not the others. The x402 modules re-export both, so existing imports and tests are unaffected.

**Stamp ownership enforcement** (`app/services/stamp_ownership.py`, when `X402_ENABLED`):
Every batch a caller can obtain is registered to them — pool acquire, direct purchase, and for-owner all call `register_stamp`. Batches the pool buys for its own inventory are registered as `POOL_OWNER` (`"pool"`) at purchase and on sync, and `check_access` **refuses** them: a caller receives one by acquiring it, which re-registers it to them. A batch absent from the registry is also refused; `STAMP_OWNERSHIP_ALLOW_UNTRACKED=true` restores the old permissive default and exists solely to recover from a lost registry file. Before #312 the pool's inventory was untracked and the untracked default was *allow*, so anyone could store data on batches the gateway had paid for — one production batch reached 50% utilisation without ever being acquired.

Expand Down Expand Up @@ -380,6 +396,8 @@ The gateway exposes a `/metrics` endpoint (Prometheus text format) when `METRICS
- `gateway_downloads_total{status}`
- `gateway_stamp_purchases_total{size, status}`
- `gateway_pool_acquires_total{size, status}`
- `gateway_stamp_spend_refusals_total{operation, limit}` — purchases and extends refused by a spending limit (`limit` = `per_request` or `daily_budget`)
- `gateway_stamp_spend_bzz_total{operation, charged}` — BZZ committed through the stamp endpoints (`charged` = `budget` or `paid`)
- `gateway_notary_signatures_total{status}`
- `gateway_x402_payments_total{mode}` (paid/free/rejected)
- `gateway_rate_limit_hits_total`
Expand All @@ -392,6 +410,7 @@ The gateway exposes a `/metrics` endpoint (Prometheus text format) when `METRICS
- `gateway_stamp_pool_available{size}`, `gateway_stamps_total`
- `gateway_stamp_min_ttl_seconds`, `gateway_uptime_seconds`
- `gateway_bandwidth_credit_accounts`, `gateway_bandwidth_credit_bytes_total` (when `CHUNK_UPLOAD_ENABLED`)
- `gateway_stamp_spend_callers`, `gateway_stamp_spend_bzz_today` — callers holding a spend balance today, and the BZZ charged to budgets so far. Polled rather than accumulated, because the day rolls over inside the tracker and a counter would keep climbing past midnight UTC.

**Info**: `gateway_info{version, environment, x402_enabled, pool_enabled, notary_enabled, chunk_upload_enabled}`

Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ Swarm Connect is a FastAPI-based API gateway that provides comprehensive access
#### 🛡️ Security & Rate Limiting
- **Upload Size Limits**: Configurable maximum upload size (default: 10 MB) with clear 413 errors
- **Global Rate Limiting**: Per-IP sliding window rate limiter with burst capacity (default: 60 req/min + 10 burst)
- **Spending Limits**: Stamp purchases and extensions are bounded per request and per caller per day, so no caller can drain the gateway's BZZ
- **Input Validation**: Strict regex validation on stamp IDs (64-char hex) and references (64-128 char hex)
- **Error Sanitization**: Internal details (IPs, ports, file paths) are never exposed in error responses
- **Server Header Suppression**: `Server` header removed to prevent version fingerprinting
Expand Down Expand Up @@ -840,7 +841,7 @@ Perform a comprehensive health check on a stamp to determine if it can be used f

### Upload Size Limits

File uploads are limited to **10 MB** by default. This applies to both `/api/v1/data/` and `/api/v1/data/manifest` endpoints.
File uploads are limited to **10 MB** by default. This applies to both `/api/v1/data/` and `/api/v1/data/manifest` endpoints, and a file of exactly the limit is accepted — the multipart envelope that wraps it does not count against the limit.

```bash
# Configure in .env (value in megabytes)
Expand All @@ -852,6 +853,35 @@ Uploads exceeding the limit receive a **413** response:
{"code": "FILE_TOO_LARGE", "message": "Upload exceeds maximum size of 10 MB.", "max_size_mb": 10}
```

### Spending Limits

`POST /api/v1/stamps/` and `PATCH /api/v1/stamps/{id}/extend` spend the gateway operator's BZZ on behalf of the caller. Two limits bound that, answering different questions:

```bash
# Configure in .env
X402_MAX_STAMP_BZZ=5.0 # Most a single request may cost (0 disables)
STAMP_DAILY_BZZ_PER_CALLER=0.5 # Most one caller may spend per day (-1 disables)
```

The first stops any one request taking a large share of the wallet however it is shaped — batch cost scales with `amount x 2^depth`, so the accepted depth and duration ranges span orders of magnitude. The second stops the first simply being applied repeatedly.

A request over the per-request ceiling receives **400**:
```json
{"code": "STAMP_COST_EXCEEDS_LIMIT", "message": "...", "cost_bzz": 12.5, "limit_bzz": 5.0}
```

A caller who has spent their daily budget receives **429**, with the reset time and what remains:
```json
{"code": "DAILY_SPEND_BUDGET_EXHAUSTED", "message": "...", "remaining_bzz": 0.02,
"daily_budget_bzz": 0.5, "resets_at": "2026-09-09T24:00:00Z"}
```

Both are checked before the wallet balance, so the answer does not depend on how much money happens to be left, and charged only after the money is actually spent — a purchase the Swarm node refuses costs the caller nothing.

The budget counts **BZZ rather than batches**, because these endpoints take a depth and a duration: a count would let a caller stay inside their allowance and still spend arbitrarily by asking for larger batches. It is keyed on the **client IP**, since the callers here are CLIs, SDKs and MCP clients that send no `Origin`. An IP is not an identity — it is shared behind NAT and cheap to change — so this bounds casual and accidental spending rather than preventing deliberate spending. A caller who needs more can pay: a settled x402 payment bypasses the daily budget, though not the per-request ceiling.

Counters `gateway_stamp_spend_refusals_total` and `gateway_stamp_spend_bzz_total` make refusals and committed spend visible in Prometheus, so a limit set too low shows up on a dashboard rather than in a complaint.

### Rate Limiting

Global per-IP rate limiting protects against abuse. Uses a sliding window algorithm with burst capacity.
Expand Down
43 changes: 39 additions & 4 deletions app/api/endpoints/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
)

logger = logging.getLogger(__name__)

# Slack allowed on Content-Length to cover the multipart envelope that wraps an
# uploaded file. 8 KB is far more than a boundary plus part headers need, and
# far less than any size that would matter for the limit itself.
MULTIPART_ENVELOPE_ALLOWANCE = 8 * 1024
router = APIRouter()


Expand Down Expand Up @@ -274,10 +279,25 @@ async def upload_data(
raise HTTPException(status_code=400, detail=detail)
stamp_validate_ms = (time.perf_counter() - stamp_start) * 1000

# Check upload size limit
# Check upload size limit.
#
# Content-Length covers the whole multipart envelope — boundary, part
# headers, trailer — not just the file. Comparing it against the file
# limit put the real ceiling a few hundred bytes below the documented
# one, so a file of exactly MAX_UPLOAD_SIZE_MB was always rejected with
# 413 while the check below, which measures the file itself, would have
# accepted it. The two checks were applying one limit to two different
# quantities.
#
# The allowance is generous relative to a real envelope (a boundary and
# one set of part headers is a few hundred bytes) because this check is
# only a coarse guard: it cannot reject early, since FastAPI parses the
# multipart form during dependency resolution and the body is already
# in memory by the time this line runs. The exact limit is enforced on
# the file's own length below.
max_size = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
content_length = request.headers.get("content-length")
if content_length and int(content_length) > max_size:
if content_length and int(content_length) > max_size + MULTIPART_ENVELOPE_ALLOWANCE:
raise HTTPException(
status_code=413,
detail={
Expand Down Expand Up @@ -704,10 +724,25 @@ async def upload_manifest(
raise HTTPException(status_code=400, detail=detail)
stamp_validate_ms = (time.perf_counter() - stamp_start) * 1000

# Check upload size limit
# Check upload size limit.
#
# Content-Length covers the whole multipart envelope — boundary, part
# headers, trailer — not just the file. Comparing it against the file
# limit put the real ceiling a few hundred bytes below the documented
# one, so a file of exactly MAX_UPLOAD_SIZE_MB was always rejected with
# 413 while the check below, which measures the file itself, would have
# accepted it. The two checks were applying one limit to two different
# quantities.
#
# The allowance is generous relative to a real envelope (a boundary and
# one set of part headers is a few hundred bytes) because this check is
# only a coarse guard: it cannot reject early, since FastAPI parses the
# multipart form during dependency resolution and the body is already
# in memory by the time this line runs. The exact limit is enforced on
# the file's own length below.
max_size = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
content_length = request.headers.get("content-length")
if content_length and int(content_length) > max_size:
if content_length and int(content_length) > max_size + MULTIPART_ENVELOPE_ALLOWANCE:
raise HTTPException(
status_code=413,
detail={
Expand Down
Loading
Loading