@suilend/sdk 10.0.0 → 11.0.0

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/package.json CHANGED
@@ -1 +1 @@
1
- {"name":"@suilend/sdk","version":"10.0.0","private":false,"description":"A TypeScript SDK for interacting with the Suilend program","author":"Suilend","license":"MIT","main":"./index.js","exports":{".":"./index.js","./client":"./client.js","./mmt":"./mmt.js","./strategies":"./strategies.js","./api/events":"./api/events.js","./api":"./api/index.js","./lib/constants":"./lib/constants.js","./lib":"./lib/index.js","./lib/initialize":"./lib/initialize.js","./lib/liquidityMining":"./lib/liquidityMining.js","./lib/proCompatible":"./lib/proCompatible.js","./lib/pyth":"./lib/pyth.js","./lib/strategyOwnerCap":"./lib/strategyOwnerCap.js","./lib/transactions":"./lib/transactions.js","./lib/types":"./lib/types.js","./margin":"./margin/index.js","./parsers/apiReserveAssetDataEvent":"./parsers/apiReserveAssetDataEvent.js","./parsers":"./parsers/index.js","./parsers/lendingMarket":"./parsers/lendingMarket.js","./parsers/obligation":"./parsers/obligation.js","./parsers/rateLimiter":"./parsers/rateLimiter.js","./parsers/reserve":"./parsers/reserve.js","./swap":"./swap/index.js","./swap/quote":"./swap/quote.js","./swap/transaction":"./swap/transaction.js","./utils/events":"./utils/events.js","./utils/feedId":"./utils/feedId.js","./utils":"./utils/index.js","./utils/obligation":"./utils/obligation.js","./utils/simulate":"./utils/simulate.js","./_generated/_framework/reified":"./_generated/_framework/reified.js","./_generated/_framework/util":"./_generated/_framework/util.js","./_generated/_framework/vector":"./_generated/_framework/vector.js","./_generated/suilend":"./_generated/suilend/index.js","./margin/margin/admin_cap":"./margin/margin/admin_cap.js","./margin/margin/market":"./margin/margin/market.js","./margin/margin/permissions":"./margin/margin/permissions.js","./margin/margin/position":"./margin/margin/position.js","./margin/margin/router":"./margin/margin/router.js","./margin/margin/version":"./margin/margin/version.js","./margin/utils":"./margin/utils/index.js","./_generated/suilend/cell/structs":"./_generated/suilend/cell/structs.js","./_generated/suilend/decimal/structs":"./_generated/suilend/decimal/structs.js","./_generated/suilend/lending-market/functions":"./_generated/suilend/lending-market/functions.js","./_generated/suilend/lending-market/structs":"./_generated/suilend/lending-market/structs.js","./_generated/suilend/lending-market-registry/functions":"./_generated/suilend/lending-market-registry/functions.js","./_generated/suilend/liquidity-mining/structs":"./_generated/suilend/liquidity-mining/structs.js","./_generated/suilend/obligation/structs":"./_generated/suilend/obligation/structs.js","./_generated/suilend/rate-limiter/functions":"./_generated/suilend/rate-limiter/functions.js","./_generated/suilend/rate-limiter/structs":"./_generated/suilend/rate-limiter/structs.js","./_generated/suilend/reserve/structs":"./_generated/suilend/reserve/structs.js","./_generated/suilend/reserve-config/functions":"./_generated/suilend/reserve-config/functions.js","./_generated/suilend/reserve-config/structs":"./_generated/suilend/reserve-config/structs.js","./_generated/_dependencies/source/0x1":"./_generated/_dependencies/source/0x1/index.js","./_generated/_dependencies/source/0x2":"./_generated/_dependencies/source/0x2/index.js","./_generated/_dependencies/source/0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e":"./_generated/_dependencies/source/0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e/index.js","./margin/margin/deps/std/type_name":"./margin/margin/deps/std/type_name.js","./margin/margin/deps/sui/vec_set":"./margin/margin/deps/sui/vec_set.js","./margin/margin/deps/suilend/lending_market":"./margin/margin/deps/suilend/lending_market.js","./_generated/_dependencies/source/0x1/ascii/structs":"./_generated/_dependencies/source/0x1/ascii/structs.js","./_generated/_dependencies/source/0x1/option/structs":"./_generated/_dependencies/source/0x1/option/structs.js","./_generated/_dependencies/source/0x1/type-name/structs":"./_generated/_dependencies/source/0x1/type-name/structs.js","./_generated/_dependencies/source/0x2/bag/structs":"./_generated/_dependencies/source/0x2/bag/structs.js","./_generated/_dependencies/source/0x2/balance/structs":"./_generated/_dependencies/source/0x2/balance/structs.js","./_generated/_dependencies/source/0x2/object/structs":"./_generated/_dependencies/source/0x2/object/structs.js","./_generated/_dependencies/source/0x2/object-table/structs":"./_generated/_dependencies/source/0x2/object-table/structs.js","./_generated/_dependencies/source/0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e/price-identifier/structs":"./_generated/_dependencies/source/0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e/price-identifier/structs.js"},"types":"./index.d.ts","scripts":{"build":"rm -rf ./dist && tsc && node ./fix-esm-imports.js","typecheck":"tsc --noEmit && tsc --noEmit -p tsconfig.test.json","test":"vitest run","lint:ci":"yarn run typecheck","prettier":"prettier --write src/ tests/","release":"yarn run build && node ./release.js && cd ./dist && npm publish --access public"},"repository":{"type":"git","url":"git+https://github.com/fireflyprotocol/lending-mono.git","directory":"ts/sdks/sdk"},"dependencies":{"@bluefin-exchange/bluefin7k-aggregator-sdk":"^7.5.0","@cetusprotocol/aggregator-sdk":"^1.5.7","@flowx-finance/sdk":"^2.1.0","@pythnetwork/hermes-client":"3.1.0","@pythnetwork/pyth-sui-js":"4.0.0","@suilend/springsui-sdk":"^4.0.0","bignumber.js":"^11.1.5","bn.js":"^5.2.2","crypto-js":"^4.2.0","lodash":"^4.17.21","p-limit":"7.3.1","uuid":"^14.0.1"},"devDependencies":{"@mysten/bcs":"^2.0.5","@mysten/sui":"2.23.2","@suilend/sui-core":"^1.0.0","@tsconfig/recommended":"^1.0.8","@types/bn.js":"^5.2.0","@types/lodash":"^4.17.25","@types/node":"^26.1.2","fast-check":"^4.9.0","prettier":"^3.3.3","ts-node":"^10.9.2","typescript":"^6.0.3","vitest":"4.1.10"},"peerDependencies":{"@mysten/bcs":"^2.0.5","@mysten/sui":">=2.22.1 <3","@suilend/sui-core":"^1.0.0"},"type":"module"}
1
+ {"name":"@suilend/sdk","version":"11.0.0","private":false,"description":"A TypeScript SDK for interacting with the Suilend program","author":"Suilend","license":"MIT","main":"./index.js","exports":{".":"./index.js","./client":"./client.js","./mmt":"./mmt.js","./strategies":"./strategies.js","./api/events":"./api/events.js","./api":"./api/index.js","./lib/constants":"./lib/constants.js","./lib":"./lib/index.js","./lib/initialize":"./lib/initialize.js","./lib/liquidityMining":"./lib/liquidityMining.js","./lib/proCompatible":"./lib/proCompatible.js","./lib/pyth":"./lib/pyth.js","./lib/strategyOwnerCap":"./lib/strategyOwnerCap.js","./lib/transactions":"./lib/transactions.js","./lib/types":"./lib/types.js","./margin":"./margin/index.js","./parsers/apiReserveAssetDataEvent":"./parsers/apiReserveAssetDataEvent.js","./parsers":"./parsers/index.js","./parsers/lendingMarket":"./parsers/lendingMarket.js","./parsers/obligation":"./parsers/obligation.js","./parsers/rateLimiter":"./parsers/rateLimiter.js","./parsers/reserve":"./parsers/reserve.js","./swap":"./swap/index.js","./swap/quote":"./swap/quote.js","./swap/transaction":"./swap/transaction.js","./utils/events":"./utils/events.js","./utils/feedId":"./utils/feedId.js","./utils":"./utils/index.js","./utils/obligation":"./utils/obligation.js","./utils/simulate":"./utils/simulate.js","./_generated/_framework/reified":"./_generated/_framework/reified.js","./_generated/_framework/util":"./_generated/_framework/util.js","./_generated/_framework/vector":"./_generated/_framework/vector.js","./_generated/suilend":"./_generated/suilend/index.js","./margin/margin/admin_cap":"./margin/margin/admin_cap.js","./margin/margin/market":"./margin/margin/market.js","./margin/margin/permissions":"./margin/margin/permissions.js","./margin/margin/position":"./margin/margin/position.js","./margin/margin/router":"./margin/margin/router.js","./margin/margin/version":"./margin/margin/version.js","./margin/utils":"./margin/utils/index.js","./_generated/suilend/cell/structs":"./_generated/suilend/cell/structs.js","./_generated/suilend/decimal/structs":"./_generated/suilend/decimal/structs.js","./_generated/suilend/lending-market/functions":"./_generated/suilend/lending-market/functions.js","./_generated/suilend/lending-market/structs":"./_generated/suilend/lending-market/structs.js","./_generated/suilend/lending-market-registry/functions":"./_generated/suilend/lending-market-registry/functions.js","./_generated/suilend/liquidity-mining/structs":"./_generated/suilend/liquidity-mining/structs.js","./_generated/suilend/obligation/structs":"./_generated/suilend/obligation/structs.js","./_generated/suilend/rate-limiter/functions":"./_generated/suilend/rate-limiter/functions.js","./_generated/suilend/rate-limiter/structs":"./_generated/suilend/rate-limiter/structs.js","./_generated/suilend/reserve/structs":"./_generated/suilend/reserve/structs.js","./_generated/suilend/reserve-config/functions":"./_generated/suilend/reserve-config/functions.js","./_generated/suilend/reserve-config/structs":"./_generated/suilend/reserve-config/structs.js","./_generated/_dependencies/source/0x1":"./_generated/_dependencies/source/0x1/index.js","./_generated/_dependencies/source/0x2":"./_generated/_dependencies/source/0x2/index.js","./_generated/_dependencies/source/0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e":"./_generated/_dependencies/source/0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e/index.js","./margin/margin/deps/std/type_name":"./margin/margin/deps/std/type_name.js","./margin/margin/deps/sui/vec_set":"./margin/margin/deps/sui/vec_set.js","./margin/margin/deps/suilend/lending_market":"./margin/margin/deps/suilend/lending_market.js","./_generated/_dependencies/source/0x1/ascii/structs":"./_generated/_dependencies/source/0x1/ascii/structs.js","./_generated/_dependencies/source/0x1/option/structs":"./_generated/_dependencies/source/0x1/option/structs.js","./_generated/_dependencies/source/0x1/type-name/structs":"./_generated/_dependencies/source/0x1/type-name/structs.js","./_generated/_dependencies/source/0x2/bag/structs":"./_generated/_dependencies/source/0x2/bag/structs.js","./_generated/_dependencies/source/0x2/balance/structs":"./_generated/_dependencies/source/0x2/balance/structs.js","./_generated/_dependencies/source/0x2/object/structs":"./_generated/_dependencies/source/0x2/object/structs.js","./_generated/_dependencies/source/0x2/object-table/structs":"./_generated/_dependencies/source/0x2/object-table/structs.js","./_generated/_dependencies/source/0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e/price-identifier/structs":"./_generated/_dependencies/source/0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e/price-identifier/structs.js"},"types":"./index.d.ts","scripts":{"build":"rm -rf ./dist && tsc && node ./fix-esm-imports.js","typecheck":"tsc --noEmit && tsc --noEmit -p tsconfig.test.json","test":"vitest run","lint:ci":"yarn run typecheck","prettier":"prettier --write src/ tests/","release":"yarn run build && node ./release.js && cd ./dist && npm publish --access public"},"repository":{"type":"git","url":"git+https://github.com/fireflyprotocol/lending-mono.git","directory":"ts/sdks/sdk"},"dependencies":{"@bluefin-exchange/bluefin7k-aggregator-sdk":"^7.5.0","@cetusprotocol/aggregator-sdk":"^1.5.7","@flowx-finance/sdk":"^2.1.0","@pythnetwork/hermes-client":"3.1.0","@pythnetwork/pyth-sui-js":"4.0.0","@suilend/springsui-sdk":"^4.0.0","bignumber.js":"^11.1.5","bn.js":"^5.2.2","crypto-js":"^4.2.0","lodash":"^4.17.21","p-limit":"7.3.1","uuid":"^14.0.1"},"devDependencies":{"@mysten/bcs":"^2.0.5","@mysten/sui":"2.23.2","@suilend/sui-core":"^1.0.0","@tsconfig/recommended":"^1.0.8","@types/bn.js":"^5.2.0","@types/lodash":"^4.17.25","@types/node":"^26.1.2","fast-check":"^4.9.0","prettier":"^3.3.3","ts-node":"^10.9.2","typescript":"^6.0.3","vitest":"4.1.10"},"peerDependencies":{"@mysten/bcs":"^2.0.5","@mysten/sui":">=2.22.1 <3","@suilend/sui-core":"^1.0.0"},"type":"module"}
package/strategies.d.ts CHANGED
@@ -11,6 +11,21 @@ import { RewardMap } from "./lib/liquidityMining";
11
11
  import { StrategyType } from "./lib/strategyOwnerCap";
12
12
  import { ParsedObligation, ParsedReserve } from "./parsers";
13
13
  export declare const STRATEGY_E: number;
14
+ /**
15
+ * Below this, a levered position is uneconomic no matter what the slippage
16
+ * tolerance says. The aggregator's dust regime dominates: routes go multi-hop
17
+ * through thin venues, quote 0.3-1.3% above the honest single-hop rate and then
18
+ * deliver 2-8% below par, and a levered round trip runs ~20 of them — measured on
19
+ * mainnet, a 36-cent position at 4x lost 19% opening and closing. The one-cent
20
+ * absolute allowance that makes those swaps executable at all is itself 11-17% of
21
+ * a seven-cent swap.
22
+ *
23
+ * Refusing to OPEN one is the only guard that helps, because the alternative —
24
+ * capping the allowance so dust cannot authorise a large percentage — would strand
25
+ * every position already below the line with no way to exit. So the loose
26
+ * tolerance stays available on the unwind path, and entry is what gets blocked.
27
+ */
28
+ export declare const STRATEGY_MIN_POSITION_USD = 10;
14
29
  export declare const LST_DECIMALS = 9;
15
30
  export type StrategyDeposit = {
16
31
  coinType: string;
package/strategies.js CHANGED
@@ -12,6 +12,107 @@ import { STRATEGY_TYPE_INFO_MAP, StrategyType, strategyBorrow, strategyClaimRewa
12
12
  import { MMT_CONTRACT_PACKAGE_ID, MMT_VERSION_OBJECT_ID } from "./mmt.js";
13
13
  import { getWeightedBorrowsUsd } from "./utils/index.js";
14
14
  export const STRATEGY_E = 10 ** -7;
15
+ /**
16
+ * Cetus applies its slippage check against its OWN quote, so a wrong quote yields
17
+ * a wrong floor. Measured on a mainnet unwind of a sub-dollar position: the
18
+ * aggregator routed 4-9 cent swaps through 3-hop exotic venues, quoted them
19
+ * 0.3-1.3% ABOVE the honest single-hop rate, and the routes then delivered
20
+ * 2-8% BELOW par — a 5-9% gap between quote and execution. The inflation decays
21
+ * with size and is gone by ~0.12 of the sell asset, above which quotes are honest
22
+ * and single-hop.
23
+ *
24
+ * So the floor is anchored to the reserves' oracle prices — the same basis the
25
+ * obligation's own health math uses — and the tolerance is bounded in BOTH
26
+ * directions:
27
+ *
28
+ * allowance = max(relative, absolute)
29
+ *
30
+ * Dust swaps need real slack or they cannot execute at all, but that slack must
31
+ * not scale with size: a percentage wide enough for a 5-cent swap would authorise
32
+ * thousands of dollars of loss on a large unwind. Above ~1 USD of output the
33
+ * relative bound governs and holds the fill to 1%; below it the absolute bound
34
+ * does, and the most a step can give up is one cent.
35
+ */
36
+ const SWAP_MAX_DEVIATION_PERCENT = 1;
37
+ const SWAP_MAX_DEVIATION_USD = 0.01;
38
+ /**
39
+ * Ceiling on the absolute term as a share of the reference output — oracle fair
40
+ * value for distinct-feed pairs, the quote itself for same-feed ones. Without
41
+ * it, an output worth less than `SWAP_MAX_DEVIATION_USD` drives the floor to
42
+ * zero, which makes `min_out` 1% of quote — the guard is not loose there, it is
43
+ * off, and the implausibility check cannot see it because an honest sub-cent
44
+ * quote sits right at fair value. Grandfathered sub-minimum positions are the
45
+ * ones that reach it, on the unwind path deliberately left open so they can
46
+ * exit.
47
+ */
48
+ const SWAP_MAX_ALLOWANCE_FRACTION_OF_REFERENCE = 0.5;
49
+ const SWAP_MIN_SLIPPAGE_PERCENT = 0.1;
50
+ const SWAP_MAX_SLIPPAGE_PERCENT = 99;
51
+ const SWAP_MAX_QUOTE_EXCESS_PERCENT = 50;
52
+ /**
53
+ * Below this, a levered position is uneconomic no matter what the slippage
54
+ * tolerance says. The aggregator's dust regime dominates: routes go multi-hop
55
+ * through thin venues, quote 0.3-1.3% above the honest single-hop rate and then
56
+ * deliver 2-8% below par, and a levered round trip runs ~20 of them — measured on
57
+ * mainnet, a 36-cent position at 4x lost 19% opening and closing. The one-cent
58
+ * absolute allowance that makes those swaps executable at all is itself 11-17% of
59
+ * a seven-cent swap.
60
+ *
61
+ * Refusing to OPEN one is the only guard that helps, because the alternative —
62
+ * capping the allowance so dust cannot authorise a large percentage — would strand
63
+ * every position already below the line with no way to exit. So the loose
64
+ * tolerance stays available on the unwind path, and entry is what gets blocked.
65
+ */
66
+ export const STRATEGY_MIN_POSITION_USD = 10;
67
+ const resolveSwapSlippage = (amountIn, quotedAmountOut, sellReserve, buyReserve) => {
68
+ if (quotedAmountOut.lte(0))
69
+ throw new Error("No swap quote found");
70
+ // Two reserves reading the SAME feed have a price ratio of 1.0 by
71
+ // construction, which says nothing about the rate between them. eEARN is
72
+ // pinned to USDC's feed while trading ~3.6% above it, so anchoring there
73
+ // would put the floor above every honest quote and refuse the swap outright —
74
+ // size-independent, unrelated to the dust regime this guard is for. Anchor the
75
+ // dual bound on the quote instead: it is the absolute allowance, not the
76
+ // anchor, that makes a dust swap executable.
77
+ //
78
+ // This is a stopgap, not the answer: repointing eEARN to a DIFFERENT wrong
79
+ // feed makes the identifiers differ while the pin stays wrong. The durable fix
80
+ // is to anchor on an aggregator quote taken at a size where quotes are honest,
81
+ // using the oracle only to choose that size. Tracked as a follow-up.
82
+ const sameFeed = sellReserve.priceIdentifier === buyReserve.priceIdentifier;
83
+ // Sell side at its low price, buy side at its high price: the fair value errs
84
+ // conservative in the direction that protects the position.
85
+ const referenceAmountOut = sameFeed
86
+ ? quotedAmountOut
87
+ : amountIn.times(sellReserve.minPrice).div(buyReserve.maxPrice);
88
+ const allowance = BigNumber.min(BigNumber.max(referenceAmountOut.times(SWAP_MAX_DEVIATION_PERCENT / 100), new BigNumber(SWAP_MAX_DEVIATION_USD).div(buyReserve.maxPrice)), referenceAmountOut.times(SWAP_MAX_ALLOWANCE_FRACTION_OF_REFERENCE));
89
+ const floorAmountOut = BigNumber.max(new BigNumber(0), referenceAmountOut.minus(allowance));
90
+ // Quoting below the floor means the route cannot fill at a price worth taking.
91
+ // Refuse rather than build a swap that trades the position down.
92
+ if (quotedAmountOut.lt(floorAmountOut))
93
+ throw new Error("Swap quote is below the oracle floor");
94
+ // A quote far above the HIGH plausible output means the anchor is wrong, not
95
+ // that the trade is good — a pin that overstates the bought asset collapses
96
+ // the floor. Compare against maxPrice/minPrice rather than the floor's own
97
+ // basis: reusing the low bound makes this tightest exactly when volatility
98
+ // widens min/max, which is when a deleverage has to get through. Same-feed
99
+ // pairs have nothing independent to compare against, so they skip it.
100
+ if (!sameFeed) {
101
+ const fairUpperAmountOut = amountIn
102
+ .times(sellReserve.maxPrice)
103
+ .div(buyReserve.minPrice);
104
+ if (quotedAmountOut.gt(fairUpperAmountOut.times(1 + SWAP_MAX_QUOTE_EXCESS_PERCENT / 100)))
105
+ throw new Error("Swap quote is implausible against the oracle price");
106
+ }
107
+ const slippagePercent = Math.min(Math.max(new BigNumber(1)
108
+ .minus(floorAmountOut.div(quotedAmountOut))
109
+ .times(100)
110
+ .toNumber(), SWAP_MIN_SLIPPAGE_PERCENT), SWAP_MAX_SLIPPAGE_PERCENT);
111
+ return {
112
+ slippagePercent,
113
+ guaranteedAmountOut: quotedAmountOut.times(1 - slippagePercent / 100),
114
+ };
115
+ };
15
116
  export const LST_DECIMALS = 9;
16
117
  export var StrategyFlashLoanProvider;
17
118
  (function (StrategyFlashLoanProvider) {
@@ -1050,7 +1151,7 @@ transaction) => {
1050
1151
  });
1051
1152
  if (!routers)
1052
1153
  throw new Error("No swap quote found");
1053
- const slippagePercent = 1;
1154
+ const { slippagePercent, guaranteedAmountOut } = resolveSwapSlippage(stepBorrowedAmount, new BigNumber(routers.amountOut.toString()).div(10 ** loopingDepositReserve.token.decimals), borrowReserve, loopingDepositReserve);
1054
1155
  let stepBaseCoin;
1055
1156
  try {
1056
1157
  stepBaseCoin = (await cetusSdk.fixableRouterSwapV3({
@@ -1074,7 +1175,14 @@ transaction) => {
1074
1175
  .toFixed(20),
1075
1176
  }, null, 2), routers);
1076
1177
  // 2.2) Deposit
1077
- const stepDepositedAmount = new BigNumber(new BigNumber(routers.amountOut.toString()).div(10 ** loopingDepositReserve.token.decimals)).decimalPlaces(loopingDepositReserve.token.decimals, BigNumber.ROUND_DOWN);
1178
+ // Credit the GUARANTEED output, not the quote. The borrow is exact but the
1179
+ // swap output is not, and the aggregator inflates dust quotes — on a mainnet
1180
+ // loop-up it claimed a 2.5% gain buying an asset that costs slightly over
1181
+ // par. Booking the quote makes the simulated deposit larger than the real
1182
+ // one while the borrow stands, so each step borrows against collateral that
1183
+ // never arrives and `obligation::borrow` aborts with EObligationIsNotHealthy.
1184
+ // Erring low leaves the position slightly under target exposure instead.
1185
+ const stepDepositedAmount = guaranteedAmountOut.decimalPlaces(loopingDepositReserve.token.decimals, BigNumber.ROUND_DOWN);
1078
1186
  const isMaxDeposit = stepDepositedAmount.eq(stepMaxDepositedAmount);
1079
1187
  console.log(`[loopStrategyToExposure] ${i} deposit.deposit |`, JSON.stringify({
1080
1188
  stepDepositedAmount: stepDepositedAmount.toFixed(20),
@@ -1376,8 +1484,25 @@ transaction, dryRunTransaction) => {
1376
1484
  .toString()),
1377
1485
  byAmountIn: true,
1378
1486
  });
1379
- if (!routers)
1380
- throw new Error("No swap quote found");
1487
+ if (!routers) {
1488
+ transaction = transactionBeforeFullRepay;
1489
+ deposits = depositsBeforeFullRepay;
1490
+ return;
1491
+ }
1492
+ let fullRepaySlippagePercent;
1493
+ try {
1494
+ ({ slippagePercent: fullRepaySlippagePercent } = resolveSwapSlippage(baseWithdrawnAmount, new BigNumber(routers.amountOut.toString()).div(10 ** borrowReserve.token.decimals), depositReserves.base, borrowReserve));
1495
+ }
1496
+ catch (err) {
1497
+ // A route that cannot fill is the same failure whether it is caught before
1498
+ // or after the swap is appended. Undo the handler and leave the debt on the
1499
+ // books, exactly as its repay `catch` below does — the base-only caller
1500
+ // breaks straight after this with no `catch` of its own.
1501
+ console.error(err);
1502
+ transaction = transactionBeforeFullRepay;
1503
+ deposits = depositsBeforeFullRepay;
1504
+ return;
1505
+ }
1381
1506
  console.log(`[unloopStrategyToExposure.fullyRepayBorrowsUsingBase] swap_base_for_borrows.get_routers`, {
1382
1507
  routers,
1383
1508
  amountIn: new BigNumber(routers.amountIn.toString())
@@ -1395,13 +1520,16 @@ transaction, dryRunTransaction) => {
1395
1520
  swapCoin = (await cetusSdk.fixableRouterSwapV3({
1396
1521
  router: routers,
1397
1522
  inputCoin: withdrawnBaseCoin,
1398
- slippage: 1 / 100,
1523
+ slippage: fullRepaySlippagePercent / 100,
1399
1524
  txb: transaction,
1400
1525
  partner: cetusPartnerId,
1401
1526
  }));
1402
1527
  }
1403
1528
  catch (err) {
1404
- throw new Error("No swap quote found");
1529
+ console.error(err);
1530
+ transaction = transactionBeforeFullRepay;
1531
+ deposits = depositsBeforeFullRepay;
1532
+ return;
1405
1533
  }
1406
1534
  // 4) Repay borrows
1407
1535
  // 4.1) Repay
@@ -1644,12 +1772,11 @@ transaction, dryRunTransaction) => {
1644
1772
  stepMaxWithdrawnAmount: stepMaxWithdrawnAmount.toFixed(20),
1645
1773
  stepMaxRepaidAmount: stepMaxRepaidAmount.toFixed(20),
1646
1774
  }, null, 2));
1647
- const slippagePercent = 1;
1648
1775
  // 1.2) Withdraw
1649
1776
  // Once this step's ceiling can cover everything still owed, it is the last
1650
1777
  // one — so size it to clear the debt outright rather than to the exact tracked
1651
- // figure. The swap may fill up to `slippagePercent`% below its quote, so scale
1652
- // by that to stay covered at the worst fill. Overshooting is free:
1778
+ // figure. The swap may fill as low as the oracle floor, so scale by that same
1779
+ // bound to stay covered at the worst fill the guard accepts. Overshooting is free:
1653
1780
  // `obligation::repay` clamps to what is actually owed
1654
1781
  // (`min(max_repay_amount, borrowed_amount)`) and the surplus is transferred
1655
1782
  // back to the user below.
@@ -1659,7 +1786,7 @@ transaction, dryRunTransaction) => {
1659
1786
  // the repay step). The next iteration then aborts with `EBorrowNotFound` — a
1660
1787
  // fully repaid borrow is removed from the obligation, so there is nothing left
1661
1788
  // to repay against.
1662
- const stepTargetRepaidAmount = pendingBorrowedAmount.div(1 - slippagePercent / 100);
1789
+ const stepTargetRepaidAmount = pendingBorrowedAmount.div(1 - SWAP_MAX_DEVIATION_PERCENT / 100);
1663
1790
  const isFinalStep = stepTargetRepaidAmount.lte(stepMaxRepaidAmount);
1664
1791
  const stepWithdrawnAmount = BigNumber.min(stepTargetRepaidAmount, stepMaxRepaidAmount)
1665
1792
  .div(baseToBorrowExchangeRate)
@@ -1683,110 +1810,120 @@ transaction, dryRunTransaction) => {
1683
1810
  // whole.
1684
1811
  const transactionBeforeStep = Transaction.from(transaction);
1685
1812
  const depositsBeforeStep = cloneDeep(deposits);
1686
- const [stepWithdrawnCoin] = strategyWithdraw(strategyType, loopingDepositReserve.coinType, strategyOwnerCapId, suilendClient.findReserveArrayIndex(loopingDepositReserve.coinType), BigInt(new BigNumber(stepWithdrawnAmount
1687
- .times(10 ** loopingDepositReserve.token.decimals)
1688
- .integerValue(BigNumber.ROUND_DOWN)
1689
- .toString())
1690
- .div(loopingDepositReserve.cTokenExchangeRate)
1691
- .integerValue(BigNumber.ROUND_UP)
1692
- .toString()), transaction);
1693
- // 1.3) Update state
1694
- deposits = addOrInsertStrategyDeposit(deposits, {
1695
- coinType: loopingDepositReserve.coinType,
1696
- depositedAmount: stepWithdrawnAmount.times(-1),
1697
- });
1698
- console.log(`[unloopStrategyToExposure] ${i} withdraw_base.update_state |`, JSON.stringify({
1699
- deposits: deposits.map((d) => ({
1700
- coinType: d.coinType,
1701
- depositedAmount: d.depositedAmount.toFixed(20),
1702
- })),
1703
- borrowedAmount: borrowedAmount.toFixed(20),
1704
- }, null, 2));
1705
- // 2) Swap base for borrows
1706
- const routers = await cetusSdk.findRouters({
1707
- from: loopingDepositReserve.coinType,
1708
- target: borrowReserve.coinType,
1709
- amount: new BN(stepWithdrawnAmount
1710
- .times(10 ** loopingDepositReserve.token.decimals)
1711
- .integerValue(BigNumber.ROUND_DOWN)
1712
- .toString()), // Estimate for loop 2 onwards (don't know exact out amount, we are not accounting for swap fees, etc)
1713
- byAmountIn: true,
1714
- splitCount: 0, // Use direct swap to avoid split algo
1715
- });
1716
- if (!routers)
1717
- throw new Error("No swap quote found");
1718
- let stepBorrowCoin;
1719
- try {
1720
- stepBorrowCoin = (await cetusSdk.fixableRouterSwapV3({
1721
- router: routers,
1722
- inputCoin: stepWithdrawnCoin,
1723
- slippage: slippagePercent / 100,
1724
- txb: transaction,
1725
- partner: cetusPartnerId,
1726
- }));
1727
- }
1728
- catch (err) {
1729
- throw new Error("No swap quote found");
1730
- }
1731
- console.log(`[unloopStrategyToExposure] ${i} swap_base_for_borrows |`, JSON.stringify({
1732
- inCoinType: loopingDepositReserve.coinType,
1733
- outCoinType: borrowReserve.coinType,
1734
- amountIn: stepWithdrawnAmount.toFixed(20),
1735
- amountOut: new BigNumber(routers.amountOut.toString())
1736
- .div(10 ** borrowReserve.token.decimals)
1737
- .decimalPlaces(borrowReserve.token.decimals, BigNumber.ROUND_DOWN)
1738
- .toFixed(20),
1739
- }, null, 2), routers);
1740
- // 3) Repay borrows
1741
- // 3.1) Repay
1742
- // Actual on-chain swap output can be up to `slippagePercent`% less than the
1743
- // Cetus quote — track pessimistically so `borrowedAmount` stays ≥ real debt.
1744
- // Otherwise per-step under-counting accumulates and the final
1745
- // `fullyRepayBorrowsUsingBase` sizes off a near-zero tracked value, stranding
1746
- // dust borrow that blocks the subsequent `MAX_U64` deposit withdraw.
1747
- const stepRepaidAmount = new BigNumber(new BigNumber(routers.amountOut.toString())
1748
- .times(1 - slippagePercent / 100)
1749
- .div(10 ** borrowReserve.token.decimals)).decimalPlaces(borrowReserve.token.decimals, BigNumber.ROUND_DOWN);
1750
- const isMaxRepay = stepRepaidAmount.eq(stepMaxRepaidAmount);
1751
- console.log(`[unloopStrategyToExposure] ${i} repay_borrows.repay |`, JSON.stringify({
1752
- stepRepaidAmount: stepRepaidAmount.toFixed(20),
1753
- isMaxRepay,
1754
- }, null, 2));
1813
+ // Every failure inside a step wants the same handling: undo the step and
1814
+ // stop. A missing route, a quote below the oracle floor, a swap that will
1815
+ // not build, a repay whose dry run aborts — all of them leave the withdraw
1816
+ // and the swap already appended to `transaction` and `deposits` already
1817
+ // decremented, so anything that escapes this block escapes the snapshot too
1818
+ // and takes the whole operation down instead of ending it with a partial
1819
+ // unwind. One catch makes that invariant explicit: nothing in a step
1820
+ // survives that step failing.
1755
1821
  let stepClearedDebt = false;
1756
1822
  try {
1757
- const txCopy = Transaction.from(transaction);
1758
- suilendClient.repay(obligationId, borrowReserve.coinType, stepBorrowCoin, txCopy);
1759
- txCopy.transferObjects([stepBorrowCoin], _address);
1760
- await dryRunTransaction(txCopy); // Throws error if fails
1761
- transaction = txCopy;
1762
- // 3.2) Update state
1763
- // A final step was sized to clear the debt even at the worst fill, and the
1764
- // repay clamps, so the target is reached exactly rather than by subtracting a
1765
- // figure the haircut has already made fictional.
1766
- borrowedAmount = isFinalStep
1767
- ? targetBorrowedAmount
1768
- : borrowedAmount.minus(stepRepaidAmount);
1769
- stepClearedDebt = isFinalStep;
1823
+ const [stepWithdrawnCoin] = strategyWithdraw(strategyType, loopingDepositReserve.coinType, strategyOwnerCapId, suilendClient.findReserveArrayIndex(loopingDepositReserve.coinType), BigInt(new BigNumber(stepWithdrawnAmount
1824
+ .times(10 ** loopingDepositReserve.token.decimals)
1825
+ .integerValue(BigNumber.ROUND_DOWN)
1826
+ .toString())
1827
+ .div(loopingDepositReserve.cTokenExchangeRate)
1828
+ .integerValue(BigNumber.ROUND_UP)
1829
+ .toString()), transaction);
1830
+ // 1.3) Update state
1831
+ deposits = addOrInsertStrategyDeposit(deposits, {
1832
+ coinType: loopingDepositReserve.coinType,
1833
+ depositedAmount: stepWithdrawnAmount.times(-1),
1834
+ });
1835
+ console.log(`[unloopStrategyToExposure] ${i} withdraw_base.update_state |`, JSON.stringify({
1836
+ deposits: deposits.map((d) => ({
1837
+ coinType: d.coinType,
1838
+ depositedAmount: d.depositedAmount.toFixed(20),
1839
+ })),
1840
+ borrowedAmount: borrowedAmount.toFixed(20),
1841
+ }, null, 2));
1842
+ // 2) Swap base for borrows
1843
+ const routers = await cetusSdk.findRouters({
1844
+ from: loopingDepositReserve.coinType,
1845
+ target: borrowReserve.coinType,
1846
+ amount: new BN(stepWithdrawnAmount
1847
+ .times(10 ** loopingDepositReserve.token.decimals)
1848
+ .integerValue(BigNumber.ROUND_DOWN)
1849
+ .toString()), // Estimate for loop 2 onwards (don't know exact out amount, we are not accounting for swap fees, etc)
1850
+ byAmountIn: true,
1851
+ splitCount: 0, // Use direct swap to avoid split algo
1852
+ });
1853
+ if (!routers)
1854
+ throw new Error("No swap quote found");
1855
+ const quotedAmountOut = new BigNumber(routers.amountOut.toString()).div(10 ** borrowReserve.token.decimals);
1856
+ const { slippagePercent, guaranteedAmountOut } = resolveSwapSlippage(stepWithdrawnAmount, quotedAmountOut, loopingDepositReserve, borrowReserve);
1857
+ console.log(`[unloopStrategyToExposure] ${i} swap_base_for_borrows.floor |`, JSON.stringify({
1858
+ quotedRate: quotedAmountOut.div(stepWithdrawnAmount).toFixed(6),
1859
+ // What the pool must actually deliver, per unit of input.
1860
+ requiredRate: guaranteedAmountOut
1861
+ .div(stepWithdrawnAmount)
1862
+ .toFixed(6),
1863
+ slippagePassedToCetus: slippagePercent.toFixed(4),
1864
+ }, null, 2));
1865
+ let stepBorrowCoin;
1866
+ {
1867
+ stepBorrowCoin = (await cetusSdk.fixableRouterSwapV3({
1868
+ router: routers,
1869
+ inputCoin: stepWithdrawnCoin,
1870
+ slippage: slippagePercent / 100,
1871
+ txb: transaction,
1872
+ partner: cetusPartnerId,
1873
+ }));
1874
+ }
1875
+ console.log(`[unloopStrategyToExposure] ${i} swap_base_for_borrows |`, JSON.stringify({
1876
+ inCoinType: loopingDepositReserve.coinType,
1877
+ outCoinType: borrowReserve.coinType,
1878
+ amountIn: stepWithdrawnAmount.toFixed(20),
1879
+ amountOut: new BigNumber(routers.amountOut.toString())
1880
+ .div(10 ** borrowReserve.token.decimals)
1881
+ .decimalPlaces(borrowReserve.token.decimals, BigNumber.ROUND_DOWN)
1882
+ .toFixed(20),
1883
+ }, null, 2), routers);
1884
+ // 3) Repay borrows
1885
+ // 3.1) Repay
1886
+ // The guaranteed output is exactly what `min_out` enforces, so tracking it
1887
+ // keeps `borrowedAmount` ≥ the real debt without the arbitrary haircut that
1888
+ // used to sit here. Under-counting would accumulate and leave
1889
+ // `fullyRepayBorrowsUsingBase` sizing off a near-zero tracked value,
1890
+ // stranding dust borrow that blocks the subsequent `MAX_U64` withdraw.
1891
+ const stepRepaidAmount = guaranteedAmountOut.decimalPlaces(borrowReserve.token.decimals, BigNumber.ROUND_DOWN);
1892
+ const isMaxRepay = stepRepaidAmount.eq(stepMaxRepaidAmount);
1893
+ console.log(`[unloopStrategyToExposure] ${i} repay_borrows.repay |`, JSON.stringify({
1894
+ stepRepaidAmount: stepRepaidAmount.toFixed(20),
1895
+ isMaxRepay,
1896
+ }, null, 2));
1897
+ {
1898
+ const txCopy = Transaction.from(transaction);
1899
+ suilendClient.repay(obligationId, borrowReserve.coinType, stepBorrowCoin, txCopy);
1900
+ txCopy.transferObjects([stepBorrowCoin], _address);
1901
+ await dryRunTransaction(txCopy); // Throws error if fails
1902
+ transaction = txCopy;
1903
+ // 3.2) Update state
1904
+ // A final step was sized to clear the debt even at the worst fill, and the
1905
+ // repay clamps, so the target is reached exactly rather than by subtracting a
1906
+ // figure the haircut has already made fictional.
1907
+ borrowedAmount = isFinalStep
1908
+ ? targetBorrowedAmount
1909
+ : borrowedAmount.minus(stepRepaidAmount);
1910
+ stepClearedDebt = isFinalStep;
1911
+ }
1912
+ console.log(`[unloopStrategyToExposure] ${i} repay_borrows.update_state |`, JSON.stringify({
1913
+ deposits: deposits.map((d) => ({
1914
+ coinType: d.coinType,
1915
+ depositedAmount: d.depositedAmount.toFixed(20),
1916
+ })),
1917
+ borrowedAmount: borrowedAmount.toFixed(20),
1918
+ stepClearedDebt,
1919
+ }, null, 2));
1770
1920
  }
1771
1921
  catch (err) {
1772
- // The step cannot land — the swap's slippage check aborted, or the borrow is
1773
- // already gone (`EBorrowNotFound`). Roll the whole step back either way:
1774
- // keeping it would leave an aborting command in the transaction, so every
1775
- // later dry run would fail on that same command and the user would be asked
1776
- // to sign a transaction known to fail.
1777
1922
  console.error(err);
1778
1923
  transaction = transactionBeforeStep;
1779
1924
  deposits = depositsBeforeStep;
1780
1925
  break;
1781
1926
  }
1782
- console.log(`[unloopStrategyToExposure] ${i} repay_borrows.update_state |`, JSON.stringify({
1783
- deposits: deposits.map((d) => ({
1784
- coinType: d.coinType,
1785
- depositedAmount: d.depositedAmount.toFixed(20),
1786
- })),
1787
- borrowedAmount: borrowedAmount.toFixed(20),
1788
- stepClearedDebt,
1789
- }, null, 2));
1790
1927
  // Nothing is owed above target any more. Returning here rather than looping
1791
1928
  // again also skips `fullyRepayBorrowsUsingBase`, which would repay a borrow
1792
1929
  // that no longer exists.
@@ -1889,7 +2026,7 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
1889
2026
  });
1890
2027
  if (!routers)
1891
2028
  throw new Error("No swap quote found");
1892
- const slippagePercent = 1;
2029
+ const { slippagePercent } = resolveSwapSlippage(deposit.depositedAmount, new BigNumber(routers.amountOut.toString()).div(10 ** depositReserve.token.decimals), otherReserve, depositReserve);
1893
2030
  let baseCoin;
1894
2031
  try {
1895
2032
  baseCoin = (await cetusSdk.fixableRouterSwapV3({
@@ -1956,6 +2093,14 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
1956
2093
  targetExposure: targetExposure.toFixed(20),
1957
2094
  }, null, 2));
1958
2095
  //
2096
+ // Entry only, and only where leverage is actually taken: the guard exists
2097
+ // because a levered round trip runs ~20 dust swaps, and 1x runs at most one
2098
+ // conversion — where a one-cent allowance is a fraction of a percent, nowhere
2099
+ // near the regime this refuses. An existing position below the line can still
2100
+ // be adjusted and unwound either way.
2101
+ const resultingTvlUsd = getStrategyTvlAmount(reserveMap, lstMap, strategyType, getStrategySimulatedObligation(reserveMap, lstMap, strategyType, addOrInsertStrategyDeposit(cloneDeep(_deposits), deposit), _borrowedAmount)).times(defaultCurrencyReserve.minPrice);
2102
+ if (targetExposure.gt(1) && resultingTvlUsd.lt(STRATEGY_MIN_POSITION_USD))
2103
+ throw new Error(`Position must be at least $${STRATEGY_MIN_POSITION_USD} to open`);
1959
2104
  let deposits = cloneDeep(_deposits);
1960
2105
  let borrowedAmount = _borrowedAmount;
1961
2106
  // 1) Deposit (1x exposure)
@@ -2117,7 +2262,7 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
2117
2262
  });
2118
2263
  if (!routers)
2119
2264
  throw new Error("No swap quote found");
2120
- const slippagePercent = 1;
2265
+ const { slippagePercent } = resolveSwapSlippage(finalWithdrawnAmount, new BigNumber(routers.amountOut.toString()).div(10 ** reserveMap[withdraw.coinType].token.decimals), depositReserve, reserveMap[withdraw.coinType]);
2121
2266
  let swappedCoin;
2122
2267
  try {
2123
2268
  swappedCoin = (await cetusSdk.fixableRouterSwapV3({
@@ -2232,7 +2377,7 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
2232
2377
  });
2233
2378
  if (!routers)
2234
2379
  throw new Error("No swap quote found");
2235
- const slippagePercent = 1;
2380
+ const { slippagePercent } = resolveSwapSlippage(estimatedWithdrawnAmount, new BigNumber(routers.amountOut.toString()).div(10 ** reserveMap[withdrawCoinType].token.decimals), depositReserve, reserveMap[withdrawCoinType]);
2236
2381
  let swappedCoin;
2237
2382
  try {
2238
2383
  swappedCoin = (await cetusSdk.fixableRouterSwapV3({
@@ -2373,12 +2518,13 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
2373
2518
  .toFixed(20),
2374
2519
  });
2375
2520
  // 2.2) Swap
2521
+ const { slippagePercent: adjustRepaySlippagePercent } = resolveSwapSlippage(new BigNumber(routers.amountIn.toString()).div(10 ** depositReserves.base.token.decimals), new BigNumber(routers.amountOut.toString()).div(10 ** borrowReserve.token.decimals), depositReserves.base, borrowReserve);
2376
2522
  let swapCoin;
2377
2523
  try {
2378
2524
  swapCoin = (await cetusSdk.fixableRouterSwapV3({
2379
2525
  router: routers,
2380
2526
  inputCoin: flashLoanBorrowedCoin,
2381
- slippage: 1 / 100,
2527
+ slippage: adjustRepaySlippagePercent / 100,
2382
2528
  txb: transaction,
2383
2529
  partner: cetusPartnerId,
2384
2530
  }));
@@ -2559,11 +2705,12 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
2559
2705
  });
2560
2706
  if (!depositRouters)
2561
2707
  throw new Error("No swap quote found");
2708
+ const { slippagePercent: depositLegSlippagePercent } = resolveSwapSlippage(new BigNumber(depositRouters.amountIn.toString()).div(10 ** flashLoanCoinReserve.token.decimals), new BigNumber(depositRouters.amountOut.toString()).div(10 ** depositReserve.token.decimals), flashLoanCoinReserve, depositReserve);
2562
2709
  try {
2563
2710
  flashLoanBorrowedCoin = (await cetusSdk.fixableRouterSwapV3({
2564
2711
  router: depositRouters,
2565
2712
  inputCoin: flashLoanBorrowedCoin,
2566
- slippage: 1 / 100,
2713
+ slippage: depositLegSlippagePercent / 100,
2567
2714
  txb: transaction,
2568
2715
  partner: cetusPartnerId,
2569
2716
  }));
@@ -2636,11 +2783,12 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
2636
2783
  });
2637
2784
  if (!repayRouters)
2638
2785
  throw new Error("No swap quote found");
2786
+ const { slippagePercent: repayLegSlippagePercent } = resolveSwapSlippage(new BigNumber(repayRouters.amountIn.toString()).div(10 ** depositReserve.token.decimals), new BigNumber(repayRouters.amountOut.toString()).div(10 ** flashLoanCoinReserve.token.decimals), depositReserve, flashLoanCoinReserve);
2639
2787
  try {
2640
2788
  flashLoanRepayCoin = (await cetusSdk.fixableRouterSwapV3({
2641
2789
  router: repayRouters,
2642
2790
  inputCoin: flashLoanRepayCoin,
2643
- slippage: 1 / 100,
2791
+ slippage: repayLegSlippagePercent / 100,
2644
2792
  txb: transaction,
2645
2793
  partner: cetusPartnerId,
2646
2794
  }));