Control & safety/ UTILITY 10Partly live

Mandates

A mandate is the rulebook an agent spends under: a daily budget, a cap per call, the only endpoints it may pay and a kill switch. Today it is a JSON file enforced inside the agent’s x402 client. Enforcement by a Solana program is planned.

On this page+

The format

mandate.json
{
  "version": 1,
  "agent": "research-bot",
  "owner": "OWNER_WALLET_ADDRESS",
  "wallet": "AGENT_WALLET_ADDRESS",
  "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
  "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
  "dailyBudgetUsdc": "2",
  "perCallCapUsdc": "0.05",
  "allowlist": ["https://api.seller.example/forecast"],
  "killSwitch": false,
  "updatedAt": "2026-10-15T12:00:00.000Z"
}
FieldMeaning
agentA name for the agent, such as research-bot.
ownerYour main wallet: the address that signed in and funds the agent.
walletThe agent wallet's address, or null if none is linked yet. The wrapper refuses a key that doesn't match it.
network, assetSolana mainnet and the USDC mint. Offers on other networks or in other tokens are never paid.
dailyBudgetUsdcDecimal USDC per UTC day, up to 6 decimals. "2" is 2 USDC. The count resets at 00:00 UTC.
perCallCapUsdcLargest single payment, decimal USDC. "0.05" is five cents.
allowlistEndpoint URLs the agent may pay. An entry matches that exact URL, or the URL followed by /… or ?…. An empty list refuses everything.
killSwitchWhen true, every payment is refused.
updatedAtWhen the mandate was last saved.

The Mandates page in the dApp edits and exports this file (Export JSON), and Ask Axon can draft one for you in the same shape. Amounts are decimal USDC strings, never floats; the wrapper converts them to atomic units (1 USDC = 1,000,000) before comparing them with an offer.

Enforcing it in the client

agent-fetch.ts
// npm i @x402/fetch @x402/svm @solana/kit
import { readFile } from "node:fs/promises";
import { createKeyPairSignerFromBytes, getBase58Encoder } from "@solana/kit";
import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
import { ExactSvmScheme } from "@x402/svm/exact/client";

/** mandate.json as exported from the Mandates page. */
type Mandate = {
  version: 1;
  agent: string;
  owner: string;
  wallet: string | null;
  network: `${string}:${string}`;
  asset: string;
  dailyBudgetUsdc: string; // decimal USDC, e.g. "2"
  perCallCapUsdc: string; // decimal USDC, e.g. "0.05"
  allowlist: string[]; // endpoint URLs; an empty list refuses everything
  killSwitch: boolean;
  updatedAt: string;
};

const mandate: Mandate = JSON.parse(await readFile(new URL("./mandate.json", import.meta.url), "utf8"));

/** "0.05" -> 50000n: decimal USDC (up to 6 dp) to atomic units, without floating point. */
function usdc(v: string): bigint {
  const m = /^(\d+)(?:\.(\d{1,6}))?$/.exec(v.trim());
  if (!m) throw new Error(`mandate: bad USDC amount "${v}"`);
  return BigInt(m[1]) * 1_000_000n + BigInt((m[2] ?? "").padEnd(6, "0"));
}

/** An entry matches the exact URL, or the URL followed by "/…" or "?…". */
const allowed = (url: string) =>
  mandate.allowlist.some((u) => url === u || url.startsWith(u.endsWith("/") ? u : u + "/") || url.startsWith(u + "?"));

const perCallCap = usdc(mandate.perCallCapUsdc);
const dailyBudget = usdc(mandate.dailyBudgetUsdc);
let day = "";
let spent = 0n;

const signer = await createKeyPairSignerFromBytes(getBase58Encoder().encode(process.env.AGENT_SECRET_KEY!));
if (mandate.wallet && signer.address !== mandate.wallet) throw new Error("mandate: this key is not the mandate's agent wallet");

const client = new x402Client()
  .register(mandate.network, new ExactSvmScheme(signer))
  // the SDK's own per-payment cap, as a second guard
  .setSpendControls({ maxAmountPerPayment: `$${mandate.perCallCapUsdc}` })
  .onBeforePaymentCreation(async ({ selectedRequirements: r }) => {
    if (mandate.killSwitch) return { abort: true, reason: "kill switch is on" };
    if (r.asset !== mandate.asset) return { abort: true, reason: "mandate covers USDC only" };
    const amount = BigInt(r.amount);
    if (amount > perCallCap) return { abort: true, reason: "over the per-call cap" };
    const today = new Date().toISOString().slice(0, 10); // budgets reset each UTC day
    if (today !== day) [day, spent] = [today, 0n];
    if (spent + amount > dailyBudget) return { abort: true, reason: "daily budget used up" };
    spent += amount; // reserved before signing; a failed call still counts
  });

const paidFetch = wrapFetchWithPayment(fetch, client);

/** fetch() for the agent: refuses sellers outside the allowlist, then pays within the limits. */
export async function mandatedFetch(url: string, init?: RequestInit) {
  if (mandate.killSwitch) throw new Error("mandate: kill switch is on");
  if (!allowed(url)) throw new Error(`mandate: ${url} is not on the allowlist`);
  return paidFetch(url, init);
}

Put mandate.json next to this file and call mandatedFetch() instead of fetch. It applies the same rules as the agent-fetch.ts the Mandates page shows; this version parses amounts as exact decimals instead of going through floating point.

How the checks work

  • The allowlist is checked on the URL before any request leaves, so an agent can’t even ask an unlisted seller for a price.
  • The before-signature hook checks the kill switch, the asset, the per-call cap and the day’s budget against the offer the client picked. Only the mandate’s network is registered, so offers on other clusters are never chosen.
  • The amount is reserved before signing, so ten parallel calls can’t all see the same remaining budget. A reserved amount still counts if the call then fails: the mandate fails closed.
  • The x402 client’s own spend control is set to the per-call cap as a second guard; it also replaces the client’s default cap of $1 per payment.
  • Budgets live in memory. An agent that restarts starts the day’s count from zero, so persist it if that matters.

On-chain mandates