@t2000/sdk 0.9.2 → 0.9.5

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/README.md CHANGED
@@ -232,7 +232,7 @@ Every operation (send, save, borrow, repay, withdraw) routes through a 3-step ga
232
232
 
233
233
  Every transaction result includes a `gasMethod` field (`'self-funded'` | `'auto-topup'` | `'sponsored'`) indicating which strategy was used.
234
234
 
235
- **Architecture:** Each protocol operation (NAVI, Suilend, Cetus, send) exposes a `buildXxxTx()` function that returns a `Transaction` without executing it. `executeWithGas()` then handles execution with the fallback chain. This separation ensures gas management is consistent across all operations.
235
+ **Architecture:** Each protocol operation (NAVI, Suilend, Cetus, send) exposes both `buildXxxTx()` (standalone transaction) and `addXxxToTx()` (composable PTB) functions. Multi-step operations (save with auto-convert, withdraw with auto-swap, rebalance) compose multiple protocol calls into a single atomic PTB. `executeWithGas()` handles execution with the gas fallback chain. If any step within a PTB fails, the entire transaction reverts — no funds left in intermediate states.
236
236
 
237
237
  ## Configuration
238
238
 
@@ -275,7 +275,7 @@ Common error codes: `INSUFFICIENT_BALANCE` · `INVALID_ADDRESS` · `INVALID_AMOU
275
275
  ## Testing
276
276
 
277
277
  ```bash
278
- # Run all SDK unit tests (262 tests)
278
+ # Run all SDK unit tests (367 tests)
279
279
  pnpm --filter @t2000/sdk test
280
280
  ```
281
281
 
@@ -291,6 +291,13 @@ pnpm --filter @t2000/sdk test
291
291
  | `send.test.ts` | Send transaction building and validation |
292
292
  | `manager.test.ts` | Gas resolution chain (self-fund, auto-topup, sponsored fallback) |
293
293
  | `autoTopUp.test.ts` | Auto-topup threshold logic and swap execution |
294
+ | `compliance.test.ts` | Adapter contract compliance (49 checks across all adapters) |
295
+ | `registry.test.ts` | Best rates, multi-protocol routing, quote aggregation |
296
+ | `cetus.test.ts` | Cetus swap adapter (metadata, quotes, transaction building) |
297
+ | `suilend.test.ts` | Suilend adapter (rates, positions, obligation lifecycle) |
298
+ | `t2000.integration.test.ts` | End-to-end flows (save, withdraw, borrow, repay, rebalance, auto-swap) |
299
+ | `protocolFee.test.ts` | Protocol fee calculation and collection |
300
+ | `sentinel.test.ts` | Sentinel attack flow, listing, fee parsing |
294
301
  | `serialization.test.ts` | Transaction JSON serialization roundtrip |
295
302
 
296
303
  ## Protocol Fees
@@ -299,7 +306,7 @@ pnpm --filter @t2000/sdk test
299
306
  |-----------|-----|-------|
300
307
  | Save (deposit) | 0.10% | Protocol fee on deposit |
301
308
  | Borrow | 0.05% | Protocol fee on loan |
302
- | Swap | **Free** | Only standard Cetus pool fees |
309
+ | Swap (internal) | **Free** | Cetus pool fees only; used internally by rebalance/auto-convert |
303
310
  | Withdraw | Free | |
304
311
  | Repay | Free | |
305
312
  | Send | Free | |
@@ -392,7 +392,7 @@ function compoundBalance(rawBalance, currentIndex) {
392
392
  const result = (rawBalance * BigInt(currentIndex) + half) / scale;
393
393
  return Number(result) / 10 ** NAVI_BALANCE_DECIMALS;
394
394
  }
395
- async function getUserState(client, address) {
395
+ async function getUserState(client, address, includeZero = false) {
396
396
  const config = await getConfig();
397
397
  const tx = new transactions.Transaction();
398
398
  tx.moveCall({
@@ -405,11 +405,13 @@ async function getUserState(client, address) {
405
405
  });
406
406
  const decoded = decodeDevInspect(result, bcs.bcs.vector(UserStateInfo));
407
407
  if (!decoded) return [];
408
- return decoded.map((s) => ({
408
+ const mapped = decoded.map((s) => ({
409
409
  assetId: s.asset_id,
410
410
  supplyBalance: toBigInt(s.supply_balance),
411
411
  borrowBalance: toBigInt(s.borrow_balance)
412
- })).filter((s) => s.supplyBalance !== 0n || s.borrowBalance !== 0n);
412
+ }));
413
+ if (includeZero) return mapped;
414
+ return mapped.filter((s) => s.supplyBalance !== 0n || s.borrowBalance !== 0n);
413
415
  }
414
416
  async function fetchCoins(client, owner, coinType) {
415
417
  const all = [];
@@ -466,20 +468,20 @@ async function buildSaveTx(client, address, amount, options = {}) {
466
468
  async function buildWithdrawTx(client, address, amount, options = {}) {
467
469
  const asset = options.asset ?? "USDC";
468
470
  const assetInfo = SUPPORTED_ASSETS[asset];
469
- const [config, pool, pools, states] = await Promise.all([
471
+ const [config, pool, pools, allStates] = await Promise.all([
470
472
  getConfig(),
471
473
  getPool(asset),
472
474
  getPools(),
473
- getUserState(client, address)
475
+ getUserState(client, address, true)
474
476
  ]);
475
- const assetState = states.find((s) => s.assetId === pool.id);
477
+ const assetState = allStates.find((s) => s.assetId === pool.id);
476
478
  const deposited = assetState ? compoundBalance(assetState.supplyBalance, pool.currentSupplyIndex) : 0;
477
479
  const effectiveAmount = Math.min(amount, Math.max(0, deposited - WITHDRAW_DUST_BUFFER));
478
480
  if (effectiveAmount <= 0) throw new T2000Error("NO_COLLATERAL", `Nothing to withdraw for ${assetInfo.displayName} on NAVI`);
479
481
  const rawAmount = Number(stableToRaw(effectiveAmount, assetInfo.decimals));
480
482
  const tx = new transactions.Transaction();
481
483
  tx.setSender(address);
482
- addOracleUpdatesForPositions(tx, config, pools, states, pool);
484
+ addOracleUpdatesForPositions(tx, config, pools, allStates, pool);
483
485
  const [balance] = tx.moveCall({
484
486
  target: `${config.package}::incentive_v3::withdraw_v2`,
485
487
  arguments: [
@@ -503,6 +505,94 @@ async function buildWithdrawTx(client, address, amount, options = {}) {
503
505
  tx.transferObjects([coin], address);
504
506
  return { tx, effectiveAmount };
505
507
  }
508
+ async function addWithdrawToTx(tx, client, address, amount, options = {}) {
509
+ const asset = options.asset ?? "USDC";
510
+ const assetInfo = SUPPORTED_ASSETS[asset];
511
+ const [config, pool, pools, allStates] = await Promise.all([
512
+ getConfig(),
513
+ getPool(asset),
514
+ getPools(),
515
+ getUserState(client, address, true)
516
+ ]);
517
+ const assetState = allStates.find((s) => s.assetId === pool.id);
518
+ const deposited = assetState ? compoundBalance(assetState.supplyBalance, pool.currentSupplyIndex) : 0;
519
+ const effectiveAmount = Math.min(amount, Math.max(0, deposited - WITHDRAW_DUST_BUFFER));
520
+ if (effectiveAmount <= 0) throw new T2000Error("NO_COLLATERAL", `Nothing to withdraw for ${assetInfo.displayName} on NAVI`);
521
+ const rawAmount = Number(stableToRaw(effectiveAmount, assetInfo.decimals));
522
+ addOracleUpdatesForPositions(tx, config, pools, allStates, pool);
523
+ const [balance] = tx.moveCall({
524
+ target: `${config.package}::incentive_v3::withdraw_v2`,
525
+ arguments: [
526
+ tx.object(CLOCK),
527
+ tx.object(config.oracle.priceOracle),
528
+ tx.object(config.storage),
529
+ tx.object(pool.contract.pool),
530
+ tx.pure.u8(pool.id),
531
+ tx.pure.u64(rawAmount),
532
+ tx.object(config.incentiveV2),
533
+ tx.object(config.incentiveV3),
534
+ tx.object(SUI_SYSTEM_STATE)
535
+ ],
536
+ typeArguments: [pool.suiCoinType]
537
+ });
538
+ const [coin] = tx.moveCall({
539
+ target: "0x2::coin::from_balance",
540
+ arguments: [balance],
541
+ typeArguments: [pool.suiCoinType]
542
+ });
543
+ return { coin, effectiveAmount };
544
+ }
545
+ async function addSaveToTx(tx, _client, _address, coin, options = {}) {
546
+ const asset = options.asset ?? "USDC";
547
+ const [config, pool] = await Promise.all([getConfig(), getPool(asset)]);
548
+ if (options.collectFee) {
549
+ addCollectFeeToTx(tx, coin, "save");
550
+ }
551
+ const [coinValue] = tx.moveCall({
552
+ target: "0x2::coin::value",
553
+ typeArguments: [pool.suiCoinType],
554
+ arguments: [coin]
555
+ });
556
+ tx.moveCall({
557
+ target: `${config.package}::incentive_v3::entry_deposit`,
558
+ arguments: [
559
+ tx.object(CLOCK),
560
+ tx.object(config.storage),
561
+ tx.object(pool.contract.pool),
562
+ tx.pure.u8(pool.id),
563
+ coin,
564
+ coinValue,
565
+ tx.object(config.incentiveV2),
566
+ tx.object(config.incentiveV3)
567
+ ],
568
+ typeArguments: [pool.suiCoinType]
569
+ });
570
+ }
571
+ async function addRepayToTx(tx, client, _address, coin, options = {}) {
572
+ const asset = options.asset ?? "USDC";
573
+ const [config, pool] = await Promise.all([getConfig(), getPool(asset)]);
574
+ addOracleUpdate(tx, config, pool);
575
+ const [coinValue] = tx.moveCall({
576
+ target: "0x2::coin::value",
577
+ typeArguments: [pool.suiCoinType],
578
+ arguments: [coin]
579
+ });
580
+ tx.moveCall({
581
+ target: `${config.package}::incentive_v3::entry_repay`,
582
+ arguments: [
583
+ tx.object(CLOCK),
584
+ tx.object(config.oracle.priceOracle),
585
+ tx.object(config.storage),
586
+ tx.object(pool.contract.pool),
587
+ tx.pure.u8(pool.id),
588
+ coin,
589
+ coinValue,
590
+ tx.object(config.incentiveV2),
591
+ tx.object(config.incentiveV3)
592
+ ],
593
+ typeArguments: [pool.suiCoinType]
594
+ });
595
+ }
506
596
  async function buildBorrowTx(client, address, amount, options = {}) {
507
597
  if (!amount || amount <= 0 || !Number.isFinite(amount)) {
508
598
  throw new T2000Error("INVALID_AMOUNT", "Borrow amount must be a positive number");
@@ -510,15 +600,15 @@ async function buildBorrowTx(client, address, amount, options = {}) {
510
600
  const asset = options.asset ?? "USDC";
511
601
  const assetInfo = SUPPORTED_ASSETS[asset];
512
602
  const rawAmount = Number(stableToRaw(amount, assetInfo.decimals));
513
- const [config, pool, pools, states] = await Promise.all([
603
+ const [config, pool, pools, allStates] = await Promise.all([
514
604
  getConfig(),
515
605
  getPool(asset),
516
606
  getPools(),
517
- getUserState(client, address)
607
+ getUserState(client, address, true)
518
608
  ]);
519
609
  const tx = new transactions.Transaction();
520
610
  tx.setSender(address);
521
- addOracleUpdatesForPositions(tx, config, pools, states, pool);
611
+ addOracleUpdatesForPositions(tx, config, pools, allStates, pool);
522
612
  const [balance] = tx.moveCall({
523
613
  target: `${config.package}::incentive_v3::borrow_v2`,
524
614
  arguments: [
@@ -794,6 +884,18 @@ var NaviAdapter = class {
794
884
  async maxBorrow(address, _asset) {
795
885
  return maxBorrowAmount(this.client, address);
796
886
  }
887
+ async addWithdrawToTx(tx, address, amount, asset) {
888
+ const stableAsset = normalizeAsset(asset);
889
+ return addWithdrawToTx(tx, this.client, address, amount, { asset: stableAsset });
890
+ }
891
+ async addSaveToTx(tx, address, coin, asset, options) {
892
+ const stableAsset = normalizeAsset(asset);
893
+ return addSaveToTx(tx, this.client, address, coin, { ...options, asset: stableAsset });
894
+ }
895
+ async addRepayToTx(tx, address, coin, asset) {
896
+ const stableAsset = normalizeAsset(asset);
897
+ return addRepayToTx(tx, this.client, address, coin, { asset: stableAsset });
898
+ }
797
899
  };
798
900
  var DEFAULT_SLIPPAGE_BPS = 300;
799
901
  function createAggregatorClient(client, signer) {
@@ -838,6 +940,41 @@ async function buildSwapTx(params) {
838
940
  toDecimals: toInfo.decimals
839
941
  };
840
942
  }
943
+ async function addSwapToTx(params) {
944
+ const { tx, client, address, inputCoin, fromAsset, toAsset, amount, maxSlippageBps = DEFAULT_SLIPPAGE_BPS } = params;
945
+ const fromInfo = SUPPORTED_ASSETS[fromAsset];
946
+ const toInfo = SUPPORTED_ASSETS[toAsset];
947
+ if (!fromInfo || !toInfo) {
948
+ throw new T2000Error("ASSET_NOT_SUPPORTED", `Swap pair ${fromAsset}/${toAsset} is not supported`);
949
+ }
950
+ const rawAmount = BigInt(Math.floor(amount * 10 ** fromInfo.decimals));
951
+ const aggClient = createAggregatorClient(client, address);
952
+ const result = await aggClient.findRouters({
953
+ from: fromInfo.type,
954
+ target: toInfo.type,
955
+ amount: rawAmount,
956
+ byAmountIn: true
957
+ });
958
+ if (!result || result.insufficientLiquidity) {
959
+ throw new T2000Error(
960
+ "ASSET_NOT_SUPPORTED",
961
+ `No swap route found for ${fromAsset} \u2192 ${toAsset}`
962
+ );
963
+ }
964
+ const slippage = maxSlippageBps / 1e4;
965
+ const outputCoin = await aggClient.routerSwap({
966
+ router: result,
967
+ txb: tx,
968
+ inputCoin,
969
+ slippage
970
+ });
971
+ const estimatedOut = Number(result.amountOut.toString());
972
+ return {
973
+ outputCoin,
974
+ estimatedOut,
975
+ toDecimals: toInfo.decimals
976
+ };
977
+ }
841
978
  async function getPoolPrice(client) {
842
979
  try {
843
980
  const pool = await client.getObject({
@@ -948,6 +1085,18 @@ var CetusAdapter = class {
948
1085
  async getPoolPrice() {
949
1086
  return getPoolPrice(this.client);
950
1087
  }
1088
+ async addSwapToTx(tx, address, inputCoin, from, to, amount, maxSlippageBps) {
1089
+ return addSwapToTx({
1090
+ tx,
1091
+ client: this.client,
1092
+ address,
1093
+ inputCoin,
1094
+ fromAsset: from,
1095
+ toAsset: to,
1096
+ amount,
1097
+ maxSlippageBps
1098
+ });
1099
+ }
951
1100
  };
952
1101
  var WAD = 1e18;
953
1102
  var MIN_HEALTH_FACTOR2 = 1.5;
@@ -1343,6 +1492,95 @@ var SuilendAdapter = class {
1343
1492
  tx.transferObjects([coin], address);
1344
1493
  return { tx, effectiveAmount };
1345
1494
  }
1495
+ async addWithdrawToTx(tx, address, amount, asset) {
1496
+ const assetKey = asset in SUPPORTED_ASSETS ? asset : "USDC";
1497
+ const assetInfo = SUPPORTED_ASSETS[assetKey];
1498
+ const [pkg, reserves] = await Promise.all([this.resolvePackage(), this.loadReserves(true)]);
1499
+ const reserve = this.findReserve(reserves, assetKey);
1500
+ if (!reserve) throw new T2000Error("ASSET_NOT_SUPPORTED", `${assetInfo.displayName} reserve not found on Suilend`);
1501
+ const caps = await this.fetchObligationCaps(address);
1502
+ if (caps.length === 0) throw new T2000Error("NO_COLLATERAL", "No Suilend position found");
1503
+ const positions = await this.getPositions(address);
1504
+ const deposited = positions.supplies.find((s) => s.asset === assetKey)?.amount ?? 0;
1505
+ const effectiveAmount = Math.min(amount, deposited);
1506
+ if (effectiveAmount <= 0) throw new T2000Error("NO_COLLATERAL", `Nothing to withdraw for ${assetInfo.displayName} on Suilend`);
1507
+ const ratio = cTokenRatio(reserve);
1508
+ const ctokenAmount = Math.ceil(effectiveAmount * 10 ** reserve.mintDecimals / ratio);
1509
+ const [ctokens] = tx.moveCall({
1510
+ target: `${pkg}::lending_market::withdraw_ctokens`,
1511
+ typeArguments: [LENDING_MARKET_TYPE, assetInfo.type],
1512
+ arguments: [
1513
+ tx.object(LENDING_MARKET_ID),
1514
+ tx.pure.u64(reserve.arrayIndex),
1515
+ tx.object(caps[0].id),
1516
+ tx.object(CLOCK2),
1517
+ tx.pure.u64(ctokenAmount)
1518
+ ]
1519
+ });
1520
+ const exemptionType = `${SUILEND_PACKAGE}::lending_market::RateLimiterExemption<${LENDING_MARKET_TYPE}, ${assetInfo.type}>`;
1521
+ const [none] = tx.moveCall({
1522
+ target: "0x1::option::none",
1523
+ typeArguments: [exemptionType]
1524
+ });
1525
+ const [coin] = tx.moveCall({
1526
+ target: `${pkg}::lending_market::redeem_ctokens_and_withdraw_liquidity`,
1527
+ typeArguments: [LENDING_MARKET_TYPE, assetInfo.type],
1528
+ arguments: [
1529
+ tx.object(LENDING_MARKET_ID),
1530
+ tx.pure.u64(reserve.arrayIndex),
1531
+ tx.object(CLOCK2),
1532
+ ctokens,
1533
+ none
1534
+ ]
1535
+ });
1536
+ return { coin, effectiveAmount };
1537
+ }
1538
+ async addSaveToTx(tx, address, coin, asset, options) {
1539
+ const assetKey = asset in SUPPORTED_ASSETS ? asset : "USDC";
1540
+ const assetInfo = SUPPORTED_ASSETS[assetKey];
1541
+ const [pkg, reserves] = await Promise.all([this.resolvePackage(), this.loadReserves()]);
1542
+ const reserve = this.findReserve(reserves, assetKey);
1543
+ if (!reserve) throw new T2000Error("ASSET_NOT_SUPPORTED", `${assetInfo.displayName} reserve not found on Suilend`);
1544
+ const caps = await this.fetchObligationCaps(address);
1545
+ let capRef;
1546
+ if (caps.length === 0) {
1547
+ const [newCap] = tx.moveCall({
1548
+ target: `${pkg}::lending_market::create_obligation`,
1549
+ typeArguments: [LENDING_MARKET_TYPE],
1550
+ arguments: [tx.object(LENDING_MARKET_ID)]
1551
+ });
1552
+ capRef = newCap;
1553
+ } else {
1554
+ capRef = caps[0].id;
1555
+ }
1556
+ if (options?.collectFee) {
1557
+ addCollectFeeToTx(tx, coin, "save");
1558
+ }
1559
+ const [ctokens] = tx.moveCall({
1560
+ target: `${pkg}::lending_market::deposit_liquidity_and_mint_ctokens`,
1561
+ typeArguments: [LENDING_MARKET_TYPE, assetInfo.type],
1562
+ arguments: [
1563
+ tx.object(LENDING_MARKET_ID),
1564
+ tx.pure.u64(reserve.arrayIndex),
1565
+ tx.object(CLOCK2),
1566
+ coin
1567
+ ]
1568
+ });
1569
+ tx.moveCall({
1570
+ target: `${pkg}::lending_market::deposit_ctokens_into_obligation`,
1571
+ typeArguments: [LENDING_MARKET_TYPE, assetInfo.type],
1572
+ arguments: [
1573
+ tx.object(LENDING_MARKET_ID),
1574
+ tx.pure.u64(reserve.arrayIndex),
1575
+ typeof capRef === "string" ? tx.object(capRef) : capRef,
1576
+ tx.object(CLOCK2),
1577
+ ctokens
1578
+ ]
1579
+ });
1580
+ if (typeof capRef !== "string") {
1581
+ tx.transferObjects([capRef], address);
1582
+ }
1583
+ }
1346
1584
  async buildBorrowTx(address, amount, asset, options) {
1347
1585
  const assetKey = asset in SUPPORTED_ASSETS ? asset : "USDC";
1348
1586
  const assetInfo = SUPPORTED_ASSETS[assetKey];
@@ -1402,6 +1640,26 @@ var SuilendAdapter = class {
1402
1640
  });
1403
1641
  return { tx };
1404
1642
  }
1643
+ async addRepayToTx(tx, address, coin, asset) {
1644
+ const assetKey = asset in SUPPORTED_ASSETS ? asset : "USDC";
1645
+ const assetInfo = SUPPORTED_ASSETS[assetKey];
1646
+ const [pkg, reserves] = await Promise.all([this.resolvePackage(), this.loadReserves()]);
1647
+ const reserve = this.findReserve(reserves, assetKey);
1648
+ if (!reserve) throw new T2000Error("ASSET_NOT_SUPPORTED", `${assetInfo.displayName} reserve not found on Suilend`);
1649
+ const caps = await this.fetchObligationCaps(address);
1650
+ if (caps.length === 0) throw new T2000Error("NO_COLLATERAL", "No Suilend obligation found");
1651
+ tx.moveCall({
1652
+ target: `${pkg}::lending_market::repay`,
1653
+ typeArguments: [LENDING_MARKET_TYPE, assetInfo.type],
1654
+ arguments: [
1655
+ tx.object(LENDING_MARKET_ID),
1656
+ tx.pure.u64(reserve.arrayIndex),
1657
+ tx.object(caps[0].id),
1658
+ tx.object(CLOCK2),
1659
+ coin
1660
+ ]
1661
+ });
1662
+ }
1405
1663
  async maxWithdraw(address, _asset) {
1406
1664
  const health = await this.getHealth(address);
1407
1665
  let maxAmount;