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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion grid_api/routers/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 6 additions & 2 deletions grid_api/services/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
46 changes: 32 additions & 14 deletions grid_api/services/deposits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -323,24 +338,27 @@ 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")),
"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.")
raise HTTPException(
403,
detail="This deposit was sent from a wallet that is not verified on your account.",
)
return sender


Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
37 changes: 37 additions & 0 deletions grid_api/services/identities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
73 changes: 67 additions & 6 deletions grid_api/services/tests/test_deposits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading