@hyperbridge/simplex 0.16.8 → 0.16.12

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
@@ -1103,6 +1103,7 @@ import { isAddress as isAddress2 } from "viem";
1103
1103
  import { ChainConfigService, bytes32ToBytes20 } from "@hyperbridge/sdk";
1104
1104
  var DEFAULT_BLOCK_SCAN_INTERVAL_SECONDS = 3;
1105
1105
  var MIN_BLOCK_SCAN_INTERVAL_SECONDS = 0.1;
1106
+ var DEFAULT_MIN_ORDER_SIZE_USD = 20;
1106
1107
  function validateRpcUrls(rpcUrls) {
1107
1108
  if (rpcUrls.length === 0) {
1108
1109
  throw new Error("rpcUrls must contain at least one URL");
@@ -1525,6 +1526,13 @@ var FillerConfigService = class {
1525
1526
  getBidValiditySeconds() {
1526
1527
  return this.fillerConfig?.bidValiditySeconds ?? 300;
1527
1528
  }
1529
+ /**
1530
+ * The order size, in USD, below which `order.fees` must cover the execution cost.
1531
+ * Defaults to 20. An order at or above it is filled whatever fees it carries.
1532
+ */
1533
+ getMinOrderSizeUsd() {
1534
+ return this.fillerConfig?.minOrderSizeUsd ?? DEFAULT_MIN_ORDER_SIZE_USD;
1535
+ }
1528
1536
  /** Ceiling bps above user-requested output. Default 500 (5%). */
1529
1537
  getMaxOverfillBps() {
1530
1538
  return BigInt(this.fillerConfig?.overfillProtection?.maxOverfillBps ?? 500);
@@ -1628,6 +1636,12 @@ function validateConfig(config, cliWatchOnly = false) {
1628
1636
  );
1629
1637
  }
1630
1638
  }
1639
+ const minOrderSize = config.simplex.minOrderSizeUsd;
1640
+ if (minOrderSize !== void 0) {
1641
+ if (!Number.isFinite(minOrderSize) || minOrderSize < 0) {
1642
+ throw new Error(`simplex.minOrderSizeUsd must be a number >= 0 (USD); got ${minOrderSize}`);
1643
+ }
1644
+ }
1631
1645
  if (config.allowlist) {
1632
1646
  for (const user of config.allowlist.users ?? []) {
1633
1647
  if (!isAddress2(user)) {
@@ -2781,11 +2795,21 @@ import { randomBytes, randomUUID } from "node:crypto";
2781
2795
  import { getChainId, MAX_DECLARED_ENTRIES } from "@hyperbridge/sdk";
2782
2796
 
2783
2797
  // src/orderbook/amounts.ts
2798
+ import { keccak256, stringToBytes } from "viem";
2784
2799
  var ORDERBOOK_SCALE = 10n ** 18n;
2785
2800
  function toRaw(amount, decimals) {
2786
2801
  const shift = 18 - decimals;
2787
2802
  return shift >= 0 ? amount / 10n ** BigInt(shift) : amount * 10n ** BigInt(-shift);
2788
2803
  }
2804
+ function budgetIdFor(limitOrderId) {
2805
+ return keccak256(stringToBytes(limitOrderId));
2806
+ }
2807
+ function budgetFor(order, token, decimals) {
2808
+ return { budgetId: budgetIdFor(order.id), cap: toRaw(BigInt(order.size), decimals), token };
2809
+ }
2810
+ function budgetRoom(budget, spent, decimals) {
2811
+ return spent >= budget.cap ? 0n : toScaled(budget.cap - spent, decimals);
2812
+ }
2789
2813
  function fromHuman(amount) {
2790
2814
  const text = amount.trim();
2791
2815
  if (!/^[0-9]+(\.[0-9]+)?$/.test(text)) {
@@ -2806,6 +2830,10 @@ function toScaled(amount, decimals) {
2806
2830
  const shift = 18 - decimals;
2807
2831
  return shift >= 0 ? amount * 10n ** BigInt(shift) : amount / 10n ** BigInt(-shift);
2808
2832
  }
2833
+ function toScaledUp(amount, decimals) {
2834
+ const shift = 18 - decimals;
2835
+ return shift >= 0 ? amount * 10n ** BigInt(shift) : divCeil(amount, 10n ** BigInt(-shift));
2836
+ }
2809
2837
  function divCeil(numerator, denominator) {
2810
2838
  return (numerator + denominator - 1n) / denominator;
2811
2839
  }
@@ -2847,6 +2875,87 @@ function inputFor(params) {
2847
2875
  return divCeil(scaled, unit) * unit;
2848
2876
  }
2849
2877
 
2878
+ // src/orderbook/matching.ts
2879
+ function limitOrderLegs(order) {
2880
+ return order.side === "BID" ? { input: order.base, output: order.quote } : { input: order.quote, output: order.base };
2881
+ }
2882
+ function matchLimitOrders(orders, incoming, resolve, now = /* @__PURE__ */ new Date()) {
2883
+ const qualifying = orders.filter((order) => serves(order, incoming, resolve, now)).map((order) => {
2884
+ const offer = offerFor({
2885
+ side: order.side,
2886
+ inputAmount: incoming.inputNet,
2887
+ price: BigInt(order.price),
2888
+ outputDecimals: incoming.outputDecimals
2889
+ });
2890
+ const available = BigInt(order.remaining);
2891
+ return { order, offer, available, payout: offer < available ? offer : available };
2892
+ }).filter((candidate) => candidate.offer >= incoming.requestedOutput && candidate.payout > 0n).sort(byBestOfferFirst);
2893
+ return qualifying;
2894
+ }
2895
+ function byBestOfferFirst(a, b) {
2896
+ if (a.offer !== b.offer) return a.offer > b.offer ? -1 : 1;
2897
+ if (a.available !== b.available) return a.available > b.available ? -1 : 1;
2898
+ return a.order.id < b.order.id ? -1 : 1;
2899
+ }
2900
+ function whyUnmatched(orders, incoming, resolve, now = /* @__PURE__ */ new Date()) {
2901
+ let furthest = -1;
2902
+ for (const order of orders) {
2903
+ const failed = firstFailed(order, incoming, resolve, now);
2904
+ furthest = Math.max(furthest, failed === null ? CHECKS.length : CHECKS.indexOf(failed));
2905
+ }
2906
+ const { source, destination, inputSymbol } = incoming;
2907
+ if (furthest < CHECKS.length) {
2908
+ switch (CHECKS[Math.max(furthest, 0)]) {
2909
+ case "open":
2910
+ return "no limit order is open";
2911
+ case "chain":
2912
+ return `no open limit order fills on ${destination}`;
2913
+ case "input":
2914
+ return `no limit order on ${destination} takes ${inputSymbol} in`;
2915
+ case "output":
2916
+ return `no limit order on ${destination} taking ${inputSymbol} in pays out the token this order asks for`;
2917
+ case "source":
2918
+ return `no limit order for this pair on ${destination} accepts swaps from ${source}`;
2919
+ }
2920
+ }
2921
+ const priced = orders.filter((order) => firstFailed(order, incoming, resolve, now) === null).map((order) => ({
2922
+ order,
2923
+ offer: offerFor({
2924
+ side: order.side,
2925
+ inputAmount: incoming.inputNet,
2926
+ price: BigInt(order.price),
2927
+ outputDecimals: incoming.outputDecimals
2928
+ })
2929
+ }));
2930
+ if (priced.some(({ offer }) => offer >= incoming.requestedOutput)) {
2931
+ return "the limit orders that meet its rate have nothing left to pay out";
2932
+ }
2933
+ const best = priced.reduce((a, b) => b.offer > a.offer ? b : a);
2934
+ const symbol = limitOrderLegs(best.order).output;
2935
+ return `it asks for ${readable(incoming.requestedOutput)} ${symbol}; the best limit order offers ${readable(best.offer)} ${symbol}`;
2936
+ }
2937
+ var CHECKS = ["open", "chain", "input", "output", "source"];
2938
+ function serves(order, incoming, resolve, now) {
2939
+ return firstFailed(order, incoming, resolve, now) === null;
2940
+ }
2941
+ function firstFailed(order, incoming, resolve, now) {
2942
+ if (order.status !== "open") return "open";
2943
+ if (order.expiresAt !== null && new Date(order.expiresAt) <= now) return "open";
2944
+ if (order.fillChain !== incoming.destination) return "chain";
2945
+ const legs = limitOrderLegs(order);
2946
+ if (normalizeSymbol(legs.input) !== normalizeSymbol(incoming.inputSymbol)) return "input";
2947
+ const outputToken = resolve(legs.output, order.fillChain);
2948
+ if (!outputToken || outputToken.toLowerCase() !== incoming.outputToken.toLowerCase()) return "output";
2949
+ if (incoming.source === incoming.destination) return null;
2950
+ return order.acceptedSources.includes(incoming.source) ? null : "source";
2951
+ }
2952
+ function readable(amount) {
2953
+ const [whole, fraction] = toHuman(amount).split(".");
2954
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
2955
+ const cut = fraction?.slice(0, 6).replace(/0+$/, "");
2956
+ return cut ? `${grouped}.${cut}` : grouped;
2957
+ }
2958
+
2850
2959
  // src/orderbook/limit-orders.ts
2851
2960
  var LIMITS_TTL_MS = 5 * 60 * 1e3;
2852
2961
  var POSTABLE = ["open", "resizing"];
@@ -3361,6 +3470,58 @@ var LimitOrderService = class {
3361
3470
  }
3362
3471
  return report;
3363
3472
  }
3473
+ /**
3474
+ * Lowers every open order's `remaining` to what the solver's account will still
3475
+ * pay for it, and puts the order back on the book at that size.
3476
+ *
3477
+ * The account tallies each order's payouts on the fill chain and refuses a fill
3478
+ * that would take the tally past the order's size. A fill that never settled
3479
+ * here, or a store restored from an older copy, leaves `remaining` above that
3480
+ * room and the order posted at a size the account will refuse. A tally behind
3481
+ * the store changes nothing: `remaining` is never raised.
3482
+ *
3483
+ * Answers how many orders were lowered.
3484
+ */
3485
+ async reconcileTallies() {
3486
+ let lowered = 0;
3487
+ for (const order of await this.store.list({ status: "open" })) {
3488
+ try {
3489
+ const clamped = await this.clampToTally(order);
3490
+ if (!clamped) continue;
3491
+ lowered++;
3492
+ await this.resize(clamped, BigInt(order.remaining) - BigInt(clamped.remaining));
3493
+ } catch (err) {
3494
+ this.logger.error({ id: order.id, err }, "Could not bring the limit order in line with its on-chain tally");
3495
+ }
3496
+ }
3497
+ return lowered;
3498
+ }
3499
+ /** The order as the store now holds it when its tally lowered it, null when it was left alone. */
3500
+ async clampToTally(order) {
3501
+ const token = this.assetRegistry.getAddress(limitOrderLegs(order).output, order.fillChain);
3502
+ if (!token) return null;
3503
+ const decimals = await this.contractService.getTokenDecimals(token, order.fillChain);
3504
+ const budget = budgetFor(order, token, decimals);
3505
+ const spent = await this.contractService.limitOrderSpent(order.fillChain, budget.budgetId);
3506
+ if (spent === null) return null;
3507
+ const room = budgetRoom(budget, spent, decimals);
3508
+ const over = BigInt(order.remaining) - (room + BigInt(order.reserved));
3509
+ if (over < toScaled(1n, decimals)) return null;
3510
+ const clamped = await this.store.clampRemaining(order.id, room.toString());
3511
+ if (!clamped) return null;
3512
+ this.logger.warn(
3513
+ {
3514
+ id: order.id,
3515
+ before: order.remaining,
3516
+ after: clamped.remaining,
3517
+ spent: spent.toString(),
3518
+ cap: budget.cap.toString(),
3519
+ reserved: clamped.reserved
3520
+ },
3521
+ "Limit order had more left than its on-chain tally allows; lowering it"
3522
+ );
3523
+ return clamped;
3524
+ }
3364
3525
  /**
3365
3526
  * Withdraws every order that has outlived the operator's own `expiresAt`.
3366
3527
  *
@@ -3622,7 +3783,7 @@ async function patchRuntimeState(store, patch) {
3622
3783
  }
3623
3784
 
3624
3785
  // src/services/wallet/mpcvault.ts
3625
- import { concatHex, keccak256, padHex, toHex } from "viem";
3786
+ import { concatHex, keccak256 as keccak2562, padHex, toHex } from "viem";
3626
3787
  import * as grpc from "@grpc/grpc-js";
3627
3788
 
3628
3789
  // src/proto/mpcvault/platform/v1/api.ts
@@ -12235,7 +12396,7 @@ var MpcVaultService = class {
12235
12396
  const messageBytes = Buffer.from(messageHash.slice(2), "hex");
12236
12397
  const prefix = Buffer.from(`Ethereum Signed Message:
12237
12398
  ${messageBytes.length}`);
12238
- const ethSignedMessageHash = keccak256(concatHex([toHex(prefix), messageHash]));
12399
+ const ethSignedMessageHash = keccak2562(concatHex([toHex(prefix), messageHash]));
12239
12400
  const ethSignedBytes = Buffer.from(ethSignedMessageHash.slice(2), "hex");
12240
12401
  const uuid = await this.createSigningRequest({
12241
12402
  vaultUuid: this.vaultUuid,
@@ -12323,11 +12484,12 @@ var SignerType = /* @__PURE__ */ ((SignerType2) => {
12323
12484
  SignerType2["PrivateKey"] = "privateKey";
12324
12485
  SignerType2["MpcVault"] = "mpcVault";
12325
12486
  SignerType2["Turnkey"] = "turnkey";
12487
+ SignerType2["SecretPhrase"] = "secretPhrase";
12326
12488
  return SignerType2;
12327
12489
  })(SignerType || {});
12328
12490
 
12329
12491
  // src/services/wallet/accounts/mpc.ts
12330
- import { keccak256 as keccak2562, serializeTransaction } from "viem";
12492
+ import { keccak256 as keccak2563, serializeTransaction } from "viem";
12331
12493
  import { hashAuthorization } from "viem/utils";
12332
12494
  function mpcVaultSigner(config) {
12333
12495
  const service = new MpcVaultService({
@@ -12362,7 +12524,7 @@ function mpcVaultSigner(config) {
12362
12524
  if (tx.authorizationList?.length) {
12363
12525
  const serializable = { ...tx, type: "eip7702" };
12364
12526
  const signature = await service.signRawHashComponents(
12365
- keccak2562(serializeTransaction(serializable))
12527
+ keccak2563(serializeTransaction(serializable))
12366
12528
  );
12367
12529
  return serializeTransaction(serializable, signature);
12368
12530
  }
@@ -12444,6 +12606,51 @@ function privateKeySigner(privateKey) {
12444
12606
  return viemSigner(privateKeyToAccount(privateKey));
12445
12607
  }
12446
12608
 
12609
+ // src/services/wallet/accounts/secretphrase.ts
12610
+ import { validateMnemonic } from "@scure/bip39";
12611
+ import { english, mnemonicToAccount } from "viem/accounts";
12612
+ var WORD_COUNTS = [12, 15, 18, 21, 24];
12613
+ var MAX_DERIVATION_INDEX = 2 ** 31 - 1;
12614
+ function normaliseSecretPhrase(phrase) {
12615
+ return phrase.trim().split(/\s+/).join(" ").toLowerCase();
12616
+ }
12617
+ function validateSecretPhrase(phrase) {
12618
+ if (typeof phrase !== "string") throw new Error("Secret phrase must be a string");
12619
+ const normalised = normaliseSecretPhrase(phrase);
12620
+ const words = normalised === "" ? [] : normalised.split(" ");
12621
+ if (!WORD_COUNTS.includes(words.length)) {
12622
+ throw new Error(`Secret phrase must have ${WORD_COUNTS.join(", ")} words; got ${words.length}`);
12623
+ }
12624
+ if (words.some((word) => !english.includes(word))) {
12625
+ throw new Error("Secret phrase contains a word that is not in the BIP-39 English wordlist");
12626
+ }
12627
+ if (!validateMnemonic(normalised, english)) {
12628
+ throw new Error("Secret phrase checksum does not match; check the words and their order");
12629
+ }
12630
+ }
12631
+ function assertDerivationIndex(index, name = "Derivation index") {
12632
+ const expected = `${name} must be an integer between 0 and ${MAX_DERIVATION_INDEX}`;
12633
+ if (typeof index !== "number") throw new Error(expected);
12634
+ if (!Number.isInteger(index) || index < 0 || index > MAX_DERIVATION_INDEX) {
12635
+ throw new Error(`${expected}; got ${index}`);
12636
+ }
12637
+ }
12638
+ function secretPhraseSigner(config) {
12639
+ validateSecretPhrase(config.phrase);
12640
+ const phrase = normaliseSecretPhrase(config.phrase);
12641
+ const accountIndex = config.accountIndex ?? 0;
12642
+ assertDerivationIndex(accountIndex, "accountIndex");
12643
+ const derive = (index) => {
12644
+ assertDerivationIndex(index);
12645
+ return { ...viemSigner(mnemonicToAccount(phrase, { addressIndex: index })), mode: "secretPhrase" };
12646
+ };
12647
+ return { ...derive(accountIndex), accountIndex, derive };
12648
+ }
12649
+ function canDerive(signer) {
12650
+ const candidate = signer;
12651
+ return typeof candidate.derive === "function" && typeof candidate.accountIndex === "number";
12652
+ }
12653
+
12447
12654
  // src/services/wallet/accounts/turnkey.ts
12448
12655
  import { Turnkey } from "@turnkey/sdk-server";
12449
12656
  import { createAccount } from "@turnkey/viem";
@@ -12467,6 +12674,7 @@ async function createSigner(config) {
12467
12674
  if (config.type === "privateKey" /* PrivateKey */) return privateKeySigner(config.key);
12468
12675
  if (config.type === "mpcVault" /* MpcVault */) return mpcVaultSigner(config);
12469
12676
  if (config.type === "turnkey" /* Turnkey */) return turnkeySigner(config);
12677
+ if (config.type === "secretPhrase" /* SecretPhrase */) return secretPhraseSigner(config);
12470
12678
  throw new Error(`Unsupported signer mode: ${config.type ?? "unknown"}`);
12471
12679
  }
12472
12680
  function validateSignerConfig(config) {
@@ -12492,13 +12700,21 @@ function validateSignerConfig(config) {
12492
12700
  if (!config.signWith) throw new Error("simplex.signer.signWith is required");
12493
12701
  return;
12494
12702
  }
12703
+ if (config.type === "secretPhrase" /* SecretPhrase */) {
12704
+ if (!config.phrase) {
12705
+ throw new Error("simplex.signer.phrase is required when simplex.signer.type=secretPhrase");
12706
+ }
12707
+ validateSecretPhrase(config.phrase);
12708
+ if (config.accountIndex !== void 0) assertDerivationIndex(config.accountIndex, "simplex.signer.accountIndex");
12709
+ return;
12710
+ }
12495
12711
  throw new Error(`Unsupported signer mode: ${config.type ?? "unknown"}`);
12496
12712
  }
12497
12713
 
12498
12714
  // src/services/wallet/account.ts
12499
12715
  import {
12500
12716
  hashTypedData,
12501
- keccak256 as keccak2563,
12717
+ keccak256 as keccak2564,
12502
12718
  serializeSignature,
12503
12719
  serializeTransaction as serializeTransaction2
12504
12720
  } from "viem";
@@ -12555,7 +12771,7 @@ function digestSigner(opts) {
12555
12771
  ),
12556
12772
  signTransaction: async (tx) => {
12557
12773
  const serializable = tx;
12558
- const signature = await sign(keccak2563(serializeTransaction2(serializable)));
12774
+ const signature = await sign(keccak2564(serializeTransaction2(serializable)));
12559
12775
  return serializeTransaction2(serializable, signature);
12560
12776
  }
12561
12777
  };
@@ -12585,6 +12801,12 @@ async function deriveSubstrateKeyPair(key) {
12585
12801
  }
12586
12802
 
12587
12803
  // src/services/CacheService.ts
12804
+ function budgetToCache(budget) {
12805
+ return { budgetId: budget.budgetId, cap: budget.cap.toString(), token: budget.token };
12806
+ }
12807
+ function budgetFromCache(budget) {
12808
+ return { budgetId: budget.budgetId, cap: BigInt(budget.cap), token: budget.token };
12809
+ }
12588
12810
  var CacheService = class {
12589
12811
  constructor(loggers = defaultLoggerContext()) {
12590
12812
  this.CACHE_EXPIRY_MS = 1 * 60 * 1e3;
@@ -12598,6 +12820,7 @@ var CacheService = class {
12598
12820
  matchedLimitOrders: {},
12599
12821
  bidPlans: {},
12600
12822
  partialFills: {},
12823
+ feeChecksWaived: {},
12601
12824
  feeTokens: {},
12602
12825
  tokenDecimals: {},
12603
12826
  solverSelection: {}
@@ -12637,6 +12860,10 @@ var CacheService = class {
12637
12860
  stalePartialIds.forEach((orderId) => {
12638
12861
  delete this.cacheData.partialFills[orderId];
12639
12862
  });
12863
+ const staleWaivedIds = Object.entries(this.cacheData.feeChecksWaived).filter(([_, data]) => !this.isCacheValid(data.timestamp)).map(([orderId]) => orderId);
12864
+ staleWaivedIds.forEach((orderId) => {
12865
+ delete this.cacheData.feeChecksWaived[orderId];
12866
+ });
12640
12867
  }
12641
12868
  getGasEstimate(orderId) {
12642
12869
  try {
@@ -12742,7 +12969,24 @@ var CacheService = class {
12742
12969
  return null;
12743
12970
  }
12744
12971
  }
12745
- setFillerOutputs(orderId, outputs, inputs = []) {
12972
+ /**
12973
+ * The budget of the limit order that priced the outputs cached for this order, or
12974
+ * null when no limit order did.
12975
+ *
12976
+ * Kept on the same entry as the outputs, so the two are replaced and expire
12977
+ * together: a bid can never sign one limit order's payout beside another's budget.
12978
+ */
12979
+ getBidBudget(orderId) {
12980
+ try {
12981
+ const cache = this.cacheData.fillerOutputs[orderId];
12982
+ if (cache?.budget && this.isCacheValid(cache.timestamp)) return budgetFromCache(cache.budget);
12983
+ return null;
12984
+ } catch (error) {
12985
+ this.logger.error({ err: error }, "Error getting bid budget");
12986
+ return null;
12987
+ }
12988
+ }
12989
+ setFillerOutputs(orderId, outputs, inputs = [], budget) {
12746
12990
  try {
12747
12991
  this.cleanupStaleData();
12748
12992
  this.cacheData.fillerOutputs[orderId] = {
@@ -12754,6 +12998,7 @@ var CacheService = class {
12754
12998
  token: o.token,
12755
12999
  amount: o.amount.toString()
12756
13000
  })),
13001
+ ...budget ? { budget: budgetToCache(budget) } : {},
12757
13002
  timestamp: Date.now()
12758
13003
  };
12759
13004
  } catch (error) {
@@ -12799,7 +13044,8 @@ var CacheService = class {
12799
13044
  data: call.data
12800
13045
  })),
12801
13046
  partialFill: plan.partialFill,
12802
- profit: plan.profit
13047
+ profit: plan.profit,
13048
+ ...plan.budget ? { budget: budgetFromCache(plan.budget) } : {}
12803
13049
  }));
12804
13050
  } catch (error) {
12805
13051
  this.logger.error({ err: error }, "Error getting bid plans");
@@ -12828,7 +13074,8 @@ var CacheService = class {
12828
13074
  data: call.data
12829
13075
  })),
12830
13076
  partialFill: plan.partialFill,
12831
- profit: plan.profit
13077
+ profit: plan.profit,
13078
+ ...plan.budget ? { budget: budgetToCache(plan.budget) } : {}
12832
13079
  })),
12833
13080
  timestamp: Date.now()
12834
13081
  };
@@ -12929,6 +13176,24 @@ var CacheService = class {
12929
13176
  clearPartialFill(orderId) {
12930
13177
  delete this.cacheData.partialFills[orderId];
12931
13178
  }
13179
+ /**
13180
+ * Whether the strategy's completed evaluation let a bid through without holding
13181
+ * `order.fees` to the execution cost, because the order is at or above the
13182
+ * operator's minimum size.
13183
+ *
13184
+ * Written and cleared the way {@link isPartialFill} is, for the same reason: the
13185
+ * caller exempts such an order from its profit floor.
13186
+ */
13187
+ isFeeCheckWaived(orderId) {
13188
+ const cache = this.cacheData.feeChecksWaived[orderId];
13189
+ return cache !== void 0 && this.isCacheValid(cache.timestamp) && cache.waived;
13190
+ }
13191
+ setFeeCheckWaived(orderId, waived) {
13192
+ this.cacheData.feeChecksWaived[orderId] = { waived, timestamp: Date.now() };
13193
+ }
13194
+ clearFeeCheckWaived(orderId) {
13195
+ delete this.cacheData.feeChecksWaived[orderId];
13196
+ }
12932
13197
  getFeeTokenWithDecimals(chain) {
12933
13198
  try {
12934
13199
  const cache = this.cacheData.feeTokens[chain];
@@ -13661,6 +13926,44 @@ import {
13661
13926
  bytes20ToBytes32
13662
13927
  } from "@hyperbridge/sdk";
13663
13928
 
13929
+ // src/config/abis/SolverAccount.ts
13930
+ var SOLVER_ACCOUNT_ABI = [
13931
+ {
13932
+ inputs: [
13933
+ { name: "orderId", type: "bytes32" },
13934
+ { name: "cap", type: "uint256" },
13935
+ { name: "token", type: "address" },
13936
+ { name: "approved", type: "uint256" },
13937
+ { name: "fee", type: "uint256" }
13938
+ ],
13939
+ name: "debitOrder",
13940
+ outputs: [],
13941
+ stateMutability: "nonpayable",
13942
+ type: "function"
13943
+ },
13944
+ {
13945
+ inputs: [{ name: "orderId", type: "bytes32" }],
13946
+ name: "spent",
13947
+ outputs: [{ name: "", type: "uint256" }],
13948
+ stateMutability: "view",
13949
+ type: "function"
13950
+ },
13951
+ {
13952
+ inputs: [{ name: "sender", type: "address" }],
13953
+ name: "AccountUnauthorized",
13954
+ type: "error"
13955
+ },
13956
+ {
13957
+ inputs: [
13958
+ { name: "orderId", type: "bytes32" },
13959
+ { name: "total", type: "uint256" },
13960
+ { name: "cap", type: "uint256" }
13961
+ ],
13962
+ name: "LimitOrderExceeded",
13963
+ type: "error"
13964
+ }
13965
+ ];
13966
+
13664
13967
  // src/config/abis/EvmHost.ts
13665
13968
  var EVM_HOST = [
13666
13969
  {
@@ -18054,6 +18357,10 @@ function describeShortfall(balances) {
18054
18357
  // src/services/ContractInteractionService.ts
18055
18358
  Decimal2.config({ precision: 28, rounding: 4 });
18056
18359
  var FUNDING_GAS_PER_CALL = 400000n;
18360
+ var DEBIT_ORDER_GAS = 60000n;
18361
+ function settles(budget) {
18362
+ return !!budget && budget.token.toLowerCase() !== ADDRESS_ZERO;
18363
+ }
18057
18364
  var LIMIT_ORDER_CALL_GAS_LIMIT = 500000n;
18058
18365
  var LIMIT_ORDER_VERIFICATION_GAS_LIMIT = 150000n;
18059
18366
  var LIMIT_ORDER_PRE_VERIFICATION_GAS = 50000n;
@@ -18214,7 +18521,10 @@ var ContractInteractionService = class {
18214
18521
  order,
18215
18522
  prependCalls: void 0,
18216
18523
  maxPriorityFeePerGasBumpPercent: gasFeeBumpConfig?.maxPriorityFeePerGasBumpPercent,
18217
- maxFeePerGasBumpPercent: gasFeeBumpConfig?.maxFeePerGasBumpPercent
18524
+ maxFeePerGasBumpPercent: gasFeeBumpConfig?.maxFeePerGasBumpPercent,
18525
+ // No bid without the bundler's own figures: an op signed with the SDK's
18526
+ // fixed fallback limits does not execute, so a failed estimate skips the order.
18527
+ requireBundlerEstimate: true
18218
18528
  });
18219
18529
  this.logger.info({ orderId: order.id }, "Caching gas estimate");
18220
18530
  this.logger.info({ estimate }, "Estimate");
@@ -18237,23 +18547,25 @@ var ContractInteractionService = class {
18237
18547
  callGasLimit: estimate.callGasLimit
18238
18548
  };
18239
18549
  } catch (error) {
18240
- this.logger.error({ err: error }, "Error estimating gas, using generous fallback values");
18550
+ this.logger.error({ err: error, orderId: order.id }, "Error estimating gas, not bidding on this order");
18241
18551
  throw new Error(`Failed to estimate gas: ${error instanceof Error ? error.message : "Unknown error"}`);
18242
18552
  }
18243
18553
  }
18244
18554
  /**
18245
18555
  * The call gas a bid needs: the shared estimate plus room for the funding calls
18246
- * this bid prepends.
18556
+ * this bid prepends and for settling its limit order's budget.
18247
18557
  *
18248
18558
  * Funding calls are not simulated (the V4 PositionManager's flash accounting does
18249
18559
  * not resolve in the bundler's estimation context), so each one adds a fixed
18250
18560
  * allowance for its modifyLiquidities + take. They differ between the bids on one
18251
18561
  * order, since each draws on its own limit order and so on its own funding, which
18252
18562
  * is why this is worked out per bid rather than baked into the cached estimate.
18563
+ * The estimate is of a lone fillOrder, so the budget call is not in it either.
18253
18564
  */
18254
- callGasLimitFor(order, baseCallGasLimit) {
18565
+ callGasLimitFor(order, baseCallGasLimit, budget) {
18255
18566
  const funding = order.id ? this.cacheService.getFundingPrepends(order.id) : null;
18256
- return baseCallGasLimit + FUNDING_GAS_PER_CALL * BigInt(funding?.calls?.length ?? 0);
18567
+ const fundingGas = FUNDING_GAS_PER_CALL * BigInt(funding?.calls?.length ?? 0);
18568
+ return baseCallGasLimit + fundingGas + (settles(budget) ? DEBIT_ORDER_GAS : 0n);
18257
18569
  }
18258
18570
  /**
18259
18571
  * Gets the fee token address and decimals for a given chain.
@@ -18435,6 +18747,27 @@ var ContractInteractionService = class {
18435
18747
  { maxRetries: 3, backoffMs: 250, logMessage: "Failed to read token balance" }
18436
18748
  );
18437
18749
  }
18750
+ /**
18751
+ * What the solver's account has tallied against a limit order's budget on `chain`, in
18752
+ * the paid token's own units, or null when it cannot be read.
18753
+ *
18754
+ * Null rather than a throw: an account whose implementation keeps no tally answers
18755
+ * with a revert or with no data, and the caller carries on without the figure either
18756
+ * way. Not retried, since that answer does not change on a second ask.
18757
+ */
18758
+ async limitOrderSpent(chain, budgetId) {
18759
+ try {
18760
+ return await this.clientManager.getPublicClient(chain).readContract({
18761
+ address: this.solverAccountAddress,
18762
+ abi: SOLVER_ACCOUNT_ABI,
18763
+ functionName: "spent",
18764
+ args: [budgetId]
18765
+ });
18766
+ } catch (err) {
18767
+ this.logger.warn({ err, chain, budgetId }, "Could not read the limit order's tally from the solver account");
18768
+ return null;
18769
+ }
18770
+ }
18438
18771
  async getSolverEntryPointBalance(chain) {
18439
18772
  const entryPointAddress = this.configService.getEntryPointAddress(chain);
18440
18773
  if (!entryPointAddress) {
@@ -18575,7 +18908,8 @@ var ContractInteractionService = class {
18575
18908
  if (!cachedEstimate) {
18576
18909
  throw new Error(`No cached gas estimate found for order ${order.id}. Call estimateGasFillPost first.`);
18577
18910
  }
18578
- const callGasLimit = this.callGasLimitFor(order, cachedEstimate.callGasLimit);
18911
+ const budget = this.cacheService.getBidBudget(order.id);
18912
+ const callGasLimit = this.callGasLimitFor(order, cachedEstimate.callGasLimit, budget);
18579
18913
  const cachedFillerOutputs = this.cacheService.getFillerOutputs(order.id);
18580
18914
  const cachedFillerInputs = this.cacheService.getFillerInputs(order.id);
18581
18915
  if (!cachedFillerOutputs || !cachedFillerInputs) {
@@ -18602,7 +18936,8 @@ var ContractInteractionService = class {
18602
18936
  order,
18603
18937
  cachedFillerOutputs,
18604
18938
  fillOptions,
18605
- cachedEstimate.totalCostInSourceFeeToken + dispatchFeeTokenAmount
18939
+ cachedEstimate.totalCostInSourceFeeToken + dispatchFeeTokenAmount,
18940
+ budget ? { budget, solverAccount: solverAccountAddress } : void 0
18606
18941
  );
18607
18942
  const commitment = orderCommitment2(order);
18608
18943
  const nonce = await this.clientManager.getPublicClient(order.destination).readContract({
@@ -18794,8 +19129,13 @@ var ContractInteractionService = class {
18794
19129
  * Same-chain fills release escrow locally with no Hyperbridge dispatch, so the
18795
19130
  * gateway never pulls the fee token — its approval is skipped. Only cross-chain
18796
19131
  * fills, which dispatch a RedeemEscrow message paid in the fee token, need it.
19132
+ *
19133
+ * A bid priced by a limit order ends with `debitOrder`, which takes the budget's id
19134
+ * as its `orderId`, works out what the fill paid from what is left of the allowance
19135
+ * and reverts the whole batch if the order's payouts would pass its size. It has to
19136
+ * come last, after the gateway has drawn on the allowance.
18797
19137
  */
18798
- async buildApprovalAndFillCalldata(order, fillerOutputs, fillOptions, requiredFeeTokenAmount) {
19138
+ async buildApprovalAndFillCalldata(order, fillerOutputs, fillOptions, requiredFeeTokenAmount, settlement) {
18799
19139
  const chain = order.destination;
18800
19140
  const intentGatewayV2Address = this.configService.getIntentGatewayAddress(chain);
18801
19141
  const perTokenRequired = /* @__PURE__ */ new Map();
@@ -18805,9 +19145,10 @@ var ContractInteractionService = class {
18805
19145
  const key = addr.toLowerCase();
18806
19146
  perTokenRequired.set(key, (perTokenRequired.get(key) ?? 0n) + output.amount);
18807
19147
  }
19148
+ let feeKey;
18808
19149
  if (order.source !== order.destination) {
18809
19150
  const feeToken = await this.getFeeTokenWithDecimals(chain);
18810
- const feeKey = feeToken.address.toLowerCase();
19151
+ feeKey = feeToken.address.toLowerCase();
18811
19152
  perTokenRequired.set(feeKey, (perTokenRequired.get(feeKey) ?? 0n) + requiredFeeTokenAmount);
18812
19153
  }
18813
19154
  const fundingPrepends = order.id ? this.cacheService.getFundingPrepends(order.id) : null;
@@ -18833,6 +19174,26 @@ var ContractInteractionService = class {
18833
19174
  value: nativeOutputValue,
18834
19175
  data: encodeFillOrder(transformOrderForContract(order), fillOptions)
18835
19176
  });
19177
+ if (settlement && settles(settlement.budget)) {
19178
+ const { budget, solverAccount } = settlement;
19179
+ const budgetToken = budget.token.toLowerCase();
19180
+ const approved = perTokenRequired.get(budgetToken) ?? 0n;
19181
+ if (approved === 0n) {
19182
+ throw new Error(
19183
+ `Bid on order ${order.id} approves nothing in ${budget.token}, the token budget ${budget.budgetId} is measured in`
19184
+ );
19185
+ }
19186
+ const fee = feeKey === budgetToken ? fillOptions.relayerFee : 0n;
19187
+ calls.push({
19188
+ target: solverAccount,
19189
+ value: 0n,
19190
+ data: encodeFunctionData2({
19191
+ abi: SOLVER_ACCOUNT_ABI,
19192
+ functionName: "debitOrder",
19193
+ args: [budget.budgetId, budget.cap, budgetToken, approved, fee]
19194
+ })
19195
+ });
19196
+ }
18836
19197
  return encodeERC7821ExecuteBatch2(calls);
18837
19198
  }
18838
19199
  };
@@ -20685,11 +21046,11 @@ function byBestRate(a, b) {
20685
21046
  return left === right ? 0 : left > right ? -1 : 1;
20686
21047
  }
20687
21048
  function chargedFor(hold, delivered, released) {
20688
- if (hold.take === void 0 || released === 0n) return delivered;
20689
- const take = BigInt(hold.take);
20690
- if (take === 0n) return delivered;
20691
- const atRate = (BigInt(hold.amount) * released + take - 1n) / take;
20692
- return atRate > delivered ? atRate : delivered;
21049
+ const take = hold.take === void 0 ? 0n : BigInt(hold.take);
21050
+ if (take === 0n || released === 0n) return toScaledUp(delivered.raw, delivered.decimals);
21051
+ const offered = toRaw(BigInt(hold.amount), delivered.decimals);
21052
+ const atRate = (offered * released + take - 1n) / take;
21053
+ return toScaledUp(atRate > delivered.raw ? atRate : delivered.raw, delivered.decimals);
20693
21054
  }
20694
21055
  var IntentFiller = class {
20695
21056
  constructor(chainConfigs, strategies, config, configService, chainClientManager, contractService, signer, scanners, rebalancingService, bidStorage, limitOrders, assetRegistry) {
@@ -20766,6 +21127,17 @@ var IntentFiller = class {
20766
21127
  setLimitOrderService(service) {
20767
21128
  this.limitOrderService = service;
20768
21129
  }
21130
+ /**
21131
+ * Brings the limit orders' `remaining` in line with their on-chain tallies.
21132
+ *
21133
+ * On the settlement queue, so the pass and a fill never resize the same order at
21134
+ * once. Resolves when the pass has run.
21135
+ */
21136
+ async reconcileLimitOrderTallies() {
21137
+ await this.settlementQueue.add(async () => {
21138
+ await this.limitOrderService?.reconcileTallies();
21139
+ });
21140
+ }
20769
21141
  /** Whether the given chain id is configured for watch-only (monitor, never fill). */
20770
21142
  isChainWatchOnly(chainId) {
20771
21143
  const watchOnly = this.config.watchOnly;
@@ -21343,6 +21715,23 @@ var IntentFiller = class {
21343
21715
  this.monitor.emit("orderSkipped", { orderId: order.id, reason: "watch-only" });
21344
21716
  return null;
21345
21717
  }
21718
+ const destinationHead = await this.destinationHead(order);
21719
+ if (destinationHead !== null && order.deadline <= destinationHead) {
21720
+ this.logger.info(
21721
+ {
21722
+ orderId: order.id,
21723
+ destChain: order.destination,
21724
+ deadline: order.deadline.toString(),
21725
+ head: destinationHead.toString()
21726
+ },
21727
+ "Skipping order: its deadline has passed on the destination chain"
21728
+ );
21729
+ this.monitor.emit("orderSkipped", {
21730
+ orderId: order.id,
21731
+ reason: "Order deadline has passed on the destination chain"
21732
+ });
21733
+ return null;
21734
+ }
21346
21735
  const evalStartMs = Date.now();
21347
21736
  const eligibleStrategies = await Promise.all(
21348
21737
  this.strategies.map(async (strategy) => {
@@ -21352,7 +21741,10 @@ var IntentFiller = class {
21352
21741
  })
21353
21742
  );
21354
21743
  const fillsPartially = this.fillsPartially(order);
21355
- const validStrategies = eligibleStrategies.filter((s) => s !== null && (s.profitability > 0 || fillsPartially)).sort((a, b) => b.profitability - a.profitability);
21744
+ const feeCheckWaived = this.feeCheckWaived(order);
21745
+ const validStrategies = eligibleStrategies.filter(
21746
+ (s) => s !== null && (s.profitability > 0 || fillsPartially || feeCheckWaived)
21747
+ ).sort((a, b) => b.profitability - a.profitability);
21356
21748
  const evalDurationSec = (Date.now() - evalStartMs) / 1e3;
21357
21749
  this.monitor.emit("orderTiming", { orderId: order.id, phase: "evaluation", durationSec: evalDurationSec });
21358
21750
  if (validStrategies.length === 0) {
@@ -21377,6 +21769,23 @@ var IntentFiller = class {
21377
21769
  );
21378
21770
  return validStrategies[0];
21379
21771
  }
21772
+ /**
21773
+ * The destination chain's latest block number, the clock `order.deadline` is read on.
21774
+ *
21775
+ * Null when the read fails. That is not an answer about the order, so evaluation goes
21776
+ * on: the gas estimate simulates the same deadline check and refuses an expired order.
21777
+ */
21778
+ async destinationHead(order) {
21779
+ try {
21780
+ return await this.chainClientManager.getPublicClient(order.destination).getBlockNumber();
21781
+ } catch (err) {
21782
+ this.logger.warn(
21783
+ { orderId: order.id, destChain: order.destination, err },
21784
+ "Could not read the destination head to check the order's deadline"
21785
+ );
21786
+ return null;
21787
+ }
21788
+ }
21380
21789
  /**
21381
21790
  * Whether the strategy's evaluation concluded in a deliberate partial fill.
21382
21791
  *
@@ -21390,6 +21799,15 @@ var IntentFiller = class {
21390
21799
  if (!order.id) return false;
21391
21800
  return this.contractService.cacheService.isPartialFill(order.id);
21392
21801
  }
21802
+ /**
21803
+ * Whether the strategy's evaluation let a bid through without the fee check, because
21804
+ * the order is at or above the operator's minimum size. Read from a flag the strategy
21805
+ * sets only once it has an answer, as {@link fillsPartially} is.
21806
+ */
21807
+ feeCheckWaived(order) {
21808
+ if (!order.id) return false;
21809
+ return this.contractService.cacheService.isFeeCheckWaived(order.id);
21810
+ }
21393
21811
  executeOrder(order, bestStrategy, solverSelectionActive, inputUsdValue, profitUsd) {
21394
21812
  const chainQueue = this.chainQueues.get(getChainId3(order.destination));
21395
21813
  if (!chainQueue) {
@@ -21411,7 +21829,7 @@ var IntentFiller = class {
21411
21829
  let reservation = [];
21412
21830
  if (plan) {
21413
21831
  if (order.id) {
21414
- cache.setFillerOutputs(order.id, plan.fillerOutputs, plan.fillerInputs);
21832
+ cache.setFillerOutputs(order.id, plan.fillerOutputs, plan.fillerInputs, plan.budget);
21415
21833
  cache.setPartialFill(order.id, plan.partialFill);
21416
21834
  if (plan.fundingCalls.length > 0) cache.setFundingPrepends(order.id, plan.fundingCalls);
21417
21835
  else cache.clearFundingPrepends(order.id);
@@ -21612,7 +22030,7 @@ var IntentFiller = class {
21612
22030
  await this.releaseAll(claimed);
21613
22031
  return;
21614
22032
  }
21615
- const settled = executedHold(claimed, delivered, released);
22033
+ const settled = executedHold(claimed, delivered.scaled, released);
21616
22034
  const charged = chargedFor(settled, delivered, released);
21617
22035
  const settledBid = rows.find(
21618
22036
  (row) => row.reservations.some(
@@ -21665,15 +22083,15 @@ var IntentFiller = class {
21665
22083
  if (delivered === null) return [];
21666
22084
  const settled = executedHold(
21667
22085
  holds.map((entry) => entry.hold),
21668
- delivered,
22086
+ delivered.scaled,
21669
22087
  released
21670
22088
  );
21671
22089
  const row = holds.find((entry) => entry.hold === settled).row;
21672
22090
  return this.bidStorage.claimReservation(commitment, row.bid ?? void 0);
21673
22091
  }
21674
22092
  /**
21675
- * What this fill delivered of the token the limit order pays, at 1e18, or null
21676
- * when the fill names none of it.
22093
+ * What this fill delivered of the token the limit order pays, or null when the
22094
+ * fill names none of it.
21677
22095
  */
21678
22096
  async deliveredAgainst(limitOrderId, chainId, outputs) {
21679
22097
  const order = await this.limitOrders?.get(limitOrderId);
@@ -21689,7 +22107,7 @@ var IntentFiller = class {
21689
22107
  }
21690
22108
  if (total === 0n) return null;
21691
22109
  const decimals = await this.contractService.getTokenDecimals(address, chain);
21692
- return toScaled(total, decimals);
22110
+ return { scaled: toScaled(total, decimals), raw: total, decimals };
21693
22111
  }
21694
22112
  /**
21695
22113
  * Gives back whatever this bid still holds against its limit order.
@@ -21785,87 +22203,6 @@ import {
21785
22203
  import { formatUnits as formatUnits4 } from "viem";
21786
22204
  import { Decimal as Decimal6 } from "decimal.js";
21787
22205
 
21788
- // src/orderbook/matching.ts
21789
- function limitOrderLegs(order) {
21790
- return order.side === "BID" ? { input: order.base, output: order.quote } : { input: order.quote, output: order.base };
21791
- }
21792
- function matchLimitOrders(orders, incoming, resolve, now = /* @__PURE__ */ new Date()) {
21793
- const qualifying = orders.filter((order) => serves(order, incoming, resolve, now)).map((order) => {
21794
- const offer = offerFor({
21795
- side: order.side,
21796
- inputAmount: incoming.inputNet,
21797
- price: BigInt(order.price),
21798
- outputDecimals: incoming.outputDecimals
21799
- });
21800
- const available = BigInt(order.remaining);
21801
- return { order, offer, available, payout: offer < available ? offer : available };
21802
- }).filter((candidate) => candidate.offer >= incoming.requestedOutput && candidate.payout > 0n).sort(byBestOfferFirst);
21803
- return qualifying;
21804
- }
21805
- function byBestOfferFirst(a, b) {
21806
- if (a.offer !== b.offer) return a.offer > b.offer ? -1 : 1;
21807
- if (a.available !== b.available) return a.available > b.available ? -1 : 1;
21808
- return a.order.id < b.order.id ? -1 : 1;
21809
- }
21810
- function whyUnmatched(orders, incoming, resolve, now = /* @__PURE__ */ new Date()) {
21811
- let furthest = -1;
21812
- for (const order of orders) {
21813
- const failed = firstFailed(order, incoming, resolve, now);
21814
- furthest = Math.max(furthest, failed === null ? CHECKS.length : CHECKS.indexOf(failed));
21815
- }
21816
- const { source, destination, inputSymbol } = incoming;
21817
- if (furthest < CHECKS.length) {
21818
- switch (CHECKS[Math.max(furthest, 0)]) {
21819
- case "open":
21820
- return "no limit order is open";
21821
- case "chain":
21822
- return `no open limit order fills on ${destination}`;
21823
- case "input":
21824
- return `no limit order on ${destination} takes ${inputSymbol} in`;
21825
- case "output":
21826
- return `no limit order on ${destination} taking ${inputSymbol} in pays out the token this order asks for`;
21827
- case "source":
21828
- return `no limit order for this pair on ${destination} accepts swaps from ${source}`;
21829
- }
21830
- }
21831
- const priced = orders.filter((order) => firstFailed(order, incoming, resolve, now) === null).map((order) => ({
21832
- order,
21833
- offer: offerFor({
21834
- side: order.side,
21835
- inputAmount: incoming.inputNet,
21836
- price: BigInt(order.price),
21837
- outputDecimals: incoming.outputDecimals
21838
- })
21839
- }));
21840
- if (priced.some(({ offer }) => offer >= incoming.requestedOutput)) {
21841
- return "the limit orders that meet its rate have nothing left to pay out";
21842
- }
21843
- const best = priced.reduce((a, b) => b.offer > a.offer ? b : a);
21844
- const symbol = limitOrderLegs(best.order).output;
21845
- return `it asks for ${readable(incoming.requestedOutput)} ${symbol}; the best limit order offers ${readable(best.offer)} ${symbol}`;
21846
- }
21847
- var CHECKS = ["open", "chain", "input", "output", "source"];
21848
- function serves(order, incoming, resolve, now) {
21849
- return firstFailed(order, incoming, resolve, now) === null;
21850
- }
21851
- function firstFailed(order, incoming, resolve, now) {
21852
- if (order.status !== "open") return "open";
21853
- if (order.expiresAt !== null && new Date(order.expiresAt) <= now) return "open";
21854
- if (order.fillChain !== incoming.destination) return "chain";
21855
- const legs = limitOrderLegs(order);
21856
- if (normalizeSymbol(legs.input) !== normalizeSymbol(incoming.inputSymbol)) return "input";
21857
- const outputToken = resolve(legs.output, order.fillChain);
21858
- if (!outputToken || outputToken.toLowerCase() !== incoming.outputToken.toLowerCase()) return "output";
21859
- if (incoming.source === incoming.destination) return null;
21860
- return order.acceptedSources.includes(incoming.source) ? null : "source";
21861
- }
21862
- function readable(amount) {
21863
- const [whole, fraction] = toHuman(amount).split(".");
21864
- const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
21865
- const cut = fraction?.slice(0, 6).replace(/0+$/, "");
21866
- return cut ? `${grouped}.${cut}` : grouped;
21867
- }
21868
-
21869
22206
  // src/orderbook/usd.ts
21870
22207
  import { Decimal as Decimal5 } from "decimal.js";
21871
22208
  function limitOrderUsdEdges(orders) {
@@ -21920,9 +22257,13 @@ var FXFiller = class _FXFiller {
21920
22257
  */
21921
22258
  constructor(signer, configService, clientManager, contractService, pairs, registry, options) {
21922
22259
  this.name = "FXFiller";
21923
- /** Consecutive orders where overfill clamp activated. */
22260
+ /**
22261
+ * Consecutive orders where the overfill clamp activated. Dormant: the clamp
22262
+ * existed for venue-priced legs, which went with Uniswap V4 pricing, so every
22263
+ * outcome is now recorded unclamped and this never climbs. See `recordOrderOutcome`.
22264
+ */
21924
22265
  this.consecutiveClamps = 0;
21925
- /** Once set, the filler refuses all orders until restart — systemic pricing error suspected. */
22266
+ /** Once set, the filler refuses all orders until reset. Unreachable while the clamp is dormant. */
21926
22267
  this.halted = false;
21927
22268
  this.logger = moduleLogger(configService.loggers, "fx-simplex");
21928
22269
  const { confirmationPolicy, fundingVenues = [], limitOrders } = options ?? {};
@@ -21971,10 +22312,9 @@ var FXFiller = class _FXFiller {
21971
22312
  this.pairs.push(pair);
21972
22313
  }
21973
22314
  /**
21974
- * Removes a market from the running engine. All-or-nothing: at least one
21975
- * market must remain and no other pair may lose its USD anchor (removing a
21976
- * reference feed that anchors a dependent market is rejected). In-flight
21977
- * rechecks of orders on the removed pair simply stop matching.
22315
+ * Removes a market from the running engine. At least one market must remain.
22316
+ * Matching reads the limit orders rather than the pairs, so removing a market
22317
+ * changes what the dashboard lists, not what the filler fills.
21978
22318
  */
21979
22319
  removePair(pair) {
21980
22320
  const index = this.pairs.indexOf(pair);
@@ -22030,25 +22370,25 @@ var FXFiller = class _FXFiller {
22030
22370
  }
22031
22371
  }
22032
22372
  /**
22033
- * Evaluates whether an order is profitable to fill under the per-pair
22034
- * `maxOrderSize` caps (where set) and the filler's current token balances.
22373
+ * Evaluates whether an order is profitable to fill against the operator's
22374
+ * limit orders and the filler's current token balances.
22035
22375
  *
22036
22376
  * High-level flow:
22037
- * - Resolve each (input, output) leg to a configured pair and direction.
22038
- * - Estimate each pair's total token0 notional in the order and cap it at
22039
- * the pair's `maxOrderSize` when one is set; pair curves are evaluated at
22040
- * that (possibly uncapped) notional.
22041
- * - Walk the legs, allocating from each pair's capped token0 budget and
22042
- * pricing outputs at the pair's rate.
22043
- * - Further cap each leg by the filler's current token balance plus
22044
- * funding-venue withdrawals.
22045
- * - Cache the resulting outputs for later use in `executeOrder`.
22046
- *
22047
- * Note: we may intentionally overfill relative to the user's requested
22048
- * outputs if the pair pricing makes that attractive. This is how we stay competitive.
22377
+ * - Match each (input, output) leg against the open limit orders.
22378
+ * - Build one bid per matching order, best offer first, each paying the
22379
+ * order's whole offer for the input at its own rate, capped by what the
22380
+ * order has left. The gateway credits the swapper the ask and splits
22381
+ * anything above it between the swapper and the protocol.
22382
+ * - Treat a payout below the ask as a partial fill, which only orders
22383
+ * without output calldata and without an earlier partial fill allow.
22384
+ * - Cap each payout by the filler's balance plus funding-venue withdrawals,
22385
+ * then gate it on fees (full fills of orders below `minOrderSizeUsd`) and the
22386
+ * same-asset spread.
22387
+ * - Cache the resulting bids for later use in `executeOrder`.
22049
22388
  */
22050
22389
  async calculateProfitability(order) {
22051
22390
  if (order.id) this.contractService.cacheService.clearPartialFill(order.id);
22391
+ if (order.id) this.contractService.cacheService.clearFeeCheckWaived(order.id);
22052
22392
  if (this.halted) {
22053
22393
  this.logger.warn({ orderId: order.id }, "FXFiller halted \u2014 rejecting order");
22054
22394
  return 0;
@@ -22063,6 +22403,16 @@ var FXFiller = class _FXFiller {
22063
22403
  const multiLeg = order.inputs.length > 1;
22064
22404
  const plans = [];
22065
22405
  const plannedOn = /* @__PURE__ */ new Map();
22406
+ const spentOn = /* @__PURE__ */ new Map();
22407
+ let atOrAboveMinSize;
22408
+ const feeCheckWaived = async () => {
22409
+ if (atOrAboveMinSize === void 0) {
22410
+ const usd = await this.getOrderUsdValue(order);
22411
+ atOrAboveMinSize = usd !== null && usd.inputUsd.gte(this.configService.getMinOrderSizeUsd());
22412
+ }
22413
+ return atOrAboveMinSize;
22414
+ };
22415
+ let feesWaived = false;
22066
22416
  for (let leg = 0; leg < order.inputs.length; leg++) {
22067
22417
  const input = order.inputs[leg];
22068
22418
  const output = order.output.assets[leg];
@@ -22084,7 +22434,30 @@ var FXFiller = class _FXFiller {
22084
22434
  for (const candidate of matches) {
22085
22435
  let partialFill = false;
22086
22436
  const fundingCalls = [];
22087
- const left = candidate.available - (plannedOn.get(candidate.order.id) ?? 0n);
22437
+ const budget = budgetFor(candidate.order, outputToken, outputDecimals);
22438
+ if (!spentOn.has(candidate.order.id)) {
22439
+ spentOn.set(
22440
+ candidate.order.id,
22441
+ await this.contractService.limitOrderSpent(destChain, budget.budgetId)
22442
+ );
22443
+ }
22444
+ const spent = spentOn.get(candidate.order.id) ?? null;
22445
+ const room = spent === null ? candidate.available : budgetRoom(budget, spent, outputDecimals);
22446
+ if (room === 0n) {
22447
+ this.logger.info(
22448
+ {
22449
+ orderId: order.id,
22450
+ leg,
22451
+ limitOrder: candidate.order.id,
22452
+ spent: spent?.toString(),
22453
+ cap: budget.cap.toString()
22454
+ },
22455
+ "Skipping a bid: the limit order has nothing left on chain"
22456
+ );
22457
+ continue;
22458
+ }
22459
+ const available = room < candidate.available ? room : candidate.available;
22460
+ const left = available - (plannedOn.get(candidate.order.id) ?? 0n);
22088
22461
  if (left <= 0n) {
22089
22462
  this.logger.info(
22090
22463
  { orderId: order.id, leg, limitOrder: candidate.order.id },
@@ -22119,10 +22492,7 @@ var FXFiller = class _FXFiller {
22119
22492
  {
22120
22493
  orderId: order.id,
22121
22494
  limitOrder: candidate.order.id,
22122
- available: formatUnits4(
22123
- candidate.available,
22124
- 18
22125
- ),
22495
+ available: formatUnits4(available, 18),
22126
22496
  userRequested: output.amount.toString(),
22127
22497
  payout: targetOutput.toString(),
22128
22498
  crossChain: sourceChain !== destChain
@@ -22167,7 +22537,10 @@ var FXFiller = class _FXFiller {
22167
22537
  }
22168
22538
  }
22169
22539
  const effectiveBalance = walletContribution + credited;
22170
- const finalOutputAmount = effectiveBalance > targetOutput ? targetOutput : effectiveBalance;
22540
+ const finalOutputAmount = toRaw(
22541
+ toScaled(effectiveBalance > targetOutput ? targetOutput : effectiveBalance, outputDecimals),
22542
+ outputDecimals
22543
+ );
22171
22544
  if (finalOutputAmount === 0n) {
22172
22545
  this.logger.info(
22173
22546
  {
@@ -22283,17 +22656,21 @@ var FXFiller = class _FXFiller {
22283
22656
  const executionCost = totalCostInSourceFeeToken + relayerFeeInSourceFeeToken;
22284
22657
  const partialEdgeUsd = sameAssetEdgeUsd.plus(payoutSurplusUsd);
22285
22658
  if (!partialFill && order.fees < executionCost) {
22286
- this.logger.info(
22287
- {
22288
- orderId: order.id,
22289
- orderFees: formatUnits4(order.fees, feeTokenDecimals),
22290
- fillGas: formatUnits4(totalCostInSourceFeeToken, feeTokenDecimals),
22291
- relayerFee: formatUnits4(relayerFeeInSourceFeeToken, feeTokenDecimals),
22292
- executionCost: formatUnits4(executionCost, feeTokenDecimals)
22293
- },
22294
- "Skipping order: attached fees do not cover execution cost (fill gas + relayer fee)"
22295
- );
22296
- continue;
22659
+ if (!await feeCheckWaived()) {
22660
+ this.logger.info(
22661
+ {
22662
+ orderId: order.id,
22663
+ orderFees: formatUnits4(order.fees, feeTokenDecimals),
22664
+ fillGas: formatUnits4(totalCostInSourceFeeToken, feeTokenDecimals),
22665
+ relayerFee: formatUnits4(relayerFeeInSourceFeeToken, feeTokenDecimals),
22666
+ executionCost: formatUnits4(executionCost, feeTokenDecimals),
22667
+ minOrderSizeUsd: this.configService.getMinOrderSizeUsd()
22668
+ },
22669
+ "Skipping order: attached fees do not cover execution cost (fill gas + relayer fee)"
22670
+ );
22671
+ continue;
22672
+ }
22673
+ feesWaived = true;
22297
22674
  }
22298
22675
  if (sameAsset && !sameAssetProfitable) {
22299
22676
  this.logger.info(
@@ -22327,7 +22704,8 @@ var FXFiller = class _FXFiller {
22327
22704
  realizedSpreadProfit: formatUnits4(realizedSpreadProfit, feeTokenDecimals),
22328
22705
  payoutSurplusUsd: payoutSurplusUsd.toString(),
22329
22706
  totalProfit,
22330
- profitable: totalProfit > 0
22707
+ profitable: totalProfit > 0,
22708
+ feeCheckWaived: !partialFill && order.fees < executionCost
22331
22709
  },
22332
22710
  "FX swap profitability evaluation"
22333
22711
  );
@@ -22343,7 +22721,8 @@ var FXFiller = class _FXFiller {
22343
22721
  fillerInputs,
22344
22722
  fundingCalls: [...fundingCalls],
22345
22723
  partialFill,
22346
- profit: totalProfit
22724
+ profit: totalProfit,
22725
+ budget
22347
22726
  });
22348
22727
  if (!partialEligibleCheap) break;
22349
22728
  }
@@ -22351,8 +22730,14 @@ var FXFiller = class _FXFiller {
22351
22730
  if (plans.length === 0) return 0;
22352
22731
  if (order.id) {
22353
22732
  this.contractService.cacheService.setBidPlans(order.id, plans);
22354
- this.contractService.cacheService.setFillerOutputs(order.id, plans[0].fillerOutputs, plans[0].fillerInputs);
22733
+ this.contractService.cacheService.setFillerOutputs(
22734
+ order.id,
22735
+ plans[0].fillerOutputs,
22736
+ plans[0].fillerInputs,
22737
+ plans[0].budget
22738
+ );
22355
22739
  this.contractService.cacheService.setPartialFill(order.id, plans[0].partialFill);
22740
+ this.contractService.cacheService.setFeeCheckWaived(order.id, feesWaived);
22356
22741
  this.contractService.cacheService.setMatchedLimitOrder(order.id, [
22357
22742
  { limitOrderId: plans[0].limitOrderId, payout: plans[0].payout }
22358
22743
  ]);
@@ -22446,10 +22831,14 @@ var FXFiller = class _FXFiller {
22446
22831
  // =========================================================================
22447
22832
  /**
22448
22833
  * Update consecutive-clamp counter after a successful order evaluation.
22449
- * Only venue-priced legs feed this counter (see clamp site) — a streak of those
22450
- * is the signal that a live market source has gone off (stale pool, manipulated
22451
- * venue) and the filler should stop until an operator investigates. Offline
22452
- * price-curve clamps warn but never reach here.
22834
+ *
22835
+ * Dormant. Only venue-priced legs ever fed a clamped outcome here: a streak
22836
+ * of those meant a live market source had gone off (stale pool, manipulated
22837
+ * venue). Venue pricing went with Uniswap V4, and limit orders are prices the
22838
+ * operator set, so the only caller records `false` and the filler never
22839
+ * halts. `isHalted`, `resetHalt` and `[simplex.overfillProtection]
22840
+ * maxConsecutiveClamps` are kept so existing configs and the halt API still
22841
+ * load; they can go once nothing reads them.
22453
22842
  */
22454
22843
  recordOrderOutcome(clamped, orderId) {
22455
22844
  if (clamped) {
@@ -22475,18 +22864,6 @@ var FXFiller = class _FXFiller {
22475
22864
  this.consecutiveClamps = 0;
22476
22865
  this.logger.warn("FXFiller halt reset by operator \u2014 resuming order evaluation");
22477
22866
  }
22478
- /**
22479
- * Given a single (input, output) leg and the remaining token0 budget of its
22480
- * pair, computes how much token0 notional to allocate to this leg and the
22481
- * corresponding maximum output amount at the pair's rate.
22482
- *
22483
- * `rate` is **token1 per 1 token0**:
22484
- * - token0 input → token1 output: token0 × rate → token1 amount.
22485
- * - token1 input → token0 output: token1 ÷ rate → token0 amount.
22486
- *
22487
- * Returns `null` when this leg cannot consume any of the pair's remaining
22488
- * budget (e.g. the cap has already been exhausted).
22489
- */
22490
22867
  /**
22491
22868
  * Whether any solver has already delivered output against this order.
22492
22869
  *
@@ -22824,9 +23201,9 @@ var LimitOrderLifecycle = class {
22824
23201
  const reconcileMs = this.options.reconcileIntervalSecs * 1e3;
22825
23202
  this.every(heartbeatMs, "heartbeat", () => this.service.heartbeat());
22826
23203
  this.every(EXPIRY_SWEEP_MS, "expiry", () => this.expire());
22827
- this.every(reconcileMs, "reconciliation", () => this.reconcile());
23204
+ this.every(reconcileMs, "reconciliation", () => this.upkeep());
22828
23205
  this.logger.info({ heartbeatMs, expiryMs: EXPIRY_SWEEP_MS, reconcileMs }, "Orderbook lifecycle started");
22829
- void this.run("reconciliation", () => this.reconcile());
23206
+ void this.upkeep();
22830
23207
  }
22831
23208
  stop() {
22832
23209
  this.timers.forEach((timer) => clearInterval(timer));
@@ -22837,6 +23214,14 @@ var LimitOrderLifecycle = class {
22837
23214
  const expired = await this.service.expireStale();
22838
23215
  if (expired > 0) this.logger.info({ expired }, "Withdrew limit orders that had outlived their expiry");
22839
23216
  }
23217
+ /**
23218
+ * The tally pass first, so reconciliation against the orderbook reads the sizes
23219
+ * it left. Each fails on its own.
23220
+ */
23221
+ async upkeep() {
23222
+ await this.run("tally reconciliation", async () => this.options.reconcileTallies());
23223
+ await this.run("reconciliation", () => this.reconcile());
23224
+ }
22840
23225
  async reconcile() {
22841
23226
  const report = await this.service.reconcile();
22842
23227
  if (report.cancelled || report.reposted || report.underFunded) {
@@ -23594,6 +23979,7 @@ async function bootFiller(config, options) {
23594
23979
  rebalancing: config.rebalancing,
23595
23980
  targetGasUnits: config.simplex.targetGasUnits,
23596
23981
  bidValiditySeconds: config.simplex.bidValiditySeconds,
23982
+ minOrderSizeUsd: config.simplex.minOrderSizeUsd,
23597
23983
  blockScanIntervalSeconds: config.simplex.blockScanIntervalSeconds,
23598
23984
  gasFeeBump: config.simplex.gasFeeBump,
23599
23985
  overfillProtection: config.simplex.overfillProtection,
@@ -23637,7 +24023,7 @@ async function bootFiller(config, options) {
23637
24023
  const sharedCacheService = new CacheService(options.loggers);
23638
24024
  if (!options.signer && !globalWatchOnly && !allChainsWatchOnly(watchOnlyConfig, resolvedChains)) {
23639
24025
  throw new Error(
23640
- "A signer is required unless every chain is watch-only: pass `signer` to Simplex.start (privateKeySigner, turnkeySigner, mpcVaultSigner, viemSigner, or your own Signer implementation). Running the binary? Configure it under [simplex.signer]."
24026
+ "A signer is required unless every chain is watch-only: pass `signer` to Simplex.start (privateKeySigner, secretPhraseSigner, turnkeySigner, mpcVaultSigner, viemSigner, or your own Signer implementation). Running the binary? Configure it under [simplex.signer]."
23641
24027
  );
23642
24028
  }
23643
24029
  const chainClientManager = new ChainClientManager(configService, options.signer);
@@ -23776,7 +24162,8 @@ async function bootFiller(config, options) {
23776
24162
  const lifecycle = new LimitOrderLifecycle(
23777
24163
  limitOrderService,
23778
24164
  {
23779
- reconcileIntervalSecs: config.orderbook?.reconcileIntervalSecs ?? DEFAULT_RECONCILE_INTERVAL_SECS
24165
+ reconcileIntervalSecs: config.orderbook?.reconcileIntervalSecs ?? DEFAULT_RECONCILE_INTERVAL_SECS,
24166
+ reconcileTallies: () => intentFiller.reconcileLimitOrderTallies()
23780
24167
  },
23781
24168
  options.loggers
23782
24169
  );
@@ -24247,6 +24634,14 @@ var MemoryLimitOrderStore = class {
24247
24634
  const remaining = BigInt(order.remaining) - BigInt(amount);
24248
24635
  return this.patch(id, { remaining: (remaining > 0n ? remaining : 0n).toString() });
24249
24636
  }
24637
+ async clampRemaining(id, room) {
24638
+ const order = this.orders.get(id);
24639
+ if (!order || order.status !== "open") return null;
24640
+ const sum = BigInt(room) + BigInt(order.reserved);
24641
+ const target = sum > 0n ? sum : 0n;
24642
+ if (target >= BigInt(order.remaining)) return null;
24643
+ return this.patch(id, { remaining: target.toString() });
24644
+ }
24250
24645
  async release(id, amount) {
24251
24646
  const order = this.orders.get(id);
24252
24647
  if (!order) return;
@@ -24755,7 +25150,8 @@ var OrderScanner = class _OrderScanner {
24755
25150
  * back from its own endpoints, which also proves they answer for one chain
24756
25151
  * before a filler ever depends on them.
24757
25152
  *
24758
- * @throws if an endpoint set is invalid or unreachable, if two entries resolve
25153
+ * @throws if an endpoint set is invalid, if none of a chain's endpoints answer,
25154
+ * if its endpoints disagree on the chain, if two entries resolve
24759
25155
  * to the same chain, or if `scanIntervalSecs` is below the minimum.
24760
25156
  */
24761
25157
  static async create(options) {
@@ -24815,7 +25211,10 @@ var OrderScanner = class _OrderScanner {
24815
25211
  async addChainNow(chain) {
24816
25212
  if (this.closed) throw new Error("This OrderScanner is closed");
24817
25213
  const rpcUrls = validateRpcUrls(chain.rpcUrls);
24818
- const chainId = chain.chainId ?? await fetchChainId(rpcUrls[0]);
25214
+ const chainId = chain.chainId ?? (await resolveChainConfigs([{ rpcUrls, bundlerUrl: chain.bundlerUrl ?? "" }], {
25215
+ loggers: this.loggers,
25216
+ tolerateUnreachable: true
25217
+ }))[0].chainId;
24819
25218
  if (this.closed) throw new Error("This OrderScanner is closed");
24820
25219
  if (this.scanners.has(chainId)) {
24821
25220
  throw new Error(`Chain ${chainId} is already in this scanner`);
@@ -24915,7 +25314,7 @@ var LimitOrderController = class {
24915
25314
  get service() {
24916
25315
  const service = this.runtime.limitOrders;
24917
25316
  if (!service) {
24918
- throw new Error("No orderbook is configured \u2014 set [orderbook] enabled and url to use limit orders");
25317
+ throw new Error("No orderbook is configured \u2014 set [orderbook] url to use limit orders");
24919
25318
  }
24920
25319
  return service;
24921
25320
  }
@@ -25380,7 +25779,7 @@ var Simplex = class _Simplex extends EventEmitter4 {
25380
25779
  const tomlSigner = options.config.simplex?.signer;
25381
25780
  if (tomlSigner && !options.signer) {
25382
25781
  throw new Error(
25383
- "config.simplex.signer describes a signer but none was passed. Simplex.start takes a Signer instance: `signer: await createSigner(config.simplex.signer)`, or build one directly with privateKeySigner/turnkeySigner/mpcVaultSigner/viemSigner."
25782
+ "config.simplex.signer describes a signer but none was passed. Simplex.start takes a Signer instance: `signer: await createSigner(config.simplex.signer)`, or build one directly with privateKeySigner/secretPhraseSigner/turnkeySigner/mpcVaultSigner/viemSigner."
25384
25783
  );
25385
25784
  }
25386
25785
  const loggers = new LoggerContext({
@@ -25583,6 +25982,8 @@ var Simplex = class _Simplex extends EventEmitter4 {
25583
25982
  export {
25584
25983
  AssetController,
25585
25984
  ChainController,
25985
+ LimitOrderController,
25986
+ LimitOrderValidationError,
25586
25987
  LoggerContext,
25587
25988
  MemoryDataStore,
25588
25989
  OrderScanner,
@@ -25595,6 +25996,7 @@ export {
25595
25996
  WalletController,
25596
25997
  addLogSink,
25597
25998
  assertConfirmationCoverage,
25999
+ canDerive,
25598
26000
  configureLogger,
25599
26001
  createSigner,
25600
26002
  digestSigner,
@@ -25607,10 +26009,12 @@ export {
25607
26009
  pickAnchorStable,
25608
26010
  privateKeySigner,
25609
26011
  registrySymbols,
26012
+ secretPhraseSigner,
25610
26013
  turnkeySigner,
25611
26014
  validateAssetDefinitions,
25612
26015
  validateConfig,
25613
26016
  validatePairConfigs,
26017
+ validateSecretPhrase,
25614
26018
  validateSignerConfig,
25615
26019
  validateVaultToml,
25616
26020
  viemSigner