@vultisig/cli 2.18.0 → 2.18.6

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/index.js +187 -281
  3. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @vultisig/cli
2
2
 
3
+ ## 2.18.6
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1003](https://github.com/vultisig/vultisig-sdk/pull/1003) [`b27786d`](https://github.com/vultisig/vultisig-sdk/commit/b27786d8ac596ea3d2d4a13da958b266f589b73c) Thanks [@neavra](https://github.com/neavra)! - fix(agent): sign purely from the `tool-output-available` channel and remove the `tx_ready` signing path ([#927](https://github.com/vultisig/vultisig-sdk/issues/927) Phase 2). The client-enriched tool-output candidate (flat builders and `execute_*` prep) is now the sole signing source, matching what the production backend emits — it writes the signable payload on tool-output and emits `data-tx_ready` only as a hollow `{typed_confirm}` marker the CLI never consumed. Removes the Phase-1 dual-read + parity cross-check machinery, the tx_ready capture/selection, and the recovered-tx_ready replay. Fail-closed postures are preserved (a structurally-unsignable candidate is never buffered), and a disconnect that ran a signable tool now warns to re-run rather than signing. Patch: no CLI API change and the same real transactions still sign — this aligns the internal signing source with production.
8
+
9
+ - [#1024](https://github.com/vultisig/vultisig-sdk/pull/1024) [`3bc7904`](https://github.com/vultisig/vultisig-sdk/commit/3bc790403483dd7e90dac2efc33d7bc64c18b921) Thanks [@neavra](https://github.com/neavra)! - Stop `tx-status` from reporting malformed or never-seen transaction hashes as `pending` forever.
10
+
11
+ - The EVM status resolver now distinguishes a genuinely-pending tx (the node knows the hash, receipt still lagging) from one the node has never seen, returning a new terminal `not_found` status for the latter instead of an indefinite `pending`.
12
+ - New `isValidTxHash(chain, hash)` helper validates a hash's shape per chain-kind; the CLI `tx-status` command validates `--tx-hash` before any RPC and fails fast with `INVALID_INPUT` (exit 4) on a malformed hash.
13
+ - CLI `tx-status` polling is now bounded by a total wait budget (`--timeout <seconds>`, default 120) and exits non-zero on give-up — `TX_NOT_FOUND` (exit 5) when the node has no record of the hash, `TX_STATUS_TIMEOUT` (exit 3, retryable) when it is still pending.
14
+ - The poll loop now caps each sleep at the remaining wait budget instead of always sleeping the full poll interval, so a small `--timeout` gives up promptly instead of overshooting by up to one poll interval.
15
+
16
+ - Updated dependencies [[`ce38186`](https://github.com/vultisig/vultisig-sdk/commit/ce381864b977b19668702eae6e1ecad63ecbdf2b), [`3bc7904`](https://github.com/vultisig/vultisig-sdk/commit/3bc790403483dd7e90dac2efc33d7bc64c18b921)]:
17
+ - @vultisig/sdk@2.18.6
18
+ - @vultisig/core-chain@2.23.3
19
+
3
20
  ## 2.18.0
4
21
 
5
22
  ### Minor Changes
package/dist/index.js CHANGED
@@ -5400,6 +5400,21 @@ var TokenNotFoundError = class extends VsigError {
5400
5400
  super(message, hint, suggestions, context);
5401
5401
  }
5402
5402
  };
5403
+ var TxNotFoundError = class extends VsigError {
5404
+ exitCode = 5 /* RESOURCE_NOT_FOUND */;
5405
+ code = "TX_NOT_FOUND";
5406
+ constructor(message, hint, suggestions, context) {
5407
+ super(message, hint, suggestions, context);
5408
+ }
5409
+ };
5410
+ var TxStatusTimeoutError = class extends VsigError {
5411
+ exitCode = 3 /* NETWORK */;
5412
+ code = "TX_STATUS_TIMEOUT";
5413
+ retryable = true;
5414
+ constructor(message, hint, suggestions, context) {
5415
+ super(message, hint, suggestions, context);
5416
+ }
5417
+ };
5403
5418
  var ExternalServiceError = class extends VsigError {
5404
5419
  exitCode = 6 /* EXTERNAL_SERVICE */;
5405
5420
  code = "EXTERNAL_SERVICE";
@@ -6538,33 +6553,78 @@ async function executeBroadcast(ctx2, params) {
6538
6553
  }
6539
6554
 
6540
6555
  // src/commands/tx-status.ts
6541
- import { Chain as Chain5, Vultisig as Vultisig5 } from "@vultisig/sdk";
6556
+ import { Chain as Chain5, isValidTxHash, Vultisig as Vultisig5 } from "@vultisig/sdk";
6542
6557
  var POLL_INTERVAL_MS = 5e3;
6543
- async function executeTxStatus(ctx2, params) {
6558
+ var DEFAULT_TIMEOUT_SEC = 120;
6559
+ var isTerminal = (status) => status === "success" || status === "error";
6560
+ function resolveTimeoutMs(timeoutSec) {
6561
+ if (typeof timeoutSec !== "number" || !Number.isFinite(timeoutSec)) {
6562
+ return DEFAULT_TIMEOUT_SEC * 1e3;
6563
+ }
6564
+ return Math.max(0, timeoutSec) * 1e3;
6565
+ }
6566
+ async function executeTxStatus(ctx2, params, opts = {}) {
6544
6567
  const vault = await ctx2.ensureActiveVault();
6545
6568
  if (!Object.values(Chain5).includes(params.chain)) {
6546
- throw new Error(`Invalid chain: ${params.chain}`);
6569
+ throw new InvalidInputError(`Invalid chain: ${params.chain}`);
6570
+ }
6571
+ if (!isValidTxHash(params.chain, params.txHash)) {
6572
+ throw new InvalidInputError(
6573
+ `Invalid transaction hash for ${params.chain}: "${params.txHash}"`,
6574
+ "Check the hash \u2014 it must match the expected format for the chain.",
6575
+ void 0,
6576
+ { chain: params.chain, txHash: params.txHash }
6577
+ );
6547
6578
  }
6579
+ const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
6548
6580
  const spinner = createSpinner("Checking transaction status...");
6549
6581
  try {
6550
6582
  let result = await vault.getTxStatus({ chain: params.chain, txHash: params.txHash });
6551
- if (!params.noWait) {
6552
- let polls = 1;
6553
- while (result.status === "pending") {
6554
- spinner.text = `Transaction pending... (${polls * 5}s)`;
6555
- await sleep(POLL_INTERVAL_MS);
6583
+ if (!params.noWait && !isTerminal(result.status)) {
6584
+ const deadline = Date.now() + resolveTimeoutMs(params.timeoutSec);
6585
+ let waited = 0;
6586
+ while (!isTerminal(result.status)) {
6587
+ const remainingMs = deadline - Date.now();
6588
+ if (remainingMs <= 0) {
6589
+ spinner.fail(`Gave up waiting after ${Math.round(waited / 1e3)}s (status: ${result.status})`);
6590
+ throw giveUpError(params, result, waited);
6591
+ }
6592
+ const sleepMs = Math.min(pollIntervalMs, remainingMs);
6593
+ waited += sleepMs;
6594
+ spinner.text = `Transaction ${result.status}... (${Math.round(waited / 1e3)}s)`;
6595
+ await sleep(sleepMs);
6556
6596
  result = await vault.getTxStatus({ chain: params.chain, txHash: params.txHash });
6557
- polls++;
6558
6597
  }
6559
6598
  }
6560
6599
  spinner.succeed(`Transaction status: ${result.status}`);
6561
6600
  displayResult(params.chain, params.txHash, result);
6562
6601
  return result;
6563
6602
  } catch (error2) {
6603
+ if (error2 instanceof TxNotFoundError || error2 instanceof TxStatusTimeoutError) {
6604
+ throw error2;
6605
+ }
6564
6606
  spinner.fail("Failed to check transaction status");
6565
6607
  throw error2;
6566
6608
  }
6567
6609
  }
6610
+ function giveUpError(params, result, waitedMs) {
6611
+ const seconds = Math.round(waitedMs / 1e3);
6612
+ const context = { chain: params.chain, txHash: params.txHash, status: result.status };
6613
+ if (result.status === "not_found") {
6614
+ return new TxNotFoundError(
6615
+ `Transaction not found on ${params.chain} after ${seconds}s: ${params.txHash}`,
6616
+ "The node has no record of this hash \u2014 it may have been dropped, replaced, or never broadcast.",
6617
+ ["Verify the transaction hash", "Re-broadcast if it was never sent"],
6618
+ context
6619
+ );
6620
+ }
6621
+ return new TxStatusTimeoutError(
6622
+ `Transaction still ${result.status} on ${params.chain} after ${seconds}s: ${params.txHash}`,
6623
+ "The transaction may still confirm later.",
6624
+ ["Re-run to keep checking", "Increase the wait budget with --timeout <seconds>"],
6625
+ context
6626
+ );
6627
+ }
6568
6628
  function displayResult(chain, txHash, result) {
6569
6629
  if (isJsonOutput()) {
6570
6630
  outputJson({
@@ -8333,12 +8393,12 @@ var AskInterface = class {
8333
8393
  }
8334
8394
  },
8335
8395
  onError: (message, code) => {
8336
- const isTerminal = isTerminalAgentErrorCode(code);
8396
+ const isTerminal2 = isTerminalAgentErrorCode(code);
8337
8397
  if (!this.hasAsked) {
8338
8398
  this.initError = { message, code };
8339
- } else if (!this.error || isTerminal && !this.errorIsTerminal) {
8399
+ } else if (!this.error || isTerminal2 && !this.errorIsTerminal) {
8340
8400
  this.error = { message, code };
8341
- this.errorIsTerminal = isTerminal;
8401
+ this.errorIsTerminal = isTerminal2;
8342
8402
  }
8343
8403
  process.stderr.write(`[error] ${message} [${code}]
8344
8404
  `);
@@ -11769,7 +11829,7 @@ var CLI_SIGNABLE_FLAT_TOOLS = /* @__PURE__ */ new Set([
11769
11829
  "build_max_subscription_renewal",
11770
11830
  "build_pro_subscription_renewal"
11771
11831
  ]);
11772
- var CLI_PARITY_PREP_TOOLS = /* @__PURE__ */ new Set([
11832
+ var CLI_SIGNABLE_PREP_TOOLS = /* @__PURE__ */ new Set([
11773
11833
  "execute_swap",
11774
11834
  "execute_send",
11775
11835
  "execute_contract_call"
@@ -11829,6 +11889,22 @@ function asChainIdString(value) {
11829
11889
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
11830
11890
  return void 0;
11831
11891
  }
11892
+ function resolvePrepChain(txArgs) {
11893
+ const byName = asChainString(txArgs.chain) ? resolveChain(asChainString(txArgs.chain)) : null;
11894
+ const byId = asChainIdString(txArgs.chain_id) ? resolveChainId(asChainIdString(txArgs.chain_id)) : null;
11895
+ if (!byName && !byId) return null;
11896
+ if (byName && byId && byName !== byId) return null;
11897
+ return byName ?? byId;
11898
+ }
11899
+ function resolvePrepParentChain(env) {
11900
+ const byChain = asChainString(env.chain) ? resolveChain(asChainString(env.chain)) : null;
11901
+ const byFromChain = asChainString(env.from_chain) ? resolveChain(asChainString(env.from_chain)) : null;
11902
+ const byId = asChainIdString(env.chain_id) ? resolveChainId(asChainIdString(env.chain_id)) : null;
11903
+ const candidates = [byChain, byFromChain, byId].filter((c) => c !== null);
11904
+ if (candidates.length === 0) return null;
11905
+ if (candidates.some((c) => c !== candidates[0])) return null;
11906
+ return candidates[0];
11907
+ }
11832
11908
  function buildTxReadyFromToolOutput(toolName, output) {
11833
11909
  if (!CLI_SIGNABLE_FLAT_TOOLS.has(toolName)) return null;
11834
11910
  const env = asRecord(output);
@@ -11863,12 +11939,24 @@ function deriveToolOutputCandidate(toolName, output) {
11863
11939
  const payload = buildTxReadyFromToolOutput(toolName, output);
11864
11940
  return payload ? { payload, source: "flat", toolName } : null;
11865
11941
  }
11866
- if (CLI_PARITY_PREP_TOOLS.has(toolName)) {
11942
+ if (CLI_SIGNABLE_PREP_TOOLS.has(toolName)) {
11867
11943
  const env = asRecord(output);
11868
11944
  if (!env || env.status === "error" || "error" in env) return null;
11869
11945
  const txArgs = asRecord(env.txArgs);
11870
11946
  if (!txArgs) return null;
11871
11947
  if (typeof txArgs.tx_encoding !== "string" || txArgs.tx_encoding === "") return null;
11948
+ const prepChain = resolvePrepChain(txArgs);
11949
+ if (!prepChain) return null;
11950
+ const hasParentChainMetadata = asChainString(env.chain) !== void 0 || asChainString(env.from_chain) !== void 0 || asChainIdString(env.chain_id) !== void 0;
11951
+ if (hasParentChainMetadata) {
11952
+ const parentChain = resolvePrepParentChain(env);
11953
+ if (!parentChain || parentChain !== prepChain) return null;
11954
+ }
11955
+ const approvalTxArgs = asRecord(env.approvalTxArgs);
11956
+ if (approvalTxArgs) {
11957
+ const approvalChain = resolvePrepChain(approvalTxArgs);
11958
+ if (!approvalChain || approvalChain !== prepChain) return null;
11959
+ }
11872
11960
  return { payload: env, source: "prep", toolName };
11873
11961
  }
11874
11962
  return null;
@@ -11878,105 +11966,6 @@ function str(value) {
11878
11966
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
11879
11967
  return void 0;
11880
11968
  }
11881
- function nestedSource(legObj) {
11882
- const nested = (asRecord(legObj.tx) || asRecord(legObj.swap_tx) || asRecord(legObj.send_tx) || asRecord(legObj.txArgs?.tx)) ?? asRecord(legObj.txArgs) ?? legObj;
11883
- return nested;
11884
- }
11885
- function canonGasLimit(value) {
11886
- if (typeof value === "number" && Number.isInteger(value) && value > 0) return String(value);
11887
- if (typeof value === "string") {
11888
- const t = value.trim();
11889
- if (/^\d+$/.test(t) || /^0x[0-9a-fA-F]+$/.test(t)) {
11890
- try {
11891
- const n = BigInt(t);
11892
- return n > 0n ? n.toString() : void 0;
11893
- } catch {
11894
- return void 0;
11895
- }
11896
- }
11897
- }
11898
- return void 0;
11899
- }
11900
- function canonLeg(legObj) {
11901
- const src = nestedSource(legObj);
11902
- const txArgs = asRecord(legObj.txArgs);
11903
- const toRaw = str(src.to) ?? str(src.to_address);
11904
- const dataRaw = str(src.data) ?? str(src.calldata);
11905
- const leg = {
11906
- to: toRaw ? toRaw.toLowerCase() : void 0,
11907
- value: normalizeValue(src.value),
11908
- data: dataRaw ? dataRaw.toLowerCase() : void 0,
11909
- gasLimit: canonGasLimit(src.gas_limit),
11910
- chain: str(legObj.chain) ?? str(txArgs?.chain) ?? str(src.chain),
11911
- chainId: str(legObj.chain_id) ?? str(txArgs?.chain_id) ?? str(src.chain_id),
11912
- txEncoding: str(src.tx_encoding) ?? str(txArgs?.tx_encoding),
11913
- amount: str(src.amount) ?? str(txArgs?.amount),
11914
- memo: str(src.memo) ?? str(txArgs?.memo)
11915
- };
11916
- return leg;
11917
- }
11918
- var TX_READY_EXCLUSIVE_KEYS = ["typed_confirm", "sequence_id", "sequence_index", "sequence_total"];
11919
- function collectExclusive(payload) {
11920
- const found = [];
11921
- const scan = (obj) => {
11922
- if (!obj) return;
11923
- for (const k of TX_READY_EXCLUSIVE_KEYS) if (k in obj && !found.includes(k)) found.push(k);
11924
- };
11925
- scan(payload);
11926
- scan(asRecord(payload.tx));
11927
- scan(asRecord(payload.txArgs));
11928
- return found;
11929
- }
11930
- function canonicalizeForParity(payload) {
11931
- const env = asRecord(payload);
11932
- if (!env) return null;
11933
- const approval = asRecord(env.approvalTxArgs);
11934
- const main = asRecord(env.txArgs);
11935
- const legs = approval && main ? [canonLeg(approval), canonLeg(main)] : [canonLeg(env)];
11936
- return { legs, exclusive: collectExclusive(env) };
11937
- }
11938
- var HARD_FIELDS = [
11939
- "to",
11940
- "value",
11941
- "data",
11942
- "chain",
11943
- "chainId",
11944
- "txEncoding",
11945
- "amount",
11946
- "memo",
11947
- // gas_limit is signing-relevant, NOT advisory: signEvmServerTx copies a
11948
- // server-supplied gas_limit into the signed ethereumSpecific.gasLimit when it
11949
- // exceeds the SDK estimate, so a gas_limit divergence means different signed
11950
- // bytes/fee. Parity must surface it.
11951
- "gasLimit"
11952
- ];
11953
- function diffToolOutputParity(enriched, txReady) {
11954
- const ce = canonicalizeForParity(enriched);
11955
- const ct = canonicalizeForParity(txReady);
11956
- const divergences = [];
11957
- if (!ce || !ct) {
11958
- return {
11959
- match: false,
11960
- divergences: [`uncomparable payload (enriched=${ce ? "ok" : "null"}, tx_ready=${ct ? "ok" : "null"})`],
11961
- txReadyExclusive: ct?.exclusive ?? []
11962
- };
11963
- }
11964
- if (ce.legs.length !== ct.legs.length) {
11965
- divergences.push(`leg count: tool-output ${ce.legs.length} vs tx_ready ${ct.legs.length}`);
11966
- }
11967
- const n = Math.min(ce.legs.length, ct.legs.length);
11968
- for (let i = 0; i < n; i++) {
11969
- for (const f of HARD_FIELDS) {
11970
- const a = ce.legs[i][f];
11971
- const b = ct.legs[i][f];
11972
- if (a !== b && !(a === void 0 && b === void 0)) {
11973
- divergences.push(`leg[${i}].${f}: tool-output ${a ?? "\u2205"} vs tx_ready ${b ?? "\u2205"}`);
11974
- }
11975
- }
11976
- }
11977
- const txReadyExclusive = ct.exclusive.filter((k) => !ce.exclusive.includes(k));
11978
- return { match: divergences.length === 0, divergences, txReadyExclusive };
11979
- }
11980
11969
  function payloadLooksSignable(payload) {
11981
11970
  const env = asRecord(payload);
11982
11971
  if (!env) return false;
@@ -12266,7 +12255,6 @@ var AgentClient = class {
12266
12255
  const result = {
12267
12256
  fullText: "",
12268
12257
  suggestions: [],
12269
- transactions: [],
12270
12258
  message: null,
12271
12259
  finished: false,
12272
12260
  disconnected: false,
@@ -12277,7 +12265,6 @@ var AgentClient = class {
12277
12265
  serverNow: res.headers.get("X-Server-Now")
12278
12266
  };
12279
12267
  const toolNameByCallId = /* @__PURE__ */ new Map();
12280
- const parity = { lastSignableToolCallId: null };
12281
12268
  const reader = res.body.getReader();
12282
12269
  const decoder = new TextDecoder();
12283
12270
  let buffer = "";
@@ -12292,7 +12279,7 @@ var AgentClient = class {
12292
12279
  currentData += (currentData ? "\n" : "") + stripLeadingSpace(line.slice(5));
12293
12280
  } else if (line === "") {
12294
12281
  if (currentData) {
12295
- this.handleSSEEvent(currentEvent || "message", currentData, result, callbacks, toolNameByCallId, parity);
12282
+ this.handleSSEEvent(currentEvent || "message", currentData, result, callbacks, toolNameByCallId);
12296
12283
  }
12297
12284
  currentEvent = "";
12298
12285
  currentData = "";
@@ -12312,7 +12299,7 @@ var AgentClient = class {
12312
12299
  if (done) {
12313
12300
  if (trailing) processLine(trailing);
12314
12301
  if (currentData) {
12315
- this.handleSSEEvent(currentEvent || "message", currentData, result, callbacks, toolNameByCallId, parity);
12302
+ this.handleSSEEvent(currentEvent || "message", currentData, result, callbacks, toolNameByCallId);
12316
12303
  }
12317
12304
  break;
12318
12305
  }
@@ -12329,7 +12316,7 @@ var AgentClient = class {
12329
12316
  }
12330
12317
  return result;
12331
12318
  }
12332
- handleSSEEvent(event, data, result, callbacks, toolNameByCallId, parity) {
12319
+ handleSSEEvent(event, data, result, callbacks, toolNameByCallId) {
12333
12320
  try {
12334
12321
  const parsed = JSON.parse(data);
12335
12322
  const v1Type = getV1Type(parsed);
@@ -12340,7 +12327,7 @@ var AgentClient = class {
12340
12327
  this.handleTextDelta(parsed, result, callbacks);
12341
12328
  break;
12342
12329
  case "tool_progress":
12343
- this.handleToolProgress(parsed, data, callbacks, toolNameByCallId, v1Type, parity);
12330
+ this.handleToolProgress(parsed, data, callbacks, toolNameByCallId, v1Type);
12344
12331
  break;
12345
12332
  case "title": {
12346
12333
  const title = v1Data?.title ?? parsed.title;
@@ -12353,15 +12340,6 @@ var AgentClient = class {
12353
12340
  callbacks.onSuggestions?.(suggestions);
12354
12341
  break;
12355
12342
  }
12356
- case "tx_ready":
12357
- if (this.verbose) process.stderr.write(`[SSE:tx_ready] raw: ${data.slice(0, 2e3)}
12358
- `);
12359
- {
12360
- const txReady = v1Data ?? parsed;
12361
- result.transactions.push(txReady);
12362
- callbacks.onTxReady?.(txReady, parity.lastSignableToolCallId ?? void 0);
12363
- }
12364
- break;
12365
12343
  case "balance_summary": {
12366
12344
  const card = v1Data ?? parsed.data ?? parsed;
12367
12345
  callbacks.onBalanceSummary?.(card);
@@ -12400,7 +12378,7 @@ var AgentClient = class {
12400
12378
  result.fullText += parsed.delta;
12401
12379
  callbacks.onTextDelta?.(parsed.delta);
12402
12380
  }
12403
- handleToolProgress(parsed, data, callbacks, toolNameByCallId, v1Type, parity) {
12381
+ handleToolProgress(parsed, data, callbacks, toolNameByCallId, v1Type) {
12404
12382
  if (this.verbose) process.stderr.write(`[SSE:tool_progress] raw: ${data.slice(0, 1e3)}
12405
12383
  `);
12406
12384
  const status = parsed.status ?? v1StatusFromType(v1Type);
@@ -12413,32 +12391,27 @@ var AgentClient = class {
12413
12391
  this.maybeEmitClientSideToolCall(parsed, callbacks, v1Type, callId, toolName);
12414
12392
  const ok = deriveToolDoneOk(status, parsed.output);
12415
12393
  if (status && toolName) callbacks.onToolProgress?.(toolName, status, label, ok);
12416
- this.maybeSignToolOutput(status, toolName, parsed.output, callbacks, callId, parity);
12394
+ this.maybeSignToolOutput(status, toolName, parsed.output, callbacks);
12417
12395
  if (status === "done" && callId) toolNameByCallId.delete(callId);
12418
12396
  }
12419
12397
  /**
12420
- * Phase-1 dual-read: derive a client-side signable candidate from a signable
12421
- * tool's raw `tool-output-available` output — the same envelope mobile reads —
12422
- * and hand it to the session via `onToolOutputTx` (SEPARATE from the backend
12423
- * `tx_ready` channel). The session decides how to use it:
12424
- * - flat off-chain tools with NO `tx_ready` (polymarket) the sign source
12425
- * (unchanged from #922);
12426
- * - `produces_calldata` tools that also emit `tx_ready` (`execute_*`,
12427
- * `erc20_approve`, `build_custom_*`) → the PARITY reference; `tx_ready`
12428
- * stays authoritative when signable.
12429
- * The bridge guards against non-tx results (`no_op` / `insufficient_*` /
12430
- * errors) so those never reach the signer. Zero backend change.
12398
+ * #927 Phase 2: derive a client-side signable candidate from a signable tool's
12399
+ * raw `tool-output-available` output — the same envelope mobile reads — and
12400
+ * hand it to the session via `onToolOutputTx` as the SOLE signing source
12401
+ * (production emits the payload here; `data-tx_ready` is a hollow marker). The
12402
+ * bridge guards against non-tx results (`no_op` / `insufficient_*` / errors)
12403
+ * and phantom-card prep envelopes so those never reach the signer. Zero backend
12404
+ * change.
12431
12405
  */
12432
- maybeSignToolOutput(status, toolName, output, callbacks, callId, parity) {
12406
+ maybeSignToolOutput(status, toolName, output, callbacks) {
12433
12407
  if (status !== "done" || !toolName || !callbacks.onToolOutputTx) return;
12434
- if (!CLI_SIGNABLE_FLAT_TOOLS.has(toolName) && !CLI_PARITY_PREP_TOOLS.has(toolName)) return;
12408
+ if (!CLI_SIGNABLE_FLAT_TOOLS.has(toolName) && !CLI_SIGNABLE_PREP_TOOLS.has(toolName)) return;
12435
12409
  const candidate = deriveToolOutputCandidate(toolName, output);
12436
12410
  if (!candidate) return;
12437
- if (callId) parity.lastSignableToolCallId = callId;
12438
12411
  if (this.verbose)
12439
12412
  process.stderr.write(`[SSE:tool_output] ${toolName} \u2192 onToolOutputTx (${candidate.source} candidate)
12440
12413
  `);
12441
- callbacks.onToolOutputTx(candidate.payload, toolName, candidate.source, callId ?? void 0);
12414
+ callbacks.onToolOutputTx(candidate.payload, toolName, candidate.source);
12442
12415
  }
12443
12416
  maybeEmitClientSideToolCall(parsed, callbacks, v1Type, callId, toolName) {
12444
12417
  if (v1Type !== "tool-input-available" || !callId || !toolName || !this.clientSideToolNames.has(toolName) || !callbacks.onClientSideToolCall) {
@@ -12455,7 +12428,9 @@ var AgentClient = class {
12455
12428
  // Maps a V1 `type` field to the legacy event bucket used by handleSSEEvent's
12456
12429
  // switch. Frame-level types (start, text-start, text-end, finish-step) and
12457
12430
  // non-critical telemetry (data-tokens, data-usage, data-confirmation) route
12458
- // to 'ignore' which is a no-op.
12431
+ // to 'ignore' which is a no-op. `data-tx_ready` also routes to 'ignore':
12432
+ // #927 Phase 2 signs purely from `tool-output-available`, and production emits
12433
+ // `data-tx_ready` only as a hollow `{typed_confirm}` marker the CLI doesn't use.
12459
12434
  mapV1EventType(type) {
12460
12435
  switch (type) {
12461
12436
  case "text-delta":
@@ -12469,8 +12444,6 @@ var AgentClient = class {
12469
12444
  return "title";
12470
12445
  case "data-suggestions":
12471
12446
  return "suggestions";
12472
- case "data-tx_ready":
12473
- return "tx_ready";
12474
12447
  case "data-balance_summary":
12475
12448
  return "balance_summary";
12476
12449
  case "data-turn_outcome":
@@ -13266,8 +13239,6 @@ var AgentSession = class {
13266
13239
  `);
13267
13240
  }
13268
13241
  }
13269
- let txReadyCandidate = null;
13270
- let txReadyTwinCallId = null;
13271
13242
  let toolOutputCandidate = null;
13272
13243
  let balanceCardRendered = false;
13273
13244
  const pendingDispatches = [];
@@ -13293,20 +13264,12 @@ var AgentSession = class {
13293
13264
  onSuggestions: (suggestions) => {
13294
13265
  ui.onSuggestions(suggestions);
13295
13266
  },
13296
- onTxReady: (tx, toolCallId) => {
13297
- if (txReadyCandidate !== null) {
13298
- this.reportDeferredSignable(ui);
13299
- return;
13300
- }
13301
- txReadyCandidate = tx;
13302
- txReadyTwinCallId = toolCallId ?? null;
13303
- },
13304
- onToolOutputTx: (payload, toolName, source, toolCallId) => {
13267
+ onToolOutputTx: (payload, toolName, source) => {
13305
13268
  if (toolOutputCandidate !== null) {
13306
13269
  this.reportDeferredSignable(ui);
13307
13270
  return;
13308
13271
  }
13309
- toolOutputCandidate = { payload, toolName, source, callId: toolCallId ?? null };
13272
+ toolOutputCandidate = { payload, toolName, source };
13310
13273
  },
13311
13274
  onBalanceSummary: (raw) => {
13312
13275
  const card = parseBalanceSummaryEnvelope(raw);
@@ -13340,7 +13303,7 @@ var AgentSession = class {
13340
13303
  }
13341
13304
  if (streamResult.disconnected && !streamResult.message) {
13342
13305
  ui.onReconnecting?.();
13343
- await this.recoverDisconnectedTurn(streamResult, callbacks.onTxReady, callbacks.onBalanceSummary);
13306
+ await this.recoverDisconnectedTurn(streamResult, callbacks.onBalanceSummary);
13344
13307
  }
13345
13308
  const responseText = streamResult.message?.content || streamResult.fullText || "";
13346
13309
  let displayText = stripLeakedToolCallTags(responseText);
@@ -13350,7 +13313,7 @@ var AgentSession = class {
13350
13313
  if (displayText) {
13351
13314
  ui.onAssistantMessage(displayText);
13352
13315
  }
13353
- const bufferedSignable = this.selectAndBufferSignable(txReadyCandidate, txReadyTwinCallId, toolOutputCandidate, ui);
13316
+ const bufferedSignable = this.selectAndBufferSignable(toolOutputCandidate);
13354
13317
  if (bufferedSignable) {
13355
13318
  if (this.config.verbose) process.stderr.write(`[session] buffered a signable server tx, signing client-side
13356
13319
  `);
@@ -13400,102 +13363,42 @@ var AgentSession = class {
13400
13363
  );
13401
13364
  }
13402
13365
  /**
13403
- * Phase-1 dual-read reconciliation. Given the two per-turn candidates, run the
13404
- * parity cross-check (when both are the SAME tool call) and buffer the SAFE
13405
- * sign source into the executor. Returns true when something was buffered.
13366
+ * #927 Phase 2 sign gate. Buffer the client-enriched tool-output candidate
13367
+ * the SOLE signing source into the executor. Returns true when something was
13368
+ * buffered (signed downstream), false otherwise.
13406
13369
  *
13407
- * Selection order (fund-safety):
13408
- * 1. a SIGNABLE `tx_ready` AUTHORITATIVE, unchanged from today's behavior;
13409
- * 2. the client tool-output candidate covers flat off-chain tools with no
13410
- * `tx_ready` (polymarket) and flat tools whose `tx_ready` is structurally
13411
- * unsignable (`build_custom_*`, divergent `to_address`/`calldata`);
13412
- * 3. an unsignable `tx_ready` as last resort — preserves today's behavior (it
13413
- * surfaces its own error at sign time) rather than silently dropping it.
13414
- * `payloadLooksSignable` mirrors the executor's real requirements so a
13415
- * structurally-present-but-unsignable `tx_ready` (which `storeServerTransaction`
13416
- * would still buffer, only to throw at sign time) doesn't pre-empt a good
13417
- * tool-output candidate.
13418
- *
13419
- * FAIL-CLOSED on divergence (review — gomesalexandre): a flat tool-output is a
13420
- * sign source ONLY when (a) there is NO `tx_ready` at all, or (b) the same-turn
13421
- * `tx_ready` MATCHED parity. Whenever a `tx_ready` is present and the flat did
13422
- * not prove equal to it, we do NOT sign the client-enriched candidate — we fall
13423
- * closed to the `tx_ready` path (which, when unsignable, errors at sign time).
13424
- *
13425
- * This sign gate is DELIBERATELY INDEPENDENT of the pairing heuristic below:
13426
- * whether the two same-turn candidates are "the same tool call" affects only
13427
- * divergence TELEMETRY, never the sign decision. So even if the pairing were
13428
- * wrong (e.g. the backend batched two tool-output frames before a `tx_ready`
13429
- * against the documented per-pending order — agent-backend agent.go:6397), a
13430
- * diverging flat still cannot be signed: it is gated by `parityMatched`, not by
13431
- * pairing. The fund-safety guarantee therefore rests on parity equality alone,
13432
- * not on a cross-repo wire-ordering invariant (security + Codex convergence).
13433
- *
13434
- * Pairing (review — gomesalexandre) — TELEMETRY ONLY: the two channels are the
13435
- * SAME tool call iff their tool-call ids match (`txReadyTwinCallId` is the id of
13436
- * the tool-output frame this `tx_ready` was emitted next to — client.ts). A
13437
- * DEFINITELY-unpaired pair (both ids present AND different) skips the parity
13438
- * diff so an unrelated same-turn pair does not emit a false `[DIVERGENCE]`; a
13439
- * missing id falls back to running the diff so a real divergence is never
13440
- * suppressed. This gate never enqueues a sign source — it only silences noise.
13370
+ * FAIL-CLOSED, two structural gates:
13371
+ * 1. `deriveToolOutputCandidate` (client.ts) already rejected non-tx envelopes
13372
+ * (`no_op` / `insufficient_*` / errors) and phantom-card prep envelopes
13373
+ * (no `tx_encoding`) so a candidate that reaches here is well-formed.
13374
+ * 2. `payloadLooksSignable` mirrors the executor's real signer requirements
13375
+ * (`extractNestedTx().to` for EVM; `txArgs.{to,amount}` for non-EVM; both
13376
+ * legs for multi-leg). A structurally-present-but-unsignable candidate is
13377
+ * NOT routed to the signer the mutation-check target for this gate.
13378
+ * `storeServerTransaction` is the final backstop (returns false if it can't
13379
+ * resolve a tx body / cross-chain multi-leg), so a false is never a wrong sign.
13441
13380
  */
13442
- selectAndBufferSignable(txReadyCandidate, txReadyTwinCallId, toolOutputCandidate, ui) {
13443
- const toolOutputCallId = toolOutputCandidate?.callId ?? null;
13444
- const definitelyUnpaired = txReadyCandidate !== null && toolOutputCandidate !== null && toolOutputCallId !== null && txReadyTwinCallId !== null && toolOutputCallId !== txReadyTwinCallId;
13445
- let parityMatched = false;
13446
- if (txReadyCandidate && toolOutputCandidate && !definitelyUnpaired) {
13447
- parityMatched = this.logToolOutputParity(toolOutputCandidate, txReadyCandidate);
13448
- }
13449
- const ordered = [];
13450
- if (txReadyCandidate && payloadLooksSignable(txReadyCandidate)) {
13451
- ordered.push({ surface: "tx_ready", payload: txReadyCandidate });
13452
- }
13453
- const flatIsSignSource = toolOutputCandidate?.source === "flat" && (txReadyCandidate === null || parityMatched);
13454
- if (toolOutputCandidate && flatIsSignSource) {
13455
- ordered.push({ surface: "tool_output", payload: toolOutputCandidate.payload });
13456
- }
13457
- if (txReadyCandidate && !payloadLooksSignable(txReadyCandidate)) {
13458
- ordered.push({ surface: "tx_ready", payload: txReadyCandidate });
13459
- }
13460
- for (const { surface, payload } of ordered) {
13461
- if (this.executor.storeServerTransaction(payload)) {
13462
- if (this.config.password) this.executor.setPassword(this.config.password);
13463
- if (this.config.verbose) {
13464
- const both = txReadyCandidate && toolOutputCandidate ? "both channels" : "single channel";
13465
- process.stderr.write(`[session] buffered signable from ${surface} (${both})
13466
- `);
13467
- }
13468
- if (surface === "tx_ready" && toolOutputCandidate?.source === "flat" && !parityMatched) {
13469
- this.reportDeferredSignable(ui);
13470
- }
13471
- return true;
13381
+ selectAndBufferSignable(toolOutputCandidate) {
13382
+ if (!toolOutputCandidate) return false;
13383
+ if (!payloadLooksSignable(toolOutputCandidate.payload)) {
13384
+ if (this.config.verbose) {
13385
+ process.stderr.write(
13386
+ `[session] tool-output candidate (${toolOutputCandidate.toolName}, ${toolOutputCandidate.source}) not structurally signable; not buffering
13387
+ `
13388
+ );
13472
13389
  }
13390
+ return false;
13473
13391
  }
13474
- return false;
13475
- }
13476
- /**
13477
- * Compare the client-enriched tool-output candidate to the backend `tx_ready`
13478
- * for the same turn and log the result. On a safety-relevant divergence this
13479
- * logs LOUDLY (always to stderr, not just under --verbose) — that divergence
13480
- * signal is exactly what Phase 1 exists to surface before Phase 2 makes
13481
- * tool-output the sole signing source.
13482
- */
13483
- logToolOutputParity(toolOutput, txReady) {
13484
- const result = diffToolOutputParity(toolOutput.payload, txReady);
13485
- const exclusive = result.txReadyExclusive.length ? ` | tx_ready-exclusive: ${result.txReadyExclusive.join(",")}` : "";
13486
- if (result.match) {
13392
+ if (this.executor.storeServerTransaction(toolOutputCandidate.payload)) {
13393
+ if (this.config.password) this.executor.setPassword(this.config.password);
13487
13394
  if (this.config.verbose) {
13488
13395
  process.stderr.write(
13489
- `[parity] ${toolOutput.toolName} (${toolOutput.source}): tool-output == tx_ready \u2713${exclusive}
13396
+ `[session] buffered signable from tool-output (${toolOutputCandidate.toolName}, ${toolOutputCandidate.source})
13490
13397
  `
13491
13398
  );
13492
13399
  }
13493
13400
  return true;
13494
13401
  }
13495
- process.stderr.write(
13496
- `[parity][DIVERGENCE] ${toolOutput.toolName} (${toolOutput.source}): tool-output != tx_ready \u2014 ${result.divergences.join("; ")}${exclusive}
13497
- `
13498
- );
13499
13402
  return false;
13500
13403
  }
13501
13404
  /**
@@ -13503,15 +13406,14 @@ var AgentSession = class {
13503
13406
  * assistant message. Polls /messages/since (server-clock anchored via
13504
13407
  * X-Server-Now) until the persisted assistant message lands or the bounded
13505
13408
  * budget is exhausted. On success it patches `streamResult.message` so the
13506
- * normal downstream flow surfaces the answer, and replays any persisted
13507
- * `data-tx_ready` part through `onTxReady` so a recovered signable card hits
13508
- * the same confirm/sign gate as a live one.
13409
+ * normal downstream flow surfaces the answer, and replays any persisted balance
13410
+ * card. #927 Phase 2: a signable tx is NEVER recovered — the signable payload
13411
+ * rides tool-output-available, which the persisted parts don't reconstruct so
13412
+ * a recovered turn that ran a signable tool warns to re-run instead of signing.
13509
13413
  */
13510
- async recoverDisconnectedTurn(streamResult, onTxReady, onBalanceSummary) {
13414
+ async recoverDisconnectedTurn(streamResult, onBalanceSummary) {
13511
13415
  if (!this.conversationId) return;
13512
- const serverAnchor = serverNowToIso(streamResult.serverNow);
13513
- const since = serverAnchor ?? new Date(Date.now() - 2e3).toISOString();
13514
- const replaySignableCards = serverAnchor !== null;
13416
+ const since = serverNowToIso(streamResult.serverNow) ?? new Date(Date.now() - 2e3).toISOString();
13515
13417
  let cursor;
13516
13418
  let authRecovered = false;
13517
13419
  for (let attempt = 0; attempt < this.recoveryMaxPolls; attempt++) {
@@ -13532,13 +13434,13 @@ var AgentSession = class {
13532
13434
  continue;
13533
13435
  }
13534
13436
  if (resp.cursor) cursor = resp.cursor;
13535
- const assistant = [...resp.messages].reverse().find((m) => m.role === "assistant" && (!!m.content || hasTxReadyPart(m.parts)));
13437
+ const assistant = [...resp.messages].reverse().find((m) => m.role === "assistant" && (!!m.content || hasSignableToolPart(m.parts)));
13536
13438
  if (assistant) {
13537
13439
  if (this.config.verbose) {
13538
13440
  process.stderr.write(`[session] recovered assistant message after ${attempt + 1} poll(s)
13539
13441
  `);
13540
13442
  }
13541
- this.applyRecoveredMessage(assistant, streamResult, onTxReady, replaySignableCards, onBalanceSummary);
13443
+ this.applyRecoveredMessage(assistant, streamResult, onBalanceSummary);
13542
13444
  return;
13543
13445
  }
13544
13446
  await this.recoverySleep();
@@ -13626,38 +13528,30 @@ var AgentSession = class {
13626
13528
  }
13627
13529
  /**
13628
13530
  * Fold a recovered assistant message back into the live stream result: the
13629
- * authoritative message wins over any partial deltas, and any persisted
13630
- * `data-tx_ready` part is replayed through the live tx_ready callback so the
13631
- * card flows through the same confirm/sign gate.
13632
- *
13633
- * `replaySignableCards` gates the tx_ready replay: it is false when the
13634
- * recovery anchor was the local-clock fallback (no X-Server-Now), where a
13635
- * recovered card cannot be proven to belong to the current turn. See
13636
- * recoverDisconnectedTurn — a stale tx_ready must never reach the signer.
13531
+ * authoritative message wins over any partial deltas, and any persisted balance
13532
+ * card is replayed. #927 Phase 2: a signable transaction is NEVER recovered —
13533
+ * the signable payload rides tool-output-available, which the persisted parts
13534
+ * do not reconstruct — so a recovered turn that ran a signable tool warns the
13535
+ * user to re-run rather than signing (fail-CLOSED, never a wrong sign).
13637
13536
  */
13638
- applyRecoveredMessage(msg, streamResult, onTxReady, replaySignableCards, onBalanceSummary) {
13537
+ applyRecoveredMessage(msg, streamResult, onBalanceSummary) {
13639
13538
  streamResult.message = msg;
13640
- let replayedTxReady = false;
13641
- let signableFlatToolPart;
13539
+ let signableToolPart;
13642
13540
  for (const part of msg.parts ?? []) {
13643
13541
  if (part.type === "data-balance_summary" && part.data) {
13644
13542
  onBalanceSummary?.(part.data);
13645
13543
  continue;
13646
13544
  }
13647
- if (replaySignableCards && part.type === "data-tx_ready" && part.data && typeof part.data === "object") {
13648
- const tx = part.data;
13649
- streamResult.transactions.push(tx);
13650
- onTxReady?.(tx);
13651
- replayedTxReady = true;
13652
- }
13653
13545
  if (typeof part.type === "string" && part.type.startsWith("tool-")) {
13654
13546
  const toolName = part.type.slice("tool-".length);
13655
- if (CLI_SIGNABLE_FLAT_TOOLS.has(toolName)) signableFlatToolPart = toolName;
13547
+ if (CLI_SIGNABLE_FLAT_TOOLS.has(toolName) || CLI_SIGNABLE_PREP_TOOLS.has(toolName)) {
13548
+ signableToolPart = toolName;
13549
+ }
13656
13550
  }
13657
13551
  }
13658
- if (signableFlatToolPart && !replayedTxReady) {
13552
+ if (signableToolPart) {
13659
13553
  process.stderr.write(
13660
- `[session][recovery] recovered turn ran signable tool '${signableFlatToolPart}', whose signable output rides tool-output-available (no data-tx_ready). The recovery path does not reconstruct that candidate, so NO transaction was signed this turn \u2014 re-run the request to sign. (Known Phase-1 limitation.)
13554
+ `[session][recovery] recovered turn ran signable tool '${signableToolPart}', whose signable output rides tool-output-available. The recovery path does not reconstruct that candidate, so NO transaction was signed this turn \u2014 re-run the request to sign.
13661
13555
  `
13662
13556
  );
13663
13557
  }
@@ -13825,8 +13719,12 @@ function serverNowToIso(serverNow) {
13825
13719
  if (!Number.isFinite(ms) || ms <= 0) return null;
13826
13720
  return new Date(ms).toISOString();
13827
13721
  }
13828
- function hasTxReadyPart(parts) {
13829
- return !!parts?.some((p) => p.type === "data-tx_ready" && !!p.data);
13722
+ function hasSignableToolPart(parts) {
13723
+ return !!parts?.some((p) => {
13724
+ if (typeof p.type !== "string" || !p.type.startsWith("tool-")) return false;
13725
+ const toolName = p.type.slice("tool-".length);
13726
+ return CLI_SIGNABLE_FLAT_TOOLS.has(toolName) || CLI_SIGNABLE_PREP_TOOLS.has(toolName);
13727
+ });
13830
13728
  }
13831
13729
  function isAuthError(err) {
13832
13730
  const msg = err instanceof Error ? err.message : String(err ?? "");
@@ -14552,7 +14450,7 @@ var cachedVersion = null;
14552
14450
  function getVersion() {
14553
14451
  if (cachedVersion) return cachedVersion;
14554
14452
  if (true) {
14555
- cachedVersion = "2.18.0";
14453
+ cachedVersion = "2.18.6";
14556
14454
  return cachedVersion;
14557
14455
  }
14558
14456
  try {
@@ -16944,19 +16842,27 @@ program.command("broadcast").description("Broadcast a pre-signed raw transaction
16944
16842
  });
16945
16843
  })
16946
16844
  );
16947
- program.command("tx-status").description("Check the status of a transaction (polls until confirmed)").requiredOption("--chain <chain>", "Target blockchain").requiredOption("--tx-hash <hash>", "Transaction hash to check").option("--no-wait", "Return immediately without waiting for confirmation").addHelpText(
16845
+ program.command("tx-status").description("Check the status of a transaction (polls until confirmed)").requiredOption("--chain <chain>", "Target blockchain").requiredOption("--tx-hash <hash>", "Transaction hash to check").option("--no-wait", "Return immediately without waiting for confirmation").option("--timeout <seconds>", "Max seconds to poll before giving up (default 120)").addHelpText(
16948
16846
  "after",
16949
16847
  `
16950
16848
  Examples:
16951
16849
  vultisig tx-status --chain Ethereum --tx-hash 0xabc...
16850
+ vultisig tx-status --chain Ethereum --tx-hash 0xabc... --timeout 300
16952
16851
  vultisig tx-status --chain Bitcoin --tx-hash abc... --no-wait --output json`
16953
16852
  ).action(
16954
16853
  withExit(async (options) => {
16955
16854
  const context = await init(program.opts().vault);
16855
+ const timeoutSec = options.timeout !== void 0 ? Number(options.timeout) : void 0;
16856
+ if (timeoutSec !== void 0 && (!Number.isFinite(timeoutSec) || timeoutSec < 0)) {
16857
+ throw new InvalidInputError(
16858
+ `Invalid --timeout: "${options.timeout}" (expected a non-negative number of seconds)`
16859
+ );
16860
+ }
16956
16861
  await executeTxStatus(context, {
16957
16862
  chain: findChainByName(options.chain) || options.chain,
16958
16863
  txHash: options.txHash,
16959
- noWait: !options.wait
16864
+ noWait: !options.wait,
16865
+ timeoutSec
16960
16866
  });
16961
16867
  })
16962
16868
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vultisig/cli",
3
- "version": "2.18.0",
3
+ "version": "2.18.6",
4
4
  "description": "The self-custody MPC wallet CLI for AI coding agents (Claude Code, Cursor, OpenCode). Natural-language agent mode, 36+ chains, DKLS23 threshold signatures. Seedless.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -74,9 +74,9 @@
74
74
  "@napi-rs/keyring": "^1.3.0",
75
75
  "@noble/hashes": "^2.2.0",
76
76
  "@vultisig/client-shared": "^0.2.17",
77
- "@vultisig/core-chain": "^2.23.1",
77
+ "@vultisig/core-chain": "^2.23.3",
78
78
  "@vultisig/rujira": "^51.0.0",
79
- "@vultisig/sdk": "^2.18.0",
79
+ "@vultisig/sdk": "^2.18.6",
80
80
  "chalk": "^5.6.2",
81
81
  "cli-table3": "^0.6.5",
82
82
  "commander": "^15.0.0",