@trinaryex/sdk 1.0.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/DESIGN.md ADDED
@@ -0,0 +1,577 @@
1
+ # `@trinaryex/sdk` — Trinary Exchange Trading SDK
2
+
3
+ **Status:** COMPLETE (2026-08-22) — all phases plus a full live-testnet trading lifecycle:
4
+ real deposits/withdrawals, resting bids picked up by the indexer, modify + cancel + refund
5
+ recovery, a REAL market-buy fill (14,999 CRED + exactly 2% fee), taker trade visible in
6
+ `/trades`, item held in the BM, settled claims, and a BM-funded limit sell — 12/12
7
+ lifecycle + 11/11 integration + 91 unit tests. Schemas verified against the published
8
+ swagger. Remaining: npm publish (CI release workflow ready; needs NPM_TOKEN + push).
9
+ **Package:** `@trinaryex/sdk` (repo: `sdk/`) — an umbrella SDK; the trading surface is the
10
+ first module, with room to grow into a higher-level, full-featured client.
11
+ **Audience:** players and bots trading on Trinary Exchange via an API key.
12
+ **Target (MVP):** **testnet only**, the **`stillness`** tenant; **only the most-recent (v1)
13
+ `triexbook` contracts** are supported. Item↔currency (CRED) markets via `multicoin_pool`
14
+ ("pools"); coin-pools are out of scope for now.
15
+ **Models after:** [`@trinaryex/keyspace`](https://www.npmjs.com/package/@trinaryex/keyspace)
16
+ (client + `executor` pattern, `queries`/`transactions` split, zod validation, vite build,
17
+ semantic-release).
18
+
19
+ ---
20
+
21
+ ## 1. What this SDK is (and isn't)
22
+
23
+ Trinary Exchange is a Sui-based, DeepBook-style market for an EVE-Frontier game economy.
24
+ Players trade **items** (multicoin balances, priced in the **CRED** trade currency) and
25
+ **currency** at **trade hubs** (Smart Storage Units / SSUs) through on-chain order books
26
+ (the `triexbook` DeepBook fork), settling through a per-player **balance manager** (the
27
+ on-chain trading account).
28
+
29
+ The SDK gives a player/bot one object to:
30
+
31
+ - **Read** market + account state through `api.trinary.exchange` (the `etl-api` indexer),
32
+ authenticated with an **API key** (`x-api-key`).
33
+ - **Write** (mutate) by building Sui **programmable transaction blocks (PTBs)** that the
34
+ caller's **wallet or keypair signs** — exactly the keyspace `executor` pattern.
35
+
36
+ > **The two planes have two different auth models.** The API key authorizes *reads only*.
37
+ > Every state change (create account, deposit, withdraw, place order) is an **on-chain
38
+ > transaction authorized by the player's Sui signature** — the API key never moves funds.
39
+ > This is the single most important thing to communicate to SDK users.
40
+
41
+ ### Non-goals (for MVP)
42
+
43
+ - No tribe/DAO governance-wrapped trading (the `armature_trading` `board_voting` path in
44
+ triex-app-api). MVP is **personal** balance-manager trading only.
45
+ - ~~No cancel/modify order, no claim-settled conveniences~~ — **revised 2026-08-21**:
46
+ cancel/cancelAll/modify, `sweepable`, and `claimSettled` were pulled into the MVP (a
47
+ trading bot is not viable without them). Full multi-hub sweep-all and price-history
48
+ endpoints remain out.
49
+ - No custody. The SDK never holds keys; signing is delegated to an `executor`.
50
+ - No websocket/relay live order-book streaming (the app's `TradeContext` relay
51
+ subscriptions). MVP is request/response against the indexer.
52
+
53
+ ---
54
+
55
+ ## 2. MVP scope → endpoint / transaction map
56
+
57
+ Each user story below is tagged **READ** (indexer or fullnode) or **WRITE** (on-chain PTB).
58
+ The "Gateway" column is the **current** state in
59
+ `dynamic-config-registry/gateway-routes/etl-api.json` (as of 2026-08-21).
60
+
61
+ | # | User story | Plane | Indexer endpoint / Move entrypoint | Gateway today |
62
+ |---|---|---|---|---|
63
+ | 1 | Create trading account **iff** none exists (balance manager) | READ+WRITE | READ on-chain `listOwnedObjects` (authoritative; `GET /v1/inventory/balance-manager` stays disabled) → WRITE `balance_manager::new()` (+ `transferObjects` to self) | n/a (on-chain read) |
64
+ | 2 | Fetch item balances | READ | `GET /v1/inventory/balances` — **hub-scoped** (`storage_unit_id` required): warehouse (wallet receipts, by `owner_address`), marketplace (BM, by `balance_manager_id`), hangar (by `inventory_key` owner-cap id) | **enabled** (50 CU) |
65
+ | 3 | Fetch trade-currency (CRED) balance | READ | **fullnode** — wallet coins (`listCoins`) + BM `BalanceKey<CRED>` dynamic field; the indexer inventory endpoint serves *items only* | n/a (fullnode) |
66
+ | 4 | Deposit items from hangar → trading account | READ+WRITE | READ `GET /v1/hubs/{hub_id}/vault` (vaultConfigId + collectionId) + on-chain owner-cap/char resolution → WRITE the **direct-from-hangar sequence** (borrow_owner_cap → receipt::deposit_for_receipt → return_owner_cap → balance_manager::deposit_multicoin); see §6.1 | **enabled** (vault) |
67
+ | 5 | Deposit currency → trading account | WRITE | `balance_manager::deposit<CRED>` (wallet coin selected/merged/split) | n/a (on-chain) |
68
+ | 6 | Discover items with live buy/sell orders across the universe | READ | `GET /v1/discovery` — **open orders sorted by recency**, with filters (hubs/item/side/bm/public) | **enabled** (150 CU) |
69
+ | 7 | Fetch trade-hub details (public/private, owner, location) | READ | `GET /v1/hubs/{hub_id}/vault` + `GET /v1/hubs/{hub_id}/location` (+ `GET /v1/collections/{collection_id}/hub` reverse lookup). `/location` **404s semantically** for unrevealed hubs (most of them) → `TradeHubDetail.location` is nullable. Tribe/fuel are NOT in these responses (post-MVP: hubs economics/enriched routes) | **enabled** |
70
+ | 8 | Fetch items with buy/sell orders at a specific storage unit | READ | `GET /v1/hubs/{hub_id}/items` (`has_bids`/`has_asks` flags) | **enabled** |
71
+ | 9 | Fetch order book for one item at that storage unit | READ | `GET /v1/hubs/{hub_id}/vault` → `GET /v1/pools/resolve` (**`collection_id`+`asset_id`**, not hub+item) → `GET /v1/pools/{pool_id}/orderbook` (returns resting **orders**, not levels) | **enabled** |
72
+ | 10 | Create **limit** buy/sell order | READ+WRITE | READ resolve chain + `GET /v1/pools/{pool_id}/metadata` (fee is 1e9-scaled `fee`, not bps) → WRITE `multicoin_pool::place_limit_order<Quote>` + deposit deficit | **enabled** (reads) |
73
+ | 11 | Create **market** buy/sell order | READ+WRITE | as #10 but `multicoin_pool::place_market_order<Quote>` (no price/expiry) | **enabled** (reads) |
74
+ | 14 | Read own open orders / fills / trades (bots) | READ | `GET /v1/balance-managers/{bm}/open-orders`, `/fills`, `/trades` (epoch-ms `before`/`after` paging) | **enabled** |
75
+ | 12 | Withdraw items from BM → storage unit | WRITE | `balance_manager::withdraw_all_multicoin` → `receipt::redeem_receipt(...ssu, character...)` | n/a (on-chain) |
76
+ | 13 | Withdraw currency from BM → wallet | WRITE | `balance_manager::withdraw_all<CRED>` + `transferObjects` to self | n/a (on-chain) |
77
+
78
+ **Phase 0 status (2026-08-21): done.** The market surface (pools, hubs, collections,
79
+ balance-manager reads, discovery) was enabled in DCR `62acb22`;
80
+ `GET /v1/inventory/balances` followed in `feat/publish-inventory-balances` (already
81
+ published to the running gateway). Two inventory routes stay deliberately disabled under
82
+ the sluice trust model (D11 — the gateway attaches no caller identity):
83
+ `/v1/inventory/balance-manager` (SDK reads on-chain instead — authoritative, §12) and
84
+ `/v1/inventory/receipt-objects` (wallet-receipt discovery is PTB *input* resolution and
85
+ belongs on the fullnode). Note `/v1/discovery` was repriced 30 → 150 CU (2 rps sustained
86
+ on the free/standard tier).
87
+
88
+ > **"Indexer-only" scopes market/account *data* reads, not PTB input resolution.** Building
89
+ > the item deposit/withdraw PTBs (#4, #12) requires live object references — owner-cap
90
+ > `version`/`digest` for `tx.receivingRef`, character/SSU owner-cap object IDs — which are
91
+ > resolved from the **fullnode `suiClient`**, not the indexer. These are always head-current
92
+ > (no lag) and are not "reads" in the indexer sense. The BM-existence check (#1) is likewise
93
+ > best done on-chain (`listOwnedObjects`) to avoid the double-create race (§13).
94
+
95
+ ---
96
+
97
+ ## 3. Architecture
98
+
99
+ ```
100
+ ┌────────────────────────────────────────────┐
101
+ player / bot │ @trinaryex/sdk │
102
+ (owns Sui key) ──┤ │
103
+ │ TriexClient (high-level facade) │
104
+ │ ├── account (balance manager lifecycle) │
105
+ │ ├── balances (item + currency reads) │
106
+ │ ├── market (discovery, hubs, orderbook) │
107
+ │ └── orders (limit/market, deposit/wdrw) │
108
+ │ │
109
+ │ queries.ts ──READ──► api.trinary.exchange│───► etl-api (indexer)
110
+ │ (fetch + x-api-key + zod validate) │ (Postgres read models)
111
+ │ │
112
+ │ transactions.ts ──build PTB──► executor() │───► player's wallet / keypair
113
+ │ (pure @mysten/sui Transaction builders) │ └─► Sui network (triexbook)
114
+ └────────────────────────────────────────────┘
115
+ ```
116
+
117
+ - **Reads** are pure HTTP to the gateway; the SDK adds `x-api-key`, validates responses with
118
+ zod, and returns typed objects. No on-chain reads in MVP (per the indexer-only decision).
119
+ - **Writes** are built as `@mysten/sui` `Transaction` objects by pure functions in
120
+ `transactions.ts`, then handed to a caller-supplied `executor(tx)` that signs+executes.
121
+ The SDK reads whatever object IDs / amounts it needs from the **indexer** first (BM id,
122
+ pool id, vault collection id, deposit deficit), then assembles the PTB.
123
+
124
+ ### Why the executor pattern (copied from keyspace)
125
+
126
+ The SDK stays custody-free and wallet-agnostic. Two canonical executors:
127
+
128
+ ```ts
129
+ // Bot / server keypair (@mysten/sui v2 core client)
130
+ executor: (tx) =>
131
+ suiClient.signAndExecuteTransaction({
132
+ transaction: tx, signer: keypair,
133
+ include: { effects: true, objectTypes: true },
134
+ })
135
+
136
+ // Browser wallet (@mysten/dapp-kit) — pass its signAndExecuteTransaction through
137
+ executor: (tx) => signAndExecuteTransaction({ transaction: tx })
138
+ ```
139
+
140
+ Executor results are **normalized** (`execute.ts`): both the v2 core-client
141
+ `TransactionResult` and the legacy `{ digest, objectChanges }` shape work, and on-chain
142
+ aborts are rethrown as typed `TransactionFailed` errors with the triexbook abort code
143
+ translated (§11 of TRIEX_SYSTEM_DESIGN). Include `effects` + `objectTypes` (v2) or
144
+ `objectChanges` (legacy) so created objects — e.g. a freshly-created `BalanceManager` —
145
+ are captured for read-your-writes.
146
+
147
+ Optional: a `sponsor` hook to route execution through the gas-station (sponsored/gasless) so
148
+ bots don't need SUI for gas. Post-MVP; the executor signature leaves room (§11, §9 Later).
149
+
150
+ ---
151
+
152
+ ## 4. Package layout (mirrors keyspace)
153
+
154
+ ```
155
+ sdk/
156
+ ├── src/
157
+ │ ├── index.ts # public surface (re-exports)
158
+ │ ├── TriexClient.ts # main facade: account/balances/market/orders groups
159
+ │ ├── ReadOnlyClient.ts # indexer-only client (no executor / no signing)
160
+ │ ├── config.ts # network + package-id + tenant resolution, defaults
161
+ │ ├── queries.ts # indexer HTTP client (fetch + x-api-key + zod parse)
162
+ │ ├── onchain.ts # fullnode reads: balances, receipts, character/cap resolution
163
+ │ ├── funding.ts # deficit sourcing: coin merge/split, receipts → hangar
164
+ │ ├── execute.ts # executor-result normalization (v2 + legacy shapes)
165
+ │ ├── book.ts # order-book analytics (levels, mid, spread, vwap)
166
+ │ ├── paging.ts # auto-pagination generators (discovery, fills, trades)
167
+ │ ├── transactions.ts # pure PTB builders (balance_manager, pool, receipt)
168
+ │ ├── money.ts # price scaling, fee/deposit math, decimals helpers
169
+ │ ├── types.ts # config, executor, domain types
170
+ │ ├── schemas.ts # zod schemas for indexer responses (versioned)
171
+ │ └── errors.ts # TriexError enum + TriexClientError class
172
+ ├── test/ # vitest/jest unit tests (mock indexer + tx snapshot tests)
173
+ ├── package.json # ESM, exports map, peerDeps, semantic-release
174
+ ├── vite.config.ts # lib build (ES only) + vite-plugin-dts
175
+ ├── tsconfig*.json
176
+ ├── .releaserc.json # semantic-release (npm publish @trinaryex/sdk)
177
+ ├── DESIGN.md # this document
178
+ └── README.md # quick-start + API reference (write after MVP lands)
179
+ ```
180
+
181
+ **Toolchain, matched to keyspace:** TypeScript ESM, `vite` lib build (ES only) +
182
+ `vite-plugin-dts`, `vitest` (keyspace uses jest; prefer **vitest** to match DCR conventions),
183
+ `eslint`/`prettier`, `semantic-release` with conventional commits, published public to npm.
184
+ `@mysten/sui` and `zod` are **peerDependencies**; the SDK bundles nothing heavy.
185
+
186
+ **Org conventions to honor** (`get_conventions scope:general`): path-based `v{N}` API
187
+ versioning (we only *consume* v1), **all timestamps are epoch-millisecond numbers** (parse
188
+ indexer timestamps as such, expose orders' `expireTimestamp` in ms), conventional-commit /
189
+ trunk-based git, no secrets committed.
190
+
191
+ ---
192
+
193
+ ## 5. Public API design
194
+
195
+ A **layered** design so the package can grow "higher level" later:
196
+
197
+ - **Low level (stable, testable):** `queries.ts` (one function per indexer endpoint) and
198
+ `transactions.ts` (one pure PTB builder per Move entrypoint). No I/O coupling.
199
+ - **High level (ergonomic facade):** `TriexClient` groups methods by domain and orchestrates
200
+ "read to resolve → build PTB → execute" flows.
201
+
202
+ ### 5.1 Construction
203
+
204
+ ```ts
205
+ import { SuiGrpcClient } from '@mysten/sui/grpc'
206
+ import { TriexClient } from '@trinaryex/sdk'
207
+
208
+ const client = new TriexClient({
209
+ // @mysten/sui v2 unified client (`.core` API) — no SuiClient/getFullnodeUrl.
210
+ suiClient: new SuiGrpcClient({
211
+ network: 'testnet',
212
+ baseUrl: 'https://fullnode.testnet.sui.io:443',
213
+ }),
214
+ apiKey: process.env.TRIEX_API_KEY!, // reads → x-api-key
215
+ indexerUrl: 'https://api.trinary.exchange', // default
216
+ network: 'testnet', // selects package-id bundle
217
+ executor: (tx) => signAndExecuteTransaction({ transaction: tx, options: { showObjectChanges: true } }),
218
+ // packageIds optional — resolved from `network` defaults, overridable:
219
+ // packageIds: { triexbook, multicoin, warehouseReceipts, credCoinType, clock: '0x6', ... }
220
+ })
221
+ ```
222
+
223
+ `ReadOnlyClient` takes the same config minus `suiClient`/`executor` and exposes the
224
+ indexer-backed read surface with explicit identity params — for dashboards/bots that only
225
+ observe. Currency balances (a fullnode read) live on `TriexClient.balances.currency()` only.
226
+
227
+ ### 5.2 Methods → user stories
228
+
229
+ ```ts
230
+ // account (balance manager lifecycle)
231
+ client.account.get(address): Promise<TradingAccount | null> // #1 read (on-chain)
232
+ client.account.ensure(): Promise<{ balanceManagerId: string; created: boolean }> // #1 write (idempotent)
233
+
234
+ // balances — items are hub-scoped indexer reads; currency is a fullnode read
235
+ client.balances.atHub({ storageUnitId, inventoryKey? }): Promise<InventoryBalances> // #2 (warehouse/marketplace/hangar)
236
+ client.balances.currency(address?): Promise<CurrencyBalances> // #3 (wallet + BM CRED, head-current)
237
+
238
+ // deposits / withdrawals (#4, #5, #12, #13)
239
+ client.account.depositCurrency({ amount }): Promise<TxResult> // #5
240
+ client.account.depositItems({ storageUnitId, items: [{ assetId, amount }] }): Promise<TxResult> // #4
241
+ client.account.withdrawCurrency({ amount? }): Promise<TxResult> // #13 (default: all)
242
+ client.account.withdrawItems({ storageUnitId, characterId, items }): Promise<TxResult> // #12
243
+
244
+ // market discovery / hub info (#6, #7, #8, #9)
245
+ client.market.discover(filters?): Promise<DiscoveryResult> // #6
246
+ client.market.hub(hubId): Promise<TradeHubDetail> // #7 (vault + location/owner/visibility)
247
+ client.market.itemsAtHub(hubId): Promise<HubItemsPage> // #8
248
+ client.market.resolvePool({ storageUnitId, assetId }): Promise<string> // #9a (vault → resolve)
249
+ client.market.orderbook({ storageUnitId, assetId }): Promise<Orderbook> // #9 (resting orders, not levels)
250
+ client.market.poolMetadata(poolId): Promise<PoolMetadata> // #10/#11 (decimals + 1e9-scaled fee)
251
+
252
+ // order status (#14) — epoch-ms before/after paging
253
+ client.orders.openOrders(params?): Promise<OpenOrdersPage>
254
+ client.orders.fills(params?): Promise<FillsPage>
255
+ client.orders.trades(params?): Promise<TradesPage>
256
+
257
+ // orders (#10, #11) — each auto-ensures BM + deposits any deficit in one PTB
258
+ client.orders.limit({ storageUnitId, assetId, side, price, quantity, expireAt? }): Promise<TxResult> // #10
259
+ client.orders.market({ storageUnitId, assetId, side, quantity, quoteBudget? }): Promise<TxResult> // #11
260
+
261
+ // pulled forward from post-MVP (bots are not viable without them):
262
+ client.orders.cancel({ storageUnitId, assetId, orderId }) / cancelAll / modify
263
+ client.account.sweepable(): Promise<Sweepable> // claimable proceeds + idle BM items
264
+ client.account.claimSettled({ poolIds? }) // withdraw_settled_amounts per pool
265
+ ```
266
+
267
+ `side: 'buy' | 'sell'` maps to `isBid`; items are identified by `assetId` (the indexer's
268
+ numeric item-type id, a string). `TxResult = { digest: string; objectChanges?: ... }`.
269
+ Amounts are `bigint` in base units; the SDK converts human ↔ base using coin/item `decimals`
270
+ and applies **price scaling** and **bid-fee overhead** in `money.ts` (see §7). Domain types
271
+ are inferred from the pinned zod wire schemas (`schemas.ts`) — single source of truth.
272
+
273
+ ---
274
+
275
+ ## 6. On-chain transaction builders (`transactions.ts`)
276
+
277
+ Confirmed Move entrypoints (from `triex-app-api` — personal, non-governance path). All are
278
+ pure functions `(args) => Transaction`; the facade fills object IDs from indexer reads.
279
+
280
+ | Builder | Move target | Notes |
281
+ |---|---|---|
282
+ | `newBalanceManager` | `${triexbook}::balance_manager::new()` | returns BM; `transferObjects([bm], self)` when freshly created |
283
+ | `depositCoin` | `${triexbook}::balance_manager::deposit<T>(bm, coin)` | coin prepared via list/merge/split of wallet coins |
284
+ | `depositMulticoinObject` | `${triexbook}::balance_manager::deposit_multicoin(bm, object)` | deposit an owned multicoin `Balance` object (wallet receipts) into BM |
285
+ | `withdrawAllCoin` | `${triexbook}::balance_manager::withdraw_all<T>(bm)` → `transferObjects` | #13 |
286
+ | `withdrawAllMulticoin` | `${triexbook}::balance_manager::withdraw_all_multicoin(bm, collectionId, assetId)` | #12 step 1 |
287
+ | `redeemReceipt` | `${warehouseReceipts}::receipt::redeem_receipt(balance, ssu, character, vaultConfig, collection, isOwner)` | #12 step 2 (BM item → hangar) |
288
+ | `ownerProof` | `${triexbook}::balance_manager::generate_proof_as_owner(bm)` | required before placing/withdrawing |
289
+ | `placeLimitOrderItem` | `${triexbook}::multicoin_pool::place_limit_order<Quote>(pool, bm, proof, orderType, selfMatch, price, qty, isBid, expireTs, clock)` | items (multicoin) |
290
+ | `placeMarketOrderItem` | `${triexbook}::multicoin_pool::place_market_order<Quote>(pool, bm, proof, selfMatch, qty, isBid, clock)` | items (multicoin) |
291
+ | `cancelOrderItem` | `${triexbook}::multicoin_pool::cancel_order<Quote>(pool, bm, proof, orderId u64, clock)` | implemented (+ `cancel_all_orders`, `modify_order`) |
292
+ | `withdrawCoin` | `${triexbook}::balance_manager::withdraw<T>(bm, amount)` | partial currency withdraw |
293
+ | `withdrawSettledAmounts` | `${triexbook}::multicoin_pool::withdraw_settled_amounts<Quote>(pool, bm, proof)` | claim post-fill proceeds into the BM |
294
+
295
+ > `<Quote>` is the CRED coin type (`credCoinType`). Item markets are `multicoin_pool`; the
296
+ > item is identified by `collectionId` + `assetId` (u64), not a Move type parameter.
297
+
298
+ **PTB composition rules learned from the app:**
299
+ - One BM per player, **created on demand** inside the same PTB as the first order, then
300
+ transferred to self. `ensure()` makes this explicit/idempotent.
301
+ - Orders are built as **atomic** PTBs: `[ensure BM] → deposit deficit → generate_proof →
302
+ place_order`. If placement aborts, the deposit rolls back.
303
+ - `clock` is the shared object `0x6`. `expireTimestamp` is **epoch ms**.
304
+ - Coin selection helper (`prepareWalletCoinInput`): `listCoins` → accumulate → `mergeCoins`
305
+ → `splitCoins(amount)`; throws a typed `InsufficientBalance` error if short.
306
+
307
+ ### 6.1 Direct-from-hangar item sourcing (resolves OQ-4)
308
+
309
+ A player can fund an item sell order **directly from their hangar** in the same PTB — the app
310
+ does this in `useTriexbookMulticoinOrders.ts`. The SDK ports this as `sourceItemsIntoBm(...)`,
311
+ covering a deposit deficit from two sources in order:
312
+
313
+ 1. **Wallet multicoin receipts.** Find owned `${multicoin}::multicoin::Balance` objects for the
314
+ `assetId` in the right collection, then for each:
315
+ `balance_manager::deposit_multicoin(bm, object)`.
316
+ 2. **SSU / character hangar inventory** (only if a deficit remains). For each relevant owner cap
317
+ (SSU owner cap and/or character owner cap):
318
+
319
+ ```
320
+ [cap, borrowReceipt] = character::borrow_owner_cap<CapType>(character, receivingRef(capRef))
321
+ [receipt] = receipt::deposit_for_receipt<CapType>(
322
+ ssuObject, character, cap, vaultConfigId,
323
+ vaultCollectionId, assetId /*u64*/, amount /*u32*/)
324
+ character::return_owner_cap<CapType>(character, cap, borrowReceipt)
325
+ balance_manager::deposit_multicoin(bm, receipt)
326
+ ```
327
+
328
+ **RQ-3 resolved (from the app):** the SSU owner-cap type arg is
329
+ `${worldOriginalPackageId}::storage_unit::StorageUnit`, the character cap
330
+ `${worldOriginalPackageId}::character::Character`; a hub owner drains the SSU slot first,
331
+ then the character slot; non-owners use the character cap only. Hangar slots are dynamic
332
+ fields on the SSU keyed by owner-cap id (`Field<0x2::object::ID, Inventory>`), which is
333
+ also the indexer's `inventory_key`.
334
+
335
+ **Inputs the SDK must resolve first:**
336
+ - `vaultConfigId`, `vaultCollectionId` — from `GET /v1/hubs/{hub_id}/vault` (indexer) keyed off
337
+ the SSU (`storageUnitId` → `0x`-padded 64-hex object id).
338
+ - `characterId` + owner-cap object IDs — on-chain (`fetchCharacterInfo` equivalent via
339
+ `suiClient`), plus the cap object's `version`/`digest` for `tx.receivingRef` (fullnode read).
340
+ - `worldPackageId` (and `worldOriginalPackageId` for the cap type arg) — from the `stillness`
341
+ package-id set (§8).
342
+
343
+ The **withdraw-items** path (#12) is the mirror: `withdraw_all_multicoin` → `redeem_receipt`
344
+ back into the SSU/hangar (needs `ssu`, `character`, `vaultConfig`, `collection`, `isOwner`).
345
+
346
+ > This adds `worldPackageId`/`worldOriginalPackageId` and the SSU-owner-cap resolution to the
347
+ > SDK's responsibilities — heavier than a plain coin deposit. It's the most involved builder
348
+ > in the MVP and the first integration-test target on a live testnet SSU.
349
+
350
+ ---
351
+
352
+ ## 7. Money math (`money.ts`)
353
+
354
+ - **Coin (currency-pair) pools:** `quote = base * price / TRIEXBOOK_PRICE_SCALING`
355
+ where `TRIEXBOOK_PRICE_SCALING = 1_000_000_000` (1e9).
356
+ - **Multicoin (item) pools:** scaling factor `1` → `quote = price * quantity` (confirmed
357
+ against the production app, which passes the unscaled price straight to
358
+ `place_limit_order`; TRIEX_SYSTEM_DESIGN §7's blanket "all prices ×1e9" describes coin
359
+ pools).
360
+ - **Bid deposit overhead:** v1 pools charge a **quote-denominated fee**; a bid must deposit
361
+ `quote × (1e9 + feeRateScaled) / 1e9` (floor-of-total — matches the app's
362
+ `computeBidQuoteDeposit` and the on-chain per-fill floor). `feeRateScaled` is pool
363
+ metadata's raw `fee` (scaled by 1e9; `20_000_000` = 2%, the default volatile fee) — the
364
+ endpoint exposes **no bps field**. Only buyers pay fees; asks are fee-free. Market bids
365
+ require an explicit `quoteBudget` (the app requires `quoteDepositAmount > 0`).
366
+ - **Decimals:** CRED and each item type carry `decimals` (pool metadata's
367
+ `base_asset_decimals` / `quote_asset_decimals`); the SDK exposes both `bigint` base-unit
368
+ and helper `toBase(human, decimals)` / `fromBase(base, decimals)`.
369
+ - **Pool version:** the SDK **always assumes v1** (it supports only the most-recent
370
+ contracts), so v1 fee treatment is hard-coded. No multi-version branching (resolves OQ-6).
371
+
372
+ > Getting scaling/fees wrong silently over/under-funds orders. `money.ts` is the highest-risk
373
+ > unit-test target: snapshot tests against known app values.
374
+
375
+ ---
376
+
377
+ ## 8. Configuration & package IDs (`config.ts`)
378
+
379
+ **Canonical source (resolves OQ-2):** package IDs live in `triex-app-api`
380
+ `src/constants/tenants.ts` under the **`stillness`** tenant (the testnet world; also
381
+ `DEFAULT_TENANT`). `triex-app-api` already exposes them at
382
+ `GET /api/v1/package-ids?tenant=stillness` (env-override-applied, `Cache-Control: max-age=60`)
383
+ — **but that endpoint returns Move *package* IDs only; registry / shared-object IDs are
384
+ excluded**. The current stillness trading IDs (for the SDK `testnet` preset):
385
+
386
+ ```
387
+ triexPackageId 0x291b9da738dffedd18d7c5049e5e6792270202e03f3c9d9db4c7097670bf6eb2
388
+ triexRegistryId 0x14a58f254b8243bf3f74c057d81cc310498b3d0fe3781650ad58405d7e5f17e4 (shared obj — not in /package-ids)
389
+ multicoinPackageId 0x99a4c039477ac7e7affcb5a5609dd23c29ab69e9436e740d94a4e168c2506cfb
390
+ warehouseReceiptsPackageId 0x0c9d4414aa12eaa1ebf9d32437c1e6403fbf941ffcdca5b9ca16d1769313417e
391
+ credCoinType 0xfbcbd9155669e157ce3999e073930b4c4b67255c3cf88d0d80c76342a31e6710::cred::CRED
392
+ worldPackageId 0x8b8a46ed766fa1358ce7c5c51f6a164b13d627a63e45343f69ed0ba0446c1aa1 (= worldOriginalPackageId)
393
+ clock 0x6
394
+ ```
395
+
396
+ **SDK strategy:** ship a `testnet`/`stillness` **preset baked into the release** (single
397
+ supported world + always-latest contracts per §Contract versioning), each field overridable
398
+ via `packageIds`. Optionally hydrate the *package* IDs at runtime from
399
+ `/api/v1/package-ids?tenant=stillness` **iff** that route is externalized through the gateway
400
+ (it's a `triex-app-api` route, not `etl-api` — needs a gateway route to be reachable at
401
+ `api.trinary.exchange`; otherwise it's origin-only). Registry/shared-object IDs
402
+ (`triexRegistryId`, vault registry, etc.) stay baked in since `/package-ids` omits them.
403
+
404
+ ---
405
+
406
+ ## 9. Phasing / milestones
407
+
408
+ - **Phase 0 — Gateway enablement. ✅ DONE 2026-08-21.** Market surface enabled in DCR
409
+ `62acb22`; `GET /v1/inventory/balances` in `feat/publish-inventory-balances` (published
410
+ to the running gateway). Two inventory routes stay disabled by design (D11) with
411
+ fullnode alternatives — see §2. Remaining nit: merge the balances branch to `main` so
412
+ the next `push-gateway.sh` run doesn't revert it.
413
+ - **Phase 1 — Read core. ✅ DONE 2026-08-21.** `queries.ts` + `schemas.ts` (pinned against
414
+ the committed etl-api upstream spec — RQ-1) + `ReadOnlyClient`: discovery, hub details,
415
+ items-at-hub, pool resolve (vault → collection+asset chain), orderbook, pool metadata,
416
+ hub-scoped item balances, order-status reads. Currency balances implemented as fullnode
417
+ reads (`onchain.ts`, ported from the app's production hooks). Domain types inferred from
418
+ the zod schemas.
419
+ - **Phase 2 — Account lifecycle + deposits/withdrawals. ✅ DONE 2026-08-21.** Deposits
420
+ (coin merge/split; item sourcing wallet-receipts → hangar per §6.1), partial + full
421
+ withdrawals, item redeem into the hangar, `sweepable` + `claimSettled`. All fullnode
422
+ resolution ported from the app (`onchain.ts`, `funding.ts`).
423
+ - **Phase 3 — Orders. ✅ DONE 2026-08-21.** limit + market, buy + sell, atomic PTBs with
424
+ create-BM-in-tx and deficit-only funding; GTC default; market-buy rounding buffer;
425
+ cancel / cancelAll / modify pulled forward from post-MVP. Composition unit-tested
426
+ command-by-command against the app's flows (63+ tests).
427
+ - **Phase 3.5 — Order-status reads (in MVP).** `balance-managers/{bm}/open-orders`, `/fills`,
428
+ `/trades` — pulled into MVP for bots (story #14). Cheap once the balance-manager routes are
429
+ enabled in Phase 0.
430
+ - **Phase 4 — Packaging & docs.** README quick-start, examples (browser wallet + bot
431
+ keypair), semantic-release → publish `@trinaryex/sdk@0.x`.
432
+ - **Later (post-MVP):** full sweep-all convenience (multi-hub redeem), tribe/governance
433
+ trading, **gas-station sponsored (gasless) execution** (optional `sponsor` hook), live
434
+ orderbook streaming, mainnet, coin-pools (currency-pair markets). (Cancel/modify and
435
+ claim-settled were pulled INTO the MVP — a trading bot is not viable without them.)
436
+
437
+ ---
438
+
439
+ ## 10. Auth & developer onboarding
440
+
441
+ - **Reads:** developer supplies an **API key** in the `x-api-key` header. Keys are minted
442
+ through the Developer API Keys panel / `stele` (surfaced in the app's `/settings`), billed
443
+ per-request in **Compute Units** against a rate-limit tier (`free`/`standard`/`growth`/…;
444
+ see `rate-limit-tiers/tiers.json`). SDK docs link **https://docs.trinary.exchange/docs/api-keys**,
445
+ which directs users to **trinary.exchange/settings** to create the key (resolves OQ-3).
446
+ - **Writes:** developer supplies a Sui **signer** (wallet in browser, keypair for bots) via
447
+ `executor`. The SDK never sees private keys. Bots need SUI for gas unless sponsored.
448
+
449
+ ---
450
+
451
+ ## 11. Decisions & remaining questions
452
+
453
+ ### Resolved (2026-08-15)
454
+
455
+ - **OQ-1 — Gateway enablement is work-to-do, not a policy blocker.** We own the routes and
456
+ will expose them; Phase 0 just needs doing (dev → prd). CU costs default to the values
457
+ already in `etl-api.json`.
458
+ - **OQ-2 — Package IDs:** source of truth is the `stillness` tenant in `triex-app-api`
459
+ (`GET /api/v1/package-ids?tenant=stillness`, package IDs only). SDK bakes a `testnet`
460
+ preset; registry/shared IDs baked in (see §8).
461
+ - **OQ-3 — Docs:** https://docs.trinary.exchange/docs/api-keys → create key at
462
+ trinary.exchange/settings (§10).
463
+ - **OQ-4 — Direct-from-hangar sourcing** fully mapped from `useTriexbookMulticoinOrders.ts`
464
+ (borrow_owner_cap → receipt::deposit_for_receipt → return_owner_cap → deposit_multicoin);
465
+ see §6.1.
466
+ - **OQ-5 — Ignore coin-pools.** MVP trades **item↔CRED** via `multicoin_pool` ("pools" family
467
+ only). Coin-pools deferred.
468
+ - **OQ-6 — Always v1.** SDK supports only the newest contracts; no version branching (§7).
469
+ - **OQ-7 — Gas:** left to the caller's `executor`; sponsored/gasless is a **post-MVP** feature
470
+ (optional `sponsor` hook).
471
+ - **OQ-8 — testnet only** (stillness).
472
+ - **OQ-9 — `/v1/discovery`** = open orders sorted by recency with filters; it's the discovery
473
+ primitive for #6. Finalize `DiscoveryResult` against its response shape in Phase 1.
474
+ - **OQ-10 — Order-status reads are IN the MVP** (story #14, Phase 3.5).
475
+ - **OQ-11 — Identity:** for now assume **one address ↔ one character ↔ one BM**; the balances
476
+ read stitches wallet + hangar + BM under that assumption.
477
+
478
+ ### Resolved (2026-08-21, Phase 0/1)
479
+
480
+ - **RQ-1 — Response schemas: pinned** in `schemas.ts` against the committed upstream spec
481
+ (`dynamic-config-registry/upstream-specs/etl-api.json`). Notable deltas from the draft:
482
+ `pools/resolve` is keyed by `collection_id`+`asset_id` (hub flows resolve the vault
483
+ first); the orderbook returns resting **orders**, not aggregated levels; pool metadata
484
+ exposes the fee as a 1e9-scaled `fee` string (no bps field); hub vault/location carry
485
+ no tribe/fuel fields (post-MVP).
486
+ - **RQ-4 — `inventory/balances` keying:** hub-scoped (`storage_unit_id` required, one hub
487
+ per call); sections selected by explicit params — `owner_address` → warehouse,
488
+ `balance_manager_id` → marketplace, `inventory_key` (owner-cap id) → hangar. It serves
489
+ **items only** — CRED (#3) is NOT in the endpoint, so currency balances are fullnode
490
+ reads (`balances.currency()`), which write-flow deficit math wants anyway (§12).
491
+
492
+ ### Remaining (resolve during Phase 2/3)
493
+
494
+ - **RQ-2:** Is `GET /api/v1/package-ids` reachable via `api.trinary.exchange`, or origin-only?
495
+ If not routed through the gateway, the SDK just uses the baked-in preset (fine for MVP).
496
+ - **RQ-3 — RESOLVED 2026-08-21** from the production app (see §6.1): SSU cap type
497
+ `{worldOriginal}::storage_unit::StorageUnit` (hub owners, drained first), character cap
498
+ `{worldOriginal}::character::Character` (everyone). Live-SSU spot check still worthwhile
499
+ once a funded character-owning signer is available.
500
+
501
+ ---
502
+
503
+ ## 12. Indexer lag & consistency
504
+
505
+ The indexer (etl-api) trails chain head by a few seconds. **This is not a correctness problem
506
+ for writes; it's a UX/efficiency concern with two edges to design around.**
507
+
508
+ **Why writes are safe regardless of lag.** Every mutation is validated against *actual
509
+ on-chain state* by Sui validators, and each order is an **atomic PTB** (deposit + proof +
510
+ place bundled). A stale indexer read can only ever produce a transaction that **aborts and
511
+ rolls back** — never a corrupt state or lost funds:
512
+
513
+ - Deposit deficit computed from a stale **too-low** balance → harmless over-deposit (funds sit
514
+ safely in the BM, usable by the next order).
515
+ - Deposit deficit from a stale **too-high** balance → the order aborts on-chain; retryable.
516
+ - Market/limit order sized against a few-seconds-old book → ordinary market slippage.
517
+
518
+ **Edge 1 — double balance-manager creation (story #1).** "One BM per player" is a *convention*,
519
+ not on-chain-enforced, so a stale "no BM" read right after creation could mint a second BM.
520
+ Mitigations (all adopted):
521
+ 1. `account.ensure()` resolves the BM id via an **on-chain `listOwnedObjects`** (a cheap,
522
+ head-current, correctness-critical single lookup), not the indexer.
523
+ 2. The client **caches the BM id** after first create/read (read-your-writes).
524
+ 3. Callers may pass a known `balanceManagerId` to skip discovery entirely.
525
+
526
+ **Edge 2 — read-your-writes for freshly created objects.** Every write returns object IDs from
527
+ `objectChanges` (new BM id, new order id). Bots should trust those over an immediate indexer
528
+ re-read. Optional `waitForTx`/confirm helper; optional short client-side cache (TTL) so a
529
+ just-submitted state isn't clobbered by a lagging read.
530
+
531
+ **Not affected by lag:** PTB *input* resolution (owner-cap `version`/`digest` for
532
+ `tx.receivingRef`, coin/receipt object selection) reads the **fullnode**, which is always
533
+ head-current. "Indexer-only" scopes market/account *data*, not transaction inputs.
534
+
535
+ **Docs stance:** the README states plainly that read endpoints are eventually-consistent
536
+ (seconds), on-chain writes are authoritative, and post-write reads should prefer returned
537
+ object IDs. For latency-sensitive bots, note the post-MVP live-stream option.
538
+
539
+ ---
540
+
541
+ ## 13. Appendix — current `etl-api` gateway route inventory
542
+
543
+ From `dynamic-config-registry/gateway-routes/etl-api.json` (65 routes), **as of 2026-08-21**
544
+ (DCR `62acb22` + `feat/publish-inventory-balances`). Phase 0 is done: everything the MVP
545
+ reads is live.
546
+
547
+ **Enabled — MVP reads** (CU costs as published):
548
+
549
+ ```
550
+ GET /v1/discovery (150 CU) # story 6 — repriced 30 → 150 (2 rps sustained on free/standard)
551
+ GET /v1/inventory/balances (50 CU) # story 2 (items only; hub-scoped)
552
+ GET /v1/hubs/{hub_id}/vault (20 CU) # stories 4,7,9 (vaultConfig/collection)
553
+ GET /v1/hubs/{hub_id}/location (20 CU) # story 7 (location/owner/visibility)
554
+ GET /v1/hubs/{hub_id}/items (20 CU) # story 8
555
+ GET /v1/collections/{collection_id}/hub (20 CU) # story 7 reverse lookup
556
+ GET /v1/pools/resolve (30 CU) # stories 9,10,11 (collection+asset → poolId)
557
+ GET /v1/pools/{pool_id}/orderbook (30 CU) # story 9
558
+ GET /v1/pools/{pool_id}/metadata (20 CU) # stories 10,11 (1e9-scaled fee)
559
+ GET /v1/balance-managers/{balance_manager_id}/open-orders (30 CU) # story 14
560
+ GET /v1/balance-managers/{balance_manager_id}/fills (30 CU) # story 14
561
+ GET /v1/balance-managers/{balance_manager_id}/trades (30 CU) # story 14
562
+ ```
563
+
564
+ (Also enabled beyond MVP need: `/v1/balance-managers/{bm}/sweepable`, `/owners`, the wider
565
+ pools/hubs/assets/activity families, `GET /v1/characters/*`, `GET /v1/orgs*`,
566
+ `GET /v1/search` (100 CU), `GET /v1/stats`, `GET /v1/trades/recent`.)
567
+
568
+ **Deliberately disabled (D11 — sluice attaches no caller identity):** the remaining
569
+ `inventory` family, including the seven POST container mutations and two GETs the SDK
570
+ replaces with fullnode reads — `GET /v1/inventory/balance-manager` (story 1: on-chain
571
+ `listOwnedObjects` is authoritative) and `GET /v1/inventory/receipt-objects` (story 4:
572
+ wallet-receipt discovery is PTB input resolution). `coin-pools/*` stays disabled by choice.
573
+
574
+ > Remember a merge to `main` does **not** update the running gateway — run
575
+ > `scripts/push-gateway.sh --env <env>` (per CLAUDE.md). The balances route is already
576
+ > published; its branch still needs merging to `main` so a future publish from a clean
577
+ > checkout doesn't revert it.