@telaro/x402
v1.0.0
Published
x402 payment gate for Solana. Verifies the paying agent's Telaro bond before settlement. Adapters for Express, Hono, and Next.js.
Maintainers
Readme
@telaro/x402
Telaro bond/score gate for x402 services on Solana.
x402 answers "did the agent pay." It does not answer "should I trust
this agent to transact." @telaro/x402 closes that gap. It pulls the
paying wallet out of the x402 payment payload and runs a Telaro
bond + score check on it before the payment settles.
pnpm add @telaro/x402Two ways to call it
The package ships two calling conventions side by side:
- The v0.x positional signature (
gate(policy, options?)). Stable, unchanged in 1.0.0-beta. Use this if you are migrating from 0.x and want the smallest diff. - The v1 strategy switch (
gate({ strategy, policy, options, keyLinkResolver })). The shape the protocol is converging on. Picks how the payment is interpreted (trust, and eventuallysacp,sacp+trust) and exposes the AgentKeyLink hot-key fallback.
Both produce the same X402GateResult. The choice is purely about
which surface you want to commit to.
Express
v0.x positional (no change from 0.2):
import { expressX402Gate } from "@telaro/x402";
app.post(
"/premium",
expressX402Gate({ minBond: 1_000_000_000n, minScore: 700 }),
yourX402PaymentMiddleware,
(_req, res) => res.json({ ok: true }),
);v1 strategy object (recommended for new integrations):
import { expressX402Gate } from "@telaro/x402";
app.post(
"/premium",
expressX402Gate({
strategy: "trust",
policy: { minBond: 1_000_000_000n, minScore: 700 },
keyLinkResolver: async (wallet) => fetchKeyLinkPda(wallet),
}),
yourX402PaymentMiddleware,
(_req, res) => res.json({ ok: true }),
);Hono
import { honoX402Gate } from "@telaro/x402";
// v0.x
app.use("/premium", honoX402Gate({ minBond: 1_000_000_000n, minScore: 700 }));
// v1
app.use("/premium", honoX402Gate({
strategy: "trust",
policy: { minBond: 1_000_000_000n, minScore: 700 },
}));Next.js / Fetch handlers
import { fetchX402Gate, getX402Result } from "@telaro/x402";
// v0.x
export const POST = fetchX402Gate(
async (req) => Response.json({ ok: true, payer: getX402Result(req)?.payer }),
{ minBond: 1_000_000_000n, minScore: 700 },
);
// v1
export const POST = fetchX402Gate(
async (req) => Response.json({ ok: true, payer: getX402Result(req)?.payer }),
{
strategy: "trust",
policy: { minBond: 1_000_000_000n, minScore: 700 },
},
);Any framework: evaluateX402 and evaluateX402Payment
Two functions, same semantics. Use whichever style fits.
import { evaluateX402, evaluateX402Payment } from "@telaro/x402";
// v0.x positional
const a = await evaluateX402Payment(
header,
{ minBond: 1_000_000_000n, minScore: 700 },
);
// v1 strategy object
const b = await evaluateX402(header, {
strategy: "trust",
policy: { minBond: 1_000_000_000n, minScore: 700 },
});extractPayer(serializedTransactionBase64) is also exported if you
only need to identify the paying wallet.
Rejection shape
A rejected request gets a JSON body and one of three HTTP statuses:
| Status | When |
| --- | --- |
| 402 | payment missing or unusable (NO_PAYMENT, MALFORMED_PAYMENT, NO_TRANSFER, UNSIGNED_PAYER) |
| 403 | agent identified but fails the policy (NOT_BONDED, BOND_BELOW_MIN, SCORE_BELOW_MIN, FROZEN) |
| 502 | the Telaro lookup itself failed (LOOKUP_FAILED) |
Pass onReject to render the rejection in your own shape (Express only).
AgentKeyLink fallback (v1)
Agents that have rotated their hot key through AgentKeyLink sign x402
payments with payment_key, not the cold controller. The default
derive-and-lookup misses them. Provide keyLinkResolver to plug in the
fallback:
The Telaro web hosts a ready-made endpoint at
https://telaro.xyz/api/agent/by-payment-key/<wallet>. It does the
getProgramAccounts scan once and caches the result. Drop it in:
import type { KeyLinkResolver } from "@telaro/x402";
const keyLinkResolver: KeyLinkResolver = async (wallet) => {
const res = await fetch(
`https://telaro.xyz/api/agent/by-payment-key/${wallet}`,
);
if (!res.ok) return null;
const body = await res.json();
return body.agent_pda ?? null;
};Want to scan yourself instead? The layout filter is:
import { Connection, PublicKey } from "@solana/web3.js";
import { decodeAgentKeyLink } from "@telaro/sdk";
import bs58 from "bs58";
const keyLinkResolver: KeyLinkResolver = async (wallet) => {
const accs = await connection.getProgramAccounts(TELARO_PROGRAM_ID, {
filters: [
{ dataSize: 152 }, // AgentKeyLink size
{ memcmp: { offset: 80, bytes: bs58.encode(new PublicKey(wallet).toBytes()) } },
],
});
if (accs.length === 0) return null;
const link = decodeAgentKeyLink(Buffer.from(accs[0].account.data));
return link?.agent.toBase58() ?? null;
};Pass it through the v1 config or as part of the v0.x options:
expressX402Gate({
strategy: "trust",
policy: { minBond: 1_000_000_000n, minScore: 700 },
keyLinkResolver,
});The resolver is only called on NOT_BONDED. Policy-rejection codes
(BOND_BELOW_MIN, SCORE_BELOW_MIN, FROZEN) skip the fallback because
the agent was already identified and the policy decided.
How identity works
The agent is identified by the SPL transfer authority that signs the x402 payment transaction. That authority must map to an agent in the Telaro registry. Two valid mappings:
- Direct: the wallet's
findAgentPda(wallet)derivation hits a registered agent. This is the v0.x default. - Linked: the wallet is a hot
payment_keyregistered against an agent throughAgentKeyLink. RequireskeyLinkResolver.
The authority is read from the transfer instruction, not the fee payer.
That means the gate works whether the agent pays its own gas or a
fee-free facilitator does. A delegated transfer signs with a key the
gate has no record of and fails as NOT_BONDED.
sACP strategy (subpath: @telaro/x402/sacp)
The sACP strategy turns a funded sACP Job into the payment proof for
an HTTP request. Different shape from the trust strategy: a separate
header (x-sacp-job-id), on-chain verification, real escrow.
Add the peer deps when you need this path:
pnpm add @telaro/x402 @telaro/sacp expressServer
import { withSacpX402 } from "@telaro/x402/sacp";
app.post(
"/mcp/invoke",
withSacpX402({
client: sacpClient,
offering: await sacpClient.offering.fetch(provider.publicKey, slotId),
cluster: "devnet",
providerSigner,
evaluatorSigner,
}),
async (req, res) => {
const settle = req.sacp!;
const result = await runYourAgent(req.body);
await settle.session!.submitWork(await uploadToIpfs(result));
res.json({ result });
},
);Client
import { sacpFetch } from "@telaro/x402/sacp";
const result = await sacpFetch(
"https://my-agent.example.com/mcp/invoke",
{ method: "POST", body: JSON.stringify({ prompt }) },
{
sacp,
client: buyerSigner,
provider: providerSigner,
evaluator: evaluatorSigner,
workUri: "ipfs://buyer-spec",
},
);
// result.response the work response
// result.body decoded JSON body if any
// result.session the funded JobSession (caller can dispute / reclaim)
// result.accepted true if autoAccept settledMigrating from @telaro/sacp-x402? Change the import path. The
function shapes (withSacpX402, sacpFetch) are unchanged.
Strategies on the roadmap
Today (1.0.0-beta.2):
trust— bond + score gate over a standard x402 payment.sacp— sACP Job id is the payment proof (subpath@telaro/x402/sacp).
Coming in 1.0.0 stable:
sacp+trust— both: the buyer funds an sACP Job and the buyer wallet must clear the trust policy.- Unified
expressX402Gate({ strategy: "sacp", ... })overload that delegates towithSacpX402so all three strategies share one API. - Deprecation shim publish of
@telaro/[email protected]pointing at the subpath.
See docs/X402_UNIFICATION_PLAN.md for the full plan.
What this package is not
- Not a payment verifier or settler. The trust gate runs and hands control back; compose it with any x402 stack.
- Solana
exactscheme only. Legacy transactions and versioned transactions without address lookup tables. - Not a replacement for
@telaro/sdk. The gate is a thin client on top of the SDK; the SDK keeps the controller, PDA, and KeyLink helpers the gate consumes.
License
MIT.
