diff --git a/grid_api/routers/accounts.py b/grid_api/routers/accounts.py index 99d5dfd5..ebd20288 100644 --- a/grid_api/routers/accounts.py +++ b/grid_api/routers/accounts.py @@ -1342,7 +1342,7 @@ async def get_deposit_config( user = await _require_v2(apikey, authorization) from ..services import deposits - return deposits.funding_config(user) + return await deposits.funding_config(user) @router.get("/v1/account/deposits") diff --git a/grid_api/services/AGENTS.md b/grid_api/services/AGENTS.md index 27f97fda..6d64d079 100644 --- a/grid_api/services/AGENTS.md +++ b/grid_api/services/AGENTS.md @@ -25,8 +25,9 @@ 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` (atomic Base funding receipts plus USDC, bounded AIPG, and - conversion-gated ETH claims), `x402_payments.py` (default-off accountless + `deposits.py` (atomic Base funding receipts from verified account wallets + 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 @@ -97,6 +98,9 @@ content sanitization, and reward settlement. - Account merges require proof of both sides, refuse active holds, revoke source keys, preserve accrued payout reachability, and move purchased balance through paired append-only ledger entries. +- Deposit senders must match a verified wallet identity on the canonical + account. Never accept a client-supplied funding address without checking its + verified identity hash. - Service keys remain long-lived backend credentials but cannot manage user accounts. Global Google/SIWE proof is verified by Core; app delegation is namespaced to one service and receives bounded inference authority. diff --git a/grid_api/services/deposits.py b/grid_api/services/deposits.py index eadbaf70..1dace126 100644 --- a/grid_api/services/deposits.py +++ b/grid_api/services/deposits.py @@ -34,7 +34,7 @@ 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 +from . import alerts, credits, identities logger = logging.getLogger("grid_api.deposits") @@ -191,13 +191,28 @@ def eth_swap_receipt_is_configured() -> bool: ) -def funding_config(account: dict) -> dict: +async def _verified_funding_wallets(account: dict) -> list[str]: + """Resolve every wallet that has been proved for the canonical account.""" + wallets: list[str] = [] + primary = (account.get("wallet") or "").lower() + if _valid_address(primary): + wallets.append(primary) + if account.get("account_id"): + for wallet in await identities.verified_wallet_addresses(account["account_id"]): + if wallet not in wallets: + wallets.append(wallet) + return wallets + + +async 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() + wallets = await _verified_funding_wallets(account) return { "chain": {"id": CHAIN_ID, "name": "Base"}, - "linked_wallet": wallet if _valid_address(wallet) else None, + # Keep the singular field during the Console rollout. + "linked_wallet": wallets[0] if wallets else None, + "linked_wallets": wallets, "terms": { "unit": "USD", "credits_transferable": False, @@ -323,16 +338,16 @@ async def _confirmed_transaction(tx_hash: str, asset: str) -> tuple[dict, dict, return tx, receipt, block_number -def _linked_sender(tx: dict, account: dict, asset: str) -> str: +async 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): + wallets = await _verified_funding_wallets(account) + if not wallets: raise HTTPException(403, detail="Link a wallet before claiming Base deposits.") - if sender != wallet: + if sender not in wallets: alerts.emit( "deposit_wallet_mismatch", "warning", - "A deposit claim sender did not match the authenticated wallet.", + "A deposit claim sender did not match a verified account wallet.", fields={ "asset": asset, "account": alerts.opaque_id(account.get("account_id")), @@ -340,7 +355,10 @@ def _linked_sender(tx: dict, account: dict, asset: str) -> str: }, 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.") + raise HTTPException( + 403, + detail="This deposit was sent from a wallet that is not verified on your account.", + ) return sender @@ -591,7 +609,7 @@ async def verify_and_credit(tx_hash: str, account: dict) -> dict: 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") + sender = await _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.") @@ -642,7 +660,7 @@ async def verify_and_credit_aipg(tx_hash: str, account: dict) -> dict: 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") + sender = await _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.") @@ -705,7 +723,7 @@ async def verify_and_credit_eth(tx_hash: str, account: dict) -> dict: ) tx_hash = _normalize_tx_hash(tx_hash) tx, receipt, block_number = await _confirmed_transaction(tx_hash, "ETH") - sender = _linked_sender(tx, account, "ETH") + sender = await _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.") amount_raw = int(tx.get("value", "0x0") or "0x0", 16) @@ -777,7 +795,7 @@ async def verify_and_credit_converted_eth(tx_hash: str, account: dict) -> dict: ) tx_hash = _normalize_tx_hash(tx_hash) tx, receipt, block_number = await _confirmed_transaction(tx_hash, "ETH->USDC") - sender = _linked_sender(tx, account, "ETH") + sender = await _linked_sender(tx, account, "ETH") amount_raw = int(tx.get("value", "0x0") or "0x0", 16) if amount_raw <= 0: raise HTTPException( diff --git a/grid_api/services/identities.py b/grid_api/services/identities.py index cf097d42..891fb796 100644 --- a/grid_api/services/identities.py +++ b/grid_api/services/identities.py @@ -131,6 +131,43 @@ async def list_identities(account_id) -> list[dict]: return [dict(row) for row in rows] +async def verified_wallet_addresses(account_id) -> list[str]: + """Return wallet addresses whose stored hint matches their verified identity hash.""" + aid = await canonical_account_id(account_id) + async with await new_session() as session: + rows = (await session.execute( + sa.select( + account_identities.c.display_hint, + account_identities.c.subject_hash, + account_identities.c.is_primary, + account_identities.c.created, + ).where( + account_identities.c.account_id == aid, + account_identities.c.kind == "wallet", + account_identities.c.verified_at.is_not(None), + ).order_by( + account_identities.c.is_primary.desc(), + account_identities.c.created, + ) + )).mappings().all() + + wallets: list[str] = [] + for row in rows: + try: + wallet = canonical_subject("wallet", row["display_hint"] or "") + except ValueError: + continue + if ( + len(wallet) == 42 + and wallet.startswith("0x") + and all(char in "0123456789abcdef" for char in wallet[2:]) + and subject_hash("wallet", wallet) == row["subject_hash"] + and wallet not in wallets + ): + wallets.append(wallet) + return wallets + + async def attach_identity(account_id, kind: str, subject: str, *, display_hint: str | None = None, metadata: dict | None = None, make_primary: bool = True, ref: str | None = None) -> dict: diff --git a/grid_api/services/tests/test_deposits.py b/grid_api/services/tests/test_deposits.py index 18d7a008..d61d4152 100644 --- a/grid_api/services/tests/test_deposits.py +++ b/grid_api/services/tests/test_deposits.py @@ -14,8 +14,8 @@ 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.services import credits, deposits, identities +from grid_api.v2.schema import account_identities, accounts, credit_ledger, metadata from grid_api.v2.schema import deposits as deposits_t WALLET = "0x1111111111111111111111111111111111111111" @@ -224,6 +224,44 @@ async def test_claim_requires_transaction_from_linked_wallet(db, funding, monkey assert await credits.get_balance(db) == 0 +@pytest.mark.asyncio +async def test_claim_accepts_any_verified_wallet_on_canonical_account( + db, + funding, + monkeypatch, +): + now = datetime.now(UTC) + async with await database.new_session() as session: + await session.execute( + sa.insert(account_identities).values( + id=uuid.uuid4(), + account_id=db, + kind="wallet", + subject_hash=identities.subject_hash("wallet", OTHER), + display_hint=OTHER, + metadata={}, + verified_at=now, + is_primary=False, + created=now, + ), + ) + await session.commit() + monkeypatch.setattr( + deposits, + "_rpc", + _rpc_for(USDC, 5_000_000, sender=OTHER), + ) + + result = await deposits.verify_and_credit( + TX, + {"account_id": db, "wallet": WALLET}, + ) + + assert result["credited"] is True + assert result["from"] == OTHER + assert await credits.get_balance(db) == 5_000_000 + + @pytest.mark.asyncio async def test_claim_rejects_rpc_on_the_wrong_chain(db, funding, monkeypatch): rpc = _rpc_for(USDC, 5_000_000) @@ -433,8 +471,9 @@ async def _deposit_count() -> int: ) -def test_funding_config_is_explicit_about_credit_terms(funding): - config = deposits.funding_config({"wallet": WALLET}) +@pytest.mark.asyncio +async def test_funding_config_is_explicit_about_credit_terms(funding): + config = await 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 @@ -446,9 +485,31 @@ def test_funding_config_is_explicit_about_credit_terms(funding): assert assets["ETH"]["status"] == "conversion_required" -def test_funding_config_exposes_swap_receipt_without_direct_send(funding, monkeypatch): +@pytest.mark.asyncio +async def test_funding_config_lists_verified_secondary_wallet( + db, + funding, +): + await identities.attach_identity( + db, + "wallet", + OTHER, + display_hint=OTHER, + make_primary=False, + ) + + config = await deposits.funding_config( + {"account_id": db, "wallet": WALLET}, + ) + + assert config["linked_wallet"] == WALLET + assert config["linked_wallets"] == [WALLET, OTHER] + + +@pytest.mark.asyncio +async 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}) + config = await 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