@quaivault/sdk 0.1.1 → 0.2.1

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/dist/index.cjs CHANGED
@@ -30,8 +30,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
+ AbortError: () => AbortError,
33
34
  ConfigError: () => ConfigError,
34
35
  Connection: () => Connection,
36
+ DEFAULT_CONCURRENCY: () => DEFAULT_CONCURRENCY,
37
+ DEFAULT_TEXT_LIMIT: () => DEFAULT_TEXT_LIMIT,
35
38
  ENV_VARS: () => ENV_VARS,
36
39
  Erc1155Abi: () => Erc1155Abi,
37
40
  Erc20Abi: () => Erc20Abi,
@@ -64,9 +67,11 @@ __export(src_exports, {
64
67
  SaltMiningError: () => SaltMiningError,
65
68
  SocialRecoveryModuleAbi: () => SocialRecoveryModuleAbi,
66
69
  StaleProposalError: () => StaleProposalError,
70
+ TokenContract: () => TokenContract,
67
71
  ValidationError: () => ValidationError,
68
72
  Vault: () => Vault,
69
73
  VaultContract: () => VaultContract,
74
+ ZERO_ADDRESS: () => ZERO_ADDRESS,
70
75
  allowedActions: () => allowedActions,
71
76
  assertQuaiAddress: () => assertQuaiAddress,
72
77
  assertQuaiAddresses: () => assertQuaiAddresses,
@@ -75,6 +80,7 @@ __export(src_exports, {
75
80
  computeBytecodeHash: () => computeBytecodeHash,
76
81
  computeFullSalt: () => computeFullSalt,
77
82
  connect: () => connect,
83
+ createWorkerThreadsStrategy: () => createWorkerThreadsStrategy,
78
84
  decodeCall: () => decodeCall,
79
85
  decodeMultiSendPayload: () => decodeMultiSendPayload,
80
86
  decodeRevert: () => decodeRevert,
@@ -97,6 +103,7 @@ __export(src_exports, {
97
103
  knownErrorSelectors: () => knownErrorSelectors,
98
104
  loadBalances: () => loadBalances,
99
105
  mainnet: () => mainnet,
106
+ mapPooled: () => mapPooled,
100
107
  mineSalt: () => mineSalt,
101
108
  minimumExpiration: () => minimumExpiration,
102
109
  networks: () => networks,
@@ -105,6 +112,7 @@ __export(src_exports, {
105
112
  recoveryCall: () => recoveryCall,
106
113
  remediationFor: () => remediationFor,
107
114
  resolveConfig: () => resolveConfig,
115
+ sanitizeText: () => sanitizeText,
108
116
  selfCall: () => selfCall,
109
117
  shardPrefixOf: () => shardPrefixOf,
110
118
  syncStrategy: () => syncStrategy,
@@ -3464,6 +3472,11 @@ var QuaiVaultError = class extends Error {
3464
3472
  };
3465
3473
  }
3466
3474
  };
3475
+ var AbortError = class extends QuaiVaultError {
3476
+ constructor(operation = "The operation") {
3477
+ super("ABORTED", `${operation} was aborted.`);
3478
+ }
3479
+ };
3467
3480
  var ConfigError = class extends QuaiVaultError {
3468
3481
  constructor(message, remediation) {
3469
3482
  super("CONFIG", message, { remediation });
@@ -3582,6 +3595,16 @@ var Connection = class {
3582
3595
  write ? this.requireSigner("This recovery write") : this.provider
3583
3596
  );
3584
3597
  }
3598
+ /**
3599
+ * Read-only handle to a token contract.
3600
+ *
3601
+ * No write variant: the SDK never calls a token directly. Moving tokens goes
3602
+ * through the vault's proposal flow, which encodes the transfer as calldata.
3603
+ */
3604
+ token(address2, standard) {
3605
+ const abi = standard === "ERC20" ? Erc20Abi : standard === "ERC721" ? Erc721Abi : Erc1155Abi;
3606
+ return new import_quais.Contract(address2, abi, this.provider);
3607
+ }
3585
3608
  };
3586
3609
  function createPrivateKeySigner(privateKey, provider) {
3587
3610
  const normalized = privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`;
@@ -3624,6 +3647,36 @@ function normalizeTxHash(value, label = "transaction hash") {
3624
3647
  // src/config/resolve.ts
3625
3648
  var import_quais2 = require("quais");
3626
3649
 
3650
+ // src/lifecycle/status.ts
3651
+ function nowSeconds() {
3652
+ return Math.floor(Date.now() / 1e3);
3653
+ }
3654
+ function executableAfterOf(approvedAt, executionDelay) {
3655
+ return approvedAt > 0 ? approvedAt + executionDelay : 0;
3656
+ }
3657
+ function deriveStatus(state, at = nowSeconds()) {
3658
+ if (state.failed) return "failed";
3659
+ if (state.executed) return "executed";
3660
+ if (state.cancelled) return state.isExpired ? "expired" : "cancelled";
3661
+ if (state.expiration > 0 && at > state.expiration) return "expired";
3662
+ if (state.approvalCount < state.threshold) return "pending";
3663
+ const executableAfter = executableAfterOf(state.approvedAt, state.executionDelay);
3664
+ if (state.executionDelay > 0 && (state.approvedAt === 0 || at < executableAfter)) {
3665
+ return "timelocked";
3666
+ }
3667
+ return "ready";
3668
+ }
3669
+ function isTerminal(status) {
3670
+ return status === "executed" || status === "failed" || status === "cancelled" || status === "expired";
3671
+ }
3672
+ function deriveRecoveryStatus(state, at = nowSeconds()) {
3673
+ if (state.executed) return "executed";
3674
+ if (state.expiration > 0 && at > state.expiration) return "expired";
3675
+ if (state.approvalCount < state.requiredThreshold) return "pending";
3676
+ if (at < state.executionTime) return "timelocked";
3677
+ return "ready";
3678
+ }
3679
+
3627
3680
  // src/config/networks.ts
3628
3681
  var INDEXER_URL = "https://xbftgyuxaxagptudledv.supabase.co";
3629
3682
  var INDEXER_PUBLISHABLE_KEY = "sb_publishable_EO-sTB-jqUzlsiugOEks-Q_qRtHDaHU";
@@ -3818,9 +3871,14 @@ function resolveConfig(options = {}) {
3818
3871
  contracts,
3819
3872
  indexer,
3820
3873
  rpcUrl,
3821
- privateKey: pick(options.privateKey, env[ENV_VARS.privateKey]),
3874
+ // An explicit signer wins outright in `Connection`, so resolving a key here would
3875
+ // only pull a secret into memory that nothing can use. Skip it.
3876
+ privateKey: options.signer ? void 0 : pick(options.privateKey, env[ENV_VARS.privateKey]),
3822
3877
  consistency,
3823
3878
  maxIndexerLagBlocks: options.maxIndexerLagBlocks ?? parseLag(env[ENV_VARS.maxIndexerLagBlocks]) ?? 50,
3879
+ // Deliberately not environment-configurable: a clock is a function, and a numeric
3880
+ // offset in an env var would reintroduce the sign convention `Clock` exists to avoid.
3881
+ now: options.now ?? nowSeconds,
3824
3882
  retry: {
3825
3883
  ...options.retry?.maxAttempts != null ? { maxAttempts: options.retry.maxAttempts } : {},
3826
3884
  ...options.retry?.baseDelayMs != null ? { baseDelayMs: options.retry.baseDelayMs } : {},
@@ -3948,7 +4006,7 @@ function isTransient(error) {
3948
4006
  }
3949
4007
  var sleep = (ms, signal) => new Promise((resolve, reject) => {
3950
4008
  if (signal?.aborted) {
3951
- reject(new Error("Aborted"));
4009
+ reject(new AbortError("Waiting to retry"));
3952
4010
  return;
3953
4011
  }
3954
4012
  const timer = setTimeout(() => {
@@ -3957,7 +4015,7 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
3957
4015
  }, ms);
3958
4016
  const onAbort = () => {
3959
4017
  clearTimeout(timer);
3960
- reject(new Error("Aborted"));
4018
+ reject(new AbortError("Waiting to retry"));
3961
4019
  };
3962
4020
  signal?.addEventListener("abort", onAbort, { once: true });
3963
4021
  });
@@ -3967,7 +4025,7 @@ async function withRetry(fn, options = {}) {
3967
4025
  const maxDelayMs = options.maxDelayMs ?? DEFAULTS.maxDelayMs;
3968
4026
  let lastError;
3969
4027
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3970
- if (options.signal?.aborted) throw new Error("Aborted");
4028
+ if (options.signal?.aborted) throw new AbortError("The retried operation");
3971
4029
  try {
3972
4030
  return await fn();
3973
4031
  } catch (error) {
@@ -4162,8 +4220,39 @@ var FactoryContract = class {
4162
4220
 
4163
4221
  // src/errors/decode.ts
4164
4222
  var import_quais4 = require("quais");
4223
+
4224
+ // src/text.ts
4225
+ var UNSAFE_CHARS = new RegExp(
4226
+ [
4227
+ "[\\u0000-\\u001F",
4228
+ // C0 controls, incl. ESC (0x1B) — the head of every ANSI sequence
4229
+ "\\u007F-\\u009F",
4230
+ // DEL and the C1 controls
4231
+ "\\u200B-\\u200F",
4232
+ // zero-width space/joiners, LRM, RLM
4233
+ "\\u202A-\\u202E",
4234
+ // bidi embeddings and overrides
4235
+ "\\u2060-\\u2064",
4236
+ // word joiner and the invisible operators
4237
+ "\\u2066-\\u2069",
4238
+ // bidi isolates
4239
+ "\\uFEFF]"
4240
+ // zero-width no-break space / BOM
4241
+ ].join(""),
4242
+ "g"
4243
+ );
4244
+ var DEFAULT_TEXT_LIMIT = 128;
4245
+ function sanitizeText(value, maxLength = DEFAULT_TEXT_LIMIT) {
4246
+ if (typeof value !== "string") return "";
4247
+ const cleaned = value.replace(UNSAFE_CHARS, "").trim();
4248
+ if (cleaned.length <= maxLength) return cleaned;
4249
+ return `${cleaned.slice(0, Math.max(0, maxLength - 1))}\u2026`;
4250
+ }
4251
+
4252
+ // src/errors/decode.ts
4165
4253
  var SOLIDITY_ERROR_SELECTOR = "0x08c379a0";
4166
4254
  var SOLIDITY_PANIC_SELECTOR = "0x4e487b71";
4255
+ var REVERT_TEXT_LIMIT = 256;
4167
4256
  var registry = null;
4168
4257
  function buildRegistry() {
4169
4258
  const map = /* @__PURE__ */ new Map();
@@ -4253,7 +4342,7 @@ function formatArgs(fragment, args) {
4253
4342
  if (args.length === 0) return "";
4254
4343
  const parts = fragment.inputs.map((input, i) => {
4255
4344
  const value = args[i];
4256
- const rendered = typeof value === "bigint" ? value.toString() : String(value);
4345
+ const rendered = typeof value === "bigint" ? value.toString() : typeof value === "string" ? sanitizeText(value, REVERT_TEXT_LIMIT) : String(value);
4257
4346
  return input.name ? `${input.name}: ${rendered}` : rendered;
4258
4347
  });
4259
4348
  return `(${parts.join(", ")})`;
@@ -4269,7 +4358,7 @@ function decodeRevert(data) {
4269
4358
  name: "Error",
4270
4359
  args: [reason],
4271
4360
  selector,
4272
- message: String(reason)
4361
+ message: sanitizeText(reason, REVERT_TEXT_LIMIT)
4273
4362
  };
4274
4363
  } catch {
4275
4364
  return void 0;
@@ -4357,6 +4446,7 @@ var MAX_EXECUTION_DELAY = 30 * 24 * 60 * 60;
4357
4446
  var MAX_OWNERS = 20;
4358
4447
  var MAX_MODULES = 50;
4359
4448
  var SENTINEL_MODULES = "0x0000000000000000000000000000000000000001";
4449
+ var ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
4360
4450
  var selfCall = {
4361
4451
  addOwner: (owner) => vault.encodeFunctionData("addOwner", [owner]),
4362
4452
  removeOwner: (owner) => vault.encodeFunctionData("removeOwner", [owner]),
@@ -4463,7 +4553,7 @@ function encodeMultiSendPayload(calls) {
4463
4553
  function encodeMultiSend(calls) {
4464
4554
  return multiSend.encodeFunctionData("multiSend", [encodeMultiSendPayload(calls)]);
4465
4555
  }
4466
- function minimumExpiration(effectiveDelaySeconds, marginSeconds = 300, at = Math.floor(Date.now() / 1e3)) {
4556
+ function minimumExpiration(effectiveDelaySeconds, marginSeconds = 300, at = nowSeconds()) {
4467
4557
  return at + effectiveDelaySeconds + marginSeconds;
4468
4558
  }
4469
4559
 
@@ -4524,7 +4614,7 @@ var syncStrategy = {
4524
4614
  async mine({ factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal }) {
4525
4615
  const startedAt = Date.now();
4526
4616
  for (let attempts = 0; attempts < maxAttempts; ) {
4527
- if (signal?.aborted) throw new SaltMiningError("Salt mining was aborted.");
4617
+ if (signal?.aborted) throw new AbortError("Salt mining");
4528
4618
  if (Date.now() - startedAt > timeoutMs) {
4529
4619
  throw new SaltMiningError(
4530
4620
  `Salt mining timed out after ${timeoutMs} ms (${attempts} attempts).`,
@@ -4552,109 +4642,127 @@ var syncStrategy = {
4552
4642
  );
4553
4643
  }
4554
4644
  };
4555
- var workerThreadsStrategy = {
4556
- name: "worker_threads",
4557
- async mine(job) {
4558
- let Worker;
4559
- let availableParallelism;
4560
- try {
4561
- ({ Worker } = await import("worker_threads"));
4562
- const os = await import("os");
4563
- availableParallelism = os.availableParallelism ?? (() => os.cpus().length);
4564
- } catch {
4565
- return syncStrategy.mine(job);
4645
+ var WorkersUnavailable = class extends Error {
4646
+ };
4647
+ async function loadNodeWorkers() {
4648
+ try {
4649
+ const { Worker } = await import("worker_threads");
4650
+ const os = await import("os");
4651
+ return { Worker, parallelism: (os.availableParallelism ?? (() => os.cpus().length))() };
4652
+ } catch {
4653
+ return null;
4654
+ }
4655
+ }
4656
+ function createWorkerThreadsStrategy(load = loadNodeWorkers) {
4657
+ return {
4658
+ name: "worker_threads",
4659
+ async mine(job) {
4660
+ const runtime = await load();
4661
+ if (!runtime) return syncStrategy.mine(job);
4662
+ try {
4663
+ return await mineInWorkers(runtime.Worker, runtime.parallelism, job);
4664
+ } catch (err) {
4665
+ if (err instanceof WorkersUnavailable) return syncStrategy.mine(job);
4666
+ throw err;
4667
+ }
4566
4668
  }
4567
- const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal } = job;
4568
- const workerCount = Math.max(1, Math.min(8, availableParallelism() - 1));
4569
- const perWorker = Math.ceil(maxAttempts / workerCount);
4570
- const startedAt = Date.now();
4571
- const workerSource = `
4572
- const { parentPort, workerData } = require('node:worker_threads');
4573
- const { getCreate2Address, hexlify, isQuaiAddress, keccak256, randomBytes, solidityPacked } = require('quais');
4574
- const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts } = workerData;
4575
- for (let i = 0; i < maxAttempts; i++) {
4576
- const salt = hexlify(randomBytes(32));
4577
- const fullSalt = keccak256(solidityPacked(['address', 'bytes32'], [deployer, salt]));
4578
- const address = getCreate2Address(factory, fullSalt, bytecodeHash);
4579
- if (address.toLowerCase().startsWith(targetPrefix) && isQuaiAddress(address)) {
4580
- parentPort.postMessage({ type: 'result', salt, address, attempts: i + 1 });
4581
- return;
4582
- }
4583
- if ((i + 1) % ${PROGRESS_INTERVAL} === 0) parentPort.postMessage({ type: 'progress', attempts: i + 1 });
4669
+ };
4670
+ }
4671
+ var workerThreadsStrategy = createWorkerThreadsStrategy();
4672
+ function mineInWorkers(Worker, parallelism, job) {
4673
+ const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal } = job;
4674
+ const workerCount = Math.max(1, Math.min(8, parallelism - 1));
4675
+ const perWorker = Math.ceil(maxAttempts / workerCount);
4676
+ const startedAt = Date.now();
4677
+ const workerSource = `
4678
+ const { parentPort, workerData } = require('node:worker_threads');
4679
+ const { getCreate2Address, hexlify, isQuaiAddress, keccak256, randomBytes, solidityPacked } = require('quais');
4680
+ const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts } = workerData;
4681
+ for (let i = 0; i < maxAttempts; i++) {
4682
+ const salt = hexlify(randomBytes(32));
4683
+ const fullSalt = keccak256(solidityPacked(['address', 'bytes32'], [deployer, salt]));
4684
+ const address = getCreate2Address(factory, fullSalt, bytecodeHash);
4685
+ if (address.toLowerCase().startsWith(targetPrefix) && isQuaiAddress(address)) {
4686
+ parentPort.postMessage({ type: 'result', salt, address, attempts: i + 1 });
4687
+ return;
4584
4688
  }
4585
- parentPort.postMessage({ type: 'exhausted', attempts: maxAttempts });
4586
- `;
4587
- return new Promise((resolve, reject) => {
4588
- const workers = [];
4589
- let settled = false;
4590
- let exhausted = 0;
4591
- let totalAttempts = 0;
4592
- const progressByWorker = new Array(workerCount).fill(0);
4593
- const cleanup = () => {
4594
- clearTimeout(timer);
4595
- signal?.removeEventListener("abort", onAbort);
4596
- for (const w of workers) void w.terminate();
4597
- };
4598
- const settle = (fn) => {
4599
- if (settled) return;
4600
- settled = true;
4601
- cleanup();
4602
- fn();
4603
- };
4604
- const timer = setTimeout(
4605
- () => settle(
4606
- () => reject(
4607
- new SaltMiningError(
4608
- `Salt mining timed out after ${timeoutMs} ms (~${totalAttempts} attempts across ${workerCount} workers).`,
4609
- "Raise timeoutMs or maxAttempts."
4610
- )
4689
+ if ((i + 1) % ${PROGRESS_INTERVAL} === 0) parentPort.postMessage({ type: 'progress', attempts: i + 1 });
4690
+ }
4691
+ parentPort.postMessage({ type: 'exhausted', attempts: maxAttempts });
4692
+ `;
4693
+ return new Promise((resolve, reject) => {
4694
+ const workers = [];
4695
+ let settled = false;
4696
+ let exhausted = 0;
4697
+ let totalAttempts = 0;
4698
+ const progressByWorker = new Array(workerCount).fill(0);
4699
+ const cleanup = () => {
4700
+ clearTimeout(timer);
4701
+ signal?.removeEventListener("abort", onAbort);
4702
+ for (const w of workers) void w.terminate();
4703
+ };
4704
+ const settle = (fn) => {
4705
+ if (settled) return;
4706
+ settled = true;
4707
+ cleanup();
4708
+ fn();
4709
+ };
4710
+ const timer = setTimeout(
4711
+ () => settle(
4712
+ () => reject(
4713
+ new SaltMiningError(
4714
+ `Salt mining timed out after ${timeoutMs} ms (~${totalAttempts} attempts across ${workerCount} workers).`,
4715
+ "Raise timeoutMs or maxAttempts."
4611
4716
  )
4612
- ),
4613
- timeoutMs
4614
- );
4615
- const onAbort = () => settle(() => reject(new SaltMiningError("Salt mining was aborted.")));
4616
- signal?.addEventListener("abort", onAbort, { once: true });
4617
- for (let i = 0; i < workerCount; i++) {
4618
- const worker = new Worker(workerSource, {
4717
+ )
4718
+ ),
4719
+ timeoutMs
4720
+ );
4721
+ const onAbort = () => settle(() => reject(new AbortError("Salt mining")));
4722
+ signal?.addEventListener("abort", onAbort, { once: true });
4723
+ for (let i = 0; i < workerCount; i++) {
4724
+ let worker;
4725
+ try {
4726
+ worker = new Worker(workerSource, {
4619
4727
  eval: true,
4620
4728
  workerData: { factory, deployer, bytecodeHash, targetPrefix, maxAttempts: perWorker }
4621
4729
  });
4622
- workers.push(worker);
4623
- worker.on("message", (msg) => {
4624
- if (msg.type === "progress") {
4625
- progressByWorker[i] = msg.attempts ?? 0;
4626
- totalAttempts = progressByWorker.reduce((a, b) => a + b, 0);
4627
- onProgress?.(totalAttempts);
4628
- } else if (msg.type === "result") {
4730
+ } catch (err) {
4731
+ settle(() => reject(new WorkersUnavailable(String(err))));
4732
+ return;
4733
+ }
4734
+ workers.push(worker);
4735
+ worker.on("message", (msg) => {
4736
+ if (msg.type === "progress") {
4737
+ progressByWorker[i] = msg.attempts ?? 0;
4738
+ totalAttempts = progressByWorker.reduce((a, b) => a + b, 0);
4739
+ onProgress?.(totalAttempts);
4740
+ } else if (msg.type === "result") {
4741
+ settle(
4742
+ () => resolve({
4743
+ salt: msg.salt,
4744
+ predictedAddress: msg.address,
4745
+ attempts: totalAttempts + (msg.attempts ?? 0),
4746
+ durationMs: Date.now() - startedAt
4747
+ })
4748
+ );
4749
+ } else if (msg.type === "exhausted") {
4750
+ if (++exhausted === workerCount) {
4629
4751
  settle(
4630
- () => resolve({
4631
- salt: msg.salt,
4632
- predictedAddress: msg.address,
4633
- attempts: totalAttempts + (msg.attempts ?? 0),
4634
- durationMs: Date.now() - startedAt
4635
- })
4636
- );
4637
- } else if (msg.type === "exhausted") {
4638
- if (++exhausted === workerCount) {
4639
- settle(
4640
- () => reject(
4641
- new SaltMiningError(
4642
- `No valid salt found for prefix ${targetPrefix} after ${maxAttempts} attempts.`,
4643
- "Raise maxAttempts, or confirm the deployer address is on the shard you expect."
4644
- )
4752
+ () => reject(
4753
+ new SaltMiningError(
4754
+ `No valid salt found for prefix ${targetPrefix} after ${maxAttempts} attempts.`,
4755
+ "Raise maxAttempts, or confirm the deployer address is on the shard you expect."
4645
4756
  )
4646
- );
4647
- }
4757
+ )
4758
+ );
4648
4759
  }
4649
- });
4650
- worker.on(
4651
- "error",
4652
- (err) => settle(() => reject(new SaltMiningError(`Salt mining worker failed: ${err.message}`)))
4653
- );
4654
- }
4655
- });
4656
- }
4657
- };
4760
+ }
4761
+ });
4762
+ worker.on("error", (err) => settle(() => reject(new WorkersUnavailable(err.message))));
4763
+ }
4764
+ });
4765
+ }
4658
4766
  function defaultStrategy() {
4659
4767
  const hasNode = typeof globalThis.process?.versions?.node === "string";
4660
4768
  return hasNode ? workerThreadsStrategy : syncStrategy;
@@ -4876,7 +4984,7 @@ function validateCreateParams(params) {
4876
4984
  owners.forEach((owner, i) => {
4877
4985
  assertQuaiAddress(owner, `owner[${i}]`);
4878
4986
  const key = owner.toLowerCase();
4879
- if (key === "0x0000000000000000000000000000000000000000" || key === "0x0000000000000000000000000000000000000001") {
4987
+ if (key === ZERO_ADDRESS || key === SENTINEL_MODULES) {
4880
4988
  throw new ValidationError(
4881
4989
  `Owner ${owner} is reserved \u2014 the zero address and the module sentinel 0x\u202601 are rejected.`
4882
4990
  );
@@ -4885,6 +4993,11 @@ function validateCreateParams(params) {
4885
4993
  seen.add(key);
4886
4994
  });
4887
4995
  if (params.initialModules?.length) {
4996
+ if (params.initialModules.length > MAX_MODULES) {
4997
+ throw new ValidationError(
4998
+ `A vault may have at most ${MAX_MODULES} modules (got ${params.initialModules.length}).`
4999
+ );
5000
+ }
4888
5001
  assertQuaiAddresses(params.initialModules, "initialModules");
4889
5002
  }
4890
5003
  if (params.initialDelegatecallTargets?.length) {
@@ -4922,7 +5035,8 @@ function extractCreatedVault(receipt, factoryAddress) {
4922
5035
  }
4923
5036
 
4924
5037
  // src/indexer/client.ts
4925
- var import_supabase_js = require("@supabase/supabase-js");
5038
+ var import_postgrest_js = require("@supabase/postgrest-js");
5039
+ var import_realtime_js = require("@supabase/realtime-js");
4926
5040
 
4927
5041
  // src/indexer/schemas.ts
4928
5042
  var schemas_exports = {};
@@ -5121,30 +5235,63 @@ function toNumber(value, fallback = 0) {
5121
5235
  }
5122
5236
 
5123
5237
  // src/indexer/client.ts
5124
- var SDK_VERSION = "0.1.0";
5238
+ var SDK_VERSION = true ? "0.2.1" : "dev";
5125
5239
  var IndexerClient = class {
5126
5240
  config;
5127
5241
  client;
5242
+ realtime = null;
5128
5243
  healthCache = null;
5129
5244
  inflightHealth = null;
5130
- /** Health results are reused for this long to keep read paths cheap. */
5245
+ /**
5246
+ * Health results are reused for this long to keep read paths cheap.
5247
+ *
5248
+ * This and `waitForBlock`'s deadline are elapsed-time arithmetic, so they use the
5249
+ * raw local clock rather than `ClientOptions.now` — see the note in `chain/retry.ts`.
5250
+ */
5131
5251
  healthCacheMs;
5132
5252
  constructor(config, options = {}) {
5133
5253
  this.config = config;
5134
5254
  this.healthCacheMs = options.healthCacheMs ?? 5e3;
5135
- this.client = (0, import_supabase_js.createClient)(config.url, config.anonKey, {
5136
- db: { schema: config.schema },
5137
- auth: { persistSession: false, autoRefreshToken: false },
5138
- global: { headers: { "x-client-info": `quaivault-sdk/${SDK_VERSION}` } }
5255
+ this.client = new import_postgrest_js.PostgrestClient(`${baseUrl(config.url)}/rest/v1`, {
5256
+ headers: {
5257
+ apikey: config.anonKey,
5258
+ Authorization: `Bearer ${config.anonKey}`,
5259
+ "x-client-info": `quaivault-sdk/${SDK_VERSION}`
5260
+ },
5261
+ schema: config.schema
5139
5262
  });
5140
5263
  }
5141
- /** Raw Supabase client, for queries the SDK does not wrap. */
5142
- get raw() {
5264
+ /** The PostgREST client, for queries the SDK does not wrap. */
5265
+ get rest() {
5143
5266
  return this.client;
5144
5267
  }
5145
5268
  from(table) {
5146
5269
  return this.client.from(table);
5147
5270
  }
5271
+ /**
5272
+ * Realtime client, constructed on first use.
5273
+ *
5274
+ * Lazy because it opens a WebSocket and starts a heartbeat, and the large majority
5275
+ * of SDK usage never calls `watch()`. Nothing here should cost a socket unless
5276
+ * somebody asked to follow a vault.
5277
+ */
5278
+ realtimeClient() {
5279
+ if (!this.realtime) {
5280
+ this.realtime = new import_realtime_js.RealtimeClient(
5281
+ `${baseUrl(this.config.url).replace(/^http/, "ws")}/realtime/v1`,
5282
+ { params: { apikey: this.config.anonKey } }
5283
+ );
5284
+ }
5285
+ return this.realtime;
5286
+ }
5287
+ /** Open a Realtime channel. See `watchVault`, which is what callers should use. */
5288
+ channel(name) {
5289
+ return this.realtimeClient().channel(name);
5290
+ }
5291
+ /** Close a channel opened by {@link channel}. */
5292
+ async removeChannel(channel) {
5293
+ await this.realtimeClient().removeChannel(channel);
5294
+ }
5148
5295
  /**
5149
5296
  * Run a query, parse each row, and surface failures as `IndexerQueryError`.
5150
5297
  *
@@ -5162,6 +5309,36 @@ var IndexerClient = class {
5162
5309
  }
5163
5310
  return (data ?? []).map((row) => schema.parse(row));
5164
5311
  }
5312
+ /**
5313
+ * Paged select: an exact `hasMore`, an approximate `total`.
5314
+ *
5315
+ * Both halves of that are deliberate. An `exact` count makes Postgres scan every
5316
+ * matching row on every page request, which is invisible on a young vault and
5317
+ * quadratic on a busy one — so the count is `estimated`, cheap and good enough for
5318
+ * "roughly how much history is there".
5319
+ *
5320
+ * But `hasMore` derived from an estimate would be a lie a caller can act on: a
5321
+ * paging loop that stops early drops real rows. So callers request one row beyond
5322
+ * the page and this trims it, which answers "is there another page" exactly, for
5323
+ * the cost of a single extra row.
5324
+ */
5325
+ async selectPage(table, schema, limit, build) {
5326
+ const { data, error, count } = await build(this.client.from(table));
5327
+ if (error) {
5328
+ throw new IndexerQueryError(`Indexer query on "${table}" failed: ${error.message}`, error);
5329
+ }
5330
+ const rows = data ?? [];
5331
+ const hasMore = rows.length > limit;
5332
+ const parsed = (hasMore ? rows.slice(0, limit) : rows).map(
5333
+ (row) => schema.parse(row)
5334
+ );
5335
+ return {
5336
+ data: parsed,
5337
+ // Never report a total below what was just handed out, however the estimate lands.
5338
+ total: Math.max(count ?? 0, parsed.length),
5339
+ hasMore
5340
+ };
5341
+ }
5165
5342
  /** Single-row variant. Returns null for PostgREST's "no rows" code. */
5166
5343
  async selectOne(table, schema, build) {
5167
5344
  const { data, error } = await build(this.client.from(table));
@@ -5216,7 +5393,7 @@ var IndexerClient = class {
5216
5393
  const pollIntervalMs = options.pollIntervalMs ?? 1500;
5217
5394
  const deadline = Date.now() + timeoutMs;
5218
5395
  for (; ; ) {
5219
- if (options.signal?.aborted) throw new Error("Aborted");
5396
+ if (options.signal?.aborted) throw new AbortError("Waiting for the indexer");
5220
5397
  const state = await this.state();
5221
5398
  const head = state?.lastIndexedBlock ?? 0;
5222
5399
  if (head >= blockNumber) return { reached: true, lastIndexedBlock: head };
@@ -5228,12 +5405,12 @@ var IndexerClient = class {
5228
5405
  }
5229
5406
  async probeHealth() {
5230
5407
  if (this.config.healthUrl) {
5408
+ const controller = new AbortController();
5409
+ const timer = setTimeout(() => controller.abort(), 5e3);
5231
5410
  try {
5232
- const controller = new AbortController();
5233
- const timer = setTimeout(() => controller.abort(), 5e3);
5234
5411
  const res = await fetch(new URL("/health", this.config.healthUrl), {
5235
5412
  signal: controller.signal
5236
- }).finally(() => clearTimeout(timer));
5413
+ });
5237
5414
  const body = await res.json();
5238
5415
  const d = body.details ?? {};
5239
5416
  return {
@@ -5245,6 +5422,8 @@ var IndexerClient = class {
5245
5422
  ...res.ok ? {} : { error: `health endpoint returned ${res.status}` }
5246
5423
  };
5247
5424
  } catch {
5425
+ } finally {
5426
+ clearTimeout(timer);
5248
5427
  }
5249
5428
  }
5250
5429
  try {
@@ -5267,10 +5446,14 @@ var IndexerClient = class {
5267
5446
  }
5268
5447
  }
5269
5448
  };
5449
+ function baseUrl(url) {
5450
+ return url.replace(/\/+$/, "");
5451
+ }
5270
5452
 
5271
5453
  // src/indexer/queries.ts
5272
5454
  var DEFAULT_LIMIT = 50;
5273
5455
  var MAX_LIMIT = 200;
5456
+ var CONFIRMATION_CHUNK = 40;
5274
5457
  function lower(address2) {
5275
5458
  return address2.toLowerCase();
5276
5459
  }
@@ -5329,14 +5512,40 @@ var IndexerQueries = class {
5329
5512
  (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("tx_hash", txHash.toLowerCase()).single()
5330
5513
  );
5331
5514
  }
5515
+ /**
5516
+ * Several transactions by hash, chunked for the same reasons as
5517
+ * {@link activeConfirmationsBatch} — hashes are long, and they go in the query string.
5518
+ *
5519
+ * Rows come back in no particular order and hashes with no row are simply absent;
5520
+ * the caller matches them up.
5521
+ */
5522
+ async transactionsByHash(vault2, txHashes) {
5523
+ if (txHashes.length === 0) return [];
5524
+ const hashes = txHashes.map((h) => h.toLowerCase());
5525
+ const chunks = [];
5526
+ for (let i = 0; i < hashes.length; i += CONFIRMATION_CHUNK) {
5527
+ chunks.push(hashes.slice(i, i + CONFIRMATION_CHUNK));
5528
+ }
5529
+ const pages = await Promise.all(
5530
+ chunks.map(
5531
+ (chunk) => this.client.select(
5532
+ "transactions",
5533
+ TransactionSchema,
5534
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).in("tx_hash", chunk)
5535
+ )
5536
+ )
5537
+ );
5538
+ return pages.flat();
5539
+ }
5332
5540
  async transactionHistory(vault2, options = {}) {
5333
5541
  const { limit, offset } = bounds(options);
5334
5542
  const statuses = options.status ?? ["executed", "cancelled", "expired", "failed"];
5335
- const { data, error, count } = await this.client.from("transactions").select("*", { count: "exact" }).eq("wallet_address", lower(vault2)).in("status", statuses).order("submitted_at_block", { ascending: false }).range(offset, offset + limit - 1);
5336
- if (error) throw new IndexerQueryError(`Indexer query failed: ${error.message}`, error);
5337
- const rows = (data ?? []).map((r) => TransactionSchema.parse(r));
5338
- const total = count ?? rows.length;
5339
- return { data: rows, total, hasMore: offset + rows.length < total };
5543
+ return this.client.selectPage(
5544
+ "transactions",
5545
+ TransactionSchema,
5546
+ limit,
5547
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).in("status", statuses).order("submitted_at_block", { ascending: false }).range(offset, offset + limit)
5548
+ );
5340
5549
  }
5341
5550
  /** All confirmations for one transaction, including revoked ones. */
5342
5551
  async confirmations(vault2, txHash) {
@@ -5347,26 +5556,41 @@ var IndexerQueries = class {
5347
5556
  );
5348
5557
  }
5349
5558
  /**
5350
- * Active confirmations for many transactions in one round trip.
5559
+ * Active confirmations for many transactions, in as few round trips as is safe.
5351
5560
  *
5352
5561
  * "Active" here means not revoked. It does NOT mean the confirming address is
5353
5562
  * still an owner — the indexer does not deactivate confirmations when an owner is
5354
5563
  * removed, while the contract invalidates them via approval epochs. Callers must
5355
5564
  * intersect with the active owner set; `Vault` does this for you.
5565
+ *
5566
+ * Chunked rather than issued as one `in(...)`, because a single filter fails two
5567
+ * ways at scale. PostgREST reads filters from the query string, and each 32-byte
5568
+ * hash costs ~70 characters there — a full {@link MAX_LIMIT} page of hashes builds a
5569
+ * ~14 KB request line, past the 8 KB cap most reverse proxies apply by default. The
5570
+ * response is bounded too: {@link CONFIRMATION_CHUNK} transactions can carry at most
5571
+ * `chunk × MAX_OWNERS` rows, and the chunk is sized to keep that under the
5572
+ * `max-rows` ceiling PostgREST deployments commonly set, so a busy vault cannot be
5573
+ * silently short-served.
5356
5574
  */
5357
5575
  async activeConfirmationsBatch(vault2, txHashes) {
5358
5576
  const result = /* @__PURE__ */ new Map();
5359
5577
  for (const hash of txHashes) result.set(hash.toLowerCase(), []);
5360
5578
  if (txHashes.length === 0) return result;
5361
- const rows = await this.client.select(
5362
- "confirmations",
5363
- ConfirmationSchema,
5364
- (q) => q.select("*").eq("wallet_address", lower(vault2)).in(
5365
- "tx_hash",
5366
- txHashes.map((h) => h.toLowerCase())
5367
- ).eq("is_active", true)
5579
+ const hashes = txHashes.map((h) => h.toLowerCase());
5580
+ const chunks = [];
5581
+ for (let i = 0; i < hashes.length; i += CONFIRMATION_CHUNK) {
5582
+ chunks.push(hashes.slice(i, i + CONFIRMATION_CHUNK));
5583
+ }
5584
+ const pages = await Promise.all(
5585
+ chunks.map(
5586
+ (chunk) => this.client.select(
5587
+ "confirmations",
5588
+ ConfirmationSchema,
5589
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).in("tx_hash", chunk).eq("is_active", true)
5590
+ )
5591
+ )
5368
5592
  );
5369
- for (const row of rows) {
5593
+ for (const row of pages.flat()) {
5370
5594
  const key = row.tx_hash.toLowerCase();
5371
5595
  const list = result.get(key);
5372
5596
  if (list) list.push(row);
@@ -5394,19 +5618,52 @@ var IndexerQueries = class {
5394
5618
  // ---- value movement ------------------------------------------------------
5395
5619
  async deposits(vault2, options = {}) {
5396
5620
  const { limit, offset } = bounds(options);
5397
- const { data, error, count } = await this.client.from("deposits").select("*", { count: "exact" }).eq("wallet_address", lower(vault2)).order("deposited_at_block", { ascending: false }).range(offset, offset + limit - 1);
5398
- if (error) throw new IndexerQueryError(`Indexer query failed: ${error.message}`, error);
5399
- const rows = (data ?? []).map((r) => DepositSchema.parse(r));
5400
- const total = count ?? rows.length;
5401
- return { data: rows, total, hasMore: offset + rows.length < total };
5621
+ return this.client.selectPage(
5622
+ "deposits",
5623
+ DepositSchema,
5624
+ limit,
5625
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).order("deposited_at_block", { ascending: false }).range(offset, offset + limit)
5626
+ );
5402
5627
  }
5403
5628
  async tokenTransfers(vault2, options = {}) {
5404
5629
  const { limit, offset } = bounds(options);
5405
- const { data, error, count } = await this.client.from("token_transfers").select("*", { count: "exact" }).eq("wallet_address", lower(vault2)).order("block_number", { ascending: false }).range(offset, offset + limit - 1);
5406
- if (error) throw new IndexerQueryError(`Indexer query failed: ${error.message}`, error);
5407
- const rows = (data ?? []).map((r) => TokenTransferSchema.parse(r));
5408
- const total = count ?? rows.length;
5409
- return { data: rows, total, hasMore: offset + rows.length < total };
5630
+ return this.client.selectPage(
5631
+ "token_transfers",
5632
+ TokenTransferSchema,
5633
+ limit,
5634
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).order("block_number", { ascending: false }).range(offset, offset + limit)
5635
+ );
5636
+ }
5637
+ /**
5638
+ * Scan up to `budget` of the most recent transfer rows, paging past {@link MAX_LIMIT}.
5639
+ *
5640
+ * `tokenTransfers` clamps a single request, so a caller asking for more than the cap
5641
+ * silently received a short page — and then computed "did I see everything?" from
5642
+ * that short page's `hasMore`, which reported truncation the caller had not actually
5643
+ * hit. Paging here keeps the caller's budget meaningful and makes the returned
5644
+ * `hasMore` mean what it says: rows exist beyond the budget that was scanned.
5645
+ *
5646
+ * Pages by offset over a descending order, so a transfer landing mid-scan can shift
5647
+ * rows by one. That is acceptable for token *discovery* — a duplicate collapses into
5648
+ * the same map key and a missed row costs at most one candidate that the next call
5649
+ * picks up. Do not reuse this for anything that must see each row exactly once.
5650
+ */
5651
+ async tokenTransferScan(vault2, budget) {
5652
+ const target = Math.max(1, Math.floor(budget));
5653
+ const data = [];
5654
+ let total = 0;
5655
+ let hasMore = false;
5656
+ while (data.length < target) {
5657
+ const page = await this.tokenTransfers(vault2, {
5658
+ limit: Math.min(MAX_LIMIT, target - data.length),
5659
+ offset: data.length
5660
+ });
5661
+ data.push(...page.data);
5662
+ total = page.total;
5663
+ hasMore = page.hasMore;
5664
+ if (page.data.length === 0 || !page.hasMore) break;
5665
+ }
5666
+ return { data, total, hasMore };
5410
5667
  }
5411
5668
  async tokens(addresses) {
5412
5669
  if (addresses.length === 0) return [];
@@ -5598,39 +5855,7 @@ var RecoveryContract = class {
5598
5855
  }
5599
5856
  };
5600
5857
 
5601
- // src/lifecycle/status.ts
5602
- function nowSeconds() {
5603
- return Math.floor(Date.now() / 1e3);
5604
- }
5605
- function executableAfterOf(approvedAt, executionDelay) {
5606
- return approvedAt > 0 ? approvedAt + executionDelay : 0;
5607
- }
5608
- function deriveStatus(state, at = nowSeconds()) {
5609
- if (state.failed) return "failed";
5610
- if (state.executed) return "executed";
5611
- if (state.cancelled) return state.isExpired ? "expired" : "cancelled";
5612
- if (state.expiration > 0 && at > state.expiration) return "expired";
5613
- if (state.approvalCount < state.threshold) return "pending";
5614
- const executableAfter = executableAfterOf(state.approvedAt, state.executionDelay);
5615
- if (state.executionDelay > 0 && (state.approvedAt === 0 || at < executableAfter)) {
5616
- return "timelocked";
5617
- }
5618
- return "ready";
5619
- }
5620
- function isTerminal(status) {
5621
- return status === "executed" || status === "failed" || status === "cancelled" || status === "expired";
5622
- }
5623
- function deriveRecoveryStatus(state, at = nowSeconds()) {
5624
- if (state.executed) return "executed";
5625
- if (state.expiration > 0 && at > state.expiration) return "expired";
5626
- if (state.approvalCount < state.requiredThreshold) return "pending";
5627
- if (at < state.executionTime) return "timelocked";
5628
- return "ready";
5629
- }
5630
-
5631
5858
  // src/recovery.ts
5632
- var SENTINEL = "0x0000000000000000000000000000000000000001";
5633
- var ZERO = "0x0000000000000000000000000000000000000000";
5634
5859
  var RecoveryModule = class {
5635
5860
  constructor(ctx) {
5636
5861
  this.ctx = ctx;
@@ -5652,14 +5877,27 @@ var RecoveryModule = class {
5652
5877
  get vault() {
5653
5878
  return this.ctx.vaultAddress;
5654
5879
  }
5880
+ /**
5881
+ * The vault this module acts on, through the same retrying facade every other vault
5882
+ * read in the SDK uses. Recovery reaches into the vault for three checks — is the
5883
+ * module enabled, is the caller an owner, who are the current owners — and each of
5884
+ * them gates a write, so none of them should be the one read that silently skips
5885
+ * transient-failure handling.
5886
+ */
5887
+ /** Now, in Unix seconds, for anything compared against a chain timestamp. */
5888
+ now() {
5889
+ return (this.ctx.now ?? nowSeconds)();
5890
+ }
5891
+ vaultContract() {
5892
+ return new VaultContract(this.ctx.connection.vault(this.vault), this.ctx.connection.retry);
5893
+ }
5655
5894
  // =========================================================================
5656
5895
  // Reads
5657
5896
  // =========================================================================
5658
5897
  /** Whether the module is currently enabled on the vault. */
5659
5898
  async isEnabled() {
5660
5899
  if (!this.ctx.moduleAddress) return false;
5661
- const vault2 = this.ctx.connection.vault(this.vault);
5662
- return vault2.getFunction("isModuleEnabled(address)")(this.address);
5900
+ return this.vaultContract().isModuleEnabled(this.address);
5663
5901
  }
5664
5902
  async config() {
5665
5903
  const raw = await this.contract().getRecoveryConfig(this.vault);
@@ -5717,7 +5955,7 @@ var RecoveryModule = class {
5717
5955
  */
5718
5956
  async history(options = {}) {
5719
5957
  const queries = this.ctx.queries;
5720
- if (!queries) return [];
5958
+ if (!queries) throw new NoIndexerError("Listing recovery history");
5721
5959
  const rows = await queries.recoveryHistory(this.vault, options);
5722
5960
  return rows.map((row) => ({
5723
5961
  hash: row.recovery_hash,
@@ -5737,7 +5975,7 @@ var RecoveryModule = class {
5737
5975
  requiredThreshold: row.required_threshold,
5738
5976
  executionTime: Number(row.execution_time),
5739
5977
  expiration: Number(row.expiration ?? 0)
5740
- }),
5978
+ }, this.now()),
5741
5979
  executed: row.status === "executed",
5742
5980
  initiator: (0, import_quais9.getAddress)(row.initiator_address),
5743
5981
  source: "indexer"
@@ -5846,21 +6084,20 @@ var RecoveryModule = class {
5846
6084
  `Not enough guardian approvals: ${request.approvalCount} of ${request.requiredThreshold}.`
5847
6085
  );
5848
6086
  }
5849
- const now = nowSeconds();
6087
+ const now = this.now();
5850
6088
  if (now < request.executionTime) {
5851
6089
  throw new PreconditionError(
5852
6090
  `The recovery period has not elapsed. Executable after ${new Date(request.executionTime * 1e3).toISOString()}.`,
5853
6091
  { retryableAt: request.executionTime }
5854
6092
  );
5855
6093
  }
5856
- const vault2 = this.ctx.connection.vault(this.vault);
5857
- const currentOwners = await vault2.getFunction("getOwners()")();
6094
+ const currentOwners = await this.vaultContract().getOwners();
5858
6095
  const current = new Set(Array.from(currentOwners).map((o) => String(o).toLowerCase()));
5859
6096
  const overlap = request.newOwners.filter((o) => current.has(o.toLowerCase())).length;
5860
6097
  const peak = current.size + request.newOwners.length - overlap;
5861
- if (peak > 20) {
6098
+ if (peak > MAX_OWNERS) {
5862
6099
  throw new PreconditionError(
5863
- `Executing this recovery would transiently hold ${peak} owners, above the 20-owner maximum \u2014 new owners are added before old ones are removed.`,
6100
+ `Executing this recovery would transiently hold ${peak} owners, above the ${MAX_OWNERS}-owner maximum \u2014 new owners are added before old ones are removed.`,
5864
6101
  {
5865
6102
  remediation: "Run a recovery that retains at least one existing owner, then a second recovery to replace it."
5866
6103
  }
@@ -5875,8 +6112,7 @@ var RecoveryModule = class {
5875
6112
  async cancel(recoveryHash) {
5876
6113
  const hash = normalizeTxHash(recoveryHash, "recovery hash");
5877
6114
  const caller = await this.ctx.connection.address();
5878
- const vault2 = this.ctx.connection.vault(this.vault);
5879
- const isOwner = await vault2.getFunction("isOwner(address)")(caller);
6115
+ const isOwner = await this.vaultContract().isOwner(caller);
5880
6116
  if (!isOwner) {
5881
6117
  throw new PreconditionError(
5882
6118
  `Only a current owner of the vault can cancel a recovery. ${caller} is not one.`
@@ -5893,7 +6129,7 @@ var RecoveryModule = class {
5893
6129
  // Affordances
5894
6130
  // =========================================================================
5895
6131
  /** What `caller` may do to a recovery right now, and when blocked actions unlock. */
5896
- async affordances(recoveryHash, caller) {
6132
+ async affordances(recoveryHash, caller, at) {
5897
6133
  const hash = normalizeTxHash(recoveryHash, "recovery hash");
5898
6134
  const who = caller ?? await this.ctx.connection.addressOrNull();
5899
6135
  if (!who) {
@@ -5907,9 +6143,9 @@ var RecoveryModule = class {
5907
6143
  this.isGuardian(address2),
5908
6144
  this.contract().recoveryApprovals(this.vault, hash, address2),
5909
6145
  this.isEnabled(),
5910
- this.ctx.connection.vault(this.vault).getFunction("isOwner(address)")(address2)
6146
+ this.vaultContract().isOwner(address2)
5911
6147
  ]);
5912
- const now = nowSeconds();
6148
+ const now = at ?? this.now();
5913
6149
  const terminal = request.status === "executed" || request.status === "cancelled";
5914
6150
  const expired = now > request.expiration && request.expiration > 0;
5915
6151
  const quorum = request.approvalCount >= request.requiredThreshold;
@@ -6006,13 +6242,10 @@ var RecoveryModule = class {
6006
6242
  const approvalCount = Number(raw.approvalCount ?? 0);
6007
6243
  const requiredThreshold = Number(raw.requiredThreshold ?? 0);
6008
6244
  const executed = Boolean(raw.executed);
6009
- const status = deriveRecoveryStatus({
6010
- executed,
6011
- approvalCount,
6012
- requiredThreshold,
6013
- executionTime,
6014
- expiration
6015
- });
6245
+ const status = deriveRecoveryStatus(
6246
+ { executed, approvalCount, requiredThreshold, executionTime, expiration },
6247
+ this.now()
6248
+ );
6016
6249
  return {
6017
6250
  hash,
6018
6251
  vault: this.vault,
@@ -6032,7 +6265,7 @@ var RecoveryModule = class {
6032
6265
  const seen = /* @__PURE__ */ new Set();
6033
6266
  for (const owner of newOwners) {
6034
6267
  const key = owner.toLowerCase();
6035
- if (key === ZERO || key === SENTINEL) {
6268
+ if (key === ZERO_ADDRESS || key === SENTINEL_MODULES) {
6036
6269
  throw new ValidationError(
6037
6270
  `New owner ${owner} is reserved \u2014 the zero address and the module sentinel 0x\u202601 are rejected.`
6038
6271
  );
@@ -6053,7 +6286,7 @@ var RecoveryModule = class {
6053
6286
  if (request.executed) {
6054
6287
  throw new PreconditionError("This recovery has already been executed.");
6055
6288
  }
6056
- if (request.expiration > 0 && nowSeconds() > request.expiration) {
6289
+ if (request.expiration > 0 && this.now() > request.expiration) {
6057
6290
  throw new PreconditionError("This recovery has expired.", {
6058
6291
  remediation: "Call expire() to clean it up, then initiate a new recovery."
6059
6292
  });
@@ -6114,15 +6347,60 @@ function terminalReason(request) {
6114
6347
 
6115
6348
  // src/balances.ts
6116
6349
  var import_quais10 = require("quais");
6350
+
6351
+ // src/chain/token-contract.ts
6352
+ var TOKEN_RETRY = { maxAttempts: 2, baseDelayMs: 150 };
6353
+ var TokenContract = class {
6354
+ constructor(contract, retry = {}) {
6355
+ this.contract = contract;
6356
+ this.retry = { ...TOKEN_RETRY, ...retry };
6357
+ }
6358
+ contract;
6359
+ retry;
6360
+ read(signature, ...args) {
6361
+ return withRetry(() => this.contract.getFunction(signature)(...args), this.retry);
6362
+ }
6363
+ /** ERC20 units held, or — for ERC721 — the number of tokens held. */
6364
+ balanceOf(owner) {
6365
+ return this.read("balanceOf(address)", owner);
6366
+ }
6367
+ /** ERC721 only. Reverts for a burned or never-minted id. */
6368
+ ownerOf(tokenId) {
6369
+ return this.read("ownerOf(uint256)", tokenId);
6370
+ }
6371
+ /** ERC1155 only. One call for many ids, so it stays a single round trip. */
6372
+ balanceOfBatch(owners, ids) {
6373
+ return this.read("balanceOfBatch(address[],uint256[])", owners, ids);
6374
+ }
6375
+ };
6376
+
6377
+ // src/pool.ts
6378
+ async function mapPooled(items, limit, fn) {
6379
+ const results = new Array(items.length);
6380
+ let next = 0;
6381
+ const worker = async () => {
6382
+ for (; ; ) {
6383
+ const i = next++;
6384
+ if (i >= items.length) return;
6385
+ results[i] = await fn(items[i], i);
6386
+ }
6387
+ };
6388
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
6389
+ return results;
6390
+ }
6391
+ var DEFAULT_CONCURRENCY = 8;
6392
+
6393
+ // src/balances.ts
6117
6394
  async function loadBalances(connection, queries, vault2, options = {}) {
6118
6395
  const verify = options.verify !== false;
6119
6396
  const maxTokens = options.maxTokens ?? 50;
6120
6397
  const transferScanLimit = options.transferScanLimit ?? 500;
6121
6398
  const maxTokenIdChecks = options.maxTokenIdChecks ?? 100;
6399
+ const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
6122
6400
  const address2 = (0, import_quais10.getAddress)(vault2);
6123
6401
  const native = BigInt(await connection.provider.getBalance(address2));
6124
6402
  if (!queries) return { native, tokens: [] };
6125
- const transfers = await queries.tokenTransfers(address2, { limit: transferScanLimit });
6403
+ const transfers = await queries.tokenTransferScan(address2, transferScanLimit);
6126
6404
  const seen = /* @__PURE__ */ new Map();
6127
6405
  for (const row of transfers.data) {
6128
6406
  const key = row.token_address.toLowerCase();
@@ -6141,17 +6419,18 @@ async function loadBalances(connection, queries, vault2, options = {}) {
6141
6419
  }
6142
6420
  const metadata = await queries.tokens(candidates);
6143
6421
  const byAddress = new Map(metadata.map((t) => [t.address.toLowerCase(), t]));
6144
- const balances = await Promise.all(
6145
- candidates.map(
6146
- (token2) => buildBalance(
6147
- connection,
6148
- address2,
6149
- token2,
6150
- byAddress.get(token2),
6151
- seen.get(token2) ?? [],
6152
- verify,
6153
- maxTokenIdChecks
6154
- )
6422
+ const balances = await mapPooled(
6423
+ candidates,
6424
+ concurrency,
6425
+ (token2) => buildBalance(
6426
+ connection,
6427
+ address2,
6428
+ token2,
6429
+ byAddress.get(token2),
6430
+ seen.get(token2) ?? [],
6431
+ verify,
6432
+ maxTokenIdChecks,
6433
+ concurrency
6155
6434
  )
6156
6435
  );
6157
6436
  return {
@@ -6161,30 +6440,35 @@ async function loadBalances(connection, queries, vault2, options = {}) {
6161
6440
  ...Object.keys(truncated).length ? { truncated } : {}
6162
6441
  };
6163
6442
  }
6164
- async function buildBalance(connection, vault2, token2, meta, transfers, verify, maxTokenIdChecks) {
6443
+ async function buildBalance(connection, vault2, token2, meta, transfers, verify, maxTokenIdChecks, concurrency) {
6165
6444
  const standard = meta?.standard ?? inferStandard(transfers);
6166
6445
  const replayed = replayBalance(transfers, standard);
6167
6446
  const base = {
6168
6447
  token: (0, import_quais10.getAddress)(token2),
6169
6448
  standard,
6170
- symbol: meta?.symbol ?? "???",
6171
- name: meta?.name ?? "Unknown token",
6449
+ // Deployer-controlled strings headed for someone's terminal. See `src/text.ts`.
6450
+ symbol: sanitizeText(meta?.symbol, 32) || "???",
6451
+ name: sanitizeText(meta?.name, 64) || "Unknown token",
6172
6452
  decimals: meta?.decimals ?? (standard === "ERC20" ? 18 : 0),
6173
6453
  balance: replayed.balance,
6174
6454
  ...replayed.tokenIds.length > 0 ? { tokenIds: replayed.tokenIds } : {},
6175
6455
  verified: false
6176
6456
  };
6177
6457
  if (!verify) return base;
6458
+ const contract = new TokenContract(connection.token(token2, standard), connection.retry);
6178
6459
  try {
6179
6460
  if (standard === "ERC20") {
6180
- const contract2 = new import_quais10.Contract(token2, Erc20Abi, connection.provider);
6181
- const balance = await contract2.getFunction("balanceOf(address)")(vault2);
6182
- return { ...base, balance: BigInt(balance), verified: true };
6461
+ return { ...base, balance: BigInt(await contract.balanceOf(vault2)), verified: true };
6183
6462
  }
6184
6463
  if (standard === "ERC721") {
6185
- const contract2 = new import_quais10.Contract(token2, Erc721Abi, connection.provider);
6186
- const count = await contract2.getFunction("balanceOf(address)")(vault2);
6187
- const owned = await filterOwned(contract2, vault2, replayed.tokenIds, maxTokenIdChecks);
6464
+ const count = await contract.balanceOf(vault2);
6465
+ const owned = await filterOwned(
6466
+ contract,
6467
+ vault2,
6468
+ replayed.tokenIds,
6469
+ maxTokenIdChecks,
6470
+ concurrency
6471
+ );
6188
6472
  return {
6189
6473
  ...base,
6190
6474
  balance: BigInt(count),
@@ -6193,10 +6477,9 @@ async function buildBalance(connection, vault2, token2, meta, transfers, verify,
6193
6477
  verified: true
6194
6478
  };
6195
6479
  }
6196
- const contract = new import_quais10.Contract(token2, Erc1155Abi, connection.provider);
6197
6480
  const ids = replayed.tokenIds;
6198
6481
  if (ids.length === 0) return { ...base, verified: true };
6199
- const amounts = await contract.getFunction("balanceOfBatch(address[],uint256[])")(
6482
+ const amounts = await contract.balanceOfBatch(
6200
6483
  ids.map(() => vault2),
6201
6484
  ids
6202
6485
  );
@@ -6241,18 +6524,16 @@ function replayBalance(transfers, standard) {
6241
6524
  }
6242
6525
  return { balance, tokenIds };
6243
6526
  }
6244
- async function filterOwned(contract, vault2, ids, limit) {
6527
+ async function filterOwned(contract, vault2, ids, limit, concurrency) {
6245
6528
  if (ids.length === 0) return [];
6246
- const results = await Promise.all(
6247
- ids.slice(0, limit).map(async (id) => {
6248
- try {
6249
- const owner = await contract.getFunction("ownerOf(uint256)")(id);
6250
- return owner.toLowerCase() === vault2.toLowerCase() ? id : null;
6251
- } catch {
6252
- return null;
6253
- }
6254
- })
6255
- );
6529
+ const results = await mapPooled(ids.slice(0, limit), concurrency, async (id) => {
6530
+ try {
6531
+ const owner = await contract.ownerOf(id);
6532
+ return owner.toLowerCase() === vault2.toLowerCase() ? id : null;
6533
+ } catch {
6534
+ return null;
6535
+ }
6536
+ });
6256
6537
  return results.filter((id) => id !== null);
6257
6538
  }
6258
6539
  function inferStandard(transfers) {
@@ -6288,7 +6569,7 @@ function watchVault(client, vault2, handler, options = {}) {
6288
6569
  const topics = options.topics ?? DEFAULT_TOPICS;
6289
6570
  const address2 = vault2.toLowerCase();
6290
6571
  const schema = client.config.schema;
6291
- const channel = client.raw.channel(`quaivault:${schema}:${address2}`);
6572
+ const channel = client.channel(`quaivault:${schema}:${address2}`);
6292
6573
  for (const topic of topics) {
6293
6574
  const table = TABLE_FOR[topic];
6294
6575
  channel.on(
@@ -6318,7 +6599,7 @@ function watchVault(client, vault2, handler, options = {}) {
6318
6599
  return {
6319
6600
  topics,
6320
6601
  async unsubscribe() {
6321
- await client.raw.removeChannel(channel);
6602
+ await client.removeChannel(channel);
6322
6603
  }
6323
6604
  };
6324
6605
  }
@@ -6767,8 +7048,9 @@ function classifyExecution(receipt, vault2, txHash) {
6767
7048
  }
6768
7049
  const thresholdReached = eventFor(events, "ThresholdReached", txHash);
6769
7050
  if (thresholdReached) {
7051
+ const approvedAt = toNumber2(thresholdReached.args.approvedAt);
6770
7052
  const executableAfter = toNumber2(thresholdReached.args.executableAfter);
6771
- if (executableAfter > Math.floor(Date.now() / 1e3)) {
7053
+ if (executableAfter > approvedAt) {
6772
7054
  return {
6773
7055
  ...base,
6774
7056
  outcome: "timelock_started",
@@ -6798,7 +7080,7 @@ function extractProposedTxHash(receipt, vault2) {
6798
7080
  }
6799
7081
 
6800
7082
  // src/vault.ts
6801
- var Vault = class {
7083
+ var Vault = class _Vault {
6802
7084
  address;
6803
7085
  /** Guardian-based recovery for this vault. */
6804
7086
  recovery;
@@ -6810,7 +7092,8 @@ var Vault = class {
6810
7092
  connection: ctx.connection,
6811
7093
  queries: ctx.queries,
6812
7094
  vaultAddress: this.address,
6813
- moduleAddress: ctx.contracts.socialRecovery
7095
+ moduleAddress: ctx.contracts.socialRecovery,
7096
+ ...ctx.now ? { now: ctx.now } : {}
6814
7097
  });
6815
7098
  }
6816
7099
  // =========================================================================
@@ -6841,6 +7124,30 @@ var Vault = class {
6841
7124
  if (!this.ctx.queries) throw new NoIndexerError(operation);
6842
7125
  return this.ctx.queries;
6843
7126
  }
7127
+ /**
7128
+ * The indexer's head, for stamping onto records read from it.
7129
+ *
7130
+ * Reads the cached health result rather than querying `indexer_state` directly.
7131
+ * `useIndexer()` has just called `health()` on this same code path and the result is
7132
+ * cached, so this is free — whereas a `state()` call is a second round trip per
7133
+ * hydration, paid on every indexed read purely to fill one advisory field.
7134
+ *
7135
+ * Undefined when the indexer is not answering: a head of 0 would read as "indexed at
7136
+ * genesis" rather than "unknown".
7137
+ */
7138
+ async indexerHead() {
7139
+ const health = await this.ctx.indexer?.health();
7140
+ return health?.available ? health.lastIndexedBlock : void 0;
7141
+ }
7142
+ /**
7143
+ * Now, in Unix seconds, for anything compared against a chain timestamp.
7144
+ *
7145
+ * Never used to measure elapsed time — see the duration sites in
7146
+ * {@link waitForExecutable}, which stay on the raw local clock deliberately.
7147
+ */
7148
+ now() {
7149
+ return (this.ctx.now ?? nowSeconds)();
7150
+ }
6844
7151
  contract(write = false) {
6845
7152
  return new VaultContract(this.ctx.connection.vault(this.address, write), this.ctx.connection.retry);
6846
7153
  }
@@ -6868,8 +7175,48 @@ var Vault = class {
6868
7175
  balance: BigInt(balance)
6869
7176
  };
6870
7177
  }
6871
- /** Active owners. Prefers the indexer, falls back to chain. */
7178
+ /**
7179
+ * Capture owners and threshold once, for reuse across a burst of reads.
7180
+ *
7181
+ * Pair with {@link pinned}:
7182
+ *
7183
+ * ```ts
7184
+ * const view = await vault.view();
7185
+ * const pinned = vault.pinned(view);
7186
+ * // every read below shares one owner/threshold read
7187
+ * const txs = await pinned.transactions(hashes);
7188
+ * ```
7189
+ */
7190
+ async view() {
7191
+ const fromIndexer = await this.useIndexer();
7192
+ const [owners, threshold, indexedAtBlock] = await Promise.all([
7193
+ this.owners(),
7194
+ this.threshold(),
7195
+ this.indexerHead()
7196
+ ]);
7197
+ return {
7198
+ owners,
7199
+ threshold,
7200
+ ...indexedAtBlock !== void 0 ? { indexedAtBlock } : {},
7201
+ capturedAt: this.now(),
7202
+ source: fromIndexer ? "indexer" : "chain"
7203
+ };
7204
+ }
7205
+ /**
7206
+ * A handle that answers {@link owners} and {@link threshold} from `view` instead of
7207
+ * re-reading them.
7208
+ *
7209
+ * Reads only. Write preconditions go through `chainOwners` / `chainThreshold`, which
7210
+ * bypass the snapshot by construction — pinning a view can never cause this SDK to
7211
+ * sign against a stale owner set. Everything else about the handle, including the
7212
+ * connection and the recovery module, is shared with the original.
7213
+ */
7214
+ pinned(view) {
7215
+ return new _Vault(this.address, { ...this.ctx, view });
7216
+ }
7217
+ /** Active owners. Prefers a pinned view, then the indexer, then chain. */
6872
7218
  async owners() {
7219
+ if (this.ctx.view) return this.ctx.view.owners;
6873
7220
  if (await this.useIndexer()) {
6874
7221
  try {
6875
7222
  const owners = await this.requireQueries("owners").owners(this.address);
@@ -6877,12 +7224,55 @@ var Vault = class {
6877
7224
  } catch {
6878
7225
  }
6879
7226
  }
7227
+ return this.chainOwners();
7228
+ }
7229
+ /**
7230
+ * Owners straight from the chain, bypassing the indexer entirely.
7231
+ *
7232
+ * {@link owners} prefers the indexer, which is right for display and wrong for
7233
+ * anything that gates a write. A lagging indexer would let the SDK build a proposal
7234
+ * against an owner set that no longer exists — admitting a `removeOwner` that drops
7235
+ * the vault below its threshold, or rejecting one that is perfectly valid. Every
7236
+ * propose-time precondition reads through here instead.
7237
+ */
7238
+ async chainOwners() {
6880
7239
  return (await this.contract().getOwners()).map((o) => (0, import_quais14.getAddress)(String(o)));
6881
7240
  }
6882
7241
  async isOwner(address2) {
6883
7242
  return this.contract().isOwner((0, import_quais14.getAddress)(address2));
6884
7243
  }
7244
+ /**
7245
+ * Whether `owner`'s approval on `txHash` currently counts toward the threshold.
7246
+ *
7247
+ * Reads the chain, and accounts for approval-epoch invalidation: removing an owner
7248
+ * bumps the vault's epoch and voids every approval that address made, so a
7249
+ * confirmation the indexer still lists may already be dead. This is the authoritative
7250
+ * answer.
7251
+ *
7252
+ * Public because it is one of the two inputs {@link computeAffordances} needs. Without
7253
+ * it a consumer wanting affordances at an adjusted time had no way to assemble an
7254
+ * `AffordanceContext` short of reimplementing this method against the raw contract.
7255
+ */
7256
+ async hasApproved(txHash, owner) {
7257
+ return this.contract().hasApproved(normalizeTxHash(txHash), (0, import_quais14.getAddress)(owner));
7258
+ }
7259
+ /** Approvals required to execute. Prefers a pinned view, then the indexer, then chain. */
6885
7260
  async threshold() {
7261
+ if (this.ctx.view) return this.ctx.view.threshold;
7262
+ if (await this.useIndexer()) {
7263
+ try {
7264
+ const wallet = await this.requireQueries("threshold").wallet(this.address);
7265
+ if (wallet) return wallet.threshold;
7266
+ } catch {
7267
+ }
7268
+ }
7269
+ return this.chainThreshold();
7270
+ }
7271
+ /**
7272
+ * Threshold straight from the chain. The write-path counterpart to {@link threshold},
7273
+ * for the same reason {@link chainOwners} exists.
7274
+ */
7275
+ async chainThreshold() {
6886
7276
  return Number(await this.contract().threshold());
6887
7277
  }
6888
7278
  async balance() {
@@ -6935,6 +7325,53 @@ var Vault = class {
6935
7325
  }
6936
7326
  return this.fromChain(hash);
6937
7327
  }
7328
+ /**
7329
+ * Several transactions at once, keyed by hash.
7330
+ *
7331
+ * The plural form exists because the singular one is expensive to loop: each
7332
+ * `transaction()` re-reads the owner set, the threshold and the indexer head, so
7333
+ * fetching fifty costs fifty times that. This resolves the whole set the way the
7334
+ * listing methods already do — one query for the rows, one owner/threshold read
7335
+ * shared across them, one batched confirmations query.
7336
+ *
7337
+ * Hashes the indexer does not have fall back to chain reads, bounded by a pool.
7338
+ * Hashes that exist nowhere are simply absent from the map rather than throwing:
7339
+ * one unknown hash should not lose the caller the other forty-nine.
7340
+ */
7341
+ async transactions(txHashes) {
7342
+ const hashes = [...new Set(txHashes.map((h) => normalizeTxHash(h)))];
7343
+ const found = /* @__PURE__ */ new Map();
7344
+ if (hashes.length === 0) return found;
7345
+ if (await this.useIndexer()) {
7346
+ try {
7347
+ const queries = this.requireQueries("Reading transactions");
7348
+ const [rows, owners, threshold] = await Promise.all([
7349
+ queries.transactionsByHash(this.address, hashes),
7350
+ this.owners(),
7351
+ this.threshold()
7352
+ ]);
7353
+ for (const tx of await this.hydrateRows(rows, owners, threshold)) {
7354
+ found.set(tx.hash.toLowerCase(), tx);
7355
+ }
7356
+ } catch {
7357
+ }
7358
+ }
7359
+ const missing = hashes.filter((hash) => !found.has(hash));
7360
+ if (missing.length > 0) {
7361
+ const resolved = await mapPooled(missing, DEFAULT_CONCURRENCY, async (hash) => {
7362
+ try {
7363
+ return await this.fromChain(hash);
7364
+ } catch (err) {
7365
+ if (err instanceof NotFoundError) return null;
7366
+ throw err;
7367
+ }
7368
+ });
7369
+ for (const tx of resolved) {
7370
+ if (tx) found.set(tx.hash.toLowerCase(), tx);
7371
+ }
7372
+ }
7373
+ return found;
7374
+ }
6938
7375
  async pendingTransactions(options = {}) {
6939
7376
  const queries = this.requireQueries("Listing pending transactions");
6940
7377
  const [rows, owners, threshold] = await Promise.all([
@@ -6965,7 +7402,7 @@ var Vault = class {
6965
7402
  this.address,
6966
7403
  rows.map((r) => r.tx_hash)
6967
7404
  );
6968
- const indexedAtBlock = (await this.ctx.indexer?.state())?.lastIndexedBlock;
7405
+ const indexedAtBlock = await this.indexerHead();
6969
7406
  return rows.map((row) => {
6970
7407
  const confirmed = (confirmations.get(row.tx_hash.toLowerCase()) ?? []).map(
6971
7408
  (c) => c.owner_address
@@ -6988,7 +7425,7 @@ var Vault = class {
6988
7425
  ]);
6989
7426
  if (!row) return null;
6990
7427
  const confirmations = await queries.confirmations(this.address, hash);
6991
- const indexedAtBlock = (await this.ctx.indexer?.state())?.lastIndexedBlock;
7428
+ const indexedAtBlock = await this.indexerHead();
6992
7429
  return this.buildTransaction({
6993
7430
  row,
6994
7431
  confirmedBy: confirmations.filter((c) => c.is_active).map((c) => c.owner_address),
@@ -7042,7 +7479,7 @@ var Vault = class {
7042
7479
  approvalCount,
7043
7480
  threshold,
7044
7481
  failed: row.status === "failed"
7045
- });
7482
+ }, this.now());
7046
7483
  const failedReturnData = row.failed_return_data ?? void 0;
7047
7484
  return {
7048
7485
  hash: row.tx_hash,
@@ -7051,7 +7488,10 @@ var Vault = class {
7051
7488
  value,
7052
7489
  data,
7053
7490
  proposer: (0, import_quais14.getAddress)(row.submitted_by),
7054
- proposedAt: toNumber(row.submitted_at_block),
7491
+ // The indexer records the block, not the chain timestamp — they are not
7492
+ // interchangeable, and reporting a block number as `proposedAt` renders as 1970.
7493
+ proposedAt: 0,
7494
+ proposedAtBlock: toNumber(row.submitted_at_block),
7055
7495
  kind: decodeResult.kind,
7056
7496
  decoded: decodeResult.decoded,
7057
7497
  summary: decodeResult.summary,
@@ -7122,7 +7562,7 @@ var Vault = class {
7122
7562
  approvedAt,
7123
7563
  approvalCount: approvals.length,
7124
7564
  threshold: Number(threshold)
7125
- }),
7565
+ }, this.now()),
7126
7566
  approvals,
7127
7567
  approvalCount: approvals.length,
7128
7568
  threshold: Number(threshold),
@@ -7140,8 +7580,20 @@ var Vault = class {
7140
7580
  * What `caller` may legally do to `txHash` right now, and when blocked actions
7141
7581
  * become available. Defaults to the connected signer's address.
7142
7582
  */
7143
- async affordances(txHash, caller) {
7583
+ async affordances(txHash, caller, at) {
7144
7584
  const hash = normalizeTxHash(txHash);
7585
+ return this.resolveAffordances(hash, this.transaction(hash), caller, at);
7586
+ }
7587
+ /**
7588
+ * Shared body of {@link affordances}, taking the transaction either as a value or as
7589
+ * an in-flight promise.
7590
+ *
7591
+ * The promise form is the point: a caller that already holds the record (like
7592
+ * {@link describe}) passes it and skips a redundant fetch, while `affordances()`
7593
+ * passes the unawaited promise so the transaction read still overlaps the two
7594
+ * ownership probes rather than serialising behind them.
7595
+ */
7596
+ async resolveAffordances(hash, transaction, caller, at) {
7145
7597
  const who = caller ?? await this.ctx.connection.addressOrNull();
7146
7598
  if (!who) {
7147
7599
  throw new ValidationError(
@@ -7150,11 +7602,17 @@ var Vault = class {
7150
7602
  }
7151
7603
  const vault2 = this.contract();
7152
7604
  const [tx, isOwner, hasApproved] = await Promise.all([
7153
- this.transaction(hash),
7605
+ transaction,
7154
7606
  vault2.isOwner((0, import_quais14.getAddress)(who)),
7155
7607
  vault2.hasApproved(hash, (0, import_quais14.getAddress)(who))
7156
7608
  ]);
7157
- return computeAffordances({ tx, caller: (0, import_quais14.getAddress)(who), isOwner, hasApproved });
7609
+ return computeAffordances({
7610
+ tx,
7611
+ caller: (0, import_quais14.getAddress)(who),
7612
+ isOwner,
7613
+ hasApproved,
7614
+ at: at ?? this.now()
7615
+ });
7158
7616
  }
7159
7617
  // =========================================================================
7160
7618
  // Proposals
@@ -7245,7 +7703,7 @@ var Vault = class {
7245
7703
  // --- self-calls -------------------------------------------------------
7246
7704
  addOwner: async (owner, options = {}) => this.proposeSelfCall(selfCall.addOwner(assertQuaiAddress(owner, "owner")), options),
7247
7705
  removeOwner: async (owner, options = {}) => {
7248
- const [owners, threshold] = await Promise.all([this.owners(), this.threshold()]);
7706
+ const [owners, threshold] = await Promise.all([this.chainOwners(), this.chainThreshold()]);
7249
7707
  if (!owners.some((o) => o.toLowerCase() === owner.toLowerCase())) {
7250
7708
  throw new PreconditionError(`${owner} is not an owner of this vault.`);
7251
7709
  }
@@ -7258,7 +7716,7 @@ var Vault = class {
7258
7716
  return this.proposeSelfCall(selfCall.removeOwner((0, import_quais14.getAddress)(owner)), options);
7259
7717
  },
7260
7718
  changeThreshold: async (threshold, options = {}) => {
7261
- const owners = await this.owners();
7719
+ const owners = await this.chainOwners();
7262
7720
  if (threshold < 1 || threshold > owners.length) {
7263
7721
  throw new ValidationError(
7264
7722
  `Invalid threshold ${threshold}: this vault has ${owners.length} owners.`
@@ -7365,11 +7823,11 @@ var Vault = class {
7365
7823
  }
7366
7824
  if (expiration > 0) {
7367
7825
  const floor = isSelfCall ? 0 : Math.max(executionDelay, await this.minExecutionDelay());
7368
- const earliest = minimumExpiration(floor, 0);
7826
+ const earliest = minimumExpiration(floor, 0, this.now());
7369
7827
  if (expiration <= earliest) {
7370
7828
  throw new ValidationError(
7371
7829
  `expiration ${expiration} is too soon: with an effective delay of ${floor}s the vault requires an expiration after ${earliest}.`,
7372
- `Use at least ${minimumExpiration(floor)} to leave a margin for block time.`
7830
+ `Use at least ${minimumExpiration(floor, 300, this.now())} to leave a margin for block time.`
7373
7831
  );
7374
7832
  }
7375
7833
  }
@@ -7692,9 +8150,7 @@ var Vault = class {
7692
8150
  const pollIntervalMs = options.pollIntervalMs ?? 15e3;
7693
8151
  const deadline = Date.now() + timeoutMs;
7694
8152
  for (; ; ) {
7695
- if (options.signal?.aborted) {
7696
- throw new PreconditionError("Waiting for the transaction was aborted.");
7697
- }
8153
+ if (options.signal?.aborted) throw new AbortError("Waiting for the transaction");
7698
8154
  const tx = await this.fromChain(hash);
7699
8155
  options.onPoll?.(tx);
7700
8156
  if (tx.status === "ready") return tx;
@@ -7710,7 +8166,7 @@ var Vault = class {
7710
8166
  { remediation: "Collect the remaining approvals, then wait for the timelock." }
7711
8167
  );
7712
8168
  }
7713
- const untilExecutable = tx.executableAfter > 0 ? tx.executableAfter * 1e3 - Date.now() : pollIntervalMs;
8169
+ const untilExecutable = tx.executableAfter > 0 ? (tx.executableAfter - this.now()) * 1e3 : pollIntervalMs;
7714
8170
  const wait = Math.max(1e3, Math.min(untilExecutable, pollIntervalMs));
7715
8171
  if (Date.now() + wait > deadline) {
7716
8172
  throw new PreconditionError(
@@ -7740,7 +8196,7 @@ var Vault = class {
7740
8196
  lines.push(` quorum reached: ${new Date(tx.approvedAt * 1e3).toISOString()}`);
7741
8197
  }
7742
8198
  if (tx.executableAfter > 0) {
7743
- const remaining = tx.executableAfter - nowSeconds();
8199
+ const remaining = tx.executableAfter - this.now();
7744
8200
  lines.push(
7745
8201
  ` executable after: ${new Date(tx.executableAfter * 1e3).toISOString()}` + (remaining > 0 ? ` (in ${remaining}s)` : " (now)")
7746
8202
  );
@@ -7751,7 +8207,7 @@ var Vault = class {
7751
8207
  if (tx.decodedRevert) lines.push(` revert: ${tx.decodedRevert.message}`);
7752
8208
  lines.push(` source: ${tx.source}`);
7753
8209
  if (caller || this.ctx.connection.hasSigner()) {
7754
- const affordances = await this.affordances(txHash, caller);
8210
+ const affordances = await this.resolveAffordances(tx.hash, tx, caller);
7755
8211
  const allowed = affordances.filter((a) => a.allowed).map((a) => a.action);
7756
8212
  lines.push(` you can: ${allowed.length > 0 ? allowed.join(", ") : "nothing right now"}`);
7757
8213
  }
@@ -7785,6 +8241,11 @@ function toRevertError(err, context) {
7785
8241
 
7786
8242
  // src/client.ts
7787
8243
  var QuaiVaultClient = class {
8244
+ /**
8245
+ * Source of "now" for time compared against chain timestamps. Resolved once from
8246
+ * {@link ClientOptions.now}, defaulting to the local clock. See {@link Clock}.
8247
+ */
8248
+ now;
7788
8249
  /**
7789
8250
  * Resolved configuration, with the private key stripped and the indexer key
7790
8251
  * masked — safe to log. The key itself is consumed once when building the signer.
@@ -7797,6 +8258,7 @@ var QuaiVaultClient = class {
7797
8258
  constructor(options = {}) {
7798
8259
  const resolved = resolveConfig(options);
7799
8260
  this.config = redactConfig(resolved);
8261
+ this.now = resolved.now;
7800
8262
  this.connection = new Connection(resolved, {
7801
8263
  ...options.provider ? { provider: options.provider } : {},
7802
8264
  ...options.signer ? { signer: options.signer } : {}
@@ -7832,7 +8294,8 @@ var QuaiVaultClient = class {
7832
8294
  queries: this.queries,
7833
8295
  contracts: this.config.contracts,
7834
8296
  consistency: this.config.consistency,
7835
- maxIndexerLagBlocks: this.config.maxIndexerLagBlocks
8297
+ maxIndexerLagBlocks: this.config.maxIndexerLagBlocks,
8298
+ now: this.now
7836
8299
  };
7837
8300
  }
7838
8301
  /** Vault discovery. Requires the indexer — there is no on-chain reverse index. */