@instadapp/fluid-sui-read-sdk 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -21
- package/dist/index.d.mts +1229 -995
- package/dist/index.mjs +719 -311
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,57 +1,184 @@
|
|
|
1
1
|
# fluid-sui-read-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
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).
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm i @instadapp/fluid-sui-read-sdk @mysten/sui
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
+
// index.mjs (ESM; runs with plain `node index.mjs`)
|
|
10
11
|
import { Client } from "@instadapp/fluid-sui-read-sdk";
|
|
11
12
|
|
|
12
|
-
const client = new Client(
|
|
13
|
-
console.log(await client.vault.getTotalVaults());
|
|
13
|
+
const client = new Client(undefined, { network: "staging" }); // network is required: no default
|
|
14
|
+
console.log("vaults", await client.vault.getTotalVaults());
|
|
14
15
|
const vault = await client.vault.getVaultEntireData(1);
|
|
16
|
+
console.log("vault 1", vault.constantViews.supplyToken, "->", vault.constantViews.borrowToken);
|
|
15
17
|
const fUsdc = await client.lending.getFTokenDetails(
|
|
16
18
|
"0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC",
|
|
17
19
|
);
|
|
18
|
-
|
|
20
|
+
console.log("fUSDC totalAssets", fUsdc.totalAssets);
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
```ts
|
|
24
|
+
import { Client, fluidRead } from "@instadapp/fluid-sui-read-sdk";
|
|
25
|
+
import { SuiGrpcClient } from "@mysten/sui/grpc";
|
|
26
|
+
|
|
27
|
+
const failover = new Client([ // endpoint failover (+ retry with backoff)
|
|
28
|
+
"https://fullnode.mainnet.sui.io:443", // gRPC
|
|
29
|
+
"jsonrpc+https://my-node.example:9000", // JSON-RPC (self-hosted)
|
|
30
|
+
], { network: "staging" });
|
|
31
|
+
const pos = await failover.vault.getPositionByVaultId(1, "<Position object id>");
|
|
32
|
+
|
|
33
|
+
// or as an extension of an existing Sui client
|
|
34
|
+
const sui = new SuiGrpcClient({ network: "mainnet", baseUrl: "https://fullnode.mainnet.sui.io:443" }).$extend(fluidRead({ network: "staging" }));
|
|
35
|
+
await sui.fluidRead.vault.getAllVaults();
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Get my positions (per wallet)
|
|
39
|
+
|
|
40
|
+
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"`:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { Client } from "@instadapp/fluid-sui-read-sdk";
|
|
22
44
|
|
|
23
|
-
|
|
45
|
+
const client = new Client(undefined, { network: "staging", discovery: "fullnode" });
|
|
46
|
+
const mine = await client.vault.getAllUserPositions("0x<your wallet address>");
|
|
47
|
+
for (const p of mine) console.log(p.vault.vault, p.nftId, p.supply, p.borrow);
|
|
48
|
+
```
|
|
24
49
|
|
|
25
|
-
|
|
50
|
+
This stops being needed once the on-chain `PositionIndex` ships (reads become pure state again).
|
|
51
|
+
|
|
52
|
+
### Lending state for integrators (Merkl etc.)
|
|
26
53
|
|
|
27
54
|
```ts
|
|
28
|
-
const client = new Client(undefined, { discovery: "
|
|
29
|
-
|
|
55
|
+
const client = new Client(undefined, { network: "staging", discovery: "indexer" }); // indexer only for holders/history
|
|
56
|
+
|
|
57
|
+
// per fToken: identity, accrued + stored exchange prices, staleness, TVL, rates (bps), rewards schedule
|
|
58
|
+
const pools = await client.lending.getLendingStateData(); // all pools, one batched read
|
|
59
|
+
|
|
60
|
+
// wallet holders of Coin<FToken<T>>, cross-checked with getBalance; totalSupply is returned for comparison
|
|
61
|
+
const h = await client.lending.getFTokenHolders(pools[0].asset);
|
|
62
|
+
|
|
63
|
+
// the same state at a past checkpoint (GraphQL object versions, clock = checkpoint timestamp)
|
|
64
|
+
const past = await client.atCheckpoint(333_000_000);
|
|
65
|
+
const then = await past.lending.getLendingStateData();
|
|
30
66
|
```
|
|
31
67
|
|
|
32
|
-
|
|
68
|
+
- `getLendingStateData` fields: `fTokenType`, `asset`, `decimals`, `timestamp` (chain time used), `tokenExchangePrice` (rewards included) and `liquidityExchangePrice` (rewards excluded) accrued to `timestamp`, `storedTokenExchangePrice` / `storedLiquidityExchangePrice` + `lastUpdateS` / `liquidityLastUpdateS` (staleness), `totalShares`, `totalAssets` (token units), `supplyRate` (organic), `rewardsRate`, `totalRate` (bps), `rewards` (start/end/yearly + queued next window).
|
|
69
|
+
- `getFTokenHolders` counts only wallet-owned coins (`AddressOwner` / `ConsensusAddressOwner`). Coins owned by objects, shared coins, wrapped `Balance<FToken<T>>` and address balances are not attributed to a wallet, so the sum of wallet balances can be below `totalSupply`.
|
|
70
|
+
- `atCheckpoint` serves object state only; Move views, oracle quotes and balances throw `CheckpointReadError`. How far back works depends on the GraphQL service's retention.
|
|
71
|
+
- 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`).
|
|
72
|
+
|
|
73
|
+
## How it reads: on-chain state, no indexer by default
|
|
74
|
+
|
|
75
|
+
- **Objects, not indexers.** Every Fluid object id is derived offline from `derived_object` and 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`).
|
|
76
|
+
- **Maths in TS, bit-exact.** These are ports of the Move code (`src/math/*`), accrued to the current second:
|
|
77
|
+
- exchange prices, revenue and limits;
|
|
78
|
+
- fToken accrual, including rewards, and previews;
|
|
79
|
+
- tick ratios;
|
|
80
|
+
- position settlement after liquidation.
|
|
81
|
+
- **Simulation only where needed.** The oracle price comes from simulating the oracle views `view::get_exchange_rate_operate` / `get_exchange_rate_liquidate` (Oracle + RedStone `PriceAdapter` + Pyth `PriceInfoObject` + Clock). Nothing is sent and no gas is spent, and the result is exactly what `operate` / `liquidate` would consume.
|
|
82
|
+
|
|
83
|
+
## Oracle (RedStone main, Pyth guard)
|
|
84
|
+
|
|
85
|
+
- `client.oracle.getOraclePrice(id)` → `{ operate, liquidate }`, 1e15 debt-per-collateral.
|
|
86
|
+
- operate = min(main, guard); it aborts on-chain if either route is older than 600s or invalid. The SDK then returns `operate: null` plus `operateError`; it never substitutes a price.
|
|
87
|
+
- liquidate = min of the routes still usable at 7200s; `null` only when both are dead.
|
|
88
|
+
- `client.oracle.getOracleRoutes(id)` reads each route on its own: RedStone via `price_adapter::price_and_timestamp`, Pyth by decoding the `PriceInfoObject`. 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.
|
|
89
|
+
- `client.oracle.getOracle(id)` → `{ nonce, main, guard }`; `derive.oracleId(nonce)` gives the id offline.
|
|
90
|
+
- Both providers are pull oracles: freshness depends on someone pushing updates. The SDK reports staleness; it doesn't push.
|
|
91
|
+
- **Discovery levels** (`new Client(rpc, { discovery })`):
|
|
92
|
+
- `"state"` (default): object reads by derived id, plus view simulation. No list or scan queries, and no GraphQL.
|
|
93
|
+
- `"fullnode"`: also the fullnode's own indexes (`listDynamicFields`, `listOwnedObjects`). These use the same endpoints and failover.
|
|
94
|
+
- `"indexer"`: also GraphQL event scans. Used only as a last resort.
|
|
95
|
+
|
|
96
|
+
A read that state alone can't answer throws `NeedsDiscoveryError` naming the on-chain fix. It never quietly falls back to an indexer.
|
|
97
|
+
- **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.
|
|
98
|
+
|
|
99
|
+
What is pure state today, and the one contract change still needed, is covered in **[docs/onchain-position-index.md](docs/onchain-position-index.md)**.
|
|
100
|
+
|
|
101
|
+
| Read | Default (`state`) |
|
|
102
|
+
|---|---|
|
|
103
|
+
| Vaults, ticks, branches, oracle, exchange prices, fToken data | pure |
|
|
104
|
+
| 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`. |
|
|
105
|
+
| Liquidity user positions | pure for every vault and lending holder. `getAllUserPositionsDetailed().untracked`, from `Table.size`, counts holders outside Fluid's own markets. |
|
|
106
|
+
| 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. |
|
|
107
|
+
| Positions owned by a user | pure once the index ships. `getUserPositionsByCapIds` is always pure. |
|
|
33
108
|
|
|
34
109
|
## Units
|
|
35
110
|
|
|
36
|
-
|
|
37
|
-
-
|
|
111
|
+
Same convention as Solana:
|
|
112
|
+
- Vault-side amounts (position supply and borrow, vault totals) are in the vault's internal 1e9 space.
|
|
113
|
+
- Liquidity-side amounts are in token units.
|
|
114
|
+
- `Vault.unscale(x, decimals)` converts between the two.
|
|
115
|
+
- Oracle rates are debt-per-collateral × 1e15 (`Oracle.toNumber`).
|
|
116
|
+
- **Every rate field is bps (1e4 = 100% per year), floored**: liquidity `borrowRate` / `supplyRate`, vault rates, and lending `supplyRate` (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) and `lendingMath.currentRewardsRate` (1e14).
|
|
117
|
+
- `supplyRate` is the contract's own formula (`reserve::calculate_exchange_prices`: interest-free ratios, both exchange prices accrued to now, fee), not an approximation.
|
|
118
|
+
- Time comes from the on-chain `Clock` (0x6) by default, never the server clock (`ClientOptions.now`).
|
|
119
|
+
- `config.mathVariant` picks 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.
|
|
38
120
|
|
|
39
|
-
##
|
|
121
|
+
## Differences from the Solana reader
|
|
40
122
|
|
|
41
|
-
|
|
123
|
+
| Solana | Sui |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `nftId: number` | `nftId` / `positionId`: Position object id, plus `capId` (PositionOwnerCap) |
|
|
126
|
+
| `PublicKey` / `BN` | `string` (object id or coin type) / `bigint` |
|
|
127
|
+
| jlToken | **fToken** (`Coin<FToken<T>>`): `getFToken*`, `FTokenDetails`, `fTokenShares`, `fTokenDetails`. `getJlToken*` / `JlToken*` remain only as `@deprecated` aliases |
|
|
128
|
+
| `totalPositions`, `nextPositionId` | stored on-chain once `PositionIndex` ships (`source: "state"`); until then `discovery: "fullnode"` (`source: "fullnode-index"`) |
|
|
129
|
+
| DEX / smart col-debt | not on Sui yet: `null` / `false` |
|
|
42
130
|
|
|
43
|
-
|
|
131
|
+
## Position counts (`totalPositions`, `nextPositionId`)
|
|
44
132
|
|
|
45
|
-
|
|
46
|
-
import { Client, configFromDeployment, STAGING } from "@instadapp/fluid-sui-read-sdk";
|
|
133
|
+
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:
|
|
47
134
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
135
|
+
1. **`state`:** the `PositionIndex` dynamic field on the vault, from the proposed Move module in [docs/move/](docs/move/). It holds the stored `next_position_id` and a `Table<u64, cap id>`, read entry by entry via derived ids. The SDK uses it automatically once it exists.
|
|
136
|
+
2. **`fullnode`:** `0x2::derived_object::Claimed(position_id)` fields on the vault. Claims are never removed, so their count equals positions ever opened, which matches EVM `totalPositions`.
|
|
137
|
+
3. **`indexer`:** `LogPositionOpened` / `LogClosePosition` events. This is incremental, deduped and retried, but opt-in only.
|
|
138
|
+
|
|
139
|
+
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.
|
|
51
140
|
|
|
52
141
|
## Tests
|
|
53
142
|
|
|
54
143
|
```
|
|
55
|
-
npm test # offline
|
|
144
|
+
npm test # offline: derivations, maths, on-chain index reader (BCS fixtures), failover
|
|
56
145
|
npm run test:live # mainnet, read-only
|
|
146
|
+
npm run example # prints every resolver against mainnet
|
|
57
147
|
```
|
|
148
|
+
|
|
149
|
+
- **`test/live/pure-state.test.ts`** runs with the GraphQL host blocked at fetch level and records every RPC method. It requires the default path to use only `getObjects`, `simulateTransaction` and `getCoinMetadata`. It also checks that:
|
|
150
|
+
- state and fullnode token lists match;
|
|
151
|
+
- liquidity positions plus `untracked` equal the full table;
|
|
152
|
+
- vault 1 positions via claims (09-30 market, and the 09-18 market checked against an independent `LogPositionOpened` count, never a fixed number);
|
|
153
|
+
- a dead first endpoint fails over.
|
|
154
|
+
- **`test/live/parity.test.ts`** requires exact equality between the SDK and the deployed Move code at the same clock second, using one simulated PTB:
|
|
155
|
+
- `reserve::get_exchange_prices` and `user::get_exchange_prices`;
|
|
156
|
+
- fUSDC `preview_*` and `total_assets`;
|
|
157
|
+
- oracle: `getOraclePrice` = an independent `view::get_both_exchange_rate` PTB = min over the two routes read separately; the two providers within 2%;
|
|
158
|
+
- **`test/live/reconcile.test.ts`**: `getLendingStateData` against 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.
|
|
159
|
+
- **`test/live/holders.test.ts`**: holder sum <= `totalSupply` at the same checkpoint, and every wallet = `getBalance` (no address balance).
|
|
160
|
+
- **`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 below `start_tvl`, 50% cap), pre-accrual TVL pricing per math variant.
|
|
161
|
+
- **`test/live/positions.test.ts`** cross-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).
|
|
162
|
+
|
|
163
|
+
## Config
|
|
164
|
+
|
|
165
|
+
**There is no default network**: every client passes `network` explicitly.
|
|
166
|
+
|
|
167
|
+
- `network: "mainnet"` = `PROD` (alias `MAINNET`): the production deployment (2026-11-02), same ids as the write SDK and fluid-packages-sui `deployments/mainnet.json`. Day one: fUSDC and vault 1 = `VaultMarket<hBTC, USDC>` `0x1aa7…a624` on oracle nonce 1 `0x6f74…3784` (RedStone BTC main + Pyth BTC/USD guard).
|
|
168
|
+
- `network: "staging"` = `STAGING`: the staging-on-mainnet deployment (staging gist, 2026-09-30): library / liquidity / lending from 2026-09-18, oracle `0xc1e7…4814` and vaults `0x08a3…6f20` from 2026-09-30. Vaults 1 SUI/USDC + 2 SUI/USDSUI, fUSDC + fUSDSUI. `npx tsx scripts/smoke-staging.ts` checks it with zero config.
|
|
169
|
+
|
|
170
|
+
`oracle` holds the RedStone adapter and the Pyth `PriceInfoObject` per feed id.
|
|
171
|
+
|
|
172
|
+
- **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:
|
|
173
|
+
```ts
|
|
174
|
+
import { readFileSync } from "node:fs";
|
|
175
|
+
import { Client, configFromDeployment, PROD } from "@instadapp/fluid-sui-read-sdk";
|
|
176
|
+
const rec = JSON.parse(readFileSync("deployments/mainnet.json", "utf8"));
|
|
177
|
+
const cfg = configFromDeployment(rec, { network: "mainnet", oracle: PROD.oracle });
|
|
178
|
+
const client = new Client(undefined, { network: cfg });
|
|
179
|
+
```
|
|
180
|
+
The record has no RedStone package or Pyth feed map, so `oracle` is passed in.
|
|
181
|
+
- Pass `new Client(rpc, { network: customConfig })` for testnet or after an upgrade.
|
|
182
|
+
- `packageId` is the latest version (the call target). `originalId` is the first version (used in type tags and keys).
|
|
183
|
+
- After the position-index upgrade, set `positionIndexKeyType` to the upgraded package's `position_index::PositionIndexKey`.
|
|
184
|
+
- A new Pyth feed needs its `PriceInfoObject` added to `oracle.pythPriceInfoObjects` (the Oracle object stores the feed id, not the object id).
|