@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.js CHANGED
@@ -44,6 +44,11 @@ var QuaiVaultError = class extends Error {
44
44
  };
45
45
  }
46
46
  };
47
+ var AbortError = class extends QuaiVaultError {
48
+ constructor(operation = "The operation") {
49
+ super("ABORTED", `${operation} was aborted.`);
50
+ }
51
+ };
47
52
  var ConfigError = class extends QuaiVaultError {
48
53
  constructor(message, remediation) {
49
54
  super("CONFIG", message, { remediation });
@@ -162,6 +167,16 @@ var Connection = class {
162
167
  write ? this.requireSigner("This recovery write") : this.provider
163
168
  );
164
169
  }
170
+ /**
171
+ * Read-only handle to a token contract.
172
+ *
173
+ * No write variant: the SDK never calls a token directly. Moving tokens goes
174
+ * through the vault's proposal flow, which encodes the transfer as calldata.
175
+ */
176
+ token(address2, standard) {
177
+ const abi = standard === "ERC20" ? Erc20Abi : standard === "ERC721" ? Erc721Abi : Erc1155Abi;
178
+ return new Contract(address2, abi, this.provider);
179
+ }
165
180
  };
166
181
  function createPrivateKeySigner(privateKey, provider) {
167
182
  const normalized = privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`;
@@ -398,7 +413,9 @@ function resolveConfig(options = {}) {
398
413
  contracts,
399
414
  indexer,
400
415
  rpcUrl,
401
- privateKey: pick(options.privateKey, env[ENV_VARS.privateKey]),
416
+ // An explicit signer wins outright in `Connection`, so resolving a key here would
417
+ // only pull a secret into memory that nothing can use. Skip it.
418
+ privateKey: options.signer ? void 0 : pick(options.privateKey, env[ENV_VARS.privateKey]),
402
419
  consistency,
403
420
  maxIndexerLagBlocks: options.maxIndexerLagBlocks ?? parseLag(env[ENV_VARS.maxIndexerLagBlocks]) ?? 50,
404
421
  retry: {
@@ -528,7 +545,7 @@ function isTransient(error) {
528
545
  }
529
546
  var sleep = (ms, signal) => new Promise((resolve, reject) => {
530
547
  if (signal?.aborted) {
531
- reject(new Error("Aborted"));
548
+ reject(new AbortError("Waiting to retry"));
532
549
  return;
533
550
  }
534
551
  const timer = setTimeout(() => {
@@ -537,7 +554,7 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
537
554
  }, ms);
538
555
  const onAbort = () => {
539
556
  clearTimeout(timer);
540
- reject(new Error("Aborted"));
557
+ reject(new AbortError("Waiting to retry"));
541
558
  };
542
559
  signal?.addEventListener("abort", onAbort, { once: true });
543
560
  });
@@ -547,7 +564,7 @@ async function withRetry(fn, options = {}) {
547
564
  const maxDelayMs = options.maxDelayMs ?? DEFAULTS.maxDelayMs;
548
565
  let lastError;
549
566
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
550
- if (options.signal?.aborted) throw new Error("Aborted");
567
+ if (options.signal?.aborted) throw new AbortError("The retried operation");
551
568
  try {
552
569
  return await fn();
553
570
  } catch (error) {
@@ -742,8 +759,39 @@ var FactoryContract = class {
742
759
 
743
760
  // src/errors/decode.ts
744
761
  import { Interface, AbiCoder } from "quais";
762
+
763
+ // src/text.ts
764
+ var UNSAFE_CHARS = new RegExp(
765
+ [
766
+ "[\\u0000-\\u001F",
767
+ // C0 controls, incl. ESC (0x1B) — the head of every ANSI sequence
768
+ "\\u007F-\\u009F",
769
+ // DEL and the C1 controls
770
+ "\\u200B-\\u200F",
771
+ // zero-width space/joiners, LRM, RLM
772
+ "\\u202A-\\u202E",
773
+ // bidi embeddings and overrides
774
+ "\\u2060-\\u2064",
775
+ // word joiner and the invisible operators
776
+ "\\u2066-\\u2069",
777
+ // bidi isolates
778
+ "\\uFEFF]"
779
+ // zero-width no-break space / BOM
780
+ ].join(""),
781
+ "g"
782
+ );
783
+ var DEFAULT_TEXT_LIMIT = 128;
784
+ function sanitizeText(value, maxLength = DEFAULT_TEXT_LIMIT) {
785
+ if (typeof value !== "string") return "";
786
+ const cleaned = value.replace(UNSAFE_CHARS, "").trim();
787
+ if (cleaned.length <= maxLength) return cleaned;
788
+ return `${cleaned.slice(0, Math.max(0, maxLength - 1))}\u2026`;
789
+ }
790
+
791
+ // src/errors/decode.ts
745
792
  var SOLIDITY_ERROR_SELECTOR = "0x08c379a0";
746
793
  var SOLIDITY_PANIC_SELECTOR = "0x4e487b71";
794
+ var REVERT_TEXT_LIMIT = 256;
747
795
  var registry = null;
748
796
  function buildRegistry() {
749
797
  const map = /* @__PURE__ */ new Map();
@@ -833,7 +881,7 @@ function formatArgs(fragment, args) {
833
881
  if (args.length === 0) return "";
834
882
  const parts = fragment.inputs.map((input, i) => {
835
883
  const value = args[i];
836
- const rendered = typeof value === "bigint" ? value.toString() : String(value);
884
+ const rendered = typeof value === "bigint" ? value.toString() : typeof value === "string" ? sanitizeText(value, REVERT_TEXT_LIMIT) : String(value);
837
885
  return input.name ? `${input.name}: ${rendered}` : rendered;
838
886
  });
839
887
  return `(${parts.join(", ")})`;
@@ -849,7 +897,7 @@ function decodeRevert(data) {
849
897
  name: "Error",
850
898
  args: [reason],
851
899
  selector,
852
- message: String(reason)
900
+ message: sanitizeText(reason, REVERT_TEXT_LIMIT)
853
901
  };
854
902
  } catch {
855
903
  return void 0;
@@ -916,7 +964,7 @@ function knownErrorSelectors() {
916
964
  }
917
965
 
918
966
  // src/encode/index.ts
919
- import { AbiCoder as AbiCoder2, Interface as Interface2, concat, getBytes, solidityPacked, zeroPadValue, toBeHex } from "quais";
967
+ import { AbiCoder as AbiCoder2, Interface as Interface2, concat, getBytes, solidityPacked } from "quais";
920
968
 
921
969
  // src/types.ts
922
970
  var Operation = /* @__PURE__ */ ((Operation2) => {
@@ -937,6 +985,7 @@ var MAX_EXECUTION_DELAY = 30 * 24 * 60 * 60;
937
985
  var MAX_OWNERS = 20;
938
986
  var MAX_MODULES = 50;
939
987
  var SENTINEL_MODULES = "0x0000000000000000000000000000000000000001";
988
+ var ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
940
989
  var selfCall = {
941
990
  addOwner: (owner) => vault.encodeFunctionData("addOwner", [owner]),
942
991
  removeOwner: (owner) => vault.encodeFunctionData("removeOwner", [owner]),
@@ -1104,7 +1153,7 @@ var syncStrategy = {
1104
1153
  async mine({ factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal }) {
1105
1154
  const startedAt = Date.now();
1106
1155
  for (let attempts = 0; attempts < maxAttempts; ) {
1107
- if (signal?.aborted) throw new SaltMiningError("Salt mining was aborted.");
1156
+ if (signal?.aborted) throw new AbortError("Salt mining");
1108
1157
  if (Date.now() - startedAt > timeoutMs) {
1109
1158
  throw new SaltMiningError(
1110
1159
  `Salt mining timed out after ${timeoutMs} ms (${attempts} attempts).`,
@@ -1132,109 +1181,127 @@ var syncStrategy = {
1132
1181
  );
1133
1182
  }
1134
1183
  };
1135
- var workerThreadsStrategy = {
1136
- name: "worker_threads",
1137
- async mine(job) {
1138
- let Worker;
1139
- let availableParallelism;
1140
- try {
1141
- ({ Worker } = await import("worker_threads"));
1142
- const os = await import("os");
1143
- availableParallelism = os.availableParallelism ?? (() => os.cpus().length);
1144
- } catch {
1145
- return syncStrategy.mine(job);
1184
+ var WorkersUnavailable = class extends Error {
1185
+ };
1186
+ async function loadNodeWorkers() {
1187
+ try {
1188
+ const { Worker } = await import("worker_threads");
1189
+ const os = await import("os");
1190
+ return { Worker, parallelism: (os.availableParallelism ?? (() => os.cpus().length))() };
1191
+ } catch {
1192
+ return null;
1193
+ }
1194
+ }
1195
+ function createWorkerThreadsStrategy(load = loadNodeWorkers) {
1196
+ return {
1197
+ name: "worker_threads",
1198
+ async mine(job) {
1199
+ const runtime = await load();
1200
+ if (!runtime) return syncStrategy.mine(job);
1201
+ try {
1202
+ return await mineInWorkers(runtime.Worker, runtime.parallelism, job);
1203
+ } catch (err) {
1204
+ if (err instanceof WorkersUnavailable) return syncStrategy.mine(job);
1205
+ throw err;
1206
+ }
1146
1207
  }
1147
- const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal } = job;
1148
- const workerCount = Math.max(1, Math.min(8, availableParallelism() - 1));
1149
- const perWorker = Math.ceil(maxAttempts / workerCount);
1150
- const startedAt = Date.now();
1151
- const workerSource = `
1152
- const { parentPort, workerData } = require('node:worker_threads');
1153
- const { getCreate2Address, hexlify, isQuaiAddress, keccak256, randomBytes, solidityPacked } = require('quais');
1154
- const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts } = workerData;
1155
- for (let i = 0; i < maxAttempts; i++) {
1156
- const salt = hexlify(randomBytes(32));
1157
- const fullSalt = keccak256(solidityPacked(['address', 'bytes32'], [deployer, salt]));
1158
- const address = getCreate2Address(factory, fullSalt, bytecodeHash);
1159
- if (address.toLowerCase().startsWith(targetPrefix) && isQuaiAddress(address)) {
1160
- parentPort.postMessage({ type: 'result', salt, address, attempts: i + 1 });
1161
- return;
1162
- }
1163
- if ((i + 1) % ${PROGRESS_INTERVAL} === 0) parentPort.postMessage({ type: 'progress', attempts: i + 1 });
1208
+ };
1209
+ }
1210
+ var workerThreadsStrategy = createWorkerThreadsStrategy();
1211
+ function mineInWorkers(Worker, parallelism, job) {
1212
+ const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal } = job;
1213
+ const workerCount = Math.max(1, Math.min(8, parallelism - 1));
1214
+ const perWorker = Math.ceil(maxAttempts / workerCount);
1215
+ const startedAt = Date.now();
1216
+ const workerSource = `
1217
+ const { parentPort, workerData } = require('node:worker_threads');
1218
+ const { getCreate2Address, hexlify, isQuaiAddress, keccak256, randomBytes, solidityPacked } = require('quais');
1219
+ const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts } = workerData;
1220
+ for (let i = 0; i < maxAttempts; i++) {
1221
+ const salt = hexlify(randomBytes(32));
1222
+ const fullSalt = keccak256(solidityPacked(['address', 'bytes32'], [deployer, salt]));
1223
+ const address = getCreate2Address(factory, fullSalt, bytecodeHash);
1224
+ if (address.toLowerCase().startsWith(targetPrefix) && isQuaiAddress(address)) {
1225
+ parentPort.postMessage({ type: 'result', salt, address, attempts: i + 1 });
1226
+ return;
1164
1227
  }
1165
- parentPort.postMessage({ type: 'exhausted', attempts: maxAttempts });
1166
- `;
1167
- return new Promise((resolve, reject) => {
1168
- const workers = [];
1169
- let settled = false;
1170
- let exhausted = 0;
1171
- let totalAttempts = 0;
1172
- const progressByWorker = new Array(workerCount).fill(0);
1173
- const cleanup = () => {
1174
- clearTimeout(timer);
1175
- signal?.removeEventListener("abort", onAbort);
1176
- for (const w of workers) void w.terminate();
1177
- };
1178
- const settle = (fn) => {
1179
- if (settled) return;
1180
- settled = true;
1181
- cleanup();
1182
- fn();
1183
- };
1184
- const timer = setTimeout(
1185
- () => settle(
1186
- () => reject(
1187
- new SaltMiningError(
1188
- `Salt mining timed out after ${timeoutMs} ms (~${totalAttempts} attempts across ${workerCount} workers).`,
1189
- "Raise timeoutMs or maxAttempts."
1190
- )
1228
+ if ((i + 1) % ${PROGRESS_INTERVAL} === 0) parentPort.postMessage({ type: 'progress', attempts: i + 1 });
1229
+ }
1230
+ parentPort.postMessage({ type: 'exhausted', attempts: maxAttempts });
1231
+ `;
1232
+ return new Promise((resolve, reject) => {
1233
+ const workers = [];
1234
+ let settled = false;
1235
+ let exhausted = 0;
1236
+ let totalAttempts = 0;
1237
+ const progressByWorker = new Array(workerCount).fill(0);
1238
+ const cleanup = () => {
1239
+ clearTimeout(timer);
1240
+ signal?.removeEventListener("abort", onAbort);
1241
+ for (const w of workers) void w.terminate();
1242
+ };
1243
+ const settle = (fn) => {
1244
+ if (settled) return;
1245
+ settled = true;
1246
+ cleanup();
1247
+ fn();
1248
+ };
1249
+ const timer = setTimeout(
1250
+ () => settle(
1251
+ () => reject(
1252
+ new SaltMiningError(
1253
+ `Salt mining timed out after ${timeoutMs} ms (~${totalAttempts} attempts across ${workerCount} workers).`,
1254
+ "Raise timeoutMs or maxAttempts."
1191
1255
  )
1192
- ),
1193
- timeoutMs
1194
- );
1195
- const onAbort = () => settle(() => reject(new SaltMiningError("Salt mining was aborted.")));
1196
- signal?.addEventListener("abort", onAbort, { once: true });
1197
- for (let i = 0; i < workerCount; i++) {
1198
- const worker = new Worker(workerSource, {
1256
+ )
1257
+ ),
1258
+ timeoutMs
1259
+ );
1260
+ const onAbort = () => settle(() => reject(new AbortError("Salt mining")));
1261
+ signal?.addEventListener("abort", onAbort, { once: true });
1262
+ for (let i = 0; i < workerCount; i++) {
1263
+ let worker;
1264
+ try {
1265
+ worker = new Worker(workerSource, {
1199
1266
  eval: true,
1200
1267
  workerData: { factory, deployer, bytecodeHash, targetPrefix, maxAttempts: perWorker }
1201
1268
  });
1202
- workers.push(worker);
1203
- worker.on("message", (msg) => {
1204
- if (msg.type === "progress") {
1205
- progressByWorker[i] = msg.attempts ?? 0;
1206
- totalAttempts = progressByWorker.reduce((a, b) => a + b, 0);
1207
- onProgress?.(totalAttempts);
1208
- } else if (msg.type === "result") {
1269
+ } catch (err) {
1270
+ settle(() => reject(new WorkersUnavailable(String(err))));
1271
+ return;
1272
+ }
1273
+ workers.push(worker);
1274
+ worker.on("message", (msg) => {
1275
+ if (msg.type === "progress") {
1276
+ progressByWorker[i] = msg.attempts ?? 0;
1277
+ totalAttempts = progressByWorker.reduce((a, b) => a + b, 0);
1278
+ onProgress?.(totalAttempts);
1279
+ } else if (msg.type === "result") {
1280
+ settle(
1281
+ () => resolve({
1282
+ salt: msg.salt,
1283
+ predictedAddress: msg.address,
1284
+ attempts: totalAttempts + (msg.attempts ?? 0),
1285
+ durationMs: Date.now() - startedAt
1286
+ })
1287
+ );
1288
+ } else if (msg.type === "exhausted") {
1289
+ if (++exhausted === workerCount) {
1209
1290
  settle(
1210
- () => resolve({
1211
- salt: msg.salt,
1212
- predictedAddress: msg.address,
1213
- attempts: totalAttempts + (msg.attempts ?? 0),
1214
- durationMs: Date.now() - startedAt
1215
- })
1216
- );
1217
- } else if (msg.type === "exhausted") {
1218
- if (++exhausted === workerCount) {
1219
- settle(
1220
- () => reject(
1221
- new SaltMiningError(
1222
- `No valid salt found for prefix ${targetPrefix} after ${maxAttempts} attempts.`,
1223
- "Raise maxAttempts, or confirm the deployer address is on the shard you expect."
1224
- )
1291
+ () => reject(
1292
+ new SaltMiningError(
1293
+ `No valid salt found for prefix ${targetPrefix} after ${maxAttempts} attempts.`,
1294
+ "Raise maxAttempts, or confirm the deployer address is on the shard you expect."
1225
1295
  )
1226
- );
1227
- }
1296
+ )
1297
+ );
1228
1298
  }
1229
- });
1230
- worker.on(
1231
- "error",
1232
- (err) => settle(() => reject(new SaltMiningError(`Salt mining worker failed: ${err.message}`)))
1233
- );
1234
- }
1235
- });
1236
- }
1237
- };
1299
+ }
1300
+ });
1301
+ worker.on("error", (err) => settle(() => reject(new WorkersUnavailable(err.message))));
1302
+ }
1303
+ });
1304
+ }
1238
1305
  function defaultStrategy() {
1239
1306
  const hasNode = typeof globalThis.process?.versions?.node === "string";
1240
1307
  return hasNode ? workerThreadsStrategy : syncStrategy;
@@ -1456,7 +1523,7 @@ function validateCreateParams(params) {
1456
1523
  owners.forEach((owner, i) => {
1457
1524
  assertQuaiAddress(owner, `owner[${i}]`);
1458
1525
  const key = owner.toLowerCase();
1459
- if (key === "0x0000000000000000000000000000000000000000" || key === "0x0000000000000000000000000000000000000001") {
1526
+ if (key === ZERO_ADDRESS || key === SENTINEL_MODULES) {
1460
1527
  throw new ValidationError(
1461
1528
  `Owner ${owner} is reserved \u2014 the zero address and the module sentinel 0x\u202601 are rejected.`
1462
1529
  );
@@ -1465,6 +1532,11 @@ function validateCreateParams(params) {
1465
1532
  seen.add(key);
1466
1533
  });
1467
1534
  if (params.initialModules?.length) {
1535
+ if (params.initialModules.length > MAX_MODULES) {
1536
+ throw new ValidationError(
1537
+ `A vault may have at most ${MAX_MODULES} modules (got ${params.initialModules.length}).`
1538
+ );
1539
+ }
1468
1540
  assertQuaiAddresses(params.initialModules, "initialModules");
1469
1541
  }
1470
1542
  if (params.initialDelegatecallTargets?.length) {
@@ -1502,7 +1574,8 @@ function extractCreatedVault(receipt, factoryAddress) {
1502
1574
  }
1503
1575
 
1504
1576
  // src/indexer/client.ts
1505
- import { createClient } from "@supabase/supabase-js";
1577
+ import { PostgrestClient } from "@supabase/postgrest-js";
1578
+ import { RealtimeClient } from "@supabase/realtime-js";
1506
1579
 
1507
1580
  // src/indexer/schemas.ts
1508
1581
  var schemas_exports = {};
@@ -1701,10 +1774,11 @@ function toNumber(value, fallback = 0) {
1701
1774
  }
1702
1775
 
1703
1776
  // src/indexer/client.ts
1704
- var SDK_VERSION = "0.1.0";
1777
+ var SDK_VERSION = true ? "0.2.0" : "dev";
1705
1778
  var IndexerClient = class {
1706
1779
  config;
1707
1780
  client;
1781
+ realtime = null;
1708
1782
  healthCache = null;
1709
1783
  inflightHealth = null;
1710
1784
  /** Health results are reused for this long to keep read paths cheap. */
@@ -1712,19 +1786,46 @@ var IndexerClient = class {
1712
1786
  constructor(config, options = {}) {
1713
1787
  this.config = config;
1714
1788
  this.healthCacheMs = options.healthCacheMs ?? 5e3;
1715
- this.client = createClient(config.url, config.anonKey, {
1716
- db: { schema: config.schema },
1717
- auth: { persistSession: false, autoRefreshToken: false },
1718
- global: { headers: { "x-client-info": `quaivault-sdk/${SDK_VERSION}` } }
1789
+ this.client = new PostgrestClient(`${baseUrl(config.url)}/rest/v1`, {
1790
+ headers: {
1791
+ apikey: config.anonKey,
1792
+ Authorization: `Bearer ${config.anonKey}`,
1793
+ "x-client-info": `quaivault-sdk/${SDK_VERSION}`
1794
+ },
1795
+ schema: config.schema
1719
1796
  });
1720
1797
  }
1721
- /** Raw Supabase client, for queries the SDK does not wrap. */
1722
- get raw() {
1798
+ /** The PostgREST client, for queries the SDK does not wrap. */
1799
+ get rest() {
1723
1800
  return this.client;
1724
1801
  }
1725
1802
  from(table) {
1726
1803
  return this.client.from(table);
1727
1804
  }
1805
+ /**
1806
+ * Realtime client, constructed on first use.
1807
+ *
1808
+ * Lazy because it opens a WebSocket and starts a heartbeat, and the large majority
1809
+ * of SDK usage never calls `watch()`. Nothing here should cost a socket unless
1810
+ * somebody asked to follow a vault.
1811
+ */
1812
+ realtimeClient() {
1813
+ if (!this.realtime) {
1814
+ this.realtime = new RealtimeClient(
1815
+ `${baseUrl(this.config.url).replace(/^http/, "ws")}/realtime/v1`,
1816
+ { params: { apikey: this.config.anonKey } }
1817
+ );
1818
+ }
1819
+ return this.realtime;
1820
+ }
1821
+ /** Open a Realtime channel. See `watchVault`, which is what callers should use. */
1822
+ channel(name) {
1823
+ return this.realtimeClient().channel(name);
1824
+ }
1825
+ /** Close a channel opened by {@link channel}. */
1826
+ async removeChannel(channel) {
1827
+ await this.realtimeClient().removeChannel(channel);
1828
+ }
1728
1829
  /**
1729
1830
  * Run a query, parse each row, and surface failures as `IndexerQueryError`.
1730
1831
  *
@@ -1742,6 +1843,36 @@ var IndexerClient = class {
1742
1843
  }
1743
1844
  return (data ?? []).map((row) => schema.parse(row));
1744
1845
  }
1846
+ /**
1847
+ * Paged select: an exact `hasMore`, an approximate `total`.
1848
+ *
1849
+ * Both halves of that are deliberate. An `exact` count makes Postgres scan every
1850
+ * matching row on every page request, which is invisible on a young vault and
1851
+ * quadratic on a busy one — so the count is `estimated`, cheap and good enough for
1852
+ * "roughly how much history is there".
1853
+ *
1854
+ * But `hasMore` derived from an estimate would be a lie a caller can act on: a
1855
+ * paging loop that stops early drops real rows. So callers request one row beyond
1856
+ * the page and this trims it, which answers "is there another page" exactly, for
1857
+ * the cost of a single extra row.
1858
+ */
1859
+ async selectPage(table, schema, limit, build) {
1860
+ const { data, error, count } = await build(this.client.from(table));
1861
+ if (error) {
1862
+ throw new IndexerQueryError(`Indexer query on "${table}" failed: ${error.message}`, error);
1863
+ }
1864
+ const rows = data ?? [];
1865
+ const hasMore = rows.length > limit;
1866
+ const parsed = (hasMore ? rows.slice(0, limit) : rows).map(
1867
+ (row) => schema.parse(row)
1868
+ );
1869
+ return {
1870
+ data: parsed,
1871
+ // Never report a total below what was just handed out, however the estimate lands.
1872
+ total: Math.max(count ?? 0, parsed.length),
1873
+ hasMore
1874
+ };
1875
+ }
1745
1876
  /** Single-row variant. Returns null for PostgREST's "no rows" code. */
1746
1877
  async selectOne(table, schema, build) {
1747
1878
  const { data, error } = await build(this.client.from(table));
@@ -1796,7 +1927,7 @@ var IndexerClient = class {
1796
1927
  const pollIntervalMs = options.pollIntervalMs ?? 1500;
1797
1928
  const deadline = Date.now() + timeoutMs;
1798
1929
  for (; ; ) {
1799
- if (options.signal?.aborted) throw new Error("Aborted");
1930
+ if (options.signal?.aborted) throw new AbortError("Waiting for the indexer");
1800
1931
  const state = await this.state();
1801
1932
  const head = state?.lastIndexedBlock ?? 0;
1802
1933
  if (head >= blockNumber) return { reached: true, lastIndexedBlock: head };
@@ -1808,12 +1939,12 @@ var IndexerClient = class {
1808
1939
  }
1809
1940
  async probeHealth() {
1810
1941
  if (this.config.healthUrl) {
1942
+ const controller = new AbortController();
1943
+ const timer = setTimeout(() => controller.abort(), 5e3);
1811
1944
  try {
1812
- const controller = new AbortController();
1813
- const timer = setTimeout(() => controller.abort(), 5e3);
1814
1945
  const res = await fetch(new URL("/health", this.config.healthUrl), {
1815
1946
  signal: controller.signal
1816
- }).finally(() => clearTimeout(timer));
1947
+ });
1817
1948
  const body = await res.json();
1818
1949
  const d = body.details ?? {};
1819
1950
  return {
@@ -1825,6 +1956,8 @@ var IndexerClient = class {
1825
1956
  ...res.ok ? {} : { error: `health endpoint returned ${res.status}` }
1826
1957
  };
1827
1958
  } catch {
1959
+ } finally {
1960
+ clearTimeout(timer);
1828
1961
  }
1829
1962
  }
1830
1963
  try {
@@ -1847,10 +1980,14 @@ var IndexerClient = class {
1847
1980
  }
1848
1981
  }
1849
1982
  };
1983
+ function baseUrl(url) {
1984
+ return url.replace(/\/+$/, "");
1985
+ }
1850
1986
 
1851
1987
  // src/indexer/queries.ts
1852
1988
  var DEFAULT_LIMIT = 50;
1853
1989
  var MAX_LIMIT = 200;
1990
+ var CONFIRMATION_CHUNK = 40;
1854
1991
  function lower(address2) {
1855
1992
  return address2.toLowerCase();
1856
1993
  }
@@ -1909,14 +2046,40 @@ var IndexerQueries = class {
1909
2046
  (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("tx_hash", txHash.toLowerCase()).single()
1910
2047
  );
1911
2048
  }
2049
+ /**
2050
+ * Several transactions by hash, chunked for the same reasons as
2051
+ * {@link activeConfirmationsBatch} — hashes are long, and they go in the query string.
2052
+ *
2053
+ * Rows come back in no particular order and hashes with no row are simply absent;
2054
+ * the caller matches them up.
2055
+ */
2056
+ async transactionsByHash(vault2, txHashes) {
2057
+ if (txHashes.length === 0) return [];
2058
+ const hashes = txHashes.map((h) => h.toLowerCase());
2059
+ const chunks = [];
2060
+ for (let i = 0; i < hashes.length; i += CONFIRMATION_CHUNK) {
2061
+ chunks.push(hashes.slice(i, i + CONFIRMATION_CHUNK));
2062
+ }
2063
+ const pages = await Promise.all(
2064
+ chunks.map(
2065
+ (chunk) => this.client.select(
2066
+ "transactions",
2067
+ TransactionSchema,
2068
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).in("tx_hash", chunk)
2069
+ )
2070
+ )
2071
+ );
2072
+ return pages.flat();
2073
+ }
1912
2074
  async transactionHistory(vault2, options = {}) {
1913
2075
  const { limit, offset } = bounds(options);
1914
2076
  const statuses = options.status ?? ["executed", "cancelled", "expired", "failed"];
1915
- 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);
1916
- if (error) throw new IndexerQueryError(`Indexer query failed: ${error.message}`, error);
1917
- const rows = (data ?? []).map((r) => TransactionSchema.parse(r));
1918
- const total = count ?? rows.length;
1919
- return { data: rows, total, hasMore: offset + rows.length < total };
2077
+ return this.client.selectPage(
2078
+ "transactions",
2079
+ TransactionSchema,
2080
+ limit,
2081
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).in("status", statuses).order("submitted_at_block", { ascending: false }).range(offset, offset + limit)
2082
+ );
1920
2083
  }
1921
2084
  /** All confirmations for one transaction, including revoked ones. */
1922
2085
  async confirmations(vault2, txHash) {
@@ -1927,26 +2090,41 @@ var IndexerQueries = class {
1927
2090
  );
1928
2091
  }
1929
2092
  /**
1930
- * Active confirmations for many transactions in one round trip.
2093
+ * Active confirmations for many transactions, in as few round trips as is safe.
1931
2094
  *
1932
2095
  * "Active" here means not revoked. It does NOT mean the confirming address is
1933
2096
  * still an owner — the indexer does not deactivate confirmations when an owner is
1934
2097
  * removed, while the contract invalidates them via approval epochs. Callers must
1935
2098
  * intersect with the active owner set; `Vault` does this for you.
2099
+ *
2100
+ * Chunked rather than issued as one `in(...)`, because a single filter fails two
2101
+ * ways at scale. PostgREST reads filters from the query string, and each 32-byte
2102
+ * hash costs ~70 characters there — a full {@link MAX_LIMIT} page of hashes builds a
2103
+ * ~14 KB request line, past the 8 KB cap most reverse proxies apply by default. The
2104
+ * response is bounded too: {@link CONFIRMATION_CHUNK} transactions can carry at most
2105
+ * `chunk × MAX_OWNERS` rows, and the chunk is sized to keep that under the
2106
+ * `max-rows` ceiling PostgREST deployments commonly set, so a busy vault cannot be
2107
+ * silently short-served.
1936
2108
  */
1937
2109
  async activeConfirmationsBatch(vault2, txHashes) {
1938
2110
  const result = /* @__PURE__ */ new Map();
1939
2111
  for (const hash of txHashes) result.set(hash.toLowerCase(), []);
1940
2112
  if (txHashes.length === 0) return result;
1941
- const rows = await this.client.select(
1942
- "confirmations",
1943
- ConfirmationSchema,
1944
- (q) => q.select("*").eq("wallet_address", lower(vault2)).in(
1945
- "tx_hash",
1946
- txHashes.map((h) => h.toLowerCase())
1947
- ).eq("is_active", true)
2113
+ const hashes = txHashes.map((h) => h.toLowerCase());
2114
+ const chunks = [];
2115
+ for (let i = 0; i < hashes.length; i += CONFIRMATION_CHUNK) {
2116
+ chunks.push(hashes.slice(i, i + CONFIRMATION_CHUNK));
2117
+ }
2118
+ const pages = await Promise.all(
2119
+ chunks.map(
2120
+ (chunk) => this.client.select(
2121
+ "confirmations",
2122
+ ConfirmationSchema,
2123
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).in("tx_hash", chunk).eq("is_active", true)
2124
+ )
2125
+ )
1948
2126
  );
1949
- for (const row of rows) {
2127
+ for (const row of pages.flat()) {
1950
2128
  const key = row.tx_hash.toLowerCase();
1951
2129
  const list = result.get(key);
1952
2130
  if (list) list.push(row);
@@ -1974,19 +2152,52 @@ var IndexerQueries = class {
1974
2152
  // ---- value movement ------------------------------------------------------
1975
2153
  async deposits(vault2, options = {}) {
1976
2154
  const { limit, offset } = bounds(options);
1977
- 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);
1978
- if (error) throw new IndexerQueryError(`Indexer query failed: ${error.message}`, error);
1979
- const rows = (data ?? []).map((r) => DepositSchema.parse(r));
1980
- const total = count ?? rows.length;
1981
- return { data: rows, total, hasMore: offset + rows.length < total };
2155
+ return this.client.selectPage(
2156
+ "deposits",
2157
+ DepositSchema,
2158
+ limit,
2159
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).order("deposited_at_block", { ascending: false }).range(offset, offset + limit)
2160
+ );
1982
2161
  }
1983
2162
  async tokenTransfers(vault2, options = {}) {
1984
2163
  const { limit, offset } = bounds(options);
1985
- 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);
1986
- if (error) throw new IndexerQueryError(`Indexer query failed: ${error.message}`, error);
1987
- const rows = (data ?? []).map((r) => TokenTransferSchema.parse(r));
1988
- const total = count ?? rows.length;
1989
- return { data: rows, total, hasMore: offset + rows.length < total };
2164
+ return this.client.selectPage(
2165
+ "token_transfers",
2166
+ TokenTransferSchema,
2167
+ limit,
2168
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).order("block_number", { ascending: false }).range(offset, offset + limit)
2169
+ );
2170
+ }
2171
+ /**
2172
+ * Scan up to `budget` of the most recent transfer rows, paging past {@link MAX_LIMIT}.
2173
+ *
2174
+ * `tokenTransfers` clamps a single request, so a caller asking for more than the cap
2175
+ * silently received a short page — and then computed "did I see everything?" from
2176
+ * that short page's `hasMore`, which reported truncation the caller had not actually
2177
+ * hit. Paging here keeps the caller's budget meaningful and makes the returned
2178
+ * `hasMore` mean what it says: rows exist beyond the budget that was scanned.
2179
+ *
2180
+ * Pages by offset over a descending order, so a transfer landing mid-scan can shift
2181
+ * rows by one. That is acceptable for token *discovery* — a duplicate collapses into
2182
+ * the same map key and a missed row costs at most one candidate that the next call
2183
+ * picks up. Do not reuse this for anything that must see each row exactly once.
2184
+ */
2185
+ async tokenTransferScan(vault2, budget) {
2186
+ const target = Math.max(1, Math.floor(budget));
2187
+ const data = [];
2188
+ let total = 0;
2189
+ let hasMore = false;
2190
+ while (data.length < target) {
2191
+ const page = await this.tokenTransfers(vault2, {
2192
+ limit: Math.min(MAX_LIMIT, target - data.length),
2193
+ offset: data.length
2194
+ });
2195
+ data.push(...page.data);
2196
+ total = page.total;
2197
+ hasMore = page.hasMore;
2198
+ if (page.data.length === 0 || !page.hasMore) break;
2199
+ }
2200
+ return { data, total, hasMore };
1990
2201
  }
1991
2202
  async tokens(addresses) {
1992
2203
  if (addresses.length === 0) return [];
@@ -2209,8 +2420,6 @@ function deriveRecoveryStatus(state, at = nowSeconds()) {
2209
2420
  }
2210
2421
 
2211
2422
  // src/recovery.ts
2212
- var SENTINEL = "0x0000000000000000000000000000000000000001";
2213
- var ZERO = "0x0000000000000000000000000000000000000000";
2214
2423
  var RecoveryModule = class {
2215
2424
  constructor(ctx) {
2216
2425
  this.ctx = ctx;
@@ -2232,14 +2441,23 @@ var RecoveryModule = class {
2232
2441
  get vault() {
2233
2442
  return this.ctx.vaultAddress;
2234
2443
  }
2444
+ /**
2445
+ * The vault this module acts on, through the same retrying facade every other vault
2446
+ * read in the SDK uses. Recovery reaches into the vault for three checks — is the
2447
+ * module enabled, is the caller an owner, who are the current owners — and each of
2448
+ * them gates a write, so none of them should be the one read that silently skips
2449
+ * transient-failure handling.
2450
+ */
2451
+ vaultContract() {
2452
+ return new VaultContract(this.ctx.connection.vault(this.vault), this.ctx.connection.retry);
2453
+ }
2235
2454
  // =========================================================================
2236
2455
  // Reads
2237
2456
  // =========================================================================
2238
2457
  /** Whether the module is currently enabled on the vault. */
2239
2458
  async isEnabled() {
2240
2459
  if (!this.ctx.moduleAddress) return false;
2241
- const vault2 = this.ctx.connection.vault(this.vault);
2242
- return vault2.getFunction("isModuleEnabled(address)")(this.address);
2460
+ return this.vaultContract().isModuleEnabled(this.address);
2243
2461
  }
2244
2462
  async config() {
2245
2463
  const raw = await this.contract().getRecoveryConfig(this.vault);
@@ -2297,7 +2515,7 @@ var RecoveryModule = class {
2297
2515
  */
2298
2516
  async history(options = {}) {
2299
2517
  const queries = this.ctx.queries;
2300
- if (!queries) return [];
2518
+ if (!queries) throw new NoIndexerError("Listing recovery history");
2301
2519
  const rows = await queries.recoveryHistory(this.vault, options);
2302
2520
  return rows.map((row) => ({
2303
2521
  hash: row.recovery_hash,
@@ -2433,14 +2651,13 @@ var RecoveryModule = class {
2433
2651
  { retryableAt: request.executionTime }
2434
2652
  );
2435
2653
  }
2436
- const vault2 = this.ctx.connection.vault(this.vault);
2437
- const currentOwners = await vault2.getFunction("getOwners()")();
2654
+ const currentOwners = await this.vaultContract().getOwners();
2438
2655
  const current = new Set(Array.from(currentOwners).map((o) => String(o).toLowerCase()));
2439
2656
  const overlap = request.newOwners.filter((o) => current.has(o.toLowerCase())).length;
2440
2657
  const peak = current.size + request.newOwners.length - overlap;
2441
- if (peak > 20) {
2658
+ if (peak > MAX_OWNERS) {
2442
2659
  throw new PreconditionError(
2443
- `Executing this recovery would transiently hold ${peak} owners, above the 20-owner maximum \u2014 new owners are added before old ones are removed.`,
2660
+ `Executing this recovery would transiently hold ${peak} owners, above the ${MAX_OWNERS}-owner maximum \u2014 new owners are added before old ones are removed.`,
2444
2661
  {
2445
2662
  remediation: "Run a recovery that retains at least one existing owner, then a second recovery to replace it."
2446
2663
  }
@@ -2455,8 +2672,7 @@ var RecoveryModule = class {
2455
2672
  async cancel(recoveryHash) {
2456
2673
  const hash = normalizeTxHash(recoveryHash, "recovery hash");
2457
2674
  const caller = await this.ctx.connection.address();
2458
- const vault2 = this.ctx.connection.vault(this.vault);
2459
- const isOwner = await vault2.getFunction("isOwner(address)")(caller);
2675
+ const isOwner = await this.vaultContract().isOwner(caller);
2460
2676
  if (!isOwner) {
2461
2677
  throw new PreconditionError(
2462
2678
  `Only a current owner of the vault can cancel a recovery. ${caller} is not one.`
@@ -2487,7 +2703,7 @@ var RecoveryModule = class {
2487
2703
  this.isGuardian(address2),
2488
2704
  this.contract().recoveryApprovals(this.vault, hash, address2),
2489
2705
  this.isEnabled(),
2490
- this.ctx.connection.vault(this.vault).getFunction("isOwner(address)")(address2)
2706
+ this.vaultContract().isOwner(address2)
2491
2707
  ]);
2492
2708
  const now = nowSeconds();
2493
2709
  const terminal = request.status === "executed" || request.status === "cancelled";
@@ -2612,7 +2828,7 @@ var RecoveryModule = class {
2612
2828
  const seen = /* @__PURE__ */ new Set();
2613
2829
  for (const owner of newOwners) {
2614
2830
  const key = owner.toLowerCase();
2615
- if (key === ZERO || key === SENTINEL) {
2831
+ if (key === ZERO_ADDRESS || key === SENTINEL_MODULES) {
2616
2832
  throw new ValidationError(
2617
2833
  `New owner ${owner} is reserved \u2014 the zero address and the module sentinel 0x\u202601 are rejected.`
2618
2834
  );
@@ -2693,16 +2909,61 @@ function terminalReason(request) {
2693
2909
  }
2694
2910
 
2695
2911
  // src/balances.ts
2696
- import { Contract as Contract2, getAddress as getAddress4 } from "quais";
2912
+ import { getAddress as getAddress4 } from "quais";
2913
+
2914
+ // src/chain/token-contract.ts
2915
+ var TOKEN_RETRY = { maxAttempts: 2, baseDelayMs: 150 };
2916
+ var TokenContract = class {
2917
+ constructor(contract, retry = {}) {
2918
+ this.contract = contract;
2919
+ this.retry = { ...TOKEN_RETRY, ...retry };
2920
+ }
2921
+ contract;
2922
+ retry;
2923
+ read(signature, ...args) {
2924
+ return withRetry(() => this.contract.getFunction(signature)(...args), this.retry);
2925
+ }
2926
+ /** ERC20 units held, or — for ERC721 — the number of tokens held. */
2927
+ balanceOf(owner) {
2928
+ return this.read("balanceOf(address)", owner);
2929
+ }
2930
+ /** ERC721 only. Reverts for a burned or never-minted id. */
2931
+ ownerOf(tokenId) {
2932
+ return this.read("ownerOf(uint256)", tokenId);
2933
+ }
2934
+ /** ERC1155 only. One call for many ids, so it stays a single round trip. */
2935
+ balanceOfBatch(owners, ids) {
2936
+ return this.read("balanceOfBatch(address[],uint256[])", owners, ids);
2937
+ }
2938
+ };
2939
+
2940
+ // src/pool.ts
2941
+ async function mapPooled(items, limit, fn) {
2942
+ const results = new Array(items.length);
2943
+ let next = 0;
2944
+ const worker = async () => {
2945
+ for (; ; ) {
2946
+ const i = next++;
2947
+ if (i >= items.length) return;
2948
+ results[i] = await fn(items[i], i);
2949
+ }
2950
+ };
2951
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
2952
+ return results;
2953
+ }
2954
+ var DEFAULT_CONCURRENCY = 8;
2955
+
2956
+ // src/balances.ts
2697
2957
  async function loadBalances(connection, queries, vault2, options = {}) {
2698
2958
  const verify = options.verify !== false;
2699
2959
  const maxTokens = options.maxTokens ?? 50;
2700
2960
  const transferScanLimit = options.transferScanLimit ?? 500;
2701
2961
  const maxTokenIdChecks = options.maxTokenIdChecks ?? 100;
2962
+ const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
2702
2963
  const address2 = getAddress4(vault2);
2703
2964
  const native = BigInt(await connection.provider.getBalance(address2));
2704
2965
  if (!queries) return { native, tokens: [] };
2705
- const transfers = await queries.tokenTransfers(address2, { limit: transferScanLimit });
2966
+ const transfers = await queries.tokenTransferScan(address2, transferScanLimit);
2706
2967
  const seen = /* @__PURE__ */ new Map();
2707
2968
  for (const row of transfers.data) {
2708
2969
  const key = row.token_address.toLowerCase();
@@ -2721,17 +2982,18 @@ async function loadBalances(connection, queries, vault2, options = {}) {
2721
2982
  }
2722
2983
  const metadata = await queries.tokens(candidates);
2723
2984
  const byAddress = new Map(metadata.map((t) => [t.address.toLowerCase(), t]));
2724
- const balances = await Promise.all(
2725
- candidates.map(
2726
- (token2) => buildBalance(
2727
- connection,
2728
- address2,
2729
- token2,
2730
- byAddress.get(token2),
2731
- seen.get(token2) ?? [],
2732
- verify,
2733
- maxTokenIdChecks
2734
- )
2985
+ const balances = await mapPooled(
2986
+ candidates,
2987
+ concurrency,
2988
+ (token2) => buildBalance(
2989
+ connection,
2990
+ address2,
2991
+ token2,
2992
+ byAddress.get(token2),
2993
+ seen.get(token2) ?? [],
2994
+ verify,
2995
+ maxTokenIdChecks,
2996
+ concurrency
2735
2997
  )
2736
2998
  );
2737
2999
  return {
@@ -2741,30 +3003,35 @@ async function loadBalances(connection, queries, vault2, options = {}) {
2741
3003
  ...Object.keys(truncated).length ? { truncated } : {}
2742
3004
  };
2743
3005
  }
2744
- async function buildBalance(connection, vault2, token2, meta, transfers, verify, maxTokenIdChecks) {
3006
+ async function buildBalance(connection, vault2, token2, meta, transfers, verify, maxTokenIdChecks, concurrency) {
2745
3007
  const standard = meta?.standard ?? inferStandard(transfers);
2746
3008
  const replayed = replayBalance(transfers, standard);
2747
3009
  const base = {
2748
3010
  token: getAddress4(token2),
2749
3011
  standard,
2750
- symbol: meta?.symbol ?? "???",
2751
- name: meta?.name ?? "Unknown token",
3012
+ // Deployer-controlled strings headed for someone's terminal. See `src/text.ts`.
3013
+ symbol: sanitizeText(meta?.symbol, 32) || "???",
3014
+ name: sanitizeText(meta?.name, 64) || "Unknown token",
2752
3015
  decimals: meta?.decimals ?? (standard === "ERC20" ? 18 : 0),
2753
3016
  balance: replayed.balance,
2754
3017
  ...replayed.tokenIds.length > 0 ? { tokenIds: replayed.tokenIds } : {},
2755
3018
  verified: false
2756
3019
  };
2757
3020
  if (!verify) return base;
3021
+ const contract = new TokenContract(connection.token(token2, standard), connection.retry);
2758
3022
  try {
2759
3023
  if (standard === "ERC20") {
2760
- const contract2 = new Contract2(token2, Erc20Abi, connection.provider);
2761
- const balance = await contract2.getFunction("balanceOf(address)")(vault2);
2762
- return { ...base, balance: BigInt(balance), verified: true };
3024
+ return { ...base, balance: BigInt(await contract.balanceOf(vault2)), verified: true };
2763
3025
  }
2764
3026
  if (standard === "ERC721") {
2765
- const contract2 = new Contract2(token2, Erc721Abi, connection.provider);
2766
- const count = await contract2.getFunction("balanceOf(address)")(vault2);
2767
- const owned = await filterOwned(contract2, vault2, replayed.tokenIds, maxTokenIdChecks);
3027
+ const count = await contract.balanceOf(vault2);
3028
+ const owned = await filterOwned(
3029
+ contract,
3030
+ vault2,
3031
+ replayed.tokenIds,
3032
+ maxTokenIdChecks,
3033
+ concurrency
3034
+ );
2768
3035
  return {
2769
3036
  ...base,
2770
3037
  balance: BigInt(count),
@@ -2773,10 +3040,9 @@ async function buildBalance(connection, vault2, token2, meta, transfers, verify,
2773
3040
  verified: true
2774
3041
  };
2775
3042
  }
2776
- const contract = new Contract2(token2, Erc1155Abi, connection.provider);
2777
3043
  const ids = replayed.tokenIds;
2778
3044
  if (ids.length === 0) return { ...base, verified: true };
2779
- const amounts = await contract.getFunction("balanceOfBatch(address[],uint256[])")(
3045
+ const amounts = await contract.balanceOfBatch(
2780
3046
  ids.map(() => vault2),
2781
3047
  ids
2782
3048
  );
@@ -2821,18 +3087,16 @@ function replayBalance(transfers, standard) {
2821
3087
  }
2822
3088
  return { balance, tokenIds };
2823
3089
  }
2824
- async function filterOwned(contract, vault2, ids, limit) {
3090
+ async function filterOwned(contract, vault2, ids, limit, concurrency) {
2825
3091
  if (ids.length === 0) return [];
2826
- const results = await Promise.all(
2827
- ids.slice(0, limit).map(async (id) => {
2828
- try {
2829
- const owner = await contract.getFunction("ownerOf(uint256)")(id);
2830
- return owner.toLowerCase() === vault2.toLowerCase() ? id : null;
2831
- } catch {
2832
- return null;
2833
- }
2834
- })
2835
- );
3092
+ const results = await mapPooled(ids.slice(0, limit), concurrency, async (id) => {
3093
+ try {
3094
+ const owner = await contract.ownerOf(id);
3095
+ return owner.toLowerCase() === vault2.toLowerCase() ? id : null;
3096
+ } catch {
3097
+ return null;
3098
+ }
3099
+ });
2836
3100
  return results.filter((id) => id !== null);
2837
3101
  }
2838
3102
  function inferStandard(transfers) {
@@ -2868,7 +3132,7 @@ function watchVault(client, vault2, handler, options = {}) {
2868
3132
  const topics = options.topics ?? DEFAULT_TOPICS;
2869
3133
  const address2 = vault2.toLowerCase();
2870
3134
  const schema = client.config.schema;
2871
- const channel = client.raw.channel(`quaivault:${schema}:${address2}`);
3135
+ const channel = client.channel(`quaivault:${schema}:${address2}`);
2872
3136
  for (const topic of topics) {
2873
3137
  const table = TABLE_FOR[topic];
2874
3138
  channel.on(
@@ -2898,7 +3162,7 @@ function watchVault(client, vault2, handler, options = {}) {
2898
3162
  return {
2899
3163
  topics,
2900
3164
  async unsubscribe() {
2901
- await client.raw.removeChannel(channel);
3165
+ await client.removeChannel(channel);
2902
3166
  }
2903
3167
  };
2904
3168
  }
@@ -3378,7 +3642,7 @@ function extractProposedTxHash(receipt, vault2) {
3378
3642
  }
3379
3643
 
3380
3644
  // src/vault.ts
3381
- var Vault = class {
3645
+ var Vault = class _Vault {
3382
3646
  address;
3383
3647
  /** Guardian-based recovery for this vault. */
3384
3648
  recovery;
@@ -3421,6 +3685,21 @@ var Vault = class {
3421
3685
  if (!this.ctx.queries) throw new NoIndexerError(operation);
3422
3686
  return this.ctx.queries;
3423
3687
  }
3688
+ /**
3689
+ * The indexer's head, for stamping onto records read from it.
3690
+ *
3691
+ * Reads the cached health result rather than querying `indexer_state` directly.
3692
+ * `useIndexer()` has just called `health()` on this same code path and the result is
3693
+ * cached, so this is free — whereas a `state()` call is a second round trip per
3694
+ * hydration, paid on every indexed read purely to fill one advisory field.
3695
+ *
3696
+ * Undefined when the indexer is not answering: a head of 0 would read as "indexed at
3697
+ * genesis" rather than "unknown".
3698
+ */
3699
+ async indexerHead() {
3700
+ const health = await this.ctx.indexer?.health();
3701
+ return health?.available ? health.lastIndexedBlock : void 0;
3702
+ }
3424
3703
  contract(write = false) {
3425
3704
  return new VaultContract(this.ctx.connection.vault(this.address, write), this.ctx.connection.retry);
3426
3705
  }
@@ -3448,8 +3727,48 @@ var Vault = class {
3448
3727
  balance: BigInt(balance)
3449
3728
  };
3450
3729
  }
3451
- /** Active owners. Prefers the indexer, falls back to chain. */
3730
+ /**
3731
+ * Capture owners and threshold once, for reuse across a burst of reads.
3732
+ *
3733
+ * Pair with {@link pinned}:
3734
+ *
3735
+ * ```ts
3736
+ * const view = await vault.view();
3737
+ * const pinned = vault.pinned(view);
3738
+ * // every read below shares one owner/threshold read
3739
+ * const txs = await pinned.transactions(hashes);
3740
+ * ```
3741
+ */
3742
+ async view() {
3743
+ const fromIndexer = await this.useIndexer();
3744
+ const [owners, threshold, indexedAtBlock] = await Promise.all([
3745
+ this.owners(),
3746
+ this.threshold(),
3747
+ this.indexerHead()
3748
+ ]);
3749
+ return {
3750
+ owners,
3751
+ threshold,
3752
+ ...indexedAtBlock !== void 0 ? { indexedAtBlock } : {},
3753
+ capturedAt: nowSeconds(),
3754
+ source: fromIndexer ? "indexer" : "chain"
3755
+ };
3756
+ }
3757
+ /**
3758
+ * A handle that answers {@link owners} and {@link threshold} from `view` instead of
3759
+ * re-reading them.
3760
+ *
3761
+ * Reads only. Write preconditions go through `chainOwners` / `chainThreshold`, which
3762
+ * bypass the snapshot by construction — pinning a view can never cause this SDK to
3763
+ * sign against a stale owner set. Everything else about the handle, including the
3764
+ * connection and the recovery module, is shared with the original.
3765
+ */
3766
+ pinned(view) {
3767
+ return new _Vault(this.address, { ...this.ctx, view });
3768
+ }
3769
+ /** Active owners. Prefers a pinned view, then the indexer, then chain. */
3452
3770
  async owners() {
3771
+ if (this.ctx.view) return this.ctx.view.owners;
3453
3772
  if (await this.useIndexer()) {
3454
3773
  try {
3455
3774
  const owners = await this.requireQueries("owners").owners(this.address);
@@ -3457,12 +3776,40 @@ var Vault = class {
3457
3776
  } catch {
3458
3777
  }
3459
3778
  }
3779
+ return this.chainOwners();
3780
+ }
3781
+ /**
3782
+ * Owners straight from the chain, bypassing the indexer entirely.
3783
+ *
3784
+ * {@link owners} prefers the indexer, which is right for display and wrong for
3785
+ * anything that gates a write. A lagging indexer would let the SDK build a proposal
3786
+ * against an owner set that no longer exists — admitting a `removeOwner` that drops
3787
+ * the vault below its threshold, or rejecting one that is perfectly valid. Every
3788
+ * propose-time precondition reads through here instead.
3789
+ */
3790
+ async chainOwners() {
3460
3791
  return (await this.contract().getOwners()).map((o) => getAddress5(String(o)));
3461
3792
  }
3462
3793
  async isOwner(address2) {
3463
3794
  return this.contract().isOwner(getAddress5(address2));
3464
3795
  }
3796
+ /** Approvals required to execute. Prefers a pinned view, then the indexer, then chain. */
3465
3797
  async threshold() {
3798
+ if (this.ctx.view) return this.ctx.view.threshold;
3799
+ if (await this.useIndexer()) {
3800
+ try {
3801
+ const wallet = await this.requireQueries("threshold").wallet(this.address);
3802
+ if (wallet) return wallet.threshold;
3803
+ } catch {
3804
+ }
3805
+ }
3806
+ return this.chainThreshold();
3807
+ }
3808
+ /**
3809
+ * Threshold straight from the chain. The write-path counterpart to {@link threshold},
3810
+ * for the same reason {@link chainOwners} exists.
3811
+ */
3812
+ async chainThreshold() {
3466
3813
  return Number(await this.contract().threshold());
3467
3814
  }
3468
3815
  async balance() {
@@ -3515,6 +3862,53 @@ var Vault = class {
3515
3862
  }
3516
3863
  return this.fromChain(hash);
3517
3864
  }
3865
+ /**
3866
+ * Several transactions at once, keyed by hash.
3867
+ *
3868
+ * The plural form exists because the singular one is expensive to loop: each
3869
+ * `transaction()` re-reads the owner set, the threshold and the indexer head, so
3870
+ * fetching fifty costs fifty times that. This resolves the whole set the way the
3871
+ * listing methods already do — one query for the rows, one owner/threshold read
3872
+ * shared across them, one batched confirmations query.
3873
+ *
3874
+ * Hashes the indexer does not have fall back to chain reads, bounded by a pool.
3875
+ * Hashes that exist nowhere are simply absent from the map rather than throwing:
3876
+ * one unknown hash should not lose the caller the other forty-nine.
3877
+ */
3878
+ async transactions(txHashes) {
3879
+ const hashes = [...new Set(txHashes.map((h) => normalizeTxHash(h)))];
3880
+ const found = /* @__PURE__ */ new Map();
3881
+ if (hashes.length === 0) return found;
3882
+ if (await this.useIndexer()) {
3883
+ try {
3884
+ const queries = this.requireQueries("Reading transactions");
3885
+ const [rows, owners, threshold] = await Promise.all([
3886
+ queries.transactionsByHash(this.address, hashes),
3887
+ this.owners(),
3888
+ this.threshold()
3889
+ ]);
3890
+ for (const tx of await this.hydrateRows(rows, owners, threshold)) {
3891
+ found.set(tx.hash.toLowerCase(), tx);
3892
+ }
3893
+ } catch {
3894
+ }
3895
+ }
3896
+ const missing = hashes.filter((hash) => !found.has(hash));
3897
+ if (missing.length > 0) {
3898
+ const resolved = await mapPooled(missing, DEFAULT_CONCURRENCY, async (hash) => {
3899
+ try {
3900
+ return await this.fromChain(hash);
3901
+ } catch (err) {
3902
+ if (err instanceof NotFoundError) return null;
3903
+ throw err;
3904
+ }
3905
+ });
3906
+ for (const tx of resolved) {
3907
+ if (tx) found.set(tx.hash.toLowerCase(), tx);
3908
+ }
3909
+ }
3910
+ return found;
3911
+ }
3518
3912
  async pendingTransactions(options = {}) {
3519
3913
  const queries = this.requireQueries("Listing pending transactions");
3520
3914
  const [rows, owners, threshold] = await Promise.all([
@@ -3545,7 +3939,7 @@ var Vault = class {
3545
3939
  this.address,
3546
3940
  rows.map((r) => r.tx_hash)
3547
3941
  );
3548
- const indexedAtBlock = (await this.ctx.indexer?.state())?.lastIndexedBlock;
3942
+ const indexedAtBlock = await this.indexerHead();
3549
3943
  return rows.map((row) => {
3550
3944
  const confirmed = (confirmations.get(row.tx_hash.toLowerCase()) ?? []).map(
3551
3945
  (c) => c.owner_address
@@ -3568,7 +3962,7 @@ var Vault = class {
3568
3962
  ]);
3569
3963
  if (!row) return null;
3570
3964
  const confirmations = await queries.confirmations(this.address, hash);
3571
- const indexedAtBlock = (await this.ctx.indexer?.state())?.lastIndexedBlock;
3965
+ const indexedAtBlock = await this.indexerHead();
3572
3966
  return this.buildTransaction({
3573
3967
  row,
3574
3968
  confirmedBy: confirmations.filter((c) => c.is_active).map((c) => c.owner_address),
@@ -3631,7 +4025,10 @@ var Vault = class {
3631
4025
  value,
3632
4026
  data,
3633
4027
  proposer: getAddress5(row.submitted_by),
3634
- proposedAt: toNumber(row.submitted_at_block),
4028
+ // The indexer records the block, not the chain timestamp — they are not
4029
+ // interchangeable, and reporting a block number as `proposedAt` renders as 1970.
4030
+ proposedAt: 0,
4031
+ proposedAtBlock: toNumber(row.submitted_at_block),
3635
4032
  kind: decodeResult.kind,
3636
4033
  decoded: decodeResult.decoded,
3637
4034
  summary: decodeResult.summary,
@@ -3722,6 +4119,18 @@ var Vault = class {
3722
4119
  */
3723
4120
  async affordances(txHash, caller) {
3724
4121
  const hash = normalizeTxHash(txHash);
4122
+ return this.resolveAffordances(hash, this.transaction(hash), caller);
4123
+ }
4124
+ /**
4125
+ * Shared body of {@link affordances}, taking the transaction either as a value or as
4126
+ * an in-flight promise.
4127
+ *
4128
+ * The promise form is the point: a caller that already holds the record (like
4129
+ * {@link describe}) passes it and skips a redundant fetch, while `affordances()`
4130
+ * passes the unawaited promise so the transaction read still overlaps the two
4131
+ * ownership probes rather than serialising behind them.
4132
+ */
4133
+ async resolveAffordances(hash, transaction, caller) {
3725
4134
  const who = caller ?? await this.ctx.connection.addressOrNull();
3726
4135
  if (!who) {
3727
4136
  throw new ValidationError(
@@ -3730,7 +4139,7 @@ var Vault = class {
3730
4139
  }
3731
4140
  const vault2 = this.contract();
3732
4141
  const [tx, isOwner, hasApproved] = await Promise.all([
3733
- this.transaction(hash),
4142
+ transaction,
3734
4143
  vault2.isOwner(getAddress5(who)),
3735
4144
  vault2.hasApproved(hash, getAddress5(who))
3736
4145
  ]);
@@ -3825,7 +4234,7 @@ var Vault = class {
3825
4234
  // --- self-calls -------------------------------------------------------
3826
4235
  addOwner: async (owner, options = {}) => this.proposeSelfCall(selfCall.addOwner(assertQuaiAddress(owner, "owner")), options),
3827
4236
  removeOwner: async (owner, options = {}) => {
3828
- const [owners, threshold] = await Promise.all([this.owners(), this.threshold()]);
4237
+ const [owners, threshold] = await Promise.all([this.chainOwners(), this.chainThreshold()]);
3829
4238
  if (!owners.some((o) => o.toLowerCase() === owner.toLowerCase())) {
3830
4239
  throw new PreconditionError(`${owner} is not an owner of this vault.`);
3831
4240
  }
@@ -3838,7 +4247,7 @@ var Vault = class {
3838
4247
  return this.proposeSelfCall(selfCall.removeOwner(getAddress5(owner)), options);
3839
4248
  },
3840
4249
  changeThreshold: async (threshold, options = {}) => {
3841
- const owners = await this.owners();
4250
+ const owners = await this.chainOwners();
3842
4251
  if (threshold < 1 || threshold > owners.length) {
3843
4252
  throw new ValidationError(
3844
4253
  `Invalid threshold ${threshold}: this vault has ${owners.length} owners.`
@@ -4272,9 +4681,7 @@ var Vault = class {
4272
4681
  const pollIntervalMs = options.pollIntervalMs ?? 15e3;
4273
4682
  const deadline = Date.now() + timeoutMs;
4274
4683
  for (; ; ) {
4275
- if (options.signal?.aborted) {
4276
- throw new PreconditionError("Waiting for the transaction was aborted.");
4277
- }
4684
+ if (options.signal?.aborted) throw new AbortError("Waiting for the transaction");
4278
4685
  const tx = await this.fromChain(hash);
4279
4686
  options.onPoll?.(tx);
4280
4687
  if (tx.status === "ready") return tx;
@@ -4331,7 +4738,7 @@ var Vault = class {
4331
4738
  if (tx.decodedRevert) lines.push(` revert: ${tx.decodedRevert.message}`);
4332
4739
  lines.push(` source: ${tx.source}`);
4333
4740
  if (caller || this.ctx.connection.hasSigner()) {
4334
- const affordances = await this.affordances(txHash, caller);
4741
+ const affordances = await this.resolveAffordances(tx.hash, tx, caller);
4335
4742
  const allowed = affordances.filter((a) => a.allowed).map((a) => a.action);
4336
4743
  lines.push(` you can: ${allowed.length > 0 ? allowed.join(", ") : "nothing right now"}`);
4337
4744
  }
@@ -4462,8 +4869,11 @@ function connect(options = {}) {
4462
4869
  }
4463
4870
  var QuaiVault = { connect };
4464
4871
  export {
4872
+ AbortError,
4465
4873
  ConfigError,
4466
4874
  Connection,
4875
+ DEFAULT_CONCURRENCY,
4876
+ DEFAULT_TEXT_LIMIT,
4467
4877
  ENV_VARS,
4468
4878
  Erc1155Abi,
4469
4879
  Erc20Abi,
@@ -4496,9 +4906,11 @@ export {
4496
4906
  SaltMiningError,
4497
4907
  SocialRecoveryModuleAbi,
4498
4908
  StaleProposalError,
4909
+ TokenContract,
4499
4910
  ValidationError,
4500
4911
  Vault,
4501
4912
  VaultContract,
4913
+ ZERO_ADDRESS,
4502
4914
  allowedActions,
4503
4915
  assertQuaiAddress,
4504
4916
  assertQuaiAddresses,
@@ -4507,6 +4919,7 @@ export {
4507
4919
  computeBytecodeHash,
4508
4920
  computeFullSalt,
4509
4921
  connect,
4922
+ createWorkerThreadsStrategy,
4510
4923
  decodeCall,
4511
4924
  decodeMultiSendPayload,
4512
4925
  decodeRevert,
@@ -4529,6 +4942,7 @@ export {
4529
4942
  knownErrorSelectors,
4530
4943
  loadBalances,
4531
4944
  mainnet,
4945
+ mapPooled,
4532
4946
  mineSalt,
4533
4947
  minimumExpiration,
4534
4948
  networks,
@@ -4537,6 +4951,7 @@ export {
4537
4951
  recoveryCall,
4538
4952
  remediationFor,
4539
4953
  resolveConfig,
4954
+ sanitizeText,
4540
4955
  selfCall,
4541
4956
  shardPrefixOf,
4542
4957
  syncStrategy,