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