@magnetoagents/cli 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -55,6 +55,8 @@ Resolution order:
55
55
  |-----|---------|
56
56
  | `MAGNETO_API_BASE` or `MAGNETO_BASE_URL` | `https://magnetoapp.io` (client appends `/api/v1`) |
57
57
  | `MAGNETO_TIMEOUT_MS` | `30000` (JSON requests only; streams and signed-URL downloads are exempt) |
58
+ | `MAGNETO_WALLET_PRIVATE_KEY` | (unset) 32-byte hex key `0x`+64. Env only — never a flag, never `credentials.json`. |
59
+ | `MAGNETO_X402_NETWORK` | `eip155:84532,eip155:8453` (comma-separated allowlist) |
58
60
 
59
61
  Local / worktree:
60
62
 
@@ -63,10 +65,12 @@ export MAGNETO_API_BASE=http://localhost:${FRONTEND_PORT:-3000}
63
65
  # worktree ports often come from repo `.ports.env`
64
66
  ```
65
67
 
66
- The client retries **once** on HTTP 429, honoring `Retry-After` (delta-seconds or HTTP-date, capped at 60s).
68
+ The client retries **once** on HTTP 429, honoring `Retry-After` (delta-seconds or HTTP-date, capped at 60s). A payable HTTP 402 with `PAYMENT-REQUIRED` is a **sibling** one-shot retry (see [Payments (x402)](#payments-x402)).
67
69
 
68
70
  ## Commands
69
71
 
72
+ Global option (any command): `--max-payment <usd>` — refuse x402 charges strictly above this USD amount (default `0.10`). Equal-to-cap is paid.
73
+
70
74
  ```bash
71
75
  magneto login <key>
72
76
 
@@ -124,7 +128,7 @@ Mutations (`create`, `start`, `stop`, `restart`, `resize`, `delete`, `skills ins
124
128
  | Code | Meaning |
125
129
  |------|---------|
126
130
  | `0` | Success |
127
- | `1` | API or client error (including 402 / 404 / 409 / 422 / 429-after-retry / 5xx / timeout / missing key) |
131
+ | `1` | API or client error (including 402 / cap-refuse / no-wallet challenge / 404 / 409 / 422 / 429-after-retry / 5xx / timeout / missing key) |
128
132
  | `2` | HTTP 401 or 403 |
129
133
  | `N` | `magneto bash` only: remote `exit_code` on HTTP 200 (`N & 255`) |
130
134
 
@@ -136,6 +140,22 @@ error: <detail> (HTTP <status>) · request-id: <id> · Retry-After: <n>
136
140
 
137
141
  `request-id` / `Retry-After` segments are omitted when the server did not send them.
138
142
 
143
+ ## Payments (x402)
144
+
145
+ Uncovered `sk_live_*` keys hitting a priced `/api/v1` route receive HTTP 402 + a `PAYMENT-REQUIRED` header (x402 v2). The CLI is an optional **payer**:
146
+
147
+ | Wallet env | What happens |
148
+ |------------|----------------|
149
+ | `MAGNETO_WALLET_PRIVATE_KEY` set | Decode the challenge, pick the first `accepted` entry on the network allowlist, refuse if the amount **exceeds** `--max-payment` (default `$0.10`; equal is paid), EIP-3009-sign (`exact`/`quote`) or Permit2-sign (`upto`), retry **once** with `PAYMENT-SIGNATURE`. On success, stderr prints `paid $X · tx <hash>`. Stdout stays the command's JSON/table. |
150
+ | unset | Print a challenge summary (price, network, asset) plus `set MAGNETO_WALLET_PRIVATE_KEY to pay, or subscribe: https://magnetoapp.io/pricing`, exit 1. Nothing is signed. |
151
+ | Bare 402 (no `PAYMENT-REQUIRED`) | Today's `error: <detail> (HTTP 402)` line, exit 1. Desktop subscription / key budget / PAYG ceiling — not a machine challenge. |
152
+
153
+ `--max-payment 0` refuses every priced challenge. A second 402 after a signed retry is terminal (no loop).
154
+
155
+ **Never pass the wallet key as a flag. Never put it in `~/.magneto/credentials.json`.** Shells log argv; the credentials file is long-lived. Use a testnet key for CI and a tiny hot wallet only.
156
+
157
+ `MAGNETO_X402_NETWORK` overrides the allowlist (default Base Sepolia `eip155:84532` and Base mainnet `eip155:8453`). Pin Sepolia with `MAGNETO_X402_NETWORK=eip155:84532`.
158
+
139
159
  ## Scopes
140
160
 
141
161
  Keys are minted in the dashboard. Default scopes do **not** include `exec`.
package/dist/cli.js CHANGED
@@ -3,8 +3,9 @@ import { Command } from 'commander';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { createClient, MagnetoApiError, normalizeApiBase } from './client.js';
6
+ import { createClient, MagnetoApiError, PaymentCapError, normalizeApiBase, } from './client.js';
7
7
  import { resolveApiKey, writeCredentials } from './credentials.js';
8
+ import { DEFAULT_MAX_PAYMENT_USD, PRICING_URL, allowedNetworksFromEnv, formatShortAsset, formatUsdDisplay, readWalletPrivateKey, requirementAmountAtomic, } from './x402.js';
8
9
  import { ACCOUNT_SPEC, COMPUTERS_SPEC, FILES_SPEC, InvalidOutputFormatError, parseOutputFlag, RUN_GET_SPEC, RUNS_SPEC, SKILLS_SPEC, TEMPLATES_SPEC, UPTIME_SPEC, formatOutput, resolveOutputFormat, } from './format.js';
9
10
  import { openUrl as defaultOpenUrl } from './open-url.js';
10
11
  import { readPackageVersion } from './package-version.js';
@@ -33,7 +34,26 @@ export function handleError(err, io) {
33
34
  throw err;
34
35
  }
35
36
  const { stderr, exit } = resolveIo(io);
37
+ if (err instanceof PaymentCapError) {
38
+ stderr.write(`error: ${err.message}\n`);
39
+ return exit(1);
40
+ }
36
41
  if (err instanceof MagnetoApiError) {
42
+ if (err.status === 402 && err.paymentRequirements) {
43
+ const first = err.paymentRequirements.accepted?.[0];
44
+ const atomic = first ? requirementAmountAtomic(first) : undefined;
45
+ const price = atomic ? formatUsdDisplay(atomic) : '$0.00';
46
+ const bits = [`error: Payment required: ${price} (HTTP 402)`];
47
+ if (err.requestId)
48
+ bits.push(`request-id: ${err.requestId}`);
49
+ stderr.write(`${bits.join(' · ')}\n`);
50
+ if (first?.network)
51
+ stderr.write(`network: ${first.network}\n`);
52
+ if (first?.asset)
53
+ stderr.write(`asset: ${formatShortAsset(first.asset)}\n`);
54
+ stderr.write(`set MAGNETO_WALLET_PRIVATE_KEY to pay, or subscribe: ${PRICING_URL}\n`);
55
+ return exit(1);
56
+ }
37
57
  const bits = [`error: ${err.detail} (HTTP ${err.status})`];
38
58
  if (err.requestId)
39
59
  bits.push(`request-id: ${err.requestId}`);
@@ -112,6 +132,14 @@ function formatUsageBlob(value) {
112
132
  export function buildProgram(io) {
113
133
  const resolved = resolveIo(io);
114
134
  const { stdout, stderr, exit, env, openUrl } = resolved;
135
+ function parseMaxPayment(raw) {
136
+ const n = Number(raw);
137
+ if (!Number.isFinite(n) || n < 0) {
138
+ stderr.write(`error: invalid --max-payment ${raw}\n`);
139
+ exit(1);
140
+ }
141
+ return n;
142
+ }
115
143
  function requireClient() {
116
144
  const apiKey = resolveApiKey(env);
117
145
  if (!apiKey) {
@@ -120,10 +148,22 @@ export function buildProgram(io) {
120
148
  }
121
149
  const timeoutRaw = env.MAGNETO_TIMEOUT_MS;
122
150
  const parsedTimeout = timeoutRaw != null ? Number(timeoutRaw) : NaN;
151
+ const rawMax = program.opts().maxPayment;
152
+ const maxPaymentUsd = rawMax == null || rawMax === '' ? DEFAULT_MAX_PAYMENT_USD : Number(rawMax);
153
+ if (!Number.isFinite(maxPaymentUsd) || maxPaymentUsd < 0) {
154
+ stderr.write(`error: invalid --max-payment ${String(rawMax)}\n`);
155
+ return exit(1);
156
+ }
123
157
  return createClient({
124
158
  apiKey,
125
159
  baseUrl: env.MAGNETO_API_BASE ?? env.MAGNETO_BASE_URL,
126
160
  fetchImpl: resolved.fetchImpl,
161
+ walletPrivateKey: readWalletPrivateKey(env) ?? undefined,
162
+ maxPaymentUsd,
163
+ allowedNetworks: allowedNetworksFromEnv(env),
164
+ onPaid: ({ usd, tx }) => {
165
+ stderr.write(`paid $${usd} · tx ${tx}\n`);
166
+ },
127
167
  ...(Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? { timeoutMs: parsedTimeout } : {}),
128
168
  });
129
169
  }
@@ -153,7 +193,8 @@ export function buildProgram(io) {
153
193
  program
154
194
  .name('magneto')
155
195
  .description('Magneto CLI — public /api/v1 with sk_live_* keys')
156
- .version(readPackageVersion());
196
+ .version(readPackageVersion())
197
+ .option('--max-payment <usd>', 'Refuse x402 charges above this USD amount (default 0.10)', parseMaxPayment, DEFAULT_MAX_PAYMENT_USD);
157
198
  program.exitOverride((err) => {
158
199
  exit(typeof err.exitCode === 'number' ? err.exitCode : 1);
159
200
  });
@@ -658,7 +699,10 @@ function isEntrypoint() {
658
699
  if (!invoked)
659
700
  return false;
660
701
  try {
661
- return path.resolve(invoked) === fileURLToPath(import.meta.url);
702
+ // npm installs bins as SYMLINKS (node_modules/.bin/magneto dist/cli.js);
703
+ // path.resolve() does not follow them, so realpath both sides or the CLI
704
+ // silently no-ops on every installed invocation (0.1.0 regression).
705
+ return fs.realpathSync(invoked) === fs.realpathSync(fileURLToPath(import.meta.url));
662
706
  }
663
707
  catch {
664
708
  return false;
package/dist/client.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { type PaymentRequiredPayload } from './x402.js';
2
+ export type { PaymentRequiredPayload, PaymentRequirement } from './x402.js';
3
+ export { PaymentCapError } from './x402.js';
1
4
  export type MagnetoClientOptions = {
2
5
  apiKey: string;
3
6
  /** Origin or full API base. Accepts `https://magnetoapp.io` or `.../api/v1`. */
@@ -7,13 +10,26 @@ export type MagnetoClientOptions = {
7
10
  timeoutMs?: number;
8
11
  /** Injected so tests can assert 429 sleep without waiting. */
9
12
  sleepImpl?: (ms: number) => Promise<void>;
13
+ /** From `MAGNETO_WALLET_PRIVATE_KEY` only — never a flag or credentials.json. */
14
+ walletPrivateKey?: string;
15
+ /** From `--max-payment`. Default `$0.10`. */
16
+ maxPaymentUsd?: number;
17
+ /** From `MAGNETO_X402_NETWORK` or the default Base Sepolia + Base pair. */
18
+ allowedNetworks?: string[];
19
+ /** Fired after a successful paid retry when `PAYMENT-RESPONSE` decodes. */
20
+ onPaid?: (info: {
21
+ usd: string;
22
+ tx: string;
23
+ network: string;
24
+ }) => void;
10
25
  };
11
26
  export declare class MagnetoApiError extends Error {
12
27
  status: number;
13
28
  detail: string;
14
29
  retryAfter?: string;
15
30
  requestId?: string;
16
- constructor(status: number, detail: string, retryAfter?: string, requestId?: string);
31
+ paymentRequirements?: PaymentRequiredPayload;
32
+ constructor(status: number, detail: string, retryAfter?: string, requestId?: string, paymentRequirements?: PaymentRequiredPayload);
17
33
  }
18
34
  /**
19
35
  * Parse `Retry-After` as delta-seconds or HTTP-date.
package/dist/client.js CHANGED
@@ -1,18 +1,22 @@
1
1
  // Thin fetch client for Magneto public `/api/v1` (#110 / #440).
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { DEFAULT_ALLOWED_NETWORKS, DEFAULT_MAX_PAYMENT_USD, PaymentCapError, atomicUsdcToUsd, exceedsMaxPayment, pickRequirement, requirementAmountAtomic, signPayment, toBase64Url, tryDecodePaymentRequired, tryDecodePaymentResponse, } from './x402.js';
5
+ export { PaymentCapError } from './x402.js';
4
6
  export class MagnetoApiError extends Error {
5
7
  status;
6
8
  detail;
7
9
  retryAfter;
8
10
  requestId;
9
- constructor(status, detail, retryAfter, requestId) {
11
+ paymentRequirements;
12
+ constructor(status, detail, retryAfter, requestId, paymentRequirements) {
10
13
  super(detail);
11
14
  this.name = 'MagnetoApiError';
12
15
  this.status = status;
13
16
  this.detail = detail;
14
17
  this.retryAfter = retryAfter;
15
18
  this.requestId = requestId;
19
+ this.paymentRequirements = paymentRequirements;
16
20
  }
17
21
  }
18
22
  const RETRY_AFTER_DEFAULT_MS = 1000;
@@ -82,11 +86,14 @@ export function createClient(opts) {
82
86
  catch {
83
87
  // ignore non-JSON error bodies
84
88
  }
85
- return new MagnetoApiError(res.status, detail, res.headers.get('Retry-After') ?? undefined, res.headers.get('x-request-id') ?? undefined);
89
+ return new MagnetoApiError(res.status, detail, res.headers.get('Retry-After') ?? undefined, res.headers.get('x-request-id') ?? undefined, tryDecodePaymentRequired(res.headers.get('PAYMENT-REQUIRED')));
86
90
  }
87
91
  async function send(sendOpts) {
88
92
  const url = resolveUrl(sendOpts.path);
89
93
  let attempt = 0;
94
+ let paymentAttempted = false;
95
+ let paymentSignature;
96
+ let signedRequirement;
90
97
  for (;;) {
91
98
  const headers = {
92
99
  Accept: 'application/json',
@@ -95,6 +102,9 @@ export function createClient(opts) {
95
102
  if (sendOpts.auth !== false) {
96
103
  headers.Authorization = `Bearer ${opts.apiKey}`;
97
104
  }
105
+ if (paymentSignature) {
106
+ headers['PAYMENT-SIGNATURE'] = paymentSignature;
107
+ }
98
108
  let payload;
99
109
  if (sendOpts.body instanceof FormData) {
100
110
  payload = sendOpts.body;
@@ -120,6 +130,39 @@ export function createClient(opts) {
120
130
  attempt += 1;
121
131
  continue;
122
132
  }
133
+ if (res.status === 402 && !paymentAttempted && opts.walletPrivateKey) {
134
+ const header = res.headers.get('PAYMENT-REQUIRED');
135
+ if (header) {
136
+ const decoded = tryDecodePaymentRequired(header);
137
+ const picked = decoded
138
+ ? pickRequirement(decoded.accepted, opts.allowedNetworks ?? DEFAULT_ALLOWED_NETWORKS)
139
+ : undefined;
140
+ if (picked) {
141
+ const maxUsd = opts.maxPaymentUsd ?? DEFAULT_MAX_PAYMENT_USD;
142
+ const atomic = requirementAmountAtomic(picked);
143
+ if (exceedsMaxPayment(atomic, maxUsd)) {
144
+ throw new PaymentCapError(atomic ? atomicUsdcToUsd(atomic) : '0.00', maxUsd);
145
+ }
146
+ paymentSignature = toBase64Url(await signPayment(picked, opts.walletPrivateKey));
147
+ signedRequirement = picked;
148
+ paymentAttempted = true;
149
+ await res.arrayBuffer().catch(() => undefined);
150
+ continue;
151
+ }
152
+ }
153
+ }
154
+ if (res.ok && paymentAttempted && opts.onPaid) {
155
+ const paid = tryDecodePaymentResponse(res.headers.get('PAYMENT-RESPONSE'));
156
+ if (paid) {
157
+ const atomic = paid.amount ??
158
+ (signedRequirement ? requirementAmountAtomic(signedRequirement) : undefined);
159
+ opts.onPaid({
160
+ usd: atomic ? atomicUsdcToUsd(atomic) : '0.00',
161
+ tx: paid.transaction,
162
+ network: paid.network,
163
+ });
164
+ }
165
+ }
123
166
  return res;
124
167
  }
125
168
  }
package/dist/x402.d.ts ADDED
@@ -0,0 +1,104 @@
1
+ export declare const DEFAULT_ALLOWED_NETWORKS: readonly ["eip155:84532", "eip155:8453"];
2
+ export declare const DEFAULT_MAX_PAYMENT_USD = 0.1;
3
+ export declare const PRICING_URL = "https://magnetoapp.io/pricing";
4
+ export type PaymentRequirement = {
5
+ scheme?: string;
6
+ network?: string;
7
+ asset?: string;
8
+ amount?: string;
9
+ maxAmountRequired?: string;
10
+ payTo?: string;
11
+ maxTimeoutSeconds?: number;
12
+ extra?: {
13
+ name?: string;
14
+ version?: string;
15
+ facilitatorAddress?: string;
16
+ spenderAddress?: string;
17
+ };
18
+ resource?: unknown;
19
+ };
20
+ export type PaymentRequiredPayload = {
21
+ x402Version: 2;
22
+ accepted: PaymentRequirement[];
23
+ error?: string;
24
+ };
25
+ export type PaymentResponsePayload = {
26
+ x402Version: 2;
27
+ success: boolean;
28
+ transaction: string;
29
+ network: string;
30
+ payer?: string;
31
+ amount?: string;
32
+ };
33
+ export type Eip3009Authorization = {
34
+ from: string;
35
+ to: string;
36
+ value: string;
37
+ validAfter: string;
38
+ validBefore: string;
39
+ nonce: string;
40
+ };
41
+ export type Permit2Authorization = {
42
+ permitted: {
43
+ token: string;
44
+ amount: string;
45
+ };
46
+ from: string;
47
+ spender: string;
48
+ nonce: string;
49
+ deadline: string;
50
+ witness: {
51
+ to: string;
52
+ facilitator: string;
53
+ validAfter: string;
54
+ };
55
+ };
56
+ export type SignedPaymentEnvelope = {
57
+ x402Version: 2;
58
+ accepted: PaymentRequirement;
59
+ payload: {
60
+ signature: string;
61
+ authorization: Eip3009Authorization;
62
+ } | {
63
+ signature: string;
64
+ permit2Authorization: Permit2Authorization;
65
+ };
66
+ };
67
+ export declare class PaymentCapError extends Error {
68
+ readonly amountUsd: string;
69
+ readonly maxPaymentUsd: string;
70
+ constructor(amountUsd: string, maxPaymentUsd: number | string);
71
+ }
72
+ export declare function toBase64Url(json: unknown): string;
73
+ export declare function fromBase64Url(header: string): unknown;
74
+ export declare function decodePaymentRequired(header: string): PaymentRequiredPayload;
75
+ export declare function tryDecodePaymentRequired(header: string | null | undefined): PaymentRequiredPayload | undefined;
76
+ export declare function decodePaymentResponse(header: string): PaymentResponsePayload;
77
+ export declare function tryDecodePaymentResponse(header: string | null | undefined): PaymentResponsePayload | undefined;
78
+ export declare function pickRequirement(accepted: PaymentRequirement[] | undefined, allowedNetworks: readonly string[]): PaymentRequirement | undefined;
79
+ /**
80
+ * USD → USDC atomic units (6 decimals). `$0.01 → "10000"`.
81
+ * Rounds to the nearest cent first so we never keep a float micros value.
82
+ */
83
+ export declare function usdToAtomicUsdc(usd: number): string;
84
+ /**
85
+ * USDC atomic units → a decimal USD string without float math.
86
+ * `"10000"` → `"0.01"`.
87
+ */
88
+ export declare function atomicUsdcToUsd(atomic: string): string;
89
+ export declare function formatUsdDisplay(atomic: string): string;
90
+ export declare function formatMaxPaymentUsd(usd: number): string;
91
+ /**
92
+ * `true` iff the challenge USD is strictly greater than the cap.
93
+ * Compare in atomic USDC units so `$0.005` is a real cap, not rounded to a cent.
94
+ */
95
+ export declare function exceedsMaxPayment(amountAtomic: string | undefined, maxPaymentUsd: number): boolean;
96
+ export declare function requirementAmountAtomic(requirement: PaymentRequirement): string | undefined;
97
+ export declare function allowedNetworksFromEnv(env?: NodeJS.ProcessEnv): string[];
98
+ export declare function readWalletPrivateKey(env?: NodeJS.ProcessEnv): string | null;
99
+ export declare function formatShortAsset(asset: string): string;
100
+ export type SignPaymentOpts = {
101
+ nowSeconds?: number;
102
+ nonceBytes?: Uint8Array;
103
+ };
104
+ export declare function signPayment(requirement: PaymentRequirement, privateKey: string, opts?: SignPaymentOpts): Promise<SignedPaymentEnvelope>;
package/dist/x402.js ADDED
@@ -0,0 +1,330 @@
1
+ // x402 v2 payer: decode PAYMENT-* headers, cap-check, EIP-3009 / Permit2 sign.
2
+ // Wallet material stays in process env — never interpolate keys into messages.
3
+ import { randomBytes } from 'node:crypto';
4
+ import { privateKeyToAccount } from 'viem/accounts';
5
+ const USDC_DECIMALS = 6;
6
+ const ATOMIC_PER_USD = 10 ** USDC_DECIMALS; // 1_000_000
7
+ const ATOMIC_PER_CENT = ATOMIC_PER_USD / 100; // 10_000
8
+ const PERMIT2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
9
+ const UPTO_PERMIT2_PROXY = '0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002';
10
+ const WALLET_KEY_RE = /^0x[0-9a-fA-F]{64}$/;
11
+ const INVALID_WALLET_KEY = 'Invalid MAGNETO_WALLET_PRIVATE_KEY';
12
+ export const DEFAULT_ALLOWED_NETWORKS = ['eip155:84532', 'eip155:8453'];
13
+ export const DEFAULT_MAX_PAYMENT_USD = 0.1;
14
+ export const PRICING_URL = 'https://magnetoapp.io/pricing';
15
+ export class PaymentCapError extends Error {
16
+ amountUsd;
17
+ maxPaymentUsd;
18
+ constructor(amountUsd, maxPaymentUsd) {
19
+ const cap = typeof maxPaymentUsd === 'number' ? formatMaxPaymentUsd(maxPaymentUsd) : maxPaymentUsd;
20
+ super(`payment of $${amountUsd} exceeds --max-payment $${cap}`);
21
+ this.name = 'PaymentCapError';
22
+ this.amountUsd = amountUsd;
23
+ this.maxPaymentUsd = cap;
24
+ }
25
+ }
26
+ export function toBase64Url(json) {
27
+ return Buffer.from(JSON.stringify(json), 'utf8').toString('base64url');
28
+ }
29
+ export function fromBase64Url(header) {
30
+ return JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
31
+ }
32
+ function looksLikeV1(payload) {
33
+ if (!payload || typeof payload !== 'object')
34
+ return false;
35
+ const obj = payload;
36
+ if (obj.x402Version === 1)
37
+ return true;
38
+ if (typeof obj.maxAmountRequired === 'string' && obj.amount === undefined) {
39
+ return true;
40
+ }
41
+ return false;
42
+ }
43
+ export function decodePaymentRequired(header) {
44
+ let parsed;
45
+ try {
46
+ parsed = fromBase64Url(header.trim());
47
+ }
48
+ catch {
49
+ throw new Error('invalid PAYMENT-REQUIRED header');
50
+ }
51
+ if (!parsed || typeof parsed !== 'object' || looksLikeV1(parsed)) {
52
+ throw new Error('invalid PAYMENT-REQUIRED header');
53
+ }
54
+ const obj = parsed;
55
+ if (obj.x402Version !== 2) {
56
+ throw new Error('invalid PAYMENT-REQUIRED header');
57
+ }
58
+ if (!Array.isArray(obj.accepted)) {
59
+ throw new Error('invalid PAYMENT-REQUIRED header');
60
+ }
61
+ return obj;
62
+ }
63
+ export function tryDecodePaymentRequired(header) {
64
+ if (header == null || header.trim() === '')
65
+ return undefined;
66
+ try {
67
+ return decodePaymentRequired(header);
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ export function decodePaymentResponse(header) {
74
+ let parsed;
75
+ try {
76
+ parsed = fromBase64Url(header.trim());
77
+ }
78
+ catch {
79
+ throw new Error('invalid PAYMENT-RESPONSE header');
80
+ }
81
+ if (!parsed || typeof parsed !== 'object') {
82
+ throw new Error('invalid PAYMENT-RESPONSE header');
83
+ }
84
+ const obj = parsed;
85
+ if (obj.x402Version !== 2 || typeof obj.transaction !== 'string' || typeof obj.network !== 'string') {
86
+ throw new Error('invalid PAYMENT-RESPONSE header');
87
+ }
88
+ return obj;
89
+ }
90
+ export function tryDecodePaymentResponse(header) {
91
+ if (header == null || header.trim() === '')
92
+ return undefined;
93
+ try {
94
+ return decodePaymentResponse(header);
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ }
100
+ export function pickRequirement(accepted, allowedNetworks) {
101
+ if (!accepted?.length)
102
+ return undefined;
103
+ const allow = new Set(allowedNetworks);
104
+ return accepted.find((entry) => typeof entry.network === 'string' && allow.has(entry.network));
105
+ }
106
+ /**
107
+ * USD → USDC atomic units (6 decimals). `$0.01 → "10000"`.
108
+ * Rounds to the nearest cent first so we never keep a float micros value.
109
+ */
110
+ export function usdToAtomicUsdc(usd) {
111
+ if (!Number.isFinite(usd) || usd < 0) {
112
+ throw new Error('Invalid USD amount');
113
+ }
114
+ const cents = Math.round(usd * 100);
115
+ return String(cents * ATOMIC_PER_CENT);
116
+ }
117
+ /**
118
+ * USDC atomic units → a decimal USD string without float math.
119
+ * `"10000"` → `"0.01"`.
120
+ */
121
+ export function atomicUsdcToUsd(atomic) {
122
+ const micros = BigInt(atomic);
123
+ const hundred = BigInt(100);
124
+ const cents = micros / BigInt(ATOMIC_PER_CENT);
125
+ const dollars = cents / hundred;
126
+ const remainder = cents % hundred;
127
+ return `${dollars}.${remainder.toString().padStart(2, '0')}`;
128
+ }
129
+ export function formatUsdDisplay(atomic) {
130
+ return `$${atomicUsdcToUsd(atomic)}`;
131
+ }
132
+ export function formatMaxPaymentUsd(usd) {
133
+ const fixed2 = usd.toFixed(2);
134
+ if (Number(fixed2) === usd)
135
+ return fixed2;
136
+ return String(usd);
137
+ }
138
+ /**
139
+ * `true` iff the challenge USD is strictly greater than the cap.
140
+ * Compare in atomic USDC units so `$0.005` is a real cap, not rounded to a cent.
141
+ */
142
+ export function exceedsMaxPayment(amountAtomic, maxPaymentUsd) {
143
+ let challenge;
144
+ try {
145
+ challenge = BigInt(amountAtomic ?? '');
146
+ }
147
+ catch {
148
+ return true;
149
+ }
150
+ if (challenge < 0n)
151
+ return true;
152
+ if (!Number.isFinite(maxPaymentUsd) || maxPaymentUsd < 0)
153
+ return true;
154
+ const cap = BigInt(Math.round(maxPaymentUsd * ATOMIC_PER_USD));
155
+ return challenge > cap;
156
+ }
157
+ export function requirementAmountAtomic(requirement) {
158
+ return requirement.amount ?? requirement.maxAmountRequired;
159
+ }
160
+ export function allowedNetworksFromEnv(env = process.env) {
161
+ const raw = env.MAGNETO_X402_NETWORK?.trim();
162
+ if (!raw)
163
+ return [...DEFAULT_ALLOWED_NETWORKS];
164
+ const parts = raw
165
+ .split(',')
166
+ .map((s) => s.trim())
167
+ .filter((s) => s.length > 0);
168
+ return parts.length > 0 ? parts : [...DEFAULT_ALLOWED_NETWORKS];
169
+ }
170
+ export function readWalletPrivateKey(env = process.env) {
171
+ const raw = env.MAGNETO_WALLET_PRIVATE_KEY?.trim();
172
+ if (!raw)
173
+ return null;
174
+ if (!WALLET_KEY_RE.test(raw)) {
175
+ throw new Error(INVALID_WALLET_KEY);
176
+ }
177
+ return raw;
178
+ }
179
+ export function formatShortAsset(asset) {
180
+ if (asset.startsWith('0x') && asset.length >= 12) {
181
+ return `${asset.slice(0, 8)}…${asset.slice(-4)}`;
182
+ }
183
+ return asset;
184
+ }
185
+ function resolveNonceHex(nonceBytes) {
186
+ const bytes = nonceBytes ?? randomBytes(32);
187
+ if (bytes.length !== 32) {
188
+ throw new Error('nonceBytes must be 32 bytes');
189
+ }
190
+ return `0x${Buffer.from(bytes).toString('hex')}`;
191
+ }
192
+ export async function signPayment(requirement, privateKey, opts) {
193
+ const key = privateKey.trim();
194
+ if (!WALLET_KEY_RE.test(key)) {
195
+ throw new Error(INVALID_WALLET_KEY);
196
+ }
197
+ const chainId = Number(requirement.network?.split(':')[1]);
198
+ const value = requirementAmountAtomic(requirement);
199
+ if (!Number.isInteger(chainId) || !requirement.asset || !requirement.payTo || !value) {
200
+ throw new Error('payment requirement missing network/asset/payTo/amount');
201
+ }
202
+ let account;
203
+ try {
204
+ account = privateKeyToAccount(key);
205
+ }
206
+ catch {
207
+ throw new Error(INVALID_WALLET_KEY);
208
+ }
209
+ const nowSeconds = opts?.nowSeconds ?? Math.floor(Date.now() / 1000);
210
+ const nonceHex = resolveNonceHex(opts?.nonceBytes);
211
+ const validBefore = String(nowSeconds + (requirement.maxTimeoutSeconds ?? 600));
212
+ try {
213
+ if (requirement.scheme === 'upto') {
214
+ const facilitator = requirement.extra?.facilitatorAddress?.trim();
215
+ if (!facilitator) {
216
+ throw new Error('upto challenge missing extra.facilitatorAddress');
217
+ }
218
+ const spender = (requirement.extra?.spenderAddress?.trim() ||
219
+ UPTO_PERMIT2_PROXY);
220
+ const permit2Authorization = {
221
+ permitted: {
222
+ token: requirement.asset,
223
+ amount: value,
224
+ },
225
+ from: account.address,
226
+ spender,
227
+ nonce: BigInt(nonceHex).toString(),
228
+ deadline: validBefore,
229
+ witness: {
230
+ to: requirement.payTo,
231
+ facilitator,
232
+ validAfter: '0',
233
+ },
234
+ };
235
+ const signature = await account.signTypedData({
236
+ domain: {
237
+ name: 'Permit2',
238
+ chainId,
239
+ verifyingContract: PERMIT2,
240
+ },
241
+ types: {
242
+ PermitWitnessTransferFrom: [
243
+ { name: 'permitted', type: 'TokenPermissions' },
244
+ { name: 'spender', type: 'address' },
245
+ { name: 'nonce', type: 'uint256' },
246
+ { name: 'deadline', type: 'uint256' },
247
+ { name: 'witness', type: 'Witness' },
248
+ ],
249
+ TokenPermissions: [
250
+ { name: 'token', type: 'address' },
251
+ { name: 'amount', type: 'uint256' },
252
+ ],
253
+ Witness: [
254
+ { name: 'to', type: 'address' },
255
+ { name: 'facilitator', type: 'address' },
256
+ { name: 'validAfter', type: 'uint256' },
257
+ ],
258
+ },
259
+ primaryType: 'PermitWitnessTransferFrom',
260
+ message: {
261
+ permitted: {
262
+ token: permit2Authorization.permitted.token,
263
+ amount: BigInt(value),
264
+ },
265
+ spender,
266
+ nonce: BigInt(nonceHex),
267
+ deadline: BigInt(validBefore),
268
+ witness: {
269
+ to: permit2Authorization.witness.to,
270
+ facilitator: permit2Authorization.witness.facilitator,
271
+ validAfter: BigInt(0),
272
+ },
273
+ },
274
+ });
275
+ return {
276
+ x402Version: 2,
277
+ accepted: requirement,
278
+ payload: { signature, permit2Authorization },
279
+ };
280
+ }
281
+ const authorization = {
282
+ from: account.address,
283
+ to: requirement.payTo,
284
+ value,
285
+ validAfter: '0',
286
+ validBefore,
287
+ nonce: nonceHex,
288
+ };
289
+ const signature = await account.signTypedData({
290
+ domain: {
291
+ name: requirement.extra?.name ?? 'USDC',
292
+ version: requirement.extra?.version ?? '2',
293
+ chainId,
294
+ verifyingContract: requirement.asset,
295
+ },
296
+ types: {
297
+ TransferWithAuthorization: [
298
+ { name: 'from', type: 'address' },
299
+ { name: 'to', type: 'address' },
300
+ { name: 'value', type: 'uint256' },
301
+ { name: 'validAfter', type: 'uint256' },
302
+ { name: 'validBefore', type: 'uint256' },
303
+ { name: 'nonce', type: 'bytes32' },
304
+ ],
305
+ },
306
+ primaryType: 'TransferWithAuthorization',
307
+ message: {
308
+ from: authorization.from,
309
+ to: authorization.to,
310
+ value: BigInt(value),
311
+ validAfter: BigInt(authorization.validAfter),
312
+ validBefore: BigInt(authorization.validBefore),
313
+ nonce: authorization.nonce,
314
+ },
315
+ });
316
+ return {
317
+ x402Version: 2,
318
+ accepted: requirement,
319
+ payload: { signature, authorization },
320
+ };
321
+ }
322
+ catch (err) {
323
+ if (err instanceof Error && err.message === INVALID_WALLET_KEY)
324
+ throw err;
325
+ if (err instanceof Error && err.message === 'upto challenge missing extra.facilitatorAddress') {
326
+ throw err;
327
+ }
328
+ throw new Error(INVALID_WALLET_KEY);
329
+ }
330
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magnetoagents/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Magneto CLI — drive computers via sk_live_* keys against /api/v1",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc -p tsconfig.json",
16
+ "pretest": "npm run build",
16
17
  "test": "vitest run",
17
18
  "typecheck": "tsc -p tsconfig.json --noEmit"
18
19
  },
@@ -29,7 +30,8 @@
29
30
  "access": "public"
30
31
  },
31
32
  "dependencies": {
32
- "commander": "^13.1.0"
33
+ "commander": "^13.1.0",
34
+ "viem": "^2.55.19"
33
35
  },
34
36
  "devDependencies": {
35
37
  "@types/node": "^20.17.0",