Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
8bdae7f
feat(router): read router answers tolerantly, and skip offers by `req…
vraspar Oct 29, 2026
680c7bc
feat(router): read prices and call limits from spec fields, never fro…
vraspar Oct 29, 2026
cecf8bb
feat(router): one routing path on the paid route (tenjin#1026, CLI half)
vraspar Oct 29, 2026
b0c8684
feat(router): pay the quoted routing fee under a $0.003 ceiling (tenj…
vraspar Oct 29, 2026
ae854cd
fix(router): make the contract fixtures match what the server sends t…
vraspar Oct 29, 2026
846a874
test(router): find the permissions doc's unpaid-call line by its new …
vraspar Oct 29, 2026
c413b51
docs(router): drop the free path from two doctor comments
vraspar Oct 29, 2026
c997a04
feat(router): send `tolerant` in accepts
vraspar Oct 29, 2026
04b93ad
feat(install): name the routing fee ceiling in the spend question
vraspar Oct 29, 2026
aa3fd92
feat(router): pin the paid route's warm form and its answer
vraspar Oct 29, 2026
bfe5a0f
feat(router): top up the routing channel in the background; a prompt …
vraspar Oct 29, 2026
8660b5d
docs(router): say deposits are set aside in the background, at most $…
vraspar Oct 29, 2026
e985905
docs(router): make these fixtures the only copy, and pin the tolerant…
vraspar Oct 29, 2026
800dd19
fix(router): retry a wallet_low top-up every 2 minutes at most
vraspar Oct 29, 2026
fce1cc2
fix(router): give the open-Bazaar discovered fixtures the specs the s…
vraspar Oct 29, 2026
dcfde56
Merge remote-tracking branch 'origin/vraspar/router-tolerant-contract…
vraspar Oct 29, 2026
25e5a9c
docs(router): correct stale comments and docs the #532 review found
vraspar Oct 30, 2026
6379a09
fix(router): small correctness fixes from the #532 review
vraspar Oct 30, 2026
d7a3237
test(router): fix stale test names, a vacuous test and dead helpers f…
vraspar Oct 30, 2026
f0bfd23
fix(pay): a spec's timeoutS never shortens the paid leg
vraspar Oct 30, 2026
6d80a47
fix(router): review fixes to the top-up's backoff, log lines and test…
vraspar Oct 30, 2026
8e504eb
docs(router): make the top-up docs match the code (#533 review)
vraspar Oct 30, 2026
4d3503e
fix(router): fail safe to native when a dropped offer is one the line…
vraspar Oct 30, 2026
01d525a
fix(router): make the warm fixtures what the top-up sends (#533 review)
vraspar Oct 30, 2026
9e39f51
fix(router): only a hook leg tells a background top-up's deposit (#53…
vraspar Oct 30, 2026
ffb7cc3
feat(doctor): check the paid route, and the fee it quotes
vraspar Oct 30, 2026
a4a9b1b
fix(router): a failed top-up's notice matches a normal call's (#533 r…
vraspar Oct 30, 2026
1ffc67d
fix(router): say a top-up deposit that lands after its budget (#533 r…
vraspar Oct 30, 2026
298f1f3
fix(router): tenjin mcp waits for a top-up in flight before it exits …
vraspar Oct 30, 2026
23a8ad9
refactor(router): keep #532 to the tolerant contract; the fee and one…
vraspar Oct 30, 2026
ce572d6
feat(router): pay the quoted routing fee under a ceiling, on one rout…
vraspar Oct 30, 2026
a6fef25
feat(install): say in the spend question that the routing payment nam…
vraspar Oct 30, 2026
30f4720
Merge origin/vraspar/router-quoted-fee into vraspar/router-background…
vraspar Oct 30, 2026
2b7620b
chore(router): drop server-repo issue refs from the top-up's comments…
vraspar Oct 30, 2026
08914bf
docs(router): make the fixtures README and README true today (#533 re…
vraspar Oct 30, 2026
8314fc7
fix(router): tell a lasting skip once, clear the backoff on an answer…
vraspar Oct 30, 2026
7a98523
fix(install): spend question back to its base length; the disclosure …
vraspar Oct 30, 2026
21694e1
fix(router): the stale hook line hands the person no command
vraspar Oct 30, 2026
3343ea8
docs(router): stale comments and fee wording from the switch to a quo…
vraspar Oct 30, 2026
530695b
Merge origin/vraspar/router-quoted-fee into vraspar/router-background…
vraspar Oct 30, 2026
ee3eefb
fix(router): size from the router's $0.05 deposit hint
vraspar Oct 30, 2026
a301c3c
Merge origin/vraspar/router-quoted-fee: the router's $0.05 deposit hint
vraspar Oct 30, 2026
56962e0
fix(router): doctor refuses a wallet below the deposit floor, as the …
vraspar Oct 30, 2026
0e7284b
docs(changeset): the routing deposit note names the $0.05 hint
vraspar Oct 30, 2026
518ce54
Merge remote-tracking branch 'origin/vraspar/router-quoted-fee' into …
vraspar Oct 30, 2026
2a8eacc
docs(changeset): release notes match the background top-up at $0.05
vraspar Oct 30, 2026
3cc1893
test(router): name the hint test for what it checks
vraspar Oct 30, 2026
69d0d2c
Merge remote-tracking branch 'origin/vraspar/router-quoted-fee' into …
vraspar Oct 30, 2026
33ae023
docs(router): a repeated wallet_low names no balance
vraspar Oct 30, 2026
dea55c6
Merge remote-tracking branch 'origin/main' into vraspar/router-quoted…
vraspar Oct 31, 2026
9a6e1f7
refactor(router): base the quoted fee on main, without the tolerant c…
vraspar Oct 31, 2026
8a7af5f
fix(router): size the deposit from the router's hint, never the $0.25…
vraspar Oct 31, 2026
27a3ced
docs(router): production's deposit hint is $0.03 until the router's u…
vraspar Oct 31, 2026
8b6efca
Merge main (tolerant contract, CLI side) into the fee PR
vraspar Nov 1, 2026
9eac5ad
Merge the fee PR's final head into the background deposit CLI PR
vraspar Nov 1, 2026
e8e8a8b
Merge main into the background deposit CLI PR
vraspar Nov 1, 2026
9c6cdc7
fix(router): wait out a detached top-up payment before tenjin mcp exits
vraspar Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/background-top-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'tenjin-cli': patch
---

A prompt never carries the routing deposit any more, so a deposit no longer costs a prompt its routing hint. `tenjin mcp` sets funds aside for routing in the background, with one `warm` call to the router that is charged nothing and carries the deposit: when it starts on a channel that cannot cover a fee (a new one included), and right after a paid call leaves the channel that low. A routing call that meets one in progress waits for it inside its own time limit, then pays from the channel. Each deposit is the smallest of the router's suggested amount ($0.05 today), the wallet balance and the per-call limit, never less than ten fees and never more than $0.25, so a big wallet no longer sets aside $0.25 at once. A wallet below ten fees is told once, and the balance is read only for the top-up; after a low wallet, the next top-up is tried within 2 minutes. A router without the `warm` form gets the deposit on the next paid call for an hour, as before, and that call reads the balance. When a session ends mid top-up, `tenjin mcp` waits up to 30 seconds for it to land before it exits. The install question now says funds are set aside at most $0.25 at a time.
2 changes: 1 addition & 1 deletion .changeset/pre/sdk-routing-deposit.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'tenjin-cli': patch
---

Size the routing deposit so the wallet keeps money for paid tools: each deposit is the router's suggested amount ($0.03 today, $0.05 once the router's update ships), less when the wallet or the per-call limit is smaller, but never less than ten fees ($0.03) or more than $0.25, so a big wallet no longer sets aside $0.25 at once. Routing keeps going until the wallet holds less than ten fees, instead of stopping below $0.25. Wallet, status, and deposit notices describe unused money as funds set aside for routing, rather than prepaid credits, and label the locally recorded routing balance as an estimate.
Keep routing when the wallet holds less than $0.25, instead of stopping below $0.25. Wallet, status, and deposit notices describe unused money as funds set aside for routing, rather than prepaid credits, and label the locally recorded routing balance as an estimate.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ The router sees your prompt and up to six earlier messages, with secrets masked,
The daily budget is shared across chats. Its spending counter resets 24 hours after the window starts; opening a new chat does not reset it.

- A call over a limit pays nothing until you say yes. The per-call limit can't go below $0.25.
- Each routing call costs a fee the router quotes, never more than $0.003, even when no tool is suggested, separate from provider charges. Tenjin sets aside funds for these fees, the router's suggested amount at a time ($0.03 today, $0.05 once the router's update ships): less when your balance or per-call limit is smaller, but never less than ten fees ($0.03) or more than $0.25, so the rest stays available for paid tools. It stops routing when those funds run out and your balance is under $0.03; unused funds remain yours, and deposits count toward your spending limits once. Browsing tools and their specs is free.
- Each routing call costs a fee the router quotes, never more than $0.003, even when no tool is suggested, separate from provider charges. Tenjin sets aside funds for these fees in the background (with the next routing call, on an older router), the router's suggested amount at a time (currently $0.05, never more than $0.25), and the rest stays available for paid tools; a wallet holding less sets aside all of it. Tenjin stops routing once those funds run out while the wallet holds less than ten fees; unused funds remain yours, and deposits count toward your spending limits once. Browsing tools and their specs is free.
- Add funds with `tenjin wallet fund 5`, or send USDC on Base to the address `tenjin wallet address` prints.

[How the limits and the fee work →](./docs/agent-permissions.md#what-bounds-a-payment)
Expand Down
6 changes: 3 additions & 3 deletions docs/agent-permissions.md

Large diffs are not rendered by default.

17 changes: 13 additions & 4 deletions src/router/codex-paid-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,18 @@ describe('Codex automatic hooks', () => {
expect(native).toMatchObject({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' },
});
// Every call went to the paid route: one deposit, then a voucher.
// Every call went to the paid route, and no leg carried the deposit:
// the background top-up did, then each leg paid a voucher.
expect(fake.log).toEqual([
'POST /api/x402-router/route unpaid',
'POST /api/x402-router/route warm unpaid',
'POST /api/x402-router/route warm paid',
'POST /api/x402-router/route unpaid',
'POST /api/x402-router/route paid',
'POST /api/x402-router/route unpaid',
'POST /api/x402-router/route paid',
]);
expect(fake.deposits).toBe(1);
expect(fake.depositedOn).toEqual(['warm']);
// The deposit counts against the daily budget, as an MCP lookup's does.
expect((await readSpendSummary(dir))?.committedAtomic).toBe('50000');
} finally {
Expand All @@ -211,7 +215,12 @@ describe('Codex automatic hooks', () => {
try {
const out = await server.run(await handler('UserPromptSubmit'), codexEvent('prompt'));
expect(out).toEqual({ systemMessage: expect.stringContaining('could not pay') });
expect(fake.log).toEqual(['POST /api/x402-router/route unpaid']);
// The leg's 402, and the top-up's, which read the balance and stopped.
expect(fake.log).toEqual([
'POST /api/x402-router/route unpaid',
'POST /api/x402-router/route warm unpaid',
]);
expect(out?.systemMessage).toContain('the wallet holds $0.02');
expect(out?.systemMessage).toContain('Your usual tools still work.');
expect(out?.systemMessage).not.toContain('tenjin wallet fund');
expect(fake.paidRequests()).toBe(0);
Expand Down Expand Up @@ -249,7 +258,7 @@ describe('Codex automatic hooks', () => {
codexEvent('prompt', { transcript_path: null }),
);
expect(out).toMatchObject({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit' } });
expect(fake.paidRequests()).toBe(1);
expect(fake.settledFees).toBe(1);
} finally {
await server.close();
}
Expand Down
99 changes: 86 additions & 13 deletions src/router/decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import type { Harness } from './names';
* and the `request` tool's, goes to the paid route ({@link ROUTE_PAID_PATH}).
* Inside `tenjin mcp` the payer sends it through the stock x402 client and
* pays the routing fee its 402 quotes ({@link DecisionRoute},
* `routing-payer.ts`); a call it cannot pay is skipped at once, with no second
* request. The free route ({@link ROUTER_PATH}) keeps only what stays free:
* `routing-payer.ts`); a call it cannot pay is skipped, with no request to any
* other route. One whose channel needs a deposit waits for the payer's
* background top-up inside its budget first. The free route ({@link ROUTER_PATH}) keeps only what stays free:
* browse, a browse row's spec and the outcome report.
*
* The backend answers with what it would do and what the provider charges,
Expand Down Expand Up @@ -750,6 +751,36 @@ export function buildToolSpecBody(request: {
};
}

/**
* THE PAID ROUTE'S `warm` CALL, a channel deposit's carrier. The payer's
* background top-up sends it and nothing else does: the batch-settlement spec
* has no deposit-only payload, so the deposit rides a paid request the server
* answers in code, with no gate and nothing stored, and settles at $0.
*/
export function buildWarmBody(
extras: { accepts?: readonly string[]; harness?: Harness } = {},
): Record<string, unknown> {
return {
schemaVersion: 1,
warm: true,
...(extras.accepts !== undefined ? { accepts: [...extras.accepts] } : {}),
...(extras.harness !== undefined ? { harness: extras.harness } : {}),
};
}

/** The `warm` answer, read tolerantly like every answer: no decision. */
const WarmResponseSchema = z.object({
schemaVersion: z.literal(1),
routerVersion: z.string().min(1).max(64),
warm: z.literal(true),
});

/** Whether a body is the `warm` answer. A router without the form answers
* anything else: a 400 for an unknown field, or a decision. */
export function isWarmAnswer(body: unknown): boolean {
return WarmResponseSchema.safeParse(body).success;
}

/** The parsers, exposed so the shared wire fixtures are checked against the
* same schemas production parses with rather than against a copy of them. */
export function parseForTests(
Expand Down Expand Up @@ -804,31 +835,60 @@ export interface DecisionDeps {
* transport failure it carries in `unreached`. */
export interface DecisionRoute {
send: (url: string, options: HttpRequestOptions) => Promise<HttpResult>;
/** What this route's calls deposited into the routing channel and the
* router took, 0n for none: the caller says so once, where it is read. */
/** What this route's own calls deposited into the routing channel and the
* router took (only on a router with no `warm` form), 0n for none: the
* caller says so once, where it is read. */
deposited?: () => bigint;
/** What the payer's background top-ups deposited since this was last
* read, 0n for none, and taken by the read. Only a hook leg reads it, so
* the person is told, in its `systemMessage`. */
toppedUp?: () => bigint;
}

/**
* What a skip knows about the money behind it, for the one sentence that says
* it: the fee the router quoted, when the payer read one, and for `wallet_low`
* the balance read for the deposit (by the background top-up, or by the call's
* own on a router with no `warm` form) and the deposit it fell short of.
*/
export interface SkipFacts {
quotedAtomic?: bigint;
balanceAtomic?: bigint;
depositAtomic?: bigint;
}

/** The paid route gave this call no answer: the wallet locked, low or over
* its spend limits, the fee quoted above the ceiling, the channel busy, or
* the payment failed. */
* its spend limits, the fee quoted above the ceiling, the channel busy or
* being topped up, or the payment failed. */
export class RouteSkipped extends Error {
/** `unreached`: the paid call could not reach the router
* ({@link isUnreachable}), so the call reads as that transport failure.
* `quotedAtomic`: the fee the router quoted, when the payer read one.
* `answered`: the router answered a request of this call (its 402, say). */
readonly unreached: FetchJsonFailure | undefined;
readonly quotedAtomic: bigint | undefined;
readonly balanceAtomic: bigint | undefined;
readonly depositAtomic: bigint | undefined;
readonly answered: boolean;
constructor(
readonly why: string,
opts: { unreached?: FetchJsonFailure; quotedAtomic?: bigint; answered?: boolean } = {},
opts: { unreached?: FetchJsonFailure; answered?: boolean } & SkipFacts = {},
) {
super(`routing fee not paid: ${why}`);
this.unreached = opts.unreached;
this.quotedAtomic = opts.quotedAtomic;
this.balanceAtomic = opts.balanceAtomic;
this.depositAtomic = opts.depositAtomic;
this.answered = opts.answered === true;
}

/** The facts it carries, with no undefined keys. */
facts(): SkipFacts {
return {
...(this.quotedAtomic !== undefined ? { quotedAtomic: this.quotedAtomic } : {}),
...(this.balanceAtomic !== undefined ? { balanceAtomic: this.balanceAtomic } : {}),
...(this.depositAtomic !== undefined ? { depositAtomic: this.depositAtomic } : {}),
};
}
}

/** Another session's call was in flight on the wallet's routing channel. */
Expand All @@ -840,6 +900,19 @@ export const PAYMENT_FAILED = 'payment_failed';
export const DEADLINE_AFTER_PAYMENT_SENT = 'deadline_after_payment_sent';
/** The route asked for the fee, and this call had no payer to pay it. */
export const NO_PAYER = 'no_payer';
/** The channel needed a deposit, and this process's background top-up did
* not land inside the call's budget: no fault. The next call pays from it if
* it lands, or gets its refusal if it was refused. */
export const TOPPING_UP = 'topping_up';
/** The background top-up got no usable answer: the router rate-limited it,
* or no connection reached it. No fault, as on any routing call, and never
* the machine-wide unreachable pause: the call it served reached the router.
* The top-up is tried again after its backoff. */
export const TOP_UP_UNAVAILABLE = 'top_up_unavailable';
/** The channel needed a deposit, which only the background top-up makes, and
* this process's could not make it now (another session's top-up of the same
* wallet is in flight, or the channel was busy): no fault. */
export const DEPOSIT_DEFERRED = 'deposit_deferred';

export type DecisionOutcome<T> =
/** `skipped`: offers in it this build cannot run, already taken out
Expand All @@ -856,10 +929,10 @@ export type DecisionOutcome<T> =
noAnswer?: true;
}
/** The routing fee was not paid ({@link RouteSkipped}, {@link NO_PAYER}):
* nothing was routed, and the native tool runs. `quotedAtomic`: the fee
* the router quoted, when the payer read one. `answered`: the router
* answered first, so it is reachable. */
| { status: 'skipped'; why: string; quotedAtomic?: bigint; answered?: true };
* nothing was routed, and the native tool runs, with what the skip knew
* ({@link SkipFacts}). `answered`: the router answered first, so it is
* reachable. */
| ({ status: 'skipped'; why: string; answered?: true } & SkipFacts);

/**
* ONE ROUTING CALL, IN TWO FORMS, ON THE PAID ROUTE. The hook sends
Expand Down Expand Up @@ -927,7 +1000,7 @@ export async function requestDecision(
return {
status: 'skipped',
why: err.why,
...(err.quotedAtomic !== undefined ? { quotedAtomic: err.quotedAtomic } : {}),
...err.facts(),
...(err.answered ? { answered: true as const } : {}),
};
}
Expand Down
6 changes: 3 additions & 3 deletions src/router/eligibility-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,9 @@ async function check(input: EligibilityInput, facts: WalletFacts): Promise<Eligi
}).catch(() => null),
]);

// The deposit the payer would sign for this balance, from the production
// server's hint and with no quote to go on but the ceiling, evaluated
// against the whole policy as the payer's is.
// The deposit the payer's top-up would sign for this balance, from the
// production server's hint and with no quote to go on but the ceiling,
// evaluated against the whole policy as the payer's is.
const deposit = evaluateSpendPolicy(input.policy, {
mode: 'automatic',
amountAtomic: routingDepositAtomic({
Comment thread
vraspar marked this conversation as resolved.
Resolved
Expand Down
52 changes: 43 additions & 9 deletions src/router/fee-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import type { RoutingPayerDeps } from './routing-payer';
* charged amount, and the channel state the client no longer copies), and a
* stale voucher with a corrective 402 carrying the last voucher the wallet
* signed, which the SDK's recovery checks. It serves one pending request per
* channel, as the real server does. No money moves and no network is touched.
* channel, as the real server does. It answers the `warm` form the way a
* router with the form does (its answer, settled at $0, with any deposit on
* it), or, as a router from before the form, with a 400 and nothing settled.
* No money moves and no network is touched.
*/

export const NETWORK = 'eip155:84532';
Expand All @@ -47,11 +50,18 @@ interface Channel {
pending: boolean;
}

/** The `warm` answer, as a router with the form sends it. */
export const WARM = { schemaVersion: 1, routerVersion: 'test', warm: true };

export class FakeRouter {
readonly channels = new Map<string, Channel>();
deposits = 0;
settledFees = 0;
/** Every request, as `METHOD path paid|unpaid`. */
/** Paid `warm` calls answered, each settled at $0. */
warms = 0;
/** What carried each settled deposit: the top-up's `warm` call, or a routing call. */
readonly depositedOn: ('warm' | 'call')[] = [];
/** Every request, as `METHOD path [warm] paid|unpaid`. */
readonly log: string[] = [];
/** Settle the next paid answer, then drop the connection before it is read. */
abortAfterSettle = false;
Expand All @@ -74,6 +84,8 @@ export class FakeRouter {
charge?: string;
/** The 402's `extra.minDeposit`; `null` leaves the field out. */
minDeposit?: string | null;
/** False: a router from before the `warm` form, which 400s it. */
warm?: boolean;
} = {},
) {
this.charge = BigInt(opts.charge ?? opts.amount ?? FEE);
Expand All @@ -85,7 +97,7 @@ export class FakeRouter {
payTo: privateKeyToAccount(generatePrivateKey()).address,
maxTimeoutSeconds: 15,
extra: {
// The $0.05 hint the router's update announces, whatever it quotes.
// The hint the production server announces explicitly, whatever it quotes.
...(opts.minDeposit === null
? {}
: { minDeposit: opts.minDeposit ?? CHANNEL_DEPOSIT_ATOMIC.toString() }),
Expand All @@ -101,10 +113,17 @@ export class FakeRouter {
const request = new Request(input, init);
const url = new URL(request.url);
const signature = request.headers.get('payment-signature');
this.log.push(`${request.method} ${url.pathname} ${signature ? 'paid' : 'unpaid'}`);
const warm = await isWarm(request);
this.log.push(
`${request.method} ${url.pathname}${warm ? ' warm' : ''} ${signature ? 'paid' : 'unpaid'}`,
);
// A router from before the form: its strict request schema refuses the
// field, after verify and before settle, so nothing is charged.
const refusesWarm = warm && this.opts.warm === false;
// The fee off (development): the paid route answers with no 402.
if (url.pathname !== ROUTE_PAID_PATH || this.opts.paid === false) {
return json(this.body, 200);
if (refusesWarm) return json({ error: { code: 'bad_request', message: 'warm' } }, 400);
return json(warm ? WARM : this.body, 200);
}
if (signature === null) return this.required(url.toString());
const payload = decodePaymentSignatureHeader(signature).payload as {
Expand All @@ -122,6 +141,9 @@ export class FakeRouter {
) {
return this.corrective(url.toString(), id, channel);
}
if (refusesWarm) return json({ error: { code: 'bad_request', message: 'warm' } }, 400);
// `warm` is answered in code, with no gate, and settles at $0.
const charge = warm ? 0n : this.charge;
channel.pending = true;
try {
if (this.delayMs > 0) {
Expand All @@ -132,26 +154,28 @@ export class FakeRouter {
if (payload.type === 'deposit') {
channel.balance += BigInt(payload.deposit!.amount);
this.deposits += 1;
this.depositedOn.push(warm ? 'warm' : 'call');
}
channel.last = {
maxClaimableAmount: payload.voucher.maxClaimableAmount,
signature: payload.voucher.signature,
};
channel.charged += this.charge;
this.settledFees += 1;
channel.charged += charge;
if (warm) this.warms += 1;
else this.settledFees += 1;
} finally {
channel.pending = false;
}
if (this.abortAfterSettle) {
this.abortAfterSettle = false;
throw new DOMException('The operation was aborted.', 'AbortError');
}
return json(this.body, 200, {
return json(warm ? WARM : this.body, 200, {
'PAYMENT-RESPONSE': encodePaymentResponseHeader({
success: true,
transaction: '0x',
network: NETWORK,
extra: { chargedAmount: this.charge.toString(), channelState: this.state(id, channel) },
extra: { chargedAmount: charge.toString(), channelState: this.state(id, channel) },
}),
});
};
Expand Down Expand Up @@ -224,6 +248,16 @@ export class FakeRouter {
}
}

/** Whether a request's JSON body is the `warm` form. */
async function isWarm(request: Request): Promise<boolean> {
if (request.method !== 'POST') return false;
try {
return ((await request.clone().json()) as { warm?: unknown }).warm === true;
} catch {
return false;
}
}

function json(body: unknown, status: number, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
Expand Down
Loading
Loading