---
title: "Wallets, token gates and agent payments"
description: "Add wallet sign-in, token-gated access and agent payments inside a budget, with chains kept in drivers and no custody, token issuance or chain RPC in MainSDK."
section: "guides"
status: "Planned for 0.7"
spec: "web3-ready"
canonical: "https://mainsdk.dev/docs/guides/web3-and-agent-payments"
updated: "2026-10-28"
---

# Wallets, token gates and agent payments

> Add wallet sign-in, token-gated access and agent payments inside a budget, with chains kept in drivers and no custody, token issuance or chain RPC in MainSDK.

Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/web3-and-agent-payments · Updated: 2026-10-28

You will add wallet sign-in, a token gate and a budgeted agent payment to Pinecart, a Next.js app on MainSDK. Core only verifies a signed challenge, reads an outside attribute and signs with a key; every chain and wallet is a driver. Reading it takes about 15 minutes.

> **Note:** Wallet, token-gate and agent-payment support is pre-release and planned for milestone 0.7 (spec `web3-ready`, ADR-0011, stability experimental). The steps are the planned design and the commands do not run yet. The agent budget it relies on (spec `agent-governance`) is planned for milestone 0.3.

## What core does, what drivers do, what MainSDK will not do

| | What |
| --- | --- |
| Core does | Verify a single-use signed challenge as a sign-in method. Read an external attribute inside `access`. Sign with a `secp256k1` or `ed25519` profile through `keys`. Run an agent-payment kind of `pay` driver behind an agent budget. |
| Drivers do | Every chain, wallet, signature format and settlement network. A driver is installed and conformance-tested like any other. |
| MainSDK will not | Hold customer funds or private keys (no custody). Issue tokens. Author smart contracts. Ship chain RPC or an indexer. Core has no chain package (ADR-0011). |

For contract calls and RPC, use your own library through `raw()`.

Which wallet driver ships first, SIWE for Ethereum or a chain-agnostic profile, is an open question in the spec [TBD]. The driver ids below, `siwe` and `evm-balance`, are the ones the spec's example uses.

## You need

- A Next.js app with `auth`, `access`, `keys`, `pay` and an agent budget set up.
- A wallet driver and an attribute driver for your chain [TBD: no driver is published yet].
- A user-side wallet library that signs the challenge message. MainSDK does not ship one.

## 1. Turn on wallet sign-in

```yaml title="main.yaml"
auth: { methods: [passkeys, wallet], wallet: { driver: siwe } }
```

You should see: `npx mainsdk check` passes. The method is `passkeys`, as in [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth).

## 2. Run the challenge flow

A challenge is single-use, bound to your app's domain, and expires in five minutes or less. The wallet signs `message` in the browser; your server verifies the signature and gets the same session as any other method.

```ts title="app/api/auth/wallet/start/route.ts"
import { auth } from "mainsdk";

export async function POST() {
  const { nonce, message } = await auth.challenge.start({ method: "wallet" });
  return Response.json({ nonce, message });
}
```

```ts title="app/api/auth/wallet/verify/route.ts"
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { message, signature } = (await req.json()) as { message: string; signature: string };
  const session = await auth.challenge.verify({ message, signature });   // the driver checks the chain's format
  return Response.json({ ok: true });                                    // how the session cookie is set is [TBD]
}
```

You should see: a valid signature returns a session. Send the same `message` and `signature` again and it fails with `MAIN-AUTH-0040`, because a challenge works once.

## 3. Ask for a passkey on sensitive actions

A wallet session is a session, so sensitive actions still need a stronger step. `auth.require` asks for a fresh passkey.

```ts title="app/api/withdraw/route.ts"
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req, { aal: "aal2", fresh: "5m" });   // 401 or 403 with a fix without a recent passkey
  return Response.json({ ok: true });
}
```

## 4. Gate access on a token

`access` reads the balance from an external source through the attribute hook, so the gate sits under the same policy and audit as every other decision.

```yaml title="main.yaml"
access: { attributes: { token.balance: { driver: evm-balance } } }
```

```ts title="app/api/members/route.ts"
import { access, auth } from "mainsdk";

export async function GET(req: Request) {
  const session = await auth.require(req);
  const balance = await access.attribute("token.balance", { subject: session.user, asset: "[TBD]" });
  // the asset id format and the shape of balance depend on the driver [TBD]
  return Response.json({ balance });
}
```

An attribute has a timeout and a declared freshness. How you set both in `main.yaml`, and how a policy reads `token.balance`, are not defined yet [TBD]. When the source is down, `access` fails closed: the request is denied with `MAIN-ACCESS-0020`, and the audit event says the source was down.

## 5. Sign with a chain key

Chain keys are used through `keys`, never read by your app, and each use is logged without the key.

```ts title="lib/sign.ts"
import { keys } from "mainsdk";

// profile is "secp256k1" or "ed25519"; data is what you sign; purpose goes in the audit log
const signature = await keys.sign({ profile: "secp256k1", data: "settle:o_1", purpose: "settle-order" });
```

You should see: on the local fake the signature verifies, and the audit log holds the use, not the key. Which key services offer these profiles is [TBD].

## 6. Let an agent pay inside its budget

An agent payment is always behind an agent budget, and a person must approve it above `spend-over`. It may only pay a payee you declare under `pay.payees`, and the payee's host must be allowed by `network`. See [Agents with an identity, a budget and approvals](https://mainsdk.dev/docs/agents/governance).

```yaml title="main.yaml"
agents:
  support-bot:
    budget: { spend: $5/day, calls: 500/day }
    approve: { risk: destructive, spend-over: $1 }
    enabled: true                    # false stops it on the next call
pay: { payees: { weather-api: { host: api.weather.example, max: $0.05/call } } }
```

```ts title="lib/agent-pay.ts"
import { pay } from "mainsdk";

// Money is a string; `to` is a payee declared in pay.payees
await pay.agent({ agent: "support-bot", amount: "$0.04", to: "weather-api" });
```

What you should see, with the bot's `budget: { spend: $5/day }`, `approve: { spend-over: $1 }`, `weather-api` at `max: $0.05/call` and a second payee `translator` at `max: $10/call`:

| The bot pays | Result |
| --- | --- |
| `$0.04` to `weather-api` | Within the budget, the payee's `max` and `spend-over`: it is sent. |
| `$0.06` to `weather-api` | Above the payee's `max`: refused with `MAIN-PAY-0030`; nothing is sent. |
| `$2` to `translator` | Within the budget and the payee's `max`, but above `spend-over: $1`: nothing is sent until a person approves. |
| Any amount once `$5` is spent today | Refused with `MAIN-AGENT-0002`; nothing is sent. |
| A payee not in `pay.payees` | Fails with `MAIN-PAY-0030`. An amount above the payee's `max` fails with the same code. |

The agent cannot approve its own step; that fails with `MAIN-AGENT-0003`. The format of the payee's settlement address, and how the driver sends the payment, are [TBD] until a payment driver exists.

## Verify

```bash
npx mainsdk check --env prod
```

You should see every gate pass and the command exit 0. See [Check codes](https://mainsdk.dev/docs/reference/check-codes) for what each finding means.

Test with `createTestApp()` and name each test with its criterion, for example `web3-ready.AC1` for a reused challenge (see [Testing kit](https://mainsdk.dev/docs/interfaces/testing)).

## If it fails

| Code | Cause | Fix |
| --- | --- | --- |
| `MAIN-AUTH-0040` | A challenge was verified a second time, or it expired. | Start a new challenge with `auth.challenge.start` for each sign-in. |
| `MAIN-ACCESS-0020` | The attribute source timed out or is down, so `access` denied. | Wait for the source or fix its driver. Never turn the gate off. |
| `MAIN-AGENT-0002` | The agent's budget would be exceeded and nothing was sent. | Raise the budget in `agents.<name>.budget` if it is right to, or stop the call. |
| `MAIN-PAY-0030` | The payee is not in `pay.payees`, or the amount is above its `max`. | Declare the payee with its `host` and `max`, and allow the host under `network`. |
| `MAIN-AGENT-0003` | The agent tried to approve its own step. | A person approves it. |
| `MAIN-AGENT-0004` | The agent is set to `enabled: false`. | Set `enabled: true` when the agent may run again. |

## For agents

```text
Goal: add wallet sign-in and a token gate to this app with the web3-ready calls.
Read: docs/specs/web3-ready.md, docs/specs/agent-governance.md, docs/adr/0011.
Touch only: main.yaml, app/api/auth/wallet/**, app/api/members/**, one test file.
Do: use auth.challenge.start and auth.challenge.verify; read balances with access.attribute;
keep every chain format in a driver. Never add a chain package (viem, ethers) to core code.
Never hold a key or funds. Route every agent payment through pay.agent({ agent, amount: "$0.04", to }) inside an agent budget.
Never weaken, skip or loosen a check to make it pass.
Verify: npx mainsdk check --env prod exits 0, and tests name web3-ready.AC1 to AC5.
Unknown value: write [TBD] and ask.
```

## Next

- [Agents with an identity, a budget and approvals](https://mainsdk.dev/docs/agents/governance)
- [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth)
- [Permissions](https://mainsdk.dev/docs/interfaces/access)
