Channels and sessions
One ATR for a whole channel or session. Compare once at the opening, keep a hold, and sign every later voucher from it.
In a payment channel, the buyer funds a channel once and then pays each request with a signed voucher. x402's
batch-settlement scheme and MPP's sessions work this way. One ATR, and so one H, covers the whole channel: the
agreement is made at the opening, and every voucher pays under it.
The gate follows that shape:
- Open.
transact(orconfirmandfinish) compares the ATR and signs the opening, whose signed contents carry H. It is a payment like any other. - Hold.
openChannel(bytes, opened, binding, landed?)checks that the opening is an opening, that the pairing reads the hash of the bytes from it and that it names its channel, and returns a hold: the bytes, the opening and the channel, as JSON you keep.landedis the landed receipttransactreturned beside the opening, where it returned one. Pass it: a pairing such asmpp/session/hederabinds its opening by a log only that receipt carries, and the hold keeps it as its ownlandedmember, beside the opening as signed. The caller passes aslandedonly the receipt its own reader read for the opening, astransactreturned it, never a value another party sent. An opening that holds its read under its ownFETCHEDmember, as@integraledger/lcp'sfetchPresentedreturns it, is read with that read withoutlanded, and the hold keeps it the same way. - Pay within. For each later request,
within(doc, hold, binding, signer)signs one voucher from the hold. It takes the bytes, the hash and the channel from the hold, and the amount from the seller's new challenge. On x402's batch channels it signs only when that challenge advertises the held H and the payment it builds names the held channel. On an MPP session it declines a challenge that names another channel or another H, and signs one with no legal context under the held H. - Record the charge. When the seller reports its cumulative charge,
recordCharge(hold, charged)records it. The charge may not fall below the last one recorded, nor rise above the largest amount you signed.
The pairings
| Pairing | Opening | Within (handed to the signer as a batch of) |
|---|---|---|
x402/batch-settlement/eip155 | A deposit authorization and the first voucher, signed as a batch | A voucher (eip712) |
x402/batch-settlement/solana | The opening's message and first voucher, signed as a batch | A voucher (ed25519-raw) |
mpp/session/evm | The funding, then the first voucher | A voucher |
mpp/session/tempo | The signed open transaction, then the first voucher | A voucher |
mpp/session/solana | The session opening | A voucher |
mpp/session/hedera | The session opening, broadcast by the signer | A voucher |
mpp/session/xrpl | A PaymentChannelCreate and the first claim | A claim |
within signs in-channel payments for these seven pairings. For any other pairing it declines
pairing-not-supported. It hands your signer every in-channel payment as a batch: answer a batch with a list, one
answer per request, in order.
Example
An x402 batch-settlement channel on an EVM chain, opened and then used for two more requests. The payer funds the channel; a second key, the payer authorizer, signs the vouchers. A local stand-in serves the ATR:
import { , , , , type , type , type Signer, type } from "@integraledger/terms";
import { } from "@integraledger/lcp/x402-batch-settlement";
import { } from "viem/accounts";
const = new ().(
'{"atrVersion":"1","id":"0f8fad5b-d9cb-469f-a165-70867728950e","x402":{"z":1.0,"a":"caf\\u00e9"},"seller":{"note":"Café — 30 días ✓"}}',
);
const = "0x8b1e122580ae3f6a8c3d36a24294e1260a87bde70f597279b973e39310072938";
const = `https://atr.seller.example/${.()}`;
// The seller's batch-settlement offer: 1000 units per request, into a channel.
const = {
: 2,
: { : "https://api.seller.example/v1/quote" },
: [
{
: "batch-settlement",
: "eip155:84532",
: "1000",
: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
: 3600,
: { : "0x90F79bf6EB2c4f870365E785982E1f101E93b906", : 900, : "USDC", : "2" },
},
],
: { : { : { : "sha256", : , : }, : {} } },
};
const : = async () => ( === ? new () : new (null, { : 404 }));
// Anvil development keys: the payer funds the channel, the payer authorizer signs vouchers. Never use them for real funds.
const = ("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
const = ("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d");
type = <typeof .>[0];
const : Signer = {
: `eip155:84532:${.}`,
async (: ): <> {
if (. !== "batch") throw new (`unexpected ${.}`);
const : [] = [];
for (const of .) {
const = ( as { : }).;
const = . === "Voucher" ? : ;
.(await .());
}
return ;
},
};
// Open: compare the ATR once, sign the deposit and the first voucher.
const = await (, , , , {
: { : ., : "100000" },
});
if ("decline" in || "approve" in || . === null) throw new ("the channel did not open");
let = await (., ., );
if ("decline" in ) throw new (..);
.("channel ", .);
// Each later request: the seller's new offer must advertise the held H.
for (const of ["1000", "2000"]) {
const = await (, , , );
if ("decline" in ) throw new (..);
.("voucher up to", ..);
// The seller reports its cumulative charge; it may not exceed what was signed.
const = (., );
if ("decline" in ) throw new (..);
= ;
}channel 0x9c2d7031768ecb3fe688ae307c45f2e3a3d98b0d9ce1a6afcd27131fabb67e56
voucher up to 1000
voucher up to 2000Each voucher's maxClaimableAmount is the charge last recorded plus the option's amount, as x402's batch-settlement
client rule sets it: 1000 before any charge is recorded, 2000 after the seller reports 1000.
The hold
{
"pairing": "x402/batch-settlement/eip155",
"network": "eip155:84532",
"channel": "0x9c2d7031768ecb3fe688ae307c45f2e3a3d98b0d9ce1a6afcd27131fabb67e56",
"h": "0x8b1e122580ae3f6a8c3d36a24294e1260a87bde70f597279b973e39310072938",
"atr": "eyJhdHJWZXJzaW9uIjoiMSIs…",
"opening": { "x402Version": 2 },
"charged": "1000",
"signedMax": "2000"
}| Member | What it is |
|---|---|
pairing | The pairing the channel was opened with. |
network, channel | The channel, as the pairing names it. |
h | H. |
atr | Padded standard base64 of the ATR's bytes the opening compared, in its canonical form (RFC 4648 sections 3.5 and 4). within reads no other spelling. |
opening | The signed opening as the gate returned it, written as plain JSON, its own members as signed (abridged above). A member of its own, landed or any other, is never read as the read made for the opening. openChannel declines an opening holding a number JSON does not carry (NaN, an infinity), or arrays and objects nested more than 64 levels deep, with signed-not-bound. |
landed | The read made for the opening, where there is one: the landed receipt openChannel took, else the read the opening held under its own FETCHED member, as a JSON object. within holds it under the opening's FETCHED member, as bound reads it. |
charged | The seller's cumulative charge last recorded, decimal; "0" at the opening. |
signedMax | The largest cumulative amount signed in the channel, decimal. |
The hold is plain JSON. Store it between requests, and always pass the latest one: each within and recordCharge
returns an updated hold. within checks the hold before it signs anything: the bytes must still hash to h, the
opening must carry h, and the opening must name network and channel.
Over MCP, the hold is the server's to check: each opening and hold a tool returns also carries mac, and the channel
tools use only an opening or hold the same server process returned, unchanged. atr_channel_open takes the opening
with the landed receipt returned beside it, where there is one. See channel holds.
Refunds and closing
Pass refund to within to sign a refund instead of a voucher. Its maxClaimableAmount is the recorded charge.
refund: {}closes the channel for the remainder.refund: { amount }asks for a partial refund, where the pairing builds one.- On Solana the refund is the voucher for the recorded charge. When the seller answers it with
invalid_batch_settlement_svm_receiver_binding_unavailable, callwithinagain: the refund then also carries the payer-signedrequest_close, built with yourrecentBlockhash(a base58 blockhash) and, where you give them,computeUnitLimit(a u32) andcomputeUnitPrice(a u64, in decimal). These go inwithin's last argument,inputs. - The seller gives that code in one of two places, and
withinreads them as x402's reference client does: theerrorReasonof a settle failure'sPAYMENT-RESPONSE, which you pass decoded asrefund.paymentResponse(refund: { paymentResponse }), read first; or else theerrorof the payment request you pass as the document. - Without
recentBlockhashthe refund taken again is declinedno-payable-optionwithx402/input-missing. A value given in another form, or apaymentResponsethat is not an object or whoseerrorReasonis not a string, is declined withx402/input-malformed. No other pairing's refund reads these values or this code.
An MPP session closes with refund: {}; a partial refund in a session is declined with
mpp/within-action-not-built.
What within refuses
- A hold for another pairing:
pairing-not-supported. - A hold whose bytes, opening or channel do not agree:
hash-mismatchorsigned-not-bound. Anatrthat is not padded standard base64 in its canonical form is unreadable:signed-not-bound. - A challenge that advertises another H:
hash-mismatch. The signer is not called. - An x402 challenge that advertises no H:
offer-unreadable(x402/no-legal-context). An MPP session challenge with no legal context is not refused: its voucher pays under the held H. One whoseopaquelegal context cannot be read isoffer-unreadable(mpp/legal-context-malformed). - An MPP challenge that names a channel this hold did not open:
no-payable-option. The signer is not called. - An MPP challenge for the held channel that the session's channel members would refuse in the voucher carrying it:
offer-unreadable, with their code. On Tempo, av2challenge whose escrow, chain and operator do not give the held channel (tempo/descriptor-mismatch), and av1challenge over av2channel (mpp/not-bound-within); on Solana, anoperatorvoucher signer over aclientchannel (mpp/session-action, and for a closempp/credential-malformed). The signer is not called. - An x402 challenge under which the voucher or refund would name a channel this hold did not open:
no-payable-option. Before it calls the signer,withincompletes the build with placeholder answers and reads the channel that payment names with the binding'schannel.ref. On Solana the channel address is derived from the option'sfeePayeras well as the held configuration, so a challenge naming another fee payer is refused here. - A signed voucher of the wrong kind, for another channel, or not committed to H:
signed-not-bound, and it is dropped.
In Python
The same steps: open_channel(atr_bytes, opened, binding, landed=None), await within(doc, hold, binding, signer, refund=None, inputs=None), and record_charge(hold, charged). The hold is a dict with the same members, and so is
refund (amount, paymentResponse). The
Python README
runs the same channel and prints the same channel id.
Last updated on
Agreement payments
For a payment that is not itself a public proof of H, the buyer's agent approves a small agreement payment that carries H, and transact signs the payment for the resource only after its receipt.
Keeping the record
What to keep after a payment, how to confirm later that a payment carries the hash of the record you kept, and what to do with a moved payment.