@suilend/sdk 9.0.0-next.ge1ea2dc → 9.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/strategies.js +142 -22
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name":"@suilend/sdk","version":"9.0.0-next.ge1ea2dc","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":"9.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.js CHANGED
@@ -1145,6 +1145,11 @@ transaction, dryRunTransaction) => {
1145
1145
  const fullyRepayBorrowsUsingLst = async (maxWithdrawRemainingLstAndRedepositAsBase) => {
1146
1146
  if (depositReserves.lst === undefined)
1147
1147
  throw new Error("LST reserve not found");
1148
+ // Everything below is appended to `transaction` while only the repay is dry-run,
1149
+ // so a failure has to undo the whole handler — otherwise an aborting command stays
1150
+ // in the transaction the user is asked to sign.
1151
+ const transactionBeforeFullRepay = Transaction.from(transaction);
1152
+ const depositsBeforeFullRepay = cloneDeep(deposits);
1148
1153
  const borrowedAmountUsd = borrowedAmount.times(borrowReserve.price);
1149
1154
  const fullRepaymentAmount = (borrowedAmountUsd.lt(0.1)
1150
1155
  ? new BigNumber(0.1).div(borrowReserve.price) // $0.1 in borrow coinType
@@ -1203,14 +1208,18 @@ transaction, dryRunTransaction) => {
1203
1208
  txCopy.transferObjects([fullRepaymentCoin], _address); // Transfer remaining SUI to user
1204
1209
  await dryRunTransaction(txCopy); // Throws error if fails
1205
1210
  transaction = txCopy;
1211
+ // 2.3) Update state
1212
+ borrowedAmount = BigNumber.max(borrowedAmount.minus(repaidAmount), new BigNumber(0));
1206
1213
  }
1207
1214
  catch (err) {
1208
- // Don't block user if fails
1215
+ // Undo the handler rather than carrying a command that aborts, and leave
1216
+ // `borrowedAmount` alone — nothing was repaid, so the caller must still see the
1217
+ // debt as outstanding.
1209
1218
  console.error(err);
1210
- transaction.transferObjects([fullRepaymentCoin], _address); // Transfer SUI to user
1219
+ transaction = transactionBeforeFullRepay;
1220
+ deposits = depositsBeforeFullRepay;
1221
+ return;
1211
1222
  }
1212
- // 2.3) Update state
1213
- borrowedAmount = BigNumber.max(borrowedAmount.minus(repaidAmount), new BigNumber(0));
1214
1223
  console.log(`[unloopStrategyToExposure.fullyRepayBorrowsUsingLst] repay_borrows.update_state |`, JSON.stringify({
1215
1224
  deposits: deposits.map((d) => ({
1216
1225
  coinType: d.coinType,
@@ -1288,6 +1297,11 @@ transaction, dryRunTransaction) => {
1288
1297
  const fullyRepayBorrowsUsingBase = async () => {
1289
1298
  if (depositReserves.base === undefined)
1290
1299
  throw new Error("Base reserve not found");
1300
+ // Everything below is appended to `transaction` while only the repay is dry-run,
1301
+ // so a failure has to undo the whole handler — otherwise an aborting command stays
1302
+ // in the transaction the user is asked to sign.
1303
+ const transactionBeforeFullRepay = Transaction.from(transaction);
1304
+ const depositsBeforeFullRepay = cloneDeep(deposits);
1291
1305
  const borrowedAmountUsd = borrowedAmount.times(borrowReserve.price);
1292
1306
  const fullRepaymentAmount = (borrowedAmountUsd.lt(0.1)
1293
1307
  ? new BigNumber(0.1).div(borrowReserve.price) // $0.1 in borrow coinType
@@ -1401,14 +1415,18 @@ transaction, dryRunTransaction) => {
1401
1415
  txCopy.transferObjects([swapCoin], _address); // Transfer remaining to user
1402
1416
  await dryRunTransaction(txCopy); // Throws error if fails
1403
1417
  transaction = txCopy;
1418
+ // 4.2) Update state
1419
+ borrowedAmount = BigNumber.max(borrowedAmount.minus(repaidAmount), new BigNumber(0));
1404
1420
  }
1405
1421
  catch (err) {
1406
- // Don't block user if fails
1422
+ // Undo the handler rather than carrying a command that aborts, and leave
1423
+ // `borrowedAmount` alone — nothing was repaid, so the caller must still see the
1424
+ // debt as outstanding.
1407
1425
  console.error(err);
1408
- transaction.transferObjects([swapCoin], _address); // Transfer to user
1426
+ transaction = transactionBeforeFullRepay;
1427
+ deposits = depositsBeforeFullRepay;
1428
+ return;
1409
1429
  }
1410
- // 4.2) Update state
1411
- borrowedAmount = BigNumber.max(borrowedAmount.minus(repaidAmount), new BigNumber(0));
1412
1430
  console.log(`[unloopStrategyToExposure.fullyRepayBorrowsUsingBase] repay_borrows.update_state |`, JSON.stringify({
1413
1431
  deposits: deposits.map((d) => ({
1414
1432
  coinType: d.coinType,
@@ -1490,6 +1508,15 @@ transaction, dryRunTransaction) => {
1490
1508
  stepMaxRepaidAmount: stepMaxRepaidAmount.toFixed(20),
1491
1509
  }, null, 2));
1492
1510
  // 1.2) Withdraw
1511
+ // Once this step's ceiling can cover everything still owed, it is the last one
1512
+ // — see the base branch for why looping again aborts with `EBorrowNotFound`.
1513
+ // Unlike that branch this needs no margin on top of `pendingBorrowedAmount`:
1514
+ // there is no swap, the redeem rate is deterministic, and every estimate here
1515
+ // errs the same way. `stepWithdrawnAmount` and `stepRepaidAmount` both round
1516
+ // down, and `lstToSuiExchangeRate` is a cached floor that only grows as staking
1517
+ // rewards accrue, so the SUI actually redeemed is at or above the estimate and
1518
+ // the tracked debt already sits at or above the real one.
1519
+ const isFinalStep = pendingBorrowedAmount.lte(stepMaxRepaidAmount);
1493
1520
  const stepWithdrawnAmount = BigNumber.min(pendingBorrowedAmount, stepMaxRepaidAmount)
1494
1521
  .times(1 - +(lst?.redeemFeePercent ?? 0) / 100) // Potential rounding issue (max 1 MIST)
1495
1522
  .div(lst?.lstToSuiExchangeRate ?? 1)
@@ -1499,6 +1526,13 @@ transaction, dryRunTransaction) => {
1499
1526
  stepWithdrawnAmount: stepWithdrawnAmount.toFixed(20),
1500
1527
  isMaxWithdraw,
1501
1528
  }, null, 2));
1529
+ // The withdraw, redeem and swap below go straight onto `transaction`, but only
1530
+ // the repay is dry-run. When that dry run fails, the step's commands are already
1531
+ // in the transaction, so every later dry run fails on that same command and the
1532
+ // user ends up signing a transaction already known to fail. Snapshot the
1533
+ // pre-step state so a failed step can be rolled back whole.
1534
+ const transactionBeforeStep = Transaction.from(transaction);
1535
+ const depositsBeforeStep = cloneDeep(deposits);
1502
1536
  const [stepWithdrawnCoin] = strategyWithdraw(strategyType, loopingDepositReserve.coinType, strategyOwnerCapId, suilendClient.findReserveArrayIndex(loopingDepositReserve.coinType), BigInt(new BigNumber(stepWithdrawnAmount
1503
1537
  .times(10 ** loopingDepositReserve.token.decimals)
1504
1538
  .integerValue(BigNumber.ROUND_DOWN)
@@ -1528,27 +1562,46 @@ transaction, dryRunTransaction) => {
1528
1562
  stepRepaidAmount: stepRepaidAmount.toFixed(20),
1529
1563
  isMaxRepay,
1530
1564
  }, null, 2));
1565
+ let stepClearedDebt = false;
1531
1566
  try {
1532
1567
  const txCopy = Transaction.from(transaction);
1533
1568
  suilendClient.repay(obligationId, borrowReserve.coinType, stepSuiCoin, txCopy);
1534
1569
  txCopy.transferObjects([stepSuiCoin], _address);
1535
1570
  await dryRunTransaction(txCopy); // Throws error if fails
1536
1571
  transaction = txCopy;
1572
+ // 3.2) Update state
1573
+ // A final step covers everything owed and `obligation::repay` clamps to the
1574
+ // real debt, so the target is reached exactly rather than by subtracting a
1575
+ // figure the rounding has already made fictional.
1576
+ borrowedAmount = isFinalStep
1577
+ ? targetBorrowedAmount
1578
+ : borrowedAmount.minus(stepRepaidAmount);
1579
+ stepClearedDebt = isFinalStep;
1537
1580
  }
1538
1581
  catch (err) {
1539
- // Don't block user if fails
1582
+ // The step cannot land — the redeem aborted, or the borrow is already gone
1583
+ // (`EBorrowNotFound`). Roll the whole step back either way: keeping it would
1584
+ // leave an aborting command in the transaction, so every later dry run would
1585
+ // fail on that same command and the user would be asked to sign a transaction
1586
+ // known to fail.
1540
1587
  console.error(err);
1541
- transaction.transferObjects([stepSuiCoin], _address);
1588
+ transaction = transactionBeforeStep;
1589
+ deposits = depositsBeforeStep;
1590
+ break;
1542
1591
  }
1543
- // 3.2) Update state
1544
- borrowedAmount = borrowedAmount.minus(stepRepaidAmount);
1545
1592
  console.log(`[unloopStrategyToExposure] ${i} repay_sui.update_state |`, JSON.stringify({
1546
1593
  deposits: deposits.map((d) => ({
1547
1594
  coinType: d.coinType,
1548
1595
  depositedAmount: d.depositedAmount.toFixed(20),
1549
1596
  })),
1550
1597
  borrowedAmount: borrowedAmount.toFixed(20),
1598
+ stepClearedDebt,
1551
1599
  }, null, 2));
1600
+ // Nothing is owed above target any more. Returning here rather than looping
1601
+ // again also skips `fullyRepayBorrowsUsingLst`, which would repay a borrow that
1602
+ // no longer exists.
1603
+ if (stepClearedDebt)
1604
+ break;
1552
1605
  }
1553
1606
  // Base only
1554
1607
  else if (loopingDepositReserve.coinType === depositReserves.base?.coinType) {
@@ -1591,8 +1644,24 @@ transaction, dryRunTransaction) => {
1591
1644
  stepMaxWithdrawnAmount: stepMaxWithdrawnAmount.toFixed(20),
1592
1645
  stepMaxRepaidAmount: stepMaxRepaidAmount.toFixed(20),
1593
1646
  }, null, 2));
1647
+ const slippagePercent = 1;
1594
1648
  // 1.2) Withdraw
1595
- const stepWithdrawnAmount = BigNumber.min(pendingBorrowedAmount, stepMaxRepaidAmount)
1649
+ // Once this step's ceiling can cover everything still owed, it is the last
1650
+ // 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:
1653
+ // `obligation::repay` clamps to what is actually owed
1654
+ // (`min(max_repay_amount, borrowed_amount)`) and the surplus is transferred
1655
+ // back to the user below.
1656
+ //
1657
+ // Sizing to the tracked figure instead leaves the loop believing a residual is
1658
+ // still owed, because that figure is deliberately held above the real debt (see
1659
+ // the repay step). The next iteration then aborts with `EBorrowNotFound` — a
1660
+ // fully repaid borrow is removed from the obligation, so there is nothing left
1661
+ // to repay against.
1662
+ const stepTargetRepaidAmount = pendingBorrowedAmount.div(1 - slippagePercent / 100);
1663
+ const isFinalStep = stepTargetRepaidAmount.lte(stepMaxRepaidAmount);
1664
+ const stepWithdrawnAmount = BigNumber.min(stepTargetRepaidAmount, stepMaxRepaidAmount)
1596
1665
  .div(baseToBorrowExchangeRate)
1597
1666
  .decimalPlaces(loopingDepositReserve.token.decimals, BigNumber.ROUND_DOWN);
1598
1667
  const isMaxWithdraw = stepWithdrawnAmount.eq(stepMaxWithdrawnAmount);
@@ -1606,6 +1675,14 @@ transaction, dryRunTransaction) => {
1606
1675
  // zero-amount quote — there's nothing meaningful left to unloop, so stop.
1607
1676
  if (stepWithdrawnAmount.lte(0))
1608
1677
  break;
1678
+ // The withdraw and the swap below go straight onto `transaction`, but only the
1679
+ // repay is dry-run. When that dry run fails, the step's commands are already in
1680
+ // the transaction — and the swap is usually what aborts — so every later dry run
1681
+ // fails on that same command and the user ends up signing a transaction already
1682
+ // known to fail. Snapshot the pre-step state so a failed step can be rolled back
1683
+ // whole.
1684
+ const transactionBeforeStep = Transaction.from(transaction);
1685
+ const depositsBeforeStep = cloneDeep(deposits);
1609
1686
  const [stepWithdrawnCoin] = strategyWithdraw(strategyType, loopingDepositReserve.coinType, strategyOwnerCapId, suilendClient.findReserveArrayIndex(loopingDepositReserve.coinType), BigInt(new BigNumber(stepWithdrawnAmount
1610
1687
  .times(10 ** loopingDepositReserve.token.decimals)
1611
1688
  .integerValue(BigNumber.ROUND_DOWN)
@@ -1638,7 +1715,6 @@ transaction, dryRunTransaction) => {
1638
1715
  });
1639
1716
  if (!routers)
1640
1717
  throw new Error("No swap quote found");
1641
- const slippagePercent = 1;
1642
1718
  let stepBorrowCoin;
1643
1719
  try {
1644
1720
  stepBorrowCoin = (await cetusSdk.fixableRouterSwapV3({
@@ -1676,27 +1752,46 @@ transaction, dryRunTransaction) => {
1676
1752
  stepRepaidAmount: stepRepaidAmount.toFixed(20),
1677
1753
  isMaxRepay,
1678
1754
  }, null, 2));
1755
+ let stepClearedDebt = false;
1679
1756
  try {
1680
1757
  const txCopy = Transaction.from(transaction);
1681
1758
  suilendClient.repay(obligationId, borrowReserve.coinType, stepBorrowCoin, txCopy);
1682
1759
  txCopy.transferObjects([stepBorrowCoin], _address);
1683
1760
  await dryRunTransaction(txCopy); // Throws error if fails
1684
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;
1685
1770
  }
1686
1771
  catch (err) {
1687
- // Don't block user if fails
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.
1688
1777
  console.error(err);
1689
- transaction.transferObjects([stepBorrowCoin], _address);
1778
+ transaction = transactionBeforeStep;
1779
+ deposits = depositsBeforeStep;
1780
+ break;
1690
1781
  }
1691
- // 3.2) Update state
1692
- borrowedAmount = borrowedAmount.minus(stepRepaidAmount);
1693
1782
  console.log(`[unloopStrategyToExposure] ${i} repay_borrows.update_state |`, JSON.stringify({
1694
1783
  deposits: deposits.map((d) => ({
1695
1784
  coinType: d.coinType,
1696
1785
  depositedAmount: d.depositedAmount.toFixed(20),
1697
1786
  })),
1698
1787
  borrowedAmount: borrowedAmount.toFixed(20),
1788
+ stepClearedDebt,
1699
1789
  }, null, 2));
1790
+ // Nothing is owed above target any more. Returning here rather than looping
1791
+ // again also skips `fullyRepayBorrowsUsingBase`, which would repay a borrow
1792
+ // that no longer exists.
1793
+ if (stepClearedDebt)
1794
+ break;
1700
1795
  }
1701
1796
  else {
1702
1797
  throw new Error("No LST or base reserve found"); // Should not happen
@@ -1963,7 +2058,22 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
1963
2058
  }
1964
2059
  // 2) Withdraw base or LST
1965
2060
  // 2.1) Withdraw
1966
- const [withdrawnCoin] = strategyWithdraw(strategyType, depositReserve.coinType, strategyOwnerCapId, suilendClient.findReserveArrayIndex(depositReserve.coinType), BigInt(new BigNumber(withdrawnAmount
2061
+ // `withdrawnAmount` is priced off the pre-unloop position, but the unloop spends
2062
+ // collateral to repay borrows and each step only counts `slippagePercent`% less
2063
+ // than its quote towards the debt. The tracked debt therefore lags the real one,
2064
+ // the loop unwinds further than it needs to, and what is left can be less than
2065
+ // the amount the user asked for. Withdrawing the unclamped amount underflows
2066
+ // `obligation::withdraw_unchecked` on-chain and fails the whole transaction, so
2067
+ // clamp to what the position still holds.
2068
+ const remainingDepositedAmount = deposits.find((d) => d.coinType === depositReserve.coinType)
2069
+ ?.depositedAmount ?? new BigNumber(0);
2070
+ const finalWithdrawnAmount = BigNumber.max(new BigNumber(0), BigNumber.min(withdrawnAmount, remainingDepositedAmount)).decimalPlaces(depositReserve.token.decimals, BigNumber.ROUND_DOWN);
2071
+ console.log(`[withdraw] withdraw.clamp |`, JSON.stringify({
2072
+ withdrawnAmount: withdrawnAmount.toFixed(20),
2073
+ remainingDepositedAmount: remainingDepositedAmount.toFixed(20),
2074
+ finalWithdrawnAmount: finalWithdrawnAmount.toFixed(20),
2075
+ }, null, 2));
2076
+ const [withdrawnCoin] = strategyWithdraw(strategyType, depositReserve.coinType, strategyOwnerCapId, suilendClient.findReserveArrayIndex(depositReserve.coinType), BigInt(new BigNumber(finalWithdrawnAmount
1967
2077
  .times(10 ** depositReserve.token.decimals)
1968
2078
  .integerValue(BigNumber.ROUND_DOWN)
1969
2079
  .toString())
@@ -1973,7 +2083,7 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
1973
2083
  // 2.2) Update state
1974
2084
  deposits = addOrInsertStrategyDeposit(deposits, {
1975
2085
  coinType: depositReserve.coinType,
1976
- depositedAmount: withdrawnAmount.times(-1),
2086
+ depositedAmount: finalWithdrawnAmount.times(-1),
1977
2087
  });
1978
2088
  const newExposure = getStrategyExposure(reserveMap, lstMap, strategyType, getStrategySimulatedObligation(reserveMap, lstMap, strategyType, deposits, borrowedAmount));
1979
2089
  const newTvlAmountUsd = getStrategyTvlAmount(reserveMap, lstMap, strategyType, getStrategySimulatedObligation(reserveMap, lstMap, strategyType, deposits, borrowedAmount)).times(defaultCurrencyReserve.price);
@@ -1998,7 +2108,7 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
1998
2108
  const routers = await cetusSdk.findRouters({
1999
2109
  from: depositReserve.coinType,
2000
2110
  target: withdraw.coinType,
2001
- amount: new BN(withdrawnAmount
2111
+ amount: new BN(finalWithdrawnAmount
2002
2112
  .times(10 ** depositReserve.token.decimals)
2003
2113
  .integerValue(BigNumber.ROUND_DOWN)
2004
2114
  .toString()),
@@ -2089,6 +2199,12 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
2089
2199
  }, null, 2));
2090
2200
  }
2091
2201
  // 2) MAX withdraw base or LST
2202
+ // The withdraw below takes every deposit, which the obligation only permits once
2203
+ // the borrows are gone. The unloop stops early if a step cannot land, so check
2204
+ // rather than build a transaction that is certain to abort on the health check —
2205
+ // the caller can surface this instead of asking the user to sign it.
2206
+ if (borrowedAmount.gt(STRATEGY_E))
2207
+ throw new Error("Failed to repay borrows before withdrawing");
2092
2208
  const estimatedWithdrawnAmount = deposits.find((d) => d.coinType === depositReserve.coinType)
2093
2209
  ?.depositedAmount ?? new BigNumber(0);
2094
2210
  const [withdrawnCoin] = strategyWithdraw(strategyType, depositReserve.coinType, strategyOwnerCapId, suilendClient.findReserveArrayIndex(depositReserve.coinType), BigInt(MAX_U64.toString()), transaction);
@@ -2150,6 +2266,10 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
2150
2266
  }
2151
2267
  // 4) Claim rewards and transfer to user
2152
2268
  if (hasClaimableRewards) {
2269
+ // Claiming is a bonus, so a failure must not block the withdraw — but the calls
2270
+ // are appended as they are built, so a throw part-way leaves half a claim behind
2271
+ // that can abort the whole transaction. Roll back to before the claim instead.
2272
+ const transactionBeforeClaim = Transaction.from(transaction);
2153
2273
  try {
2154
2274
  const mergedCoinsMap = strategyClaimRewardsAndMergeCoins(strategyType, rewardsMap, strategyOwnerCapId, transaction);
2155
2275
  for (const coin of Object.values(mergedCoinsMap)) {
@@ -2157,8 +2277,8 @@ lstMap, strategyType, suiGrpcClient, suilendClient, cetusSdk, cetusPartnerId, _a
2157
2277
  }
2158
2278
  }
2159
2279
  catch (err) {
2160
- // Don't block user if fails
2161
2280
  console.error(err);
2281
+ transaction = transactionBeforeClaim;
2162
2282
  }
2163
2283
  }
2164
2284
  return { deposits, borrowedAmount, transaction };