Buying a plan
Three routes to entitlement. They differ in the protocol, not in the price: all three end at the same contract call, with the same price ceiling, at the same tariff.
| You need | Round trips | Read | |
|---|---|---|---|
| On chain | a key that can sign | prepare, sign once, relay | below |
| x402 | an HTTP client that speaks 402 | challenge, sign once, paid retry | x402 |
| MCP | an MCP host | not callable yet — see the MCP server | MCP tools |
The ordering rule
Your payment and your payload both come from your account, and both use a nonce. A payment executed
at nonce n invalidates a payload you signed at nonce n.
So: buy, wait for the purchase to be final, read your nonce again, and only then build the transaction you actually wanted to send.
This is also why POST /v1/relay does not accept payments. Its 402 answer carries a
PAYMENT-REQUIRED header saying where to pay, and nothing more — a relay body already holds one
signed transaction and must not carry a second.
On chain, in one transaction
/**
* Buying a plan on chain, with no EGLD: one signature, and CoRelayer pays the fee of the purchase.
*
* POST /v1/subscribe/prepare → an unsigned `depositAndSubscribe` transaction with the relayer
* already set, the quote it was priced from, and a lease
* check it → YOUR contract, YOUR chain, YOUR ceiling, an active relayer
* sign once, POST /v1/relay → the same route every relayed transaction uses
*
* The transaction carries `max_price`. If the price moved between the quote and the block, the
* contract reverts instead of charging more — the ceiling you checked is the ceiling you signed.
*/
import {
ApiError,
type components,
guardSignOnce,
type RelayResponse,
type TransactionPlain,
} from '@corelayer/sdk';
import { connect, presenceProof, type RelayContext } from './send-token.ts';
import type { UnsignedTransaction } from './wallet.ts';
type PreparedPurchase = components['schemas']['PreparedPurchase'];
type SubscribeQuote = components['schemas']['SubscribeQuote'];
type Prepared = components['schemas']['UnsignedTransaction'];
export interface PurchaseContext extends RelayContext {
/** The CoRelayer contract of `chainId`, from YOUR configuration. */
readonly contract: string;
}
export interface BuyPlanRequest {
readonly tierId: number;
/** Months to prepay, 1…12. The metered agent tier takes 0. */
readonly months: number;
/** For the metered tier: USDC to deposit beyond the price, in micro-USDC. */
readonly extraDepositMicroUsdc?: bigint;
/** The most you are willing to pay in this transaction, micro-USDC. Checked before signing. */
readonly maxPayMicroUsdc: bigint;
readonly intentKey: string;
readonly signal?: AbortSignal;
}
/** Narrows the prepared transaction to what a relayed transaction must be — or refuses. */
function relayable(tx: Prepared, context: PurchaseContext): UnsignedTransaction {
const { relayer, version, options, ...rest } = tx;
if (tx.sender !== context.wallet.address)
throw new Error(`Prepared for ${tx.sender}, not for this wallet.`);
if (tx.receiver !== context.contract) {
throw new Error(`Prepared transaction pays ${tx.receiver}, not the contract you pinned.`);
}
if (tx.chainID !== context.chainId) throw new Error(`Prepared for chain ${tx.chainID}.`);
if (relayer === undefined) throw new Error('Prepared transaction names no relayer.');
if (version !== 2) throw new Error(`Relayed v3 needs version 2, got ${version}.`);
if (options !== undefined && options !== 0 && options !== 1 && options !== 2 && options !== 3) {
throw new Error(`Unsupported transaction options ${options}.`);
}
return { ...rest, relayer, version, ...(options === undefined ? {} : { options }) };
}
export async function buyPlan(
context: PurchaseContext,
request: BuyPlanRequest,
): Promise<{ readonly response: RelayResponse; readonly quote: SubscribeQuote }> {
const { client, wallet, relayers } = context;
const { signal } = request;
const options = signal === undefined ? {} : { signal };
await connect(context, signal);
const { data: prepared } = await client.transport.post<PreparedPurchase>(
'/v1/subscribe/prepare',
{
address: wallet.address,
tierId: request.tierId,
months: request.months,
...(request.extraDepositMicroUsdc === undefined
? {}
: { extraDepositMicroUsdc: request.extraDepositMicroUsdc.toString() }),
},
options,
);
const pay = BigInt(prepared.payMicroUsdc);
if (pay > request.maxPayMicroUsdc) {
throw new Error(
`The purchase costs ${pay} micro-USDC; your ceiling is ${request.maxPayMicroUsdc}.`,
);
}
const assignment = prepared.assignment;
if (assignment === undefined)
throw new Error('No assignment: this purchase would not be relayed.');
const unsigned = relayable(prepared.transaction, context);
if (unsigned.relayer !== assignment.relayer)
throw new Error('Transaction and assignment disagree.');
// Same rule as every relayed transaction: never sign for a relayer you have not checked.
await relayers.verify(wallet.address, assignment.relayer, assignment.registryVersion, signal);
const sign = guardSignOnce(async ({ transaction }) => wallet.signTransaction(transaction));
const signed: TransactionPlain = await sign({ assignment, transaction: unsigned });
if (signed.nonce !== unsigned.nonce) {
throw new Error(
`The wallet signed nonce ${signed.nonce}, the purchase was prepared for ${unsigned.nonce}.`,
);
}
const submit = async (lease: string): Promise<RelayResponse> =>
(await client.relay({ tx: signed, lease }, { intentKey: request.intentKey, ...options })).data;
try {
return { response: await submit(assignment.lease), quote: prepared.quote };
} catch (error) {
// A signer slower than the 60 s lease (a hardware wallet, a person reading the screen) is the
// one case handled here: renew the lease for the same relayer and send the identical bytes.
if (
error instanceof ApiError &&
error.code === 'LEASE_EXPIRED' &&
error.detail<boolean>('renewable')
) {
const proof = await presenceProof(context, signal);
const { data: renewed } = await client.assignRelayer(
{ sender: wallet.address, renewFor: assignment.relayer, proof },
signal,
);
return { response: await submit(renewed.lease), quote: prepared.quote };
}
throw error;
}
}
The purchase is on the free list: CoRelayer pays the fee of the transaction that buys the plan, which is what makes buying possible with zero EGLD. The checks before the signature are the point of the function: the transaction pays your pinned contract, on your chain, at no more than your ceiling, through a relayer the chain says is active.
What the contract does with it:
depositAndSubscribe(tier_id, months, max_price, ref)
→ swap the USDC in the same call
→ credit the account 1:1 from the USDC paid
→ price the tier at the block timestamp
→ revert if price > max_price
→ write the plan block
ref is opaque, at most 32 bytes, and echoed in the event; the prepare route puts the quoteId in
it.
What can go wrong, and what it means
| Error | Meaning |
|---|---|
PRICE_ABOVE_MAX | The price at execution exceeded the ceiling you signed. Get a fresh quote. |
QUOTE_EXPIRED | The quote's 120 seconds passed. Prepare again. |
TIER_NOT_PURCHASABLE | The tier is configured but not being sold. |
INSUFFICIENT_CREDITS | A purchase from existing credits, and there are not enough. Deposit, or use depositAndSubscribe. |
DEPOSIT_BELOW_MIN | Below one USDC. |
SWAP_VENUE_PAUSED | The exchange venue is unavailable. The transaction would revert, so it is refused before it costs anyone anything. |
CONTRACT_PAUSED | Our own contract is paused for deposits. The contract starts paused, and deposits open when the owner unpauses them. |
FREE_FLOW_BUSY | You already have a free-flow transaction in flight. One at a time. |
Everything on the purchase path is checked before co-signing, by running the same sequence the contract will run against mirrored state. That is not politeness: a purchase that reverts on chain is paid for by our relayer.
Quotes
POST /v1/subscribe/prepare returns a PreparedPurchase: the unsigned transaction, the assignment
whose lease it is submitted with, payMicroUsdc (what this transaction pays),
fromCreditsMicroUsdc (what existing credits cover), and the quote it was priced from:
{
"quoteId": "q_01JA7M3Z9K",
"tierId": 12,
"months": 1,
"tariff": "10000",
"tariffVersion": 1,
"tariffEffectiveMs": 1789000000000,
"priceMicroUsdc": "85000000",
"maxPrice": "85000000",
"pendingTariff": null,
"issuedAtMs": 1789819200000,
"expiresAtMs": 1789819320000,
"quoteMac": "…"
}
| Life | 120,000 ms. |
| Storage | None. A quote is content plus a MAC over that content, so any host can verify one it did not issue. Asking twice gives you two valid quotes. |
| Slack | None: maxPrice equals priceMicroUsdc. A price decrease in between simply charges less. |
| Across a scheduled increase | Priced at the pending, higher value, with pendingTariff filled. A transaction that lands before the activation pays the lower current price and the surplus stays as credits. |
| Amounts | Decimal strings in micro-USDC. Parse them as integers, never as floats. |
Choosing a tier programmatically
/**
* Choosing a tier from the live pricing document.
*
* `GET /v1/pricing?audience=agent` is public. Every amount in it is a decimal string in micro-USDC
* (the payment token has 6 decimals). Compare amounts as BigInt, because floats lose precision. Do
* not multiply a `price` by a million: it is already in micro-USDC.
*/
import type { CoRelayerClient, components } from '@corelayer/sdk';
type PricingTier = components['schemas']['PricingTier'];
/**
* The largest purchasable plan whose monthly price fits the budget; the metered tier when none
* does. `undefined` only when nothing at all is on sale.
*/
export function pickTier(
tiers: readonly PricingTier[],
monthlyBudgetMicroUsdc: bigint,
): PricingTier | undefined {
const onSale = tiers.filter((tier) => tier.status === 'active' && tier.available !== false);
const metered = onSale.find((tier) => tier.periodMs === 0);
const plans = onSale
.filter((tier) => tier.periodMs > 0 && BigInt(tier.price) <= monthlyBudgetMicroUsdc)
.sort((a, b) =>
BigInt(b.capRu) > BigInt(a.capRu) ? 1 : BigInt(b.capRu) < BigInt(a.capRu) ? -1 : 0,
);
return plans[0] ?? metered;
}
export async function pickAgentTier(
client: CoRelayerClient,
monthlyBudgetMicroUsdc: bigint,
): Promise<PricingTier | undefined> {
const { data } = await client.getPricing({ audience: 'agent' });
return pickTier(data.tiers, monthlyBudgetMicroUsdc);
}
If nothing fits, Agent Metered is the floor: no period, no cap, every unit
metered out of an escrow you decide the size of — and depositAndSubscribe(11, 0, …) sets it all up
in a single transaction.
Renewing without a human
setAutoRenew(enabled, renew_tier_id, max_renew_price)
max_renew_price must be greater than zero when enabled, and zero never means unlimited. An
account that renews itself has to state its own ceiling; there is no open-ended standing
authorisation in this system, which matters most for exactly the accounts that run unattended.
Renewal is permissionless — anyone may trigger it for an account that opted in, because the terms are the account's own and the contract enforces them. Your agent does not need to be awake at the right moment.
Watch for the notice rather than polling: a scheduled tariff increase arrives as an account notice, in the notice feed and on the account stream, 48 hours before it takes effect, so a ceiling that is about to become too low is something you can see coming. (Tariff)
Topping up without buying a plan
POST /v1/x402/topup { payer, beneficiary?, amountMicroUsdc }
or, on chain, deposit() / depositFor(beneficiary), prepared by POST /v1/deposit/prepare.
Credits are a USDC-denominated balance inside the contract; they buy plans and fund pay-as-you-go
escrow. Minimum one USDC.
Nothing you pay in is refundable to your wallet. Credits stay credits; escrow that pay-as-you-go did not use goes back to credits when you turn pay-as-you-go off. (Credits and billing)