@moltbankhq/x402-mod 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ {
2
+ "modProtocolVersion": "1.1",
3
+ "name": "x402",
4
+ "displayName": "x402",
5
+ "version": "0.1.0",
6
+ "description": "x402 Capability. Provides cap.moltbank.x402 — Moltbank-orchestrated x402 payment flows on Base. The buy command funds the agent's registered x402 signer wallet from the Safe via account_execute (session-key path). The record command persists a completed x402 payment attestation via account_record_receipt (evm-local-attested verify-only) and records the payment row via record_x402_payment_result. All EVM signing delegates to the host's cap.signer.evm — this mod never holds wallet keys (architecture spec §10.1: only the trusted core signs).",
7
+ "publisher": "moltbankhq",
8
+ "homepage": "https://moltbank.bot/mods/x402",
9
+ "repository": "https://github.com/moltbankhq/openclaw-plugin",
10
+ "license": "MIT",
11
+ "tier": "official",
12
+ "signature": {
13
+ "algorithm": "ed25519",
14
+ "publisherKeyId": "moltbank-2026-01",
15
+ "issuedAt": "2026-05-14T00:00:00Z",
16
+ "expiresAt": "2027-05-14T00:00:00Z",
17
+ "value": ""
18
+ },
19
+ "category": "capability",
20
+ "riskLevel": "trade",
21
+ "moltbank": {
22
+ "minCliVersion": "0.1.8",
23
+ "requires": ["cap.signer.evm", "cap.identity.whoami"],
24
+ "provides": ["cap.moltbank.x402"]
25
+ },
26
+ "permissions": {
27
+ "requested": ["network", "signer_evm", "moltbank_mcp"],
28
+ "network": {
29
+ "allowedDomains": [
30
+ "base-rpc.publicnode.com",
31
+ "base-sepolia-rpc.publicnode.com"
32
+ ]
33
+ }
34
+ },
35
+ "payments": {
36
+ "spends": true,
37
+ "currency": "USDC",
38
+ "estimateCommand": "estimate",
39
+ "ledgerPath": "state/x402-ledger.jsonl"
40
+ },
41
+ "config": {
42
+ "schemaPath": "schemas/config.schema.json",
43
+ "setupCommand": "setup"
44
+ },
45
+ "state": { "scope": "~/.moltbank/mods/x402/" },
46
+ "backendExtension": { "allowed": false },
47
+ "interfaces": [
48
+ {
49
+ "type": "cli",
50
+ "binary": "x402-mod",
51
+ "package": "@moltbankhq/x402-mod",
52
+ "commands": [
53
+ "setup",
54
+ "doctor",
55
+ "estimate",
56
+ "run",
57
+ "status",
58
+ "feedback",
59
+ "buy",
60
+ "record",
61
+ "balances",
62
+ "help"
63
+ ],
64
+ "commandMetadata": {
65
+ "setup": { "readOnly": false, "destructive": false, "idempotent": false, "openWorld": false },
66
+ "doctor": { "readOnly": true, "destructive": false, "idempotent": true, "openWorld": false },
67
+ "estimate": { "readOnly": true, "destructive": false, "idempotent": true, "openWorld": false },
68
+ "run": { "readOnly": true, "destructive": false, "idempotent": true, "openWorld": false },
69
+ "status": { "readOnly": true, "destructive": false, "idempotent": true, "openWorld": false },
70
+ "feedback": { "readOnly": false, "destructive": false, "idempotent": false, "openWorld": false },
71
+ "buy": { "readOnly": false, "destructive": false, "idempotent": false, "openWorld": true },
72
+ "record": { "readOnly": false, "destructive": false, "idempotent": false, "openWorld": true },
73
+ "balances": { "readOnly": true, "destructive": false, "idempotent": true, "openWorld": true },
74
+ "help": { "readOnly": true, "destructive": false, "idempotent": true, "openWorld": false }
75
+ }
76
+ }
77
+ ],
78
+ "skill": {
79
+ "path": "SKILL.md",
80
+ "runtimes": ["claude-code", "openclaw"]
81
+ },
82
+ "review": { "securityReviewUrl": "https://moltbank.bot/mods/x402/review" },
83
+ "keywords": ["x402", "base", "usdc", "payments", "moltbank", "cap.moltbank.x402"],
84
+ "mcpScopes": [
85
+ "mcp:tool:get_account_details",
86
+ "mcp:tool:get_agent_wallet_status",
87
+ "mcp:tool:account_execute",
88
+ "mcp:tool:account_record_receipt"
89
+ ]
90
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@moltbankhq/x402-mod",
3
+ "version": "0.1.0",
4
+ "description": "x402 Capability. Provides cap.moltbank.x402 — Moltbank-orchestrated x402 payment flows on Base. Funds the agent's registered x402 signer wallet from the Safe and records completed payment attestations. All EVM signing delegates to the host.",
5
+ "type": "module",
6
+ "bin": {
7
+ "x402-mod": "bin/x402-mod"
8
+ },
9
+ "main": "./src/index.ts",
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "schemas/",
14
+ "SKILL.md",
15
+ "moltbank.mod.json",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --import tsx --test tests/**/*.test.ts"
20
+ },
21
+ "dependencies": {
22
+ "@moltbankhq/mod-sdk": "^0.1.1",
23
+ "tsx": "^4.20.6",
24
+ "viem": "^2.21.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=22"
28
+ }
29
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "x402 Mod Config",
4
+ "type": "object",
5
+ "properties": {
6
+ "defaultChain": {
7
+ "type": "string",
8
+ "enum": ["base"],
9
+ "description": "Default chain for x402 payments (currently only Base is supported)."
10
+ }
11
+ },
12
+ "additionalProperties": false
13
+ }
@@ -0,0 +1,26 @@
1
+ import { encodeFunctionData, parseAbi, type Address } from 'viem';
2
+ import { USDC_DECIMALS } from './constants.ts';
3
+
4
+ const ERC20_ABI = parseAbi(['function transfer(address to, uint256 amount) returns (bool)']);
5
+
6
+ export function buildErc20TransferCalldata(to: string, amountRaw: bigint): string {
7
+ return encodeFunctionData({
8
+ abi: ERC20_ABI,
9
+ functionName: 'transfer',
10
+ args: [to as Address, amountRaw]
11
+ });
12
+ }
13
+
14
+ export function parseUsdcAmount(amount: string | number): bigint {
15
+ const str = String(amount).trim();
16
+ const [intPart, fracPart = ''] = str.split('.');
17
+ const padded = fracPart.padEnd(USDC_DECIMALS, '0').slice(0, USDC_DECIMALS);
18
+ return BigInt(intPart + padded);
19
+ }
20
+
21
+ export function formatUsdc(raw: bigint): string {
22
+ const str = raw.toString().padStart(USDC_DECIMALS + 1, '0');
23
+ const intPart = str.slice(0, str.length - USDC_DECIMALS) || '0';
24
+ const fracPart = str.slice(str.length - USDC_DECIMALS).replace(/0+$/, '');
25
+ return fracPart ? `${intPart}.${fracPart}` : intPart;
26
+ }
package/src/config.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { MoltbankCli } from '@moltbankhq/mod-sdk';
2
+
3
+ export type X402Config = {
4
+ defaultChain?: 'base';
5
+ };
6
+
7
+ const CONFIG_KEY = 'x402-config';
8
+
9
+ export const ConfigSchema = {
10
+ type: 'object',
11
+ properties: {
12
+ defaultChain: { type: 'string', enum: ['base'] }
13
+ },
14
+ additionalProperties: false
15
+ } as const;
16
+
17
+ export async function loadConfig(moltbank: MoltbankCli): Promise<X402Config | null> {
18
+ try {
19
+ const state = moltbank.state;
20
+ const raw = await state.get(CONFIG_KEY);
21
+ if (!raw) return null;
22
+ return JSON.parse(raw as string) as X402Config;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function saveConfig(config: X402Config, moltbank: MoltbankCli): Promise<void> {
29
+ const state = moltbank.state;
30
+ await state.set(CONFIG_KEY, JSON.stringify(config));
31
+ }
@@ -0,0 +1,16 @@
1
+ // USDC contract addresses — env-resolved at runtime via NEXT_PUBLIC_ENVIRONMENT.
2
+ // Fallbacks are mainnet; testnet overrides via env vars.
3
+ export const USDC_ADDRESS_BASE: string =
4
+ process.env.USDC_ADDRESS_BASE ?? '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
5
+
6
+ export const USDC_ADDRESS_BASE_SEPOLIA: string =
7
+ process.env.USDC_ADDRESS_BASE_SEPOLIA ?? '0x036CbD53842c5426634e7929541eC2318f3dCF7e';
8
+
9
+ export const USDC_DECIMALS = 6;
10
+
11
+ export type X402ChainKey = 'base';
12
+
13
+ export function getX402UsdcAddress(env?: string): string {
14
+ const isTestnet = env === 'testnet' || env === 'staging';
15
+ return isTestnet ? USDC_ADDRESS_BASE_SEPOLIA : USDC_ADDRESS_BASE;
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,601 @@
1
+ // x402 Mod — entry point.
2
+ // Provides cap.moltbank.x402:
3
+ // buy — fund the agent's registered x402 signer wallet on Base from the Safe
4
+ // via account_execute (safe-7579 session-key USDC transfer).
5
+ // record — record a completed x402 payment: calls account_record_receipt
6
+ // (evm-local-attested verify-only) when signedTypedData is provided,
7
+ // then calls record_x402_payment_result to persist the x402 payment row.
8
+ // balances — read USDC balance of the registered x402 signer wallet from Base.
9
+ //
10
+ // All EVM signing delegates to the host. This mod never holds wallet keys.
11
+
12
+ import { randomUUID } from 'node:crypto';
13
+ import { createPublicClient, getAddress, http, parseAbi, type Address } from 'viem';
14
+ import { base, baseSepolia } from 'viem/chains';
15
+ import {
16
+ defineMod,
17
+ type DoctorCheck,
18
+ type ModSdkLifecycleResult,
19
+ type MoltbankCli,
20
+ useFeedback,
21
+ useMoltbank,
22
+ useState
23
+ } from '@moltbankhq/mod-sdk';
24
+ import { USDC_ADDRESS_BASE, USDC_ADDRESS_BASE_SEPOLIA, USDC_DECIMALS } from './constants.ts';
25
+ import { buildErc20TransferCalldata, formatUsdc, parseUsdcAmount } from './calldata.ts';
26
+ import { ConfigSchema, loadConfig, saveConfig, type X402Config } from './config.ts';
27
+ import { getAccountSafeAddress, getRegisteredX402SignerAddress } from './mcp.ts';
28
+ import { buildPolicySpec } from './policy-spec.ts';
29
+
30
+ // ---------- Helpers ---------------------------------------------------------
31
+
32
+ function envelopeOk(command: string, data: unknown): ModSdkLifecycleResult {
33
+ return { ok: true, command, data };
34
+ }
35
+
36
+ function envelopeErr(
37
+ command: string,
38
+ code: string,
39
+ message: string,
40
+ details?: unknown
41
+ ): ModSdkLifecycleResult {
42
+ return {
43
+ ok: false,
44
+ command,
45
+ error: { code, message, ...(details !== undefined ? { details } : {}) }
46
+ };
47
+ }
48
+
49
+ function kebabToCamel(s: string): string {
50
+ return s.replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
51
+ }
52
+
53
+ function parseValue(raw: string): unknown {
54
+ try {
55
+ return JSON.parse(raw);
56
+ } catch {
57
+ return raw;
58
+ }
59
+ }
60
+
61
+ function parseModArgs(args: string[]): Record<string, unknown> {
62
+ const out: Record<string, unknown> = {};
63
+ for (let i = 0; i < args.length; i += 1) {
64
+ const entry = args[i];
65
+ if (typeof entry !== 'string') continue;
66
+ if (entry.startsWith('--') && entry.length > 2) {
67
+ const eq = entry.indexOf('=');
68
+ if (eq >= 0) {
69
+ const key = kebabToCamel(entry.slice(2, eq));
70
+ out[key] = parseValue(entry.slice(eq + 1));
71
+ continue;
72
+ }
73
+ const key = kebabToCamel(entry.slice(2));
74
+ const next = args[i + 1];
75
+ if (typeof next !== 'string' || next.startsWith('--')) {
76
+ out[key] = true;
77
+ continue;
78
+ }
79
+ out[key] = parseValue(next);
80
+ i += 1;
81
+ continue;
82
+ }
83
+ const eq = entry.indexOf('=');
84
+ if (eq > 0) {
85
+ const key = kebabToCamel(entry.slice(0, eq));
86
+ out[key] = parseValue(entry.slice(eq + 1));
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function getUsdcAddress(config: X402Config | null): string {
93
+ const env = process.env.NEXT_PUBLIC_ENVIRONMENT;
94
+ const isTestnet = env === 'testnet' || env === 'staging';
95
+ return isTestnet ? USDC_ADDRESS_BASE_SEPOLIA : USDC_ADDRESS_BASE;
96
+ }
97
+
98
+ function getBaseChain(config: X402Config | null) {
99
+ const env = process.env.NEXT_PUBLIC_ENVIRONMENT;
100
+ const isTestnet = env === 'testnet' || env === 'staging';
101
+ return isTestnet ? baseSepolia : base;
102
+ }
103
+
104
+ function getBaseRpcUrl(): string {
105
+ const env = process.env.NEXT_PUBLIC_ENVIRONMENT;
106
+ const isTestnet = env === 'testnet' || env === 'staging';
107
+ return isTestnet ? 'https://base-sepolia-rpc.publicnode.com' : 'https://base-rpc.publicnode.com';
108
+ }
109
+
110
+ // ---------- buy (fund x402 signer wallet) -----------------------------------
111
+
112
+ async function handleBuy(
113
+ args: string[],
114
+ config: X402Config | null,
115
+ moltbank: MoltbankCli
116
+ ): Promise<ModSdkLifecycleResult> {
117
+ const parsed = parseModArgs(args);
118
+ const organizationName =
119
+ typeof parsed.organizationName === 'string'
120
+ ? parsed.organizationName.trim()
121
+ : typeof parsed.org === 'string'
122
+ ? parsed.org.trim()
123
+ : '';
124
+ const accountName =
125
+ typeof parsed.accountName === 'string'
126
+ ? parsed.accountName.trim()
127
+ : typeof parsed.account === 'string'
128
+ ? parsed.account.trim()
129
+ : '';
130
+
131
+ if (!organizationName) return envelopeErr('buy', 'INVALID_INPUT', '--org / --organization-name is required.');
132
+ if (!accountName) return envelopeErr('buy', 'INVALID_INPUT', '--account / --account-name is required.');
133
+ if (!parsed.amount) return envelopeErr('buy', 'INVALID_INPUT', '--amount is required (USDC to fund, e.g. 5 or 5.5).');
134
+
135
+ let amountRaw: bigint;
136
+ try {
137
+ amountRaw = parseUsdcAmount(parsed.amount as string | number);
138
+ if (amountRaw <= 0n) throw new Error('Amount must be greater than zero.');
139
+ } catch (error) {
140
+ return envelopeErr('buy', 'INVALID_INPUT', `--amount: ${error instanceof Error ? error.message : String(error)}`);
141
+ }
142
+
143
+ const amountHuman = formatUsdc(amountRaw);
144
+
145
+ let safeAddress: string;
146
+ try {
147
+ safeAddress = getAddress(await getAccountSafeAddress(moltbank, organizationName, accountName));
148
+ } catch (error) {
149
+ return envelopeErr(
150
+ 'buy',
151
+ 'ACCOUNT_RESOLVE_FAILED',
152
+ `Could not resolve Safe address: ${error instanceof Error ? error.message : String(error)}`
153
+ );
154
+ }
155
+
156
+ let signerAddress: string;
157
+ try {
158
+ const addr = await getRegisteredX402SignerAddress(moltbank);
159
+ if (!addr) {
160
+ return envelopeErr(
161
+ 'buy',
162
+ 'SIGNER_NOT_REGISTERED',
163
+ "No x402 signer wallet registered for this agent. Call 'register_x402_wallet' first."
164
+ );
165
+ }
166
+ signerAddress = getAddress(addr);
167
+ } catch (error) {
168
+ return envelopeErr(
169
+ 'buy',
170
+ 'SIGNER_RESOLVE_FAILED',
171
+ `Could not resolve x402 signer address: ${error instanceof Error ? error.message : String(error)}`
172
+ );
173
+ }
174
+
175
+ const usdcAddress = getUsdcAddress(config);
176
+ const transferData = buildErc20TransferCalldata(signerAddress, amountRaw);
177
+ const idempotencyKey = randomUUID();
178
+
179
+ console.error(`[phase9 x402 buy] safeAddress=${safeAddress} signerAddress=${signerAddress}`);
180
+ console.error(`[phase9 x402 buy] amount=${amountHuman} usdc=${usdcAddress}`);
181
+
182
+ const request = {
183
+ modName: 'x402',
184
+ capability: 'cap.moltbank.x402',
185
+ operationId: 'x402.fund-wallet',
186
+ organizationName,
187
+ accountName,
188
+ chain: 'base' as const,
189
+ signerMode: 'safe-7579' as const,
190
+ execution: {
191
+ kind: 'evm.safe7579.call' as const,
192
+ calls: [{ to: usdcAddress, data: transferData }],
193
+ signerPolicy: 'allowance-session' as const,
194
+ confirmation: 'wait' as const
195
+ },
196
+ budget: {
197
+ asset: 'USDC',
198
+ amountRaw: amountRaw.toString(),
199
+ budgetKind: 'transfer' as const,
200
+ recipientAddress: signerAddress
201
+ },
202
+ audit: {
203
+ summary: `x402 fund signer wallet: ${amountHuman} USDC → ${signerAddress}`,
204
+ rail: 'evm',
205
+ correlators: { operationId: 'x402.fund-wallet', signerAddress }
206
+ },
207
+ idempotencyKey
208
+ };
209
+
210
+ try {
211
+ console.error(`[phase9 x402 buy] → account_execute`);
212
+ const result = await moltbank.host.executeOperation(request);
213
+ console.error(`[phase9 x402 buy] account_execute result:`, JSON.stringify(result));
214
+ return envelopeOk('buy', {
215
+ amountUsdc: amountHuman,
216
+ signerAddress,
217
+ safeAddress,
218
+ ...(result as object)
219
+ });
220
+ } catch (error) {
221
+ return envelopeErr(
222
+ 'buy',
223
+ 'EXECUTE_FAILED',
224
+ `account_execute failed: ${error instanceof Error ? error.message : String(error)}`
225
+ );
226
+ }
227
+ }
228
+
229
+ // ---------- record (persist completed x402 payment) -------------------------
230
+
231
+ type SignedTypedData = {
232
+ domain: unknown;
233
+ types: unknown;
234
+ message: unknown;
235
+ signature: string;
236
+ };
237
+
238
+ function extractSignedTypedData(localPaymentResult: unknown): SignedTypedData | null {
239
+ if (!localPaymentResult || typeof localPaymentResult !== 'object') return null;
240
+ const lpr = localPaymentResult as Record<string, unknown>;
241
+ const sa = lpr.signedAuthorization as Record<string, unknown> | undefined;
242
+ if (!sa) return null;
243
+ const td = sa.typedData as Record<string, unknown> | undefined;
244
+ const sig = sa.signatureHex ?? sa.signature;
245
+ if (!td || typeof sig !== 'string' || !sig) return null;
246
+ return {
247
+ domain: td.domain ?? td,
248
+ types: td.types ?? {},
249
+ message: td.message ?? td,
250
+ signature: sig
251
+ };
252
+ }
253
+
254
+ async function handleRecord(
255
+ args: string[],
256
+ config: X402Config | null,
257
+ moltbank: MoltbankCli
258
+ ): Promise<ModSdkLifecycleResult> {
259
+ const parsed = parseModArgs(args);
260
+ const organizationName =
261
+ typeof parsed.organizationName === 'string'
262
+ ? parsed.organizationName.trim()
263
+ : typeof parsed.org === 'string'
264
+ ? parsed.org.trim()
265
+ : '';
266
+ const accountName =
267
+ typeof parsed.accountName === 'string'
268
+ ? parsed.accountName.trim()
269
+ : typeof parsed.account === 'string'
270
+ ? parsed.account.trim()
271
+ : '';
272
+ const x402Url =
273
+ typeof parsed.x402Url === 'string' ? parsed.x402Url.trim() : typeof parsed.url === 'string' ? parsed.url.trim() : '';
274
+ const paidAmount = parsed.paidAmount ?? parsed.amount;
275
+ const paymentTxHash = typeof parsed.paymentTxHash === 'string' ? parsed.paymentTxHash.trim() : undefined;
276
+ const paymentHttpStatus =
277
+ typeof parsed.paymentHttpStatus === 'number'
278
+ ? parsed.paymentHttpStatus
279
+ : typeof parsed.httpStatus === 'number'
280
+ ? parsed.httpStatus
281
+ : undefined;
282
+ const localPaymentResult =
283
+ typeof parsed.localPaymentResult === 'object' && parsed.localPaymentResult !== null
284
+ ? parsed.localPaymentResult
285
+ : typeof parsed.localPaymentResult === 'string'
286
+ ? parseValue(parsed.localPaymentResult)
287
+ : undefined;
288
+ const signedTypedDataRaw =
289
+ typeof parsed.signedTypedData === 'object' && parsed.signedTypedData !== null
290
+ ? parsed.signedTypedData
291
+ : typeof parsed.signedTypedData === 'string'
292
+ ? parseValue(parsed.signedTypedData)
293
+ : null;
294
+ const fundingProposalId = typeof parsed.fundingProposalId === 'string' ? parsed.fundingProposalId.trim() : undefined;
295
+ const fundingTxHash = typeof parsed.fundingTxHash === 'string' ? parsed.fundingTxHash.trim() : undefined;
296
+
297
+ if (!organizationName) return envelopeErr('record', 'INVALID_INPUT', '--org / --organization-name is required.');
298
+ if (!accountName) return envelopeErr('record', 'INVALID_INPUT', '--account / --account-name is required.');
299
+ if (!x402Url) return envelopeErr('record', 'INVALID_INPUT', '--x402-url is required.');
300
+ if (!paidAmount) return envelopeErr('record', 'INVALID_INPUT', '--paid-amount is required (USDC amount paid).');
301
+ if (!paymentHttpStatus) {
302
+ return envelopeErr('record', 'INVALID_INPUT', '--payment-http-status is required (HTTP status from the merchant response).');
303
+ }
304
+
305
+ let amountRaw: bigint;
306
+ try {
307
+ amountRaw = parseUsdcAmount(paidAmount as string | number);
308
+ if (amountRaw <= 0n) throw new Error('Paid amount must be greater than zero.');
309
+ } catch (error) {
310
+ return envelopeErr('record', 'INVALID_INPUT', `--paid-amount: ${error instanceof Error ? error.message : String(error)}`);
311
+ }
312
+
313
+ const idempotencyKey = randomUUID();
314
+ const operationId = `x402.record-${paymentTxHash ?? idempotencyKey}`;
315
+
316
+ // Resolve signer address for attestation
317
+ let signerAddress: string | null = null;
318
+ try {
319
+ signerAddress = await getRegisteredX402SignerAddress(moltbank);
320
+ } catch {
321
+ // non-fatal — skip attestation if signer not resolvable
322
+ }
323
+
324
+ // account_record_receipt: verifies the EIP-712 attestation (evm-local-attested verify-only)
325
+ // and persists the full payment details as the canonical receipt in mod_execution_receipts.
326
+ // This is the single generic write primitive — no protocol-specific recording tool needed.
327
+ const signedTypedData: SignedTypedData | null =
328
+ (signedTypedDataRaw as SignedTypedData | null) ?? extractSignedTypedData(localPaymentResult);
329
+
330
+ const receiptRequest = {
331
+ modName: 'x402',
332
+ capability: 'cap.moltbank.x402',
333
+ operationId,
334
+ organizationName,
335
+ accountName,
336
+ chain: 'base' as const,
337
+ signerMode: 'evm-local-attested' as const,
338
+ execution: {
339
+ kind: 'evm.local-attested.attestation' as const,
340
+ signerRef: signerAddress ?? '',
341
+ mode: 'verify-only' as const,
342
+ ...(signedTypedData ? { signedTypedData } : {})
343
+ },
344
+ receipt: {
345
+ protocolReceiptKind: 'x402.payment',
346
+ payload: {
347
+ id: paymentTxHash ?? operationId,
348
+ x402Url,
349
+ amountRaw: amountRaw.toString(),
350
+ paymentTxHash: paymentTxHash ?? null,
351
+ paymentHttpStatus: paymentHttpStatus ?? null,
352
+ fundingProposalId: fundingProposalId ?? null,
353
+ fundingTxHash: fundingTxHash ?? null,
354
+ signerAddress: signerAddress ?? null,
355
+ ...(localPaymentResult !== undefined ? { localPaymentResult } : {})
356
+ }
357
+ },
358
+ audit: {
359
+ summary: `x402 payment recorded: ${x402Url} ${formatUsdc(amountRaw)} USDC`,
360
+ rail: 'evm',
361
+ correlators: { x402Url, operationId }
362
+ },
363
+ idempotencyKey
364
+ };
365
+
366
+ console.error(`[phase9 x402 record] x402Url=${x402Url} amount=${formatUsdc(amountRaw)}`);
367
+ console.error(`[phase9 x402 record] signerAddress=${signerAddress} hasTypedData=${signedTypedData !== null}`);
368
+ console.error(`[phase9 x402 record] → account_record_receipt evm-local-attested verify-only`);
369
+
370
+ let receiptResult: unknown;
371
+ try {
372
+ receiptResult = await moltbank.host.recordReceipt(receiptRequest);
373
+ console.error(`[phase9 x402 record] account_record_receipt result:`, JSON.stringify(receiptResult));
374
+ } catch (error) {
375
+ return envelopeErr(
376
+ 'record',
377
+ 'RECORD_FAILED',
378
+ `account_record_receipt failed: ${error instanceof Error ? error.message : String(error)}`
379
+ );
380
+ }
381
+
382
+ const r = receiptResult as { executionId?: string | null; status?: string } | null;
383
+ return envelopeOk('record', {
384
+ x402Url,
385
+ amountUsdc: formatUsdc(amountRaw),
386
+ paymentTxHash: paymentTxHash ?? null,
387
+ executionId: r?.executionId ?? null,
388
+ status: r?.status ?? 'recorded',
389
+ attestationVerified: signedTypedData !== null
390
+ });
391
+ }
392
+
393
+ // ---------- balances ---------------------------------------------------------
394
+
395
+ const ERC20_BALANCE_ABI = parseAbi(['function balanceOf(address) returns (uint256)']);
396
+
397
+ async function handleBalances(
398
+ args: string[],
399
+ config: X402Config | null,
400
+ moltbank: MoltbankCli
401
+ ): Promise<ModSdkLifecycleResult> {
402
+ let signerAddress: string | null;
403
+ try {
404
+ signerAddress = await getRegisteredX402SignerAddress(moltbank);
405
+ } catch (error) {
406
+ return envelopeErr(
407
+ 'balances',
408
+ 'SIGNER_RESOLVE_FAILED',
409
+ `Could not resolve x402 signer address: ${error instanceof Error ? error.message : String(error)}`
410
+ );
411
+ }
412
+
413
+ if (!signerAddress) {
414
+ return envelopeErr(
415
+ 'balances',
416
+ 'SIGNER_NOT_REGISTERED',
417
+ "No x402 signer wallet registered. Call 'register_x402_wallet' first."
418
+ );
419
+ }
420
+
421
+ const chain = getBaseChain(config);
422
+ const rpcUrl = getBaseRpcUrl();
423
+ const usdcAddress = getUsdcAddress(config);
424
+
425
+ console.error(`[phase9 x402 balances] signerAddress=${signerAddress} usdc=${usdcAddress} rpc=${rpcUrl}`);
426
+
427
+ const publicClient = createPublicClient({ chain, transport: http(rpcUrl) });
428
+
429
+ try {
430
+ const balanceRaw = (await publicClient.readContract({
431
+ address: usdcAddress as Address,
432
+ abi: ERC20_BALANCE_ABI,
433
+ functionName: 'balanceOf',
434
+ args: [getAddress(signerAddress)]
435
+ })) as bigint;
436
+
437
+ return envelopeOk('balances', {
438
+ signerAddress: getAddress(signerAddress),
439
+ chain: 'base',
440
+ usdc: {
441
+ balanceRaw: balanceRaw.toString(),
442
+ balanceUsdc: formatUsdc(balanceRaw),
443
+ decimals: USDC_DECIMALS,
444
+ token: usdcAddress
445
+ }
446
+ });
447
+ } catch (error) {
448
+ return envelopeErr(
449
+ 'balances',
450
+ 'CHAIN_READ_FAILED',
451
+ `Failed to read USDC balance from chain: ${error instanceof Error ? error.message : String(error)}`
452
+ );
453
+ }
454
+ }
455
+
456
+ // ---------- doctor checks ---------------------------------------------------
457
+
458
+ function x402DoctorChecks(moltbank: MoltbankCli | undefined): DoctorCheck[] {
459
+ return [
460
+ {
461
+ key: 'moltbank-cli',
462
+ run: async () => {
463
+ if (!moltbank) {
464
+ return {
465
+ key: 'moltbank-cli',
466
+ ok: false,
467
+ message: 'Moltbank CLI handle not available. Run via `moltbank mod`.',
468
+ code: 'MOLTBANK_CLI_MISSING',
469
+ remediation: 'Ensure the Moltbank CLI is installed and run commands through `moltbank mod run x402`.'
470
+ };
471
+ }
472
+ return { key: 'moltbank-cli', ok: true, message: 'Moltbank CLI handle is available.' };
473
+ }
474
+ },
475
+ {
476
+ key: 'x402-signer-registered',
477
+ run: async () => {
478
+ if (!moltbank) {
479
+ return { key: 'x402-signer-registered', ok: false, message: 'CLI handle missing.', code: 'MOLTBANK_CLI_MISSING' };
480
+ }
481
+ try {
482
+ const addr = await getRegisteredX402SignerAddress(moltbank);
483
+ if (!addr) {
484
+ return {
485
+ key: 'x402-signer-registered',
486
+ ok: false,
487
+ message: 'No x402 signer wallet registered.',
488
+ code: 'SIGNER_NOT_REGISTERED',
489
+ remediation: "Call 'register_x402_wallet' with your Base EOA wallet address."
490
+ };
491
+ }
492
+ return { key: 'x402-signer-registered', ok: true, message: `x402 signer wallet registered: ${addr}` };
493
+ } catch (error) {
494
+ return {
495
+ key: 'x402-signer-registered',
496
+ ok: false,
497
+ message: `Failed to check signer: ${error instanceof Error ? error.message : String(error)}`,
498
+ code: 'CHECK_FAILED'
499
+ };
500
+ }
501
+ }
502
+ }
503
+ ];
504
+ }
505
+
506
+ // ---------- createMod / dispatch --------------------------------------------
507
+
508
+ export type CreateModOptions = {
509
+ moltbank?: MoltbankCli;
510
+ requireMoltbankCli?: boolean;
511
+ };
512
+
513
+ export function createMod(opts: CreateModOptions = {}) {
514
+ const sdkMod = defineMod<X402Config>({
515
+ name: 'x402',
516
+ requireMoltbankCli: opts.requireMoltbankCli,
517
+ ...(opts.moltbank ? { moltbank: opts.moltbank } : {}),
518
+ config: {
519
+ schema: ConfigSchema,
520
+ setup: async (_ctx) => {
521
+ const next: X402Config = { defaultChain: 'base' };
522
+ if (opts.moltbank) await saveConfig(next, opts.moltbank);
523
+ return next;
524
+ }
525
+ },
526
+ doctor: ({ moltbank }) => x402DoctorChecks(moltbank),
527
+ estimate: () => ({
528
+ totalUsdc: 0,
529
+ band: { lowUsdc: 0, highUsdc: 0 },
530
+ steps: [
531
+ {
532
+ step: 'buy-gas',
533
+ usdc: 0,
534
+ description: 'ERC20 transfer gas on Base (~25k gas at current base fee). Covered by the session-key wallet.'
535
+ }
536
+ ],
537
+ notes: [
538
+ 'buy transfers USDC from the Safe to the registered x402 signer wallet.',
539
+ 'record does not spend additional USDC — it only persists the payment receipt.',
540
+ 'Gas for the buy transfer is paid by the session-key wallet (not the Safe).'
541
+ ]
542
+ }),
543
+ run: async () => {
544
+ throw new Error('x402 run is not implemented. Use buy / record / balances directly.');
545
+ },
546
+ status: async () => ({
547
+ modName: 'x402',
548
+ configured: true,
549
+ provides: ['cap.moltbank.x402']
550
+ })
551
+ });
552
+
553
+ async function dispatch(
554
+ subcommand: string,
555
+ argv: { args?: string[]; text?: string; runId?: string }
556
+ ): Promise<ModSdkLifecycleResult> {
557
+ const cmd = (subcommand || 'help').trim().toLowerCase();
558
+
559
+ if (cmd === 'buy' || cmd === 'record' || cmd === 'balances') {
560
+ if (!opts.moltbank) {
561
+ return envelopeErr(cmd, 'INVALID_INPUT', 'x402: moltbank CLI handle required.');
562
+ }
563
+ const config = opts.moltbank ? await loadConfig(opts.moltbank) : null;
564
+ if (cmd === 'buy') return handleBuy(argv.args ?? [], config, opts.moltbank);
565
+ if (cmd === 'record') return handleRecord(argv.args ?? [], config, opts.moltbank);
566
+ return handleBalances(argv.args ?? [], config, opts.moltbank);
567
+ }
568
+
569
+ if (cmd === 'policy-spec') {
570
+ const env = process.env.NEXT_PUBLIC_ENVIRONMENT;
571
+ return envelopeOk('policy-spec', buildPolicySpec(env));
572
+ }
573
+
574
+ if (cmd === 'help') {
575
+ return {
576
+ ok: true,
577
+ command: 'help',
578
+ data: {
579
+ modName: 'x402',
580
+ subcommands: ['setup', 'doctor', 'estimate', 'status', 'feedback', 'buy', 'record', 'balances', 'help'],
581
+ usage: {
582
+ buy: 'x402-mod buy --org <org> --account <account> --amount <usdc>',
583
+ record:
584
+ 'x402-mod record --org <org> --account <account> --x402-url <url> --paid-amount <usdc> --payment-http-status <code> [--payment-tx-hash <0x...>] [--local-payment-result <json>]',
585
+ balances: 'x402-mod balances'
586
+ }
587
+ }
588
+ };
589
+ }
590
+
591
+ return sdkMod.dispatch(cmd, argv);
592
+ }
593
+
594
+ return { dispatch };
595
+ }
596
+
597
+ const productionMod = createMod({ moltbank: useMoltbank(), requireMoltbankCli: true });
598
+ export const dispatchX402 = productionMod.dispatch;
599
+ export const dispatch = productionMod.dispatch;
600
+
601
+ export { useFeedback, useMoltbank, useState };
package/src/mcp.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { MoltbankCli } from '@moltbankhq/mod-sdk';
2
+
3
+ export async function callMcpTool<T = unknown>(
4
+ moltbank: MoltbankCli,
5
+ toolName: string,
6
+ args: Record<string, unknown>
7
+ ): Promise<T> {
8
+ const cliArgs = ['mcp', 'call', '--tool', toolName, '--body', JSON.stringify(args), '--json'];
9
+ try {
10
+ return await moltbank.invoke<T>(cliArgs);
11
+ } catch (error) {
12
+ const message = error instanceof Error ? error.message : String(error);
13
+ const upstream = error as { code?: unknown; cliCode?: unknown; details?: unknown } | undefined;
14
+ const enriched = new Error(`moltbank mcp call ${toolName}: ${message}`) as Error & {
15
+ upstreamCode?: string;
16
+ upstreamDetails?: unknown;
17
+ };
18
+ const code =
19
+ typeof upstream?.cliCode === 'string'
20
+ ? upstream.cliCode
21
+ : typeof upstream?.code === 'string'
22
+ ? upstream.code
23
+ : undefined;
24
+ if (code) enriched.upstreamCode = code;
25
+ if (typeof upstream?.details !== 'undefined') enriched.upstreamDetails = upstream.details;
26
+ throw enriched;
27
+ }
28
+ }
29
+
30
+ export type AccountDetailsPayload = {
31
+ address: string;
32
+ organizationName?: string;
33
+ accountName?: string;
34
+ };
35
+
36
+ export async function getAccountSafeAddress(
37
+ moltbank: MoltbankCli,
38
+ organizationName: string,
39
+ accountName: string
40
+ ): Promise<string> {
41
+ const payload = await callMcpTool<AccountDetailsPayload>(moltbank, 'get_account_details', {
42
+ organizationName,
43
+ accountName
44
+ });
45
+ if (!payload || typeof payload !== 'object' || typeof payload.address !== 'string') {
46
+ throw new Error(`get_account_details returned no address (got ${JSON.stringify(payload)}).`);
47
+ }
48
+ return payload.address;
49
+ }
50
+
51
+ export type AgentWalletStatusPayload = {
52
+ agentId?: string;
53
+ agentLabel?: string;
54
+ organizationId?: string;
55
+ x402WalletAddress?: string | null;
56
+ status?: string;
57
+ };
58
+
59
+ export async function getRegisteredX402SignerAddress(moltbank: MoltbankCli): Promise<string | null> {
60
+ const payload = await callMcpTool<AgentWalletStatusPayload>(moltbank, 'get_agent_wallet_status', {});
61
+ return payload?.x402WalletAddress ?? null;
62
+ }
@@ -0,0 +1,37 @@
1
+ import type { ModPolicySpec } from '@moltbankhq/mod-sdk';
2
+ import { USDC_ADDRESS_BASE, USDC_DECIMALS } from './constants.ts';
3
+
4
+ const SELECTOR_ERC20_TRANSFER = 'a9059cbb';
5
+
6
+ export function buildPolicySpec(env?: string): ModPolicySpec {
7
+ const isTestnet = env === 'testnet' || env === 'staging';
8
+ const usdcAddress = isTestnet
9
+ ? (process.env.USDC_ADDRESS_BASE_SEPOLIA ?? '0x036CbD53842c5426634e7929541eC2318f3dCF7e')
10
+ : USDC_ADDRESS_BASE;
11
+
12
+ return {
13
+ modName: 'x402',
14
+ chain: 'base',
15
+ signerModes: ['safe-7579', 'evm-local-attested'],
16
+ receipts: ['x402.payment'],
17
+ facets: [
18
+ {
19
+ id: 'x402.fund-signer',
20
+ chain: 'base',
21
+ target: usdcAddress,
22
+ selector: SELECTOR_ERC20_TRANSFER,
23
+ asset: usdcAddress,
24
+ assetDecimals: USDC_DECIMALS,
25
+ label: 'x402 Fund Signer Wallet',
26
+ budgetCurrency: 'USDC',
27
+ defaultPeriod: 'Month',
28
+ deductsFromAllowance: true
29
+ }
30
+ ],
31
+ setupActions: [],
32
+ labels: {
33
+ buy: { description: 'x402 USDC Fund', direction: 'out' },
34
+ record: { description: 'x402 Payment Record', direction: 'out' }
35
+ }
36
+ };
37
+ }