@unlink-xyz/cli 0.1.4 → 0.1.5

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.
@@ -20,7 +20,8 @@ export function registerDepositCommands(program) {
20
20
  const token = cmdOpts["token"];
21
21
  const ethRpcUrl = options.nodeUrl ?? ctx.gatewayUrl;
22
22
  const provider = new ethers.JsonRpcProvider(ethRpcUrl, ctx.wallet.chainId, { staticNetwork: true });
23
- const signer = new ethers.Wallet(options.privateKey, provider);
23
+ const wallet = new ethers.Wallet(options.privateKey, provider);
24
+ const signer = new ethers.NonceManager(wallet);
24
25
  const depositor = await signer.getAddress();
25
26
  const erc20 = new ethers.Contract(token, [
26
27
  "function allowance(address,address) view returns (uint256)",
@@ -38,11 +39,9 @@ export function registerDepositCommands(program) {
38
39
  deposits: [{ token, amount }],
39
40
  });
40
41
  log(`Relay ID: ${relay.relayId}\nSubmitting transaction...`, options);
41
- const nonce = await provider.getTransactionCount(depositor, "pending");
42
42
  const tx = await signer.sendTransaction({
43
43
  to: relay.to,
44
44
  data: relay.calldata,
45
- nonce,
46
45
  });
47
46
  log(`Tx hash: ${tx.hash}\nWaiting for confirmation...`, options);
48
47
  await tx.wait();
@@ -1,2 +1,2 @@
1
- import type { Command } from "commander";
1
+ import { type Command } from "commander";
2
2
  export declare function registerMultisigCommands(program: Command): void;
@@ -1,10 +1,13 @@
1
+ import * as readline from "node:readline";
1
2
  import { createMultisigWallet, runSigningListener, signMultisig, } from "@unlink-xyz/multisig";
3
+ import { Option } from "commander";
2
4
  import { ethers } from "ethers";
3
5
  import { createContext } from "../lib/context.js";
4
6
  import { withErrorHandler } from "../lib/errors.js";
5
7
  import { listMultisigAccounts, loadMultisigAccount, saveMultisigAccount, } from "../lib/multisig-store.js";
6
8
  import { requireChainId, requireGatewayUrl, requirePoolAddress, requirePrivateKey, resolveOptions, } from "../lib/options.js";
7
9
  import { log, output } from "../lib/output.js";
10
+ import { isRelaySuccess, pollRelayStatus } from "../lib/relay.js";
8
11
  async function loadAccountView(options, groupId) {
9
12
  const account = await loadMultisigAccount(options.dataDir, groupId);
10
13
  const msWallet = createMultisigWallet({
@@ -22,6 +25,49 @@ function parseOptionalParticipantIndex(cmdOpts) {
22
25
  }
23
26
  return participantIndex;
24
27
  }
28
+ function parseParticipants(raw, config) {
29
+ if (raw === undefined) {
30
+ return Array.from({ length: config.threshold }, (_, i) => i + 1);
31
+ }
32
+ const indices = raw.split(",").map(Number);
33
+ for (const idx of indices) {
34
+ if (!Number.isInteger(idx) || idx < 1 || idx > config.totalShares) {
35
+ throw new Error(`Invalid participant index ${idx}: must be between 1 and ${config.totalShares}`);
36
+ }
37
+ }
38
+ const unique = new Set(indices);
39
+ if (unique.size !== indices.length) {
40
+ throw new Error("Duplicate participant indices are not allowed");
41
+ }
42
+ if (indices.length < config.threshold) {
43
+ throw new Error(`Need at least ${config.threshold} participants (threshold), got ${indices.length}`);
44
+ }
45
+ return indices;
46
+ }
47
+ async function promptApproval(signal) {
48
+ if (signal.aborted)
49
+ return false;
50
+ const rl = readline.createInterface({
51
+ input: process.stdin,
52
+ output: process.stderr,
53
+ });
54
+ return new Promise((resolve) => {
55
+ let settled = false;
56
+ const finish = (approved) => {
57
+ if (settled)
58
+ return;
59
+ settled = true;
60
+ signal.removeEventListener("abort", onAbort);
61
+ rl.close();
62
+ resolve(approved);
63
+ };
64
+ const onAbort = () => finish(false);
65
+ signal.addEventListener("abort", onAbort, { once: true });
66
+ rl.question("Approve? [y/N] ", (answer) => {
67
+ finish(answer.toLowerCase() === "y");
68
+ });
69
+ });
70
+ }
25
71
  export function registerMultisigCommands(program) {
26
72
  const multisig = program
27
73
  .command("multisig")
@@ -172,25 +218,50 @@ export function registerMultisigCommands(program) {
172
218
  }));
173
219
  multisig
174
220
  .command("listen")
175
- .description("Listen for signing sessions and auto-sign (co-signer mode)")
221
+ .description("Listen for signing sessions and co-sign after approval")
176
222
  .requiredOption("-g, --group-id <id>", "Multisig group ID")
177
223
  .option("-p, --participant-index <n>", "Participant index override when multiple local participants exist for a group")
224
+ .option("--auto-approve", "Skip approval prompt (auto-sign, for dev/testing)")
178
225
  .action(withErrorHandler(async (cmdOpts, cmd) => {
179
226
  const options = resolveOptions(cmd.optsWithGlobals());
180
227
  const groupId = cmdOpts["groupId"];
181
228
  const participantIndex = parseOptionalParticipantIndex(cmdOpts);
229
+ const autoApprove = cmdOpts["autoApprove"] === true;
182
230
  const account = await loadMultisigAccount(options.dataDir, groupId, participantIndex);
183
231
  log(`Listening for signing sessions on group ${groupId} (participant ${account.participantIndex})...`, options);
232
+ if (autoApprove) {
233
+ log("Auto-approve enabled — signing all sessions automatically.", options);
234
+ }
184
235
  log("Press Ctrl+C to stop.", options);
185
236
  const controller = new AbortController();
186
- process.on("SIGINT", () => controller.abort());
187
- await runSigningListener({
188
- account,
189
- signal: controller.signal,
190
- onSession: (session) => {
191
- log(`Joining session ${session.code} (message: ${session.message})`, options);
192
- },
193
- });
237
+ const onSigint = () => controller.abort();
238
+ process.on("SIGINT", onSigint);
239
+ try {
240
+ await runSigningListener({
241
+ account,
242
+ signal: controller.signal,
243
+ onSession: (session) => {
244
+ log(`\nSession ${session.code} (message: ${session.message})`, options);
245
+ if (session.metadata) {
246
+ const m = session.metadata;
247
+ if (m["type"])
248
+ log(` Type: ${m["type"]}`, options);
249
+ if (m["recipient"])
250
+ log(` To: ${m["recipient"]}`, options);
251
+ if (m["token"])
252
+ log(` Token: ${m["token"]}`, options);
253
+ if (m["amount"])
254
+ log(` Amount: ${m["amount"]}`, options);
255
+ }
256
+ },
257
+ approve: autoApprove
258
+ ? undefined
259
+ : () => promptApproval(controller.signal),
260
+ });
261
+ }
262
+ finally {
263
+ process.off("SIGINT", onSigint);
264
+ }
194
265
  log("Listener stopped.", options);
195
266
  }));
196
267
  // --- Pool operations (require createContext for UnlinkWallet) ---
@@ -213,7 +284,8 @@ export function registerMultisigCommands(program) {
213
284
  const ctx = await createContext(options);
214
285
  const ethRpcUrl = options.nodeUrl ?? ctx.gatewayUrl;
215
286
  const provider = new ethers.JsonRpcProvider(ethRpcUrl, ctx.wallet.chainId, { staticNetwork: true });
216
- const signer = new ethers.Wallet(options.privateKey, provider);
287
+ const wallet = new ethers.Wallet(options.privateKey, provider);
288
+ const signer = new ethers.NonceManager(wallet);
217
289
  const depositor = await signer.getAddress();
218
290
  const erc20 = new ethers.Contract(token, [
219
291
  "function allowance(address,address) view returns (uint256)",
@@ -232,11 +304,9 @@ export function registerMultisigCommands(program) {
232
304
  account: accountView,
233
305
  });
234
306
  log(`Relay ID: ${relay.relayId}\nSubmitting transaction...`, options);
235
- const nonce = await provider.getTransactionCount(depositor, "pending");
236
307
  const tx = await signer.sendTransaction({
237
308
  to: relay.to,
238
309
  data: relay.calldata,
239
- nonce,
240
310
  });
241
311
  log(`Tx hash: ${tx.hash}\nWaiting for confirmation...`, options);
242
312
  await tx.wait();
@@ -315,6 +385,7 @@ export function registerMultisigCommands(program) {
315
385
  .requiredOption("-r, --to <address>", "Recipient Unlink address (0zk1...)")
316
386
  .requiredOption("-t, --token <address>", "Token contract address")
317
387
  .requiredOption("-a, --amount <value>", "Amount (raw atomic units)")
388
+ .option("-P, --participants <indices>", "Comma-separated participant indices for threshold signing (default: first t)")
318
389
  .action(withErrorHandler(async (cmdOpts, cmd) => {
319
390
  const options = resolveOptions(cmd.optsWithGlobals());
320
391
  requireGatewayUrl(options);
@@ -323,21 +394,91 @@ export function registerMultisigCommands(program) {
323
394
  const groupId = cmdOpts["groupId"];
324
395
  const { account, accountView } = await loadAccountView(options, groupId);
325
396
  const ctx = await createContext(options);
326
- const participants = Array.from({ length: account.config.totalShares }, (_, i) => i + 1);
397
+ const participants = parseParticipants(cmdOpts["participants"], account.config);
398
+ const token = cmdOpts["token"];
399
+ const recipient = cmdOpts["to"];
400
+ const amount = BigInt(cmdOpts["amount"]);
327
401
  const msWallet = createMultisigWallet({
328
402
  gatewayUrl: account.gatewayUrl,
329
403
  });
330
- const signer = msWallet.createSigner({ account, participants });
404
+ const signer = msWallet.createSigner({
405
+ account,
406
+ participants,
407
+ metadata: {
408
+ type: "transfer",
409
+ recipient,
410
+ token,
411
+ amount: amount.toString(),
412
+ },
413
+ onSessionCreated: (code) => {
414
+ log(`Signing session created: ${code}`, options);
415
+ const cliName = cmd.parent?.parent?.name() ?? "unlink";
416
+ log(`Co-signers can run:\n ${cliName} multisig sign -g ${groupId} -s ${code} -m <message>`, options);
417
+ log("Waiting for co-signers...", options);
418
+ },
419
+ });
331
420
  log("Planning and submitting transfer...", options);
332
421
  const result = await ctx.wallet.transfer({
333
- transfers: [
334
- {
335
- token: cmdOpts["token"],
336
- recipient: cmdOpts["to"],
337
- amount: BigInt(cmdOpts["amount"]),
338
- },
339
- ],
422
+ transfers: [{ token, recipient, amount }],
340
423
  }, { signer, account: accountView });
341
424
  output({ relayId: result.relayId, status: "submitted" }, options);
342
425
  }));
426
+ multisig
427
+ .command("withdraw")
428
+ .description("Withdraw from a multisig account to an EOA (FROST signing)")
429
+ .requiredOption("-g, --group-id <id>", "Multisig group ID")
430
+ .requiredOption("--to <address>", "Recipient EOA address (0x...)")
431
+ .requiredOption("-t, --token <address>", "Token contract address")
432
+ .requiredOption("-a, --amount <value>", "Amount (raw atomic units)")
433
+ .option("-P, --participants <indices>", "Comma-separated participant indices for threshold signing (default: first t)")
434
+ .addOption(new Option("--wait", "Wait for relay confirmation").default(true))
435
+ .addOption(new Option("--no-wait", "Fire-and-forget (do not wait for confirmation)"))
436
+ .action(withErrorHandler(async (cmdOpts, cmd) => {
437
+ const options = resolveOptions(cmd.optsWithGlobals());
438
+ requireGatewayUrl(options);
439
+ requireChainId(options);
440
+ requirePoolAddress(options);
441
+ const groupId = cmdOpts["groupId"];
442
+ const { account, accountView } = await loadAccountView(options, groupId);
443
+ const ctx = await createContext(options);
444
+ const participants = parseParticipants(cmdOpts["participants"], account.config);
445
+ const token = cmdOpts["token"];
446
+ const recipient = cmdOpts["to"];
447
+ const amount = BigInt(cmdOpts["amount"]);
448
+ const msWallet = createMultisigWallet({
449
+ gatewayUrl: account.gatewayUrl,
450
+ });
451
+ const signer = msWallet.createSigner({
452
+ account,
453
+ participants,
454
+ metadata: {
455
+ type: "withdraw",
456
+ recipient,
457
+ token,
458
+ amount: amount.toString(),
459
+ },
460
+ onSessionCreated: (code) => {
461
+ log(`Signing session created: ${code}`, options);
462
+ const cliName = cmd.parent?.parent?.name() ?? "unlink";
463
+ log(`Co-signers can run:\n ${cliName} multisig sign -g ${groupId} -s ${code} -m <message>`, options);
464
+ log("Waiting for co-signers...", options);
465
+ },
466
+ });
467
+ log("Planning and submitting withdrawal...", options);
468
+ const result = await ctx.wallet.withdraw({
469
+ withdrawals: [{ token, recipient, amount }],
470
+ }, { signer, account: accountView });
471
+ log(`Relay ID: ${result.relayId}`, options);
472
+ const shouldWait = cmdOpts["wait"];
473
+ if (shouldWait) {
474
+ const final = await pollRelayStatus(ctx.wallet, result.relayId, options);
475
+ if (!isRelaySuccess(final)) {
476
+ throw new Error(`Withdrawal relay ${final.relayId} ended with status "${final.status}"${final.error ? `: ${final.error}` : ""}`);
477
+ }
478
+ output(final, options);
479
+ }
480
+ else {
481
+ output({ relayId: result.relayId, status: "submitted" }, options);
482
+ }
483
+ }));
343
484
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unlink-xyz/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "unlink-cli": "./dist/index.js"
@@ -26,9 +26,9 @@
26
26
  "format:check": "prettier . --check"
27
27
  },
28
28
  "dependencies": {
29
- "@unlink-xyz/core": "0.1.4",
30
- "@unlink-xyz/multisig": "0.1.4",
31
- "@unlink-xyz/node": "0.1.4",
29
+ "@unlink-xyz/core": "0.1.5",
30
+ "@unlink-xyz/multisig": "0.1.5",
31
+ "@unlink-xyz/node": "0.1.5",
32
32
  "commander": "^13.0.0",
33
33
  "dotenv": "^16.0.0",
34
34
  "ethers": "6.15.0"