From 1a5b9d7c64e712c15d2757138a4b728b9e3b966c Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Tue, 8 Sep 2026 14:11:54 +0200 Subject: [PATCH 1/7] Stop the multipart envelope counting against the upload limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file of exactly MAX_UPLOAD_SIZE_MB was always rejected with 413. The documented ceiling was unreachable. Two checks were applying one limit to two different quantities. The first compares the request's Content-Length, which covers the whole multipart envelope — boundary, part headers, trailer — against max_size. The second compares the file's own length against the same max_size. So a 10,485,760-byte file arrived as roughly 10,485,960 bytes on the wire and tripped the first check, while the second would have accepted it. Content-Length now gets an 8 KB allowance for the envelope. The file's own length is still measured exactly, so the limit itself does not move. Both the data and manifest endpoints had the same code and both are fixed. Worth noting the Content-Length check cannot reject early despite looking like it should: FastAPI parses the multipart form during dependency resolution, so the body is already in memory by the time the handler runs. Verified against a local instance with a client paced at 1 MB/s — the server sends nothing until the full body has arrived, at 11 MB and at 50 MB. It is a coarse guard, not a fast path, and the comment now says so. The existing test named test_upload_at_exact_limit_succeeds sent 1 MB against a 2 MB limit, with a comment explaining that multipart overhead meant the file had to be 'well under the limit'. That comment described the bug as if it were intended. It now tests the actual boundary, joined by a one-byte-over case and a long-filename case — the envelope grows with the filename, so the ceiling must not depend on what the caller names their file. test_content_length_header_rejection declared one byte over the limit, which now falls inside the allowance and asserted nothing; it declares double the limit and asserts the 413 rather than accepting 200 or 413. 1122 passed, 25 skipped. --- app/api/endpoints/data.py | 43 ++++++++++++++++++++-- tests/test_upload_size_limit.py | 65 ++++++++++++++++++++++++++++----- 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/app/api/endpoints/data.py b/app/api/endpoints/data.py index 38c3ad6..41ac2d3 100644 --- a/app/api/endpoints/data.py +++ b/app/api/endpoints/data.py @@ -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() @@ -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={ @@ -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={ diff --git a/tests/test_upload_size_limit.py b/tests/test_upload_size_limit.py index 3959fbe..2867864 100644 --- a/tests/test_upload_size_limit.py +++ b/tests/test_upload_size_limit.py @@ -48,31 +48,76 @@ def test_upload_exceeding_limit_returns_413(self, mock_settings, mock_upload): @patch('app.api.endpoints.data.upload_data_to_swarm', return_value="ref123") @patch('app.api.endpoints.data.settings') def test_upload_at_exact_limit_succeeds(self, mock_settings, mock_upload): - """File just under the size limit should be accepted.""" + """A file of exactly MAX_UPLOAD_SIZE_MB is accepted. + + This test used to send 1 MB against a 2 MB limit and call that "exact", + with a comment explaining that multipart overhead meant the file had to + be "well under the limit". That comment described the bug: Content-Length + includes the multipart envelope, and it was being compared against the + limit that applies to the file, so the advertised ceiling was unreachable + by a few hundred bytes. Testing a value nowhere near the boundary is what + let it survive. + """ mock_settings.MAX_UPLOAD_SIZE_MB = 2 - # 1 MB file — under 2 MB limit (multipart encoding adds overhead - # to Content-Length, so file must be well under the limit) - data = b"x" * (1 * 1024 * 1024) + data = b"x" * (2 * 1024 * 1024) response = client.post( f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", files={"file": ("exact.bin", io.BytesIO(data), "application/octet-stream")} ) - assert response.status_code == 200 + assert response.status_code == 200, response.text + + @patch('app.api.endpoints.data.upload_data_to_swarm', return_value="ref123") + @patch('app.api.endpoints.data.settings') + def test_one_byte_over_the_limit_is_still_rejected(self, mock_settings, mock_upload): + """The envelope allowance must not become slack in the limit itself. + + Content-Length gets an 8 KB allowance so the envelope does not count + against the file, but the file's own length is still measured exactly. + """ + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024 + 1) + response = client.post( + f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", + files={"file": ("over.bin", io.BytesIO(data), "application/octet-stream")} + ) + assert response.status_code == 413 + assert response.json()["detail"]["code"] == "FILE_TOO_LARGE" + + @patch('app.api.endpoints.data.upload_data_to_swarm', return_value="ref123") + @patch('app.api.endpoints.data.settings') + def test_a_long_filename_does_not_eat_into_the_limit(self, mock_settings, mock_upload): + """The envelope varies with the filename, so the ceiling must not. + + A caller uploading a file at the limit should not be rejected because + the name they chose is long. + """ + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024) + response = client.post( + f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", + files={"file": ("a" * 200 + ".bin", io.BytesIO(data), "application/octet-stream")} + ) + assert response.status_code == 200, response.text @patch('app.api.endpoints.data.settings') def test_content_length_header_rejection(self, mock_settings): - """Content-Length header exceeding limit should cause fast reject.""" + """A declared Content-Length well over the limit is rejected. + + The declared length must exceed the limit by more than the envelope + allowance, or this asserts nothing: a value one byte over now falls + inside the slack that exists so the multipart wrapper does not count + against the file. + """ mock_settings.MAX_UPLOAD_SIZE_MB = 1 max_bytes = 1 * 1024 * 1024 - # Send small actual data but large Content-Length header data = b"x" * 100 response = client.post( f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", files={"file": ("test.bin", io.BytesIO(data), "application/octet-stream")}, - headers={"content-length": str(max_bytes + 1)} + headers={"content-length": str(max_bytes * 2)} ) - # TestClient may override content-length, so accept either 413 or 200 - assert response.status_code in [200, 413] + assert response.status_code == 413 + assert response.json()["detail"]["code"] == "FILE_TOO_LARGE" class TestManifestUploadSizeLimit: From 16e3807d1573dba373ac6ef9b8505ef279bbeed3 Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Wed, 9 Sep 2026 11:53:54 +0200 Subject: [PATCH 2/7] Bound what a caller can spend of the gateway's BZZ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /stamps/ and PATCH /stamps/{id}/extend both spend the operator's money for whoever asks, and neither had a spending bound. The pool got a daily allowance and these did not, which made them the cheaper way to spend it. 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, so a single request sized to what the wallet CAN afford takes all of it. Extend is the softer of the two. 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 at all. Verified against staging: an unauthenticated extend returns 404 from the handler, not 402. It also tops up any batch on the node, including ones the caller does not own. Two bounds, answering different questions: - X402_MAX_STAMP_BZZ caps a single request, so no one call takes a large share of the wallet however it is shaped. This setting was in config from the start and referenced nowhere — grep returned the definition and nothing else. A cap that appears in configuration and enforces nothing is worse than an absent one, because it reads as protection during review. - STAMP_DAILY_BZZ_PER_CALLER caps a caller over a day, so the first bound cannot simply be applied repeatedly. The budget counts BZZ rather than batches, unlike the pool's. The pool hands out fixed inventory, so counting per size bounds the spend; these endpoints 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 rather than Origin, because these callers are CLIs, SDKs and the MCP plugin, which send no Origin and would collapse into one bucket. An IP is not an identity; this is the same bargain bandwidth_free_tier already makes, and it bounds the casual and accidental spending that actually happened rather than pretending to stop deliberate spending. Both checks run before the wallet balance check, so the refusal does not depend on how much is left. Consumed only after the money is spent, so a purchase Bee refuses costs nothing. A settled payment bypasses the daily budget but not the per-request ceiling, since the gateway fronts the BZZ either way, and the bypass is withheld on a testnet for the same reason as the pool's. Cost is derived from total_cost via a new swarm_api.plur_to_bzz rather than read out of check_sufficient_funds' response, so a partial mock of that function omitting a key cannot turn a spending limit into a 500. conftest neutralises both limits for the rest of the suite: several suites purchase at the top of the valid depth and amount ranges to test that validation accepts them, and at production defaults those requests legitimately exceed the cap, which would leave them asserting the cap rather than the validation they were written for. check() and consume() still run everywhere, so the plumbing stays covered. 19 new tests. 1139 passed, 25 skipped. --- .env.example | 13 +- .github/workflows/deploy.yml | 4 + CLAUDE.md | 18 +- app/api/endpoints/stamps.py | 101 +++++++++++ app/core/config.py | 16 ++ app/services/spend_budget.py | 159 ++++++++++++++++++ app/services/swarm_api.py | 17 +- tests/conftest.py | 37 ++++ tests/test_spend_budget.py | 318 +++++++++++++++++++++++++++++++++++ 9 files changed, 677 insertions(+), 6 deletions(-) create mode 100644 app/services/spend_budget.py create mode 100644 tests/test_spend_budget.py diff --git a/.env.example b/.env.example index 272d85c..7cf4508 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 793c4f1..a48ce6d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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' }} @@ -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' }} diff --git a/CLAUDE.md b/CLAUDE.md index cc27ca4..3dec2c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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**: @@ -160,6 +160,20 @@ 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. + **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. diff --git a/app/api/endpoints/stamps.py b/app/api/endpoints/stamps.py index c907d8f..4bad22e 100644 --- a/app/api/endpoints/stamps.py +++ b/app/api/endpoints/stamps.py @@ -7,8 +7,11 @@ from app.core.config import settings from app.services import swarm_api +from app.services.swarm_api import plur_to_bzz from app.services.stamp_ownership import stamp_ownership_manager from app.services.stamp_tracker import record_purchase +from app.services.spend_budget import spend_budget_tracker +from app.x402.middleware import get_client_ip from app.services.metrics import stamp_purchases_total from app.api.models.stamp import ( StampDetails, @@ -26,6 +29,80 @@ logger = logging.getLogger(__name__) +def _enforce_spend_limits(request: Optional[Request], cost_bzz: float, operation: str) -> Optional[str]: + """Bound what one request, and one caller in a day, may spend. + + Both stamp endpoints spend the gateway's BZZ for whoever asks. Two limits + apply, and they answer different questions: + + - `X402_MAX_STAMP_BZZ` bounds a SINGLE request, so no one call can take a + large share of the wallet however it is shaped. + - `STAMP_DAILY_BZZ_PER_CALLER` bounds a caller over a day, so the first + limit cannot simply be applied repeatedly. + + Returns the caller key to charge once the money is actually spent, or None + when the spend is not charged to anyone (a settled payment). Raises rather + than returning a failure, because every caller of this must stop. + """ + max_single = settings.X402_MAX_STAMP_BZZ + if max_single > 0 and cost_bzz > max_single: + logger.warning( + "Refusing %s costing %.6f BZZ, above the per-request limit of %.6f", + operation, cost_bzz, max_single, + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "STAMP_COST_EXCEEDS_LIMIT", + "message": ( + f"This {operation} would cost {cost_bzz:.6f} BZZ, above the " + f"per-request limit of {max_single:.6f} BZZ. Ask for a smaller " + f"depth or a shorter duration." + ), + "cost_bzz": round(cost_bzz, 6), + "limit_bzz": max_single, + }, + ) + + # A settled payment is not drawn from the giveaway budget — the caller has + # funded it. Withheld on a test network for the same reason as the pool: + # testnet currency is free from a faucet, so honouring it there would + # replace a bounded giveaway with an unbounded one. + if request is not None and getattr(request.state, "x402_mode", None) == "paid": + if settings.paid_bypass_is_honoured(): + return None + logger.warning( + "Payment for %s settled on %s, which is a test network: the daily " + "spend budget still applies.", operation, settings.X402_NETWORK, + ) + + caller = get_client_ip(request) if request is not None else "unknown" + allowed, info = spend_budget_tracker.check(caller, cost_bzz) + if not allowed: + logger.info( + "Daily spend budget exhausted for %s: %.6f of %.6f BZZ used, request needs %.6f", + caller, info["spent_bzz"], info["daily_budget_bzz"], cost_bzz, + ) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={ + "code": "DAILY_SPEND_BUDGET_EXHAUSTED", + "message": ( + f"This {operation} would cost {cost_bzz:.6f} BZZ and only " + f"{info['remaining_bzz']:.6f} BZZ remains of today's " + f"{info['daily_budget_bzz']:.6f} BZZ allowance. It resets at " + f"{info['resets_at']}. A smaller or shorter batch may still fit." + ), + "cost_bzz": info["request_cost_bzz"], + "daily_budget_bzz": info["daily_budget_bzz"], + "spent_bzz": info["spent_bzz"], + "remaining_bzz": info["remaining_bzz"], + "resets_at": info["resets_at"], + }, + ) + return caller + + def _bee_error_detail(exc: httpx.HTTPError): """Extract (status_code, message) from a failed Bee request. @@ -401,6 +478,13 @@ async def purchase_stamp( total_cost = swarm_api.calculate_stamp_total_cost(amount, effective_depth) funds_check = await swarm_api.check_sufficient_funds(total_cost) + # Bound the spend BEFORE the funds check, so the answer does not depend + # on how much money happens to be left. Refusing a 243,074 BZZ request + # for "insufficient funds" told the caller the wallet was the only limit, + # which was true and is the defect this closes. + cost_bzz = plur_to_bzz(total_cost) + charge_to = _enforce_spend_limits(request, cost_bzz, "stamp purchase") + if not funds_check["sufficient"]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -418,6 +502,11 @@ async def purchase_stamp( label=stamp_request.label ) + # Charged only now: a purchase that failed downstream must not cost the + # caller their budget. + if charge_to is not None: + spend_budget_tracker.consume(charge_to, cost_bzz) + # Record purchase time for propagation tracking record_purchase(batch_id) @@ -490,6 +579,7 @@ async def purchase_stamp( summary="Extend an Existing Swarm Postage Stamp" ) async def extend_stamp( + request: Request, stamp_id: str = Path(..., description="The Batch ID of the stamp to extend.", example="a1b2c3d4e5f6...", pattern=r"^[a-fA-F0-9]{64}$"), extension_request: StampExtensionRequest = ... ) -> Any: @@ -550,6 +640,14 @@ async def extend_stamp( total_cost = swarm_api.calculate_stamp_total_cost(amount, stamp_depth) funds_check = await swarm_api.check_sufficient_funds(total_cost) + # Extend is NOT in PROTECTED_ENDPOINTS — is_protected_endpoint matches on + # method, and this route is PATCH while only POST paths are listed — so + # there is no payment gate and no free-tier rate limit in front of it. + # It also tops up any batch on the node, including ones the caller does + # not own. The budget is therefore the only thing bounding it. + cost_bzz = plur_to_bzz(total_cost) + charge_to = _enforce_spend_limits(request, cost_bzz, "stamp extension") + if not funds_check["sufficient"]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -566,6 +664,9 @@ async def extend_stamp( amount=amount ) + if charge_to is not None: + spend_budget_tracker.consume(charge_to, cost_bzz) + return StampExtensionResponse( batchID=batch_id, message="Postage stamp extended successfully" diff --git a/app/core/config.py b/app/core/config.py index f213778..309befb 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -51,7 +51,23 @@ class Settings(BaseSettings): X402_CHEQUEBOOK_WARN_THRESHOLD: float = 5.0 # Warn if chequebook < threshold # === x402 Limits === + # Hard ceiling on what a single purchase or extend may cost the gateway. + # + # 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. It is enforced as + # of #102, on POST /stamps/ and PATCH /stamps/{id}/extend alike. + # Zero or negative disables it. X402_MAX_STAMP_BZZ: float = 5.0 # Max single stamp purchase in BZZ + + # Daily BZZ a single caller may spend through the stamp endpoints, keyed on + # client IP. -1 disables the bound. See app/services/spend_budget.py for why + # this counts money rather than batches, and why the key is the IP. + # + # 0.5 BZZ is roughly 25 small 24-hour batches a day, which is far more than + # any observed legitimate caller and far less than the wallet. + STAMP_DAILY_BZZ_PER_CALLER: float = 0.5 + STAMP_SPEND_BUDGET_STATE_FILE: str = "data/stamp_spend_budget.json" X402_RATE_LIMIT_PER_IP: int = 10 # Requests per minute per IP (for paying users) # === x402 Free Tier Settings === diff --git a/app/services/spend_budget.py b/app/services/spend_budget.py new file mode 100644 index 0000000..336715b --- /dev/null +++ b/app/services/spend_budget.py @@ -0,0 +1,159 @@ +# app/services/spend_budget.py +"""Daily cap on how much of the gateway's BZZ one caller can spend. + +Two endpoints spend the operator's money on behalf of whoever asks: + +- `POST /api/v1/stamps/` buys a postage batch. +- `PATCH /api/v1/stamps/{id}/extend` tops one up. + +Neither had a spending bound. The pool got a daily allowance (#320, #326) and +these did not, which made them the cheaper way to spend the operator's money. +`X402_MAX_STAMP_BZZ` existed in configuration and was referenced nowhere, so the +only thing standing between a caller and the whole wallet was the wallet running +out. Measured on staging, an anonymous free-tier request reached the point of +the gateway costing a 243,074 BZZ batch and was refused for insufficient funds +rather than by any policy. + +## Why a BZZ budget rather than a count of batches + +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 x 2^depth`, +which is continuous and spans orders of magnitude — a count would let a caller +stay inside its allowance and still spend arbitrarily by asking for bigger +batches. Counting the money directly is the only bound that means the same thing +whatever shape the request takes. + +## Why the client IP + +The pool keys on `Origin` because its consumer is a browser app. The callers +here are CLIs, SDKs and the MCP plugin, which send no `Origin` at all, so it +would collapse every one of them into a single shared bucket. The IP is what +distinguishes them. + +An IP is not an identity: it is shared behind NAT and cheap to change with a +proxy. This is the same bargain the free tier already makes elsewhere in the +gateway (`bandwidth_free_tier.py` bounds chunk uploads the same way). It bounds +casual and accidental spending — which is what actually happened, twice — and +raises the cost of deliberate spending without pretending to prevent it. A +caller who wants a real allowance pays, and paying bypasses this entirely. +""" +import json +import logging +import os +from datetime import datetime, timezone +from threading import Lock +from typing import Dict, Optional, Tuple + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +# Sentinel for "no limit", matching pool_allowance so the two read alike. +UNLIMITED = -1.0 + + +def _today() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +class SpendBudgetTracker: + """Tracks BZZ spent per caller per UTC day.""" + + def __init__(self, state_file: Optional[str] = None): + self._lock = Lock() + self._state_file = state_file + self._day = _today() + self._spent: Dict[str, float] = {} + self._load() + + # --- persistence ------------------------------------------------------- + # + # Persisted for the same reason as the pool allowance: without it a + # crash-looping gateway grants a fresh budget on every restart, which is + # precisely the shape of the incident this exists to prevent. + + def _path(self) -> str: + return self._state_file or settings.STAMP_SPEND_BUDGET_STATE_FILE + + def _load(self) -> None: + try: + path = self._path() + if not os.path.exists(path): + return + with open(path) as f: + data = json.load(f) + if data.get("day") == self._day: + self._spent = {k: float(v) for k, v in (data.get("spent") or {}).items()} + logger.info("Loaded spend budget state for %s: %s", self._day, self._spent) + except Exception as e: + # Never fail startup over a counter. + logger.warning("Could not load spend budget state: %s", e) + + def _save(self) -> None: + try: + path = self._path() + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + tmp = f"{path}.tmp" + with open(tmp, "w") as f: + json.dump({"day": self._day, "spent": self._spent}, f) + os.replace(tmp, path) + except Exception as e: + logger.warning("Could not persist spend budget state: %s", e) + + # --- budget ------------------------------------------------------------ + + def _roll_day(self) -> None: + today = _today() + if today != self._day: + logger.info("Spend budget day rolled %s -> %s, resetting", self._day, today) + self._day = today + self._spent = {} + self._save() + + def budget(self) -> float: + return settings.STAMP_DAILY_BZZ_PER_CALLER + + def check(self, caller: str, cost_bzz: float) -> Tuple[bool, dict]: + """Whether this caller may spend `cost_bzz`, and the numbers behind it. + + Does not consume — call `consume` once the money has actually been + spent, so a purchase that fails downstream does not cost the caller + their budget. + + The check is against the cost of THIS request, not merely whether any + budget remains: a caller with 0.01 BZZ left must not be allowed to start + a 5 BZZ purchase. + """ + limit = self.budget() + with self._lock: + self._roll_day() + spent = self._spent.get(caller, 0.0) + + remaining = UNLIMITED if limit == UNLIMITED else max(0.0, limit - spent) + info = { + "caller": caller, + "daily_budget_bzz": limit, + "spent_bzz": round(spent, 6), + "remaining_bzz": remaining if limit == UNLIMITED else round(remaining, 6), + "request_cost_bzz": round(cost_bzz, 6), + "resets_at": f"{_today()}T24:00:00Z", + } + if limit == UNLIMITED: + return True, info + return (spent + cost_bzz) <= limit, info + + def consume(self, caller: str, cost_bzz: float) -> None: + with self._lock: + self._roll_day() + self._spent[caller] = self._spent.get(caller, 0.0) + cost_bzz + self._save() + + def snapshot(self) -> dict: + with self._lock: + self._roll_day() + return {"day": self._day, "spent": dict(self._spent)} + + +spend_budget_tracker = SpendBudgetTracker() diff --git a/app/services/swarm_api.py b/app/services/swarm_api.py index e951bda..5af0757 100644 --- a/app/services/swarm_api.py +++ b/app/services/swarm_api.py @@ -1227,6 +1227,18 @@ def calculate_stamp_total_cost(amount: int, depth: int) -> int: return amount * (2 ** depth) +# BZZ is denominated in PLUR on chain. Named so callers that need a cost in BZZ +# can convert it themselves rather than reading it out of check_sufficient_funds' +# response — a partial mock of that function omitting a key should not be able to +# turn a spending limit into a 500. +PLUR_PER_BZZ = 10 ** 16 + + +def plur_to_bzz(plur: int) -> float: + """Convert an on-chain PLUR amount to BZZ.""" + return plur / PLUR_PER_BZZ + + async def check_sufficient_funds(required_plur: int) -> Dict[str, Any]: """ Checks if the wallet has sufficient BZZ funds for a stamp purchase. @@ -1249,9 +1261,8 @@ async def check_sufficient_funds(required_plur: int) -> Dict[str, Any]: wallet_info = await get_wallet_info() wallet_balance_plur = int(wallet_info.get("bzzBalance", 0)) - plur_per_bzz = 10 ** 16 - wallet_balance_bzz = wallet_balance_plur / plur_per_bzz - required_bzz = required_plur / plur_per_bzz + wallet_balance_bzz = plur_to_bzz(wallet_balance_plur) + required_bzz = plur_to_bzz(required_plur) sufficient = wallet_balance_plur >= required_plur shortfall_bzz = 0.0 if sufficient else required_bzz - wallet_balance_bzz diff --git a/tests/conftest.py b/tests/conftest.py index 94a7c2a..2acbb8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,3 +10,40 @@ # from interfering with test assertions. Rate limiter unit tests # test the component directly without relying on middleware. os.environ["RATE_LIMIT_ENABLED"] = "false" + +# The daily spend budget lives in a module-level singleton with persisted state, +# so without this every purchase and extend in the suite charges the same caller +# ("testclient") and the budget is exhausted partway through — turning unrelated +# tests into 429s depending on how many ran before them. +# +# Reset per test, and pointed at a temporary file so a test run never writes to +# the real state path. Tests that exercise the budget itself construct their own +# tracker and are unaffected. +import pytest + + +@pytest.fixture(autouse=True) +def _isolate_spend_budget(tmp_path, monkeypatch): + from app.services import spend_budget + + from app.core.config import settings + + # Unlimited, so the limit never decides the outcome of a test about + # something else — several suites purchase at the top of the valid amount + # range, which costs far more than any plausible budget. check() and + # consume() still run on every purchase and extend, so the plumbing stays + # covered; only the refusal is off. Tests about the budget set their own. + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", -1.0) + + # Same reasoning for the per-request ceiling. Several suites deliberately + # purchase at the top of the valid depth and amount ranges to test that + # validation accepts them; at the production default of 5 BZZ those requests + # legitimately cost more than the cap allows, and the test would then be + # asserting the cap rather than the validation it was written for. + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) # 0 disables the cap + + tracker = spend_budget.SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + monkeypatch.setattr(spend_budget, "spend_budget_tracker", tracker) + import app.api.endpoints.stamps as stamps_ep + monkeypatch.setattr(stamps_ep, "spend_budget_tracker", tracker) + yield tracker diff --git a/tests/test_spend_budget.py b/tests/test_spend_budget.py new file mode 100644 index 0000000..3b64d89 --- /dev/null +++ b/tests/test_spend_budget.py @@ -0,0 +1,318 @@ +"""Bounds on what a caller can spend of the gateway's BZZ. + +`POST /api/v1/stamps/` and `PATCH /api/v1/stamps/{id}/extend` both spend the +operator's money for whoever asks. Neither had a spending bound: measured on the +staging gateway, 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 wallet balance was the limit. + +Extend is the softer of the two — `is_protected_endpoint` matches on method, and +only POST routes are listed, so a PATCH is not payment-gated at all and does not +even meet the free-tier rate limit. It also tops up any batch on the node, +including ones the caller does not own. + +Two limits, answering different questions: a per-request ceiling so no single +call takes a large share of the wallet, and a per-caller daily budget so the +first cannot simply be applied repeatedly. +""" +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.main import app +from app.services.spend_budget import SpendBudgetTracker, UNLIMITED + +STAMP_ID = "a" * 64 + +# depth 17 at this price is a fraction of a BZZ; the tests set costs explicitly +# via the chainstate price where the exact figure matters. +CHAINSTATE = {"currentPrice": "24000", "block": 1, "chainTip": 1, "totalAmount": "1"} +FUNDS_OK = {"sufficient": True, "wallet_balance_bzz": 100.0, + "required_bzz": 0.01, "shortfall_bzz": 0.0} + + +@pytest.fixture +def tracker(tmp_path, monkeypatch): + t = SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + from app.services import spend_budget + monkeypatch.setattr(spend_budget, "spend_budget_tracker", t) + import app.api.endpoints.stamps as stamps_ep + monkeypatch.setattr(stamps_ep, "spend_budget_tracker", t) + return t + + +class TestBudgetArithmetic: + def test_a_request_that_would_overrun_is_refused_before_it_starts(self, tracker, monkeypatch): + """Not "is any budget left" but "does THIS request fit". + + A caller with 0.01 BZZ remaining must not be allowed to begin a 5 BZZ + purchase; checking only for a non-zero remainder would let them. + """ + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + tracker.consume("1.2.3.4", 0.99) + assert not tracker.check("1.2.3.4", 0.5)[0] + assert tracker.check("1.2.3.4", 0.005)[0] + + def test_spending_exactly_the_budget_is_allowed(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + assert tracker.check("1.2.3.4", 1.0)[0] + tracker.consume("1.2.3.4", 1.0) + assert not tracker.check("1.2.3.4", 0.000001)[0] + + def test_callers_have_separate_budgets(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + tracker.consume("1.2.3.4", 1.0) + assert not tracker.check("1.2.3.4", 0.1)[0] + assert tracker.check("5.6.7.8", 0.1)[0], "one caller must not spend another's budget" + + def test_unlimited_is_the_pre_existing_behaviour(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + tracker.consume("1.2.3.4", 10_000.0) + assert tracker.check("1.2.3.4", 10_000.0)[0] + + def test_a_restart_does_not_grant_a_fresh_budget(self, tmp_path, monkeypatch): + """A crash loop must not hand out a full budget per restart — that is the + shape of the incident this exists to prevent.""" + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + path = str(tmp_path / "spend.json") + first = SpendBudgetTracker(state_file=path) + first.consume("1.2.3.4", 0.9) + + second = SpendBudgetTracker(state_file=path) + assert not second.check("1.2.3.4", 0.5)[0] + assert second.check("1.2.3.4", 0.05)[0] + + def test_state_from_a_previous_day_is_ignored(self, tmp_path, monkeypatch): + import json + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + path = tmp_path / "spend.json" + path.write_text(json.dumps({"day": "1999-01-01", "spent": {"1.2.3.4": 999.0}})) + assert SpendBudgetTracker(state_file=str(path)).check("1.2.3.4", 0.9)[0] + + def test_an_unreadable_state_file_does_not_break_startup(self, tmp_path, monkeypatch): + """Never fail to start over a counter.""" + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + path = tmp_path / "spend.json" + path.write_text("{not json") + assert SpendBudgetTracker(state_file=str(path)).check("1.2.3.4", 0.5)[0] + + +class TestPerRequestCeiling: + """X402_MAX_STAMP_BZZ was in configuration from the start and referenced + nowhere — a cap that reads as protection during review and enforces + nothing.""" + + def _purchase(self, depth=20, duration=8760): + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + return TestClient(app).post( + "/api/v1/stamps/", + json={"depth": depth, "duration_hours": duration}, + ) + + def test_an_expensive_request_is_refused_on_policy(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + r = self._purchase() + assert r.status_code == 400 + d = r.json()["detail"] + assert d["code"] == "STAMP_COST_EXCEEDS_LIMIT" + assert d["limit_bzz"] == 0.001 + assert d["cost_bzz"] > d["limit_bzz"] + + def test_the_refusal_does_not_depend_on_the_wallet_balance(self, tracker, monkeypatch): + """The old answer was "insufficient funds", which told the caller the + wallet was the only limit. It was, and that is the defect.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + rich = {"sufficient": True, "wallet_balance_bzz": 10 ** 9, + "required_bzz": 0.01, "shortfall_bzz": 0.0} + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=rich)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + r = TestClient(app).post("/api/v1/stamps/", + json={"depth": 20, "duration_hours": 8760}) + assert r.status_code == 400 + assert r.json()["detail"]["code"] == "STAMP_COST_EXCEEDS_LIMIT" + + def test_zero_disables_the_ceiling(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + assert self._purchase().status_code == 201 + + def test_a_cheap_request_is_unaffected(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 5.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + assert self._purchase(depth=17, duration=24).status_code == 201 + + +class TestPurchaseEndpoint: + def _purchase(self, depth=17, duration=24): + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + return TestClient(app).post( + "/api/v1/stamps/", + json={"depth": depth, "duration_hours": duration}, + ) + + def test_the_budget_stops_repeated_purchases(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.05) + + allowed = 0 + for _ in range(40): + if self._purchase().status_code != 201: + break + allowed += 1 + else: + pytest.fail("the budget never refused a purchase") + + assert allowed > 0, "the budget refused the very first purchase" + assert tracker.snapshot()["spent"] + + def test_the_refusal_says_what_a_caller_can_do(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + tracker.consume("testclient", 0.001) + + r = self._purchase() + assert r.status_code == 429 + d = r.json()["detail"] + assert d["code"] == "DAILY_SPEND_BUDGET_EXHAUSTED" + assert d["resets_at"] + assert d["remaining_bzz"] == 0 + assert "resets" in d["message"] + assert str(d["daily_budget_bzz"]) in d["message"], "the number is not taken from config" + + def test_a_failed_purchase_does_not_spend_the_budget(self, tracker, monkeypatch): + """Bee refusing must not cost the caller their allowance.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 10.0) + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(side_effect=RuntimeError("bee said no"))): + TestClient(app).post("/api/v1/stamps/", + json={"depth": 17, "duration_hours": 24}) + assert tracker.snapshot()["spent"] == {}, "a failed purchase charged the caller" + + +class TestExtendEndpoint: + """Extend is not in PROTECTED_ENDPOINTS — is_protected_endpoint matches on + method and only POST paths are listed — so it has no payment gate and no + free-tier rate limit. The budget is the only thing bounding it.""" + + def _extend(self, duration=24): + existing = [{"batchID": STAMP_ID, "depth": 17, "batchTTL": 86400}] + with patch("app.services.swarm_api.get_all_stamps_processed", + new=AsyncMock(return_value=existing)), \ + patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.extend_postage_stamp", + new=AsyncMock(return_value=STAMP_ID)): + return TestClient(app).patch( + f"/api/v1/stamps/{STAMP_ID}/extend", + json={"duration_hours": duration}, + ) + + def test_extend_is_bounded_too(self, tracker, monkeypatch): + """Bounding purchase alone would leave the cheaper path open.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + tracker.consume("testclient", 0.001) + + r = self._extend() + assert r.status_code == 429 + assert r.json()["detail"]["code"] == "DAILY_SPEND_BUDGET_EXHAUSTED" + + def test_extend_draws_on_the_same_budget_as_purchase(self, tracker, monkeypatch): + """Separate budgets would double what a caller can spend.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + assert self._extend().status_code == 200 + spent = tracker.snapshot()["spent"] + assert list(spent) == ["testclient"], spent + assert spent["testclient"] > 0 + + +class TestPaidCallersBypass: + def test_a_settled_payment_is_not_drawn_from_the_giveaway_budget(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + monkeypatch.setattr(settings, "X402_NETWORK", "base") + tracker.consume("testclient", 0.001) + + # Drive the real helper with the payment state the dependency would set. + import app.api.endpoints.stamps as stamps_ep + from types import SimpleNamespace + + class _Req: + def __init__(self): + self.state = SimpleNamespace(x402_mode="paid") + self.headers = {} + self.client = None + + # None means "charge nobody" — the caller funded it themselves. + assert stamps_ep._enforce_spend_limits(_Req(), 5.0, "stamp purchase") is None + assert tracker.snapshot()["spent"] == {"testclient": 0.001}, "the payer was charged" + + def test_a_testnet_payment_does_not_bypass(self, tracker, monkeypatch): + """Testnet currency is free from a faucet, so a payment settled there is + not evidence anyone paid — the same reasoning as the pool bypass.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + monkeypatch.setattr(settings, "X402_NETWORK", "base-sepolia") + monkeypatch.setattr(settings, "X402_ALLOW_TESTNET_PAID_BYPASS", False) + tracker.consume("1.2.3.4", 0.001) + + import app.api.endpoints.stamps as stamps_ep + from fastapi import HTTPException + from types import SimpleNamespace + + class _Req: + def __init__(self): + self.state = SimpleNamespace(x402_mode="paid") + self.headers = {"X-Forwarded-For": "1.2.3.4"} + self.client = None + + with pytest.raises(HTTPException) as e: + stamps_ep._enforce_spend_limits(_Req(), 0.5, "stamp purchase") + assert e.value.status_code == 429 + + def test_the_per_request_ceiling_applies_to_paid_callers_too(self, tracker, monkeypatch): + """The gateway fronts the BZZ either way, so one transaction's exposure + is bounded regardless of who is paying.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 1.0) + monkeypatch.setattr(settings, "X402_NETWORK", "base") + + import app.api.endpoints.stamps as stamps_ep + from fastapi import HTTPException + from types import SimpleNamespace + + class _Req: + def __init__(self): + self.state = SimpleNamespace(x402_mode="paid") + self.headers = {} + self.client = None + + with pytest.raises(HTTPException) as e: + stamps_ep._enforce_spend_limits(_Req(), 5.0, "stamp purchase") + assert e.value.status_code == 400 + assert e.value.detail["code"] == "STAMP_COST_EXCEEDS_LIMIT" From 6cc2acda48e3cfdf4d2dc1f4abcc18ad25b20f19 Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Wed, 9 Sep 2026 14:25:04 +0200 Subject: [PATCH 3/7] Make spending refusals visible in metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily budget is a number chosen without usage data. Refusals were only logged, so the way we would have learned that 0.5 BZZ a day is too low for a real integration is a complaint, rather than a dashboard. Two counters and two gauges: - gateway_stamp_spend_refusals_total{operation, limit} — the limit label separates the per-request ceiling from the daily budget, because a single number would not say which one is set wrong. - gateway_stamp_spend_bzz_total{operation, charged} — BZZ committed, split by whether it drew on a budget or was paid for, so giveaway volume and paid volume are distinguishable. - gateway_stamp_spend_callers and gateway_stamp_spend_bzz_today — polled from the tracker rather than accumulated at the call site, because the day rolls over inside it and a counter would keep climbing past midnight UTC. No caller identity appears as a label. An IP is high-cardinality and would multiply the series, and it is personal data going to a third-party metrics store; the logs already name the caller for anyone diagnosing a specific case. A test asserts no caller string reaches /metrics. 5 new tests, including that a refusal records no spend — a refusal must not look like money going out the door. 1144 passed, 25 skipped. --- CLAUDE.md | 3 ++ app/api/endpoints/stamps.py | 15 ++++++- app/services/metrics.py | 43 ++++++++++++++++++ tests/test_spend_budget.py | 88 +++++++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3dec2c1..1b441b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -394,6 +394,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` @@ -406,6 +408,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}` diff --git a/app/api/endpoints/stamps.py b/app/api/endpoints/stamps.py index 4bad22e..32d8e75 100644 --- a/app/api/endpoints/stamps.py +++ b/app/api/endpoints/stamps.py @@ -12,7 +12,11 @@ from app.services.stamp_tracker import record_purchase from app.services.spend_budget import spend_budget_tracker from app.x402.middleware import get_client_ip -from app.services.metrics import stamp_purchases_total +from app.services.metrics import ( + stamp_purchases_total, + stamp_spend_refusals_total, + stamp_spend_bzz_total, +) from app.api.models.stamp import ( StampDetails, StampPurchaseRequest, @@ -50,6 +54,7 @@ def _enforce_spend_limits(request: Optional[Request], cost_bzz: float, operation "Refusing %s costing %.6f BZZ, above the per-request limit of %.6f", operation, cost_bzz, max_single, ) + stamp_spend_refusals_total.labels(operation=operation, limit="per_request").inc() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ @@ -70,6 +75,7 @@ def _enforce_spend_limits(request: Optional[Request], cost_bzz: float, operation # replace a bounded giveaway with an unbounded one. if request is not None and getattr(request.state, "x402_mode", None) == "paid": if settings.paid_bypass_is_honoured(): + stamp_spend_bzz_total.labels(operation=operation, charged="paid").inc(cost_bzz) return None logger.warning( "Payment for %s settled on %s, which is a test network: the daily " @@ -83,6 +89,7 @@ def _enforce_spend_limits(request: Optional[Request], cost_bzz: float, operation "Daily spend budget exhausted for %s: %.6f of %.6f BZZ used, request needs %.6f", caller, info["spent_bzz"], info["daily_budget_bzz"], cost_bzz, ) + stamp_spend_refusals_total.labels(operation=operation, limit="daily_budget").inc() raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail={ @@ -506,6 +513,9 @@ async def purchase_stamp( # caller their budget. if charge_to is not None: spend_budget_tracker.consume(charge_to, cost_bzz) + stamp_spend_bzz_total.labels( + operation="stamp purchase", charged="budget" + ).inc(cost_bzz) # Record purchase time for propagation tracking record_purchase(batch_id) @@ -666,6 +676,9 @@ async def extend_stamp( if charge_to is not None: spend_budget_tracker.consume(charge_to, cost_bzz) + stamp_spend_bzz_total.labels( + operation="stamp extension", charged="budget" + ).inc(cost_bzz) return StampExtensionResponse( batchID=batch_id, diff --git a/app/services/metrics.py b/app/services/metrics.py index 02c5c89..ac5a31d 100644 --- a/app/services/metrics.py +++ b/app/services/metrics.py @@ -115,6 +115,36 @@ "gateway_bandwidth_topup_bytes_total", "Total bytes of bandwidth credit sold via top-ups" ) +# ── Spending limits (#102) ────────────────────────────────────────────────── +# +# Refusals are the signal that matters. The daily budget is a number chosen +# without usage data, so the only way to tell "correctly bounding abuse" from +# "turning away a legitimate integration" is to watch how often it fires and +# whether it is always the same caller. Without these, we would learn about a +# limit set too low from a complaint rather than a dashboard. +# +# No caller identity is exposed as a label: an IP is high-cardinality and would +# multiply the series, and it is personal data going to a third-party metrics +# store. The logs already name the caller for anyone diagnosing a specific case. +stamp_spend_refusals_total = Counter( + "gateway_stamp_spend_refusals_total", + "Stamp purchases and extensions refused by a spending limit", + ["operation", "limit"], +) +stamp_spend_bzz_total = Counter( + "gateway_stamp_spend_bzz_total", + "BZZ committed through the stamp endpoints, by whether it was charged to a budget", + ["operation", "charged"], +) +stamp_spend_callers = Gauge( + "gateway_stamp_spend_callers", + "Distinct callers holding a non-zero spend balance today", +) +stamp_spend_bzz_today = Gauge( + "gateway_stamp_spend_bzz_today", + "Total BZZ charged to daily budgets today, across all callers", +) + # ── Bandwidth credit gauges (updated by background poller) ─────────────────── bandwidth_credit_accounts = Gauge( @@ -284,6 +314,19 @@ async def _poll_balances(): except Exception as e: logger.debug(f"Metrics: failed to get bandwidth credit state: {e}") + # Daily spend budgets on the stamp endpoints (#102). + # + # Polled from the tracker rather than incremented at the call site, + # because the day rolls over inside it: a counter would keep + # climbing while the underlying balances reset at midnight UTC. + try: + from app.services.spend_budget import spend_budget_tracker + spent = spend_budget_tracker.snapshot()["spent"] + stamp_spend_callers.set(len(spent)) + stamp_spend_bzz_today.set(sum(spent.values())) + except Exception as e: + logger.debug(f"Metrics: failed to get spend budget state: {e}") + # Gnosis signer wallet balances (buy-batch-for-owner feature) if settings.STAMP_PURCHASE_FOR_OTHERS_ENABLED: try: diff --git a/tests/test_spend_budget.py b/tests/test_spend_budget.py index 3b64d89..05b9d89 100644 --- a/tests/test_spend_budget.py +++ b/tests/test_spend_budget.py @@ -23,6 +23,7 @@ from app.core.config import settings from app.main import app from app.services.spend_budget import SpendBudgetTracker, UNLIMITED +from prometheus_client import REGISTRY STAMP_ID = "a" * 64 @@ -316,3 +317,90 @@ def __init__(self): stamps_ep._enforce_spend_limits(_Req(), 5.0, "stamp purchase") assert e.value.status_code == 400 assert e.value.detail["code"] == "STAMP_COST_EXCEEDS_LIMIT" + + +def _counter(name, **labels): + """Current value of a labelled counter, or 0 before it is first touched.""" + v = REGISTRY.get_sample_value(name, labels) + return 0.0 if v is None else v + + +class TestMetrics: + """The budget is a number chosen without usage data. + + Refusals are how we tell "correctly bounding abuse" from "turning away a + legitimate integration", so they have to be visible somewhere other than the + logs. These pin that the counters actually move. + """ + + def _purchase(self): + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + return TestClient(app).post("/api/v1/stamps/", + json={"depth": 17, "duration_hours": 24}) + + def test_a_budget_refusal_is_counted(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + tracker.consume("testclient", 0.001) + + before = _counter("gateway_stamp_spend_refusals_total", + operation="stamp purchase", limit="daily_budget") + assert self._purchase().status_code == 429 + after = _counter("gateway_stamp_spend_refusals_total", + operation="stamp purchase", limit="daily_budget") + assert after == before + 1 + + def test_a_ceiling_refusal_is_counted_separately(self, tracker, monkeypatch): + """The two limits mean different things — one number for both would not + say whether the cap is too low or the budget is.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0000001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + + before = _counter("gateway_stamp_spend_refusals_total", + operation="stamp purchase", limit="per_request") + assert self._purchase().status_code == 400 + after = _counter("gateway_stamp_spend_refusals_total", + operation="stamp purchase", limit="per_request") + assert after == before + 1 + + def test_committed_bzz_is_counted(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + + before = _counter("gateway_stamp_spend_bzz_total", + operation="stamp purchase", charged="budget") + assert self._purchase().status_code == 201 + after = _counter("gateway_stamp_spend_bzz_total", + operation="stamp purchase", charged="budget") + assert after > before, "the BZZ committed was not recorded" + + def test_a_refused_purchase_records_no_spend(self, tracker, monkeypatch): + """A refusal must not look like money going out the door.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0000001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + + before = _counter("gateway_stamp_spend_bzz_total", + operation="stamp purchase", charged="budget") + assert self._purchase().status_code == 400 + after = _counter("gateway_stamp_spend_bzz_total", + operation="stamp purchase", charged="budget") + assert after == before + + def test_no_caller_identity_leaks_into_a_label(self, tracker, monkeypatch): + """An IP is high-cardinality and is personal data going to a + third-party metrics store. The logs name the caller; the metrics + must not.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + tracker.consume("testclient", 0.001) + self._purchase() + + body = TestClient(app).get("/metrics").text + for line in body.splitlines(): + if line.startswith("gateway_stamp_spend"): + assert "testclient" not in line, line From b50f38b65fed8a03b5a85dce65e16a5969aed8b3 Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Wed, 9 Sep 2026 14:52:08 +0200 Subject: [PATCH 4/7] Cover the manifest boundary and what the envelope allowance is not The fix changed both upload endpoints but only the data endpoint had boundary tests, so the manifest side was corrected with nothing proving it. It now has the same three: an archive at exactly the ceiling accepted, one byte over rejected, and a declared Content-Length beyond the allowance short-circuiting. Two more pin the shape of the allowance itself, which is where this fix could go wrong: - A file inside the 8 KB allowance but over the limit is still refused. Widening Content-Length by 8 KB would raise the real ceiling by 8 KB if the exact check on the file's own length were ever dropped. - A long field name as well as a long filename does not shrink the ceiling. The envelope grows with both, and the original defect was exactly that something the caller chooses could eat into their limit. 1127 passed, 25 skipped. --- tests/test_upload_size_limit.py | 90 +++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/test_upload_size_limit.py b/tests/test_upload_size_limit.py index 2867864..9cac451 100644 --- a/tests/test_upload_size_limit.py +++ b/tests/test_upload_size_limit.py @@ -150,6 +150,96 @@ def test_manifest_within_limit_succeeds(self, mock_validate, mock_count, mock_up assert response.status_code == 200 + @patch('app.api.endpoints.data.upload_collection_to_swarm', return_value="ref456") + @patch('app.api.endpoints.data.count_tar_files', return_value=1) + @patch('app.api.endpoints.data.validate_tar') + @patch('app.api.endpoints.data.settings') + def test_manifest_at_exact_limit_succeeds(self, mock_settings, mock_validate, + mock_count, mock_upload): + """The manifest endpoint carried the same defect as the data endpoint. + + Both compare the request's Content-Length — which covers the multipart + envelope — against the limit that applies to the file itself. Only the + data endpoint had a boundary test, so the manifest side was fixed + without anything proving it. An archive at exactly the ceiling must be + accepted. + """ + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024) + response = client.post( + f"/api/v1/data/manifest?stamp_id={VALID_STAMP_ID}", + files={"file": ("exact.tar", io.BytesIO(data), "application/x-tar")} + ) + assert response.status_code == 200, response.text + + @patch('app.api.endpoints.data.upload_collection_to_swarm', return_value="ref456") + @patch('app.api.endpoints.data.count_tar_files', return_value=1) + @patch('app.api.endpoints.data.validate_tar') + @patch('app.api.endpoints.data.settings') + def test_manifest_one_byte_over_is_still_rejected(self, mock_settings, mock_validate, + mock_count, mock_upload): + """The envelope allowance must not become slack in the limit.""" + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024 + 1) + response = client.post( + f"/api/v1/data/manifest?stamp_id={VALID_STAMP_ID}", + files={"file": ("over.tar", io.BytesIO(data), "application/x-tar")} + ) + assert response.status_code == 413 + assert response.json()["detail"]["code"] == "FILE_TOO_LARGE" + + @patch('app.api.endpoints.data.settings') + def test_manifest_content_length_far_over_is_rejected(self, mock_settings): + """Declared length beyond the allowance short-circuits on Content-Length.""" + mock_settings.MAX_UPLOAD_SIZE_MB = 1 + response = client.post( + f"/api/v1/data/manifest?stamp_id={VALID_STAMP_ID}", + files={"file": ("t.tar", io.BytesIO(b"x" * 100), "application/x-tar")}, + headers={"content-length": str(2 * 1024 * 1024)} + ) + assert response.status_code == 413 + + +class TestEnvelopeAllowance: + """What the allowance is and is not. + + It exists so the multipart wrapper does not count against the file. It must + not become slack in the limit itself, and it must not vary with anything the + caller controls. + """ + + @patch('app.api.endpoints.data.upload_data_to_swarm', return_value="ref123") + @patch('app.api.endpoints.data.settings') + def test_a_long_field_name_does_not_shrink_the_ceiling(self, mock_settings, mock_upload): + """The envelope grows with the field name as well as the filename.""" + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024) + response = client.post( + f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", + files={"file": ("f" * 180 + ".bin", io.BytesIO(data), "application/octet-stream")}, + data={"unused" * 20: "y" * 500}, + ) + assert response.status_code == 200, response.text + + @patch('app.api.endpoints.data.upload_data_to_swarm', return_value="ref123") + @patch('app.api.endpoints.data.settings') + def test_the_allowance_is_not_extra_capacity(self, mock_settings, mock_upload): + """A file inside the 8 KB allowance but over the limit is still refused. + + This is the failure mode of the fix: widening Content-Length by 8 KB + would raise the real ceiling by 8 KB if the exact check were not also + applied to the file's own length. + """ + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024 + 4096) + response = client.post( + f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", + files={"file": ("over.bin", io.BytesIO(data), "application/octet-stream")} + ) + assert response.status_code == 413 + assert response.json()["detail"]["code"] == "FILE_TOO_LARGE" + + class TestConfigurableLimit: """Tests that the limit is configurable via settings.""" From e6506c0a64ed6f2ae72c4ada7208568f78266620 Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Wed, 9 Sep 2026 14:53:27 +0200 Subject: [PATCH 5/7] Close the coverage gaps in the spending limits, and stop duplicating PLUR_PER_BZZ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage of spend_budget.py was 92%; the untested lines were the day rollover and the save-failure path. Both matter more than the percentage suggests. Day rollover is the whole contract. If the reset failed, a caller would be locked out permanently after their first day rather than for the rest of it. Nothing exercised a live rollover — the existing test loads yesterday's file at startup, which is a different code path. Three tests now cover a running tracker crossing midnight, the reset being persisted rather than only in memory (a restart just after midnight would otherwise reload yesterday's spend), and snapshot() rolling too, since the metrics gauges read through it and would otherwise report yesterday's total as today's. Durability: a request must survive an unwritable state file. Losing the counter is recoverable; failing to sell a stamp because the disk is full is not. Concurrency: this is a money path and the tracker is shared across threads. Without the lock, interleaved read-modify-write loses updates and the recorded spend comes out lower than what was committed, which is the direction that costs money. Eight threads, 400 increments, exact total asserted. Endpoint gaps closed: the per-request ceiling on extend (only the daily budget was covered there), the refusal naming the right operation, refusals counted under their own operation label, and every non-paid x402 mode still being charged — treating anything non-None as paid would hand the bypass to the entire free-tier population the budget exists for. Added a test that the handler passes the LIVE request to the limits. The other paid tests drive the helper with a stand-in, so they would pass even if the handler forgot and the payment state never reached the check. Mutating app middleware to simulate a payment was tried first and leaked into later tests in the same file; wrapping the real helper does the same job without touching global state. A control test alongside it confirms the budget still refuses without a payment, so a pass cannot be the limit quietly failing to apply. Removed the unreachable request-is-None branches rather than testing them: both call sites always pass a Request. Separately: this branch had added a THIRD plur_to_bzz and a SIXTH PLUR_PER_BZZ. Both now live once in swarm_api, the lowest layer, and the rest import them. The copies all agreed, but nothing made them agree, and one drifting would produce wrong money arithmetic in one place and not the others. The x402 modules re-export both so existing imports and tests are untouched. spend_budget.py is now at 100%. 1158 passed, 25 skipped. --- CLAUDE.md | 2 + app/api/endpoints/stamps.py | 6 +- app/api/endpoints/stamps_for_owner.py | 2 +- app/services/gnosis_chain.py | 2 +- app/services/swarm_api.py | 17 +- app/x402/preflight.py | 9 +- app/x402/pricing.py | 9 +- tests/test_spend_budget.py | 225 ++++++++++++++++++++++++++ 8 files changed, 251 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1b441b1..07b258b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,6 +174,8 @@ The key is the **client IP**, not `Origin`. The callers here are CLIs, SDKs and 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. diff --git a/app/api/endpoints/stamps.py b/app/api/endpoints/stamps.py index 32d8e75..a4320cf 100644 --- a/app/api/endpoints/stamps.py +++ b/app/api/endpoints/stamps.py @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) -def _enforce_spend_limits(request: Optional[Request], cost_bzz: float, operation: str) -> Optional[str]: +def _enforce_spend_limits(request: Request, cost_bzz: float, operation: str) -> Optional[str]: """Bound what one request, and one caller in a day, may spend. Both stamp endpoints spend the gateway's BZZ for whoever asks. Two limits @@ -73,7 +73,7 @@ def _enforce_spend_limits(request: Optional[Request], cost_bzz: float, operation # funded it. Withheld on a test network for the same reason as the pool: # testnet currency is free from a faucet, so honouring it there would # replace a bounded giveaway with an unbounded one. - if request is not None and getattr(request.state, "x402_mode", None) == "paid": + if getattr(request.state, "x402_mode", None) == "paid": if settings.paid_bypass_is_honoured(): stamp_spend_bzz_total.labels(operation=operation, charged="paid").inc(cost_bzz) return None @@ -82,7 +82,7 @@ def _enforce_spend_limits(request: Optional[Request], cost_bzz: float, operation "spend budget still applies.", operation, settings.X402_NETWORK, ) - caller = get_client_ip(request) if request is not None else "unknown" + caller = get_client_ip(request) allowed, info = spend_budget_tracker.check(caller, cost_bzz) if not allowed: logger.info( diff --git a/app/api/endpoints/stamps_for_owner.py b/app/api/endpoints/stamps_for_owner.py index 8e8ea89..b0dd46d 100644 --- a/app/api/endpoints/stamps_for_owner.py +++ b/app/api/endpoints/stamps_for_owner.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) router = APIRouter() -PLUR_PER_BZZ = 10 ** 16 +from app.services.swarm_api import PLUR_PER_BZZ # noqa: F401 @router.post( diff --git a/app/services/gnosis_chain.py b/app/services/gnosis_chain.py index 795c959..df27ad9 100644 --- a/app/services/gnosis_chain.py +++ b/app/services/gnosis_chain.py @@ -32,7 +32,7 @@ logger = logging.getLogger(__name__) BUCKET_DEPTH = 16 # fixed by the Swarm protocol -PLUR_PER_BZZ = 10 ** 16 # 1 BZZ = 10^16 PLUR +from app.services.swarm_api import PLUR_PER_BZZ # noqa: F401 # Verified contract/token addresses per chain (ethersphere/go-storage-incentives-abi). CHAIN_DEFAULTS = { diff --git a/app/services/swarm_api.py b/app/services/swarm_api.py index 5af0757..4a86b9f 100644 --- a/app/services/swarm_api.py +++ b/app/services/swarm_api.py @@ -1227,10 +1227,19 @@ def calculate_stamp_total_cost(amount: int, depth: int) -> int: return amount * (2 ** depth) -# BZZ is denominated in PLUR on chain. Named so callers that need a cost in BZZ -# can convert it themselves rather than reading it out of check_sufficient_funds' -# response — a partial mock of that function omitting a key should not be able to -# turn a spending limit into a 500. +# BZZ is denominated in PLUR on chain. +# +# Defined here, in the lowest layer, and imported by everything else that needs +# it. There were five separate copies of this constant and two of the function +# before #102 — app/x402/pricing.py, app/x402/preflight.py, +# app/services/gnosis_chain.py, app/api/endpoints/stamps_for_owner.py and this +# module. They all agreed, but nothing made them agree, and a single one drifting +# would have produced wrong money arithmetic in one place and not the others. +# +# Named rather than inlined so callers needing a cost in BZZ can convert it +# themselves rather than reading it out of check_sufficient_funds' response: a +# partial mock of that function omitting a key should not be able to turn a +# spending limit into a 500. PLUR_PER_BZZ = 10 ** 16 diff --git a/app/x402/preflight.py b/app/x402/preflight.py index 192b618..a14b908 100644 --- a/app/x402/preflight.py +++ b/app/x402/preflight.py @@ -20,15 +20,12 @@ logger = logging.getLogger(__name__) # Conversion constants -PLUR_PER_BZZ = 10 ** 16 # 1 BZZ = 10^16 PLUR +# Single source in app/services/swarm_api; re-exported here because callers +# and tests import it from this module. +from app.services.swarm_api import PLUR_PER_BZZ, plur_to_bzz # noqa: F401 WEI_PER_XDAI = 10 ** 18 # 1 xDAI = 10^18 wei -def plur_to_bzz(plur: int) -> float: - """Convert PLUR to BZZ.""" - return plur / PLUR_PER_BZZ - - def wei_to_xdai(wei: int) -> float: """Convert wei to xDAI.""" return wei / WEI_PER_XDAI diff --git a/app/x402/pricing.py b/app/x402/pricing.py index d143fd8..291c8e1 100644 --- a/app/x402/pricing.py +++ b/app/x402/pricing.py @@ -26,15 +26,12 @@ logger = logging.getLogger(__name__) # Conversion constants -PLUR_PER_BZZ = 10 ** 16 # 1 BZZ = 10^16 PLUR +# Single source in app/services/swarm_api; re-exported here because callers +# and tests import it from this module. +from app.services.swarm_api import PLUR_PER_BZZ, plur_to_bzz # noqa: F401 BYTES_PER_GB = 10 ** 9 # 1 GB = 10^9 bytes (for bandwidth pricing) -def plur_to_bzz(plur: int) -> float: - """Convert PLUR to BZZ.""" - return plur / PLUR_PER_BZZ - - def bzz_to_usd(bzz: float, rate: Optional[float] = None) -> float: """ Convert BZZ to USD using configured or provided exchange rate. diff --git a/tests/test_spend_budget.py b/tests/test_spend_budget.py index 05b9d89..66b07e2 100644 --- a/tests/test_spend_budget.py +++ b/tests/test_spend_budget.py @@ -404,3 +404,228 @@ def test_no_caller_identity_leaks_into_a_label(self, tracker, monkeypatch): for line in body.splitlines(): if line.startswith("gateway_stamp_spend"): assert "testclient" not in line, line + + +class TestDayRollover: + """"Daily" is the whole contract. + + If the reset failed, a caller would be locked out permanently after their + first day rather than for the rest of it — a limit that never releases is a + different product from one that resets, and nothing else in the suite + exercised a live rollover: the other test loads yesterday's file at startup, + which is a different code path. + """ + + def test_a_running_tracker_resets_when_the_day_turns(self, tmp_path, monkeypatch): + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + t = SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + t.consume("1.2.3.4", 1.0) + assert not t.check("1.2.3.4", 0.5)[0] + + from app.services import spend_budget + monkeypatch.setattr(spend_budget, "_today", lambda: "2099-01-01") + + allowed, info = t.check("1.2.3.4", 1.0) + assert allowed, "the budget did not reset when the day turned" + assert info["spent_bzz"] == 0 + assert info["resets_at"].startswith("2099-01-01") + + def test_the_reset_is_persisted_not_just_in_memory(self, tmp_path, monkeypatch): + """Otherwise a restart just after midnight would reload yesterday's + spend and re-apply it to the new day.""" + import json + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + path = tmp_path / "spend.json" + t = SpendBudgetTracker(state_file=str(path)) + t.consume("1.2.3.4", 1.0) + + from app.services import spend_budget + monkeypatch.setattr(spend_budget, "_today", lambda: "2099-01-01") + t.check("1.2.3.4", 0.1) + + on_disk = json.loads(path.read_text()) + assert on_disk["day"] == "2099-01-01" + assert on_disk["spent"] == {} + + def test_snapshot_also_rolls_the_day(self, tmp_path, monkeypatch): + """The metrics gauges read through snapshot(); a stale day there would + report yesterday's total as today's.""" + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + t = SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + t.consume("1.2.3.4", 0.5) + + from app.services import spend_budget + monkeypatch.setattr(spend_budget, "_today", lambda: "2099-01-01") + assert t.snapshot() == {"day": "2099-01-01", "spent": {}} + + +class TestDurability: + def test_a_request_survives_an_unwritable_state_file(self, tmp_path, monkeypatch): + """Losing the counter is recoverable; failing the request is not. A full + or read-only disk must not stop the gateway selling stamps.""" + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + t = SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + + def boom(*a, **k): + raise OSError("read-only file system") + + monkeypatch.setattr("builtins.open", boom) + t.consume("1.2.3.4", 0.1) # must not raise + monkeypatch.undo() + + # The in-memory count still moved, so the budget holds for this process. + assert t.check("1.2.3.4", 0.95)[0] is False + + def test_concurrent_requests_cannot_overspend(self, tmp_path, monkeypatch): + """This is a money path and the tracker is shared across threads. + + Without the lock, interleaved read-modify-write on the same caller loses + updates, and the recorded spend comes out lower than what was actually + committed — which is the direction that costs money. + """ + import threading + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", -1.0) + t = SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + + def spend(): + for _ in range(50): + t.consume("1.2.3.4", 0.01) + + threads = [threading.Thread(target=spend) for _ in range(8)] + for th in threads: + th.start() + for th in threads: + th.join() + + assert t.snapshot()["spent"]["1.2.3.4"] == pytest.approx(8 * 50 * 0.01) + + +class TestBothLimitsApplyToBothEndpoints: + """The two endpoints were bounded in one change, so it is easy for a later + edit to fix or break one and not the other. These pin the symmetry.""" + + def _extend(self): + existing = [{"batchID": STAMP_ID, "depth": 17, "batchTTL": 86400}] + with patch("app.services.swarm_api.get_all_stamps_processed", + new=AsyncMock(return_value=existing)), \ + patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.extend_postage_stamp", + new=AsyncMock(return_value=STAMP_ID)): + return TestClient(app).patch(f"/api/v1/stamps/{STAMP_ID}/extend", + json={"duration_hours": 8760}) + + def test_the_per_request_ceiling_applies_to_extend(self, tracker, monkeypatch): + """Only the daily budget was covered on this endpoint before.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0000001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + r = self._extend() + assert r.status_code == 400 + assert r.json()["detail"]["code"] == "STAMP_COST_EXCEEDS_LIMIT" + + def test_the_refusal_names_the_operation(self, tracker, monkeypatch): + """A caller seeing "stamp purchase" on an extend has been told the wrong + thing about what they just did.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0000001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + assert "extension" in self._extend().json()["detail"]["message"] + + def test_a_refused_extend_is_counted_under_its_own_operation(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0000001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + before = _counter("gateway_stamp_spend_refusals_total", + operation="stamp extension", limit="per_request") + self._extend() + after = _counter("gateway_stamp_spend_refusals_total", + operation="stamp extension", limit="per_request") + assert after == before + 1 + + +class TestOnlyASettledPaymentBypasses: + """`paid` is one of several x402 modes. Treating anything non-None as paid + would hand the bypass to every free-tier caller, which is the population the + budget exists for.""" + + def _helper_with_mode(self, mode): + import app.api.endpoints.stamps as stamps_ep + from types import SimpleNamespace + + class _Req: + def __init__(self): + self.state = SimpleNamespace(x402_mode=mode) + self.headers = {} + self.client = None + + return stamps_ep, _Req() + + @pytest.mark.parametrize("mode", ["free", "free-tier", "rejected", None]) + def test_a_non_paid_mode_is_still_charged(self, tracker, monkeypatch, mode): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + monkeypatch.setattr(settings, "X402_NETWORK", "base") + stamps_ep, req = self._helper_with_mode(mode) + assert stamps_ep._enforce_spend_limits(req, 0.1, "stamp purchase") is not None, \ + f"mode {mode!r} was treated as a settled payment" + + def test_the_handler_passes_the_live_request_to_the_limits(self, tracker, monkeypatch): + """The other paid tests drive the helper with a stand-in request, so + they would still pass if the handler forgot to pass the real one and the + payment state never reached the check. + + Mutating app middleware to simulate a settled payment was tried and + leaked into later tests in the same file. This wraps the real helper + instead: it sets the payment state on whatever request the handler + actually passed, then calls through, so both the wiring and the bypass + are exercised without touching global app state. + """ + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + monkeypatch.setattr(settings, "X402_NETWORK", "base") + tracker.consume("testclient", 0.001) + + import app.api.endpoints.stamps as stamps_ep + real = stamps_ep._enforce_spend_limits + seen = {} + + def as_paid(request, cost_bzz, operation): + seen["request"] = request + request.state.x402_mode = "paid" + return real(request, cost_bzz, operation) + + monkeypatch.setattr(stamps_ep, "_enforce_spend_limits", as_paid) + + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + r = TestClient(app).post("/api/v1/stamps/", + json={"depth": 17, "duration_hours": 24}) + + from starlette.requests import Request as StarletteRequest + assert isinstance(seen.get("request"), StarletteRequest), \ + "the handler did not pass the live request to the spending check" + assert r.status_code == 201, r.text + assert tracker.snapshot()["spent"] == {"testclient": 0.001}, \ + "a settled payment was charged to the giveaway budget" + + def test_an_exhausted_budget_still_refuses_without_a_payment(self, tracker, monkeypatch): + """The control for the test above: same setup, no payment state set, so + a pass there cannot be the budget quietly failing to apply.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + monkeypatch.setattr(settings, "X402_NETWORK", "base") + tracker.consume("testclient", 0.001) + + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + r = TestClient(app).post("/api/v1/stamps/", + json={"depth": 17, "duration_hours": 24}) + assert r.status_code == 429 From f4e24dae5d15adce0c0b4d65d8d2c5f7c39e2f2e Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Wed, 9 Sep 2026 14:56:17 +0200 Subject: [PATCH 6/7] Say in the README that a file at exactly the limit is accepted It was not, before this branch: the multipart envelope pushed Content-Length over the ceiling, so the documented 10 MB was unreachable. Worth stating explicitly rather than leaving a reader to discover the boundary works. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cb1cf10..704c15c 100644 --- a/README.md +++ b/README.md @@ -840,7 +840,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) From 26d874dd250bac1b43f55e413ea8bdcca0d9e311 Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Wed, 9 Sep 2026 14:57:07 +0200 Subject: [PATCH 7/7] Document the spending limits in the README The security section covered upload size and rate limiting but said nothing about the endpoints that spend money, which is the one thing an operator running this most needs to know about before exposing it. Covers both settings, both refusal shapes with their codes and fields, and the two design choices a reader would otherwise have to infer from the source: why the budget counts BZZ rather than batches, and what keying on the client IP is and is not worth. --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index cb1cf10..af855c3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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.