@quaivault/sdk 0.2.0 → 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 CHANGED
@@ -164,9 +164,11 @@ qv.indexerHealth()
164
164
  ```ts
165
165
  vault.info() / owners() / threshold() / balance() / modules() / delegatecallTargets()
166
166
  vault.isOwner(a) / isModuleEnabled(m) / isDelegatecallAllowed(t) / isValidSignature(hash)
167
- vault.transaction(txHash) / pendingTransactions(page?) / transactionHistory(page?)
168
- vault.transactionHash(to, value, data, nonce?)
169
- vault.affordances(txHash, caller?) / describe(txHash, caller?)
167
+ vault.transaction(txHash) / transactions(txHashes) / pendingTransactions(page?)
168
+ vault.transactionHistory(page?) / transactionHash(to, value, data, nonce?)
169
+ vault.hasApproved(txHash, owner)
170
+ vault.affordances(txHash, caller?, at?) / describe(txHash, caller?)
171
+ vault.view() / pinned(view) // explicit owners+threshold snapshot
170
172
  vault.balances(opts?) / deposits(page?) / tokenTransfers(page?) / signedMessages()
171
173
  vault.waitForExecutable(txHash, opts?)
172
174
  vault.watch(handler, opts?) // Supabase Realtime
@@ -335,6 +337,38 @@ nonce and funds errors are permanent by definition, and anything unrecognised is
335
337
  permanent too — so a genuine bug surfaces immediately instead of hiding behind three slow
336
338
  attempts. Rate limits, 5xx and raw transport failures are retried.
337
339
 
340
+ ## Clock skew
341
+
342
+ The contracts decide with `block.timestamp`. Everything the SDK derives locally — a
343
+ transaction's `ready` vs `timelocked`, what a caller may do next, whether a recovery period
344
+ has elapsed — predicts that, and a machine whose clock is wrong predicts wrongly.
345
+ Containers, CI runners and VMs resumed from a snapshot drift in ways a desktop usually
346
+ does not.
347
+
348
+ If you have measured the offset, feed it back in:
349
+
350
+ ```ts
351
+ const block = await qv.provider.getBlock('latest');
352
+ const skew = Date.now() / 1000 - Number(block.timestamp); // positive: local clock ahead
353
+
354
+ const qv = connect({ network: 'mainnet', now: () => Date.now() / 1000 - skew });
355
+ ```
356
+
357
+ A function rather than a scalar offset on purpose: an offset needs a sign convention, and
358
+ getting it backwards doubles the error instead of cancelling it, silently. Here the
359
+ arithmetic sits in your code, where it reads as what it means.
360
+
361
+ Detection is deliberately yours. Deriving the offset costs an RPC call the SDK should not
362
+ make on your behalf, and caching one has no clear invalidation.
363
+
364
+ **This never touches elapsed-time measurement** — retry backoff, timeouts, poll intervals
365
+ and cache TTLs stay on the raw local clock. A clock being 12 seconds fast does not make 30
366
+ seconds of backoff into 18.
367
+
368
+ Nothing here can change an on-chain outcome; the chain is the authority and a wrong local
369
+ time fails safe into a revert or a refusal. What it changes is what the SDK *tells* you,
370
+ which is most of what the SDK is for.
371
+
338
372
  ## Development
339
373
 
340
374
  ```bash
package/dist/index.cjs CHANGED
@@ -3647,6 +3647,36 @@ function normalizeTxHash(value, label = "transaction hash") {
3647
3647
  // src/config/resolve.ts
3648
3648
  var import_quais2 = require("quais");
3649
3649
 
3650
+ // src/lifecycle/status.ts
3651
+ function nowSeconds() {
3652
+ return Math.floor(Date.now() / 1e3);
3653
+ }
3654
+ function executableAfterOf(approvedAt, executionDelay) {
3655
+ return approvedAt > 0 ? approvedAt + executionDelay : 0;
3656
+ }
3657
+ function deriveStatus(state, at = nowSeconds()) {
3658
+ if (state.failed) return "failed";
3659
+ if (state.executed) return "executed";
3660
+ if (state.cancelled) return state.isExpired ? "expired" : "cancelled";
3661
+ if (state.expiration > 0 && at > state.expiration) return "expired";
3662
+ if (state.approvalCount < state.threshold) return "pending";
3663
+ const executableAfter = executableAfterOf(state.approvedAt, state.executionDelay);
3664
+ if (state.executionDelay > 0 && (state.approvedAt === 0 || at < executableAfter)) {
3665
+ return "timelocked";
3666
+ }
3667
+ return "ready";
3668
+ }
3669
+ function isTerminal(status) {
3670
+ return status === "executed" || status === "failed" || status === "cancelled" || status === "expired";
3671
+ }
3672
+ function deriveRecoveryStatus(state, at = nowSeconds()) {
3673
+ if (state.executed) return "executed";
3674
+ if (state.expiration > 0 && at > state.expiration) return "expired";
3675
+ if (state.approvalCount < state.requiredThreshold) return "pending";
3676
+ if (at < state.executionTime) return "timelocked";
3677
+ return "ready";
3678
+ }
3679
+
3650
3680
  // src/config/networks.ts
3651
3681
  var INDEXER_URL = "https://xbftgyuxaxagptudledv.supabase.co";
3652
3682
  var INDEXER_PUBLISHABLE_KEY = "sb_publishable_EO-sTB-jqUzlsiugOEks-Q_qRtHDaHU";
@@ -3846,6 +3876,9 @@ function resolveConfig(options = {}) {
3846
3876
  privateKey: options.signer ? void 0 : pick(options.privateKey, env[ENV_VARS.privateKey]),
3847
3877
  consistency,
3848
3878
  maxIndexerLagBlocks: options.maxIndexerLagBlocks ?? parseLag(env[ENV_VARS.maxIndexerLagBlocks]) ?? 50,
3879
+ // Deliberately not environment-configurable: a clock is a function, and a numeric
3880
+ // offset in an env var would reintroduce the sign convention `Clock` exists to avoid.
3881
+ now: options.now ?? nowSeconds,
3849
3882
  retry: {
3850
3883
  ...options.retry?.maxAttempts != null ? { maxAttempts: options.retry.maxAttempts } : {},
3851
3884
  ...options.retry?.baseDelayMs != null ? { baseDelayMs: options.retry.baseDelayMs } : {},
@@ -4520,7 +4553,7 @@ function encodeMultiSendPayload(calls) {
4520
4553
  function encodeMultiSend(calls) {
4521
4554
  return multiSend.encodeFunctionData("multiSend", [encodeMultiSendPayload(calls)]);
4522
4555
  }
4523
- function minimumExpiration(effectiveDelaySeconds, marginSeconds = 300, at = Math.floor(Date.now() / 1e3)) {
4556
+ function minimumExpiration(effectiveDelaySeconds, marginSeconds = 300, at = nowSeconds()) {
4524
4557
  return at + effectiveDelaySeconds + marginSeconds;
4525
4558
  }
4526
4559
 
@@ -5202,14 +5235,19 @@ function toNumber(value, fallback = 0) {
5202
5235
  }
5203
5236
 
5204
5237
  // src/indexer/client.ts
5205
- var SDK_VERSION = true ? "0.2.0" : "dev";
5238
+ var SDK_VERSION = true ? "0.2.1" : "dev";
5206
5239
  var IndexerClient = class {
5207
5240
  config;
5208
5241
  client;
5209
5242
  realtime = null;
5210
5243
  healthCache = null;
5211
5244
  inflightHealth = null;
5212
- /** Health results are reused for this long to keep read paths cheap. */
5245
+ /**
5246
+ * Health results are reused for this long to keep read paths cheap.
5247
+ *
5248
+ * This and `waitForBlock`'s deadline are elapsed-time arithmetic, so they use the
5249
+ * raw local clock rather than `ClientOptions.now` — see the note in `chain/retry.ts`.
5250
+ */
5213
5251
  healthCacheMs;
5214
5252
  constructor(config, options = {}) {
5215
5253
  this.config = config;
@@ -5817,36 +5855,6 @@ var RecoveryContract = class {
5817
5855
  }
5818
5856
  };
5819
5857
 
5820
- // src/lifecycle/status.ts
5821
- function nowSeconds() {
5822
- return Math.floor(Date.now() / 1e3);
5823
- }
5824
- function executableAfterOf(approvedAt, executionDelay) {
5825
- return approvedAt > 0 ? approvedAt + executionDelay : 0;
5826
- }
5827
- function deriveStatus(state, at = nowSeconds()) {
5828
- if (state.failed) return "failed";
5829
- if (state.executed) return "executed";
5830
- if (state.cancelled) return state.isExpired ? "expired" : "cancelled";
5831
- if (state.expiration > 0 && at > state.expiration) return "expired";
5832
- if (state.approvalCount < state.threshold) return "pending";
5833
- const executableAfter = executableAfterOf(state.approvedAt, state.executionDelay);
5834
- if (state.executionDelay > 0 && (state.approvedAt === 0 || at < executableAfter)) {
5835
- return "timelocked";
5836
- }
5837
- return "ready";
5838
- }
5839
- function isTerminal(status) {
5840
- return status === "executed" || status === "failed" || status === "cancelled" || status === "expired";
5841
- }
5842
- function deriveRecoveryStatus(state, at = nowSeconds()) {
5843
- if (state.executed) return "executed";
5844
- if (state.expiration > 0 && at > state.expiration) return "expired";
5845
- if (state.approvalCount < state.requiredThreshold) return "pending";
5846
- if (at < state.executionTime) return "timelocked";
5847
- return "ready";
5848
- }
5849
-
5850
5858
  // src/recovery.ts
5851
5859
  var RecoveryModule = class {
5852
5860
  constructor(ctx) {
@@ -5876,6 +5884,10 @@ var RecoveryModule = class {
5876
5884
  * them gates a write, so none of them should be the one read that silently skips
5877
5885
  * transient-failure handling.
5878
5886
  */
5887
+ /** Now, in Unix seconds, for anything compared against a chain timestamp. */
5888
+ now() {
5889
+ return (this.ctx.now ?? nowSeconds)();
5890
+ }
5879
5891
  vaultContract() {
5880
5892
  return new VaultContract(this.ctx.connection.vault(this.vault), this.ctx.connection.retry);
5881
5893
  }
@@ -5963,7 +5975,7 @@ var RecoveryModule = class {
5963
5975
  requiredThreshold: row.required_threshold,
5964
5976
  executionTime: Number(row.execution_time),
5965
5977
  expiration: Number(row.expiration ?? 0)
5966
- }),
5978
+ }, this.now()),
5967
5979
  executed: row.status === "executed",
5968
5980
  initiator: (0, import_quais9.getAddress)(row.initiator_address),
5969
5981
  source: "indexer"
@@ -6072,7 +6084,7 @@ var RecoveryModule = class {
6072
6084
  `Not enough guardian approvals: ${request.approvalCount} of ${request.requiredThreshold}.`
6073
6085
  );
6074
6086
  }
6075
- const now = nowSeconds();
6087
+ const now = this.now();
6076
6088
  if (now < request.executionTime) {
6077
6089
  throw new PreconditionError(
6078
6090
  `The recovery period has not elapsed. Executable after ${new Date(request.executionTime * 1e3).toISOString()}.`,
@@ -6117,7 +6129,7 @@ var RecoveryModule = class {
6117
6129
  // Affordances
6118
6130
  // =========================================================================
6119
6131
  /** What `caller` may do to a recovery right now, and when blocked actions unlock. */
6120
- async affordances(recoveryHash, caller) {
6132
+ async affordances(recoveryHash, caller, at) {
6121
6133
  const hash = normalizeTxHash(recoveryHash, "recovery hash");
6122
6134
  const who = caller ?? await this.ctx.connection.addressOrNull();
6123
6135
  if (!who) {
@@ -6133,7 +6145,7 @@ var RecoveryModule = class {
6133
6145
  this.isEnabled(),
6134
6146
  this.vaultContract().isOwner(address2)
6135
6147
  ]);
6136
- const now = nowSeconds();
6148
+ const now = at ?? this.now();
6137
6149
  const terminal = request.status === "executed" || request.status === "cancelled";
6138
6150
  const expired = now > request.expiration && request.expiration > 0;
6139
6151
  const quorum = request.approvalCount >= request.requiredThreshold;
@@ -6230,13 +6242,10 @@ var RecoveryModule = class {
6230
6242
  const approvalCount = Number(raw.approvalCount ?? 0);
6231
6243
  const requiredThreshold = Number(raw.requiredThreshold ?? 0);
6232
6244
  const executed = Boolean(raw.executed);
6233
- const status = deriveRecoveryStatus({
6234
- executed,
6235
- approvalCount,
6236
- requiredThreshold,
6237
- executionTime,
6238
- expiration
6239
- });
6245
+ const status = deriveRecoveryStatus(
6246
+ { executed, approvalCount, requiredThreshold, executionTime, expiration },
6247
+ this.now()
6248
+ );
6240
6249
  return {
6241
6250
  hash,
6242
6251
  vault: this.vault,
@@ -6277,7 +6286,7 @@ var RecoveryModule = class {
6277
6286
  if (request.executed) {
6278
6287
  throw new PreconditionError("This recovery has already been executed.");
6279
6288
  }
6280
- if (request.expiration > 0 && nowSeconds() > request.expiration) {
6289
+ if (request.expiration > 0 && this.now() > request.expiration) {
6281
6290
  throw new PreconditionError("This recovery has expired.", {
6282
6291
  remediation: "Call expire() to clean it up, then initiate a new recovery."
6283
6292
  });
@@ -7039,8 +7048,9 @@ function classifyExecution(receipt, vault2, txHash) {
7039
7048
  }
7040
7049
  const thresholdReached = eventFor(events, "ThresholdReached", txHash);
7041
7050
  if (thresholdReached) {
7051
+ const approvedAt = toNumber2(thresholdReached.args.approvedAt);
7042
7052
  const executableAfter = toNumber2(thresholdReached.args.executableAfter);
7043
- if (executableAfter > Math.floor(Date.now() / 1e3)) {
7053
+ if (executableAfter > approvedAt) {
7044
7054
  return {
7045
7055
  ...base,
7046
7056
  outcome: "timelock_started",
@@ -7082,7 +7092,8 @@ var Vault = class _Vault {
7082
7092
  connection: ctx.connection,
7083
7093
  queries: ctx.queries,
7084
7094
  vaultAddress: this.address,
7085
- moduleAddress: ctx.contracts.socialRecovery
7095
+ moduleAddress: ctx.contracts.socialRecovery,
7096
+ ...ctx.now ? { now: ctx.now } : {}
7086
7097
  });
7087
7098
  }
7088
7099
  // =========================================================================
@@ -7128,6 +7139,15 @@ var Vault = class _Vault {
7128
7139
  const health = await this.ctx.indexer?.health();
7129
7140
  return health?.available ? health.lastIndexedBlock : void 0;
7130
7141
  }
7142
+ /**
7143
+ * Now, in Unix seconds, for anything compared against a chain timestamp.
7144
+ *
7145
+ * Never used to measure elapsed time — see the duration sites in
7146
+ * {@link waitForExecutable}, which stay on the raw local clock deliberately.
7147
+ */
7148
+ now() {
7149
+ return (this.ctx.now ?? nowSeconds)();
7150
+ }
7131
7151
  contract(write = false) {
7132
7152
  return new VaultContract(this.ctx.connection.vault(this.address, write), this.ctx.connection.retry);
7133
7153
  }
@@ -7178,7 +7198,7 @@ var Vault = class _Vault {
7178
7198
  owners,
7179
7199
  threshold,
7180
7200
  ...indexedAtBlock !== void 0 ? { indexedAtBlock } : {},
7181
- capturedAt: nowSeconds(),
7201
+ capturedAt: this.now(),
7182
7202
  source: fromIndexer ? "indexer" : "chain"
7183
7203
  };
7184
7204
  }
@@ -7221,6 +7241,21 @@ var Vault = class _Vault {
7221
7241
  async isOwner(address2) {
7222
7242
  return this.contract().isOwner((0, import_quais14.getAddress)(address2));
7223
7243
  }
7244
+ /**
7245
+ * Whether `owner`'s approval on `txHash` currently counts toward the threshold.
7246
+ *
7247
+ * Reads the chain, and accounts for approval-epoch invalidation: removing an owner
7248
+ * bumps the vault's epoch and voids every approval that address made, so a
7249
+ * confirmation the indexer still lists may already be dead. This is the authoritative
7250
+ * answer.
7251
+ *
7252
+ * Public because it is one of the two inputs {@link computeAffordances} needs. Without
7253
+ * it a consumer wanting affordances at an adjusted time had no way to assemble an
7254
+ * `AffordanceContext` short of reimplementing this method against the raw contract.
7255
+ */
7256
+ async hasApproved(txHash, owner) {
7257
+ return this.contract().hasApproved(normalizeTxHash(txHash), (0, import_quais14.getAddress)(owner));
7258
+ }
7224
7259
  /** Approvals required to execute. Prefers a pinned view, then the indexer, then chain. */
7225
7260
  async threshold() {
7226
7261
  if (this.ctx.view) return this.ctx.view.threshold;
@@ -7444,7 +7479,7 @@ var Vault = class _Vault {
7444
7479
  approvalCount,
7445
7480
  threshold,
7446
7481
  failed: row.status === "failed"
7447
- });
7482
+ }, this.now());
7448
7483
  const failedReturnData = row.failed_return_data ?? void 0;
7449
7484
  return {
7450
7485
  hash: row.tx_hash,
@@ -7527,7 +7562,7 @@ var Vault = class _Vault {
7527
7562
  approvedAt,
7528
7563
  approvalCount: approvals.length,
7529
7564
  threshold: Number(threshold)
7530
- }),
7565
+ }, this.now()),
7531
7566
  approvals,
7532
7567
  approvalCount: approvals.length,
7533
7568
  threshold: Number(threshold),
@@ -7545,9 +7580,9 @@ var Vault = class _Vault {
7545
7580
  * What `caller` may legally do to `txHash` right now, and when blocked actions
7546
7581
  * become available. Defaults to the connected signer's address.
7547
7582
  */
7548
- async affordances(txHash, caller) {
7583
+ async affordances(txHash, caller, at) {
7549
7584
  const hash = normalizeTxHash(txHash);
7550
- return this.resolveAffordances(hash, this.transaction(hash), caller);
7585
+ return this.resolveAffordances(hash, this.transaction(hash), caller, at);
7551
7586
  }
7552
7587
  /**
7553
7588
  * Shared body of {@link affordances}, taking the transaction either as a value or as
@@ -7558,7 +7593,7 @@ var Vault = class _Vault {
7558
7593
  * passes the unawaited promise so the transaction read still overlaps the two
7559
7594
  * ownership probes rather than serialising behind them.
7560
7595
  */
7561
- async resolveAffordances(hash, transaction, caller) {
7596
+ async resolveAffordances(hash, transaction, caller, at) {
7562
7597
  const who = caller ?? await this.ctx.connection.addressOrNull();
7563
7598
  if (!who) {
7564
7599
  throw new ValidationError(
@@ -7571,7 +7606,13 @@ var Vault = class _Vault {
7571
7606
  vault2.isOwner((0, import_quais14.getAddress)(who)),
7572
7607
  vault2.hasApproved(hash, (0, import_quais14.getAddress)(who))
7573
7608
  ]);
7574
- return computeAffordances({ tx, caller: (0, import_quais14.getAddress)(who), isOwner, hasApproved });
7609
+ return computeAffordances({
7610
+ tx,
7611
+ caller: (0, import_quais14.getAddress)(who),
7612
+ isOwner,
7613
+ hasApproved,
7614
+ at: at ?? this.now()
7615
+ });
7575
7616
  }
7576
7617
  // =========================================================================
7577
7618
  // Proposals
@@ -7782,11 +7823,11 @@ var Vault = class _Vault {
7782
7823
  }
7783
7824
  if (expiration > 0) {
7784
7825
  const floor = isSelfCall ? 0 : Math.max(executionDelay, await this.minExecutionDelay());
7785
- const earliest = minimumExpiration(floor, 0);
7826
+ const earliest = minimumExpiration(floor, 0, this.now());
7786
7827
  if (expiration <= earliest) {
7787
7828
  throw new ValidationError(
7788
7829
  `expiration ${expiration} is too soon: with an effective delay of ${floor}s the vault requires an expiration after ${earliest}.`,
7789
- `Use at least ${minimumExpiration(floor)} to leave a margin for block time.`
7830
+ `Use at least ${minimumExpiration(floor, 300, this.now())} to leave a margin for block time.`
7790
7831
  );
7791
7832
  }
7792
7833
  }
@@ -8125,7 +8166,7 @@ var Vault = class _Vault {
8125
8166
  { remediation: "Collect the remaining approvals, then wait for the timelock." }
8126
8167
  );
8127
8168
  }
8128
- const untilExecutable = tx.executableAfter > 0 ? tx.executableAfter * 1e3 - Date.now() : pollIntervalMs;
8169
+ const untilExecutable = tx.executableAfter > 0 ? (tx.executableAfter - this.now()) * 1e3 : pollIntervalMs;
8129
8170
  const wait = Math.max(1e3, Math.min(untilExecutable, pollIntervalMs));
8130
8171
  if (Date.now() + wait > deadline) {
8131
8172
  throw new PreconditionError(
@@ -8155,7 +8196,7 @@ var Vault = class _Vault {
8155
8196
  lines.push(` quorum reached: ${new Date(tx.approvedAt * 1e3).toISOString()}`);
8156
8197
  }
8157
8198
  if (tx.executableAfter > 0) {
8158
- const remaining = tx.executableAfter - nowSeconds();
8199
+ const remaining = tx.executableAfter - this.now();
8159
8200
  lines.push(
8160
8201
  ` executable after: ${new Date(tx.executableAfter * 1e3).toISOString()}` + (remaining > 0 ? ` (in ${remaining}s)` : " (now)")
8161
8202
  );
@@ -8200,6 +8241,11 @@ function toRevertError(err, context) {
8200
8241
 
8201
8242
  // src/client.ts
8202
8243
  var QuaiVaultClient = class {
8244
+ /**
8245
+ * Source of "now" for time compared against chain timestamps. Resolved once from
8246
+ * {@link ClientOptions.now}, defaulting to the local clock. See {@link Clock}.
8247
+ */
8248
+ now;
8203
8249
  /**
8204
8250
  * Resolved configuration, with the private key stripped and the indexer key
8205
8251
  * masked — safe to log. The key itself is consumed once when building the signer.
@@ -8212,6 +8258,7 @@ var QuaiVaultClient = class {
8212
8258
  constructor(options = {}) {
8213
8259
  const resolved = resolveConfig(options);
8214
8260
  this.config = redactConfig(resolved);
8261
+ this.now = resolved.now;
8215
8262
  this.connection = new Connection(resolved, {
8216
8263
  ...options.provider ? { provider: options.provider } : {},
8217
8264
  ...options.signer ? { signer: options.signer } : {}
@@ -8247,7 +8294,8 @@ var QuaiVaultClient = class {
8247
8294
  queries: this.queries,
8248
8295
  contracts: this.config.contracts,
8249
8296
  consistency: this.config.consistency,
8250
- maxIndexerLagBlocks: this.config.maxIndexerLagBlocks
8297
+ maxIndexerLagBlocks: this.config.maxIndexerLagBlocks,
8298
+ now: this.now
8251
8299
  };
8252
8300
  }
8253
8301
  /** Vault discovery. Requires the indexer — there is no on-chain reverse index. */