@quaivault/sdk 0.1.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 ADDED
@@ -0,0 +1,4549 @@
1
+ import {
2
+ Erc1155Abi,
3
+ Erc20Abi,
4
+ Erc721Abi,
5
+ MultiSendCallOnlyAbi,
6
+ QuaiVaultAbi,
7
+ QuaiVaultFactoryAbi,
8
+ QuaiVaultProxyAbi,
9
+ QuaiVaultProxyBytecode,
10
+ SocialRecoveryModuleAbi,
11
+ __export
12
+ } from "./chunk-Y3PVKON6.js";
13
+
14
+ // src/client.ts
15
+ import { getAddress as getAddress6 } from "quais";
16
+
17
+ // src/chain/connection.ts
18
+ import { Contract, JsonRpcProvider, Wallet, getZoneForAddress, isQuaiAddress } from "quais";
19
+
20
+ // src/errors/index.ts
21
+ var QuaiVaultError = class extends Error {
22
+ code;
23
+ /** What the caller can do about it, in plain language. */
24
+ remediation;
25
+ /** Unix seconds after which retrying could succeed. */
26
+ retryableAt;
27
+ cause;
28
+ constructor(code, message, options = {}) {
29
+ super(message);
30
+ this.name = new.target.name;
31
+ this.code = code;
32
+ this.remediation = options.remediation;
33
+ this.retryableAt = options.retryableAt;
34
+ this.cause = options.cause;
35
+ }
36
+ /** Structured form, for logs and machine consumers. */
37
+ toJSON() {
38
+ return {
39
+ name: this.name,
40
+ code: this.code,
41
+ message: this.message,
42
+ remediation: this.remediation,
43
+ retryableAt: this.retryableAt
44
+ };
45
+ }
46
+ };
47
+ var ConfigError = class extends QuaiVaultError {
48
+ constructor(message, remediation) {
49
+ super("CONFIG", message, { remediation });
50
+ }
51
+ };
52
+ var NoSignerError = class extends QuaiVaultError {
53
+ constructor(operation) {
54
+ super("NO_SIGNER", `${operation} requires a signer.`, {
55
+ remediation: "Pass connect({ privateKey }) or connect({ signer }), or set QUAIVAULT_PRIVATE_KEY."
56
+ });
57
+ }
58
+ };
59
+ var NoIndexerError = class extends QuaiVaultError {
60
+ constructor(operation) {
61
+ super("NO_INDEXER", `${operation} requires the indexer, which is not configured.`, {
62
+ remediation: "Set QUAIVAULT_INDEXER_URL, QUAIVAULT_INDEXER_ANON_KEY and QUAIVAULT_INDEXER_SCHEMA."
63
+ });
64
+ }
65
+ };
66
+ var IndexerQueryError = class extends QuaiVaultError {
67
+ constructor(message, cause) {
68
+ super("INDEXER_QUERY", message, { cause });
69
+ }
70
+ };
71
+ var ValidationError = class extends QuaiVaultError {
72
+ constructor(message, remediation) {
73
+ super("VALIDATION", message, { remediation });
74
+ }
75
+ };
76
+ var NotFoundError = class extends QuaiVaultError {
77
+ constructor(message) {
78
+ super("NOT_FOUND", message);
79
+ }
80
+ };
81
+ var PreconditionError = class extends QuaiVaultError {
82
+ constructor(message, options = {}) {
83
+ super("PRECONDITION", message, options);
84
+ }
85
+ };
86
+ var RevertError = class extends QuaiVaultError {
87
+ revert;
88
+ constructor(message, revert, options = {}) {
89
+ super("REVERT", message, options);
90
+ this.revert = revert;
91
+ }
92
+ toJSON() {
93
+ return { ...super.toJSON(), revert: this.revert };
94
+ }
95
+ };
96
+ var SaltMiningError = class extends QuaiVaultError {
97
+ constructor(message, remediation) {
98
+ super("SALT_MINING", message, { remediation });
99
+ }
100
+ };
101
+ var StaleProposalError = class extends QuaiVaultError {
102
+ constructor(message) {
103
+ super("STALE_PROPOSAL", message, {
104
+ remediation: "Cancel this proposal and create a new one against the current module list."
105
+ });
106
+ }
107
+ };
108
+
109
+ // src/chain/connection.ts
110
+ var Connection = class {
111
+ provider;
112
+ /** Retry policy for reads. Shared with every contract facade this hands out. */
113
+ retry;
114
+ _signer;
115
+ constructor(config, explicit = {}) {
116
+ this.retry = config.retry ?? {};
117
+ this.provider = explicit.provider ?? explicit.signer?.provider ?? new JsonRpcProvider(config.rpcUrl, void 0, { usePathing: true });
118
+ if (explicit.signer) {
119
+ this._signer = explicit.signer;
120
+ } else if (config.privateKey) {
121
+ this._signer = createPrivateKeySigner(config.privateKey, this.provider);
122
+ } else {
123
+ this._signer = null;
124
+ }
125
+ }
126
+ get signer() {
127
+ return this._signer;
128
+ }
129
+ hasSigner() {
130
+ return this._signer !== null;
131
+ }
132
+ /** The signer, or a typed error naming the operation that needs one. */
133
+ requireSigner(operation) {
134
+ if (!this._signer) throw new NoSignerError(operation);
135
+ return this._signer;
136
+ }
137
+ async address() {
138
+ return this.requireSigner("Reading the connected address").getAddress();
139
+ }
140
+ /** Connected address, or null when read-only. */
141
+ async addressOrNull() {
142
+ return this._signer ? this._signer.getAddress() : null;
143
+ }
144
+ vault(address2, write = false) {
145
+ return new Contract(
146
+ address2,
147
+ QuaiVaultAbi,
148
+ write ? this.requireSigner("This vault write") : this.provider
149
+ );
150
+ }
151
+ factory(address2, write = false) {
152
+ return new Contract(
153
+ address2,
154
+ QuaiVaultFactoryAbi,
155
+ write ? this.requireSigner("This factory write") : this.provider
156
+ );
157
+ }
158
+ socialRecovery(address2, write = false) {
159
+ return new Contract(
160
+ address2,
161
+ SocialRecoveryModuleAbi,
162
+ write ? this.requireSigner("This recovery write") : this.provider
163
+ );
164
+ }
165
+ };
166
+ function createPrivateKeySigner(privateKey, provider) {
167
+ const normalized = privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`;
168
+ if (!/^0x[0-9a-fA-F]{64}$/.test(normalized)) {
169
+ throw new ConfigError(
170
+ "Invalid private key: expected 32 bytes of hex (64 characters, with or without a 0x prefix).",
171
+ "Check QUAIVAULT_PRIVATE_KEY. Never commit a private key or pass one on the command line."
172
+ );
173
+ }
174
+ try {
175
+ const wallet = new Wallet(normalized, provider);
176
+ const zone = getZoneForAddress(wallet.address);
177
+ if (!zone) {
178
+ throw new ConfigError(
179
+ `The supplied private key derives ${wallet.address}, which does not fall within any Quai shard prefix, so it cannot hold funds or transact.`,
180
+ "Use a key from a Quai wallet \u2014 quais HD derivation scans for shard-valid addresses. An arbitrary Ethereum key will not work."
181
+ );
182
+ }
183
+ if (!isQuaiAddress(wallet.address)) {
184
+ throw new ConfigError(
185
+ `The supplied private key derives ${wallet.address}, which is a Qi-ledger address. QuaiVault operates on the Quai ledger.`,
186
+ "Use a Quai-ledger key."
187
+ );
188
+ }
189
+ return wallet;
190
+ } catch (cause) {
191
+ if (cause instanceof ConfigError) throw cause;
192
+ throw new ConfigError(
193
+ `Could not build a signer from the supplied private key: ${cause instanceof Error ? cause.message : String(cause)}`
194
+ );
195
+ }
196
+ }
197
+ function normalizeTxHash(value, label = "transaction hash") {
198
+ if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) {
199
+ throw new ValidationError(`Invalid ${label}: expected a 0x-prefixed 32-byte hex string.`);
200
+ }
201
+ return value.toLowerCase();
202
+ }
203
+
204
+ // src/config/resolve.ts
205
+ import { isAddress } from "quais";
206
+
207
+ // src/config/networks.ts
208
+ var INDEXER_URL = "https://xbftgyuxaxagptudledv.supabase.co";
209
+ var INDEXER_PUBLISHABLE_KEY = "sb_publishable_EO-sTB-jqUzlsiugOEks-Q_qRtHDaHU";
210
+ var mainnet = {
211
+ name: "mainnet",
212
+ chainId: 9,
213
+ rpcUrl: "https://rpc.quai.network",
214
+ explorerUrl: "https://quaiscan.io",
215
+ contracts: {
216
+ implementation: "0x0038E6d84412A10CdcE41b0f62A05350023f1fb6",
217
+ factory: "0x003613aC5FFd45bFF7B2F0210DA2fF660908c488",
218
+ socialRecovery: "0x000dbc29Bdd311C29a4008164052fc2E67c0D937",
219
+ multiSendCallOnly: "0x003f62e6a7f2EB6b94345a9A41671888eC4A3ebA"
220
+ },
221
+ indexer: {
222
+ url: INDEXER_URL,
223
+ anonKey: INDEXER_PUBLISHABLE_KEY,
224
+ schema: "mainnet",
225
+ healthUrl: "https://index.quaivault.org"
226
+ }
227
+ };
228
+ var testnet = {
229
+ name: "testnet",
230
+ chainId: 15e3,
231
+ // Orchard moved from rpc.orchard.quai.network (now NXDOMAIN) to this hostname.
232
+ rpcUrl: "https://orchard.rpc.quai.network",
233
+ explorerUrl: "https://orchard.quaiscan.io",
234
+ contracts: {
235
+ implementation: "0x004E539Cf477A5Cb456A56023f083cD91Bc4934e",
236
+ factory: "0x002d1305D597c157bB975967FA2e5337674b0E5F",
237
+ socialRecovery: "0x003a465e661D0E44C1e78b7B256D8C37f699E61e",
238
+ multiSendCallOnly: "0x002ae8A47C2da497fe569AfCF0486410aA1093E0"
239
+ },
240
+ indexer: {
241
+ url: INDEXER_URL,
242
+ anonKey: INDEXER_PUBLISHABLE_KEY,
243
+ schema: "testnet",
244
+ healthUrl: "https://index.devnet.quaivault.org"
245
+ }
246
+ };
247
+ var networks = { mainnet, testnet };
248
+ function isNetworkName(value) {
249
+ return typeof value === "string" && value in networks;
250
+ }
251
+
252
+ // src/config/resolve.ts
253
+ var ENV_VARS = {
254
+ network: "QUAIVAULT_NETWORK",
255
+ rpcUrl: "QUAIVAULT_RPC_URL",
256
+ privateKey: "QUAIVAULT_PRIVATE_KEY",
257
+ indexerUrl: "QUAIVAULT_INDEXER_URL",
258
+ indexerAnonKey: "QUAIVAULT_INDEXER_ANON_KEY",
259
+ indexerSchema: "QUAIVAULT_INDEXER_SCHEMA",
260
+ indexerHealthUrl: "QUAIVAULT_INDEXER_HEALTH_URL",
261
+ factory: "QUAIVAULT_FACTORY",
262
+ implementation: "QUAIVAULT_IMPLEMENTATION",
263
+ socialRecovery: "QUAIVAULT_SOCIAL_RECOVERY_MODULE",
264
+ multiSendCallOnly: "QUAIVAULT_MULTISEND_CALL_ONLY",
265
+ consistency: "QUAIVAULT_CONSISTENCY",
266
+ maxIndexerLagBlocks: "QUAIVAULT_MAX_INDEXER_LAG_BLOCKS"
267
+ };
268
+ function redactConfig(config) {
269
+ const { privateKey: _omitted, ...rest } = config;
270
+ return {
271
+ ...rest,
272
+ ...rest.indexer ? { indexer: { ...rest.indexer, anonKey: maskKey(rest.indexer.anonKey) } } : {},
273
+ // `network` carries its own copy of the indexer block; mask that one as well or
274
+ // a config dump still prints the key in full.
275
+ network: rest.network.indexer ? {
276
+ ...rest.network,
277
+ indexer: { ...rest.network.indexer, anonKey: maskKey(rest.network.indexer.anonKey) }
278
+ } : rest.network
279
+ };
280
+ }
281
+ function maskKey(key) {
282
+ return key.length <= 12 ? "***" : `${key.slice(0, 8)}\u2026${key.slice(-4)}`;
283
+ }
284
+ function readEnv(useEnv) {
285
+ if (!useEnv) return {};
286
+ const proc = globalThis.process;
287
+ return proc?.env ?? {};
288
+ }
289
+ function pick(...values) {
290
+ for (const v of values) {
291
+ if (v !== void 0 && v !== "") return v;
292
+ }
293
+ return void 0;
294
+ }
295
+ function requireAddress(value, label, envVar) {
296
+ if (!value) {
297
+ throw new ConfigError(
298
+ `Missing ${label}. Set ${envVar} or pass it via connect({ contracts: { \u2026 } }).`
299
+ );
300
+ }
301
+ if (!isAddress(value)) {
302
+ throw new ConfigError(`Invalid ${label}: "${value}" is not a valid address.`);
303
+ }
304
+ return value;
305
+ }
306
+ function optionalAddress(value, label) {
307
+ if (!value) return void 0;
308
+ if (!isAddress(value)) {
309
+ throw new ConfigError(`Invalid ${label}: "${value}" is not a valid address.`);
310
+ }
311
+ return value;
312
+ }
313
+ function parseConsistency(value) {
314
+ if (value === void 0) return void 0;
315
+ if (value === "auto" || value === "indexed" || value === "chain") return value;
316
+ throw new ConfigError(
317
+ `Invalid consistency "${value}". Expected one of: auto, indexed, chain.`
318
+ );
319
+ }
320
+ function parseLag(value) {
321
+ if (value === void 0) return void 0;
322
+ const n = Number(value);
323
+ if (!Number.isFinite(n) || n < 0) {
324
+ throw new ConfigError(`Invalid ${ENV_VARS.maxIndexerLagBlocks}: "${value}". Expected a non-negative number.`);
325
+ }
326
+ return n;
327
+ }
328
+ function resolveConfig(options = {}) {
329
+ const env = readEnv(options.useEnv !== false);
330
+ const networkInput = options.network ?? env[ENV_VARS.network] ?? "mainnet";
331
+ let base;
332
+ if (typeof networkInput === "string") {
333
+ if (!isNetworkName(networkInput)) {
334
+ throw new ConfigError(
335
+ `Unknown network "${networkInput}". Built-in networks: ${Object.keys(networks).join(", ")}. Pass a full NetworkConfig object for a custom deployment.`
336
+ );
337
+ }
338
+ base = networks[networkInput];
339
+ } else {
340
+ base = networkInput;
341
+ }
342
+ const rpcUrl = pick(options.rpcUrl, env[ENV_VARS.rpcUrl], base.rpcUrl);
343
+ if (!rpcUrl) {
344
+ throw new ConfigError(`Missing RPC URL. Set ${ENV_VARS.rpcUrl} or pass connect({ rpcUrl }).`);
345
+ }
346
+ const contracts = {
347
+ factory: requireAddress(
348
+ pick(options.contracts?.factory, env[ENV_VARS.factory], base.contracts.factory),
349
+ "factory address",
350
+ ENV_VARS.factory
351
+ ),
352
+ implementation: requireAddress(
353
+ pick(
354
+ options.contracts?.implementation,
355
+ env[ENV_VARS.implementation],
356
+ base.contracts.implementation
357
+ ),
358
+ "implementation address",
359
+ ENV_VARS.implementation
360
+ ),
361
+ socialRecovery: optionalAddress(
362
+ pick(options.contracts?.socialRecovery, env[ENV_VARS.socialRecovery], base.contracts.socialRecovery),
363
+ "social recovery module address"
364
+ ),
365
+ multiSendCallOnly: optionalAddress(
366
+ pick(
367
+ options.contracts?.multiSendCallOnly,
368
+ env[ENV_VARS.multiSendCallOnly],
369
+ base.contracts.multiSendCallOnly
370
+ ),
371
+ "MultiSendCallOnly address"
372
+ )
373
+ };
374
+ const indexerUrl = pick(options.indexer?.url, env[ENV_VARS.indexerUrl], base.indexer?.url);
375
+ const anonKey = pick(options.indexer?.anonKey, env[ENV_VARS.indexerAnonKey], base.indexer?.anonKey);
376
+ const schema = pick(options.indexer?.schema, env[ENV_VARS.indexerSchema], base.indexer?.schema);
377
+ let indexer;
378
+ if (indexerUrl && anonKey && schema) {
379
+ indexer = {
380
+ url: indexerUrl,
381
+ anonKey,
382
+ schema,
383
+ healthUrl: pick(
384
+ options.indexer?.healthUrl,
385
+ env[ENV_VARS.indexerHealthUrl],
386
+ base.indexer?.healthUrl
387
+ )
388
+ };
389
+ }
390
+ const consistency = options.consistency ?? parseConsistency(env[ENV_VARS.consistency]) ?? "auto";
391
+ if (!indexer && consistency === "indexed") {
392
+ throw new ConfigError(
393
+ `consistency: 'indexed' requires indexer configuration. Set ${ENV_VARS.indexerUrl}, ${ENV_VARS.indexerAnonKey} and ${ENV_VARS.indexerSchema}.`
394
+ );
395
+ }
396
+ return {
397
+ network: { ...base, rpcUrl, contracts, ...indexer ? { indexer } : {} },
398
+ contracts,
399
+ indexer,
400
+ rpcUrl,
401
+ privateKey: pick(options.privateKey, env[ENV_VARS.privateKey]),
402
+ consistency,
403
+ maxIndexerLagBlocks: options.maxIndexerLagBlocks ?? parseLag(env[ENV_VARS.maxIndexerLagBlocks]) ?? 50,
404
+ retry: {
405
+ ...options.retry?.maxAttempts != null ? { maxAttempts: options.retry.maxAttempts } : {},
406
+ ...options.retry?.baseDelayMs != null ? { baseDelayMs: options.retry.baseDelayMs } : {},
407
+ ...options.retry?.maxDelayMs != null ? { maxDelayMs: options.retry.maxDelayMs } : {},
408
+ ...options.retry?.onRetry ? { onRetry: options.retry.onRetry } : {}
409
+ }
410
+ };
411
+ }
412
+
413
+ // src/factory.ts
414
+ import { Interface as Interface4, getAddress as getAddress2 } from "quais";
415
+
416
+ // src/address.ts
417
+ import { getAddress, getZoneForAddress as getZoneForAddress2, isAddress as isAddress2, isQiAddress } from "quais";
418
+ function inspectAddress(value) {
419
+ if (typeof value !== "string" || !isAddress2(value)) {
420
+ return { valid: false, reason: "not a valid address" };
421
+ }
422
+ const checksummed = getAddress(value);
423
+ const zone = getZoneForAddress2(checksummed);
424
+ const ledger = isQiAddress(checksummed) ? "qi" : "quai";
425
+ if (!zone) {
426
+ return {
427
+ valid: false,
428
+ checksummed,
429
+ ledger,
430
+ reason: "belongs to no Quai shard (its leading byte is not a valid zone prefix)"
431
+ };
432
+ }
433
+ if (ledger === "qi") {
434
+ return {
435
+ valid: false,
436
+ checksummed,
437
+ zone,
438
+ ledger,
439
+ reason: "is a Qi-ledger address, which cannot interact with smart contracts on Quai"
440
+ };
441
+ }
442
+ return { valid: true, checksummed, zone, ledger };
443
+ }
444
+ function isUsableQuaiAddress(value) {
445
+ return inspectAddress(value).valid;
446
+ }
447
+ function assertQuaiAddress(value, label = "address") {
448
+ const check = inspectAddress(value);
449
+ if (check.valid) return check.checksummed;
450
+ const shown = typeof value === "string" ? value : String(value);
451
+ const remediation = check.ledger === "qi" ? "Qi is the UTXO ledger and has no contract execution. Use a Quai-ledger address (the 9th bit of the address \u2014 the high bit of its second byte \u2014 must be clear)." : "Quai addresses must begin with a valid zone prefix: 0x00\u20130x02, 0x10\u20130x12, or 0x20\u20130x22.";
452
+ throw new ValidationError(`Invalid ${label} "${shown}": it ${check.reason}.`, remediation);
453
+ }
454
+ function assertQuaiAddresses(values, label) {
455
+ return values.map((value, i) => assertQuaiAddress(value, `${label}[${i}]`));
456
+ }
457
+
458
+ // src/chain/retry.ts
459
+ var DEFAULTS = {
460
+ maxAttempts: 3,
461
+ baseDelayMs: 250,
462
+ maxDelayMs: 4e3
463
+ };
464
+ var PERMANENT_CODES = /* @__PURE__ */ new Set([
465
+ "CALL_EXCEPTION",
466
+ // the call reverted — deterministic
467
+ "INVALID_ARGUMENT",
468
+ "MISSING_ARGUMENT",
469
+ "UNEXPECTED_ARGUMENT",
470
+ "NOT_IMPLEMENTED",
471
+ "UNSUPPORTED_OPERATION",
472
+ "ACTION_REJECTED",
473
+ // the user declined in their wallet
474
+ "NONCE_EXPIRED",
475
+ "INSUFFICIENT_FUNDS",
476
+ "REPLACEMENT_UNDERPRICED",
477
+ "TRANSACTION_REPLACED",
478
+ "VALUE_MISMATCH"
479
+ ]);
480
+ var TRANSIENT_CODES = /* @__PURE__ */ new Set(["NETWORK_ERROR", "SERVER_ERROR", "TIMEOUT", "BAD_DATA"]);
481
+ var TRANSIENT_PATTERNS = [
482
+ "fetch failed",
483
+ "econnreset",
484
+ "econnrefused",
485
+ "etimedout",
486
+ "epipe",
487
+ "socket hang up",
488
+ "network error",
489
+ "timeout",
490
+ "timed out",
491
+ "rate limit",
492
+ "too many requests",
493
+ "service unavailable",
494
+ "bad gateway",
495
+ "gateway timeout",
496
+ "temporarily unavailable",
497
+ "connection terminated",
498
+ "server error"
499
+ ];
500
+ var TRANSIENT_STATUS = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
501
+ function statusOf(error) {
502
+ if (typeof error !== "object" || error === null) return void 0;
503
+ const e = error;
504
+ for (const key of ["status", "statusCode"]) {
505
+ const value = e[key];
506
+ if (typeof value === "number") return value;
507
+ }
508
+ const info = e.info;
509
+ const response = info?.response;
510
+ if (typeof response?.status === "number") return response.status;
511
+ return void 0;
512
+ }
513
+ function isTransient(error) {
514
+ if (error === null || error === void 0) return false;
515
+ const code = error.code;
516
+ if (typeof code === "string") {
517
+ if (PERMANENT_CODES.has(code)) return false;
518
+ if (TRANSIENT_CODES.has(code)) return true;
519
+ }
520
+ const status = statusOf(error);
521
+ if (status !== void 0) return TRANSIENT_STATUS.has(status);
522
+ const message = error instanceof Error ? error.message : String(error);
523
+ const haystack = message.toLowerCase();
524
+ if (TRANSIENT_PATTERNS.some((p) => haystack.includes(p))) return true;
525
+ const cause = error.cause;
526
+ if (cause && cause !== error) return isTransient(cause);
527
+ return false;
528
+ }
529
+ var sleep = (ms, signal) => new Promise((resolve, reject) => {
530
+ if (signal?.aborted) {
531
+ reject(new Error("Aborted"));
532
+ return;
533
+ }
534
+ const timer = setTimeout(() => {
535
+ signal?.removeEventListener("abort", onAbort);
536
+ resolve();
537
+ }, ms);
538
+ const onAbort = () => {
539
+ clearTimeout(timer);
540
+ reject(new Error("Aborted"));
541
+ };
542
+ signal?.addEventListener("abort", onAbort, { once: true });
543
+ });
544
+ async function withRetry(fn, options = {}) {
545
+ const maxAttempts = options.maxAttempts ?? DEFAULTS.maxAttempts;
546
+ const baseDelayMs = options.baseDelayMs ?? DEFAULTS.baseDelayMs;
547
+ const maxDelayMs = options.maxDelayMs ?? DEFAULTS.maxDelayMs;
548
+ let lastError;
549
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
550
+ if (options.signal?.aborted) throw new Error("Aborted");
551
+ try {
552
+ return await fn();
553
+ } catch (error) {
554
+ lastError = error;
555
+ if (attempt === maxAttempts || !isTransient(error)) throw error;
556
+ const ceiling = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
557
+ const delayMs = Math.round(Math.random() * ceiling);
558
+ options.onRetry?.({ attempt, delayMs, error });
559
+ await sleep(delayMs, options.signal);
560
+ }
561
+ }
562
+ throw lastError;
563
+ }
564
+
565
+ // src/chain/vault-contract.ts
566
+ var VaultContract = class {
567
+ constructor(contract, retry = {}) {
568
+ this.contract = contract;
569
+ this.retry = retry;
570
+ }
571
+ contract;
572
+ retry;
573
+ fn(signature) {
574
+ return this.contract.getFunction(signature);
575
+ }
576
+ /**
577
+ * Reads retry transient RPC failures (see {@link withRetry}); writes never do,
578
+ * because a resubmit that looks like a timeout may already be in the mempool.
579
+ */
580
+ read(signature, ...args) {
581
+ return withRetry(() => this.fn(signature)(...args), this.retry);
582
+ }
583
+ get interface() {
584
+ return this.contract.interface;
585
+ }
586
+ // ---- reads ---------------------------------------------------------------
587
+ getOwners() {
588
+ return this.read("getOwners()");
589
+ }
590
+ isOwner(address2) {
591
+ return this.read("isOwner(address)", address2);
592
+ }
593
+ threshold() {
594
+ return this.read("threshold()");
595
+ }
596
+ nonce() {
597
+ return this.read("nonce()");
598
+ }
599
+ minExecutionDelay() {
600
+ return this.read("minExecutionDelay()");
601
+ }
602
+ moduleCount() {
603
+ return this.read("moduleCount()");
604
+ }
605
+ getModules() {
606
+ return this.read("getModules()");
607
+ }
608
+ isModuleEnabled(module) {
609
+ return this.read("isModuleEnabled(address)", module);
610
+ }
611
+ delegatecallAllowed(target) {
612
+ return this.read("delegatecallAllowed(address)", target);
613
+ }
614
+ transactions(txHash) {
615
+ return this.read("transactions(bytes32)", txHash);
616
+ }
617
+ /**
618
+ * Whether an owner's approval currently counts toward the threshold.
619
+ * Accounts for approval-epoch invalidation from owner removal.
620
+ */
621
+ hasApproved(txHash, owner) {
622
+ return this.read("hasApproved(bytes32,address)", txHash, owner);
623
+ }
624
+ /** True when the transaction was closed by `expireTransaction`, not cancelled. */
625
+ expiredTxs(txHash) {
626
+ return this.read("expiredTxs(bytes32)", txHash);
627
+ }
628
+ getTransactionHash(to, value, data, nonce) {
629
+ return this.read(
630
+ "getTransactionHash(address,uint256,bytes,uint256)",
631
+ to,
632
+ value,
633
+ data,
634
+ nonce
635
+ );
636
+ }
637
+ isValidSignature(hash, signature) {
638
+ return this.read("isValidSignature(bytes32,bytes)", hash, signature);
639
+ }
640
+ // ---- writes --------------------------------------------------------------
641
+ /**
642
+ * Overload with expiration and delay. The 3-argument overload is used when
643
+ * neither is supplied, so a caller that wants no expiry never has to pass 0.
644
+ */
645
+ proposeTransactionFull(to, value, data, expiration, executionDelay) {
646
+ return this.fn("proposeTransaction(address,uint256,bytes,uint48,uint32)")(
647
+ to,
648
+ value,
649
+ data,
650
+ expiration,
651
+ executionDelay
652
+ );
653
+ }
654
+ proposeTransactionSimple(to, value, data) {
655
+ return this.fn("proposeTransaction(address,uint256,bytes)")(
656
+ to,
657
+ value,
658
+ data
659
+ );
660
+ }
661
+ approveTransaction(txHash) {
662
+ return this.fn("approveTransaction(bytes32)")(txHash);
663
+ }
664
+ approveAndExecute(txHash) {
665
+ return this.fn("approveAndExecute(bytes32)")(txHash);
666
+ }
667
+ executeTransaction(txHash) {
668
+ return this.fn("executeTransaction(bytes32)")(txHash);
669
+ }
670
+ revokeApproval(txHash) {
671
+ return this.fn("revokeApproval(bytes32)")(txHash);
672
+ }
673
+ cancelTransaction(txHash) {
674
+ return this.fn("cancelTransaction(bytes32)")(txHash);
675
+ }
676
+ expireTransaction(txHash) {
677
+ return this.fn("expireTransaction(bytes32)")(txHash);
678
+ }
679
+ encode(signature, args) {
680
+ return this.contract.interface.encodeFunctionData(signature, args);
681
+ }
682
+ };
683
+ var FactoryContract = class {
684
+ constructor(contract, retry = {}) {
685
+ this.contract = contract;
686
+ this.retry = retry;
687
+ }
688
+ contract;
689
+ retry;
690
+ fn(signature) {
691
+ return this.contract.getFunction(signature);
692
+ }
693
+ /**
694
+ * Reads retry transient RPC failures (see {@link withRetry}); writes never do,
695
+ * because a resubmit that looks like a timeout may already be in the mempool.
696
+ */
697
+ read(signature, ...args) {
698
+ return withRetry(() => this.fn(signature)(...args), this.retry);
699
+ }
700
+ get interface() {
701
+ return this.contract.interface;
702
+ }
703
+ implementation() {
704
+ return this.read("implementation()");
705
+ }
706
+ isWallet(address2) {
707
+ return this.read("isWallet(address)", address2);
708
+ }
709
+ getWalletCount() {
710
+ return this.read("getWalletCount()");
711
+ }
712
+ deployedWallets(index) {
713
+ return this.read("deployedWallets(uint256)", index);
714
+ }
715
+ predictWalletAddress(deployer, salt, owners, threshold, minExecutionDelay, initialModules, initialDelegatecallTargets) {
716
+ return this.read(
717
+ "predictWalletAddress(address,bytes32,address[],uint256,uint32,address[],address[])",
718
+ deployer,
719
+ salt,
720
+ owners,
721
+ threshold,
722
+ minExecutionDelay,
723
+ initialModules,
724
+ initialDelegatecallTargets
725
+ );
726
+ }
727
+ /** Full 6-argument overload. The SDK always uses it so mining stays consistent. */
728
+ createWallet(owners, threshold, salt, minExecutionDelay, initialModules, initialDelegatecallTargets) {
729
+ return this.fn("createWallet(address[],uint256,bytes32,uint32,address[],address[])")(
730
+ owners,
731
+ threshold,
732
+ salt,
733
+ minExecutionDelay,
734
+ initialModules,
735
+ initialDelegatecallTargets
736
+ );
737
+ }
738
+ registerWallet(wallet) {
739
+ return this.fn("registerWallet(address)")(wallet);
740
+ }
741
+ };
742
+
743
+ // src/errors/decode.ts
744
+ import { Interface, AbiCoder } from "quais";
745
+ var SOLIDITY_ERROR_SELECTOR = "0x08c379a0";
746
+ var SOLIDITY_PANIC_SELECTOR = "0x4e487b71";
747
+ var registry = null;
748
+ function buildRegistry() {
749
+ const map = /* @__PURE__ */ new Map();
750
+ for (const abi of [QuaiVaultAbi, QuaiVaultFactoryAbi, SocialRecoveryModuleAbi, QuaiVaultProxyAbi]) {
751
+ const iface = new Interface(abi);
752
+ iface.forEachError((fragment) => {
753
+ if (!map.has(fragment.selector)) map.set(fragment.selector, { fragment, iface });
754
+ });
755
+ }
756
+ return map;
757
+ }
758
+ function getRegistry() {
759
+ if (!registry) registry = buildRegistry();
760
+ return registry;
761
+ }
762
+ var REMEDIATION = {
763
+ // --- QuaiVault: authorisation
764
+ NotAnOwner: "The calling address is not an owner of this vault.",
765
+ OnlySelf: "This function can only be invoked as a vault self-call. Propose a transaction targeting the vault itself.",
766
+ NotAnAuthorizedModule: "The caller is not an enabled module on this vault.",
767
+ NotProposer: "Only the address that proposed a transaction may cancel it directly.",
768
+ // --- QuaiVault: transaction lifecycle
769
+ TransactionDoesNotExist: "No transaction with that hash exists on this vault.",
770
+ TransactionAlreadyExecuted: "This transaction has already been executed and is terminal.",
771
+ TransactionAlreadyCancelled: "This transaction has already been cancelled or expired.",
772
+ TransactionAlreadyExists: "An identical transaction (same target, value, data and nonce) is already pending.",
773
+ AlreadyApproved: "This owner has already approved the transaction.",
774
+ NotApproved: "This owner has no active approval to revoke.",
775
+ NotEnoughApprovals: "The approval threshold has not been reached yet.",
776
+ CannotCancelApprovedTransaction: "The transaction reached quorum, which permanently locks proposer-cancel. Propose a cancelByConsensus self-call instead.",
777
+ TransactionIsExpired: "The transaction passed its expiration timestamp and can no longer execute.",
778
+ TransactionNotExpired: "The transaction has not passed its expiration timestamp yet.",
779
+ TimelockNotElapsed: "The execution delay has not elapsed. Retry after the timelock expires.",
780
+ ExpirationTooSoon: "The expiration must be later than now plus the execution delay, so at least one execution attempt is possible.",
781
+ SelfCallCannotHaveValue: "Self-calls must have value 0.",
782
+ InvalidDestinationAddress: "The destination address is the zero address.",
783
+ CalldataTooShort: "Self-call data must contain at least a 4-byte function selector.",
784
+ UnrecognizedSelfCall: "The self-call selector is not one the vault dispatches. Check the encoded function against the QuaiVault ABI.",
785
+ // --- QuaiVault: owners and threshold
786
+ OwnersRequired: "At least one owner is required.",
787
+ TooManyOwners: "A vault may have at most 20 owners.",
788
+ MaxOwnersReached: "This vault already has the maximum of 20 owners.",
789
+ InvalidThreshold: "The threshold must be between 1 and the number of owners.",
790
+ InvalidOwnerAddress: "Owner addresses cannot be the zero address, the vault itself, or the sentinel 0x\u202601.",
791
+ DuplicateOwner: "The owner list contains a duplicate address.",
792
+ AlreadyAnOwner: "That address is already an owner.",
793
+ CannotRemoveOwnerWouldFallBelowThreshold: "Removing this owner would leave fewer owners than the threshold. Lower the threshold first.",
794
+ // --- QuaiVault: modules and delegatecall
795
+ ModuleAlreadyEnabled: "That module is already enabled.",
796
+ ModuleNotEnabled: "That module is not enabled on this vault.",
797
+ InvalidModule: "Module addresses cannot be the zero address or the sentinel 0x\u202601.",
798
+ InvalidPrevModule: "The predecessor in the module linked list is stale \u2014 the module list changed since the proposal was created.",
799
+ MaxModulesReached: "This vault already has the maximum of 50 modules.",
800
+ DelegateCallNotAllowed: "DelegateCall targets must be explicitly whitelisted. Propose addDelegatecallTarget for this address first.",
801
+ DelegatecallTargetAlreadyAllowed: "That address is already on the DelegateCall whitelist.",
802
+ DelegatecallTargetNotAllowed: "That address is not on the DelegateCall whitelist.",
803
+ ImplementationSlotTampered: "A DelegateCall overwrote the ERC1967 implementation slot and was reverted. The target contract is hostile or buggy.",
804
+ // --- QuaiVault: misc
805
+ ExecutionDelayTooLong: "The execution delay may not exceed 30 days (2,592,000 seconds).",
806
+ MessageNotSigned: "That message hash is not currently signed by the vault.",
807
+ // --- Factory
808
+ InvalidImplementationAddress: "The implementation address is the zero address.",
809
+ InvalidWalletAddress: "The vault address is the zero address.",
810
+ WalletAlreadyRegistered: "That vault is already registered with the factory.",
811
+ CallerIsNotAnOwner: "Only an owner of the vault may register it with the factory.",
812
+ InvalidWalletImplementation: "The address is not a QuaiVaultProxy pointing at this factory\u2019s implementation.",
813
+ // --- SocialRecoveryModule
814
+ GuardiansRequired: "At least one guardian is required.",
815
+ TooManyGuardians: "Too many guardians for the recovery configuration.",
816
+ RecoveryPeriodTooShort: "The recovery period is below the module minimum.",
817
+ CannotUpdateConfigWhileRecoveriesPending: "Cancel or resolve all pending recoveries before changing the guardian configuration.",
818
+ InvalidGuardianAddress: "A guardian address is invalid.",
819
+ DuplicateGuardian: "The guardian list contains a duplicate address.",
820
+ RecoveryNotConfigured: "This vault has no social recovery configuration.",
821
+ NotAGuardian: "The calling address is not a guardian of this vault.",
822
+ RecoveryAlreadyInitiated: "An identical recovery is already pending.",
823
+ RecoveryNotInitiated: "No recovery with that hash exists.",
824
+ RecoveryAlreadyExecuted: "That recovery has already been executed.",
825
+ RecoveryPeriodNotElapsed: "The recovery time lock has not elapsed yet.",
826
+ RecoveryExpired: "The recovery passed its expiration deadline.",
827
+ RecoveryNotExpired: "The recovery has not expired yet.",
828
+ TooManyPendingRecoveries: "This vault has reached its cap on concurrent pending recoveries.",
829
+ RecoveryExceedsMaxOwners: "Applying this recovery would temporarily exceed the 20-owner maximum during the add-before-remove sequence.",
830
+ MustBeCalledByWallet: "This function must be called by the vault itself."
831
+ };
832
+ function formatArgs(fragment, args) {
833
+ if (args.length === 0) return "";
834
+ const parts = fragment.inputs.map((input, i) => {
835
+ const value = args[i];
836
+ const rendered = typeof value === "bigint" ? value.toString() : String(value);
837
+ return input.name ? `${input.name}: ${rendered}` : rendered;
838
+ });
839
+ return `(${parts.join(", ")})`;
840
+ }
841
+ function decodeRevert(data) {
842
+ if (typeof data !== "string" || !data.startsWith("0x") || data.length < 10) return void 0;
843
+ const selector = data.slice(0, 10).toLowerCase();
844
+ const coder = AbiCoder.defaultAbiCoder();
845
+ if (selector === SOLIDITY_ERROR_SELECTOR) {
846
+ try {
847
+ const [reason] = coder.decode(["string"], "0x" + data.slice(10));
848
+ return {
849
+ name: "Error",
850
+ args: [reason],
851
+ selector,
852
+ message: String(reason)
853
+ };
854
+ } catch {
855
+ return void 0;
856
+ }
857
+ }
858
+ if (selector === SOLIDITY_PANIC_SELECTOR) {
859
+ try {
860
+ const [code] = coder.decode(["uint256"], "0x" + data.slice(10));
861
+ return {
862
+ name: "Panic",
863
+ args: [code],
864
+ selector,
865
+ message: `Panic(0x${BigInt(code).toString(16)}) \u2014 arithmetic or assertion failure in the target contract`
866
+ };
867
+ } catch {
868
+ return void 0;
869
+ }
870
+ }
871
+ const entry = getRegistry().get(selector);
872
+ if (!entry) return void 0;
873
+ let args = [];
874
+ try {
875
+ args = Array.from(entry.iface.decodeErrorResult(entry.fragment, data));
876
+ } catch {
877
+ }
878
+ const name = entry.fragment.name;
879
+ const remediation = REMEDIATION[name];
880
+ const rendered = `${name}${formatArgs(entry.fragment, args)}`;
881
+ return {
882
+ name,
883
+ args,
884
+ selector,
885
+ message: remediation ? `${rendered} \u2014 ${remediation}` : rendered
886
+ };
887
+ }
888
+ function remediationFor(errorName) {
889
+ return REMEDIATION[errorName];
890
+ }
891
+ function decodeRevertFromError(error) {
892
+ const seen = /* @__PURE__ */ new Set();
893
+ const walk = (node, depth) => {
894
+ if (node == null || depth > 6 || seen.has(node)) return void 0;
895
+ if (typeof node === "string") return decodeRevert(node);
896
+ if (typeof node !== "object") return void 0;
897
+ seen.add(node);
898
+ const obj = node;
899
+ for (const key of ["data", "errorData", "return", "returnData"]) {
900
+ const decoded = decodeRevert(obj[key]);
901
+ if (decoded) return decoded;
902
+ }
903
+ for (const key of ["error", "cause", "info", "body", "value"]) {
904
+ const decoded = walk(obj[key], depth + 1);
905
+ if (decoded) return decoded;
906
+ }
907
+ return void 0;
908
+ };
909
+ return walk(error, 0);
910
+ }
911
+ function knownErrorSelectors() {
912
+ return Array.from(getRegistry().entries()).map(([selector, { fragment }]) => ({
913
+ selector,
914
+ signature: fragment.format("sighash")
915
+ }));
916
+ }
917
+
918
+ // src/encode/index.ts
919
+ import { AbiCoder as AbiCoder2, Interface as Interface2, concat, getBytes, solidityPacked, zeroPadValue, toBeHex } from "quais";
920
+
921
+ // src/types.ts
922
+ var Operation = /* @__PURE__ */ ((Operation2) => {
923
+ Operation2[Operation2["Call"] = 0] = "Call";
924
+ Operation2[Operation2["DelegateCall"] = 1] = "DelegateCall";
925
+ return Operation2;
926
+ })(Operation || {});
927
+
928
+ // src/encode/index.ts
929
+ var vault = new Interface2(QuaiVaultAbi);
930
+ var recovery = new Interface2(SocialRecoveryModuleAbi);
931
+ var multiSend = new Interface2(MultiSendCallOnlyAbi);
932
+ var erc20 = new Interface2(Erc20Abi);
933
+ var erc721 = new Interface2(Erc721Abi);
934
+ var erc1155 = new Interface2(Erc1155Abi);
935
+ var interfaces = { vault, recovery, multiSend, erc20, erc721, erc1155 };
936
+ var MAX_EXECUTION_DELAY = 30 * 24 * 60 * 60;
937
+ var MAX_OWNERS = 20;
938
+ var MAX_MODULES = 50;
939
+ var SENTINEL_MODULES = "0x0000000000000000000000000000000000000001";
940
+ var selfCall = {
941
+ addOwner: (owner) => vault.encodeFunctionData("addOwner", [owner]),
942
+ removeOwner: (owner) => vault.encodeFunctionData("removeOwner", [owner]),
943
+ changeThreshold: (threshold) => {
944
+ if (!Number.isInteger(threshold) || threshold < 1) {
945
+ throw new ValidationError(`Invalid threshold ${threshold}: must be an integer >= 1.`);
946
+ }
947
+ return vault.encodeFunctionData("changeThreshold", [threshold]);
948
+ },
949
+ enableModule: (module) => vault.encodeFunctionData("enableModule", [module]),
950
+ /**
951
+ * Disable a module.
952
+ *
953
+ * `prevModule` is the predecessor in the Zodiac linked list and must be resolved
954
+ * against the live list — a proposal built against a stale list can never execute.
955
+ * Prefer `Vault.propose.disableModule`, which resolves it for you.
956
+ */
957
+ disableModule: (prevModule, module) => vault.encodeFunctionData("disableModule", [prevModule, module]),
958
+ setMinExecutionDelay: (seconds) => {
959
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > MAX_EXECUTION_DELAY) {
960
+ throw new ValidationError(
961
+ `Invalid execution delay ${seconds}: must be an integer between 0 and ${MAX_EXECUTION_DELAY} (30 days).`
962
+ );
963
+ }
964
+ return vault.encodeFunctionData("setMinExecutionDelay", [seconds]);
965
+ },
966
+ addDelegatecallTarget: (target) => vault.encodeFunctionData("addDelegatecallTarget", [target]),
967
+ removeDelegatecallTarget: (target) => vault.encodeFunctionData("removeDelegatecallTarget", [target]),
968
+ cancelByConsensus: (txHash) => vault.encodeFunctionData("cancelByConsensus", [txHash]),
969
+ /** Sign arbitrary bytes. For EIP-1271 hash pre-approval use {@link approveHashForEip1271}. */
970
+ signMessage: (data) => vault.encodeFunctionData("signMessage", [data]),
971
+ unsignMessage: (data) => vault.encodeFunctionData("unsignMessage", [data]),
972
+ /**
973
+ * Pre-approve a hash so `isValidSignature(hash, …)` returns the EIP-1271 magic value.
974
+ *
975
+ * `isValidSignature(h)` checks `signedMessages[getMessageHash(abi.encode(h))]`, while
976
+ * `signMessage(data)` stores `getMessageHash(data)`. Because `abi.encode` of a static
977
+ * `bytes32` is the identity on its 32 bytes, this produces calldata identical to
978
+ * `signMessage(hash)` — the encoding step is a no-op for a well-formed hash.
979
+ *
980
+ * It is a separate helper because the *length* is what matters: signing an
981
+ * arbitrary-length message `M` does NOT make `isValidSignature(keccak256(M))`
982
+ * validate, since EIP-1271 hashes the 32-byte dataHash, not `M`. This entry point
983
+ * enforces the 32-byte precondition and states the intent, so that mistake is
984
+ * caught here rather than discovered when a counterparty's check silently fails.
985
+ */
986
+ approveHashForEip1271: (hash) => {
987
+ if (!/^0x[0-9a-fA-F]{64}$/.test(hash)) {
988
+ throw new ValidationError("approveHashForEip1271 expects a 32-byte hash.");
989
+ }
990
+ const encoded = AbiCoder2.defaultAbiCoder().encode(["bytes32"], [hash]);
991
+ return vault.encodeFunctionData("signMessage", [encoded]);
992
+ },
993
+ /** Revoke a hash previously approved via {@link approveHashForEip1271}. */
994
+ revokeHashForEip1271: (hash) => {
995
+ if (!/^0x[0-9a-fA-F]{64}$/.test(hash)) {
996
+ throw new ValidationError("revokeHashForEip1271 expects a 32-byte hash.");
997
+ }
998
+ const encoded = AbiCoder2.defaultAbiCoder().encode(["bytes32"], [hash]);
999
+ return vault.encodeFunctionData("unsignMessage", [encoded]);
1000
+ }
1001
+ };
1002
+ var token = {
1003
+ erc20Transfer: (to, amount) => erc20.encodeFunctionData("transfer", [to, amount]),
1004
+ erc20Approve: (spender, amount) => erc20.encodeFunctionData("approve", [spender, amount]),
1005
+ erc721Transfer: (from, to, tokenId) => erc721.encodeFunctionData("safeTransferFrom(address,address,uint256)", [from, to, tokenId]),
1006
+ erc1155Transfer: (from, to, id, amount, data = "0x") => erc1155.encodeFunctionData("safeTransferFrom", [from, to, id, amount, data]),
1007
+ erc1155BatchTransfer: (from, to, ids, amounts, data = "0x") => {
1008
+ if (ids.length !== amounts.length) {
1009
+ throw new ValidationError("erc1155BatchTransfer: ids and amounts must be the same length.");
1010
+ }
1011
+ return erc1155.encodeFunctionData("safeBatchTransferFrom", [from, to, ids, amounts, data]);
1012
+ }
1013
+ };
1014
+ var recoveryCall = {
1015
+ /** Encoded for a vault-proposed call to the module (not a self-call). */
1016
+ setupRecovery: (vaultAddress, guardians, threshold, recoveryPeriodSeconds) => {
1017
+ if (guardians.length === 0) throw new ValidationError("At least one guardian is required.");
1018
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > guardians.length) {
1019
+ throw new ValidationError(
1020
+ `Invalid guardian threshold ${threshold}: must be between 1 and ${guardians.length}.`
1021
+ );
1022
+ }
1023
+ return recovery.encodeFunctionData("setupRecovery", [
1024
+ vaultAddress,
1025
+ guardians,
1026
+ threshold,
1027
+ recoveryPeriodSeconds
1028
+ ]);
1029
+ }
1030
+ };
1031
+ function encodeMultiSendPayload(calls) {
1032
+ if (calls.length === 0) throw new ValidationError("encodeMultiSendPayload: no calls supplied.");
1033
+ const parts = calls.map((call) => {
1034
+ const data = call.data ?? "0x";
1035
+ const bytes = getBytes(data);
1036
+ return solidityPacked(
1037
+ ["uint8", "address", "uint256", "uint256", "bytes"],
1038
+ [0 /* Call */, call.to, call.value ?? 0n, bytes.length, bytes]
1039
+ );
1040
+ });
1041
+ return concat(parts);
1042
+ }
1043
+ function encodeMultiSend(calls) {
1044
+ return multiSend.encodeFunctionData("multiSend", [encodeMultiSendPayload(calls)]);
1045
+ }
1046
+ function minimumExpiration(effectiveDelaySeconds, marginSeconds = 300, at = Math.floor(Date.now() / 1e3)) {
1047
+ return at + effectiveDelaySeconds + marginSeconds;
1048
+ }
1049
+
1050
+ // src/salt/mine.ts
1051
+ import { getCreate2Address as getCreate2Address2, hexlify, isQuaiAddress as isQuaiAddress2, keccak256 as keccak2562, randomBytes, solidityPacked as solidityPacked3 } from "quais";
1052
+
1053
+ // src/salt/predict.ts
1054
+ import { AbiCoder as AbiCoder3, Interface as Interface3, concat as concat2, getCreate2Address, keccak256, solidityPacked as solidityPacked2 } from "quais";
1055
+ var vaultInterface = new Interface3(QuaiVaultAbi);
1056
+ function encodeInitData(params) {
1057
+ return vaultInterface.encodeFunctionData("initialize", [
1058
+ params.owners,
1059
+ params.threshold,
1060
+ params.minExecutionDelay ?? 0,
1061
+ params.initialModules ?? [],
1062
+ params.initialDelegatecallTargets ?? []
1063
+ ]);
1064
+ }
1065
+ function computeBytecodeHash(implementation, initData) {
1066
+ const encodedArgs = AbiCoder3.defaultAbiCoder().encode(
1067
+ ["address", "bytes"],
1068
+ [implementation, initData]
1069
+ );
1070
+ return keccak256(concat2([QuaiVaultProxyBytecode, encodedArgs]));
1071
+ }
1072
+ function computeFullSalt(deployer, userSalt) {
1073
+ return keccak256(solidityPacked2(["address", "bytes32"], [deployer, userSalt]));
1074
+ }
1075
+ function predictVaultAddress(args) {
1076
+ const initData = encodeInitData(args.params);
1077
+ const bytecodeHash = computeBytecodeHash(args.implementation, initData);
1078
+ const fullSalt = computeFullSalt(args.deployer, args.salt);
1079
+ return getCreate2Address(args.factory, fullSalt, bytecodeHash);
1080
+ }
1081
+ function shardPrefixOf(address2) {
1082
+ if (typeof address2 !== "string" || !address2.startsWith("0x") || address2.length < 4) {
1083
+ throw new ValidationError(`Cannot derive a shard prefix from "${address2}".`);
1084
+ }
1085
+ return address2.slice(0, 4).toLowerCase();
1086
+ }
1087
+
1088
+ // src/salt/mine.ts
1089
+ var DEFAULT_MAX_ATTEMPTS = 5e5;
1090
+ var DEFAULT_TIMEOUT_MS = 12e4;
1091
+ var PROGRESS_INTERVAL = 1e3;
1092
+ var CHUNK = 250;
1093
+ function tryOne(factory, deployer, bytecodeHash, targetPrefix) {
1094
+ const salt = hexlify(randomBytes(32));
1095
+ const fullSalt = keccak2562(solidityPacked3(["address", "bytes32"], [deployer, salt]));
1096
+ const address2 = getCreate2Address2(factory, fullSalt, bytecodeHash);
1097
+ if (address2.toLowerCase().startsWith(targetPrefix) && isQuaiAddress2(address2)) {
1098
+ return { salt, address: address2 };
1099
+ }
1100
+ return null;
1101
+ }
1102
+ var syncStrategy = {
1103
+ name: "sync",
1104
+ async mine({ factory, deployer, bytecodeHash, targetPrefix, maxAttempts, timeoutMs, onProgress, signal }) {
1105
+ const startedAt = Date.now();
1106
+ for (let attempts = 0; attempts < maxAttempts; ) {
1107
+ if (signal?.aborted) throw new SaltMiningError("Salt mining was aborted.");
1108
+ if (Date.now() - startedAt > timeoutMs) {
1109
+ throw new SaltMiningError(
1110
+ `Salt mining timed out after ${timeoutMs} ms (${attempts} attempts).`,
1111
+ "Raise timeoutMs, or use the worker_threads strategy on Node for more throughput."
1112
+ );
1113
+ }
1114
+ const chunkEnd = Math.min(attempts + CHUNK, maxAttempts);
1115
+ for (; attempts < chunkEnd; attempts++) {
1116
+ const hit = tryOne(factory, deployer, bytecodeHash, targetPrefix);
1117
+ if (hit) {
1118
+ return {
1119
+ salt: hit.salt,
1120
+ predictedAddress: hit.address,
1121
+ attempts: attempts + 1,
1122
+ durationMs: Date.now() - startedAt
1123
+ };
1124
+ }
1125
+ if ((attempts + 1) % PROGRESS_INTERVAL === 0) onProgress?.(attempts + 1);
1126
+ }
1127
+ await new Promise((resolve) => setTimeout(resolve, 0));
1128
+ }
1129
+ throw new SaltMiningError(
1130
+ `No valid salt found for prefix ${targetPrefix} after ${maxAttempts} attempts.`,
1131
+ "Raise maxAttempts, or confirm the deployer address is on the shard you expect."
1132
+ );
1133
+ }
1134
+ };
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);
1146
+ }
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 });
1164
+ }
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
+ )
1191
+ )
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, {
1199
+ eval: true,
1200
+ workerData: { factory, deployer, bytecodeHash, targetPrefix, maxAttempts: perWorker }
1201
+ });
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") {
1209
+ 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
+ )
1225
+ )
1226
+ );
1227
+ }
1228
+ }
1229
+ });
1230
+ worker.on(
1231
+ "error",
1232
+ (err) => settle(() => reject(new SaltMiningError(`Salt mining worker failed: ${err.message}`)))
1233
+ );
1234
+ }
1235
+ });
1236
+ }
1237
+ };
1238
+ function defaultStrategy() {
1239
+ const hasNode = typeof globalThis.process?.versions?.node === "string";
1240
+ return hasNode ? workerThreadsStrategy : syncStrategy;
1241
+ }
1242
+ async function mineSalt(options, strategy = defaultStrategy()) {
1243
+ const targetPrefix = (options.targetPrefix ?? shardPrefixOf(options.deployer)).toLowerCase();
1244
+ const initData = encodeInitData(options.params);
1245
+ const bytecodeHash = computeBytecodeHash(options.implementation, initData);
1246
+ return strategy.mine({
1247
+ factory: options.factory,
1248
+ deployer: options.deployer,
1249
+ bytecodeHash,
1250
+ targetPrefix,
1251
+ maxAttempts: options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
1252
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1253
+ onProgress: options.onProgress,
1254
+ signal: options.signal
1255
+ });
1256
+ }
1257
+
1258
+ // src/factory.ts
1259
+ var factoryInterface = new Interface4(QuaiVaultFactoryAbi);
1260
+ var Factory = class {
1261
+ constructor(ctx) {
1262
+ this.ctx = ctx;
1263
+ }
1264
+ ctx;
1265
+ get address() {
1266
+ return this.ctx.contracts.factory;
1267
+ }
1268
+ contract(write = false) {
1269
+ return new FactoryContract(this.ctx.connection.factory(this.address, write), this.ctx.connection.retry);
1270
+ }
1271
+ /** The implementation every proxy from this factory delegates to. */
1272
+ async implementation() {
1273
+ return getAddress2(await this.contract().implementation());
1274
+ }
1275
+ /** Whether the factory has this vault in its registry. */
1276
+ async isRegistered(vault2) {
1277
+ return this.contract().isWallet(getAddress2(vault2));
1278
+ }
1279
+ async vaultCount() {
1280
+ return Number(await this.contract().getWalletCount());
1281
+ }
1282
+ async vaultAt(index) {
1283
+ return getAddress2(await this.contract().deployedWallets(index));
1284
+ }
1285
+ /**
1286
+ * Verify the configured implementation address matches what the factory reports.
1287
+ *
1288
+ * A mismatch means salt mining will predict addresses the factory will not deploy
1289
+ * to, so it is worth catching before a deployment rather than after.
1290
+ */
1291
+ async verify() {
1292
+ const errors = [];
1293
+ try {
1294
+ const onChain = await this.implementation();
1295
+ if (onChain.toLowerCase() !== this.ctx.contracts.implementation.toLowerCase()) {
1296
+ errors.push(
1297
+ `Configured implementation ${this.ctx.contracts.implementation} does not match the factory's ${onChain}. Address prediction and salt mining will be wrong.`
1298
+ );
1299
+ }
1300
+ const code = await this.ctx.connection.provider.getCode(onChain);
1301
+ if (code === "0x") errors.push(`No contract code at implementation ${onChain}.`);
1302
+ } catch (err) {
1303
+ errors.push(
1304
+ `Could not reach the factory at ${this.address}: ` + (err instanceof Error ? err.message : String(err))
1305
+ );
1306
+ }
1307
+ return { valid: errors.length === 0, errors };
1308
+ }
1309
+ /** Off-chain CREATE2 prediction. Identical to `factory.predictWalletAddress`. */
1310
+ predictAddress(deployer, salt, params) {
1311
+ validateCreateParams(params);
1312
+ return predictVaultAddress({
1313
+ factory: this.address,
1314
+ implementation: this.ctx.contracts.implementation,
1315
+ deployer: getAddress2(deployer),
1316
+ salt,
1317
+ params
1318
+ });
1319
+ }
1320
+ /**
1321
+ * Mine a CREATE2 salt placing the vault on the deployer's shard.
1322
+ *
1323
+ * Quai addresses are shard-scoped, so an arbitrary salt usually produces a vault
1324
+ * the deployer cannot reach. The mined salt is only valid for these exact create
1325
+ * params — changing owners, threshold, delay, modules or delegatecall targets
1326
+ * changes the predicted address.
1327
+ */
1328
+ async mineSalt(params, options = {}, strategy) {
1329
+ validateCreateParams(params);
1330
+ const deployer = options.deployer ?? await this.ctx.connection.address();
1331
+ return mineSalt(
1332
+ {
1333
+ factory: this.address,
1334
+ implementation: this.ctx.contracts.implementation,
1335
+ deployer: getAddress2(deployer),
1336
+ params,
1337
+ ...options
1338
+ },
1339
+ strategy
1340
+ );
1341
+ }
1342
+ /**
1343
+ * Deploy a vault, mining a salt first when one is not supplied.
1344
+ *
1345
+ * Always uses the 6-argument `createWallet` overload so the deployed bytecode
1346
+ * matches what mining predicted, whatever combination of modules and delegatecall
1347
+ * targets is requested.
1348
+ */
1349
+ async create(params, options = {}) {
1350
+ const { onProgress } = options;
1351
+ validateCreateParams(params);
1352
+ onProgress?.({ step: "validating", message: "Checking factory configuration\u2026" });
1353
+ const verification = await this.verify();
1354
+ if (!verification.valid) {
1355
+ throw new PreconditionError(verification.errors.join(" "), {
1356
+ remediation: "Check the configured factory and implementation addresses for this network."
1357
+ });
1358
+ }
1359
+ const deployer = getAddress2(await this.ctx.connection.address());
1360
+ let salt = params.salt;
1361
+ let predictedAddress;
1362
+ if (salt) {
1363
+ predictedAddress = this.predictAddress(deployer, salt, params);
1364
+ } else {
1365
+ onProgress?.({ step: "mining", message: "Mining a salt for your shard\u2026" });
1366
+ const mined = await this.mineSalt(
1367
+ params,
1368
+ {
1369
+ deployer,
1370
+ ...options.maxAttempts != null ? { maxAttempts: options.maxAttempts } : {},
1371
+ ...options.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {},
1372
+ onProgress: (attempts) => onProgress?.({ step: "mining", message: `Mining\u2026 ${attempts} attempts`, attempts })
1373
+ },
1374
+ options.miningStrategy
1375
+ );
1376
+ salt = mined.salt;
1377
+ predictedAddress = mined.predictedAddress;
1378
+ onProgress?.({
1379
+ step: "mining",
1380
+ message: `Found ${mined.predictedAddress} after ${mined.attempts} attempts`,
1381
+ attempts: mined.attempts,
1382
+ predictedAddress: mined.predictedAddress
1383
+ });
1384
+ }
1385
+ onProgress?.({ step: "deploying", message: "Submitting the deployment\u2026", predictedAddress });
1386
+ const factory = this.contract(true);
1387
+ let receipt;
1388
+ let chainTxHash;
1389
+ try {
1390
+ const sent = await factory.createWallet(
1391
+ params.owners.map((o) => getAddress2(o)),
1392
+ params.threshold,
1393
+ salt,
1394
+ params.minExecutionDelay ?? 0,
1395
+ (params.initialModules ?? []).map((m) => getAddress2(m)),
1396
+ (params.initialDelegatecallTargets ?? []).map((t) => getAddress2(t))
1397
+ );
1398
+ chainTxHash = sent.hash;
1399
+ onProgress?.({ step: "confirming", message: "Waiting for confirmation\u2026", chainTxHash });
1400
+ receipt = await sent.wait();
1401
+ } catch (err) {
1402
+ const decoded = decodeRevertFromError(err);
1403
+ throw new RevertError(
1404
+ `Vault creation failed${decoded ? `: ${decoded.message}` : ""}`,
1405
+ decoded,
1406
+ { cause: err }
1407
+ );
1408
+ }
1409
+ if (!receipt || receipt.status === 0) {
1410
+ throw new RevertError("The deployment transaction reverted.");
1411
+ }
1412
+ const address2 = extractCreatedVault(receipt, this.address);
1413
+ if (!address2) {
1414
+ throw new RevertError(
1415
+ "The deployment was mined but no WalletCreated event was found."
1416
+ );
1417
+ }
1418
+ onProgress?.({ step: "done", message: `Vault deployed at ${address2}`, chainTxHash });
1419
+ return {
1420
+ address: address2,
1421
+ chainTxHash,
1422
+ salt,
1423
+ predictionMatched: predictedAddress !== void 0 && predictedAddress.toLowerCase() === address2.toLowerCase()
1424
+ };
1425
+ }
1426
+ /** Register an externally deployed proxy. Callable only by one of its owners. */
1427
+ async register(vault2) {
1428
+ const factory = this.contract(true);
1429
+ try {
1430
+ const sent = await factory.registerWallet(getAddress2(vault2));
1431
+ const receipt = await sent.wait();
1432
+ if (!receipt || receipt.status === 0) {
1433
+ throw new RevertError("Registration reverted.");
1434
+ }
1435
+ return { chainTxHash: receipt.hash ?? receipt.transactionHash ?? "" };
1436
+ } catch (err) {
1437
+ if (err instanceof RevertError) throw err;
1438
+ const decoded = decodeRevertFromError(err);
1439
+ throw new RevertError(
1440
+ `Registering the vault failed${decoded ? `: ${decoded.message}` : ""}`,
1441
+ decoded,
1442
+ { cause: err }
1443
+ );
1444
+ }
1445
+ }
1446
+ };
1447
+ function validateCreateParams(params) {
1448
+ const { owners, threshold } = params;
1449
+ if (!Array.isArray(owners) || owners.length === 0) {
1450
+ throw new ValidationError("At least one owner is required.");
1451
+ }
1452
+ if (owners.length > MAX_OWNERS) {
1453
+ throw new ValidationError(`A vault may have at most ${MAX_OWNERS} owners (got ${owners.length}).`);
1454
+ }
1455
+ const seen = /* @__PURE__ */ new Set();
1456
+ owners.forEach((owner, i) => {
1457
+ assertQuaiAddress(owner, `owner[${i}]`);
1458
+ const key = owner.toLowerCase();
1459
+ if (key === "0x0000000000000000000000000000000000000000" || key === "0x0000000000000000000000000000000000000001") {
1460
+ throw new ValidationError(
1461
+ `Owner ${owner} is reserved \u2014 the zero address and the module sentinel 0x\u202601 are rejected.`
1462
+ );
1463
+ }
1464
+ if (seen.has(key)) throw new ValidationError(`Duplicate owner: ${owner}.`);
1465
+ seen.add(key);
1466
+ });
1467
+ if (params.initialModules?.length) {
1468
+ assertQuaiAddresses(params.initialModules, "initialModules");
1469
+ }
1470
+ if (params.initialDelegatecallTargets?.length) {
1471
+ assertQuaiAddresses(params.initialDelegatecallTargets, "initialDelegatecallTargets");
1472
+ }
1473
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > owners.length) {
1474
+ throw new ValidationError(
1475
+ `Invalid threshold ${threshold}: must be an integer between 1 and ${owners.length}.`
1476
+ );
1477
+ }
1478
+ const delay = params.minExecutionDelay ?? 0;
1479
+ if (!Number.isInteger(delay) || delay < 0 || delay > MAX_EXECUTION_DELAY) {
1480
+ throw new ValidationError(
1481
+ `Invalid minExecutionDelay ${delay}: must be between 0 and ${MAX_EXECUTION_DELAY} seconds (30 days).`
1482
+ );
1483
+ }
1484
+ }
1485
+ function extractCreatedVault(receipt, factoryAddress) {
1486
+ const target = factoryAddress.toLowerCase();
1487
+ for (const log of receipt.logs ?? []) {
1488
+ if (log.address && log.address.toLowerCase() !== target) continue;
1489
+ try {
1490
+ const parsed = factoryInterface.parseLog({
1491
+ topics: Array.from(log.topics),
1492
+ data: log.data
1493
+ });
1494
+ if (parsed?.name === "WalletCreated") {
1495
+ return getAddress2(String(parsed.args[0]));
1496
+ }
1497
+ } catch {
1498
+ continue;
1499
+ }
1500
+ }
1501
+ return null;
1502
+ }
1503
+
1504
+ // src/indexer/client.ts
1505
+ import { createClient } from "@supabase/supabase-js";
1506
+
1507
+ // src/indexer/schemas.ts
1508
+ var schemas_exports = {};
1509
+ __export(schemas_exports, {
1510
+ ConfirmationSchema: () => ConfirmationSchema,
1511
+ DelegatecallTargetSchema: () => DelegatecallTargetSchema,
1512
+ DepositSchema: () => DepositSchema,
1513
+ IndexerStateSchema: () => IndexerStateSchema,
1514
+ RecoveryApprovalSchema: () => RecoveryApprovalSchema,
1515
+ RecoveryConfigSchema: () => RecoveryConfigSchema,
1516
+ RecoveryGuardianSchema: () => RecoveryGuardianSchema,
1517
+ RecoverySchema: () => RecoverySchema,
1518
+ SignedMessageSchema: () => SignedMessageSchema,
1519
+ TokenSchema: () => TokenSchema,
1520
+ TokenTransferSchema: () => TokenTransferSchema,
1521
+ TransactionSchema: () => TransactionSchema,
1522
+ TransactionStatusEnum: () => TransactionStatusEnum,
1523
+ WalletModuleSchema: () => WalletModuleSchema,
1524
+ WalletOwnerSchema: () => WalletOwnerSchema,
1525
+ WalletSchema: () => WalletSchema,
1526
+ toNumber: () => toNumber
1527
+ });
1528
+ import { z } from "zod";
1529
+ var address = z.string();
1530
+ var nullableNumber = z.union([z.number(), z.string()]).nullable().optional();
1531
+ var WalletSchema = z.object({
1532
+ address,
1533
+ name: z.string().nullable().optional(),
1534
+ threshold: z.number(),
1535
+ owner_count: z.number(),
1536
+ created_at_block: z.union([z.number(), z.string()]),
1537
+ created_at_tx: z.string(),
1538
+ min_execution_delay: z.number().nullable().optional(),
1539
+ created_at: z.string().nullable().optional(),
1540
+ updated_at: z.string().nullable().optional()
1541
+ });
1542
+ var WalletOwnerSchema = z.object({
1543
+ wallet_address: address,
1544
+ owner_address: address,
1545
+ added_at_block: z.union([z.number(), z.string()]),
1546
+ added_at_tx: z.string(),
1547
+ removed_at_block: nullableNumber,
1548
+ removed_at_tx: z.string().nullable().optional(),
1549
+ is_active: z.boolean()
1550
+ });
1551
+ var TransactionStatusEnum = z.enum([
1552
+ "pending",
1553
+ "executed",
1554
+ "cancelled",
1555
+ "expired",
1556
+ "failed"
1557
+ ]);
1558
+ var TransactionSchema = z.object({
1559
+ wallet_address: address,
1560
+ tx_hash: z.string(),
1561
+ to_address: address,
1562
+ value: z.string(),
1563
+ data: z.string().nullable().optional(),
1564
+ transaction_type: z.string(),
1565
+ decoded_params: z.unknown().nullable().optional(),
1566
+ status: TransactionStatusEnum,
1567
+ confirmation_count: z.number().nullable().optional(),
1568
+ submitted_by: address,
1569
+ submitted_at_block: z.union([z.number(), z.string()]),
1570
+ submitted_at_tx: z.string(),
1571
+ executed_at_block: nullableNumber,
1572
+ executed_at_tx: z.string().nullable().optional(),
1573
+ executed_by: z.string().nullable().optional(),
1574
+ cancelled_at_block: nullableNumber,
1575
+ cancelled_at_tx: z.string().nullable().optional(),
1576
+ expiration: z.union([z.number(), z.string()]).nullable().optional(),
1577
+ execution_delay: z.number().nullable().optional(),
1578
+ approved_at: z.union([z.number(), z.string()]).nullable().optional(),
1579
+ executable_after: z.union([z.number(), z.string()]).nullable().optional(),
1580
+ is_expired: z.boolean().nullable().optional(),
1581
+ failed_return_data: z.string().nullable().optional(),
1582
+ created_at: z.string().nullable().optional(),
1583
+ updated_at: z.string().nullable().optional()
1584
+ });
1585
+ var ConfirmationSchema = z.object({
1586
+ wallet_address: address,
1587
+ tx_hash: z.string(),
1588
+ owner_address: address,
1589
+ confirmed_at_block: z.union([z.number(), z.string()]),
1590
+ confirmed_at_tx: z.string(),
1591
+ revoked_at_block: nullableNumber,
1592
+ revoked_at_tx: z.string().nullable().optional(),
1593
+ is_active: z.boolean()
1594
+ });
1595
+ var WalletModuleSchema = z.object({
1596
+ wallet_address: address,
1597
+ module_address: address,
1598
+ enabled_at_block: z.union([z.number(), z.string()]),
1599
+ enabled_at_tx: z.string(),
1600
+ disabled_at_block: nullableNumber,
1601
+ disabled_at_tx: z.string().nullable().optional(),
1602
+ is_active: z.boolean()
1603
+ });
1604
+ var DelegatecallTargetSchema = z.object({
1605
+ wallet_address: address,
1606
+ target_address: address,
1607
+ added_at_block: z.union([z.number(), z.string()]),
1608
+ added_at_tx: z.string(),
1609
+ removed_at_block: nullableNumber,
1610
+ removed_at_tx: z.string().nullable().optional(),
1611
+ is_active: z.boolean()
1612
+ });
1613
+ var DepositSchema = z.object({
1614
+ wallet_address: address,
1615
+ sender_address: address,
1616
+ amount: z.string(),
1617
+ deposited_at_block: z.union([z.number(), z.string()]),
1618
+ deposited_at_tx: z.string(),
1619
+ created_at: z.string().nullable().optional()
1620
+ });
1621
+ var TokenSchema = z.object({
1622
+ address,
1623
+ standard: z.enum(["ERC20", "ERC721", "ERC1155"]),
1624
+ symbol: z.string(),
1625
+ name: z.string(),
1626
+ decimals: z.number(),
1627
+ discovered_at_block: nullableNumber,
1628
+ discovered_via: z.string().nullable().optional()
1629
+ });
1630
+ var TokenTransferSchema = z.object({
1631
+ token_address: address,
1632
+ wallet_address: address,
1633
+ from_address: address,
1634
+ to_address: address,
1635
+ value: z.string(),
1636
+ token_id: z.string().nullable().optional(),
1637
+ batch_index: z.number().nullable().optional(),
1638
+ direction: z.enum(["inflow", "outflow"]),
1639
+ block_number: z.union([z.number(), z.string()]),
1640
+ transaction_hash: z.string(),
1641
+ log_index: z.number()
1642
+ });
1643
+ var SignedMessageSchema = z.object({
1644
+ wallet_address: address,
1645
+ msg_hash: z.string(),
1646
+ data: z.string().nullable().optional(),
1647
+ signed_at_block: z.union([z.number(), z.string()]),
1648
+ signed_at_tx: z.string(),
1649
+ unsigned_at_block: nullableNumber,
1650
+ unsigned_at_tx: z.string().nullable().optional(),
1651
+ is_active: z.boolean()
1652
+ });
1653
+ var IndexerStateSchema = z.object({
1654
+ id: z.string(),
1655
+ last_indexed_block: z.union([z.number(), z.string()]),
1656
+ last_block_hash: z.string().nullable().optional(),
1657
+ last_indexed_at: z.string().nullable().optional(),
1658
+ is_syncing: z.boolean().nullable().optional()
1659
+ });
1660
+ var RecoveryConfigSchema = z.object({
1661
+ wallet_address: address,
1662
+ threshold: z.number(),
1663
+ recovery_period: z.union([z.number(), z.string()]),
1664
+ setup_at_block: z.union([z.number(), z.string()]),
1665
+ setup_at_tx: z.string(),
1666
+ is_active: z.boolean()
1667
+ });
1668
+ var RecoveryGuardianSchema = z.object({
1669
+ wallet_address: address,
1670
+ guardian_address: address,
1671
+ added_at_block: z.union([z.number(), z.string()]),
1672
+ added_at_tx: z.string(),
1673
+ is_active: z.boolean()
1674
+ });
1675
+ var RecoverySchema = z.object({
1676
+ wallet_address: address,
1677
+ recovery_hash: z.string(),
1678
+ new_owners: z.array(address),
1679
+ new_threshold: z.number(),
1680
+ initiator_address: address,
1681
+ approval_count: z.number().nullable().optional(),
1682
+ required_threshold: z.number(),
1683
+ execution_time: z.union([z.number(), z.string()]),
1684
+ status: z.enum(["pending", "executed", "cancelled", "invalidated", "expired"]),
1685
+ initiated_at_block: z.union([z.number(), z.string()]),
1686
+ initiated_at_tx: z.string(),
1687
+ expiration: z.union([z.number(), z.string()]).nullable().optional()
1688
+ });
1689
+ var RecoveryApprovalSchema = z.object({
1690
+ wallet_address: address,
1691
+ recovery_hash: z.string(),
1692
+ guardian_address: address,
1693
+ approved_at_block: z.union([z.number(), z.string()]),
1694
+ approved_at_tx: z.string(),
1695
+ is_active: z.boolean()
1696
+ });
1697
+ function toNumber(value, fallback = 0) {
1698
+ if (value === null || value === void 0) return fallback;
1699
+ const n = typeof value === "string" ? Number(value) : value;
1700
+ return Number.isFinite(n) ? n : fallback;
1701
+ }
1702
+
1703
+ // src/indexer/client.ts
1704
+ var SDK_VERSION = "0.1.0";
1705
+ var IndexerClient = class {
1706
+ config;
1707
+ client;
1708
+ healthCache = null;
1709
+ inflightHealth = null;
1710
+ /** Health results are reused for this long to keep read paths cheap. */
1711
+ healthCacheMs;
1712
+ constructor(config, options = {}) {
1713
+ this.config = config;
1714
+ 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}` } }
1719
+ });
1720
+ }
1721
+ /** Raw Supabase client, for queries the SDK does not wrap. */
1722
+ get raw() {
1723
+ return this.client;
1724
+ }
1725
+ from(table) {
1726
+ return this.client.from(table);
1727
+ }
1728
+ /**
1729
+ * Run a query, parse each row, and surface failures as `IndexerQueryError`.
1730
+ *
1731
+ * No retry wrapper here on purpose. `postgrest-js` already retries GET/HEAD/OPTIONS
1732
+ * internally (3 attempts, retryable statuses and fetch errors, honouring
1733
+ * `Retry-After`), and it reports failures as a resolved `{ error }` value rather
1734
+ * than a rejection — so an outer `withRetry` would be dead code that silently
1735
+ * multiplied attempts if it were ever made to work. Chain reads, which do reject,
1736
+ * are retried in `src/chain/retry.ts`.
1737
+ */
1738
+ async select(table, schema, build) {
1739
+ const { data, error } = await build(this.client.from(table));
1740
+ if (error) {
1741
+ throw new IndexerQueryError(`Indexer query on "${table}" failed: ${error.message}`, error);
1742
+ }
1743
+ return (data ?? []).map((row) => schema.parse(row));
1744
+ }
1745
+ /** Single-row variant. Returns null for PostgREST's "no rows" code. */
1746
+ async selectOne(table, schema, build) {
1747
+ const { data, error } = await build(this.client.from(table));
1748
+ if (error) {
1749
+ if (error.code === "PGRST116") return null;
1750
+ throw new IndexerQueryError(`Indexer query on "${table}" failed: ${error.message}`, error);
1751
+ }
1752
+ return data === null ? null : schema.parse(data);
1753
+ }
1754
+ /** The indexer's sync head, straight from the `indexer_state` table. */
1755
+ async state() {
1756
+ const row = await this.selectOne(
1757
+ "indexer_state",
1758
+ IndexerStateSchema,
1759
+ (q) => q.select("*").eq("id", "main").single()
1760
+ );
1761
+ if (!row) return null;
1762
+ return {
1763
+ lastIndexedBlock: toNumber(row.last_indexed_block),
1764
+ isSyncing: row.is_syncing ?? false
1765
+ };
1766
+ }
1767
+ /**
1768
+ * Liveness and lag. Prefers the HTTP health endpoint (which knows the chain head)
1769
+ * and falls back to the `indexer_state` table when it is unreachable.
1770
+ */
1771
+ async health(force = false) {
1772
+ const now = Date.now();
1773
+ if (!force && this.healthCache && now - this.healthCache.at < this.healthCacheMs) {
1774
+ return this.healthCache.value;
1775
+ }
1776
+ if (this.inflightHealth) return this.inflightHealth;
1777
+ this.inflightHealth = this.probeHealth().then((value) => {
1778
+ this.healthCache = { value, at: Date.now() };
1779
+ return value;
1780
+ }).finally(() => {
1781
+ this.inflightHealth = null;
1782
+ });
1783
+ return this.inflightHealth;
1784
+ }
1785
+ /**
1786
+ * Block until the indexer has processed `blockNumber`.
1787
+ *
1788
+ * Closes the write-then-read race: a transaction confirmed at block N is not
1789
+ * queryable until the indexer reaches N, so `propose()` immediately followed by
1790
+ * `pendingTransactions()` will silently miss it. Await this in between.
1791
+ *
1792
+ * Resolves immediately when the head is already at or past the target.
1793
+ */
1794
+ async waitForBlock(blockNumber, options = {}) {
1795
+ const timeoutMs = options.timeoutMs ?? 6e4;
1796
+ const pollIntervalMs = options.pollIntervalMs ?? 1500;
1797
+ const deadline = Date.now() + timeoutMs;
1798
+ for (; ; ) {
1799
+ if (options.signal?.aborted) throw new Error("Aborted");
1800
+ const state = await this.state();
1801
+ const head = state?.lastIndexedBlock ?? 0;
1802
+ if (head >= blockNumber) return { reached: true, lastIndexedBlock: head };
1803
+ if (Date.now() + pollIntervalMs > deadline) {
1804
+ return { reached: false, lastIndexedBlock: head };
1805
+ }
1806
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1807
+ }
1808
+ }
1809
+ async probeHealth() {
1810
+ if (this.config.healthUrl) {
1811
+ try {
1812
+ const controller = new AbortController();
1813
+ const timer = setTimeout(() => controller.abort(), 5e3);
1814
+ const res = await fetch(new URL("/health", this.config.healthUrl), {
1815
+ signal: controller.signal
1816
+ }).finally(() => clearTimeout(timer));
1817
+ const body = await res.json();
1818
+ const d = body.details ?? {};
1819
+ return {
1820
+ available: res.ok && body.status === "healthy",
1821
+ lastIndexedBlock: d.lastIndexedBlock ?? 0,
1822
+ chainHead: d.currentBlock,
1823
+ blocksBehind: d.blocksBehind,
1824
+ isSyncing: d.isSyncing ?? false,
1825
+ ...res.ok ? {} : { error: `health endpoint returned ${res.status}` }
1826
+ };
1827
+ } catch {
1828
+ }
1829
+ }
1830
+ try {
1831
+ const state = await this.state();
1832
+ if (!state) {
1833
+ return { available: false, lastIndexedBlock: 0, isSyncing: false, error: "no indexer_state row" };
1834
+ }
1835
+ return {
1836
+ available: true,
1837
+ lastIndexedBlock: state.lastIndexedBlock,
1838
+ isSyncing: state.isSyncing
1839
+ };
1840
+ } catch (err) {
1841
+ return {
1842
+ available: false,
1843
+ lastIndexedBlock: 0,
1844
+ isSyncing: false,
1845
+ error: err instanceof Error ? err.message : String(err)
1846
+ };
1847
+ }
1848
+ }
1849
+ };
1850
+
1851
+ // src/indexer/queries.ts
1852
+ var DEFAULT_LIMIT = 50;
1853
+ var MAX_LIMIT = 200;
1854
+ function lower(address2) {
1855
+ return address2.toLowerCase();
1856
+ }
1857
+ function bounds(options = {}) {
1858
+ const limit = Math.min(Math.max(options.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
1859
+ const offset = Math.max(options.offset ?? 0, 0);
1860
+ return { limit, offset };
1861
+ }
1862
+ var IndexerQueries = class {
1863
+ constructor(client) {
1864
+ this.client = client;
1865
+ }
1866
+ client;
1867
+ // ---- wallets -------------------------------------------------------------
1868
+ async wallet(address2) {
1869
+ return this.client.selectOne(
1870
+ "wallets",
1871
+ WalletSchema,
1872
+ (q) => q.select("*").eq("address", lower(address2)).single()
1873
+ );
1874
+ }
1875
+ /** Active owner addresses, lowercased as stored. */
1876
+ async owners(address2) {
1877
+ const rows = await this.client.select(
1878
+ "wallet_owners",
1879
+ WalletOwnerSchema,
1880
+ (q) => q.select("*").eq("wallet_address", lower(address2)).eq("is_active", true)
1881
+ );
1882
+ return rows.map((r) => r.owner_address);
1883
+ }
1884
+ async vaultsForOwner(owner, options = {}) {
1885
+ const { limit, offset } = bounds(options);
1886
+ const { data, error } = await this.client.from("wallet_owners").select("wallet_address, wallets (*)").eq("owner_address", lower(owner)).eq("is_active", true).range(offset, offset + limit - 1);
1887
+ if (error) throw new IndexerQueryError(`Indexer query failed: ${error.message}`, error);
1888
+ return extractJoined(data, WalletSchema);
1889
+ }
1890
+ async vaultsForGuardian(guardian, options = {}) {
1891
+ const { limit, offset } = bounds(options);
1892
+ const { data, error } = await this.client.from("social_recovery_guardians").select("wallet_address, wallets (*)").eq("guardian_address", lower(guardian)).eq("is_active", true).range(offset, offset + limit - 1);
1893
+ if (error) throw new IndexerQueryError(`Indexer query failed: ${error.message}`, error);
1894
+ return extractJoined(data, WalletSchema);
1895
+ }
1896
+ // ---- transactions --------------------------------------------------------
1897
+ async pendingTransactions(vault2, options = {}) {
1898
+ const { limit, offset } = bounds(options);
1899
+ return this.client.select(
1900
+ "transactions",
1901
+ TransactionSchema,
1902
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("status", "pending").order("submitted_at_block", { ascending: false }).range(offset, offset + limit - 1)
1903
+ );
1904
+ }
1905
+ async transaction(vault2, txHash) {
1906
+ return this.client.selectOne(
1907
+ "transactions",
1908
+ TransactionSchema,
1909
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("tx_hash", txHash.toLowerCase()).single()
1910
+ );
1911
+ }
1912
+ async transactionHistory(vault2, options = {}) {
1913
+ const { limit, offset } = bounds(options);
1914
+ 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 };
1920
+ }
1921
+ /** All confirmations for one transaction, including revoked ones. */
1922
+ async confirmations(vault2, txHash) {
1923
+ return this.client.select(
1924
+ "confirmations",
1925
+ ConfirmationSchema,
1926
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("tx_hash", txHash.toLowerCase()).order("confirmed_at_block", { ascending: true })
1927
+ );
1928
+ }
1929
+ /**
1930
+ * Active confirmations for many transactions in one round trip.
1931
+ *
1932
+ * "Active" here means not revoked. It does NOT mean the confirming address is
1933
+ * still an owner — the indexer does not deactivate confirmations when an owner is
1934
+ * removed, while the contract invalidates them via approval epochs. Callers must
1935
+ * intersect with the active owner set; `Vault` does this for you.
1936
+ */
1937
+ async activeConfirmationsBatch(vault2, txHashes) {
1938
+ const result = /* @__PURE__ */ new Map();
1939
+ for (const hash of txHashes) result.set(hash.toLowerCase(), []);
1940
+ 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)
1948
+ );
1949
+ for (const row of rows) {
1950
+ const key = row.tx_hash.toLowerCase();
1951
+ const list = result.get(key);
1952
+ if (list) list.push(row);
1953
+ else result.set(key, [row]);
1954
+ }
1955
+ return result;
1956
+ }
1957
+ // ---- modules and delegatecall --------------------------------------------
1958
+ async modules(vault2) {
1959
+ const rows = await this.client.select(
1960
+ "wallet_modules",
1961
+ WalletModuleSchema,
1962
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("is_active", true)
1963
+ );
1964
+ return rows.map((r) => r.module_address);
1965
+ }
1966
+ async delegatecallTargets(vault2) {
1967
+ const rows = await this.client.select(
1968
+ "wallet_delegatecall_targets",
1969
+ DelegatecallTargetSchema,
1970
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("is_active", true)
1971
+ );
1972
+ return rows.map((r) => r.target_address);
1973
+ }
1974
+ // ---- value movement ------------------------------------------------------
1975
+ async deposits(vault2, options = {}) {
1976
+ 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 };
1982
+ }
1983
+ async tokenTransfers(vault2, options = {}) {
1984
+ 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 };
1990
+ }
1991
+ async tokens(addresses) {
1992
+ if (addresses.length === 0) return [];
1993
+ return this.client.select(
1994
+ "tokens",
1995
+ TokenSchema,
1996
+ (q) => q.select("*").in("address", addresses.map(lower))
1997
+ );
1998
+ }
1999
+ async signedMessages(vault2) {
2000
+ return this.client.select(
2001
+ "signed_messages",
2002
+ SignedMessageSchema,
2003
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("is_active", true)
2004
+ );
2005
+ }
2006
+ // ---- social recovery -----------------------------------------------------
2007
+ async recoveryConfig(vault2) {
2008
+ const config = await this.client.selectOne(
2009
+ "social_recovery_configs",
2010
+ RecoveryConfigSchema,
2011
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("is_active", true).single()
2012
+ );
2013
+ if (!config) return null;
2014
+ const guardians = await this.client.select(
2015
+ "social_recovery_guardians",
2016
+ RecoveryGuardianSchema,
2017
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("is_active", true)
2018
+ );
2019
+ return {
2020
+ threshold: config.threshold,
2021
+ recoveryPeriod: Number(config.recovery_period),
2022
+ guardians: guardians.map((g) => g.guardian_address)
2023
+ };
2024
+ }
2025
+ async pendingRecoveries(vault2) {
2026
+ return this.client.select(
2027
+ "social_recoveries",
2028
+ RecoverySchema,
2029
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("status", "pending")
2030
+ );
2031
+ }
2032
+ async recoveryHistory(vault2, options = {}) {
2033
+ const { limit, offset } = bounds(options);
2034
+ return this.client.select(
2035
+ "social_recoveries",
2036
+ RecoverySchema,
2037
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).order("initiated_at_block", { ascending: false }).range(offset, offset + limit - 1)
2038
+ );
2039
+ }
2040
+ async recoveryApprovals(vault2, recoveryHash) {
2041
+ return this.client.select(
2042
+ "social_recovery_approvals",
2043
+ RecoveryApprovalSchema,
2044
+ (q) => q.select("*").eq("wallet_address", lower(vault2)).eq("recovery_hash", recoveryHash.toLowerCase()).eq("is_active", true)
2045
+ );
2046
+ }
2047
+ };
2048
+ function extractJoined(data, schema) {
2049
+ const out = [];
2050
+ for (const row of data ?? []) {
2051
+ const embedded = row.wallets;
2052
+ if (!embedded) continue;
2053
+ const candidates = Array.isArray(embedded) ? embedded : [embedded];
2054
+ for (const candidate of candidates) {
2055
+ try {
2056
+ out.push(schema.parse(candidate));
2057
+ } catch {
2058
+ continue;
2059
+ }
2060
+ }
2061
+ }
2062
+ return out;
2063
+ }
2064
+
2065
+ // src/vault.ts
2066
+ import { formatQuai as formatQuai2, getAddress as getAddress5, getZoneForAddress as getZoneForAddress3, toShard } from "quais";
2067
+
2068
+ // src/recovery.ts
2069
+ import { getAddress as getAddress3 } from "quais";
2070
+
2071
+ // src/chain/recovery-contract.ts
2072
+ var RecoveryContract = class {
2073
+ constructor(contract, retry = {}) {
2074
+ this.contract = contract;
2075
+ this.retry = retry;
2076
+ }
2077
+ contract;
2078
+ retry;
2079
+ fn(signature) {
2080
+ return this.contract.getFunction(signature);
2081
+ }
2082
+ /**
2083
+ * Reads retry transient RPC failures (see {@link withRetry}); writes never do,
2084
+ * because a resubmit that looks like a timeout may already be in the mempool.
2085
+ */
2086
+ read(signature, ...args) {
2087
+ return withRetry(() => this.fn(signature)(...args), this.retry);
2088
+ }
2089
+ get interface() {
2090
+ return this.contract.interface;
2091
+ }
2092
+ // ---- reads ---------------------------------------------------------------
2093
+ getRecoveryConfig(vault2) {
2094
+ return this.read("getRecoveryConfig(address)", vault2);
2095
+ }
2096
+ getRecovery(vault2, recoveryHash) {
2097
+ return this.read("getRecovery(address,bytes32)", vault2, recoveryHash);
2098
+ }
2099
+ isGuardian(vault2, guardian) {
2100
+ return this.read("isGuardian(address,address)", vault2, guardian);
2101
+ }
2102
+ getPendingRecoveryHashes(vault2) {
2103
+ return this.read("getPendingRecoveryHashes(address)", vault2);
2104
+ }
2105
+ hasPendingRecoveries(vault2) {
2106
+ return this.read("hasPendingRecoveries(address)", vault2);
2107
+ }
2108
+ recoveryApprovals(vault2, recoveryHash, guardian) {
2109
+ return this.read(
2110
+ "recoveryApprovals(address,bytes32,address)",
2111
+ vault2,
2112
+ recoveryHash,
2113
+ guardian
2114
+ );
2115
+ }
2116
+ recoveryNonces(vault2) {
2117
+ return this.read("recoveryNonces(address)", vault2);
2118
+ }
2119
+ maxGuardians() {
2120
+ return this.read("MAX_GUARDIANS()");
2121
+ }
2122
+ /** Hash for an explicit nonce. Use {@link predictNextRecoveryHash} for the next one. */
2123
+ getRecoveryHash(vault2, newOwners, newThreshold, nonce) {
2124
+ return this.read(
2125
+ "getRecoveryHash(address,address[],uint256,uint256)",
2126
+ vault2,
2127
+ newOwners,
2128
+ newThreshold,
2129
+ nonce
2130
+ );
2131
+ }
2132
+ /** Hash the next `initiateRecovery` would produce, at the current nonce. */
2133
+ predictNextRecoveryHash(vault2, newOwners, newThreshold) {
2134
+ return this.read(
2135
+ "predictNextRecoveryHash(address,address[],uint256)",
2136
+ vault2,
2137
+ newOwners,
2138
+ newThreshold
2139
+ );
2140
+ }
2141
+ // ---- writes --------------------------------------------------------------
2142
+ initiateRecovery(vault2, newOwners, newThreshold) {
2143
+ return this.fn("initiateRecovery(address,address[],uint256)")(
2144
+ vault2,
2145
+ newOwners,
2146
+ newThreshold
2147
+ );
2148
+ }
2149
+ approveRecovery(vault2, recoveryHash) {
2150
+ return this.fn("approveRecovery(address,bytes32)")(
2151
+ vault2,
2152
+ recoveryHash
2153
+ );
2154
+ }
2155
+ revokeRecoveryApproval(vault2, recoveryHash) {
2156
+ return this.fn("revokeRecoveryApproval(address,bytes32)")(
2157
+ vault2,
2158
+ recoveryHash
2159
+ );
2160
+ }
2161
+ executeRecovery(vault2, recoveryHash) {
2162
+ return this.fn("executeRecovery(address,bytes32)")(
2163
+ vault2,
2164
+ recoveryHash
2165
+ );
2166
+ }
2167
+ cancelRecovery(vault2, recoveryHash) {
2168
+ return this.fn("cancelRecovery(address,bytes32)")(
2169
+ vault2,
2170
+ recoveryHash
2171
+ );
2172
+ }
2173
+ expireRecovery(vault2, recoveryHash) {
2174
+ return this.fn("expireRecovery(address,bytes32)")(
2175
+ vault2,
2176
+ recoveryHash
2177
+ );
2178
+ }
2179
+ };
2180
+
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
+ // src/recovery.ts
2212
+ var SENTINEL = "0x0000000000000000000000000000000000000001";
2213
+ var ZERO = "0x0000000000000000000000000000000000000000";
2214
+ var RecoveryModule = class {
2215
+ constructor(ctx) {
2216
+ this.ctx = ctx;
2217
+ }
2218
+ ctx;
2219
+ /** The module address, or a typed error naming the variable to set. */
2220
+ get address() {
2221
+ if (!this.ctx.moduleAddress) {
2222
+ throw new ValidationError(
2223
+ "No SocialRecoveryModule address is configured for this network.",
2224
+ "Set QUAIVAULT_SOCIAL_RECOVERY_MODULE or pass contracts.socialRecovery."
2225
+ );
2226
+ }
2227
+ return this.ctx.moduleAddress;
2228
+ }
2229
+ contract(write = false) {
2230
+ return new RecoveryContract(this.ctx.connection.socialRecovery(this.address, write), this.ctx.connection.retry);
2231
+ }
2232
+ get vault() {
2233
+ return this.ctx.vaultAddress;
2234
+ }
2235
+ // =========================================================================
2236
+ // Reads
2237
+ // =========================================================================
2238
+ /** Whether the module is currently enabled on the vault. */
2239
+ async isEnabled() {
2240
+ if (!this.ctx.moduleAddress) return false;
2241
+ const vault2 = this.ctx.connection.vault(this.vault);
2242
+ return vault2.getFunction("isModuleEnabled(address)")(this.address);
2243
+ }
2244
+ async config() {
2245
+ const raw = await this.contract().getRecoveryConfig(this.vault);
2246
+ const guardians = Array.from(raw.guardians ?? []).map((g) => getAddress3(String(g)));
2247
+ return {
2248
+ guardians,
2249
+ threshold: Number(raw.threshold ?? 0),
2250
+ recoveryPeriod: Number(raw.recoveryPeriod ?? 0),
2251
+ configured: guardians.length > 0
2252
+ };
2253
+ }
2254
+ async isGuardian(address2) {
2255
+ return this.contract().isGuardian(this.vault, getAddress3(address2));
2256
+ }
2257
+ /** Hashes of recoveries the module still tracks as pending. */
2258
+ async pendingHashes() {
2259
+ return (await this.contract().getPendingRecoveryHashes(this.vault)).map((h) => String(h));
2260
+ }
2261
+ async hasPending() {
2262
+ return this.contract().hasPendingRecoveries(this.vault);
2263
+ }
2264
+ /**
2265
+ * One recovery request, read from chain.
2266
+ *
2267
+ * Throws `NotFoundError` for a hash that was never initiated *or* was cancelled —
2268
+ * `cancelRecovery` deletes the struct, so the two are indistinguishable on chain.
2269
+ * Use {@link history} for cancelled recoveries.
2270
+ */
2271
+ async get(recoveryHash) {
2272
+ const hash = normalizeTxHash(recoveryHash, "recovery hash");
2273
+ const raw = await this.contract().getRecovery(this.vault, hash);
2274
+ if (Number(raw.executionTime ?? 0) === 0) {
2275
+ throw new NotFoundError(
2276
+ `No active recovery ${hash} on vault ${this.vault}. It was never initiated, or it was cancelled (cancellation deletes the on-chain record).`
2277
+ );
2278
+ }
2279
+ return this.buildFromChain(hash, raw);
2280
+ }
2281
+ /** Every recovery the module still tracks as pending, hydrated. */
2282
+ async pending() {
2283
+ const hashes = await this.pendingHashes();
2284
+ const contract = this.contract();
2285
+ const results = await Promise.all(
2286
+ hashes.map(async (hash) => {
2287
+ const raw = await contract.getRecovery(this.vault, hash);
2288
+ if (Number(raw.executionTime ?? 0) === 0) return null;
2289
+ return this.buildFromChain(hash, raw);
2290
+ })
2291
+ );
2292
+ return results.filter((r) => r !== null);
2293
+ }
2294
+ /**
2295
+ * Full recovery history including executed, cancelled and expired requests.
2296
+ * Indexer-only — the chain retains no record of cancelled recoveries.
2297
+ */
2298
+ async history(options = {}) {
2299
+ const queries = this.ctx.queries;
2300
+ if (!queries) return [];
2301
+ const rows = await queries.recoveryHistory(this.vault, options);
2302
+ return rows.map((row) => ({
2303
+ hash: row.recovery_hash,
2304
+ vault: this.vault,
2305
+ newOwners: row.new_owners.map((o) => getAddress3(o)),
2306
+ newThreshold: row.new_threshold,
2307
+ approvalCount: row.approval_count ?? 0,
2308
+ requiredThreshold: row.required_threshold,
2309
+ executionTime: Number(row.execution_time),
2310
+ expiration: Number(row.expiration ?? 0),
2311
+ // Trust the indexer only for states it alone can observe (cancelled /
2312
+ // invalidated); derive everything else, since an un-cleaned recovery stays
2313
+ // 'pending' in the database long after its deadline.
2314
+ status: row.status === "cancelled" || row.status === "invalidated" ? "cancelled" : deriveRecoveryStatus({
2315
+ executed: row.status === "executed",
2316
+ approvalCount: row.approval_count ?? 0,
2317
+ requiredThreshold: row.required_threshold,
2318
+ executionTime: Number(row.execution_time),
2319
+ expiration: Number(row.expiration ?? 0)
2320
+ }),
2321
+ executed: row.status === "executed",
2322
+ initiator: getAddress3(row.initiator_address),
2323
+ source: "indexer"
2324
+ }));
2325
+ }
2326
+ /** Guardians who have an active approval on a recovery. */
2327
+ async approvals(recoveryHash) {
2328
+ const hash = normalizeTxHash(recoveryHash, "recovery hash");
2329
+ const { guardians } = await this.config();
2330
+ const contract = this.contract();
2331
+ const flags = await Promise.all(
2332
+ guardians.map((g) => contract.recoveryApprovals(this.vault, hash, g))
2333
+ );
2334
+ return guardians.filter((_, i) => flags[i] === true);
2335
+ }
2336
+ /** Hash the next `initiate` with these parameters would produce. */
2337
+ async predictHash(newOwners, newThreshold) {
2338
+ return this.contract().predictNextRecoveryHash(
2339
+ this.vault,
2340
+ newOwners.map((o) => getAddress3(o)),
2341
+ newThreshold
2342
+ );
2343
+ }
2344
+ // =========================================================================
2345
+ // Writes
2346
+ // =========================================================================
2347
+ /**
2348
+ * Initiate a recovery. Guardians only.
2349
+ *
2350
+ * Returns the recovery hash, read from the `RecoveryInitiated` event — the function's
2351
+ * return value is not recoverable from a receipt.
2352
+ */
2353
+ async initiate(params) {
2354
+ const newOwners = params.newOwners.map((o, i) => assertQuaiAddress(o, `newOwners[${i}]`));
2355
+ this.validateNewOwners(newOwners, params.newThreshold);
2356
+ const caller = await this.ctx.connection.address();
2357
+ await this.assertEnabled("Initiating a recovery");
2358
+ const config = await this.config();
2359
+ if (!config.configured) {
2360
+ throw new PreconditionError("This vault has no social recovery configuration.", {
2361
+ remediation: "Owners must first propose and execute setupRecovery via vault.propose.setupRecovery()."
2362
+ });
2363
+ }
2364
+ if (!await this.isGuardian(caller)) {
2365
+ throw new PreconditionError(`${caller} is not a guardian of this vault.`);
2366
+ }
2367
+ const contract = this.contract(true);
2368
+ try {
2369
+ const sent = await contract.initiateRecovery(this.vault, newOwners, params.newThreshold);
2370
+ const receipt = await sent.wait();
2371
+ if (!receipt || receipt.status === 0) {
2372
+ throw new RevertError("The recovery initiation reverted.");
2373
+ }
2374
+ const recoveryHash = this.extractRecoveryHash(receipt);
2375
+ if (!recoveryHash) {
2376
+ throw new RevertError(
2377
+ "The recovery was initiated but no RecoveryInitiated event was found."
2378
+ );
2379
+ }
2380
+ return { recoveryHash, chainTxHash: receipt.hash ?? receipt.transactionHash ?? "" };
2381
+ } catch (err) {
2382
+ throw this.toRevert(err, "Initiating the recovery failed");
2383
+ }
2384
+ }
2385
+ /** Approve a recovery. Guardians only. */
2386
+ async approve(recoveryHash) {
2387
+ const hash = normalizeTxHash(recoveryHash, "recovery hash");
2388
+ const caller = await this.ctx.connection.address();
2389
+ await this.assertEnabled("Approving a recovery");
2390
+ if (!await this.isGuardian(caller)) {
2391
+ throw new PreconditionError(`${caller} is not a guardian of this vault.`);
2392
+ }
2393
+ const request = await this.get(hash);
2394
+ this.assertLive(request);
2395
+ if (await this.contract().recoveryApprovals(this.vault, hash, caller)) {
2396
+ throw new PreconditionError("You have already approved this recovery.");
2397
+ }
2398
+ return this.send((c) => c.approveRecovery(this.vault, hash), "Approving the recovery failed");
2399
+ }
2400
+ /** Withdraw a guardian approval before the recovery executes. */
2401
+ async revokeApproval(recoveryHash) {
2402
+ const hash = normalizeTxHash(recoveryHash, "recovery hash");
2403
+ const caller = await this.ctx.connection.address();
2404
+ if (!await this.isGuardian(caller)) {
2405
+ throw new PreconditionError(`${caller} is not a guardian of this vault.`);
2406
+ }
2407
+ if (!await this.contract().recoveryApprovals(this.vault, hash, caller)) {
2408
+ throw new PreconditionError("You have no active approval on this recovery to revoke.");
2409
+ }
2410
+ return this.send(
2411
+ (c) => c.revokeRecoveryApproval(this.vault, hash),
2412
+ "Revoking the recovery approval failed"
2413
+ );
2414
+ }
2415
+ /**
2416
+ * Execute a recovery, replacing the vault's owner set. Permissionless once the
2417
+ * guardian threshold is met and the recovery period has elapsed.
2418
+ */
2419
+ async execute(recoveryHash) {
2420
+ const hash = normalizeTxHash(recoveryHash, "recovery hash");
2421
+ await this.assertEnabled("Executing a recovery");
2422
+ const request = await this.get(hash);
2423
+ this.assertLive(request);
2424
+ if (request.approvalCount < request.requiredThreshold) {
2425
+ throw new PreconditionError(
2426
+ `Not enough guardian approvals: ${request.approvalCount} of ${request.requiredThreshold}.`
2427
+ );
2428
+ }
2429
+ const now = nowSeconds();
2430
+ if (now < request.executionTime) {
2431
+ throw new PreconditionError(
2432
+ `The recovery period has not elapsed. Executable after ${new Date(request.executionTime * 1e3).toISOString()}.`,
2433
+ { retryableAt: request.executionTime }
2434
+ );
2435
+ }
2436
+ const vault2 = this.ctx.connection.vault(this.vault);
2437
+ const currentOwners = await vault2.getFunction("getOwners()")();
2438
+ const current = new Set(Array.from(currentOwners).map((o) => String(o).toLowerCase()));
2439
+ const overlap = request.newOwners.filter((o) => current.has(o.toLowerCase())).length;
2440
+ const peak = current.size + request.newOwners.length - overlap;
2441
+ if (peak > 20) {
2442
+ 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.`,
2444
+ {
2445
+ remediation: "Run a recovery that retains at least one existing owner, then a second recovery to replace it."
2446
+ }
2447
+ );
2448
+ }
2449
+ return this.send((c) => c.executeRecovery(this.vault, hash), "Executing the recovery failed");
2450
+ }
2451
+ /**
2452
+ * Cancel a recovery. Callable by any current vault owner — this is the owners'
2453
+ * defence against a hostile or mistaken guardian action.
2454
+ */
2455
+ async cancel(recoveryHash) {
2456
+ const hash = normalizeTxHash(recoveryHash, "recovery hash");
2457
+ 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);
2460
+ if (!isOwner) {
2461
+ throw new PreconditionError(
2462
+ `Only a current owner of the vault can cancel a recovery. ${caller} is not one.`
2463
+ );
2464
+ }
2465
+ return this.send((c) => c.cancelRecovery(this.vault, hash), "Cancelling the recovery failed");
2466
+ }
2467
+ /** Clean up a recovery past its deadline. Permissionless. */
2468
+ async expire(recoveryHash) {
2469
+ const hash = normalizeTxHash(recoveryHash, "recovery hash");
2470
+ return this.send((c) => c.expireRecovery(this.vault, hash), "Expiring the recovery failed");
2471
+ }
2472
+ // =========================================================================
2473
+ // Affordances
2474
+ // =========================================================================
2475
+ /** What `caller` may do to a recovery right now, and when blocked actions unlock. */
2476
+ async affordances(recoveryHash, caller) {
2477
+ const hash = normalizeTxHash(recoveryHash, "recovery hash");
2478
+ const who = caller ?? await this.ctx.connection.addressOrNull();
2479
+ if (!who) {
2480
+ throw new ValidationError(
2481
+ "affordances() needs a caller address when the client has no signer."
2482
+ );
2483
+ }
2484
+ const address2 = getAddress3(who);
2485
+ const [request, isGuardian, hasApproved, enabled, isOwner] = await Promise.all([
2486
+ this.get(hash),
2487
+ this.isGuardian(address2),
2488
+ this.contract().recoveryApprovals(this.vault, hash, address2),
2489
+ this.isEnabled(),
2490
+ this.ctx.connection.vault(this.vault).getFunction("isOwner(address)")(address2)
2491
+ ]);
2492
+ const now = nowSeconds();
2493
+ const terminal = request.status === "executed" || request.status === "cancelled";
2494
+ const expired = now > request.expiration && request.expiration > 0;
2495
+ const quorum = request.approvalCount >= request.requiredThreshold;
2496
+ const out = [];
2497
+ const blocked = (action, reason, blockedBy, availableAt) => ({
2498
+ action,
2499
+ allowed: false,
2500
+ reason,
2501
+ blockedBy,
2502
+ ...availableAt !== void 0 ? { availableAt } : {}
2503
+ });
2504
+ if (terminal) out.push(blocked("approve", terminalReason(request), "terminal_state"));
2505
+ else if (expired) out.push(blocked("approve", "The recovery has expired.", "expired"));
2506
+ else if (!enabled) {
2507
+ out.push(blocked("approve", "The recovery module is not enabled on this vault.", "module_disabled"));
2508
+ } else if (!isGuardian) {
2509
+ out.push(blocked("approve", "Only guardians can approve a recovery.", "not_guardian"));
2510
+ } else if (hasApproved) {
2511
+ out.push(blocked("approve", "You have already approved this recovery.", "already_approved"));
2512
+ } else {
2513
+ out.push({ action: "approve", allowed: true, reason: "You can approve this recovery." });
2514
+ }
2515
+ if (terminal) out.push(blocked("revokeApproval", terminalReason(request), "terminal_state"));
2516
+ else if (!isGuardian) {
2517
+ out.push(blocked("revokeApproval", "Only guardians can revoke a recovery approval.", "not_guardian"));
2518
+ } else if (!hasApproved) {
2519
+ out.push(blocked("revokeApproval", "You have no active approval to revoke.", "not_approved"));
2520
+ } else {
2521
+ out.push({ action: "revokeApproval", allowed: true, reason: "You can revoke your approval." });
2522
+ }
2523
+ if (terminal) out.push(blocked("execute", terminalReason(request), "terminal_state"));
2524
+ else if (expired) out.push(blocked("execute", "The recovery has expired.", "expired"));
2525
+ else if (!enabled) {
2526
+ out.push(blocked("execute", "The recovery module is not enabled on this vault.", "module_disabled"));
2527
+ } else if (!quorum) {
2528
+ out.push(
2529
+ blocked(
2530
+ "execute",
2531
+ `Needs ${request.requiredThreshold} guardian approvals, has ${request.approvalCount}.`,
2532
+ "threshold"
2533
+ )
2534
+ );
2535
+ } else if (now < request.executionTime) {
2536
+ out.push(
2537
+ blocked(
2538
+ "execute",
2539
+ `Recovery period ends ${new Date(request.executionTime * 1e3).toISOString()}.`,
2540
+ "timelock",
2541
+ request.executionTime
2542
+ )
2543
+ );
2544
+ } else {
2545
+ out.push({
2546
+ action: "execute",
2547
+ allowed: true,
2548
+ reason: "This recovery is ready to execute and will replace the vault owner set."
2549
+ });
2550
+ }
2551
+ if (terminal) out.push(blocked("cancel", terminalReason(request), "terminal_state"));
2552
+ else if (!isOwner) {
2553
+ out.push(blocked("cancel", "Only a current vault owner can cancel a recovery.", "not_owner"));
2554
+ } else {
2555
+ out.push({
2556
+ action: "cancel",
2557
+ allowed: true,
2558
+ reason: "As a vault owner you can cancel this recovery."
2559
+ });
2560
+ }
2561
+ if (terminal) out.push(blocked("expire", terminalReason(request), "terminal_state"));
2562
+ else if (!expired) {
2563
+ out.push(
2564
+ blocked(
2565
+ "expire",
2566
+ `Not expired yet. Expires ${new Date(request.expiration * 1e3).toISOString()}.`,
2567
+ "not_expired",
2568
+ request.expiration + 1
2569
+ )
2570
+ );
2571
+ } else {
2572
+ out.push({
2573
+ action: "expire",
2574
+ allowed: true,
2575
+ reason: "Past its deadline \u2014 anyone can clean it up, which also unblocks setupRecovery."
2576
+ });
2577
+ }
2578
+ return out;
2579
+ }
2580
+ // =========================================================================
2581
+ // Internals
2582
+ // =========================================================================
2583
+ buildFromChain(hash, raw) {
2584
+ const executionTime = Number(raw.executionTime ?? 0);
2585
+ const expiration = Number(raw.expiration ?? 0);
2586
+ const approvalCount = Number(raw.approvalCount ?? 0);
2587
+ const requiredThreshold = Number(raw.requiredThreshold ?? 0);
2588
+ const executed = Boolean(raw.executed);
2589
+ const status = deriveRecoveryStatus({
2590
+ executed,
2591
+ approvalCount,
2592
+ requiredThreshold,
2593
+ executionTime,
2594
+ expiration
2595
+ });
2596
+ return {
2597
+ hash,
2598
+ vault: this.vault,
2599
+ newOwners: Array.from(raw.newOwners ?? []).map((o) => getAddress3(String(o))),
2600
+ newThreshold: Number(raw.newThreshold ?? 0),
2601
+ approvalCount,
2602
+ requiredThreshold,
2603
+ executionTime,
2604
+ expiration,
2605
+ status,
2606
+ executed,
2607
+ source: "chain"
2608
+ };
2609
+ }
2610
+ validateNewOwners(newOwners, newThreshold) {
2611
+ if (newOwners.length === 0) throw new ValidationError("At least one new owner is required.");
2612
+ const seen = /* @__PURE__ */ new Set();
2613
+ for (const owner of newOwners) {
2614
+ const key = owner.toLowerCase();
2615
+ if (key === ZERO || key === SENTINEL) {
2616
+ throw new ValidationError(
2617
+ `New owner ${owner} is reserved \u2014 the zero address and the module sentinel 0x\u202601 are rejected.`
2618
+ );
2619
+ }
2620
+ if (key === this.vault.toLowerCase()) {
2621
+ throw new ValidationError("The vault cannot be an owner of itself.");
2622
+ }
2623
+ if (seen.has(key)) throw new ValidationError(`Duplicate new owner: ${owner}.`);
2624
+ seen.add(key);
2625
+ }
2626
+ if (!Number.isInteger(newThreshold) || newThreshold < 1 || newThreshold > newOwners.length) {
2627
+ throw new ValidationError(
2628
+ `Invalid new threshold ${newThreshold}: must be between 1 and ${newOwners.length}.`
2629
+ );
2630
+ }
2631
+ }
2632
+ assertLive(request) {
2633
+ if (request.executed) {
2634
+ throw new PreconditionError("This recovery has already been executed.");
2635
+ }
2636
+ if (request.expiration > 0 && nowSeconds() > request.expiration) {
2637
+ throw new PreconditionError("This recovery has expired.", {
2638
+ remediation: "Call expire() to clean it up, then initiate a new recovery."
2639
+ });
2640
+ }
2641
+ }
2642
+ async assertEnabled(operation) {
2643
+ if (!await this.isEnabled()) {
2644
+ throw new PreconditionError(
2645
+ `${operation} requires the SocialRecoveryModule to be enabled on this vault.`,
2646
+ {
2647
+ remediation: "Owners must propose and execute enableModule for the recovery module first."
2648
+ }
2649
+ );
2650
+ }
2651
+ }
2652
+ async send(call, context) {
2653
+ try {
2654
+ const sent = await call(this.contract(true));
2655
+ const receipt = await sent.wait();
2656
+ if (!receipt || receipt.status === 0) throw new RevertError(`${context}: reverted.`);
2657
+ return { chainTxHash: receipt.hash ?? receipt.transactionHash ?? "" };
2658
+ } catch (err) {
2659
+ throw this.toRevert(err, context);
2660
+ }
2661
+ }
2662
+ extractRecoveryHash(receipt) {
2663
+ const iface = this.contract().interface;
2664
+ const target = this.address.toLowerCase();
2665
+ for (const log of receipt.logs ?? []) {
2666
+ if (log.address && log.address.toLowerCase() !== target) continue;
2667
+ try {
2668
+ const parsed = iface.parseLog({ topics: Array.from(log.topics), data: log.data });
2669
+ if (parsed?.name === "RecoveryInitiated") {
2670
+ const index = parsed.fragment.inputs.findIndex((i) => i.name === "recoveryHash");
2671
+ const value = index >= 0 ? parsed.args[index] : void 0;
2672
+ if (typeof value === "string") return value;
2673
+ }
2674
+ } catch {
2675
+ continue;
2676
+ }
2677
+ }
2678
+ return null;
2679
+ }
2680
+ toRevert(err, context) {
2681
+ if (err instanceof RevertError || err instanceof PreconditionError) return err;
2682
+ const decoded = decodeRevertFromError(err);
2683
+ const base = err instanceof Error ? err.message : String(err);
2684
+ return new RevertError(`${context}${decoded ? `: ${decoded.message}` : `: ${base}`}`, decoded, {
2685
+ cause: err
2686
+ });
2687
+ }
2688
+ };
2689
+ function terminalReason(request) {
2690
+ if (request.executed) return "This recovery has already been executed.";
2691
+ if (request.status === "cancelled") return "This recovery was cancelled.";
2692
+ return "This recovery is in a terminal state.";
2693
+ }
2694
+
2695
+ // src/balances.ts
2696
+ import { Contract as Contract2, getAddress as getAddress4 } from "quais";
2697
+ async function loadBalances(connection, queries, vault2, options = {}) {
2698
+ const verify = options.verify !== false;
2699
+ const maxTokens = options.maxTokens ?? 50;
2700
+ const transferScanLimit = options.transferScanLimit ?? 500;
2701
+ const maxTokenIdChecks = options.maxTokenIdChecks ?? 100;
2702
+ const address2 = getAddress4(vault2);
2703
+ const native = BigInt(await connection.provider.getBalance(address2));
2704
+ if (!queries) return { native, tokens: [] };
2705
+ const transfers = await queries.tokenTransfers(address2, { limit: transferScanLimit });
2706
+ const seen = /* @__PURE__ */ new Map();
2707
+ for (const row of transfers.data) {
2708
+ const key = row.token_address.toLowerCase();
2709
+ const list = seen.get(key);
2710
+ if (list) list.push(row);
2711
+ else seen.set(key, [row]);
2712
+ }
2713
+ const allCandidates = Array.from(seen.keys());
2714
+ const candidates = allCandidates.slice(0, maxTokens);
2715
+ const truncated = {
2716
+ ...transfers.hasMore ? { transfers: true } : {},
2717
+ ...allCandidates.length > maxTokens ? { tokens: true } : {}
2718
+ };
2719
+ if (candidates.length === 0) {
2720
+ return { native, tokens: [], ...Object.keys(truncated).length ? { truncated } : {} };
2721
+ }
2722
+ const metadata = await queries.tokens(candidates);
2723
+ 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
+ )
2735
+ )
2736
+ );
2737
+ return {
2738
+ native,
2739
+ // Drop dust and fully-exited positions so callers get a holdings view, not a history.
2740
+ tokens: balances.filter((b) => b !== null && b.balance > 0n),
2741
+ ...Object.keys(truncated).length ? { truncated } : {}
2742
+ };
2743
+ }
2744
+ async function buildBalance(connection, vault2, token2, meta, transfers, verify, maxTokenIdChecks) {
2745
+ const standard = meta?.standard ?? inferStandard(transfers);
2746
+ const replayed = replayBalance(transfers, standard);
2747
+ const base = {
2748
+ token: getAddress4(token2),
2749
+ standard,
2750
+ symbol: meta?.symbol ?? "???",
2751
+ name: meta?.name ?? "Unknown token",
2752
+ decimals: meta?.decimals ?? (standard === "ERC20" ? 18 : 0),
2753
+ balance: replayed.balance,
2754
+ ...replayed.tokenIds.length > 0 ? { tokenIds: replayed.tokenIds } : {},
2755
+ verified: false
2756
+ };
2757
+ if (!verify) return base;
2758
+ try {
2759
+ 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 };
2763
+ }
2764
+ 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);
2768
+ return {
2769
+ ...base,
2770
+ balance: BigInt(count),
2771
+ ...owned.length > 0 ? { tokenIds: owned } : {},
2772
+ ...replayed.tokenIds.length > maxTokenIdChecks ? { tokenIdsTruncated: true } : {},
2773
+ verified: true
2774
+ };
2775
+ }
2776
+ const contract = new Contract2(token2, Erc1155Abi, connection.provider);
2777
+ const ids = replayed.tokenIds;
2778
+ if (ids.length === 0) return { ...base, verified: true };
2779
+ const amounts = await contract.getFunction("balanceOfBatch(address[],uint256[])")(
2780
+ ids.map(() => vault2),
2781
+ ids
2782
+ );
2783
+ const held = [];
2784
+ let total = 0n;
2785
+ ids.forEach((id, i) => {
2786
+ const amount = BigInt(amounts[i] ?? 0);
2787
+ if (amount > 0n) {
2788
+ held.push(id);
2789
+ total += amount;
2790
+ }
2791
+ });
2792
+ return { ...base, balance: total, ...held.length > 0 ? { tokenIds: held } : {}, verified: true };
2793
+ } catch {
2794
+ return base;
2795
+ }
2796
+ }
2797
+ function replayBalance(transfers, standard) {
2798
+ if (standard === "ERC20") {
2799
+ let balance2 = 0n;
2800
+ for (const t of transfers) {
2801
+ const value = safeBigInt(t.value);
2802
+ balance2 += t.direction === "inflow" ? value : -value;
2803
+ }
2804
+ return { balance: balance2 > 0n ? balance2 : 0n, tokenIds: [] };
2805
+ }
2806
+ const perId = /* @__PURE__ */ new Map();
2807
+ const ordered = [...transfers].sort((a, b) => Number(a.block_number) - Number(b.block_number));
2808
+ for (const t of ordered) {
2809
+ const id = t.token_id ?? "0";
2810
+ const amount = standard === "ERC721" ? 1n : safeBigInt(t.value);
2811
+ const current = perId.get(id) ?? 0n;
2812
+ perId.set(id, current + (t.direction === "inflow" ? amount : -amount));
2813
+ }
2814
+ const tokenIds = [];
2815
+ let balance = 0n;
2816
+ for (const [id, amount] of perId) {
2817
+ if (amount > 0n) {
2818
+ tokenIds.push(id);
2819
+ balance += amount;
2820
+ }
2821
+ }
2822
+ return { balance, tokenIds };
2823
+ }
2824
+ async function filterOwned(contract, vault2, ids, limit) {
2825
+ 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
+ );
2836
+ return results.filter((id) => id !== null);
2837
+ }
2838
+ function inferStandard(transfers) {
2839
+ const withId = transfers.find((t) => t.token_id != null);
2840
+ if (!withId) return "ERC20";
2841
+ return transfers.some((t) => (t.batch_index ?? 0) > 0) ? "ERC1155" : "ERC721";
2842
+ }
2843
+ function safeBigInt(value) {
2844
+ try {
2845
+ return BigInt(value);
2846
+ } catch {
2847
+ return 0n;
2848
+ }
2849
+ }
2850
+
2851
+ // src/indexer/watch.ts
2852
+ import { isAddress as isAddress3 } from "quais";
2853
+ var TABLE_FOR = {
2854
+ transactions: "transactions",
2855
+ confirmations: "confirmations",
2856
+ owners: "wallet_owners",
2857
+ modules: "wallet_modules",
2858
+ deposits: "deposits",
2859
+ tokenTransfers: "token_transfers",
2860
+ recoveries: "social_recoveries",
2861
+ signedMessages: "signed_messages"
2862
+ };
2863
+ var DEFAULT_TOPICS = ["transactions", "confirmations", "owners"];
2864
+ function watchVault(client, vault2, handler, options = {}) {
2865
+ if (!isAddress3(vault2)) {
2866
+ throw new ValidationError(`Invalid vault address for subscription: "${vault2}".`);
2867
+ }
2868
+ const topics = options.topics ?? DEFAULT_TOPICS;
2869
+ const address2 = vault2.toLowerCase();
2870
+ const schema = client.config.schema;
2871
+ const channel = client.raw.channel(`quaivault:${schema}:${address2}`);
2872
+ for (const topic of topics) {
2873
+ const table = TABLE_FOR[topic];
2874
+ channel.on(
2875
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2876
+ "postgres_changes",
2877
+ {
2878
+ event: "*",
2879
+ schema,
2880
+ table,
2881
+ // Every watched table keys the vault on wallet_address.
2882
+ filter: `wallet_address=eq.${address2}`
2883
+ },
2884
+ (payload) => {
2885
+ handler({
2886
+ topic,
2887
+ table,
2888
+ type: payload.eventType,
2889
+ row: payload.new && Object.keys(payload.new).length > 0 ? payload.new : null,
2890
+ previous: payload.old && Object.keys(payload.old).length > 0 ? payload.old : null
2891
+ });
2892
+ }
2893
+ );
2894
+ }
2895
+ channel.subscribe((status, err) => {
2896
+ options.onStatus?.(status, err instanceof Error ? err : void 0);
2897
+ });
2898
+ return {
2899
+ topics,
2900
+ async unsubscribe() {
2901
+ await client.raw.removeChannel(channel);
2902
+ }
2903
+ };
2904
+ }
2905
+
2906
+ // src/decode/index.ts
2907
+ import { formatQuai, getBytes as getBytes2 } from "quais";
2908
+ var SELF_CALL_ADMIN = /* @__PURE__ */ new Set([
2909
+ "addOwner",
2910
+ "removeOwner",
2911
+ "changeThreshold",
2912
+ "enableModule",
2913
+ "disableModule",
2914
+ "cancelByConsensus",
2915
+ "setMinExecutionDelay",
2916
+ "addDelegatecallTarget",
2917
+ "removeDelegatecallTarget"
2918
+ ]);
2919
+ var MESSAGE_SIGNING = /* @__PURE__ */ new Set(["signMessage", "unsignMessage"]);
2920
+ function short(address2) {
2921
+ return address2.length > 12 ? `${address2.slice(0, 6)}\u2026${address2.slice(-4)}` : address2;
2922
+ }
2923
+ function formatDuration(seconds) {
2924
+ if (seconds === 0) return "none";
2925
+ const units = [
2926
+ [86400, "day"],
2927
+ [3600, "hour"],
2928
+ [60, "minute"]
2929
+ ];
2930
+ for (const [size, label] of units) {
2931
+ if (seconds >= size) {
2932
+ const n = Math.floor(seconds / size);
2933
+ return `${n} ${label}${n === 1 ? "" : "s"}`;
2934
+ }
2935
+ }
2936
+ return `${seconds} seconds`;
2937
+ }
2938
+ function namedArgs(fragmentInputs, args) {
2939
+ const out = {};
2940
+ fragmentInputs.forEach((input, i) => {
2941
+ out[input.name || `arg${i}`] = args[i];
2942
+ });
2943
+ return out;
2944
+ }
2945
+ function decodeCall(ctx) {
2946
+ const { vault: vault2, to, value, data } = ctx;
2947
+ const isSelfCall = to.toLowerCase() === vault2.toLowerCase();
2948
+ const hasData = typeof data === "string" && data !== "0x" && data.length > 2;
2949
+ if (!hasData) {
2950
+ return {
2951
+ kind: "transfer",
2952
+ summary: `Transfer ${formatQuai(value)} QUAI to ${short(to)}`
2953
+ };
2954
+ }
2955
+ if (isSelfCall) {
2956
+ const parsed = tryParse(interfaces.vault, data);
2957
+ if (parsed) {
2958
+ const decoded = {
2959
+ name: parsed.name,
2960
+ signature: parsed.signature,
2961
+ args: parsed.args,
2962
+ target: "vault"
2963
+ };
2964
+ if (MESSAGE_SIGNING.has(parsed.name)) {
2965
+ return {
2966
+ kind: "message_signing",
2967
+ decoded,
2968
+ summary: parsed.name === "signMessage" ? "Sign a message on behalf of the vault (EIP-1271)" : "Revoke a previously signed message (EIP-1271)"
2969
+ };
2970
+ }
2971
+ if (SELF_CALL_ADMIN.has(parsed.name)) {
2972
+ return { kind: "wallet_admin", decoded, summary: describeAdmin(parsed.name, parsed.args) };
2973
+ }
2974
+ return { kind: "wallet_admin", decoded, summary: `Vault self-call: ${parsed.name}` };
2975
+ }
2976
+ }
2977
+ if (ctx.socialRecovery && to.toLowerCase() === ctx.socialRecovery.toLowerCase()) {
2978
+ const parsed = tryParse(interfaces.recovery, data);
2979
+ if (parsed) {
2980
+ const decoded = { ...parsed, target: "socialRecovery" };
2981
+ if (parsed.name === "setupRecovery") {
2982
+ const guardians = parsed.args.guardians;
2983
+ const threshold = parsed.args.threshold;
2984
+ return {
2985
+ kind: "recovery_setup",
2986
+ decoded,
2987
+ summary: `Configure social recovery: ${guardians?.length ?? "?"} guardians, ${String(
2988
+ threshold ?? "?"
2989
+ )} required`
2990
+ };
2991
+ }
2992
+ return { kind: "module_config", decoded, summary: `Social recovery: ${parsed.name}` };
2993
+ }
2994
+ }
2995
+ if (ctx.multiSendCallOnly && to.toLowerCase() === ctx.multiSendCallOnly.toLowerCase()) {
2996
+ const parsed = tryParse(interfaces.multiSend, data);
2997
+ if (parsed?.name === "multiSend") {
2998
+ const inner = decodeMultiSendPayload(parsed.args.transactions);
2999
+ return {
3000
+ kind: "batched_call",
3001
+ decoded: { ...parsed, target: "multiSend" },
3002
+ summary: `Batched call: ${inner.length} sub-transaction${inner.length === 1 ? "" : "s"}`
3003
+ };
3004
+ }
3005
+ }
3006
+ const asVault = tryParse(interfaces.vault, data);
3007
+ if (asVault?.name.startsWith("execTransactionFromModule")) {
3008
+ return {
3009
+ kind: "module_execution",
3010
+ decoded: { ...asVault, target: "vault" },
3011
+ summary: `Module execution against ${short(String(asVault.args.to ?? to))}`
3012
+ };
3013
+ }
3014
+ const erc202 = tryParse(interfaces.erc20, data);
3015
+ if (erc202 && ["transfer", "approve", "transferFrom"].includes(erc202.name)) {
3016
+ return {
3017
+ kind: "erc20_transfer",
3018
+ decoded: { ...erc202, target: "erc20" },
3019
+ summary: describeErc20(erc202.name, erc202.args, to)
3020
+ };
3021
+ }
3022
+ const erc11552 = tryParse(interfaces.erc1155, data);
3023
+ if (erc11552 && erc11552.name.startsWith("safe")) {
3024
+ return {
3025
+ kind: "erc1155_transfer",
3026
+ decoded: { ...erc11552, target: "erc1155" },
3027
+ summary: erc11552.name === "safeBatchTransferFrom" ? `Transfer a batch of ERC1155 tokens from ${short(to)}` : `Transfer ERC1155 token #${String(erc11552.args.id ?? "?")} from ${short(to)}`
3028
+ };
3029
+ }
3030
+ const erc7212 = tryParse(interfaces.erc721, data);
3031
+ if (erc7212 && erc7212.name === "safeTransferFrom") {
3032
+ return {
3033
+ kind: "erc721_transfer",
3034
+ decoded: { ...erc7212, target: "erc721" },
3035
+ summary: `Transfer NFT #${String(erc7212.args.tokenId ?? "?")} to ${short(
3036
+ String(erc7212.args.to ?? "?")
3037
+ )}`
3038
+ };
3039
+ }
3040
+ const selector = data.slice(0, 10);
3041
+ return {
3042
+ kind: "external_call",
3043
+ summary: `Call ${short(to)} (selector ${selector})` + (value > 0n ? ` with ${formatQuai(value)} QUAI` : "")
3044
+ };
3045
+ }
3046
+ function tryParse(iface, data) {
3047
+ try {
3048
+ const parsed = iface.parseTransaction({ data });
3049
+ if (!parsed) return null;
3050
+ return {
3051
+ name: parsed.name,
3052
+ signature: parsed.fragment.format("sighash"),
3053
+ args: namedArgs(parsed.fragment.inputs, parsed.args)
3054
+ };
3055
+ } catch {
3056
+ return null;
3057
+ }
3058
+ }
3059
+ function describeAdmin(name, args) {
3060
+ switch (name) {
3061
+ case "addOwner":
3062
+ return `Add owner ${short(String(args.owner))}`;
3063
+ case "removeOwner":
3064
+ return `Remove owner ${short(String(args.owner))}`;
3065
+ case "changeThreshold":
3066
+ return `Change approval threshold to ${String(args._threshold ?? args.threshold)}`;
3067
+ case "enableModule":
3068
+ return `Enable module ${short(String(args.module))}`;
3069
+ case "disableModule":
3070
+ return `Disable module ${short(String(args.module))}`;
3071
+ case "setMinExecutionDelay":
3072
+ return `Set minimum execution delay to ${formatDuration(Number(args.delay ?? 0))}`;
3073
+ case "addDelegatecallTarget":
3074
+ return `Whitelist ${short(String(args.target))} as a DelegateCall target`;
3075
+ case "removeDelegatecallTarget":
3076
+ return `Remove ${short(String(args.target))} from the DelegateCall whitelist`;
3077
+ case "cancelByConsensus":
3078
+ return `Cancel transaction ${short(String(args.txHash))} by consensus`;
3079
+ default:
3080
+ return `Vault admin: ${name}`;
3081
+ }
3082
+ }
3083
+ function describeErc20(name, args, tokenAddress) {
3084
+ switch (name) {
3085
+ case "transfer":
3086
+ return `Transfer ${String(args.amount)} units of token ${short(tokenAddress)} to ${short(
3087
+ String(args.to)
3088
+ )}`;
3089
+ case "approve":
3090
+ return `Approve ${short(String(args.spender))} to spend ${String(args.amount)} units of ${short(
3091
+ tokenAddress
3092
+ )}`;
3093
+ default:
3094
+ return `ERC20 ${name} on ${short(tokenAddress)}`;
3095
+ }
3096
+ }
3097
+ function decodeMultiSendPayload(payload) {
3098
+ const bytes = getBytes2(payload);
3099
+ const out = [];
3100
+ let offset = 0;
3101
+ while (offset < bytes.length) {
3102
+ if (offset + 85 > bytes.length) break;
3103
+ const operation = bytes[offset];
3104
+ offset += 1;
3105
+ const to = hexOf(bytes.subarray(offset, offset + 20));
3106
+ offset += 20;
3107
+ const value = BigInt(hexOf(bytes.subarray(offset, offset + 32)));
3108
+ offset += 32;
3109
+ const length = Number(BigInt(hexOf(bytes.subarray(offset, offset + 32))));
3110
+ offset += 32;
3111
+ if (offset + length > bytes.length) break;
3112
+ const data = hexOf(bytes.subarray(offset, offset + length));
3113
+ offset += length;
3114
+ out.push({ operation, to, value, data });
3115
+ }
3116
+ return out;
3117
+ }
3118
+ function hexOf(bytes) {
3119
+ let s = "0x";
3120
+ for (const b of bytes) s += b.toString(16).padStart(2, "0");
3121
+ return s;
3122
+ }
3123
+
3124
+ // src/lifecycle/affordances.ts
3125
+ function terminalReason2(tx) {
3126
+ switch (tx.status) {
3127
+ case "executed":
3128
+ return "The transaction has already been executed.";
3129
+ case "failed":
3130
+ return "The transaction executed but its target call reverted. This state is terminal.";
3131
+ case "cancelled":
3132
+ return "The transaction has been cancelled.";
3133
+ case "expired":
3134
+ return "The transaction passed its expiration timestamp.";
3135
+ default:
3136
+ return "The transaction is in a terminal state.";
3137
+ }
3138
+ }
3139
+ function computeAffordances(ctx) {
3140
+ const { tx, isOwner, hasApproved } = ctx;
3141
+ const at = ctx.at ?? nowSeconds();
3142
+ const terminal = isTerminal(tx.status);
3143
+ const quorum = tx.approvalCount >= tx.threshold;
3144
+ const isSelfCall = tx.to.toLowerCase() === tx.vault.toLowerCase();
3145
+ const executableAfter = executableAfterOf(tx.approvedAt, tx.executionDelay);
3146
+ const timelockActive = !isSelfCall && tx.executionDelay > 0 && (tx.approvedAt === 0 || at < executableAfter);
3147
+ const isProposer = tx.proposer.toLowerCase() === ctx.caller.toLowerCase();
3148
+ const out = [];
3149
+ const notOwner = (action) => ({
3150
+ action,
3151
+ allowed: false,
3152
+ reason: "Only owners of this vault may take this action.",
3153
+ blockedBy: "not_owner"
3154
+ });
3155
+ const isTerminalBlocked = (action) => ({
3156
+ action,
3157
+ allowed: false,
3158
+ reason: terminalReason2(tx),
3159
+ blockedBy: "terminal_state"
3160
+ });
3161
+ if (terminal) out.push(isTerminalBlocked("approve"));
3162
+ else if (!isOwner) out.push(notOwner("approve"));
3163
+ else if (hasApproved) {
3164
+ out.push({
3165
+ action: "approve",
3166
+ allowed: false,
3167
+ reason: "You have already approved this transaction.",
3168
+ blockedBy: "already_approved"
3169
+ });
3170
+ } else {
3171
+ out.push({ action: "approve", allowed: true, reason: "You can approve this transaction." });
3172
+ }
3173
+ if (terminal) out.push(isTerminalBlocked("revokeApproval"));
3174
+ else if (!isOwner) out.push(notOwner("revokeApproval"));
3175
+ else if (!hasApproved) {
3176
+ out.push({
3177
+ action: "revokeApproval",
3178
+ allowed: false,
3179
+ reason: "You have no active approval on this transaction to revoke.",
3180
+ blockedBy: "not_approved"
3181
+ });
3182
+ } else {
3183
+ out.push({
3184
+ action: "revokeApproval",
3185
+ allowed: true,
3186
+ reason: tx.approvedAt > 0 ? "You can revoke your approval. Note that quorum was already reached, so the timelock clock keeps running and the proposer still cannot cancel." : "You can revoke your approval."
3187
+ });
3188
+ }
3189
+ if (terminal) out.push(isTerminalBlocked("execute"));
3190
+ else if (!isOwner) out.push(notOwner("execute"));
3191
+ else if (!quorum) {
3192
+ out.push({
3193
+ action: "execute",
3194
+ allowed: false,
3195
+ reason: `Needs ${tx.threshold} approvals, has ${tx.approvalCount}.`,
3196
+ blockedBy: "threshold"
3197
+ });
3198
+ } else if (timelockActive) {
3199
+ out.push({
3200
+ action: "execute",
3201
+ allowed: false,
3202
+ reason: tx.approvedAt === 0 ? `Quorum is met but the timelock clock has not started. The next execute call will start it and return without executing; you must call execute again ${tx.executionDelay}s later.` : `Timelocked until ${new Date(executableAfter * 1e3).toISOString()}.`,
3203
+ blockedBy: "timelock",
3204
+ ...tx.approvedAt > 0 ? { availableAt: executableAfter } : {}
3205
+ });
3206
+ } else {
3207
+ out.push({ action: "execute", allowed: true, reason: "This transaction is ready to execute." });
3208
+ }
3209
+ if (terminal) out.push(isTerminalBlocked("approveAndExecute"));
3210
+ else if (!isOwner) out.push(notOwner("approveAndExecute"));
3211
+ else if (hasApproved) {
3212
+ out.push({
3213
+ action: "approveAndExecute",
3214
+ allowed: false,
3215
+ reason: "You have already approved. Use execute instead.",
3216
+ blockedBy: "already_approved"
3217
+ });
3218
+ } else {
3219
+ const wouldReachQuorum = tx.approvalCount + 1 >= tx.threshold;
3220
+ out.push({
3221
+ action: "approveAndExecute",
3222
+ allowed: true,
3223
+ reason: !wouldReachQuorum ? `Your approval will be recorded, but execution needs ${tx.threshold} approvals (would be ${tx.approvalCount + 1}). The call returns without executing.` : timelockActive ? "Your approval will be recorded and will start the timelock, but the call returns without executing. Execute again after the delay elapses." : "Your approval meets the threshold and the transaction will execute in the same call."
3224
+ });
3225
+ }
3226
+ if (terminal) out.push(isTerminalBlocked("cancel"));
3227
+ else if (!isOwner) out.push(notOwner("cancel"));
3228
+ else if (!isProposer) {
3229
+ out.push({
3230
+ action: "cancel",
3231
+ allowed: false,
3232
+ reason: "Only the proposer can cancel directly. Others need a cancelByConsensus proposal.",
3233
+ blockedBy: "not_proposer"
3234
+ });
3235
+ } else if (tx.approvedAt !== 0) {
3236
+ out.push({
3237
+ action: "cancel",
3238
+ allowed: false,
3239
+ reason: "This transaction reached quorum, which permanently blocks proposer-cancel \u2014 even though approvals may since have been revoked. Use a cancelByConsensus proposal instead.",
3240
+ blockedBy: "quorum_locked"
3241
+ });
3242
+ } else {
3243
+ out.push({ action: "cancel", allowed: true, reason: "You proposed this and can cancel it." });
3244
+ }
3245
+ if (tx.status === "executed" || tx.status === "failed" || tx.status === "cancelled") {
3246
+ out.push(isTerminalBlocked("expire"));
3247
+ } else if (tx.expiration === 0) {
3248
+ out.push({
3249
+ action: "expire",
3250
+ allowed: false,
3251
+ reason: "This transaction has no expiration and can never be expired.",
3252
+ blockedBy: "no_expiration"
3253
+ });
3254
+ } else if (at <= tx.expiration) {
3255
+ out.push({
3256
+ action: "expire",
3257
+ allowed: false,
3258
+ reason: `Not expired yet. Expires at ${new Date(tx.expiration * 1e3).toISOString()}.`,
3259
+ blockedBy: "expired",
3260
+ availableAt: tx.expiration + 1
3261
+ });
3262
+ } else {
3263
+ out.push({
3264
+ action: "expire",
3265
+ allowed: true,
3266
+ reason: "Past its expiration \u2014 anyone may close it out and reclaim approval storage."
3267
+ });
3268
+ }
3269
+ if (terminal) out.push(isTerminalBlocked("proposeCancelByConsensus"));
3270
+ else if (!isOwner) out.push(notOwner("proposeCancelByConsensus"));
3271
+ else {
3272
+ out.push({
3273
+ action: "proposeCancelByConsensus",
3274
+ allowed: true,
3275
+ reason: "You can propose a cancelByConsensus self-call. It needs the same threshold as executing the original transaction, but self-calls bypass the timelock, so it always resolves faster."
3276
+ });
3277
+ }
3278
+ return out;
3279
+ }
3280
+ function allowedActions(affordances) {
3281
+ return affordances.filter((a) => a.allowed);
3282
+ }
3283
+
3284
+ // src/lifecycle/outcome.ts
3285
+ import { Interface as Interface5 } from "quais";
3286
+ var vaultInterface2 = new Interface5(QuaiVaultAbi);
3287
+ function parseVaultEvents(receipt, vault2) {
3288
+ const out = [];
3289
+ const target = vault2.toLowerCase();
3290
+ for (const log of receipt.logs ?? []) {
3291
+ if (log.address && log.address.toLowerCase() !== target) continue;
3292
+ try {
3293
+ const parsed = vaultInterface2.parseLog({
3294
+ topics: Array.from(log.topics),
3295
+ data: log.data
3296
+ });
3297
+ if (!parsed) continue;
3298
+ const args = {};
3299
+ parsed.fragment.inputs.forEach((input, i) => {
3300
+ if (input.name) args[input.name] = parsed.args[i];
3301
+ });
3302
+ out.push({ name: parsed.name, args });
3303
+ } catch {
3304
+ }
3305
+ }
3306
+ return out;
3307
+ }
3308
+ function eventFor(events, name, txHash) {
3309
+ const wanted = txHash.toLowerCase();
3310
+ return events.find(
3311
+ (e) => e.name === name && String(e.args.txHash ?? "").toLowerCase() === wanted
3312
+ );
3313
+ }
3314
+ function toNumber2(value) {
3315
+ if (typeof value === "bigint") return Number(value);
3316
+ if (typeof value === "number") return value;
3317
+ return Number(value ?? 0);
3318
+ }
3319
+ function classifyExecution(receipt, vault2, txHash) {
3320
+ const chainTxHash = receipt.hash ?? receipt.transactionHash ?? "";
3321
+ const base = {
3322
+ txHash,
3323
+ chainTxHash,
3324
+ blockNumber: toNumber2(receipt.blockNumber),
3325
+ gasUsed: BigInt(receipt.gasUsed ?? 0)
3326
+ };
3327
+ const events = parseVaultEvents(receipt, vault2);
3328
+ const executed = eventFor(events, "TransactionExecuted", txHash);
3329
+ if (executed) {
3330
+ return {
3331
+ ...base,
3332
+ outcome: "executed",
3333
+ message: "Transaction executed successfully."
3334
+ };
3335
+ }
3336
+ const failed = eventFor(events, "TransactionFailed", txHash);
3337
+ if (failed) {
3338
+ const returnData = String(failed.args.returnData ?? "0x");
3339
+ const decodedRevert = decodeRevert(returnData);
3340
+ return {
3341
+ ...base,
3342
+ outcome: "failed",
3343
+ returnData,
3344
+ decodedRevert,
3345
+ message: `The vault executed the transaction but the target call reverted` + (decodedRevert ? `: ${decodedRevert.message}` : ".") + " This state is terminal \u2014 the transaction is permanently marked executed and cannot be retried. Propose a new transaction to try again."
3346
+ };
3347
+ }
3348
+ const thresholdReached = eventFor(events, "ThresholdReached", txHash);
3349
+ if (thresholdReached) {
3350
+ const executableAfter = toNumber2(thresholdReached.args.executableAfter);
3351
+ if (executableAfter > Math.floor(Date.now() / 1e3)) {
3352
+ return {
3353
+ ...base,
3354
+ outcome: "timelock_started",
3355
+ executableAfter,
3356
+ message: `Quorum was reached and the execution delay started. Nothing has executed yet. Call execute again after ${new Date(executableAfter * 1e3).toISOString()}.`
3357
+ };
3358
+ }
3359
+ return {
3360
+ ...base,
3361
+ outcome: "approved_only",
3362
+ executableAfter,
3363
+ message: "Quorum was reached but the transaction did not execute in this call. Call execute again."
3364
+ };
3365
+ }
3366
+ const approved = eventFor(events, "TransactionApproved", txHash);
3367
+ return {
3368
+ ...base,
3369
+ outcome: "approved_only",
3370
+ message: approved ? "Your approval was recorded, but the transaction did not execute \u2014 the threshold is not yet met." : "The transaction did not execute. It may be timelocked, or already approved by this owner without the threshold being met."
3371
+ };
3372
+ }
3373
+ function extractProposedTxHash(receipt, vault2) {
3374
+ const events = parseVaultEvents(receipt, vault2);
3375
+ const proposed = events.find((e) => e.name === "TransactionProposed");
3376
+ const hash = proposed?.args.txHash;
3377
+ return typeof hash === "string" ? hash : null;
3378
+ }
3379
+
3380
+ // src/vault.ts
3381
+ var Vault = class {
3382
+ address;
3383
+ /** Guardian-based recovery for this vault. */
3384
+ recovery;
3385
+ ctx;
3386
+ constructor(address2, ctx) {
3387
+ this.address = assertQuaiAddress(address2, "vault address");
3388
+ this.ctx = ctx;
3389
+ this.recovery = new RecoveryModule({
3390
+ connection: ctx.connection,
3391
+ queries: ctx.queries,
3392
+ vaultAddress: this.address,
3393
+ moduleAddress: ctx.contracts.socialRecovery
3394
+ });
3395
+ }
3396
+ // =========================================================================
3397
+ // Read resolution
3398
+ // =========================================================================
3399
+ /**
3400
+ * Whether an indexer read is acceptable right now.
3401
+ *
3402
+ * `auto` uses the indexer only while it is live and within the configured lag
3403
+ * budget, so a stalled indexer silently degrades to chain reads instead of
3404
+ * serving stale state.
3405
+ */
3406
+ async useIndexer() {
3407
+ if (this.ctx.consistency === "chain") return false;
3408
+ if (!this.ctx.indexer || !this.ctx.queries) {
3409
+ if (this.ctx.consistency === "indexed") throw new NoIndexerError("This read");
3410
+ return false;
3411
+ }
3412
+ if (this.ctx.consistency === "indexed") return true;
3413
+ const health = await this.ctx.indexer.health();
3414
+ if (!health.available) return false;
3415
+ if (health.blocksBehind !== void 0 && health.blocksBehind > this.ctx.maxIndexerLagBlocks) {
3416
+ return false;
3417
+ }
3418
+ return true;
3419
+ }
3420
+ requireQueries(operation) {
3421
+ if (!this.ctx.queries) throw new NoIndexerError(operation);
3422
+ return this.ctx.queries;
3423
+ }
3424
+ contract(write = false) {
3425
+ return new VaultContract(this.ctx.connection.vault(this.address, write), this.ctx.connection.retry);
3426
+ }
3427
+ // =========================================================================
3428
+ // Vault state
3429
+ // =========================================================================
3430
+ /** Owners, threshold, timelock floor, nonce and balance. Always read from chain. */
3431
+ async info() {
3432
+ const vault2 = this.contract();
3433
+ const [owners, threshold, minExecutionDelay, nonce, moduleCount, balance] = await Promise.all([
3434
+ vault2.getOwners(),
3435
+ vault2.threshold(),
3436
+ vault2.minExecutionDelay(),
3437
+ vault2.nonce(),
3438
+ vault2.moduleCount(),
3439
+ this.ctx.connection.provider.getBalance(this.address)
3440
+ ]);
3441
+ return {
3442
+ address: this.address,
3443
+ owners: Array.from(owners).map((o) => getAddress5(String(o))),
3444
+ threshold: Number(threshold),
3445
+ minExecutionDelay: Number(minExecutionDelay),
3446
+ nonce: Number(nonce),
3447
+ moduleCount: Number(moduleCount),
3448
+ balance: BigInt(balance)
3449
+ };
3450
+ }
3451
+ /** Active owners. Prefers the indexer, falls back to chain. */
3452
+ async owners() {
3453
+ if (await this.useIndexer()) {
3454
+ try {
3455
+ const owners = await this.requireQueries("owners").owners(this.address);
3456
+ if (owners.length > 0) return owners.map((o) => getAddress5(o));
3457
+ } catch {
3458
+ }
3459
+ }
3460
+ return (await this.contract().getOwners()).map((o) => getAddress5(String(o)));
3461
+ }
3462
+ async isOwner(address2) {
3463
+ return this.contract().isOwner(getAddress5(address2));
3464
+ }
3465
+ async threshold() {
3466
+ return Number(await this.contract().threshold());
3467
+ }
3468
+ async balance() {
3469
+ return BigInt(await this.ctx.connection.provider.getBalance(this.address));
3470
+ }
3471
+ /**
3472
+ * Enabled modules, in linked-list order.
3473
+ *
3474
+ * Order matters: `disableModule` needs each module's predecessor, so this always
3475
+ * reads the chain rather than the indexer (which stores an unordered set).
3476
+ */
3477
+ async modules() {
3478
+ return (await this.contract().getModules()).map((m) => getAddress5(String(m)));
3479
+ }
3480
+ async isModuleEnabled(module) {
3481
+ return this.contract().isModuleEnabled(getAddress5(module));
3482
+ }
3483
+ /** Addresses permitted as DelegateCall targets. Empty means DelegateCall is disabled. */
3484
+ async delegatecallTargets() {
3485
+ const targets = await this.requireQueries("Listing DelegateCall targets").delegatecallTargets(
3486
+ this.address
3487
+ );
3488
+ return targets.map((t) => getAddress5(t));
3489
+ }
3490
+ async isDelegatecallAllowed(target) {
3491
+ return this.contract().delegatecallAllowed(getAddress5(target));
3492
+ }
3493
+ /** Whether a hash has been pre-approved for EIP-1271 validation. */
3494
+ async isValidSignature(hash) {
3495
+ const magic = await this.contract().isValidSignature(normalizeTxHash(hash, "hash"), "0x");
3496
+ return magic.toLowerCase() === "0x1626ba7e";
3497
+ }
3498
+ // =========================================================================
3499
+ // Transactions
3500
+ // =========================================================================
3501
+ /** The vault-transaction hash for a given call, at a given nonce. */
3502
+ async transactionHash(to, value, data, nonce) {
3503
+ const vault2 = this.contract();
3504
+ const n = nonce ?? Number(await vault2.nonce());
3505
+ return vault2.getTransactionHash(getAddress5(to), value, data, n);
3506
+ }
3507
+ async transaction(txHash) {
3508
+ const hash = normalizeTxHash(txHash);
3509
+ if (await this.useIndexer()) {
3510
+ try {
3511
+ const tx = await this.fromIndexer(hash);
3512
+ if (tx) return tx;
3513
+ } catch {
3514
+ }
3515
+ }
3516
+ return this.fromChain(hash);
3517
+ }
3518
+ async pendingTransactions(options = {}) {
3519
+ const queries = this.requireQueries("Listing pending transactions");
3520
+ const [rows, owners, threshold] = await Promise.all([
3521
+ queries.pendingTransactions(this.address, options),
3522
+ this.owners(),
3523
+ this.threshold()
3524
+ ]);
3525
+ return this.hydrateRows(rows, owners, threshold);
3526
+ }
3527
+ async transactionHistory(options = {}) {
3528
+ const queries = this.requireQueries("Listing transaction history");
3529
+ const [page, owners, threshold] = await Promise.all([
3530
+ queries.transactionHistory(this.address, options),
3531
+ this.owners(),
3532
+ this.threshold()
3533
+ ]);
3534
+ return {
3535
+ data: await this.hydrateRows(page.data, owners, threshold),
3536
+ total: page.total,
3537
+ hasMore: page.hasMore
3538
+ };
3539
+ }
3540
+ // ---- hydration -----------------------------------------------------------
3541
+ async hydrateRows(rows, owners, threshold) {
3542
+ if (rows.length === 0) return [];
3543
+ const queries = this.requireQueries("Loading confirmations");
3544
+ const confirmations = await queries.activeConfirmationsBatch(
3545
+ this.address,
3546
+ rows.map((r) => r.tx_hash)
3547
+ );
3548
+ const indexedAtBlock = (await this.ctx.indexer?.state())?.lastIndexedBlock;
3549
+ return rows.map((row) => {
3550
+ const confirmed = (confirmations.get(row.tx_hash.toLowerCase()) ?? []).map(
3551
+ (c) => c.owner_address
3552
+ );
3553
+ return this.buildTransaction({
3554
+ row,
3555
+ confirmedBy: confirmed,
3556
+ owners,
3557
+ threshold,
3558
+ indexedAtBlock
3559
+ });
3560
+ });
3561
+ }
3562
+ async fromIndexer(hash) {
3563
+ const queries = this.requireQueries("Reading a transaction");
3564
+ const [row, owners, threshold] = await Promise.all([
3565
+ queries.transaction(this.address, hash),
3566
+ this.owners(),
3567
+ this.threshold()
3568
+ ]);
3569
+ if (!row) return null;
3570
+ const confirmations = await queries.confirmations(this.address, hash);
3571
+ const indexedAtBlock = (await this.ctx.indexer?.state())?.lastIndexedBlock;
3572
+ return this.buildTransaction({
3573
+ row,
3574
+ confirmedBy: confirmations.filter((c) => c.is_active).map((c) => c.owner_address),
3575
+ owners,
3576
+ threshold,
3577
+ indexedAtBlock
3578
+ });
3579
+ }
3580
+ /**
3581
+ * Build a `VaultTransaction` from an indexed row.
3582
+ *
3583
+ * Approval counting intersects the indexer's confirmations with the current owner
3584
+ * set. The indexer marks a confirmation inactive only on an explicit
3585
+ * `ApprovalRevoked`; it does not react to `OwnerRemoved`. On chain, removing an
3586
+ * owner bumps `ownerVersions` and invalidates every approval that address made, so
3587
+ * the stored `confirmation_count` over-reports after any removal. Intersecting here
3588
+ * reproduces the contract's `_countValidApprovals`.
3589
+ */
3590
+ buildTransaction(input) {
3591
+ const { row, owners, threshold, indexedAtBlock } = input;
3592
+ const ownerSet = new Set(owners.map((o) => o.toLowerCase()));
3593
+ const confirmedSet = new Set(input.confirmedBy.map((c) => c.toLowerCase()));
3594
+ const approvals = owners.filter((o) => confirmedSet.has(o.toLowerCase())).map((o) => ({ owner: o, active: true }));
3595
+ for (const confirmed of confirmedSet) {
3596
+ if (!ownerSet.has(confirmed)) {
3597
+ approvals.push({ owner: getAddress5(confirmed), active: false });
3598
+ }
3599
+ }
3600
+ const approvalCount = approvals.filter((a) => a.active).length;
3601
+ const value = BigInt(row.value);
3602
+ const data = row.data ?? "0x";
3603
+ const to = getAddress5(row.to_address);
3604
+ const decodeResult = decodeCall({
3605
+ vault: this.address,
3606
+ to,
3607
+ value,
3608
+ data,
3609
+ socialRecovery: this.ctx.contracts.socialRecovery,
3610
+ multiSendCallOnly: this.ctx.contracts.multiSendCallOnly
3611
+ });
3612
+ const expiration = toNumber(row.expiration);
3613
+ const executionDelay = toNumber(row.execution_delay);
3614
+ const approvedAt = toNumber(row.approved_at);
3615
+ const status = deriveStatus({
3616
+ executed: row.status === "executed" || row.status === "failed",
3617
+ cancelled: row.status === "cancelled" || row.status === "expired",
3618
+ isExpired: row.is_expired ?? row.status === "expired",
3619
+ expiration,
3620
+ executionDelay,
3621
+ approvedAt,
3622
+ approvalCount,
3623
+ threshold,
3624
+ failed: row.status === "failed"
3625
+ });
3626
+ const failedReturnData = row.failed_return_data ?? void 0;
3627
+ return {
3628
+ hash: row.tx_hash,
3629
+ vault: this.address,
3630
+ to,
3631
+ value,
3632
+ data,
3633
+ proposer: getAddress5(row.submitted_by),
3634
+ proposedAt: toNumber(row.submitted_at_block),
3635
+ kind: decodeResult.kind,
3636
+ decoded: decodeResult.decoded,
3637
+ summary: decodeResult.summary,
3638
+ status,
3639
+ approvals,
3640
+ approvalCount,
3641
+ threshold,
3642
+ expiration,
3643
+ executionDelay,
3644
+ approvedAt,
3645
+ executableAfter: executableAfterOf(approvedAt, executionDelay),
3646
+ failedReturnData,
3647
+ decodedRevert: failedReturnData ? decodeRevert(failedReturnData) : void 0,
3648
+ source: "indexer",
3649
+ indexedAtBlock
3650
+ };
3651
+ }
3652
+ /** Authoritative read: the struct plus a `hasApproved` probe per current owner. */
3653
+ async fromChain(hash) {
3654
+ const vault2 = this.contract();
3655
+ const [raw, owners, threshold, isExpired] = await Promise.all([
3656
+ vault2.transactions(hash),
3657
+ vault2.getOwners(),
3658
+ vault2.threshold(),
3659
+ vault2.expiredTxs(hash)
3660
+ ]);
3661
+ const to = String(raw.to);
3662
+ if (!to || to === "0x0000000000000000000000000000000000000000") {
3663
+ throw new NotFoundError(`No transaction ${hash} on vault ${this.address}.`);
3664
+ }
3665
+ const ownerList = owners.map((o) => getAddress5(String(o)));
3666
+ const approvalFlags = await Promise.all(
3667
+ ownerList.map((owner) => vault2.hasApproved(hash, owner))
3668
+ );
3669
+ const approvals = ownerList.map((owner, i) => ({ owner, active: approvalFlags[i] === true })).filter((a) => a.active);
3670
+ const value = BigInt(raw.value ?? 0);
3671
+ const data = String(raw.data ?? "0x");
3672
+ const expiration = Number(raw.expiration ?? 0);
3673
+ const executionDelay = Number(raw.executionDelay ?? 0);
3674
+ const approvedAt = Number(raw.approvedAt ?? 0);
3675
+ const executed = Boolean(raw.executed);
3676
+ const cancelled = Boolean(raw.cancelled);
3677
+ const decodeResult = decodeCall({
3678
+ vault: this.address,
3679
+ to: getAddress5(to),
3680
+ value,
3681
+ data,
3682
+ socialRecovery: this.ctx.contracts.socialRecovery,
3683
+ multiSendCallOnly: this.ctx.contracts.multiSendCallOnly
3684
+ });
3685
+ return {
3686
+ hash,
3687
+ vault: this.address,
3688
+ to: getAddress5(to),
3689
+ value,
3690
+ data,
3691
+ proposer: getAddress5(String(raw.proposer)),
3692
+ proposedAt: Number(raw.timestamp ?? 0),
3693
+ kind: decodeResult.kind,
3694
+ decoded: decodeResult.decoded,
3695
+ summary: decodeResult.summary,
3696
+ status: deriveStatus({
3697
+ executed,
3698
+ cancelled,
3699
+ isExpired,
3700
+ expiration,
3701
+ executionDelay,
3702
+ approvedAt,
3703
+ approvalCount: approvals.length,
3704
+ threshold: Number(threshold)
3705
+ }),
3706
+ approvals,
3707
+ approvalCount: approvals.length,
3708
+ threshold: Number(threshold),
3709
+ expiration,
3710
+ executionDelay,
3711
+ approvedAt,
3712
+ executableAfter: executableAfterOf(approvedAt, executionDelay),
3713
+ source: "chain"
3714
+ };
3715
+ }
3716
+ // =========================================================================
3717
+ // Affordances
3718
+ // =========================================================================
3719
+ /**
3720
+ * What `caller` may legally do to `txHash` right now, and when blocked actions
3721
+ * become available. Defaults to the connected signer's address.
3722
+ */
3723
+ async affordances(txHash, caller) {
3724
+ const hash = normalizeTxHash(txHash);
3725
+ const who = caller ?? await this.ctx.connection.addressOrNull();
3726
+ if (!who) {
3727
+ throw new ValidationError(
3728
+ "affordances() needs a caller address when the client has no signer."
3729
+ );
3730
+ }
3731
+ const vault2 = this.contract();
3732
+ const [tx, isOwner, hasApproved] = await Promise.all([
3733
+ this.transaction(hash),
3734
+ vault2.isOwner(getAddress5(who)),
3735
+ vault2.hasApproved(hash, getAddress5(who))
3736
+ ]);
3737
+ return computeAffordances({ tx, caller: getAddress5(who), isOwner, hasApproved });
3738
+ }
3739
+ // =========================================================================
3740
+ // Proposals
3741
+ // =========================================================================
3742
+ /**
3743
+ * Proposal builders.
3744
+ *
3745
+ * Every method here is async, including the ones whose work is purely local. Mixing
3746
+ * synchronous throws with rejected promises is a footgun: `propose.addOwner(bad)`
3747
+ * would throw before `.catch()` could attach. Validation failures are always
3748
+ * rejections.
3749
+ */
3750
+ propose = {
3751
+ /** Propose an arbitrary call. Every other `propose.*` helper routes through this. */
3752
+ call: async (params) => this.proposeCall({
3753
+ to: assertQuaiAddress(params.to, "target"),
3754
+ value: params.value,
3755
+ data: params.data,
3756
+ ...proposeOptionsOf(params)
3757
+ }),
3758
+ /** Send native QUAI. */
3759
+ transfer: async (params) => this.proposeCall({
3760
+ // Native value sent to a Qi address leaves the Quai ledger and is unrecoverable.
3761
+ to: assertQuaiAddress(params.to, "recipient"),
3762
+ value: params.amount,
3763
+ data: "0x",
3764
+ ...proposeOptionsOf(params)
3765
+ }),
3766
+ erc20Transfer: async (params) => this.proposeCall({
3767
+ to: assertQuaiAddress(params.token, "token"),
3768
+ value: 0n,
3769
+ data: token.erc20Transfer(assertQuaiAddress(params.to, "recipient"), params.amount),
3770
+ ...proposeOptionsOf(params)
3771
+ }),
3772
+ erc721Transfer: async (params) => this.proposeCall({
3773
+ to: assertQuaiAddress(params.token, "token"),
3774
+ value: 0n,
3775
+ data: token.erc721Transfer(
3776
+ this.address,
3777
+ assertQuaiAddress(params.to, "recipient"),
3778
+ params.tokenId
3779
+ ),
3780
+ ...proposeOptionsOf(params)
3781
+ }),
3782
+ erc1155Transfer: async (params) => this.proposeCall({
3783
+ to: assertQuaiAddress(params.token, "token"),
3784
+ value: 0n,
3785
+ data: token.erc1155Transfer(
3786
+ this.address,
3787
+ assertQuaiAddress(params.to, "recipient"),
3788
+ params.id,
3789
+ params.amount,
3790
+ params.data ?? "0x"
3791
+ ),
3792
+ ...proposeOptionsOf(params)
3793
+ }),
3794
+ /**
3795
+ * Batch several calls through MultiSendCallOnly.
3796
+ *
3797
+ * Requires MultiSendCallOnly to be on this vault's DelegateCall whitelist, which
3798
+ * is empty by default. The precondition is checked before signing so the failure
3799
+ * names the missing `addDelegatecallTarget` proposal instead of reverting.
3800
+ */
3801
+ batch: async (calls, options = {}) => {
3802
+ const multiSend2 = this.ctx.contracts.multiSendCallOnly;
3803
+ if (!multiSend2) {
3804
+ throw new ValidationError(
3805
+ "No MultiSendCallOnly address is configured for this network.",
3806
+ "Set QUAIVAULT_MULTISEND_CALL_ONLY or pass contracts.multiSendCallOnly."
3807
+ );
3808
+ }
3809
+ if (!await this.isDelegatecallAllowed(multiSend2)) {
3810
+ throw new PreconditionError(
3811
+ `MultiSendCallOnly (${multiSend2}) is not on this vault's DelegateCall whitelist, so a batched call cannot execute.`,
3812
+ {
3813
+ remediation: `Propose and execute addDelegatecallTarget(${multiSend2}) first \u2014 it requires full owner consensus, by design.`
3814
+ }
3815
+ );
3816
+ }
3817
+ calls.forEach((call, i) => assertQuaiAddress(call.to, `calls[${i}].to`));
3818
+ return this.proposeCall({
3819
+ to: multiSend2,
3820
+ value: 0n,
3821
+ data: encodeMultiSend(calls),
3822
+ ...proposeOptionsOf(options)
3823
+ });
3824
+ },
3825
+ // --- self-calls -------------------------------------------------------
3826
+ addOwner: async (owner, options = {}) => this.proposeSelfCall(selfCall.addOwner(assertQuaiAddress(owner, "owner")), options),
3827
+ removeOwner: async (owner, options = {}) => {
3828
+ const [owners, threshold] = await Promise.all([this.owners(), this.threshold()]);
3829
+ if (!owners.some((o) => o.toLowerCase() === owner.toLowerCase())) {
3830
+ throw new PreconditionError(`${owner} is not an owner of this vault.`);
3831
+ }
3832
+ if (owners.length - 1 < threshold) {
3833
+ throw new PreconditionError(
3834
+ `Removing an owner would leave ${owners.length - 1} owners, below the threshold of ${threshold}.`,
3835
+ { remediation: "Lower the threshold first, in a separate proposal." }
3836
+ );
3837
+ }
3838
+ return this.proposeSelfCall(selfCall.removeOwner(getAddress5(owner)), options);
3839
+ },
3840
+ changeThreshold: async (threshold, options = {}) => {
3841
+ const owners = await this.owners();
3842
+ if (threshold < 1 || threshold > owners.length) {
3843
+ throw new ValidationError(
3844
+ `Invalid threshold ${threshold}: this vault has ${owners.length} owners.`
3845
+ );
3846
+ }
3847
+ return this.proposeSelfCall(selfCall.changeThreshold(threshold), options);
3848
+ },
3849
+ setMinExecutionDelay: async (seconds, options = {}) => this.proposeSelfCall(selfCall.setMinExecutionDelay(seconds), options),
3850
+ enableModule: async (module, options = {}) => this.proposeSelfCall(selfCall.enableModule(assertQuaiAddress(module, "module")), options),
3851
+ /**
3852
+ * Disable a module, resolving its predecessor in the Zodiac linked list.
3853
+ *
3854
+ * The predecessor is baked into the proposal, so any module added or removed
3855
+ * before this executes invalidates it. `execute` re-checks and raises
3856
+ * `StaleProposalError` rather than letting it revert on chain.
3857
+ */
3858
+ disableModule: async (module, options = {}) => {
3859
+ const prev = await this.previousModule(getAddress5(module));
3860
+ return this.proposeSelfCall(selfCall.disableModule(prev, getAddress5(module)), options);
3861
+ },
3862
+ addDelegatecallTarget: async (target, options = {}) => this.proposeSelfCall(
3863
+ selfCall.addDelegatecallTarget(assertQuaiAddress(target, "delegatecall target")),
3864
+ options
3865
+ ),
3866
+ removeDelegatecallTarget: async (target, options = {}) => this.proposeSelfCall(selfCall.removeDelegatecallTarget(getAddress5(target)), options),
3867
+ /** Cancel a transaction that has already reached quorum. Needs full consensus. */
3868
+ cancelByConsensus: async (txHash, options = {}) => this.proposeSelfCall(selfCall.cancelByConsensus(normalizeTxHash(txHash)), options),
3869
+ /** Sign arbitrary bytes. For EIP-1271 hash approval use `approveHashForEip1271`. */
3870
+ signMessage: async (message, options = {}) => this.proposeSelfCall(selfCall.signMessage(message), options),
3871
+ unsignMessage: async (message, options = {}) => this.proposeSelfCall(selfCall.unsignMessage(message), options),
3872
+ /**
3873
+ * Make `isValidSignature(hash, …)` return the EIP-1271 magic value.
3874
+ *
3875
+ * Enforces the 32-byte precondition: signing an arbitrary-length message `M`
3876
+ * does not make `isValidSignature(keccak256(M))` validate, because EIP-1271
3877
+ * hashes the dataHash itself, not `M`.
3878
+ */
3879
+ approveHashForEip1271: async (hash, options = {}) => this.proposeSelfCall(selfCall.approveHashForEip1271(normalizeTxHash(hash, "hash")), options),
3880
+ revokeHashForEip1271: async (hash, options = {}) => this.proposeSelfCall(selfCall.revokeHashForEip1271(normalizeTxHash(hash, "hash")), options),
3881
+ /** Configure social recovery. A call to the module, not a self-call. */
3882
+ setupRecovery: async (params) => {
3883
+ const module = this.ctx.contracts.socialRecovery;
3884
+ if (!module) {
3885
+ throw new ValidationError(
3886
+ "No SocialRecoveryModule address is configured for this network.",
3887
+ "Set QUAIVAULT_SOCIAL_RECOVERY_MODULE or pass contracts.socialRecovery."
3888
+ );
3889
+ }
3890
+ return this.proposeCall({
3891
+ to: module,
3892
+ value: 0n,
3893
+ data: recoveryCall.setupRecovery(
3894
+ this.address,
3895
+ // A Qi guardian could never approve a recovery.
3896
+ assertQuaiAddresses(params.guardians, "guardians"),
3897
+ params.threshold,
3898
+ params.recoveryPeriodSeconds
3899
+ ),
3900
+ ...proposeOptionsOf(params)
3901
+ });
3902
+ }
3903
+ };
3904
+ /** Predecessor of `module` in the Zodiac linked list, for `disableModule`. */
3905
+ async previousModule(module) {
3906
+ const modules = await this.modules();
3907
+ const index = modules.findIndex((m) => m.toLowerCase() === module.toLowerCase());
3908
+ if (index === -1) {
3909
+ throw new PreconditionError(`Module ${module} is not enabled on this vault.`);
3910
+ }
3911
+ return index === 0 ? SENTINEL_MODULES : modules[index - 1];
3912
+ }
3913
+ proposeSelfCall(data, options) {
3914
+ if (options.executionDelay) {
3915
+ throw new ValidationError(
3916
+ "Self-calls always execute immediately \u2014 the vault forces executionDelay to 0 for them."
3917
+ );
3918
+ }
3919
+ return this.proposeCall({
3920
+ to: this.address,
3921
+ value: 0n,
3922
+ data,
3923
+ ...proposeOptionsOf(options)
3924
+ });
3925
+ }
3926
+ async proposeCall(params) {
3927
+ const to = getAddress5(params.to);
3928
+ const value = params.value ?? 0n;
3929
+ const data = params.data ?? "0x";
3930
+ if (data !== "0x" && !/^0x([0-9a-fA-F]{2})*$/.test(data)) {
3931
+ throw new ValidationError("Transaction data must be 0x-prefixed hex with an even length.");
3932
+ }
3933
+ const isSelfCall = to.toLowerCase() === this.address.toLowerCase();
3934
+ if (isSelfCall && value > 0n) {
3935
+ throw new ValidationError(
3936
+ "Self-calls cannot carry value \u2014 the vault rejects them at proposal time."
3937
+ );
3938
+ }
3939
+ const expiration = params.expiration ?? 0;
3940
+ const executionDelay = params.executionDelay ?? 0;
3941
+ if (executionDelay > MAX_EXECUTION_DELAY) {
3942
+ throw new ValidationError(
3943
+ `executionDelay ${executionDelay}s exceeds the 30-day maximum (${MAX_EXECUTION_DELAY}s).`
3944
+ );
3945
+ }
3946
+ if (expiration > 0) {
3947
+ const floor = isSelfCall ? 0 : Math.max(executionDelay, await this.minExecutionDelay());
3948
+ const earliest = minimumExpiration(floor, 0);
3949
+ if (expiration <= earliest) {
3950
+ throw new ValidationError(
3951
+ `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.`
3953
+ );
3954
+ }
3955
+ }
3956
+ const useFullOverload = params.expiration != null || params.executionDelay != null;
3957
+ if (params.dryRun) {
3958
+ const readVault = this.contract();
3959
+ const encoded = useFullOverload ? readVault.encode("proposeTransaction(address,uint256,bytes,uint48,uint32)", [
3960
+ to,
3961
+ value,
3962
+ data,
3963
+ expiration,
3964
+ executionDelay
3965
+ ]) : readVault.encode("proposeTransaction(address,uint256,bytes)", [to, value, data]);
3966
+ let gasEstimate = null;
3967
+ let wouldRevert;
3968
+ const from = await this.ctx.connection.addressOrNull();
3969
+ if (from) {
3970
+ try {
3971
+ gasEstimate = BigInt(
3972
+ await this.ctx.connection.provider.estimateGas({
3973
+ to: this.address,
3974
+ data: encoded,
3975
+ from
3976
+ })
3977
+ );
3978
+ } catch (err) {
3979
+ wouldRevert = decodeRevertFromError(err);
3980
+ }
3981
+ }
3982
+ const decoded = decodeCall({
3983
+ vault: this.address,
3984
+ to,
3985
+ value,
3986
+ data,
3987
+ socialRecovery: this.ctx.contracts.socialRecovery,
3988
+ multiSendCallOnly: this.ctx.contracts.multiSendCallOnly
3989
+ });
3990
+ return {
3991
+ dryRun: true,
3992
+ to: this.address,
3993
+ data: encoded,
3994
+ value: 0n,
3995
+ gasEstimate,
3996
+ wouldRevert,
3997
+ description: `Propose: ${decoded.summary}`
3998
+ };
3999
+ }
4000
+ await this.assertCallerIsOwner("Proposing a transaction");
4001
+ const writeVault = this.contract(true);
4002
+ let receipt;
4003
+ try {
4004
+ const sent = useFullOverload ? await writeVault.proposeTransactionFull(to, value, data, expiration, executionDelay) : await writeVault.proposeTransactionSimple(to, value, data);
4005
+ receipt = await sent.wait();
4006
+ } catch (err) {
4007
+ throw toRevertError(err, "Transaction proposal failed");
4008
+ }
4009
+ assertReceipt(receipt, "Proposal", "The proposal transaction reverted.");
4010
+ const txHash = extractProposedTxHash(receipt, this.address);
4011
+ if (!txHash) {
4012
+ throw new RevertError(
4013
+ "The proposal was mined but no TransactionProposed event was found. Check that the vault address is correct."
4014
+ );
4015
+ }
4016
+ return {
4017
+ txHash,
4018
+ chainTxHash: receipt.hash ?? receipt.transactionHash ?? "",
4019
+ to,
4020
+ value,
4021
+ data
4022
+ };
4023
+ }
4024
+ async minExecutionDelay() {
4025
+ return Number(await this.contract().minExecutionDelay());
4026
+ }
4027
+ // =========================================================================
4028
+ // Lifecycle writes
4029
+ // =========================================================================
4030
+ async approve(txHash) {
4031
+ const hash = normalizeTxHash(txHash);
4032
+ const caller = await this.assertCallerIsOwner("Approving a transaction");
4033
+ const vault2 = this.contract(true);
4034
+ if (await vault2.hasApproved(hash, caller)) {
4035
+ throw new PreconditionError("You have already approved this transaction.");
4036
+ }
4037
+ try {
4038
+ const sent = await vault2.approveTransaction(hash);
4039
+ const receipt = await sent.wait();
4040
+ assertReceipt(receipt, "Approval", "The approval transaction reverted.");
4041
+ return { chainTxHash: receipt.hash ?? receipt.transactionHash ?? "" };
4042
+ } catch (err) {
4043
+ throw toRevertError(err, "Approval failed");
4044
+ }
4045
+ }
4046
+ async revokeApproval(txHash) {
4047
+ const hash = normalizeTxHash(txHash);
4048
+ const caller = await this.assertCallerIsOwner("Revoking an approval");
4049
+ const vault2 = this.contract(true);
4050
+ if (!await vault2.hasApproved(hash, caller)) {
4051
+ throw new PreconditionError("You have no active approval on this transaction to revoke.");
4052
+ }
4053
+ try {
4054
+ const sent = await vault2.revokeApproval(hash);
4055
+ const receipt = await sent.wait();
4056
+ assertReceipt(receipt, "Revocation", "The revocation transaction reverted.");
4057
+ return { chainTxHash: receipt.hash ?? receipt.transactionHash ?? "" };
4058
+ } catch (err) {
4059
+ throw toRevertError(err, "Revoking the approval failed");
4060
+ }
4061
+ }
4062
+ /**
4063
+ * Execute a transaction that has reached quorum.
4064
+ *
4065
+ * The returned {@link ExecuteResult} says what actually happened. A successful
4066
+ * receipt does not imply execution — see {@link classifyExecution}.
4067
+ */
4068
+ async execute(txHash) {
4069
+ const hash = normalizeTxHash(txHash);
4070
+ await this.assertCallerIsOwner("Executing a transaction");
4071
+ await this.assertExecutable(hash);
4072
+ const vault2 = this.contract(true);
4073
+ try {
4074
+ const sent = await vault2.executeTransaction(hash);
4075
+ const receipt = await sent.wait();
4076
+ assertReceipt(receipt, "Execution", "The execution transaction reverted.");
4077
+ return classifyExecution(receipt, this.address, hash);
4078
+ } catch (err) {
4079
+ throw toRevertError(err, "Execution failed");
4080
+ }
4081
+ }
4082
+ /** Approve and, if that meets quorum and the timelock allows, execute in one call. */
4083
+ async approveAndExecute(txHash) {
4084
+ const hash = normalizeTxHash(txHash);
4085
+ const caller = await this.assertCallerIsOwner("Approving and executing");
4086
+ const vault2 = this.contract(true);
4087
+ if (await vault2.hasApproved(hash, caller)) {
4088
+ throw new PreconditionError("You have already approved this transaction.", {
4089
+ remediation: "Use execute() instead."
4090
+ });
4091
+ }
4092
+ try {
4093
+ const sent = await vault2.approveAndExecute(hash);
4094
+ const receipt = await sent.wait();
4095
+ assertReceipt(receipt, "Approve-and-execute", "The transaction reverted.");
4096
+ return classifyExecution(receipt, this.address, hash);
4097
+ } catch (err) {
4098
+ throw toRevertError(err, "Approve-and-execute failed");
4099
+ }
4100
+ }
4101
+ /** Cancel a transaction you proposed, before it ever reaches quorum. */
4102
+ async cancel(txHash) {
4103
+ const hash = normalizeTxHash(txHash);
4104
+ const caller = await this.assertCallerIsOwner("Cancelling a transaction");
4105
+ const tx = await this.fromChain(hash);
4106
+ if (tx.proposer.toLowerCase() !== caller.toLowerCase()) {
4107
+ throw new PreconditionError("Only the proposer can cancel a transaction directly.", {
4108
+ remediation: "Propose a cancelByConsensus self-call instead."
4109
+ });
4110
+ }
4111
+ if (tx.approvedAt !== 0) {
4112
+ throw new PreconditionError(
4113
+ "This transaction reached quorum, which permanently blocks proposer-cancel.",
4114
+ { remediation: "Use propose.cancelByConsensus() instead." }
4115
+ );
4116
+ }
4117
+ try {
4118
+ const sent = await this.contract(true).cancelTransaction(hash);
4119
+ const receipt = await sent.wait();
4120
+ assertReceipt(receipt, "Cancellation", "The cancellation reverted.");
4121
+ return { chainTxHash: receipt.hash ?? receipt.transactionHash ?? "" };
4122
+ } catch (err) {
4123
+ throw toRevertError(err, "Cancellation failed");
4124
+ }
4125
+ }
4126
+ /** Close out an expired transaction. Permissionless — no ownership required. */
4127
+ async expire(txHash) {
4128
+ const hash = normalizeTxHash(txHash);
4129
+ try {
4130
+ const sent = await this.contract(true).expireTransaction(hash);
4131
+ const receipt = await sent.wait();
4132
+ assertReceipt(receipt, "Expiry", "The expiry transaction reverted.");
4133
+ return { chainTxHash: receipt.hash ?? receipt.transactionHash ?? "" };
4134
+ } catch (err) {
4135
+ throw toRevertError(err, "Expiring the transaction failed");
4136
+ }
4137
+ }
4138
+ // =========================================================================
4139
+ // Preconditions
4140
+ // =========================================================================
4141
+ async assertCallerIsOwner(operation) {
4142
+ const caller = await this.ctx.connection.address();
4143
+ const isOwner = await this.contract().isOwner(caller);
4144
+ if (!isOwner) {
4145
+ throw new PreconditionError(`${operation} requires an owner. ${caller} is not one.`);
4146
+ }
4147
+ return getAddress5(caller);
4148
+ }
4149
+ /**
4150
+ * Re-validate on chain immediately before signing an execute.
4151
+ *
4152
+ * Reads always go through the chain here, never the indexer: indexed approval
4153
+ * counts can over-report after an owner removal, and a stale count must never gate
4154
+ * a signature.
4155
+ */
4156
+ async assertExecutable(hash) {
4157
+ const tx = await this.fromChain(hash);
4158
+ if (tx.status === "executed" || tx.status === "failed") {
4159
+ throw new PreconditionError("This transaction has already been executed.");
4160
+ }
4161
+ if (tx.status === "cancelled") {
4162
+ throw new PreconditionError("This transaction has been cancelled.");
4163
+ }
4164
+ if (tx.status === "expired") {
4165
+ throw new PreconditionError("This transaction has expired and can no longer execute.", {
4166
+ remediation: "Call expire() to close it out, then propose a replacement."
4167
+ });
4168
+ }
4169
+ if (tx.approvalCount < tx.threshold) {
4170
+ throw new PreconditionError(
4171
+ `Not enough approvals: ${tx.approvalCount} of ${tx.threshold}.`
4172
+ );
4173
+ }
4174
+ if (tx.decoded?.name === "disableModule" && tx.decoded.target === "vault") {
4175
+ const module = String(tx.decoded.args.module ?? "");
4176
+ const bakedPrev = String(tx.decoded.args.prevModule ?? "");
4177
+ const modules = await this.modules();
4178
+ const index = modules.findIndex((m) => m.toLowerCase() === module.toLowerCase());
4179
+ if (index === -1) {
4180
+ throw new StaleProposalError(
4181
+ `This proposal disables module ${module}, which is no longer enabled.`
4182
+ );
4183
+ }
4184
+ const expectedPrev = index === 0 ? SENTINEL_MODULES : modules[index - 1];
4185
+ if (expectedPrev.toLowerCase() !== bakedPrev.toLowerCase()) {
4186
+ throw new StaleProposalError(
4187
+ `The module list changed since this proposal was created: it names ${bakedPrev} as the predecessor of ${module}, but the current predecessor is ${expectedPrev}.`
4188
+ );
4189
+ }
4190
+ }
4191
+ }
4192
+ // =========================================================================
4193
+ // Holdings and activity
4194
+ // =========================================================================
4195
+ /**
4196
+ * Native and token holdings.
4197
+ *
4198
+ * Token discovery needs the indexer — the chain cannot answer "which contracts has
4199
+ * this address touched" without scanning every log. Amounts are verified on chain
4200
+ * by default; pass `{ verify: false }` to accept replayed transfer totals instead.
4201
+ */
4202
+ async balances(options = {}) {
4203
+ return loadBalances(this.ctx.connection, this.ctx.queries, this.address, options);
4204
+ }
4205
+ /** Native QUAI received by the vault. Indexer-only. */
4206
+ async deposits(options = {}) {
4207
+ return this.requireQueries("Listing deposits").deposits(this.address, options);
4208
+ }
4209
+ /** ERC20/721/1155 transfers involving this vault. Indexer-only. */
4210
+ async tokenTransfers(options = {}) {
4211
+ return this.requireQueries("Listing token transfers").tokenTransfers(this.address, options);
4212
+ }
4213
+ /** Message hashes currently signed by the vault for EIP-1271. Indexer-only. */
4214
+ async signedMessages() {
4215
+ return this.requireQueries("Listing signed messages").signedMessages(this.address);
4216
+ }
4217
+ /**
4218
+ * Follow indexer changes for this vault over Supabase Realtime.
4219
+ *
4220
+ * Events fire after the indexer processes a block, so treat them as a signal to
4221
+ * re-read rather than as state themselves — re-read through this handle so approval
4222
+ * counts stay intersected with the current owner set.
4223
+ *
4224
+ * ```ts
4225
+ * const sub = vault.watch((e) => console.log(e.topic, e.type));
4226
+ * // …later
4227
+ * await sub.unsubscribe();
4228
+ * ```
4229
+ */
4230
+ watch(handler, options = {}) {
4231
+ if (!this.ctx.indexer) throw new NoIndexerError("Watching a vault");
4232
+ return watchVault(this.ctx.indexer, this.address, handler, options);
4233
+ }
4234
+ // =========================================================================
4235
+ // Waiting
4236
+ // =========================================================================
4237
+ /**
4238
+ * Block until the indexer has caught up to `blockNumber` (or to the chain head
4239
+ * when omitted), so a subsequent indexed read reflects a write you just made.
4240
+ *
4241
+ * Returns `reached: false` on timeout rather than throwing — a lagging indexer is
4242
+ * an expected operating condition, and the caller may reasonably fall back to a
4243
+ * chain read. Requires an indexer; without one there is nothing to wait for.
4244
+ */
4245
+ async waitForIndexer(blockNumber, options = {}) {
4246
+ if (!this.ctx.indexer) throw new NoIndexerError("Waiting for the indexer");
4247
+ let target = blockNumber;
4248
+ if (target === void 0) {
4249
+ const zone = getZoneForAddress3(this.address);
4250
+ if (!zone) {
4251
+ throw new ValidationError(
4252
+ `Cannot determine the Quai zone for vault ${this.address}; pass an explicit block number.`
4253
+ );
4254
+ }
4255
+ target = Number(await this.ctx.connection.provider.getBlockNumber(toShard(zone)));
4256
+ }
4257
+ return this.ctx.indexer.waitForBlock(target, options);
4258
+ }
4259
+ /**
4260
+ * Poll until a transaction is executable, then resolve with its current state.
4261
+ *
4262
+ * Lets a caller express "run this when it can run" instead of reimplementing the
4263
+ * timelock arithmetic. Resolves as soon as the status reaches `ready`; rejects if
4264
+ * the transaction reaches a terminal state first, or if the deadline passes.
4265
+ *
4266
+ * Reads go through the chain so the answer is authoritative — a `ready` verdict
4267
+ * here is immediately actionable.
4268
+ */
4269
+ async waitForExecutable(txHash, options = {}) {
4270
+ const hash = normalizeTxHash(txHash);
4271
+ const timeoutMs = options.timeoutMs ?? 60 * 60 * 1e3;
4272
+ const pollIntervalMs = options.pollIntervalMs ?? 15e3;
4273
+ const deadline = Date.now() + timeoutMs;
4274
+ for (; ; ) {
4275
+ if (options.signal?.aborted) {
4276
+ throw new PreconditionError("Waiting for the transaction was aborted.");
4277
+ }
4278
+ const tx = await this.fromChain(hash);
4279
+ options.onPoll?.(tx);
4280
+ if (tx.status === "ready") return tx;
4281
+ if (tx.status === "executed" || tx.status === "failed") {
4282
+ throw new PreconditionError(`Transaction ${hash} has already been executed.`);
4283
+ }
4284
+ if (tx.status === "cancelled" || tx.status === "expired") {
4285
+ throw new PreconditionError(`Transaction ${hash} is ${tx.status} and can never execute.`);
4286
+ }
4287
+ if (tx.approvalCount < tx.threshold) {
4288
+ throw new PreconditionError(
4289
+ `Transaction ${hash} needs ${tx.threshold} approvals and has ${tx.approvalCount}. Waiting cannot resolve this.`,
4290
+ { remediation: "Collect the remaining approvals, then wait for the timelock." }
4291
+ );
4292
+ }
4293
+ const untilExecutable = tx.executableAfter > 0 ? tx.executableAfter * 1e3 - Date.now() : pollIntervalMs;
4294
+ const wait = Math.max(1e3, Math.min(untilExecutable, pollIntervalMs));
4295
+ if (Date.now() + wait > deadline) {
4296
+ throw new PreconditionError(
4297
+ `Timed out after ${timeoutMs}ms waiting for ${hash} to become executable.`,
4298
+ {
4299
+ ...tx.executableAfter > 0 ? { retryableAt: tx.executableAfter } : {},
4300
+ remediation: tx.executableAfter > 0 ? `Executable after ${new Date(tx.executableAfter * 1e3).toISOString()}.` : "The timelock clock has not started yet."
4301
+ }
4302
+ );
4303
+ }
4304
+ await new Promise((resolve) => setTimeout(resolve, wait));
4305
+ }
4306
+ }
4307
+ // =========================================================================
4308
+ // Presentation
4309
+ // =========================================================================
4310
+ /** Compact human-readable summary of a transaction's state. */
4311
+ async describe(txHash, caller) {
4312
+ const tx = await this.transaction(txHash);
4313
+ const lines = [
4314
+ `Transaction ${tx.hash}`,
4315
+ ` ${tx.summary}`,
4316
+ ` status: ${tx.status} (${tx.approvalCount}/${tx.threshold} approvals)`
4317
+ ];
4318
+ if (tx.value > 0n) lines.push(` value: ${formatQuai2(tx.value)} QUAI`);
4319
+ if (tx.approvedAt > 0) {
4320
+ lines.push(` quorum reached: ${new Date(tx.approvedAt * 1e3).toISOString()}`);
4321
+ }
4322
+ if (tx.executableAfter > 0) {
4323
+ const remaining = tx.executableAfter - nowSeconds();
4324
+ lines.push(
4325
+ ` executable after: ${new Date(tx.executableAfter * 1e3).toISOString()}` + (remaining > 0 ? ` (in ${remaining}s)` : " (now)")
4326
+ );
4327
+ }
4328
+ if (tx.expiration > 0) {
4329
+ lines.push(` expires: ${new Date(tx.expiration * 1e3).toISOString()}`);
4330
+ }
4331
+ if (tx.decodedRevert) lines.push(` revert: ${tx.decodedRevert.message}`);
4332
+ lines.push(` source: ${tx.source}`);
4333
+ if (caller || this.ctx.connection.hasSigner()) {
4334
+ const affordances = await this.affordances(txHash, caller);
4335
+ const allowed = affordances.filter((a) => a.allowed).map((a) => a.action);
4336
+ lines.push(` you can: ${allowed.length > 0 ? allowed.join(", ") : "nothing right now"}`);
4337
+ }
4338
+ return lines.join("\n");
4339
+ }
4340
+ };
4341
+ function assertReceipt(receipt, operation, revertMessage) {
4342
+ if (!receipt) {
4343
+ throw new RevertError(
4344
+ `${operation} produced no receipt \u2014 the transaction was replaced or dropped before it was mined.`,
4345
+ void 0,
4346
+ { remediation: "Check the mempool for a replacement, then retry if it never landed." }
4347
+ );
4348
+ }
4349
+ if (receipt.status === 0) throw new RevertError(revertMessage);
4350
+ }
4351
+ function proposeOptionsOf(options) {
4352
+ return {
4353
+ ...options.expiration != null ? { expiration: options.expiration } : {},
4354
+ ...options.executionDelay != null ? { executionDelay: options.executionDelay } : {},
4355
+ ...options.dryRun != null ? { dryRun: options.dryRun } : {}
4356
+ };
4357
+ }
4358
+ function toRevertError(err, context) {
4359
+ if (err instanceof RevertError || err instanceof PreconditionError) return err;
4360
+ const decoded = decodeRevertFromError(err);
4361
+ const detail = decoded ? `: ${decoded.message}` : "";
4362
+ const base = err instanceof Error ? err.message : String(err);
4363
+ return new RevertError(`${context}${detail || `: ${base}`}`, decoded, { cause: err });
4364
+ }
4365
+
4366
+ // src/client.ts
4367
+ var QuaiVaultClient = class {
4368
+ /**
4369
+ * Resolved configuration, with the private key stripped and the indexer key
4370
+ * masked — safe to log. The key itself is consumed once when building the signer.
4371
+ */
4372
+ config;
4373
+ connection;
4374
+ factory;
4375
+ indexer;
4376
+ queries;
4377
+ constructor(options = {}) {
4378
+ const resolved = resolveConfig(options);
4379
+ this.config = redactConfig(resolved);
4380
+ this.connection = new Connection(resolved, {
4381
+ ...options.provider ? { provider: options.provider } : {},
4382
+ ...options.signer ? { signer: options.signer } : {}
4383
+ });
4384
+ this.indexer = resolved.indexer ? new IndexerClient(resolved.indexer) : null;
4385
+ this.queries = this.indexer ? new IndexerQueries(this.indexer) : null;
4386
+ this.factory = new Factory({
4387
+ connection: this.connection,
4388
+ contracts: this.config.contracts
4389
+ });
4390
+ }
4391
+ get network() {
4392
+ return this.config.network;
4393
+ }
4394
+ get provider() {
4395
+ return this.connection.provider;
4396
+ }
4397
+ get signer() {
4398
+ return this.connection.signer;
4399
+ }
4400
+ /** The connected address, or null when read-only. */
4401
+ async address() {
4402
+ return this.connection.addressOrNull();
4403
+ }
4404
+ /** A handle to one vault. No I/O. */
4405
+ vault(address2) {
4406
+ return new Vault(address2, this.vaultContext());
4407
+ }
4408
+ vaultContext() {
4409
+ return {
4410
+ connection: this.connection,
4411
+ indexer: this.indexer,
4412
+ queries: this.queries,
4413
+ contracts: this.config.contracts,
4414
+ consistency: this.config.consistency,
4415
+ maxIndexerLagBlocks: this.config.maxIndexerLagBlocks
4416
+ };
4417
+ }
4418
+ /** Vault discovery. Requires the indexer — there is no on-chain reverse index. */
4419
+ vaults = {
4420
+ /**
4421
+ * Vaults where `owner` is an active owner.
4422
+ *
4423
+ * Indexer-only: `factory.getWalletsByCreator` was removed from the contracts for
4424
+ * gas reasons, and the factory registry is not indexed by owner.
4425
+ */
4426
+ forOwner: async (owner, options = {}) => {
4427
+ const queries = this.requireQueries("Looking up vaults by owner");
4428
+ const rows = await queries.vaultsForOwner(getAddress6(owner), options);
4429
+ return rows.map((r) => getAddress6(r.address));
4430
+ },
4431
+ /** Vaults where `guardian` is an active social-recovery guardian. */
4432
+ forGuardian: async (guardian, options = {}) => {
4433
+ const queries = this.requireQueries("Looking up vaults by guardian");
4434
+ const rows = await queries.vaultsForGuardian(getAddress6(guardian), options);
4435
+ return rows.map((r) => getAddress6(r.address));
4436
+ },
4437
+ /** Whether an address is a vault this factory deployed or registered. */
4438
+ exists: async (address2) => {
4439
+ return this.factory.isRegistered(getAddress6(address2));
4440
+ },
4441
+ get: (address2) => this.vault(address2)
4442
+ };
4443
+ requireQueries(operation) {
4444
+ if (!this.queries) throw new NoIndexerError(operation);
4445
+ return this.queries;
4446
+ }
4447
+ /** Indexer liveness and lag. Reports unavailable when no indexer is configured. */
4448
+ async indexerHealth() {
4449
+ if (!this.indexer) {
4450
+ return {
4451
+ available: false,
4452
+ lastIndexedBlock: 0,
4453
+ isSyncing: false,
4454
+ error: "no indexer configured"
4455
+ };
4456
+ }
4457
+ return this.indexer.health();
4458
+ }
4459
+ };
4460
+ function connect(options = {}) {
4461
+ return new QuaiVaultClient(options);
4462
+ }
4463
+ var QuaiVault = { connect };
4464
+ export {
4465
+ ConfigError,
4466
+ Connection,
4467
+ ENV_VARS,
4468
+ Erc1155Abi,
4469
+ Erc20Abi,
4470
+ Erc721Abi,
4471
+ Factory,
4472
+ FactoryContract,
4473
+ IndexerClient,
4474
+ IndexerQueries,
4475
+ IndexerQueryError,
4476
+ MAX_EXECUTION_DELAY,
4477
+ MAX_MODULES,
4478
+ MAX_OWNERS,
4479
+ MultiSendCallOnlyAbi,
4480
+ NoIndexerError,
4481
+ NoSignerError,
4482
+ NotFoundError,
4483
+ Operation,
4484
+ PreconditionError,
4485
+ QuaiVault,
4486
+ QuaiVaultAbi,
4487
+ QuaiVaultClient,
4488
+ QuaiVaultError,
4489
+ QuaiVaultFactoryAbi,
4490
+ QuaiVaultProxyAbi,
4491
+ QuaiVaultProxyBytecode,
4492
+ RecoveryContract,
4493
+ RecoveryModule,
4494
+ RevertError,
4495
+ SENTINEL_MODULES,
4496
+ SaltMiningError,
4497
+ SocialRecoveryModuleAbi,
4498
+ StaleProposalError,
4499
+ ValidationError,
4500
+ Vault,
4501
+ VaultContract,
4502
+ allowedActions,
4503
+ assertQuaiAddress,
4504
+ assertQuaiAddresses,
4505
+ classifyExecution,
4506
+ computeAffordances,
4507
+ computeBytecodeHash,
4508
+ computeFullSalt,
4509
+ connect,
4510
+ decodeCall,
4511
+ decodeMultiSendPayload,
4512
+ decodeRevert,
4513
+ decodeRevertFromError,
4514
+ defaultStrategy,
4515
+ deriveRecoveryStatus,
4516
+ deriveStatus,
4517
+ encodeInitData,
4518
+ encodeMultiSend,
4519
+ encodeMultiSendPayload,
4520
+ executableAfterOf,
4521
+ extractProposedTxHash,
4522
+ schemas_exports as indexerSchemas,
4523
+ inspectAddress,
4524
+ interfaces,
4525
+ isNetworkName,
4526
+ isTerminal,
4527
+ isTransient,
4528
+ isUsableQuaiAddress,
4529
+ knownErrorSelectors,
4530
+ loadBalances,
4531
+ mainnet,
4532
+ mineSalt,
4533
+ minimumExpiration,
4534
+ networks,
4535
+ nowSeconds,
4536
+ predictVaultAddress,
4537
+ recoveryCall,
4538
+ remediationFor,
4539
+ resolveConfig,
4540
+ selfCall,
4541
+ shardPrefixOf,
4542
+ syncStrategy,
4543
+ testnet,
4544
+ token as tokenCalls,
4545
+ watchVault,
4546
+ withRetry,
4547
+ workerThreadsStrategy
4548
+ };
4549
+ //# sourceMappingURL=index.js.map