Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,28 @@ STAMP_OWNERSHIP_ALLOW_UNTRACKED=false
# site's, so this stops other WEBSITES spending your postage — but any
# non-browser client can claim any origin. The budget is what protects you.
POOL_DAILY_ALLOWANCES=
# Allowance for origins not listed above, including callers sending no Origin
# Allowance for the SHARED bucket: every origin not listed above, plus callers
# sending no Origin at all. One bucket between them, not one each — the header
# is caller-supplied, so per-origin buckets for unknown origins would let anyone
# mint unlimited budgets by rotating it.
# (CLIs, SDKs, server-to-server). -1 = unlimited (the behaviour before this
# existed), 0 = refuse. Left at -1 so deploying changes nothing until the
# allowances are deliberately configured.
POOL_DEFAULT_DAILY_ALLOWANCE=-1
POOL_ALLOWANCE_STATE_FILE=data/pool_allowance.json

# Premium charged for taking a pre-bought batch from the pool rather than buying
# one, as a percentage on top of the batch's own cost. 0 = at cost, 100 = double.
#
# The pool is a different product from a purchase: the capital is already spent,
# the batch is kept alive whether or not anyone takes it, and it may expire
# unused. What the caller buys is the absence of the minute an on-chain purchase
# takes.
X402_POOL_MARKUP_PERCENT=100

# 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
# true only to exercise the paid path against a testnet (staging). It has no
# effect on a mainnet network, where the bypass is always honoured.
X402_ALLOW_TESTNET_PAID_BYPASS=false
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ jobs:
STAMP_POOL_MAX_PURCHASES_PER_HOUR=${{ vars.STAMP_POOL_MAX_PURCHASES_PER_HOUR || '10' }}
POOL_DAILY_ALLOWANCES=${{ vars.POOL_DAILY_ALLOWANCES }}
POOL_DEFAULT_DAILY_ALLOWANCE=${{ vars.POOL_DEFAULT_DAILY_ALLOWANCE || '-1' }}
X402_POOL_MARKUP_PERCENT=${{ vars.X402_POOL_MARKUP_PERCENT || '100' }}
X402_ALLOW_TESTNET_PAID_BYPASS=${{ vars.X402_ALLOW_TESTNET_PAID_BYPASS || 'false' }}
STAMP_POOL_CHECK_INTERVAL_SECONDS=${{ vars.STAMP_POOL_CHECK_INTERVAL_SECONDS || '900' }}
STAMP_POOL_MIN_TTL_HOURS=${{ vars.STAMP_POOL_MIN_TTL_HOURS || '24' }}
STAMP_POOL_TOPUP_HOURS=${{ vars.STAMP_POOL_TOPUP_HOURS || '168' }}
Expand Down Expand Up @@ -121,6 +123,8 @@ jobs:
STAMP_POOL_MAX_PURCHASES_PER_HOUR=${{ vars.STAMP_POOL_MAX_PURCHASES_PER_HOUR || '10' }}
POOL_DAILY_ALLOWANCES=${{ vars.POOL_DAILY_ALLOWANCES }}
POOL_DEFAULT_DAILY_ALLOWANCE=${{ vars.POOL_DEFAULT_DAILY_ALLOWANCE || '-1' }}
X402_POOL_MARKUP_PERCENT=${{ vars.X402_POOL_MARKUP_PERCENT || '100' }}
X402_ALLOW_TESTNET_PAID_BYPASS=${{ vars.X402_ALLOW_TESTNET_PAID_BYPASS || 'false' }}
STAMP_POOL_CHECK_INTERVAL_SECONDS=${{ vars.STAMP_POOL_CHECK_INTERVAL_SECONDS || '900' }}
STAMP_POOL_MIN_TTL_HOURS=${{ vars.STAMP_POOL_MIN_TTL_HOURS || '24' }}
STAMP_POOL_TOPUP_HOURS=${{ vars.STAMP_POOL_TOPUP_HOURS || '168' }}
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Every batch a caller can obtain is registered to them — pool acquire, direct p

#### Stamp Pool (Low-Latency Provisioning)
- `GET /api/v1/pool/status`: Get pool status and reserve levels
- `POST /api/v1/pool/acquire`: Acquire stamp from pool instantly (<5 seconds vs >1 minute). Subject to a **daily allowance per origin and per size** (`POOL_DAILY_ALLOWANCES`, e.g. `https://app.example=50`; unlisted origins and callers sending no `Origin` use `POOL_DEFAULT_DAILY_ALLOWANCE`, `-1` = unlimited). Returns `429` with code `DAILY_STAMP_ALLOWANCE_EXHAUSTED` and a message written to be shown to a person, plus `resets_at` and the direct-purchase alternative. Consumed only once a batch is actually handed over, so an empty pool costs the caller nothing. **Origin is attribution, not authentication** — a browser cannot forge another site's, so this stops other websites spending the operator's postage, but any non-browser client can claim any origin; the budget is the control. Counters persist across restarts, or a crash loop would grant a fresh allowance each time.
- `POST /api/v1/pool/acquire`: Acquire stamp from pool instantly (<5 seconds vs >1 minute). **Priced** from what the pool paid — `STAMP_POOL_DEFAULT_DURATION_HOURS + 1` at the requested depth, matching `_purchase_stamp`'s cost basis — plus `X402_POOL_MARKUP_PERCENT` (default 100, i.e. double). Before this the path matched no branch in the pricing dispatcher and fell through to `X402_MIN_PRICE_USD` regardless of size, so a depth-20 batch was quoted the same as the floor. **Payment is optional here**: a caller presenting an `X-PAYMENT` header has it settled and **bypasses the allowance entirely** — the budget bounds what the operator gives away, not what someone has paid for. A caller presenting none falls through to the allowance. **The bypass is withheld on a test network** (`is_testnet_network`, anything not recognised as mainnet), because testnet USDC is free from a faucet and honouring it would replace a bounded giveaway with an unbounded one — capped only by `STAMP_POOL_MAX_PURCHASES_PER_HOUR`, roughly 4 BZZ a day. Production ran `X402_NETWORK=base-sepolia` against the public `x402.org` facilitator when this was written, so it would have applied there. The payment is still settled and the batch still registered to the payer; only the bypass is withheld, and the 429 message then points at direct purchase rather than a payment that would not help. `X402_ALLOW_TESTNET_PAID_BYPASS=true` overrides it so staging can exercise the path. The router uses `settle_payment_if_offered`, not `require_x402_payment`, because the latter answers 402 to any caller sending no payment header, which is every current caller. Subject to a **daily allowance per size**, with a bucket per **configured** origin and one **shared** bucket for everything else — an unlisted origin does not get its own allowance, because the header is caller-supplied and rotating it would otherwise mint unlimited budgets (`POOL_DAILY_ALLOWANCES`, e.g. `https://app.example=50`; unlisted origins and callers sending no `Origin` use `POOL_DEFAULT_DAILY_ALLOWANCE`, `-1` = unlimited). Returns `429` with code `DAILY_STAMP_ALLOWANCE_EXHAUSTED` and a message written to be shown to a person, plus `resets_at` and the direct-purchase alternative. Consumed only once a batch is actually handed over, so an empty pool costs the caller nothing. **Origin is attribution, not authentication** — a browser cannot forge another site's, so this stops other websites spending the operator's postage, but any non-browser client can claim any origin; the budget is the control. Counters persist across restarts, or a crash loop would grant a fresh allowance each time.
- `GET /api/v1/pool/available`: List available stamps in pool
> **Top-up follows the reserve config**: only depths the pool still wants are kept alive. A depth whose target drops to 0 is not replaced when handed out, so paying to extend it maintains inventory the configuration has stopped carrying — batches at a dropped depth are left to expire and sync removes them. They stay usable until then.

Expand Down
108 changes: 75 additions & 33 deletions app/api/endpoints/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,44 +210,84 @@ async def acquire_stamp(
requested_depth = 17 # Default to small
requested_size = depth_to_size_name(requested_depth)

# A settled payment bypasses the allowance. The allowance bounds what the
# operator GIVES AWAY; it has no business limiting what someone has paid for.
#
# This branch did not execute for a long time: the handler read
# request.state.x402_payer and registered the batch to that wallet when
# present, but the payment dependency was not attached to this router, so
# the attribute was always None and every acquire fell to the "shared" path.
x402_mode_pre = getattr(http_request.state, "x402_mode", None)
settled = x402_mode_pre == "paid"

# A settled payment only buys a bypass where the payment was worth
# something. On a testnet the currency is free from a faucet, so honouring
# it would hand anyone an unlimited supply of batches the operator paid real
# BZZ for — strictly worse than the allowance it replaces. The payment is
# still settled and the batch still registered to the payer; only the
# bypass is withheld, so the caller keeps its normal daily allowance.
paid = settled and settings.paid_bypass_is_honoured()
if settled and not paid:
logger.warning(
"Pool acquire settled on %s, which is a test network: allowance "
"still applies. Set X402_ALLOW_TESTNET_PAID_BYPASS to override.",
settings.X402_NETWORK,
)

allowed_by_budget, budget = pool_allowance_tracker.check(origin, requested_size)
if not allowed_by_budget:
if paid:
logger.info("Pool acquire paid via x402, bypassing the daily allowance")
elif not allowed_by_budget:
logger.info(
"Pool allowance exhausted for origin %s (%s/%s today)",
budget["origin"], budget["used"], budget["allowance"],
)
# Written to be shown to a person, not just logged: the caller is a
# browser app whose user has never heard of a postage batch, so it says
# what they can do rather than only what failed.
#
# The offer to pay is conditional. Where a settled payment does not buy
# a bypass — a testnet, without the explicit override — telling the
# caller to pay would send them to a path that takes their payment and
# still refuses them, which is worse than not offering it at all.
message = (
f"The daily free allowance of {budget['allowance']} {requested_size} stamps for this "
f"application has been used up. It resets at {budget['resets_at']}. "
)
detail = {
"code": "DAILY_STAMP_ALLOWANCE_EXHAUSTED",
"size": requested_size,
"allowance": budget["allowance"],
"used": budget["used"],
"resets_at": budget["resets_at"],
}
if settings.paid_bypass_is_honoured():
message += (
"To continue now, pay with x402: send an X-PAYMENT header with "
"this same request and you get a pooled stamp immediately, "
"without drawing on the allowance."
)
detail["alternative"] = {
"endpoint": "POST /api/v1/pool/acquire",
"payment": "x402",
"header": "X-PAYMENT",
"note": "Paid acquires bypass the allowance and are immediate.",
}
else:
message += (
"To continue now, buy a stamp directly with POST /api/v1/stamps/ — "
"that is not drawn from the pool, so this limit does not apply. "
"It takes about a minute to become usable rather than seconds."
)
detail["alternative"] = {
"endpoint": "POST /api/v1/stamps/",
"note": "Direct purchase is not drawn from the pool, so the allowance does not apply.",
}
detail["message"] = message

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

# Try to get exact match first
Expand Down Expand Up @@ -307,8 +347,10 @@ async def acquire_stamp(
)

# Consumed only now: the batch has been released to the caller, so the
# allowance has genuinely been spent.
pool_allowance_tracker.consume(origin, requested_size)
# allowance has genuinely been spent. A paid acquire consumes nothing — the
# caller bought this batch rather than drawing on the free budget.
if not paid:
pool_allowance_tracker.consume(origin, requested_size)

# Trigger immediate replenishment if pool is below target
# This runs in the background and doesn't affect the response
Expand Down
56 changes: 56 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@
load_dotenv()


# Networks where the currency is free from a faucet, so a settled payment is
# not evidence that value changed hands. Matched on a normalised name rather
# than an exact list membership, because x402 network identifiers vary by
# version ("base-sepolia", "base_sepolia", "sepolia") and a name we fail to
# recognise must be treated as a testnet: guessing wrong in that direction
# refuses a real payment, guessing wrong the other way gives batches away.
_MAINNET_NETWORKS = {"base", "base-mainnet", "ethereum", "mainnet", "polygon", "avalanche"}


def is_testnet_network(network: str) -> bool:
"""Whether an x402 network identifier names a test network.

Anything not explicitly recognised as a mainnet counts as a testnet.
"""
if not network:
return True
return network.strip().lower().replace("_", "-") not in _MAINNET_NETWORKS


class Settings(BaseSettings):
PROJECT_NAME: str = "Provenance Gateway"
API_V1_STR: str = "/api/v1"
Expand Down Expand Up @@ -104,6 +123,32 @@ class Settings(BaseSettings):
# configured. A limit that arrives unannounced breaks callers.
POOL_DEFAULT_DAILY_ALLOWANCE: int = -1
POOL_ALLOWANCE_STATE_FILE: str = "data/pool_allowance.json"
# Premium charged for taking a pre-bought batch from the pool instead of
# buying one, as a percentage on top of what the batch itself costs.
# 0 = at cost, 100 = double.
#
# The pool is not the same product as a purchase. The operator has already
# spent the capital, is paying to keep the batch alive whether or not anyone
# takes it, and carries the risk of it expiring unused — in exchange the
# caller waits seconds instead of a minute. That convenience is the thing
# being sold, and it has a real cost behind it.
X402_POOL_MARKUP_PERCENT: int = 100

# Whether a settled x402 payment is allowed to bypass the pool's daily
# allowance. A bypass is only defensible when the payment is worth
# something: on a testnet, USDC is free from a faucet, so honouring it
# would replace a bounded giveaway with an unbounded one.
#
# Production ran X402_NETWORK=base-sepolia against the public x402.org
# facilitator when paid pool access was written, so shipping the bypass
# unguarded would have made the allowance meaningless there — capped only
# by STAMP_POOL_MAX_PURCHASES_PER_HOUR, about 4 BZZ a day against a wallet
# holding 16.
#
# Set this true only where the paid path needs exercising against a testnet
# (staging). It has no effect on a mainnet network, where the bypass is
# always honoured.
X402_ALLOW_TESTNET_PAID_BYPASS: bool = False
STAMP_POOL_MIN_TTL_HOURS: int = 24 # Top up if TTL below this
STAMP_POOL_TOPUP_HOURS: int = 168 # How much TTL to add (1 week)
STAMP_POOL_LOW_RESERVE_THRESHOLD: int = 1 # Alert when reserve drops to this level
Expand Down Expand Up @@ -292,6 +337,17 @@ def get_pool_daily_allowances(self) -> dict:
out[f"{p.scheme.lower()}://{p.hostname.lower()}"] = limit
return out

def paid_bypass_is_honoured(self) -> bool:
"""Whether a settled x402 payment may bypass the pool daily allowance.

True on a mainnet network, or on a testnet when the operator has
explicitly opted in. Testnet USDC costs nothing to obtain, so a payment
settled there is not evidence that anyone paid for anything.
"""
if self.X402_ALLOW_TESTNET_PAID_BYPASS:
return True
return not is_testnet_network(self.X402_NETWORK)

def get_pool_admin_addresses(self) -> List[str]:
"""Parse the pool-admin allow-list into lowercased 0x addresses."""
if not self.POOL_ADMIN_ADDRESSES:
Expand Down
13 changes: 12 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,18 @@ async def lifespan(app: FastAPI):
app.include_router(stamps.router, prefix=f"{settings.API_V1_STR}/stamps", tags=["stamps"], dependencies=x402_deps)
app.include_router(data.router, prefix=f"{settings.API_V1_STR}/data", tags=["data"], dependencies=x402_deps)
app.include_router(wallet.router, prefix=f"{settings.API_V1_STR}", tags=["wallet"])
app.include_router(pool.router, prefix=f"{settings.API_V1_STR}/pool", tags=["pool"])
# Pool: payment is OPTIONAL rather than required. A caller presenting an
# X-PAYMENT header has it settled and bypasses the daily allowance; one
# presenting none falls through to the allowance and is refused by the handler
# with a message naming the paid route if it is spent. Using the mandatory
# dependency here would answer 402 to every existing caller, none of which sends
# a payment header.
if settings.X402_ENABLED:
from app.x402.dependency import settle_payment_if_offered
pool_deps = [Depends(settle_payment_if_offered)]
else:
pool_deps = []
app.include_router(pool.router, prefix=f"{settings.API_V1_STR}/pool", tags=["pool"], dependencies=pool_deps)
app.include_router(notary.router, prefix=f"{settings.API_V1_STR}/notary", tags=["notary"])
# Chunk forwarding (Flow A). Router is always mounted; the handler guards on
# CHUNK_UPLOAD_ENABLED (returns 404 when off). The x402 dependency gates only the
Expand Down
Loading
Loading