@unlink-xyz/cli 0.1.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 +9 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +46 -0
- package/dist/commands/account.d.ts +2 -0
- package/dist/commands/account.js +83 -0
- package/dist/commands/balance.d.ts +2 -0
- package/dist/commands/balance.js +75 -0
- package/dist/commands/burner.d.ts +2 -0
- package/dist/commands/burner.js +114 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +140 -0
- package/dist/commands/deposit.d.ts +2 -0
- package/dist/commands/deposit.js +58 -0
- package/dist/commands/history.d.ts +2 -0
- package/dist/commands/history.js +30 -0
- package/dist/commands/multisig.d.ts +2 -0
- package/dist/commands/multisig.js +343 -0
- package/dist/commands/notes.d.ts +2 -0
- package/dist/commands/notes.js +28 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +51 -0
- package/dist/commands/transfer.d.ts +2 -0
- package/dist/commands/transfer.js +47 -0
- package/dist/commands/tx-status.d.ts +2 -0
- package/dist/commands/tx-status.js +31 -0
- package/dist/commands/wallet.d.ts +2 -0
- package/dist/commands/wallet.js +98 -0
- package/dist/commands/withdraw.d.ts +2 -0
- package/dist/commands/withdraw.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/lib/context.d.ts +14 -0
- package/dist/lib/context.js +42 -0
- package/dist/lib/errors.d.ts +1 -0
- package/dist/lib/errors.js +24 -0
- package/dist/lib/multisig-store.d.ts +8 -0
- package/dist/lib/multisig-store.js +121 -0
- package/dist/lib/options.d.ts +40 -0
- package/dist/lib/options.js +109 -0
- package/dist/lib/output.d.ts +6 -0
- package/dist/lib/output.js +34 -0
- package/dist/lib/relay.d.ts +18 -0
- package/dist/lib/relay.js +35 -0
- package/dist/lib/tokens.d.ts +14 -0
- package/dist/lib/tokens.js +142 -0
- package/dist/storage/sqlite.d.ts +1 -0
- package/dist/storage/sqlite.js +1 -0
- package/dist/test-utils.d.ts +56 -0
- package/dist/test-utils.js +73 -0
- package/package.json +45 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Option } from "commander";
|
|
2
|
+
import { createContext } from "../lib/context.js";
|
|
3
|
+
import { withErrorHandler } from "../lib/errors.js";
|
|
4
|
+
import { mergeConfigDefaults, requireChainId, requireGatewayUrl, requirePoolAddress, resolveOptions, } from "../lib/options.js";
|
|
5
|
+
import { log, output } from "../lib/output.js";
|
|
6
|
+
import { isRelaySuccess, pollRelayStatus } from "../lib/relay.js";
|
|
7
|
+
export function registerWithdrawCommands(program) {
|
|
8
|
+
program
|
|
9
|
+
.command("withdraw")
|
|
10
|
+
.requiredOption("--to <address>", "Recipient EOA address (0x...)")
|
|
11
|
+
.requiredOption("--token <address>", "Token contract address")
|
|
12
|
+
.requiredOption("--amount <value>", "Amount (raw atomic units)")
|
|
13
|
+
.addOption(new Option("--wait", "Wait for relay confirmation").default(true))
|
|
14
|
+
.addOption(new Option("--no-wait", "Fire-and-forget (do not wait for confirmation)"))
|
|
15
|
+
.description("Withdraw tokens from the privacy pool to an EOA")
|
|
16
|
+
.action(withErrorHandler(async (cmdOpts, cmd) => {
|
|
17
|
+
const options = mergeConfigDefaults(resolveOptions(cmd.optsWithGlobals()));
|
|
18
|
+
requireGatewayUrl(options);
|
|
19
|
+
requireChainId(options);
|
|
20
|
+
requirePoolAddress(options);
|
|
21
|
+
const ctx = await createContext(options);
|
|
22
|
+
const activeIdx = await ctx.wallet.accounts.getActiveIndex();
|
|
23
|
+
log(`Using account #${activeIdx}`, options);
|
|
24
|
+
log("Planning and submitting withdrawal...", options);
|
|
25
|
+
const result = await ctx.wallet.withdraw({
|
|
26
|
+
withdrawals: [
|
|
27
|
+
{
|
|
28
|
+
token: cmdOpts["token"],
|
|
29
|
+
amount: BigInt(cmdOpts["amount"]),
|
|
30
|
+
recipient: cmdOpts["to"],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
log(`Relay ID: ${result.relayId}`, options);
|
|
35
|
+
const shouldWait = cmdOpts["wait"];
|
|
36
|
+
if (shouldWait) {
|
|
37
|
+
const final = await pollRelayStatus(ctx.wallet, result.relayId, options);
|
|
38
|
+
if (!isRelaySuccess(final)) {
|
|
39
|
+
throw new Error(`Withdrawal relay ${final.relayId} ended with status "${final.status}"${final.error ? `: ${final.error}` : ""}`);
|
|
40
|
+
}
|
|
41
|
+
output(final, options);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
output({ relayId: result.relayId, status: "submitted" }, options);
|
|
45
|
+
}
|
|
46
|
+
}));
|
|
47
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { UnlinkWallet } from "@unlink-xyz/core";
|
|
2
|
+
import type { ResolvedOptions } from "./options.js";
|
|
3
|
+
export type CLIContext = {
|
|
4
|
+
wallet: UnlinkWallet;
|
|
5
|
+
options: ResolvedOptions;
|
|
6
|
+
gatewayUrl: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Create a CLI context.
|
|
10
|
+
* Pass `local: true` for wallet/account commands that don't need network.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createContext(options: ResolvedOptions, opts?: {
|
|
13
|
+
local?: boolean;
|
|
14
|
+
}): Promise<CLIContext>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { UnlinkWallet } from "@unlink-xyz/core";
|
|
4
|
+
import { createSqliteStorage } from "../storage/sqlite.js";
|
|
5
|
+
const rng = (n) => new Uint8Array(crypto.randomBytes(n));
|
|
6
|
+
/**
|
|
7
|
+
* Create a CLI context.
|
|
8
|
+
* Pass `local: true` for wallet/account commands that don't need network.
|
|
9
|
+
*/
|
|
10
|
+
export async function createContext(options, opts) {
|
|
11
|
+
const local = opts?.local ?? false;
|
|
12
|
+
if (!local && !options.gatewayUrl) {
|
|
13
|
+
throw new Error("--gateway-url or UNLINK_GATEWAY_URL is required");
|
|
14
|
+
}
|
|
15
|
+
const gatewayUrl = local
|
|
16
|
+
? (options.gatewayUrl ?? "http://localhost:0")
|
|
17
|
+
: options.gatewayUrl;
|
|
18
|
+
const dbPath = path.join(options.dataDir, "wallet.db");
|
|
19
|
+
const storage = createSqliteStorage({ path: dbPath });
|
|
20
|
+
await storage.open();
|
|
21
|
+
const prover = options.artifactVersion
|
|
22
|
+
? { artifactSource: { version: options.artifactVersion } }
|
|
23
|
+
: undefined;
|
|
24
|
+
const wallet = await UnlinkWallet.create({
|
|
25
|
+
chainId: local ? 0 : options.chainId,
|
|
26
|
+
poolAddress: local
|
|
27
|
+
? "0x0000000000000000000000000000000000000000"
|
|
28
|
+
: options.poolAddress,
|
|
29
|
+
gatewayUrl,
|
|
30
|
+
chainRpcUrl: options.nodeUrl,
|
|
31
|
+
storage,
|
|
32
|
+
rng,
|
|
33
|
+
fetch: globalThis.fetch,
|
|
34
|
+
autoSync: false,
|
|
35
|
+
prover,
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
wallet,
|
|
39
|
+
options,
|
|
40
|
+
gatewayUrl,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function withErrorHandler<T extends unknown[]>(fn: (...args: T) => Promise<void>): (...args: T) => Promise<void>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function withErrorHandler(fn) {
|
|
2
|
+
return async (...args) => {
|
|
3
|
+
try {
|
|
4
|
+
await fn(...args);
|
|
5
|
+
}
|
|
6
|
+
catch (err) {
|
|
7
|
+
// Commander always passes the Command object as the last argument
|
|
8
|
+
const last = args[args.length - 1];
|
|
9
|
+
const json = last !== null &&
|
|
10
|
+
typeof last === "object" &&
|
|
11
|
+
"optsWithGlobals" in last
|
|
12
|
+
? Boolean(last.optsWithGlobals()["json"])
|
|
13
|
+
: false;
|
|
14
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
15
|
+
if (json) {
|
|
16
|
+
process.stderr.write(JSON.stringify({ error: message }) + "\n");
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
20
|
+
}
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type MultisigAccount } from "@unlink-xyz/multisig";
|
|
2
|
+
export type MultisigAccountRef = {
|
|
3
|
+
groupId: string;
|
|
4
|
+
participantIndex: number;
|
|
5
|
+
};
|
|
6
|
+
export declare function saveMultisigAccount(dataDir: string, account: MultisigAccount): Promise<void>;
|
|
7
|
+
export declare function loadMultisigAccount(dataDir: string, groupId: string, participantIndex?: number): Promise<MultisigAccount>;
|
|
8
|
+
export declare function listMultisigAccounts(dataDir: string): Promise<MultisigAccountRef[]>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { deserializeMultisigAccount, serializeMultisigAccount, } from "@unlink-xyz/multisig";
|
|
4
|
+
const MULTISIG_DIR = "multisig";
|
|
5
|
+
const FILE_SUFFIX = ".json";
|
|
6
|
+
const FILENAME_RE = /^(.*)__p([1-9][0-9]*)\.json$/;
|
|
7
|
+
function multisigDir(dataDir) {
|
|
8
|
+
return join(dataDir, MULTISIG_DIR);
|
|
9
|
+
}
|
|
10
|
+
function toRef(account) {
|
|
11
|
+
assertParticipantIndex(account.participantIndex);
|
|
12
|
+
return {
|
|
13
|
+
groupId: account.groupId,
|
|
14
|
+
participantIndex: account.participantIndex,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function filename(ref) {
|
|
18
|
+
const encodedGroupId = encodeURIComponent(ref.groupId);
|
|
19
|
+
return `${encodedGroupId}__p${ref.participantIndex}${FILE_SUFFIX}`;
|
|
20
|
+
}
|
|
21
|
+
function path(dataDir, ref) {
|
|
22
|
+
const dir = multisigDir(dataDir);
|
|
23
|
+
const resolved = resolve(dir, filename(ref));
|
|
24
|
+
if (!resolved.startsWith(resolve(dir) + "/")) {
|
|
25
|
+
throw new Error("invalid groupId: path traversal detected");
|
|
26
|
+
}
|
|
27
|
+
return resolved;
|
|
28
|
+
}
|
|
29
|
+
function parseFilename(name) {
|
|
30
|
+
const match = FILENAME_RE.exec(name);
|
|
31
|
+
if (!match)
|
|
32
|
+
return null;
|
|
33
|
+
const encodedGroupId = match[1];
|
|
34
|
+
const participantIndex = Number(match[2]);
|
|
35
|
+
if (!encodedGroupId)
|
|
36
|
+
return null;
|
|
37
|
+
if (!Number.isInteger(participantIndex) || participantIndex < 1)
|
|
38
|
+
return null;
|
|
39
|
+
let groupId;
|
|
40
|
+
try {
|
|
41
|
+
groupId = decodeURIComponent(encodedGroupId);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
groupId,
|
|
48
|
+
participantIndex,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function assertParticipantIndex(value) {
|
|
52
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
53
|
+
throw new Error(`Invalid multisig account: participantIndex must be a positive integer (received ${value})`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function saveMultisigAccount(dataDir, account) {
|
|
57
|
+
const dir = multisigDir(dataDir);
|
|
58
|
+
const ref = toRef(account);
|
|
59
|
+
const dest = path(dataDir, ref);
|
|
60
|
+
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
|
|
61
|
+
await mkdir(dir, { recursive: true });
|
|
62
|
+
await writeFile(tmp, JSON.stringify(serializeMultisigAccount(account)), "utf-8");
|
|
63
|
+
await rename(tmp, dest);
|
|
64
|
+
}
|
|
65
|
+
export async function loadMultisigAccount(dataDir, groupId, participantIndex) {
|
|
66
|
+
if (participantIndex !== undefined) {
|
|
67
|
+
return loadByRef(dataDir, { groupId, participantIndex });
|
|
68
|
+
}
|
|
69
|
+
const matches = (await listMultisigAccounts(dataDir)).filter((ref) => ref.groupId === groupId);
|
|
70
|
+
if (matches.length === 0) {
|
|
71
|
+
throw new Error(`Multisig account not found: ${groupId}`);
|
|
72
|
+
}
|
|
73
|
+
const accounts = await Promise.all(matches.map(async (ref) => ({
|
|
74
|
+
ref,
|
|
75
|
+
account: await loadByRef(dataDir, ref),
|
|
76
|
+
})));
|
|
77
|
+
if (accounts.length > 1) {
|
|
78
|
+
const participants = accounts
|
|
79
|
+
.map(({ ref }) => ref.participantIndex)
|
|
80
|
+
.sort((a, b) => a - b)
|
|
81
|
+
.join(", ");
|
|
82
|
+
throw new Error(`Multiple multisig accounts found for ${groupId} (participants: ${participants}).`);
|
|
83
|
+
}
|
|
84
|
+
const [single] = accounts;
|
|
85
|
+
if (!single) {
|
|
86
|
+
throw new Error(`Multisig account not found: ${groupId}`);
|
|
87
|
+
}
|
|
88
|
+
return single.account;
|
|
89
|
+
}
|
|
90
|
+
async function loadByRef(dataDir, ref) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = await readFile(path(dataDir, ref), "utf-8");
|
|
93
|
+
const account = deserializeMultisigAccount(JSON.parse(raw));
|
|
94
|
+
if (account.groupId !== ref.groupId ||
|
|
95
|
+
account.participantIndex !== ref.participantIndex) {
|
|
96
|
+
throw new Error(`Invalid multisig account file for ${ref.groupId}: filename and JSON identity mismatch`);
|
|
97
|
+
}
|
|
98
|
+
return account;
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (err.code === "ENOENT") {
|
|
102
|
+
throw new Error(`Multisig account not found: ${ref.groupId}`);
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export async function listMultisigAccounts(dataDir) {
|
|
108
|
+
try {
|
|
109
|
+
const files = await readdir(multisigDir(dataDir));
|
|
110
|
+
return files
|
|
111
|
+
.filter((f) => f.endsWith(FILE_SUFFIX))
|
|
112
|
+
.map(parseFilename)
|
|
113
|
+
.filter((ref) => ref !== null);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (err.code === "ENOENT") {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type ResolvedOptions = {
|
|
2
|
+
gatewayUrl: string | undefined;
|
|
3
|
+
nodeUrl: string | undefined;
|
|
4
|
+
chainId: number | undefined;
|
|
5
|
+
poolAddress: string | undefined;
|
|
6
|
+
dataDir: string;
|
|
7
|
+
json: boolean;
|
|
8
|
+
privateKey: string | undefined;
|
|
9
|
+
artifactVersion: string | undefined;
|
|
10
|
+
};
|
|
11
|
+
export type ConfigFile = {
|
|
12
|
+
gatewayUrl?: string;
|
|
13
|
+
chainId?: number;
|
|
14
|
+
poolAddress?: string;
|
|
15
|
+
nodeUrl?: string;
|
|
16
|
+
artifactVersion?: string;
|
|
17
|
+
};
|
|
18
|
+
/** Read config.json from the data directory. Returns empty object if missing or invalid. */
|
|
19
|
+
export declare function loadConfigFile(dataDir: string): ConfigFile;
|
|
20
|
+
/** Write config.json to the data directory. */
|
|
21
|
+
export declare function saveConfigFile(dataDir: string, config: ConfigFile): void;
|
|
22
|
+
export declare function resolveOptions(opts: Record<string, unknown>): ResolvedOptions;
|
|
23
|
+
/** Merge config file defaults into resolved options. CLI flags/env vars take priority. */
|
|
24
|
+
export declare function mergeConfigDefaults(options: ResolvedOptions): ResolvedOptions;
|
|
25
|
+
export declare function requireChainId(opts: ResolvedOptions): asserts opts is ResolvedOptions & {
|
|
26
|
+
chainId: number;
|
|
27
|
+
};
|
|
28
|
+
export declare function requirePoolAddress(opts: ResolvedOptions): asserts opts is ResolvedOptions & {
|
|
29
|
+
poolAddress: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function requireGatewayUrl(opts: ResolvedOptions): asserts opts is ResolvedOptions & {
|
|
32
|
+
gatewayUrl: string;
|
|
33
|
+
};
|
|
34
|
+
export declare function requirePrivateKey(opts: ResolvedOptions): asserts opts is ResolvedOptions & {
|
|
35
|
+
privateKey: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function requireNodeUrl(opts: ResolvedOptions): asserts opts is ResolvedOptions & {
|
|
38
|
+
nodeUrl: string;
|
|
39
|
+
};
|
|
40
|
+
export declare function parseIndex(arg: string): number;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function asNonEmptyString(value) {
|
|
5
|
+
if (typeof value !== "string")
|
|
6
|
+
return undefined;
|
|
7
|
+
const trimmed = value.trim();
|
|
8
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
9
|
+
}
|
|
10
|
+
function asChainId(value) {
|
|
11
|
+
if (typeof value !== "number")
|
|
12
|
+
return undefined;
|
|
13
|
+
if (!Number.isInteger(value) || !Number.isFinite(value) || value < 0) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
/** Read config.json from the data directory. Returns empty object if missing or invalid. */
|
|
19
|
+
export function loadConfigFile(dataDir) {
|
|
20
|
+
const filePath = path.join(dataDir, "config.json");
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
const result = {};
|
|
25
|
+
const gatewayUrl = asNonEmptyString(parsed.gatewayUrl);
|
|
26
|
+
const chainId = asChainId(parsed.chainId);
|
|
27
|
+
const poolAddress = asNonEmptyString(parsed.poolAddress);
|
|
28
|
+
const nodeUrl = asNonEmptyString(parsed.nodeUrl);
|
|
29
|
+
const artifactVersion = asNonEmptyString(parsed.artifactVersion);
|
|
30
|
+
if (gatewayUrl !== undefined)
|
|
31
|
+
result.gatewayUrl = gatewayUrl;
|
|
32
|
+
if (chainId !== undefined)
|
|
33
|
+
result.chainId = chainId;
|
|
34
|
+
if (poolAddress !== undefined)
|
|
35
|
+
result.poolAddress = poolAddress;
|
|
36
|
+
if (nodeUrl !== undefined)
|
|
37
|
+
result.nodeUrl = nodeUrl;
|
|
38
|
+
if (artifactVersion !== undefined)
|
|
39
|
+
result.artifactVersion = artifactVersion;
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Write config.json to the data directory. */
|
|
47
|
+
export function saveConfigFile(dataDir, config) {
|
|
48
|
+
const filePath = path.join(dataDir, "config.json");
|
|
49
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
50
|
+
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
51
|
+
}
|
|
52
|
+
export function resolveOptions(opts) {
|
|
53
|
+
return {
|
|
54
|
+
gatewayUrl: opts["gatewayUrl"] || undefined,
|
|
55
|
+
nodeUrl: opts["nodeUrl"] || undefined,
|
|
56
|
+
chainId: opts["chainId"] ? Number(opts["chainId"]) : undefined,
|
|
57
|
+
poolAddress: opts["poolAddress"] || undefined,
|
|
58
|
+
dataDir: opts["dataDir"] || path.join(os.homedir(), ".unlink"),
|
|
59
|
+
json: Boolean(opts["json"]),
|
|
60
|
+
privateKey: opts["privateKey"] || undefined,
|
|
61
|
+
artifactVersion: opts["artifactVersion"] || undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Merge config file defaults into resolved options. CLI flags/env vars take priority. */
|
|
65
|
+
export function mergeConfigDefaults(options) {
|
|
66
|
+
const config = loadConfigFile(options.dataDir);
|
|
67
|
+
return {
|
|
68
|
+
...options,
|
|
69
|
+
gatewayUrl: options.gatewayUrl ?? config.gatewayUrl,
|
|
70
|
+
nodeUrl: options.nodeUrl ?? config.nodeUrl,
|
|
71
|
+
chainId: options.chainId ?? config.chainId,
|
|
72
|
+
poolAddress: options.poolAddress ?? config.poolAddress,
|
|
73
|
+
artifactVersion: options.artifactVersion ?? config.artifactVersion,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function requireChainId(opts) {
|
|
77
|
+
if (opts.chainId === undefined ||
|
|
78
|
+
!Number.isInteger(opts.chainId) ||
|
|
79
|
+
opts.chainId < 0) {
|
|
80
|
+
throw new Error("--chain-id or UNLINK_CHAIN_ID must be a valid number");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function requirePoolAddress(opts) {
|
|
84
|
+
if (!opts.poolAddress) {
|
|
85
|
+
throw new Error("--pool-address or UNLINK_POOL_ADDRESS is required for this command");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function requireGatewayUrl(opts) {
|
|
89
|
+
if (!opts.gatewayUrl) {
|
|
90
|
+
throw new Error("--gateway-url or UNLINK_GATEWAY_URL is required for this command");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function requirePrivateKey(opts) {
|
|
94
|
+
if (!opts.privateKey) {
|
|
95
|
+
throw new Error("--private-key or UNLINK_PRIVATE_KEY is required for this command");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function requireNodeUrl(opts) {
|
|
99
|
+
if (!opts.nodeUrl) {
|
|
100
|
+
throw new Error("--node-url or UNLINK_RPC_HTTP_URL is required for this command");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function parseIndex(arg) {
|
|
104
|
+
const index = Number(arg);
|
|
105
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
106
|
+
throw new Error("Index must be a non-negative integer");
|
|
107
|
+
}
|
|
108
|
+
return index;
|
|
109
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function bigintReplacer(_key, value) {
|
|
2
|
+
return typeof value === "bigint" ? value.toString() : value;
|
|
3
|
+
}
|
|
4
|
+
export function output(data, opts) {
|
|
5
|
+
if (opts.json) {
|
|
6
|
+
process.stdout.write(JSON.stringify(data, bigintReplacer, 2) + "\n");
|
|
7
|
+
}
|
|
8
|
+
else if (typeof data === "string") {
|
|
9
|
+
process.stdout.write(data + "\n");
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
process.stdout.write(formatHuman(data) + "\n");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function formatHuman(data) {
|
|
16
|
+
if (data === null || data === undefined)
|
|
17
|
+
return "null";
|
|
18
|
+
if (typeof data === "bigint")
|
|
19
|
+
return data.toString();
|
|
20
|
+
if (typeof data !== "object")
|
|
21
|
+
return String(data);
|
|
22
|
+
if (Array.isArray(data)) {
|
|
23
|
+
return data.length === 0 ? "(empty)" : data.map(formatHuman).join("\n");
|
|
24
|
+
}
|
|
25
|
+
return Object.entries(data)
|
|
26
|
+
.map(([k, v]) => `${k}: ${formatHuman(v)}`)
|
|
27
|
+
.join("\n");
|
|
28
|
+
}
|
|
29
|
+
/** Write progress/status to stderr. Suppressed in JSON mode. */
|
|
30
|
+
export function log(message, opts) {
|
|
31
|
+
if (!opts.json) {
|
|
32
|
+
process.stderr.write(message + "\n");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { UnlinkWallet } from "@unlink-xyz/core";
|
|
2
|
+
import { type OutputOptions } from "./output.js";
|
|
3
|
+
export type RelayPollResult = {
|
|
4
|
+
relayId: string;
|
|
5
|
+
status: string;
|
|
6
|
+
txHash?: string;
|
|
7
|
+
block?: number;
|
|
8
|
+
error?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function isRelaySuccess(result: RelayPollResult): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Poll relay status until terminal state.
|
|
13
|
+
* Logs progress to stderr in human-readable mode.
|
|
14
|
+
*/
|
|
15
|
+
export declare function pollRelayStatus(wallet: UnlinkWallet, relayId: string, outputOpts: OutputOptions, opts?: {
|
|
16
|
+
timeout?: number;
|
|
17
|
+
interval?: number;
|
|
18
|
+
}): Promise<RelayPollResult>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { log } from "./output.js";
|
|
2
|
+
const TERMINAL_STATES = new Set(["succeeded", "reverted", "failed", "dead"]);
|
|
3
|
+
const DEFAULT_POLL_INTERVAL_MS = 3_000;
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
5
|
+
export function isRelaySuccess(result) {
|
|
6
|
+
return result.status === "succeeded";
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Poll relay status until terminal state.
|
|
10
|
+
* Logs progress to stderr in human-readable mode.
|
|
11
|
+
*/
|
|
12
|
+
export async function pollRelayStatus(wallet, relayId, outputOpts, opts) {
|
|
13
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
14
|
+
const interval = opts?.interval ?? DEFAULT_POLL_INTERVAL_MS;
|
|
15
|
+
const deadline = Date.now() + timeout;
|
|
16
|
+
let lastState = "";
|
|
17
|
+
while (Date.now() < deadline) {
|
|
18
|
+
const status = await wallet.getTxStatus(relayId);
|
|
19
|
+
if (status.state !== lastState) {
|
|
20
|
+
lastState = status.state;
|
|
21
|
+
log(`Status: ${status.state}`, outputOpts);
|
|
22
|
+
}
|
|
23
|
+
if (TERMINAL_STATES.has(status.state)) {
|
|
24
|
+
return {
|
|
25
|
+
relayId,
|
|
26
|
+
status: status.state,
|
|
27
|
+
txHash: status.txHash ?? undefined,
|
|
28
|
+
block: status.receipt?.blockNumber ?? undefined,
|
|
29
|
+
error: status.error ?? undefined,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Timed out waiting for relay ${relayId} after ${timeout / 1000}s (last state: ${lastState || "unknown"})`);
|
|
35
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve ERC-20 token symbol via eth_call. Caches results.
|
|
3
|
+
* Falls back to truncated address on failure.
|
|
4
|
+
*/
|
|
5
|
+
export declare function resolveTokenSymbol(nodeUrl: string, address: string): Promise<string>;
|
|
6
|
+
/**
|
|
7
|
+
* Resolve ERC-20 decimals via eth_call. Returns undefined if unavailable.
|
|
8
|
+
* Caches both successful and failed lookups.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveTokenDecimals(nodeUrl: string, address: string): Promise<number | undefined>;
|
|
11
|
+
/** Format raw token amount as human-readable when decimals are known. */
|
|
12
|
+
export declare function formatTokenAmount(amount: bigint, decimals?: number): string;
|
|
13
|
+
/** Clear token metadata cache (for testing). */
|
|
14
|
+
export declare function clearSymbolCache(): void;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const symbolCache = new Map();
|
|
2
|
+
const decimalsCache = new Map();
|
|
3
|
+
// ERC-20 symbol() selector
|
|
4
|
+
const SYMBOL_SELECTOR = "0x95d89b41";
|
|
5
|
+
// ERC-20 decimals() selector
|
|
6
|
+
const DECIMALS_SELECTOR = "0x313ce567";
|
|
7
|
+
/**
|
|
8
|
+
* Resolve ERC-20 token symbol via eth_call. Caches results.
|
|
9
|
+
* Falls back to truncated address on failure.
|
|
10
|
+
*/
|
|
11
|
+
export async function resolveTokenSymbol(nodeUrl, address) {
|
|
12
|
+
const key = `${nodeUrl}:${address.toLowerCase()}`;
|
|
13
|
+
const cached = symbolCache.get(key);
|
|
14
|
+
if (cached)
|
|
15
|
+
return cached;
|
|
16
|
+
try {
|
|
17
|
+
const result = await callContract(nodeUrl, address, SYMBOL_SELECTOR);
|
|
18
|
+
if (result && result !== "0x" && result.length > 2) {
|
|
19
|
+
const symbol = decodeStringResult(result);
|
|
20
|
+
if (symbol) {
|
|
21
|
+
symbolCache.set(key, symbol);
|
|
22
|
+
return symbol;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Fall through to truncated address
|
|
28
|
+
}
|
|
29
|
+
const fallback = truncateAddress(address);
|
|
30
|
+
symbolCache.set(key, fallback);
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve ERC-20 decimals via eth_call. Returns undefined if unavailable.
|
|
35
|
+
* Caches both successful and failed lookups.
|
|
36
|
+
*/
|
|
37
|
+
export async function resolveTokenDecimals(nodeUrl, address) {
|
|
38
|
+
const key = `${nodeUrl}:${address.toLowerCase()}`;
|
|
39
|
+
if (decimalsCache.has(key)) {
|
|
40
|
+
return decimalsCache.get(key);
|
|
41
|
+
}
|
|
42
|
+
let decimals;
|
|
43
|
+
try {
|
|
44
|
+
const result = await callContract(nodeUrl, address, DECIMALS_SELECTOR);
|
|
45
|
+
if (result && result !== "0x" && result.length >= 4) {
|
|
46
|
+
decimals = decodeUint8Result(result);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Leave undefined on failure.
|
|
51
|
+
}
|
|
52
|
+
decimalsCache.set(key, decimals);
|
|
53
|
+
return decimals;
|
|
54
|
+
}
|
|
55
|
+
async function callContract(nodeUrl, address, data) {
|
|
56
|
+
const resp = await fetch(nodeUrl, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
jsonrpc: "2.0",
|
|
61
|
+
method: "eth_call",
|
|
62
|
+
params: [{ to: address, data }, "latest"],
|
|
63
|
+
id: 1,
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
const json = (await resp.json());
|
|
67
|
+
return json.result;
|
|
68
|
+
}
|
|
69
|
+
/** Decode ABI-encoded string return value. */
|
|
70
|
+
function decodeStringResult(hex) {
|
|
71
|
+
try {
|
|
72
|
+
// Remove 0x prefix.
|
|
73
|
+
const data = hex.slice(2);
|
|
74
|
+
// bytes32 encoding (some tokens like MKR): no offset, just raw bytes.
|
|
75
|
+
if (data.length === 64) {
|
|
76
|
+
const bytes = Buffer.from(data, "hex");
|
|
77
|
+
const end = bytes.indexOf(0);
|
|
78
|
+
const str = bytes.subarray(0, end === -1 ? 32 : end).toString("utf-8");
|
|
79
|
+
if (str.length > 0 && /^[\x20-\x7E]+$/.test(str)) {
|
|
80
|
+
return str;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Standard ABI string encoding: offset (32 bytes) + length (32 bytes) + data.
|
|
84
|
+
if (data.length >= 128) {
|
|
85
|
+
const length = Number.parseInt(data.slice(64, 128), 16);
|
|
86
|
+
if (length > 0 && length <= 32) {
|
|
87
|
+
const str = Buffer.from(data.slice(128, 128 + length * 2), "hex")
|
|
88
|
+
.toString("utf-8")
|
|
89
|
+
.trim();
|
|
90
|
+
if (str.length > 0)
|
|
91
|
+
return str;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Fall through.
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function decodeUint8Result(hex) {
|
|
101
|
+
try {
|
|
102
|
+
const data = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
103
|
+
if (data.length < 2)
|
|
104
|
+
return undefined;
|
|
105
|
+
const val = Number.parseInt(data.slice(-2), 16);
|
|
106
|
+
if (Number.isNaN(val))
|
|
107
|
+
return undefined;
|
|
108
|
+
return val;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Format raw token amount as human-readable when decimals are known. */
|
|
115
|
+
export function formatTokenAmount(amount, decimals) {
|
|
116
|
+
if (decimals === undefined ||
|
|
117
|
+
!Number.isInteger(decimals) ||
|
|
118
|
+
decimals < 0 ||
|
|
119
|
+
decimals > 36) {
|
|
120
|
+
return amount.toString();
|
|
121
|
+
}
|
|
122
|
+
if (decimals === 0)
|
|
123
|
+
return amount.toString();
|
|
124
|
+
const base = 10n ** BigInt(decimals);
|
|
125
|
+
const whole = amount / base;
|
|
126
|
+
const frac = amount % base;
|
|
127
|
+
if (frac === 0n)
|
|
128
|
+
return whole.toString();
|
|
129
|
+
// Pad to decimals digits, strip trailing zeros.
|
|
130
|
+
const fracStr = frac.toString().padStart(decimals, "0").replace(/0+$/, "");
|
|
131
|
+
return `${whole}.${fracStr}`;
|
|
132
|
+
}
|
|
133
|
+
function truncateAddress(address) {
|
|
134
|
+
if (address.length <= 12)
|
|
135
|
+
return address;
|
|
136
|
+
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
|
137
|
+
}
|
|
138
|
+
/** Clear token metadata cache (for testing). */
|
|
139
|
+
export function clearSymbolCache() {
|
|
140
|
+
symbolCache.clear();
|
|
141
|
+
decimalsCache.clear();
|
|
142
|
+
}
|