@lizard-build/cli 0.3.99 → 4.0.1

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.
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
5
5
  import os from "node:os";
6
6
  import { spawn } from "node:child_process";
7
7
 
8
- export const CURRENT_VERSION = "0.3.99";
8
+ export const CURRENT_VERSION = "4.0.1";
9
9
  const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
10
10
  const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
11
11
 
@@ -0,0 +1,131 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import type { PaymentRequired } from '@x402/core/types';
6
+ import { APIError, getBaseURL, getRequestToken } from './api.js';
7
+
8
+ const NETWORK = 'eip155:8453';
9
+ const ASSET = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
10
+ const ENDPOINT = '/api/billing/purchase/x402';
11
+ export interface X402Quote { creditCents: number; feeCents: number; totalCents: number; network: string; asset: string; payTo: string; }
12
+ export interface X402Result extends X402Quote { requestId: string; attemptId: string; status: string; transaction?: string | null; paymentIntentId?: string | null; }
13
+ class PaymentInputError extends Error {}
14
+ interface Journal { requestId: string; creditCents: number; maxTotalCents: number; origin: string; signature?: string; result?: X402Result; }
15
+ export function dollarsToCents(value: string): number {
16
+ if (!/^\d+(\.\d{1,2})?$/.test(value)) throw new Error('Use a positive dollar amount with at most two decimal places.');
17
+ const [whole, fraction = ''] = value.split('.');
18
+ const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));
19
+ if (!Number.isSafeInteger(cents) || cents <= 0) throw new Error('Invalid dollar amount.');
20
+ return cents;
21
+ }
22
+ export function paymentOrigin(): string {
23
+ const url = new URL(getBaseURL());
24
+ const trusted = process.env.LIZARD_X402_TRUSTED_ORIGIN || 'https://lizard.build';
25
+ if (url.protocol !== 'https:' || url.origin !== trusted || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
26
+ throw new Error('x402 requires a trusted HTTPS API origin. Set LIZARD_X402_TRUSTED_ORIGIN explicitly for another server.');
27
+ }
28
+ return url.origin;
29
+ }
30
+ async function request(method: string, route: string, body?: unknown, signature?: string): Promise<Response> {
31
+ const token = getRequestToken();
32
+ if (!token) throw new APIError(401, 'Sign in before buying credits.');
33
+ const headers: Record<string, string> = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
34
+ if (signature) headers['PAYMENT-SIGNATURE'] = signature;
35
+ return fetch(paymentOrigin() + route, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(45_000) });
36
+ }
37
+ async function json<T>(res: Response): Promise<T> {
38
+ const value = await res.json() as T & { error?: string; message?: string };
39
+ if (!res.ok) throw new APIError(res.status, value.error || value.message || 'Payment request failed.', value.error, value);
40
+ return value;
41
+ }
42
+ export function validateQuote(q: X402Quote, creditCents: number, maxTotalCents = Number.MAX_SAFE_INTEGER) {
43
+ if (!Number.isSafeInteger(q.creditCents) || !Number.isSafeInteger(q.feeCents) || !Number.isSafeInteger(q.totalCents) ||
44
+ q.creditCents !== creditCents || q.feeCents < 0 || q.totalCents !== q.creditCents + q.feeCents || q.totalCents > maxTotalCents ||
45
+ q.network !== NETWORK || q.asset.toLowerCase() !== ASSET.toLowerCase() || !/^0x[0-9a-fA-F]{40}$/.test(q.payTo)) throw new Error('Payment quote is invalid or exceeds --max-total.');
46
+ }
47
+ export async function getX402Quote(creditCents: number): Promise<X402Quote> {
48
+ const q = await json<X402Quote>(await request('POST', `${ENDPOINT}/quote`, { creditCents }));
49
+ validateQuote(q, creditCents);
50
+ return q;
51
+ }
52
+ export function validateChallenge(value: PaymentRequired, q: X402Quote, origin: string) {
53
+ const r = value.accepts?.[0];
54
+ if (value.x402Version !== 2 || value.accepts?.length !== 1 || value.resource?.url !== origin + ENDPOINT || !r ||
55
+ r.scheme !== 'exact' || r.network !== NETWORK || r.asset.toLowerCase() !== ASSET.toLowerCase() ||
56
+ r.payTo.toLowerCase() !== q.payTo.toLowerCase() || r.amount !== String(BigInt(q.totalCents) * 10_000n) ||
57
+ r.maxTimeoutSeconds <= 0 || r.maxTimeoutSeconds > 300 ||
58
+ (value.extensions && Object.keys(value.extensions).length > 0) ||
59
+ (r.extra?.assetTransferMethod !== undefined && r.extra.assetTransferMethod !== 'eip3009') ||
60
+ Object.keys(r.extra || {}).some(key => !['name', 'version', 'assetTransferMethod'].includes(key)) || r.extra?.name !== 'USD Coin' || r.extra?.version !== '2') throw new Error('Untrusted x402 payment challenge.');
61
+ }
62
+ function journalPath(creditCents: number, requestId?: string) {
63
+ if (requestId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(requestId)) throw new Error('--request-id must be a UUID.');
64
+ const account = createHash('sha256').update(paymentOrigin() + '\0' + getRequestToken()).digest('hex');
65
+ const dir = path.join(process.env.LIZARD_HOME || os.homedir(), '.lizard', 'payments', account);
66
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
67
+ const pending = path.join(dir, `pending-${creditCents}.json`);
68
+ if (requestId && fs.existsSync(pending)) {
69
+ const previous = JSON.parse(fs.readFileSync(pending, 'utf8')) as Journal;
70
+ if (previous.requestId === requestId) return pending;
71
+ }
72
+ return path.join(dir, requestId || `pending-${creditCents}.json`);
73
+ }
74
+ function persist(file: string, state: Journal) {
75
+ const temporary = `${file}.${randomUUID()}.tmp`;
76
+ fs.writeFileSync(temporary, JSON.stringify(state), { mode: 0o600, flag: 'wx' });
77
+ fs.renameSync(temporary, file);
78
+ }
79
+ export async function payX402(creditCents: number, maxTotalCents: number, expected: X402Quote, requestId?: string): Promise<X402Result> {
80
+ validateQuote(expected, creditCents, maxTotalCents);
81
+ const origin = paymentOrigin();
82
+ const file = journalPath(creditCents, requestId);
83
+ let state: Journal = { requestId: requestId || randomUUID(), creditCents, maxTotalCents, origin };
84
+ try { fs.writeFileSync(file, JSON.stringify(state), { flag: 'wx', mode: 0o600 }); }
85
+ catch (e) {
86
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
87
+ state = JSON.parse(fs.readFileSync(file, 'utf8')) as Journal;
88
+ if (state.origin !== origin || state.creditCents !== creditCents || state.maxTotalCents > maxTotalCents) throw new Error('Saved payment differs. Resume with the original amount and limit.');
89
+ if (state.result?.status === 'paid') {
90
+ if (requestId) return state.result;
91
+ throw new Error(`Previous payment ${state.requestId} is paid. Use a new --request-id UUID for another purchase of this amount.`);
92
+ }
93
+ }
94
+ try {
95
+ let response = await request('POST', ENDPOINT, { creditCents, requestId: state.requestId }, state.signature);
96
+ if (response.status === 402 && !state.signature) {
97
+ const header = response.headers.get('PAYMENT-REQUIRED');
98
+ if (!header || header.length > 16_384) throw new Error('Server returned 402 without a valid x402 challenge.');
99
+ const challenge = JSON.parse(Buffer.from(header, 'base64').toString('utf8')) as PaymentRequired;
100
+ const body = await response.json() as X402Result;
101
+ validateQuote(body, creditCents, Math.min(state.maxTotalCents, maxTotalCents));
102
+ if (body.payTo.toLowerCase() !== expected.payTo.toLowerCase() || body.totalCents !== expected.totalCents) throw new Error('Quote changed. Request a fresh quote before paying.');
103
+ validateChallenge(challenge, body, origin);
104
+ const key = process.env.LIZARD_X402_PRIVATE_KEY;
105
+ if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key)) throw new PaymentInputError('Set LIZARD_X402_PRIVATE_KEY to a funded Base wallet key through your secret manager.');
106
+ const [{ x402Client }, { ExactEvmScheme }, { privateKeyToAccount }] = await Promise.all([
107
+ import('@x402/core/client'), import('@x402/evm/exact/client'), import('viem/accounts'),
108
+ ]);
109
+ const signer = privateKeyToAccount(key as `0x${string}`);
110
+ const client = new x402Client().register(NETWORK, new ExactEvmScheme(signer))
111
+ .setSpendControls({ maxAmountPerPayment: `$${(Math.min(state.maxTotalCents, maxTotalCents) / 100).toFixed(2)}` });
112
+ const payload = await client.createPaymentPayload(challenge);
113
+ state.signature = Buffer.from(JSON.stringify(payload)).toString('base64');
114
+ state.result = body;
115
+ persist(file, state); // Persist the authorization before sending it anywhere.
116
+ response = await request('POST', ENDPOINT, { creditCents, requestId: state.requestId }, state.signature);
117
+ }
118
+ const result = await json<X402Result>(response);
119
+ state.result = result;
120
+ persist(file, state);
121
+ return result;
122
+ } catch (e) {
123
+ if (e instanceof APIError && e.code === 'QUOTE_EXPIRED') throw new Error('Quote expired before payment was accepted. Use a new --request-id UUID.');
124
+ const message = e instanceof APIError || e instanceof PaymentInputError ? e.message : 'Payment did not finish. No new payment was started.';
125
+ throw new Error(`${message} Resume with --request-id ${state.requestId}. Saved state: ${file}`);
126
+ }
127
+ }
128
+ export async function getX402Status(attemptId: string): Promise<X402Result> {
129
+ if (!/^[0-9a-f-]{36}$/i.test(attemptId)) throw new Error('Payment attempt ID must be a UUID.');
130
+ return json<X402Result>(await request('GET', `${ENDPOINT}/${attemptId}`));
131
+ }
@@ -0,0 +1,61 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { dollarsToCents, validateQuote, validateChallenge, paymentOrigin, payX402, getX402Quote } from '../../src/lib/x402.js';
6
+ import { setBaseURL, setAccessToken } from '../../src/lib/api.js';
7
+ const payTo='0x'+'22'.repeat(20);
8
+ const quote={creditCents:2000,feeCents:31,totalCents:2031,network:'eip155:8453',asset:'0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',payTo};
9
+ const challenge={x402Version:2,resource:{url:'https://lizard.build/api/billing/purchase/x402',mimeType:'application/json'},accepts:[{scheme:'exact',network:'eip155:8453' as const,asset:quote.asset,amount:'20310000',payTo,maxTimeoutSeconds:300,extra:{name:'USD Coin',version:'2'}}]};
10
+ const fetchMock=vi.fn();
11
+ let home: string;
12
+ function unpaid() { return new Response(JSON.stringify({...quote,attemptId:'a0000000-0000-4000-8000-000000000001',status:'quoted',...challenge}), {status:402,headers:{'PAYMENT-REQUIRED':Buffer.from(JSON.stringify(challenge)).toString('base64')}}); }
13
+ beforeEach(() => {
14
+ fetchMock.mockReset(); vi.stubGlobal('fetch',fetchMock); setBaseURL('https://lizard.build'); setAccessToken('test-token');
15
+ home=fs.mkdtempSync(path.join(os.tmpdir(),'lizard-x402-')); vi.stubEnv('LIZARD_HOME',home);
16
+ vi.stubEnv('LIZARD_X402_TRUSTED_ORIGIN',''); vi.stubEnv('LIZARD_X402_PRIVATE_KEY','0x'+'11'.repeat(32));
17
+ });
18
+ afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
19
+ describe('x402 client', () => {
20
+ it('parses money without rounding fractional cents', () => {
21
+ expect(dollarsToCents('20.31')).toBe(2031);
22
+ for(const invalid of ['1.001','NaN','1e3','-1','0','Infinity']) expect(()=>dollarsToCents(invalid)).toThrow();
23
+ });
24
+ it('blocks untrusted hosts and malformed quotes', () => {
25
+ setBaseURL('http://lizard.build'); expect(paymentOrigin).toThrow();
26
+ setBaseURL('https://evil.example'); expect(paymentOrigin).toThrow();
27
+ expect(()=>validateQuote({...quote,totalCents:2200},2000,2100)).toThrow();
28
+ expect(()=>validateQuote({...quote,asset:payTo},2000,2100)).toThrow();
29
+ });
30
+ it('rejects a changed recipient, token, network, amount, domain or payment scheme', () => {
31
+ for(const patch of [{payTo:'0x'+'33'.repeat(20)},{asset:payTo},{network:'eip155:1'},{amount:'99999999'},{extra:{name:'USDC',version:'2'}},{scheme:'upto'},{extra:{name:'USD Coin',version:'2',assetTransferMethod:'permit2'}}]) {
32
+ expect(()=>validateChallenge({...challenge,accepts:[{...challenge.accepts[0],...patch}]} as never,quote,'https://lizard.build')).toThrow();
33
+ }
34
+ });
35
+ it('quotes without accessing a wallet or signing', async () => {
36
+ vi.stubEnv('LIZARD_X402_PRIVATE_KEY',''); fetchMock.mockResolvedValue(new Response(JSON.stringify(quote)));
37
+ expect(await getX402Quote(2000)).toEqual(quote); expect(fetchMock).toHaveBeenCalledTimes(1);
38
+ });
39
+ it('never signs a plain INSUFFICIENT_CREDITS 402', async () => {
40
+ fetchMock.mockResolvedValue(new Response(JSON.stringify({error:'INSUFFICIENT_CREDITS'}),{status:402}));
41
+ await expect(payX402(2000,2100,quote)).rejects.toThrow('Resume with --request-id'); expect(fetchMock).toHaveBeenCalledTimes(1);
42
+ });
43
+ it('persists the real SDK authorization and reuses it after a lost response', async () => {
44
+ fetchMock.mockResolvedValueOnce(unpaid()).mockRejectedValueOnce(new Error('network timeout'));
45
+ await expect(payX402(2000,2100,quote)).rejects.toThrow('Resume with --request-id');
46
+ const sent=fetchMock.mock.calls[1][1]; const first=JSON.parse(sent.body);
47
+ expect(sent.redirect).toBe('error'); expect(sent.headers.Authorization).toBe('Bearer test-token');
48
+ const payload=JSON.parse(Buffer.from(sent.headers['PAYMENT-SIGNATURE'],'base64').toString());
49
+ expect(payload.payload.authorization).toMatchObject({to:payTo,value:'20310000'});
50
+ const paid={...quote,requestId:first.requestId,attemptId:'a0000000-0000-4000-8000-000000000001',status:'paid'};
51
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(paid)));
52
+ expect(await payX402(2000,2100,quote,first.requestId)).toEqual(paid);
53
+ expect(fetchMock.mock.calls[2][1].headers['PAYMENT-SIGNATURE']).toBe(sent.headers['PAYMENT-SIGNATURE']);
54
+ expect(JSON.parse(fetchMock.mock.calls[2][1].body).requestId).toBe(first.requestId);
55
+ expect(JSON.stringify(fs.readdirSync(path.join(home,'.lizard','payments')))).not.toContain('test-token');
56
+ await expect(payX402(2000,2100,quote)).rejects.toThrow('Previous payment');
57
+ });
58
+ it('refuses a quote over the approved limit before any request', async () => {
59
+ await expect(payX402(2000,2000,quote)).rejects.toThrow('exceeds --max-total'); expect(fetchMock).not.toHaveBeenCalled();
60
+ });
61
+ });