@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/dist/index.js CHANGED
@@ -219,6 +219,36 @@ function normalizeTxHash(value, label = "transaction hash") {
219
219
  // src/config/resolve.ts
220
220
  import { isAddress } from "quais";
221
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
+
222
252
  // src/config/networks.ts
223
253
  var INDEXER_URL = "https://xbftgyuxaxagptudledv.supabase.co";
224
254
  var INDEXER_PUBLISHABLE_KEY = "sb_publishable_EO-sTB-jqUzlsiugOEks-Q_qRtHDaHU";
@@ -418,6 +448,9 @@ function resolveConfig(options = {}) {
418
448
  privateKey: options.signer ? void 0 : pick(options.privateKey, env[ENV_VARS.privateKey]),
419
449
  consistency,
420
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,
421
454
  retry: {
422
455
  ...options.retry?.maxAttempts != null ? { maxAttempts: options.retry.maxAttempts } : {},
423
456
  ...options.retry?.baseDelayMs != null ? { baseDelayMs: options.retry.baseDelayMs } : {},
@@ -1092,7 +1125,7 @@ function encodeMultiSendPayload(calls) {
1092
1125
  function encodeMultiSend(calls) {
1093
1126
  return multiSend.encodeFunctionData("multiSend", [encodeMultiSendPayload(calls)]);
1094
1127
  }
1095
- function minimumExpiration(effectiveDelaySeconds, marginSeconds = 300, at = Math.floor(Date.now() / 1e3)) {
1128
+ function minimumExpiration(effectiveDelaySeconds, marginSeconds = 300, at = nowSeconds()) {
1096
1129
  return at + effectiveDelaySeconds + marginSeconds;
1097
1130
  }
1098
1131
 
@@ -1774,14 +1807,19 @@ function toNumber(value, fallback = 0) {
1774
1807
  }
1775
1808
 
1776
1809
  // src/indexer/client.ts
1777
- var SDK_VERSION = true ? "0.2.0" : "dev";
1810
+ var SDK_VERSION = true ? "0.2.1" : "dev";
1778
1811
  var IndexerClient = class {
1779
1812
  config;
1780
1813
  client;
1781
1814
  realtime = null;
1782
1815
  healthCache = null;
1783
1816
  inflightHealth = null;
1784
- /** Health results are reused for this long to keep read paths cheap. */
1817
+ /**
1818
+ * Health results are reused for this long to keep read paths cheap.
1819
+ *
1820
+ * This and `waitForBlock`'s deadline are elapsed-time arithmetic, so they use the
1821
+ * raw local clock rather than `ClientOptions.now` — see the note in `chain/retry.ts`.
1822
+ */
1785
1823
  healthCacheMs;
1786
1824
  constructor(config, options = {}) {
1787
1825
  this.config = config;
@@ -2389,36 +2427,6 @@ var RecoveryContract = class {
2389
2427
  }
2390
2428
  };
2391
2429
 
2392
- // src/lifecycle/status.ts
2393
- function nowSeconds() {
2394
- return Math.floor(Date.now() / 1e3);
2395
- }
2396
- function executableAfterOf(approvedAt, executionDelay) {
2397
- return approvedAt > 0 ? approvedAt + executionDelay : 0;
2398
- }
2399
- function deriveStatus(state, at = nowSeconds()) {
2400
- if (state.failed) return "failed";
2401
- if (state.executed) return "executed";
2402
- if (state.cancelled) return state.isExpired ? "expired" : "cancelled";
2403
- if (state.expiration > 0 && at > state.expiration) return "expired";
2404
- if (state.approvalCount < state.threshold) return "pending";
2405
- const executableAfter = executableAfterOf(state.approvedAt, state.executionDelay);
2406
- if (state.executionDelay > 0 && (state.approvedAt === 0 || at < executableAfter)) {
2407
- return "timelocked";
2408
- }
2409
- return "ready";
2410
- }
2411
- function isTerminal(status) {
2412
- return status === "executed" || status === "failed" || status === "cancelled" || status === "expired";
2413
- }
2414
- function deriveRecoveryStatus(state, at = nowSeconds()) {
2415
- if (state.executed) return "executed";
2416
- if (state.expiration > 0 && at > state.expiration) return "expired";
2417
- if (state.approvalCount < state.requiredThreshold) return "pending";
2418
- if (at < state.executionTime) return "timelocked";
2419
- return "ready";
2420
- }
2421
-
2422
2430
  // src/recovery.ts
2423
2431
  var RecoveryModule = class {
2424
2432
  constructor(ctx) {
@@ -2448,6 +2456,10 @@ var RecoveryModule = class {
2448
2456
  * them gates a write, so none of them should be the one read that silently skips
2449
2457
  * transient-failure handling.
2450
2458
  */
2459
+ /** Now, in Unix seconds, for anything compared against a chain timestamp. */
2460
+ now() {
2461
+ return (this.ctx.now ?? nowSeconds)();
2462
+ }
2451
2463
  vaultContract() {
2452
2464
  return new VaultContract(this.ctx.connection.vault(this.vault), this.ctx.connection.retry);
2453
2465
  }
@@ -2535,7 +2547,7 @@ var RecoveryModule = class {
2535
2547
  requiredThreshold: row.required_threshold,
2536
2548
  executionTime: Number(row.execution_time),
2537
2549
  expiration: Number(row.expiration ?? 0)
2538
- }),
2550
+ }, this.now()),
2539
2551
  executed: row.status === "executed",
2540
2552
  initiator: getAddress3(row.initiator_address),
2541
2553
  source: "indexer"
@@ -2644,7 +2656,7 @@ var RecoveryModule = class {
2644
2656
  `Not enough guardian approvals: ${request.approvalCount} of ${request.requiredThreshold}.`
2645
2657
  );
2646
2658
  }
2647
- const now = nowSeconds();
2659
+ const now = this.now();
2648
2660
  if (now < request.executionTime) {
2649
2661
  throw new PreconditionError(
2650
2662
  `The recovery period has not elapsed. Executable after ${new Date(request.executionTime * 1e3).toISOString()}.`,
@@ -2689,7 +2701,7 @@ var RecoveryModule = class {
2689
2701
  // Affordances
2690
2702
  // =========================================================================
2691
2703
  /** What `caller` may do to a recovery right now, and when blocked actions unlock. */
2692
- async affordances(recoveryHash, caller) {
2704
+ async affordances(recoveryHash, caller, at) {
2693
2705
  const hash = normalizeTxHash(recoveryHash, "recovery hash");
2694
2706
  const who = caller ?? await this.ctx.connection.addressOrNull();
2695
2707
  if (!who) {
@@ -2705,7 +2717,7 @@ var RecoveryModule = class {
2705
2717
  this.isEnabled(),
2706
2718
  this.vaultContract().isOwner(address2)
2707
2719
  ]);
2708
- const now = nowSeconds();
2720
+ const now = at ?? this.now();
2709
2721
  const terminal = request.status === "executed" || request.status === "cancelled";
2710
2722
  const expired = now > request.expiration && request.expiration > 0;
2711
2723
  const quorum = request.approvalCount >= request.requiredThreshold;
@@ -2802,13 +2814,10 @@ var RecoveryModule = class {
2802
2814
  const approvalCount = Number(raw.approvalCount ?? 0);
2803
2815
  const requiredThreshold = Number(raw.requiredThreshold ?? 0);
2804
2816
  const executed = Boolean(raw.executed);
2805
- const status = deriveRecoveryStatus({
2806
- executed,
2807
- approvalCount,
2808
- requiredThreshold,
2809
- executionTime,
2810
- expiration
2811
- });
2817
+ const status = deriveRecoveryStatus(
2818
+ { executed, approvalCount, requiredThreshold, executionTime, expiration },
2819
+ this.now()
2820
+ );
2812
2821
  return {
2813
2822
  hash,
2814
2823
  vault: this.vault,
@@ -2849,7 +2858,7 @@ var RecoveryModule = class {
2849
2858
  if (request.executed) {
2850
2859
  throw new PreconditionError("This recovery has already been executed.");
2851
2860
  }
2852
- if (request.expiration > 0 && nowSeconds() > request.expiration) {
2861
+ if (request.expiration > 0 && this.now() > request.expiration) {
2853
2862
  throw new PreconditionError("This recovery has expired.", {
2854
2863
  remediation: "Call expire() to clean it up, then initiate a new recovery."
2855
2864
  });
@@ -3611,8 +3620,9 @@ function classifyExecution(receipt, vault2, txHash) {
3611
3620
  }
3612
3621
  const thresholdReached = eventFor(events, "ThresholdReached", txHash);
3613
3622
  if (thresholdReached) {
3623
+ const approvedAt = toNumber2(thresholdReached.args.approvedAt);
3614
3624
  const executableAfter = toNumber2(thresholdReached.args.executableAfter);
3615
- if (executableAfter > Math.floor(Date.now() / 1e3)) {
3625
+ if (executableAfter > approvedAt) {
3616
3626
  return {
3617
3627
  ...base,
3618
3628
  outcome: "timelock_started",
@@ -3654,7 +3664,8 @@ var Vault = class _Vault {
3654
3664
  connection: ctx.connection,
3655
3665
  queries: ctx.queries,
3656
3666
  vaultAddress: this.address,
3657
- moduleAddress: ctx.contracts.socialRecovery
3667
+ moduleAddress: ctx.contracts.socialRecovery,
3668
+ ...ctx.now ? { now: ctx.now } : {}
3658
3669
  });
3659
3670
  }
3660
3671
  // =========================================================================
@@ -3700,6 +3711,15 @@ var Vault = class _Vault {
3700
3711
  const health = await this.ctx.indexer?.health();
3701
3712
  return health?.available ? health.lastIndexedBlock : void 0;
3702
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
+ }
3703
3723
  contract(write = false) {
3704
3724
  return new VaultContract(this.ctx.connection.vault(this.address, write), this.ctx.connection.retry);
3705
3725
  }
@@ -3750,7 +3770,7 @@ var Vault = class _Vault {
3750
3770
  owners,
3751
3771
  threshold,
3752
3772
  ...indexedAtBlock !== void 0 ? { indexedAtBlock } : {},
3753
- capturedAt: nowSeconds(),
3773
+ capturedAt: this.now(),
3754
3774
  source: fromIndexer ? "indexer" : "chain"
3755
3775
  };
3756
3776
  }
@@ -3793,6 +3813,21 @@ var Vault = class _Vault {
3793
3813
  async isOwner(address2) {
3794
3814
  return this.contract().isOwner(getAddress5(address2));
3795
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
+ }
3796
3831
  /** Approvals required to execute. Prefers a pinned view, then the indexer, then chain. */
3797
3832
  async threshold() {
3798
3833
  if (this.ctx.view) return this.ctx.view.threshold;
@@ -4016,7 +4051,7 @@ var Vault = class _Vault {
4016
4051
  approvalCount,
4017
4052
  threshold,
4018
4053
  failed: row.status === "failed"
4019
- });
4054
+ }, this.now());
4020
4055
  const failedReturnData = row.failed_return_data ?? void 0;
4021
4056
  return {
4022
4057
  hash: row.tx_hash,
@@ -4099,7 +4134,7 @@ var Vault = class _Vault {
4099
4134
  approvedAt,
4100
4135
  approvalCount: approvals.length,
4101
4136
  threshold: Number(threshold)
4102
- }),
4137
+ }, this.now()),
4103
4138
  approvals,
4104
4139
  approvalCount: approvals.length,
4105
4140
  threshold: Number(threshold),
@@ -4117,9 +4152,9 @@ var Vault = class _Vault {
4117
4152
  * What `caller` may legally do to `txHash` right now, and when blocked actions
4118
4153
  * become available. Defaults to the connected signer's address.
4119
4154
  */
4120
- async affordances(txHash, caller) {
4155
+ async affordances(txHash, caller, at) {
4121
4156
  const hash = normalizeTxHash(txHash);
4122
- return this.resolveAffordances(hash, this.transaction(hash), caller);
4157
+ return this.resolveAffordances(hash, this.transaction(hash), caller, at);
4123
4158
  }
4124
4159
  /**
4125
4160
  * Shared body of {@link affordances}, taking the transaction either as a value or as
@@ -4130,7 +4165,7 @@ var Vault = class _Vault {
4130
4165
  * passes the unawaited promise so the transaction read still overlaps the two
4131
4166
  * ownership probes rather than serialising behind them.
4132
4167
  */
4133
- async resolveAffordances(hash, transaction, caller) {
4168
+ async resolveAffordances(hash, transaction, caller, at) {
4134
4169
  const who = caller ?? await this.ctx.connection.addressOrNull();
4135
4170
  if (!who) {
4136
4171
  throw new ValidationError(
@@ -4143,7 +4178,13 @@ var Vault = class _Vault {
4143
4178
  vault2.isOwner(getAddress5(who)),
4144
4179
  vault2.hasApproved(hash, getAddress5(who))
4145
4180
  ]);
4146
- return computeAffordances({ tx, caller: getAddress5(who), isOwner, hasApproved });
4181
+ return computeAffordances({
4182
+ tx,
4183
+ caller: getAddress5(who),
4184
+ isOwner,
4185
+ hasApproved,
4186
+ at: at ?? this.now()
4187
+ });
4147
4188
  }
4148
4189
  // =========================================================================
4149
4190
  // Proposals
@@ -4354,11 +4395,11 @@ var Vault = class _Vault {
4354
4395
  }
4355
4396
  if (expiration > 0) {
4356
4397
  const floor = isSelfCall ? 0 : Math.max(executionDelay, await this.minExecutionDelay());
4357
- const earliest = minimumExpiration(floor, 0);
4398
+ const earliest = minimumExpiration(floor, 0, this.now());
4358
4399
  if (expiration <= earliest) {
4359
4400
  throw new ValidationError(
4360
4401
  `expiration ${expiration} is too soon: with an effective delay of ${floor}s the vault requires an expiration after ${earliest}.`,
4361
- `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.`
4362
4403
  );
4363
4404
  }
4364
4405
  }
@@ -4697,7 +4738,7 @@ var Vault = class _Vault {
4697
4738
  { remediation: "Collect the remaining approvals, then wait for the timelock." }
4698
4739
  );
4699
4740
  }
4700
- const untilExecutable = tx.executableAfter > 0 ? tx.executableAfter * 1e3 - Date.now() : pollIntervalMs;
4741
+ const untilExecutable = tx.executableAfter > 0 ? (tx.executableAfter - this.now()) * 1e3 : pollIntervalMs;
4701
4742
  const wait = Math.max(1e3, Math.min(untilExecutable, pollIntervalMs));
4702
4743
  if (Date.now() + wait > deadline) {
4703
4744
  throw new PreconditionError(
@@ -4727,7 +4768,7 @@ var Vault = class _Vault {
4727
4768
  lines.push(` quorum reached: ${new Date(tx.approvedAt * 1e3).toISOString()}`);
4728
4769
  }
4729
4770
  if (tx.executableAfter > 0) {
4730
- const remaining = tx.executableAfter - nowSeconds();
4771
+ const remaining = tx.executableAfter - this.now();
4731
4772
  lines.push(
4732
4773
  ` executable after: ${new Date(tx.executableAfter * 1e3).toISOString()}` + (remaining > 0 ? ` (in ${remaining}s)` : " (now)")
4733
4774
  );
@@ -4772,6 +4813,11 @@ function toRevertError(err, context) {
4772
4813
 
4773
4814
  // src/client.ts
4774
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;
4775
4821
  /**
4776
4822
  * Resolved configuration, with the private key stripped and the indexer key
4777
4823
  * masked — safe to log. The key itself is consumed once when building the signer.
@@ -4784,6 +4830,7 @@ var QuaiVaultClient = class {
4784
4830
  constructor(options = {}) {
4785
4831
  const resolved = resolveConfig(options);
4786
4832
  this.config = redactConfig(resolved);
4833
+ this.now = resolved.now;
4787
4834
  this.connection = new Connection(resolved, {
4788
4835
  ...options.provider ? { provider: options.provider } : {},
4789
4836
  ...options.signer ? { signer: options.signer } : {}
@@ -4819,7 +4866,8 @@ var QuaiVaultClient = class {
4819
4866
  queries: this.queries,
4820
4867
  contracts: this.config.contracts,
4821
4868
  consistency: this.config.consistency,
4822
- maxIndexerLagBlocks: this.config.maxIndexerLagBlocks
4869
+ maxIndexerLagBlocks: this.config.maxIndexerLagBlocks,
4870
+ now: this.now
4823
4871
  };
4824
4872
  }
4825
4873
  /** Vault discovery. Requires the indexer — there is no on-chain reverse index. */