run402 4.119.0 → 4.121.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.
Files changed (81) hide show
  1. package/core-dist/wallet-auth.js +77 -38
  2. package/core-dist/wallet.js +21 -0
  3. package/lib/ci.mjs +1 -1
  4. package/lib/command-manifest.mjs +3 -0
  5. package/lib/config.mjs +8 -0
  6. package/lib/contacts.mjs +3 -3
  7. package/lib/contracts.mjs +126 -15
  8. package/lib/orgs.mjs +18 -8
  9. package/lib/session-proofs.mjs +8 -4
  10. package/lib/subscriptions.mjs +2 -2
  11. package/lib/wallets.mjs +41 -1
  12. package/lib/webhook-secret.mjs +1 -1
  13. package/package.json +2 -1
  14. package/sdk/core-dist/wallet-auth.js +77 -38
  15. package/sdk/core-dist/wallet.js +21 -0
  16. package/sdk/dist/credentials.d.ts +15 -1
  17. package/sdk/dist/credentials.d.ts.map +1 -1
  18. package/sdk/dist/errors.d.ts +14 -0
  19. package/sdk/dist/errors.d.ts.map +1 -1
  20. package/sdk/dist/errors.js +22 -0
  21. package/sdk/dist/errors.js.map +1 -1
  22. package/sdk/dist/index.d.ts +1 -1
  23. package/sdk/dist/index.d.ts.map +1 -1
  24. package/sdk/dist/index.js +1 -1
  25. package/sdk/dist/index.js.map +1 -1
  26. package/sdk/dist/namespaces/contracts.d.ts +47 -0
  27. package/sdk/dist/namespaces/contracts.d.ts.map +1 -1
  28. package/sdk/dist/namespaces/contracts.js +36 -0
  29. package/sdk/dist/namespaces/contracts.js.map +1 -1
  30. package/sdk/dist/namespaces/org.d.ts.map +1 -1
  31. package/sdk/dist/namespaces/org.js +5 -3
  32. package/sdk/dist/namespaces/org.js.map +1 -1
  33. package/sdk/dist/namespaces/org.types.d.ts +5 -6
  34. package/sdk/dist/namespaces/org.types.d.ts.map +1 -1
  35. package/sdk/dist/node/_paid-stack.d.ts +18 -0
  36. package/sdk/dist/node/_paid-stack.d.ts.map +1 -1
  37. package/sdk/dist/node/_paid-stack.js +19 -0
  38. package/sdk/dist/node/_paid-stack.js.map +1 -1
  39. package/sdk/dist/node/ci.d.ts +1 -1
  40. package/sdk/dist/node/ci.d.ts.map +1 -1
  41. package/sdk/dist/node/ci.js +8 -6
  42. package/sdk/dist/node/ci.js.map +1 -1
  43. package/sdk/dist/node/credentials.d.ts.map +1 -1
  44. package/sdk/dist/node/credentials.js +5 -4
  45. package/sdk/dist/node/credentials.js.map +1 -1
  46. package/sdk/dist/node/index.d.ts +2 -1
  47. package/sdk/dist/node/index.d.ts.map +1 -1
  48. package/sdk/dist/node/index.js +2 -1
  49. package/sdk/dist/node/index.js.map +1 -1
  50. package/sdk/dist/node/org-adopt.d.ts +1 -1
  51. package/sdk/dist/node/org-adopt.d.ts.map +1 -1
  52. package/sdk/dist/node/org-adopt.js +9 -7
  53. package/sdk/dist/node/org-adopt.js.map +1 -1
  54. package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
  55. package/sdk/dist/node/paid-fetch.js +6 -4
  56. package/sdk/dist/node/paid-fetch.js.map +1 -1
  57. package/sdk/dist/node/signers/index.d.ts +26 -0
  58. package/sdk/dist/node/signers/index.d.ts.map +1 -0
  59. package/sdk/dist/node/signers/index.js +35 -0
  60. package/sdk/dist/node/signers/index.js.map +1 -0
  61. package/sdk/dist/node/signers/kms.d.ts +54 -0
  62. package/sdk/dist/node/signers/kms.d.ts.map +1 -0
  63. package/sdk/dist/node/signers/kms.js +236 -0
  64. package/sdk/dist/node/signers/kms.js.map +1 -0
  65. package/sdk/dist/node/wallets-send.d.ts +62 -0
  66. package/sdk/dist/node/wallets-send.d.ts.map +1 -0
  67. package/sdk/dist/node/wallets-send.js +149 -0
  68. package/sdk/dist/node/wallets-send.js.map +1 -0
  69. package/sdk/dist/node/wallets.d.ts +33 -5
  70. package/sdk/dist/node/wallets.d.ts.map +1 -1
  71. package/sdk/dist/node/wallets.js +78 -12
  72. package/sdk/dist/node/wallets.js.map +1 -1
  73. package/sdk/dist/scoped.d.ts +4 -1
  74. package/sdk/dist/scoped.d.ts.map +1 -1
  75. package/sdk/dist/scoped.js +9 -0
  76. package/sdk/dist/scoped.js.map +1 -1
  77. package/sdk/dist/secret-gate.d.ts +1 -1
  78. package/sdk/dist/secret-gate.d.ts.map +1 -1
  79. package/sdk/dist/secret-gate.js +7 -2
  80. package/sdk/dist/secret-gate.js.map +1 -1
  81. package/vault-surface.json +1 -1
@@ -6,7 +6,7 @@ import { randomBytes } from "node:crypto";
6
6
  import { secp256k1 } from "@noble/curves/secp256k1.js";
7
7
  import { keccak_256 } from "@noble/hashes/sha3.js";
8
8
  import { bytesToHex } from "@noble/hashes/utils.js";
9
- import { readWallet } from "./wallet.js";
9
+ import { isSignerWallet, readWallet } from "./wallet.js";
10
10
  import { getApiBase } from "./config.js";
11
11
  /**
12
12
  * EIP-55 mixed-case checksum encoding.
@@ -57,6 +57,33 @@ function personalSign(privateKeyHex, address, message) {
57
57
  const vHex = (recovery + 27).toString(16).padStart(2, "0");
58
58
  return "0x" + r + s + vHex;
59
59
  }
60
+ export function prepareSIWx(opts) {
61
+ const message = formatSIWEMessage(opts, opts.address);
62
+ return {
63
+ message,
64
+ finish(signature) {
65
+ const payload = {
66
+ domain: opts.domain,
67
+ address: toChecksumAddress(opts.address),
68
+ statement: opts.statement,
69
+ uri: opts.uri,
70
+ version: opts.version ?? "1",
71
+ chainId: payloadChainId(opts.chainId),
72
+ type: opts.type ?? "eip191",
73
+ nonce: opts.nonce,
74
+ issuedAt: opts.issuedAt,
75
+ expirationTime: opts.expirationTime,
76
+ signature,
77
+ };
78
+ if (opts.resources !== undefined) {
79
+ payload.resources = opts.resources;
80
+ }
81
+ return {
82
+ "SIGN-IN-WITH-X": Buffer.from(JSON.stringify(payload)).toString("base64"),
83
+ };
84
+ },
85
+ };
86
+ }
60
87
  /**
61
88
  * Format an EIP-4361 (SIWE) message. Must be byte-for-byte compatible
62
89
  * with the `siwe` library's message format used server-side for verification.
@@ -85,29 +112,54 @@ export function formatSIWEMessage(opts, address) {
85
112
  }
86
113
  return lines.join("\n");
87
114
  }
115
+ /** SIWX headers signed with a local private key (synchronous). */
88
116
  export function buildSIWxAuthHeaders(opts) {
89
- const message = formatSIWEMessage(opts, opts.wallet.address);
90
- const signature = personalSign(opts.wallet.privateKey, opts.wallet.address, message);
91
- const payload = {
92
- domain: opts.domain,
93
- address: toChecksumAddress(opts.wallet.address),
94
- statement: opts.statement,
95
- uri: opts.uri,
96
- version: opts.version ?? "1",
97
- chainId: payloadChainId(opts.chainId),
98
- type: opts.type ?? "eip191",
99
- nonce: opts.nonce,
100
- issuedAt: opts.issuedAt,
101
- expirationTime: opts.expirationTime,
102
- signature,
103
- };
104
- if (opts.resources !== undefined) {
105
- payload.resources = opts.resources;
117
+ if (!opts.wallet.privateKey) {
118
+ throw new SignerWalletError();
106
119
  }
120
+ const prepared = prepareSIWx({ ...opts, address: opts.wallet.address });
121
+ return prepared.finish(personalSign(opts.wallet.privateKey, opts.wallet.address, prepared.message));
122
+ }
123
+ /**
124
+ * Thrown where a synchronous signature is required but the wallet signs
125
+ * through a signer (`wallet.json` `signer`). Callers that can wait use
126
+ * `getWalletAuthHeadersAsync` with the SDK's signer instead.
127
+ */
128
+ export class SignerWalletError extends Error {
129
+ code = "SIGNER_WALLET_ASYNC";
130
+ constructor() {
131
+ super("This wallet signs through a signer (wallet.json 'signer'), which this synchronous path cannot use.");
132
+ this.name = "SignerWalletError";
133
+ }
134
+ }
135
+ function walletSIWxOptions(path) {
136
+ const apiBase = getApiBase();
137
+ const url = new URL(apiBase);
138
+ const now = new Date();
107
139
  return {
108
- "SIGN-IN-WITH-X": Buffer.from(JSON.stringify(payload)).toString("base64"),
140
+ domain: url.hostname,
141
+ uri: `${apiBase}${path}`,
142
+ statement: "Sign in to Run402",
143
+ chainId: "eip155:84532",
144
+ nonce: randomBytes(16).toString("hex"),
145
+ issuedAt: now.toISOString(),
146
+ expirationTime: new Date(now.getTime() + 5 * 60 * 1000).toISOString(),
109
147
  };
110
148
  }
149
+ /**
150
+ * SIWX headers for any wallet: a key-backed wallet signs locally; a
151
+ * signer-backed one through `signerFor(wallet)`. Null when no wallet exists.
152
+ */
153
+ export async function getWalletAuthHeadersAsync(path, walletPath, signerFor) {
154
+ const localWallet = readWallet(walletPath);
155
+ if (!localWallet || !localWallet.address)
156
+ return null;
157
+ if (!isSignerWallet(localWallet))
158
+ return getWalletAuthHeaders(path, walletPath);
159
+ const prepared = prepareSIWx({ ...walletSIWxOptions(path), address: localWallet.address });
160
+ const sign = await signerFor(localWallet);
161
+ return prepared.finish(await sign(prepared.message));
162
+ }
111
163
  /**
112
164
  * Get SIWX auth headers for the Run402 API.
113
165
  * Returns null if no local wallet is configured.
@@ -121,26 +173,13 @@ export function getWalletAuthHeaders(path, walletPath) {
121
173
  // this helper returns SIWxAuthHeaders | null. Re-throw so callers above
122
174
  // the CLI's wrapper (e.g. SDK paid-fetch) can decide whether to swallow it.
123
175
  const localWallet = readWallet(walletPath);
124
- if (!localWallet || !localWallet.address || !localWallet.privateKey)
176
+ if (!localWallet || !localWallet.address)
125
177
  return null;
126
- const apiBase = getApiBase();
127
- const url = new URL(apiBase);
128
- const domain = url.hostname;
129
- const uri = `${apiBase}${path}`;
130
- const nonce = randomBytes(16).toString("hex");
131
- const now = new Date();
132
- const issuedAt = now.toISOString();
133
- const expirationTime = new Date(now.getTime() + 5 * 60 * 1000).toISOString();
134
- return buildSIWxAuthHeaders({
135
- wallet: localWallet,
136
- domain,
137
- uri,
138
- statement: "Sign in to Run402",
139
- chainId: "eip155:84532",
140
- nonce,
141
- issuedAt,
142
- expirationTime,
143
- });
178
+ if (isSignerWallet(localWallet))
179
+ throw new SignerWalletError();
180
+ if (!localWallet.privateKey)
181
+ return null;
182
+ return buildSIWxAuthHeaders({ wallet: localWallet, ...walletSIWxOptions(path) });
144
183
  }
145
184
  function messageChainId(chainId) {
146
185
  if (typeof chainId === "number")
@@ -6,6 +6,12 @@ import { getWalletPath } from "./config.js";
6
6
  const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/;
7
7
  // 0x-prefixed 64-hex secp256k1 private key (32 bytes).
8
8
  const PRIVATE_KEY_RE = /^0x[a-fA-F0-9]{64}$/;
9
+ // arn:aws:kms:<region>:<account>:key/<id>
10
+ export const KMS_KEY_ARN_RE = /^arn:aws[a-z-]*:kms:([a-z0-9-]+):\d{12}:key\/[A-Za-z0-9-]+$/;
11
+ /** True when the wallet signs through a signer instead of a local key. */
12
+ export function isSignerWallet(wallet) {
13
+ return wallet.signer !== undefined;
14
+ }
9
15
  /**
10
16
  * Load the agent wallet from disk.
11
17
  *
@@ -78,6 +84,21 @@ export function readWallet(path) {
78
84
  throw new Error("wallet.json missing valid 'address' (expected 0x-prefixed 40-hex string). " +
79
85
  "Back up the file and run 'run402 init' to recreate it.");
80
86
  }
87
+ if (data.privateKey !== undefined && data.signer !== undefined) {
88
+ throw new Error(`${p} has both 'privateKey' and 'signer'; a wallet is backed by exactly one. ` +
89
+ "Remove the one that does not belong to this wallet.");
90
+ }
91
+ if (data.signer !== undefined) {
92
+ const signer = data.signer;
93
+ if (!signer || typeof signer !== "object" || signer.kind !== "aws_kms" ||
94
+ typeof signer.key_arn !== "string" || !KMS_KEY_ARN_RE.test(signer.key_arn) ||
95
+ typeof signer.region !== "string" || signer.region.length === 0 ||
96
+ (signer.aws_profile !== undefined && (typeof signer.aws_profile !== "string" || signer.aws_profile.length === 0))) {
97
+ throw new Error(`${p} has a malformed 'signer' (expected { kind: "aws_kms", key_arn, region, aws_profile? }). ` +
98
+ "Re-import the wallet with 'run402 wallets import <name> --kms <key-arn>'.");
99
+ }
100
+ return data;
101
+ }
81
102
  if (typeof data.privateKey !== "string" || !PRIVATE_KEY_RE.test(data.privateKey)) {
82
103
  throw new Error("wallet.json missing valid 'privateKey' (expected 0x-prefixed 64-hex string). " +
83
104
  "Back up the file and run 'run402 init' to recreate it.");
package/lib/ci.mjs CHANGED
@@ -325,7 +325,7 @@ async function linkGithub(args) {
325
325
 
326
326
  let signedDelegation;
327
327
  try {
328
- signedDelegation = signCiDelegation(values, { apiBase: API });
328
+ signedDelegation = await signCiDelegation(values, { apiBase: API });
329
329
  } catch (err) {
330
330
  fail({
331
331
  code: "NO_WALLET",
@@ -466,6 +466,9 @@ export const COMMAND_MANIFEST = [
466
466
  { path: ["contracts", "set-alert"], positionals: [p("signer_id")], projectScoped: true, legacyPositionalProject: true, minimalArgs: ["cwlt_gate1", "--threshold-wei", "1"] },
467
467
  { path: ["contracts", "call"], positionals: [p("signer_id")], projectScoped: true, legacyPositionalProject: true, minimalArgs: ["cwlt_gate1", "--to", "0x4444444444444444444444444444444444444444", "--abi", "[]", "--fn", "noop", "--args", "[]"] },
468
468
  { path: ["contracts", "deploy"], positionals: [p("signer_id")], projectScoped: true, legacyPositionalProject: true, minimalArgs: ["cwlt_gate1", "--bytecode", "0x00"] },
469
+ { path: ["contracts", "transfers", "create"], positionals: [p("signer_id")], projectScoped: true, legacyPositionalProject: true, minimalArgs: ["cwlt_gate1", "--to", "0x4444444444444444444444444444444444444444", "--amount", "1"] },
470
+ { path: ["contracts", "transfers", "get"], positionals: [p("signer_id")], projectScoped: true, legacyPositionalProject: true, minimalArgs: ["cwlt_gate1", "--transfer", "txf_gate1"] },
471
+ { path: ["contracts", "transfers", "list"], positionals: [p("signer_id")], projectScoped: true, legacyPositionalProject: true, minimalArgs: ["cwlt_gate1"] },
469
472
  { path: ["contracts", "read"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--chain", "base-sepolia", "--to", "0x4444444444444444444444444444444444444444", "--abi", "[]", "--fn", "noop", "--args", "[]"] },
470
473
  { path: ["contracts", "status"], positionals: [p("call_id")], projectScoped: true, legacyPositionalProject: true, minimalArgs: ["ccall_gate1"] },
471
474
  { path: ["contracts", "drain"], positionals: [p("signer_id")], projectScoped: true, legacyPositionalProject: true, minimalArgs: ["cwlt_gate1", "--to", "0x4444444444444444444444444444444444444444", "--confirm"] },
package/lib/config.mjs CHANGED
@@ -81,7 +81,15 @@ export function saveWallet(data) {
81
81
  coreSaveWallet(data);
82
82
  }
83
83
 
84
+ /**
85
+ * The wallet's SIWX headers for `path`, or the NO_WALLET failure. Most
86
+ * callers use this only as a "a wallet is configured" check before handing
87
+ * the request to the SDK. A signer-backed wallet (KMS) signs asynchronously
88
+ * inside the SDK, so here it only proves the wallet exists and returns no
89
+ * headers; callers that need its signature use the SDK.
90
+ */
84
91
  export function walletAuthHeaders(path) {
92
+ if (readWallet()?.signer) return {};
85
93
  const headers = coreGetWalletAuthHeaders(path);
86
94
  if (!headers) {
87
95
  fail({
package/lib/contacts.mjs CHANGED
@@ -104,7 +104,7 @@ async function preferences(args) {
104
104
  }
105
105
  }
106
106
  try {
107
- const data = await getSdk().admin.setNotificationPreferences(patch, sessionProofs("/agent/v1/notifications/preferences"));
107
+ const data = await getSdk().admin.setNotificationPreferences(patch, await sessionProofs("/agent/v1/notifications/preferences"));
108
108
  console.log(JSON.stringify(data, null, 2));
109
109
  } catch (err) {
110
110
  reportSdkError(err);
@@ -185,7 +185,7 @@ async function channelsConnect(args) {
185
185
  fail({ code: "BAD_USAGE", message: `Unexpected argument for notifications channels connect: ${positionals[1]}` });
186
186
  }
187
187
 
188
- const proofs = sessionProofs("/agent/v1/notifications/channels/telegram");
188
+ const proofs = await sessionProofs("/agent/v1/notifications/channels/telegram");
189
189
  let pending;
190
190
  try {
191
191
  pending = await getSdk().admin.channels.connectTelegram(label ? { label } : {}, proofs);
@@ -234,7 +234,7 @@ async function channelsRevoke(args) {
234
234
  });
235
235
  walletAuthHeaders("/agent/v1/notifications/channels/telegram");
236
236
  try {
237
- console.log(JSON.stringify(await getSdk().admin.channels.revokeTelegram(bindingId, sessionProofs("/agent/v1/notifications/channels/telegram")), null, 2));
237
+ console.log(JSON.stringify(await getSdk().admin.channels.revokeTelegram(bindingId, await sessionProofs("/agent/v1/notifications/channels/telegram")), null, 2));
238
238
  } catch (err) {
239
239
  reportSdkError(err);
240
240
  }
package/lib/contracts.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  failUnknownSubcommand,
12
12
  } from "./argparse.mjs";
13
13
 
14
- const HELP = `run402 contracts — KMS-backed Ethereum signers for smart-contract calls
14
+ const HELP = `run402 contracts — signers, your app's wallet, for smart-contract calls
15
15
 
16
16
  Pricing: $0.04/day per signer ($1.20/month) plus $0.000005 per contract call.
17
17
  Signer creation requires $1.20 of allowance (30 days of rent).
@@ -22,19 +22,25 @@ Usage:
22
22
 
23
23
  Subcommands:
24
24
  provision-signer <project_id> --chain <base-mainnet|base-sepolia> [--recovery 0x...]
25
- Provision a KMS signer ($0.04/day, requires $1.20 prepay).
25
+ Provision a signer ($0.04/day wallet rent, requires $1.20 prepay).
26
26
  get-signer <project_id> <signer_id>
27
27
  Get signer metadata + live native balance.
28
28
  list-signers <project_id>
29
- List all KMS signers for the project (includes deleted).
29
+ List all signers for the project (includes deleted).
30
+ transfers create <signer_id> --to 0x... --amount <base units> [--asset usdc] [--chain <base-mainnet|base-sepolia>] [--idempotency-key <k>]
31
+ Send a token from the signer with no gas on it: the platform's facilitator settles the signed EIP-3009 authorization and pays the gas. One signing fee.
32
+ transfers get <signer_id> --transfer <transfer_id>
33
+ Read one transfer (status, tx hash, nonce, failure phase).
34
+ transfers list <signer_id>
35
+ The signer's transfers, newest first.
30
36
  set-recovery <project_id> <signer_id> [--address 0x... | --clear]
31
37
  Set/clear the optional recovery address.
32
38
  set-alert <project_id> <signer_id> --threshold-wei <n>
33
39
  Set the low-balance alert threshold (in wei).
34
40
  call <project_id> <signer_id> --to 0x... --abi <json> --fn <name> --args <json> [--value-wei <n>] [--idempotency-key <k>]
35
- Submit a contract write call (chain gas + $0.000005 KMS sign fee).
41
+ Submit a contract write call (gas from the signer's balance, topped up at cost when short, + $0.000005 signing fee).
36
42
  deploy <project_id> <signer_id> --bytecode 0x... [--chain <base-mainnet|base-sepolia>] [--value-wei <n>] [--idempotency-key <k>]
37
- Deploy a contract (chain gas + $0.000005 KMS sign fee). Returns deterministic CREATE address synchronously.
43
+ Deploy a contract (gas from the signer's balance + $0.000005 signing fee). Returns deterministic CREATE address synchronously.
38
44
  read --chain <chain> --to 0x... --abi <json> --fn <name> --args <json>
39
45
  Read-only contract call (free).
40
46
  status <project_id> <call_id>
@@ -42,7 +48,7 @@ Subcommands:
42
48
  drain <project_id> <signer_id> --to 0x... --confirm
43
49
  Drain native balance to a destination address. Works on suspended signers.
44
50
  delete <project_id> <signer_id> --confirm
45
- Schedule the KMS key for deletion (refused if balance >= dust).
51
+ Schedule the signing key for deletion (refused if balance >= dust).
46
52
 
47
53
  Examples:
48
54
  run402 contracts provision-signer proj_abc --chain base-mainnet
@@ -50,7 +56,22 @@ Examples:
50
56
  `;
51
57
 
52
58
  const SUB_HELP = {
53
- "provision-signer": `run402 contracts provision-signer — Provision a KMS-backed signer
59
+ transfers: `run402 contracts transfers — Send a token from a signer with no gas on it
60
+
61
+ Usage:
62
+ run402 contracts transfers create <signer_id> --to 0x... --amount <base units> [--asset usdc] [--chain <base-mainnet|base-sepolia>] [--idempotency-key <k>] [--project <project_id>]
63
+ run402 contracts transfers get <signer_id> --transfer <transfer_id> [--project <project_id>]
64
+ run402 contracts transfers list <signer_id> [--project <project_id>]
65
+
66
+ The signer is the payer of an x402 payment: Run402 signs the token's EIP-3009
67
+ authorization with the signer's key and its facilitator settles it and pays the
68
+ gas. One $0.000005 signing fee, no gas, no ETH on the signer. --amount is in the
69
+ asset's base units (USDC: 6 decimals; 100000 is $0.10). An idempotency key
70
+ makes a repeat return the transfer already made; a transfer that failed before
71
+ settlement re-executes under its key, one that died in settlement never does.
72
+ A "unknown" status means the settle outcome never came back: poll it with get.
73
+ `,
74
+ "provision-signer": `run402 contracts provision-signer — Provision a signer
54
75
 
55
76
  Usage:
56
77
  run402 contracts provision-signer --chain <chain> [--project <project_id>] [options]
@@ -91,11 +112,11 @@ Legacy (still supported):
91
112
  run402 contracts call <project_id> <signer_id> --to 0x... --abi <json>
92
113
  --fn <name> --args <json> [options]
93
114
  `,
94
- deploy: `run402 contracts deploy — Deploy a smart contract from a KMS signer
115
+ deploy: `run402 contracts deploy — Deploy a smart contract from a signer
95
116
 
96
- KMS-signs a contract-creation transaction (to: null + data: bytecode) and broadcasts.
117
+ the platform's key custody-signs a contract-creation transaction (to: null + data: bytecode) and broadcasts.
97
118
  Returns the deterministic CREATE address synchronously — known from (signer, nonce)
98
- before the tx confirms. Cost: chain gas at-cost + $0.000005 KMS sign fee.
119
+ before the tx confirms. Cost: chain gas at-cost + $0.000005 signing fee.
99
120
 
100
121
  Usage:
101
122
  run402 contracts deploy <signer_id> --bytecode 0x... [--project <project_id>] [options]
@@ -130,7 +151,7 @@ Usage:
130
151
  Legacy (still supported):
131
152
  run402 contracts drain <project_id> <signer_id> --to 0x... --confirm
132
153
  `,
133
- delete: `run402 contracts delete — Schedule the KMS key for deletion
154
+ delete: `run402 contracts delete — Schedule the signing key for deletion
134
155
 
135
156
  Usage:
136
157
  run402 contracts delete <signer_id> --confirm [--project <project_id>]
@@ -153,7 +174,7 @@ Arguments:
153
174
  Examples:
154
175
  run402 contracts get-signer prj_abc123 cwlt_abc123
155
176
  `,
156
- "list-signers": `run402 contracts list-signers — List all KMS signers for a project
177
+ "list-signers": `run402 contracts list-signers — List all signers for a project
157
178
 
158
179
  Usage:
159
180
  run402 contracts list-signers [--project <project_id>]
@@ -344,7 +365,7 @@ async function call(projectId, signerId, args) {
344
365
  fail({
345
366
  code: "BAD_USAGE",
346
367
  message: "Required flags: --to, --abi, --fn, --args.",
347
- hint: "Cost: chain gas + $0.000005 KMS sign fee.",
368
+ hint: "Cost: chain gas + $0.000005 signing fee.",
348
369
  });
349
370
  }
350
371
  assertAllowedValue(chain, ["base-mainnet", "base-sepolia"], "--chain");
@@ -369,6 +390,89 @@ async function call(projectId, signerId, args) {
369
390
  }
370
391
  }
371
392
 
393
+ /**
394
+ * `run402 contracts transfers create|get|list` — a signer sends a token with
395
+ * no gas on it: the gateway signs the EIP-3009 authorization with the
396
+ * signer's key and the platform's facilitator settles it and pays the gas.
397
+ */
398
+ async function transfers(projectId, args) {
399
+ const [transferAction, ...rest] = Array.isArray(args) ? args : [];
400
+ if (transferAction === "create") return transfersCreate(projectId, rest[0], rest.slice(1));
401
+ if (transferAction === "get") return transfersGet(projectId, rest[0], rest.slice(1));
402
+ if (transferAction === "list") return transfersList(projectId, rest[0], rest.slice(1));
403
+ return fail({
404
+ code: "BAD_USAGE",
405
+ message: `Unknown contracts transfers verb: ${transferAction ?? "(none)"}`,
406
+ hint: "Verbs: create <signer_id> --to 0x… --amount <base units> [--asset usdc] [--chain …] [--idempotency-key <k>]; get <signer_id> --transfer <transfer_id>; list <signer_id>.",
407
+ });
408
+ }
409
+
410
+ async function transfersCreate(projectId, signerId, args) {
411
+ const parsedArgs = normalizeArgv(args);
412
+ const valueFlags = ["--to", "--amount", "--asset", "--chain", "--idempotency-key"];
413
+ assertKnownFlags(parsedArgs, [...valueFlags, "--help", "-h"], valueFlags);
414
+ const extra = positionalArgs(parsedArgs, valueFlags);
415
+ if (!signerId || !/^[a-z]+_[A-Za-z0-9]{8,}$/.test(signerId)) {
416
+ fail({ code: "BAD_USAGE", message: "Missing <signer_id>.", hint: "run402 contracts transfers create <signer_id> --to 0x… --amount <base units>" });
417
+ }
418
+ if (extra.length > 0) {
419
+ fail({ code: "BAD_USAGE", message: `Unexpected argument for contracts transfers create: ${extra[0]}` });
420
+ }
421
+ const to = flagValue(parsedArgs, "--to");
422
+ const amount = flagValue(parsedArgs, "--amount");
423
+ const asset = flagValue(parsedArgs, "--asset") || "usdc";
424
+ const chain = flagValue(parsedArgs, "--chain") || "base-mainnet";
425
+ const idempotency = flagValue(parsedArgs, "--idempotency-key");
426
+ if (!to || !amount) {
427
+ fail({
428
+ code: "BAD_USAGE",
429
+ message: "Required flags: --to, --amount.",
430
+ hint: "--amount is in the asset's base units as a decimal string (USDC: 6 decimals, so 100000 is $0.10). Cost: one $0.000005 signing fee, no gas.",
431
+ });
432
+ }
433
+ assertAllowedValue(chain, ["base-mainnet", "base-sepolia"], "--chain");
434
+ validateEvmAddress(to, "--to");
435
+ if (!/^[1-9][0-9]*$/.test(amount)) {
436
+ fail({ code: "BAD_USAGE", message: "--amount must be a positive integer in the asset's base units." });
437
+ }
438
+ try {
439
+ const data = await getSdk().contracts.transfer(projectId, {
440
+ signerId, chain, asset, to, amount, idempotencyKey: idempotency ?? undefined,
441
+ });
442
+ console.log(JSON.stringify(data, null, 2));
443
+ } catch (err) {
444
+ reportSdkError(err);
445
+ }
446
+ }
447
+
448
+ async function transfersGet(projectId, signerId, args) {
449
+ const parsedArgs = normalizeArgv(args);
450
+ assertKnownFlags(parsedArgs, ["--transfer", "--help", "-h"], ["--transfer"]);
451
+ const extra = positionalArgs(parsedArgs, ["--transfer"]);
452
+ const transferId = flagValue(parsedArgs, "--transfer");
453
+ if (!signerId || !transferId || extra.length > 0) {
454
+ fail({ code: "BAD_USAGE", message: "Usage: run402 contracts transfers get <signer_id> --transfer <transfer_id>" });
455
+ }
456
+ try {
457
+ console.log(JSON.stringify(await getSdk().contracts.transferStatus(projectId, signerId, transferId), null, 2));
458
+ } catch (err) {
459
+ reportSdkError(err);
460
+ }
461
+ }
462
+
463
+ async function transfersList(projectId, signerId, args) {
464
+ const parsedArgs = normalizeArgv(args);
465
+ assertKnownFlags(parsedArgs, ["--help", "-h"], []);
466
+ if (!signerId) {
467
+ fail({ code: "BAD_USAGE", message: "Usage: run402 contracts transfers list <signer_id>" });
468
+ }
469
+ try {
470
+ console.log(JSON.stringify(await getSdk().contracts.listTransfers(projectId, signerId), null, 2));
471
+ } catch (err) {
472
+ reportSdkError(err);
473
+ }
474
+ }
475
+
372
476
  async function deploy(projectId, signerId, args) {
373
477
  const parsedArgs = normalizeArgv(args);
374
478
  const valueFlags = ["--bytecode", "--value-wei", "--chain", "--idempotency-key"];
@@ -385,7 +489,7 @@ async function deploy(projectId, signerId, args) {
385
489
  fail({
386
490
  code: "BAD_USAGE",
387
491
  message: "Required flag: --bytecode (0x-prefixed hex; full creation calldata = creation bytecode + ABI-encoded constructor args, concatenated client-side).",
388
- hint: "Cost: chain gas + $0.000005 KMS sign fee. Returns deterministic CREATE address synchronously.",
492
+ hint: "Cost: chain gas + $0.000005 signing fee. Returns deterministic CREATE address synchronously.",
389
493
  });
390
494
  }
391
495
  assertAllowedValue(chain, ["base-mainnet", "base-sepolia"], "--chain");
@@ -469,7 +573,7 @@ async function drain(projectId, signerId, args) {
469
573
  fail({
470
574
  code: "BAD_USAGE",
471
575
  message: "Required: --to 0x... and --confirm.",
472
- hint: "Cost: chain gas + $0.000005 KMS sign fee.",
576
+ hint: "Cost: chain gas + $0.000005 signing fee.",
473
577
  });
474
578
  }
475
579
  validateEvmAddress(to, "--to");
@@ -508,6 +612,13 @@ export async function run(sub, args) {
508
612
  // unknown subcommands (and the anonymous `read`) never trip project
509
613
  // resolution.
510
614
  const select = () => resolveProjectSelector(normalizeArgv(Array.isArray(args) ? args : []));
615
+ // The transfers group dispatches with `if` (not `case`) so the sync test
616
+ // inventories its leaf actions through parseContractsTransferActions.
617
+ if (sub === "transfers") {
618
+ const { projectId, rest } = select();
619
+ await transfers(projectId, rest);
620
+ return;
621
+ }
511
622
  switch (sub) {
512
623
  case "provision-signer": { const { projectId, rest } = select(); await provisionSigner(projectId, rest); break; }
513
624
  case "get-signer": { const { projectId, rest } = select(); await getSigner(projectId, rest[0], rest.slice(1)); break; }
package/lib/orgs.mjs CHANGED
@@ -21,7 +21,7 @@ Usage:
21
21
  run402 orgs list
22
22
  run402 orgs get [<org_id>]
23
23
  run402 orgs rename [<org_id>] --name <display_name> (or: --clear to remove the label)
24
- run402 orgs payout-wallet [<org_id>] <wallet_address> (or: --clear to remove the explicit default)
24
+ run402 orgs payout-wallet [<org_id>] <wallet_address> (or: --signer <signer_id>; --clear removes the explicit default)
25
25
  run402 orgs lightning-receiver set [<org_id>] <nwc-uri | -> (receive-only NWC pairing; - reads it from stdin)
26
26
  run402 orgs lightning-receiver status [<org_id>]
27
27
  run402 orgs lightning-receiver delete [<org_id>]
@@ -161,6 +161,7 @@ call after a dropped response can never double-bill.
161
161
 
162
162
  Usage:
163
163
  run402 orgs payout-wallet [<org_id>] <wallet_address>
164
+ run402 orgs payout-wallet [<org_id>] --signer <signer_id>
164
165
  run402 orgs payout-wallet [<org_id>] --clear
165
166
 
166
167
  Legacy (still supported):
@@ -170,6 +171,11 @@ Admin/owner-only + step-up gated. The wallet must already be active and linked
170
171
  to the same org. This wallet receives x402 settlement for function web routes
171
172
  that declare pricing.pay_to = "org_default_payout". Pass --clear to remove the
172
173
  explicit default; a single active org wallet may still resolve automatically.
174
+ Pass --signer <signer_id> to make one of the org's own signers (your app's
175
+ wallet, run402 contracts provision-signer) the payout address instead, so what
176
+ your priced routes earn is what your app pays out from. The signer must be
177
+ active and owned by a project of this org; while it is selected, that project
178
+ cannot be transferred out.
173
179
 
174
180
  The JSON response includes recovery.status, active_wallet_count, and
175
181
  next_actions for PAYOUT_WALLET_REQUIRED / PAYOUT_WALLET_AMBIGUOUS setup.
@@ -478,20 +484,24 @@ async function rename(args) {
478
484
 
479
485
  async function payoutWallet(args) {
480
486
  const a = normalizeArgv(args);
481
- assertKnownFlags(a, ["--org", "--address", "--clear", "--help", "-h"], ["--org", "--address"]);
487
+ assertKnownFlags(a, ["--org", "--address", "--signer", "--clear", "--help", "-h"], ["--org", "--address", "--signer"]);
482
488
  const clear = a.includes("--clear");
483
489
  const walletFlag = flagValue(a, "--address");
484
- const single = clear || walletFlag !== null;
485
- const { orgId: org, rest: pos } = await takeOrgPositional(a, ["--org", "--address"], { cmd: "orgs" });
490
+ const signerFlag = flagValue(a, "--signer");
491
+ if (signerFlag !== null && (clear || walletFlag !== null)) {
492
+ fail({ code: "BAD_USAGE", message: "--signer cannot be combined with --address or --clear." });
493
+ }
494
+ const single = clear || walletFlag !== null || signerFlag !== null;
495
+ const { orgId: org, rest: pos } = await takeOrgPositional(a, ["--org", "--address", "--signer"], { cmd: "orgs" });
486
496
  requireRest(pos, {
487
497
  min: single ? 0 : 1,
488
498
  max: single ? 0 : 1,
489
- command: "run402 orgs payout-wallet [<org_id>] --address <wallet_address>",
490
- missing: "Missing <wallet_address> (use --address, or pass --clear).",
499
+ command: "run402 orgs payout-wallet [<org_id>] --address <wallet_address> | --signer <signer_id> | --clear",
500
+ missing: "Missing <wallet_address> (use --address, --signer <signer_id>, or --clear).",
491
501
  });
492
- const walletAddress = clear ? null : (walletFlag ?? pos[0]);
502
+ const input = signerFlag !== null ? { signerId: signerFlag } : { walletAddress: clear ? null : (walletFlag ?? pos[0]) };
493
503
  try {
494
- console.log(JSON.stringify(await getSdk().org(org).setPayoutWallet({ walletAddress }), null, 2));
504
+ console.log(JSON.stringify(await getSdk().org(org).setPayoutWallet(input), null, 2));
495
505
  } catch (err) {
496
506
  reportSdkError(err);
497
507
  }
@@ -9,13 +9,17 @@
9
9
  * signed in once is never asked to enroll a second passkey. With no live cached
10
10
  * session this returns `undefined` and the verb uses wallet auth alone.
11
11
  */
12
- import { walletAuthHeaders } from "./config.mjs";
12
+ import { walletAuthHeaders, walletFile } from "./config.mjs";
13
13
  import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.js";
14
+ import { getWalletAuthHeadersAsync } from "../core-dist/wallet-auth.js";
15
+ import { personalMessageSignerFor } from "#sdk/node";
14
16
 
15
17
  /** `{ siwx, token }` for `path`, or `undefined` when no live sign-in session is cached. */
16
- export function sessionProofs(path) {
17
- const siwx = walletAuthHeaders(path)["SIGN-IN-WITH-X"];
18
+ export async function sessionProofs(path) {
19
+ walletAuthHeaders(path); // NO_WALLET when none is configured
18
20
  const live = loadLiveControlPlaneSession();
19
21
  if (!live || !live.control_plane_session_token) return undefined;
20
- return { siwx, token: live.control_plane_session_token };
22
+ // A key-backed wallet signs locally; a signer-backed one (KMS) through its signer.
23
+ const headers = await getWalletAuthHeadersAsync(path, walletFile(), (w) => personalMessageSignerFor(w));
24
+ return { siwx: headers["SIGN-IN-WITH-X"], token: live.control_plane_session_token };
21
25
  }
@@ -136,7 +136,7 @@ async function rulesAdd(args) {
136
136
 
137
137
  walletAuthHeaders("/agent/v1/notifications/rules");
138
138
  try {
139
- console.log(JSON.stringify(await getSdk().admin.rules.create(input, sessionProofs("/agent/v1/notifications/rules")), null, 2));
139
+ console.log(JSON.stringify(await getSdk().admin.rules.create(input, await sessionProofs("/agent/v1/notifications/rules")), null, 2));
140
140
  } catch (err) {
141
141
  reportSdkError(err);
142
142
  }
@@ -162,7 +162,7 @@ async function rulesRm(args) {
162
162
  });
163
163
  walletAuthHeaders("/agent/v1/notifications/rules");
164
164
  try {
165
- console.log(JSON.stringify(await getSdk().admin.rules.delete(ruleId, sessionProofs("/agent/v1/notifications/rules")), null, 2));
165
+ console.log(JSON.stringify(await getSdk().admin.rules.delete(ruleId, await sessionProofs("/agent/v1/notifications/rules")), null, 2));
166
166
  } catch (err) {
167
167
  reportSdkError(err);
168
168
  }