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.
- package/dist/commands/backup.d.ts +3 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +106 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/daemon.d.ts.map +1 -1
- package/dist/commands/daemon.js +67 -0
- package/dist/commands/daemon.js.map +1 -1
- package/dist/commands/discover.d.ts.map +1 -1
- package/dist/commands/discover.js +60 -15
- package/dist/commands/discover.js.map +1 -1
- package/dist/commands/wallet.d.ts.map +1 -1
- package/dist/commands/wallet.js +136 -52
- package/dist/commands/wallet.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +146 -9
- package/dist/commands/watch.js.map +1 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +25 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon/config.d.ts +15 -1
- package/dist/daemon/config.d.ts.map +1 -1
- package/dist/daemon/config.js +32 -0
- package/dist/daemon/config.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +66 -21
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/notify.d.ts +35 -6
- package/dist/daemon/notify.d.ts.map +1 -1
- package/dist/daemon/notify.js +176 -48
- package/dist/daemon/notify.js.map +1 -1
- package/dist/endpoint-notify.d.ts +2 -1
- package/dist/endpoint-notify.d.ts.map +1 -1
- package/dist/endpoint-notify.js +12 -3
- package/dist/endpoint-notify.js.map +1 -1
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +2 -0
- package/dist/program.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/backup.ts +117 -0
- package/src/commands/daemon.ts +73 -0
- package/src/commands/discover.ts +74 -21
- package/src/commands/wallet.ts +137 -51
- package/src/commands/watch.ts +207 -10
- package/src/config.ts +39 -1
- package/src/daemon/config.ts +35 -0
- package/src/daemon/index.ts +65 -26
- package/src/daemon/notify.ts +199 -59
- package/src/endpoint-notify.ts +13 -3
- package/src/index.ts +26 -0
- package/src/program.ts +2 -0
package/src/commands/watch.ts
CHANGED
|
@@ -1,21 +1,218 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import {
|
|
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
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const line = "─".repeat(
|
|
16
|
-
|
|
17
|
-
console.log(
|
|
18
|
-
console.log(
|
|
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
|
-
|
|
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 {
|
package/src/daemon/config.ts
CHANGED
|
@@ -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)
|
package/src/daemon/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
type DaemonConfig,
|
|
28
28
|
} from "./config";
|
|
29
29
|
import { verifyWallet, getWalletBalance } from "./wallet-monitor";
|
|
30
|
-
import {
|
|
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 =
|
|
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
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
|
|
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();
|