@aixyz/cli 0.7.0 → 0.9.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 +1 -1
- package/bin.ts +137 -0
- package/build/AixyzConfigPlugin.ts +1 -1
- package/build/AixyzServerPlugin.ts +43 -9
- package/build/index.ts +1 -1
- package/dev/index.ts +1 -1
- package/package.json +11 -6
- package/register/index.ts +8 -0
- package/register/register.test.ts +66 -0
- package/register/register.ts +204 -0
- package/register/update.test.ts +47 -0
- package/register/update.ts +191 -0
- package/register/utils/chain.ts +56 -0
- package/register/utils/erc8004-file.ts +65 -0
- package/register/utils/prompt.ts +78 -0
- package/register/utils/result.ts +14 -0
- package/register/utils/spinner.ts +39 -0
- package/register/utils/transaction.ts +94 -0
- package/register/utils.test.ts +81 -0
- package/register/utils.ts +38 -0
- package/register/wallet/browser.test.ts +124 -0
- package/register/wallet/browser.ts +753 -0
- package/register/wallet/index.test.ts +80 -0
- package/register/wallet/index.ts +83 -0
- package/register/wallet/keystore.test.ts +63 -0
- package/register/wallet/keystore.ts +32 -0
- package/register/wallet/privatekey.test.ts +30 -0
- package/register/wallet/privatekey.ts +19 -0
- package/register/wallet/sign.test.ts +78 -0
- package/register/wallet/sign.ts +112 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { encodeFunctionData, formatEther, parseEventLogs, type Chain, type Log } from "viem";
|
|
2
|
+
import { IdentityRegistryAbi } from "@aixyz/erc-8004";
|
|
3
|
+
import { selectWalletMethod } from "./wallet";
|
|
4
|
+
import { signTransaction } from "./wallet/sign";
|
|
5
|
+
import {
|
|
6
|
+
resolveChainConfig,
|
|
7
|
+
resolveRegistryAddress,
|
|
8
|
+
validateBrowserRpcConflict,
|
|
9
|
+
getExplorerUrl,
|
|
10
|
+
CHAINS,
|
|
11
|
+
} from "./utils/chain";
|
|
12
|
+
import { writeResultJson } from "./utils/result";
|
|
13
|
+
import { label, truncateUri, broadcastAndConfirm, logSignResult } from "./utils/transaction";
|
|
14
|
+
import { promptAgentUrl, promptSelectRegistration, deriveAgentUri } from "./utils/prompt";
|
|
15
|
+
import { readRegistrations } from "./utils/erc8004-file";
|
|
16
|
+
import { confirm } from "@inquirer/prompts";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import boxen from "boxen";
|
|
19
|
+
import type { BaseOptions } from "./index";
|
|
20
|
+
|
|
21
|
+
export interface UpdateOptions extends BaseOptions {
|
|
22
|
+
url?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function update(options: UpdateOptions): Promise<void> {
|
|
26
|
+
// Step 1: Read registrations from app/erc-8004.ts
|
|
27
|
+
const registrations = await readRegistrations();
|
|
28
|
+
|
|
29
|
+
if (registrations.length === 0) {
|
|
30
|
+
throw new Error("No registrations found in app/erc-8004.ts. Run `aixyz erc-8004 register` first.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Step 2: Select which registration to update
|
|
34
|
+
const selected = await promptSelectRegistration(registrations);
|
|
35
|
+
|
|
36
|
+
// Step 3: Derive chain info from agentRegistry (eip155:<chainId>:<address>)
|
|
37
|
+
const parts = selected.agentRegistry.split(":");
|
|
38
|
+
if (parts.length < 3 || parts[0] !== "eip155") {
|
|
39
|
+
throw new Error(`Invalid agentRegistry format: ${selected.agentRegistry}. Expected eip155:<chainId>:<address>`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const chainId = Number(parts[1]);
|
|
43
|
+
const registryAddress = parts.slice(2).join(":") as `0x${string}`;
|
|
44
|
+
const chainName = Object.entries(CHAINS).find(([, config]) => config.chainId === chainId)?.[0] ?? `chain-${chainId}`;
|
|
45
|
+
const chainConfig = resolveChainConfig(chainName);
|
|
46
|
+
|
|
47
|
+
// Step 4: Get new agent URL and derive URI
|
|
48
|
+
const agentUrl = options.url ?? (await promptAgentUrl());
|
|
49
|
+
const resolvedUri = deriveAgentUri(agentUrl);
|
|
50
|
+
|
|
51
|
+
const yes = await confirm({
|
|
52
|
+
message: `Will update URI to: ${chalk.cyan(resolvedUri)} — confirm?`,
|
|
53
|
+
default: true,
|
|
54
|
+
});
|
|
55
|
+
if (!yes) {
|
|
56
|
+
throw new Error("Aborted.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Step 5: Encode transaction
|
|
60
|
+
const data = encodeFunctionData({
|
|
61
|
+
abi: IdentityRegistryAbi,
|
|
62
|
+
functionName: "setAgentURI",
|
|
63
|
+
args: [BigInt(selected.agentId), resolvedUri],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const printTxDetails = (header: string) => {
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log(chalk.dim(header));
|
|
69
|
+
console.log(` ${label("To")}${registryAddress}`);
|
|
70
|
+
console.log(` ${label("Data")}${data.slice(0, 10)}${chalk.dim("\u2026" + (data.length - 2) / 2 + " bytes")}`);
|
|
71
|
+
console.log(` ${label("Chain")}${chainName}`);
|
|
72
|
+
console.log(` ${label("Function")}setAgentURI(uint256 agentId, string calldata newURI)`);
|
|
73
|
+
console.log(` ${label("Agent ID")}${selected.agentId}`);
|
|
74
|
+
console.log(` ${label("URI")}${truncateUri(resolvedUri)}`);
|
|
75
|
+
console.log("");
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
validateBrowserRpcConflict(options.browser, options.rpcUrl);
|
|
79
|
+
|
|
80
|
+
if (!options.broadcast) {
|
|
81
|
+
if (options.browser || options.keystore || process.env.PRIVATE_KEY) {
|
|
82
|
+
console.warn("Note: --browser/--keystore/PRIVATE_KEY ignored in dry-run mode. Pass --broadcast to use a wallet.");
|
|
83
|
+
}
|
|
84
|
+
printTxDetails("Transaction details (dry-run)");
|
|
85
|
+
console.log("Dry-run complete. To sign and broadcast, re-run with --broadcast.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const walletMethod = await selectWalletMethod(options);
|
|
90
|
+
validateBrowserRpcConflict(walletMethod.type === "browser" || undefined, options.rpcUrl);
|
|
91
|
+
|
|
92
|
+
printTxDetails("Signing transaction...");
|
|
93
|
+
|
|
94
|
+
const result = await signTransaction({
|
|
95
|
+
walletMethod,
|
|
96
|
+
tx: { to: registryAddress, data },
|
|
97
|
+
chain: chainConfig.chain,
|
|
98
|
+
rpcUrl: options.rpcUrl,
|
|
99
|
+
options: {
|
|
100
|
+
browser: { chainId: chainConfig.chainId, chainName, uri: resolvedUri, mode: "update" },
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
logSignResult(walletMethod.type, result);
|
|
104
|
+
|
|
105
|
+
const { hash, receipt, timestamp } = await broadcastAndConfirm({
|
|
106
|
+
result,
|
|
107
|
+
chain: chainConfig.chain,
|
|
108
|
+
rpcUrl: options.rpcUrl,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const resultData = printResult(receipt, timestamp, chainConfig.chain, chainConfig.chainId, hash);
|
|
112
|
+
|
|
113
|
+
if (options.outDir) {
|
|
114
|
+
writeResultJson(options.outDir, "update", resultData);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface UpdateResult {
|
|
119
|
+
agentId?: string;
|
|
120
|
+
newUri?: string;
|
|
121
|
+
updatedBy?: `0x${string}`;
|
|
122
|
+
chainId: number;
|
|
123
|
+
block: string;
|
|
124
|
+
timestamp: string;
|
|
125
|
+
gasPaid: string;
|
|
126
|
+
nativeCurrency: string;
|
|
127
|
+
txHash: string;
|
|
128
|
+
explorer?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function printResult(
|
|
132
|
+
receipt: { blockNumber: bigint; gasUsed: bigint; effectiveGasPrice: bigint; logs: readonly unknown[] },
|
|
133
|
+
timestamp: bigint,
|
|
134
|
+
chain: Chain,
|
|
135
|
+
chainId: number,
|
|
136
|
+
hash: `0x${string}`,
|
|
137
|
+
): UpdateResult {
|
|
138
|
+
const events = parseEventLogs({ abi: IdentityRegistryAbi, logs: receipt.logs as Log[] });
|
|
139
|
+
const uriUpdated = events.find((e) => e.eventName === "URIUpdated");
|
|
140
|
+
|
|
141
|
+
const lines: string[] = [];
|
|
142
|
+
const result: UpdateResult = {
|
|
143
|
+
chainId,
|
|
144
|
+
block: receipt.blockNumber.toString(),
|
|
145
|
+
timestamp: new Date(Number(timestamp) * 1000).toUTCString(),
|
|
146
|
+
gasPaid: formatEther(receipt.gasUsed * receipt.effectiveGasPrice),
|
|
147
|
+
nativeCurrency: chain.nativeCurrency?.symbol ?? "ETH",
|
|
148
|
+
txHash: hash,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (uriUpdated) {
|
|
152
|
+
const { agentId, newURI, updatedBy } = uriUpdated.args as {
|
|
153
|
+
agentId: bigint;
|
|
154
|
+
newURI: string;
|
|
155
|
+
updatedBy: `0x${string}`;
|
|
156
|
+
};
|
|
157
|
+
result.agentId = agentId.toString();
|
|
158
|
+
result.newUri = newURI;
|
|
159
|
+
result.updatedBy = updatedBy;
|
|
160
|
+
|
|
161
|
+
lines.push(`${label("Agent ID")}${chalk.bold(result.agentId)}`);
|
|
162
|
+
lines.push(`${label("New URI")}${truncateUri(newURI)}`);
|
|
163
|
+
lines.push(`${label("Updated By")}${updatedBy}`);
|
|
164
|
+
lines.push(`${label("Block")}${receipt.blockNumber}`);
|
|
165
|
+
} else {
|
|
166
|
+
lines.push(`${label("Block")}${receipt.blockNumber}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
lines.push(`${label("Timestamp")}${result.timestamp}`);
|
|
170
|
+
lines.push(`${label("Gas Paid")}${result.gasPaid} ${result.nativeCurrency}`);
|
|
171
|
+
lines.push(`${label("Tx Hash")}${hash}`);
|
|
172
|
+
|
|
173
|
+
const explorerUrl = getExplorerUrl(chain, hash);
|
|
174
|
+
if (explorerUrl) {
|
|
175
|
+
result.explorer = explorerUrl;
|
|
176
|
+
lines.push(`${label("Explorer")}${chalk.cyan(explorerUrl)}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
console.log("");
|
|
180
|
+
console.log(
|
|
181
|
+
boxen(lines.join("\n"), {
|
|
182
|
+
padding: { left: 1, right: 1, top: 0, bottom: 0 },
|
|
183
|
+
borderStyle: "round",
|
|
184
|
+
borderColor: "green",
|
|
185
|
+
title: "Agent URI updated successfully",
|
|
186
|
+
titleAlignment: "left",
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isAddress, type Chain } from "viem";
|
|
2
|
+
import { mainnet, sepolia, baseSepolia, foundry } from "viem/chains";
|
|
3
|
+
import { CHAIN_ID, getIdentityRegistryAddress } from "@aixyz/erc-8004";
|
|
4
|
+
import { select } from "@inquirer/prompts";
|
|
5
|
+
|
|
6
|
+
export interface ChainConfig {
|
|
7
|
+
chain: Chain;
|
|
8
|
+
chainId: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const CHAINS: Record<string, ChainConfig> = {
|
|
12
|
+
mainnet: { chain: mainnet, chainId: CHAIN_ID.MAINNET },
|
|
13
|
+
sepolia: { chain: sepolia, chainId: CHAIN_ID.SEPOLIA },
|
|
14
|
+
"base-sepolia": { chain: baseSepolia, chainId: CHAIN_ID.BASE_SEPOLIA },
|
|
15
|
+
localhost: { chain: foundry, chainId: 31337 },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function resolveChainConfig(chainName: string): ChainConfig {
|
|
19
|
+
const config = CHAINS[chainName];
|
|
20
|
+
if (!config) {
|
|
21
|
+
throw new Error(`Unsupported chain: ${chainName}. Supported chains: ${Object.keys(CHAINS).join(", ")}`);
|
|
22
|
+
}
|
|
23
|
+
return config;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function selectChain(): Promise<string> {
|
|
27
|
+
return select({
|
|
28
|
+
message: "Select target chain:",
|
|
29
|
+
choices: Object.keys(CHAINS).map((name) => ({ name, value: name })),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveRegistryAddress(chainName: string, chainId: number, registry?: string): `0x${string}` {
|
|
34
|
+
if (registry) {
|
|
35
|
+
if (!isAddress(registry)) {
|
|
36
|
+
throw new Error(`Invalid registry address: ${registry}`);
|
|
37
|
+
}
|
|
38
|
+
return registry as `0x${string}`;
|
|
39
|
+
}
|
|
40
|
+
if (chainName === "localhost") {
|
|
41
|
+
throw new Error("--registry is required for localhost (no default contract deployment)");
|
|
42
|
+
}
|
|
43
|
+
return getIdentityRegistryAddress(chainId) as `0x${string}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateBrowserRpcConflict(browser: boolean | undefined, rpcUrl: string | undefined): void {
|
|
47
|
+
if (browser && rpcUrl) {
|
|
48
|
+
throw new Error("--rpc-url cannot be used with browser wallet. The browser wallet uses its own RPC endpoint.");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getExplorerUrl(chain: Chain, txHash: string): string | null {
|
|
53
|
+
const explorer = chain.blockExplorers?.default;
|
|
54
|
+
if (!explorer) return null;
|
|
55
|
+
return `${explorer.url}/tx/${txHash}`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { RegistrationEntry } from "@aixyz/erc-8004/schemas/registration";
|
|
4
|
+
|
|
5
|
+
function getFilePath(cwd: string = process.cwd()): string {
|
|
6
|
+
return resolve(cwd, "app/erc-8004.ts");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hasErc8004File(cwd?: string): boolean {
|
|
10
|
+
return existsSync(getFilePath(cwd));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createErc8004File(supportedTrust: string[], cwd?: string): void {
|
|
14
|
+
const filePath = getFilePath(cwd);
|
|
15
|
+
const trustArray = supportedTrust.map((t) => `"${t}"`).join(", ");
|
|
16
|
+
|
|
17
|
+
const content = `import type { ERC8004Registration } from "aixyz/erc-8004";
|
|
18
|
+
|
|
19
|
+
const metadata: ERC8004Registration = {
|
|
20
|
+
registrations: [],
|
|
21
|
+
supportedTrust: [${trustArray}],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default metadata;
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
writeFileSync(filePath, content, "utf-8");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readRegistrations(cwd?: string): Promise<RegistrationEntry[]> {
|
|
31
|
+
const filePath = getFilePath(cwd);
|
|
32
|
+
|
|
33
|
+
if (!existsSync(filePath)) {
|
|
34
|
+
throw new Error(`No app/erc-8004.ts found. Run \`aixyz erc-8004 register\` first.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const mod = await import(filePath);
|
|
38
|
+
const data = mod.default;
|
|
39
|
+
|
|
40
|
+
if (!data || !Array.isArray(data.registrations)) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return data.registrations;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function writeRegistrationEntry(entry: { agentId: number; agentRegistry: string }, cwd?: string): void {
|
|
48
|
+
const filePath = getFilePath(cwd);
|
|
49
|
+
const content = readFileSync(filePath, "utf-8");
|
|
50
|
+
const entryStr = `{ agentId: ${entry.agentId}, agentRegistry: "${entry.agentRegistry}" }`;
|
|
51
|
+
|
|
52
|
+
// Try to find `registrations: [...]` and insert the entry
|
|
53
|
+
const match = content.match(/registrations:\s*\[([^\]]*)\]/s);
|
|
54
|
+
if (match) {
|
|
55
|
+
const existing = match[1]!.trim();
|
|
56
|
+
const newEntries = existing ? `${existing}, ${entryStr}` : entryStr;
|
|
57
|
+
const updated = content.replace(/registrations:\s*\[([^\]]*)\]/s, `registrations: [${newEntries}]`);
|
|
58
|
+
writeFileSync(filePath, updated, "utf-8");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fallback: append as comment
|
|
63
|
+
const comment = `\n// Registration added by \`aixyz erc-8004 register\`:\n// ${entryStr}\n`;
|
|
64
|
+
writeFileSync(filePath, content + comment, "utf-8");
|
|
65
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { checkbox, confirm, input, select } from "@inquirer/prompts";
|
|
2
|
+
import type { RegistrationEntry } from "@aixyz/erc-8004/schemas/registration";
|
|
3
|
+
|
|
4
|
+
export async function promptAgentId(): Promise<string> {
|
|
5
|
+
return input({
|
|
6
|
+
message: "Agent ID (token ID) to update:",
|
|
7
|
+
validate: (value) => {
|
|
8
|
+
const n = Number(value);
|
|
9
|
+
if (value.trim() === "" || !Number.isInteger(n) || n < 0) return "Must be a non-negative integer";
|
|
10
|
+
return true;
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function promptUri(): Promise<string> {
|
|
16
|
+
return input({
|
|
17
|
+
message: "New agent metadata URI or path to .json file (leave empty to clear):",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function promptAgentUrl(): Promise<string> {
|
|
22
|
+
return input({
|
|
23
|
+
message: "Agent deployment URL (e.g., https://my-agent.example.com):",
|
|
24
|
+
validate: (value) => {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(value);
|
|
27
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
28
|
+
return "URL must start with https:// or http://";
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return "Must be a valid URL (e.g., https://my-agent.example.com)";
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function promptSupportedTrust(): Promise<string[]> {
|
|
39
|
+
return checkbox({
|
|
40
|
+
message: "Select supported trust mechanisms:",
|
|
41
|
+
choices: [
|
|
42
|
+
{ name: "reputation", value: "reputation", checked: true },
|
|
43
|
+
{ name: "crypto-economic", value: "crypto-economic" },
|
|
44
|
+
{ name: "tee-attestation", value: "tee-attestation" },
|
|
45
|
+
{ name: "social", value: "social" },
|
|
46
|
+
{ name: "governance", value: "governance" },
|
|
47
|
+
],
|
|
48
|
+
required: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function promptSelectRegistration(registrations: RegistrationEntry[]): Promise<RegistrationEntry> {
|
|
53
|
+
if (registrations.length === 1) {
|
|
54
|
+
const reg = registrations[0]!;
|
|
55
|
+
const yes = await confirm({
|
|
56
|
+
message: `Update this registration? (agentId: ${reg.agentId}, registry: ${reg.agentRegistry})`,
|
|
57
|
+
default: true,
|
|
58
|
+
});
|
|
59
|
+
if (!yes) {
|
|
60
|
+
throw new Error("Aborted.");
|
|
61
|
+
}
|
|
62
|
+
return reg;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return select({
|
|
66
|
+
message: "Select registration to update:",
|
|
67
|
+
choices: registrations.map((reg) => ({
|
|
68
|
+
name: `agentId: ${reg.agentId} — ${reg.agentRegistry}`,
|
|
69
|
+
value: reg,
|
|
70
|
+
})),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function deriveAgentUri(url: string): string {
|
|
75
|
+
// Ensure no trailing slash before appending path
|
|
76
|
+
const base = url.replace(/\/+$/, "");
|
|
77
|
+
return `${base}/_aixyz/erc-8004.json`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
export function writeResultJson(outDir: string, filenamePrefix: string, result: { chainId: number }): void {
|
|
6
|
+
if (!existsSync(outDir)) {
|
|
7
|
+
mkdirSync(outDir, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const filename = `${filenamePrefix}-${result.chainId}-${Date.now()}.json`;
|
|
11
|
+
const filePath = join(outDir, filename);
|
|
12
|
+
writeFileSync(filePath, JSON.stringify(result, null, 2) + "\n");
|
|
13
|
+
console.log(`\n${chalk.green("✓")} Result written to ${chalk.bold(filePath)}`);
|
|
14
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
+
const INTERVAL = 80;
|
|
3
|
+
|
|
4
|
+
export interface Spinner {
|
|
5
|
+
pause(): void;
|
|
6
|
+
stop(finalMessage?: string): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function startSpinner(message: string): Spinner {
|
|
10
|
+
if (!process.stderr.isTTY) {
|
|
11
|
+
process.stderr.write(`${message}\n`);
|
|
12
|
+
return { pause() {}, stop() {} };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let i = 0;
|
|
16
|
+
let timer: ReturnType<typeof setInterval> | null = setInterval(() => {
|
|
17
|
+
process.stderr.write(`\r${FRAMES[i++ % FRAMES.length]} ${message}`);
|
|
18
|
+
}, INTERVAL);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
pause() {
|
|
22
|
+
if (timer) {
|
|
23
|
+
clearInterval(timer);
|
|
24
|
+
timer = null;
|
|
25
|
+
process.stderr.write(`\r\x1b[2K`);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
stop(finalMessage?: string) {
|
|
29
|
+
if (timer) {
|
|
30
|
+
clearInterval(timer);
|
|
31
|
+
timer = null;
|
|
32
|
+
}
|
|
33
|
+
process.stderr.write(`\r\x1b[2K`);
|
|
34
|
+
if (finalMessage) {
|
|
35
|
+
process.stderr.write(`${finalMessage}\n`);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createPublicClient, http, type Chain, type Log } from "viem";
|
|
2
|
+
import { startSpinner } from "./spinner";
|
|
3
|
+
import { getExplorerUrl } from "./chain";
|
|
4
|
+
import type { SignTransactionResult } from "../wallet/sign";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import boxen from "boxen";
|
|
7
|
+
|
|
8
|
+
export function label(text: string): string {
|
|
9
|
+
return chalk.dim(text.padEnd(14));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function truncateUri(uri: string, maxLength = 80): string {
|
|
13
|
+
if (uri.length <= maxLength) return uri;
|
|
14
|
+
return uri.slice(0, maxLength) + "...";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BroadcastAndConfirmParams {
|
|
18
|
+
result: SignTransactionResult;
|
|
19
|
+
chain: Chain;
|
|
20
|
+
rpcUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BroadcastAndConfirmResult {
|
|
24
|
+
hash: `0x${string}`;
|
|
25
|
+
receipt: {
|
|
26
|
+
blockNumber: bigint;
|
|
27
|
+
gasUsed: bigint;
|
|
28
|
+
effectiveGasPrice: bigint;
|
|
29
|
+
logs: Log[];
|
|
30
|
+
};
|
|
31
|
+
timestamp: bigint;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function broadcastAndConfirm({
|
|
35
|
+
result,
|
|
36
|
+
chain,
|
|
37
|
+
rpcUrl,
|
|
38
|
+
}: BroadcastAndConfirmParams): Promise<BroadcastAndConfirmResult> {
|
|
39
|
+
const transport = rpcUrl ? http(rpcUrl) : http();
|
|
40
|
+
const publicClient = createPublicClient({ chain, transport });
|
|
41
|
+
|
|
42
|
+
let hash: `0x${string}`;
|
|
43
|
+
if (result.kind === "sent") {
|
|
44
|
+
hash = result.txHash;
|
|
45
|
+
} else {
|
|
46
|
+
const broadcastSpinner = startSpinner("Broadcasting transaction...");
|
|
47
|
+
try {
|
|
48
|
+
hash = await publicClient.sendRawTransaction({ serializedTransaction: result.raw });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
broadcastSpinner.stop();
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
broadcastSpinner.stop(`${chalk.green("\u2713")} Transaction broadcast`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
printTxHashBox(chain, hash);
|
|
57
|
+
|
|
58
|
+
const confirmSpinner = startSpinner("Waiting for confirmation...");
|
|
59
|
+
let receipt;
|
|
60
|
+
try {
|
|
61
|
+
receipt = await publicClient.waitForTransactionReceipt({ hash });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
confirmSpinner.stop();
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
confirmSpinner.stop(`${chalk.green("\u2713")} Confirmed in block ${chalk.bold(receipt.blockNumber.toString())}`);
|
|
67
|
+
|
|
68
|
+
const block = await publicClient.getBlock({ blockNumber: receipt.blockNumber });
|
|
69
|
+
|
|
70
|
+
return { hash, receipt, timestamp: block.timestamp };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function printTxHashBox(chain: Chain, hash: `0x${string}`): void {
|
|
74
|
+
const lines = [`${label("Tx Hash")}${hash}`];
|
|
75
|
+
const explorerUrl = getExplorerUrl(chain, hash);
|
|
76
|
+
if (explorerUrl) {
|
|
77
|
+
lines.push(`${label("Explorer")}${chalk.cyan(explorerUrl)}`);
|
|
78
|
+
}
|
|
79
|
+
console.log(
|
|
80
|
+
boxen(lines.join("\n"), {
|
|
81
|
+
padding: { left: 1, right: 1, top: 0, bottom: 0 },
|
|
82
|
+
borderStyle: "round",
|
|
83
|
+
borderColor: "cyan",
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function logSignResult(walletType: string, result: SignTransactionResult): void {
|
|
89
|
+
if (result.kind === "signed") {
|
|
90
|
+
console.log(`${chalk.green("\u2713")} Transaction signed ${chalk.dim(`(${walletType} \u00b7 ${result.address})`)}`);
|
|
91
|
+
} else {
|
|
92
|
+
console.log(`${chalk.green("\u2713")} Transaction signed ${chalk.dim(`(${walletType})`)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, test, afterAll, beforeAll } from "bun:test";
|
|
2
|
+
import { rmSync } from "fs";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { resolveUri } from "./utils";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
describe("resolveUri", () => {
|
|
8
|
+
const testDir = join(import.meta.dir, "__test_fixtures__");
|
|
9
|
+
const testJsonPath = join(testDir, "test-metadata.json");
|
|
10
|
+
const testMetadata = { name: "Test Agent", description: "A test agent" };
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
// ensure test directory exists
|
|
14
|
+
mkdir(testDir, { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterAll(() => {
|
|
18
|
+
// clean up test directory
|
|
19
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns ipfs:// URIs unchanged", () => {
|
|
23
|
+
const uri = "ipfs://QmTest123";
|
|
24
|
+
expect(resolveUri(uri)).toStrictEqual(uri);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns https:// URIs unchanged", () => {
|
|
28
|
+
const uri = "https://example.com/metadata.json";
|
|
29
|
+
expect(resolveUri(uri)).toStrictEqual(uri);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns http:// URIs unchanged", () => {
|
|
33
|
+
const uri = "http://example.com/metadata.json";
|
|
34
|
+
expect(resolveUri(uri)).toStrictEqual(uri);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("converts .json file to base64 data URI", async () => {
|
|
38
|
+
await Bun.write(testJsonPath, JSON.stringify(testMetadata));
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const result = resolveUri(testJsonPath);
|
|
42
|
+
expect(result.startsWith("data:application/json;base64,")).toStrictEqual(true);
|
|
43
|
+
|
|
44
|
+
// Decode and verify content
|
|
45
|
+
const base64 = result.replace("data:application/json;base64,", "");
|
|
46
|
+
const decoded = JSON.parse(Buffer.from(base64, "base64").toString("utf-8"));
|
|
47
|
+
expect(decoded).toStrictEqual(testMetadata);
|
|
48
|
+
} finally {
|
|
49
|
+
await Bun.file(testJsonPath).unlink();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("throws for directory path", async () => {
|
|
54
|
+
await mkdir(testDir, { recursive: true });
|
|
55
|
+
const dirWithJsonSuffix = join(testDir, "not-a-file.json");
|
|
56
|
+
await mkdir(dirWithJsonSuffix, { recursive: true });
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
expect(() => resolveUri(dirWithJsonSuffix)).toThrow(Error);
|
|
60
|
+
expect(() => resolveUri(dirWithJsonSuffix)).toThrow("Not a file");
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(dirWithJsonSuffix, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("throws for non-existent .json file", () => {
|
|
67
|
+
expect(() => resolveUri("./non-existent.json")).toThrow(Error);
|
|
68
|
+
expect(() => resolveUri("./non-existent.json")).toThrow("File not found");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("throws for invalid JSON content", async () => {
|
|
72
|
+
await Bun.write(testJsonPath, "not valid json {{{");
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
expect(() => resolveUri(testJsonPath)).toThrow(Error);
|
|
76
|
+
expect(() => resolveUri(testJsonPath)).toThrow("Invalid JSON");
|
|
77
|
+
} finally {
|
|
78
|
+
await Bun.file(testJsonPath).unlink();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function resolveUri(uri: string): string {
|
|
5
|
+
// Return as-is for URLs (ipfs://, https://, data:, etc.)
|
|
6
|
+
if (uri.startsWith("http://") || uri.startsWith("https://") || uri.startsWith("ipfs://") || uri.startsWith("data:")) {
|
|
7
|
+
return uri;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Check if it's a file path (ends with .json or exists as a file)
|
|
11
|
+
if (uri.endsWith(".json") || existsSync(uri)) {
|
|
12
|
+
const filePath = resolve(uri);
|
|
13
|
+
|
|
14
|
+
if (!existsSync(filePath)) {
|
|
15
|
+
throw new Error(`File not found: ${filePath}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!statSync(filePath).isFile()) {
|
|
19
|
+
throw new Error(`Not a file: ${filePath}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const content = readFileSync(filePath, "utf-8");
|
|
23
|
+
|
|
24
|
+
// Validate it's valid JSON
|
|
25
|
+
try {
|
|
26
|
+
JSON.parse(content);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`Invalid JSON in file: ${filePath}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Convert to base64 data URI
|
|
32
|
+
const base64 = Buffer.from(content).toString("base64");
|
|
33
|
+
return `data:application/json;base64,${base64}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Return as-is for other URIs
|
|
37
|
+
return uri;
|
|
38
|
+
}
|