arc402-cli 0.7.0 → 0.7.2

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 (53) hide show
  1. package/dist/commands/backup.d.ts +3 -0
  2. package/dist/commands/backup.d.ts.map +1 -0
  3. package/dist/commands/backup.js +106 -0
  4. package/dist/commands/backup.js.map +1 -0
  5. package/dist/commands/daemon.d.ts.map +1 -1
  6. package/dist/commands/daemon.js +67 -0
  7. package/dist/commands/daemon.js.map +1 -1
  8. package/dist/commands/discover.d.ts.map +1 -1
  9. package/dist/commands/discover.js +60 -15
  10. package/dist/commands/discover.js.map +1 -1
  11. package/dist/commands/wallet.d.ts.map +1 -1
  12. package/dist/commands/wallet.js +136 -52
  13. package/dist/commands/wallet.js.map +1 -1
  14. package/dist/commands/watch.d.ts.map +1 -1
  15. package/dist/commands/watch.js +146 -9
  16. package/dist/commands/watch.js.map +1 -1
  17. package/dist/config.d.ts +8 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +25 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/daemon/config.d.ts +15 -1
  22. package/dist/daemon/config.d.ts.map +1 -1
  23. package/dist/daemon/config.js +32 -0
  24. package/dist/daemon/config.js.map +1 -1
  25. package/dist/daemon/index.d.ts.map +1 -1
  26. package/dist/daemon/index.js +66 -21
  27. package/dist/daemon/index.js.map +1 -1
  28. package/dist/daemon/notify.d.ts +35 -6
  29. package/dist/daemon/notify.d.ts.map +1 -1
  30. package/dist/daemon/notify.js +176 -48
  31. package/dist/daemon/notify.js.map +1 -1
  32. package/dist/endpoint-notify.d.ts +2 -1
  33. package/dist/endpoint-notify.d.ts.map +1 -1
  34. package/dist/endpoint-notify.js +12 -3
  35. package/dist/endpoint-notify.js.map +1 -1
  36. package/dist/index.js +26 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/program.d.ts.map +1 -1
  39. package/dist/program.js +2 -0
  40. package/dist/program.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/commands/backup.ts +117 -0
  43. package/src/commands/daemon.ts +73 -0
  44. package/src/commands/discover.ts +74 -21
  45. package/src/commands/wallet.ts +137 -51
  46. package/src/commands/watch.ts +207 -10
  47. package/src/config.ts +39 -1
  48. package/src/daemon/config.ts +35 -0
  49. package/src/daemon/index.ts +65 -26
  50. package/src/daemon/notify.ts +199 -59
  51. package/src/endpoint-notify.ts +13 -3
  52. package/src/index.ts +26 -0
  53. package/src/program.ts +2 -0
@@ -1,21 +1,218 @@
1
1
  import { Command } from "commander";
2
- import { c } from "../ui/colors";
2
+ import { ethers } from "ethers";
3
3
  import { loadConfig } from "../config";
4
+ import { getClient } from "../client";
5
+ import { c } from "../ui/colors";
6
+
7
+ // ─── Minimal ABIs for event watching ─────────────────────────────────────────
8
+
9
+ const AGENT_REGISTRY_WATCH_ABI = [
10
+ "event AgentRegistered(address indexed wallet, string name, string serviceType, uint256 timestamp)",
11
+ "event AgentUpdated(address indexed wallet, string name, string serviceType)",
12
+ "event AgentDeactivated(address indexed wallet)",
13
+ ];
14
+
15
+ const SERVICE_AGREEMENT_WATCH_ABI = [
16
+ "event AgreementProposed(uint256 indexed id, address indexed client, address indexed provider, string serviceType, uint256 price, address token, uint256 deadline)",
17
+ "event AgreementAccepted(uint256 indexed id, address indexed provider)",
18
+ "event AgreementFulfilled(uint256 indexed id, address indexed provider, bytes32 deliverablesHash)",
19
+ "event AgreementDisputed(uint256 indexed id, address indexed initiator, string reason)",
20
+ "event AgreementCancelled(uint256 indexed id, address indexed client)",
21
+ ];
22
+
23
+ const HANDSHAKE_WATCH_ABI = [
24
+ "event HandshakeSent(uint256 indexed handshakeId, address indexed from, address indexed to, uint8 hsType, address token, uint256 amount, string note, uint256 timestamp)",
25
+ ];
26
+
27
+ const DISPUTE_MODULE_WATCH_ABI = [
28
+ "event DisputeRaised(uint256 indexed agreementId, address indexed initiator, string reason)",
29
+ "event DisputeResolved(uint256 indexed agreementId, bool favorProvider, string resolution)",
30
+ ];
31
+
32
+ const HS_TYPE_LABELS: Record<number, string> = {
33
+ 0: "Respected",
34
+ 1: "Curious",
35
+ 2: "Endorsed",
36
+ 3: "Thanked",
37
+ 4: "Collaborated",
38
+ 5: "Challenged",
39
+ 6: "Referred",
40
+ 7: "Hello",
41
+ };
42
+
43
+ function shortAddr(addr: string): string {
44
+ return addr.length > 10 ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : addr;
45
+ }
46
+
47
+ function nowHHMM(): string {
48
+ const d = new Date();
49
+ const h = d.getHours().toString().padStart(2, "0");
50
+ const m = d.getMinutes().toString().padStart(2, "0");
51
+ return `${h}:${m}`;
52
+ }
53
+
54
+ function printEvent(label: string, detail: string, status?: "ok" | "warn" | "err"): void {
55
+ const ts = c.dim(`[${nowHHMM()}]`);
56
+ const col = status === "ok" ? c.green : status === "err" ? c.red : status === "warn" ? c.yellow : c.white;
57
+ process.stdout.write(` ${ts} ${col(label)} ${c.dim(detail)}\n`);
58
+ }
4
59
 
5
60
  export function registerWatchCommand(program: Command): void {
6
61
  program
7
62
  .command("watch")
8
- .description("Watch wallet activity in real-time")
63
+ .description("Watch wallet activity in real-time (live onchain event feed)")
9
64
  .action(async () => {
10
65
  const config = loadConfig();
11
- const wallet = config.walletContractAddress ?? "(no wallet)";
12
- const shortWallet = wallet.length > 10
13
- ? `${wallet.slice(0, 6)}...${wallet.slice(-4)}`
14
- : wallet;
15
- const line = "─".repeat(20);
16
-
17
- console.log(`${c.mark} ARC-402 Watching ${c.white(shortWallet)} ${c.dim(line)}`);
18
- console.log(`${c.dim("···")} ${c.dim("waiting")}`);
66
+ const { provider } = await getClient(config);
67
+ const myWallet = (config.walletContractAddress ?? "").toLowerCase();
68
+ const shortMe = myWallet ? shortAddr(config.walletContractAddress!) : "(no wallet)";
69
+
70
+ const line = "─".repeat(22);
71
+ console.log(`\n ${c.mark} ${c.white("ARC-402 Live Feed")} ${c.dim(line)}`);
72
+ console.log(` ${c.dim("Watching")} ${c.brightCyan(shortMe)} ${c.dim("on")} ${c.dim(config.network)}`);
73
+ console.log(` ${c.dim("Ctrl+C to exit")}\n`);
74
+
75
+ // ── Build contract instances ───────────────────────────────────────────
76
+
77
+ const contractLabels: string[] = [];
78
+
79
+ if (config.agentRegistryAddress) contractLabels.push("AgentRegistry");
80
+ if (config.serviceAgreementAddress) contractLabels.push("ServiceAgreement");
81
+ if (config.handshakeAddress) contractLabels.push("Handshake");
82
+ if (config.disputeModuleAddress) contractLabels.push("DisputeModule");
83
+
84
+ if (contractLabels.length === 0) {
85
+ console.log(` ${c.warning} No contract addresses configured. Run arc402 config init.`);
86
+ process.exit(1);
87
+ }
88
+
89
+ console.log(` ${c.dim(`Monitoring ${contractLabels.length} contract${contractLabels.length !== 1 ? "s" : ""}: ${contractLabels.join(", ")}`)}\n`);
90
+
91
+ // ── Helpers ────────────────────────────────────────────────────────────
92
+
93
+ function isMe(addr: string): boolean {
94
+ return myWallet !== "" && addr.toLowerCase() === myWallet;
95
+ }
96
+
97
+ function fmtAddr(addr: string): string {
98
+ return isMe(addr) ? c.brightCyan("you") : c.dim(shortAddr(addr));
99
+ }
100
+
101
+ // ── AgentRegistry ──────────────────────────────────────────────────────
102
+
103
+ if (config.agentRegistryAddress) {
104
+ const reg = new ethers.Contract(config.agentRegistryAddress, AGENT_REGISTRY_WATCH_ABI, provider);
105
+
106
+ reg.on("AgentRegistered", (wallet: string, name: string, serviceType: string) => {
107
+ printEvent(`Agent registered: ${name}`, `${fmtAddr(wallet)} ${c.dim(serviceType)}`, "ok");
108
+ });
109
+
110
+ reg.on("AgentUpdated", (wallet: string, name: string, serviceType: string) => {
111
+ printEvent(`Agent updated: ${name}`, `${fmtAddr(wallet)} ${c.dim(serviceType)}`);
112
+ });
113
+
114
+ reg.on("AgentDeactivated", (wallet: string) => {
115
+ printEvent(`Agent deactivated`, fmtAddr(wallet), "warn");
116
+ });
117
+ }
118
+
119
+ // ── ServiceAgreement ───────────────────────────────────────────────────
120
+
121
+ if (config.serviceAgreementAddress) {
122
+ const sa = new ethers.Contract(config.serviceAgreementAddress, SERVICE_AGREEMENT_WATCH_ABI, provider);
123
+
124
+ sa.on("AgreementProposed", (id: bigint, client: string, agentProvider: string, serviceType: string) => {
125
+ const involved = isMe(client) || isMe(agentProvider);
126
+ printEvent(
127
+ `Agreement #${id} proposed`,
128
+ `${fmtAddr(client)} → ${fmtAddr(agentProvider)} ${c.dim(serviceType)}`,
129
+ involved ? "ok" : undefined
130
+ );
131
+ });
132
+
133
+ sa.on("AgreementAccepted", (id: bigint, agentProvider: string) => {
134
+ printEvent(`Agreement #${id} → ${c.green("ACCEPTED")}`, fmtAddr(agentProvider));
135
+ });
136
+
137
+ sa.on("AgreementFulfilled", (id: bigint, agentProvider: string, deliverablesHash: string) => {
138
+ printEvent(
139
+ `Agreement #${id} → ${c.green("DELIVERED")}`,
140
+ `${fmtAddr(agentProvider)} ${c.dim(deliverablesHash.slice(0, 10) + "...")}`,
141
+ "ok"
142
+ );
143
+ });
144
+
145
+ sa.on("AgreementDisputed", (id: bigint, initiator: string, reason: string) => {
146
+ printEvent(
147
+ `Agreement #${id} → ${c.red("DISPUTED")}`,
148
+ `${fmtAddr(initiator)} ${c.dim(reason.slice(0, 40))}`,
149
+ "err"
150
+ );
151
+ });
152
+
153
+ sa.on("AgreementCancelled", (id: bigint, client: string) => {
154
+ printEvent(`Agreement #${id} → ${c.yellow("CANCELLED")}`, fmtAddr(client), "warn");
155
+ });
156
+ }
157
+
158
+ // ── Handshake ──────────────────────────────────────────────────────────
159
+
160
+ if (config.handshakeAddress) {
161
+ const hs = new ethers.Contract(config.handshakeAddress, HANDSHAKE_WATCH_ABI, provider);
162
+
163
+ hs.on("HandshakeSent", (_id: bigint, from: string, to: string, hsType: number, _token: string, _amount: bigint, note: string) => {
164
+ const typeLabel = HS_TYPE_LABELS[hsType] ?? `type ${hsType}`;
165
+ const toMe = isMe(to);
166
+ const noteStr = note ? ` ${c.dim(`(${note.slice(0, 30)})`)}` : "";
167
+ printEvent(
168
+ `Handshake from ${fmtAddr(from)} → ${fmtAddr(to)}`,
169
+ `${c.dim(typeLabel)}${noteStr}`,
170
+ toMe ? "ok" : undefined
171
+ );
172
+ });
173
+ }
174
+
175
+ // ── DisputeModule ──────────────────────────────────────────────────────
176
+
177
+ if (config.disputeModuleAddress) {
178
+ const dm = new ethers.Contract(config.disputeModuleAddress, DISPUTE_MODULE_WATCH_ABI, provider);
179
+
180
+ dm.on("DisputeRaised", (agreementId: bigint, initiator: string, reason: string) => {
181
+ printEvent(
182
+ `Dispute raised on #${agreementId}`,
183
+ `${fmtAddr(initiator)} ${c.dim(reason.slice(0, 40))}`,
184
+ "err"
185
+ );
186
+ });
187
+
188
+ dm.on("DisputeResolved", (agreementId: bigint, favorProvider: boolean, resolution: string) => {
189
+ printEvent(
190
+ `Dispute #${agreementId} → ${c.green("RESOLVED")}`,
191
+ `${c.dim(favorProvider ? "provider wins" : "client wins")} ${c.dim(resolution.slice(0, 30))}`,
192
+ "ok"
193
+ );
194
+ });
195
+ }
196
+
197
+ // ── Block heartbeat (shows feed is alive) ──────────────────────────────
198
+
199
+ let lastBlock = 0;
200
+ provider.on("block", (blockNumber: number) => {
201
+ if (blockNumber > lastBlock) {
202
+ lastBlock = blockNumber;
203
+ if (blockNumber % 10 === 0) {
204
+ process.stdout.write(` ${c.dim(`· block ${blockNumber}`)}\n`);
205
+ }
206
+ }
207
+ });
208
+
209
+ // ── Clean exit ─────────────────────────────────────────────────────────
210
+
211
+ process.on("SIGINT", () => {
212
+ console.log(`\n ${c.dim("Feed stopped.")}`);
213
+ provider.removeAllListeners();
214
+ process.exit(0);
215
+ });
19
216
 
20
217
  // Keep process alive
21
218
  process.stdin.resume();
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import * as os from "os";
4
+ import { randomUUID } from "crypto";
4
5
 
5
6
  export interface Arc402Config {
6
7
  network: "base-mainnet" | "base-sepolia";
@@ -40,16 +41,25 @@ export interface Arc402Config {
40
41
  telegramBotToken?: string;
41
42
  telegramChatId?: string;
42
43
  telegramThreadId?: number;
44
+ /** Tracks onboarding progress so `wallet deploy` can resume after interruption. */
45
+ onboardingProgress?: {
46
+ walletAddress: string;
47
+ step: number; // last completed step number (2=machineKey, 3=passkey, 4=policy, 5=agent)
48
+ completedSteps: string[];
49
+ };
43
50
  wcSession?: {
44
51
  topic: string;
45
52
  expiry: number; // Unix timestamp
46
53
  account: string; // Phone wallet address
47
54
  chainId: number;
48
55
  };
56
+ deviceId?: string; // UUID identifying the device this config was created on
57
+ lastCliVersion?: string; // Last CLI version that wrote this config (for upgrade detection)
49
58
  }
50
59
 
51
60
  const CONFIG_DIR = path.join(os.homedir(), ".arc402");
52
61
  const CONFIG_PATH = process.env.ARC402_CONFIG || path.join(CONFIG_DIR, "config.json");
62
+ const DEVICE_ID_PATH = path.join(CONFIG_DIR, "device.id");
53
63
 
54
64
  // WalletConnect project ID — get your own at cloud.walletconnect.com
55
65
  const DEFAULT_WC_PROJECT_ID = "455e9425343b9156fce1428250c9a54a";
@@ -57,7 +67,20 @@ export const getWcProjectId = () => process.env.WC_PROJECT_ID ?? DEFAULT_WC_PROJ
57
67
 
58
68
  export const getConfigPath = () => CONFIG_PATH;
59
69
 
70
+ /** Returns this device's stable UUID, creating it on first call. */
71
+ function getOrCreateDeviceId(): string {
72
+ if (fs.existsSync(DEVICE_ID_PATH)) {
73
+ return fs.readFileSync(DEVICE_ID_PATH, "utf-8").trim();
74
+ }
75
+ const id = randomUUID();
76
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
77
+ fs.writeFileSync(DEVICE_ID_PATH, id, { mode: 0o600 });
78
+ return id;
79
+ }
80
+
60
81
  export function loadConfig(): Arc402Config {
82
+ const thisDeviceId = getOrCreateDeviceId();
83
+
61
84
  if (!fs.existsSync(CONFIG_PATH)) {
62
85
  // Auto-create with Base Mainnet defaults — zero friction
63
86
  const defaults = NETWORK_DEFAULTS["base-mainnet"] ?? {};
@@ -78,13 +101,28 @@ export function loadConfig(): Arc402Config {
78
101
  walletFactoryAddress: defaults.walletFactoryAddress,
79
102
  sessionChannelsAddress: defaults.sessionChannelsAddress,
80
103
  disputeModuleAddress: defaults.disputeModuleAddress,
104
+ deviceId: thisDeviceId,
81
105
  };
82
106
  saveConfig(autoConfig);
83
107
  console.log(`◈ Config auto-created at ${CONFIG_PATH} (Base Mainnet)`);
84
108
  console.log("⚠ Base Mainnet — real funds at risk. Use arc402 config init for testnet.");
85
109
  return autoConfig;
86
110
  }
87
- return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as Arc402Config;
111
+
112
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as Arc402Config;
113
+
114
+ // Multi-device awareness: warn if config was created on a different device
115
+ if (config.deviceId && config.deviceId !== thisDeviceId) {
116
+ console.warn("⚠ This config was created on a different device. Some keys may not work.");
117
+ }
118
+
119
+ // Backfill deviceId if missing (older config)
120
+ if (!config.deviceId) {
121
+ config.deviceId = thisDeviceId;
122
+ saveConfig(config);
123
+ }
124
+
125
+ return config;
88
126
  }
89
127
 
90
128
  export function saveConfig(config: Arc402Config): void {
@@ -68,6 +68,9 @@ export interface DaemonConfig {
68
68
  notify_on_channel_challenge: boolean;
69
69
  notify_on_low_balance: boolean;
70
70
  low_balance_threshold_eth: string;
71
+ discord: { webhook_url: string };
72
+ webhook: { url: string; headers: Record<string, string> };
73
+ email: { smtp_host: string; smtp_port: number; smtp_user: string; smtp_pass: string; to: string };
71
74
  };
72
75
  work: {
73
76
  handler: "exec" | "http" | "noop";
@@ -114,6 +117,9 @@ function withDefaults(raw: Record<string, unknown>): DaemonConfig {
114
117
  const wt = (raw.watchtower as Record<string, unknown>) ?? {};
115
118
  const p = (raw.policy as Record<string, unknown>) ?? {};
116
119
  const notif = (raw.notifications as Record<string, unknown>) ?? {};
120
+ const notifDiscord = (notif.discord as Record<string, unknown>) ?? {};
121
+ const notifWebhook = (notif.webhook as Record<string, unknown>) ?? {};
122
+ const notifEmail = (notif.email as Record<string, unknown>) ?? {};
117
123
  const work = (raw.work as Record<string, unknown>) ?? {};
118
124
 
119
125
  return {
@@ -169,6 +175,18 @@ function withDefaults(raw: Record<string, unknown>): DaemonConfig {
169
175
  notify_on_channel_challenge: bool(notif.notify_on_channel_challenge, true),
170
176
  notify_on_low_balance: bool(notif.notify_on_low_balance, true),
171
177
  low_balance_threshold_eth: str(notif.low_balance_threshold_eth, "0.005"),
178
+ discord: { webhook_url: str(notifDiscord.webhook_url) },
179
+ webhook: {
180
+ url: str(notifWebhook.url),
181
+ headers: (notifWebhook.headers as Record<string, string>) ?? {},
182
+ },
183
+ email: {
184
+ smtp_host: str(notifEmail.smtp_host),
185
+ smtp_port: num(notifEmail.smtp_port, 587),
186
+ smtp_user: str(notifEmail.smtp_user),
187
+ smtp_pass: str(notifEmail.smtp_pass, "env:SMTP_PASS"),
188
+ to: str(notifEmail.to),
189
+ },
172
190
  },
173
191
  work: {
174
192
  handler: (str(work.handler, "noop")) as "exec" | "http" | "noop",
@@ -213,6 +231,9 @@ export function loadDaemonConfig(configPath = DAEMON_TOML): DaemonConfig {
213
231
  // Resolve optional env: values silently (missing = disabled feature)
214
232
  config.notifications.telegram_bot_token = tryResolveEnvValue(config.notifications.telegram_bot_token);
215
233
  config.notifications.telegram_chat_id = tryResolveEnvValue(config.notifications.telegram_chat_id);
234
+ config.notifications.discord.webhook_url = tryResolveEnvValue(config.notifications.discord.webhook_url);
235
+ config.notifications.webhook.url = tryResolveEnvValue(config.notifications.webhook.url);
236
+ config.notifications.email.smtp_pass = tryResolveEnvValue(config.notifications.email.smtp_pass);
216
237
  config.work.http_auth_token = tryResolveEnvValue(config.work.http_auth_token);
217
238
 
218
239
  return config;
@@ -300,6 +321,20 @@ notify_on_channel_challenge = true # Notify when watchtower submits a channel c
300
321
  notify_on_low_balance = false # Disabled by default — enable if you want balance alerts
301
322
  low_balance_threshold_eth = "0.005" # Balance alert threshold
302
323
 
324
+ [notifications.discord]
325
+ webhook_url = "" # Discord channel webhook URL (leave empty to disable)
326
+
327
+ [notifications.webhook]
328
+ url = "" # POST JSON {title, body, timestamp} to this URL (leave empty to disable)
329
+ # headers = { Authorization = "Bearer ..." } # Optional headers
330
+
331
+ [notifications.email]
332
+ smtp_host = "" # SMTP server hostname (leave empty to disable)
333
+ smtp_port = 587
334
+ smtp_user = "" # SMTP login / from address
335
+ smtp_pass = "env:SMTP_PASS" # Load from env, not hardcoded
336
+ to = "" # Recipient address
337
+
303
338
  [work]
304
339
  handler = "noop" # exec | http | noop
305
340
  exec_command = "" # called with agreementId and spec as args (exec mode)
@@ -27,7 +27,7 @@ import {
27
27
  type DaemonConfig,
28
28
  } from "./config";
29
29
  import { verifyWallet, getWalletBalance } from "./wallet-monitor";
30
- import { Notifier } from "./notify";
30
+ import { buildNotifier } from "./notify";
31
31
  import { HireListener } from "./hire-listener";
32
32
  import { UserOpsManager, buildAcceptCalldata } from "./userops";
33
33
  import { generateReceipt, extractLearnings, createJobDirectory, cleanJobDirectory } from "./job-lifecycle";
@@ -188,6 +188,9 @@ function checkRateLimit(ip: string): boolean {
188
188
  return bucket.count <= RATE_LIMIT;
189
189
  }
190
190
 
191
+ // Cleanup stale rate limit entries every 5 minutes to prevent unbounded growth
192
+ let rateLimitCleanupInterval: ReturnType<typeof setInterval> | null = null;
193
+
191
194
  // ─── Body size limit ──────────────────────────────────────────────────────────
192
195
 
193
196
  const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
@@ -516,19 +519,7 @@ export async function runDaemon(foreground = false): Promise<void> {
516
519
  }
517
520
 
518
521
  // ── Setup notifier ───────────────────────────────────────────────────────
519
- const notifier = new Notifier(
520
- config.notifications.telegram_bot_token,
521
- config.notifications.telegram_chat_id,
522
- {
523
- hire_request: config.notifications.notify_on_hire_request,
524
- hire_accepted: config.notifications.notify_on_hire_accepted,
525
- hire_rejected: config.notifications.notify_on_hire_rejected,
526
- delivery: config.notifications.notify_on_delivery,
527
- dispute: config.notifications.notify_on_dispute,
528
- channel_challenge: config.notifications.notify_on_channel_challenge,
529
- low_balance: config.notifications.notify_on_low_balance,
530
- }
531
- );
522
+ const notifier = buildNotifier(config);
532
523
 
533
524
  // ── Step 10: Start relay listener ───────────────────────────────────────
534
525
  const hireListener = new HireListener(config, db, notifier, config.wallet.contract_address);
@@ -588,6 +579,14 @@ export async function runDaemon(foreground = false): Promise<void> {
588
579
  }
589
580
  }, 30_000);
590
581
 
582
+ // Rate limit map cleanup — every 5 minutes (prevents unbounded growth)
583
+ rateLimitCleanupInterval = setInterval(() => {
584
+ const now = Date.now();
585
+ for (const [ip, bucket] of rateLimitMap) {
586
+ if (bucket.resetTime < now) rateLimitMap.delete(ip);
587
+ }
588
+ }, 5 * 60 * 1000);
589
+
591
590
  // Balance monitor — every 5 minutes
592
591
  const balanceInterval = setInterval(async () => {
593
592
  try {
@@ -617,6 +616,26 @@ export async function runDaemon(foreground = false): Promise<void> {
617
616
  // ── Start HTTP relay server (public endpoint) ────────────────────────────
618
617
  const httpPort = config.relay.listen_port ?? 4402;
619
618
 
619
+ /**
620
+ * Optionally verifies X-ARC402-Signature against the request body.
621
+ * Logs the result but never rejects — unsigned requests are accepted for backwards compat.
622
+ */
623
+ function verifyRequestSignature(body: string, req: http.IncomingMessage): void {
624
+ const sig = req.headers["x-arc402-signature"] as string | undefined;
625
+ if (!sig) return;
626
+ const claimedSigner = req.headers["x-arc402-signer"] as string | undefined;
627
+ try {
628
+ const recovered = ethers.verifyMessage(body, sig);
629
+ if (claimedSigner && recovered.toLowerCase() !== claimedSigner.toLowerCase()) {
630
+ log({ event: "sig_mismatch", claimed: claimedSigner, recovered });
631
+ } else {
632
+ log({ event: "sig_verified", signer: recovered });
633
+ }
634
+ } catch {
635
+ log({ event: "sig_invalid" });
636
+ }
637
+ }
638
+
620
639
  /**
621
640
  * Read request body with a size cap. Destroys the request and sends 413
622
641
  * if the body exceeds MAX_BODY_SIZE. Returns null in that case.
@@ -643,11 +662,22 @@ export async function runDaemon(foreground = false): Promise<void> {
643
662
 
644
663
  const PUBLIC_GET_PATHS = new Set(["/", "/health", "/agent", "/capabilities", "/status"]);
645
664
 
665
+ // CORS whitelist — localhost for local tooling, arc402.xyz for the web app
666
+ const CORS_WHITELIST = new Set(["localhost", "127.0.0.1", "arc402.xyz", "app.arc402.xyz"]);
667
+
646
668
  const httpServer = http.createServer(async (req, res) => {
647
- // CORS headers
648
- res.setHeader("Access-Control-Allow-Origin", "*");
649
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
650
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
669
+ // CORS — only reflect origin header if it's in the whitelist
670
+ const origin = (req.headers["origin"] ?? "") as string;
671
+ if (origin) {
672
+ try {
673
+ const { hostname } = new URL(origin);
674
+ if (CORS_WHITELIST.has(hostname)) {
675
+ res.setHeader("Access-Control-Allow-Origin", origin);
676
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
677
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
678
+ }
679
+ } catch { /* ignore invalid origin */ }
680
+ }
651
681
  if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
652
682
 
653
683
  const url = new URL(req.url || "/", `http://localhost:${httpPort}`);
@@ -707,6 +737,7 @@ export async function runDaemon(foreground = false): Promise<void> {
707
737
  if (pathname === "/hire" && req.method === "POST") {
708
738
  const body = await readBody(req, res);
709
739
  if (body === null) return;
740
+ verifyRequestSignature(body, req);
710
741
  try {
711
742
  const msg = JSON.parse(body) as Record<string, unknown>;
712
743
 
@@ -787,6 +818,7 @@ export async function runDaemon(foreground = false): Promise<void> {
787
818
  if (pathname === "/handshake" && req.method === "POST") {
788
819
  const body = await readBody(req, res);
789
820
  if (body === null) return;
821
+ verifyRequestSignature(body, req);
790
822
  try {
791
823
  const msg = JSON.parse(body);
792
824
  log({ event: "handshake_received", from: msg.from, type: msg.type, note: msg.note });
@@ -824,6 +856,7 @@ export async function runDaemon(foreground = false): Promise<void> {
824
856
  if (pathname === "/message" && req.method === "POST") {
825
857
  const body = await readBody(req, res);
826
858
  if (body === null) return;
859
+ verifyRequestSignature(body, req);
827
860
  try {
828
861
  const msg = JSON.parse(body) as Record<string, unknown>;
829
862
  const from = String(msg.from ?? "");
@@ -844,6 +877,7 @@ export async function runDaemon(foreground = false): Promise<void> {
844
877
  if (pathname === "/delivery" && req.method === "POST") {
845
878
  const body = await readBody(req, res);
846
879
  if (body === null) return;
880
+ verifyRequestSignature(body, req);
847
881
  try {
848
882
  const msg = JSON.parse(body) as Record<string, unknown>;
849
883
  const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
@@ -961,21 +995,25 @@ export async function runDaemon(foreground = false): Promise<void> {
961
995
  return;
962
996
  }
963
997
 
964
- // GET /status — health with active agreement count
998
+ // GET /status — health with active agreement count (sensitive counts only for authenticated)
965
999
  if (pathname === "/status" && req.method === "GET") {
966
- const activeList = db.listActiveHireRequests();
967
- const pendingList = db.listPendingHireRequests();
968
- res.writeHead(200, { "Content-Type": "application/json" });
969
- res.end(JSON.stringify({
1000
+ const statusAuth = (req.headers["authorization"] ?? "") as string;
1001
+ const statusToken = statusAuth.startsWith("Bearer ") ? statusAuth.slice(7) : "";
1002
+ const statusAuthed = statusToken === apiToken;
1003
+ const statusPayload: Record<string, unknown> = {
970
1004
  protocol: "arc-402",
971
1005
  version: "0.3.0",
972
1006
  agent: config.wallet.contract_address,
973
1007
  status: "online",
974
1008
  uptime: Math.floor((Date.now() - ipcCtx.startTime) / 1000),
975
- active_agreements: activeList.length,
976
- pending_approval: pendingList.length,
977
1009
  capabilities: config.policy.allowed_capabilities,
978
- }));
1010
+ };
1011
+ if (statusAuthed) {
1012
+ statusPayload.active_agreements = db.listActiveHireRequests().length;
1013
+ statusPayload.pending_approval = db.listPendingHireRequests().length;
1014
+ }
1015
+ res.writeHead(200, { "Content-Type": "application/json" });
1016
+ res.end(JSON.stringify(statusPayload));
979
1017
  return;
980
1018
  }
981
1019
 
@@ -1011,6 +1049,7 @@ export async function runDaemon(foreground = false): Promise<void> {
1011
1049
  if (relayInterval) clearInterval(relayInterval);
1012
1050
  clearInterval(timeoutInterval);
1013
1051
  clearInterval(balanceInterval);
1052
+ if (rateLimitCleanupInterval) clearInterval(rateLimitCleanupInterval);
1014
1053
 
1015
1054
  // Close HTTP + IPC
1016
1055
  httpServer.close();