@quaivault/sdk 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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}`;
@@ -3818,7 +3841,9 @@ function resolveConfig(options = {}) {
3818
3841
  contracts,
3819
3842
  indexer,
3820
3843
  rpcUrl,
3821
- privateKey: pick(options.privateKey, env[ENV_VARS.privateKey]),
3844
+ // An explicit signer wins outright in `Connection`, so resolving a key here would
3845
+ // only pull a secret into memory that nothing can use. Skip it.
3846
+ privateKey: options.signer ? void 0 : pick(options.privateKey, env[ENV_VARS.privateKey]),
3822
3847
  consistency,
3823
3848
  maxIndexerLagBlocks: options.maxIndexerLagBlocks ?? parseLag(env[ENV_VARS.maxIndexerLagBlocks]) ?? 50,
3824
3849
  retry: {
@@ -3948,7 +3973,7 @@ function isTransient(error) {
3948
3973
  }
3949
3974
  var sleep = (ms, signal) => new Promise((resolve, reject) => {
3950
3975
  if (signal?.aborted) {
3951
- reject(new Error("Aborted"));
3976
+ reject(new AbortError("Waiting to retry"));
3952
3977
  return;
3953
3978
  }
3954
3979
  const timer = setTimeout(() => {
@@ -3957,7 +3982,7 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
3957
3982
  }, ms);
3958
3983
  const onAbort = () => {
3959
3984
  clearTimeout(timer);
3960
- reject(new Error("Aborted"));
3985
+ reject(new AbortError("Waiting to retry"));
3961
3986
  };
3962
3987
  signal?.addEventListener("abort", onAbort, { once: true });
3963
3988
  });
@@ -3967,7 +3992,7 @@ async function withRetry(fn, options = {}) {
3967
3992
  const maxDelayMs = options.maxDelayMs ?? DEFAULTS.maxDelayMs;
3968
3993
  let lastError;
3969
3994
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3970
- if (options.signal?.aborted) throw new Error("Aborted");
3995
+ if (options.signal?.aborted) throw new AbortError("The retried operation");
3971
3996
  try {
3972
3997
  return await fn();
3973
3998
  } catch (error) {
@@ -4162,8 +4187,39 @@ var FactoryContract = class {
4162
4187
 
4163
4188
  // src/errors/decode.ts
4164
4189
  var import_quais4 = require("quais");
4190
+
4191
+ // src/text.ts
4192
+ var UNSAFE_CHARS = new RegExp(
4193
+ [
4194
+ "[\\u0000-\\u001F",
4195
+ // C0 controls, incl. ESC (0x1B) — the head of every ANSI sequence
4196
+ "\\u007F-\\u009F",
4197
+ // DEL and the C1 controls
4198
+ "\\u200B-\\u200F",
4199
+ // zero-width space/joiners, LRM, RLM
4200
+ "\\u202A-\\u202E",
4201
+ // bidi embeddings and overrides
4202
+ "\\u2060-\\u2064",
4203
+ // word joiner and the invisible operators
4204
+ "\\u2066-\\u2069",
4205
+ // bidi isolates
4206
+ "\\uFEFF]"
4207
+ // zero-width no-break space / BOM
4208
+ ].join(""),
4209
+ "g"
4210
+ );
4211
+ var DEFAULT_TEXT_LIMIT = 128;
4212
+ function sanitizeText(value, maxLength = DEFAULT_TEXT_LIMIT) {
4213
+ if (typeof value !== "string") return "";
4214
+ const cleaned = value.replace(UNSAFE_CHARS, "").trim();
4215
+ if (cleaned.length <= maxLength) return cleaned;
4216
+ return `${cleaned.slice(0, Math.max(0, maxLength - 1))}\u2026`;
4217
+ }
4218
+
4219
+ // src/errors/decode.ts
4165
4220
  var SOLIDITY_ERROR_SELECTOR = "0x08c379a0";
4166
4221
  var SOLIDITY_PANIC_SELECTOR = "0x4e487b71";
4222
+ var REVERT_TEXT_LIMIT = 256;
4167
4223
  var registry = null;
4168
4224
  function buildRegistry() {
4169
4225
  const map = /* @__PURE__ */ new Map();
@@ -4253,7 +4309,7 @@ function formatArgs(fragment, args) {
4253
4309
  if (args.length === 0) return "";
4254
4310
  const parts = fragment.inputs.map((input, i) => {
4255
4311
  const value = args[i];
4256
- const rendered = typeof value === "bigint" ? value.toString() : String(value);
4312
+ const rendered = typeof value === "bigint" ? value.toString() : typeof value === "string" ? sanitizeText(value, REVERT_TEXT_LIMIT) : String(value);
4257
4313
  return input.name ? `${input.name}: ${rendered}` : rendered;
4258
4314
  });
4259
4315
  return `(${parts.join(", ")})`;
@@ -4269,7 +4325,7 @@ function decodeRevert(data) {
4269
4325
  name: "Error",
4270
4326
  args: [reason],
4271
4327
  selector,
4272
- message: String(reason)
4328
+ message: sanitizeText(reason, REVERT_TEXT_LIMIT)
4273
4329
  };
4274
4330
  } catch {
4275
4331
  return void 0;
@@ -4357,6 +4413,7 @@ var MAX_EXECUTION_DELAY = 30 * 24 * 60 * 60;
4357
4413
  var MAX_OWNERS = 20;
4358
4414
  var MAX_MODULES = 50;
4359
4415
  var SENTINEL_MODULES = "0x0000000000000000000000000000000000000001";
4416
+ var ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
4360
4417
  var selfCall = {
4361
4418
  addOwner: (owner) => vault.encodeFunctionData("addOwner", [owner]),
4362
4419
  removeOwner: (owner) => vault.encodeFunctionData("removeOwner", [owner]),
@@ -4524,7 +4581,7 @@ var syncStrategy = {
4524
4581
  async mine({ factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal }) {
4525
4582
  const startedAt = Date.now();
4526
4583
  for (let attempts = 0; attempts < maxAttempts; ) {
4527
- if (signal?.aborted) throw new SaltMiningError("Salt mining was aborted.");
4584
+ if (signal?.aborted) throw new AbortError("Salt mining");
4528
4585
  if (Date.now() - startedAt > timeoutMs) {
4529
4586
  throw new SaltMiningError(
4530
4587
  `Salt mining timed out after ${timeoutMs} ms (${attempts} attempts).`,
@@ -4552,109 +4609,127 @@ var syncStrategy = {
4552
4609
  );
4553
4610
  }
4554
4611
  };
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);
4612
+ var WorkersUnavailable = class extends Error {
4613
+ };
4614
+ async function loadNodeWorkers() {
4615
+ try {
4616
+ const { Worker } = await import("worker_threads");
4617
+ const os = await import("os");
4618
+ return { Worker, parallelism: (os.availableParallelism ?? (() => os.cpus().length))() };
4619
+ } catch {
4620
+ return null;
4621
+ }
4622
+ }
4623
+ function createWorkerThreadsStrategy(load = loadNodeWorkers) {
4624
+ return {
4625
+ name: "worker_threads",
4626
+ async mine(job) {
4627
+ const runtime = await load();
4628
+ if (!runtime) return syncStrategy.mine(job);
4629
+ try {
4630
+ return await mineInWorkers(runtime.Worker, runtime.parallelism, job);
4631
+ } catch (err) {
4632
+ if (err instanceof WorkersUnavailable) return syncStrategy.mine(job);
4633
+ throw err;
4634
+ }
4566
4635
  }
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 });
4636
+ };
4637
+ }
4638
+ var workerThreadsStrategy = createWorkerThreadsStrategy();
4639
+ function mineInWorkers(Worker, parallelism, job) {
4640
+ const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal } = job;
4641
+ const workerCount = Math.max(1, Math.min(8, parallelism - 1));
4642
+ const perWorker = Math.ceil(maxAttempts / workerCount);
4643
+ const startedAt = Date.now();
4644
+ const workerSource = `
4645
+ const { parentPort, workerData } = require('node:worker_threads');
4646
+ const { getCreate2Address, hexlify, isQuaiAddress, keccak256, randomBytes, solidityPacked } = require('quais');
4647
+ const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts } = workerData;
4648
+ for (let i = 0; i < maxAttempts; i++) {
4649
+ const salt = hexlify(randomBytes(32));
4650
+ const fullSalt = keccak256(solidityPacked(['address', 'bytes32'], [deployer, salt]));
4651
+ const address = getCreate2Address(factory, fullSalt, bytecodeHash);
4652
+ if (address.toLowerCase().startsWith(targetPrefix) && isQuaiAddress(address)) {
4653
+ parentPort.postMessage({ type: 'result', salt, address, attempts: i + 1 });
4654
+ return;
4584
4655
  }
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
- )
4656
+ if ((i + 1) % ${PROGRESS_INTERVAL} === 0) parentPort.postMessage({ type: 'progress', attempts: i + 1 });
4657
+ }
4658
+ parentPort.postMessage({ type: 'exhausted', attempts: maxAttempts });
4659
+ `;
4660
+ return new Promise((resolve, reject) => {
4661
+ const workers = [];
4662
+ let settled = false;
4663
+ let exhausted = 0;
4664
+ let totalAttempts = 0;
4665
+ const progressByWorker = new Array(workerCount).fill(0);
4666
+ const cleanup = () => {
4667
+ clearTimeout(timer);
4668
+ signal?.removeEventListener("abort", onAbort);
4669
+ for (const w of workers) void w.terminate();
4670
+ };
4671
+ const settle = (fn) => {
4672
+ if (settled) return;
4673
+ settled = true;
4674
+ cleanup();
4675
+ fn();
4676
+ };
4677
+ const timer = setTimeout(
4678
+ () => settle(
4679
+ () => reject(
4680
+ new SaltMiningError(
4681
+ `Salt mining timed out after ${timeoutMs} ms (~${totalAttempts} attempts across ${workerCount} workers).`,
4682
+ "Raise timeoutMs or maxAttempts."
4611
4683
  )
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, {
4684
+ )
4685
+ ),
4686
+ timeoutMs
4687
+ );
4688
+ const onAbort = () => settle(() => reject(new AbortError("Salt mining")));
4689
+ signal?.addEventListener("abort", onAbort, { once: true });
4690
+ for (let i = 0; i < workerCount; i++) {
4691
+ let worker;
4692
+ try {
4693
+ worker = new Worker(workerSource, {
4619
4694
  eval: true,
4620
4695
  workerData: { factory, deployer, bytecodeHash, targetPrefix, maxAttempts: perWorker }
4621
4696
  });
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") {
4697
+ } catch (err) {
4698
+ settle(() => reject(new WorkersUnavailable(String(err))));
4699
+ return;
4700
+ }
4701
+ workers.push(worker);
4702
+ worker.on("message", (msg) => {
4703
+ if (msg.type === "progress") {
4704
+ progressByWorker[i] = msg.attempts ?? 0;
4705
+ totalAttempts = progressByWorker.reduce((a, b) => a + b, 0);
4706
+ onProgress?.(totalAttempts);
4707
+ } else if (msg.type === "result") {
4708
+ settle(
4709
+ () => resolve({
4710
+ salt: msg.salt,
4711
+ predictedAddress: msg.address,
4712
+ attempts: totalAttempts + (msg.attempts ?? 0),
4713
+ durationMs: Date.now() - startedAt
4714
+ })
4715
+ );
4716
+ } else if (msg.type === "exhausted") {
4717
+ if (++exhausted === workerCount) {
4629
4718
  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
- )
4719
+ () => reject(
4720
+ new SaltMiningError(
4721
+ `No valid salt found for prefix ${targetPrefix} after ${maxAttempts} attempts.`,
4722
+ "Raise maxAttempts, or confirm the deployer address is on the shard you expect."
4645
4723
  )
4646
- );
4647
- }
4724
+ )
4725
+ );
4648
4726
  }
4649
- });
4650
- worker.on(
4651
- "error",
4652
- (err) => settle(() => reject(new SaltMiningError(`Salt mining worker failed: ${err.message}`)))
4653
- );
4654
- }
4655
- });
4656
- }
4657
- };
4727
+ }
4728
+ });
4729
+ worker.on("error", (err) => settle(() => reject(new WorkersUnavailable(err.message))));
4730
+ }
4731
+ });
4732
+ }
4658
4733
  function defaultStrategy() {
4659
4734
  const hasNode = typeof globalThis.process?.versions?.node === "string";
4660
4735
  return hasNode ? workerThreadsStrategy : syncStrategy;
@@ -4876,7 +4951,7 @@ function validateCreateParams(params) {
4876
4951
  owners.forEach((owner, i) => {
4877
4952
  assertQuaiAddress(owner, `owner[${i}]`);
4878
4953
  const key = owner.toLowerCase();
4879
- if (key === "0x0000000000000000000000000000000000000000" || key === "0x0000000000000000000000000000000000000001") {
4954
+ if (key === ZERO_ADDRESS || key === SENTINEL_MODULES) {
4880
4955
  throw new ValidationError(
4881
4956
  `Owner ${owner} is reserved \u2014 the zero address and the module sentinel 0x\u202601 are rejected.`
4882
4957
  );
@@ -4885,6 +4960,11 @@ function validateCreateParams(params) {
4885
4960
  seen.add(key);
4886
4961
  });
4887
4962
  if (params.initialModules?.length) {
4963
+ if (params.initialModules.length > MAX_MODULES) {
4964
+ throw new ValidationError(
4965
+ `A vault may have at most ${MAX_MODULES} modules (got ${params.initialModules.length}).`
4966
+ );
4967
+ }
4888
4968
  assertQuaiAddresses(params.initialModules, "initialModules");
4889
4969
  }
4890
4970
  if (params.initialDelegatecallTargets?.length) {
@@ -4922,7 +5002,8 @@ function extractCreatedVault(receipt, factoryAddress) {
4922
5002
  }
4923
5003
 
4924
5004
  // src/indexer/client.ts
4925
- var import_supabase_js = require("@supabase/supabase-js");
5005
+ var import_postgrest_js = require("@supabase/postgrest-js");
5006
+ var import_realtime_js = require("@supabase/realtime-js");
4926
5007
 
4927
5008
  // src/indexer/schemas.ts
4928
5009
  var schemas_exports = {};
@@ -5121,10 +5202,11 @@ function toNumber(value, fallback = 0) {
5121
5202
  }
5122
5203
 
5123
5204
  // src/indexer/client.ts
5124
- var SDK_VERSION = "0.1.0";
5205
+ var SDK_VERSION = true ? "0.2.0" : "dev";
5125
5206
  var IndexerClient = class {
5126
5207
  config;
5127
5208
  client;
5209
+ realtime = null;
5128
5210
  healthCache = null;
5129
5211
  inflightHealth = null;
5130
5212
  /** Health results are reused for this long to keep read paths cheap. */
@@ -5132,19 +5214,46 @@ var IndexerClient = class {
5132
5214
  constructor(config, options = {}) {
5133
5215
  this.config = config;
5134
5216
  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}` } }
5217
+ this.client = new import_postgrest_js.PostgrestClient(`${baseUrl(config.url)}/rest/v1`, {
5218
+ headers: {
5219
+ apikey: config.anonKey,
5220
+ Authorization: `Bearer ${config.anonKey}`,
5221
+ "x-client-info": `quaivault-sdk/${SDK_VERSION}`
5222
+ },
5223
+ schema: config.schema
5139
5224
  });
5140
5225
  }
5141
- /** Raw Supabase client, for queries the SDK does not wrap. */
5142
- get raw() {
5226
+ /** The PostgREST client, for queries the SDK does not wrap. */
5227
+ get rest() {
5143
5228
  return this.client;
5144
5229
  }
5145
5230
  from(table) {
5146
5231
  return this.client.from(table);
5147
5232
  }
5233
+ /**
5234
+ * Realtime client, constructed on first use.
5235
+ *
5236
+ * Lazy because it opens a WebSocket and starts a heartbeat, and the large majority
5237
+ * of SDK usage never calls `watch()`. Nothing here should cost a socket unless
5238
+ * somebody asked to follow a vault.
5239
+ */
5240
+ realtimeClient() {
5241
+ if (!this.realtime) {
5242
+ this.realtime = new import_realtime_js.RealtimeClient(
5243
+ `${baseUrl(this.config.url).replace(/^http/, "ws")}/realtime/v1`,
5244
+ { params: { apikey: this.config.anonKey } }
5245
+ );
5246
+ }
5247
+ return this.realtime;
5248
+ }
5249
+ /** Open a Realtime channel. See `watchVault`, which is what callers should use. */
5250
+ channel(name) {
5251
+ return this.realtimeClient().channel(name);
5252
+ }
5253
+ /** Close a channel opened by {@link channel}. */
5254
+ async removeChannel(channel) {
5255
+ await this.realtimeClient().removeChannel(channel);
5256
+ }
5148
5257
  /**
5149
5258
  * Run a query, parse each row, and surface failures as `IndexerQueryError`.
5150
5259
  *
@@ -5162,6 +5271,36 @@ var IndexerClient = class {
5162
5271
  }
5163
5272
  return (data ?? []).map((row) => schema.parse(row));
5164
5273
  }
5274
+ /**
5275
+ * Paged select: an exact `hasMore`, an approximate `total`.
5276
+ *
5277
+ * Both halves of that are deliberate. An `exact` count makes Postgres scan every
5278
+ * matching row on every page request, which is invisible on a young vault and
5279
+ * quadratic on a busy one — so the count is `estimated`, cheap and good enough for
5280
+ * "roughly how much history is there".
5281
+ *
5282
+ * But `hasMore` derived from an estimate would be a lie a caller can act on: a
5283
+ * paging loop that stops early drops real rows. So callers request one row beyond
5284
+ * the page and this trims it, which answers "is there another page" exactly, for
5285
+ * the cost of a single extra row.
5286
+ */
5287
+ async selectPage(table, schema, limit, build) {
5288
+ const { data, error, count } = await build(this.client.from(table));
5289
+ if (error) {
5290
+ throw new IndexerQueryError(`Indexer query on "${table}" failed: ${error.message}`, error);
5291
+ }
5292
+ const rows = data ?? [];
5293
+ const hasMore = rows.length > limit;
5294
+ const parsed = (hasMore ? rows.slice(0, limit) : rows).map(
5295
+ (row) => schema.parse(row)
5296
+ );
5297
+ return {
5298
+ data: parsed,
5299
+ // Never report a total below what was just handed out, however the estimate lands.
5300
+ total: Math.max(count ?? 0, parsed.length),
5301
+ hasMore
5302
+ };
5303
+ }
5165
5304
  /** Single-row variant. Returns null for PostgREST's "no rows" code. */
5166
5305
  async selectOne(table, schema, build) {
5167
5306
  const { data, error } = await build(this.client.from(table));
@@ -5216,7 +5355,7 @@ var IndexerClient = class {
5216
5355
  const pollIntervalMs = options.pollIntervalMs ?? 1500;
5217
5356
  const deadline = Date.now() + timeoutMs;
5218
5357
  for (; ; ) {
5219
- if (options.signal?.aborted) throw new Error("Aborted");
5358
+ if (options.signal?.aborted) throw new AbortError("Waiting for the indexer");
5220
5359
  const state = await this.state();
5221
5360
  const head = state?.lastIndexedBlock ?? 0;
5222
5361
  if (head >= blockNumber) return { reached: true, lastIndexedBlock: head };
@@ -5228,12 +5367,12 @@ var IndexerClient = class {
5228
5367
  }
5229
5368
  async probeHealth() {
5230
5369
  if (this.config.healthUrl) {
5370
+ const controller = new AbortController();
5371
+ const timer = setTimeout(() => controller.abort(), 5e3);
5231
5372
  try {
5232
- const controller = new AbortController();
5233
- const timer = setTimeout(() => controller.abort(), 5e3);
5234
5373
  const res = await fetch(new URL("/health", this.config.healthUrl), {
5235
5374
  signal: controller.signal
5236
- }).finally(() => clearTimeout(timer));
5375
+ });
5237
5376
  const body = await res.json();
5238
5377
  const d = body.details ?? {};
5239
5378
  return {
@@ -5245,6 +5384,8 @@ var IndexerClient = class {
5245
5384
  ...res.ok ? {} : { error: `health endpoint returned ${res.status}` }
5246
5385
  };
5247
5386
  } catch {
5387
+ } finally {
5388
+ clearTimeout(timer);
5248
5389
  }
5249
5390
  }
5250
5391
  try {
@@ -5267,10 +5408,14 @@ var IndexerClient = class {
5267
5408
  }
5268
5409
  }
5269
5410
  };
5411
+ function baseUrl(url) {
5412
+ return url.replace(/\/+$/, "");
5413
+ }
5270
5414
 
5271
5415
  // src/indexer/queries.ts
5272
5416
  var DEFAULT_LIMIT = 50;
5273
5417
  var MAX_LIMIT = 200;
5418
+ var CONFIRMATION_CHUNK = 40;
5274
5419
  function lower(address2) {
5275
5420
  return address2.toLowerCase();
5276
5421
  }
@@ -5329,14 +5474,40 @@ var IndexerQueries = class {
5329
5474
  (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("tx_hash", txHash.toLowerCase()).single()
5330
5475
  );
5331
5476
  }
5477
+ /**
5478
+ * Several transactions by hash, chunked for the same reasons as
5479
+ * {@link activeConfirmationsBatch} — hashes are long, and they go in the query string.
5480
+ *
5481
+ * Rows come back in no particular order and hashes with no row are simply absent;
5482
+ * the caller matches them up.
5483
+ */
5484
+ async transactionsByHash(vault2, txHashes) {
5485
+ if (txHashes.length === 0) return [];
5486
+ const hashes = txHashes.map((h) => h.toLowerCase());
5487
+ const chunks = [];
5488
+ for (let i = 0; i < hashes.length; i += CONFIRMATION_CHUNK) {
5489
+ chunks.push(hashes.slice(i, i + CONFIRMATION_CHUNK));
5490
+ }
5491
+ const pages = await Promise.all(
5492
+ chunks.map(
5493
+ (chunk) => this.client.select(
5494
+ "transactions",
5495
+ TransactionSchema,
5496
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).in("tx_hash", chunk)
5497
+ )
5498
+ )
5499
+ );
5500
+ return pages.flat();
5501
+ }
5332
5502
  async transactionHistory(vault2, options = {}) {
5333
5503
  const { limit, offset } = bounds(options);
5334
5504
  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 };
5505
+ return this.client.selectPage(
5506
+ "transactions",
5507
+ TransactionSchema,
5508
+ limit,
5509
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).in("status", statuses).order("submitted_at_block", { ascending: false }).range(offset, offset + limit)
5510
+ );
5340
5511
  }
5341
5512
  /** All confirmations for one transaction, including revoked ones. */
5342
5513
  async confirmations(vault2, txHash) {
@@ -5347,26 +5518,41 @@ var IndexerQueries = class {
5347
5518
  );
5348
5519
  }
5349
5520
  /**
5350
- * Active confirmations for many transactions in one round trip.
5521
+ * Active confirmations for many transactions, in as few round trips as is safe.
5351
5522
  *
5352
5523
  * "Active" here means not revoked. It does NOT mean the confirming address is
5353
5524
  * still an owner — the indexer does not deactivate confirmations when an owner is
5354
5525
  * removed, while the contract invalidates them via approval epochs. Callers must
5355
5526
  * intersect with the active owner set; `Vault` does this for you.
5527
+ *
5528
+ * Chunked rather than issued as one `in(...)`, because a single filter fails two
5529
+ * ways at scale. PostgREST reads filters from the query string, and each 32-byte
5530
+ * hash costs ~70 characters there — a full {@link MAX_LIMIT} page of hashes builds a
5531
+ * ~14 KB request line, past the 8 KB cap most reverse proxies apply by default. The
5532
+ * response is bounded too: {@link CONFIRMATION_CHUNK} transactions can carry at most
5533
+ * `chunk × MAX_OWNERS` rows, and the chunk is sized to keep that under the
5534
+ * `max-rows` ceiling PostgREST deployments commonly set, so a busy vault cannot be
5535
+ * silently short-served.
5356
5536
  */
5357
5537
  async activeConfirmationsBatch(vault2, txHashes) {
5358
5538
  const result = /* @__PURE__ */ new Map();
5359
5539
  for (const hash of txHashes) result.set(hash.toLowerCase(), []);
5360
5540
  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)
5541
+ const hashes = txHashes.map((h) => h.toLowerCase());
5542
+ const chunks = [];
5543
+ for (let i = 0; i < hashes.length; i += CONFIRMATION_CHUNK) {
5544
+ chunks.push(hashes.slice(i, i + CONFIRMATION_CHUNK));
5545
+ }
5546
+ const pages = await Promise.all(
5547
+ chunks.map(
5548
+ (chunk) => this.client.select(
5549
+ "confirmations",
5550
+ ConfirmationSchema,
5551
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).in("tx_hash", chunk).eq("is_active", true)
5552
+ )
5553
+ )
5368
5554
  );
5369
- for (const row of rows) {
5555
+ for (const row of pages.flat()) {
5370
5556
  const key = row.tx_hash.toLowerCase();
5371
5557
  const list = result.get(key);
5372
5558
  if (list) list.push(row);
@@ -5394,19 +5580,52 @@ var IndexerQueries = class {
5394
5580
  // ---- value movement ------------------------------------------------------
5395
5581
  async deposits(vault2, options = {}) {
5396
5582
  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 };
5583
+ return this.client.selectPage(
5584
+ "deposits",
5585
+ DepositSchema,
5586
+ limit,
5587
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).order("deposited_at_block", { ascending: false }).range(offset, offset + limit)
5588
+ );
5402
5589
  }
5403
5590
  async tokenTransfers(vault2, options = {}) {
5404
5591
  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 };
5592
+ return this.client.selectPage(
5593
+ "token_transfers",
5594
+ TokenTransferSchema,
5595
+ limit,
5596
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).order("block_number", { ascending: false }).range(offset, offset + limit)
5597
+ );
5598
+ }
5599
+ /**
5600
+ * Scan up to `budget` of the most recent transfer rows, paging past {@link MAX_LIMIT}.
5601
+ *
5602
+ * `tokenTransfers` clamps a single request, so a caller asking for more than the cap
5603
+ * silently received a short page — and then computed "did I see everything?" from
5604
+ * that short page's `hasMore`, which reported truncation the caller had not actually
5605
+ * hit. Paging here keeps the caller's budget meaningful and makes the returned
5606
+ * `hasMore` mean what it says: rows exist beyond the budget that was scanned.
5607
+ *
5608
+ * Pages by offset over a descending order, so a transfer landing mid-scan can shift
5609
+ * rows by one. That is acceptable for token *discovery* — a duplicate collapses into
5610
+ * the same map key and a missed row costs at most one candidate that the next call
5611
+ * picks up. Do not reuse this for anything that must see each row exactly once.
5612
+ */
5613
+ async tokenTransferScan(vault2, budget) {
5614
+ const target = Math.max(1, Math.floor(budget));
5615
+ const data = [];
5616
+ let total = 0;
5617
+ let hasMore = false;
5618
+ while (data.length < target) {
5619
+ const page = await this.tokenTransfers(vault2, {
5620
+ limit: Math.min(MAX_LIMIT, target - data.length),
5621
+ offset: data.length
5622
+ });
5623
+ data.push(...page.data);
5624
+ total = page.total;
5625
+ hasMore = page.hasMore;
5626
+ if (page.data.length === 0 || !page.hasMore) break;
5627
+ }
5628
+ return { data, total, hasMore };
5410
5629
  }
5411
5630
  async tokens(addresses) {
5412
5631
  if (addresses.length === 0) return [];
@@ -5629,8 +5848,6 @@ function deriveRecoveryStatus(state, at = nowSeconds()) {
5629
5848
  }
5630
5849
 
5631
5850
  // src/recovery.ts
5632
- var SENTINEL = "0x0000000000000000000000000000000000000001";
5633
- var ZERO = "0x0000000000000000000000000000000000000000";
5634
5851
  var RecoveryModule = class {
5635
5852
  constructor(ctx) {
5636
5853
  this.ctx = ctx;
@@ -5652,14 +5869,23 @@ var RecoveryModule = class {
5652
5869
  get vault() {
5653
5870
  return this.ctx.vaultAddress;
5654
5871
  }
5872
+ /**
5873
+ * The vault this module acts on, through the same retrying facade every other vault
5874
+ * read in the SDK uses. Recovery reaches into the vault for three checks — is the
5875
+ * module enabled, is the caller an owner, who are the current owners — and each of
5876
+ * them gates a write, so none of them should be the one read that silently skips
5877
+ * transient-failure handling.
5878
+ */
5879
+ vaultContract() {
5880
+ return new VaultContract(this.ctx.connection.vault(this.vault), this.ctx.connection.retry);
5881
+ }
5655
5882
  // =========================================================================
5656
5883
  // Reads
5657
5884
  // =========================================================================
5658
5885
  /** Whether the module is currently enabled on the vault. */
5659
5886
  async isEnabled() {
5660
5887
  if (!this.ctx.moduleAddress) return false;
5661
- const vault2 = this.ctx.connection.vault(this.vault);
5662
- return vault2.getFunction("isModuleEnabled(address)")(this.address);
5888
+ return this.vaultContract().isModuleEnabled(this.address);
5663
5889
  }
5664
5890
  async config() {
5665
5891
  const raw = await this.contract().getRecoveryConfig(this.vault);
@@ -5717,7 +5943,7 @@ var RecoveryModule = class {
5717
5943
  */
5718
5944
  async history(options = {}) {
5719
5945
  const queries = this.ctx.queries;
5720
- if (!queries) return [];
5946
+ if (!queries) throw new NoIndexerError("Listing recovery history");
5721
5947
  const rows = await queries.recoveryHistory(this.vault, options);
5722
5948
  return rows.map((row) => ({
5723
5949
  hash: row.recovery_hash,
@@ -5853,14 +6079,13 @@ var RecoveryModule = class {
5853
6079
  { retryableAt: request.executionTime }
5854
6080
  );
5855
6081
  }
5856
- const vault2 = this.ctx.connection.vault(this.vault);
5857
- const currentOwners = await vault2.getFunction("getOwners()")();
6082
+ const currentOwners = await this.vaultContract().getOwners();
5858
6083
  const current = new Set(Array.from(currentOwners).map((o) => String(o).toLowerCase()));
5859
6084
  const overlap = request.newOwners.filter((o) => current.has(o.toLowerCase())).length;
5860
6085
  const peak = current.size + request.newOwners.length - overlap;
5861
- if (peak > 20) {
6086
+ if (peak > MAX_OWNERS) {
5862
6087
  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.`,
6088
+ `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
6089
  {
5865
6090
  remediation: "Run a recovery that retains at least one existing owner, then a second recovery to replace it."
5866
6091
  }
@@ -5875,8 +6100,7 @@ var RecoveryModule = class {
5875
6100
  async cancel(recoveryHash) {
5876
6101
  const hash = normalizeTxHash(recoveryHash, "recovery hash");
5877
6102
  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);
6103
+ const isOwner = await this.vaultContract().isOwner(caller);
5880
6104
  if (!isOwner) {
5881
6105
  throw new PreconditionError(
5882
6106
  `Only a current owner of the vault can cancel a recovery. ${caller} is not one.`
@@ -5907,7 +6131,7 @@ var RecoveryModule = class {
5907
6131
  this.isGuardian(address2),
5908
6132
  this.contract().recoveryApprovals(this.vault, hash, address2),
5909
6133
  this.isEnabled(),
5910
- this.ctx.connection.vault(this.vault).getFunction("isOwner(address)")(address2)
6134
+ this.vaultContract().isOwner(address2)
5911
6135
  ]);
5912
6136
  const now = nowSeconds();
5913
6137
  const terminal = request.status === "executed" || request.status === "cancelled";
@@ -6032,7 +6256,7 @@ var RecoveryModule = class {
6032
6256
  const seen = /* @__PURE__ */ new Set();
6033
6257
  for (const owner of newOwners) {
6034
6258
  const key = owner.toLowerCase();
6035
- if (key === ZERO || key === SENTINEL) {
6259
+ if (key === ZERO_ADDRESS || key === SENTINEL_MODULES) {
6036
6260
  throw new ValidationError(
6037
6261
  `New owner ${owner} is reserved \u2014 the zero address and the module sentinel 0x\u202601 are rejected.`
6038
6262
  );
@@ -6114,15 +6338,60 @@ function terminalReason(request) {
6114
6338
 
6115
6339
  // src/balances.ts
6116
6340
  var import_quais10 = require("quais");
6341
+
6342
+ // src/chain/token-contract.ts
6343
+ var TOKEN_RETRY = { maxAttempts: 2, baseDelayMs: 150 };
6344
+ var TokenContract = class {
6345
+ constructor(contract, retry = {}) {
6346
+ this.contract = contract;
6347
+ this.retry = { ...TOKEN_RETRY, ...retry };
6348
+ }
6349
+ contract;
6350
+ retry;
6351
+ read(signature, ...args) {
6352
+ return withRetry(() => this.contract.getFunction(signature)(...args), this.retry);
6353
+ }
6354
+ /** ERC20 units held, or — for ERC721 — the number of tokens held. */
6355
+ balanceOf(owner) {
6356
+ return this.read("balanceOf(address)", owner);
6357
+ }
6358
+ /** ERC721 only. Reverts for a burned or never-minted id. */
6359
+ ownerOf(tokenId) {
6360
+ return this.read("ownerOf(uint256)", tokenId);
6361
+ }
6362
+ /** ERC1155 only. One call for many ids, so it stays a single round trip. */
6363
+ balanceOfBatch(owners, ids) {
6364
+ return this.read("balanceOfBatch(address[],uint256[])", owners, ids);
6365
+ }
6366
+ };
6367
+
6368
+ // src/pool.ts
6369
+ async function mapPooled(items, limit, fn) {
6370
+ const results = new Array(items.length);
6371
+ let next = 0;
6372
+ const worker = async () => {
6373
+ for (; ; ) {
6374
+ const i = next++;
6375
+ if (i >= items.length) return;
6376
+ results[i] = await fn(items[i], i);
6377
+ }
6378
+ };
6379
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
6380
+ return results;
6381
+ }
6382
+ var DEFAULT_CONCURRENCY = 8;
6383
+
6384
+ // src/balances.ts
6117
6385
  async function loadBalances(connection, queries, vault2, options = {}) {
6118
6386
  const verify = options.verify !== false;
6119
6387
  const maxTokens = options.maxTokens ?? 50;
6120
6388
  const transferScanLimit = options.transferScanLimit ?? 500;
6121
6389
  const maxTokenIdChecks = options.maxTokenIdChecks ?? 100;
6390
+ const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
6122
6391
  const address2 = (0, import_quais10.getAddress)(vault2);
6123
6392
  const native = BigInt(await connection.provider.getBalance(address2));
6124
6393
  if (!queries) return { native, tokens: [] };
6125
- const transfers = await queries.tokenTransfers(address2, { limit: transferScanLimit });
6394
+ const transfers = await queries.tokenTransferScan(address2, transferScanLimit);
6126
6395
  const seen = /* @__PURE__ */ new Map();
6127
6396
  for (const row of transfers.data) {
6128
6397
  const key = row.token_address.toLowerCase();
@@ -6141,17 +6410,18 @@ async function loadBalances(connection, queries, vault2, options = {}) {
6141
6410
  }
6142
6411
  const metadata = await queries.tokens(candidates);
6143
6412
  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
- )
6413
+ const balances = await mapPooled(
6414
+ candidates,
6415
+ concurrency,
6416
+ (token2) => buildBalance(
6417
+ connection,
6418
+ address2,
6419
+ token2,
6420
+ byAddress.get(token2),
6421
+ seen.get(token2) ?? [],
6422
+ verify,
6423
+ maxTokenIdChecks,
6424
+ concurrency
6155
6425
  )
6156
6426
  );
6157
6427
  return {
@@ -6161,30 +6431,35 @@ async function loadBalances(connection, queries, vault2, options = {}) {
6161
6431
  ...Object.keys(truncated).length ? { truncated } : {}
6162
6432
  };
6163
6433
  }
6164
- async function buildBalance(connection, vault2, token2, meta, transfers, verify, maxTokenIdChecks) {
6434
+ async function buildBalance(connection, vault2, token2, meta, transfers, verify, maxTokenIdChecks, concurrency) {
6165
6435
  const standard = meta?.standard ?? inferStandard(transfers);
6166
6436
  const replayed = replayBalance(transfers, standard);
6167
6437
  const base = {
6168
6438
  token: (0, import_quais10.getAddress)(token2),
6169
6439
  standard,
6170
- symbol: meta?.symbol ?? "???",
6171
- name: meta?.name ?? "Unknown token",
6440
+ // Deployer-controlled strings headed for someone's terminal. See `src/text.ts`.
6441
+ symbol: sanitizeText(meta?.symbol, 32) || "???",
6442
+ name: sanitizeText(meta?.name, 64) || "Unknown token",
6172
6443
  decimals: meta?.decimals ?? (standard === "ERC20" ? 18 : 0),
6173
6444
  balance: replayed.balance,
6174
6445
  ...replayed.tokenIds.length > 0 ? { tokenIds: replayed.tokenIds } : {},
6175
6446
  verified: false
6176
6447
  };
6177
6448
  if (!verify) return base;
6449
+ const contract = new TokenContract(connection.token(token2, standard), connection.retry);
6178
6450
  try {
6179
6451
  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 };
6452
+ return { ...base, balance: BigInt(await contract.balanceOf(vault2)), verified: true };
6183
6453
  }
6184
6454
  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);
6455
+ const count = await contract.balanceOf(vault2);
6456
+ const owned = await filterOwned(
6457
+ contract,
6458
+ vault2,
6459
+ replayed.tokenIds,
6460
+ maxTokenIdChecks,
6461
+ concurrency
6462
+ );
6188
6463
  return {
6189
6464
  ...base,
6190
6465
  balance: BigInt(count),
@@ -6193,10 +6468,9 @@ async function buildBalance(connection, vault2, token2, meta, transfers, verify,
6193
6468
  verified: true
6194
6469
  };
6195
6470
  }
6196
- const contract = new import_quais10.Contract(token2, Erc1155Abi, connection.provider);
6197
6471
  const ids = replayed.tokenIds;
6198
6472
  if (ids.length === 0) return { ...base, verified: true };
6199
- const amounts = await contract.getFunction("balanceOfBatch(address[],uint256[])")(
6473
+ const amounts = await contract.balanceOfBatch(
6200
6474
  ids.map(() => vault2),
6201
6475
  ids
6202
6476
  );
@@ -6241,18 +6515,16 @@ function replayBalance(transfers, standard) {
6241
6515
  }
6242
6516
  return { balance, tokenIds };
6243
6517
  }
6244
- async function filterOwned(contract, vault2, ids, limit) {
6518
+ async function filterOwned(contract, vault2, ids, limit, concurrency) {
6245
6519
  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
- );
6520
+ const results = await mapPooled(ids.slice(0, limit), concurrency, async (id) => {
6521
+ try {
6522
+ const owner = await contract.ownerOf(id);
6523
+ return owner.toLowerCase() === vault2.toLowerCase() ? id : null;
6524
+ } catch {
6525
+ return null;
6526
+ }
6527
+ });
6256
6528
  return results.filter((id) => id !== null);
6257
6529
  }
6258
6530
  function inferStandard(transfers) {
@@ -6288,7 +6560,7 @@ function watchVault(client, vault2, handler, options = {}) {
6288
6560
  const topics = options.topics ?? DEFAULT_TOPICS;
6289
6561
  const address2 = vault2.toLowerCase();
6290
6562
  const schema = client.config.schema;
6291
- const channel = client.raw.channel(`quaivault:${schema}:${address2}`);
6563
+ const channel = client.channel(`quaivault:${schema}:${address2}`);
6292
6564
  for (const topic of topics) {
6293
6565
  const table = TABLE_FOR[topic];
6294
6566
  channel.on(
@@ -6318,7 +6590,7 @@ function watchVault(client, vault2, handler, options = {}) {
6318
6590
  return {
6319
6591
  topics,
6320
6592
  async unsubscribe() {
6321
- await client.raw.removeChannel(channel);
6593
+ await client.removeChannel(channel);
6322
6594
  }
6323
6595
  };
6324
6596
  }
@@ -6798,7 +7070,7 @@ function extractProposedTxHash(receipt, vault2) {
6798
7070
  }
6799
7071
 
6800
7072
  // src/vault.ts
6801
- var Vault = class {
7073
+ var Vault = class _Vault {
6802
7074
  address;
6803
7075
  /** Guardian-based recovery for this vault. */
6804
7076
  recovery;
@@ -6841,6 +7113,21 @@ var Vault = class {
6841
7113
  if (!this.ctx.queries) throw new NoIndexerError(operation);
6842
7114
  return this.ctx.queries;
6843
7115
  }
7116
+ /**
7117
+ * The indexer's head, for stamping onto records read from it.
7118
+ *
7119
+ * Reads the cached health result rather than querying `indexer_state` directly.
7120
+ * `useIndexer()` has just called `health()` on this same code path and the result is
7121
+ * cached, so this is free — whereas a `state()` call is a second round trip per
7122
+ * hydration, paid on every indexed read purely to fill one advisory field.
7123
+ *
7124
+ * Undefined when the indexer is not answering: a head of 0 would read as "indexed at
7125
+ * genesis" rather than "unknown".
7126
+ */
7127
+ async indexerHead() {
7128
+ const health = await this.ctx.indexer?.health();
7129
+ return health?.available ? health.lastIndexedBlock : void 0;
7130
+ }
6844
7131
  contract(write = false) {
6845
7132
  return new VaultContract(this.ctx.connection.vault(this.address, write), this.ctx.connection.retry);
6846
7133
  }
@@ -6868,8 +7155,48 @@ var Vault = class {
6868
7155
  balance: BigInt(balance)
6869
7156
  };
6870
7157
  }
6871
- /** Active owners. Prefers the indexer, falls back to chain. */
7158
+ /**
7159
+ * Capture owners and threshold once, for reuse across a burst of reads.
7160
+ *
7161
+ * Pair with {@link pinned}:
7162
+ *
7163
+ * ```ts
7164
+ * const view = await vault.view();
7165
+ * const pinned = vault.pinned(view);
7166
+ * // every read below shares one owner/threshold read
7167
+ * const txs = await pinned.transactions(hashes);
7168
+ * ```
7169
+ */
7170
+ async view() {
7171
+ const fromIndexer = await this.useIndexer();
7172
+ const [owners, threshold, indexedAtBlock] = await Promise.all([
7173
+ this.owners(),
7174
+ this.threshold(),
7175
+ this.indexerHead()
7176
+ ]);
7177
+ return {
7178
+ owners,
7179
+ threshold,
7180
+ ...indexedAtBlock !== void 0 ? { indexedAtBlock } : {},
7181
+ capturedAt: nowSeconds(),
7182
+ source: fromIndexer ? "indexer" : "chain"
7183
+ };
7184
+ }
7185
+ /**
7186
+ * A handle that answers {@link owners} and {@link threshold} from `view` instead of
7187
+ * re-reading them.
7188
+ *
7189
+ * Reads only. Write preconditions go through `chainOwners` / `chainThreshold`, which
7190
+ * bypass the snapshot by construction — pinning a view can never cause this SDK to
7191
+ * sign against a stale owner set. Everything else about the handle, including the
7192
+ * connection and the recovery module, is shared with the original.
7193
+ */
7194
+ pinned(view) {
7195
+ return new _Vault(this.address, { ...this.ctx, view });
7196
+ }
7197
+ /** Active owners. Prefers a pinned view, then the indexer, then chain. */
6872
7198
  async owners() {
7199
+ if (this.ctx.view) return this.ctx.view.owners;
6873
7200
  if (await this.useIndexer()) {
6874
7201
  try {
6875
7202
  const owners = await this.requireQueries("owners").owners(this.address);
@@ -6877,12 +7204,40 @@ var Vault = class {
6877
7204
  } catch {
6878
7205
  }
6879
7206
  }
7207
+ return this.chainOwners();
7208
+ }
7209
+ /**
7210
+ * Owners straight from the chain, bypassing the indexer entirely.
7211
+ *
7212
+ * {@link owners} prefers the indexer, which is right for display and wrong for
7213
+ * anything that gates a write. A lagging indexer would let the SDK build a proposal
7214
+ * against an owner set that no longer exists — admitting a `removeOwner` that drops
7215
+ * the vault below its threshold, or rejecting one that is perfectly valid. Every
7216
+ * propose-time precondition reads through here instead.
7217
+ */
7218
+ async chainOwners() {
6880
7219
  return (await this.contract().getOwners()).map((o) => (0, import_quais14.getAddress)(String(o)));
6881
7220
  }
6882
7221
  async isOwner(address2) {
6883
7222
  return this.contract().isOwner((0, import_quais14.getAddress)(address2));
6884
7223
  }
7224
+ /** Approvals required to execute. Prefers a pinned view, then the indexer, then chain. */
6885
7225
  async threshold() {
7226
+ if (this.ctx.view) return this.ctx.view.threshold;
7227
+ if (await this.useIndexer()) {
7228
+ try {
7229
+ const wallet = await this.requireQueries("threshold").wallet(this.address);
7230
+ if (wallet) return wallet.threshold;
7231
+ } catch {
7232
+ }
7233
+ }
7234
+ return this.chainThreshold();
7235
+ }
7236
+ /**
7237
+ * Threshold straight from the chain. The write-path counterpart to {@link threshold},
7238
+ * for the same reason {@link chainOwners} exists.
7239
+ */
7240
+ async chainThreshold() {
6886
7241
  return Number(await this.contract().threshold());
6887
7242
  }
6888
7243
  async balance() {
@@ -6935,6 +7290,53 @@ var Vault = class {
6935
7290
  }
6936
7291
  return this.fromChain(hash);
6937
7292
  }
7293
+ /**
7294
+ * Several transactions at once, keyed by hash.
7295
+ *
7296
+ * The plural form exists because the singular one is expensive to loop: each
7297
+ * `transaction()` re-reads the owner set, the threshold and the indexer head, so
7298
+ * fetching fifty costs fifty times that. This resolves the whole set the way the
7299
+ * listing methods already do — one query for the rows, one owner/threshold read
7300
+ * shared across them, one batched confirmations query.
7301
+ *
7302
+ * Hashes the indexer does not have fall back to chain reads, bounded by a pool.
7303
+ * Hashes that exist nowhere are simply absent from the map rather than throwing:
7304
+ * one unknown hash should not lose the caller the other forty-nine.
7305
+ */
7306
+ async transactions(txHashes) {
7307
+ const hashes = [...new Set(txHashes.map((h) => normalizeTxHash(h)))];
7308
+ const found = /* @__PURE__ */ new Map();
7309
+ if (hashes.length === 0) return found;
7310
+ if (await this.useIndexer()) {
7311
+ try {
7312
+ const queries = this.requireQueries("Reading transactions");
7313
+ const [rows, owners, threshold] = await Promise.all([
7314
+ queries.transactionsByHash(this.address, hashes),
7315
+ this.owners(),
7316
+ this.threshold()
7317
+ ]);
7318
+ for (const tx of await this.hydrateRows(rows, owners, threshold)) {
7319
+ found.set(tx.hash.toLowerCase(), tx);
7320
+ }
7321
+ } catch {
7322
+ }
7323
+ }
7324
+ const missing = hashes.filter((hash) => !found.has(hash));
7325
+ if (missing.length > 0) {
7326
+ const resolved = await mapPooled(missing, DEFAULT_CONCURRENCY, async (hash) => {
7327
+ try {
7328
+ return await this.fromChain(hash);
7329
+ } catch (err) {
7330
+ if (err instanceof NotFoundError) return null;
7331
+ throw err;
7332
+ }
7333
+ });
7334
+ for (const tx of resolved) {
7335
+ if (tx) found.set(tx.hash.toLowerCase(), tx);
7336
+ }
7337
+ }
7338
+ return found;
7339
+ }
6938
7340
  async pendingTransactions(options = {}) {
6939
7341
  const queries = this.requireQueries("Listing pending transactions");
6940
7342
  const [rows, owners, threshold] = await Promise.all([
@@ -6965,7 +7367,7 @@ var Vault = class {
6965
7367
  this.address,
6966
7368
  rows.map((r) => r.tx_hash)
6967
7369
  );
6968
- const indexedAtBlock = (await this.ctx.indexer?.state())?.lastIndexedBlock;
7370
+ const indexedAtBlock = await this.indexerHead();
6969
7371
  return rows.map((row) => {
6970
7372
  const confirmed = (confirmations.get(row.tx_hash.toLowerCase()) ?? []).map(
6971
7373
  (c) => c.owner_address
@@ -6988,7 +7390,7 @@ var Vault = class {
6988
7390
  ]);
6989
7391
  if (!row) return null;
6990
7392
  const confirmations = await queries.confirmations(this.address, hash);
6991
- const indexedAtBlock = (await this.ctx.indexer?.state())?.lastIndexedBlock;
7393
+ const indexedAtBlock = await this.indexerHead();
6992
7394
  return this.buildTransaction({
6993
7395
  row,
6994
7396
  confirmedBy: confirmations.filter((c) => c.is_active).map((c) => c.owner_address),
@@ -7051,7 +7453,10 @@ var Vault = class {
7051
7453
  value,
7052
7454
  data,
7053
7455
  proposer: (0, import_quais14.getAddress)(row.submitted_by),
7054
- proposedAt: toNumber(row.submitted_at_block),
7456
+ // The indexer records the block, not the chain timestamp — they are not
7457
+ // interchangeable, and reporting a block number as `proposedAt` renders as 1970.
7458
+ proposedAt: 0,
7459
+ proposedAtBlock: toNumber(row.submitted_at_block),
7055
7460
  kind: decodeResult.kind,
7056
7461
  decoded: decodeResult.decoded,
7057
7462
  summary: decodeResult.summary,
@@ -7142,6 +7547,18 @@ var Vault = class {
7142
7547
  */
7143
7548
  async affordances(txHash, caller) {
7144
7549
  const hash = normalizeTxHash(txHash);
7550
+ return this.resolveAffordances(hash, this.transaction(hash), caller);
7551
+ }
7552
+ /**
7553
+ * Shared body of {@link affordances}, taking the transaction either as a value or as
7554
+ * an in-flight promise.
7555
+ *
7556
+ * The promise form is the point: a caller that already holds the record (like
7557
+ * {@link describe}) passes it and skips a redundant fetch, while `affordances()`
7558
+ * passes the unawaited promise so the transaction read still overlaps the two
7559
+ * ownership probes rather than serialising behind them.
7560
+ */
7561
+ async resolveAffordances(hash, transaction, caller) {
7145
7562
  const who = caller ?? await this.ctx.connection.addressOrNull();
7146
7563
  if (!who) {
7147
7564
  throw new ValidationError(
@@ -7150,7 +7567,7 @@ var Vault = class {
7150
7567
  }
7151
7568
  const vault2 = this.contract();
7152
7569
  const [tx, isOwner, hasApproved] = await Promise.all([
7153
- this.transaction(hash),
7570
+ transaction,
7154
7571
  vault2.isOwner((0, import_quais14.getAddress)(who)),
7155
7572
  vault2.hasApproved(hash, (0, import_quais14.getAddress)(who))
7156
7573
  ]);
@@ -7245,7 +7662,7 @@ var Vault = class {
7245
7662
  // --- self-calls -------------------------------------------------------
7246
7663
  addOwner: async (owner, options = {}) => this.proposeSelfCall(selfCall.addOwner(assertQuaiAddress(owner, "owner")), options),
7247
7664
  removeOwner: async (owner, options = {}) => {
7248
- const [owners, threshold] = await Promise.all([this.owners(), this.threshold()]);
7665
+ const [owners, threshold] = await Promise.all([this.chainOwners(), this.chainThreshold()]);
7249
7666
  if (!owners.some((o) => o.toLowerCase() === owner.toLowerCase())) {
7250
7667
  throw new PreconditionError(`${owner} is not an owner of this vault.`);
7251
7668
  }
@@ -7258,7 +7675,7 @@ var Vault = class {
7258
7675
  return this.proposeSelfCall(selfCall.removeOwner((0, import_quais14.getAddress)(owner)), options);
7259
7676
  },
7260
7677
  changeThreshold: async (threshold, options = {}) => {
7261
- const owners = await this.owners();
7678
+ const owners = await this.chainOwners();
7262
7679
  if (threshold < 1 || threshold > owners.length) {
7263
7680
  throw new ValidationError(
7264
7681
  `Invalid threshold ${threshold}: this vault has ${owners.length} owners.`
@@ -7692,9 +8109,7 @@ var Vault = class {
7692
8109
  const pollIntervalMs = options.pollIntervalMs ?? 15e3;
7693
8110
  const deadline = Date.now() + timeoutMs;
7694
8111
  for (; ; ) {
7695
- if (options.signal?.aborted) {
7696
- throw new PreconditionError("Waiting for the transaction was aborted.");
7697
- }
8112
+ if (options.signal?.aborted) throw new AbortError("Waiting for the transaction");
7698
8113
  const tx = await this.fromChain(hash);
7699
8114
  options.onPoll?.(tx);
7700
8115
  if (tx.status === "ready") return tx;
@@ -7751,7 +8166,7 @@ var Vault = class {
7751
8166
  if (tx.decodedRevert) lines.push(` revert: ${tx.decodedRevert.message}`);
7752
8167
  lines.push(` source: ${tx.source}`);
7753
8168
  if (caller || this.ctx.connection.hasSigner()) {
7754
- const affordances = await this.affordances(txHash, caller);
8169
+ const affordances = await this.resolveAffordances(tx.hash, tx, caller);
7755
8170
  const allowed = affordances.filter((a) => a.allowed).map((a) => a.action);
7756
8171
  lines.push(` you can: ${allowed.length > 0 ? allowed.join(", ") : "nothing right now"}`);
7757
8172
  }