From b6a87412137f18bf5c34e1aec19bf6d94f0eaf4a Mon Sep 17 00:00:00 2001 From: halfaipg Date: Mon, 27 Jul 2026 11:00:33 -0400 Subject: [PATCH 1/2] billing: add guarded Base funding rails --- alembic/AGENTS.md | 6 +- alembic/versions/0003_credits.py | 9 +- alembic/versions/0017_funding_deposits.py | 72 ++ alembic/versions/0018_x402_payments.py | 84 ++ deploy/env.template | 44 +- docs/FUNDING_RAIL.md | 215 ++++- grid_api/main.py | 3 + grid_api/routers/AGENTS.md | 11 +- grid_api/routers/accounts.py | 53 +- grid_api/routers/openai.py | 107 ++- grid_api/services/AGENTS.md | 17 +- grid_api/services/credits.py | 233 +++++- grid_api/services/deposits.py | 792 ++++++++++++++---- grid_api/services/settlement/aggregate.py | 64 +- .../tests/test_credits_concurrency.py | 49 +- grid_api/services/tests/test_deposits.py | 297 +++++++ grid_api/services/tests/test_x402_payments.py | 317 +++++++ grid_api/services/x402_payments.py | 326 +++++++ grid_api/v2/schema.py | 94 +++ pyproject.toml | 5 +- requirements-grid.txt | 5 +- 21 files changed, 2510 insertions(+), 293 deletions(-) create mode 100644 alembic/versions/0017_funding_deposits.py create mode 100644 alembic/versions/0018_x402_payments.py create mode 100644 grid_api/services/tests/test_deposits.py create mode 100644 grid_api/services/tests/test_x402_payments.py create mode 100644 grid_api/services/x402_payments.py diff --git a/alembic/AGENTS.md b/alembic/AGENTS.md index c76a51ec..be04e2f2 100644 --- a/alembic/AGENTS.md +++ b/alembic/AGENTS.md @@ -10,14 +10,16 @@ production database match the Grid-owned schema contracts without relying on - `env.py` - Alembic environment. - `script.py.mako` - revision template. -- `versions/` - ordered migration revisions. Current head: `0016` +- `versions/` - ordered migration revisions. Current head: `0018` (`0009` payout-pref cols, `0010` grid_revenue, `0011` grid_payout_legs, `0012` reservations.free_micro, `0013` universal identities, scoped keys, promotional grants, and reservations.promo_micro; `0014` codifies safe DB defaults for Grid-native inserts into the optional legacy waiting-prompts table; `0015` adds native service clients, expiring-key metadata, and reservation-time price snapshots; `0016` reconciles early production - constraint drift for ledger idempotency and validator evidence). + constraint drift for ledger idempotency and validator evidence; `0017` adds + immutable Base deposit receipts committed with purchased-credit movements; + `0018` records x402 reservation provenance and on-chain settlement receipts). ## Local Contracts diff --git a/alembic/versions/0003_credits.py b/alembic/versions/0003_credits.py index 540d518a..4dfa0530 100644 --- a/alembic/versions/0003_credits.py +++ b/alembic/versions/0003_credits.py @@ -39,7 +39,14 @@ def upgrade() -> None: ) op.create_table( "grid_credit_ledger", - sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True), + # SQLite only autoincrements an exact INTEGER PRIMARY KEY. Keep the + # canonical Postgres BIGINT while matching v2 schema on embedded grids. + sa.Column( + "id", + sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + primary_key=True, + autoincrement=True, + ), sa.Column("account_id", sa.Uuid, sa.ForeignKey("grid_accounts.id", ondelete="CASCADE"), nullable=False, index=True), sa.Column("delta_micro", sa.BigInteger, nullable=False), sa.Column("reason", sa.String(64), nullable=False), diff --git a/alembic/versions/0017_funding_deposits.py b/alembic/versions/0017_funding_deposits.py new file mode 100644 index 00000000..14edf03f --- /dev/null +++ b/alembic/versions/0017_funding_deposits.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2026 AI Power Grid +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Add immutable Base funding receipts. + +Revision ID: 0017 +Revises: 0016 +Create Date: 2026-07-27 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "0017" +down_revision = "0016" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "grid_deposits", + sa.Column( + "id", + sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + primary_key=True, + autoincrement=True, + ), + sa.Column( + "account_id", + sa.Uuid(), + sa.ForeignKey("grid_accounts.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("chain_id", sa.BigInteger(), nullable=False), + sa.Column("asset", sa.String(length=12), nullable=False), + sa.Column("token_address", sa.String(length=42), nullable=True), + sa.Column("tx_hash", sa.String(length=66), nullable=False), + sa.Column("block_number", sa.BigInteger(), nullable=False), + sa.Column("from_address", sa.String(length=42), nullable=False), + sa.Column("treasury_address", sa.String(length=42), nullable=False), + sa.Column("amount_raw", sa.Numeric(precision=78, scale=0), nullable=False), + sa.Column("amount_decimals", sa.Integer(), nullable=False), + sa.Column("price_micro", sa.BigInteger(), nullable=False), + sa.Column("price_source", sa.String(length=128), nullable=False), + sa.Column("price_timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("price_block", sa.BigInteger(), nullable=True), + sa.Column("credited_micro", sa.BigInteger(), nullable=False), + sa.Column("refund_address", sa.String(length=42), nullable=False), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("created", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("amount_raw > 0", name="ck_grid_deposit_positive_amount"), + sa.CheckConstraint("credited_micro > 0", name="ck_grid_deposit_positive_credit"), + sa.UniqueConstraint( + "chain_id", + "asset", + "tx_hash", + name="uq_grid_deposit_chain_asset_tx", + ), + ) + op.create_index("ix_grid_deposits_account_id", "grid_deposits", ["account_id"]) + op.create_index("ix_grid_deposits_asset", "grid_deposits", ["asset"]) + op.create_index("ix_grid_deposits_status", "grid_deposits", ["status"]) + op.create_index("ix_grid_deposits_created", "grid_deposits", ["created"]) + + +def downgrade() -> None: + op.drop_index("ix_grid_deposits_created", table_name="grid_deposits") + op.drop_index("ix_grid_deposits_status", table_name="grid_deposits") + op.drop_index("ix_grid_deposits_asset", table_name="grid_deposits") + op.drop_index("ix_grid_deposits_account_id", table_name="grid_deposits") + op.drop_table("grid_deposits") diff --git a/alembic/versions/0018_x402_payments.py b/alembic/versions/0018_x402_payments.py new file mode 100644 index 00000000..059e4aeb --- /dev/null +++ b/alembic/versions/0018_x402_payments.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: 2026 AI Power Grid +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Add x402 reservation provenance and settlement receipts. + +Revision ID: 0018 +Revises: 0017 +Create Date: 2026-07-27 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "0018" +down_revision = "0017" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("grid_reservations") as batch: + batch.add_column( + sa.Column( + "billing_source", + sa.String(length=16), + nullable=False, + server_default=sa.text("'credits'"), + ), + ) + batch.add_column(sa.Column("external_payer", sa.String(length=64), nullable=True)) + batch.add_column(sa.Column("actual_micro", sa.BigInteger(), nullable=True)) + batch.create_index("ix_grid_reservations_billing_source", ["billing_source"]) + batch.create_index("ix_grid_reservations_external_payer", ["external_payer"]) + + op.create_table( + "grid_x402_payments", + sa.Column("job_id", sa.String(length=64), primary_key=True), + sa.Column("authorization_id", sa.String(length=64), nullable=False), + sa.Column("payer", sa.String(length=64), nullable=False), + sa.Column("network", sa.String(length=64), nullable=False), + sa.Column("asset", sa.String(length=64), nullable=False), + sa.Column("pay_to", sa.String(length=64), nullable=False), + sa.Column("authorized_micro", sa.BigInteger(), nullable=False), + sa.Column("settled_micro", sa.BigInteger(), nullable=True), + sa.Column("tx_hash", sa.String(length=80), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("error", sa.String(length=255), nullable=True), + sa.Column("created", sa.DateTime(timezone=True), nullable=False), + sa.Column("settled", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "authorized_micro > 0", + name="ck_grid_x402_positive_authorization", + ), + sa.CheckConstraint( + "settled_micro IS NULL OR settled_micro > 0", + name="ck_grid_x402_positive_settlement", + ), + sa.CheckConstraint( + "settled_micro IS NULL OR settled_micro <= authorized_micro", + name="ck_grid_x402_settlement_within_authorization", + ), + sa.UniqueConstraint( + "authorization_id", + name="uq_grid_x402_payments_authorization_id", + ), + sa.UniqueConstraint("tx_hash", name="uq_grid_x402_payments_tx_hash"), + ) + op.create_index("ix_grid_x402_payments_payer", "grid_x402_payments", ["payer"]) + op.create_index("ix_grid_x402_payments_status", "grid_x402_payments", ["status"]) + op.create_index("ix_grid_x402_payments_created", "grid_x402_payments", ["created"]) + + +def downgrade() -> None: + op.drop_index("ix_grid_x402_payments_created", table_name="grid_x402_payments") + op.drop_index("ix_grid_x402_payments_status", table_name="grid_x402_payments") + op.drop_index("ix_grid_x402_payments_payer", table_name="grid_x402_payments") + op.drop_table("grid_x402_payments") + + with op.batch_alter_table("grid_reservations") as batch: + batch.drop_index("ix_grid_reservations_external_payer") + batch.drop_index("ix_grid_reservations_billing_source") + batch.drop_column("actual_micro") + batch.drop_column("external_payer") + batch.drop_column("billing_source") diff --git a/deploy/env.template b/deploy/env.template index c319e7a6..3a46ded2 100644 --- a/deploy/env.template +++ b/deploy/env.template @@ -161,18 +161,56 @@ GRID_HOLDER_MIN_AIPG=100000 GRID_HOLDINGS_TTL=600 GRID_ETH_PRICE_TTL=60 -# Base deposit claims remain disabled while GRID_DEPOSITS_ENABLED=0. Both -# treasury addresses must be controlled and monitored before enabling. +# Base funding remains disabled while GRID_DEPOSITS_ENABLED=0. USDC is the +# launch rail. AIPG additionally requires its own switch plus a fresh, +# conservative operator price epoch. Direct ETH remains disabled until an +# explicit conversion policy is selected. +GRID_BASE_CHAIN_ID=8453 GRID_BASE_RPC=https://mainnet.base.org GRID_USDC_CONTRACT=0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 GRID_USDC_TREASURY= -GRID_ETH_TREASURY= GRID_DEPOSIT_CONFIRMATIONS=3 +GRID_DEPOSIT_MIN_MICRO=10000 + +GRID_AIPG_DEPOSITS_ENABLED=0 GRID_AIPG_TOKEN=0xa1c0deCaFE3E9Bf06A5F29B7015CD373a9854608 +GRID_AIPG_TREASURY= GRID_AIPG_DECIMALS=18 +GRID_AIPG_CREDIT_PRICE_MICRO=0 +GRID_AIPG_PRICE_EPOCH= +GRID_AIPG_PRICE_AS_OF= +GRID_AIPG_PRICE_VALID_UNTIL= +GRID_AIPG_PRICE_BLOCK=0 +GRID_AIPG_PRICE_MAX_AGE_SECONDS=86400 +GRID_AIPG_DEPOSIT_HAIRCUT_BPS=300 +GRID_AIPG_MAX_DEPOSIT_MICRO=100000000 +GRID_AIPG_ACCOUNT_DAILY_MICRO=100000000 +GRID_AIPG_NETWORK_DAILY_MICRO=500000000 + +# Direct ETH is intentionally unavailable while this is "disabled". The +# "buffered" mode is a capped pilot only; production target = swap to USDC and +# claim the actual stablecoin proceeds. +GRID_ETH_CONVERSION_MODE=disabled +GRID_ETH_TREASURY= +GRID_ETH_DEPOSIT_HAIRCUT_BPS=100 +GRID_ETH_MAX_DEPOSIT_MICRO=100000000 +GRID_ETH_ACCOUNT_DAILY_MICRO=100000000 +GRID_ETH_NETWORK_DAILY_MICRO=500000000 GRID_ETH_USD_FEED=0x71041dddad3595f9ced3dccfbe3d1f4b0a16bb70 GRID_ETH_USD_FEED_DECIMALS=8 +# Accountless agent payments. Default-off and text/non-streaming only in the +# initial rail. The payer authorizes up to $1 USDC; Core settles actual +# grid-counted usage. Base mainnet requires CDP facilitator credentials. +GRID_X402_ENABLED=0 +GRID_X402_NETWORK=eip155:8453 +GRID_X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402 +GRID_X402_PAY_TO= +GRID_X402_MAX_AUTH_MICRO=1000000 +GRID_X402_DEFAULT_MAX_TOKENS=4096 +CDP_API_KEY_ID= +CDP_API_KEY_SECRET= + # ════════════════════════════════════════════════════════════════════ # [OPTIONAL] Pricing and future multi-asset payout policy # ════════════════════════════════════════════════════════════════════ diff --git a/docs/FUNDING_RAIL.md b/docs/FUNDING_RAIL.md index f2d69d90..08609fa9 100644 --- a/docs/FUNDING_RAIL.md +++ b/docs/FUNDING_RAIL.md @@ -1,53 +1,178 @@ -# Demand-side funding rail — USDC deposits → credits +# Demand-side funding rail -Status: **USDC deposit rail built + deployed DORMANT (2026-07-02).** Activates when -a treasury address is configured. This is step 1 of the dollar loop -(see GRID_ECONOMICS.md; the buyback/token-sink is a separate design). +Status: **implemented, migration-gated, and default-off.** USDC is the launch +rail. AIPG is code-complete behind a separate switch and expiring price epoch. +Direct ETH is conversion-gated and must remain disabled for normal production +until the Grid can turn it into USDC without carrying an open ETH/USD position. -## The flow +All rails fund one integer micro-USD purchased-credit balance. Credits buy Grid +services; they are non-transferable and non-withdrawable. Operator-reviewed +refunds go back to the recorded source address. -1. User sends **USDC on Base** to the grid treasury address. -2. User calls `POST /v1/account/deposits/claim` `{ "tx_hash": "0x…" }` with their - API key. -3. The grid (`services/deposits.py`) verifies on-chain via a Base RPC: - - the tx succeeded and has ≥ `GRID_DEPOSIT_CONFIRMATIONS` confirmations, - - it contains a **USDC Transfer to the treasury**, - - the **sender == the account's SIWE wallet** (so a transfer can't be claimed - by another account), - then credits the balance **1:1** — USDC has 6 decimals, so its base-unit value - IS micro-USD; no oracle, no conversion. -4. `credits.credit(ref="usdc:")` moves the balance and is **idempotent on the - tx hash**, so a deposit can never double-credit. +## Invariants -Balance is then spent by the existing reservation/settlement metering -(`credits.py`, dark until `GRID_CHARGING_ENABLED=1`). +Every successful Base claim commits one SQL transaction containing: -## Activate it (what's needed to go live) +1. An immutable `grid_deposits` receipt: chain, asset, token, transaction, + block, sender, treasury, raw amount/decimals, valuation source/time/block, + credited micro-USD, and refund address. +2. The idempotent `grid_credit_ledger` movement. +3. The updated `grid_credits` balance cache. -Set on prod (`/etc/aipg/grid.env`) and restart: -``` +Failure rolls back all three. `(chain_id, asset, tx_hash)` and the credit +ledger reference are unique, so retries cannot double-credit. The transaction +sender must be the authenticated account's linked wallet and the ERC-20 +transfer must be a direct transfer from that wallet to the configured treasury. + +## API + +- `GET /v1/account/deposits/config` - signed-in wallet, enabled assets, + addresses, valuation terms, and limits for the Console. +- `GET /v1/account/deposits` - immutable funding history for the account. +- `POST /v1/account/deposits/claim` - claim direct Base USDC. +- `POST /v1/account/deposits/claim-aipg` - claim guarded Base AIPG. +- `POST /v1/account/deposits/claim-eth` - direct ETH pilot; unavailable unless + the operator explicitly selects `buffered`. + +Each claim accepts `{ "tx_hash": "0x..." }`, waits for +`GRID_DEPOSIT_CONFIRMATIONS`, verifies that `GRID_BASE_RPC` reports the expected +chain id, and is safe to retry. + +## USDC launch + +Native Circle USDC on Base credits 1:1. Its six base-unit decimals are already +micro-USD, so there is no oracle or rounding conversion. + +```dotenv GRID_DEPOSITS_ENABLED=1 -GRID_USDC_TREASURY=0x… # the Base wallet that receives user USDC (OWNER TO PROVIDE) -GRID_BASE_RPC=https://… # a Base mainnet RPC (default: https://mainnet.base.org) -# optional: GRID_DEPOSIT_CONFIRMATIONS=3, GRID_USDC_CONTRACT= +GRID_USDC_TREASURY=0x... +GRID_BASE_RPC=https://... +GRID_DEPOSIT_CONFIRMATIONS=3 ``` -Until `GRID_USDC_TREASURY` is set the claim endpoint returns **503** (safe dormant). - -## Design notes / limits - -- **Self-custody only (V0):** the deposit must come FROM the account's linked - wallet. Users paying from an exchange (different `from`) can't claim — they'll - use the card/Stripe path (not built yet). Fine for the crypto-native early users. -- **Claim-flow, not a watcher:** V0 verifies a user-submitted tx hash rather than - continuously indexing the chain. Simpler + robust; a background deposit watcher - (auto-credit without the claim call) is a later upgrade. -- **Non-USDC (ETH/cbBTC/AIPG):** out of scope here — those swap to USDC at the - door before crediting (a future deposit-widget/DEX step), per credits.py's note. - -## What's next in the dollar loop - -- **Card path (Stripe)** for the non-crypto majority → same credit balance. -- **Flip charging on** (`GRID_CHARGING_ENABLED=1`) after the pre-flight balance - gate + den-input clamps + refund path land. -- **Payout + buyback sink:** revenue → worker USDC + revenue-funded AIPG buyback - that pays the worker AIPG slice and burns the surplus (see the token-sink design). + +Roll out with a linked operator wallet and a small real transfer first. Verify +the Base transaction, `grid_deposits`, credit ledger, balance, duplicate-claim +no-op, and one paid inference reservation before exposing the button broadly. + +## Guarded AIPG + +The AIPG/USDC Base pool is too thin for a spot price to be a credit oracle. +Core therefore accepts no autonomous pool quote. An operator must publish a +conservative valuation epoch with an as-of time, expiry, and optional source +block. Core applies a further haircut and enforces transaction, account/day, +and network/day USD exposure caps under a database lock. + +```dotenv +GRID_AIPG_DEPOSITS_ENABLED=1 +GRID_AIPG_TREASURY=0x... +GRID_AIPG_CREDIT_PRICE_MICRO=1200 +GRID_AIPG_PRICE_EPOCH=2026-07-27-a +GRID_AIPG_PRICE_AS_OF=2026-07-27T13:00:00Z +GRID_AIPG_PRICE_VALID_UNTIL=2026-07-28T13:00:00Z +GRID_AIPG_PRICE_BLOCK=12345678 +GRID_AIPG_DEPOSIT_HAIRCUT_BPS=300 +GRID_AIPG_MAX_DEPOSIT_MICRO=100000000 +GRID_AIPG_ACCOUNT_DAILY_MICRO=100000000 +GRID_AIPG_NETWORK_DAILY_MICRO=500000000 +``` + +`GRID_AIPG_CREDIT_PRICE_MICRO` is micro-USD per whole AIPG. The example is +$0.0012/AIPG before the 3% haircut; it is an example, not a live price. A missing, +future, stale, or expired epoch disables the rail with no fallback. + +Received AIPG stays in the AIPG treasury for reviewed network uses such as +worker/validator rewards. The funding path does not market-sell it. + +## ETH policy + +The target ETH experience is **pay with ETH, receive actual USDC proceeds**: +the wallet or a reviewed deposit router swaps ETH to USDC, sends USDC to the +treasury, and the normal USDC receipt is credited. This leaves no fixed-dollar +liability backed by volatile ETH and requires no ETH oracle in request billing. + +The existing direct-ETH verifier is retained only as a tightly capped +`GRID_ETH_CONVERSION_MODE=buffered` pilot. It applies a Chainlink valuation +haircut and the same transaction/account/network caps, but the operator still +owns treasury conversion risk. Keep it disabled for public launch. Even when +that operator-only claim path is enabled, the Console does not offer a direct +ETH transfer button. + +## x402 agent payments + +`POST /v1/x402/chat/completions` is the accountless agent rail. It is +code-complete but default-off. The client receives an x402 `402 Payment +Required`, authorizes up to `GRID_X402_MAX_AUTH_MICRO` in Base USDC, and retries +with the payment signature. Core then: + +1. verifies the authorization through the configured facilitator; +2. writes an external reservation plus `grid_x402_payments` receipt before + dispatch; +3. rejects requests whose maximum grid quote exceeds the signed ceiling; +4. settles worker-side usage from grid-counted prompt/completion tokens; +5. asks the facilitator to transfer only that actual amount; and +6. marks the receipt settled with its transaction hash. + +No Grid account, API key, free allowance, promotional grant, or purchased +balance is created. An authorized-but-unsettled x402 job is excluded from worker +payout aggregation. This prevents a valid signature followed by failed +on-chain settlement from creating a payable worker reward. + +The first route is deliberately non-streaming and text-only. x402's current +FastAPI middleware buffers the full response before settlement; advertising +streaming would turn SSE into a delayed response. Streaming, media, and +Anthropic/Responses compatibility require a separate stream-aware adapter. + +```dotenv +GRID_X402_ENABLED=1 +GRID_X402_NETWORK=eip155:8453 +GRID_X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402 +GRID_X402_PAY_TO=0x... +GRID_X402_MAX_AUTH_MICRO=1000000 +GRID_X402_DEFAULT_MAX_TOKENS=4096 +CDP_API_KEY_ID=... +CDP_API_KEY_SECRET=... +``` + +Base-mainnet startup fails closed without CDP facilitator credentials. Before +enabling, run a Base Sepolia end-to-end payment, prove actual-amount settlement, +exercise handler/facilitator/database failures, then run a small mainnet canary. +The implementation follows the x402 `upto` flow documented in the +[x402 seller quickstart](https://docs.x402.org/getting-started/quickstart-for-sellers) +and request-bound JWT authentication documented by +[Coinbase CDP](https://docs.cdp.coinbase.com/api-reference/v2/authentication). + +## Rollout order + +1. Deploy code with `GRID_DEPOSITS_ENABLED=0`, + `GRID_AIPG_DEPOSITS_ENABLED=0`, `GRID_ETH_CONVERSION_MODE=disabled`, and + `GRID_X402_ENABLED=0`. +2. Run Alembic through `0018`, then require `alembic check` to report no drift. +3. Configure dedicated monitored Base treasury addresses and an RPC with chain + id `8453`. Do not reuse a payout hot-wallet private key in Core or Console. +4. Enable USDC only. Use a linked operator wallet for a minimum-size canary; + prove one receipt, one credit movement, one balance increase, and a + duplicate-claim no-op before exposing Console funding. +5. Keep AIPG dark until the price-epoch owner, expiry alert, refund owner, and + low transaction/account/network caps are operational. The Console validates + known minimum and per-transaction limits before opening a transfer. +6. Keep direct ETH out of the public Console. Build the swap-to-USDC path before + calling ETH a production funding asset. +7. Prove x402 on Base Sepolia, then run a low-ceiling Base mainnet canary. + Worker payout must remain excluded until the USDC receipt is settled. +8. Add automated x402 receipt reconciliation before raising limits or adding + streaming/media routes. Card funding remains a later adapter into the same + non-transferable credit ledger. + +## Limitations + +- V0 is claim-based rather than a chain indexer. +- Transfers from exchanges cannot be claimed because the transaction sender is + not the linked wallet. +- Contract-routed token transfers are deliberately rejected. Add an audited + allowlist and transaction-intent binding before supporting swap routers. +- AIPG price epochs are operational input. They require an owner, monitoring, + and expiry automation before broad limits are raised. +- Card top-ups remain a future adapter into the account credit model. +- A post-settlement database failure can leave paid x402 revenue pending manual + reconciliation. The API fails the response and blocks worker payout; an + automated on-chain receipt reconciler is required before raising x402 limits. diff --git a/grid_api/main.py b/grid_api/main.py index 131a0f60..82f6a189 100644 --- a/grid_api/main.py +++ b/grid_api/main.py @@ -41,6 +41,7 @@ worker_enrollment, worker_ws, ) +from .services import x402_payments from .services.p2p import close_p2p, init_p2p logging.basicConfig( @@ -278,6 +279,8 @@ async def rate_limit_handler(request: Request, exc: RateLimitExceeded): ) +x402_payments.install_middleware(app) + app.add_middleware( CORSMiddleware, allow_origins=["*"], diff --git a/grid_api/routers/AGENTS.md b/grid_api/routers/AGENTS.md index 4ed060eb..346ba0e7 100644 --- a/grid_api/routers/AGENTS.md +++ b/grid_api/routers/AGENTS.md @@ -7,10 +7,13 @@ transport, accounts, stats, health/metrics. ## Ownership -- `openai.py` - `POST /v1/chat/completions`, `GET /v1/models`, +- `openai.py` - `POST /v1/chat/completions`, + `POST /v1/x402/chat/completions`, `GET /v1/models`, `GET /v1/models/{model_id}`. Sanitizes messages pre-dispatch, detects chat-routed media models, reserves text credits in live mode, and streams or collects worker output. + The x402 route is a separate default-off, accountless Base-USDC lane. It is + non-streaming and text-only until a stream-aware settlement adapter exists. - `anthropic.py` - `POST /v1/messages` raw Anthropic Messages passthrough. - `responses.py` - `POST /v1/responses` raw OpenAI Responses passthrough. - `_passthrough.py` - shared raw passthrough submit/stream/collect and deep @@ -33,7 +36,8 @@ transport, accounts, stats, health/metrics. pockets; `total_spendable_*` = what can pay NOW vs `total_preview_*`; `free.active` tracks GRID_FREE_SPENDABLE_LIVE), `GET /v1/account/jobs` (operator trust view: my workers' jobs + den + result_hash + signed flag, - scoped to the payout wallet), deposit claims (USDC + Chainlink-priced ETH). + scoped to the payout wallet), immutable deposit history/config, and deposit + claims (USDC launch rail, bounded expiring-price AIPG, conversion-gated ETH). `POST /v1/accounts/session` is the retired internal-token bridge. It resolves on exactly one authoritative identity (`oauth_sub` first, then wallet, then verified email only when it is the sole identity); supplemental @@ -73,6 +77,9 @@ transport, accounts, stats, health/metrics. - Demand billing must be applied uniformly across all paid inference entry points before live charging. Do not add a new work-submitting route without reserve/reconcile or an explicit no-charge policy. +- x402 requests must use the external reservation path and return the final + grid-counted micro-USD amount through the SDK settlement override. Never let + them draw daily free, promotional, or purchased account credit. - `worker_ws.py` must not trust worker-reported counts for rewards or customer billing without a server-side cap or verification path. - Core rejects retired model identities during the worker handshake. Worker-side diff --git a/grid_api/routers/accounts.py b/grid_api/routers/accounts.py index 45a23c85..aff42680 100644 --- a/grid_api/routers/accounts.py +++ b/grid_api/routers/accounts.py @@ -1278,6 +1278,26 @@ async def claim_deposit( return await deposits.verify_and_credit(form.tx_hash, user) +@router.post("/v1/account/deposits/claim-aipg") +@limiter.limit("20/minute") +async def claim_aipg_deposit( + request: Request, + form: ClaimDepositForm, + apikey: Optional[str] = Header(None), + authorization: Optional[str] = Header(None), +): + """Credit a direct AIPG-on-Base deposit under the current price epoch. + + AIPG funding is available only while an operator-published valuation is + fresh. Core applies a haircut and hard transaction/account/network caps + before atomically writing the deposit receipt and purchased credit. + """ + user = await _require_v2(apikey, authorization) + from ..services import deposits + + return await deposits.verify_and_credit_aipg(form.tx_hash, user) + + @router.post("/v1/account/deposits/claim-eth") @limiter.limit("20/minute") async def claim_eth_deposit( @@ -1288,11 +1308,9 @@ async def claim_eth_deposit( ): """Credit the account for a native-ETH deposit to the grid treasury. - The user sends ETH on Base, then submits the tx hash here; the grid verifies - the transfer (to the treasury, from the account's own wallet, enough - confirmations) and credits the prepaid balance in USD, priced ETH→USD via the - Chainlink feed at claim time. Idempotent on the tx hash. 503 until the grid is - configured with a treasury (GRID_ETH_TREASURY, or the shared GRID_USDC_TREASURY). + Direct ETH is disabled by default. A tightly capped ``buffered`` pilot can + be enabled explicitly; the target production flow swaps ETH to USDC first + and credits the actual stablecoin received. """ user = await _require_v2(apikey, authorization) from ..services import deposits @@ -1300,6 +1318,31 @@ async def claim_eth_deposit( return await deposits.verify_and_credit_eth(form.tx_hash, user) +@router.get("/v1/account/deposits/config") +async def get_deposit_config( + apikey: Optional[str] = Header(None), + authorization: Optional[str] = Header(None), +): + """Funding assets, Base addresses, limits, and non-withdrawable terms.""" + user = await _require_v2(apikey, authorization) + from ..services import deposits + + return deposits.funding_config(user) + + +@router.get("/v1/account/deposits") +async def get_deposit_history( + limit: int = 50, + apikey: Optional[str] = Header(None), + authorization: Optional[str] = Header(None), +): + """Immutable Base funding receipts for the authenticated account.""" + user = await _require_v2(apikey, authorization) + from ..services import deposits + + return {"deposits": await deposits.list_deposits(user, limit)} + + @router.get("/v1/account/credits") async def get_credits( apikey: Optional[str] = Header(None), diff --git a/grid_api/routers/openai.py b/grid_api/routers/openai.py index d5c1aa21..424481fd 100644 --- a/grid_api/routers/openai.py +++ b/grid_api/routers/openai.py @@ -30,7 +30,7 @@ # 32768 (le); keep this in step. Tunable via env. DEFAULT_MAX_TOKENS = int(os.getenv("GRID_DEFAULT_MAX_TOKENS", "32768")) -from fastapi import APIRouter, Header, HTTPException, Request +from fastapi import APIRouter, Header, HTTPException, Request, Response from fastapi.responses import StreamingResponse from ..ratelimit import limiter @@ -58,6 +58,8 @@ async def _observe_dry(user, model, prompt_tokens, completion_tokens, job_id): every job whether or not the client stayed connected. Doing it here too would double-settle and depend on the client staying connected. Never breaks a response (already sent), so errors are swallowed.""" + if (user or {}).get("billing_source") == "x402": + return if credits.charging_enabled_for(user, model): return try: @@ -138,6 +140,44 @@ async def chat_completions( raise HTTPException(status_code=500, detail="Internal error while processing the request.") +@router.post("/v1/x402/chat/completions") +@limiter.limit("30/minute") +async def x402_chat_completions( + request: Request, + response: Response, + body: ChatCompletionRequest, +): + """Accountless, non-streaming chat paid per request in Base USDC.""" + from ..services import x402_payments + + if not x402_payments.ENABLED: + raise HTTPException(status_code=404, detail="x402 payments are not enabled") + if body.stream: + raise HTTPException( + status_code=400, + detail="x402 streaming is not enabled; send stream=false", + ) + body.max_tokens = body.max_tokens or x402_payments.DEFAULT_MAX_TOKENS + payment_payload = getattr(request.state, "payment_payload", None) + payment_requirements = getattr(request.state, "payment_requirements", None) + if payment_payload is None or payment_requirements is None: + raise HTTPException(status_code=500, detail="x402 middleware did not verify this request") + details = x402_payments.payment_payload_details( + payment_payload, + payment_requirements, + ) + user = { + "wallet": details["payer"], + "billing_source": "x402", + } + return await _handle_chat_completions_for_user( + body, + user, + x402_payment=(payment_payload, payment_requirements), + x402_response=response, + ) + + async def _detect_media_model(model: str) -> Optional[str]: """Return 'image'/'video' if `model` is a media model, else None. @@ -292,6 +332,16 @@ async def _handle_chat_completions(request: ChatCompletionRequest, apikey: str, apikey, user_assertion, user_token=user_token, required_scope="inference.submit", ) + return await _handle_chat_completions_for_user(request, user) + + +async def _handle_chat_completions_for_user( + request: ChatCompletionRequest, + user: dict, + *, + x402_payment: tuple | None = None, + x402_response: Response | None = None, +): _assert_request_size(request.messages) # Media abstraction: if the requested model is an image/video model, run a @@ -300,6 +350,11 @@ async def _handle_chat_completions(request: ChatCompletionRequest, apikey: str, # dedicated /v1/images|videos endpoints stay for advanced control. media_kind = await _detect_media_model(request.model) if media_kind: + if x402_payment is not None: + raise HTTPException( + status_code=400, + detail="the initial x402 rail supports text models only", + ) await quota.check_and_consume(dict(user)) return await _chat_media( request, media_kind, account_id=user.get("account_id"), user=user, @@ -348,7 +403,8 @@ async def _handle_chat_completions(request: ChatCompletionRequest, apikey: str, # Free-tier daily quota. Checked here (after auth + worker availability) # so a user only spends quota on a request that's actually going to queue. - await quota.check_and_consume(dict(user)) + if x402_payment is None: + await quota.check_and_consume(dict(user)) # Sanitize messages — strip credentials before they reach workers. # We can read+scrub here because this is the OBSERVE-mode path (the grid is @@ -386,6 +442,8 @@ async def _handle_chat_completions(request: ChatCompletionRequest, apikey: str, # Create job job_id = str(uuid4()) + if x402_response is not None: + x402_response.headers["X-Grid-Job-ID"] = job_id payload = { "request": request_body, "api_format": "openai-chat", @@ -411,15 +469,27 @@ async def _handle_chat_completions(request: ChatCompletionRequest, apikey: str, # allowed). Released in the finally below (collect/error) or, for a stream, # handed to the generator's finally (which fires on finish AND disconnect). aid = (user or {}).get("account_id") + if x402_payment is not None: + aid = f"x402:{(user or {}).get('wallet', '').lower()}" if aid and not await concurrency.acquire(aid, "text", TEXT_CONCURRENCY): raise HTTPException(status_code=429, detail=f"Too many concurrent requests (limit {TEXT_CONCURRENCY}). Retry shortly.") inflight_held = bool(aid) try: - auth = await credits.authorize_request( - user, model, prompt_toks, request.max_tokens, job_id, - record_reservation=True, - ) + if x402_payment is not None: + auth = await credits.authorize_x402_request( + model, + prompt_toks, + request.max_tokens, + job_id, + payment_payload=x402_payment[0], + payment_requirements=x402_payment[1], + ) + else: + auth = await credits.authorize_request( + user, model, prompt_toks, request.max_tokens, job_id, + record_reservation=True, + ) if not auth["ok"]: raise HTTPException(status_code=402, detail=auth.get("reason", "payment required")) @@ -449,7 +519,30 @@ async def _handle_chat_completions(request: ChatCompletionRequest, apikey: str, return resp else: # Awaited → the handler stays alive through it; the finally releases. - return await _collect_response(job_id, model, user, request.seed, prompt_toks, routing_meta) + result = await _collect_response( + job_id, + model, + user, + request.seed, + prompt_toks, + routing_meta, + ) + if x402_payment is not None: + from x402.http.middleware.fastapi import set_settlement_overrides + + actual_micro = await credits.reservation_actual_micro(job_id) + if actual_micro is None or actual_micro <= 0: + raise HTTPException( + status_code=503, + detail="x402 usage settlement is not ready; no payment was collected", + ) + if x402_response is None: + raise RuntimeError("x402 response context missing") + set_settlement_overrides( + x402_response, + {"amount": str(actual_micro)}, + ) + return result finally: if inflight_held: await concurrency.release(aid, "text") diff --git a/grid_api/services/AGENTS.md b/grid_api/services/AGENTS.md index 2b940718..4c789ef8 100644 --- a/grid_api/services/AGENTS.md +++ b/grid_api/services/AGENTS.md @@ -25,7 +25,10 @@ content sanitization, and reward settlement. `assertions.py` (legacy app-only assertions), `economics.py` (splits, payout-asset + conversion-fee knobs, `worker_share_bps`), `holdings.py` (cached on-chain AIPG balance + Chainlink ETH/USD), - `deposits.py` (USDC/ETH deposit claims), `model_registry.py` (ModelVault sync). + `deposits.py` (atomic Base funding receipts plus USDC, bounded AIPG, and + conversion-gated ETH claims), `x402_payments.py` (default-off accountless + Base USDC authorization and settlement receipts), `model_registry.py` + (ModelVault sync). - **Worker trust:** `worker_identity.py` verifies a payout-wallet delegation to a funds-less per-rig signer plus a fresh registration proof; `signing.py` verifies that delegated signer over `aipg-job:{job_id}:{result_hash}`. @@ -55,6 +58,18 @@ content sanitization, and reward settlement. prevent poison-job eviction cascades. Stale jobs reclaimed by the loop in `main.py`. - Money paths must stay idempotent and tested; value-moving credit ledger writes require non-null refs and must not overdraft under concurrency. +- A successful Base funding claim atomically writes its immutable + `grid_deposits` receipt and purchased-credit ledger movement. AIPG valuation + must use a fresh operator epoch plus hard transaction/account/network caps; + do not derive credit from the thin pool's spot price. Deposit claims are the + narrow exception to the no-request-path-chain-read rule: they must verify the + configured RPC is on the expected chain before trusting transaction/receipt + data, and they must never sit in the inference hot path. +- x402 authorization is not revenue. Its reservation and verified-payment row + commit before dispatch; worker payout aggregation must exclude that job until + the facilitator result is durably `settled`. The initial route is Base USDC, + `upto`, text-only, and non-streaming because the upstream middleware buffers + the response before settlement. - Media billing reserves exact deterministic cost before dispatch and refunds on non-running paths; text billing reserves max cost and reconciles against trusted usage. diff --git a/grid_api/services/credits.py b/grid_api/services/credits.py index 690d0191..dfc50676 100644 --- a/grid_api/services/credits.py +++ b/grid_api/services/credits.py @@ -8,11 +8,11 @@ """Prepaid credit ledger — USD-native (integer micro-USD, USD × 1e6). -No runtime oracle: a USDC deposit credits the balance 1:1 (micro-USD), and a -charge debits USD directly (priced by `pricing`, which pegs to competitors at -deploy time only). `balance_micro` / `delta_micro` are micro-USD. Non-USDC -deposits (ETH/cbBTC) are swapped to USDC at the door; AIPG deposits credit at -the peg — the conversion happens in the deposit watcher, never here. +No request-time oracle: a USDC deposit credits the balance 1:1 (micro-USD), and +a charge debits USD directly (priced by `pricing`, which pegs to competitors at +deploy time only). `balance_micro` / `delta_micro` are micro-USD. Non-stable +funding adapters perform and record their bounded deposit-time valuation before +calling this ledger; request settlement never reprices deposited value. `debit` is overdraft-safe (a conditional UPDATE: balance only moves if it covers the charge) and idempotent (unique `ref` per charge — a retried request @@ -246,7 +246,9 @@ async def _insert_reservation_in_session(s, job_id, account_id, model: str, rese prompt_toks: int, free_micro: int = 0, promo_micro: int = 0, input_rate: int | None = None, output_rate: int | None = None, discount_bps: int = 0, - service_id: str | None = None) -> None: + service_id: str | None = None, + billing_source: str = "credits", + external_payer: str | None = None) -> None: await s.execute(sa.insert(reservations_t).values( job_id=str(job_id), account_id=account_id, model=model, reserved_micro=int(reserved_micro or 0), free_micro=int(free_micro or 0), @@ -256,6 +258,8 @@ async def _insert_reservation_in_session(s, job_id, account_id, model: str, rese output_per_mtok_micro=output_rate, discount_bps=int(discount_bps or 0), service_id=service_id, + billing_source=billing_source, + external_payer=external_payer, status="held", created=_now(), )) @@ -601,6 +605,164 @@ async def authorize_request(user: dict, model: str, prompt_tokens: int, max_toke "reason": "insufficient credits"} +async def authorize_x402_request( + model: str, + prompt_tokens: int, + max_tokens: int, + job_id, + *, + payment_payload, + payment_requirements, +) -> dict: + """Open a durable external reservation after x402 verification. + + This never touches a Grid credit balance or daily free/promotional pockets. + The x402 middleware already verified the payer's authorization; we still + price default-deny, require the quoted maximum to fit inside the signed + authorization, and atomically write both reservation and payment receipt + before dispatch. + """ + from . import x402_payments + + if not x402_payments.ENABLED: + return { + "ok": False, + "reserved": 0, + "status": "disabled", + "reason": "x402 payments are not enabled", + } + if not pricing.is_priced_for(model, "text"): + return { + "ok": False, + "reserved": 0, + "status": "unpriced", + "reason": f"model '{model}' has no text price", + } + + details = x402_payments.payment_payload_details( + payment_payload, + payment_requirements, + ) + if details["network"] != x402_payments.NETWORK: + return { + "ok": False, + "reserved": 0, + "status": "wrong_network", + "reason": "x402 payment uses the wrong network", + } + if details["asset"] != x402_payments.USDC: + return { + "ok": False, + "reserved": 0, + "status": "wrong_asset", + "reason": "x402 payment must use configured Base USDC", + } + if details["pay_to"] != x402_payments.PAY_TO: + return { + "ok": False, + "reserved": 0, + "status": "wrong_recipient", + "reason": "x402 payment uses the wrong recipient", + } + + input_rate, output_rate = _snapshot_rates(model) + cost = _quote_snapshot(prompt_tokens, max_tokens, input_rate, output_rate, 0) + if cost <= 0: + return { + "ok": False, + "reserved": 0, + "status": "invalid_price", + "reason": "x402 requires a positive quoted amount", + } + if cost > int(details["authorized_micro"]): + return { + "ok": False, + "reserved": 0, + "status": "authorization_too_small", + "reason": ( + f"request can cost up to {cost} micro-USD; " + f"x402 authorization covers {details['authorized_micro']}" + ), + } + + async with await new_session() as session: + try: + await _insert_reservation_in_session( + session, + job_id, + None, + model, + cost, + prompt_tokens, + input_rate=input_rate, + output_rate=output_rate, + billing_source="x402", + external_payer=details["payer"], + ) + await x402_payments.insert_verified_in_session( + session, + job_id=str(job_id), + details=details, + ) + await session.commit() + except IntegrityError: + await session.rollback() + async with await new_session() as check: + row = ( + await check.execute( + sa.select( + reservations_t.c.reserved_micro, + reservations_t.c.external_payer, + reservations_t.c.billing_source, + ).where(reservations_t.c.job_id == str(job_id)) + ) + ).first() + if ( + row + and row[1] == details["payer"] + and row[2] == "x402" + and int(row[0]) <= int(details["authorized_micro"]) + ): + return {"ok": True, "reserved": int(row[0]), "status": "already"} + return { + "ok": False, + "reserved": 0, + "status": "conflict", + "reason": "x402 request id conflicts with another payment", + } + except Exception: + await session.rollback() + logger.exception("x402 reservation failed job=%s", job_id) + return { + "ok": False, + "reserved": 0, + "status": "reservation_failed", + "reason": "x402 reservation failed", + } + return { + "ok": True, + "reserved": cost, + "status": "ok", + "payer": details["payer"], + } + + +async def reservation_actual_micro(job_id) -> int | None: + """Return the immutable terminal charge once worker-side settlement wins.""" + async with await new_session() as session: + row = ( + await session.execute( + sa.select( + reservations_t.c.status, + reservations_t.c.actual_micro, + ).where(reservations_t.c.job_id == str(job_id)) + ) + ).first() + if not row or row[0] != "settled" or row[1] is None: + return None + return int(row[1]) + + async def reconcile(user: dict, model: str, prompt_tokens: int, completion_tokens: int, reserved_micro: int, job_id) -> None: """Post-completion settlement (LIVE mode only). We reserved the max up front; @@ -1063,18 +1225,50 @@ async def record_and_settle(*, ledger_values: dict, completion_tokens: int = 0, reservations_t.c.input_per_mtok_micro, reservations_t.c.output_per_mtok_micro, reservations_t.c.discount_bps, - reservations_t.c.service_id) + reservations_t.c.service_id, + reservations_t.c.billing_source) .where(reservations_t.c.job_id == job_id) )).first() if not row: await s.commit() # ledger stands; nothing to settle (dry-run/legacy/free) return "no_reservation" + aid, model = row[0], row[1] + from .identities import canonical_account_id + paid_account_id = await canonical_account_id(aid, session=s) if aid else aid + reserved, prompt_toks = int(row[2] or 0), int(row[3] or 0) + free_held = int(row[4] or 0) + promo_held = int(row[5] or 0) + free_restore = None # (keep_micro) — Redis, applied after commit + promo_restore = None + service_keep = reserved + billing_source = row[10] or "credits" + actual = reserved + if reserved > 0 and not exact: + if row[6] is not None and row[7] is not None: + actual = _quote_snapshot( + prompt_toks, completion_tokens, int(row[6]), int(row[7]), int(row[8] or 0), + ) + else: + # Compatibility for reservations opened before migration 0015. + actual = pricing.quote_text(model, prompt_toks, int(completion_tokens or 0)) + if actual > reserved and billing_source == "x402": + _economic_alert( + "x402_settlement_under_authorized", + "critical", + "Grid-counted usage exceeded the payer's x402 authorization.", + job=job_id, + model=model, + actual_micro=actual, + authorized_micro=reserved, + ) + actual = reserved + service_keep = actual res = await s.execute( sa.update(reservations_t) .where(sa.and_(reservations_t.c.job_id == job_id, reservations_t.c.status == "held")) - .values(status="settled", settled=_now()) + .values(status="settled", settled=_now(), actual_micro=actual) ) if res.rowcount == 0: # A reservation EXISTS but is no longer held — it was already @@ -1088,30 +1282,13 @@ async def record_and_settle(*, ledger_values: dict, completion_tokens: int = 0, "late_success_no_payout", "warning", "A worker success arrived after its demand reservation was already closed.", - account=row[0], + account=aid, job=job_id, - model=row[1], + model=model, ) return "stale_no_payout" - aid, model = row[0], row[1] - from .identities import canonical_account_id - paid_account_id = await canonical_account_id(aid, session=s) if aid else aid - reserved, prompt_toks = int(row[2] or 0), int(row[3] or 0) - free_held = int(row[4] or 0) - promo_held = int(row[5] or 0) - free_restore = None # (keep_micro) — Redis, applied after commit - promo_restore = None - service_keep = reserved - if aid and reserved > 0 and not exact: - if row[6] is not None and row[7] is not None: - actual = _quote_snapshot( - prompt_toks, completion_tokens, int(row[6]), int(row[7]), int(row[8] or 0), - ) - else: - # Compatibility for reservations opened before migration 0015. - actual = pricing.quote_text(model, prompt_toks, int(completion_tokens or 0)) - service_keep = actual + if aid and reserved > 0 and billing_source == "credits" and not exact: # Three pockets, never converted: promo → daily free → paid # (matching the draw); the paid refund/extra moves in THIS txn, # the free restore follows the commit (crash between = free-day diff --git a/grid_api/services/deposits.py b/grid_api/services/deposits.py index 0961b70f..b11c63d3 100644 --- a/grid_api/services/deposits.py +++ b/grid_api/services/deposits.py @@ -1,144 +1,551 @@ # SPDX-FileCopyrightText: 2026 AI Power Grid # SPDX-License-Identifier: AGPL-3.0-or-later -"""USDC-on-Base deposit → credit rail (the demand-side funding front door). - -A user sends USDC to the grid treasury on Base, then submits the tx hash; we -verify the transfer on-chain and credit their prepaid balance 1:1 (USDC has 6 -decimals, so its base-unit value IS micro-USD — no oracle, no conversion). The -credits service (credit(), idempotent on `ref`) is the only thing that moves the -balance, so a tx can never double-credit. - -Config-gated and DORMANT until deployed with a treasury address (mirrors the -charging-dark / probe-dark pattern): with GRID_USDC_TREASURY unset the claim -endpoint returns 503, so this ships safely before the treasury exists. - -Security posture: -- The deposit is bound to the authenticated account's SIWE wallet (tx `from` - must equal it) so nobody can claim someone else's transfer. -- Confirmation-gated (GRID_DEPOSIT_CONFIRMATIONS) so a reorg can't un-mine a - credited tx. -- Idempotent on the tx hash via credits.credit(ref=...). +"""Base deposits -> non-transferable Grid service credits. + +USDC is the launch rail and credits 1:1 in integer micro-USD. AIPG is an +explicitly bounded rail: an operator publishes a conservative, expiring price +epoch and the service enforces per-transaction, per-account/day, and +network/day exposure caps. It never derives credit from manipulable spot price. + +Every successful claim atomically commits: + +* one immutable ``grid_deposits`` receipt with the raw on-chain value and + valuation provenance; and +* one idempotent ``grid_credit_ledger`` movement plus its cached balance. + +ETH verification remains implemented, but the rail stays unavailable unless an +explicit conversion policy is selected. Production should route ETH through a +swap-to-USDC transaction and claim the actual USDC received rather than leave a +dollar liability backed by volatile treasury inventory. """ +from __future__ import annotations + import logging import os +from datetime import UTC, datetime, timedelta +from decimal import Decimal import httpx +import sqlalchemy as sa from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError +from ..database import new_session +from ..v2.schema import credits as credits_t +from ..v2.schema import deposits as deposits_t from . import alerts, credits logger = logging.getLogger("grid_api.deposits") +CHAIN_ID = int(os.getenv("GRID_BASE_CHAIN_ID", "8453") or 8453) DEPOSITS_ENABLED = os.getenv("GRID_DEPOSITS_ENABLED", "0").lower() in ("1", "true", "yes", "on") -# Where users send USDC. Unset → rail is dormant (503). TREASURY = os.getenv("GRID_USDC_TREASURY", "").strip().lower() BASE_RPC = os.getenv("GRID_BASE_RPC", "https://mainnet.base.org").strip() -# USDC on Base (6 decimals) — the canonical Circle contract. Overridable for testnet. -USDC = os.getenv("GRID_USDC_CONTRACT", "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913").strip().lower() -CONFIRMATIONS = int(os.getenv("GRID_DEPOSIT_CONFIRMATIONS", "3") or 3) -# Native-ETH deposits credit at the Chainlink ETH/USD price at claim time (a -# deposit-time oracle; the request path stays oracle-free). Recipient defaults to -# the same treasury as USDC. Unset treasury → the ETH claim endpoint 503s too. +USDC = os.getenv( + "GRID_USDC_CONTRACT", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", +).strip().lower() +CONFIRMATIONS = max(1, int(os.getenv("GRID_DEPOSIT_CONFIRMATIONS", "3") or 3)) +MIN_CREDIT_MICRO = max(1, int(os.getenv("GRID_DEPOSIT_MIN_MICRO", "10000") or 10000)) + +AIPG_ENABLED = os.getenv("GRID_AIPG_DEPOSITS_ENABLED", "0").lower() in ("1", "true", "yes", "on") +AIPG_TOKEN = os.getenv( + "GRID_AIPG_TOKEN", + "0xa1c0deCaFE3E9Bf06A5F29B7015CD373a9854608", +).strip().lower() +AIPG_TREASURY = (os.getenv("GRID_AIPG_TREASURY", "") or TREASURY).strip().lower() +AIPG_DECIMALS = int(os.getenv("GRID_AIPG_DECIMALS", "18") or 18) +AIPG_PRICE_MICRO = int(os.getenv("GRID_AIPG_CREDIT_PRICE_MICRO", "0") or 0) +AIPG_PRICE_EPOCH = os.getenv("GRID_AIPG_PRICE_EPOCH", "").strip() +AIPG_PRICE_AS_OF_RAW = os.getenv("GRID_AIPG_PRICE_AS_OF", "").strip() +AIPG_PRICE_VALID_UNTIL_RAW = os.getenv("GRID_AIPG_PRICE_VALID_UNTIL", "").strip() +AIPG_PRICE_BLOCK = int(os.getenv("GRID_AIPG_PRICE_BLOCK", "0") or 0) +AIPG_PRICE_MAX_AGE_SECONDS = max( + 300, + int(os.getenv("GRID_AIPG_PRICE_MAX_AGE_SECONDS", "86400") or 86400), +) +AIPG_HAIRCUT_BPS = min( + 5_000, + max(0, int(os.getenv("GRID_AIPG_DEPOSIT_HAIRCUT_BPS", "300") or 300)), +) +AIPG_MAX_DEPOSIT_MICRO = max( + MIN_CREDIT_MICRO, + int(os.getenv("GRID_AIPG_MAX_DEPOSIT_MICRO", "100000000") or 100_000_000), +) +AIPG_ACCOUNT_DAILY_MICRO = max( + AIPG_MAX_DEPOSIT_MICRO, + int(os.getenv("GRID_AIPG_ACCOUNT_DAILY_MICRO", "100000000") or 100_000_000), +) +AIPG_NETWORK_DAILY_MICRO = max( + AIPG_ACCOUNT_DAILY_MICRO, + int(os.getenv("GRID_AIPG_NETWORK_DAILY_MICRO", "500000000") or 500_000_000), +) + +# Direct ETH creates USD liabilities before conversion, so it is disabled by +# default even when the shared deposit switch and treasury are configured. +# "buffered" is an explicit operator opt-in for a tightly capped pilot. +ETH_CONVERSION_MODE = os.getenv("GRID_ETH_CONVERSION_MODE", "disabled").strip().lower() ETH_TREASURY = (os.getenv("GRID_ETH_TREASURY", "") or TREASURY).strip().lower() +ETH_HAIRCUT_BPS = min( + 5_000, + max(100, int(os.getenv("GRID_ETH_DEPOSIT_HAIRCUT_BPS", "100") or 100)), +) +ETH_MAX_DEPOSIT_MICRO = max( + MIN_CREDIT_MICRO, + int(os.getenv("GRID_ETH_MAX_DEPOSIT_MICRO", "100000000") or 100_000_000), +) +ETH_ACCOUNT_DAILY_MICRO = max( + ETH_MAX_DEPOSIT_MICRO, + int(os.getenv("GRID_ETH_ACCOUNT_DAILY_MICRO", "100000000") or 100_000_000), +) +ETH_NETWORK_DAILY_MICRO = max( + ETH_ACCOUNT_DAILY_MICRO, + int(os.getenv("GRID_ETH_NETWORK_DAILY_MICRO", "500000000") or 500_000_000), +) -# keccak256("Transfer(address,address,uint256)") _TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" +def _now() -> datetime: + return datetime.now(UTC) + + +def _parse_time(raw: str) -> datetime | None: + if not raw: + return None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _valid_address(value: str) -> bool: + value = (value or "").lower() + return value.startswith("0x") and len(value) == 42 and all(c in "0123456789abcdef" for c in value[2:]) + + def is_configured() -> bool: - return DEPOSITS_ENABLED and bool(TREASURY) + return DEPOSITS_ENABLED and _valid_address(TREASURY) and _valid_address(USDC) + + +def _aipg_price_epoch(now: datetime | None = None) -> tuple[datetime, datetime] | None: + now = now or _now() + as_of = _parse_time(AIPG_PRICE_AS_OF_RAW) + valid_until = _parse_time(AIPG_PRICE_VALID_UNTIL_RAW) + if ( + AIPG_PRICE_MICRO <= 0 + or not AIPG_PRICE_EPOCH + or as_of is None + or valid_until is None + or as_of > now + or valid_until < now + or valid_until <= as_of + or now - as_of > timedelta(seconds=AIPG_PRICE_MAX_AGE_SECONDS) + ): + return None + return as_of, valid_until + + +def aipg_is_configured() -> bool: + return ( + DEPOSITS_ENABLED + and AIPG_ENABLED + and _valid_address(AIPG_TREASURY) + and _valid_address(AIPG_TOKEN) + and _aipg_price_epoch() is not None + ) + + +def eth_is_configured() -> bool: + return ( + DEPOSITS_ENABLED + and ETH_CONVERSION_MODE == "buffered" + and _valid_address(ETH_TREASURY) + ) + + +def funding_config(account: dict) -> dict: + """Safe client configuration for the signed-in Console funding flow.""" + epoch = _aipg_price_epoch() + wallet = (account.get("wallet") or "").lower() + return { + "chain": {"id": CHAIN_ID, "name": "Base"}, + "linked_wallet": wallet if _valid_address(wallet) else None, + "terms": { + "unit": "USD", + "credits_transferable": False, + "credits_withdrawable": False, + "refund_policy": "operator_review_to_source", + }, + "assets": [ + { + "asset": "USDC", + "enabled": is_configured(), + "treasury": TREASURY or None, + "token_address": USDC, + "decimals": 6, + "price_micro": 1_000_000, + "minimum_credit_micro": MIN_CREDIT_MICRO, + "status": "available" if is_configured() else "disabled", + }, + { + "asset": "AIPG", + "enabled": aipg_is_configured(), + "treasury": AIPG_TREASURY or None, + "token_address": AIPG_TOKEN, + "decimals": AIPG_DECIMALS, + "price_micro": AIPG_PRICE_MICRO if epoch else None, + "price_epoch": AIPG_PRICE_EPOCH if epoch else None, + "price_valid_until": epoch[1].isoformat() if epoch else None, + "haircut_bps": AIPG_HAIRCUT_BPS, + "minimum_credit_micro": MIN_CREDIT_MICRO, + "maximum_credit_micro": AIPG_MAX_DEPOSIT_MICRO, + "account_daily_micro": AIPG_ACCOUNT_DAILY_MICRO, + "status": "available" if aipg_is_configured() else "price_unavailable", + }, + { + "asset": "ETH", + # A buffered treasury pilot can accept operator-reviewed claims, + # but the public Console must wait for conversion-backed funding. + "enabled": False, + "backend_claim_enabled": eth_is_configured(), + "treasury": ETH_TREASURY or None, + "token_address": None, + "decimals": 18, + "conversion_mode": ETH_CONVERSION_MODE, + "haircut_bps": ETH_HAIRCUT_BPS, + "minimum_credit_micro": MIN_CREDIT_MICRO, + "maximum_credit_micro": ETH_MAX_DEPOSIT_MICRO, + "status": "operator_pilot" if eth_is_configured() else "conversion_required", + }, + ], + } async def _rpc(method: str, params: list): - async with httpx.AsyncClient(timeout=15) as c: - r = await c.post(BASE_RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}) - r.raise_for_status() - body = r.json() + async with httpx.AsyncClient(timeout=15) as client: + response = await client.post( + BASE_RPC, + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + ) + response.raise_for_status() + body = response.json() if body.get("error"): raise RuntimeError(f"rpc {method}: {body['error']}") return body.get("result") +def _normalize_tx_hash(tx_hash: str) -> str: + value = (tx_hash or "").strip().lower() + if not (value.startswith("0x") and len(value) == 66 and all(c in "0123456789abcdef" for c in value[2:])): + raise HTTPException(400, detail="tx_hash must be a 0x-prefixed 32-byte hash.") + return value + + def _addr_from_topic(topic: str) -> str: - # 32-byte-padded address → 0x + last 20 bytes. return ("0x" + topic[-40:]).lower() -async def verify_and_credit(tx_hash: str, account: dict) -> dict: - """Verify a USDC-to-treasury transfer and credit the account. Idempotent.""" - if not is_configured(): - raise HTTPException(503, detail="USDC deposits are not enabled on this grid yet.") - tx_hash = (tx_hash or "").strip().lower() - if not (tx_hash.startswith("0x") and len(tx_hash) == 66): - raise HTTPException(400, detail="tx_hash must be a 0x-prefixed 32-byte hash.") - +async def _confirmed_transaction(tx_hash: str, asset: str) -> tuple[dict, dict, int]: try: + chain_id = int(await _rpc("eth_chainId", []), 16) + if chain_id != CHAIN_ID: + raise RuntimeError( + f"configured Base RPC returned chain id {chain_id}, expected {CHAIN_ID}", + ) + tx = await _rpc("eth_getTransactionByHash", [tx_hash]) receipt = await _rpc("eth_getTransactionReceipt", [tx_hash]) - except Exception as e: - logger.warning("deposit rpc failed for %s: %s", tx_hash, e) + except Exception as exc: + logger.warning("%s deposit rpc failed for %s: %s", asset.lower(), tx_hash, exc) alerts.emit( "deposit_rpc_failed", "critical", - "A USDC deposit claim could not be verified against Base.", - fields={"asset": "USDC", "tx": alerts.opaque_id(tx_hash), "error_type": type(e).__name__}, - dedupe_key="deposit-rpc:usdc", + "A Base deposit claim could not be verified.", + fields={"asset": asset, "tx": alerts.opaque_id(tx_hash), "error_type": type(exc).__name__}, + dedupe_key=f"deposit-rpc:{asset.lower()}", ) raise HTTPException(502, detail="Could not reach Base to verify the transaction.") - if not receipt: + if not tx or not receipt: raise HTTPException(400, detail="Transaction not found or not yet mined.") if receipt.get("status") not in ("0x1", 1): raise HTTPException(400, detail="Transaction failed on-chain.") - - # Confirmation gate — a reorg must not un-mine a credited deposit. + block_number = int(receipt["blockNumber"], 16) try: - latest = int(await _rpc("eth_blockNumber", []), 16) - confs = latest - int(receipt["blockNumber"], 16) + latest_raw = await _rpc("eth_blockNumber", []) + confirmations = int(latest_raw, 16) - block_number + 1 except Exception: - confs = 0 - if confs < CONFIRMATIONS: - raise HTTPException(425, detail=f"Only {confs} confirmations; need {CONFIRMATIONS}. Retry shortly.") - - # Find the USDC Transfer whose recipient is our treasury. - value_micro = 0 - sender = "" - for log in receipt.get("logs", []): - topics = log.get("topics", []) - if ( - log.get("address", "").lower() == USDC - and len(topics) >= 3 - and topics[0].lower() == _TRANSFER_TOPIC - and _addr_from_topic(topics[2]) == TREASURY - ): - sender = _addr_from_topic(topics[1]) - value_micro = int(log.get("data", "0x0"), 16) # USDC base units == micro-USD - break - if value_micro <= 0: - raise HTTPException(400, detail="No USDC transfer to the grid treasury found in this transaction.") - - # Bind the deposit to the account's wallet so a transfer can't be claimed by - # someone else. (Account wallet is set at SIWE login.) - acct_wallet = (account.get("wallet") or "").lower() - if not acct_wallet: - raise HTTPException(403, detail="Link a wallet (sign in with your wallet) before claiming deposits.") - if acct_wallet != sender: + confirmations = 0 + if confirmations < CONFIRMATIONS: + raise HTTPException( + 425, + detail=f"Only {max(0, confirmations)} confirmations; need {CONFIRMATIONS}. Retry shortly.", + ) + return tx, receipt, block_number + + +def _linked_sender(tx: dict, account: dict, asset: str) -> str: + sender = (tx.get("from") or "").lower() + wallet = (account.get("wallet") or "").lower() + if not _valid_address(wallet): + raise HTTPException(403, detail="Link a wallet before claiming Base deposits.") + if sender != wallet: alerts.emit( "deposit_wallet_mismatch", "warning", - "A deposit claim was rejected because its sender did not match the authenticated wallet.", + "A deposit claim sender did not match the authenticated wallet.", fields={ - "asset": "USDC", + "asset": asset, "account": alerts.opaque_id(account.get("account_id")), - "tx": alerts.opaque_id(tx_hash), + "tx": alerts.opaque_id(tx.get("hash")), }, dedupe_key=f"deposit-wallet-mismatch:{alerts.opaque_id(account.get('account_id'))}", ) raise HTTPException(403, detail="This deposit was sent from a different wallet than your account's.") + return sender + + +def _direct_erc20_amount(receipt: dict, token: str, treasury: str, sender: str) -> int: + """Sum direct wallet->treasury transfers of one token in the transaction.""" + amount = 0 + for event in receipt.get("logs", []): + topics = event.get("topics", []) + if ( + (event.get("address") or "").lower() == token + and len(topics) >= 3 + and topics[0].lower() == _TRANSFER_TOPIC + and _addr_from_topic(topics[1]) == sender + and _addr_from_topic(topics[2]) == treasury + ): + amount += int(event.get("data", "0x0"), 16) + return amount + + +async def _lock_network_cap(session, asset: str) -> None: + bind = session.get_bind() + if bind.dialect.name == "postgresql": + await session.execute( + sa.text("SELECT pg_advisory_xact_lock(hashtext(:name))"), + {"name": f"grid:{asset.lower()}-deposit-cap"}, + ) + + +async def _enforce_daily_caps( + session, + *, + account_id, + asset: str, + credit_micro: int, + per_tx_micro: int, + account_daily_micro: int, + network_daily_micro: int, +) -> None: + if credit_micro > per_tx_micro: + raise HTTPException( + 422, + detail=f"{asset} deposit exceeds the ${per_tx_micro / 1_000_000:.2f} pilot maximum.", + ) + await _lock_network_cap(session, asset) + start = _now().replace(hour=0, minute=0, second=0, microsecond=0) + sums = ( + await session.execute( + sa.select( + sa.func.coalesce( + sa.func.sum( + sa.case( + (deposits_t.c.account_id == account_id, deposits_t.c.credited_micro), + else_=0, + ), + ), + 0, + ), + sa.func.coalesce(sa.func.sum(deposits_t.c.credited_micro), 0), + ).where( + sa.and_( + deposits_t.c.asset == asset, + deposits_t.c.status == "credited", + deposits_t.c.created >= start, + ), + ), + ) + ).one() + account_used, network_used = int(sums[0]), int(sums[1]) + if account_used + credit_micro > account_daily_micro: + raise HTTPException(429, detail=f"{asset} account funding limit reached for today.") + if network_used + credit_micro > network_daily_micro: + raise HTTPException(503, detail=f"{asset} network funding limit reached for today.") + + +async def _existing_deposit(chain_id: int, asset: str, tx_hash: str) -> dict | None: + async with await new_session() as session: + row = ( + await session.execute( + sa.select(deposits_t).where( + sa.and_( + deposits_t.c.chain_id == chain_id, + deposits_t.c.asset == asset, + deposits_t.c.tx_hash == tx_hash, + ), + ), + ) + ).mappings().first() + return dict(row) if row else None + + +async def _record_and_credit( + *, + account: dict, + asset: str, + token_address: str | None, + tx_hash: str, + block_number: int, + sender: str, + treasury: str, + amount_raw: int, + decimals: int, + price_micro: int, + price_source: str, + price_timestamp: datetime, + price_block: int | None, + credited_micro: int, + caps: tuple[int, int, int] | None = None, +) -> tuple[bool, dict, int]: + ref = f"base:{CHAIN_ID}:{asset.lower()}:{tx_hash}" + async with await new_session() as session: + canonical_id = await credits._locked_canonical_account(session, account["account_id"]) + existing = ( + await session.execute( + sa.select(deposits_t).where( + sa.and_( + deposits_t.c.chain_id == CHAIN_ID, + deposits_t.c.asset == asset, + deposits_t.c.tx_hash == tx_hash, + ), + ), + ) + ).mappings().first() + if existing: + if existing["account_id"] != canonical_id: + raise HTTPException(409, detail="This Base transaction was already claimed.") + balance = ( + await session.execute( + sa.select(credits_t.c.balance_micro).where(credits_t.c.account_id == canonical_id), + ) + ).scalar_one_or_none() or 0 + return False, dict(existing), int(balance) + if caps: + await _enforce_daily_caps( + session, + account_id=canonical_id, + asset=asset, + credit_micro=credited_micro, + per_tx_micro=caps[0], + account_daily_micro=caps[1], + network_daily_micro=caps[2], + ) + values = { + "account_id": canonical_id, + "chain_id": CHAIN_ID, + "asset": asset, + "token_address": token_address, + "tx_hash": tx_hash, + "block_number": block_number, + "from_address": sender, + "treasury_address": treasury, + "amount_raw": Decimal(amount_raw), + "amount_decimals": decimals, + "price_micro": price_micro, + "price_source": price_source, + "price_timestamp": price_timestamp, + "price_block": price_block, + "credited_micro": credited_micro, + "refund_address": sender, + "status": "credited", + "created": _now(), + } + try: + inserted = ( + await session.execute(sa.insert(deposits_t).values(**values).returning(deposits_t)) + ).mappings().one() + await credits._credit_in_session( + session, + canonical_id, + credited_micro, + reason=f"{asset.lower()}_deposit", + ref=ref, + ) + balance = ( + await session.execute( + sa.select(credits_t.c.balance_micro).where(credits_t.c.account_id == canonical_id), + ) + ).scalar_one() + await session.commit() + return True, dict(inserted), int(balance) + except IntegrityError: + await session.rollback() + + existing = await _existing_deposit(CHAIN_ID, asset, tx_hash) + if not existing: + logger.error("deposit idempotency conflict without receipt asset=%s tx=%s", asset, tx_hash) + raise HTTPException(409, detail="Deposit claim conflicted with an existing credit reference.") + async with await new_session() as session: + canonical_id = await credits._locked_canonical_account(session, account["account_id"]) + if str(existing["account_id"]) != str(canonical_id): + raise HTTPException(409, detail="This Base transaction was already claimed.") + balance = await credits.get_balance(existing["account_id"]) + return False, existing, balance + + +def _response(applied: bool, deposit: dict, balance: int) -> dict: + raw = int(deposit["amount_raw"]) + decimals = int(deposit["amount_decimals"]) + return { + "credited": applied, + "already_claimed": not applied, + "deposit_id": int(deposit["id"]), + "asset": deposit["asset"], + "amount": format(Decimal(raw) / (Decimal(10) ** decimals), "f"), + "amount_raw": str(raw), + "amount_usd": round(int(deposit["credited_micro"]) / 1_000_000, 6), + "balance_usd": round(balance / 1_000_000, 6), + "from": deposit["from_address"], + "tx_hash": deposit["tx_hash"], + "block_number": int(deposit["block_number"]), + "price_source": deposit["price_source"], + } + - applied = await credits.credit( - account["account_id"], value_micro, reason="usdc_deposit", ref=f"usdc:{tx_hash}" +async def verify_and_credit(tx_hash: str, account: dict) -> dict: + """Verify and atomically credit a direct USDC transfer on Base.""" + if not is_configured(): + raise HTTPException(503, detail="USDC deposits are not enabled on this grid yet.") + tx_hash = _normalize_tx_hash(tx_hash) + tx, receipt, block_number = await _confirmed_transaction(tx_hash, "USDC") + sender = _linked_sender(tx, account, "USDC") + amount_raw = _direct_erc20_amount(receipt, USDC, TREASURY, sender) + if amount_raw <= 0: + raise HTTPException(400, detail="No direct USDC transfer to the grid treasury was found.") + if amount_raw < MIN_CREDIT_MICRO: + raise HTTPException(422, detail="USDC deposit is below the minimum funding amount.") + applied, deposit, balance = await _record_and_credit( + account=account, + asset="USDC", + token_address=USDC, + tx_hash=tx_hash, + block_number=block_number, + sender=sender, + treasury=TREASURY, + amount_raw=amount_raw, + decimals=6, + price_micro=1_000_000, + price_source="usdc:1:1", + price_timestamp=_now(), + price_block=block_number, + credited_micro=amount_raw, ) - balance = await credits.get_balance(account["account_id"]) if applied: alerts.emit( "deposit_credited", @@ -148,112 +555,118 @@ async def verify_and_credit(tx_hash: str, account: dict) -> dict: "asset": "USDC", "account": alerts.opaque_id(account["account_id"]), "tx": alerts.opaque_id(tx_hash), - "amount_micro": value_micro, + "amount_micro": amount_raw, }, dedupe_key=f"deposit-credited:usdc:{alerts.opaque_id(tx_hash)}", ) - return { - "credited": bool(applied), - "already_claimed": not applied, - "amount_usd": round(value_micro / 1_000_000, 6), - "balance_usd": round(balance / 1_000_000, 6), - "from": sender, - "tx_hash": tx_hash, - } - - -def eth_is_configured() -> bool: - return DEPOSITS_ENABLED and bool(ETH_TREASURY) + return _response(applied, deposit, balance) + + +async def verify_and_credit_aipg(tx_hash: str, account: dict) -> dict: + """Credit a direct AIPG transfer under a bounded, expiring price epoch.""" + epoch = _aipg_price_epoch() + if not aipg_is_configured() or epoch is None: + raise HTTPException(503, detail="AIPG deposits do not have a valid funding price right now.") + tx_hash = _normalize_tx_hash(tx_hash) + tx, receipt, block_number = await _confirmed_transaction(tx_hash, "AIPG") + sender = _linked_sender(tx, account, "AIPG") + amount_raw = _direct_erc20_amount(receipt, AIPG_TOKEN, AIPG_TREASURY, sender) + if amount_raw <= 0: + raise HTTPException(400, detail="No direct AIPG transfer to the grid treasury was found.") + market_micro = amount_raw * AIPG_PRICE_MICRO // (10 ** AIPG_DECIMALS) + credited_micro = market_micro * (10_000 - AIPG_HAIRCUT_BPS) // 10_000 + if credited_micro < MIN_CREDIT_MICRO: + raise HTTPException(422, detail="AIPG deposit is below the minimum funding amount.") + source = f"operator:{AIPG_PRICE_EPOCH}:haircut-{AIPG_HAIRCUT_BPS}bps" + applied, deposit, balance = await _record_and_credit( + account=account, + asset="AIPG", + token_address=AIPG_TOKEN, + tx_hash=tx_hash, + block_number=block_number, + sender=sender, + treasury=AIPG_TREASURY, + amount_raw=amount_raw, + decimals=AIPG_DECIMALS, + price_micro=AIPG_PRICE_MICRO, + price_source=source, + price_timestamp=epoch[0], + price_block=AIPG_PRICE_BLOCK or None, + credited_micro=credited_micro, + caps=(AIPG_MAX_DEPOSIT_MICRO, AIPG_ACCOUNT_DAILY_MICRO, AIPG_NETWORK_DAILY_MICRO), + ) + if applied: + alerts.emit( + "deposit_credited", + "success", + "A verified Base deposit was credited to a Grid account.", + fields={ + "asset": "AIPG", + "account": alerts.opaque_id(account["account_id"]), + "tx": alerts.opaque_id(tx_hash), + "amount_micro": credited_micro, + "price_epoch": AIPG_PRICE_EPOCH, + }, + dedupe_key=f"deposit-credited:aipg:{alerts.opaque_id(tx_hash)}", + ) + return _response(applied, deposit, balance) async def verify_and_credit_eth(tx_hash: str, account: dict) -> dict: - """Verify a native-ETH transfer to the treasury and credit the account in USD. + """Verify a tightly capped native-ETH pilot deposit. - Same posture as the USDC path (SIWE-wallet bound, confirmation-gated, idempotent - on the tx hash) — the only difference is the amount is priced ETH→USD via the - Chainlink ETH/USD feed on Base at claim time (deposit-time oracle only).""" + The default conversion mode is ``disabled``. ``buffered`` is an explicit + operator opt-in and applies a valuation haircut plus daily exposure caps; + it is not a substitute for the target swap-to-USDC rail. + """ if not eth_is_configured(): - raise HTTPException(503, detail="ETH deposits are not enabled on this grid yet.") - tx_hash = (tx_hash or "").strip().lower() - if not (tx_hash.startswith("0x") and len(tx_hash) == 66): - raise HTTPException(400, detail="tx_hash must be a 0x-prefixed 32-byte hash.") - - try: - tx = await _rpc("eth_getTransactionByHash", [tx_hash]) - receipt = await _rpc("eth_getTransactionReceipt", [tx_hash]) - except Exception as e: - logger.warning("eth deposit rpc failed for %s: %s", tx_hash, e) - alerts.emit( - "deposit_rpc_failed", - "critical", - "An ETH deposit claim could not be verified against Base.", - fields={"asset": "ETH", "tx": alerts.opaque_id(tx_hash), "error_type": type(e).__name__}, - dedupe_key="deposit-rpc:eth", + raise HTTPException( + 503, + detail="Direct ETH funding is unavailable until a conversion-backed rail is configured.", ) - raise HTTPException(502, detail="Could not reach Base to verify the transaction.") - if not tx or not receipt: - raise HTTPException(400, detail="Transaction not found or not yet mined.") - if receipt.get("status") not in ("0x1", 1): - raise HTTPException(400, detail="Transaction failed on-chain.") - - # A plain ETH send: tx.to is the treasury and it carried value. (Value forwarded - # via a contract's internal call is intentionally NOT credited — direct sends only.) + tx_hash = _normalize_tx_hash(tx_hash) + tx, receipt, block_number = await _confirmed_transaction(tx_hash, "ETH") + sender = _linked_sender(tx, account, "ETH") if (tx.get("to") or "").lower() != ETH_TREASURY: raise HTTPException(400, detail="This transaction did not send ETH to the grid treasury.") - value_wei = int(tx.get("value", "0x0") or "0x0", 16) - if value_wei <= 0: + amount_raw = int(tx.get("value", "0x0") or "0x0", 16) + if amount_raw <= 0: raise HTTPException(400, detail="No ETH value in this transaction.") - - # Confirmation gate — a reorg must not un-mine a credited deposit. - try: - latest = int(await _rpc("eth_blockNumber", []), 16) - confs = latest - int(receipt["blockNumber"], 16) - except Exception: - confs = 0 - if confs < CONFIRMATIONS: - raise HTTPException(425, detail=f"Only {confs} confirmations; need {CONFIRMATIONS}. Retry shortly.") - - # Bind to the account's own wallet (tx sender). - sender = (tx.get("from") or "").lower() - acct_wallet = (account.get("wallet") or "").lower() - if not acct_wallet: - raise HTTPException(403, detail="Link a wallet (sign in with your wallet) before claiming deposits.") - if acct_wallet != sender: - alerts.emit( - "deposit_wallet_mismatch", - "warning", - "A deposit claim was rejected because its sender did not match the authenticated wallet.", - fields={ - "asset": "ETH", - "account": alerts.opaque_id(account.get("account_id")), - "tx": alerts.opaque_id(tx_hash), - }, - dedupe_key=f"deposit-wallet-mismatch:{alerts.opaque_id(account.get('account_id'))}", - ) - raise HTTPException(403, detail="This deposit was sent from a different wallet than your account's.") - - # Price ETH→USD at claim time (Chainlink on Base). Never guess a price. from . import holdings + try: - px_micro = await holdings.eth_usd_micro() # micro-USD per 1 ETH - except Exception as e: - logger.warning("eth/usd price read failed for deposit %s: %s", tx_hash, e) + market_price_micro = await holdings.eth_usd_micro() + except Exception as exc: + logger.warning("eth/usd price read failed for deposit %s: %s", tx_hash, exc) alerts.emit( "deposit_oracle_failed", "critical", "An ETH deposit could not be priced from the Base Chainlink feed.", - fields={"asset": "ETH", "tx": alerts.opaque_id(tx_hash), "error_type": type(e).__name__}, + fields={"asset": "ETH", "tx": alerts.opaque_id(tx_hash), "error_type": type(exc).__name__}, dedupe_key="deposit-oracle:eth-usd", ) raise HTTPException(502, detail="Could not read the ETH/USD price feed; retry shortly.") - value_micro = value_wei * px_micro // (10 ** 18) - if value_micro <= 0: - raise HTTPException(400, detail="ETH amount too small to credit at the current price.") - - applied = await credits.credit( - account["account_id"], value_micro, reason="eth_deposit", ref=f"eth:{tx_hash}" + price_micro = market_price_micro * (10_000 - ETH_HAIRCUT_BPS) // 10_000 + credited_micro = amount_raw * price_micro // (10 ** 18) + if credited_micro < MIN_CREDIT_MICRO: + raise HTTPException(422, detail="ETH deposit is below the minimum funding amount.") + applied, deposit, balance = await _record_and_credit( + account=account, + asset="ETH", + token_address=None, + tx_hash=tx_hash, + block_number=block_number, + sender=sender, + treasury=ETH_TREASURY, + amount_raw=amount_raw, + decimals=18, + price_micro=price_micro, + price_source=f"chainlink:eth-usd:haircut-{ETH_HAIRCUT_BPS}bps", + price_timestamp=_now(), + price_block=block_number, + credited_micro=credited_micro, + caps=(ETH_MAX_DEPOSIT_MICRO, ETH_ACCOUNT_DAILY_MICRO, ETH_NETWORK_DAILY_MICRO), ) - balance = await credits.get_balance(account["account_id"]) if applied: alerts.emit( "deposit_credited", @@ -263,17 +676,40 @@ async def verify_and_credit_eth(tx_hash: str, account: dict) -> dict: "asset": "ETH", "account": alerts.opaque_id(account["account_id"]), "tx": alerts.opaque_id(tx_hash), - "amount_micro": value_micro, + "amount_micro": credited_micro, }, dedupe_key=f"deposit-credited:eth:{alerts.opaque_id(tx_hash)}", ) - return { - "credited": bool(applied), - "already_claimed": not applied, - "amount_usd": round(value_micro / 1_000_000, 6), - "eth": round(value_wei / 1e18, 8), - "eth_usd": round(px_micro / 1_000_000, 2), - "balance_usd": round(balance / 1_000_000, 6), - "from": sender, - "tx_hash": tx_hash, - } + return _response(applied, deposit, balance) + + +async def list_deposits(account: dict, limit: int = 50) -> list[dict]: + """Return the signed-in account's immutable funding receipts.""" + limit = max(1, min(int(limit or 50), 100)) + async with await new_session() as session: + canonical_id = await credits._locked_canonical_account(session, account["account_id"]) + rows = ( + await session.execute( + sa.select(deposits_t) + .where(deposits_t.c.account_id == canonical_id) + .order_by(deposits_t.c.created.desc()) + .limit(limit), + ) + ).mappings().all() + return [ + { + "deposit_id": int(row["id"]), + "asset": row["asset"], + "amount": format( + Decimal(int(row["amount_raw"])) / (Decimal(10) ** int(row["amount_decimals"])), + "f", + ), + "credited_usd": round(int(row["credited_micro"]) / 1_000_000, 6), + "tx_hash": row["tx_hash"], + "block_number": int(row["block_number"]), + "status": row["status"], + "price_source": row["price_source"], + "created": row["created"].isoformat() if row["created"] else None, + } + for row in rows + ] diff --git a/grid_api/services/settlement/aggregate.py b/grid_api/services/settlement/aggregate.py index 7683d31d..fb2ba50d 100644 --- a/grid_api/services/settlement/aggregate.py +++ b/grid_api/services/settlement/aggregate.py @@ -25,7 +25,35 @@ from ...database import new_session from ...v2.schema import accounts as accounts_table from ...v2.schema import ledger as ledger_table +from ...v2.schema import reservations as reservations_table from ...v2.schema import workers as workers_table +from ...v2.schema import x402_payments as x402_payments_table + + +def _funded_job(): + """Exclude x402 work until its on-chain USDC settlement is durable. + + Credit-funded and legacy jobs have no x402 reservation and pass directly. + """ + unsettled_x402 = ( + sa.select(sa.literal(1)) + .select_from( + reservations_table.join( + x402_payments_table, + x402_payments_table.c.job_id == reservations_table.c.job_id, + isouter=True, + ), + ) + .where( + sa.func.replace(reservations_table.c.job_id, "-", "") == sa.func.replace(sa.cast(ledger_table.c.job_id, sa.String()), "-", ""), + reservations_table.c.billing_source == "x402", + sa.or_( + x402_payments_table.c.job_id.is_(None), + x402_payments_table.c.status != "settled", + ), + ) + ) + return ~sa.exists(unsettled_x402) async def aggregate_den_by_account(start: datetime, end: datetime, *, min_den: float = 0.0) -> list[dict]: @@ -40,10 +68,8 @@ async def aggregate_den_by_account(start: datetime, end: datetime, *, min_den: f Returns [{account_id, den, payout_address}] where payout_address is None when the account hasn't set a wallet yet (→ the caller ACCRUES that share).""" - j = ( - ledger_table - .join(workers_table, workers_table.c.id == ledger_table.c.worker_id, isouter=True) - .join(accounts_table, accounts_table.c.id == workers_table.c.account_id, isouter=True) + j = ledger_table.join(workers_table, workers_table.c.id == ledger_table.c.worker_id, isouter=True).join( + accounts_table, accounts_table.c.id == workers_table.c.account_id, isouter=True, ) async with await new_session() as session: result = await session.execute( @@ -58,10 +84,10 @@ async def aggregate_den_by_account(start: datetime, end: datetime, *, min_den: f ledger_table.c.created >= start, ledger_table.c.created < end, workers_table.c.account_id.isnot(None), + _funded_job(), ) - .group_by(workers_table.c.account_id, - accounts_table.c.payout_wallet, accounts_table.c.wallet) - .having(sa.func.sum(ledger_table.c.den) > min_den) + .group_by(workers_table.c.account_id, accounts_table.c.payout_wallet, accounts_table.c.wallet) + .having(sa.func.sum(ledger_table.c.den) > min_den), ) out = [] for row in result: @@ -75,10 +101,15 @@ async def total_den_in_window(start: datetime, end: datetime) -> float: truly has NO account (vs the per-account rollup which excludes account_id IS NULL). den_no_account = total - sum(per-account).""" async with await new_session() as session: - row = (await session.execute( - sa.select(sa.func.coalesce(sa.func.sum(ledger_table.c.den), 0.0)) - .where(ledger_table.c.created >= start, ledger_table.c.created < end) - )).first() + row = ( + await session.execute( + sa.select(sa.func.coalesce(sa.func.sum(ledger_table.c.den), 0.0)).where( + ledger_table.c.created >= start, + ledger_table.c.created < end, + _funded_job(), + ), + ) + ).first() return float(row[0] or 0.0) @@ -107,14 +138,12 @@ async def aggregate_den_for_period( ledger_table.c.created < end, ledger_table.c.wallet != "", ledger_table.c.wallet.isnot(None), + _funded_job(), ) .group_by(ledger_table.c.wallet) - .having(sa.func.sum(ledger_table.c.den) > min_den) + .having(sa.func.sum(ledger_table.c.den) > min_den), ) - return [ - {"address": row.wallet, "den": float(row.den)} - for row in result - ] + return [{"address": row.wallet, "den": float(row.den)} for row in result] async def count_unattributed_den(start: datetime, end: datetime) -> dict: @@ -135,7 +164,8 @@ async def count_unattributed_den(start: datetime, end: datetime) -> dict: ledger_table.c.wallet == "", ledger_table.c.wallet.is_(None), ), - ) + _funded_job(), + ), ) row = result.first() return {"jobs": int(row.jobs), "den": float(row.den)} diff --git a/grid_api/services/tests/test_credits_concurrency.py b/grid_api/services/tests/test_credits_concurrency.py index 3864a21e..2105a43d 100644 --- a/grid_api/services/tests/test_credits_concurrency.py +++ b/grid_api/services/tests/test_credits_concurrency.py @@ -12,6 +12,7 @@ import asyncio import os import uuid +from types import SimpleNamespace import pytest import pytest_asyncio @@ -19,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from grid_api import database -from grid_api.services import credits, identities +from grid_api.services import credits, identities, pricing, x402_payments from grid_api.v2.schema import accounts as accounts_t from grid_api.v2.schema import metadata as v2_metadata @@ -67,7 +68,7 @@ async def test_concurrent_debits_never_overdraft(pg): assert await credits.credit(aid, cost * covered, "seed", ref=f"seed:{aid}") results = await asyncio.gather( - *[credits.debit(aid, cost, "race", ref=f"race:{aid}:{i}") for i in range(n)] + *[credits.debit(aid, cost, "race", ref=f"race:{aid}:{i}") for i in range(n)], ) oks = sum(1 for r in results if r == "ok") @@ -119,3 +120,47 @@ async def test_credit_racing_merge_is_not_stranded_on_retired_account(pg): canonical = await identities.canonical_account_id(source) assert canonical == await identities.canonical_account_id(destination) assert await credits.get_balance(canonical) == 50_000 + + +@pytest.mark.asyncio +async def test_x402_authorization_cannot_open_multiple_jobs_under_race(pg, monkeypatch): + payer = "0x1111111111111111111111111111111111111111" + usdc = "0x2222222222222222222222222222222222222222" + treasury = "0x3333333333333333333333333333333333333333" + model = "gpt-oss-120b" + maximum = pricing.quote_text(model, 100, 500) + monkeypatch.setattr(x402_payments, "ENABLED", True) + monkeypatch.setattr(x402_payments, "NETWORK", "eip155:8453") + monkeypatch.setattr(x402_payments, "USDC", usdc) + monkeypatch.setattr(x402_payments, "PAY_TO", treasury) + payload = SimpleNamespace( + payload={ + "permit2Authorization": { + "from": payer, + "nonce": "same-signed-permit", + }, + }, + ) + requirements = SimpleNamespace( + network="eip155:8453", + asset=usdc, + pay_to=treasury, + amount=str(maximum), + ) + + results = await asyncio.gather( + *[ + credits.authorize_x402_request( + model, + 100, + 500, + str(uuid.uuid4()), + payment_payload=payload, + payment_requirements=requirements, + ) + for _ in range(20) + ], + ) + + assert sum(1 for result in results if result["ok"]) == 1, results + assert sum(1 for result in results if result["status"] == "conflict") == 19, results diff --git a/grid_api/services/tests/test_deposits.py b/grid_api/services/tests/test_deposits.py new file mode 100644 index 00000000..03d3e662 --- /dev/null +++ b/grid_api/services/tests/test_deposits.py @@ -0,0 +1,297 @@ +# SPDX-FileCopyrightText: 2026 AI Power Grid +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""DB-backed Base funding receipt and credit invariants.""" + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +import pytest_asyncio +import sqlalchemy as sa +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from grid_api import database +from grid_api.services import credits, deposits +from grid_api.v2.schema import accounts, credit_ledger, metadata +from grid_api.v2.schema import deposits as deposits_t + +WALLET = "0x1111111111111111111111111111111111111111" +OTHER = "0x2222222222222222222222222222222222222222" +TREASURY = "0x3333333333333333333333333333333333333333" +USDC = "0x4444444444444444444444444444444444444444" +AIPG = "0x5555555555555555555555555555555555555555" +TX = "0x" + "ab" * 32 + + +@pytest_asyncio.fixture +async def db(): + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + async with engine.begin() as connection: + await connection.run_sync(metadata.create_all) + old = database._session_factory + database._session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + account_id = uuid.uuid4() + async with await database.new_session() as session: + await session.execute( + sa.insert(accounts).values( + id=account_id, + wallet=WALLET, + flags={}, + created=datetime.now(UTC), + ), + ) + await session.commit() + try: + yield account_id + finally: + database._session_factory = old + await engine.dispose() + + +@pytest.fixture +def funding(monkeypatch): + now = datetime.now(UTC) + monkeypatch.setattr(deposits, "CHAIN_ID", 8453) + monkeypatch.setattr(deposits, "DEPOSITS_ENABLED", True) + monkeypatch.setattr(deposits, "TREASURY", TREASURY) + monkeypatch.setattr(deposits, "USDC", USDC) + monkeypatch.setattr(deposits, "AIPG_ENABLED", True) + monkeypatch.setattr(deposits, "AIPG_TREASURY", TREASURY) + monkeypatch.setattr(deposits, "AIPG_TOKEN", AIPG) + monkeypatch.setattr(deposits, "AIPG_PRICE_MICRO", 2_000) + monkeypatch.setattr(deposits, "AIPG_PRICE_EPOCH", "test-epoch") + monkeypatch.setattr(deposits, "AIPG_PRICE_AS_OF_RAW", (now - timedelta(minutes=5)).isoformat()) + monkeypatch.setattr(deposits, "AIPG_PRICE_VALID_UNTIL_RAW", (now + timedelta(hours=1)).isoformat()) + monkeypatch.setattr(deposits, "AIPG_PRICE_MAX_AGE_SECONDS", 86_400) + monkeypatch.setattr(deposits, "AIPG_PRICE_BLOCK", 123) + monkeypatch.setattr(deposits, "AIPG_HAIRCUT_BPS", 300) + monkeypatch.setattr(deposits, "AIPG_MAX_DEPOSIT_MICRO", 100_000_000) + monkeypatch.setattr(deposits, "AIPG_ACCOUNT_DAILY_MICRO", 100_000_000) + monkeypatch.setattr(deposits, "AIPG_NETWORK_DAILY_MICRO", 500_000_000) + monkeypatch.setattr(deposits, "CONFIRMATIONS", 3) + monkeypatch.setattr(deposits, "MIN_CREDIT_MICRO", 10_000) + + +def _topic(address: str) -> str: + return "0x" + address[2:].lower().rjust(64, "0") + + +def _transfer_log(token: str, sender: str, recipient: str, amount: int) -> dict: + return { + "address": token, + "topics": [ + deposits._TRANSFER_TOPIC, + _topic(sender), + _topic(recipient), + ], + "data": hex(amount), + } + + +def _rpc_for(token: str, amount: int, *, sender: str = WALLET): + transaction = {"hash": TX, "from": sender, "to": token, "value": "0x0"} + receipt = { + "status": "0x1", + "blockNumber": hex(100), + "logs": [_transfer_log(token, sender, TREASURY, amount)], + } + + async def rpc(method, _params): + return { + "eth_chainId": hex(8453), + "eth_getTransactionByHash": transaction, + "eth_getTransactionReceipt": receipt, + "eth_blockNumber": hex(102), + }[method] + + return rpc + + +@pytest.mark.asyncio +async def test_usdc_claim_is_atomic_and_idempotent(db, funding, monkeypatch): + monkeypatch.setattr(deposits, "_rpc", _rpc_for(USDC, 5_000_000)) + account = {"account_id": db, "wallet": WALLET} + + first = await deposits.verify_and_credit(TX, account) + second = await deposits.verify_and_credit(TX, account) + + assert first["credited"] is True + assert first["amount_usd"] == 5.0 + assert first["balance_usd"] == 5.0 + assert second["credited"] is False + assert second["already_claimed"] is True + assert second["balance_usd"] == 5.0 + + async with await database.new_session() as session: + receipt_count = await session.scalar(sa.select(sa.func.count()).select_from(deposits_t)) + ledger_count = await session.scalar(sa.select(sa.func.count()).select_from(credit_ledger)) + assert receipt_count == 1 + assert ledger_count == 1 + assert await credits.get_balance(db) == 5_000_000 + + +@pytest.mark.asyncio +async def test_credit_failure_rolls_back_deposit_receipt(db, funding, monkeypatch): + monkeypatch.setattr(deposits, "_rpc", _rpc_for(USDC, 5_000_000)) + + async def fail_credit(*_args, **_kwargs): + raise RuntimeError("credit write failed") + + monkeypatch.setattr(credits, "_credit_in_session", fail_credit) + with pytest.raises(RuntimeError, match="credit write failed"): + await deposits.verify_and_credit(TX, {"account_id": db, "wallet": WALLET}) + + async with await database.new_session() as session: + receipt_count = await session.scalar(sa.select(sa.func.count()).select_from(deposits_t)) + ledger_count = await session.scalar(sa.select(sa.func.count()).select_from(credit_ledger)) + assert receipt_count == 0 + assert ledger_count == 0 + + +@pytest.mark.asyncio +async def test_claim_requires_transaction_from_linked_wallet(db, funding, monkeypatch): + monkeypatch.setattr(deposits, "_rpc", _rpc_for(USDC, 5_000_000, sender=OTHER)) + with pytest.raises(HTTPException) as exc: + await deposits.verify_and_credit(TX, {"account_id": db, "wallet": WALLET}) + assert exc.value.status_code == 403 + assert await credits.get_balance(db) == 0 + + +@pytest.mark.asyncio +async def test_claim_rejects_rpc_on_the_wrong_chain(db, funding, monkeypatch): + rpc = _rpc_for(USDC, 5_000_000) + + async def wrong_chain(method, params): + if method == "eth_chainId": + return hex(1) + return await rpc(method, params) + + monkeypatch.setattr(deposits, "_rpc", wrong_chain) + with pytest.raises(HTTPException) as exc: + await deposits.verify_and_credit(TX, {"account_id": db, "wallet": WALLET}) + assert exc.value.status_code == 502 + assert await credits.get_balance(db) == 0 + + +@pytest.mark.asyncio +async def test_aipg_claim_uses_epoch_haircut_and_records_provenance(db, funding, monkeypatch): + # 10,000 AIPG at $0.002, less 3% = $19.40. + amount = 10_000 * 10**18 + monkeypatch.setattr(deposits, "_rpc", _rpc_for(AIPG, amount)) + + result = await deposits.verify_and_credit_aipg( + TX, + {"account_id": db, "wallet": WALLET}, + ) + + assert result["credited"] is True + assert result["asset"] == "AIPG" + assert result["amount_usd"] == 19.4 + assert result["price_source"] == "operator:test-epoch:haircut-300bps" + assert await credits.get_balance(db) == 19_400_000 + + +@pytest.mark.asyncio +async def test_aipg_expired_epoch_fails_before_rpc(db, funding, monkeypatch): + monkeypatch.setattr( + deposits, + "AIPG_PRICE_VALID_UNTIL_RAW", + (datetime.now(UTC) - timedelta(seconds=1)).isoformat(), + ) + + async def should_not_call(*_args): + raise AssertionError("RPC must not run for an expired price") + + monkeypatch.setattr(deposits, "_rpc", should_not_call) + with pytest.raises(HTTPException) as exc: + await deposits.verify_and_credit_aipg( + TX, + {"account_id": db, "wallet": WALLET}, + ) + assert exc.value.status_code == 503 + + +@pytest.mark.asyncio +async def test_aipg_per_transaction_cap_is_atomic(db, funding, monkeypatch): + monkeypatch.setattr(deposits, "AIPG_MAX_DEPOSIT_MICRO", 1_000_000) + monkeypatch.setattr(deposits, "AIPG_ACCOUNT_DAILY_MICRO", 1_000_000) + monkeypatch.setattr(deposits, "_rpc", _rpc_for(AIPG, 10_000 * 10**18)) + + with pytest.raises(HTTPException) as exc: + await deposits.verify_and_credit_aipg( + TX, + {"account_id": db, "wallet": WALLET}, + ) + assert exc.value.status_code == 422 + assert await credits.get_balance(db) == 0 + async with await database.new_session() as session: + assert await session.scalar(sa.select(sa.func.count()).select_from(deposits_t)) == 0 + + +@pytest.mark.asyncio +async def test_aipg_account_daily_cap_is_atomic(db, funding, monkeypatch): + monkeypatch.setattr(deposits, "AIPG_ACCOUNT_DAILY_MICRO", 30_000_000) + monkeypatch.setattr(deposits, "AIPG_NETWORK_DAILY_MICRO", 100_000_000) + monkeypatch.setattr(deposits, "_rpc", _rpc_for(AIPG, 10_000 * 10**18)) + account = {"account_id": db, "wallet": WALLET} + + await deposits.verify_and_credit_aipg(TX, account) + with pytest.raises(HTTPException) as exc: + await deposits.verify_and_credit_aipg("0x" + "cd" * 32, account) + assert exc.value.status_code == 429 + assert await credits.get_balance(db) == 19_400_000 + + +@pytest.mark.asyncio +async def test_aipg_network_daily_cap_is_atomic(db, funding, monkeypatch): + other_id = uuid.uuid4() + async with await database.new_session() as session: + await session.execute( + sa.insert(accounts).values( + id=other_id, + wallet=OTHER, + flags={}, + created=datetime.now(UTC), + ), + ) + await session.commit() + monkeypatch.setattr(deposits, "AIPG_ACCOUNT_DAILY_MICRO", 100_000_000) + monkeypatch.setattr(deposits, "AIPG_NETWORK_DAILY_MICRO", 30_000_000) + monkeypatch.setattr(deposits, "_rpc", _rpc_for(AIPG, 10_000 * 10**18)) + await deposits.verify_and_credit_aipg(TX, {"account_id": db, "wallet": WALLET}) + + monkeypatch.setattr( + deposits, + "_rpc", + _rpc_for(AIPG, 10_000 * 10**18, sender=OTHER), + ) + with pytest.raises(HTTPException) as exc: + await deposits.verify_and_credit_aipg( + "0x" + "ef" * 32, + {"account_id": other_id, "wallet": OTHER}, + ) + assert exc.value.status_code == 503 + assert await credits.get_balance(other_id) == 0 + + +def test_funding_config_is_explicit_about_credit_terms(funding): + config = deposits.funding_config({"wallet": WALLET}) + assets = {asset["asset"]: asset for asset in config["assets"]} + assert config["chain"] == {"id": 8453, "name": "Base"} + assert config["terms"]["credits_transferable"] is False + assert config["terms"]["credits_withdrawable"] is False + assert assets["USDC"]["enabled"] is True + assert assets["AIPG"]["enabled"] is True + assert assets["ETH"]["enabled"] is False + assert assets["ETH"]["status"] == "conversion_required" diff --git a/grid_api/services/tests/test_x402_payments.py b/grid_api/services/tests/test_x402_payments.py new file mode 100644 index 00000000..7838daa7 --- /dev/null +++ b/grid_api/services/tests/test_x402_payments.py @@ -0,0 +1,317 @@ +# SPDX-FileCopyrightText: 2026 AI Power Grid +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""x402 authorization, usage settlement, and worker-payout gating.""" + +import uuid +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import jwt +import pytest +import pytest_asyncio +import sqlalchemy as sa +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from grid_api import database +from grid_api.services import credits, pricing, x402_payments +from grid_api.services.settlement.aggregate import aggregate_den_by_account +from grid_api.v2.schema import accounts, credit_ledger, ledger, metadata, reservations, workers +from grid_api.v2.schema import x402_payments as payments + +MODEL = "gpt-oss-120b" +PAYER = "0x1111111111111111111111111111111111111111" +USDC = "0x2222222222222222222222222222222222222222" +TREASURY = "0x3333333333333333333333333333333333333333" + + +@pytest_asyncio.fixture +async def db(): + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + async with engine.begin() as connection: + await connection.run_sync(metadata.create_all) + old = database._session_factory + database._session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + try: + yield + finally: + database._session_factory = old + await engine.dispose() + + +@pytest.fixture +def enabled(monkeypatch): + monkeypatch.setattr(x402_payments, "ENABLED", True) + monkeypatch.setattr(x402_payments, "NETWORK", "eip155:8453") + monkeypatch.setattr(x402_payments, "USDC", USDC) + monkeypatch.setattr(x402_payments, "PAY_TO", TREASURY) + + +def _payment(amount: int): + payload = SimpleNamespace( + payload={"permit2Authorization": {"from": PAYER, "nonce": "12345"}}, + ) + requirements = SimpleNamespace( + network="eip155:8453", + asset=USDC, + pay_to=TREASURY, + amount=str(amount), + ) + return payload, requirements + + +async def _rows(table): + async with await database.new_session() as session: + return (await session.execute(sa.select(table))).all() + + +def test_cdp_headers_are_short_lived_and_bound_to_each_endpoint(monkeypatch): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + + private_key = ec.generate_private_key(ec.SECP256R1()) + secret = private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ).decode() + monkeypatch.setattr(x402_payments, "CDP_API_KEY_ID", "organizations/test/apiKeys/key") + monkeypatch.setattr(x402_payments, "CDP_API_KEY_SECRET", secret) + monkeypatch.setattr( + x402_payments, + "FACILITATOR_URL", + "https://api.cdp.coinbase.com/platform/v2/x402", + ) + + headers = x402_payments._facilitator_headers() + verify_token = headers["verify"]["Authorization"].removeprefix("Bearer ") + settle_token = headers["settle"]["Authorization"].removeprefix("Bearer ") + verify_claims = jwt.decode( + verify_token, + options={"verify_signature": False, "verify_aud": False}, + ) + settle_claims = jwt.decode( + settle_token, + options={"verify_signature": False, "verify_aud": False}, + ) + + assert verify_claims["uris"] == ["POST api.cdp.coinbase.com/platform/v2/x402/verify"] + assert settle_claims["uris"] == ["POST api.cdp.coinbase.com/platform/v2/x402/settle"] + assert 0 < verify_claims["exp"] - verify_claims["nbf"] <= 120 + + +def test_mainnet_config_fails_closed_without_facilitator_credentials(monkeypatch): + monkeypatch.setattr(x402_payments, "ENABLED", True) + monkeypatch.setattr(x402_payments, "NETWORK", "eip155:8453") + monkeypatch.setattr(x402_payments, "PAY_TO", TREASURY) + monkeypatch.setattr(x402_payments, "USDC", USDC) + monkeypatch.setattr( + x402_payments, + "FACILITATOR_URL", + "https://api.cdp.coinbase.com/platform/v2/x402", + ) + monkeypatch.setattr(x402_payments, "CDP_API_KEY_ID", "") + monkeypatch.setattr(x402_payments, "CDP_API_KEY_SECRET", "") + + with pytest.raises(RuntimeError, match="requires CDP"): + x402_payments.validate_config() + + +@pytest.mark.asyncio +async def test_external_reservation_is_atomic_and_never_touches_credit_ledger(db, enabled): + job_id = str(uuid.uuid4()) + maximum = pricing.quote_text(MODEL, 100, 500) + payload, requirements = _payment(maximum) + + result = await credits.authorize_x402_request( + MODEL, + 100, + 500, + job_id, + payment_payload=payload, + payment_requirements=requirements, + ) + + assert result == { + "ok": True, + "reserved": maximum, + "status": "ok", + "payer": PAYER, + } + reservation = (await _rows(reservations))[0] + receipt = (await _rows(payments))[0] + assert reservation.billing_source == "x402" + assert reservation.external_payer == PAYER + assert receipt.status == "verified" + assert receipt.authorized_micro == maximum + assert await _rows(credit_ledger) == [] + + +@pytest.mark.asyncio +async def test_under_authorized_request_writes_nothing(db, enabled): + job_id = str(uuid.uuid4()) + payload, requirements = _payment(1) + + result = await credits.authorize_x402_request( + MODEL, + 100, + 500, + job_id, + payment_payload=payload, + payment_requirements=requirements, + ) + + assert result["ok"] is False + assert result["status"] == "authorization_too_small" + assert await _rows(reservations) == [] + assert await _rows(payments) == [] + + +@pytest.mark.asyncio +async def test_one_authorization_cannot_open_two_jobs(db, enabled): + maximum = pricing.quote_text(MODEL, 100, 500) + payload, requirements = _payment(maximum) + first = await credits.authorize_x402_request( + MODEL, + 100, + 500, + str(uuid.uuid4()), + payment_payload=payload, + payment_requirements=requirements, + ) + second = await credits.authorize_x402_request( + MODEL, + 100, + 500, + str(uuid.uuid4()), + payment_payload=payload, + payment_requirements=requirements, + ) + + assert first["ok"] is True + assert second["ok"] is False + assert second["status"] == "conflict" + assert len(await _rows(reservations)) == 1 + assert len(await _rows(payments)) == 1 + + +@pytest.mark.asyncio +async def test_database_rejects_settlement_above_authorization(db): + async with await database.new_session() as session: + with pytest.raises(IntegrityError): + await session.execute( + sa.insert(payments).values( + job_id=str(uuid.uuid4()), + authorization_id="a" * 64, + payer=PAYER, + network="eip155:8453", + asset=USDC, + pay_to=TREASURY, + authorized_micro=100, + settled_micro=101, + status="settled", + created=datetime.now(UTC), + settled=datetime.now(UTC), + ), + ) + await session.commit() + + +@pytest.mark.asyncio +async def test_actual_cost_is_recorded_and_payout_waits_for_onchain_settlement(db, enabled): + account_id = uuid.uuid4() + worker_id = uuid.uuid4() + job_id = str(uuid.uuid4()) + now = datetime.now(UTC) + async with await database.new_session() as session: + await session.execute( + sa.insert(accounts).values( + id=account_id, + wallet=PAYER, + payout_wallet=PAYER, + flags={}, + created=now, + ), + ) + await session.execute( + sa.insert(workers).values( + id=worker_id, + account_id=account_id, + name="x402-test-worker", + type="text", + wallet=PAYER, + models=[MODEL], + capabilities={}, + first_seen=now, + jobs_completed=0, + den_earned=0, + ), + ) + await session.commit() + + maximum = pricing.quote_text(MODEL, 100, 500) + payload, requirements = _payment(maximum) + assert ( + await credits.authorize_x402_request( + MODEL, + 100, + 500, + job_id, + payment_payload=payload, + payment_requirements=requirements, + ) + )["ok"] + + terminal = await credits.record_and_settle( + ledger_values={ + "job_id": job_id, + "worker_id": str(worker_id), + "wallet": PAYER, + "model": MODEL, + "job_type": "text", + "den": 25.0, + "output_units": 50, + "prompt_hash": None, + "result_hash": None, + }, + completion_tokens=50, + ) + assert terminal == "settled" + actual = pricing.quote_text(MODEL, 100, 50) + assert await credits.reservation_actual_micro(job_id) == actual + assert len(await _rows(ledger)) == 1 + + before = await aggregate_den_by_account(now - timedelta(minutes=1), now + timedelta(minutes=1)) + assert before == [] + + async with await database.new_session() as session: + await session.execute( + sa.update(payments) + .where(payments.c.job_id == job_id) + .values( + status="settled", + settled_micro=actual, + tx_hash="0x" + "ab" * 32, + settled=now, + ), + ) + await session.commit() + + after = await aggregate_den_by_account(now - timedelta(minutes=1), now + timedelta(minutes=1)) + assert after == [ + { + "account_id": str(account_id), + "den": 25.0, + "payout_address": PAYER, + }, + ] diff --git a/grid_api/services/x402_payments.py b/grid_api/services/x402_payments.py new file mode 100644 index 00000000..a42d7739 --- /dev/null +++ b/grid_api/services/x402_payments.py @@ -0,0 +1,326 @@ +# SPDX-FileCopyrightText: 2026 AI Power Grid +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Gated x402 USDC settlement for accountless agent requests. + +The first production surface is deliberately narrow: + +* Base USDC only; +* the x402 ``upto`` scheme, so the payer authorizes a fixed ceiling while the + Grid settles only trusted, grid-counted usage; +* non-streaming OpenAI chat only, because the upstream FastAPI middleware + buffers response bodies before settlement; +* disabled unless every required operator setting is present. + +An x402 signature is authorization, not revenue. The request handler writes a +``verified`` payment row before dispatch and the SDK after-settle hook records +the transfer. Worker payout queries exclude the job until that row is settled. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import os +import secrets +import time +from dataclasses import dataclass +from datetime import UTC, datetime +from urllib.parse import urlparse + +import sqlalchemy as sa + +from ..database import new_session +from ..v2.schema import x402_payments as payments_t +from . import alerts + +logger = logging.getLogger("grid_api.x402") + +ENABLED = os.getenv("GRID_X402_ENABLED", "0").lower() in ("1", "true", "yes", "on") +NETWORK = os.getenv("GRID_X402_NETWORK", "eip155:8453").strip() +FACILITATOR_URL = ( + os.getenv( + "GRID_X402_FACILITATOR_URL", + "https://api.cdp.coinbase.com/platform/v2/x402", + ) + .strip() + .rstrip("/") +) +CDP_API_KEY_ID = os.getenv("CDP_API_KEY_ID", "").strip() +CDP_API_KEY_SECRET = os.getenv("CDP_API_KEY_SECRET", "").strip() +PAY_TO = (os.getenv("GRID_X402_PAY_TO", "") or os.getenv("GRID_USDC_TREASURY", "")).strip().lower() +USDC = ( + os.getenv( + "GRID_USDC_CONTRACT", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + ) + .strip() + .lower() +) +MAX_AUTH_MICRO = max(1, int(os.getenv("GRID_X402_MAX_AUTH_MICRO", "1000000") or 1_000_000)) +DEFAULT_MAX_TOKENS = max( + 1, + int(os.getenv("GRID_X402_DEFAULT_MAX_TOKENS", "4096") or 4096), +) +ROUTE = "/v1/x402/chat/completions" + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _address(value: str, name: str) -> str: + value = (value or "").strip().lower() + if len(value) != 42 or not value.startswith("0x"): + raise RuntimeError(f"{name} must be a 20-byte EVM address") + try: + int(value[2:], 16) + except ValueError as exc: + raise RuntimeError(f"{name} must be a hexadecimal EVM address") from exc + return value + + +def validate_config() -> None: + """Fail startup when an enabled money rail is only half configured.""" + if not ENABLED: + return + if NETWORK not in {"eip155:8453", "eip155:84532"}: + raise RuntimeError("GRID_X402_NETWORK must be Base mainnet or Base Sepolia") + _address(PAY_TO, "GRID_X402_PAY_TO/GRID_USDC_TREASURY") + _address(USDC, "GRID_USDC_CONTRACT") + parsed = urlparse(FACILITATOR_URL) + if parsed.scheme != "https" or not parsed.netloc: + raise RuntimeError("GRID_X402_FACILITATOR_URL must be an https URL") + if NETWORK == "eip155:8453" and not (CDP_API_KEY_ID and CDP_API_KEY_SECRET): + raise RuntimeError("Base-mainnet x402 requires CDP_API_KEY_ID and CDP_API_KEY_SECRET") + + +def _load_cdp_key(secret: str): + """Load either a PEM P-256 key or Coinbase's base64 Ed25519 secret.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + + normalized = secret.replace("\\n", "\n") + try: + key = serialization.load_pem_private_key(normalized.encode(), password=None) + if isinstance(key, ec.EllipticCurvePrivateKey): + return key, "ES256" + except (TypeError, ValueError): + pass + try: + raw = base64.b64decode(normalized, validate=True) + if len(raw) == 64: + return ed25519.Ed25519PrivateKey.from_private_bytes(raw[:32]), "EdDSA" + except (ValueError, TypeError): + pass + raise RuntimeError("CDP_API_KEY_SECRET must be a PEM EC key or base64 Ed25519 key") + + +def _cdp_jwt(method: str, path: str) -> str: + """Create the short-lived, request-bound bearer token required by CDP.""" + import jwt + + key, algorithm = _load_cdp_key(CDP_API_KEY_SECRET) + host = urlparse(FACILITATOR_URL).netloc + now = int(time.time()) + claims = { + "sub": CDP_API_KEY_ID, + "iss": "cdp", + "aud": None, + "nbf": now, + "exp": now + 120, + "uris": [f"{method} {host}{path}"], + } + headers = { + "alg": algorithm, + "kid": CDP_API_KEY_ID, + "typ": "JWT", + "nonce": f"{secrets.randbelow(10**16):016d}", + } + return jwt.encode(claims, key, algorithm=algorithm, headers=headers) + + +def _facilitator_headers() -> dict[str, dict[str, str]]: + """Header callback expected by x402's HTTP facilitator client.""" + base_path = urlparse(FACILITATOR_URL).path.rstrip("/") + common = { + "Content-Type": "application/json", + "Correlation-Context": "source=aipg-grid,source_version=1", + } + if not (CDP_API_KEY_ID and CDP_API_KEY_SECRET): + return {key: dict(common) for key in ("verify", "settle", "supported", "list")} + + def authenticated(method: str, suffix: str) -> dict[str, str]: + path = f"{base_path}/{suffix}" + return {**common, "Authorization": f"Bearer {_cdp_jwt(method, path)}"} + + return { + "verify": authenticated("POST", "verify"), + "settle": authenticated("POST", "settle"), + "supported": authenticated("GET", "supported"), + "list": dict(common), + } + + +def payment_payload_details(payload, requirements) -> dict: + """Extract verified EVM payer and immutable requirement details.""" + raw = payload.payload if hasattr(payload, "payload") else {} + auth = raw.get("permit2Authorization") if isinstance(raw, dict) else None + payer = (auth or {}).get("from", "") + payer = _address(payer, "x402 payer") + nonce = str((auth or {}).get("nonce", "")).strip() + if not nonce: + raise RuntimeError("x402 Permit2 authorization nonce is required") + authorization_id = hashlib.sha256( + json.dumps( + { + "payer": payer, + "network": str(requirements.network), + "asset": str(requirements.asset).lower(), + "nonce": nonce, + }, + separators=(",", ":"), + sort_keys=True, + ).encode(), + ).hexdigest() + return { + "authorization_id": authorization_id, + "payer": payer, + "network": str(requirements.network), + "asset": _address(str(requirements.asset), "x402 asset"), + "pay_to": _address(str(requirements.pay_to), "x402 payTo"), + "authorized_micro": int(requirements.amount), + } + + +async def insert_verified_in_session(session, *, job_id: str, details: dict) -> None: + """Persist verified authorization in the caller's reservation transaction.""" + await session.execute( + sa.insert(payments_t).values( + job_id=str(job_id), + authorization_id=details["authorization_id"], + payer=details["payer"], + network=details["network"], + asset=details["asset"], + pay_to=details["pay_to"], + authorized_micro=int(details["authorized_micro"]), + status="verified", + created=_now(), + ), + ) + + +async def settled_amount(job_id: str) -> int | None: + async with await new_session() as session: + row = ( + await session.execute( + sa.select(payments_t.c.settled_micro).where( + payments_t.c.job_id == str(job_id), + ), + ), + ).first() + return int(row[0]) if row and row[0] is not None else None + + +async def _update_from_hook(context, *, status: str, error: str | None = None) -> None: + transport = getattr(context, "transport_context", None) + headers = getattr(transport, "response_headers", None) or {} + job_id = next( + (value for key, value in headers.items() if key.lower() == "x-grid-job-id"), + None, + ) + if not job_id: + logger.error("x402 %s hook missing X-Grid-Job-ID", status) + return + + values: dict = {"status": status, "error": (error or "")[:255] or None} + if status == "settled": + result = context.result + values.update( + settled_micro=int(context.requirements.amount), + tx_hash=str(result.transaction), + settled=_now(), + error=None, + ) + try: + async with await new_session() as session: + updated = await session.execute( + sa.update(payments_t).where(payments_t.c.job_id == str(job_id)).values(**values), + ) + if updated.rowcount != 1: + raise RuntimeError(f"x402 payment row missing for job {job_id}") + await session.commit() + except Exception: + logger.exception("x402 %s could not be persisted job=%s", status, job_id) + alerts.emit( + "x402_receipt_persist_failed", + "critical", + "An x402 facilitator result could not be persisted.", + fields={"job": alerts.opaque_id(job_id), "status": status}, + dedupe_key=f"x402-receipt:{alerts.opaque_id(job_id)}", + ) + raise + + +async def _after_settle(context) -> None: + await _update_from_hook(context, status="settled") + + +async def _on_settle_failure(context): + await _update_from_hook(context, status="failed", error=str(context.error)) + return None + + +@dataclass(frozen=True) +class X402Runtime: + server: object + routes: dict + + +def build_runtime() -> X402Runtime: + """Build the official x402 resource server after strict config validation.""" + validate_config() + from x402 import x402ResourceServer + from x402.http import HTTPFacilitatorClient + from x402.http.types import PaymentOption, RouteConfig + from x402.mechanisms.evm.upto import UptoEvmServerScheme + + facilitator = HTTPFacilitatorClient( + {"url": FACILITATOR_URL, "create_headers": _facilitator_headers}, + ) + server = x402ResourceServer(facilitator) + server.register(NETWORK, UptoEvmServerScheme()) + server.on_after_settle(_after_settle) + server.on_settle_failure(_on_settle_failure) + routes = { + f"POST {ROUTE}": RouteConfig( + accepts=PaymentOption( + scheme="upto", + pay_to=PAY_TO, + price={"amount": str(MAX_AUTH_MICRO), "asset": USDC}, + network=NETWORK, + max_timeout_seconds=300, + ), + resource=ROUTE, + description="Non-streaming OpenAI-compatible Grid inference paid in Base USDC.", + mime_type="application/json", + service_name="AI Power Grid", + tags=["ai", "inference", "base", "usdc"], + ), + } + return X402Runtime(server=server, routes=routes) + + +def install_middleware(app) -> None: + """Install no middleware at all while dark; enabled misconfig fails startup.""" + if not ENABLED: + return + from x402.http.middleware.fastapi import PaymentMiddlewareASGI + + runtime = build_runtime() + app.add_middleware( + PaymentMiddlewareASGI, + routes=runtime.routes, + server=runtime.server, + ) diff --git a/grid_api/v2/schema.py b/grid_api/v2/schema.py index 1eeb44fc..8cb4b2dc 100644 --- a/grid_api/v2/schema.py +++ b/grid_api/v2/schema.py @@ -458,6 +458,54 @@ def utcnow() -> datetime: ) +# Immutable Base funding receipts. A deposit credit and this audit row are +# committed in the same SQL transaction, so the purchased-credit balance can be +# traced back to the exact chain, asset, raw amount, valuation, and refund +# address that funded it. Credits are service value, not a withdrawable token +# balance; refund_address exists for operator-reviewed refunds to the source. +deposits = sa.Table( + "grid_deposits", + metadata, + sa.Column( + "id", + sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + primary_key=True, + autoincrement=True, + ), + sa.Column( + "account_id", + sa.Uuid, + sa.ForeignKey("grid_accounts.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ), + sa.Column("chain_id", sa.BigInteger, nullable=False), + sa.Column("asset", sa.String(12), nullable=False, index=True), + sa.Column("token_address", sa.String(42), nullable=True), + sa.Column("tx_hash", sa.String(66), nullable=False), + sa.Column("block_number", sa.BigInteger, nullable=False), + sa.Column("from_address", sa.String(42), nullable=False), + sa.Column("treasury_address", sa.String(42), nullable=False), + # Numeric(78, 0) safely stores uint256 token values; BigInteger cannot hold + # normal 18-decimal ETH/AIPG deposits. + sa.Column("amount_raw", sa.Numeric(78, 0), nullable=False), + sa.Column("amount_decimals", sa.Integer, nullable=False), + # micro-USD per one whole asset. USDC is exactly 1_000_000; non-stable + # assets record the bounded deposit-time valuation used for this receipt. + sa.Column("price_micro", sa.BigInteger, nullable=False), + sa.Column("price_source", sa.String(128), nullable=False), + sa.Column("price_timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("price_block", sa.BigInteger, nullable=True), + sa.Column("credited_micro", sa.BigInteger, nullable=False), + sa.Column("refund_address", sa.String(42), nullable=False), + sa.Column("status", sa.String(24), nullable=False, default="credited", index=True), + sa.Column("created", sa.DateTime(timezone=True), nullable=False, default=utcnow, index=True), + sa.UniqueConstraint("chain_id", "asset", "tx_hash", name="uq_grid_deposit_chain_asset_tx"), + sa.CheckConstraint("credited_micro > 0", name="ck_grid_deposit_positive_credit"), + sa.CheckConstraint("amount_raw > 0", name="ck_grid_deposit_positive_amount"), +) + + # Durable per-job reservation state. A reserve writes one 'held' row before # dispatch; the worker-WS handler (the authority that reaches a terminal state # for EVERY job regardless of whether the client stayed connected) flips it @@ -485,6 +533,14 @@ def utcnow() -> datetime: sa.Column("output_per_mtok_micro", sa.BigInteger, nullable=True), sa.Column("discount_bps", sa.Integer, nullable=False, server_default=sa.text("0"), default=0), sa.Column("service_id", sa.String(64), nullable=True, index=True), + # Purchased credits remain the default. x402 reservations authorize an + # external USDC payment instead of debiting an account balance. + sa.Column("billing_source", sa.String(16), nullable=False, + server_default=sa.text("'credits'"), default="credits", index=True), + sa.Column("external_payer", sa.String(64), nullable=True, index=True), + # Final grid-counted charge. This makes variable-price external settlement + # auditable without reconstructing historical pricing code. + sa.Column("actual_micro", sa.BigInteger, nullable=True), # 'held' until a terminal state settles it; the held→settled UPDATE is the # exactly-once guard (only the winning UPDATE moves money). sa.Column("status", sa.String(16), nullable=False, default="held", index=True), @@ -493,6 +549,44 @@ def utcnow() -> datetime: ) +# x402 is a post-response on-chain settlement rail. A verified authorization is +# recorded before dispatch, then an SDK after-settle hook records the actual USDC +# transfer. Worker payout aggregation excludes x402 jobs until this row is +# `settled`, so a verified signature or failed facilitator call cannot mint a +# worker payout. +x402_payments = sa.Table( + "grid_x402_payments", + metadata, + sa.Column("job_id", sa.String(64), primary_key=True), + sa.Column("authorization_id", sa.String(64), nullable=False), + sa.Column("payer", sa.String(64), nullable=False, index=True), + sa.Column("network", sa.String(64), nullable=False), + sa.Column("asset", sa.String(64), nullable=False), + sa.Column("pay_to", sa.String(64), nullable=False), + sa.Column("authorized_micro", sa.BigInteger, nullable=False), + sa.Column("settled_micro", sa.BigInteger, nullable=True), + sa.Column("tx_hash", sa.String(80), nullable=True), + sa.Column("status", sa.String(16), nullable=False, default="verified", index=True), + sa.Column("error", sa.String(255), nullable=True), + sa.Column("created", sa.DateTime(timezone=True), nullable=False, default=utcnow, index=True), + sa.Column("settled", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("authorized_micro > 0", name="ck_grid_x402_positive_authorization"), + sa.CheckConstraint( + "settled_micro IS NULL OR settled_micro > 0", + name="ck_grid_x402_positive_settlement", + ), + sa.CheckConstraint( + "settled_micro IS NULL OR settled_micro <= authorized_micro", + name="ck_grid_x402_settlement_within_authorization", + ), + sa.UniqueConstraint( + "authorization_id", + name="uq_grid_x402_payments_authorization_id", + ), + sa.UniqueConstraint("tx_hash", name="uq_grid_x402_payments_tx_hash"), +) + + # Custodial worker payouts (v1, pre-on-chain): one row per (period, account). # Den is attributed to the ACCOUNT (the worker authenticates with its account key), # so earnings never strand for lack of a wallet — an account with no payout_wallet diff --git a/pyproject.toml b/pyproject.toml index d4812411..2dee1861 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,10 @@ dependencies = [ "prometheus-client>=0.20.0", "tiktoken>=0.7.0", "eth-account==0.13.7", - "web3>=6.0.0", + "web3>=7.0.0", + "x402[evm]==2.16.0", + "PyJWT>=2.10.1", + "cryptography>=42.0.0", "google-auth>=2.29,<3", "python-dotenv>=1.0.0", "aiosqlite>=0.20.0", diff --git a/requirements-grid.txt b/requirements-grid.txt index 5b772907..8a3d064c 100644 --- a/requirements-grid.txt +++ b/requirements-grid.txt @@ -19,7 +19,10 @@ pillow>=10.0.1 prometheus-client>=0.20.0 tiktoken>=0.7.0 eth-account==0.13.7 -web3>=6.0.0 +web3>=7.0.0 +x402[evm]==2.16.0 +PyJWT>=2.10.1 +cryptography>=42.0.0 google-auth>=2.29,<3 python-dotenv>=1.0.0 From 7e8ac9d5177325f0b1e12ed709c6432a2bc8780b Mon Sep 17 00:00:00 2001 From: halfaipg Date: Mon, 27 Jul 2026 11:33:13 -0400 Subject: [PATCH 2/2] billing: harden Base funding settlement --- alembic/versions/0018_x402_payments.py | 7 + deploy/env.template | 13 +- docs/AGENTS.md | 3 + docs/FUNDING_CANARY_RUNBOOK.md | 180 +++++++++ docs/FUNDING_RAIL.md | 80 +++- grid_api/main.py | 2 + grid_api/routers/AGENTS.md | 3 +- grid_api/routers/accounts.py | 15 + grid_api/services/AGENTS.md | 14 +- grid_api/services/deposits.py | 160 +++++++- .../tests/test_credits_concurrency.py | 106 +++++- grid_api/services/tests/test_deposits.py | 159 ++++++++ grid_api/services/tests/test_x402_payments.py | 269 +++++++++++++ grid_api/services/x402_payments.py | 355 ++++++++++++++++-- grid_api/v2/schema.py | 10 +- 15 files changed, 1319 insertions(+), 57 deletions(-) create mode 100644 docs/FUNDING_CANARY_RUNBOOK.md diff --git a/alembic/versions/0018_x402_payments.py b/alembic/versions/0018_x402_payments.py index 059e4aeb..0052ddb3 100644 --- a/alembic/versions/0018_x402_payments.py +++ b/alembic/versions/0018_x402_payments.py @@ -45,6 +45,13 @@ def upgrade() -> None: sa.Column("tx_hash", sa.String(length=80), nullable=True), sa.Column("status", sa.String(length=16), nullable=False), sa.Column("error", sa.String(length=255), nullable=True), + sa.Column( + "attempts", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column("last_attempt", sa.DateTime(timezone=True), nullable=True), sa.Column("created", sa.DateTime(timezone=True), nullable=False), sa.Column("settled", sa.DateTime(timezone=True), nullable=True), sa.CheckConstraint( diff --git a/deploy/env.template b/deploy/env.template index 3a46ded2..9b869b47 100644 --- a/deploy/env.template +++ b/deploy/env.template @@ -171,6 +171,9 @@ GRID_USDC_CONTRACT=0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 GRID_USDC_TREASURY= GRID_DEPOSIT_CONFIRMATIONS=3 GRID_DEPOSIT_MIN_MICRO=10000 +GRID_USDC_MAX_DEPOSIT_MICRO=10000000000 +GRID_USDC_ACCOUNT_DAILY_MICRO=25000000000 +GRID_USDC_NETWORK_DAILY_MICRO=100000000000 GRID_AIPG_DEPOSITS_ENABLED=0 GRID_AIPG_TOKEN=0xa1c0deCaFE3E9Bf06A5F29B7015CD373a9854608 @@ -187,9 +190,10 @@ GRID_AIPG_MAX_DEPOSIT_MICRO=100000000 GRID_AIPG_ACCOUNT_DAILY_MICRO=100000000 GRID_AIPG_NETWORK_DAILY_MICRO=500000000 -# Direct ETH is intentionally unavailable while this is "disabled". The -# "buffered" mode is a capped pilot only; production target = swap to USDC and -# claim the actual stablecoin proceeds. +# Direct ETH is intentionally unavailable while this is "disabled". +# "swap_receipt" credits only actual canonical-USDC proceeds delivered by a +# linked-wallet ETH swap and is the production policy. "buffered" is a capped, +# operator-only oracle pilot and must not be exposed in the public Console. GRID_ETH_CONVERSION_MODE=disabled GRID_ETH_TREASURY= GRID_ETH_DEPOSIT_HAIRCUT_BPS=100 @@ -208,6 +212,9 @@ GRID_X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402 GRID_X402_PAY_TO= GRID_X402_MAX_AUTH_MICRO=1000000 GRID_X402_DEFAULT_MAX_TOKENS=4096 +# A pre-settlement attempt without a durable receipt becomes manual_review +# after this interval. Core never blindly retries an ambiguous Permit2 nonce. +GRID_X402_STALE_SECONDS=900 CDP_API_KEY_ID= CDP_API_KEY_SECRET= diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 73c617d9..a8d0bdaa 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -12,6 +12,9 @@ for humans and agents. - `architecture-migration/` - Flask-to-FastAPI/Redis-stream/worker migration planning. - `BLOCKCHAIN_INTEGRATION.md` - legacy/on-chain integration guide. +- `FUNDING_RAIL.md` - Base asset acceptance and x402 architecture. +- `FUNDING_CANARY_RUNBOOK.md` - dark deploy, real-money canary evidence, and + rollback gates. - `V2.md` - v2 API/design notes. ## Local Contracts diff --git a/docs/FUNDING_CANARY_RUNBOOK.md b/docs/FUNDING_CANARY_RUNBOOK.md new file mode 100644 index 00000000..c2416ccc --- /dev/null +++ b/docs/FUNDING_CANARY_RUNBOOK.md @@ -0,0 +1,180 @@ +# Base funding canary runbook + +Status: **commands and schema flow tested on disposable Postgres; real Base +transfers require operator authorization.** + +This runbook is the release gate for accepting Base USDC. It does not authorize +a production deploy, a treasury change, or a transfer. AIPG, direct ETH, and +x402 remain separate flags and must not be enabled by accident. + +## Invariants + +- Purchased balances and ledger movements are integer micro-USD. +- Credits buy Grid service. They are non-transferable and nonwithdrawable. +- One Base transaction creates at most one immutable deposit receipt and one + positive credit-ledger movement. +- The claiming wallet must already be linked to the authenticated account. +- A duplicate claim is a read-only idempotent success. +- Worker payout from x402 work remains blocked until the payment row is + durably `settled`. + +## 1. Dark deploy + +Deploy migration `0018` before the application release. Keep every new rail +dark: + +```dotenv +GRID_DEPOSITS_ENABLED=0 +GRID_AIPG_DEPOSITS_ENABLED=0 +GRID_ETH_CONVERSION_MODE=disabled +GRID_X402_ENABLED=0 +``` + +Run from the immutable release directory: + +```bash +.venv/bin/alembic upgrade head +.venv/bin/alembic check +``` + +Expected: current revision `0018` and `No new upgrade operations detected`. +Confirm Core health and ordinary free/paid inference before changing a funding +flag. + +## 2. USDC preflight + +Use a dedicated, monitored Base treasury address. Core needs only its public +address and RPC URL; it must not hold the treasury or payout private key. + +```dotenv +GRID_BASE_CHAIN_ID=8453 +GRID_BASE_RPC= +GRID_DEPOSIT_CONFIRMATIONS=3 +GRID_DEPOSIT_MIN_MICRO=10000 +GRID_USDC_MAX_DEPOSIT_MICRO=10000000000 +GRID_USDC_ACCOUNT_DAILY_MICRO=25000000000 +GRID_USDC_NETWORK_DAILY_MICRO=100000000000 +GRID_USDC_CONTRACT=0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 +GRID_USDC_TREASURY= +GRID_DEPOSITS_ENABLED=1 +GRID_AIPG_DEPOSITS_ENABLED=0 +GRID_ETH_CONVERSION_MODE=disabled +GRID_X402_ENABLED=0 +``` + +Restart Core and verify authenticated +`GET /v1/account/deposits/config` reports: + +- chain id `8453`; +- USDC enabled with 6 decimals; +- the intended treasury and canonical USDC contract; +- credits non-transferable and nonwithdrawable; +- AIPG unavailable and ETH direct-send disabled. + +Stop if any value differs. + +## 3. $0.01 USDC canary + +1. Sign in to the Console with the operator account. +2. Link the Base wallet that will send the canary. +3. Open Funding and send exactly `0.01 USDC` to the displayed treasury. +4. Wait for the configured confirmations and claim the transaction. +5. Record the account id, transaction hash, pre/post balance, receipt id, block, + source wallet, and timestamp in the release evidence. + +The UI must show one immutable USDC receipt and a `+0.01` purchased-balance +change. BaseScan must show canonical Base USDC moving directly from the linked +wallet to the configured treasury. + +Database evidence: + +```sql +SELECT account_id, chain_id, asset, token_address, tx_hash, block_number, + from_address, treasury_address, amount_raw, amount_decimals, + price_micro, price_source, credited_micro, refund_address, status +FROM grid_deposits +WHERE chain_id = 8453 AND asset = 'USDC' AND tx_hash = ''; + +SELECT account_id, delta_micro, reason, ref +FROM grid_credit_ledger +WHERE ref = 'base:8453:usdc:'; +``` + +Expected: exactly one row from each query, `amount_raw=10000`, +`price_micro=1000000`, `credited_micro=10000`, `delta_micro=10000`, and the +same account id. + +For that account, prove the purchased-balance cache equals its append-only +ledger: + +```sql +SELECT c.balance_micro, l.ledger_micro +FROM grid_credits c +JOIN ( + SELECT account_id, SUM(delta_micro) AS ledger_micro + FROM grid_credit_ledger + WHERE account_id = '' + GROUP BY account_id +) l ON l.account_id = c.account_id; +``` + +Expected: `balance_micro = ledger_micro`. + +## 4. Replay proof + +Submit the same transaction hash through +`POST /v1/account/deposits/claim` using the same authenticated account. +Expected: `already_claimed=true`, the same receipt, no balance change, and still +exactly one deposit row and one ledger row. + +Attempting to claim the transaction from a different account must fail because +the on-chain sender is not that account's linked wallet. + +## 5. x402 canary + +Do this only after the USDC deposit canary passes. Start on Base Sepolia with +matching test USDC, facilitator, network, and treasury values. Use a fresh +low-value payer and `GRID_X402_MAX_AUTH_MICRO=50000` ($0.05 maximum). + +Evidence required: + +1. Initial request returns a valid x402 payment requirement. +2. Paid retry produces one non-streaming text completion. +3. Reservation records grid-counted actual usage below the authorization. +4. Payment transitions `verified -> settling -> reported -> settled`. +5. Base receipt proves canonical USDC from payer to recipient for exactly + `settled_micro`. +6. One repeated authorization cannot open or settle a second job. +7. Worker payout excludes `verified`, `settling`, `reported`, and + `manual_review`, then includes only independently proven `settled`. +8. A forced ambiguous failure enters `manual_review` and alerts. + +For an ambiguous real transfer, reconcile only with the exact Base transaction: + +```bash +.venv/bin/python -m grid_api.services.x402_payments \ + --reconcile-job \ + --tx +``` + +Repeat on Base mainnet with the same $0.05 ceiling only after Sepolia passes. +Do not raise the ceiling or add streaming/media until an automated chain +indexer/reconciler is deployed. + +## 6. Holds + +- **AIPG:** keep dark until a named price-epoch owner, expiry alert, refund + owner, and conservative exposure caps are operational. +- **ETH:** Core can verify `swap_receipt` and credit only actual canonical USDC + proceeds, but the Console still needs a reviewed quote/router adapter and + transaction-intent binding. +- **Cards:** later adapter into the same non-transferable credit ledger; no + separate balance system. + +## Rollback + +Set `GRID_DEPOSITS_ENABLED=0` and `GRID_X402_ENABLED=0`, then restart Core. +This stops new claims and x402 requests without deleting receipts or changing +existing balances. Never roll back by deleting economic rows. Investigate any +credited mistake through the refund/adjustment process with a new durable ledger +reference. diff --git a/docs/FUNDING_RAIL.md b/docs/FUNDING_RAIL.md index 08609fa9..9f4648a9 100644 --- a/docs/FUNDING_RAIL.md +++ b/docs/FUNDING_RAIL.md @@ -5,6 +5,9 @@ rail. AIPG is code-complete behind a separate switch and expiring price epoch. Direct ETH is conversion-gated and must remain disabled for normal production until the Grid can turn it into USDC without carrying an open ETH/USD position. +Operational release evidence is defined in +[FUNDING_CANARY_RUNBOOK.md](FUNDING_CANARY_RUNBOOK.md). + All rails fund one integer micro-USD purchased-credit balance. Credits buy Grid services; they are non-transferable and non-withdrawable. Operator-reviewed refunds go back to the recorded source address. @@ -33,6 +36,9 @@ transfer must be a direct transfer from that wallet to the configured treasury. - `POST /v1/account/deposits/claim-aipg` - claim guarded Base AIPG. - `POST /v1/account/deposits/claim-eth` - direct ETH pilot; unavailable unless the operator explicitly selects `buffered`. +- `POST /v1/account/deposits/claim-eth-converted` - credit actual canonical + Base USDC delivered to the treasury by a linked-wallet ETH swap; available + only in `swap_receipt` mode. Each claim accepts `{ "tx_hash": "0x..." }`, waits for `GRID_DEPOSIT_CONFIRMATIONS`, verifies that `GRID_BASE_RPC` reports the expected @@ -41,13 +47,18 @@ chain id, and is safe to retry. ## USDC launch Native Circle USDC on Base credits 1:1. Its six base-unit decimals are already -micro-USD, so there is no oracle or rounding conversion. +micro-USD, so there is no oracle or rounding conversion. Transaction, +account/day, and network/day caps bound operational mistakes and accounting +exposure. ```dotenv GRID_DEPOSITS_ENABLED=1 GRID_USDC_TREASURY=0x... GRID_BASE_RPC=https://... GRID_DEPOSIT_CONFIRMATIONS=3 +GRID_USDC_MAX_DEPOSIT_MICRO=10000000000 +GRID_USDC_ACCOUNT_DAILY_MICRO=25000000000 +GRID_USDC_NETWORK_DAILY_MICRO=100000000000 ``` Roll out with a linked operator wallet and a small real transfer first. Verify @@ -60,7 +71,9 @@ The AIPG/USDC Base pool is too thin for a spot price to be a credit oracle. Core therefore accepts no autonomous pool quote. An operator must publish a conservative valuation epoch with an as-of time, expiry, and optional source block. Core applies a further haircut and enforces transaction, account/day, -and network/day USD exposure caps under a database lock. +and network/day USD exposure caps under a database lock. The transfer itself +must be mined inside that epoch; historical treasury transfers cannot acquire +credit under a newer price. ```dotenv GRID_AIPG_DEPOSITS_ENABLED=1 @@ -85,10 +98,22 @@ worker/validator rewards. The funding path does not market-sell it. ## ETH policy -The target ETH experience is **pay with ETH, receive actual USDC proceeds**: -the wallet or a reviewed deposit router swaps ETH to USDC, sends USDC to the -treasury, and the normal USDC receipt is credited. This leaves no fixed-dollar -liability backed by volatile ETH and requires no ETH oracle in request billing. +The production ETH policy is **pay with ETH, credit actual USDC proceeds**. +With `GRID_ETH_CONVERSION_MODE=swap_receipt`, a linked wallet executes an ETH +swap whose recipient is the Grid's USDC treasury, then submits that transaction +hash. Core verifies the transaction spent native ETH, verifies canonical Base +USDC was delivered to the treasury, and credits exactly those six-decimal USDC +proceeds. The immutable receipt records ETH as the source asset, raw ETH value, +effective execution price, swap block/time, USDC-backed credited amount, and +the linked source/refund address. + +Core intentionally does not choose or trust a DEX quote: a future Console quote +adapter may use a reviewed router, but the money invariant depends only on +confirmed canonical USDC received. This leaves no fixed-dollar liability backed +by volatile ETH and requires no ETH oracle in request billing. The same +per-transaction, per-account/day, and network/day ETH funding caps apply to the +actual USDC proceeds; an over-cap transfer is not silently credited and requires +operator-reviewed refund handling. The existing direct-ETH verifier is retained only as a tightly capped `GRID_ETH_CONVERSION_MODE=buffered` pilot. It applies a Chainlink valuation @@ -109,8 +134,12 @@ with the payment signature. Core then: dispatch; 3. rejects requests whose maximum grid quote exceeds the signed ceiling; 4. settles worker-side usage from grid-counted prompt/completion tokens; -5. asks the facilitator to transfer only that actual amount; and -6. marks the receipt settled with its transaction hash. +5. durably moves the receipt to `settling` with that exact amount before asking + the facilitator to touch chain; +6. asks the facilitator to transfer only that actual amount; and +7. records facilitator success as `reported` with its transaction hash; then +8. independently verifies the exact canonical-USDC Base transfer before + promoting it to `settled`. No Grid account, API key, free allowance, promotional grant, or purchased balance is created. An authorized-but-unsettled x402 job is excluded from worker @@ -129,10 +158,26 @@ GRID_X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402 GRID_X402_PAY_TO=0x... GRID_X402_MAX_AUTH_MICRO=1000000 GRID_X402_DEFAULT_MAX_TOKENS=4096 +GRID_X402_STALE_SECONDS=900 CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... ``` +An ambiguous facilitator/database outcome is never retried automatically. +`settling` and unproven `reported` rows age into `manual_review`, remain excluded +from worker payouts, and emit a critical operator alert. Reconcile only after +proving the exact canonical-USDC transfer from the recorded payer to recipient: + +```bash +python -m grid_api.services.x402_payments \ + --reconcile-job \ + --tx +``` + +The command verifies Base chain/confirmations, token, payer, recipient, and the +exact grid-counted amount before recording revenue. It is idempotent and rejects +a different transaction for an already settled job. + Base-mainnet startup fails closed without CDP facilitator credentials. Before enabling, run a Base Sepolia end-to-end payment, prove actual-amount settlement, exercise handler/facilitator/database failures, then run a small mainnet canary. @@ -155,11 +200,13 @@ and request-bound JWT authentication documented by 5. Keep AIPG dark until the price-epoch owner, expiry alert, refund owner, and low transaction/account/network caps are operational. The Console validates known minimum and per-transaction limits before opening a transfer. -6. Keep direct ETH out of the public Console. Build the swap-to-USDC path before - calling ETH a production funding asset. +6. Keep direct ETH out of the public Console. Integrate and review a quote/router + adapter for `swap_receipt`; Core already credits only actual canonical USDC + proceeds, independent of the quoted route. 7. Prove x402 on Base Sepolia, then run a low-ceiling Base mainnet canary. Worker payout must remain excluded until the USDC receipt is settled. -8. Add automated x402 receipt reconciliation before raising limits or adding +8. Keep the exact-transfer operator reconciler and low limits for canaries. Add + an automated chain indexer/reconciler before raising limits or adding streaming/media routes. Card funding remains a later adapter into the same non-transferable credit ledger. @@ -168,11 +215,14 @@ and request-bound JWT authentication documented by - V0 is claim-based rather than a chain indexer. - Transfers from exchanges cannot be claimed because the transaction sender is not the linked wallet. -- Contract-routed token transfers are deliberately rejected. Add an audited - allowlist and transaction-intent binding before supporting swap routers. +- Direct USDC/AIPG claims reject contract-routed token transfers. ETH + `swap_receipt` may use a contract route because credit is based only on + canonical USDC actually delivered to the treasury; the Console still needs a + reviewed quote/router adapter and transaction-intent binding. - AIPG price epochs are operational input. They require an owner, monitoring, and expiry automation before broad limits are raised. - Card top-ups remain a future adapter into the account credit model. - A post-settlement database failure can leave paid x402 revenue pending manual - reconciliation. The API fails the response and blocks worker payout; an - automated on-chain receipt reconciler is required before raising x402 limits. + reconciliation. The API fails the response, flags the attempt, blocks worker + payout, and provides an exact-transfer operator reconciler. An automated chain + indexer/reconciler is required before raising x402 limits. diff --git a/grid_api/main.py b/grid_api/main.py index 82f6a189..c69923ef 100644 --- a/grid_api/main.py +++ b/grid_api/main.py @@ -150,6 +150,8 @@ async def _billing_monitor(): }, dedupe_key="billing-holds-aging", ) + await x402_payments.verify_reported_settlements() + await x402_payments.flag_stale_settlements() except Exception as exc: logger.error("Billing invariant monitor error: %s", exc) alerts.emit( diff --git a/grid_api/routers/AGENTS.md b/grid_api/routers/AGENTS.md index 346ba0e7..31579ddc 100644 --- a/grid_api/routers/AGENTS.md +++ b/grid_api/routers/AGENTS.md @@ -37,7 +37,8 @@ transport, accounts, stats, health/metrics. `free.active` tracks GRID_FREE_SPENDABLE_LIVE), `GET /v1/account/jobs` (operator trust view: my workers' jobs + den + result_hash + signed flag, scoped to the payout wallet), immutable deposit history/config, and deposit - claims (USDC launch rail, bounded expiring-price AIPG, conversion-gated ETH). + claims (USDC launch rail, bounded expiring-price AIPG, actual-USDC + swap-receipt ETH, and operator-only buffered ETH). `POST /v1/accounts/session` is the retired internal-token bridge. It resolves on exactly one authoritative identity (`oauth_sub` first, then wallet, then verified email only when it is the sole identity); supplemental diff --git a/grid_api/routers/accounts.py b/grid_api/routers/accounts.py index aff42680..99d5dfd5 100644 --- a/grid_api/routers/accounts.py +++ b/grid_api/routers/accounts.py @@ -1318,6 +1318,21 @@ async def claim_eth_deposit( return await deposits.verify_and_credit_eth(form.tx_hash, user) +@router.post("/v1/account/deposits/claim-eth-converted") +@limiter.limit("20/minute") +async def claim_converted_eth_deposit( + request: Request, + form: ClaimDepositForm, + apikey: Optional[str] = Header(None), + authorization: Optional[str] = Header(None), +): + """Credit actual USDC delivered by a confirmed Base ETH swap transaction.""" + user = await _require_v2(apikey, authorization) + from ..services import deposits + + return await deposits.verify_and_credit_converted_eth(form.tx_hash, user) + + @router.get("/v1/account/deposits/config") async def get_deposit_config( apikey: Optional[str] = Header(None), diff --git a/grid_api/services/AGENTS.md b/grid_api/services/AGENTS.md index 4c789ef8..27f97fda 100644 --- a/grid_api/services/AGENTS.md +++ b/grid_api/services/AGENTS.md @@ -65,9 +65,19 @@ content sanitization, and reward settlement. narrow exception to the no-request-path-chain-read rule: they must verify the configured RPC is on the expected chain before trusting transaction/receipt data, and they must never sit in the inference hot path. +- Production ETH funding uses `swap_receipt`: the linked wallet spends native + ETH and the confirmed transaction must deliver canonical Base USDC directly + to the configured USDC treasury. Credit only the actual USDC Transfer amount. + The oracle-priced `buffered` mode is an operator-only pilot, not a public + funding path. +- AIPG funding claims must bind the transfer block timestamp to the active + operator price epoch. Never value a historical transfer under a newer epoch. - x402 authorization is not revenue. Its reservation and verified-payment row - commit before dispatch; worker payout aggregation must exclude that job until - the facilitator result is durably `settled`. The initial route is Base USDC, + commit before dispatch; its exact attempt is persisted as `settling` before + the facilitator can touch chain. Facilitator success is only `reported`; + worker payout aggregation must exclude that job until Core independently + proves the exact canonical-USDC Base transfer and records `settled`. + Ambiguous attempts go to `manual_review`. The initial route is Base USDC, `upto`, text-only, and non-streaming because the upstream middleware buffers the response before settlement. - Media billing reserves exact deterministic cost before dispatch and refunds on diff --git a/grid_api/services/deposits.py b/grid_api/services/deposits.py index b11c63d3..eadbaf70 100644 --- a/grid_api/services/deposits.py +++ b/grid_api/services/deposits.py @@ -48,6 +48,18 @@ ).strip().lower() CONFIRMATIONS = max(1, int(os.getenv("GRID_DEPOSIT_CONFIRMATIONS", "3") or 3)) MIN_CREDIT_MICRO = max(1, int(os.getenv("GRID_DEPOSIT_MIN_MICRO", "10000") or 10000)) +USDC_MAX_DEPOSIT_MICRO = max( + MIN_CREDIT_MICRO, + int(os.getenv("GRID_USDC_MAX_DEPOSIT_MICRO", "10000000000") or 10_000_000_000), +) +USDC_ACCOUNT_DAILY_MICRO = max( + USDC_MAX_DEPOSIT_MICRO, + int(os.getenv("GRID_USDC_ACCOUNT_DAILY_MICRO", "25000000000") or 25_000_000_000), +) +USDC_NETWORK_DAILY_MICRO = max( + USDC_ACCOUNT_DAILY_MICRO, + int(os.getenv("GRID_USDC_NETWORK_DAILY_MICRO", "100000000000") or 100_000_000_000), +) AIPG_ENABLED = os.getenv("GRID_AIPG_DEPOSITS_ENABLED", "0").lower() in ("1", "true", "yes", "on") AIPG_TOKEN = os.getenv( @@ -105,6 +117,7 @@ ) _TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" +_MAX_SIGNED_BIGINT = (1 << 63) - 1 def _now() -> datetime: @@ -168,6 +181,16 @@ def eth_is_configured() -> bool: ) +def eth_swap_receipt_is_configured() -> bool: + """True when Core may credit actual USDC proceeds from an ETH swap.""" + return ( + DEPOSITS_ENABLED + and ETH_CONVERSION_MODE == "swap_receipt" + and _valid_address(TREASURY) + and _valid_address(USDC) + ) + + def funding_config(account: dict) -> dict: """Safe client configuration for the signed-in Console funding flow.""" epoch = _aipg_price_epoch() @@ -190,6 +213,8 @@ def funding_config(account: dict) -> dict: "decimals": 6, "price_micro": 1_000_000, "minimum_credit_micro": MIN_CREDIT_MICRO, + "maximum_credit_micro": USDC_MAX_DEPOSIT_MICRO, + "account_daily_micro": USDC_ACCOUNT_DAILY_MICRO, "status": "available" if is_configured() else "disabled", }, { @@ -212,15 +237,26 @@ def funding_config(account: dict) -> dict: # A buffered treasury pilot can accept operator-reviewed claims, # but the public Console must wait for conversion-backed funding. "enabled": False, - "backend_claim_enabled": eth_is_configured(), - "treasury": ETH_TREASURY or None, + "backend_claim_enabled": ( + eth_is_configured() or eth_swap_receipt_is_configured() + ), + "treasury": ( + TREASURY if eth_swap_receipt_is_configured() else ETH_TREASURY + ) + or None, "token_address": None, "decimals": 18, "conversion_mode": ETH_CONVERSION_MODE, "haircut_bps": ETH_HAIRCUT_BPS, "minimum_credit_micro": MIN_CREDIT_MICRO, "maximum_credit_micro": ETH_MAX_DEPOSIT_MICRO, - "status": "operator_pilot" if eth_is_configured() else "conversion_required", + "status": ( + "conversion_ready" + if eth_swap_receipt_is_configured() + else "operator_pilot" + if eth_is_configured() + else "conversion_required" + ), }, ], } @@ -324,6 +360,37 @@ def _direct_erc20_amount(receipt: dict, token: str, treasury: str, sender: str) return amount +def _erc20_received(receipt: dict, token: str, treasury: str) -> int: + """Sum canonical-token transfers received by treasury from any swap route.""" + amount = 0 + for event in receipt.get("logs", []): + topics = event.get("topics", []) + if ( + (event.get("address") or "").lower() == token + and len(topics) >= 3 + and topics[0].lower() == _TRANSFER_TOPIC + and _addr_from_topic(topics[2]) == treasury + ): + amount += int(event.get("data", "0x0"), 16) + return amount + + +async def _block_timestamp(block_number: int) -> datetime: + try: + block = await _rpc("eth_getBlockByNumber", [hex(block_number), False]) + return datetime.fromtimestamp(int(block["timestamp"], 16), tz=UTC) + except Exception as exc: + logger.warning( + "deposit block timestamp read failed block=%s: %s", + block_number, + exc, + ) + raise HTTPException( + 502, + detail="Could not verify the deposit block timestamp.", + ) from exc + + async def _lock_network_cap(session, asset: str) -> None: bind = session.get_bind() if bind.dialect.name == "postgresql": @@ -530,6 +597,7 @@ async def verify_and_credit(tx_hash: str, account: dict) -> dict: raise HTTPException(400, detail="No direct USDC transfer to the grid treasury was found.") if amount_raw < MIN_CREDIT_MICRO: raise HTTPException(422, detail="USDC deposit is below the minimum funding amount.") + block_timestamp = await _block_timestamp(block_number) applied, deposit, balance = await _record_and_credit( account=account, asset="USDC", @@ -542,9 +610,14 @@ async def verify_and_credit(tx_hash: str, account: dict) -> dict: decimals=6, price_micro=1_000_000, price_source="usdc:1:1", - price_timestamp=_now(), + price_timestamp=block_timestamp, price_block=block_number, credited_micro=amount_raw, + caps=( + USDC_MAX_DEPOSIT_MICRO, + USDC_ACCOUNT_DAILY_MICRO, + USDC_NETWORK_DAILY_MICRO, + ), ) if applied: alerts.emit( @@ -573,6 +646,12 @@ async def verify_and_credit_aipg(tx_hash: str, account: dict) -> dict: amount_raw = _direct_erc20_amount(receipt, AIPG_TOKEN, AIPG_TREASURY, sender) if amount_raw <= 0: raise HTTPException(400, detail="No direct AIPG transfer to the grid treasury was found.") + block_timestamp = await _block_timestamp(block_number) + if not epoch[0] <= block_timestamp <= epoch[1]: + raise HTTPException( + 422, + detail="The AIPG transfer was not mined inside the active funding price epoch.", + ) market_micro = amount_raw * AIPG_PRICE_MICRO // (10 ** AIPG_DECIMALS) credited_micro = market_micro * (10_000 - AIPG_HAIRCUT_BPS) // 10_000 if credited_micro < MIN_CREDIT_MICRO: @@ -683,6 +762,79 @@ async def verify_and_credit_eth(tx_hash: str, account: dict) -> dict: return _response(applied, deposit, balance) +async def verify_and_credit_converted_eth(tx_hash: str, account: dict) -> dict: + """Credit actual USDC received by treasury from a linked-wallet ETH swap. + + The transaction must carry native ETH from the account's linked wallet and + its confirmed receipt must contain a canonical-USDC Transfer to the Grid's + USDC treasury. Credit equals those actual six-decimal USDC proceeds, so the + Grid never books a fixed-dollar liability against ETH inventory. + """ + if not eth_swap_receipt_is_configured(): + raise HTTPException( + 503, + detail="Conversion-backed ETH funding is not enabled on this grid.", + ) + tx_hash = _normalize_tx_hash(tx_hash) + tx, receipt, block_number = await _confirmed_transaction(tx_hash, "ETH->USDC") + sender = _linked_sender(tx, account, "ETH") + amount_raw = int(tx.get("value", "0x0") or "0x0", 16) + if amount_raw <= 0: + raise HTTPException( + 400, + detail="The conversion transaction did not spend native ETH.", + ) + usdc_received = _erc20_received(receipt, USDC, TREASURY) + if usdc_received < MIN_CREDIT_MICRO: + raise HTTPException( + 422, + detail="The conversion did not deliver the minimum USDC amount to the grid treasury.", + ) + block_timestamp = await _block_timestamp(block_number) + effective_price_micro = usdc_received * (10**18) // amount_raw + if not 0 < effective_price_micro <= _MAX_SIGNED_BIGINT: + raise HTTPException( + 422, + detail="The conversion execution price is outside supported accounting bounds.", + ) + applied, deposit, balance = await _record_and_credit( + account=account, + asset="ETH", + token_address=None, + tx_hash=tx_hash, + block_number=block_number, + sender=sender, + treasury=TREASURY, + amount_raw=amount_raw, + decimals=18, + price_micro=effective_price_micro, + price_source="swap:actual-base-usdc-proceeds", + price_timestamp=block_timestamp, + price_block=block_number, + credited_micro=usdc_received, + caps=( + ETH_MAX_DEPOSIT_MICRO, + ETH_ACCOUNT_DAILY_MICRO, + ETH_NETWORK_DAILY_MICRO, + ), + ) + if applied: + alerts.emit( + "deposit_credited", + "success", + "A verified Base ETH conversion was credited to a Grid account.", + fields={ + "asset": "ETH", + "account": alerts.opaque_id(account["account_id"]), + "tx": alerts.opaque_id(tx_hash), + "amount_micro": usdc_received, + "valuation": "actual_usdc_proceeds", + }, + dedupe_key=f"deposit-credited:converted-eth:{alerts.opaque_id(tx_hash)}", + ) + return _response(applied, deposit, balance) + + async def list_deposits(account: dict, limit: int = 50) -> list[dict]: """Return the signed-in account's immutable funding receipts.""" limit = max(1, min(int(limit or 50), 100)) diff --git a/grid_api/services/tests/test_credits_concurrency.py b/grid_api/services/tests/test_credits_concurrency.py index 2105a43d..f13ff161 100644 --- a/grid_api/services/tests/test_credits_concurrency.py +++ b/grid_api/services/tests/test_credits_concurrency.py @@ -12,17 +12,21 @@ import asyncio import os import uuid +from datetime import UTC, datetime from types import SimpleNamespace import pytest import pytest_asyncio import sqlalchemy as sa +from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from grid_api import database -from grid_api.services import credits, identities, pricing, x402_payments +from grid_api.services import credits, deposits, identities, pricing, x402_payments from grid_api.v2.schema import accounts as accounts_t +from grid_api.v2.schema import deposits as deposits_t from grid_api.v2.schema import metadata as v2_metadata +from grid_api.v2.schema import x402_payments as x402_payments_t async def _seed_account() -> uuid.UUID: @@ -164,3 +168,103 @@ async def test_x402_authorization_cannot_open_multiple_jobs_under_race(pg, monke assert sum(1 for result in results if result["ok"]) == 1, results assert sum(1 for result in results if result["status"] == "conflict") == 19, results + + +@pytest.mark.asyncio +async def test_x402_settlement_attempt_is_claimed_once_under_race(pg, monkeypatch): + payer = "0x1111111111111111111111111111111111111111" + usdc = "0x2222222222222222222222222222222222222222" + treasury = "0x3333333333333333333333333333333333333333" + model = "gpt-oss-120b" + job_id = str(uuid.uuid4()) + maximum = pricing.quote_text(model, 100, 500) + actual = pricing.quote_text(model, 100, 50) + monkeypatch.setattr(x402_payments, "ENABLED", True) + monkeypatch.setattr(x402_payments, "NETWORK", "eip155:8453") + monkeypatch.setattr(x402_payments, "USDC", usdc) + monkeypatch.setattr(x402_payments, "PAY_TO", treasury) + payload = SimpleNamespace( + payload={"permit2Authorization": {"from": payer, "nonce": "one-attempt"}}, + ) + requirements = SimpleNamespace( + network="eip155:8453", + asset=usdc, + pay_to=treasury, + amount=str(maximum), + ) + assert ( + await credits.authorize_x402_request( + model, + 100, + 500, + job_id, + payment_payload=payload, + payment_requirements=requirements, + ) + )["ok"] + + context = SimpleNamespace( + requirements=SimpleNamespace(amount=str(actual)), + transport_context=SimpleNamespace( + response_headers={"X-Grid-Job-ID": job_id}, + ), + ) + results = await asyncio.gather( + *[x402_payments._before_settle(context) for _ in range(20)], + return_exceptions=True, + ) + assert sum(result is None for result in results) == 1 + assert sum(isinstance(result, RuntimeError) for result in results) == 19 + + async with await database.new_session() as session: + row = ( + await session.execute( + sa.select( + x402_payments_t.c.status, + x402_payments_t.c.settled_micro, + x402_payments_t.c.attempts, + ).where(x402_payments_t.c.job_id == job_id), + ) + ).one() + assert row == ("settling", actual, 1) + + +@pytest.mark.asyncio +async def test_usdc_daily_caps_hold_under_concurrent_deposits(pg, monkeypatch): + aid = await _seed_account() + wallet = "0x1111111111111111111111111111111111111111" + treasury = "0x2222222222222222222222222222222222222222" + token = "0x3333333333333333333333333333333333333333" + per_deposit = 1_000 + allowed = 5 + monkeypatch.setattr(deposits, "CHAIN_ID", 8453) + + async def fund(index: int): + return await deposits._record_and_credit( + account={"account_id": aid, "wallet": wallet}, + asset="USDC", + token_address=token, + tx_hash="0x" + f"{index:064x}", + block_number=100 + index, + sender=wallet, + treasury=treasury, + amount_raw=per_deposit, + decimals=6, + price_micro=1_000_000, + price_source="usdc:1:1", + price_timestamp=datetime.now(UTC), + price_block=100 + index, + credited_micro=per_deposit, + caps=(per_deposit, per_deposit * allowed, per_deposit * allowed), + ) + + results = await asyncio.gather( + *[fund(index) for index in range(20)], + return_exceptions=True, + ) + assert sum(isinstance(result, tuple) and result[0] for result in results) == allowed + assert sum(isinstance(result, HTTPException) for result in results) == 20 - allowed + assert await credits.get_balance(aid) == per_deposit * allowed + async with await database.new_session() as session: + count = await session.scalar(sa.select(sa.func.count()).select_from(deposits_t)) + assert count == allowed diff --git a/grid_api/services/tests/test_deposits.py b/grid_api/services/tests/test_deposits.py index 03d3e662..18d7a008 100644 --- a/grid_api/services/tests/test_deposits.py +++ b/grid_api/services/tests/test_deposits.py @@ -23,6 +23,8 @@ TREASURY = "0x3333333333333333333333333333333333333333" USDC = "0x4444444444444444444444444444444444444444" AIPG = "0x5555555555555555555555555555555555555555" +ROUTER = "0x6666666666666666666666666666666666666666" +POOL = "0x7777777777777777777777777777777777777777" TX = "0x" + "ab" * 32 @@ -66,6 +68,9 @@ def funding(monkeypatch): monkeypatch.setattr(deposits, "DEPOSITS_ENABLED", True) monkeypatch.setattr(deposits, "TREASURY", TREASURY) monkeypatch.setattr(deposits, "USDC", USDC) + monkeypatch.setattr(deposits, "USDC_MAX_DEPOSIT_MICRO", 100_000_000) + monkeypatch.setattr(deposits, "USDC_ACCOUNT_DAILY_MICRO", 100_000_000) + monkeypatch.setattr(deposits, "USDC_NETWORK_DAILY_MICRO", 500_000_000) monkeypatch.setattr(deposits, "AIPG_ENABLED", True) monkeypatch.setattr(deposits, "AIPG_TREASURY", TREASURY) monkeypatch.setattr(deposits, "AIPG_TOKEN", AIPG) @@ -79,6 +84,7 @@ def funding(monkeypatch): monkeypatch.setattr(deposits, "AIPG_MAX_DEPOSIT_MICRO", 100_000_000) monkeypatch.setattr(deposits, "AIPG_ACCOUNT_DAILY_MICRO", 100_000_000) monkeypatch.setattr(deposits, "AIPG_NETWORK_DAILY_MICRO", 500_000_000) + monkeypatch.setattr(deposits, "ETH_CONVERSION_MODE", "disabled") monkeypatch.setattr(deposits, "CONFIRMATIONS", 3) monkeypatch.setattr(deposits, "MIN_CREDIT_MICRO", 10_000) @@ -113,6 +119,42 @@ async def rpc(method, _params): "eth_getTransactionByHash": transaction, "eth_getTransactionReceipt": receipt, "eth_blockNumber": hex(102), + "eth_getBlockByNumber": {"timestamp": hex(int(datetime.now(UTC).timestamp()))}, + }[method] + + return rpc + + +def _rpc_for_swap( + usdc_received: int, + *, + eth_value: int = 10**18, + sender: str = WALLET, +): + transaction = { + "hash": TX, + "from": sender, + "to": ROUTER, + "value": hex(eth_value), + } + logs = ( + [_transfer_log(USDC, POOL, TREASURY, usdc_received)] + if usdc_received + else [] + ) + receipt = { + "status": "0x1", + "blockNumber": hex(100), + "logs": logs, + } + + async def rpc(method, _params): + return { + "eth_chainId": hex(8453), + "eth_getTransactionByHash": transaction, + "eth_getTransactionReceipt": receipt, + "eth_blockNumber": hex(102), + "eth_getBlockByNumber": {"timestamp": hex(1_785_139_200)}, }[method] return rpc @@ -159,6 +201,20 @@ async def fail_credit(*_args, **_kwargs): assert ledger_count == 0 +@pytest.mark.asyncio +async def test_usdc_claim_rejects_over_cap_transfer(db, funding, monkeypatch): + monkeypatch.setattr(deposits, "_rpc", _rpc_for(USDC, 100_000_001)) + + with pytest.raises(HTTPException) as exc: + await deposits.verify_and_credit( + TX, + {"account_id": db, "wallet": WALLET}, + ) + assert exc.value.status_code == 422 + assert await credits.get_balance(db) == 0 + assert await _deposit_count() == 0 + + @pytest.mark.asyncio async def test_claim_requires_transaction_from_linked_wallet(db, funding, monkeypatch): monkeypatch.setattr(deposits, "_rpc", _rpc_for(USDC, 5_000_000, sender=OTHER)) @@ -202,6 +258,30 @@ async def test_aipg_claim_uses_epoch_haircut_and_records_provenance(db, funding, assert await credits.get_balance(db) == 19_400_000 +@pytest.mark.asyncio +async def test_aipg_rejects_transfer_outside_price_epoch(db, funding, monkeypatch): + rpc = _rpc_for(AIPG, 10_000 * 10**18) + + async def old_block(method, params): + if method == "eth_getBlockByNumber": + return { + "timestamp": hex( + int((datetime.now(UTC) - timedelta(days=2)).timestamp()), + ), + } + return await rpc(method, params) + + monkeypatch.setattr(deposits, "_rpc", old_block) + with pytest.raises(HTTPException) as exc: + await deposits.verify_and_credit_aipg( + TX, + {"account_id": db, "wallet": WALLET}, + ) + assert exc.value.status_code == 422 + assert await credits.get_balance(db) == 0 + assert await _deposit_count() == 0 + + @pytest.mark.asyncio async def test_aipg_expired_epoch_fails_before_rpc(db, funding, monkeypatch): monkeypatch.setattr( @@ -285,6 +365,74 @@ async def test_aipg_network_daily_cap_is_atomic(db, funding, monkeypatch): assert await credits.get_balance(other_id) == 0 +@pytest.mark.asyncio +async def test_converted_eth_credits_actual_usdc_and_records_execution(db, funding, monkeypatch): + monkeypatch.setattr(deposits, "ETH_CONVERSION_MODE", "swap_receipt") + monkeypatch.setattr(deposits, "_rpc", _rpc_for_swap(25_000_000)) + account = {"account_id": db, "wallet": WALLET} + + first = await deposits.verify_and_credit_converted_eth(TX, account) + second = await deposits.verify_and_credit_converted_eth(TX, account) + + assert first["credited"] is True + assert first["asset"] == "ETH" + assert first["amount"] == "1" + assert first["amount_usd"] == 25.0 + assert first["price_source"] == "swap:actual-base-usdc-proceeds" + assert second["already_claimed"] is True + assert await credits.get_balance(db) == 25_000_000 + + async with await database.new_session() as session: + row = (await session.execute(sa.select(deposits_t))).mappings().one() + assert int(row["amount_raw"]) == 10**18 + assert row["credited_micro"] == 25_000_000 + assert row["price_micro"] == 25_000_000 + assert row["price_block"] == 100 + assert row["price_timestamp"].replace(tzinfo=UTC) == datetime.fromtimestamp( + 1_785_139_200, + tz=UTC, + ) + + +@pytest.mark.asyncio +async def test_converted_eth_rejects_missing_usdc_proceeds(db, funding, monkeypatch): + monkeypatch.setattr(deposits, "ETH_CONVERSION_MODE", "swap_receipt") + monkeypatch.setattr(deposits, "_rpc", _rpc_for_swap(0)) + + with pytest.raises(HTTPException) as exc: + await deposits.verify_and_credit_converted_eth( + TX, + {"account_id": db, "wallet": WALLET}, + ) + assert exc.value.status_code == 422 + assert await credits.get_balance(db) == 0 + assert await _deposit_count() == 0 + + +@pytest.mark.asyncio +async def test_converted_eth_rejects_over_cap_proceeds(db, funding, monkeypatch): + monkeypatch.setattr(deposits, "ETH_CONVERSION_MODE", "swap_receipt") + monkeypatch.setattr(deposits, "_rpc", _rpc_for_swap(100_000_001)) + + with pytest.raises(HTTPException) as exc: + await deposits.verify_and_credit_converted_eth( + TX, + {"account_id": db, "wallet": WALLET}, + ) + assert exc.value.status_code == 422 + assert await credits.get_balance(db) == 0 + assert await _deposit_count() == 0 + + +async def _deposit_count() -> int: + async with await database.new_session() as session: + return int( + await session.scalar( + sa.select(sa.func.count()).select_from(deposits_t), + ), + ) + + def test_funding_config_is_explicit_about_credit_terms(funding): config = deposits.funding_config({"wallet": WALLET}) assets = {asset["asset"]: asset for asset in config["assets"]} @@ -292,6 +440,17 @@ def test_funding_config_is_explicit_about_credit_terms(funding): assert config["terms"]["credits_transferable"] is False assert config["terms"]["credits_withdrawable"] is False assert assets["USDC"]["enabled"] is True + assert assets["USDC"]["maximum_credit_micro"] == 100_000_000 assert assets["AIPG"]["enabled"] is True assert assets["ETH"]["enabled"] is False assert assets["ETH"]["status"] == "conversion_required" + + +def test_funding_config_exposes_swap_receipt_without_direct_send(funding, monkeypatch): + monkeypatch.setattr(deposits, "ETH_CONVERSION_MODE", "swap_receipt") + config = deposits.funding_config({"wallet": WALLET}) + eth = next(asset for asset in config["assets"] if asset["asset"] == "ETH") + assert eth["enabled"] is False + assert eth["backend_claim_enabled"] is True + assert eth["status"] == "conversion_ready" + assert eth["treasury"] == TREASURY diff --git a/grid_api/services/tests/test_x402_payments.py b/grid_api/services/tests/test_x402_payments.py index 7838daa7..df6a4d01 100644 --- a/grid_api/services/tests/test_x402_payments.py +++ b/grid_api/services/tests/test_x402_payments.py @@ -25,6 +25,8 @@ PAYER = "0x1111111111111111111111111111111111111111" USDC = "0x2222222222222222222222222222222222222222" TREASURY = "0x3333333333333333333333333333333333333333" +TX_HASH = "0x" + "ab" * 32 +TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" @pytest_asyncio.fixture @@ -70,11 +72,46 @@ def _payment(amount: int): return payload, requirements +def _topic(address: str) -> str: + return "0x" + "0" * 24 + address[2:] + + async def _rows(table): async with await database.new_session() as session: return (await session.execute(sa.select(table))).all() +def _hook_context(job_id: str, amount: int, *, transaction: str | None = None): + return SimpleNamespace( + requirements=SimpleNamespace(amount=str(amount)), + transport_context=SimpleNamespace( + response_headers={"X-Grid-Job-ID": job_id}, + ), + result=SimpleNamespace(transaction=transaction), + error=RuntimeError("facilitator outcome ambiguous"), + ) + + +async def _terminal_payment(job_id: str, maximum: int, actual: int) -> None: + payload, requirements = _payment(maximum) + result = await credits.authorize_x402_request( + MODEL, + 100, + 500, + job_id, + payment_payload=payload, + payment_requirements=requirements, + ) + assert result["ok"] is True + async with await database.new_session() as session: + await session.execute( + sa.update(reservations) + .where(reservations.c.job_id == job_id) + .values(status="settled", actual_micro=actual, settled=datetime.now(UTC)), + ) + await session.commit() + + def test_cdp_headers_are_short_lived_and_bound_to_each_endpoint(monkeypatch): from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec @@ -315,3 +352,235 @@ async def test_actual_cost_is_recorded_and_payout_waits_for_onchain_settlement(d "payout_address": PAYER, }, ] + + +@pytest.mark.asyncio +async def test_settlement_hooks_persist_attempt_before_receipt(db, enabled): + job_id = str(uuid.uuid4()) + maximum = pricing.quote_text(MODEL, 100, 500) + actual = pricing.quote_text(MODEL, 100, 50) + await _terminal_payment(job_id, maximum, actual) + context = _hook_context(job_id, actual, transaction=TX_HASH) + + await x402_payments._before_settle(context) + attempted = (await _rows(payments))[0] + assert attempted.status == "settling" + assert attempted.settled_micro == actual + assert attempted.attempts == 1 + assert attempted.last_attempt is not None + + await x402_payments._after_settle(context) + reported = (await _rows(payments))[0] + assert reported.status == "reported" + assert reported.tx_hash == TX_HASH + assert await x402_payments.settled_amount(job_id) is None + + +@pytest.mark.asyncio +async def test_reported_payment_requires_independent_base_proof( + db, + enabled, + monkeypatch, +): + job_id = str(uuid.uuid4()) + maximum = pricing.quote_text(MODEL, 100, 500) + actual = pricing.quote_text(MODEL, 100, 50) + await _terminal_payment(job_id, maximum, actual) + context = _hook_context(job_id, actual, transaction=TX_HASH) + await x402_payments._before_settle(context) + await x402_payments._after_settle(context) + receipt = { + "status": "0x1", + "blockNumber": "0x64", + "logs": [ + { + "address": USDC, + "topics": [TRANSFER_TOPIC, _topic(PAYER), _topic(TREASURY)], + "data": hex(actual), + }, + ], + } + + async def confirmed(_tx_hash, _label): + return {"hash": TX_HASH}, receipt, 100 + + async def block_timestamp(_block): + return datetime.now(UTC) + + from grid_api.services import deposits + + monkeypatch.setattr(deposits, "_confirmed_transaction", confirmed) + monkeypatch.setattr(deposits, "_block_timestamp", block_timestamp) + assert await x402_payments.verify_reported_settlements() == { + "settled": 1, + "pending": 0, + "manual_review": 0, + } + assert (await _rows(payments))[0].status == "settled" + assert await x402_payments.settled_amount(job_id) == actual + + +@pytest.mark.asyncio +async def test_unproven_facilitator_report_goes_to_manual_review( + db, + enabled, + monkeypatch, +): + job_id = str(uuid.uuid4()) + maximum = pricing.quote_text(MODEL, 100, 500) + actual = pricing.quote_text(MODEL, 100, 50) + await _terminal_payment(job_id, maximum, actual) + context = _hook_context(job_id, actual, transaction=TX_HASH) + await x402_payments._before_settle(context) + await x402_payments._after_settle(context) + receipt = { + "status": "0x1", + "blockNumber": "0x64", + "logs": [ + { + "address": USDC, + "topics": [TRANSFER_TOPIC, _topic(PAYER), _topic(TREASURY)], + "data": hex(actual - 1), + }, + ], + } + + async def confirmed(_tx_hash, _label): + return {"hash": TX_HASH}, receipt, 100 + + async def block_timestamp(_block): + return datetime.now(UTC) + + from grid_api.services import deposits + + monkeypatch.setattr(deposits, "_confirmed_transaction", confirmed) + monkeypatch.setattr(deposits, "_block_timestamp", block_timestamp) + assert await x402_payments.verify_reported_settlements() == { + "settled": 0, + "pending": 0, + "manual_review": 1, + } + assert (await _rows(payments))[0].status == "manual_review" + assert await x402_payments.settled_amount(job_id) is None + + +@pytest.mark.asyncio +async def test_ambiguous_settlement_never_auto_retries_or_unlocks_payout(db, enabled): + job_id = str(uuid.uuid4()) + maximum = pricing.quote_text(MODEL, 100, 500) + actual = pricing.quote_text(MODEL, 100, 50) + await _terminal_payment(job_id, maximum, actual) + context = _hook_context(job_id, actual) + + await x402_payments._before_settle(context) + await x402_payments._on_settle_failure(context) + + row = (await _rows(payments))[0] + assert row.status == "manual_review" + assert row.settled_micro == actual + assert await x402_payments.settled_amount(job_id) is None + + +@pytest.mark.asyncio +async def test_stale_settling_is_flagged_for_manual_review(db, enabled): + job_id = str(uuid.uuid4()) + maximum = pricing.quote_text(MODEL, 100, 500) + actual = pricing.quote_text(MODEL, 100, 50) + await _terminal_payment(job_id, maximum, actual) + context = _hook_context(job_id, actual) + await x402_payments._before_settle(context) + async with await database.new_session() as session: + await session.execute( + sa.update(payments) + .where(payments.c.job_id == job_id) + .values(last_attempt=datetime.now(UTC) - timedelta(hours=2)), + ) + await session.commit() + + assert await x402_payments.flag_stale_settlements(older_than_seconds=60) == 1 + row = (await _rows(payments))[0] + assert row.status == "manual_review" + + +@pytest.mark.asyncio +async def test_operator_reconcile_requires_exact_confirmed_usdc_transfer( + db, + enabled, + monkeypatch, +): + job_id = str(uuid.uuid4()) + maximum = pricing.quote_text(MODEL, 100, 500) + actual = pricing.quote_text(MODEL, 100, 50) + await _terminal_payment(job_id, maximum, actual) + context = _hook_context(job_id, actual) + await x402_payments._before_settle(context) + await x402_payments._on_settle_failure(context) + + receipt = { + "status": "0x1", + "blockNumber": "0x64", + "logs": [ + { + "address": USDC, + "topics": [TRANSFER_TOPIC, _topic(PAYER), _topic(TREASURY)], + "data": hex(actual), + }, + ], + } + + async def confirmed(tx_hash, _label): + assert tx_hash == TX_HASH + return {"hash": tx_hash}, receipt, 100 + + async def block_timestamp(_block): + return datetime.now(UTC) + + from grid_api.services import deposits + + monkeypatch.setattr(deposits, "_confirmed_transaction", confirmed) + monkeypatch.setattr(deposits, "_block_timestamp", block_timestamp) + result = await x402_payments.reconcile_transaction(job_id, TX_HASH) + assert result["status"] == "settled" + assert result["settled_micro"] == actual + assert result["already_reconciled"] is False + assert (await _rows(payments))[0].tx_hash == TX_HASH + + again = await x402_payments.reconcile_transaction(job_id, TX_HASH) + assert again["already_reconciled"] is True + + +@pytest.mark.asyncio +async def test_operator_reconcile_rejects_wrong_amount(db, enabled, monkeypatch): + job_id = str(uuid.uuid4()) + maximum = pricing.quote_text(MODEL, 100, 500) + actual = pricing.quote_text(MODEL, 100, 50) + await _terminal_payment(job_id, maximum, actual) + context = _hook_context(job_id, actual) + await x402_payments._before_settle(context) + await x402_payments._on_settle_failure(context) + + receipt = { + "status": "0x1", + "blockNumber": "0x64", + "logs": [ + { + "address": USDC, + "topics": [TRANSFER_TOPIC, _topic(PAYER), _topic(TREASURY)], + "data": hex(actual - 1), + }, + ], + } + + async def confirmed(_tx_hash, _label): + return {"hash": TX_HASH}, receipt, 100 + + async def block_timestamp(_block): + return datetime.now(UTC) + + from grid_api.services import deposits + + monkeypatch.setattr(deposits, "_confirmed_transaction", confirmed) + monkeypatch.setattr(deposits, "_block_timestamp", block_timestamp) + with pytest.raises(RuntimeError, match="exact x402 USDC"): + await x402_payments.reconcile_transaction(job_id, TX_HASH) + assert (await _rows(payments))[0].status == "manual_review" diff --git a/grid_api/services/x402_payments.py b/grid_api/services/x402_payments.py index a42d7739..0c9b0e25 100644 --- a/grid_api/services/x402_payments.py +++ b/grid_api/services/x402_payments.py @@ -12,8 +12,11 @@ * disabled unless every required operator setting is present. An x402 signature is authorization, not revenue. The request handler writes a -``verified`` payment row before dispatch and the SDK after-settle hook records -the transfer. Worker payout queries exclude the job until that row is settled. +``verified`` payment row before dispatch. A before-settle hook durably records +the exact attempted amount before the facilitator can touch chain. Facilitator +success is only ``reported``; Core promotes it to ``settled`` after independently +proving the exact canonical-USDC transfer on Base. Worker payout queries exclude +every other state. """ from __future__ import annotations @@ -26,10 +29,11 @@ import secrets import time from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from urllib.parse import urlparse import sqlalchemy as sa +from fastapi import HTTPException from ..database import new_session from ..v2.schema import x402_payments as payments_t @@ -63,6 +67,10 @@ 1, int(os.getenv("GRID_X402_DEFAULT_MAX_TOKENS", "4096") or 4096), ) +STALE_SECONDS = max( + 300, + int(os.getenv("GRID_X402_STALE_SECONDS", "900") or 900), +) ROUTE = "/v1/x402/chat/completions" @@ -216,62 +224,330 @@ async def settled_amount(job_id: str) -> int | None: row = ( await session.execute( sa.select(payments_t.c.settled_micro).where( - payments_t.c.job_id == str(job_id), + sa.and_( + payments_t.c.job_id == str(job_id), + payments_t.c.status == "settled", + ), ), - ), + ) ).first() return int(row[0]) if row and row[0] is not None else None -async def _update_from_hook(context, *, status: str, error: str | None = None) -> None: +def _job_id_from_context(context) -> str | None: transport = getattr(context, "transport_context", None) headers = getattr(transport, "response_headers", None) or {} - job_id = next( + return next( (value for key, value in headers.items() if key.lower() == "x-grid-job-id"), None, ) + + +async def _before_settle(context) -> None: + """Persist the exact attempted amount before any facilitator side effect.""" + job_id = _job_id_from_context(context) if not job_id: - logger.error("x402 %s hook missing X-Grid-Job-ID", status) - return + raise RuntimeError("x402 before-settle hook missing X-Grid-Job-ID") + amount = int(context.requirements.amount) + if amount <= 0: + raise RuntimeError("x402 settlement amount must be positive") - values: dict = {"status": status, "error": (error or "")[:255] or None} - if status == "settled": - result = context.result - values.update( - settled_micro=int(context.requirements.amount), - tx_hash=str(result.transaction), - settled=_now(), - error=None, + async with await new_session() as session: + updated = await session.execute( + sa.update(payments_t) + .where( + sa.and_( + payments_t.c.job_id == str(job_id), + payments_t.c.status == "verified", + amount <= payments_t.c.authorized_micro, + ), + ) + .values( + status="settling", + settled_micro=amount, + attempts=payments_t.c.attempts + 1, + last_attempt=_now(), + error=None, + ), ) + if updated.rowcount != 1: + await session.rollback() + raise RuntimeError( + f"x402 payment {job_id} is missing, already attempted, or under-authorized", + ) + await session.commit() + + +async def _after_settle(context) -> None: + job_id = _job_id_from_context(context) + if not job_id: + raise RuntimeError("x402 after-settle hook missing X-Grid-Job-ID") + result = context.result + tx_hash = str(result.transaction or "").lower() + if not ( + tx_hash.startswith("0x") + and len(tx_hash) == 66 + and all(char in "0123456789abcdef" for char in tx_hash[2:]) + ): + raise RuntimeError("x402 facilitator success omitted a valid transaction hash") try: async with await new_session() as session: updated = await session.execute( - sa.update(payments_t).where(payments_t.c.job_id == str(job_id)).values(**values), + sa.update(payments_t) + .where( + sa.and_( + payments_t.c.job_id == str(job_id), + payments_t.c.status == "settling", + ), + ) + .values( + status="reported", + tx_hash=tx_hash, + settled=None, + error=None, + ), ) if updated.rowcount != 1: - raise RuntimeError(f"x402 payment row missing for job {job_id}") + raise RuntimeError(f"x402 payment {job_id} was not in settling state") await session.commit() except Exception: - logger.exception("x402 %s could not be persisted job=%s", status, job_id) + logger.exception("x402 settled receipt could not be persisted job=%s", job_id) alerts.emit( "x402_receipt_persist_failed", "critical", - "An x402 facilitator result could not be persisted.", - fields={"job": alerts.opaque_id(job_id), "status": status}, + "An x402 facilitator receipt could not be persisted.", + fields={"job": alerts.opaque_id(job_id), "status": "settling"}, dedupe_key=f"x402-receipt:{alerts.opaque_id(job_id)}", ) raise -async def _after_settle(context) -> None: - await _update_from_hook(context, status="settled") - - async def _on_settle_failure(context): - await _update_from_hook(context, status="failed", error=str(context.error)) + job_id = _job_id_from_context(context) + if not job_id: + logger.error("x402 failure hook missing X-Grid-Job-ID") + return None + error = str(context.error)[:255] or "settlement failed" + try: + async with await new_session() as session: + # Only a call that crossed the durable before-settle boundary is + # ambiguous. A failure before that boundary leaves `verified` + # retryable and cannot have called the facilitator. + await session.execute( + sa.update(payments_t) + .where( + sa.and_( + payments_t.c.job_id == str(job_id), + payments_t.c.status == "settling", + ), + ) + .values(status="manual_review", error=error), + ) + await session.commit() + except Exception: + logger.exception("x402 failure state could not be persisted job=%s", job_id) + alerts.emit( + "x402_failure_persist_failed", + "critical", + "An ambiguous x402 settlement failure could not be persisted.", + fields={"job": alerts.opaque_id(job_id)}, + dedupe_key=f"x402-failure:{alerts.opaque_id(job_id)}", + ) + raise return None +async def flag_stale_settlements(older_than_seconds: int | None = None) -> int: + """Move abandoned `settling`/`reported` rows to manual review.""" + cutoff = _now() - timedelta(seconds=older_than_seconds or STALE_SECONDS) + async with await new_session() as session: + updated = await session.execute( + sa.update(payments_t) + .where( + sa.and_( + payments_t.c.status.in_(("settling", "reported")), + payments_t.c.last_attempt < cutoff, + ), + ) + .values( + status="manual_review", + error="settlement outcome not independently proven before timeout", + ), + ) + await session.commit() + count = int(updated.rowcount or 0) + if count: + alerts.emit( + "x402_settlement_stale", + "critical", + "x402 settlements require on-chain operator reconciliation.", + fields={"count": count}, + dedupe_key="x402-settlement-stale", + ) + return count + + +async def verify_reported_settlements(limit: int = 50) -> dict[str, int]: + """Promote facilitator reports only after exact Base transfer proof.""" + limit = max(1, min(int(limit or 50), 250)) + async with await new_session() as session: + rows = ( + await session.execute( + sa.select(payments_t.c.job_id, payments_t.c.tx_hash) + .where(payments_t.c.status == "reported") + .order_by(payments_t.c.last_attempt) + .limit(limit), + ) + ).all() + outcome = {"settled": 0, "pending": 0, "manual_review": 0} + for job_id, tx_hash in rows: + try: + await reconcile_transaction(str(job_id), str(tx_hash)) + outcome["settled"] += 1 + except HTTPException: + # Not mined, not sufficiently confirmed, or RPC unavailable. Keep + # the report payout-ineligible and try again until the stale gate. + outcome["pending"] += 1 + except RuntimeError as exc: + async with await new_session() as session: + updated = await session.execute( + sa.update(payments_t) + .where( + sa.and_( + payments_t.c.job_id == str(job_id), + payments_t.c.status == "reported", + ), + ) + .values(status="manual_review", error=str(exc)[:255]), + ) + await session.commit() + if updated.rowcount: + outcome["manual_review"] += 1 + alerts.emit( + "x402_report_unproven", + "critical", + "A facilitator-reported x402 transfer failed independent Base verification.", + fields={"job": alerts.opaque_id(job_id)}, + dedupe_key=f"x402-unproven:{alerts.opaque_id(job_id)}", + ) + return outcome + + +async def reconcile_transaction(job_id: str, tx_hash: str) -> dict: + """Prove an ambiguous x402 payment from its confirmed Base USDC transfer. + + This is intentionally an operator path, not an automatic retry. The + transaction must contain canonical-USDC transfers from the recorded payer + to the recorded recipient totaling the exact grid-counted charge. + """ + from ..v2.schema import reservations as reservations_t + from . import deposits + + job_id = str(job_id).strip() + tx_hash = deposits._normalize_tx_hash(tx_hash) + async with await new_session() as session: + row = ( + await session.execute( + sa.select( + payments_t.c.payer, + payments_t.c.asset, + payments_t.c.pay_to, + payments_t.c.authorized_micro, + payments_t.c.settled_micro, + payments_t.c.tx_hash, + payments_t.c.status, + payments_t.c.last_attempt, + reservations_t.c.actual_micro, + ) + .join( + reservations_t, + reservations_t.c.job_id == payments_t.c.job_id, + ) + .where(payments_t.c.job_id == job_id), + ) + ).mappings().first() + if not row: + raise RuntimeError("x402 payment job was not found") + if row["status"] == "settled": + if (row["tx_hash"] or "").lower() != tx_hash: + raise RuntimeError("x402 payment is already settled by another transaction") + return { + "job_id": job_id, + "status": "settled", + "tx_hash": tx_hash, + "already_reconciled": True, + } + + actual = int(row["actual_micro"] or 0) + attempted = int(row["settled_micro"] or 0) + if actual <= 0 or attempted != actual: + raise RuntimeError("x402 payment has no exact terminal charge to reconcile") + if actual > int(row["authorized_micro"]): + raise RuntimeError("x402 terminal charge exceeds its authorization") + attempted_at = row["last_attempt"] + if attempted_at is None: + raise RuntimeError("x402 payment has no durable settlement attempt") + if attempted_at.tzinfo is None: + attempted_at = attempted_at.replace(tzinfo=UTC) + + _tx, receipt, block_number = await deposits._confirmed_transaction( + tx_hash, + "x402 USDC", + ) + block_time = await deposits._block_timestamp(block_number) + if block_time < attempted_at - timedelta(minutes=5) or block_time > _now() + timedelta(minutes=5): + raise RuntimeError("confirmed transaction is outside the x402 settlement window") + received = deposits._direct_erc20_amount( + receipt, + str(row["asset"]).lower(), + str(row["pay_to"]).lower(), + str(row["payer"]).lower(), + ) + if received != actual: + raise RuntimeError( + "confirmed transaction does not prove the exact x402 USDC transfer", + ) + + async with await new_session() as session: + try: + updated = await session.execute( + sa.update(payments_t) + .where( + sa.and_( + payments_t.c.job_id == job_id, + payments_t.c.status != "settled", + payments_t.c.settled_micro == actual, + ), + ) + .values( + status="settled", + tx_hash=tx_hash, + settled=_now(), + error=None, + ), + ) + if updated.rowcount != 1: + raise RuntimeError("x402 payment changed during reconciliation") + await session.commit() + except Exception: + await session.rollback() + raise + alerts.emit( + "x402_payment_reconciled", + "success", + "An ambiguous x402 payment was reconciled from a confirmed Base transfer.", + fields={"job": alerts.opaque_id(job_id), "tx": alerts.opaque_id(tx_hash)}, + dedupe_key=f"x402-reconciled:{alerts.opaque_id(job_id)}", + ) + return { + "job_id": job_id, + "status": "settled", + "tx_hash": tx_hash, + "settled_micro": actual, + "already_reconciled": False, + } + + @dataclass(frozen=True) class X402Runtime: server: object @@ -291,6 +567,7 @@ def build_runtime() -> X402Runtime: ) server = x402ResourceServer(facilitator) server.register(NETWORK, UptoEvmServerScheme()) + server.on_before_settle(_before_settle) server.on_after_settle(_after_settle) server.on_settle_failure(_on_settle_failure) routes = { @@ -324,3 +601,27 @@ def install_middleware(app) -> None: routes=runtime.routes, server=runtime.server, ) + + +async def _run_reconcile_cli(job_id: str, tx_hash: str) -> None: + from ..database import close_database, init_database + + await init_database() + try: + result = await reconcile_transaction(job_id, tx_hash) + print(json.dumps(result, sort_keys=True)) + finally: + await close_database() + + +if __name__ == "__main__": + import argparse + import asyncio + + parser = argparse.ArgumentParser( + description="Reconcile one ambiguous x402 job from a confirmed Base transaction.", + ) + parser.add_argument("--reconcile-job", required=True, help="Grid job UUID") + parser.add_argument("--tx", required=True, help="Base transaction hash") + args = parser.parse_args() + asyncio.run(_run_reconcile_cli(args.reconcile_job, args.tx)) diff --git a/grid_api/v2/schema.py b/grid_api/v2/schema.py index 8cb4b2dc..0c21fefe 100644 --- a/grid_api/v2/schema.py +++ b/grid_api/v2/schema.py @@ -550,10 +550,10 @@ def utcnow() -> datetime: # x402 is a post-response on-chain settlement rail. A verified authorization is -# recorded before dispatch, then an SDK after-settle hook records the actual USDC -# transfer. Worker payout aggregation excludes x402 jobs until this row is -# `settled`, so a verified signature or failed facilitator call cannot mint a -# worker payout. +# recorded before dispatch. The before-settle hook durably records the exact +# attempted amount before the facilitator can touch chain. Facilitator success +# becomes `reported`; only independent exact-transfer verification moves it to +# `settled`. Worker payout aggregation excludes every other state. x402_payments = sa.Table( "grid_x402_payments", metadata, @@ -568,6 +568,8 @@ def utcnow() -> datetime: sa.Column("tx_hash", sa.String(80), nullable=True), sa.Column("status", sa.String(16), nullable=False, default="verified", index=True), sa.Column("error", sa.String(255), nullable=True), + sa.Column("attempts", sa.Integer, nullable=False, server_default=sa.text("0"), default=0), + sa.Column("last_attempt", sa.DateTime(timezone=True), nullable=True), sa.Column("created", sa.DateTime(timezone=True), nullable=False, default=utcnow, index=True), sa.Column("settled", sa.DateTime(timezone=True), nullable=True), sa.CheckConstraint("authorized_micro > 0", name="ck_grid_x402_positive_authorization"),