Skip to content

Commit 274c615

Browse files
committed
feat(mpp): reuse sessions across requests, persist channelId, settle on shutdown
- Cache MppProxyHandler at serve/plugin level instead of per-request - Persist channelId to ~/.config/x402-proxy/session.json for tracking - Send X-Payer-Address header on MPP requests for cross-restart session recovery - Close MPP session on SIGTERM/stop (serve command + OpenClaw plugin) - Add --debug global CLI flag (X402_PROXY_DEBUG=1) - Upgrade mppx ^0.4.9 -> ^0.5.1
1 parent 1d976b7 commit 274c615

9 files changed

Lines changed: 172 additions & 50 deletions

File tree

packages/x402-proxy/CHANGELOG.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.10.4] - 2026-04-01
11+
12+
### Fixed
13+
- MPP sessions now reuse a single handler across requests instead of creating a new one per request - eliminates redundant escrow deposits and wasted USDC
14+
- MPP sessions properly settle on process shutdown (`serve` command and OpenClaw plugin both call `close()` on SIGTERM/stop)
15+
- MPP channelId persisted to `~/.config/x402-proxy/session.json` for tracking; cleared on session close
16+
17+
### Added
18+
- `--debug` global CLI flag - sets `X402_PROXY_DEBUG=1` for verbose stderr logging of MPP SSE lifecycle, channelId, and proxy routing
19+
- Debug trace points in inference proxy: upstream routing, SSE start/end, usage stats, errors
20+
21+
### Changed
22+
- Upgraded `mppx` from ^0.4.9 to ^0.5.1
23+
- MPP requests now send `X-Payer-Address` header so the server can include the payer's existing `channelId` in 402 challenges, enabling cross-restart session recovery
24+
- Inference proxy no longer accepts `getEvmKey` option - receives pre-built `getMppHandler` instead
25+
1026
## [0.10.3] - 2026-04-01
1127

1228
### Fixed
@@ -351,7 +367,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
351367
- `appendHistory` / `readHistory` / `calcSpend` - JSONL transaction history
352368
- Re-exports from `@x402/fetch`, `@x402/svm`, `@x402/evm`
353369

354-
[Unreleased]: https://github.com/cascade-protocol/x402-proxy/compare/v0.10.2...HEAD
370+
[Unreleased]: https://github.com/cascade-protocol/x402-proxy/compare/v0.10.4...HEAD
371+
[0.10.4]: https://github.com/cascade-protocol/x402-proxy/compare/v0.10.3...v0.10.4
372+
[0.10.3]: https://github.com/cascade-protocol/x402-proxy/compare/v0.10.2...v0.10.3
355373
[0.10.2]: https://github.com/cascade-protocol/x402-proxy/compare/v0.10.1...v0.10.2
356374
[0.10.1]: https://github.com/cascade-protocol/x402-proxy/compare/v0.10.0...v0.10.1
357375
[0.10.0]: https://github.com/cascade-protocol/x402-proxy/compare/v0.9.4...v0.10.0

packages/x402-proxy/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "x402-proxy",
3-
"version": "0.10.3",
3+
"version": "0.10.4",
44
"description": "curl for x402 paid APIs. Auto-pays any endpoint on Base, Solana, and Tempo. Also works as an OpenClaw plugin.",
55
"type": "module",
66
"sideEffects": false,
@@ -63,7 +63,7 @@
6363
"@x402/mcp": "^2.8.0",
6464
"@x402/svm": "^2.8.0",
6565
"ethers": "^6.16.0",
66-
"mppx": "^0.4.9",
66+
"mppx": "^0.5.1",
6767
"picocolors": "^1.1.1",
6868
"viem": "^2.47.6",
6969
"yaml": "^2.8.3"

packages/x402-proxy/src/bin/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ for (let i = 0; i < rawArgs.length; i++) {
2020
const dir = resolve(a.slice("--config-dir=".length));
2121
process.env.XDG_CONFIG_HOME = dir;
2222
process.env.X402_PROXY_CONFIG_DIR_OVERRIDE = dir;
23+
} else if (a === "--debug") {
24+
process.env.X402_PROXY_DEBUG = "1";
2325
} else {
2426
args.push(a);
2527
}

packages/x402-proxy/src/commands/serve.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { once } from "node:events";
22
import http, { type IncomingMessage, type ServerResponse } from "node:http";
33
import { buildCommand, type CommandContext } from "@stricli/core";
4-
import { createX402ProxyHandler } from "../handler.js";
4+
import { createMppProxyHandler, createX402ProxyHandler } from "../handler.js";
55
import { getHistoryPath, loadConfig } from "../lib/config.js";
66
import { dim, error, info, isTTY, success } from "../lib/output.js";
77
import { buildX402Client, resolveWallet, type WalletResolution } from "../lib/resolve-wallet.js";
@@ -132,6 +132,9 @@ export async function startServeServer(
132132
spendLimitPerTx: config?.spendLimitPerTx,
133133
});
134134
const x402Proxy = createX402ProxyHandler({ client: x402Client });
135+
const mppHandler = wallet.evmKey
136+
? await createMppProxyHandler({ evmKey: wallet.evmKey, maxDeposit: configuredMppBudget })
137+
: null;
135138
const { providers, models } = resolveProviders({
136139
protocol: resolvedProtocol,
137140
mppSessionBudget: configuredMppBudget,
@@ -148,9 +151,9 @@ export async function startServeServer(
148151
const routeHandler = createInferenceProxyRouteHandler({
149152
providers,
150153
getX402Proxy: () => x402Proxy,
154+
getMppHandler: () => mppHandler,
151155
getWalletAddress: () => wallet.solanaAddress ?? wallet.evmAddress ?? null,
152156
getWalletAddressForNetwork: (network) => walletAddressForNetwork(wallet, network),
153-
getEvmKey: () => wallet.evmKey ?? null,
154157
historyPath: getHistoryPath(),
155158
allModels: models,
156159
logger: {
@@ -185,6 +188,13 @@ export async function startServeServer(
185188
server,
186189
port,
187190
close: async () => {
191+
if (mppHandler) {
192+
try {
193+
await mppHandler.close();
194+
} catch {
195+
// best effort
196+
}
197+
}
188198
if (!server.listening) return;
189199
server.close();
190200
await once(server, "close");

packages/x402-proxy/src/handler.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,15 @@ export async function createMppProxyHandler(opts: {
139139
}): Promise<MppProxyHandler> {
140140
const { Mppx, tempo } = await import("mppx/client");
141141
const { privateKeyToAccount } = await import("viem/accounts");
142+
const { saveSession, clearSession } = await import("./lib/config.js");
142143

143144
const account = privateKeyToAccount(opts.evmKey as `0x${string}`);
144145
const maxDeposit = opts.maxDeposit ?? "1";
145146
const paymentQueue: MppPaymentInfo[] = [];
146147
let lastChallengeAmount: string | undefined;
147148

149+
const debug = process.env.X402_PROXY_DEBUG === "1";
150+
148151
const mppx = Mppx.create({
149152
methods: [tempo({ account, maxDeposit })],
150153
polyfill: false,
@@ -157,12 +160,26 @@ export async function createMppProxyHandler(opts: {
157160
},
158161
});
159162

163+
const payerAddress = account.address;
164+
165+
function injectPayerHeader(init?: RequestInit): RequestInit {
166+
const headers = new Headers(init?.headers);
167+
headers.set("X-Payer-Address", payerAddress);
168+
return { ...init, headers };
169+
}
170+
160171
// Lazy session creation - only needed for SSE streaming
161172
let session: ReturnType<typeof tempo.session> | undefined;
173+
let persistedChannelId: string | undefined;
162174

163175
return {
176+
// Non-streaming uses stateless one-shot charges (not sessions) - intentional.
177+
// Each fetch() handles its own 402 challenge/response cycle independently.
164178
async fetch(input: string | URL, init?: RequestInit): Promise<Response> {
165-
const response = await mppx.fetch(typeof input === "string" ? input : input.toString(), init);
179+
const response = await mppx.fetch(
180+
typeof input === "string" ? input : input.toString(),
181+
injectPayerHeader(init),
182+
);
166183

167184
// Extract payment info from Payment-Receipt header
168185
const receiptHeader = response.headers.get("Payment-Receipt");
@@ -193,7 +210,24 @@ export async function createMppProxyHandler(opts: {
193210
async sse(input: string | URL, init?: RequestInit): Promise<AsyncIterable<string>> {
194211
session ??= tempo.session({ account, maxDeposit });
195212
const url = typeof input === "string" ? input : input.toString();
196-
const iterable = await session.sse(url, init as Parameters<typeof session.sse>[1]);
213+
const iterable = await session.sse(
214+
url,
215+
injectPayerHeader(init) as Parameters<typeof session.sse>[1],
216+
);
217+
218+
// Persist channelId for tracking. The server includes channelId in 402
219+
// challenges for returning payers, so mppx auto-recovers on restart
220+
// via tryRecoverChannel() without any client-side injection.
221+
if (session.channelId && session.channelId !== persistedChannelId) {
222+
persistedChannelId = session.channelId;
223+
if (debug) process.stderr.write(`[x402-proxy] channelId: ${persistedChannelId}\n`);
224+
try {
225+
saveSession({ channelId: session.channelId, createdAt: new Date().toISOString() });
226+
} catch {
227+
// Non-critical
228+
}
229+
}
230+
197231
paymentQueue.push({ protocol: "mpp", network: TEMPO_NETWORK, intent: "session" });
198232
return iterable;
199233
},
@@ -203,6 +237,11 @@ export async function createMppProxyHandler(opts: {
203237
async close(): Promise<void> {
204238
if (session?.opened) {
205239
const receipt = await session.close();
240+
try {
241+
clearSession();
242+
} catch {
243+
// Non-critical
244+
}
206245
if (receipt) {
207246
// spent is in USDC base units (6 decimals)
208247
const spentUsdc = receipt.spent

packages/x402-proxy/src/lib/config.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,42 @@ export function saveConfig(config: ProxyConfig): void {
9595
fs.writeFileSync(p, stringifyYaml(config), "utf-8");
9696
}
9797

98+
// --- MPP session persistence ---
99+
100+
export type StoredSession = {
101+
channelId: string;
102+
createdAt: string;
103+
};
104+
105+
export function getSessionPath(): string {
106+
return path.join(getConfigDir(), "session.json");
107+
}
108+
109+
export function loadStoredSession(): StoredSession | null {
110+
const p = getSessionPath();
111+
if (!fs.existsSync(p)) return null;
112+
try {
113+
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
114+
if (typeof data.channelId === "string") return data as StoredSession;
115+
return null;
116+
} catch {
117+
return null;
118+
}
119+
}
120+
121+
export function saveSession(session: StoredSession): void {
122+
ensureConfigDir();
123+
fs.writeFileSync(getSessionPath(), JSON.stringify(session, null, 2), "utf-8");
124+
}
125+
126+
export function clearSession(): void {
127+
try {
128+
fs.unlinkSync(getSessionPath());
129+
} catch {
130+
// file may not exist
131+
}
132+
}
133+
98134
export function isConfigured(): boolean {
99135
if (process.env.X402_PROXY_WALLET_MNEMONIC) return true;
100136
if (process.env.X402_PROXY_WALLET_EVM_KEY) return true;

packages/x402-proxy/src/openclaw/plugin.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { join } from "node:path";
33
import { createKeyPairSignerFromBytes, type KeyPairSigner } from "@solana/kit";
44
import { x402Client } from "@x402/fetch";
55
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
6-
import { createX402ProxyHandler, type X402ProxyHandler } from "../handler.js";
6+
import {
7+
createMppProxyHandler,
8+
createX402ProxyHandler,
9+
type MppProxyHandler,
10+
type X402ProxyHandler,
11+
} from "../handler.js";
712
import { getHistoryPath } from "../lib/config.js";
813
import { OptimizedSvmScheme } from "../lib/optimized-svm-scheme.js";
914
import { resolveWallet } from "../lib/resolve-wallet.js";
@@ -50,6 +55,7 @@ export function register(api: OpenClawPluginApi): void {
5055
let evmWalletAddress: string | null = null;
5156
let signerRef: KeyPairSigner | null = null;
5257
let proxyRef: X402ProxyHandler | null = null;
58+
let mppHandlerRef: MppProxyHandler | null = null;
5359
let evmKeyRef: string | null = null;
5460
let walletLoadPromise: Promise<void> | null = null;
5561

@@ -61,10 +67,10 @@ export function register(api: OpenClawPluginApi): void {
6167
const handler = createInferenceProxyRouteHandler({
6268
providers,
6369
getX402Proxy: () => proxyRef,
70+
getMppHandler: () => mppHandlerRef,
6471
getWalletAddress: () => solanaWalletAddress ?? evmWalletAddress,
6572
getWalletAddressForNetwork: (network) =>
6673
addressForNetwork(evmWalletAddress, solanaWalletAddress, network),
67-
getEvmKey: () => evmKeyRef,
6874
historyPath,
6975
allModels,
7076
logger: api.logger,
@@ -122,6 +128,18 @@ export function register(api: OpenClawPluginApi): void {
122128
proxyRef = null;
123129
}
124130

131+
if (evmKeyRef) {
132+
const maxBudget = Math.max(
133+
...providers.map((p) => Number(p.mppSessionBudget) || 0.5),
134+
).toString();
135+
mppHandlerRef = await createMppProxyHandler({
136+
evmKey: evmKeyRef,
137+
maxDeposit: maxBudget,
138+
});
139+
} else {
140+
mppHandlerRef = null;
141+
}
142+
125143
api.logger.info(
126144
`wallets: solana=${solanaWalletAddress ?? "missing"} evm=${evmWalletAddress ?? "missing"}`,
127145
);
@@ -139,7 +157,15 @@ export function register(api: OpenClawPluginApi): void {
139157
async start() {
140158
await ensureWalletLoaded();
141159
},
142-
async stop() {},
160+
async stop() {
161+
if (mppHandlerRef) {
162+
try {
163+
await mppHandlerRef.close();
164+
} catch {
165+
// best effort
166+
}
167+
}
168+
},
143169
});
144170

145171
const toolCtx = {

0 commit comments

Comments
 (0)