@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.
Files changed (4) hide show
  1. package/README.md +148 -21
  2. package/dist/index.d.mts +1229 -995
  3. package/dist/index.mjs +719 -311
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,57 +1,184 @@
1
1
  # fluid-sui-read-sdk
2
2
 
3
- Read-only TypeScript SDK for Fluid on Sui. Same shape as the Solana reader `@jup-ag/lend-read`: `client.liquidity`, `client.lending`, `client.vault`, `client.oracle`. Built on `@mysten/sui` v2.
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(); // staging deployment on Sui mainnet, pure on-chain state
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
- const price = await client.oracle.getOraclePrice(1); // { operate, liquidate, source: "v2" }
20
+ console.log("fUSDC totalAssets", fUsdc.totalAssets);
19
21
  ```
20
22
 
21
- Endpoint failover: `new Client(["https://fullnode.mainnet.sui.io:443", "jsonrpc+https://my-node.example:9000"])`.
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
- ## Positions per wallet
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
- The default client can't enumerate a wallet's positions (`getAllUserPositions` throws `NeedsDiscoveryError`). Enable fullnode discovery:
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: "fullnode" });
29
- const mine = await client.vault.getAllUserPositions("0x<wallet>");
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
- Discovery levels: `"state"` (default, pure object reads + view simulation), `"fullnode"` (adds fullnode indexes), `"indexer"` (adds GraphQL event scans, last resort). A read that needs more throws `NeedsDiscoveryError`; it never silently falls back.
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
- - Vault-side amounts: the vault's internal 1e9 space. Liquidity-side: token units. `Vault.unscale(x, decimals)` converts.
37
- - Oracle rates: debt-per-collateral × 1e15.
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
- ## Config
121
+ ## Differences from the Solana reader
40
122
 
41
- **The built-in ids are the STAGING deployment on Sui mainnet, not production** and will change at launch. `"mainnet"` / `MAINNET` are deprecated aliases of it.
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
- To use your own deployment, pass a config built from a deployment record (`packages.{library,liquidity,lending,oracle,vaults}.packageId`, plus `objects.registry` for all but `library`):
131
+ ## Position counts (`totalPositions`, `nextPositionId`)
44
132
 
45
- ```ts
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
- const cfg = configFromDeployment(rec, { network: "mainnet", oracleV2: STAGING.oracleV2 });
49
- const client = new Client(undefined, { network: cfg });
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).