MainSDK
Search⌘K
How-to

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.

Planned for 0.7

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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.

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

What
Core doesVerify 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 doEvery chain, wallet, signature format and settlement network. A driver is installed and conformance-tested like any other.
MainSDK will notHold 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. 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 [: 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

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.

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.

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 });
}
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 [to be decided]
}

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.

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.

main.yaml
access: { attributes: { token.balance: { driver: evm-balance } } }
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: "[to be decided]" });
  // the asset id format and the shape of balance depend on the driver [to be decided]
  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. 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.

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 not decided yet.

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.

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 } } }
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 paysResult
$0.04 to weather-apiWithin the budget, the payee's max and spend-over: it is sent.
$0.06 to weather-apiAbove the payee's max: refused with MAIN-PAY-0030; nothing is sent.
$2 to translatorWithin 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 todayRefused with MAIN-AGENT-0002; nothing is sent.
A payee not in pay.payeesFails 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 not decided yet 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 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).

If it fails

CodeCauseFix
MAIN-AUTH-0040A challenge was verified a second time, or it expired.Start a new challenge with auth.challenge.start for each sign-in.
MAIN-ACCESS-0020The 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-0002The 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-0030The 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-0003The agent tried to approve its own step.A person approves it.
MAIN-AGENT-0004The 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 [to be decided] and ask.

Next

Open questions

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet