@suilend/sdk 9.0.0-next.ge1ea2dc → 10.0.0-next.g5c83439

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/client.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { SuiClientTypes } from "@mysten/sui/client";
2
2
  import { SuiGrpcClient } from "@mysten/sui/grpc";
3
3
  import { Transaction, TransactionObjectArgument, TransactionObjectInput } from "@mysten/sui/transactions";
4
- import { SuiPriceServiceConnection, SuiPythClient } from "@pythnetwork/pyth-sui-js";
4
+ import { SuiPythClient } from "@pythnetwork/pyth-sui-js";
5
5
  import { LendingMarket, ObligationOwnerCap } from "./_generated/suilend/lending-market/structs";
6
6
  import { Obligation } from "./_generated/suilend/obligation/structs";
7
7
  import { NewConfigArgs as CreateRateLimiterConfigArgs } from "./_generated/suilend/rate-limiter/functions";
8
8
  import { CreateReserveConfigArgs } from "./_generated/suilend/reserve-config/functions";
9
+ import { type PriceUpdateDataSource } from "./lib/pyth";
9
10
  import { Side } from "./lib/types";
10
11
  export declare const ADMIN_ADDRESS: string;
11
12
  export declare const LENDING_MARKET_REGISTRY_ID: string;
@@ -43,7 +44,18 @@ export declare class SuilendClient {
43
44
  lendingMarket: LendingMarket<string>;
44
45
  suiGrpcClient: SuiGrpcClient;
45
46
  pythClient: SuiPythClient;
46
- pythConnection: SuiPriceServiceConnection;
47
+ /**
48
+ * The source of the signed update this client puts ON-CHAIN.
49
+ *
50
+ * Typed as the one-method `PriceUpdateDataSource` rather than the concrete
51
+ * `SuiPriceServiceConnection` it used to be: a real connection still satisfies
52
+ * it structurally, and the narrower type is what lets a caller inject an
53
+ * authenticated or proxied source without reimplementing a pyth client. It
54
+ * stays a public, reassignable field because consumers already assign it
55
+ * directly — the indexer to reach its failover connection, the admin dialogs
56
+ * to read update data for a reserve change.
57
+ */
58
+ pythConnection: PriceUpdateDataSource;
47
59
  constructor(lendingMarket: LendingMarket<string>, suiGrpcClient: SuiGrpcClient);
48
60
  static initialize(lendingMarketId: string, lendingMarketType: string, suiGrpcClient: SuiGrpcClient, logPackageId?: boolean): Promise<SuilendClient>;
49
61
  /**
package/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SUI_CLOCK_OBJECT_ID, SUI_SYSTEM_STATE_OBJECT_ID, normalizeStructTag, normalizeSuiObjectId, toHex, } from "@mysten/sui/utils";
2
- import { SuiPriceServiceConnection, SuiPythClient, } from "@pythnetwork/pyth-sui-js";
2
+ import { SuiPythClient } from "@pythnetwork/pyth-sui-js";
3
3
  import { extractCTokenCoinType, getAllCoins, getSpendableCoin, } from "@suilend/sui-core";
4
4
  import { phantom } from "./_generated/_framework/reified.js";
5
5
  import { PKG_V10, setPublishedAt } from "./_generated/suilend/index.js";
@@ -11,7 +11,7 @@ import { Obligation } from "./_generated/suilend/obligation/structs.js";
11
11
  import { newConfig as createRateLimiterConfig, } from "./_generated/suilend/rate-limiter/functions.js";
12
12
  import { createReserveConfig, } from "./_generated/suilend/reserve-config/functions.js";
13
13
  import { addReserveProCompatible, addReserveV2ProCompatible, changeReservePriceFeedProCompatible, priceInfoPublishTime, refreshReservePriceProCompatible, } from "./lib/proCompatible.js";
14
- import { PRIMARY_PYTH_ENDPOINT } from "./lib/pyth.js";
14
+ import { resolvePriceUpdateDataSource, } from "./lib/pyth.js";
15
15
  import { Side } from "./lib/types.js";
16
16
  import { chunkedMultiGet } from "./utils/obligation.js";
17
17
  const SUI_COINTYPE = "0x2::sui::SUI";
@@ -144,6 +144,17 @@ export class SuilendClient {
144
144
  lendingMarket;
145
145
  suiGrpcClient;
146
146
  pythClient;
147
+ /**
148
+ * The source of the signed update this client puts ON-CHAIN.
149
+ *
150
+ * Typed as the one-method `PriceUpdateDataSource` rather than the concrete
151
+ * `SuiPriceServiceConnection` it used to be: a real connection still satisfies
152
+ * it structurally, and the narrower type is what lets a caller inject an
153
+ * authenticated or proxied source without reimplementing a pyth client. It
154
+ * stays a public, reassignable field because consumers already assign it
155
+ * directly — the indexer to reach its failover connection, the admin dialogs
156
+ * to read update data for a reserve change.
157
+ */
147
158
  pythConnection;
148
159
  constructor(lendingMarket, suiGrpcClient) {
149
160
  this.lendingMarket = lendingMarket;
@@ -161,10 +172,13 @@ export class SuilendClient {
161
172
  // deposit/withdraw/borrow/repay, and nothing read-only touches it, so a
162
173
  // typecheck and every page-load check stay green while writes are broken.
163
174
  this.pythClient = new SuiPythClient(suiGrpcClient, PYTH_STATE_ID, WORMHOLE_STATE_ID);
164
- const pythEndpoint = PRIMARY_PYTH_ENDPOINT; // TODO: Use getWorkingPythEndpoint
165
- this.pythConnection = new SuiPriceServiceConnection(pythEndpoint, {
166
- timeout: 30 * 1000,
167
- });
175
+ // Deliberately NOT a public-Hermes connection, which is what stood here.
176
+ // `WORMHOLE_STATE_ID` above is unconditionally the pro-compatible
177
+ // deployment, and public Hermes signs with a guardian set that deployment
178
+ // does not hold — so that default could not produce a usable update for any
179
+ // feed, and only looked harmless because `refreshAll` skips the update
180
+ // entirely while a feed is fresh. See `resolvePriceUpdateDataSource`.
181
+ this.pythConnection = resolvePriceUpdateDataSource();
168
182
  }
169
183
  static async initialize(lendingMarketId, lendingMarketType, suiGrpcClient, logPackageId) {
170
184
  const lendingMarket = await LendingMarket.fetch(suiGrpcClient, phantom(lendingMarketType), lendingMarketId);
@@ -488,8 +502,10 @@ export class SuilendClient {
488
502
  });
489
503
  const publishTime = priceInfoPublishTime(object.json, priceInfoObjectId);
490
504
  const stalenessSeconds = Date.now() / 1000 - Number(publishTime);
505
+ // 20s, not MAX_STALENESS_SECONDS (60): the update has to leave the
506
+ // object fresh enough to still satisfy `refresh_reserve_price` after
507
+ // the transaction lands, not merely fresh at the moment it is read.
491
508
  if (stalenessSeconds > 20) {
492
- const reserve = this.lendingMarket.reserves[Number(reserveArrayIndexes[+i])];
493
509
  stalePriceIdentifiers.push(priceIdentifiers[+i]);
494
510
  }
495
511
  })()));
package/lib/pyth.d.ts CHANGED
@@ -106,3 +106,64 @@ export declare const hermesConnectionConfig: (hermesConfig?: HermesClientConfig)
106
106
  * works without an injected connection.
107
107
  */
108
108
  export declare const resolvePythConnection: (pythConnectionOverride?: PriceFeedSource, fallbackPythEndpoint?: string, hermesConfig?: HermesClientConfig) => Promise<PriceFeedSource>;
109
+ /**
110
+ * Where `SuilendClient` gets the signed price update it puts ON-CHAIN.
111
+ *
112
+ * Not the same thing as `PriceFeedSource`, and the distinction is the whole
113
+ * point of this type existing. `PriceFeedSource` answers "what is this feed
114
+ * worth" and is read off-chain; the answer is a number and any Hermes can
115
+ * produce it. This answers "give me a VAA the Pyth deployment our contracts
116
+ * link will verify" — a signed blob that a *specific* deployment's guardian set
117
+ * has to have signed. Injecting one has never affected the other, which is how
118
+ * an app could believe it had moved to Pyth Pro while every transaction it built
119
+ * still carried a public-Hermes VAA.
120
+ *
121
+ * One method, because that is all `SuilendClient` ever calls on the field.
122
+ */
123
+ export interface PriceUpdateDataSource {
124
+ getPriceFeedsUpdateData(priceIds: string[]): Promise<Buffer[]>;
125
+ }
126
+ /**
127
+ * Register the process-wide source for on-chain price updates. Call at startup;
128
+ * clients constructed BEFORE the call still pick it up, because the SDK default
129
+ * resolves late (see `deferredPriceUpdateDataSource`).
130
+ *
131
+ * Process-wide rather than a constructor argument on purpose: consumers build a
132
+ * `SuilendClient` in many places (perpetual-ui alone has 17 `initialize` call
133
+ * sites across four apps), and a per-site argument means the site somebody adds
134
+ * next silently reverts to the broken path. One registration covers every
135
+ * instance, including ones constructed by code the caller does not own.
136
+ *
137
+ * Resolution order, most specific first:
138
+ * 1. `client.pythConnection = …` — a per-instance assignment always wins, and
139
+ * is how the indexer injects its authenticated failover connection.
140
+ * 2. whatever was registered here.
141
+ * 3. the SDK default.
142
+ *
143
+ * That order is load-bearing for the planned change to have the SDK resolve its
144
+ * own default endpoint: adding a real default at (3) cannot fight a consumer
145
+ * that registered at (2), so registrations made now become redundant rather
146
+ * than wrong.
147
+ */
148
+ export declare function setPriceUpdateDataSource(source: PriceUpdateDataSource): void;
149
+ /**
150
+ * Test-only. The registration is process-wide by design, so without this one
151
+ * test's source would leak into the next.
152
+ */
153
+ export declare function clearPriceUpdateDataSource(): void;
154
+ /**
155
+ * The source a newly-constructed `SuilendClient` should start with: ALWAYS the
156
+ * late-resolving wrapper, never the registered source directly.
157
+ *
158
+ * Returning the registered source when one happened to exist made resolution
159
+ * depend on construction order after all — a client built before registration
160
+ * followed the registry, one built after snapshotted it. So a re-registration (a
161
+ * provider re-running on hot reload, a refreshed credential behind the source)
162
+ * left every already-built client calling the source it replaced, which is the
163
+ * same silent-divergence failure this seam exists to prevent, reached through
164
+ * timing instead of a missed call site.
165
+ *
166
+ * Per-instance assignment still wins, because the field stays public and
167
+ * reassignable; this only decides what a client starts with.
168
+ */
169
+ export declare function resolvePriceUpdateDataSource(): PriceUpdateDataSource;
package/lib/pyth.js CHANGED
@@ -180,3 +180,86 @@ export const resolvePythConnection = async (pythConnectionOverride, fallbackPyth
180
180
  const pythEndpoint = await getWorkingPythEndpoint(fallbackPythEndpoint);
181
181
  return createHermesPriceFeedSource(new HermesClient(pythEndpoint, hermesConnectionConfig(hermesConfig)));
182
182
  };
183
+ /**
184
+ * The SDK's default: it resolves the registered source LATE, and fails only if
185
+ * there still isn't one.
186
+ *
187
+ * Late resolution is the point. A client reads this default at construction, so
188
+ * a default that captured "nothing registered" would leave every client built
189
+ * before startup finished permanently broken — and consumers construct clients
190
+ * inside React hooks and module initialisers, where ordering against an app's
191
+ * own bootstrap is not something the SDK can dictate. Deferring to call time
192
+ * means registration order simply does not matter.
193
+ *
194
+ * When there genuinely is no source, it names the problem rather than returning
195
+ * something unusable. There is no default that could work: `WORMHOLE_STATE_ID`
196
+ * is unconditionally the pro-compatible deployment, whose vendored wormhole
197
+ * holds a single guardian set, so a public-Hermes VAA carries the Wormhole
198
+ * network's set 7 and aborts in `dynamic_field::borrow_child_object` as the
199
+ * first command of the user's transaction. Shipping public Hermes here — which
200
+ * is what this SDK did — was not a lenient default but a guaranteed on-chain
201
+ * abort, deferred until a feed drifted past the staleness gate.
202
+ */
203
+ const deferredPriceUpdateDataSource = {
204
+ getPriceFeedsUpdateData: (priceIds) => {
205
+ if (registeredPriceUpdateDataSource) {
206
+ return registeredPriceUpdateDataSource.getPriceFeedsUpdateData(priceIds);
207
+ }
208
+ return Promise.reject(new Error("@suilend/sdk: no price-update source is registered, so an on-chain " +
209
+ "price update cannot be built. Call setPriceUpdateDataSource() at " +
210
+ "startup with a source backed by the pro-compatible Pyth deployment " +
211
+ "(public Hermes cannot be used: its VAAs are signed by a guardian set " +
212
+ "the linked wormhole instance does not hold, and verification aborts)."));
213
+ },
214
+ };
215
+ let registeredPriceUpdateDataSource;
216
+ /**
217
+ * Register the process-wide source for on-chain price updates. Call at startup;
218
+ * clients constructed BEFORE the call still pick it up, because the SDK default
219
+ * resolves late (see `deferredPriceUpdateDataSource`).
220
+ *
221
+ * Process-wide rather than a constructor argument on purpose: consumers build a
222
+ * `SuilendClient` in many places (perpetual-ui alone has 17 `initialize` call
223
+ * sites across four apps), and a per-site argument means the site somebody adds
224
+ * next silently reverts to the broken path. One registration covers every
225
+ * instance, including ones constructed by code the caller does not own.
226
+ *
227
+ * Resolution order, most specific first:
228
+ * 1. `client.pythConnection = …` — a per-instance assignment always wins, and
229
+ * is how the indexer injects its authenticated failover connection.
230
+ * 2. whatever was registered here.
231
+ * 3. the SDK default.
232
+ *
233
+ * That order is load-bearing for the planned change to have the SDK resolve its
234
+ * own default endpoint: adding a real default at (3) cannot fight a consumer
235
+ * that registered at (2), so registrations made now become redundant rather
236
+ * than wrong.
237
+ */
238
+ export function setPriceUpdateDataSource(source) {
239
+ registeredPriceUpdateDataSource = source;
240
+ }
241
+ /**
242
+ * Test-only. The registration is process-wide by design, so without this one
243
+ * test's source would leak into the next.
244
+ */
245
+ export function clearPriceUpdateDataSource() {
246
+ registeredPriceUpdateDataSource = undefined;
247
+ }
248
+ /**
249
+ * The source a newly-constructed `SuilendClient` should start with: ALWAYS the
250
+ * late-resolving wrapper, never the registered source directly.
251
+ *
252
+ * Returning the registered source when one happened to exist made resolution
253
+ * depend on construction order after all — a client built before registration
254
+ * followed the registry, one built after snapshotted it. So a re-registration (a
255
+ * provider re-running on hot reload, a refreshed credential behind the source)
256
+ * left every already-built client calling the source it replaced, which is the
257
+ * same silent-divergence failure this seam exists to prevent, reached through
258
+ * timing instead of a missed call site.
259
+ *
260
+ * Per-instance assignment still wins, because the field stays public and
261
+ * reassignable; this only decides what a client starts with.
262
+ */
263
+ export function resolvePriceUpdateDataSource() {
264
+ return deferredPriceUpdateDataSource;
265
+ }
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":"10.0.0-next.g5c83439","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 };