@hyperbridge/simplex 0.16.5 → 0.16.7

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
@@ -2402,6 +2402,7 @@ function closedKey(chain, vault) {
2402
2402
  // src/config/external-links.json
2403
2403
  var external_links_default = {
2404
2404
  hyperfxApp: "https://app.hyperfx.finance",
2405
+ alchemyDashboard: "https://dashboard.alchemy.com/",
2405
2406
  hyperbridgeExplorers: {
2406
2407
  mainnet: "https://nexus.statescan.io",
2407
2408
  testnet: "https://gargantua.statescan.io"
@@ -2421,6 +2422,52 @@ var external_links_default = {
2421
2422
  };
2422
2423
 
2423
2424
  // src/cli/init/chains.ts
2425
+ var FREE_RPC_URLS = {
2426
+ 1: [
2427
+ "https://mainnet.gateway.tenderly.co",
2428
+ "https://rpc.mevblocker.io",
2429
+ "https://ethereum-rpc.publicnode.com",
2430
+ "https://ethereum.public.blockpi.network/v1/rpc/public",
2431
+ "https://eth.blockrazor.xyz",
2432
+ "https://eth.api.pocket.network",
2433
+ "https://ethereum-public.nodies.app",
2434
+ "https://eth-mainnet.public.blastapi.io",
2435
+ "https://public-eth.nownodes.io"
2436
+ ],
2437
+ 56: [
2438
+ "https://bsc-rpc.publicnode.com",
2439
+ "https://public-bsc-mainnet.fastnode.io",
2440
+ "https://public-bsc.nownodes.io",
2441
+ "https://bsc.blockrazor.xyz",
2442
+ "https://rpc-bsc.48.club",
2443
+ "https://bsc.api.pocket.network"
2444
+ ],
2445
+ 137: [
2446
+ "https://polygon-bor-rpc.publicnode.com",
2447
+ "https://polygon.drpc.org",
2448
+ "https://polygon.gateway.tenderly.co",
2449
+ "https://rpc.private.mev-x.com/polygon",
2450
+ "https://poly.api.pocket.network"
2451
+ ],
2452
+ 8453: [
2453
+ "https://base-rpc.publicnode.com",
2454
+ "https://base.gateway.tenderly.co",
2455
+ "https://base.public.blockpi.network/v1/rpc/public",
2456
+ "https://base.rpc.blxrbdn.com",
2457
+ "https://rpc.baseazul.dev",
2458
+ "https://mainnet.base.org",
2459
+ "https://base-public.nodies.app"
2460
+ ],
2461
+ 42161: [
2462
+ "https://arbitrum-one-rpc.publicnode.com",
2463
+ "https://arbitrum.gateway.tenderly.co",
2464
+ "https://arbitrum.drpc.org",
2465
+ "https://public-arb-mainnet.fastnode.io",
2466
+ "https://arb-one.api.pocket.network",
2467
+ "https://arbitrum-one-public.nodies.app",
2468
+ "https://arb1.arbitrum.io/rpc"
2469
+ ]
2470
+ };
2424
2471
  var CHAIN_NATIVE_SYMBOLS = {
2425
2472
  1: "ETH",
2426
2473
  56: "BNB",
@@ -2448,7 +2495,8 @@ var INIT_CHAINS = [
2448
2495
  label: "Ethereum",
2449
2496
  network: "mainnet",
2450
2497
  alchemySubdomain: "eth-mainnet",
2451
- explorerUrl: external_links_default.chainExplorers.ethereum
2498
+ explorerUrl: external_links_default.chainExplorers.ethereum,
2499
+ defaultRpcUrls: FREE_RPC_URLS[1]
2452
2500
  },
2453
2501
  {
2454
2502
  chainId: 42161,
@@ -2456,7 +2504,8 @@ var INIT_CHAINS = [
2456
2504
  label: "Arbitrum",
2457
2505
  network: "mainnet",
2458
2506
  alchemySubdomain: "arb-mainnet",
2459
- explorerUrl: external_links_default.chainExplorers.arbitrum
2507
+ explorerUrl: external_links_default.chainExplorers.arbitrum,
2508
+ defaultRpcUrls: FREE_RPC_URLS[42161]
2460
2509
  },
2461
2510
  {
2462
2511
  chainId: 8453,
@@ -2464,7 +2513,8 @@ var INIT_CHAINS = [
2464
2513
  label: "Base",
2465
2514
  network: "mainnet",
2466
2515
  alchemySubdomain: "base-mainnet",
2467
- explorerUrl: external_links_default.chainExplorers.base
2516
+ explorerUrl: external_links_default.chainExplorers.base,
2517
+ defaultRpcUrls: FREE_RPC_URLS[8453]
2468
2518
  },
2469
2519
  {
2470
2520
  chainId: 137,
@@ -2472,7 +2522,8 @@ var INIT_CHAINS = [
2472
2522
  label: "Polygon",
2473
2523
  network: "mainnet",
2474
2524
  alchemySubdomain: "polygon-mainnet",
2475
- explorerUrl: external_links_default.chainExplorers.polygon
2525
+ explorerUrl: external_links_default.chainExplorers.polygon,
2526
+ defaultRpcUrls: FREE_RPC_URLS[137]
2476
2527
  },
2477
2528
  {
2478
2529
  chainId: 56,
@@ -2480,7 +2531,8 @@ var INIT_CHAINS = [
2480
2531
  label: "BNB Chain",
2481
2532
  network: "mainnet",
2482
2533
  alchemySubdomain: "bnb-mainnet",
2483
- explorerUrl: external_links_default.chainExplorers.bnb
2534
+ explorerUrl: external_links_default.chainExplorers.bnb,
2535
+ defaultRpcUrls: FREE_RPC_URLS[56]
2484
2536
  },
2485
2537
  {
2486
2538
  chainId: 11155111,
@@ -2597,10 +2649,16 @@ var CANCEL_ORDER_MUTATION = `
2597
2649
  `;
2598
2650
  var OrderbookRequestError = class extends Error {
2599
2651
  };
2652
+ function graphqlEndpoint(url) {
2653
+ const trimmed = url.trim();
2654
+ if (trimmed.includes("?")) return trimmed;
2655
+ const withoutTrailingSlash = trimmed.replace(/\/+$/, "");
2656
+ return withoutTrailingSlash.endsWith("/graphql") ? withoutTrailingSlash : `${withoutTrailingSlash}/graphql`;
2657
+ }
2600
2658
  var OrderbookClient = class {
2601
2659
  constructor(url, requestTimeoutMs, loggers = defaultLoggerContext()) {
2602
- this.url = url;
2603
2660
  this.requestTimeoutMs = requestTimeoutMs;
2661
+ this.url = graphqlEndpoint(url);
2604
2662
  this.logger = loggers.get("orderbook");
2605
2663
  }
2606
2664
  /** The limits to validate against before posting, plus the books on offer. */
@@ -2698,7 +2756,9 @@ var OrderbookClient = class {
2698
2756
  clearTimeout(timer);
2699
2757
  }
2700
2758
  if (!response.ok) {
2701
- throw new OrderbookRequestError(`Orderbook returned HTTP ${response.status} ${response.statusText}`);
2759
+ throw new OrderbookRequestError(
2760
+ `Orderbook at ${this.url} returned HTTP ${response.status} ${response.statusText}`
2761
+ );
2702
2762
  }
2703
2763
  const body = await response.json().catch((err) => {
2704
2764
  throw new OrderbookRequestError(`Orderbook returned a body that is not JSON: ${describe(err)}`);
@@ -2813,7 +2873,7 @@ var HEARTBEAT_TYPES = {
2813
2873
  var LimitOrderValidationError = class extends Error {
2814
2874
  };
2815
2875
  var LimitOrderService = class {
2816
- constructor(store, client, contractService, configService, assetRegistry, signer, defaultTtlSecs, delegationService, loggers = defaultLoggerContext(), startingNonce = initialOrderNonce) {
2876
+ constructor(store, client, contractService, configService, assetRegistry, signer, defaultTtlSecs, delegationService, loggers = defaultLoggerContext(), startingNonce = initialOrderNonce, vaultBalances) {
2817
2877
  this.store = store;
2818
2878
  this.client = client;
2819
2879
  this.contractService = contractService;
@@ -2823,6 +2883,7 @@ var LimitOrderService = class {
2823
2883
  this.defaultTtlSecs = defaultTtlSecs;
2824
2884
  this.delegationService = delegationService;
2825
2885
  this.startingNonce = startingNonce;
2886
+ this.vaultBalances = vaultBalances;
2826
2887
  this.logger = loggers.get("limit-orders");
2827
2888
  }
2828
2889
  /**
@@ -2958,6 +3019,18 @@ var LimitOrderService = class {
2958
3019
  throw err;
2959
3020
  }
2960
3021
  }
3022
+ /**
3023
+ * The pairs an order can be written against, the smallest payout each token may carry, and
3024
+ * the tokens the orderbook registers on each chain.
3025
+ *
3026
+ * The orderbook lists the books; `resolveBook` refuses a request naming anything else, so the
3027
+ * operator is offered exactly these and no combination of symbols they could not post. The
3028
+ * chains do the same for the source chains an order can accept.
3029
+ */
3030
+ async books() {
3031
+ const limits = await this.limits();
3032
+ return { books: limits.books, minOrderSizes: limits.serverInfo.minOrderSizes, chains: limits.chains ?? [] };
3033
+ }
2961
3034
  /**
2962
3035
  * Reads `serverInfo` and `books`, reusing the last read for {@link LIMITS_TTL_MS}.
2963
3036
  *
@@ -3038,6 +3111,18 @@ var LimitOrderService = class {
3038
3111
  );
3039
3112
  }
3040
3113
  }
3114
+ if (book.base !== book.quote) {
3115
+ const input = normalizeSymbol(request.tokenIn);
3116
+ const lacking = sources.filter((source) => {
3117
+ const listed = limits.chains?.find((chain) => chain.id === source);
3118
+ return listed && !listed.tokens.some((token) => normalizeSymbol(token.symbol) === input);
3119
+ });
3120
+ if (lacking.length > 0) {
3121
+ throw new LimitOrderValidationError(
3122
+ `The orderbook has no ${request.tokenIn} on ${lacking.join(", ")}, so swaps from there cannot pay this order`
3123
+ );
3124
+ }
3125
+ }
3041
3126
  if (ttlSecs < limits.serverInfo.minOrderTtlSecs) {
3042
3127
  throw new LimitOrderValidationError(
3043
3128
  `ttlSecs must be at least the orderbook's minimum of ${limits.serverInfo.minOrderTtlSecs}; got ${ttlSecs}`
@@ -3065,28 +3150,61 @@ var LimitOrderService = class {
3065
3150
  * balance on the fill chain, so the operator hears it while they are creating
3066
3151
  * the order.
3067
3152
  *
3153
+ * A fill pays out of the wallet and then withdraws any shortfall from the
3154
+ * configured ERC-4626 vaults in the same batch, so what those vaults hold of
3155
+ * the payout token backs the order too and is counted here. Without it an
3156
+ * operator who sweeps inventory into a vault is refused orders their filler
3157
+ * would have filled.
3158
+ *
3068
3159
  * Each order is checked against the whole balance, not against what is left of
3069
3160
  * it after the others. One balance backs every order resting on it — that is
3070
3161
  * what quoting both sides of a book is — and the orderbook says as much: it
3071
3162
  * advertises each entry at `min(quoted, balance)` rather than dividing the
3072
3163
  * balance between them. Whichever order fills first draws the inventory down,
3073
3164
  * and the rest are cut to what is left. Netting them here would refuse the
3074
- * second side of every book.
3165
+ * second side of every book. Vault positions are counted the same way: the
3166
+ * whole position, not what is left after reservations for fills in flight.
3075
3167
  */
3076
3168
  async assertWalletCanPay(symbol, chain, payout) {
3077
3169
  const token = this.assetRegistry.getAddress(symbol, chain);
3078
3170
  if (!token) return;
3079
3171
  const decimals = await this.decimalsFor(symbol, token, chain);
3080
- const balance = toScaled(
3172
+ const wallet = toScaled(
3081
3173
  await this.contractService.getTokenBalance(chain, token, this.signer.address),
3082
3174
  decimals
3083
3175
  );
3084
- if (balance < payout) {
3176
+ const vaults = await this.vaultHoldings(token, chain);
3177
+ if (wallet + vaults < payout) {
3178
+ const held = vaults > 0n ? `${toHuman(wallet)} in the wallet and ${toHuman(vaults)} in vaults` : toHuman(wallet);
3085
3179
  throw new LimitOrderValidationError(
3086
- `The wallet holds ${toHuman(balance)} ${symbol} on ${chain}, which cannot pay out ${toHuman(payout)}`
3180
+ `The wallet holds ${held} ${symbol} on ${chain}, which cannot pay out ${toHuman(payout)}`
3087
3181
  );
3088
3182
  }
3089
3183
  }
3184
+ /**
3185
+ * What the configured vaults hold of `token` on `chain`, in scaled units.
3186
+ *
3187
+ * Zero when no vault is configured. A snapshot that cannot be read is also
3188
+ * zero: the wallet alone then has to cover the order, which refuses one the
3189
+ * filler might have managed rather than accepting one it could not.
3190
+ */
3191
+ async vaultHoldings(token, chain) {
3192
+ if (!this.vaultBalances) return 0n;
3193
+ let positions;
3194
+ try {
3195
+ positions = await this.vaultBalances.getBalanceSnapshot(chain);
3196
+ } catch (err) {
3197
+ this.logger.warn({ chain, token, err }, "Could not read vault balances; backing the order on the wallet alone");
3198
+ return 0n;
3199
+ }
3200
+ const wanted = token.toLowerCase();
3201
+ let total = 0n;
3202
+ for (const position of positions) {
3203
+ if (position.chain !== chain || position.asset.toLowerCase() !== wanted) continue;
3204
+ total += toScaled(position.positionAssets, position.decimals);
3205
+ }
3206
+ return total;
3207
+ }
3090
3208
  /**
3091
3209
  * Works a limit order down by what a fill delivered, and puts the rest back on
3092
3210
  * the orderbook.
@@ -12890,14 +13008,10 @@ var RATE_LIMIT_RPC_CODES = /* @__PURE__ */ new Set([-32005, -32097, -32016, 429]
12890
13008
  var RATE_LIMIT_SUSPENSION_MS = 5 * 6e4;
12891
13009
  var SCAN_BUDGET = {
12892
13010
  timeoutMs: 5e3,
12893
- retryCount: 1,
12894
- retryDelayMs: 500,
12895
13011
  deadlineMs: 12e3
12896
13012
  };
12897
13013
  var CONFIRMATION_BUDGET = {
12898
13014
  timeoutMs: 1e4,
12899
- retryCount: 1,
12900
- retryDelayMs: 500,
12901
13015
  deadlineMs: 3e4
12902
13016
  };
12903
13017
  var MALFORMED_RESPONSE_SUSPENSION_MS = 3e4;
@@ -12989,8 +13103,14 @@ var _QuorumPublicClient = class _QuorumPublicClient {
12989
13103
  chain,
12990
13104
  transport: http(url, {
12991
13105
  timeout: budget.timeoutMs,
12992
- retryCount: budget.retryCount,
12993
- retryDelay: budget.retryDelayMs
13106
+ // No transport-level retry. viem's retry sleeps for the provider's
13107
+ // `Retry-After` verbatim and uncapped, which reaches the socket
13108
+ // before {@link suspensionMsFor} can apply its clamp — and even
13109
+ // without that header, retrying inside the task is what makes a
13110
+ // task's worst case (timeout, backoff, timeout) approach the
13111
+ // deadline that is supposed to bound it. One attempt per endpoint
13112
+ // per call; `retryPromise` above the quorum owns the retry policy.
13113
+ retryCount: 0
12994
13114
  })
12995
13115
  })
12996
13116
  );
@@ -13004,11 +13124,10 @@ var _QuorumPublicClient = class _QuorumPublicClient {
13004
13124
  return this.rpcUrls.filter((url) => (_QuorumPublicClient.benchedUntil.get(url) ?? 0) > now);
13005
13125
  }
13006
13126
  /**
13007
- * The endpoints this call queries — everyone not benched — and the quorum
13008
- * bar for exactly that set. A benched endpoint is dropped outright: it
13009
- * answers nothing while throttled, so counting it could only fail calls the
13010
- * remaining endpoints agree on. With every endpoint benched, everyone is
13011
- * queried again; there is nobody left to prefer.
13127
+ * The endpoints this call queries: everyone not benched. A benched endpoint is
13128
+ * dropped outright: it answers nothing while throttled, so asking it could only
13129
+ * cost a request. With every endpoint benched, everyone is queried again; there
13130
+ * is nobody left to prefer.
13012
13131
  */
13013
13132
  participants() {
13014
13133
  const now = Date.now();
@@ -13016,12 +13135,7 @@ var _QuorumPublicClient = class _QuorumPublicClient {
13016
13135
  const active = all.filter(({ idx }) => (_QuorumPublicClient.benchedUntil.get(this.rpcUrls[idx]) ?? 0) <= now);
13017
13136
  const allBenched = active.length === 0;
13018
13137
  const queried = allBenched ? all : active;
13019
- return {
13020
- queried,
13021
- skipped: all.length - queried.length,
13022
- threshold: quorumThreshold(queried.length),
13023
- allBenched
13024
- };
13138
+ return { queried, skipped: all.length - queried.length, allBenched };
13025
13139
  }
13026
13140
  /**
13027
13141
  * Records a task failure against its endpoint. Only the failures
@@ -13066,17 +13180,21 @@ var _QuorumPublicClient = class _QuorumPublicClient {
13066
13180
  * retries would otherwise stall every confirmation poll). Stragglers settle
13067
13181
  * out of band and are ignored. `tryDecide` returns undefined to keep
13068
13182
  * waiting; once every task has settled, `finalize` must decide (it throws
13069
- * the QuorumError when the full response set still has no quorum). Early
13070
- * exit never weakens the trust model — a decision still requires the same
13071
- * quorum, it just doesn't wait for votes it no longer needs.
13183
+ * the QuorumError when the full response set still has no quorum).
13184
+ *
13185
+ * Both are handed the bar: the BFT threshold over the endpoints that have not
13186
+ * failed, answered or still out. A failure lowers it rather than counting
13187
+ * against it. Counting the pending endpoints keeps an early decision to a bar
13188
+ * no lower than the full response set would get, so a vote it did not wait for
13189
+ * could never have reversed it.
13072
13190
  *
13073
13191
  * `deadlineMs` bounds the other direction: when the bar is NOT reachable
13074
13192
  * from the fast responders, the call used to wait out every straggler, and
13075
13193
  * the caller's own retry loop multiplied that into minutes of a chain not
13076
13194
  * being scanned at all, silently. At the deadline the outstanding endpoints
13077
13195
  * are treated as failures — named in the QuorumError, so the operator sees
13078
- * which ones held the call up — and `finalize` decides on the rest. The
13079
- * trust model is untouched: a decision still needs the same quorum.
13196
+ * which ones held the call up — and `finalize` decides over the endpoints
13197
+ * that answered.
13080
13198
  */
13081
13199
  settleUntilQuorum(tasks, tryDecide, finalize) {
13082
13200
  return new Promise((resolve, reject) => {
@@ -13094,13 +13212,14 @@ var _QuorumPublicClient = class _QuorumPublicClient {
13094
13212
  const evaluate = () => {
13095
13213
  if (done) return;
13096
13214
  try {
13097
- const early = tryDecide(fulfilled);
13215
+ const threshold = quorumThreshold(tasks.length - failures.length);
13216
+ const early = tryDecide(fulfilled, threshold);
13098
13217
  if (early !== void 0) {
13099
13218
  finish(() => resolve(early));
13100
13219
  return;
13101
13220
  }
13102
13221
  if (outstanding === 0) {
13103
- const value = finalize(fulfilled, failures);
13222
+ const value = finalize(fulfilled, failures, threshold);
13104
13223
  finish(() => resolve(value));
13105
13224
  }
13106
13225
  } catch (error) {
@@ -13118,7 +13237,7 @@ var _QuorumPublicClient = class _QuorumPublicClient {
13118
13237
  error: new Error(`no answer within the ${this.deadlineMs}ms quorum deadline`)
13119
13238
  }));
13120
13239
  try {
13121
- const value = finalize(fulfilled, [...failures, ...stragglers]);
13240
+ const value = finalize(fulfilled, [...failures, ...stragglers], quorumThreshold(fulfilled.length));
13122
13241
  finish(() => resolve(value));
13123
13242
  } catch (error) {
13124
13243
  finish(() => reject(error));
@@ -13145,22 +13264,22 @@ var _QuorumPublicClient = class _QuorumPublicClient {
13145
13264
  });
13146
13265
  }
13147
13266
  /**
13148
- * Highest block head backed by a full quorum. Throws {@link QuorumError}
13149
- * when fewer than `threshold` endpoints respond.
13267
+ * Highest block head backed by a quorum of the endpoints that answer. Throws
13268
+ * {@link QuorumError} when none do.
13150
13269
  */
13151
13270
  async getBlockNumber() {
13152
- const { queried, skipped, threshold, allBenched } = this.participants();
13271
+ const { queried, skipped, allBenched } = this.participants();
13153
13272
  return this.settleUntilQuorum(
13154
13273
  queried.map(({ idx, client }) => ({ idx, task: client.getBlockNumber() })),
13155
13274
  // Early exit as soon as a quorum is satisfiable. Late voters could
13156
13275
  // only raise the reported head; the earlier (lower) head is
13157
13276
  // conservative for every consumer (fewer confirmations counted,
13158
13277
  // smaller scan windows).
13159
- (fulfilled) => this.quorumHead(
13278
+ (fulfilled, threshold) => this.quorumHead(
13160
13279
  fulfilled.map((f) => f.value),
13161
13280
  threshold
13162
13281
  ) ?? void 0,
13163
- (fulfilled, failures) => {
13282
+ (fulfilled, failures, threshold) => {
13164
13283
  const head = this.quorumHead(
13165
13284
  fulfilled.map((f) => f.value),
13166
13285
  threshold
@@ -13201,7 +13320,7 @@ var _QuorumPublicClient = class _QuorumPublicClient {
13201
13320
  }
13202
13321
  return views;
13203
13322
  };
13204
- const { queried, skipped, threshold, allBenched } = this.participants();
13323
+ const { queried, skipped, allBenched } = this.participants();
13205
13324
  return this.settleUntilQuorum(
13206
13325
  queried.map(({ idx, client }) => ({
13207
13326
  idx,
@@ -13220,8 +13339,8 @@ var _QuorumPublicClient = class _QuorumPublicClient {
13220
13339
  // Early exit only on a POSITIVE quorum: an agreeing inclusion group can
13221
13340
  // only grow, so the first satisfiable quorum is final. Not-found votes
13222
13341
  // never decide early — "not confirmed" needs the full response set.
13223
- (fulfilled) => aggregateConfirmations(toViews(fulfilled), threshold) ?? void 0,
13224
- (fulfilled, failures) => {
13342
+ (fulfilled, threshold) => aggregateConfirmations(toViews(fulfilled), threshold) ?? void 0,
13343
+ (fulfilled, failures, threshold) => {
13225
13344
  const confirmations = aggregateConfirmations(toViews(fulfilled), threshold);
13226
13345
  if (confirmations === null) {
13227
13346
  throw new QuorumError(
@@ -13250,7 +13369,7 @@ var _QuorumPublicClient = class _QuorumPublicClient {
13250
13369
  }
13251
13370
  return groups;
13252
13371
  };
13253
- const { queried, skipped, threshold, allBenched } = this.participants();
13372
+ const { queried, skipped, allBenched } = this.participants();
13254
13373
  return this.settleUntilQuorum(
13255
13374
  queried.map(({ idx, client }) => ({
13256
13375
  idx,
@@ -13262,13 +13381,13 @@ var _QuorumPublicClient = class _QuorumPublicClient {
13262
13381
  // two disjoint groups can't both hold one), so the first satisfiable
13263
13382
  // agreement is final — no need to wait out stragglers that could
13264
13383
  // only join or lose.
13265
- (fulfilled) => {
13384
+ (fulfilled, threshold) => {
13266
13385
  for (const group of groupOf(fulfilled).values()) {
13267
13386
  if (group.providerIdxs.length >= threshold) return group.result;
13268
13387
  }
13269
13388
  return void 0;
13270
13389
  },
13271
- (fulfilled, failures) => {
13390
+ (fulfilled, failures, threshold) => {
13272
13391
  const groups = groupOf(fulfilled);
13273
13392
  for (const group of groups.values()) {
13274
13393
  if (group.providerIdxs.length >= threshold) return group.result;
@@ -21224,8 +21343,15 @@ var IntentFiller = class {
21224
21343
  const evalDurationSec = (Date.now() - evalStartMs) / 1e3;
21225
21344
  this.monitor.emit("orderTiming", { orderId: order.id, phase: "evaluation", durationSec: evalDurationSec });
21226
21345
  if (validStrategies.length === 0) {
21227
- this.logger.warn({ orderId: order.id }, "No profitable strategy found for order");
21228
- this.monitor.emit("orderSkipped", { orderId: order.id, reason: "No profitable strategy" });
21346
+ const priced = eligibleStrategies.some((s) => s !== null);
21347
+ this.logger.warn(
21348
+ { orderId: order.id },
21349
+ priced ? "No profitable strategy found for order" : "No strategy can fill this order"
21350
+ );
21351
+ this.monitor.emit("orderSkipped", {
21352
+ orderId: order.id,
21353
+ reason: priced ? "No profitable strategy" : "No strategy can fill this order"
21354
+ });
21229
21355
  return null;
21230
21356
  }
21231
21357
  this.logger.info(
@@ -21672,16 +21798,63 @@ function byBestOfferFirst(a, b) {
21672
21798
  if (a.available !== b.available) return a.available > b.available ? -1 : 1;
21673
21799
  return a.order.id < b.order.id ? -1 : 1;
21674
21800
  }
21801
+ function whyUnmatched(orders, incoming, resolve, now = /* @__PURE__ */ new Date()) {
21802
+ let furthest = -1;
21803
+ for (const order of orders) {
21804
+ const failed = firstFailed(order, incoming, resolve, now);
21805
+ furthest = Math.max(furthest, failed === null ? CHECKS.length : CHECKS.indexOf(failed));
21806
+ }
21807
+ const { source, destination, inputSymbol } = incoming;
21808
+ if (furthest < CHECKS.length) {
21809
+ switch (CHECKS[Math.max(furthest, 0)]) {
21810
+ case "open":
21811
+ return "no limit order is open";
21812
+ case "chain":
21813
+ return `no open limit order fills on ${destination}`;
21814
+ case "input":
21815
+ return `no limit order on ${destination} takes ${inputSymbol} in`;
21816
+ case "output":
21817
+ return `no limit order on ${destination} taking ${inputSymbol} in pays out the token this order asks for`;
21818
+ case "source":
21819
+ return `no limit order for this pair on ${destination} accepts swaps from ${source}`;
21820
+ }
21821
+ }
21822
+ const priced = orders.filter((order) => firstFailed(order, incoming, resolve, now) === null).map((order) => ({
21823
+ order,
21824
+ offer: offerFor({
21825
+ side: order.side,
21826
+ inputAmount: incoming.inputNet,
21827
+ price: BigInt(order.price),
21828
+ outputDecimals: incoming.outputDecimals
21829
+ })
21830
+ }));
21831
+ if (priced.some(({ offer }) => offer >= incoming.requestedOutput)) {
21832
+ return "the limit orders that meet its rate have nothing left to pay out";
21833
+ }
21834
+ const best = priced.reduce((a, b) => b.offer > a.offer ? b : a);
21835
+ const symbol = limitOrderLegs(best.order).output;
21836
+ return `it asks for ${readable(incoming.requestedOutput)} ${symbol}; the best limit order offers ${readable(best.offer)} ${symbol}`;
21837
+ }
21838
+ var CHECKS = ["open", "chain", "input", "output", "source"];
21675
21839
  function serves(order, incoming, resolve, now) {
21676
- if (order.status !== "open") return false;
21677
- if (order.expiresAt !== null && new Date(order.expiresAt) <= now) return false;
21678
- if (order.fillChain !== incoming.destination) return false;
21840
+ return firstFailed(order, incoming, resolve, now) === null;
21841
+ }
21842
+ function firstFailed(order, incoming, resolve, now) {
21843
+ if (order.status !== "open") return "open";
21844
+ if (order.expiresAt !== null && new Date(order.expiresAt) <= now) return "open";
21845
+ if (order.fillChain !== incoming.destination) return "chain";
21679
21846
  const legs = limitOrderLegs(order);
21680
- if (normalizeSymbol(legs.input) !== normalizeSymbol(incoming.inputSymbol)) return false;
21847
+ if (normalizeSymbol(legs.input) !== normalizeSymbol(incoming.inputSymbol)) return "input";
21681
21848
  const outputToken = resolve(legs.output, order.fillChain);
21682
- if (!outputToken || outputToken.toLowerCase() !== incoming.outputToken.toLowerCase()) return false;
21683
- if (incoming.source === incoming.destination) return true;
21684
- return order.acceptedSources.includes(incoming.source);
21849
+ if (!outputToken || outputToken.toLowerCase() !== incoming.outputToken.toLowerCase()) return "output";
21850
+ if (incoming.source === incoming.destination) return null;
21851
+ return order.acceptedSources.includes(incoming.source) ? null : "source";
21852
+ }
21853
+ function readable(amount) {
21854
+ const [whole, fraction] = toHuman(amount).split(".");
21855
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
21856
+ const cut = fraction?.slice(0, 6).replace(/0+$/, "");
21857
+ return cut ? `${grouped}.${cut}` : grouped;
21685
21858
  }
21686
21859
 
21687
21860
  // src/orderbook/usd.ts
@@ -21823,15 +21996,20 @@ var FXFiller = class _FXFiller {
21823
21996
  }
21824
21997
  try {
21825
21998
  if (order.inputs.length !== order.output.assets.length) {
21826
- this.logger.debug(
21827
- { inputs: order.inputs.length, outputs: order.output.assets.length },
21999
+ this.logger.info(
22000
+ { orderId: order.id, inputs: order.inputs.length, outputs: order.output.assets.length },
21828
22001
  "Order input/output length mismatch or empty"
21829
22002
  );
21830
22003
  return false;
21831
22004
  }
21832
22005
  if ((await this.matchOrder(order)).length === 0) {
21833
- this.logger.debug(
21834
- { orderId: order.id, sourceChain: order.source, destChain: order.destination },
22006
+ this.logger.info(
22007
+ {
22008
+ orderId: order.id,
22009
+ sourceChain: order.source,
22010
+ destChain: order.destination,
22011
+ reason: await this.explainUnmatched(order)
22012
+ },
21835
22013
  "No limit order matches this order"
21836
22014
  );
21837
22015
  return false;
@@ -22373,30 +22551,53 @@ var FXFiller = class _FXFiller {
22373
22551
  */
22374
22552
  async matchLeg(order, leg) {
22375
22553
  if (!this.limitOrders) return [];
22554
+ const incoming = await this.incomingFor(order, leg);
22555
+ if (!incoming) return [];
22556
+ return matchLimitOrders(
22557
+ await this.limitOrders.open(),
22558
+ incoming,
22559
+ (symbol, chain) => this.registry.getAddress(symbol, chain)
22560
+ );
22561
+ }
22562
+ /** Why no limit order serves the order, leg by leg, for the line that says it was passed over. */
22563
+ async explainUnmatched(order) {
22564
+ if (!this.limitOrders) return "limit orders are not loaded";
22565
+ const open = await this.limitOrders.open();
22566
+ const reasons = [];
22567
+ for (let leg = 0; leg < order.inputs.length; leg++) {
22568
+ const incoming = await this.incomingFor(order, leg);
22569
+ const reason = incoming ? whyUnmatched(open, incoming, (symbol, chain) => this.registry.getAddress(symbol, chain)) : `its input token is not in the asset registry on ${order.source}`;
22570
+ reasons.push(order.inputs.length > 1 ? `leg ${leg}: ${reason}` : reason);
22571
+ }
22572
+ return reasons.join("; ");
22573
+ }
22574
+ /**
22575
+ * One leg as the matcher sees it, or null when its input token is not one the asset
22576
+ * registry knows on the source chain.
22577
+ *
22578
+ * Amounts cross into the matcher at 1e18, the unit limit orders are kept in.
22579
+ */
22580
+ async incomingFor(order, leg) {
22376
22581
  const input = order.inputs[leg];
22377
22582
  const output = order.output.assets[leg];
22378
- if (!input || !output) return [];
22583
+ if (!input || !output) return null;
22379
22584
  const inputToken = bytes32ToBytes204(input.token);
22380
22585
  const outputToken = bytes32ToBytes204(output.token);
22381
22586
  const inputSymbol = this.registry.symbolFor(inputToken, order.source);
22382
- if (!inputSymbol) return [];
22587
+ if (!inputSymbol) return null;
22383
22588
  const [inputDecimals, outputDecimals] = await Promise.all([
22384
22589
  this.contractService.getTokenDecimals(inputToken, order.source),
22385
22590
  this.contractService.getTokenDecimals(outputToken, order.destination)
22386
22591
  ]);
22387
- return matchLimitOrders(
22388
- await this.limitOrders.open(),
22389
- {
22390
- source: order.source,
22391
- destination: order.destination,
22392
- inputSymbol,
22393
- outputToken,
22394
- inputNet: toScaled(input.amount, inputDecimals),
22395
- requestedOutput: toScaled(output.amount, outputDecimals),
22396
- outputDecimals
22397
- },
22398
- (symbol, chain) => this.registry.getAddress(symbol, chain)
22399
- );
22592
+ return {
22593
+ source: order.source,
22594
+ destination: order.destination,
22595
+ inputSymbol,
22596
+ outputToken,
22597
+ inputNet: toScaled(input.amount, inputDecimals),
22598
+ requestedOutput: toScaled(output.amount, outputDecimals),
22599
+ outputDecimals
22600
+ };
22400
22601
  }
22401
22602
  /**
22402
22603
  * The order's input in **USD**, or null when no limit order connects its input
@@ -23563,7 +23764,10 @@ async function bootFiller(config, options) {
23563
23764
  runtimeSigner,
23564
23765
  config.orderbook.defaultTtlSecs ?? DEFAULT_LIMIT_ORDER_TTL_SECONDS,
23565
23766
  new DelegationService(chainClientManager, configService, runtimeSigner),
23566
- options.loggers
23767
+ options.loggers,
23768
+ initialOrderNonce,
23769
+ // A fill withdraws a payout shortfall from the vaults, so what they hold backs an order too.
23770
+ vaultVenue
23567
23771
  );
23568
23772
  intentFiller.setLimitOrderService(limitOrderService);
23569
23773
  const lifecycle = new LimitOrderLifecycle(
@@ -24106,6 +24310,8 @@ var MAX_BLOCK_RANGE = 1000n;
24106
24310
  var PUBLISH_CHUNK = 250;
24107
24311
  var yieldToConsumers = () => new Promise((resolve) => setImmediate(resolve));
24108
24312
  var STOP_DRAIN_TIMEOUT_MS = 5e3;
24313
+ var STALL_THRESHOLD_MS = 45e3;
24314
+ var STALL_REPORT_INTERVAL_MS = 6e4;
24109
24315
  var ChainScanner = class {
24110
24316
  constructor(target, loggers = defaultLoggerContext(), scanIntervalMs = DEFAULT_SCAN_INTERVAL_MS) {
24111
24317
  this.target = target;
@@ -24119,6 +24325,18 @@ var ChainScanner = class {
24119
24325
  this.errorHandler = () => {
24120
24326
  };
24121
24327
  this.stopped = false;
24328
+ /**
24329
+ * How far past the cursor the next pass reads, as `toBlock - fromBlock`:
24330
+ * halved after a failed read, doubled after a good one, between 0 (one block)
24331
+ * and {@link MAX_BLOCK_RANGE}.
24332
+ *
24333
+ * Free endpoints cap `eth_getLogs` ranges far below the full span — on
24334
+ * Arbitrum three of seven refused 100 blocks — and the cursor never moves
24335
+ * past a range it failed to read. At a fixed span a catch-up that outgrew
24336
+ * those caps was stuck for good: the range only grew, so the capped endpoints
24337
+ * never rejoined and the quorum never formed.
24338
+ */
24339
+ this.span = MAX_BLOCK_RANGE;
24122
24340
  this.logger = loggers.get("chain-scanner");
24123
24341
  this.quorumClient = new QuorumPublicClient(target.chainId, target.rpcUrls, loggers);
24124
24342
  if (this.quorumClient.size > 1) {
@@ -24161,8 +24379,9 @@ var ChainScanner = class {
24161
24379
  if (this.timer || this.stopped) return;
24162
24380
  this.logger.info({ chainId: this.target.chainId }, "Block scanner started");
24163
24381
  this.timer = setInterval(() => {
24164
- if (this.mutex.isLocked()) return;
24382
+ if (this.mutex.isLocked()) return this.reportStall();
24165
24383
  void this.mutex.runExclusive(async () => {
24384
+ this.pass = { phase: "head", startedAt: Date.now(), reportedAt: 0 };
24166
24385
  try {
24167
24386
  await this.scan();
24168
24387
  } catch (error) {
@@ -24176,10 +24395,66 @@ var ChainScanner = class {
24176
24395
  "Error in block scanner"
24177
24396
  );
24178
24397
  this.errorHandler(error);
24398
+ } finally {
24399
+ this.notePassEnd();
24179
24400
  }
24180
24401
  });
24181
24402
  }, this.scanIntervalMs);
24182
24403
  }
24404
+ /**
24405
+ * Records which await the current pass is sitting on, for the watchdog.
24406
+ *
24407
+ * A no-op when no pass is registered — `scan()` is driven directly by tests
24408
+ * and by nothing else, so there is no watchdog to inform.
24409
+ */
24410
+ enter(phase) {
24411
+ if (this.pass) this.pass.phase = phase;
24412
+ }
24413
+ /**
24414
+ * Says that a pass has outlived the quorum budgets and names the phase it is
24415
+ * stuck in, then repeats every {@link STALL_REPORT_INTERVAL_MS} until it ends.
24416
+ *
24417
+ * Observation only: the pass is left alone. Aborting it would need a cancel
24418
+ * path through the quorum client that does not exist, and the cursor is safe
24419
+ * either way — a pass that never completes never advances it.
24420
+ */
24421
+ reportStall() {
24422
+ const pass = this.pass;
24423
+ if (!pass) return;
24424
+ const now = Date.now();
24425
+ const heldMs = now - pass.startedAt;
24426
+ if (heldMs < STALL_THRESHOLD_MS) return;
24427
+ if (pass.reportedAt !== 0 && now - pass.reportedAt < STALL_REPORT_INTERVAL_MS) return;
24428
+ pass.reportedAt = now;
24429
+ this.logger.warn(
24430
+ {
24431
+ chainId: this.target.chainId,
24432
+ phase: pass.phase,
24433
+ heldMs,
24434
+ cursor: this.cursor?.toString(),
24435
+ rpcUrls: this.target.rpcUrls
24436
+ },
24437
+ "Block scan pass has outlived its budget \u2014 this chain is not being scanned"
24438
+ );
24439
+ }
24440
+ /**
24441
+ * Closes out a pass, and says so if the watchdog complained about it — without
24442
+ * this the log shows a chain going quiet and never shows it coming back.
24443
+ */
24444
+ notePassEnd() {
24445
+ const pass = this.pass;
24446
+ this.pass = void 0;
24447
+ if (!pass || pass.reportedAt === 0) return;
24448
+ this.logger.warn(
24449
+ {
24450
+ chainId: this.target.chainId,
24451
+ phase: pass.phase,
24452
+ heldMs: Date.now() - pass.startedAt,
24453
+ cursor: this.cursor?.toString()
24454
+ },
24455
+ "Block scan pass finished after outliving its budget \u2014 scanning has resumed"
24456
+ );
24457
+ }
24183
24458
  /**
24184
24459
  * Points this loop at different endpoints, keeping the cursor.
24185
24460
  *
@@ -24221,11 +24496,12 @@ var ChainScanner = class {
24221
24496
  }
24222
24497
  if (currentBlock <= this.cursor) return;
24223
24498
  const fromBlock = this.cursor + 1n;
24224
- const toBlock = fromBlock + MAX_BLOCK_RANGE > currentBlock ? currentBlock : fromBlock + MAX_BLOCK_RANGE;
24499
+ const toBlock = fromBlock + this.span > currentBlock ? currentBlock : fromBlock + this.span;
24225
24500
  this.logger.debug(
24226
24501
  { chainId: this.target.chainId, fromBlock, toBlock, gap: Number(toBlock - fromBlock) },
24227
24502
  "Scanning blocks"
24228
24503
  );
24504
+ this.enter("logs");
24229
24505
  let logs;
24230
24506
  try {
24231
24507
  logs = await retryPromise3(
@@ -24247,6 +24523,7 @@ var ChainScanner = class {
24247
24523
  );
24248
24524
  } catch (error) {
24249
24525
  if (isBlockRangeError(error)) return;
24526
+ this.narrow(toBlock - fromBlock);
24250
24527
  throw error;
24251
24528
  }
24252
24529
  if (this.stopped) return;
@@ -24257,6 +24534,7 @@ var ChainScanner = class {
24257
24534
  { chainId: this.target.chainId, fromBlock, toBlock, eventCount: placed.length },
24258
24535
  "Found OrderPlaced events in block scan"
24259
24536
  );
24537
+ this.enter("publish-orders");
24260
24538
  await this.publishOrders(placed);
24261
24539
  }
24262
24540
  if (filled.length > 0) {
@@ -24264,10 +24542,36 @@ var ChainScanner = class {
24264
24542
  { chainId: this.target.chainId, fromBlock, toBlock, eventCount: filled.length },
24265
24543
  "Found OrderFilled events in block scan"
24266
24544
  );
24545
+ this.enter("publish-fills");
24267
24546
  await this.publishFills(filled);
24268
24547
  }
24269
24548
  if (this.stopped) return;
24270
24549
  this.cursor = toBlock;
24550
+ this.widen();
24551
+ }
24552
+ /**
24553
+ * Halves the span below the range that just failed, so the next pass asks
24554
+ * for something a range-capped endpoint will answer. Halving the attempted
24555
+ * range rather than the span matters near the head, where a pass reads fewer
24556
+ * blocks than the span allows.
24557
+ */
24558
+ narrow(attempted) {
24559
+ const next = attempted / 2n;
24560
+ if (next >= this.span) return;
24561
+ this.span = next;
24562
+ this.logger.warn(
24563
+ { chainId: this.target.chainId, span: Number(next) },
24564
+ "Narrowing the block scan range after a failed read"
24565
+ );
24566
+ }
24567
+ /** Doubles the span back toward {@link MAX_BLOCK_RANGE} after a good read. */
24568
+ widen() {
24569
+ if (this.span >= MAX_BLOCK_RANGE) return;
24570
+ const next = this.span === 0n ? 1n : this.span * 2n;
24571
+ this.span = next > MAX_BLOCK_RANGE ? MAX_BLOCK_RANGE : next;
24572
+ if (this.span === MAX_BLOCK_RANGE) {
24573
+ this.logger.info({ chainId: this.target.chainId }, "Block scan range back to full");
24574
+ }
24271
24575
  }
24272
24576
  async publishOrders(logs) {
24273
24577
  const rebuilt = reconstructOrdersFromLogs(logs, {
@@ -24634,6 +24938,10 @@ var LimitOrderController = class {
24634
24938
  ]);
24635
24939
  return { order, fills, bids };
24636
24940
  }
24941
+ /** The pairs the orderbook lists, the smallest payout each token may carry, and its tokens per chain. */
24942
+ books() {
24943
+ return this.service.books();
24944
+ }
24637
24945
  /** Creates the order, posts it, and reports what the orderbook made of it. */
24638
24946
  async create(request) {
24639
24947
  const posted = await this.service.create(request);