@instadapp/fluid-sui-read-sdk
v0.3.0
Published
Read-only TypeScript SDK for Fluid on Sui, shaped like @jup-ag/lend-read
Downloads
738
Keywords
Readme
fluid-sui-read-sdk
A read-only TypeScript SDK for Fluid on Sui. It has the same shape and field names as the Solana reader @jup-ag/lend-read (jup-lend-read-sdk): client.liquidity, client.lending, client.vault, plus client.oracle. It's built on @mysten/sui v2 (the core API).
npm i @instadapp/fluid-sui-read-sdk @mysten/sui// index.mjs (ESM; runs with plain `node index.mjs`)
import { Client } from "@instadapp/fluid-sui-read-sdk";
const client = new Client(undefined, { network: "staging" }); // network is required: no default
console.log("vaults", await client.vault.getTotalVaults());
const vault = await client.vault.getVaultEntireData(1);
console.log("vault 1", vault.constantViews.supplyToken, "->", vault.constantViews.borrowToken);
const fUsdc = await client.lending.getFTokenDetails(
"0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC",
);
console.log("fUSDC totalAssets", fUsdc.totalAssets);import { Client, fluidRead } from "@instadapp/fluid-sui-read-sdk";
import { SuiGrpcClient } from "@mysten/sui/grpc";
const failover = new Client([ // endpoint failover (+ retry with backoff)
"https://fullnode.mainnet.sui.io:443", // gRPC
"jsonrpc+https://my-node.example:9000", // JSON-RPC (self-hosted)
], { network: "staging" });
const pos = await failover.vault.getPositionByVaultId(1, "<Position object id>");
// or as an extension of an existing Sui client
const sui = new SuiGrpcClient({ network: "mainnet", baseUrl: "https://fullnode.mainnet.sui.io:443" }).$extend(fluidRead({ network: "staging" }));
await sui.fluidRead.vault.getAllVaults();Get my positions (per wallet)
The default client reads pure on-chain state and can't enumerate a wallet's vault positions yet: getAllUserPositions(user) throws NeedsDiscoveryError, and totalPositions / nextPositionId come back as -1. Pass discovery: "fullnode":
import { Client } from "@instadapp/fluid-sui-read-sdk";
const client = new Client(undefined, { network: "staging", discovery: "fullnode" });
const mine = await client.vault.getAllUserPositions("0x<your wallet address>");
for (const p of mine) console.log(p.vault.vault, p.nftId, p.supply, p.borrow);This stops being needed once the on-chain PositionIndex ships (reads become pure state again).
Lending state for integrators (Merkl etc.)
const client = new Client(undefined, { network: "staging", discovery: "indexer" }); // indexer only for holders/history
// per fToken: identity, accrued + stored exchange prices, staleness, TVL, rates (bps), rewards schedule
const pools = await client.lending.getLendingStateData(); // all pools, one batched read
// wallet holders of Coin<FToken<T>>, cross-checked with getBalance; totalSupply is returned for comparison
const h = await client.lending.getFTokenHolders(pools[0].asset);
// the same state at a past checkpoint (GraphQL object versions, clock = checkpoint timestamp)
const past = await client.atCheckpoint(333_000_000);
const then = await past.lending.getLendingStateData();getLendingStateDatafields:fTokenType,asset,decimals,timestamp(chain time used),tokenExchangePrice(rewards included) andliquidityExchangePrice(rewards excluded) accrued totimestamp,storedTokenExchangePrice/storedLiquidityExchangePrice+lastUpdateS/liquidityLastUpdateS(staleness),totalShares,totalAssets(token units),supplyRate(organic),rewardsRate,totalRate(bps),rewards(start/end/yearly + queued next window).getFTokenHolderscounts only wallet-owned coins (AddressOwner/ConsensusAddressOwner). Coins owned by objects, shared coins, wrappedBalance<FToken<T>>and address balances are not attributed to a wallet, so the sum of wallet balances can be belowtotalSupply.atCheckpointserves object state only; Move views, oracle quotes and balances throwCheckpointReadError. How far back works depends on the GraphQL service's retention.- Polling: state changes per checkpoint, so poll one batched read at most once per checkpoint; public endpoints are rate-limited. Reads retry with exponential backoff (
ClientOptions.retry).
How it reads: on-chain state, no indexer by default
- Objects, not indexers. Every Fluid object id is derived offline from
derived_objectand dynamic-field ids, the Sui equivalent of Solana PDAs. Objects are fetched in batches of 50 and decoded with BCS layouts transcribed from the deployed packages (src/bcs.ts). - Maths in TS, bit-exact. These are ports of the Move code (
src/math/*), accrued to the current second:- exchange prices, revenue and limits;
- fToken accrual, including rewards, and previews;
- tick ratios;
- position settlement after liquidation.
- Simulation only where needed. The oracle price comes from simulating the oracle views
view::get_exchange_rate_operate/get_exchange_rate_liquidate(Oracle + RedStonePriceAdapter+ PythPriceInfoObject+ Clock). Nothing is sent and no gas is spent, and the result is exactly whatoperate/liquidatewould consume.
Oracle (RedStone main, Pyth guard)
client.oracle.getOraclePrice(id)→{ operate, liquidate }, 1e15 debt-per-collateral.- operate = min(main, guard); it aborts on-chain if either route is older than 600s or invalid. The SDK then returns
operate: nullplusoperateError; it never substitutes a price. - liquidate = min of the routes still usable at 7200s;
nullonly when both are dead.
- operate = min(main, guard); it aborts on-chain if either route is older than 600s or invalid. The SDK then returns
client.oracle.getOracleRoutes(id)reads each route on its own: RedStone viaprice_adapter::price_and_timestamp, Pyth by decoding thePriceInfoObject. Each route returns its raw price, 1e15 rate, age at the chain clock, and whether it passes the operate / liquidate checks (mirrors*_core::reading_error). This shows which route sets the price and whether the guard is live.client.oracle.getOracle(id)→{ nonce, main, guard };derive.oracleId(nonce)gives the id offline.Both providers are pull oracles: freshness depends on someone pushing updates. The SDK reports staleness; it doesn't push.
Discovery levels (
new Client(rpc, { discovery })):"state"(default): object reads by derived id, plus view simulation. No list or scan queries, and no GraphQL."fullnode": also the fullnode's own indexes (listDynamicFields,listOwnedObjects). These use the same endpoints and failover."indexer": also GraphQL event scans. Used only as a last resort.
A read that state alone can't answer throws
NeedsDiscoveryErrornaming the on-chain fix. It never quietly falls back to an indexer.Failover. If an endpoint throws (network error, timeout, 5xx), the read moves on to the next one. The last healthy endpoint is tried first next time.
What is pure state today, and the one contract change still needed, is covered in docs/onchain-position-index.md.
| Read | Default (state) |
|---|---|
| Vaults, ticks, branches, oracle, exchange prices, fToken data | pure |
| Token list | pure: config.tokens plus every vault's collateral and debt types (vault ids 1..next_vault_id-1). discoverTokens() reports untrackedCaps, from Registry.active_caps.size. |
| Liquidity user positions | pure for every vault and lending holder. getAllUserPositionsDetailed().untracked, from Table.size, counts holders outside Fluid's own markets. |
| Vault position counts / ids | needs the on-chain PositionIndex. Until then use discovery: "fullnode". That reads the derived_object::Claimed fields, one per position ever opened, with no event indexer involved. |
| Positions owned by a user | pure once the index ships. getUserPositionsByCapIds is always pure. |
Units
Same convention as Solana:
- Vault-side amounts (position supply and borrow, vault totals) are in the vault's internal 1e9 space.
- Liquidity-side amounts are in token units.
Vault.unscale(x, decimals)converts between the two.- Oracle rates are debt-per-collateral × 1e15 (
Oracle.toNumber). - Every rate field is bps (1e4 = 100% per year), floored: liquidity
borrowRate/supplyRate, vault rates, and lendingsupplyRate(organic),rewardsRate,totalRate(=supplyRate + rewardsRate),maxRate. On chain the two rates use different scales (supply: 1e12 = 100%; rewards: 1e14 = 100%), which is why the SDK converts both; unfloored raw values:liquidityMath.supplyRateAt(1e12) andlendingMath.currentRewardsRate(1e14). supplyRateis the contract's own formula (reserve::calculate_exchange_prices: interest-free ratios, both exchange prices accrued to now, fee), not an approximation.- Time comes from the on-chain
Clock(0x6) by default, never the server clock (ClientOptions.now). config.mathVariantpicks the on-chain maths:"pre-audit"for the 2026-09-18 staging liquidity/lending packages (checked against deployed bytecode),"audited"(default) for fluid-packages-sui 742ea21 and later.
Differences from the Solana reader
| Solana | Sui |
|---|---|
| nftId: number | nftId / positionId: Position object id, plus capId (PositionOwnerCap) |
| PublicKey / BN | string (object id or coin type) / bigint |
| jlToken | fToken (Coin<FToken<T>>): getFToken*, FTokenDetails, fTokenShares, fTokenDetails. getJlToken* / JlToken* remain only as @deprecated aliases |
| totalPositions, nextPositionId | stored on-chain once PositionIndex ships (source: "state"); until then discovery: "fullnode" (source: "fullnode-index") |
| DEX / smart col-debt | not on Sui yet: null / false |
Position counts (totalPositions, nextPositionId)
The deployed VaultMarket has no counter. A Position is a derived object keyed by its random PositionOwnerCap id. vault.getVaultPositionCounts(id) resolves the counts in this order:
state: thePositionIndexdynamic field on the vault, from the proposed Move module in docs/move/. It holds the storednext_position_idand aTable<u64, cap id>, read entry by entry via derived ids. The SDK uses it automatically once it exists.fullnode:0x2::derived_object::Claimed(position_id)fields on the vault. Claims are never removed, so their count equals positions ever opened, which matches EVMtotalPositions.indexer:LogPositionOpened/LogClosePositionevents. This is incremental, deduped and retried, but opt-in only.
In every tier, openPositions counts only ids whose Position object still exists, because close deletes the object. getVaultState / getVaultEntireData report -1 when the active level can't resolve counts, and the rest of the vault read still succeeds.
Tests
npm test # offline: derivations, maths, on-chain index reader (BCS fixtures), failover
npm run test:live # mainnet, read-only
npm run example # prints every resolver against mainnettest/live/pure-state.test.tsruns with the GraphQL host blocked at fetch level and records every RPC method. It requires the default path to use onlygetObjects,simulateTransactionandgetCoinMetadata. It also checks that:- state and fullnode token lists match;
- liquidity positions plus
untrackedequal the full table; - vault 1 positions via claims (09-30 market, and the 09-18 market checked against an independent
LogPositionOpenedcount, never a fixed number); - a dead first endpoint fails over.
test/live/parity.test.tsrequires exact equality between the SDK and the deployed Move code at the same clock second, using one simulated PTB:reserve::get_exchange_pricesanduser::get_exchange_prices;- fUSDC
preview_*andtotal_assets; - oracle:
getOraclePrice= an independentview::get_both_exchange_ratePTB = min over the two routes read separately; the two providers within 2%;
test/live/reconcile.test.ts:getLendingStateDataagainst one simulated PTB per fToken (reserve::get_exchange_prices,user::update_rate,accounting::total_assets, stored prices, shares, rewards schedule) at the same clock second, with object versions checked unchanged before and after.test/live/holders.test.ts: holder sum <=totalSupplyat the same checkpoint, and every wallet =getBalance(no address balance).test/unit/rate-goldens.test.ts/rewards-tvl.test.ts: hand-derived supply and rewards rate goldens (zero borrow, interest-free supply/borrow, window boundaries, TVL belowstart_tvl, 50% cap), pre-accrual TVL pricing per math variant.test/live/positions.test.tscross-checks the claim set against an independent raw event count, on the current market and on the 09-18 market (which still holds 9 positions, so position maths stays covered while the new market is empty).
Config
There is no default network: every client passes network explicitly.
network: "mainnet"=PROD(aliasMAINNET): the production deployment (2026-11-02), same ids as the write SDK and fluid-packages-suideployments/mainnet.json. Day one: fUSDC and vault 1 =VaultMarket<hBTC, USDC>0x1aa7…a624on oracle nonce 10x6f74…3784(RedStone BTC main + Pyth BTC/USD guard).network: "staging"=STAGING: the staging-on-mainnet deployment (staging gist, 2026-09-30): library / liquidity / lending from 2026-09-18, oracle0xc1e7…4814and vaults0x08a3…6f20from 2026-09-30. Vaults 1 SUI/USDC + 2 SUI/USDSUI, fUSDC + fUSDSUI.npx tsx scripts/smoke-staging.tschecks it with zero config.
oracle holds the RedStone adapter and the Pyth PriceInfoObject per feed id.
- Don't depend on the built-in ids: build the config from a deployment record (fluid-packages-sui
deployments/<network>.json, the write SDK's source) and pass it in:
The record has no RedStone package or Pyth feed map, soimport { readFileSync } from "node:fs"; import { Client, configFromDeployment, PROD } from "@instadapp/fluid-sui-read-sdk"; const rec = JSON.parse(readFileSync("deployments/mainnet.json", "utf8")); const cfg = configFromDeployment(rec, { network: "mainnet", oracle: PROD.oracle }); const client = new Client(undefined, { network: cfg });oracleis passed in. - Pass
new Client(rpc, { network: customConfig })for testnet or after an upgrade. packageIdis the latest version (the call target).originalIdis the first version (used in type tags and keys).- After the position-index upgrade, set
positionIndexKeyTypeto the upgraded package'sposition_index::PositionIndexKey. - A new Pyth feed needs its
PriceInfoObjectadded tooracle.pythPriceInfoObjects(the Oracle object stores the feed id, not the object id).
