@quaivault/sdk 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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}`;
@@ -204,6 +219,36 @@ function normalizeTxHash(value, label = "transaction hash") {
204
219
  // src/config/resolve.ts
205
220
  import { isAddress } from "quais";
206
221
 
222
+ // src/lifecycle/status.ts
223
+ function nowSeconds() {
224
+ return Math.floor(Date.now() / 1e3);
225
+ }
226
+ function executableAfterOf(approvedAt, executionDelay) {
227
+ return approvedAt > 0 ? approvedAt + executionDelay : 0;
228
+ }
229
+ function deriveStatus(state, at = nowSeconds()) {
230
+ if (state.failed) return "failed";
231
+ if (state.executed) return "executed";
232
+ if (state.cancelled) return state.isExpired ? "expired" : "cancelled";
233
+ if (state.expiration > 0 && at > state.expiration) return "expired";
234
+ if (state.approvalCount < state.threshold) return "pending";
235
+ const executableAfter = executableAfterOf(state.approvedAt, state.executionDelay);
236
+ if (state.executionDelay > 0 && (state.approvedAt === 0 || at < executableAfter)) {
237
+ return "timelocked";
238
+ }
239
+ return "ready";
240
+ }
241
+ function isTerminal(status) {
242
+ return status === "executed" || status === "failed" || status === "cancelled" || status === "expired";
243
+ }
244
+ function deriveRecoveryStatus(state, at = nowSeconds()) {
245
+ if (state.executed) return "executed";
246
+ if (state.expiration > 0 && at > state.expiration) return "expired";
247
+ if (state.approvalCount < state.requiredThreshold) return "pending";
248
+ if (at < state.executionTime) return "timelocked";
249
+ return "ready";
250
+ }
251
+
207
252
  // src/config/networks.ts
208
253
  var INDEXER_URL = "https://xbftgyuxaxagptudledv.supabase.co";
209
254
  var INDEXER_PUBLISHABLE_KEY = "sb_publishable_EO-sTB-jqUzlsiugOEks-Q_qRtHDaHU";
@@ -398,9 +443,14 @@ function resolveConfig(options = {}) {
398
443
  contracts,
399
444
  indexer,
400
445
  rpcUrl,
401
- privateKey: pick(options.privateKey, env[ENV_VARS.privateKey]),
446
+ // An explicit signer wins outright in `Connection`, so resolving a key here would
447
+ // only pull a secret into memory that nothing can use. Skip it.
448
+ privateKey: options.signer ? void 0 : pick(options.privateKey, env[ENV_VARS.privateKey]),
402
449
  consistency,
403
450
  maxIndexerLagBlocks: options.maxIndexerLagBlocks ?? parseLag(env[ENV_VARS.maxIndexerLagBlocks]) ?? 50,
451
+ // Deliberately not environment-configurable: a clock is a function, and a numeric
452
+ // offset in an env var would reintroduce the sign convention `Clock` exists to avoid.
453
+ now: options.now ?? nowSeconds,
404
454
  retry: {
405
455
  ...options.retry?.maxAttempts != null ? { maxAttempts: options.retry.maxAttempts } : {},
406
456
  ...options.retry?.baseDelayMs != null ? { baseDelayMs: options.retry.baseDelayMs } : {},
@@ -528,7 +578,7 @@ function isTransient(error) {
528
578
  }
529
579
  var sleep = (ms, signal) => new Promise((resolve, reject) => {
530
580
  if (signal?.aborted) {
531
- reject(new Error("Aborted"));
581
+ reject(new AbortError("Waiting to retry"));
532
582
  return;
533
583
  }
534
584
  const timer = setTimeout(() => {
@@ -537,7 +587,7 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
537
587
  }, ms);
538
588
  const onAbort = () => {
539
589
  clearTimeout(timer);
540
- reject(new Error("Aborted"));
590
+ reject(new AbortError("Waiting to retry"));
541
591
  };
542
592
  signal?.addEventListener("abort", onAbort, { once: true });
543
593
  });
@@ -547,7 +597,7 @@ async function withRetry(fn, options = {}) {
547
597
  const maxDelayMs = options.maxDelayMs ?? DEFAULTS.maxDelayMs;
548
598
  let lastError;
549
599
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
550
- if (options.signal?.aborted) throw new Error("Aborted");
600
+ if (options.signal?.aborted) throw new AbortError("The retried operation");
551
601
  try {
552
602
  return await fn();
553
603
  } catch (error) {
@@ -742,8 +792,39 @@ var FactoryContract = class {
742
792
 
743
793
  // src/errors/decode.ts
744
794
  import { Interface, AbiCoder } from "quais";
795
+
796
+ // src/text.ts
797
+ var UNSAFE_CHARS = new RegExp(
798
+ [
799
+ "[\\u0000-\\u001F",
800
+ // C0 controls, incl. ESC (0x1B) — the head of every ANSI sequence
801
+ "\\u007F-\\u009F",
802
+ // DEL and the C1 controls
803
+ "\\u200B-\\u200F",
804
+ // zero-width space/joiners, LRM, RLM
805
+ "\\u202A-\\u202E",
806
+ // bidi embeddings and overrides
807
+ "\\u2060-\\u2064",
808
+ // word joiner and the invisible operators
809
+ "\\u2066-\\u2069",
810
+ // bidi isolates
811
+ "\\uFEFF]"
812
+ // zero-width no-break space / BOM
813
+ ].join(""),
814
+ "g"
815
+ );
816
+ var DEFAULT_TEXT_LIMIT = 128;
817
+ function sanitizeText(value, maxLength = DEFAULT_TEXT_LIMIT) {
818
+ if (typeof value !== "string") return "";
819
+ const cleaned = value.replace(UNSAFE_CHARS, "").trim();
820
+ if (cleaned.length <= maxLength) return cleaned;
821
+ return `${cleaned.slice(0, Math.max(0, maxLength - 1))}\u2026`;
822
+ }
823
+
824
+ // src/errors/decode.ts
745
825
  var SOLIDITY_ERROR_SELECTOR = "0x08c379a0";
746
826
  var SOLIDITY_PANIC_SELECTOR = "0x4e487b71";
827
+ var REVERT_TEXT_LIMIT = 256;
747
828
  var registry = null;
748
829
  function buildRegistry() {
749
830
  const map = /* @__PURE__ */ new Map();
@@ -833,7 +914,7 @@ function formatArgs(fragment, args) {
833
914
  if (args.length === 0) return "";
834
915
  const parts = fragment.inputs.map((input, i) => {
835
916
  const value = args[i];
836
- const rendered = typeof value === "bigint" ? value.toString() : String(value);
917
+ const rendered = typeof value === "bigint" ? value.toString() : typeof value === "string" ? sanitizeText(value, REVERT_TEXT_LIMIT) : String(value);
837
918
  return input.name ? `${input.name}: ${rendered}` : rendered;
838
919
  });
839
920
  return `(${parts.join(", ")})`;
@@ -849,7 +930,7 @@ function decodeRevert(data) {
849
930
  name: "Error",
850
931
  args: [reason],
851
932
  selector,
852
- message: String(reason)
933
+ message: sanitizeText(reason, REVERT_TEXT_LIMIT)
853
934
  };
854
935
  } catch {
855
936
  return void 0;
@@ -916,7 +997,7 @@ function knownErrorSelectors() {
916
997
  }
917
998
 
918
999
  // src/encode/index.ts
919
- import { AbiCoder as AbiCoder2, Interface as Interface2, concat, getBytes, solidityPacked, zeroPadValue, toBeHex } from "quais";
1000
+ import { AbiCoder as AbiCoder2, Interface as Interface2, concat, getBytes, solidityPacked } from "quais";
920
1001
 
921
1002
  // src/types.ts
922
1003
  var Operation = /* @__PURE__ */ ((Operation2) => {
@@ -937,6 +1018,7 @@ var MAX_EXECUTION_DELAY = 30 * 24 * 60 * 60;
937
1018
  var MAX_OWNERS = 20;
938
1019
  var MAX_MODULES = 50;
939
1020
  var SENTINEL_MODULES = "0x0000000000000000000000000000000000000001";
1021
+ var ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
940
1022
  var selfCall = {
941
1023
  addOwner: (owner) => vault.encodeFunctionData("addOwner", [owner]),
942
1024
  removeOwner: (owner) => vault.encodeFunctionData("removeOwner", [owner]),
@@ -1043,7 +1125,7 @@ function encodeMultiSendPayload(calls) {
1043
1125
  function encodeMultiSend(calls) {
1044
1126
  return multiSend.encodeFunctionData("multiSend", [encodeMultiSendPayload(calls)]);
1045
1127
  }
1046
- function minimumExpiration(effectiveDelaySeconds, marginSeconds = 300, at = Math.floor(Date.now() / 1e3)) {
1128
+ function minimumExpiration(effectiveDelaySeconds, marginSeconds = 300, at = nowSeconds()) {
1047
1129
  return at + effectiveDelaySeconds + marginSeconds;
1048
1130
  }
1049
1131
 
@@ -1104,7 +1186,7 @@ var syncStrategy = {
1104
1186
  async mine({ factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal }) {
1105
1187
  const startedAt = Date.now();
1106
1188
  for (let attempts = 0; attempts < maxAttempts; ) {
1107
- if (signal?.aborted) throw new SaltMiningError("Salt mining was aborted.");
1189
+ if (signal?.aborted) throw new AbortError("Salt mining");
1108
1190
  if (Date.now() - startedAt > timeoutMs) {
1109
1191
  throw new SaltMiningError(
1110
1192
  `Salt mining timed out after ${timeoutMs} ms (${attempts} attempts).`,
@@ -1132,109 +1214,127 @@ var syncStrategy = {
1132
1214
  );
1133
1215
  }
1134
1216
  };
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);
1217
+ var WorkersUnavailable = class extends Error {
1218
+ };
1219
+ async function loadNodeWorkers() {
1220
+ try {
1221
+ const { Worker } = await import("worker_threads");
1222
+ const os = await import("os");
1223
+ return { Worker, parallelism: (os.availableParallelism ?? (() => os.cpus().length))() };
1224
+ } catch {
1225
+ return null;
1226
+ }
1227
+ }
1228
+ function createWorkerThreadsStrategy(load = loadNodeWorkers) {
1229
+ return {
1230
+ name: "worker_threads",
1231
+ async mine(job) {
1232
+ const runtime = await load();
1233
+ if (!runtime) return syncStrategy.mine(job);
1234
+ try {
1235
+ return await mineInWorkers(runtime.Worker, runtime.parallelism, job);
1236
+ } catch (err) {
1237
+ if (err instanceof WorkersUnavailable) return syncStrategy.mine(job);
1238
+ throw err;
1239
+ }
1146
1240
  }
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 });
1241
+ };
1242
+ }
1243
+ var workerThreadsStrategy = createWorkerThreadsStrategy();
1244
+ function mineInWorkers(Worker, parallelism, job) {
1245
+ const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal } = job;
1246
+ const workerCount = Math.max(1, Math.min(8, parallelism - 1));
1247
+ const perWorker = Math.ceil(maxAttempts / workerCount);
1248
+ const startedAt = Date.now();
1249
+ const workerSource = `
1250
+ const { parentPort, workerData } = require('node:worker_threads');
1251
+ const { getCreate2Address, hexlify, isQuaiAddress, keccak256, randomBytes, solidityPacked } = require('quais');
1252
+ const { factory, deployer, bytecodeHash, targetPrefix, maxAttempts } = workerData;
1253
+ for (let i = 0; i < maxAttempts; i++) {
1254
+ const salt = hexlify(randomBytes(32));
1255
+ const fullSalt = keccak256(solidityPacked(['address', 'bytes32'], [deployer, salt]));
1256
+ const address = getCreate2Address(factory, fullSalt, bytecodeHash);
1257
+ if (address.toLowerCase().startsWith(targetPrefix) && isQuaiAddress(address)) {
1258
+ parentPort.postMessage({ type: 'result', salt, address, attempts: i + 1 });
1259
+ return;
1164
1260
  }
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
- )
1261
+ if ((i + 1) % ${PROGRESS_INTERVAL} === 0) parentPort.postMessage({ type: 'progress', attempts: i + 1 });
1262
+ }
1263
+ parentPort.postMessage({ type: 'exhausted', attempts: maxAttempts });
1264
+ `;
1265
+ return new Promise((resolve, reject) => {
1266
+ const workers = [];
1267
+ let settled = false;
1268
+ let exhausted = 0;
1269
+ let totalAttempts = 0;
1270
+ const progressByWorker = new Array(workerCount).fill(0);
1271
+ const cleanup = () => {
1272
+ clearTimeout(timer);
1273
+ signal?.removeEventListener("abort", onAbort);
1274
+ for (const w of workers) void w.terminate();
1275
+ };
1276
+ const settle = (fn) => {
1277
+ if (settled) return;
1278
+ settled = true;
1279
+ cleanup();
1280
+ fn();
1281
+ };
1282
+ const timer = setTimeout(
1283
+ () => settle(
1284
+ () => reject(
1285
+ new SaltMiningError(
1286
+ `Salt mining timed out after ${timeoutMs} ms (~${totalAttempts} attempts across ${workerCount} workers).`,
1287
+ "Raise timeoutMs or maxAttempts."
1191
1288
  )
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, {
1289
+ )
1290
+ ),
1291
+ timeoutMs
1292
+ );
1293
+ const onAbort = () => settle(() => reject(new AbortError("Salt mining")));
1294
+ signal?.addEventListener("abort", onAbort, { once: true });
1295
+ for (let i = 0; i < workerCount; i++) {
1296
+ let worker;
1297
+ try {
1298
+ worker = new Worker(workerSource, {
1199
1299
  eval: true,
1200
1300
  workerData: { factory, deployer, bytecodeHash, targetPrefix, maxAttempts: perWorker }
1201
1301
  });
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") {
1302
+ } catch (err) {
1303
+ settle(() => reject(new WorkersUnavailable(String(err))));
1304
+ return;
1305
+ }
1306
+ workers.push(worker);
1307
+ worker.on("message", (msg) => {
1308
+ if (msg.type === "progress") {
1309
+ progressByWorker[i] = msg.attempts ?? 0;
1310
+ totalAttempts = progressByWorker.reduce((a, b) => a + b, 0);
1311
+ onProgress?.(totalAttempts);
1312
+ } else if (msg.type === "result") {
1313
+ settle(
1314
+ () => resolve({
1315
+ salt: msg.salt,
1316
+ predictedAddress: msg.address,
1317
+ attempts: totalAttempts + (msg.attempts ?? 0),
1318
+ durationMs: Date.now() - startedAt
1319
+ })
1320
+ );
1321
+ } else if (msg.type === "exhausted") {
1322
+ if (++exhausted === workerCount) {
1209
1323
  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
- )
1324
+ () => reject(
1325
+ new SaltMiningError(
1326
+ `No valid salt found for prefix ${targetPrefix} after ${maxAttempts} attempts.`,
1327
+ "Raise maxAttempts, or confirm the deployer address is on the shard you expect."
1225
1328
  )
1226
- );
1227
- }
1329
+ )
1330
+ );
1228
1331
  }
1229
- });
1230
- worker.on(
1231
- "error",
1232
- (err) => settle(() => reject(new SaltMiningError(`Salt mining worker failed: ${err.message}`)))
1233
- );
1234
- }
1235
- });
1236
- }
1237
- };
1332
+ }
1333
+ });
1334
+ worker.on("error", (err) => settle(() => reject(new WorkersUnavailable(err.message))));
1335
+ }
1336
+ });
1337
+ }
1238
1338
  function defaultStrategy() {
1239
1339
  const hasNode = typeof globalThis.process?.versions?.node === "string";
1240
1340
  return hasNode ? workerThreadsStrategy : syncStrategy;
@@ -1456,7 +1556,7 @@ function validateCreateParams(params) {
1456
1556
  owners.forEach((owner, i) => {
1457
1557
  assertQuaiAddress(owner, `owner[${i}]`);
1458
1558
  const key = owner.toLowerCase();
1459
- if (key === "0x0000000000000000000000000000000000000000" || key === "0x0000000000000000000000000000000000000001") {
1559
+ if (key === ZERO_ADDRESS || key === SENTINEL_MODULES) {
1460
1560
  throw new ValidationError(
1461
1561
  `Owner ${owner} is reserved \u2014 the zero address and the module sentinel 0x\u202601 are rejected.`
1462
1562
  );
@@ -1465,6 +1565,11 @@ function validateCreateParams(params) {
1465
1565
  seen.add(key);
1466
1566
  });
1467
1567
  if (params.initialModules?.length) {
1568
+ if (params.initialModules.length > MAX_MODULES) {
1569
+ throw new ValidationError(
1570
+ `A vault may have at most ${MAX_MODULES} modules (got ${params.initialModules.length}).`
1571
+ );
1572
+ }
1468
1573
  assertQuaiAddresses(params.initialModules, "initialModules");
1469
1574
  }
1470
1575
  if (params.initialDelegatecallTargets?.length) {
@@ -1502,7 +1607,8 @@ function extractCreatedVault(receipt, factoryAddress) {
1502
1607
  }
1503
1608
 
1504
1609
  // src/indexer/client.ts
1505
- import { createClient } from "@supabase/supabase-js";
1610
+ import { PostgrestClient } from "@supabase/postgrest-js";
1611
+ import { RealtimeClient } from "@supabase/realtime-js";
1506
1612
 
1507
1613
  // src/indexer/schemas.ts
1508
1614
  var schemas_exports = {};
@@ -1701,30 +1807,63 @@ function toNumber(value, fallback = 0) {
1701
1807
  }
1702
1808
 
1703
1809
  // src/indexer/client.ts
1704
- var SDK_VERSION = "0.1.0";
1810
+ var SDK_VERSION = true ? "0.2.1" : "dev";
1705
1811
  var IndexerClient = class {
1706
1812
  config;
1707
1813
  client;
1814
+ realtime = null;
1708
1815
  healthCache = null;
1709
1816
  inflightHealth = null;
1710
- /** Health results are reused for this long to keep read paths cheap. */
1817
+ /**
1818
+ * Health results are reused for this long to keep read paths cheap.
1819
+ *
1820
+ * This and `waitForBlock`'s deadline are elapsed-time arithmetic, so they use the
1821
+ * raw local clock rather than `ClientOptions.now` — see the note in `chain/retry.ts`.
1822
+ */
1711
1823
  healthCacheMs;
1712
1824
  constructor(config, options = {}) {
1713
1825
  this.config = config;
1714
1826
  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}` } }
1827
+ this.client = new PostgrestClient(`${baseUrl(config.url)}/rest/v1`, {
1828
+ headers: {
1829
+ apikey: config.anonKey,
1830
+ Authorization: `Bearer ${config.anonKey}`,
1831
+ "x-client-info": `quaivault-sdk/${SDK_VERSION}`
1832
+ },
1833
+ schema: config.schema
1719
1834
  });
1720
1835
  }
1721
- /** Raw Supabase client, for queries the SDK does not wrap. */
1722
- get raw() {
1836
+ /** The PostgREST client, for queries the SDK does not wrap. */
1837
+ get rest() {
1723
1838
  return this.client;
1724
1839
  }
1725
1840
  from(table) {
1726
1841
  return this.client.from(table);
1727
1842
  }
1843
+ /**
1844
+ * Realtime client, constructed on first use.
1845
+ *
1846
+ * Lazy because it opens a WebSocket and starts a heartbeat, and the large majority
1847
+ * of SDK usage never calls `watch()`. Nothing here should cost a socket unless
1848
+ * somebody asked to follow a vault.
1849
+ */
1850
+ realtimeClient() {
1851
+ if (!this.realtime) {
1852
+ this.realtime = new RealtimeClient(
1853
+ `${baseUrl(this.config.url).replace(/^http/, "ws")}/realtime/v1`,
1854
+ { params: { apikey: this.config.anonKey } }
1855
+ );
1856
+ }
1857
+ return this.realtime;
1858
+ }
1859
+ /** Open a Realtime channel. See `watchVault`, which is what callers should use. */
1860
+ channel(name) {
1861
+ return this.realtimeClient().channel(name);
1862
+ }
1863
+ /** Close a channel opened by {@link channel}. */
1864
+ async removeChannel(channel) {
1865
+ await this.realtimeClient().removeChannel(channel);
1866
+ }
1728
1867
  /**
1729
1868
  * Run a query, parse each row, and surface failures as `IndexerQueryError`.
1730
1869
  *
@@ -1742,6 +1881,36 @@ var IndexerClient = class {
1742
1881
  }
1743
1882
  return (data ?? []).map((row) => schema.parse(row));
1744
1883
  }
1884
+ /**
1885
+ * Paged select: an exact `hasMore`, an approximate `total`.
1886
+ *
1887
+ * Both halves of that are deliberate. An `exact` count makes Postgres scan every
1888
+ * matching row on every page request, which is invisible on a young vault and
1889
+ * quadratic on a busy one — so the count is `estimated`, cheap and good enough for
1890
+ * "roughly how much history is there".
1891
+ *
1892
+ * But `hasMore` derived from an estimate would be a lie a caller can act on: a
1893
+ * paging loop that stops early drops real rows. So callers request one row beyond
1894
+ * the page and this trims it, which answers "is there another page" exactly, for
1895
+ * the cost of a single extra row.
1896
+ */
1897
+ async selectPage(table, schema, limit, build) {
1898
+ const { data, error, count } = await build(this.client.from(table));
1899
+ if (error) {
1900
+ throw new IndexerQueryError(`Indexer query on "${table}" failed: ${error.message}`, error);
1901
+ }
1902
+ const rows = data ?? [];
1903
+ const hasMore = rows.length > limit;
1904
+ const parsed = (hasMore ? rows.slice(0, limit) : rows).map(
1905
+ (row) => schema.parse(row)
1906
+ );
1907
+ return {
1908
+ data: parsed,
1909
+ // Never report a total below what was just handed out, however the estimate lands.
1910
+ total: Math.max(count ?? 0, parsed.length),
1911
+ hasMore
1912
+ };
1913
+ }
1745
1914
  /** Single-row variant. Returns null for PostgREST's "no rows" code. */
1746
1915
  async selectOne(table, schema, build) {
1747
1916
  const { data, error } = await build(this.client.from(table));
@@ -1796,7 +1965,7 @@ var IndexerClient = class {
1796
1965
  const pollIntervalMs = options.pollIntervalMs ?? 1500;
1797
1966
  const deadline = Date.now() + timeoutMs;
1798
1967
  for (; ; ) {
1799
- if (options.signal?.aborted) throw new Error("Aborted");
1968
+ if (options.signal?.aborted) throw new AbortError("Waiting for the indexer");
1800
1969
  const state = await this.state();
1801
1970
  const head = state?.lastIndexedBlock ?? 0;
1802
1971
  if (head >= blockNumber) return { reached: true, lastIndexedBlock: head };
@@ -1808,12 +1977,12 @@ var IndexerClient = class {
1808
1977
  }
1809
1978
  async probeHealth() {
1810
1979
  if (this.config.healthUrl) {
1980
+ const controller = new AbortController();
1981
+ const timer = setTimeout(() => controller.abort(), 5e3);
1811
1982
  try {
1812
- const controller = new AbortController();
1813
- const timer = setTimeout(() => controller.abort(), 5e3);
1814
1983
  const res = await fetch(new URL("/health", this.config.healthUrl), {
1815
1984
  signal: controller.signal
1816
- }).finally(() => clearTimeout(timer));
1985
+ });
1817
1986
  const body = await res.json();
1818
1987
  const d = body.details ?? {};
1819
1988
  return {
@@ -1825,6 +1994,8 @@ var IndexerClient = class {
1825
1994
  ...res.ok ? {} : { error: `health endpoint returned ${res.status}` }
1826
1995
  };
1827
1996
  } catch {
1997
+ } finally {
1998
+ clearTimeout(timer);
1828
1999
  }
1829
2000
  }
1830
2001
  try {
@@ -1847,10 +2018,14 @@ var IndexerClient = class {
1847
2018
  }
1848
2019
  }
1849
2020
  };
2021
+ function baseUrl(url) {
2022
+ return url.replace(/\/+$/, "");
2023
+ }
1850
2024
 
1851
2025
  // src/indexer/queries.ts
1852
2026
  var DEFAULT_LIMIT = 50;
1853
2027
  var MAX_LIMIT = 200;
2028
+ var CONFIRMATION_CHUNK = 40;
1854
2029
  function lower(address2) {
1855
2030
  return address2.toLowerCase();
1856
2031
  }
@@ -1909,14 +2084,40 @@ var IndexerQueries = class {
1909
2084
  (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("tx_hash", txHash.toLowerCase()).single()
1910
2085
  );
1911
2086
  }
2087
+ /**
2088
+ * Several transactions by hash, chunked for the same reasons as
2089
+ * {@link activeConfirmationsBatch} — hashes are long, and they go in the query string.
2090
+ *
2091
+ * Rows come back in no particular order and hashes with no row are simply absent;
2092
+ * the caller matches them up.
2093
+ */
2094
+ async transactionsByHash(vault2, txHashes) {
2095
+ if (txHashes.length === 0) return [];
2096
+ const hashes = txHashes.map((h) => h.toLowerCase());
2097
+ const chunks = [];
2098
+ for (let i = 0; i < hashes.length; i += CONFIRMATION_CHUNK) {
2099
+ chunks.push(hashes.slice(i, i + CONFIRMATION_CHUNK));
2100
+ }
2101
+ const pages = await Promise.all(
2102
+ chunks.map(
2103
+ (chunk) => this.client.select(
2104
+ "transactions",
2105
+ TransactionSchema,
2106
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).in("tx_hash", chunk)
2107
+ )
2108
+ )
2109
+ );
2110
+ return pages.flat();
2111
+ }
1912
2112
  async transactionHistory(vault2, options = {}) {
1913
2113
  const { limit, offset } = bounds(options);
1914
2114
  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 };
2115
+ return this.client.selectPage(
2116
+ "transactions",
2117
+ TransactionSchema,
2118
+ limit,
2119
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).in("status", statuses).order("submitted_at_block", { ascending: false }).range(offset, offset + limit)
2120
+ );
1920
2121
  }
1921
2122
  /** All confirmations for one transaction, including revoked ones. */
1922
2123
  async confirmations(vault2, txHash) {
@@ -1927,26 +2128,41 @@ var IndexerQueries = class {
1927
2128
  );
1928
2129
  }
1929
2130
  /**
1930
- * Active confirmations for many transactions in one round trip.
2131
+ * Active confirmations for many transactions, in as few round trips as is safe.
1931
2132
  *
1932
2133
  * "Active" here means not revoked. It does NOT mean the confirming address is
1933
2134
  * still an owner — the indexer does not deactivate confirmations when an owner is
1934
2135
  * removed, while the contract invalidates them via approval epochs. Callers must
1935
2136
  * intersect with the active owner set; `Vault` does this for you.
2137
+ *
2138
+ * Chunked rather than issued as one `in(...)`, because a single filter fails two
2139
+ * ways at scale. PostgREST reads filters from the query string, and each 32-byte
2140
+ * hash costs ~70 characters there — a full {@link MAX_LIMIT} page of hashes builds a
2141
+ * ~14 KB request line, past the 8 KB cap most reverse proxies apply by default. The
2142
+ * response is bounded too: {@link CONFIRMATION_CHUNK} transactions can carry at most
2143
+ * `chunk × MAX_OWNERS` rows, and the chunk is sized to keep that under the
2144
+ * `max-rows` ceiling PostgREST deployments commonly set, so a busy vault cannot be
2145
+ * silently short-served.
1936
2146
  */
1937
2147
  async activeConfirmationsBatch(vault2, txHashes) {
1938
2148
  const result = /* @__PURE__ */ new Map();
1939
2149
  for (const hash of txHashes) result.set(hash.toLowerCase(), []);
1940
2150
  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)
2151
+ const hashes = txHashes.map((h) => h.toLowerCase());
2152
+ const chunks = [];
2153
+ for (let i = 0; i < hashes.length; i += CONFIRMATION_CHUNK) {
2154
+ chunks.push(hashes.slice(i, i + CONFIRMATION_CHUNK));
2155
+ }
2156
+ const pages = await Promise.all(
2157
+ chunks.map(
2158
+ (chunk) => this.client.select(
2159
+ "confirmations",
2160
+ ConfirmationSchema,
2161
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).in("tx_hash", chunk).eq("is_active", true)
2162
+ )
2163
+ )
1948
2164
  );
1949
- for (const row of rows) {
2165
+ for (const row of pages.flat()) {
1950
2166
  const key = row.tx_hash.toLowerCase();
1951
2167
  const list = result.get(key);
1952
2168
  if (list) list.push(row);
@@ -1974,19 +2190,52 @@ var IndexerQueries = class {
1974
2190
  // ---- value movement ------------------------------------------------------
1975
2191
  async deposits(vault2, options = {}) {
1976
2192
  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 };
2193
+ return this.client.selectPage(
2194
+ "deposits",
2195
+ DepositSchema,
2196
+ limit,
2197
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).order("deposited_at_block", { ascending: false }).range(offset, offset + limit)
2198
+ );
1982
2199
  }
1983
2200
  async tokenTransfers(vault2, options = {}) {
1984
2201
  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 };
2202
+ return this.client.selectPage(
2203
+ "token_transfers",
2204
+ TokenTransferSchema,
2205
+ limit,
2206
+ (q) => q.select("*", { count: "estimated" }).eq("wallet_address", lower(vault2)).order("block_number", { ascending: false }).range(offset, offset + limit)
2207
+ );
2208
+ }
2209
+ /**
2210
+ * Scan up to `budget` of the most recent transfer rows, paging past {@link MAX_LIMIT}.
2211
+ *
2212
+ * `tokenTransfers` clamps a single request, so a caller asking for more than the cap
2213
+ * silently received a short page — and then computed "did I see everything?" from
2214
+ * that short page's `hasMore`, which reported truncation the caller had not actually
2215
+ * hit. Paging here keeps the caller's budget meaningful and makes the returned
2216
+ * `hasMore` mean what it says: rows exist beyond the budget that was scanned.
2217
+ *
2218
+ * Pages by offset over a descending order, so a transfer landing mid-scan can shift
2219
+ * rows by one. That is acceptable for token *discovery* — a duplicate collapses into
2220
+ * the same map key and a missed row costs at most one candidate that the next call
2221
+ * picks up. Do not reuse this for anything that must see each row exactly once.
2222
+ */
2223
+ async tokenTransferScan(vault2, budget) {
2224
+ const target = Math.max(1, Math.floor(budget));
2225
+ const data = [];
2226
+ let total = 0;
2227
+ let hasMore = false;
2228
+ while (data.length < target) {
2229
+ const page = await this.tokenTransfers(vault2, {
2230
+ limit: Math.min(MAX_LIMIT, target - data.length),
2231
+ offset: data.length
2232
+ });
2233
+ data.push(...page.data);
2234
+ total = page.total;
2235
+ hasMore = page.hasMore;
2236
+ if (page.data.length === 0 || !page.hasMore) break;
2237
+ }
2238
+ return { data, total, hasMore };
1990
2239
  }
1991
2240
  async tokens(addresses) {
1992
2241
  if (addresses.length === 0) return [];
@@ -2178,39 +2427,7 @@ var RecoveryContract = class {
2178
2427
  }
2179
2428
  };
2180
2429
 
2181
- // src/lifecycle/status.ts
2182
- function nowSeconds() {
2183
- return Math.floor(Date.now() / 1e3);
2184
- }
2185
- function executableAfterOf(approvedAt, executionDelay) {
2186
- return approvedAt > 0 ? approvedAt + executionDelay : 0;
2187
- }
2188
- function deriveStatus(state, at = nowSeconds()) {
2189
- if (state.failed) return "failed";
2190
- if (state.executed) return "executed";
2191
- if (state.cancelled) return state.isExpired ? "expired" : "cancelled";
2192
- if (state.expiration > 0 && at > state.expiration) return "expired";
2193
- if (state.approvalCount < state.threshold) return "pending";
2194
- const executableAfter = executableAfterOf(state.approvedAt, state.executionDelay);
2195
- if (state.executionDelay > 0 && (state.approvedAt === 0 || at < executableAfter)) {
2196
- return "timelocked";
2197
- }
2198
- return "ready";
2199
- }
2200
- function isTerminal(status) {
2201
- return status === "executed" || status === "failed" || status === "cancelled" || status === "expired";
2202
- }
2203
- function deriveRecoveryStatus(state, at = nowSeconds()) {
2204
- if (state.executed) return "executed";
2205
- if (state.expiration > 0 && at > state.expiration) return "expired";
2206
- if (state.approvalCount < state.requiredThreshold) return "pending";
2207
- if (at < state.executionTime) return "timelocked";
2208
- return "ready";
2209
- }
2210
-
2211
2430
  // src/recovery.ts
2212
- var SENTINEL = "0x0000000000000000000000000000000000000001";
2213
- var ZERO = "0x0000000000000000000000000000000000000000";
2214
2431
  var RecoveryModule = class {
2215
2432
  constructor(ctx) {
2216
2433
  this.ctx = ctx;
@@ -2232,14 +2449,27 @@ var RecoveryModule = class {
2232
2449
  get vault() {
2233
2450
  return this.ctx.vaultAddress;
2234
2451
  }
2452
+ /**
2453
+ * The vault this module acts on, through the same retrying facade every other vault
2454
+ * read in the SDK uses. Recovery reaches into the vault for three checks — is the
2455
+ * module enabled, is the caller an owner, who are the current owners — and each of
2456
+ * them gates a write, so none of them should be the one read that silently skips
2457
+ * transient-failure handling.
2458
+ */
2459
+ /** Now, in Unix seconds, for anything compared against a chain timestamp. */
2460
+ now() {
2461
+ return (this.ctx.now ?? nowSeconds)();
2462
+ }
2463
+ vaultContract() {
2464
+ return new VaultContract(this.ctx.connection.vault(this.vault), this.ctx.connection.retry);
2465
+ }
2235
2466
  // =========================================================================
2236
2467
  // Reads
2237
2468
  // =========================================================================
2238
2469
  /** Whether the module is currently enabled on the vault. */
2239
2470
  async isEnabled() {
2240
2471
  if (!this.ctx.moduleAddress) return false;
2241
- const vault2 = this.ctx.connection.vault(this.vault);
2242
- return vault2.getFunction("isModuleEnabled(address)")(this.address);
2472
+ return this.vaultContract().isModuleEnabled(this.address);
2243
2473
  }
2244
2474
  async config() {
2245
2475
  const raw = await this.contract().getRecoveryConfig(this.vault);
@@ -2297,7 +2527,7 @@ var RecoveryModule = class {
2297
2527
  */
2298
2528
  async history(options = {}) {
2299
2529
  const queries = this.ctx.queries;
2300
- if (!queries) return [];
2530
+ if (!queries) throw new NoIndexerError("Listing recovery history");
2301
2531
  const rows = await queries.recoveryHistory(this.vault, options);
2302
2532
  return rows.map((row) => ({
2303
2533
  hash: row.recovery_hash,
@@ -2317,7 +2547,7 @@ var RecoveryModule = class {
2317
2547
  requiredThreshold: row.required_threshold,
2318
2548
  executionTime: Number(row.execution_time),
2319
2549
  expiration: Number(row.expiration ?? 0)
2320
- }),
2550
+ }, this.now()),
2321
2551
  executed: row.status === "executed",
2322
2552
  initiator: getAddress3(row.initiator_address),
2323
2553
  source: "indexer"
@@ -2426,21 +2656,20 @@ var RecoveryModule = class {
2426
2656
  `Not enough guardian approvals: ${request.approvalCount} of ${request.requiredThreshold}.`
2427
2657
  );
2428
2658
  }
2429
- const now = nowSeconds();
2659
+ const now = this.now();
2430
2660
  if (now < request.executionTime) {
2431
2661
  throw new PreconditionError(
2432
2662
  `The recovery period has not elapsed. Executable after ${new Date(request.executionTime * 1e3).toISOString()}.`,
2433
2663
  { retryableAt: request.executionTime }
2434
2664
  );
2435
2665
  }
2436
- const vault2 = this.ctx.connection.vault(this.vault);
2437
- const currentOwners = await vault2.getFunction("getOwners()")();
2666
+ const currentOwners = await this.vaultContract().getOwners();
2438
2667
  const current = new Set(Array.from(currentOwners).map((o) => String(o).toLowerCase()));
2439
2668
  const overlap = request.newOwners.filter((o) => current.has(o.toLowerCase())).length;
2440
2669
  const peak = current.size + request.newOwners.length - overlap;
2441
- if (peak > 20) {
2670
+ if (peak > MAX_OWNERS) {
2442
2671
  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.`,
2672
+ `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
2673
  {
2445
2674
  remediation: "Run a recovery that retains at least one existing owner, then a second recovery to replace it."
2446
2675
  }
@@ -2455,8 +2684,7 @@ var RecoveryModule = class {
2455
2684
  async cancel(recoveryHash) {
2456
2685
  const hash = normalizeTxHash(recoveryHash, "recovery hash");
2457
2686
  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);
2687
+ const isOwner = await this.vaultContract().isOwner(caller);
2460
2688
  if (!isOwner) {
2461
2689
  throw new PreconditionError(
2462
2690
  `Only a current owner of the vault can cancel a recovery. ${caller} is not one.`
@@ -2473,7 +2701,7 @@ var RecoveryModule = class {
2473
2701
  // Affordances
2474
2702
  // =========================================================================
2475
2703
  /** What `caller` may do to a recovery right now, and when blocked actions unlock. */
2476
- async affordances(recoveryHash, caller) {
2704
+ async affordances(recoveryHash, caller, at) {
2477
2705
  const hash = normalizeTxHash(recoveryHash, "recovery hash");
2478
2706
  const who = caller ?? await this.ctx.connection.addressOrNull();
2479
2707
  if (!who) {
@@ -2487,9 +2715,9 @@ var RecoveryModule = class {
2487
2715
  this.isGuardian(address2),
2488
2716
  this.contract().recoveryApprovals(this.vault, hash, address2),
2489
2717
  this.isEnabled(),
2490
- this.ctx.connection.vault(this.vault).getFunction("isOwner(address)")(address2)
2718
+ this.vaultContract().isOwner(address2)
2491
2719
  ]);
2492
- const now = nowSeconds();
2720
+ const now = at ?? this.now();
2493
2721
  const terminal = request.status === "executed" || request.status === "cancelled";
2494
2722
  const expired = now > request.expiration && request.expiration > 0;
2495
2723
  const quorum = request.approvalCount >= request.requiredThreshold;
@@ -2586,13 +2814,10 @@ var RecoveryModule = class {
2586
2814
  const approvalCount = Number(raw.approvalCount ?? 0);
2587
2815
  const requiredThreshold = Number(raw.requiredThreshold ?? 0);
2588
2816
  const executed = Boolean(raw.executed);
2589
- const status = deriveRecoveryStatus({
2590
- executed,
2591
- approvalCount,
2592
- requiredThreshold,
2593
- executionTime,
2594
- expiration
2595
- });
2817
+ const status = deriveRecoveryStatus(
2818
+ { executed, approvalCount, requiredThreshold, executionTime, expiration },
2819
+ this.now()
2820
+ );
2596
2821
  return {
2597
2822
  hash,
2598
2823
  vault: this.vault,
@@ -2612,7 +2837,7 @@ var RecoveryModule = class {
2612
2837
  const seen = /* @__PURE__ */ new Set();
2613
2838
  for (const owner of newOwners) {
2614
2839
  const key = owner.toLowerCase();
2615
- if (key === ZERO || key === SENTINEL) {
2840
+ if (key === ZERO_ADDRESS || key === SENTINEL_MODULES) {
2616
2841
  throw new ValidationError(
2617
2842
  `New owner ${owner} is reserved \u2014 the zero address and the module sentinel 0x\u202601 are rejected.`
2618
2843
  );
@@ -2633,7 +2858,7 @@ var RecoveryModule = class {
2633
2858
  if (request.executed) {
2634
2859
  throw new PreconditionError("This recovery has already been executed.");
2635
2860
  }
2636
- if (request.expiration > 0 && nowSeconds() > request.expiration) {
2861
+ if (request.expiration > 0 && this.now() > request.expiration) {
2637
2862
  throw new PreconditionError("This recovery has expired.", {
2638
2863
  remediation: "Call expire() to clean it up, then initiate a new recovery."
2639
2864
  });
@@ -2693,16 +2918,61 @@ function terminalReason(request) {
2693
2918
  }
2694
2919
 
2695
2920
  // src/balances.ts
2696
- import { Contract as Contract2, getAddress as getAddress4 } from "quais";
2921
+ import { getAddress as getAddress4 } from "quais";
2922
+
2923
+ // src/chain/token-contract.ts
2924
+ var TOKEN_RETRY = { maxAttempts: 2, baseDelayMs: 150 };
2925
+ var TokenContract = class {
2926
+ constructor(contract, retry = {}) {
2927
+ this.contract = contract;
2928
+ this.retry = { ...TOKEN_RETRY, ...retry };
2929
+ }
2930
+ contract;
2931
+ retry;
2932
+ read(signature, ...args) {
2933
+ return withRetry(() => this.contract.getFunction(signature)(...args), this.retry);
2934
+ }
2935
+ /** ERC20 units held, or — for ERC721 — the number of tokens held. */
2936
+ balanceOf(owner) {
2937
+ return this.read("balanceOf(address)", owner);
2938
+ }
2939
+ /** ERC721 only. Reverts for a burned or never-minted id. */
2940
+ ownerOf(tokenId) {
2941
+ return this.read("ownerOf(uint256)", tokenId);
2942
+ }
2943
+ /** ERC1155 only. One call for many ids, so it stays a single round trip. */
2944
+ balanceOfBatch(owners, ids) {
2945
+ return this.read("balanceOfBatch(address[],uint256[])", owners, ids);
2946
+ }
2947
+ };
2948
+
2949
+ // src/pool.ts
2950
+ async function mapPooled(items, limit, fn) {
2951
+ const results = new Array(items.length);
2952
+ let next = 0;
2953
+ const worker = async () => {
2954
+ for (; ; ) {
2955
+ const i = next++;
2956
+ if (i >= items.length) return;
2957
+ results[i] = await fn(items[i], i);
2958
+ }
2959
+ };
2960
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
2961
+ return results;
2962
+ }
2963
+ var DEFAULT_CONCURRENCY = 8;
2964
+
2965
+ // src/balances.ts
2697
2966
  async function loadBalances(connection, queries, vault2, options = {}) {
2698
2967
  const verify = options.verify !== false;
2699
2968
  const maxTokens = options.maxTokens ?? 50;
2700
2969
  const transferScanLimit = options.transferScanLimit ?? 500;
2701
2970
  const maxTokenIdChecks = options.maxTokenIdChecks ?? 100;
2971
+ const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
2702
2972
  const address2 = getAddress4(vault2);
2703
2973
  const native = BigInt(await connection.provider.getBalance(address2));
2704
2974
  if (!queries) return { native, tokens: [] };
2705
- const transfers = await queries.tokenTransfers(address2, { limit: transferScanLimit });
2975
+ const transfers = await queries.tokenTransferScan(address2, transferScanLimit);
2706
2976
  const seen = /* @__PURE__ */ new Map();
2707
2977
  for (const row of transfers.data) {
2708
2978
  const key = row.token_address.toLowerCase();
@@ -2721,17 +2991,18 @@ async function loadBalances(connection, queries, vault2, options = {}) {
2721
2991
  }
2722
2992
  const metadata = await queries.tokens(candidates);
2723
2993
  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
- )
2994
+ const balances = await mapPooled(
2995
+ candidates,
2996
+ concurrency,
2997
+ (token2) => buildBalance(
2998
+ connection,
2999
+ address2,
3000
+ token2,
3001
+ byAddress.get(token2),
3002
+ seen.get(token2) ?? [],
3003
+ verify,
3004
+ maxTokenIdChecks,
3005
+ concurrency
2735
3006
  )
2736
3007
  );
2737
3008
  return {
@@ -2741,30 +3012,35 @@ async function loadBalances(connection, queries, vault2, options = {}) {
2741
3012
  ...Object.keys(truncated).length ? { truncated } : {}
2742
3013
  };
2743
3014
  }
2744
- async function buildBalance(connection, vault2, token2, meta, transfers, verify, maxTokenIdChecks) {
3015
+ async function buildBalance(connection, vault2, token2, meta, transfers, verify, maxTokenIdChecks, concurrency) {
2745
3016
  const standard = meta?.standard ?? inferStandard(transfers);
2746
3017
  const replayed = replayBalance(transfers, standard);
2747
3018
  const base = {
2748
3019
  token: getAddress4(token2),
2749
3020
  standard,
2750
- symbol: meta?.symbol ?? "???",
2751
- name: meta?.name ?? "Unknown token",
3021
+ // Deployer-controlled strings headed for someone's terminal. See `src/text.ts`.
3022
+ symbol: sanitizeText(meta?.symbol, 32) || "???",
3023
+ name: sanitizeText(meta?.name, 64) || "Unknown token",
2752
3024
  decimals: meta?.decimals ?? (standard === "ERC20" ? 18 : 0),
2753
3025
  balance: replayed.balance,
2754
3026
  ...replayed.tokenIds.length > 0 ? { tokenIds: replayed.tokenIds } : {},
2755
3027
  verified: false
2756
3028
  };
2757
3029
  if (!verify) return base;
3030
+ const contract = new TokenContract(connection.token(token2, standard), connection.retry);
2758
3031
  try {
2759
3032
  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 };
3033
+ return { ...base, balance: BigInt(await contract.balanceOf(vault2)), verified: true };
2763
3034
  }
2764
3035
  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);
3036
+ const count = await contract.balanceOf(vault2);
3037
+ const owned = await filterOwned(
3038
+ contract,
3039
+ vault2,
3040
+ replayed.tokenIds,
3041
+ maxTokenIdChecks,
3042
+ concurrency
3043
+ );
2768
3044
  return {
2769
3045
  ...base,
2770
3046
  balance: BigInt(count),
@@ -2773,10 +3049,9 @@ async function buildBalance(connection, vault2, token2, meta, transfers, verify,
2773
3049
  verified: true
2774
3050
  };
2775
3051
  }
2776
- const contract = new Contract2(token2, Erc1155Abi, connection.provider);
2777
3052
  const ids = replayed.tokenIds;
2778
3053
  if (ids.length === 0) return { ...base, verified: true };
2779
- const amounts = await contract.getFunction("balanceOfBatch(address[],uint256[])")(
3054
+ const amounts = await contract.balanceOfBatch(
2780
3055
  ids.map(() => vault2),
2781
3056
  ids
2782
3057
  );
@@ -2821,18 +3096,16 @@ function replayBalance(transfers, standard) {
2821
3096
  }
2822
3097
  return { balance, tokenIds };
2823
3098
  }
2824
- async function filterOwned(contract, vault2, ids, limit) {
3099
+ async function filterOwned(contract, vault2, ids, limit, concurrency) {
2825
3100
  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
- );
3101
+ const results = await mapPooled(ids.slice(0, limit), concurrency, async (id) => {
3102
+ try {
3103
+ const owner = await contract.ownerOf(id);
3104
+ return owner.toLowerCase() === vault2.toLowerCase() ? id : null;
3105
+ } catch {
3106
+ return null;
3107
+ }
3108
+ });
2836
3109
  return results.filter((id) => id !== null);
2837
3110
  }
2838
3111
  function inferStandard(transfers) {
@@ -2868,7 +3141,7 @@ function watchVault(client, vault2, handler, options = {}) {
2868
3141
  const topics = options.topics ?? DEFAULT_TOPICS;
2869
3142
  const address2 = vault2.toLowerCase();
2870
3143
  const schema = client.config.schema;
2871
- const channel = client.raw.channel(`quaivault:${schema}:${address2}`);
3144
+ const channel = client.channel(`quaivault:${schema}:${address2}`);
2872
3145
  for (const topic of topics) {
2873
3146
  const table = TABLE_FOR[topic];
2874
3147
  channel.on(
@@ -2898,7 +3171,7 @@ function watchVault(client, vault2, handler, options = {}) {
2898
3171
  return {
2899
3172
  topics,
2900
3173
  async unsubscribe() {
2901
- await client.raw.removeChannel(channel);
3174
+ await client.removeChannel(channel);
2902
3175
  }
2903
3176
  };
2904
3177
  }
@@ -3347,8 +3620,9 @@ function classifyExecution(receipt, vault2, txHash) {
3347
3620
  }
3348
3621
  const thresholdReached = eventFor(events, "ThresholdReached", txHash);
3349
3622
  if (thresholdReached) {
3623
+ const approvedAt = toNumber2(thresholdReached.args.approvedAt);
3350
3624
  const executableAfter = toNumber2(thresholdReached.args.executableAfter);
3351
- if (executableAfter > Math.floor(Date.now() / 1e3)) {
3625
+ if (executableAfter > approvedAt) {
3352
3626
  return {
3353
3627
  ...base,
3354
3628
  outcome: "timelock_started",
@@ -3378,7 +3652,7 @@ function extractProposedTxHash(receipt, vault2) {
3378
3652
  }
3379
3653
 
3380
3654
  // src/vault.ts
3381
- var Vault = class {
3655
+ var Vault = class _Vault {
3382
3656
  address;
3383
3657
  /** Guardian-based recovery for this vault. */
3384
3658
  recovery;
@@ -3390,7 +3664,8 @@ var Vault = class {
3390
3664
  connection: ctx.connection,
3391
3665
  queries: ctx.queries,
3392
3666
  vaultAddress: this.address,
3393
- moduleAddress: ctx.contracts.socialRecovery
3667
+ moduleAddress: ctx.contracts.socialRecovery,
3668
+ ...ctx.now ? { now: ctx.now } : {}
3394
3669
  });
3395
3670
  }
3396
3671
  // =========================================================================
@@ -3421,6 +3696,30 @@ var Vault = class {
3421
3696
  if (!this.ctx.queries) throw new NoIndexerError(operation);
3422
3697
  return this.ctx.queries;
3423
3698
  }
3699
+ /**
3700
+ * The indexer's head, for stamping onto records read from it.
3701
+ *
3702
+ * Reads the cached health result rather than querying `indexer_state` directly.
3703
+ * `useIndexer()` has just called `health()` on this same code path and the result is
3704
+ * cached, so this is free — whereas a `state()` call is a second round trip per
3705
+ * hydration, paid on every indexed read purely to fill one advisory field.
3706
+ *
3707
+ * Undefined when the indexer is not answering: a head of 0 would read as "indexed at
3708
+ * genesis" rather than "unknown".
3709
+ */
3710
+ async indexerHead() {
3711
+ const health = await this.ctx.indexer?.health();
3712
+ return health?.available ? health.lastIndexedBlock : void 0;
3713
+ }
3714
+ /**
3715
+ * Now, in Unix seconds, for anything compared against a chain timestamp.
3716
+ *
3717
+ * Never used to measure elapsed time — see the duration sites in
3718
+ * {@link waitForExecutable}, which stay on the raw local clock deliberately.
3719
+ */
3720
+ now() {
3721
+ return (this.ctx.now ?? nowSeconds)();
3722
+ }
3424
3723
  contract(write = false) {
3425
3724
  return new VaultContract(this.ctx.connection.vault(this.address, write), this.ctx.connection.retry);
3426
3725
  }
@@ -3448,8 +3747,48 @@ var Vault = class {
3448
3747
  balance: BigInt(balance)
3449
3748
  };
3450
3749
  }
3451
- /** Active owners. Prefers the indexer, falls back to chain. */
3750
+ /**
3751
+ * Capture owners and threshold once, for reuse across a burst of reads.
3752
+ *
3753
+ * Pair with {@link pinned}:
3754
+ *
3755
+ * ```ts
3756
+ * const view = await vault.view();
3757
+ * const pinned = vault.pinned(view);
3758
+ * // every read below shares one owner/threshold read
3759
+ * const txs = await pinned.transactions(hashes);
3760
+ * ```
3761
+ */
3762
+ async view() {
3763
+ const fromIndexer = await this.useIndexer();
3764
+ const [owners, threshold, indexedAtBlock] = await Promise.all([
3765
+ this.owners(),
3766
+ this.threshold(),
3767
+ this.indexerHead()
3768
+ ]);
3769
+ return {
3770
+ owners,
3771
+ threshold,
3772
+ ...indexedAtBlock !== void 0 ? { indexedAtBlock } : {},
3773
+ capturedAt: this.now(),
3774
+ source: fromIndexer ? "indexer" : "chain"
3775
+ };
3776
+ }
3777
+ /**
3778
+ * A handle that answers {@link owners} and {@link threshold} from `view` instead of
3779
+ * re-reading them.
3780
+ *
3781
+ * Reads only. Write preconditions go through `chainOwners` / `chainThreshold`, which
3782
+ * bypass the snapshot by construction — pinning a view can never cause this SDK to
3783
+ * sign against a stale owner set. Everything else about the handle, including the
3784
+ * connection and the recovery module, is shared with the original.
3785
+ */
3786
+ pinned(view) {
3787
+ return new _Vault(this.address, { ...this.ctx, view });
3788
+ }
3789
+ /** Active owners. Prefers a pinned view, then the indexer, then chain. */
3452
3790
  async owners() {
3791
+ if (this.ctx.view) return this.ctx.view.owners;
3453
3792
  if (await this.useIndexer()) {
3454
3793
  try {
3455
3794
  const owners = await this.requireQueries("owners").owners(this.address);
@@ -3457,12 +3796,55 @@ var Vault = class {
3457
3796
  } catch {
3458
3797
  }
3459
3798
  }
3799
+ return this.chainOwners();
3800
+ }
3801
+ /**
3802
+ * Owners straight from the chain, bypassing the indexer entirely.
3803
+ *
3804
+ * {@link owners} prefers the indexer, which is right for display and wrong for
3805
+ * anything that gates a write. A lagging indexer would let the SDK build a proposal
3806
+ * against an owner set that no longer exists — admitting a `removeOwner` that drops
3807
+ * the vault below its threshold, or rejecting one that is perfectly valid. Every
3808
+ * propose-time precondition reads through here instead.
3809
+ */
3810
+ async chainOwners() {
3460
3811
  return (await this.contract().getOwners()).map((o) => getAddress5(String(o)));
3461
3812
  }
3462
3813
  async isOwner(address2) {
3463
3814
  return this.contract().isOwner(getAddress5(address2));
3464
3815
  }
3816
+ /**
3817
+ * Whether `owner`'s approval on `txHash` currently counts toward the threshold.
3818
+ *
3819
+ * Reads the chain, and accounts for approval-epoch invalidation: removing an owner
3820
+ * bumps the vault's epoch and voids every approval that address made, so a
3821
+ * confirmation the indexer still lists may already be dead. This is the authoritative
3822
+ * answer.
3823
+ *
3824
+ * Public because it is one of the two inputs {@link computeAffordances} needs. Without
3825
+ * it a consumer wanting affordances at an adjusted time had no way to assemble an
3826
+ * `AffordanceContext` short of reimplementing this method against the raw contract.
3827
+ */
3828
+ async hasApproved(txHash, owner) {
3829
+ return this.contract().hasApproved(normalizeTxHash(txHash), getAddress5(owner));
3830
+ }
3831
+ /** Approvals required to execute. Prefers a pinned view, then the indexer, then chain. */
3465
3832
  async threshold() {
3833
+ if (this.ctx.view) return this.ctx.view.threshold;
3834
+ if (await this.useIndexer()) {
3835
+ try {
3836
+ const wallet = await this.requireQueries("threshold").wallet(this.address);
3837
+ if (wallet) return wallet.threshold;
3838
+ } catch {
3839
+ }
3840
+ }
3841
+ return this.chainThreshold();
3842
+ }
3843
+ /**
3844
+ * Threshold straight from the chain. The write-path counterpart to {@link threshold},
3845
+ * for the same reason {@link chainOwners} exists.
3846
+ */
3847
+ async chainThreshold() {
3466
3848
  return Number(await this.contract().threshold());
3467
3849
  }
3468
3850
  async balance() {
@@ -3515,6 +3897,53 @@ var Vault = class {
3515
3897
  }
3516
3898
  return this.fromChain(hash);
3517
3899
  }
3900
+ /**
3901
+ * Several transactions at once, keyed by hash.
3902
+ *
3903
+ * The plural form exists because the singular one is expensive to loop: each
3904
+ * `transaction()` re-reads the owner set, the threshold and the indexer head, so
3905
+ * fetching fifty costs fifty times that. This resolves the whole set the way the
3906
+ * listing methods already do — one query for the rows, one owner/threshold read
3907
+ * shared across them, one batched confirmations query.
3908
+ *
3909
+ * Hashes the indexer does not have fall back to chain reads, bounded by a pool.
3910
+ * Hashes that exist nowhere are simply absent from the map rather than throwing:
3911
+ * one unknown hash should not lose the caller the other forty-nine.
3912
+ */
3913
+ async transactions(txHashes) {
3914
+ const hashes = [...new Set(txHashes.map((h) => normalizeTxHash(h)))];
3915
+ const found = /* @__PURE__ */ new Map();
3916
+ if (hashes.length === 0) return found;
3917
+ if (await this.useIndexer()) {
3918
+ try {
3919
+ const queries = this.requireQueries("Reading transactions");
3920
+ const [rows, owners, threshold] = await Promise.all([
3921
+ queries.transactionsByHash(this.address, hashes),
3922
+ this.owners(),
3923
+ this.threshold()
3924
+ ]);
3925
+ for (const tx of await this.hydrateRows(rows, owners, threshold)) {
3926
+ found.set(tx.hash.toLowerCase(), tx);
3927
+ }
3928
+ } catch {
3929
+ }
3930
+ }
3931
+ const missing = hashes.filter((hash) => !found.has(hash));
3932
+ if (missing.length > 0) {
3933
+ const resolved = await mapPooled(missing, DEFAULT_CONCURRENCY, async (hash) => {
3934
+ try {
3935
+ return await this.fromChain(hash);
3936
+ } catch (err) {
3937
+ if (err instanceof NotFoundError) return null;
3938
+ throw err;
3939
+ }
3940
+ });
3941
+ for (const tx of resolved) {
3942
+ if (tx) found.set(tx.hash.toLowerCase(), tx);
3943
+ }
3944
+ }
3945
+ return found;
3946
+ }
3518
3947
  async pendingTransactions(options = {}) {
3519
3948
  const queries = this.requireQueries("Listing pending transactions");
3520
3949
  const [rows, owners, threshold] = await Promise.all([
@@ -3545,7 +3974,7 @@ var Vault = class {
3545
3974
  this.address,
3546
3975
  rows.map((r) => r.tx_hash)
3547
3976
  );
3548
- const indexedAtBlock = (await this.ctx.indexer?.state())?.lastIndexedBlock;
3977
+ const indexedAtBlock = await this.indexerHead();
3549
3978
  return rows.map((row) => {
3550
3979
  const confirmed = (confirmations.get(row.tx_hash.toLowerCase()) ?? []).map(
3551
3980
  (c) => c.owner_address
@@ -3568,7 +3997,7 @@ var Vault = class {
3568
3997
  ]);
3569
3998
  if (!row) return null;
3570
3999
  const confirmations = await queries.confirmations(this.address, hash);
3571
- const indexedAtBlock = (await this.ctx.indexer?.state())?.lastIndexedBlock;
4000
+ const indexedAtBlock = await this.indexerHead();
3572
4001
  return this.buildTransaction({
3573
4002
  row,
3574
4003
  confirmedBy: confirmations.filter((c) => c.is_active).map((c) => c.owner_address),
@@ -3622,7 +4051,7 @@ var Vault = class {
3622
4051
  approvalCount,
3623
4052
  threshold,
3624
4053
  failed: row.status === "failed"
3625
- });
4054
+ }, this.now());
3626
4055
  const failedReturnData = row.failed_return_data ?? void 0;
3627
4056
  return {
3628
4057
  hash: row.tx_hash,
@@ -3631,7 +4060,10 @@ var Vault = class {
3631
4060
  value,
3632
4061
  data,
3633
4062
  proposer: getAddress5(row.submitted_by),
3634
- proposedAt: toNumber(row.submitted_at_block),
4063
+ // The indexer records the block, not the chain timestamp — they are not
4064
+ // interchangeable, and reporting a block number as `proposedAt` renders as 1970.
4065
+ proposedAt: 0,
4066
+ proposedAtBlock: toNumber(row.submitted_at_block),
3635
4067
  kind: decodeResult.kind,
3636
4068
  decoded: decodeResult.decoded,
3637
4069
  summary: decodeResult.summary,
@@ -3702,7 +4134,7 @@ var Vault = class {
3702
4134
  approvedAt,
3703
4135
  approvalCount: approvals.length,
3704
4136
  threshold: Number(threshold)
3705
- }),
4137
+ }, this.now()),
3706
4138
  approvals,
3707
4139
  approvalCount: approvals.length,
3708
4140
  threshold: Number(threshold),
@@ -3720,8 +4152,20 @@ var Vault = class {
3720
4152
  * What `caller` may legally do to `txHash` right now, and when blocked actions
3721
4153
  * become available. Defaults to the connected signer's address.
3722
4154
  */
3723
- async affordances(txHash, caller) {
4155
+ async affordances(txHash, caller, at) {
3724
4156
  const hash = normalizeTxHash(txHash);
4157
+ return this.resolveAffordances(hash, this.transaction(hash), caller, at);
4158
+ }
4159
+ /**
4160
+ * Shared body of {@link affordances}, taking the transaction either as a value or as
4161
+ * an in-flight promise.
4162
+ *
4163
+ * The promise form is the point: a caller that already holds the record (like
4164
+ * {@link describe}) passes it and skips a redundant fetch, while `affordances()`
4165
+ * passes the unawaited promise so the transaction read still overlaps the two
4166
+ * ownership probes rather than serialising behind them.
4167
+ */
4168
+ async resolveAffordances(hash, transaction, caller, at) {
3725
4169
  const who = caller ?? await this.ctx.connection.addressOrNull();
3726
4170
  if (!who) {
3727
4171
  throw new ValidationError(
@@ -3730,11 +4174,17 @@ var Vault = class {
3730
4174
  }
3731
4175
  const vault2 = this.contract();
3732
4176
  const [tx, isOwner, hasApproved] = await Promise.all([
3733
- this.transaction(hash),
4177
+ transaction,
3734
4178
  vault2.isOwner(getAddress5(who)),
3735
4179
  vault2.hasApproved(hash, getAddress5(who))
3736
4180
  ]);
3737
- return computeAffordances({ tx, caller: getAddress5(who), isOwner, hasApproved });
4181
+ return computeAffordances({
4182
+ tx,
4183
+ caller: getAddress5(who),
4184
+ isOwner,
4185
+ hasApproved,
4186
+ at: at ?? this.now()
4187
+ });
3738
4188
  }
3739
4189
  // =========================================================================
3740
4190
  // Proposals
@@ -3825,7 +4275,7 @@ var Vault = class {
3825
4275
  // --- self-calls -------------------------------------------------------
3826
4276
  addOwner: async (owner, options = {}) => this.proposeSelfCall(selfCall.addOwner(assertQuaiAddress(owner, "owner")), options),
3827
4277
  removeOwner: async (owner, options = {}) => {
3828
- const [owners, threshold] = await Promise.all([this.owners(), this.threshold()]);
4278
+ const [owners, threshold] = await Promise.all([this.chainOwners(), this.chainThreshold()]);
3829
4279
  if (!owners.some((o) => o.toLowerCase() === owner.toLowerCase())) {
3830
4280
  throw new PreconditionError(`${owner} is not an owner of this vault.`);
3831
4281
  }
@@ -3838,7 +4288,7 @@ var Vault = class {
3838
4288
  return this.proposeSelfCall(selfCall.removeOwner(getAddress5(owner)), options);
3839
4289
  },
3840
4290
  changeThreshold: async (threshold, options = {}) => {
3841
- const owners = await this.owners();
4291
+ const owners = await this.chainOwners();
3842
4292
  if (threshold < 1 || threshold > owners.length) {
3843
4293
  throw new ValidationError(
3844
4294
  `Invalid threshold ${threshold}: this vault has ${owners.length} owners.`
@@ -3945,11 +4395,11 @@ var Vault = class {
3945
4395
  }
3946
4396
  if (expiration > 0) {
3947
4397
  const floor = isSelfCall ? 0 : Math.max(executionDelay, await this.minExecutionDelay());
3948
- const earliest = minimumExpiration(floor, 0);
4398
+ const earliest = minimumExpiration(floor, 0, this.now());
3949
4399
  if (expiration <= earliest) {
3950
4400
  throw new ValidationError(
3951
4401
  `expiration ${expiration} is too soon: with an effective delay of ${floor}s the vault requires an expiration after ${earliest}.`,
3952
- `Use at least ${minimumExpiration(floor)} to leave a margin for block time.`
4402
+ `Use at least ${minimumExpiration(floor, 300, this.now())} to leave a margin for block time.`
3953
4403
  );
3954
4404
  }
3955
4405
  }
@@ -4272,9 +4722,7 @@ var Vault = class {
4272
4722
  const pollIntervalMs = options.pollIntervalMs ?? 15e3;
4273
4723
  const deadline = Date.now() + timeoutMs;
4274
4724
  for (; ; ) {
4275
- if (options.signal?.aborted) {
4276
- throw new PreconditionError("Waiting for the transaction was aborted.");
4277
- }
4725
+ if (options.signal?.aborted) throw new AbortError("Waiting for the transaction");
4278
4726
  const tx = await this.fromChain(hash);
4279
4727
  options.onPoll?.(tx);
4280
4728
  if (tx.status === "ready") return tx;
@@ -4290,7 +4738,7 @@ var Vault = class {
4290
4738
  { remediation: "Collect the remaining approvals, then wait for the timelock." }
4291
4739
  );
4292
4740
  }
4293
- const untilExecutable = tx.executableAfter > 0 ? tx.executableAfter * 1e3 - Date.now() : pollIntervalMs;
4741
+ const untilExecutable = tx.executableAfter > 0 ? (tx.executableAfter - this.now()) * 1e3 : pollIntervalMs;
4294
4742
  const wait = Math.max(1e3, Math.min(untilExecutable, pollIntervalMs));
4295
4743
  if (Date.now() + wait > deadline) {
4296
4744
  throw new PreconditionError(
@@ -4320,7 +4768,7 @@ var Vault = class {
4320
4768
  lines.push(` quorum reached: ${new Date(tx.approvedAt * 1e3).toISOString()}`);
4321
4769
  }
4322
4770
  if (tx.executableAfter > 0) {
4323
- const remaining = tx.executableAfter - nowSeconds();
4771
+ const remaining = tx.executableAfter - this.now();
4324
4772
  lines.push(
4325
4773
  ` executable after: ${new Date(tx.executableAfter * 1e3).toISOString()}` + (remaining > 0 ? ` (in ${remaining}s)` : " (now)")
4326
4774
  );
@@ -4331,7 +4779,7 @@ var Vault = class {
4331
4779
  if (tx.decodedRevert) lines.push(` revert: ${tx.decodedRevert.message}`);
4332
4780
  lines.push(` source: ${tx.source}`);
4333
4781
  if (caller || this.ctx.connection.hasSigner()) {
4334
- const affordances = await this.affordances(txHash, caller);
4782
+ const affordances = await this.resolveAffordances(tx.hash, tx, caller);
4335
4783
  const allowed = affordances.filter((a) => a.allowed).map((a) => a.action);
4336
4784
  lines.push(` you can: ${allowed.length > 0 ? allowed.join(", ") : "nothing right now"}`);
4337
4785
  }
@@ -4365,6 +4813,11 @@ function toRevertError(err, context) {
4365
4813
 
4366
4814
  // src/client.ts
4367
4815
  var QuaiVaultClient = class {
4816
+ /**
4817
+ * Source of "now" for time compared against chain timestamps. Resolved once from
4818
+ * {@link ClientOptions.now}, defaulting to the local clock. See {@link Clock}.
4819
+ */
4820
+ now;
4368
4821
  /**
4369
4822
  * Resolved configuration, with the private key stripped and the indexer key
4370
4823
  * masked — safe to log. The key itself is consumed once when building the signer.
@@ -4377,6 +4830,7 @@ var QuaiVaultClient = class {
4377
4830
  constructor(options = {}) {
4378
4831
  const resolved = resolveConfig(options);
4379
4832
  this.config = redactConfig(resolved);
4833
+ this.now = resolved.now;
4380
4834
  this.connection = new Connection(resolved, {
4381
4835
  ...options.provider ? { provider: options.provider } : {},
4382
4836
  ...options.signer ? { signer: options.signer } : {}
@@ -4412,7 +4866,8 @@ var QuaiVaultClient = class {
4412
4866
  queries: this.queries,
4413
4867
  contracts: this.config.contracts,
4414
4868
  consistency: this.config.consistency,
4415
- maxIndexerLagBlocks: this.config.maxIndexerLagBlocks
4869
+ maxIndexerLagBlocks: this.config.maxIndexerLagBlocks,
4870
+ now: this.now
4416
4871
  };
4417
4872
  }
4418
4873
  /** Vault discovery. Requires the indexer — there is no on-chain reverse index. */
@@ -4462,8 +4917,11 @@ function connect(options = {}) {
4462
4917
  }
4463
4918
  var QuaiVault = { connect };
4464
4919
  export {
4920
+ AbortError,
4465
4921
  ConfigError,
4466
4922
  Connection,
4923
+ DEFAULT_CONCURRENCY,
4924
+ DEFAULT_TEXT_LIMIT,
4467
4925
  ENV_VARS,
4468
4926
  Erc1155Abi,
4469
4927
  Erc20Abi,
@@ -4496,9 +4954,11 @@ export {
4496
4954
  SaltMiningError,
4497
4955
  SocialRecoveryModuleAbi,
4498
4956
  StaleProposalError,
4957
+ TokenContract,
4499
4958
  ValidationError,
4500
4959
  Vault,
4501
4960
  VaultContract,
4961
+ ZERO_ADDRESS,
4502
4962
  allowedActions,
4503
4963
  assertQuaiAddress,
4504
4964
  assertQuaiAddresses,
@@ -4507,6 +4967,7 @@ export {
4507
4967
  computeBytecodeHash,
4508
4968
  computeFullSalt,
4509
4969
  connect,
4970
+ createWorkerThreadsStrategy,
4510
4971
  decodeCall,
4511
4972
  decodeMultiSendPayload,
4512
4973
  decodeRevert,
@@ -4529,6 +4990,7 @@ export {
4529
4990
  knownErrorSelectors,
4530
4991
  loadBalances,
4531
4992
  mainnet,
4993
+ mapPooled,
4532
4994
  mineSalt,
4533
4995
  minimumExpiration,
4534
4996
  networks,
@@ -4537,6 +4999,7 @@ export {
4537
4999
  recoveryCall,
4538
5000
  remediationFor,
4539
5001
  resolveConfig,
5002
+ sanitizeText,
4540
5003
  selfCall,
4541
5004
  shardPrefixOf,
4542
5005
  syncStrategy,