arc402-cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -0
- package/dist/abis.d.ts +19 -0
- package/dist/abis.d.ts.map +1 -0
- package/dist/abis.js +177 -0
- package/dist/abis.js.map +1 -0
- package/dist/bundler.d.ts +65 -0
- package/dist/bundler.d.ts.map +1 -0
- package/dist/bundler.js +181 -0
- package/dist/bundler.js.map +1 -0
- package/dist/client.d.ts +14 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +24 -0
- package/dist/client.js.map +1 -0
- package/dist/coinbase-smart-wallet.d.ts +28 -0
- package/dist/coinbase-smart-wallet.d.ts.map +1 -0
- package/dist/coinbase-smart-wallet.js +38 -0
- package/dist/coinbase-smart-wallet.js.map +1 -0
- package/dist/commands/accept.d.ts +3 -0
- package/dist/commands/accept.d.ts.map +1 -0
- package/dist/commands/accept.js +26 -0
- package/dist/commands/accept.js.map +1 -0
- package/dist/commands/agent-handshake.d.ts +3 -0
- package/dist/commands/agent-handshake.d.ts.map +1 -0
- package/dist/commands/agent-handshake.js +61 -0
- package/dist/commands/agent-handshake.js.map +1 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +417 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/agreements.d.ts +3 -0
- package/dist/commands/agreements.d.ts.map +1 -0
- package/dist/commands/agreements.js +344 -0
- package/dist/commands/agreements.js.map +1 -0
- package/dist/commands/arbitrator.d.ts +3 -0
- package/dist/commands/arbitrator.d.ts.map +1 -0
- package/dist/commands/arbitrator.js +157 -0
- package/dist/commands/arbitrator.js.map +1 -0
- package/dist/commands/arena-handshake.d.ts +3 -0
- package/dist/commands/arena-handshake.d.ts.map +1 -0
- package/dist/commands/arena-handshake.js +187 -0
- package/dist/commands/arena-handshake.js.map +1 -0
- package/dist/commands/cancel.d.ts +3 -0
- package/dist/commands/cancel.d.ts.map +1 -0
- package/dist/commands/cancel.js +30 -0
- package/dist/commands/cancel.js.map +1 -0
- package/dist/commands/channel.d.ts +3 -0
- package/dist/commands/channel.d.ts.map +1 -0
- package/dist/commands/channel.js +238 -0
- package/dist/commands/channel.js.map +1 -0
- package/dist/commands/coldstart.d.ts +3 -0
- package/dist/commands/coldstart.d.ts.map +1 -0
- package/dist/commands/coldstart.js +148 -0
- package/dist/commands/coldstart.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +40 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/contract-interaction.d.ts +3 -0
- package/dist/commands/contract-interaction.d.ts.map +1 -0
- package/dist/commands/contract-interaction.js +165 -0
- package/dist/commands/contract-interaction.js.map +1 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.d.ts.map +1 -0
- package/dist/commands/daemon.js +891 -0
- package/dist/commands/daemon.js.map +1 -0
- package/dist/commands/deliver.d.ts +3 -0
- package/dist/commands/deliver.d.ts.map +1 -0
- package/dist/commands/deliver.js +156 -0
- package/dist/commands/deliver.js.map +1 -0
- package/dist/commands/discover.d.ts +3 -0
- package/dist/commands/discover.d.ts.map +1 -0
- package/dist/commands/discover.js +224 -0
- package/dist/commands/discover.js.map +1 -0
- package/dist/commands/dispute.d.ts +3 -0
- package/dist/commands/dispute.d.ts.map +1 -0
- package/dist/commands/dispute.js +348 -0
- package/dist/commands/dispute.js.map +1 -0
- package/dist/commands/endpoint.d.ts +3 -0
- package/dist/commands/endpoint.d.ts.map +1 -0
- package/dist/commands/endpoint.js +604 -0
- package/dist/commands/endpoint.js.map +1 -0
- package/dist/commands/hire.d.ts +3 -0
- package/dist/commands/hire.d.ts.map +1 -0
- package/dist/commands/hire.js +189 -0
- package/dist/commands/hire.js.map +1 -0
- package/dist/commands/migrate.d.ts +3 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +163 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/negotiate.d.ts +3 -0
- package/dist/commands/negotiate.d.ts.map +1 -0
- package/dist/commands/negotiate.js +247 -0
- package/dist/commands/negotiate.js.map +1 -0
- package/dist/commands/openshell.d.ts +3 -0
- package/dist/commands/openshell.d.ts.map +1 -0
- package/dist/commands/openshell.js +952 -0
- package/dist/commands/openshell.js.map +1 -0
- package/dist/commands/owner.d.ts +3 -0
- package/dist/commands/owner.d.ts.map +1 -0
- package/dist/commands/owner.js +32 -0
- package/dist/commands/owner.js.map +1 -0
- package/dist/commands/policy.d.ts +4 -0
- package/dist/commands/policy.d.ts.map +1 -0
- package/dist/commands/policy.js +248 -0
- package/dist/commands/policy.js.map +1 -0
- package/dist/commands/relay.d.ts +3 -0
- package/dist/commands/relay.d.ts.map +1 -0
- package/dist/commands/relay.js +279 -0
- package/dist/commands/relay.js.map +1 -0
- package/dist/commands/remediate.d.ts +3 -0
- package/dist/commands/remediate.d.ts.map +1 -0
- package/dist/commands/remediate.js +42 -0
- package/dist/commands/remediate.js.map +1 -0
- package/dist/commands/reputation.d.ts +4 -0
- package/dist/commands/reputation.d.ts.map +1 -0
- package/dist/commands/reputation.js +72 -0
- package/dist/commands/reputation.js.map +1 -0
- package/dist/commands/setup.d.ts +3 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +332 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/trust.d.ts +3 -0
- package/dist/commands/trust.d.ts.map +1 -0
- package/dist/commands/trust.js +23 -0
- package/dist/commands/trust.js.map +1 -0
- package/dist/commands/verify.d.ts +3 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +88 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/wallet.d.ts +3 -0
- package/dist/commands/wallet.d.ts.map +1 -0
- package/dist/commands/wallet.js +2520 -0
- package/dist/commands/wallet.js.map +1 -0
- package/dist/commands/watchtower.d.ts +3 -0
- package/dist/commands/watchtower.d.ts.map +1 -0
- package/dist/commands/watchtower.js +238 -0
- package/dist/commands/watchtower.js.map +1 -0
- package/dist/commands/workroom.d.ts +3 -0
- package/dist/commands/workroom.d.ts.map +1 -0
- package/dist/commands/workroom.js +855 -0
- package/dist/commands/workroom.js.map +1 -0
- package/dist/config.d.ts +62 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +141 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon/config.d.ts +74 -0
- package/dist/daemon/config.d.ts.map +1 -0
- package/dist/daemon/config.js +271 -0
- package/dist/daemon/config.js.map +1 -0
- package/dist/daemon/hire-listener.d.ts +31 -0
- package/dist/daemon/hire-listener.d.ts.map +1 -0
- package/dist/daemon/hire-listener.js +207 -0
- package/dist/daemon/hire-listener.js.map +1 -0
- package/dist/daemon/index.d.ts +29 -0
- package/dist/daemon/index.d.ts.map +1 -0
- package/dist/daemon/index.js +535 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/daemon/job-lifecycle.d.ts +62 -0
- package/dist/daemon/job-lifecycle.d.ts.map +1 -0
- package/dist/daemon/job-lifecycle.js +201 -0
- package/dist/daemon/job-lifecycle.js.map +1 -0
- package/dist/daemon/notify.d.ts +22 -0
- package/dist/daemon/notify.d.ts.map +1 -0
- package/dist/daemon/notify.js +148 -0
- package/dist/daemon/notify.js.map +1 -0
- package/dist/daemon/token-metering.d.ts +42 -0
- package/dist/daemon/token-metering.d.ts.map +1 -0
- package/dist/daemon/token-metering.js +178 -0
- package/dist/daemon/token-metering.js.map +1 -0
- package/dist/daemon/userops.d.ts +21 -0
- package/dist/daemon/userops.d.ts.map +1 -0
- package/dist/daemon/userops.js +88 -0
- package/dist/daemon/userops.js.map +1 -0
- package/dist/daemon/wallet-monitor.d.ts +16 -0
- package/dist/daemon/wallet-monitor.d.ts.map +1 -0
- package/dist/daemon/wallet-monitor.js +57 -0
- package/dist/daemon/wallet-monitor.js.map +1 -0
- package/dist/drain-v4.d.ts +2 -0
- package/dist/drain-v4.d.ts.map +1 -0
- package/dist/drain-v4.js +167 -0
- package/dist/drain-v4.js.map +1 -0
- package/dist/endpoint-config.d.ts +36 -0
- package/dist/endpoint-config.d.ts.map +1 -0
- package/dist/endpoint-config.js +96 -0
- package/dist/endpoint-config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +79 -0
- package/dist/index.js.map +1 -0
- package/dist/openshell-runtime.d.ts +55 -0
- package/dist/openshell-runtime.d.ts.map +1 -0
- package/dist/openshell-runtime.js +268 -0
- package/dist/openshell-runtime.js.map +1 -0
- package/dist/signing.d.ts +2 -0
- package/dist/signing.d.ts.map +1 -0
- package/dist/signing.js +23 -0
- package/dist/signing.js.map +1 -0
- package/dist/telegram-notify.d.ts +23 -0
- package/dist/telegram-notify.d.ts.map +1 -0
- package/dist/telegram-notify.js +106 -0
- package/dist/telegram-notify.js.map +1 -0
- package/dist/ui/banner.d.ts +7 -0
- package/dist/ui/banner.d.ts.map +1 -0
- package/dist/ui/banner.js +37 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/colors.d.ts +14 -0
- package/dist/ui/colors.d.ts.map +1 -0
- package/dist/ui/colors.js +29 -0
- package/dist/ui/colors.js.map +1 -0
- package/dist/ui/format.d.ts +26 -0
- package/dist/ui/format.d.ts.map +1 -0
- package/dist/ui/format.js +77 -0
- package/dist/ui/format.js.map +1 -0
- package/dist/ui/spinner.d.ts +8 -0
- package/dist/ui/spinner.d.ts.map +1 -0
- package/dist/ui/spinner.js +43 -0
- package/dist/ui/spinner.js.map +1 -0
- package/dist/utils/format.d.ts +10 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/format.js +61 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/hash.d.ts +3 -0
- package/dist/utils/hash.d.ts.map +1 -0
- package/dist/utils/hash.js +43 -0
- package/dist/utils/hash.js.map +1 -0
- package/dist/utils/time.d.ts +3 -0
- package/dist/utils/time.d.ts.map +1 -0
- package/dist/utils/time.js +21 -0
- package/dist/utils/time.js.map +1 -0
- package/dist/wallet-router.d.ts +25 -0
- package/dist/wallet-router.d.ts.map +1 -0
- package/dist/wallet-router.js +153 -0
- package/dist/wallet-router.js.map +1 -0
- package/dist/walletconnect-session.d.ts +12 -0
- package/dist/walletconnect-session.d.ts.map +1 -0
- package/dist/walletconnect-session.js +26 -0
- package/dist/walletconnect-session.js.map +1 -0
- package/dist/walletconnect.d.ts +46 -0
- package/dist/walletconnect.d.ts.map +1 -0
- package/dist/walletconnect.js +267 -0
- package/dist/walletconnect.js.map +1 -0
- package/package.json +38 -0
- package/scripts/authorize-machine-key.ts +43 -0
- package/scripts/drain-wallet.ts +149 -0
- package/scripts/execute-spend-only.ts +81 -0
- package/scripts/register-agent-userop.ts +186 -0
- package/src/abis.ts +187 -0
- package/src/bundler.ts +235 -0
- package/src/client.ts +34 -0
- package/src/coinbase-smart-wallet.ts +51 -0
- package/src/commands/accept.ts +25 -0
- package/src/commands/agent-handshake.ts +67 -0
- package/src/commands/agent.ts +458 -0
- package/src/commands/agreements.ts +324 -0
- package/src/commands/arbitrator.ts +129 -0
- package/src/commands/arena-handshake.ts +217 -0
- package/src/commands/cancel.ts +26 -0
- package/src/commands/channel.ts +208 -0
- package/src/commands/coldstart.ts +156 -0
- package/src/commands/config.ts +35 -0
- package/src/commands/contract-interaction.ts +166 -0
- package/src/commands/daemon.ts +971 -0
- package/src/commands/deliver.ts +116 -0
- package/src/commands/discover.ts +295 -0
- package/src/commands/dispute.ts +373 -0
- package/src/commands/endpoint.ts +619 -0
- package/src/commands/hire.ts +200 -0
- package/src/commands/migrate.ts +175 -0
- package/src/commands/negotiate.ts +270 -0
- package/src/commands/openshell.ts +1053 -0
- package/src/commands/owner.ts +30 -0
- package/src/commands/policy.ts +252 -0
- package/src/commands/relay.ts +272 -0
- package/src/commands/remediate.ts +22 -0
- package/src/commands/reputation.ts +71 -0
- package/src/commands/setup.ts +343 -0
- package/src/commands/trust.ts +15 -0
- package/src/commands/verify.ts +88 -0
- package/src/commands/wallet.ts +2892 -0
- package/src/commands/watchtower.ts +232 -0
- package/src/commands/workroom.ts +889 -0
- package/src/config.ts +153 -0
- package/src/daemon/config.ts +308 -0
- package/src/daemon/hire-listener.ts +226 -0
- package/src/daemon/index.ts +609 -0
- package/src/daemon/job-lifecycle.ts +215 -0
- package/src/daemon/notify.ts +157 -0
- package/src/daemon/token-metering.ts +183 -0
- package/src/daemon/userops.ts +119 -0
- package/src/daemon/wallet-monitor.ts +90 -0
- package/src/drain-v4.ts +159 -0
- package/src/endpoint-config.ts +83 -0
- package/src/index.ts +75 -0
- package/src/openshell-runtime.ts +277 -0
- package/src/signing.ts +28 -0
- package/src/telegram-notify.ts +88 -0
- package/src/ui/banner.ts +41 -0
- package/src/ui/colors.ts +30 -0
- package/src/ui/format.ts +77 -0
- package/src/ui/spinner.ts +46 -0
- package/src/utils/format.ts +48 -0
- package/src/utils/hash.ts +5 -0
- package/src/utils/time.ts +15 -0
- package/src/wallet-router.ts +178 -0
- package/src/walletconnect-session.ts +27 -0
- package/src/walletconnect.ts +294 -0
- package/test/time.test.js +11 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,2892 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { PolicyClient, TrustClient } from "@arc402/sdk";
|
|
3
|
+
import { ethers } from "ethers";
|
|
4
|
+
import prompts from "prompts";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import os from "os";
|
|
8
|
+
import { spawnSync } from "child_process";
|
|
9
|
+
import { Arc402Config, getConfigPath, getUsdcAddress, loadConfig, NETWORK_DEFAULTS, saveConfig } from "../config";
|
|
10
|
+
import { getClient, requireSigner } from "../client";
|
|
11
|
+
import { getTrustTier } from "../utils/format";
|
|
12
|
+
import { ARC402_WALLET_EXECUTE_ABI, ARC402_WALLET_GUARDIAN_ABI, ARC402_WALLET_MACHINE_KEY_ABI, ARC402_WALLET_OWNER_ABI, ARC402_WALLET_PASSKEY_ABI, ARC402_WALLET_PROTOCOL_ABI, ARC402_WALLET_REGISTRY_ABI, POLICY_ENGINE_GOVERNANCE_ABI, POLICY_ENGINE_LIMITS_ABI, TRUST_REGISTRY_ABI, WALLET_FACTORY_ABI } from "../abis";
|
|
13
|
+
import { warnIfPublicRpc } from "../config";
|
|
14
|
+
import { connectPhoneWallet, sendTransactionWithSession, requestPhoneWalletSignature } from "../walletconnect";
|
|
15
|
+
import { BundlerClient, buildSponsoredUserOp, PaymasterClient, DEFAULT_ENTRY_POINT } from "../bundler";
|
|
16
|
+
import { clearWCSession } from "../walletconnect-session";
|
|
17
|
+
import { handleWalletError } from "../wallet-router";
|
|
18
|
+
import { requestCoinbaseSmartWalletSignature } from "../coinbase-smart-wallet";
|
|
19
|
+
import { sendTelegramMessage } from "../telegram-notify";
|
|
20
|
+
|
|
21
|
+
const POLICY_ENGINE_DEFAULT = "0x44102e70c2A366632d98Fe40d892a2501fC7fFF2";
|
|
22
|
+
|
|
23
|
+
function parseAmount(raw: string): bigint {
|
|
24
|
+
const lower = raw.toLowerCase();
|
|
25
|
+
if (lower.endsWith("eth")) {
|
|
26
|
+
return ethers.parseEther(lower.slice(0, -3).trim());
|
|
27
|
+
}
|
|
28
|
+
return BigInt(raw);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Standard onboarding categories required for a newly deployed wallet.
|
|
32
|
+
const ONBOARDING_CATEGORIES = [
|
|
33
|
+
{ name: "general", amountEth: "0.001" },
|
|
34
|
+
{ name: "compute", amountEth: "0.05" },
|
|
35
|
+
{ name: "research", amountEth: "0.05" },
|
|
36
|
+
{ name: "protocol", amountEth: "0.1" },
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* P0: Mandatory post-deploy onboarding ceremony.
|
|
41
|
+
* Registers wallet on PolicyEngine, enables DeFi access, and sets required spend limits.
|
|
42
|
+
* Uses a sendTx callback so it works with any signing method (WalletConnect, private key, etc.).
|
|
43
|
+
* Skips registerWallet/enableDeFiAccess if they were already done by the wallet constructor.
|
|
44
|
+
*/
|
|
45
|
+
async function runWalletOnboardingCeremony(
|
|
46
|
+
walletAddress: string,
|
|
47
|
+
ownerAddress: string,
|
|
48
|
+
config: Arc402Config,
|
|
49
|
+
provider: ethers.JsonRpcProvider,
|
|
50
|
+
sendTx: (call: { to: string; data: string; value: string }, description: string) => Promise<string>,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const policyAddress = config.policyEngineAddress ?? POLICY_ENGINE_DEFAULT;
|
|
53
|
+
const executeIface = new ethers.Interface(ARC402_WALLET_EXECUTE_ABI);
|
|
54
|
+
const govIface = new ethers.Interface(POLICY_ENGINE_GOVERNANCE_ABI);
|
|
55
|
+
const limitsIface = new ethers.Interface(POLICY_ENGINE_LIMITS_ABI);
|
|
56
|
+
const policyGov = new ethers.Contract(policyAddress, POLICY_ENGINE_GOVERNANCE_ABI, provider);
|
|
57
|
+
|
|
58
|
+
// Check what's already done (constructor may have done registerWallet + enableDefiAccess)
|
|
59
|
+
let alreadyRegistered = false;
|
|
60
|
+
let alreadyDefiEnabled = false;
|
|
61
|
+
try {
|
|
62
|
+
const registeredOwner: string = await policyGov.walletOwners(walletAddress);
|
|
63
|
+
alreadyRegistered = registeredOwner !== ethers.ZeroAddress;
|
|
64
|
+
} catch { /* older PolicyEngine without this getter — assume not registered */ }
|
|
65
|
+
try {
|
|
66
|
+
alreadyDefiEnabled = await policyGov.defiAccessEnabled(walletAddress);
|
|
67
|
+
} catch { /* assume not enabled */ }
|
|
68
|
+
|
|
69
|
+
console.log("\n── Onboarding ceremony ────────────────────────────────────────");
|
|
70
|
+
console.log(` PolicyEngine: ${policyAddress}`);
|
|
71
|
+
console.log(` Wallet: ${walletAddress}`);
|
|
72
|
+
|
|
73
|
+
// Step 1: registerWallet (if not already done)
|
|
74
|
+
if (!alreadyRegistered) {
|
|
75
|
+
const registerCalldata = govIface.encodeFunctionData("registerWallet", [walletAddress, ownerAddress]);
|
|
76
|
+
await sendTx({
|
|
77
|
+
to: walletAddress,
|
|
78
|
+
data: executeIface.encodeFunctionData("executeContractCall", [{
|
|
79
|
+
target: policyAddress,
|
|
80
|
+
data: registerCalldata,
|
|
81
|
+
value: 0n,
|
|
82
|
+
minReturnValue: 0n,
|
|
83
|
+
maxApprovalAmount: 0n,
|
|
84
|
+
approvalToken: ethers.ZeroAddress,
|
|
85
|
+
}]),
|
|
86
|
+
value: "0x0",
|
|
87
|
+
}, "registerWallet on PolicyEngine");
|
|
88
|
+
} else {
|
|
89
|
+
console.log(" ✓ registerWallet — already done by constructor");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Step 2: enableDefiAccess (if not already done)
|
|
93
|
+
if (!alreadyDefiEnabled) {
|
|
94
|
+
await sendTx({
|
|
95
|
+
to: policyAddress,
|
|
96
|
+
data: govIface.encodeFunctionData("enableDefiAccess", [walletAddress]),
|
|
97
|
+
value: "0x0",
|
|
98
|
+
}, "enableDefiAccess on PolicyEngine");
|
|
99
|
+
} else {
|
|
100
|
+
console.log(" ✓ enableDefiAccess — already done by constructor");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Steps 3–6: category limits (always set — idempotent)
|
|
104
|
+
for (const { name, amountEth } of ONBOARDING_CATEGORIES) {
|
|
105
|
+
await sendTx({
|
|
106
|
+
to: policyAddress,
|
|
107
|
+
data: limitsIface.encodeFunctionData("setCategoryLimitFor", [
|
|
108
|
+
walletAddress,
|
|
109
|
+
name,
|
|
110
|
+
ethers.parseEther(amountEth),
|
|
111
|
+
]),
|
|
112
|
+
value: "0x0",
|
|
113
|
+
}, `setCategoryLimitFor: ${name} → ${amountEth} ETH`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log("── Onboarding complete ─────────────────────────────────────────");
|
|
117
|
+
console.log("💡 Tip: For production security, also configure:");
|
|
118
|
+
console.log(" arc402 wallet set-velocity-limit <eth> — wallet-level hourly ETH cap");
|
|
119
|
+
console.log(" arc402 wallet policy set-daily-limit --category general --amount <eth> — daily per-category cap");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printOpenShellHint(): void {
|
|
123
|
+
const r = spawnSync("which", ["openshell"], { encoding: "utf-8" });
|
|
124
|
+
if (r.status === 0 && r.stdout.trim()) {
|
|
125
|
+
console.log("\nOpenShell detected. Run: arc402 openshell init");
|
|
126
|
+
} else {
|
|
127
|
+
console.log("\nOptional: install OpenShell for sandboxed execution: arc402 openshell install");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function registerWalletCommands(program: Command): void {
|
|
132
|
+
const wallet = program.command("wallet").description("Wallet utilities");
|
|
133
|
+
|
|
134
|
+
// ─── status ────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
wallet.command("status").description("Show address, balances, contract wallet, guardian, and frozen status").option("--json").action(async (opts) => {
|
|
137
|
+
const config = loadConfig();
|
|
138
|
+
const { provider, address } = await getClient(config);
|
|
139
|
+
if (!address) throw new Error("No wallet configured");
|
|
140
|
+
const usdcAddress = getUsdcAddress(config);
|
|
141
|
+
const usdc = new ethers.Contract(usdcAddress, ["function balanceOf(address owner) external view returns (uint256)"], provider);
|
|
142
|
+
const trust = new TrustClient(config.trustRegistryAddress, provider);
|
|
143
|
+
const [ethBalance, usdcBalance, score] = await Promise.all([
|
|
144
|
+
provider.getBalance(address),
|
|
145
|
+
usdc.balanceOf(address),
|
|
146
|
+
trust.getScore(address),
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
// Query contract wallet for frozen/guardian state if deployed
|
|
150
|
+
let contractFrozen: boolean | null = null;
|
|
151
|
+
let contractGuardian: string | null = null;
|
|
152
|
+
if (config.walletContractAddress) {
|
|
153
|
+
try {
|
|
154
|
+
const walletContract = new ethers.Contract(config.walletContractAddress, ARC402_WALLET_GUARDIAN_ABI, provider);
|
|
155
|
+
[contractFrozen, contractGuardian] = await Promise.all([
|
|
156
|
+
walletContract.frozen(),
|
|
157
|
+
walletContract.guardian(),
|
|
158
|
+
]);
|
|
159
|
+
} catch { /* contract may not be deployed yet */ }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const payload = {
|
|
163
|
+
address,
|
|
164
|
+
network: config.network,
|
|
165
|
+
ethBalance: ethers.formatEther(ethBalance),
|
|
166
|
+
usdcBalance: (Number(usdcBalance) / 1e6).toFixed(2),
|
|
167
|
+
trustScore: score.score,
|
|
168
|
+
trustTier: getTrustTier(score.score),
|
|
169
|
+
walletContractAddress: config.walletContractAddress ?? null,
|
|
170
|
+
frozen: contractFrozen,
|
|
171
|
+
guardian: contractGuardian,
|
|
172
|
+
guardianAddress: config.guardianAddress ?? null,
|
|
173
|
+
};
|
|
174
|
+
if (opts.json) {
|
|
175
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
176
|
+
} else {
|
|
177
|
+
console.log(`${payload.address}\nETH=${payload.ethBalance}\nUSDC=${payload.usdcBalance}\nTrust=${payload.trustScore} ${payload.trustTier}`);
|
|
178
|
+
if (payload.walletContractAddress) console.log(`Contract=${payload.walletContractAddress}`);
|
|
179
|
+
if (contractFrozen !== null) console.log(`Frozen=${contractFrozen}`);
|
|
180
|
+
if (contractGuardian && contractGuardian !== ethers.ZeroAddress) console.log(`Guardian=${contractGuardian}`);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ─── wc-reset ──────────────────────────────────────────────────────────────
|
|
185
|
+
//
|
|
186
|
+
// Clears the saved WalletConnect session from config AND wipes the WC SDK
|
|
187
|
+
// storage file (~/.arc402/wc-storage.json). Use when MetaMask killed the
|
|
188
|
+
// session on its end and the CLI is stuck trying to resume a dead connection.
|
|
189
|
+
// Next wallet command will trigger a fresh QR pairing flow.
|
|
190
|
+
|
|
191
|
+
wallet.command("wc-reset")
|
|
192
|
+
.description("Clear stale WalletConnect session — forces a fresh QR pairing on next connection")
|
|
193
|
+
.option("--json")
|
|
194
|
+
.action(async (opts) => {
|
|
195
|
+
const config = loadConfig();
|
|
196
|
+
|
|
197
|
+
const hadSession = !!config.wcSession;
|
|
198
|
+
|
|
199
|
+
// 1. Clear from config
|
|
200
|
+
clearWCSession(config);
|
|
201
|
+
|
|
202
|
+
// 2. Wipe WC SDK storage (may be a file or a directory depending on SDK version)
|
|
203
|
+
const wcStoragePath = path.join(os.homedir(), ".arc402", "wc-storage.json");
|
|
204
|
+
let storageWiped = false;
|
|
205
|
+
try {
|
|
206
|
+
if (fs.existsSync(wcStoragePath)) {
|
|
207
|
+
fs.rmSync(wcStoragePath, { recursive: true, force: true });
|
|
208
|
+
storageWiped = true;
|
|
209
|
+
}
|
|
210
|
+
} catch (e) {
|
|
211
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
212
|
+
if (opts.json) {
|
|
213
|
+
console.log(JSON.stringify({ ok: false, error: `Could not delete ${wcStoragePath}: ${msg}` }));
|
|
214
|
+
} else {
|
|
215
|
+
console.warn(`⚠ Could not delete ${wcStoragePath}: ${msg}`);
|
|
216
|
+
console.warn(" You may need to delete it manually.");
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (opts.json) {
|
|
222
|
+
console.log(JSON.stringify({ ok: true, hadSession, storageWiped }));
|
|
223
|
+
} else {
|
|
224
|
+
console.log("✓ WalletConnect session cleared");
|
|
225
|
+
if (storageWiped) console.log(` Storage wiped: ${wcStoragePath}`);
|
|
226
|
+
else console.log(" (No storage file found — already clean)");
|
|
227
|
+
console.log("\nNext: run any wallet command and scan the fresh QR code.");
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ─── new ───────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
wallet.command("new")
|
|
234
|
+
.description("Generate a fresh keypair and save to config")
|
|
235
|
+
.option("--network <network>", "Network (base-mainnet or base-sepolia)", "base-sepolia")
|
|
236
|
+
.action(async (opts) => {
|
|
237
|
+
const network = opts.network as "base-mainnet" | "base-sepolia";
|
|
238
|
+
const defaults = NETWORK_DEFAULTS[network];
|
|
239
|
+
if (!defaults) {
|
|
240
|
+
console.error(`Unknown network: ${network}. Use base-mainnet or base-sepolia.`);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
const generated = ethers.Wallet.createRandom();
|
|
244
|
+
const config: Arc402Config = {
|
|
245
|
+
network,
|
|
246
|
+
rpcUrl: defaults.rpcUrl!,
|
|
247
|
+
privateKey: generated.privateKey,
|
|
248
|
+
trustRegistryAddress: defaults.trustRegistryAddress!,
|
|
249
|
+
walletFactoryAddress: defaults.walletFactoryAddress,
|
|
250
|
+
};
|
|
251
|
+
saveConfig(config);
|
|
252
|
+
console.log(`Address: ${generated.address}`);
|
|
253
|
+
console.log(`Config saved to ${getConfigPath()}`);
|
|
254
|
+
console.log(`Next: fund your wallet with ETH, then run: arc402 wallet deploy`);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ─── import ────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
wallet.command("import <privateKey>")
|
|
260
|
+
.description("Import an existing private key")
|
|
261
|
+
.option("--network <network>", "Network (base-mainnet or base-sepolia)", "base-sepolia")
|
|
262
|
+
.action(async (privateKey, opts) => {
|
|
263
|
+
const network = opts.network as "base-mainnet" | "base-sepolia";
|
|
264
|
+
const defaults = NETWORK_DEFAULTS[network];
|
|
265
|
+
if (!defaults) {
|
|
266
|
+
console.error(`Unknown network: ${network}. Use base-mainnet or base-sepolia.`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
let imported: ethers.Wallet;
|
|
270
|
+
try {
|
|
271
|
+
imported = new ethers.Wallet(privateKey);
|
|
272
|
+
} catch {
|
|
273
|
+
console.error("Invalid private key. Must be a 0x-prefixed hex string.");
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
const config: Arc402Config = {
|
|
277
|
+
network,
|
|
278
|
+
rpcUrl: defaults.rpcUrl!,
|
|
279
|
+
privateKey: imported.privateKey,
|
|
280
|
+
trustRegistryAddress: defaults.trustRegistryAddress!,
|
|
281
|
+
walletFactoryAddress: defaults.walletFactoryAddress,
|
|
282
|
+
};
|
|
283
|
+
saveConfig(config);
|
|
284
|
+
console.log(`Address: ${imported.address}`);
|
|
285
|
+
console.log(`Config saved to ${getConfigPath()}`);
|
|
286
|
+
console.warn(`WARN: Store your private key safely — anyone with it controls your wallet`);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ─── fund ──────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
wallet.command("fund")
|
|
292
|
+
.description("Show how to get ETH onto your wallet")
|
|
293
|
+
.action(async () => {
|
|
294
|
+
const config = loadConfig();
|
|
295
|
+
const { provider, address } = await getClient(config);
|
|
296
|
+
if (!address) throw new Error("No wallet configured");
|
|
297
|
+
const ethBalance = await provider.getBalance(address);
|
|
298
|
+
console.log(`\nYour wallet address:\n ${address}`);
|
|
299
|
+
console.log(`\nCurrent balance: ${ethers.formatEther(ethBalance)} ETH`);
|
|
300
|
+
console.log(`\nFunding options:`);
|
|
301
|
+
console.log(` Bridge (Base mainnet): https://bridge.base.org`);
|
|
302
|
+
console.log(` Coinbase: If you use Coinbase, you can withdraw directly to Base mainnet`);
|
|
303
|
+
if (config.network === "base-sepolia") {
|
|
304
|
+
console.log(` Testnet faucet: https://www.alchemy.com/faucets/base-sepolia`);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ─── balance ───────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
wallet.command("balance")
|
|
311
|
+
.description("Check ETH balance on Base")
|
|
312
|
+
.option("--json")
|
|
313
|
+
.action(async (opts) => {
|
|
314
|
+
const config = loadConfig();
|
|
315
|
+
const { provider, address } = await getClient(config);
|
|
316
|
+
if (!address) throw new Error("No wallet configured");
|
|
317
|
+
const ethBalance = await provider.getBalance(address);
|
|
318
|
+
const formatted = ethers.formatEther(ethBalance);
|
|
319
|
+
if (opts.json) {
|
|
320
|
+
console.log(JSON.stringify({ address, balance: formatted, balanceWei: ethBalance.toString() }));
|
|
321
|
+
} else {
|
|
322
|
+
console.log(`Balance: ${formatted} ETH`);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ─── list ──────────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
wallet.command("list")
|
|
329
|
+
.description("List all ARC402Wallet contracts owned by the configured master key")
|
|
330
|
+
.option("--owner <address>", "Master key address to query (defaults to config.ownerAddress)")
|
|
331
|
+
.option("--json")
|
|
332
|
+
.action(async (opts) => {
|
|
333
|
+
const config = loadConfig();
|
|
334
|
+
const factoryAddress = config.walletFactoryAddress ?? NETWORK_DEFAULTS[config.network]?.walletFactoryAddress;
|
|
335
|
+
if (!factoryAddress) {
|
|
336
|
+
console.error("walletFactoryAddress not found in config or NETWORK_DEFAULTS.");
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
const ownerAddress: string = opts.owner ?? config.ownerAddress;
|
|
340
|
+
if (!ownerAddress) {
|
|
341
|
+
console.error("No owner address. Pass --owner <address> or set ownerAddress in config (run `arc402 wallet deploy` first).");
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
345
|
+
const factory = new ethers.Contract(factoryAddress, WALLET_FACTORY_ABI, provider);
|
|
346
|
+
const wallets: string[] = await factory.getWallets(ownerAddress);
|
|
347
|
+
|
|
348
|
+
const results = await Promise.all(
|
|
349
|
+
wallets.map(async (addr) => {
|
|
350
|
+
const walletContract = new ethers.Contract(addr, ARC402_WALLET_GUARDIAN_ABI, provider);
|
|
351
|
+
const trustContract = new ethers.Contract(config.trustRegistryAddress, TRUST_REGISTRY_ABI, provider);
|
|
352
|
+
const [frozen, score] = await Promise.all([
|
|
353
|
+
walletContract.frozen().catch(() => null),
|
|
354
|
+
trustContract.getScore(addr).catch(() => BigInt(0)),
|
|
355
|
+
]);
|
|
356
|
+
return { address: addr, frozen: frozen as boolean | null, score: Number(score) };
|
|
357
|
+
})
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
if (opts.json) {
|
|
361
|
+
console.log(JSON.stringify(results.map((w, i) => ({
|
|
362
|
+
index: i + 1,
|
|
363
|
+
address: w.address,
|
|
364
|
+
active: w.address.toLowerCase() === config.walletContractAddress?.toLowerCase(),
|
|
365
|
+
trustScore: w.score,
|
|
366
|
+
frozen: w.frozen,
|
|
367
|
+
})), null, 2));
|
|
368
|
+
} else {
|
|
369
|
+
const short = (addr: string) => `${addr.slice(0, 6)}...${addr.slice(-5)}`;
|
|
370
|
+
console.log(`\nARC-402 Wallets owned by ${short(ownerAddress)}\n`);
|
|
371
|
+
results.forEach((w, i) => {
|
|
372
|
+
const active = w.address.toLowerCase() === config.walletContractAddress?.toLowerCase();
|
|
373
|
+
const activeTag = active ? " [active]" : " ";
|
|
374
|
+
console.log(` #${i + 1} ${w.address}${activeTag} Trust: ${w.score} Frozen: ${w.frozen}`);
|
|
375
|
+
});
|
|
376
|
+
console.log(`\n ${results.length} wallet${results.length === 1 ? "" : "s"} total`);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ─── use ───────────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
wallet.command("use <address>")
|
|
383
|
+
.description("Switch the active wallet contract address in config")
|
|
384
|
+
.action(async (address) => {
|
|
385
|
+
let checksumAddress: string;
|
|
386
|
+
try {
|
|
387
|
+
checksumAddress = ethers.getAddress(address);
|
|
388
|
+
} catch {
|
|
389
|
+
console.error(`Invalid address: ${address}`);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
const config = loadConfig();
|
|
393
|
+
const factoryAddress = config.walletFactoryAddress ?? NETWORK_DEFAULTS[config.network]?.walletFactoryAddress;
|
|
394
|
+
if (factoryAddress && config.ownerAddress) {
|
|
395
|
+
try {
|
|
396
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
397
|
+
const factory = new ethers.Contract(factoryAddress, WALLET_FACTORY_ABI, provider);
|
|
398
|
+
const wallets: string[] = await factory.getWallets(config.ownerAddress);
|
|
399
|
+
const found = wallets.some((w) => w.toLowerCase() === checksumAddress.toLowerCase());
|
|
400
|
+
if (!found) {
|
|
401
|
+
console.warn(`WARN: ${checksumAddress} was not found in WalletFactory wallets for owner ${config.ownerAddress}`);
|
|
402
|
+
console.warn(" Proceeding anyway — use 'arc402 wallet list' to see known wallets.");
|
|
403
|
+
}
|
|
404
|
+
} catch { /* allow override if factory call fails */ }
|
|
405
|
+
}
|
|
406
|
+
config.walletContractAddress = checksumAddress;
|
|
407
|
+
saveConfig(config);
|
|
408
|
+
console.log(`Active wallet set to ${checksumAddress}`);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ─── deploy ────────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
wallet.command("deploy")
|
|
414
|
+
.description("Deploy ARC402Wallet contract via WalletFactory (phone wallet signs via WalletConnect)")
|
|
415
|
+
.option("--smart-wallet", "Connect via Base Smart Wallet (Coinbase Wallet SDK) instead of WalletConnect")
|
|
416
|
+
.option("--hardware", "Hardware wallet mode: show raw wc: URI only (for Ledger Live, Trezor Suite, etc.)")
|
|
417
|
+
.option("--sponsored", "Use CDP paymaster for gas sponsorship (requires paymasterUrl + cdpKeyName + CDP_PRIVATE_KEY env)")
|
|
418
|
+
.action(async (opts) => {
|
|
419
|
+
const config = loadConfig();
|
|
420
|
+
const factoryAddress = config.walletFactoryAddress ?? NETWORK_DEFAULTS[config.network]?.walletFactoryAddress;
|
|
421
|
+
if (!factoryAddress) {
|
|
422
|
+
console.error("walletFactoryAddress not found in config or NETWORK_DEFAULTS. Add walletFactoryAddress to your config.");
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
426
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
427
|
+
const factoryInterface = new ethers.Interface(WALLET_FACTORY_ABI);
|
|
428
|
+
|
|
429
|
+
if (opts.sponsored) {
|
|
430
|
+
// ── Sponsored deploy via CDP paymaster + ERC-4337 bundler ─────────────
|
|
431
|
+
// Note: WalletFactoryV3/V4 use msg.sender as wallet owner. In ERC-4337
|
|
432
|
+
// context msg.sender = EntryPoint. A factory upgrade with explicit owner
|
|
433
|
+
// param is needed for fully correct sponsored deployment. Until then,
|
|
434
|
+
// this path is available for testing and future-proofing.
|
|
435
|
+
const paymasterUrl = config.paymasterUrl ?? NETWORK_DEFAULTS[config.network]?.paymasterUrl;
|
|
436
|
+
const cdpKeyName = config.cdpKeyName ?? process.env.CDP_KEY_NAME;
|
|
437
|
+
const cdpPrivateKey = config.cdpPrivateKey ?? process.env.CDP_PRIVATE_KEY;
|
|
438
|
+
if (!paymasterUrl) {
|
|
439
|
+
console.error("paymasterUrl not configured. Add it to config or set NEXT_PUBLIC_PAYMASTER_URL.");
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
const { signer, address: ownerAddress } = await requireSigner(config);
|
|
443
|
+
const bundlerUrl = process.env.BUNDLER_URL ?? "https://api.pimlico.io/v2/base/rpc";
|
|
444
|
+
const pm = new PaymasterClient(paymasterUrl, cdpKeyName, cdpPrivateKey);
|
|
445
|
+
const bundler = new BundlerClient(bundlerUrl, DEFAULT_ENTRY_POINT, chainId);
|
|
446
|
+
|
|
447
|
+
console.log(`Sponsoring deploy via ${paymasterUrl}...`);
|
|
448
|
+
const factoryIface = new ethers.Interface(WALLET_FACTORY_ABI);
|
|
449
|
+
const factoryData = factoryIface.encodeFunctionData("createWallet", [DEFAULT_ENTRY_POINT]);
|
|
450
|
+
|
|
451
|
+
// Predict counterfactual sender address using EntryPoint.getSenderAddress
|
|
452
|
+
const entryPoint = new ethers.Contract(
|
|
453
|
+
DEFAULT_ENTRY_POINT,
|
|
454
|
+
["function getSenderAddress(bytes calldata initCode) external"],
|
|
455
|
+
provider
|
|
456
|
+
);
|
|
457
|
+
const initCodePacked = ethers.concat([factoryAddress, factoryData]);
|
|
458
|
+
let senderAddress: string;
|
|
459
|
+
try {
|
|
460
|
+
// getSenderAddress always reverts with SenderAddressResult(address)
|
|
461
|
+
await entryPoint.getSenderAddress(initCodePacked);
|
|
462
|
+
throw new Error("getSenderAddress did not revert as expected");
|
|
463
|
+
} catch (e: unknown) {
|
|
464
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
465
|
+
const match = msg.match(/0x6ca7b806([0-9a-fA-F]{64})/);
|
|
466
|
+
if (!match) {
|
|
467
|
+
console.error("Could not predict wallet address:", msg);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
}
|
|
470
|
+
senderAddress = ethers.getAddress("0x" + match[1].slice(24));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
console.log(`Predicted wallet address: ${senderAddress}`);
|
|
474
|
+
const userOp = await pm.sponsorUserOperation(
|
|
475
|
+
{
|
|
476
|
+
sender: senderAddress,
|
|
477
|
+
nonce: "0x0",
|
|
478
|
+
callData: "0x",
|
|
479
|
+
factory: factoryAddress,
|
|
480
|
+
factoryData,
|
|
481
|
+
callGasLimit: ethers.toBeHex(300_000),
|
|
482
|
+
verificationGasLimit: ethers.toBeHex(400_000),
|
|
483
|
+
preVerificationGas: ethers.toBeHex(60_000),
|
|
484
|
+
maxFeePerGas: ethers.toBeHex((await provider.getFeeData()).maxFeePerGas ?? BigInt(1_000_000_000)),
|
|
485
|
+
maxPriorityFeePerGas: ethers.toBeHex((await provider.getFeeData()).maxPriorityFeePerGas ?? BigInt(100_000_000)),
|
|
486
|
+
signature: "0x",
|
|
487
|
+
},
|
|
488
|
+
DEFAULT_ENTRY_POINT
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
// Sign UserOp with owner key
|
|
492
|
+
const userOpHash = ethers.keccak256(
|
|
493
|
+
ethers.AbiCoder.defaultAbiCoder().encode(
|
|
494
|
+
["address", "uint256", "bytes32", "bytes32", "bytes32", "uint256", "bytes32", "bytes32"],
|
|
495
|
+
[
|
|
496
|
+
userOp.sender, BigInt(userOp.nonce),
|
|
497
|
+
ethers.keccak256(userOp.factory ? ethers.concat([userOp.factory, userOp.factoryData ?? "0x"]) : "0x"),
|
|
498
|
+
ethers.keccak256(userOp.callData),
|
|
499
|
+
ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(
|
|
500
|
+
["uint256", "uint256", "uint256", "uint256", "uint256", "address", "bytes"],
|
|
501
|
+
[userOp.verificationGasLimit, userOp.callGasLimit, userOp.preVerificationGas, userOp.maxFeePerGas, userOp.maxPriorityFeePerGas, userOp.paymaster ?? ethers.ZeroAddress, userOp.paymasterData ?? "0x"]
|
|
502
|
+
)),
|
|
503
|
+
BigInt(chainId), DEFAULT_ENTRY_POINT, ethers.ZeroHash,
|
|
504
|
+
]
|
|
505
|
+
)
|
|
506
|
+
);
|
|
507
|
+
userOp.signature = await signer.signMessage(ethers.getBytes(userOpHash));
|
|
508
|
+
|
|
509
|
+
const userOpHash2 = await bundler.sendUserOperation(userOp);
|
|
510
|
+
console.log(`UserOp submitted: ${userOpHash2}`);
|
|
511
|
+
console.log("Waiting for confirmation...");
|
|
512
|
+
const receipt = await bundler.getUserOperationReceipt(userOpHash2);
|
|
513
|
+
if (!receipt.success) {
|
|
514
|
+
console.error("UserOperation failed on-chain.");
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
config.walletContractAddress = senderAddress;
|
|
519
|
+
config.ownerAddress = ownerAddress;
|
|
520
|
+
saveConfig(config);
|
|
521
|
+
console.log(`\n✓ ARC402Wallet deployed (sponsored) at: ${senderAddress}`);
|
|
522
|
+
console.log("Gas sponsorship active — initial setup ops are free");
|
|
523
|
+
console.log(`Owner: ${ownerAddress}`);
|
|
524
|
+
console.log(`\n⚠ IMPORTANT: Onboarding ceremony was not run on this wallet.`);
|
|
525
|
+
console.log(` Category spend limits have NOT been configured. All executeSpend and`);
|
|
526
|
+
console.log(` executeTokenSpend calls will fail with "PolicyEngine: category not configured"`);
|
|
527
|
+
console.log(` until you run governance setup manually via WalletConnect:`);
|
|
528
|
+
console.log(`\n arc402 wallet governance setup`);
|
|
529
|
+
console.log(`\n This must be done before making any spend from this wallet.`);
|
|
530
|
+
console.log(`\nNext: arc402 wallet set-passkey <x> <y> --sponsored`);
|
|
531
|
+
printOpenShellHint();
|
|
532
|
+
} else if (opts.smartWallet) {
|
|
533
|
+
const { txHash, account } = await requestCoinbaseSmartWalletSignature(
|
|
534
|
+
chainId,
|
|
535
|
+
(ownerAccount) => ({
|
|
536
|
+
to: factoryAddress,
|
|
537
|
+
data: factoryInterface.encodeFunctionData("createWallet", ["0x0000000071727De22E5E9d8BAf0edAc6f37da032"]),
|
|
538
|
+
value: "0x0",
|
|
539
|
+
}),
|
|
540
|
+
"Approve ARC402Wallet deployment — you will be set as owner"
|
|
541
|
+
);
|
|
542
|
+
console.log(`\nTransaction submitted: ${txHash}`);
|
|
543
|
+
console.log("Waiting for confirmation...");
|
|
544
|
+
const receipt = await provider.waitForTransaction(txHash);
|
|
545
|
+
if (!receipt) {
|
|
546
|
+
console.error("Transaction not confirmed. Check on-chain.");
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
let walletAddress: string | null = null;
|
|
550
|
+
const factoryContract = new ethers.Contract(factoryAddress, WALLET_FACTORY_ABI, provider);
|
|
551
|
+
for (const log of receipt.logs) {
|
|
552
|
+
try {
|
|
553
|
+
const parsed = factoryContract.interface.parseLog(log);
|
|
554
|
+
if (parsed?.name === "WalletCreated") {
|
|
555
|
+
walletAddress = parsed.args.walletAddress as string;
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
} catch { /* skip unparseable logs */ }
|
|
559
|
+
}
|
|
560
|
+
if (!walletAddress) {
|
|
561
|
+
console.error("Could not find WalletCreated event in receipt. Check the transaction on-chain.");
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
config.walletContractAddress = walletAddress;
|
|
565
|
+
config.ownerAddress = account;
|
|
566
|
+
saveConfig(config);
|
|
567
|
+
console.log(`ARC402Wallet deployed at: ${walletAddress}`);
|
|
568
|
+
console.log(`Owner: ${account} (your Base Smart Wallet)`);
|
|
569
|
+
console.log(`Your wallet contract is ready for policy enforcement`);
|
|
570
|
+
console.log(`\nNext: run 'arc402 wallet set-guardian' to configure the emergency guardian key.`);
|
|
571
|
+
printOpenShellHint();
|
|
572
|
+
} else if (config.walletConnectProjectId) {
|
|
573
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
574
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
575
|
+
: undefined;
|
|
576
|
+
|
|
577
|
+
// ── Step 1: Connect ────────────────────────────────────────────────────
|
|
578
|
+
const { client, session, account } = await connectPhoneWallet(
|
|
579
|
+
config.walletConnectProjectId,
|
|
580
|
+
chainId,
|
|
581
|
+
config,
|
|
582
|
+
{ telegramOpts, prompt: "Approve ARC402Wallet deployment — you will be set as owner", hardware: !!opts.hardware }
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
const networkName = chainId === 8453 ? "Base" : "Base Sepolia";
|
|
586
|
+
const shortAddr = `${account.slice(0, 6)}...${account.slice(-5)}`;
|
|
587
|
+
console.log(`\n✓ Connected: ${shortAddr} on ${networkName}`);
|
|
588
|
+
|
|
589
|
+
if (telegramOpts) {
|
|
590
|
+
// Send "connected" message with a deploy confirmation button.
|
|
591
|
+
// TODO: wire up full callback_data round-trip when a persistent bot process is available.
|
|
592
|
+
await sendTelegramMessage({
|
|
593
|
+
botToken: telegramOpts.botToken,
|
|
594
|
+
chatId: telegramOpts.chatId,
|
|
595
|
+
threadId: telegramOpts.threadId,
|
|
596
|
+
text: `✓ Wallet connected: ${shortAddr} — tap to deploy:`,
|
|
597
|
+
buttons: [[{ text: "🚀 Deploy ARC-402 Wallet", callback_data: "arc402_deploy_confirm" }]],
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── Step 2: Confirm & Deploy ───────────────────────────────────────────
|
|
602
|
+
// WalletConnect approval already confirmed intent — sending automatically
|
|
603
|
+
|
|
604
|
+
console.log("Deploying...");
|
|
605
|
+
const txHash = await sendTransactionWithSession(client, session, account, chainId, {
|
|
606
|
+
to: factoryAddress,
|
|
607
|
+
data: factoryInterface.encodeFunctionData("createWallet", ["0x0000000071727De22E5E9d8BAf0edAc6f37da032"]),
|
|
608
|
+
value: "0x0",
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
console.log(`\nTransaction submitted: ${txHash}`);
|
|
612
|
+
console.log("Waiting for confirmation...");
|
|
613
|
+
const receipt = await provider.waitForTransaction(txHash);
|
|
614
|
+
if (!receipt) {
|
|
615
|
+
console.error("Transaction not confirmed. Check on-chain.");
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
let walletAddress: string | null = null;
|
|
619
|
+
const factoryContract = new ethers.Contract(factoryAddress, WALLET_FACTORY_ABI, provider);
|
|
620
|
+
for (const log of receipt.logs) {
|
|
621
|
+
try {
|
|
622
|
+
const parsed = factoryContract.interface.parseLog(log);
|
|
623
|
+
if (parsed?.name === "WalletCreated") {
|
|
624
|
+
walletAddress = parsed.args.walletAddress as string;
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
} catch { /* skip unparseable logs */ }
|
|
628
|
+
}
|
|
629
|
+
if (!walletAddress) {
|
|
630
|
+
console.error("Could not find WalletCreated event in receipt. Check the transaction on-chain.");
|
|
631
|
+
process.exit(1);
|
|
632
|
+
}
|
|
633
|
+
config.walletContractAddress = walletAddress;
|
|
634
|
+
config.ownerAddress = account;
|
|
635
|
+
saveConfig(config);
|
|
636
|
+
console.log(`\n✓ ARC402Wallet deployed at: ${walletAddress}`);
|
|
637
|
+
console.log(`Owner: ${account} (your phone wallet)`);
|
|
638
|
+
|
|
639
|
+
// ── Mandatory onboarding ceremony (same WalletConnect session) ────────
|
|
640
|
+
console.log("\nStarting mandatory onboarding ceremony in this WalletConnect session...");
|
|
641
|
+
await runWalletOnboardingCeremony(
|
|
642
|
+
walletAddress,
|
|
643
|
+
account,
|
|
644
|
+
config,
|
|
645
|
+
provider,
|
|
646
|
+
async (call, description) => {
|
|
647
|
+
console.log(` Sending: ${description}`);
|
|
648
|
+
const hash = await sendTransactionWithSession(client, session, account, chainId, call);
|
|
649
|
+
await provider.waitForTransaction(hash, 1);
|
|
650
|
+
console.log(` ✓ ${description}: ${hash}`);
|
|
651
|
+
return hash;
|
|
652
|
+
},
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
console.log(`Your wallet contract is ready for policy enforcement`);
|
|
656
|
+
const paymasterUrl2 = config.paymasterUrl ?? NETWORK_DEFAULTS[config.network]?.paymasterUrl;
|
|
657
|
+
const deployedBalance = await provider.getBalance(walletAddress);
|
|
658
|
+
if (paymasterUrl2 && deployedBalance < BigInt(1_000_000_000_000_000)) {
|
|
659
|
+
console.log("Gas sponsorship active — initial setup ops are free");
|
|
660
|
+
}
|
|
661
|
+
console.log(`\nNext: run 'arc402 wallet set-guardian' to configure the emergency guardian key.`);
|
|
662
|
+
printOpenShellHint();
|
|
663
|
+
} else {
|
|
664
|
+
console.warn("⚠ WalletConnect not configured. Using stored private key (insecure).");
|
|
665
|
+
console.warn(" Run `arc402 config set walletConnectProjectId <id>` to enable phone wallet signing.");
|
|
666
|
+
const { signer, address } = await requireSigner(config);
|
|
667
|
+
const factory = new ethers.Contract(factoryAddress, WALLET_FACTORY_ABI, signer);
|
|
668
|
+
console.log(`Deploying ARC402Wallet via factory at ${factoryAddress}...`);
|
|
669
|
+
const tx = await factory.createWallet("0x0000000071727De22E5E9d8BAf0edAc6f37da032");
|
|
670
|
+
const receipt = await tx.wait();
|
|
671
|
+
let walletAddress: string | null = null;
|
|
672
|
+
for (const log of receipt.logs) {
|
|
673
|
+
try {
|
|
674
|
+
const parsed = factory.interface.parseLog(log);
|
|
675
|
+
if (parsed?.name === "WalletCreated") {
|
|
676
|
+
walletAddress = parsed.args.walletAddress as string;
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
} catch { /* skip unparseable logs */ }
|
|
680
|
+
}
|
|
681
|
+
if (!walletAddress) {
|
|
682
|
+
console.error("Could not find WalletCreated event in receipt. Check the transaction on-chain.");
|
|
683
|
+
process.exit(1);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Generate guardian key (separate from hot key) and call setGuardian
|
|
687
|
+
const guardianWallet = ethers.Wallet.createRandom();
|
|
688
|
+
config.walletContractAddress = walletAddress;
|
|
689
|
+
config.guardianPrivateKey = guardianWallet.privateKey;
|
|
690
|
+
config.guardianAddress = guardianWallet.address;
|
|
691
|
+
saveConfig(config);
|
|
692
|
+
|
|
693
|
+
// Call setGuardian on the deployed wallet
|
|
694
|
+
const walletContract = new ethers.Contract(walletAddress, ARC402_WALLET_GUARDIAN_ABI, signer);
|
|
695
|
+
const setGuardianTx = await walletContract.setGuardian(guardianWallet.address);
|
|
696
|
+
await setGuardianTx.wait();
|
|
697
|
+
|
|
698
|
+
// ── Mandatory onboarding ceremony (private key path) ──────────────────
|
|
699
|
+
console.log("\nRunning mandatory onboarding ceremony...");
|
|
700
|
+
const provider2 = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
701
|
+
await runWalletOnboardingCeremony(
|
|
702
|
+
walletAddress,
|
|
703
|
+
address,
|
|
704
|
+
config,
|
|
705
|
+
provider2,
|
|
706
|
+
async (call, description) => {
|
|
707
|
+
console.log(` Sending: ${description}`);
|
|
708
|
+
const tx2 = await signer.sendTransaction({ to: call.to, data: call.data, value: call.value === "0x0" ? 0n : BigInt(call.value) });
|
|
709
|
+
await tx2.wait(1);
|
|
710
|
+
console.log(` ✓ ${description}: ${tx2.hash}`);
|
|
711
|
+
return tx2.hash;
|
|
712
|
+
},
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
console.log(`ARC402Wallet deployed at: ${walletAddress}`);
|
|
716
|
+
console.log(`Guardian key generated: ${guardianWallet.address}`);
|
|
717
|
+
console.log(`Guardian private key saved to config (keep it safe — used for emergency freeze only)`);
|
|
718
|
+
console.log(`Your wallet contract is ready for policy enforcement`);
|
|
719
|
+
printOpenShellHint();
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// ─── send ──────────────────────────────────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
wallet.command("send <address> <amount>")
|
|
726
|
+
.description("Send ETH from configured wallet (amount: '0.001eth' or wei)")
|
|
727
|
+
.option("--json")
|
|
728
|
+
.action(async (to, amountRaw, opts) => {
|
|
729
|
+
const config = loadConfig();
|
|
730
|
+
const { signer } = await requireSigner(config);
|
|
731
|
+
const value = parseAmount(amountRaw);
|
|
732
|
+
const tx = await signer.sendTransaction({ to, value });
|
|
733
|
+
if (opts.json) {
|
|
734
|
+
console.log(JSON.stringify({ txHash: tx.hash, to, amount: ethers.formatEther(value) }));
|
|
735
|
+
} else {
|
|
736
|
+
console.log(`Tx hash: ${tx.hash}`);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// ─── policy ────────────────────────────────────────────────────────────────
|
|
741
|
+
|
|
742
|
+
const walletPolicy = wallet.command("policy").description("View and set spending policy on ARC402Wallet");
|
|
743
|
+
|
|
744
|
+
walletPolicy.command("show")
|
|
745
|
+
.description("Show per-tx and daily spending limits for a category")
|
|
746
|
+
.requiredOption("--category <cat>", "Category name (e.g. code.review)")
|
|
747
|
+
.action(async (opts) => {
|
|
748
|
+
const config = loadConfig();
|
|
749
|
+
const policyAddress = config.policyEngineAddress ?? POLICY_ENGINE_DEFAULT;
|
|
750
|
+
const walletAddr = config.walletContractAddress;
|
|
751
|
+
if (!walletAddr) {
|
|
752
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
753
|
+
process.exit(1);
|
|
754
|
+
}
|
|
755
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
756
|
+
const contract = new ethers.Contract(policyAddress, POLICY_ENGINE_LIMITS_ABI, provider);
|
|
757
|
+
const [perTxLimit, dailyLimit]: [bigint, bigint] = await Promise.all([
|
|
758
|
+
contract.categoryLimits(walletAddr, opts.category),
|
|
759
|
+
contract.dailyCategoryLimit(walletAddr, opts.category),
|
|
760
|
+
]);
|
|
761
|
+
console.log(`Category: ${opts.category}`);
|
|
762
|
+
console.log(`Per-tx: ${perTxLimit === 0n ? "(not set)" : ethers.formatEther(perTxLimit) + " ETH"}`);
|
|
763
|
+
console.log(`Daily: ${dailyLimit === 0n ? "(not set)" : ethers.formatEther(dailyLimit) + " ETH"}`);
|
|
764
|
+
if (dailyLimit > 0n) {
|
|
765
|
+
console.log(`\nNote: Daily limits use two 12-hour buckets (current + previous window).`);
|
|
766
|
+
console.log(` The effective limit applies across a rolling 12-24 hour period, not a strict calendar day.`);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
walletPolicy.command("set-limit")
|
|
771
|
+
.description("Set a spending limit for a category (phone wallet signs via WalletConnect)")
|
|
772
|
+
.requiredOption("--category <cat>", "Category name (e.g. code.review)")
|
|
773
|
+
.requiredOption("--amount <eth>", "Limit in ETH (e.g. 0.1)")
|
|
774
|
+
.action(async (opts) => {
|
|
775
|
+
const config = loadConfig();
|
|
776
|
+
const policyAddress = config.policyEngineAddress ?? POLICY_ENGINE_DEFAULT;
|
|
777
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
778
|
+
const amount = ethers.parseEther(opts.amount);
|
|
779
|
+
const policyInterface = new ethers.Interface(POLICY_ENGINE_LIMITS_ABI);
|
|
780
|
+
|
|
781
|
+
if (config.walletConnectProjectId) {
|
|
782
|
+
const walletAddr = config.walletContractAddress;
|
|
783
|
+
if (!walletAddr) {
|
|
784
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
785
|
+
process.exit(1);
|
|
786
|
+
}
|
|
787
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
788
|
+
const { txHash } = await requestPhoneWalletSignature(
|
|
789
|
+
config.walletConnectProjectId,
|
|
790
|
+
chainId,
|
|
791
|
+
(account) => ({
|
|
792
|
+
to: policyAddress,
|
|
793
|
+
data: policyInterface.encodeFunctionData("setCategoryLimitFor", [walletAddr, opts.category, amount]),
|
|
794
|
+
value: "0x0",
|
|
795
|
+
}),
|
|
796
|
+
`Approve spend limit: ${opts.category} → ${opts.amount} ETH`,
|
|
797
|
+
config.telegramBotToken && config.telegramChatId ? {
|
|
798
|
+
botToken: config.telegramBotToken,
|
|
799
|
+
chatId: config.telegramChatId,
|
|
800
|
+
threadId: config.telegramThreadId,
|
|
801
|
+
} : undefined,
|
|
802
|
+
config
|
|
803
|
+
);
|
|
804
|
+
console.log(`\nTransaction submitted: ${txHash}`);
|
|
805
|
+
await provider.waitForTransaction(txHash);
|
|
806
|
+
console.log(`Spend limit for ${opts.category} set to ${opts.amount} ETH`);
|
|
807
|
+
} else {
|
|
808
|
+
console.warn("⚠ WalletConnect not configured. Using stored private key (insecure).");
|
|
809
|
+
console.warn(" Run `arc402 config set walletConnectProjectId <id>` to enable phone wallet signing.");
|
|
810
|
+
const { signer, address } = await requireSigner(config);
|
|
811
|
+
const contract = new ethers.Contract(policyAddress, POLICY_ENGINE_LIMITS_ABI, signer);
|
|
812
|
+
await (await contract.setCategoryLimitFor(address, opts.category, amount)).wait();
|
|
813
|
+
console.log(`Spend limit for ${opts.category} set to ${opts.amount} ETH`);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// ─── policy set-daily-limit (J8-01) ──────────────────────────────────────
|
|
818
|
+
//
|
|
819
|
+
// Sets the daily (rolling 12/24h window) category limit on PolicyEngine.
|
|
820
|
+
// Note: the limit uses two 12-hour buckets — the effective maximum across
|
|
821
|
+
// any 24h window is up to 2× the configured value at bucket boundaries.
|
|
822
|
+
|
|
823
|
+
walletPolicy.command("set-daily-limit")
|
|
824
|
+
.description("Set a daily category spending limit (phone wallet signs via WalletConnect). Note: uses 12-hour rolling buckets — see below.")
|
|
825
|
+
.requiredOption("--category <cat>", "Category name (e.g. compute)")
|
|
826
|
+
.requiredOption("--amount <eth>", "Daily limit in ETH (e.g. 0.5)")
|
|
827
|
+
.action(async (opts) => {
|
|
828
|
+
const config = loadConfig();
|
|
829
|
+
console.log(`\nNote: ARC-402 has two independent velocity limit layers:`);
|
|
830
|
+
console.log(` 1. Wallet-level (arc402 wallet set-velocity-limit): ETH cap per rolling hour, enforced by ARC402Wallet contract. Breach auto-freezes wallet.`);
|
|
831
|
+
console.log(` 2. PolicyEngine-level (arc402 wallet policy set-daily-limit): Per-category daily cap, enforced by PolicyEngine. Breach returns a soft error without freezing.`);
|
|
832
|
+
console.log(` Both must be configured for full protection.\n`);
|
|
833
|
+
const policyAddress = config.policyEngineAddress ?? POLICY_ENGINE_DEFAULT;
|
|
834
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
835
|
+
const walletAddr = config.walletContractAddress;
|
|
836
|
+
if (!walletAddr) {
|
|
837
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
838
|
+
process.exit(1);
|
|
839
|
+
}
|
|
840
|
+
const amount = ethers.parseEther(opts.amount);
|
|
841
|
+
console.log(`\nNote: Daily limits use two 12-hour buckets (current + previous window).`);
|
|
842
|
+
console.log(` The effective limit applies across a rolling 12-24 hour period, not a strict calendar day.`);
|
|
843
|
+
console.log(` Setting daily limit for category "${opts.category}" to ${opts.amount} ETH.\n`);
|
|
844
|
+
const policyInterface = new ethers.Interface(POLICY_ENGINE_LIMITS_ABI);
|
|
845
|
+
if (config.walletConnectProjectId) {
|
|
846
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
847
|
+
const { txHash } = await requestPhoneWalletSignature(
|
|
848
|
+
config.walletConnectProjectId,
|
|
849
|
+
chainId,
|
|
850
|
+
() => ({
|
|
851
|
+
to: policyAddress,
|
|
852
|
+
data: policyInterface.encodeFunctionData("setDailyLimitFor", [walletAddr, opts.category, amount]),
|
|
853
|
+
value: "0x0",
|
|
854
|
+
}),
|
|
855
|
+
`Approve daily limit: ${opts.category} → ${opts.amount} ETH`,
|
|
856
|
+
config.telegramBotToken && config.telegramChatId ? {
|
|
857
|
+
botToken: config.telegramBotToken,
|
|
858
|
+
chatId: config.telegramChatId,
|
|
859
|
+
threadId: config.telegramThreadId,
|
|
860
|
+
} : undefined,
|
|
861
|
+
config
|
|
862
|
+
);
|
|
863
|
+
await provider.waitForTransaction(txHash);
|
|
864
|
+
console.log(`Daily limit for ${opts.category} set to ${opts.amount} ETH (12/24h rolling window)`);
|
|
865
|
+
} else {
|
|
866
|
+
console.warn("⚠ WalletConnect not configured. Using stored private key (insecure).");
|
|
867
|
+
const { signer, address } = await requireSigner(config);
|
|
868
|
+
const contract = new ethers.Contract(policyAddress, POLICY_ENGINE_LIMITS_ABI, signer);
|
|
869
|
+
await (await contract.setDailyLimitFor(address, opts.category, amount)).wait();
|
|
870
|
+
console.log(`Daily limit for ${opts.category} set to ${opts.amount} ETH (12/24h rolling window)`);
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
walletPolicy.command("set <policyId>")
|
|
875
|
+
.description("Set the active policy ID on ARC402Wallet (phone wallet signs via WalletConnect)")
|
|
876
|
+
.action(async (policyId) => {
|
|
877
|
+
const config = loadConfig();
|
|
878
|
+
if (!config.walletContractAddress) {
|
|
879
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
880
|
+
process.exit(1);
|
|
881
|
+
}
|
|
882
|
+
if (!config.walletConnectProjectId) {
|
|
883
|
+
console.error("walletConnectProjectId not set in config. Run `arc402 config set walletConnectProjectId <id>`.");
|
|
884
|
+
process.exit(1);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Normalise policyId to bytes32 hex
|
|
888
|
+
let policyIdHex: string;
|
|
889
|
+
try {
|
|
890
|
+
policyIdHex = ethers.zeroPadValue(ethers.hexlify(policyId.startsWith("0x") ? policyId : ethers.toUtf8Bytes(policyId)), 32);
|
|
891
|
+
} catch {
|
|
892
|
+
console.error("Invalid policyId — must be a hex string (0x…) or UTF-8 label.");
|
|
893
|
+
process.exit(1);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
897
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
898
|
+
const ownerInterface = new ethers.Interface(ARC402_WALLET_OWNER_ABI);
|
|
899
|
+
|
|
900
|
+
let currentPolicy = "(unknown)";
|
|
901
|
+
try {
|
|
902
|
+
const walletContract = new ethers.Contract(config.walletContractAddress, ARC402_WALLET_OWNER_ABI, provider);
|
|
903
|
+
currentPolicy = await walletContract.activePolicyId();
|
|
904
|
+
} catch { /* contract may not be deployed yet */ }
|
|
905
|
+
|
|
906
|
+
console.log(`\nWallet: ${config.walletContractAddress}`);
|
|
907
|
+
console.log(`Current policy: ${currentPolicy}`);
|
|
908
|
+
console.log(`New policy: ${policyIdHex}`);
|
|
909
|
+
|
|
910
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
911
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
912
|
+
: undefined;
|
|
913
|
+
|
|
914
|
+
const { txHash } = await requestPhoneWalletSignature(
|
|
915
|
+
config.walletConnectProjectId,
|
|
916
|
+
chainId,
|
|
917
|
+
() => ({
|
|
918
|
+
to: config.walletContractAddress!,
|
|
919
|
+
data: ownerInterface.encodeFunctionData("updatePolicy", [policyIdHex]),
|
|
920
|
+
value: "0x0",
|
|
921
|
+
}),
|
|
922
|
+
`Approve: update policy to ${policyIdHex}`,
|
|
923
|
+
telegramOpts,
|
|
924
|
+
config
|
|
925
|
+
);
|
|
926
|
+
|
|
927
|
+
await provider.waitForTransaction(txHash);
|
|
928
|
+
console.log(`\n✓ Active policy updated`);
|
|
929
|
+
console.log(` Tx: ${txHash}`);
|
|
930
|
+
console.log(` Policy: ${policyIdHex}`);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
// ─── freeze (guardian key — emergency wallet freeze) ──────────────────────
|
|
934
|
+
//
|
|
935
|
+
// Uses the guardian private key from config to call ARC402Wallet.freeze() or
|
|
936
|
+
// ARC402Wallet.freezeAndDrain() directly on the wallet contract.
|
|
937
|
+
// No human approval needed — designed for immediate AI-initiated emergency response.
|
|
938
|
+
|
|
939
|
+
wallet.command("freeze")
|
|
940
|
+
.description("Emergency freeze via guardian key. Use immediately if suspicious activity is detected. Owner must unfreeze.")
|
|
941
|
+
.option("--drain", "Also drain all ETH to owner address (use when machine compromise is suspected)")
|
|
942
|
+
.option("--json")
|
|
943
|
+
.action(async (opts) => {
|
|
944
|
+
const config = loadConfig();
|
|
945
|
+
if (!config.walletContractAddress) {
|
|
946
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
947
|
+
process.exit(1);
|
|
948
|
+
}
|
|
949
|
+
if (!config.guardianPrivateKey) {
|
|
950
|
+
console.error("guardianPrivateKey not set in config. Guardian key was generated during `arc402 wallet deploy`.");
|
|
951
|
+
process.exit(1);
|
|
952
|
+
}
|
|
953
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
954
|
+
const guardianSigner = new ethers.Wallet(config.guardianPrivateKey, provider);
|
|
955
|
+
const walletContract = new ethers.Contract(config.walletContractAddress, ARC402_WALLET_GUARDIAN_ABI, guardianSigner);
|
|
956
|
+
|
|
957
|
+
let tx;
|
|
958
|
+
if (opts.drain) {
|
|
959
|
+
console.log("Triggering freeze-and-drain via guardian key...");
|
|
960
|
+
tx = await walletContract.freezeAndDrain();
|
|
961
|
+
} else {
|
|
962
|
+
console.log("Triggering emergency freeze via guardian key...");
|
|
963
|
+
tx = await walletContract.freeze();
|
|
964
|
+
}
|
|
965
|
+
const receipt = await tx.wait();
|
|
966
|
+
|
|
967
|
+
if (opts.json) {
|
|
968
|
+
console.log(JSON.stringify({ txHash: receipt.hash, walletAddress: config.walletContractAddress, drained: !!opts.drain }));
|
|
969
|
+
} else {
|
|
970
|
+
console.log(`Wallet ${config.walletContractAddress} is now FROZEN`);
|
|
971
|
+
if (opts.drain) console.log("All ETH drained to owner.");
|
|
972
|
+
console.log(`Tx: ${receipt.hash}`);
|
|
973
|
+
console.log(`\nOwner must unfreeze: arc402 wallet unfreeze`);
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// ─── unfreeze (owner key — requires WalletConnect) ────────────────────────
|
|
978
|
+
//
|
|
979
|
+
// Deliberately uses WalletConnect (phone wallet) so unfreezing requires owner
|
|
980
|
+
// approval from the phone. Guardian can freeze fast; only owner can unfreeze.
|
|
981
|
+
|
|
982
|
+
wallet.command("unfreeze")
|
|
983
|
+
.description("Unfreeze wallet contract via owner phone wallet (WalletConnect). Only the owner can unfreeze — guardian cannot.")
|
|
984
|
+
.option("--hardware", "Hardware wallet mode: show raw wc: URI only")
|
|
985
|
+
.option("--json")
|
|
986
|
+
.action(async (opts) => {
|
|
987
|
+
const config = loadConfig();
|
|
988
|
+
if (!config.walletContractAddress) {
|
|
989
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
990
|
+
process.exit(1);
|
|
991
|
+
}
|
|
992
|
+
if (!config.walletConnectProjectId) {
|
|
993
|
+
console.error("walletConnectProjectId not set in config. Run `arc402 config set walletConnectProjectId <id>`.");
|
|
994
|
+
process.exit(1);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
998
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
999
|
+
const walletInterface = new ethers.Interface(ARC402_WALLET_GUARDIAN_ABI);
|
|
1000
|
+
|
|
1001
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1002
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1003
|
+
: undefined;
|
|
1004
|
+
|
|
1005
|
+
const { client, session, account } = await connectPhoneWallet(
|
|
1006
|
+
config.walletConnectProjectId,
|
|
1007
|
+
chainId,
|
|
1008
|
+
config,
|
|
1009
|
+
{ telegramOpts, prompt: "Approve: unfreeze ARC402Wallet", hardware: !!opts.hardware }
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
const networkName = chainId === 8453 ? "Base" : "Base Sepolia";
|
|
1013
|
+
const shortAddr = `${account.slice(0, 6)}...${account.slice(-5)}`;
|
|
1014
|
+
console.log(`\n✓ Connected: ${shortAddr} on ${networkName}`);
|
|
1015
|
+
console.log(`\nWallet to unfreeze: ${config.walletContractAddress}`);
|
|
1016
|
+
// WalletConnect approval already confirmed intent — sending automatically
|
|
1017
|
+
|
|
1018
|
+
console.log("Sending transaction...");
|
|
1019
|
+
const txHash = await sendTransactionWithSession(client, session, account, chainId, {
|
|
1020
|
+
to: config.walletContractAddress,
|
|
1021
|
+
data: walletInterface.encodeFunctionData("unfreeze", []),
|
|
1022
|
+
value: "0x0",
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
await provider.waitForTransaction(txHash);
|
|
1026
|
+
if (opts.json) {
|
|
1027
|
+
console.log(JSON.stringify({ txHash, walletAddress: config.walletContractAddress }));
|
|
1028
|
+
} else {
|
|
1029
|
+
console.log(`\n✓ Wallet ${config.walletContractAddress} unfrozen`);
|
|
1030
|
+
console.log(` Tx: ${txHash}`);
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// ─── set-guardian ──────────────────────────────────────────────────────────
|
|
1035
|
+
//
|
|
1036
|
+
// Generates a guardian key locally, then registers it on-chain via the owner's
|
|
1037
|
+
// phone wallet (WalletConnect). Guardian changes require owner approval.
|
|
1038
|
+
|
|
1039
|
+
wallet.command("set-guardian")
|
|
1040
|
+
.description("Generate a new guardian key and register it on the wallet contract (phone wallet signs via WalletConnect)")
|
|
1041
|
+
.option("--guardian-key <key>", "Use an existing private key as the guardian (optional)")
|
|
1042
|
+
.option("--hardware", "Hardware wallet mode: show raw wc: URI only")
|
|
1043
|
+
.action(async (opts) => {
|
|
1044
|
+
const config = loadConfig();
|
|
1045
|
+
if (!config.walletContractAddress) {
|
|
1046
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1047
|
+
process.exit(1);
|
|
1048
|
+
}
|
|
1049
|
+
if (!config.walletConnectProjectId) {
|
|
1050
|
+
console.error("walletConnectProjectId not set in config. Run `arc402 config set walletConnectProjectId <id>`.");
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
let guardianWallet: ethers.Wallet;
|
|
1055
|
+
if (opts.guardianKey) {
|
|
1056
|
+
try {
|
|
1057
|
+
guardianWallet = new ethers.Wallet(opts.guardianKey);
|
|
1058
|
+
} catch {
|
|
1059
|
+
console.error("Invalid guardian key. Must be a 0x-prefixed hex string.");
|
|
1060
|
+
process.exit(1);
|
|
1061
|
+
}
|
|
1062
|
+
} else {
|
|
1063
|
+
guardianWallet = new ethers.Wallet(ethers.Wallet.createRandom().privateKey);
|
|
1064
|
+
console.log("Generated new guardian key.");
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
1068
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
1069
|
+
const walletInterface = new ethers.Interface(ARC402_WALLET_GUARDIAN_ABI);
|
|
1070
|
+
|
|
1071
|
+
console.log(`\nGuardian address: ${guardianWallet.address}`);
|
|
1072
|
+
console.log(`Wallet contract: ${config.walletContractAddress}`);
|
|
1073
|
+
|
|
1074
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1075
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1076
|
+
: undefined;
|
|
1077
|
+
|
|
1078
|
+
const { client, session, account } = await connectPhoneWallet(
|
|
1079
|
+
config.walletConnectProjectId,
|
|
1080
|
+
chainId,
|
|
1081
|
+
config,
|
|
1082
|
+
{ telegramOpts, prompt: `Approve: set guardian to ${guardianWallet.address}`, hardware: !!opts.hardware }
|
|
1083
|
+
);
|
|
1084
|
+
|
|
1085
|
+
const networkName = chainId === 8453 ? "Base" : "Base Sepolia";
|
|
1086
|
+
const shortAddr = `${account.slice(0, 6)}...${account.slice(-5)}`;
|
|
1087
|
+
console.log(`\n✓ Connected: ${shortAddr} on ${networkName}`);
|
|
1088
|
+
// WalletConnect approval already confirmed intent — sending automatically
|
|
1089
|
+
|
|
1090
|
+
console.log("Sending transaction...");
|
|
1091
|
+
const txHash = await sendTransactionWithSession(client, session, account, chainId, {
|
|
1092
|
+
to: config.walletContractAddress,
|
|
1093
|
+
data: walletInterface.encodeFunctionData("setGuardian", [guardianWallet.address]),
|
|
1094
|
+
value: "0x0",
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
await provider.waitForTransaction(txHash);
|
|
1098
|
+
config.guardianPrivateKey = guardianWallet.privateKey;
|
|
1099
|
+
config.guardianAddress = guardianWallet.address;
|
|
1100
|
+
saveConfig(config);
|
|
1101
|
+
console.log(`\n✓ Guardian set to: ${guardianWallet.address}`);
|
|
1102
|
+
console.log(` Tx: ${txHash}`);
|
|
1103
|
+
console.log(` Guardian private key saved to config.`);
|
|
1104
|
+
console.log(` WARN: The guardian key can freeze your wallet. Store it separately from your hot key.`);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// ─── policy-engine freeze / unfreeze (legacy — for PolicyEngine-level freeze) ──
|
|
1108
|
+
|
|
1109
|
+
wallet.command("freeze-policy <walletAddress>")
|
|
1110
|
+
.description("Freeze PolicyEngine spend for a wallet address (authorized freeze agents only)")
|
|
1111
|
+
.action(async (walletAddress) => {
|
|
1112
|
+
const config = loadConfig();
|
|
1113
|
+
if (!config.policyEngineAddress) throw new Error("policyEngineAddress missing in config");
|
|
1114
|
+
const { signer } = await requireSigner(config);
|
|
1115
|
+
const client = new PolicyClient(config.policyEngineAddress, signer);
|
|
1116
|
+
await client.freezeSpend(walletAddress);
|
|
1117
|
+
console.log(`wallet ${walletAddress} spend frozen (PolicyEngine)`);
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
wallet.command("unfreeze-policy <walletAddress>")
|
|
1121
|
+
.description("Unfreeze PolicyEngine spend for a wallet. Only callable by the wallet or its registered owner.")
|
|
1122
|
+
.action(async (walletAddress) => {
|
|
1123
|
+
const config = loadConfig();
|
|
1124
|
+
if (!config.policyEngineAddress) throw new Error("policyEngineAddress missing in config");
|
|
1125
|
+
const { signer } = await requireSigner(config);
|
|
1126
|
+
const client = new PolicyClient(config.policyEngineAddress, signer);
|
|
1127
|
+
await client.unfreeze(walletAddress);
|
|
1128
|
+
console.log(`wallet ${walletAddress} spend unfrozen (PolicyEngine)`);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// ─── upgrade-registry ──────────────────────────────────────────────────────
|
|
1132
|
+
|
|
1133
|
+
wallet.command("upgrade-registry <newRegistryAddress>")
|
|
1134
|
+
.description("Propose a registry upgrade on the ARC402Wallet (2-day timelock, phone wallet signs via WalletConnect)")
|
|
1135
|
+
.option("--dry-run", "Show calldata without connecting to wallet")
|
|
1136
|
+
.option("--hardware", "Hardware wallet mode: show raw wc: URI only")
|
|
1137
|
+
.action(async (newRegistryAddress, opts) => {
|
|
1138
|
+
const config = loadConfig();
|
|
1139
|
+
if (!config.walletContractAddress) {
|
|
1140
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1141
|
+
process.exit(1);
|
|
1142
|
+
}
|
|
1143
|
+
if (!config.walletConnectProjectId && !opts.dryRun) {
|
|
1144
|
+
console.error("walletConnectProjectId not set in config. Run `arc402 config set walletConnectProjectId <id>`.");
|
|
1145
|
+
process.exit(1);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
let checksumAddress: string;
|
|
1149
|
+
try {
|
|
1150
|
+
checksumAddress = ethers.getAddress(newRegistryAddress);
|
|
1151
|
+
} catch {
|
|
1152
|
+
console.error(`Invalid address: ${newRegistryAddress}`);
|
|
1153
|
+
process.exit(1);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
1157
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
1158
|
+
const walletInterface = new ethers.Interface(ARC402_WALLET_REGISTRY_ABI);
|
|
1159
|
+
|
|
1160
|
+
let currentRegistry = "(unknown)";
|
|
1161
|
+
try {
|
|
1162
|
+
const walletContract = new ethers.Contract(config.walletContractAddress, ARC402_WALLET_REGISTRY_ABI, provider);
|
|
1163
|
+
currentRegistry = await walletContract.registry();
|
|
1164
|
+
} catch { /* contract may not expose registry() */ }
|
|
1165
|
+
|
|
1166
|
+
// Box: 54-char inner width (║ + 54 + ║ = 56 total)
|
|
1167
|
+
const fromPad = currentRegistry.padEnd(42);
|
|
1168
|
+
console.log(`\n╔══════════════════════════════════════════════════════╗`);
|
|
1169
|
+
console.log(`║ ARC402Wallet Registry Upgrade ║`);
|
|
1170
|
+
console.log(`╟──────────────────────────────────────────────────────╢`);
|
|
1171
|
+
console.log(`║ Wallet: ${config.walletContractAddress}║`);
|
|
1172
|
+
console.log(`║ From: ${fromPad}║`);
|
|
1173
|
+
console.log(`║ To: ${checksumAddress}║`);
|
|
1174
|
+
console.log(`║ Timelock: 2 days (cancelable) ║`);
|
|
1175
|
+
console.log(`║ Action: proposeRegistryUpdate() ║`);
|
|
1176
|
+
console.log(`╚══════════════════════════════════════════════════════╝\n`);
|
|
1177
|
+
|
|
1178
|
+
const calldata = walletInterface.encodeFunctionData("proposeRegistryUpdate", [checksumAddress]);
|
|
1179
|
+
|
|
1180
|
+
if (opts.dryRun) {
|
|
1181
|
+
console.log("Calldata (dry-run):");
|
|
1182
|
+
console.log(` To: ${config.walletContractAddress}`);
|
|
1183
|
+
console.log(` Data: ${calldata}`);
|
|
1184
|
+
console.log(` Value: 0x0`);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1189
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1190
|
+
: undefined;
|
|
1191
|
+
|
|
1192
|
+
const { client, session, account } = await connectPhoneWallet(
|
|
1193
|
+
config.walletConnectProjectId!,
|
|
1194
|
+
chainId,
|
|
1195
|
+
config,
|
|
1196
|
+
{ telegramOpts, prompt: "Approve registry upgrade proposal on ARC402Wallet", hardware: !!opts.hardware }
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
const networkName = chainId === 8453 ? "Base" : "Base Sepolia";
|
|
1200
|
+
const shortAddr = `${account.slice(0, 6)}...${account.slice(-5)}`;
|
|
1201
|
+
console.log(`\n✓ Connected: ${shortAddr} on ${networkName}`);
|
|
1202
|
+
|
|
1203
|
+
// WalletConnect approval already confirmed intent — sending automatically
|
|
1204
|
+
|
|
1205
|
+
console.log("Sending transaction...");
|
|
1206
|
+
const txHash = await sendTransactionWithSession(client, session, account, chainId, {
|
|
1207
|
+
to: config.walletContractAddress,
|
|
1208
|
+
data: calldata,
|
|
1209
|
+
value: "0x0",
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
const unlockAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
|
|
1213
|
+
console.log(`\n✓ Registry upgrade proposed`);
|
|
1214
|
+
console.log(` Tx: ${txHash}`);
|
|
1215
|
+
console.log(` Unlock at: ${unlockAt.toISOString()} (approximately)`);
|
|
1216
|
+
console.log(`\nNext steps:`);
|
|
1217
|
+
console.log(` Wait 2 days, then run:`);
|
|
1218
|
+
console.log(` arc402 wallet execute-registry-upgrade`);
|
|
1219
|
+
console.log(`\nTo cancel before execution:`);
|
|
1220
|
+
console.log(` arc402 wallet cancel-registry-upgrade`);
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
// ─── execute-registry-upgrade ──────────────────────────────────────────────
|
|
1224
|
+
|
|
1225
|
+
wallet.command("execute-registry-upgrade")
|
|
1226
|
+
.description("Execute a pending registry upgrade after the 2-day timelock (phone wallet signs via WalletConnect)")
|
|
1227
|
+
.action(async () => {
|
|
1228
|
+
const config = loadConfig();
|
|
1229
|
+
if (!config.walletContractAddress) {
|
|
1230
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1231
|
+
process.exit(1);
|
|
1232
|
+
}
|
|
1233
|
+
if (!config.walletConnectProjectId) {
|
|
1234
|
+
console.error("walletConnectProjectId not set in config. Run `arc402 config set walletConnectProjectId <id>`.");
|
|
1235
|
+
process.exit(1);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
1239
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
1240
|
+
const walletContract = new ethers.Contract(config.walletContractAddress, ARC402_WALLET_REGISTRY_ABI, provider);
|
|
1241
|
+
|
|
1242
|
+
let pendingRegistry: string;
|
|
1243
|
+
let unlockAt: bigint;
|
|
1244
|
+
try {
|
|
1245
|
+
[pendingRegistry, unlockAt] = await Promise.all([
|
|
1246
|
+
walletContract.pendingRegistry(),
|
|
1247
|
+
walletContract.registryUpdateUnlockAt(),
|
|
1248
|
+
]);
|
|
1249
|
+
} catch (e) {
|
|
1250
|
+
console.error("Failed to read pending registry from contract:", e);
|
|
1251
|
+
process.exit(1);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (pendingRegistry === ethers.ZeroAddress) {
|
|
1255
|
+
console.log("No pending registry upgrade.");
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const nowSeconds = BigInt(Math.floor(Date.now() / 1000));
|
|
1260
|
+
if (unlockAt > nowSeconds) {
|
|
1261
|
+
const remaining = Number(unlockAt - nowSeconds);
|
|
1262
|
+
const hours = Math.floor(remaining / 3600);
|
|
1263
|
+
const minutes = Math.floor((remaining % 3600) / 60);
|
|
1264
|
+
console.log(`Timelock not yet elapsed.`);
|
|
1265
|
+
console.log(`Pending registry: ${pendingRegistry}`);
|
|
1266
|
+
console.log(`Unlocks in: ${hours}h ${minutes}m`);
|
|
1267
|
+
console.log(`Unlock at: ${new Date(Number(unlockAt) * 1000).toISOString()}`);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
console.log(`Pending registry: ${pendingRegistry}`);
|
|
1272
|
+
console.log("Timelock elapsed — proceeding with executeRegistryUpdate()");
|
|
1273
|
+
|
|
1274
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1275
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1276
|
+
: undefined;
|
|
1277
|
+
|
|
1278
|
+
const walletInterface = new ethers.Interface(ARC402_WALLET_REGISTRY_ABI);
|
|
1279
|
+
const { txHash } = await requestPhoneWalletSignature(
|
|
1280
|
+
config.walletConnectProjectId,
|
|
1281
|
+
chainId,
|
|
1282
|
+
() => ({
|
|
1283
|
+
to: config.walletContractAddress!,
|
|
1284
|
+
data: walletInterface.encodeFunctionData("executeRegistryUpdate", []),
|
|
1285
|
+
value: "0x0",
|
|
1286
|
+
}),
|
|
1287
|
+
"Approve registry upgrade execution on ARC402Wallet",
|
|
1288
|
+
telegramOpts,
|
|
1289
|
+
config
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1292
|
+
// Wait for tx to confirm, then read back the active registry (J6-02)
|
|
1293
|
+
await provider.waitForTransaction(txHash);
|
|
1294
|
+
let confirmedRegistry = pendingRegistry;
|
|
1295
|
+
try {
|
|
1296
|
+
confirmedRegistry = await walletContract.registry();
|
|
1297
|
+
} catch { /* use pendingRegistry as fallback */ }
|
|
1298
|
+
|
|
1299
|
+
console.log(`\n✓ Registry upgrade executed`);
|
|
1300
|
+
console.log(` Tx: ${txHash}`);
|
|
1301
|
+
console.log(` New registry: ${confirmedRegistry}`);
|
|
1302
|
+
if (confirmedRegistry.toLowerCase() === pendingRegistry.toLowerCase()) {
|
|
1303
|
+
console.log(` Registry updated successfully — addresses now resolve through new registry.`);
|
|
1304
|
+
} else {
|
|
1305
|
+
console.warn(` WARN: Confirmed registry (${confirmedRegistry}) differs from expected (${pendingRegistry}). Check the transaction.`);
|
|
1306
|
+
}
|
|
1307
|
+
console.log(`\nVerify contracts resolve correctly with \`arc402 wallet status\``);
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
// ─── whitelist-contract ────────────────────────────────────────────────────
|
|
1311
|
+
//
|
|
1312
|
+
// Adds a contract to the per-wallet DeFi whitelist on PolicyEngine so that
|
|
1313
|
+
// executeContractCall can target it. Called directly by the owner (MetaMask)
|
|
1314
|
+
// on PolicyEngine — does NOT route through the wallet contract.
|
|
1315
|
+
|
|
1316
|
+
wallet.command("whitelist-contract <target>")
|
|
1317
|
+
.description("Whitelist a contract address on PolicyEngine so this wallet can call it via executeContractCall (phone wallet signs via WalletConnect)")
|
|
1318
|
+
.option("--hardware", "Hardware wallet mode: show raw wc: URI only")
|
|
1319
|
+
.option("--json")
|
|
1320
|
+
.action(async (target, opts) => {
|
|
1321
|
+
const config = loadConfig();
|
|
1322
|
+
if (!config.walletContractAddress) {
|
|
1323
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1324
|
+
process.exit(1);
|
|
1325
|
+
}
|
|
1326
|
+
if (!config.walletConnectProjectId) {
|
|
1327
|
+
console.error("walletConnectProjectId not set in config.");
|
|
1328
|
+
process.exit(1);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
let checksumTarget: string;
|
|
1332
|
+
try {
|
|
1333
|
+
checksumTarget = ethers.getAddress(target);
|
|
1334
|
+
} catch {
|
|
1335
|
+
console.error(`Invalid address: ${target}`);
|
|
1336
|
+
process.exit(1);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const policyAddress = config.policyEngineAddress ?? POLICY_ENGINE_DEFAULT;
|
|
1340
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
1341
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
1342
|
+
|
|
1343
|
+
// Check if already whitelisted
|
|
1344
|
+
const peAbi = [
|
|
1345
|
+
"function whitelistContract(address wallet, address target) external",
|
|
1346
|
+
"function isContractWhitelisted(address wallet, address target) external view returns (bool)",
|
|
1347
|
+
];
|
|
1348
|
+
const pe = new ethers.Contract(policyAddress, peAbi, provider);
|
|
1349
|
+
let alreadyWhitelisted = false;
|
|
1350
|
+
try {
|
|
1351
|
+
alreadyWhitelisted = await pe.isContractWhitelisted(config.walletContractAddress, checksumTarget);
|
|
1352
|
+
} catch { /* ignore */ }
|
|
1353
|
+
|
|
1354
|
+
if (alreadyWhitelisted) {
|
|
1355
|
+
console.log(`✓ ${checksumTarget} is already whitelisted for ${config.walletContractAddress}`);
|
|
1356
|
+
process.exit(0);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
console.log(`\nWallet: ${config.walletContractAddress}`);
|
|
1360
|
+
console.log(`PolicyEngine: ${policyAddress}`);
|
|
1361
|
+
console.log(`Whitelisting: ${checksumTarget}`);
|
|
1362
|
+
|
|
1363
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1364
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1365
|
+
: undefined;
|
|
1366
|
+
|
|
1367
|
+
const policyIface = new ethers.Interface(peAbi);
|
|
1368
|
+
|
|
1369
|
+
const { txHash } = await requestPhoneWalletSignature(
|
|
1370
|
+
config.walletConnectProjectId,
|
|
1371
|
+
chainId,
|
|
1372
|
+
() => ({
|
|
1373
|
+
to: policyAddress,
|
|
1374
|
+
data: policyIface.encodeFunctionData("whitelistContract", [
|
|
1375
|
+
config.walletContractAddress!,
|
|
1376
|
+
checksumTarget,
|
|
1377
|
+
]),
|
|
1378
|
+
value: "0x0",
|
|
1379
|
+
}),
|
|
1380
|
+
`Approve: whitelist ${checksumTarget} on PolicyEngine for your wallet`,
|
|
1381
|
+
telegramOpts,
|
|
1382
|
+
config
|
|
1383
|
+
);
|
|
1384
|
+
|
|
1385
|
+
await provider.waitForTransaction(txHash);
|
|
1386
|
+
|
|
1387
|
+
if (opts.json) {
|
|
1388
|
+
console.log(JSON.stringify({ ok: true, txHash, wallet: config.walletContractAddress, target: checksumTarget }));
|
|
1389
|
+
} else {
|
|
1390
|
+
console.log(`\n✓ Contract whitelisted`);
|
|
1391
|
+
console.log(` Tx: ${txHash}`);
|
|
1392
|
+
console.log(` Wallet: ${config.walletContractAddress}`);
|
|
1393
|
+
console.log(` Target: ${checksumTarget}`);
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// ─── set-interceptor ───────────────────────────────────────────────────────
|
|
1398
|
+
|
|
1399
|
+
wallet.command("set-interceptor <address>")
|
|
1400
|
+
.description("Set the authorized X402 interceptor address on ARC402Wallet (phone wallet signs via WalletConnect)")
|
|
1401
|
+
.action(async (interceptorAddress) => {
|
|
1402
|
+
const config = loadConfig();
|
|
1403
|
+
if (!config.walletContractAddress) {
|
|
1404
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1405
|
+
process.exit(1);
|
|
1406
|
+
}
|
|
1407
|
+
if (!config.walletConnectProjectId) {
|
|
1408
|
+
console.error("walletConnectProjectId not set in config. Run `arc402 config set walletConnectProjectId <id>`.");
|
|
1409
|
+
process.exit(1);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
let checksumAddress: string;
|
|
1413
|
+
try {
|
|
1414
|
+
checksumAddress = ethers.getAddress(interceptorAddress);
|
|
1415
|
+
} catch {
|
|
1416
|
+
console.error(`Invalid address: ${interceptorAddress}`);
|
|
1417
|
+
process.exit(1);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
1421
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
1422
|
+
const ownerInterface = new ethers.Interface(ARC402_WALLET_OWNER_ABI);
|
|
1423
|
+
|
|
1424
|
+
let currentInterceptor = "(unknown)";
|
|
1425
|
+
try {
|
|
1426
|
+
const walletContract = new ethers.Contract(config.walletContractAddress, ARC402_WALLET_OWNER_ABI, provider);
|
|
1427
|
+
currentInterceptor = await walletContract.authorizedInterceptor();
|
|
1428
|
+
} catch { /* contract may not be deployed yet */ }
|
|
1429
|
+
|
|
1430
|
+
console.log(`\nWallet: ${config.walletContractAddress}`);
|
|
1431
|
+
console.log(`Current interceptor: ${currentInterceptor}`);
|
|
1432
|
+
console.log(`New interceptor: ${checksumAddress}`);
|
|
1433
|
+
|
|
1434
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1435
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1436
|
+
: undefined;
|
|
1437
|
+
|
|
1438
|
+
const { txHash } = await requestPhoneWalletSignature(
|
|
1439
|
+
config.walletConnectProjectId,
|
|
1440
|
+
chainId,
|
|
1441
|
+
() => ({
|
|
1442
|
+
to: config.walletContractAddress!,
|
|
1443
|
+
data: ownerInterface.encodeFunctionData("setAuthorizedInterceptor", [checksumAddress]),
|
|
1444
|
+
value: "0x0",
|
|
1445
|
+
}),
|
|
1446
|
+
`Approve: set X402 interceptor to ${checksumAddress}`,
|
|
1447
|
+
telegramOpts,
|
|
1448
|
+
config
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1451
|
+
await provider.waitForTransaction(txHash);
|
|
1452
|
+
console.log(`\n✓ X402 interceptor updated`);
|
|
1453
|
+
console.log(` Tx: ${txHash}`);
|
|
1454
|
+
console.log(` Interceptor: ${checksumAddress}`);
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
// ─── set-velocity-limit ────────────────────────────────────────────────────
|
|
1458
|
+
|
|
1459
|
+
wallet.command("set-velocity-limit <limit>")
|
|
1460
|
+
.description("Set the per-rolling-window ETH velocity limit on ARC402Wallet (limit in ETH, phone wallet signs via WalletConnect)")
|
|
1461
|
+
.action(async (limitEth) => {
|
|
1462
|
+
const config = loadConfig();
|
|
1463
|
+
console.log(`\nNote: ARC-402 has two independent velocity limit layers:`);
|
|
1464
|
+
console.log(` 1. Wallet-level (arc402 wallet set-velocity-limit): ETH cap per rolling hour, enforced by ARC402Wallet contract. Breach auto-freezes wallet.`);
|
|
1465
|
+
console.log(` 2. PolicyEngine-level (arc402 wallet policy set-daily-limit): Per-category daily cap, enforced by PolicyEngine. Breach returns a soft error without freezing.`);
|
|
1466
|
+
console.log(` Both must be configured for full protection.\n`);
|
|
1467
|
+
if (!config.walletContractAddress) {
|
|
1468
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1469
|
+
process.exit(1);
|
|
1470
|
+
}
|
|
1471
|
+
if (!config.walletConnectProjectId) {
|
|
1472
|
+
console.error("walletConnectProjectId not set in config. Run `arc402 config set walletConnectProjectId <id>`.");
|
|
1473
|
+
process.exit(1);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
let limitWei: bigint;
|
|
1477
|
+
try {
|
|
1478
|
+
limitWei = ethers.parseEther(limitEth);
|
|
1479
|
+
} catch {
|
|
1480
|
+
console.error(`Invalid limit: ${limitEth}. Provide a value in ETH (e.g. 0.5)`);
|
|
1481
|
+
process.exit(1);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
1485
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
1486
|
+
const ownerInterface = new ethers.Interface(ARC402_WALLET_OWNER_ABI);
|
|
1487
|
+
|
|
1488
|
+
let currentLimit = "(unknown)";
|
|
1489
|
+
try {
|
|
1490
|
+
const walletContract = new ethers.Contract(config.walletContractAddress, ARC402_WALLET_OWNER_ABI, provider);
|
|
1491
|
+
const raw: bigint = await walletContract.velocityLimit();
|
|
1492
|
+
currentLimit = raw === 0n ? "disabled" : `${ethers.formatEther(raw)} ETH`;
|
|
1493
|
+
} catch { /* contract may not be deployed yet */ }
|
|
1494
|
+
|
|
1495
|
+
console.log(`\nWallet: ${config.walletContractAddress}`);
|
|
1496
|
+
console.log(`Current limit: ${currentLimit}`);
|
|
1497
|
+
console.log(`New limit: ${limitEth} ETH (max ETH per rolling window)`);
|
|
1498
|
+
|
|
1499
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1500
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1501
|
+
: undefined;
|
|
1502
|
+
|
|
1503
|
+
const { txHash } = await requestPhoneWalletSignature(
|
|
1504
|
+
config.walletConnectProjectId,
|
|
1505
|
+
chainId,
|
|
1506
|
+
() => ({
|
|
1507
|
+
to: config.walletContractAddress!,
|
|
1508
|
+
data: ownerInterface.encodeFunctionData("setVelocityLimit", [limitWei]),
|
|
1509
|
+
value: "0x0",
|
|
1510
|
+
}),
|
|
1511
|
+
`Approve: set velocity limit to ${limitEth} ETH`,
|
|
1512
|
+
telegramOpts,
|
|
1513
|
+
config
|
|
1514
|
+
);
|
|
1515
|
+
|
|
1516
|
+
await provider.waitForTransaction(txHash);
|
|
1517
|
+
console.log(`\n✓ Velocity limit updated`);
|
|
1518
|
+
console.log(` Tx: ${txHash}`);
|
|
1519
|
+
console.log(` New limit: ${limitEth} ETH per rolling window`);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
// ─── register-policy ───────────────────────────────────────────────────────
|
|
1523
|
+
//
|
|
1524
|
+
// Calls registerWallet(walletAddress, ownerAddress) on PolicyEngine via
|
|
1525
|
+
// executeContractCall on the ARC402Wallet. PolicyEngine requires msg.sender == wallet,
|
|
1526
|
+
// so this must go through the wallet contract — not called directly by the owner key.
|
|
1527
|
+
|
|
1528
|
+
wallet.command("register-policy")
|
|
1529
|
+
.description("Register this wallet on PolicyEngine (required before spend limits can be set)")
|
|
1530
|
+
.option("--hardware", "Hardware wallet mode: show raw wc: URI only")
|
|
1531
|
+
.action(async (opts) => {
|
|
1532
|
+
const config = loadConfig();
|
|
1533
|
+
if (!config.walletContractAddress) {
|
|
1534
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1535
|
+
process.exit(1);
|
|
1536
|
+
}
|
|
1537
|
+
if (!config.walletConnectProjectId) {
|
|
1538
|
+
console.error("walletConnectProjectId not set in config. Run `arc402 config set walletConnectProjectId <id>`.");
|
|
1539
|
+
process.exit(1);
|
|
1540
|
+
}
|
|
1541
|
+
const ownerAddress = config.ownerAddress;
|
|
1542
|
+
if (!ownerAddress) {
|
|
1543
|
+
console.error("ownerAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1544
|
+
process.exit(1);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
const policyAddress = config.policyEngineAddress ?? POLICY_ENGINE_DEFAULT;
|
|
1548
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
1549
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
1550
|
+
|
|
1551
|
+
// Encode registerWallet(wallet, owner) calldata — called on PolicyEngine
|
|
1552
|
+
const policyInterface = new ethers.Interface([
|
|
1553
|
+
"function registerWallet(address wallet, address owner) external",
|
|
1554
|
+
]);
|
|
1555
|
+
const registerCalldata = policyInterface.encodeFunctionData("registerWallet", [
|
|
1556
|
+
config.walletContractAddress,
|
|
1557
|
+
ownerAddress,
|
|
1558
|
+
]);
|
|
1559
|
+
|
|
1560
|
+
const executeInterface = new ethers.Interface(ARC402_WALLET_EXECUTE_ABI);
|
|
1561
|
+
|
|
1562
|
+
console.log(`\nWallet: ${config.walletContractAddress}`);
|
|
1563
|
+
console.log(`PolicyEngine: ${policyAddress}`);
|
|
1564
|
+
console.log(`Owner: ${ownerAddress}`);
|
|
1565
|
+
|
|
1566
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1567
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1568
|
+
: undefined;
|
|
1569
|
+
|
|
1570
|
+
const { txHash } = await requestPhoneWalletSignature(
|
|
1571
|
+
config.walletConnectProjectId,
|
|
1572
|
+
chainId,
|
|
1573
|
+
() => ({
|
|
1574
|
+
to: config.walletContractAddress!,
|
|
1575
|
+
data: executeInterface.encodeFunctionData("executeContractCall", [{
|
|
1576
|
+
target: policyAddress,
|
|
1577
|
+
data: registerCalldata,
|
|
1578
|
+
value: 0n,
|
|
1579
|
+
minReturnValue: 0n,
|
|
1580
|
+
maxApprovalAmount: 0n,
|
|
1581
|
+
approvalToken: ethers.ZeroAddress,
|
|
1582
|
+
}]),
|
|
1583
|
+
value: "0x0",
|
|
1584
|
+
}),
|
|
1585
|
+
`Approve: register wallet on PolicyEngine`,
|
|
1586
|
+
telegramOpts,
|
|
1587
|
+
config
|
|
1588
|
+
);
|
|
1589
|
+
|
|
1590
|
+
await provider.waitForTransaction(txHash);
|
|
1591
|
+
console.log(`\n✓ Wallet registered on PolicyEngine`);
|
|
1592
|
+
console.log(` Tx: ${txHash}`);
|
|
1593
|
+
console.log(`\nNext: run 'arc402 wallet policy set-limit' to configure spending limits.`);
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
// ─── cancel-registry-upgrade ───────────────────────────────────────────────
|
|
1597
|
+
|
|
1598
|
+
wallet.command("cancel-registry-upgrade")
|
|
1599
|
+
.description("Cancel a pending registry upgrade before it executes (phone wallet signs via WalletConnect)")
|
|
1600
|
+
.action(async () => {
|
|
1601
|
+
const config = loadConfig();
|
|
1602
|
+
if (!config.walletContractAddress) {
|
|
1603
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1604
|
+
process.exit(1);
|
|
1605
|
+
}
|
|
1606
|
+
if (!config.walletConnectProjectId) {
|
|
1607
|
+
console.error("walletConnectProjectId not set in config. Run `arc402 config set walletConnectProjectId <id>`.");
|
|
1608
|
+
process.exit(1);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
1612
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
1613
|
+
const walletContract = new ethers.Contract(config.walletContractAddress, ARC402_WALLET_REGISTRY_ABI, provider);
|
|
1614
|
+
|
|
1615
|
+
let pendingRegistry: string;
|
|
1616
|
+
let unlockAtCancel: bigint = 0n;
|
|
1617
|
+
try {
|
|
1618
|
+
[pendingRegistry, unlockAtCancel] = await Promise.all([
|
|
1619
|
+
walletContract.pendingRegistry(),
|
|
1620
|
+
walletContract.registryUpdateUnlockAt().catch(() => 0n),
|
|
1621
|
+
]);
|
|
1622
|
+
} catch (e) {
|
|
1623
|
+
console.error("Failed to read pending registry from contract:", e);
|
|
1624
|
+
process.exit(1);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (pendingRegistry === ethers.ZeroAddress) {
|
|
1628
|
+
console.log("No pending registry upgrade to cancel.");
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
const nowSecondsCancel = BigInt(Math.floor(Date.now() / 1000));
|
|
1633
|
+
const unlockDateCancel = unlockAtCancel > 0n
|
|
1634
|
+
? new Date(Number(unlockAtCancel) * 1000).toISOString()
|
|
1635
|
+
: "(unknown)";
|
|
1636
|
+
const timelockStatus = unlockAtCancel > nowSecondsCancel
|
|
1637
|
+
? `ACTIVE — executes at ${unlockDateCancel}`
|
|
1638
|
+
: `ELAPSED at ${unlockDateCancel} — execution window open`;
|
|
1639
|
+
|
|
1640
|
+
console.log(`\nPending registry upgrade:`);
|
|
1641
|
+
console.log(` Pending address: ${pendingRegistry}`);
|
|
1642
|
+
console.log(` Timelock: ${timelockStatus}`);
|
|
1643
|
+
console.log(`\nCancelling pending registry upgrade to: ${pendingRegistry}`);
|
|
1644
|
+
|
|
1645
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1646
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1647
|
+
: undefined;
|
|
1648
|
+
|
|
1649
|
+
const walletInterface = new ethers.Interface(ARC402_WALLET_REGISTRY_ABI);
|
|
1650
|
+
const { txHash } = await requestPhoneWalletSignature(
|
|
1651
|
+
config.walletConnectProjectId,
|
|
1652
|
+
chainId,
|
|
1653
|
+
() => ({
|
|
1654
|
+
to: config.walletContractAddress!,
|
|
1655
|
+
data: walletInterface.encodeFunctionData("cancelRegistryUpdate", []),
|
|
1656
|
+
value: "0x0",
|
|
1657
|
+
}),
|
|
1658
|
+
"Approve registry upgrade cancellation on ARC402Wallet",
|
|
1659
|
+
telegramOpts,
|
|
1660
|
+
config
|
|
1661
|
+
);
|
|
1662
|
+
|
|
1663
|
+
console.log(`\n✓ Registry upgrade cancelled`);
|
|
1664
|
+
console.log(` Tx: ${txHash}`);
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
// ─── governance setup ──────────────────────────────────────────────────────
|
|
1668
|
+
//
|
|
1669
|
+
// Interactive wizard that collects velocity limit, guardian key, and category
|
|
1670
|
+
// limits in one session, then batches all transactions through a single
|
|
1671
|
+
// WalletConnect session (wallet_sendCalls if supported, else sequential).
|
|
1672
|
+
|
|
1673
|
+
const governance = wallet.command("governance").description("Wallet governance management");
|
|
1674
|
+
|
|
1675
|
+
governance.command("setup")
|
|
1676
|
+
.description("Interactive governance setup — velocity limit, guardian key, and spending limits in one WalletConnect session")
|
|
1677
|
+
.action(async () => {
|
|
1678
|
+
const config = loadConfig();
|
|
1679
|
+
if (!config.walletContractAddress) {
|
|
1680
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1681
|
+
process.exit(1);
|
|
1682
|
+
}
|
|
1683
|
+
if (!config.walletConnectProjectId) {
|
|
1684
|
+
console.error("walletConnectProjectId not set in config. Run `arc402 config set walletConnectProjectId <id>`.");
|
|
1685
|
+
process.exit(1);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const policyAddress = config.policyEngineAddress ?? POLICY_ENGINE_DEFAULT;
|
|
1689
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
1690
|
+
|
|
1691
|
+
// ── Step 1: velocity limit ────────────────────────────────────────────
|
|
1692
|
+
const { velocityEth } = await prompts({
|
|
1693
|
+
type: "text",
|
|
1694
|
+
name: "velocityEth",
|
|
1695
|
+
message: "Velocity limit (max ETH per rolling window)",
|
|
1696
|
+
initial: "0.05",
|
|
1697
|
+
validate: (v: string) => {
|
|
1698
|
+
try { ethers.parseEther(v); return true; } catch { return "Enter a valid ETH amount (e.g. 0.05)"; }
|
|
1699
|
+
},
|
|
1700
|
+
});
|
|
1701
|
+
if (velocityEth === undefined) { console.log("Aborted."); return; }
|
|
1702
|
+
|
|
1703
|
+
// ── Step 2: guardian key ──────────────────────────────────────────────
|
|
1704
|
+
const { wantGuardian } = await prompts({
|
|
1705
|
+
type: "confirm",
|
|
1706
|
+
name: "wantGuardian",
|
|
1707
|
+
message: "Set guardian key?",
|
|
1708
|
+
initial: true,
|
|
1709
|
+
});
|
|
1710
|
+
if (wantGuardian === undefined) { console.log("Aborted."); return; }
|
|
1711
|
+
|
|
1712
|
+
let guardianWallet: ethers.Wallet | null = null;
|
|
1713
|
+
if (wantGuardian) {
|
|
1714
|
+
guardianWallet = new ethers.Wallet(ethers.Wallet.createRandom().privateKey);
|
|
1715
|
+
console.log(` Generated guardian address: ${guardianWallet.address}`);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// ── Step 3: spending categories ───────────────────────────────────────
|
|
1719
|
+
type CategoryEntry = { category: string; amountEth: string };
|
|
1720
|
+
const categories: CategoryEntry[] = [];
|
|
1721
|
+
|
|
1722
|
+
const defaultCategories = [
|
|
1723
|
+
{ label: "general", default: "0.02" },
|
|
1724
|
+
{ label: "research", default: "0.05" },
|
|
1725
|
+
{ label: "compute", default: "0.10" },
|
|
1726
|
+
];
|
|
1727
|
+
|
|
1728
|
+
console.log("\nSpending categories — press Enter to skip any:");
|
|
1729
|
+
|
|
1730
|
+
for (const { label, default: def } of defaultCategories) {
|
|
1731
|
+
const { amountRaw } = await prompts({
|
|
1732
|
+
type: "text",
|
|
1733
|
+
name: "amountRaw",
|
|
1734
|
+
message: ` ${label} limit in ETH`,
|
|
1735
|
+
initial: def,
|
|
1736
|
+
});
|
|
1737
|
+
if (amountRaw === undefined) { console.log("Aborted."); return; }
|
|
1738
|
+
if (amountRaw.trim() !== "") {
|
|
1739
|
+
categories.push({ category: label, amountEth: amountRaw.trim() });
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// Custom categories loop
|
|
1744
|
+
while (true) {
|
|
1745
|
+
const { customName } = await prompts({
|
|
1746
|
+
type: "text",
|
|
1747
|
+
name: "customName",
|
|
1748
|
+
message: " Add custom category? [name or Enter to skip]",
|
|
1749
|
+
initial: "",
|
|
1750
|
+
});
|
|
1751
|
+
if (customName === undefined || customName.trim() === "") break;
|
|
1752
|
+
const { customAmount } = await prompts({
|
|
1753
|
+
type: "text",
|
|
1754
|
+
name: "customAmount",
|
|
1755
|
+
message: ` ${customName.trim()} limit in ETH`,
|
|
1756
|
+
initial: "0.05",
|
|
1757
|
+
validate: (v: string) => {
|
|
1758
|
+
try { ethers.parseEther(v); return true; } catch { return "Enter a valid ETH amount"; }
|
|
1759
|
+
},
|
|
1760
|
+
});
|
|
1761
|
+
if (customAmount === undefined) { console.log("Aborted."); return; }
|
|
1762
|
+
categories.push({ category: customName.trim(), amountEth: customAmount.trim() });
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// ── Step 4: summary ───────────────────────────────────────────────────
|
|
1766
|
+
console.log("\n─────────────────────────────────────────────────────");
|
|
1767
|
+
console.log("Changes to be made:");
|
|
1768
|
+
console.log(` Wallet: ${config.walletContractAddress}`);
|
|
1769
|
+
console.log(` Velocity limit: ${velocityEth} ETH per rolling window`);
|
|
1770
|
+
if (guardianWallet) {
|
|
1771
|
+
console.log(` Guardian key: ${guardianWallet.address} (new — private key will be saved to config)`);
|
|
1772
|
+
}
|
|
1773
|
+
if (categories.length > 0) {
|
|
1774
|
+
console.log(" Spending limits:");
|
|
1775
|
+
for (const { category, amountEth } of categories) {
|
|
1776
|
+
console.log(` ${category.padEnd(12)} ${amountEth} ETH`);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
console.log(` Transactions: ${1 + (guardianWallet ? 1 : 0) + categories.length} + onboarding (registerWallet, enableDefiAccess) total`);
|
|
1780
|
+
console.log("─────────────────────────────────────────────────────");
|
|
1781
|
+
|
|
1782
|
+
// ── Step 5: confirm ───────────────────────────────────────────────────
|
|
1783
|
+
const { confirmed } = await prompts({
|
|
1784
|
+
type: "confirm",
|
|
1785
|
+
name: "confirmed",
|
|
1786
|
+
message: "Confirm and sign with your wallet?",
|
|
1787
|
+
initial: true,
|
|
1788
|
+
});
|
|
1789
|
+
if (!confirmed) { console.log("Aborted."); return; }
|
|
1790
|
+
|
|
1791
|
+
// ── Step 6: connect WalletConnect once, send all transactions ─────────
|
|
1792
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1793
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1794
|
+
: undefined;
|
|
1795
|
+
|
|
1796
|
+
console.log("\nConnecting wallet...");
|
|
1797
|
+
const { client, session, account } = await connectPhoneWallet(
|
|
1798
|
+
config.walletConnectProjectId,
|
|
1799
|
+
chainId,
|
|
1800
|
+
config,
|
|
1801
|
+
{ telegramOpts, prompt: "Approve governance setup transactions on ARC402Wallet" }
|
|
1802
|
+
);
|
|
1803
|
+
|
|
1804
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
1805
|
+
const ownerInterface = new ethers.Interface(ARC402_WALLET_OWNER_ABI);
|
|
1806
|
+
const guardianInterface = new ethers.Interface(ARC402_WALLET_GUARDIAN_ABI);
|
|
1807
|
+
const policyInterface = new ethers.Interface(POLICY_ENGINE_LIMITS_ABI);
|
|
1808
|
+
const executeInterface = new ethers.Interface(ARC402_WALLET_EXECUTE_ABI);
|
|
1809
|
+
const govInterface = new ethers.Interface(POLICY_ENGINE_GOVERNANCE_ABI);
|
|
1810
|
+
const policyGovContract = new ethers.Contract(policyAddress, POLICY_ENGINE_GOVERNANCE_ABI, provider);
|
|
1811
|
+
|
|
1812
|
+
// Build the list of calls
|
|
1813
|
+
type TxCall = { to: string; data: string; value: string };
|
|
1814
|
+
const calls: TxCall[] = [];
|
|
1815
|
+
|
|
1816
|
+
// ── P0: mandatory onboarding calls (registerWallet + enableDefiAccess) ──
|
|
1817
|
+
// Check what the constructor already did to avoid double-registration reverts
|
|
1818
|
+
let govAlreadyRegistered = false;
|
|
1819
|
+
let govAlreadyDefiEnabled = false;
|
|
1820
|
+
try {
|
|
1821
|
+
const registeredOwner: string = await policyGovContract.walletOwners(config.walletContractAddress!);
|
|
1822
|
+
govAlreadyRegistered = registeredOwner !== ethers.ZeroAddress;
|
|
1823
|
+
} catch { /* assume not registered */ }
|
|
1824
|
+
try {
|
|
1825
|
+
govAlreadyDefiEnabled = await policyGovContract.defiAccessEnabled(config.walletContractAddress!);
|
|
1826
|
+
} catch { /* assume not enabled */ }
|
|
1827
|
+
|
|
1828
|
+
if (!govAlreadyRegistered) {
|
|
1829
|
+
const registerCalldata = govInterface.encodeFunctionData("registerWallet", [config.walletContractAddress!, account]);
|
|
1830
|
+
calls.push({
|
|
1831
|
+
to: config.walletContractAddress!,
|
|
1832
|
+
data: executeInterface.encodeFunctionData("executeContractCall", [{
|
|
1833
|
+
target: policyAddress,
|
|
1834
|
+
data: registerCalldata,
|
|
1835
|
+
value: 0n,
|
|
1836
|
+
minReturnValue: 0n,
|
|
1837
|
+
maxApprovalAmount: 0n,
|
|
1838
|
+
approvalToken: ethers.ZeroAddress,
|
|
1839
|
+
}]),
|
|
1840
|
+
value: "0x0",
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
if (!govAlreadyDefiEnabled) {
|
|
1845
|
+
calls.push({
|
|
1846
|
+
to: policyAddress,
|
|
1847
|
+
data: govInterface.encodeFunctionData("enableDefiAccess", [config.walletContractAddress!]),
|
|
1848
|
+
value: "0x0",
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// velocity limit
|
|
1853
|
+
calls.push({
|
|
1854
|
+
to: config.walletContractAddress!,
|
|
1855
|
+
data: ownerInterface.encodeFunctionData("setVelocityLimit", [ethers.parseEther(velocityEth)]),
|
|
1856
|
+
value: "0x0",
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
// guardian
|
|
1860
|
+
if (guardianWallet) {
|
|
1861
|
+
calls.push({
|
|
1862
|
+
to: config.walletContractAddress!,
|
|
1863
|
+
data: guardianInterface.encodeFunctionData("setGuardian", [guardianWallet.address]),
|
|
1864
|
+
value: "0x0",
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// category limits — called directly on PolicyEngine by owner key
|
|
1869
|
+
for (const { category, amountEth } of categories) {
|
|
1870
|
+
calls.push({
|
|
1871
|
+
to: policyAddress,
|
|
1872
|
+
data: policyInterface.encodeFunctionData("setCategoryLimitFor", [
|
|
1873
|
+
config.walletContractAddress!,
|
|
1874
|
+
category,
|
|
1875
|
+
ethers.parseEther(amountEth),
|
|
1876
|
+
]),
|
|
1877
|
+
value: "0x0",
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// Try wallet_sendCalls (EIP-5792) first, fall back to sequential eth_sendTransaction
|
|
1882
|
+
let txHashes: string[] = [];
|
|
1883
|
+
let usedBatch = false;
|
|
1884
|
+
|
|
1885
|
+
try {
|
|
1886
|
+
const batchResult = await client.request<{ id: string }>({
|
|
1887
|
+
topic: session.topic,
|
|
1888
|
+
chainId: `eip155:${chainId}`,
|
|
1889
|
+
request: {
|
|
1890
|
+
method: "wallet_sendCalls",
|
|
1891
|
+
params: [{
|
|
1892
|
+
version: "1.0",
|
|
1893
|
+
chainId: `0x${chainId.toString(16)}`,
|
|
1894
|
+
from: account,
|
|
1895
|
+
calls: calls.map(c => ({ to: c.to, data: c.data, value: c.value })),
|
|
1896
|
+
}],
|
|
1897
|
+
},
|
|
1898
|
+
});
|
|
1899
|
+
txHashes = [typeof batchResult === "string" ? batchResult : batchResult.id];
|
|
1900
|
+
usedBatch = true;
|
|
1901
|
+
} catch {
|
|
1902
|
+
// wallet_sendCalls not supported — send sequentially
|
|
1903
|
+
console.log(" (wallet_sendCalls not supported — sending sequentially)");
|
|
1904
|
+
for (let i = 0; i < calls.length; i++) {
|
|
1905
|
+
console.log(` Sending transaction ${i + 1}/${calls.length}...`);
|
|
1906
|
+
const txHash = await sendTransactionWithSession(client, session, account, chainId, calls[i]);
|
|
1907
|
+
txHashes.push(txHash);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// Persist guardian key if generated
|
|
1912
|
+
if (guardianWallet) {
|
|
1913
|
+
config.guardianPrivateKey = guardianWallet.privateKey;
|
|
1914
|
+
config.guardianAddress = guardianWallet.address;
|
|
1915
|
+
saveConfig(config);
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
console.log(`\n✓ Governance setup complete`);
|
|
1919
|
+
if (usedBatch) {
|
|
1920
|
+
console.log(` Batch tx: ${txHashes[0]}`);
|
|
1921
|
+
} else {
|
|
1922
|
+
txHashes.forEach((h, i) => console.log(` Tx ${i + 1}: ${h}`));
|
|
1923
|
+
}
|
|
1924
|
+
if (guardianWallet) {
|
|
1925
|
+
console.log(` Guardian key saved to config — address: ${guardianWallet.address}`);
|
|
1926
|
+
console.log(` WARN: Store the guardian private key separately from your hot key.`);
|
|
1927
|
+
}
|
|
1928
|
+
console.log(`\nVerify with: arc402 wallet status && arc402 wallet policy show`);
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
// ─── authorize-machine-key ─────────────────────────────────────────────────
|
|
1932
|
+
|
|
1933
|
+
wallet.command("authorize-machine-key <key>")
|
|
1934
|
+
.description("Authorize a machine key (hot key) on your ARC402Wallet (phone wallet signs via WalletConnect)")
|
|
1935
|
+
.action(async (keyAddress: string) => {
|
|
1936
|
+
const config = loadConfig();
|
|
1937
|
+
if (!config.walletContractAddress) {
|
|
1938
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
1939
|
+
process.exit(1);
|
|
1940
|
+
}
|
|
1941
|
+
if (!config.walletConnectProjectId) {
|
|
1942
|
+
console.error("walletConnectProjectId not set in config.");
|
|
1943
|
+
process.exit(1);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
let checksumKey: string;
|
|
1947
|
+
try {
|
|
1948
|
+
checksumKey = ethers.getAddress(keyAddress);
|
|
1949
|
+
} catch {
|
|
1950
|
+
console.error(`Invalid address: ${keyAddress}`);
|
|
1951
|
+
process.exit(1);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
1955
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
1956
|
+
const machineKeyAbi = ["function authorizeMachineKey(address key) external", "function authorizedMachineKeys(address) external view returns (bool)"];
|
|
1957
|
+
const walletContract = new ethers.Contract(config.walletContractAddress, machineKeyAbi, provider);
|
|
1958
|
+
|
|
1959
|
+
let alreadyAuthorized = false;
|
|
1960
|
+
try {
|
|
1961
|
+
alreadyAuthorized = await walletContract.authorizedMachineKeys(checksumKey);
|
|
1962
|
+
} catch { /* ignore */ }
|
|
1963
|
+
|
|
1964
|
+
if (alreadyAuthorized) {
|
|
1965
|
+
console.log(`\n✓ ${checksumKey} is already authorized as a machine key on ${config.walletContractAddress}`);
|
|
1966
|
+
process.exit(0);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
console.log(`\nWallet: ${config.walletContractAddress}`);
|
|
1970
|
+
console.log(`Machine key: ${checksumKey}`);
|
|
1971
|
+
|
|
1972
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
1973
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
1974
|
+
: undefined;
|
|
1975
|
+
|
|
1976
|
+
const walletInterface = new ethers.Interface(machineKeyAbi);
|
|
1977
|
+
const txData = {
|
|
1978
|
+
to: config.walletContractAddress,
|
|
1979
|
+
data: walletInterface.encodeFunctionData("authorizeMachineKey", [checksumKey]),
|
|
1980
|
+
value: "0x0",
|
|
1981
|
+
};
|
|
1982
|
+
|
|
1983
|
+
const { client, session, account } = await connectPhoneWallet(
|
|
1984
|
+
config.walletConnectProjectId,
|
|
1985
|
+
chainId,
|
|
1986
|
+
config,
|
|
1987
|
+
{
|
|
1988
|
+
telegramOpts,
|
|
1989
|
+
prompt: `Authorize machine key ${checksumKey} on ARC402Wallet — allows autonomous protocol ops`,
|
|
1990
|
+
}
|
|
1991
|
+
);
|
|
1992
|
+
|
|
1993
|
+
console.log(`\n✓ Connected: ${account}`);
|
|
1994
|
+
console.log("Sending authorizeMachineKey transaction...");
|
|
1995
|
+
|
|
1996
|
+
const hash = await sendTransactionWithSession(client, session, account, chainId, txData);
|
|
1997
|
+
console.log(`\nTransaction submitted: ${hash}`);
|
|
1998
|
+
console.log("Waiting for confirmation...");
|
|
1999
|
+
|
|
2000
|
+
const receipt = await provider.waitForTransaction(hash, 1, 60000);
|
|
2001
|
+
if (!receipt || receipt.status !== 1) {
|
|
2002
|
+
console.error("Transaction failed.");
|
|
2003
|
+
process.exit(1);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
const confirmed = await walletContract.authorizedMachineKeys(checksumKey);
|
|
2007
|
+
console.log(`\n✓ Machine key authorized: ${confirmed ? "YES" : "NO"}`);
|
|
2008
|
+
console.log(` Wallet: ${config.walletContractAddress}`);
|
|
2009
|
+
console.log(` Machine key: ${checksumKey}`);
|
|
2010
|
+
console.log(` Tx: ${hash}`);
|
|
2011
|
+
|
|
2012
|
+
await client.disconnect({ topic: session.topic, reason: { code: 6000, message: "done" } });
|
|
2013
|
+
process.exit(0);
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
// ─── revoke-machine-key (J1-04 / J5-01) ───────────────────────────────────
|
|
2017
|
+
//
|
|
2018
|
+
// Revokes an authorized machine key via owner WalletConnect approval.
|
|
2019
|
+
// Pre-checks that the key IS currently authorized before sending.
|
|
2020
|
+
|
|
2021
|
+
wallet.command("revoke-machine-key <address>")
|
|
2022
|
+
.description("Revoke an authorized machine key on ARC402Wallet (phone wallet signs via WalletConnect)")
|
|
2023
|
+
.action(async (keyAddress: string) => {
|
|
2024
|
+
const config = loadConfig();
|
|
2025
|
+
if (!config.walletContractAddress) {
|
|
2026
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
2027
|
+
process.exit(1);
|
|
2028
|
+
}
|
|
2029
|
+
if (!config.walletConnectProjectId) {
|
|
2030
|
+
console.error("walletConnectProjectId not set in config.");
|
|
2031
|
+
process.exit(1);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
let checksumKey: string;
|
|
2035
|
+
try {
|
|
2036
|
+
checksumKey = ethers.getAddress(keyAddress);
|
|
2037
|
+
} catch {
|
|
2038
|
+
console.error(`Invalid address: ${keyAddress}`);
|
|
2039
|
+
process.exit(1);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
2043
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
2044
|
+
const walletContract = new ethers.Contract(config.walletContractAddress, ARC402_WALLET_MACHINE_KEY_ABI, provider);
|
|
2045
|
+
|
|
2046
|
+
// Pre-check: verify the key IS currently authorized
|
|
2047
|
+
let isAuthorized = false;
|
|
2048
|
+
try {
|
|
2049
|
+
isAuthorized = await walletContract.authorizedMachineKeys(checksumKey);
|
|
2050
|
+
} catch { /* ignore — attempt revoke anyway */ }
|
|
2051
|
+
|
|
2052
|
+
if (!isAuthorized) {
|
|
2053
|
+
console.error(`Machine key ${checksumKey} is NOT currently authorized on ${config.walletContractAddress}.`);
|
|
2054
|
+
console.error(`Run \`arc402 wallet list-machine-keys\` to see authorized keys.`);
|
|
2055
|
+
process.exit(1);
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
console.log(`\nWallet: ${config.walletContractAddress}`);
|
|
2059
|
+
console.log(`Revoking: ${checksumKey}`);
|
|
2060
|
+
|
|
2061
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
2062
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
2063
|
+
: undefined;
|
|
2064
|
+
|
|
2065
|
+
const walletInterface = new ethers.Interface(ARC402_WALLET_MACHINE_KEY_ABI);
|
|
2066
|
+
const { client, session, account } = await connectPhoneWallet(
|
|
2067
|
+
config.walletConnectProjectId,
|
|
2068
|
+
chainId,
|
|
2069
|
+
config,
|
|
2070
|
+
{ telegramOpts, prompt: `Revoke machine key ${checksumKey} on ARC402Wallet` }
|
|
2071
|
+
);
|
|
2072
|
+
|
|
2073
|
+
console.log(`\n✓ Connected: ${account}`);
|
|
2074
|
+
console.log("Sending revokeMachineKey transaction...");
|
|
2075
|
+
|
|
2076
|
+
const hash = await sendTransactionWithSession(client, session, account, chainId, {
|
|
2077
|
+
to: config.walletContractAddress,
|
|
2078
|
+
data: walletInterface.encodeFunctionData("revokeMachineKey", [checksumKey]),
|
|
2079
|
+
value: "0x0",
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
console.log(`\nTransaction submitted: ${hash}`);
|
|
2083
|
+
console.log("Waiting for confirmation...");
|
|
2084
|
+
|
|
2085
|
+
const receipt = await provider.waitForTransaction(hash, 1, 60000);
|
|
2086
|
+
if (!receipt || receipt.status !== 1) {
|
|
2087
|
+
console.error("Transaction failed.");
|
|
2088
|
+
process.exit(1);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
const stillAuthorized = await walletContract.authorizedMachineKeys(checksumKey);
|
|
2092
|
+
console.log(`\n✓ Machine key revoked: ${stillAuthorized ? "NO (still authorized — check tx)" : "YES"}`);
|
|
2093
|
+
console.log(` Wallet: ${config.walletContractAddress}`);
|
|
2094
|
+
console.log(` Machine key: ${checksumKey}`);
|
|
2095
|
+
console.log(` Tx: ${hash}`);
|
|
2096
|
+
|
|
2097
|
+
await client.disconnect({ topic: session.topic, reason: { code: 6000, message: "done" } });
|
|
2098
|
+
process.exit(0);
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
// ─── list-machine-keys (J5-02) ─────────────────────────────────────────────
|
|
2102
|
+
//
|
|
2103
|
+
// Lists authorized machine keys by scanning MachineKeyAuthorized/MachineKeyRevoked
|
|
2104
|
+
// events. Falls back to checking the configured machine key if no events found.
|
|
2105
|
+
|
|
2106
|
+
wallet.command("list-machine-keys")
|
|
2107
|
+
.description("List authorized machine keys by scanning contract events")
|
|
2108
|
+
.option("--json")
|
|
2109
|
+
.action(async (opts) => {
|
|
2110
|
+
const config = loadConfig();
|
|
2111
|
+
const walletAddr = config.walletContractAddress;
|
|
2112
|
+
if (!walletAddr) {
|
|
2113
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
2114
|
+
process.exit(1);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
2118
|
+
const walletContract = new ethers.Contract(walletAddr, ARC402_WALLET_MACHINE_KEY_ABI, provider);
|
|
2119
|
+
|
|
2120
|
+
// Scan for MachineKeyAuthorized and MachineKeyRevoked events
|
|
2121
|
+
const authorizedTopic = ethers.id("MachineKeyAuthorized(address)");
|
|
2122
|
+
const revokedTopic = ethers.id("MachineKeyRevoked(address)");
|
|
2123
|
+
|
|
2124
|
+
const authorizedKeys = new Set<string>();
|
|
2125
|
+
const revokedKeys = new Set<string>();
|
|
2126
|
+
|
|
2127
|
+
try {
|
|
2128
|
+
const [authLogs, revokeLogs] = await Promise.all([
|
|
2129
|
+
provider.getLogs({ address: walletAddr, topics: [authorizedTopic], fromBlock: 0 }),
|
|
2130
|
+
provider.getLogs({ address: walletAddr, topics: [revokedTopic], fromBlock: 0 }),
|
|
2131
|
+
]);
|
|
2132
|
+
for (const log of authLogs) {
|
|
2133
|
+
const key = ethers.getAddress("0x" + log.topics[1].slice(26));
|
|
2134
|
+
authorizedKeys.add(key);
|
|
2135
|
+
}
|
|
2136
|
+
for (const log of revokeLogs) {
|
|
2137
|
+
const key = ethers.getAddress("0x" + log.topics[1].slice(26));
|
|
2138
|
+
revokedKeys.add(key);
|
|
2139
|
+
}
|
|
2140
|
+
} catch { /* event scan failed — fall back to config key */ }
|
|
2141
|
+
|
|
2142
|
+
// Build active key list: authorized but not revoked
|
|
2143
|
+
const activeFromEvents = [...authorizedKeys].filter((k) => !revokedKeys.has(k));
|
|
2144
|
+
|
|
2145
|
+
// Also check configured machine key
|
|
2146
|
+
const configMachineKey = config.privateKey ? new ethers.Wallet(config.privateKey).address : null;
|
|
2147
|
+
|
|
2148
|
+
// Verify each candidate against chain
|
|
2149
|
+
const candidates = new Set<string>(activeFromEvents);
|
|
2150
|
+
if (configMachineKey) candidates.add(configMachineKey);
|
|
2151
|
+
|
|
2152
|
+
const results: Array<{ address: string; authorized: boolean }> = [];
|
|
2153
|
+
for (const addr of candidates) {
|
|
2154
|
+
let authorized = false;
|
|
2155
|
+
try {
|
|
2156
|
+
authorized = await walletContract.authorizedMachineKeys(addr);
|
|
2157
|
+
} catch { /* ignore */ }
|
|
2158
|
+
results.push({ address: addr, authorized });
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
if (opts.json) {
|
|
2162
|
+
console.log(JSON.stringify({ walletAddress: walletAddr, machineKeys: results }, null, 2));
|
|
2163
|
+
} else {
|
|
2164
|
+
console.log(`\nMachine keys for wallet: ${walletAddr}\n`);
|
|
2165
|
+
if (results.length === 0) {
|
|
2166
|
+
console.log(" No machine keys found.");
|
|
2167
|
+
} else {
|
|
2168
|
+
for (const r of results) {
|
|
2169
|
+
const status = r.authorized ? "AUTHORIZED" : "not authorized";
|
|
2170
|
+
const tag = r.address === configMachineKey ? " [configured]" : "";
|
|
2171
|
+
console.log(` ${r.address} ${status}${tag}`);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
console.log(`\nTo authorize: arc402 wallet authorize-machine-key <address>`);
|
|
2175
|
+
console.log(`To revoke: arc402 wallet revoke-machine-key <address>`);
|
|
2176
|
+
}
|
|
2177
|
+
});
|
|
2178
|
+
|
|
2179
|
+
// ─── open-context (J1-03) ──────────────────────────────────────────────────
|
|
2180
|
+
//
|
|
2181
|
+
// Standalone command for opening a spend context via machine key.
|
|
2182
|
+
// Note: each context allows only one spend — a new context must be opened per payment.
|
|
2183
|
+
|
|
2184
|
+
wallet.command("open-context")
|
|
2185
|
+
.description("Open a spend context on the wallet via machine key (each context allows only one spend)")
|
|
2186
|
+
.option("--task-type <type>", "Task type string for the context", "general")
|
|
2187
|
+
.option("--json")
|
|
2188
|
+
.action(async (opts) => {
|
|
2189
|
+
const config = loadConfig();
|
|
2190
|
+
warnIfPublicRpc(config);
|
|
2191
|
+
const walletAddr = config.walletContractAddress;
|
|
2192
|
+
if (!walletAddr) {
|
|
2193
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
2194
|
+
process.exit(1);
|
|
2195
|
+
}
|
|
2196
|
+
if (!config.privateKey) {
|
|
2197
|
+
console.error("privateKey not set in config — machine key required for open-context.");
|
|
2198
|
+
process.exit(1);
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
2202
|
+
const machineKey = new ethers.Wallet(config.privateKey, provider);
|
|
2203
|
+
const walletContract = new ethers.Contract(walletAddr, ARC402_WALLET_PROTOCOL_ABI, machineKey);
|
|
2204
|
+
|
|
2205
|
+
// Check context isn't already open
|
|
2206
|
+
const isOpen: boolean = await walletContract.contextOpen();
|
|
2207
|
+
if (isOpen) {
|
|
2208
|
+
console.error("A context is already open on this wallet.");
|
|
2209
|
+
console.error("Close it first: arc402 wallet close-context");
|
|
2210
|
+
process.exit(1);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
const contextId = ethers.hexlify(ethers.randomBytes(32));
|
|
2214
|
+
console.log(`Opening context (taskType: ${opts.taskType})...`);
|
|
2215
|
+
const tx = await walletContract.openContext(contextId, opts.taskType);
|
|
2216
|
+
const receipt = await tx.wait(1);
|
|
2217
|
+
|
|
2218
|
+
if (opts.json) {
|
|
2219
|
+
console.log(JSON.stringify({ walletAddress: walletAddr, contextId, taskType: opts.taskType, txHash: receipt?.hash }));
|
|
2220
|
+
} else {
|
|
2221
|
+
console.log(`✓ Context opened`);
|
|
2222
|
+
console.log(` contextId: ${contextId}`);
|
|
2223
|
+
console.log(` taskType: ${opts.taskType}`);
|
|
2224
|
+
console.log(` Tx: ${receipt?.hash}`);
|
|
2225
|
+
console.log(`\nNote: Each context allows only one spend. Call \`arc402 wallet attest\` then \`arc402 wallet drain\` (or executeSpend directly).`);
|
|
2226
|
+
}
|
|
2227
|
+
});
|
|
2228
|
+
|
|
2229
|
+
// ─── attest (J1-03) ────────────────────────────────────────────────────────
|
|
2230
|
+
//
|
|
2231
|
+
// Standalone command for creating an attestation via machine key.
|
|
2232
|
+
// Returns the attestationId for use in executeSpend / drain.
|
|
2233
|
+
|
|
2234
|
+
wallet.command("attest")
|
|
2235
|
+
.description("Create an attestation via machine key directly on wallet, returns attestationId")
|
|
2236
|
+
.requiredOption("--recipient <addr>", "Recipient address")
|
|
2237
|
+
.requiredOption("--amount <eth>", "Amount in ETH")
|
|
2238
|
+
.requiredOption("--category <cat>", "Spend category (used as action)")
|
|
2239
|
+
.option("--token <addr>", "Token contract address (default: ETH / zero address)")
|
|
2240
|
+
.option("--ttl <seconds>", "Attestation TTL in seconds (default: 600)", "600")
|
|
2241
|
+
.option("--json")
|
|
2242
|
+
.action(async (opts) => {
|
|
2243
|
+
const config = loadConfig();
|
|
2244
|
+
warnIfPublicRpc(config);
|
|
2245
|
+
const walletAddr = config.walletContractAddress;
|
|
2246
|
+
if (!walletAddr) {
|
|
2247
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
2248
|
+
process.exit(1);
|
|
2249
|
+
}
|
|
2250
|
+
if (!config.privateKey) {
|
|
2251
|
+
console.error("privateKey not set in config — machine key required for attest.");
|
|
2252
|
+
process.exit(1);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
let checksumRecipient: string;
|
|
2256
|
+
try {
|
|
2257
|
+
checksumRecipient = ethers.getAddress(opts.recipient);
|
|
2258
|
+
} catch {
|
|
2259
|
+
console.error(`Invalid recipient address: ${opts.recipient}`);
|
|
2260
|
+
process.exit(1);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
const tokenAddress = opts.token ? ethers.getAddress(opts.token) : ethers.ZeroAddress;
|
|
2264
|
+
const amount = ethers.parseEther(opts.amount);
|
|
2265
|
+
const expiresAt = Math.floor(Date.now() / 1000) + parseInt(opts.ttl, 10);
|
|
2266
|
+
const attestationId = ethers.hexlify(ethers.randomBytes(32));
|
|
2267
|
+
|
|
2268
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
2269
|
+
const machineKey = new ethers.Wallet(config.privateKey, provider);
|
|
2270
|
+
const walletContract = new ethers.Contract(walletAddr, ARC402_WALLET_PROTOCOL_ABI, machineKey);
|
|
2271
|
+
|
|
2272
|
+
console.log(`Creating attestation...`);
|
|
2273
|
+
const tx = await walletContract.attest(
|
|
2274
|
+
attestationId,
|
|
2275
|
+
opts.category,
|
|
2276
|
+
`cli attest: ${opts.category} to ${checksumRecipient}`,
|
|
2277
|
+
checksumRecipient,
|
|
2278
|
+
amount,
|
|
2279
|
+
tokenAddress,
|
|
2280
|
+
expiresAt,
|
|
2281
|
+
);
|
|
2282
|
+
const receipt = await tx.wait(1);
|
|
2283
|
+
|
|
2284
|
+
if (opts.json) {
|
|
2285
|
+
console.log(JSON.stringify({
|
|
2286
|
+
walletAddress: walletAddr,
|
|
2287
|
+
attestationId,
|
|
2288
|
+
recipient: checksumRecipient,
|
|
2289
|
+
amount: opts.amount,
|
|
2290
|
+
token: tokenAddress,
|
|
2291
|
+
category: opts.category,
|
|
2292
|
+
expiresAt,
|
|
2293
|
+
txHash: receipt?.hash,
|
|
2294
|
+
}));
|
|
2295
|
+
} else {
|
|
2296
|
+
console.log(`✓ Attestation created`);
|
|
2297
|
+
console.log(` attestationId: ${attestationId}`);
|
|
2298
|
+
console.log(` recipient: ${checksumRecipient}`);
|
|
2299
|
+
console.log(` amount: ${opts.amount} ETH`);
|
|
2300
|
+
console.log(` token: ${tokenAddress === ethers.ZeroAddress ? "ETH" : tokenAddress}`);
|
|
2301
|
+
console.log(` expiresAt: ${new Date(expiresAt * 1000).toISOString()}`);
|
|
2302
|
+
console.log(` Tx: ${receipt?.hash}`);
|
|
2303
|
+
console.log(`\nUse this attestationId in \`arc402 wallet drain\` or your spend flow.`);
|
|
2304
|
+
}
|
|
2305
|
+
});
|
|
2306
|
+
|
|
2307
|
+
// ─── velocity-status (J8-03) ───────────────────────────────────────────────
|
|
2308
|
+
//
|
|
2309
|
+
// Read-only: shows wallet-level velocity limit, window start, cumulative spend, and remaining.
|
|
2310
|
+
|
|
2311
|
+
wallet.command("velocity-status")
|
|
2312
|
+
.description("Show wallet-level velocity limit, current window spend, and remaining budget")
|
|
2313
|
+
.option("--json")
|
|
2314
|
+
.action(async (opts) => {
|
|
2315
|
+
const config = loadConfig();
|
|
2316
|
+
const walletAddr = config.walletContractAddress;
|
|
2317
|
+
if (!walletAddr) {
|
|
2318
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
2319
|
+
process.exit(1);
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
2323
|
+
const walletContract = new ethers.Contract(walletAddr, ARC402_WALLET_OWNER_ABI, provider);
|
|
2324
|
+
|
|
2325
|
+
let velocityLimit = 0n;
|
|
2326
|
+
let velocityWindowStart = 0n;
|
|
2327
|
+
let cumulativeSpend = 0n;
|
|
2328
|
+
|
|
2329
|
+
try {
|
|
2330
|
+
[velocityLimit, velocityWindowStart, cumulativeSpend] = await Promise.all([
|
|
2331
|
+
walletContract.velocityLimit(),
|
|
2332
|
+
walletContract.velocityWindowStart(),
|
|
2333
|
+
walletContract.cumulativeSpend(),
|
|
2334
|
+
]);
|
|
2335
|
+
} catch (e) {
|
|
2336
|
+
console.error("Failed to read velocity data from wallet:", e instanceof Error ? e.message : String(e));
|
|
2337
|
+
process.exit(1);
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
const remaining = velocityLimit === 0n ? null : (velocityLimit > cumulativeSpend ? velocityLimit - cumulativeSpend : 0n);
|
|
2341
|
+
const windowStartDate = velocityWindowStart === 0n ? null : new Date(Number(velocityWindowStart) * 1000);
|
|
2342
|
+
|
|
2343
|
+
if (opts.json) {
|
|
2344
|
+
console.log(JSON.stringify({
|
|
2345
|
+
walletAddress: walletAddr,
|
|
2346
|
+
velocityLimit: ethers.formatEther(velocityLimit),
|
|
2347
|
+
velocityLimitEnabled: velocityLimit > 0n,
|
|
2348
|
+
velocityWindowStart: windowStartDate?.toISOString() ?? null,
|
|
2349
|
+
cumulativeSpend: ethers.formatEther(cumulativeSpend),
|
|
2350
|
+
remaining: remaining !== null ? ethers.formatEther(remaining) : null,
|
|
2351
|
+
}, null, 2));
|
|
2352
|
+
} else {
|
|
2353
|
+
console.log(`\nWallet velocity status: ${walletAddr}\n`);
|
|
2354
|
+
if (velocityLimit === 0n) {
|
|
2355
|
+
console.log(` Velocity limit: disabled (set with \`arc402 wallet set-velocity-limit <eth>\`)`);
|
|
2356
|
+
} else {
|
|
2357
|
+
console.log(` Limit: ${ethers.formatEther(velocityLimit)} ETH per rolling window`);
|
|
2358
|
+
console.log(` Window start: ${windowStartDate?.toISOString() ?? "(no window yet)"}`);
|
|
2359
|
+
console.log(` Spent: ${ethers.formatEther(cumulativeSpend)} ETH`);
|
|
2360
|
+
console.log(` Remaining: ${remaining !== null ? ethers.formatEther(remaining) + " ETH" : "N/A"}`);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
// ─── check-context ─────────────────────────────────────────────────────────
|
|
2366
|
+
//
|
|
2367
|
+
// P1 guardrail: inspect on-chain context state before attempting openContext.
|
|
2368
|
+
|
|
2369
|
+
wallet.command("check-context")
|
|
2370
|
+
.description("Check whether the wallet's spend context is currently open (uses Alchemy RPC)")
|
|
2371
|
+
.option("--json")
|
|
2372
|
+
.action(async (opts) => {
|
|
2373
|
+
const config = loadConfig();
|
|
2374
|
+
warnIfPublicRpc(config);
|
|
2375
|
+
const walletAddr = config.walletContractAddress;
|
|
2376
|
+
if (!walletAddr) {
|
|
2377
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
2378
|
+
process.exit(1);
|
|
2379
|
+
}
|
|
2380
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
2381
|
+
const walletContract = new ethers.Contract(walletAddr, ARC402_WALLET_PROTOCOL_ABI, provider);
|
|
2382
|
+
const isOpen: boolean = await walletContract.contextOpen();
|
|
2383
|
+
if (opts.json) {
|
|
2384
|
+
console.log(JSON.stringify({ walletAddress: walletAddr, contextOpen: isOpen }));
|
|
2385
|
+
} else {
|
|
2386
|
+
console.log(`Wallet: ${walletAddr}`);
|
|
2387
|
+
console.log(`contextOpen: ${isOpen ? "OPEN — close before opening a new context" : "closed"}`);
|
|
2388
|
+
}
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
// ─── close-context ─────────────────────────────────────────────────────────
|
|
2392
|
+
//
|
|
2393
|
+
// P1 guardrail: force-close a stale context that was left open by a failed operation.
|
|
2394
|
+
// Uses the machine key (config.privateKey) — onlyOwnerOrMachineKey.
|
|
2395
|
+
|
|
2396
|
+
wallet.command("close-context")
|
|
2397
|
+
.description("Force-close a stale open context on the wallet (machine key signs — onlyOwnerOrMachineKey)")
|
|
2398
|
+
.option("--json")
|
|
2399
|
+
.action(async (opts) => {
|
|
2400
|
+
const config = loadConfig();
|
|
2401
|
+
warnIfPublicRpc(config);
|
|
2402
|
+
const walletAddr = config.walletContractAddress;
|
|
2403
|
+
if (!walletAddr) {
|
|
2404
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
2405
|
+
process.exit(1);
|
|
2406
|
+
}
|
|
2407
|
+
if (!config.privateKey) {
|
|
2408
|
+
console.error("privateKey not set in config — machine key required for close-context.");
|
|
2409
|
+
process.exit(1);
|
|
2410
|
+
}
|
|
2411
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
2412
|
+
const machineKey = new ethers.Wallet(config.privateKey, provider);
|
|
2413
|
+
const walletContract = new ethers.Contract(walletAddr, ARC402_WALLET_PROTOCOL_ABI, machineKey);
|
|
2414
|
+
|
|
2415
|
+
const isOpen: boolean = await walletContract.contextOpen();
|
|
2416
|
+
if (!isOpen) {
|
|
2417
|
+
if (opts.json) {
|
|
2418
|
+
console.log(JSON.stringify({ walletAddress: walletAddr, contextOpen: false, action: "nothing — already closed" }));
|
|
2419
|
+
} else {
|
|
2420
|
+
console.log("Context is already closed — nothing to do.");
|
|
2421
|
+
}
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
console.log("Closing stale context...");
|
|
2426
|
+
const tx = await walletContract.closeContext();
|
|
2427
|
+
const receipt = await tx.wait(2);
|
|
2428
|
+
if (opts.json) {
|
|
2429
|
+
console.log(JSON.stringify({ walletAddress: walletAddr, txHash: receipt?.hash, contextOpen: false }));
|
|
2430
|
+
} else {
|
|
2431
|
+
console.log(`✓ Context closed`);
|
|
2432
|
+
console.log(` Tx: ${receipt?.hash}`);
|
|
2433
|
+
console.log(` Wallet: ${walletAddr}`);
|
|
2434
|
+
}
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
// ─── drain ─────────────────────────────────────────────────────────────────
|
|
2438
|
+
//
|
|
2439
|
+
// P1 + BUG-DRAIN-06: full autonomous drain via machine key.
|
|
2440
|
+
// Flow: check context → close if stale → openContext → attest (direct) → executeSpend → closeContext
|
|
2441
|
+
// All transactions signed by machine key (onlyOwnerOrMachineKey). No WalletConnect needed.
|
|
2442
|
+
|
|
2443
|
+
wallet.command("drain")
|
|
2444
|
+
.description("Drain ETH from wallet contract to recipient via machine key (openContext → attest → executeSpend → closeContext). Note: each context allows exactly one spend — a new context is opened per call.")
|
|
2445
|
+
.argument("[recipient]", "Recipient address (defaults to config.ownerAddress)")
|
|
2446
|
+
.option("--amount <eth>", "Amount to drain in ETH (default: all minus 0.00005 ETH gas reserve)")
|
|
2447
|
+
.option("--category <cat>", "Spend category (default: general)", "general")
|
|
2448
|
+
.option("--json")
|
|
2449
|
+
.action(async (recipientArg: string | undefined, opts) => {
|
|
2450
|
+
const config = loadConfig();
|
|
2451
|
+
warnIfPublicRpc(config);
|
|
2452
|
+
|
|
2453
|
+
const walletAddr = config.walletContractAddress;
|
|
2454
|
+
if (!walletAddr) {
|
|
2455
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
2456
|
+
process.exit(1);
|
|
2457
|
+
}
|
|
2458
|
+
if (!config.privateKey) {
|
|
2459
|
+
console.error("privateKey not set in config — machine key required for drain.");
|
|
2460
|
+
process.exit(1);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
const recipient = recipientArg ?? config.ownerAddress;
|
|
2464
|
+
if (!recipient) {
|
|
2465
|
+
console.error("No recipient address. Pass a recipient argument or set ownerAddress in config.");
|
|
2466
|
+
process.exit(1);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
let checksumRecipient: string;
|
|
2470
|
+
try {
|
|
2471
|
+
checksumRecipient = ethers.getAddress(recipient);
|
|
2472
|
+
} catch {
|
|
2473
|
+
console.error(`Invalid recipient address: ${recipient}`);
|
|
2474
|
+
process.exit(1);
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
const GAS_RESERVE = ethers.parseEther("0.00005");
|
|
2478
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
2479
|
+
const machineKey = new ethers.Wallet(config.privateKey, provider);
|
|
2480
|
+
const walletContract = new ethers.Contract(walletAddr, ARC402_WALLET_PROTOCOL_ABI, machineKey);
|
|
2481
|
+
|
|
2482
|
+
// ── Pre-flight checks ──────────────────────────────────────────────────
|
|
2483
|
+
const balance = await provider.getBalance(walletAddr);
|
|
2484
|
+
console.log(`Wallet balance: ${ethers.formatEther(balance)} ETH`);
|
|
2485
|
+
|
|
2486
|
+
if (balance <= GAS_RESERVE) {
|
|
2487
|
+
console.error(`Insufficient balance: ${ethers.formatEther(balance)} ETH — need more than ${ethers.formatEther(GAS_RESERVE)} ETH reserve`);
|
|
2488
|
+
process.exit(1);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// Check category is configured on PolicyEngine
|
|
2492
|
+
const policyAddress = config.policyEngineAddress ?? POLICY_ENGINE_DEFAULT;
|
|
2493
|
+
const policyContract = new ethers.Contract(policyAddress, POLICY_ENGINE_LIMITS_ABI, provider);
|
|
2494
|
+
const categoryLimit: bigint = await policyContract.categoryLimits(walletAddr, opts.category);
|
|
2495
|
+
if (categoryLimit === 0n) {
|
|
2496
|
+
console.error(`Category "${opts.category}" is not configured on PolicyEngine for this wallet.`);
|
|
2497
|
+
console.error(`Fix: arc402 wallet policy set-limit --category ${opts.category} --amount <eth>`);
|
|
2498
|
+
process.exit(1);
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// Verify machine key is authorized
|
|
2502
|
+
const machineKeyAbi = ["function authorizedMachineKeys(address) external view returns (bool)"];
|
|
2503
|
+
const walletCheck = new ethers.Contract(walletAddr, machineKeyAbi, provider);
|
|
2504
|
+
let isAuthorized = false;
|
|
2505
|
+
try {
|
|
2506
|
+
isAuthorized = await walletCheck.authorizedMachineKeys(machineKey.address);
|
|
2507
|
+
} catch { /* older wallet — assume authorized */ isAuthorized = true; }
|
|
2508
|
+
if (!isAuthorized) {
|
|
2509
|
+
console.error(`Machine key ${machineKey.address} is not authorized on wallet ${walletAddr}`);
|
|
2510
|
+
console.error(`Fix: arc402 wallet authorize-machine-key ${machineKey.address}`);
|
|
2511
|
+
process.exit(1);
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// Compute drain amount
|
|
2515
|
+
let drainAmount: bigint;
|
|
2516
|
+
if (opts.amount) {
|
|
2517
|
+
drainAmount = ethers.parseEther(opts.amount);
|
|
2518
|
+
} else {
|
|
2519
|
+
drainAmount = balance - GAS_RESERVE;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
if (drainAmount > categoryLimit) {
|
|
2523
|
+
console.warn(`WARN: drainAmount (${ethers.formatEther(drainAmount)} ETH) exceeds category limit (${ethers.formatEther(categoryLimit)} ETH)`);
|
|
2524
|
+
console.warn(` Capping at category limit.`);
|
|
2525
|
+
drainAmount = categoryLimit;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
console.log(`\nDrain plan:`);
|
|
2529
|
+
console.log(` Wallet: ${walletAddr}`);
|
|
2530
|
+
console.log(` Recipient: ${checksumRecipient}`);
|
|
2531
|
+
console.log(` Amount: ${ethers.formatEther(drainAmount)} ETH`);
|
|
2532
|
+
console.log(` Category: ${opts.category}`);
|
|
2533
|
+
console.log(` MachineKey: ${machineKey.address}`);
|
|
2534
|
+
console.log(`\nNote: Each context allows exactly one spend. A new context is opened for each drain call.\n`);
|
|
2535
|
+
|
|
2536
|
+
// ── Step 1: context cleanup ────────────────────────────────────────────
|
|
2537
|
+
const isOpen: boolean = await walletContract.contextOpen();
|
|
2538
|
+
if (isOpen) {
|
|
2539
|
+
console.log("Stale context found — closing it first...");
|
|
2540
|
+
const closeTx = await walletContract.closeContext();
|
|
2541
|
+
await closeTx.wait(2);
|
|
2542
|
+
console.log(` ✓ Closed: ${closeTx.hash}`);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// ── Step 2: openContext ────────────────────────────────────────────────
|
|
2546
|
+
const contextId = ethers.keccak256(ethers.toUtf8Bytes(`drain-${Date.now()}`));
|
|
2547
|
+
console.log("Opening context...");
|
|
2548
|
+
const openTx = await walletContract.openContext(contextId, "drain");
|
|
2549
|
+
const openReceipt = await openTx.wait(1);
|
|
2550
|
+
console.log(` ✓ openContext: ${openReceipt?.hash}`);
|
|
2551
|
+
|
|
2552
|
+
// ── Step 3: attest (direct on wallet — onlyOwnerOrMachineKey, NOT via executeContractCall)
|
|
2553
|
+
const attestationId = ethers.hexlify(ethers.randomBytes(32));
|
|
2554
|
+
const expiry = Math.floor(Date.now() / 1000) + 600; // 10 min TTL
|
|
2555
|
+
console.log("Creating attestation (direct on wallet)...");
|
|
2556
|
+
const attestTx = await walletContract.attest(
|
|
2557
|
+
attestationId,
|
|
2558
|
+
"spend",
|
|
2559
|
+
`drain to ${checksumRecipient}`,
|
|
2560
|
+
checksumRecipient,
|
|
2561
|
+
drainAmount,
|
|
2562
|
+
ethers.ZeroAddress,
|
|
2563
|
+
expiry,
|
|
2564
|
+
);
|
|
2565
|
+
const attestReceipt = await attestTx.wait(1);
|
|
2566
|
+
console.log(` ✓ attest: ${attestReceipt?.hash}`);
|
|
2567
|
+
|
|
2568
|
+
// ── Step 4: executeSpend ───────────────────────────────────────────────
|
|
2569
|
+
console.log("Executing spend...");
|
|
2570
|
+
let spendReceiptHash: string | undefined;
|
|
2571
|
+
try {
|
|
2572
|
+
const spendTx = await walletContract.executeSpend(
|
|
2573
|
+
checksumRecipient,
|
|
2574
|
+
drainAmount,
|
|
2575
|
+
opts.category,
|
|
2576
|
+
attestationId,
|
|
2577
|
+
);
|
|
2578
|
+
const spendReceipt = await spendTx.wait(1);
|
|
2579
|
+
spendReceiptHash = spendReceipt?.hash;
|
|
2580
|
+
} catch (e) {
|
|
2581
|
+
handleWalletError(e);
|
|
2582
|
+
}
|
|
2583
|
+
console.log(` ✓ executeSpend: ${spendReceiptHash}`);
|
|
2584
|
+
|
|
2585
|
+
// ── Step 5: closeContext ───────────────────────────────────────────────
|
|
2586
|
+
console.log("Closing context...");
|
|
2587
|
+
const closeTx2 = await walletContract.closeContext();
|
|
2588
|
+
const closeReceipt = await closeTx2.wait(1);
|
|
2589
|
+
console.log(` ✓ closeContext: ${closeReceipt?.hash}`);
|
|
2590
|
+
|
|
2591
|
+
const newBalance = await provider.getBalance(walletAddr);
|
|
2592
|
+
if (opts.json) {
|
|
2593
|
+
console.log(JSON.stringify({
|
|
2594
|
+
ok: true,
|
|
2595
|
+
walletAddress: walletAddr,
|
|
2596
|
+
recipient: checksumRecipient,
|
|
2597
|
+
amount: ethers.formatEther(drainAmount),
|
|
2598
|
+
category: opts.category,
|
|
2599
|
+
txHashes: {
|
|
2600
|
+
openContext: openReceipt?.hash,
|
|
2601
|
+
attest: attestReceipt?.hash,
|
|
2602
|
+
executeSpend: spendReceiptHash,
|
|
2603
|
+
closeContext: closeReceipt?.hash,
|
|
2604
|
+
},
|
|
2605
|
+
remainingBalance: ethers.formatEther(newBalance),
|
|
2606
|
+
}));
|
|
2607
|
+
} else {
|
|
2608
|
+
console.log(`\n✓ Drain complete`);
|
|
2609
|
+
console.log(` Sent: ${ethers.formatEther(drainAmount)} ETH → ${checksumRecipient}`);
|
|
2610
|
+
console.log(` Remaining: ${ethers.formatEther(newBalance)} ETH`);
|
|
2611
|
+
}
|
|
2612
|
+
});
|
|
2613
|
+
|
|
2614
|
+
// ─── drain-token ───────────────────────────────────────────────────────────
|
|
2615
|
+
//
|
|
2616
|
+
// ERC-20 token drain via machine key (J1-07).
|
|
2617
|
+
// Flow: check context → close if stale → openContext → attest (with token address)
|
|
2618
|
+
// → executeTokenSpend → closeContext
|
|
2619
|
+
// Note: Each context can only be used for one spend. A new context must be opened
|
|
2620
|
+
// for each payment.
|
|
2621
|
+
|
|
2622
|
+
wallet.command("drain-token")
|
|
2623
|
+
.description("Drain ERC-20 tokens from wallet contract to recipient via machine key (openContext → attest → executeTokenSpend → closeContext). Note: each context allows exactly one spend.")
|
|
2624
|
+
.argument("<recipient>", "Recipient address")
|
|
2625
|
+
.argument("<amount>", "Token amount in human units (e.g. 1.5 for 1.5 USDC)")
|
|
2626
|
+
.requiredOption("--token <address>", "ERC-20 token contract address (or 'usdc' for configured USDC address)")
|
|
2627
|
+
.option("--category <cat>", "Spend category (default: general)", "general")
|
|
2628
|
+
.option("--decimals <n>", "Token decimals override (default: auto-detect from contract)", "auto")
|
|
2629
|
+
.option("--json")
|
|
2630
|
+
.action(async (recipientArg: string, amountArg: string, opts) => {
|
|
2631
|
+
const config = loadConfig();
|
|
2632
|
+
warnIfPublicRpc(config);
|
|
2633
|
+
|
|
2634
|
+
const walletAddr = config.walletContractAddress;
|
|
2635
|
+
if (!walletAddr) {
|
|
2636
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
2637
|
+
process.exit(1);
|
|
2638
|
+
}
|
|
2639
|
+
if (!config.privateKey) {
|
|
2640
|
+
console.error("privateKey not set in config — machine key required for drain-token.");
|
|
2641
|
+
process.exit(1);
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
// Resolve token address
|
|
2645
|
+
let tokenAddress: string;
|
|
2646
|
+
if (opts.token.toLowerCase() === "usdc") {
|
|
2647
|
+
tokenAddress = getUsdcAddress(config);
|
|
2648
|
+
} else {
|
|
2649
|
+
try {
|
|
2650
|
+
tokenAddress = ethers.getAddress(opts.token);
|
|
2651
|
+
} catch {
|
|
2652
|
+
console.error(`Invalid token address: ${opts.token}`);
|
|
2653
|
+
process.exit(1);
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
let checksumRecipient: string;
|
|
2658
|
+
try {
|
|
2659
|
+
checksumRecipient = ethers.getAddress(recipientArg);
|
|
2660
|
+
} catch {
|
|
2661
|
+
console.error(`Invalid recipient address: ${recipientArg}`);
|
|
2662
|
+
process.exit(1);
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
2666
|
+
const machineKey = new ethers.Wallet(config.privateKey, provider);
|
|
2667
|
+
|
|
2668
|
+
// Determine token decimals
|
|
2669
|
+
const erc20Abi = [
|
|
2670
|
+
"function decimals() external view returns (uint8)",
|
|
2671
|
+
"function balanceOf(address owner) external view returns (uint256)",
|
|
2672
|
+
];
|
|
2673
|
+
const erc20 = new ethers.Contract(tokenAddress, erc20Abi, provider);
|
|
2674
|
+
|
|
2675
|
+
let decimals: number;
|
|
2676
|
+
if (opts.decimals !== "auto") {
|
|
2677
|
+
decimals = parseInt(opts.decimals, 10);
|
|
2678
|
+
} else {
|
|
2679
|
+
try {
|
|
2680
|
+
decimals = Number(await erc20.decimals());
|
|
2681
|
+
} catch {
|
|
2682
|
+
decimals = 18;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
let tokenAmount: bigint;
|
|
2687
|
+
try {
|
|
2688
|
+
tokenAmount = ethers.parseUnits(amountArg, decimals);
|
|
2689
|
+
} catch {
|
|
2690
|
+
console.error(`Invalid amount: ${amountArg}. Provide a decimal value (e.g. 1.5).`);
|
|
2691
|
+
process.exit(1);
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
// Check token balance
|
|
2695
|
+
const tokenBalance: bigint = await erc20.balanceOf(walletAddr);
|
|
2696
|
+
if (tokenBalance < tokenAmount) {
|
|
2697
|
+
console.error(`Insufficient token balance: ${ethers.formatUnits(tokenBalance, decimals)} < ${amountArg}`);
|
|
2698
|
+
process.exit(1);
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
// Check category is configured on PolicyEngine
|
|
2702
|
+
const policyAddressT = config.policyEngineAddress ?? POLICY_ENGINE_DEFAULT;
|
|
2703
|
+
const policyContractT = new ethers.Contract(policyAddressT, POLICY_ENGINE_LIMITS_ABI, provider);
|
|
2704
|
+
const categoryLimitT: bigint = await policyContractT.categoryLimits(walletAddr, opts.category);
|
|
2705
|
+
if (categoryLimitT === 0n) {
|
|
2706
|
+
console.error(`Category "${opts.category}" is not configured on PolicyEngine for this wallet.`);
|
|
2707
|
+
console.error(`Fix: arc402 wallet policy set-limit --category ${opts.category} --amount <eth>`);
|
|
2708
|
+
process.exit(1);
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// Verify machine key is authorized
|
|
2712
|
+
const mkAbi = ["function authorizedMachineKeys(address) external view returns (bool)"];
|
|
2713
|
+
const walletCheckT = new ethers.Contract(walletAddr, mkAbi, provider);
|
|
2714
|
+
let isAuthorizedT = false;
|
|
2715
|
+
try {
|
|
2716
|
+
isAuthorizedT = await walletCheckT.authorizedMachineKeys(machineKey.address);
|
|
2717
|
+
} catch { isAuthorizedT = true; }
|
|
2718
|
+
if (!isAuthorizedT) {
|
|
2719
|
+
console.error(`Machine key ${machineKey.address} is not authorized on wallet ${walletAddr}`);
|
|
2720
|
+
console.error(`Fix: arc402 wallet authorize-machine-key ${machineKey.address}`);
|
|
2721
|
+
process.exit(1);
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
const walletContractT = new ethers.Contract(walletAddr, ARC402_WALLET_PROTOCOL_ABI, machineKey);
|
|
2725
|
+
|
|
2726
|
+
console.log(`\nDrain token plan:`);
|
|
2727
|
+
console.log(` Wallet: ${walletAddr}`);
|
|
2728
|
+
console.log(` Recipient: ${checksumRecipient}`);
|
|
2729
|
+
console.log(` Amount: ${amountArg} (${tokenAmount.toString()} units)`);
|
|
2730
|
+
console.log(` Token: ${tokenAddress}`);
|
|
2731
|
+
console.log(` Category: ${opts.category}`);
|
|
2732
|
+
console.log(` MachineKey: ${machineKey.address}`);
|
|
2733
|
+
console.log(`\nNote: Each context allows exactly one spend. A new context is opened for each drain-token call.\n`);
|
|
2734
|
+
|
|
2735
|
+
// ── Step 1: context cleanup ──────────────────────────────────────────────
|
|
2736
|
+
const isOpenT: boolean = await walletContractT.contextOpen();
|
|
2737
|
+
if (isOpenT) {
|
|
2738
|
+
console.log("Stale context found — closing it first...");
|
|
2739
|
+
const closeTxT = await walletContractT.closeContext();
|
|
2740
|
+
await closeTxT.wait(2);
|
|
2741
|
+
console.log(` ✓ Closed: ${closeTxT.hash}`);
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
// ── Step 2: openContext ──────────────────────────────────────────────────
|
|
2745
|
+
const contextIdT = ethers.keccak256(ethers.toUtf8Bytes(`drain-token-${Date.now()}`));
|
|
2746
|
+
console.log("Opening context...");
|
|
2747
|
+
const openTxT = await walletContractT.openContext(contextIdT, "drain");
|
|
2748
|
+
const openReceiptT = await openTxT.wait(1);
|
|
2749
|
+
console.log(` ✓ openContext: ${openReceiptT?.hash}`);
|
|
2750
|
+
|
|
2751
|
+
// ── Step 3: attest with token address ────────────────────────────────────
|
|
2752
|
+
const attestationIdT = ethers.hexlify(ethers.randomBytes(32));
|
|
2753
|
+
const expiryT = Math.floor(Date.now() / 1000) + 600; // 10 min TTL
|
|
2754
|
+
console.log("Creating attestation (with token address)...");
|
|
2755
|
+
const attestTxT = await walletContractT.attest(
|
|
2756
|
+
attestationIdT,
|
|
2757
|
+
"spend",
|
|
2758
|
+
`token drain to ${checksumRecipient}`,
|
|
2759
|
+
checksumRecipient,
|
|
2760
|
+
tokenAmount,
|
|
2761
|
+
tokenAddress,
|
|
2762
|
+
expiryT,
|
|
2763
|
+
);
|
|
2764
|
+
const attestReceiptT = await attestTxT.wait(1);
|
|
2765
|
+
console.log(` ✓ attest: ${attestReceiptT?.hash}`);
|
|
2766
|
+
|
|
2767
|
+
// ── Step 4: executeTokenSpend ────────────────────────────────────────────
|
|
2768
|
+
console.log("Executing token spend...");
|
|
2769
|
+
const spendTxT = await walletContractT.executeTokenSpend(
|
|
2770
|
+
checksumRecipient,
|
|
2771
|
+
tokenAmount,
|
|
2772
|
+
tokenAddress,
|
|
2773
|
+
opts.category,
|
|
2774
|
+
attestationIdT,
|
|
2775
|
+
);
|
|
2776
|
+
const spendReceiptT = await spendTxT.wait(1);
|
|
2777
|
+
console.log(` ✓ executeTokenSpend: ${spendReceiptT?.hash}`);
|
|
2778
|
+
|
|
2779
|
+
// ── Step 5: closeContext ─────────────────────────────────────────────────
|
|
2780
|
+
console.log("Closing context...");
|
|
2781
|
+
const closeTxT2 = await walletContractT.closeContext();
|
|
2782
|
+
const closeReceiptT = await closeTxT2.wait(1);
|
|
2783
|
+
console.log(` ✓ closeContext: ${closeReceiptT?.hash}`);
|
|
2784
|
+
|
|
2785
|
+
const newTokenBalance: bigint = await erc20.balanceOf(walletAddr);
|
|
2786
|
+
if (opts.json) {
|
|
2787
|
+
console.log(JSON.stringify({
|
|
2788
|
+
ok: true,
|
|
2789
|
+
walletAddress: walletAddr,
|
|
2790
|
+
recipient: checksumRecipient,
|
|
2791
|
+
amount: amountArg,
|
|
2792
|
+
token: tokenAddress,
|
|
2793
|
+
category: opts.category,
|
|
2794
|
+
txHashes: {
|
|
2795
|
+
openContext: openReceiptT?.hash,
|
|
2796
|
+
attest: attestReceiptT?.hash,
|
|
2797
|
+
executeTokenSpend: spendReceiptT?.hash,
|
|
2798
|
+
closeContext: closeReceiptT?.hash,
|
|
2799
|
+
},
|
|
2800
|
+
remainingTokenBalance: ethers.formatUnits(newTokenBalance, decimals),
|
|
2801
|
+
}));
|
|
2802
|
+
} else {
|
|
2803
|
+
console.log(`\n✓ Token drain complete`);
|
|
2804
|
+
console.log(` Sent: ${amountArg} → ${checksumRecipient}`);
|
|
2805
|
+
console.log(` Token: ${tokenAddress}`);
|
|
2806
|
+
console.log(` Remaining: ${ethers.formatUnits(newTokenBalance, decimals)}`);
|
|
2807
|
+
}
|
|
2808
|
+
});
|
|
2809
|
+
|
|
2810
|
+
// ─── set-passkey ───────────────────────────────────────────────────────────
|
|
2811
|
+
//
|
|
2812
|
+
// Called after registering a Face ID on app.arc402.xyz/onboard (Step 2).
|
|
2813
|
+
// Takes the P256 public key coordinates extracted from the WebAuthn credential
|
|
2814
|
+
// and writes them on-chain via ARC402Wallet.setPasskey(bytes32, bytes32).
|
|
2815
|
+
// After this call, governance UserOps must carry a P256 signature (Face ID).
|
|
2816
|
+
|
|
2817
|
+
wallet.command("set-passkey <pubKeyX> <pubKeyY>")
|
|
2818
|
+
.description("Activate passkey (Face ID) on ARC402Wallet — takes P256 x/y coords from passkey setup (phone wallet signs via WalletConnect)")
|
|
2819
|
+
.action(async (pubKeyX: string, pubKeyY: string) => {
|
|
2820
|
+
const config = loadConfig();
|
|
2821
|
+
if (!config.walletContractAddress) {
|
|
2822
|
+
console.error("walletContractAddress not set in config. Run `arc402 wallet deploy` first.");
|
|
2823
|
+
process.exit(1);
|
|
2824
|
+
}
|
|
2825
|
+
if (!config.walletConnectProjectId) {
|
|
2826
|
+
console.error("walletConnectProjectId not set in config.");
|
|
2827
|
+
process.exit(1);
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
// Validate hex bytes32 format
|
|
2831
|
+
const isBytes32Hex = (v: string) => /^0x[0-9a-fA-F]{64}$/.test(v);
|
|
2832
|
+
if (!isBytes32Hex(pubKeyX)) {
|
|
2833
|
+
console.error(`Invalid pubKeyX: expected 0x-prefixed 32-byte hex, got: ${pubKeyX}`);
|
|
2834
|
+
process.exit(1);
|
|
2835
|
+
}
|
|
2836
|
+
if (!isBytes32Hex(pubKeyY)) {
|
|
2837
|
+
console.error(`Invalid pubKeyY: expected 0x-prefixed 32-byte hex, got: ${pubKeyY}`);
|
|
2838
|
+
process.exit(1);
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
const chainId = config.network === "base-mainnet" ? 8453 : 84532;
|
|
2842
|
+
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
2843
|
+
const walletInterface = new ethers.Interface(ARC402_WALLET_PASSKEY_ABI);
|
|
2844
|
+
|
|
2845
|
+
console.log(`\nWallet: ${config.walletContractAddress}`);
|
|
2846
|
+
console.log(`pubKeyX: ${pubKeyX}`);
|
|
2847
|
+
console.log(`pubKeyY: ${pubKeyY}`);
|
|
2848
|
+
|
|
2849
|
+
const telegramOpts = config.telegramBotToken && config.telegramChatId
|
|
2850
|
+
? { botToken: config.telegramBotToken, chatId: config.telegramChatId, threadId: config.telegramThreadId }
|
|
2851
|
+
: undefined;
|
|
2852
|
+
|
|
2853
|
+
const txData = {
|
|
2854
|
+
to: config.walletContractAddress,
|
|
2855
|
+
data: walletInterface.encodeFunctionData("setPasskey", [pubKeyX, pubKeyY]),
|
|
2856
|
+
value: "0x0",
|
|
2857
|
+
};
|
|
2858
|
+
|
|
2859
|
+
const { client, session, account } = await connectPhoneWallet(
|
|
2860
|
+
config.walletConnectProjectId,
|
|
2861
|
+
chainId,
|
|
2862
|
+
config,
|
|
2863
|
+
{
|
|
2864
|
+
telegramOpts,
|
|
2865
|
+
prompt: `Activate passkey (Face ID) on ARC402Wallet — enables P256 governance signing`,
|
|
2866
|
+
}
|
|
2867
|
+
);
|
|
2868
|
+
|
|
2869
|
+
console.log(`\n✓ Connected: ${account}`);
|
|
2870
|
+
console.log("Sending setPasskey transaction...");
|
|
2871
|
+
|
|
2872
|
+
const hash = await sendTransactionWithSession(client, session, account, chainId, txData);
|
|
2873
|
+
console.log(`\nTransaction submitted: ${hash}`);
|
|
2874
|
+
console.log("Waiting for confirmation...");
|
|
2875
|
+
|
|
2876
|
+
const receipt = await provider.waitForTransaction(hash, 1, 60000);
|
|
2877
|
+
if (!receipt || receipt.status !== 1) {
|
|
2878
|
+
console.error("Transaction failed.");
|
|
2879
|
+
process.exit(1);
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
console.log(`\n✓ Passkey activated on ARC402Wallet`);
|
|
2883
|
+
console.log(` Wallet: ${config.walletContractAddress}`);
|
|
2884
|
+
console.log(` pubKeyX: ${pubKeyX}`);
|
|
2885
|
+
console.log(` pubKeyY: ${pubKeyY}`);
|
|
2886
|
+
console.log(` Tx: ${hash}`);
|
|
2887
|
+
console.log(`\nGovernance ops now require Face ID instead of MetaMask.`);
|
|
2888
|
+
|
|
2889
|
+
await client.disconnect({ topic: session.topic, reason: { code: 6000, message: "done" } });
|
|
2890
|
+
process.exit(0);
|
|
2891
|
+
});
|
|
2892
|
+
}
|