> For the complete documentation index, see [llms.txt](https://naviprotocol.gitbook.io/navi-protocol-developer-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://naviprotocol.gitbook.io/navi-protocol-developer-docs/navi-lending-vault/navi-lending-vault-developer-guide.md).

# NAVI Lending Vault — Developer Guide

Interface specification and integration reference for the NAVI Vault contract on Sui.

**Scope.** This document covers the on-chain module `navi_vault::navi_vault` and its event module `navi_vault::events`. It is intended for wallets, aggregators, frontends, indexers, automation services, and teams operating a vault.

The described interface is identical at `VAULT_VERSION` 1 and 2: the two differ only in the version constant itself, which gates the migration described in §9. Every signature, error code, event and behaviour specified here applies to both.

§1.3 lists the mainnet identifiers an integration requires. The call-target package changes on every upgrade; re-confirm it before integrating.

**Contents**

1. Overview
2. Share accounting
3. Snapshot accounting and same-PTB preconditions
4. Object discovery
5. Transaction construction
6. State queries
7. Fees
8. Governance
9. Pause and versioning
10. Interface reference
11. Integration requirements
12. Appendix A — TypeScript reference implementation

***

### 1. Overview

A NAVI Vault accepts deposits of a single asset, issues proportional shares, and deploys the assets across one or more NAVI lending markets. Yield accrues to share value. Allocation across markets is performed by a designated allocator; strategy parameters are set by a curator, subject to a timelock on sensitive changes.

The contract is **snapshot-accounted**: a market position's value is a cached figure refreshed only by an explicit call. Value-changing operations assert that the cache was refreshed within the same programmable transaction block. Section 3 specifies this requirement, which governs the structure of every deposit, withdrawal and pricing query.

#### 1.1 Object model

| Object                   | Ownership             | Purpose                                                                                                     |
| ------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------------- |
| `Vault<CoinType>`        | shared                | One instance per underlying asset. Holds the idle balance, market positions, share ledger and reward state. |
| `Receipt`                | owned (`key + store`) | A depositor's position. Transferable; transferring the object transfers the position.                       |
| `Timelocks<CoinType>`    | shared                | Pending governance proposals for one vault.                                                                 |
| `Allocators`, `Curators` | shared                | Capability registries.                                                                                      |

Shares are not represented as a coin. They are recorded in `Vault.user_states`, a table keyed by the **Receipt object address**. There is no share token, no `Balance<Share>` and no treasury capability; the unit of ownership is the Receipt object. A single account may hold multiple Receipts against one vault, each an independent position that never merges with the others.

**Locating a holder's positions.** `Receipt` is **not generic**. A single type — `<original_package>::navi_vault::Receipt`, with no type parameter — covers every vault on the deployment. Therefore:

* List the account's owned objects filtered by that exact type. Filtering by `Receipt<CoinType>` matches nothing, because no such type exists.
* Read each Receipt's `vault_address` field and compare it against the vault of interest. This is the only way to attribute a Receipt to a vault, and it is mandatory rather than an optimisation when several vaults share a `CoinType` (§1.3): the Receipt type is identical across all of them.
* Use the **original** package identifier in the type filter, never the latest (§1.3).

```
Vault<CoinType>
├── idle_balance : Balance<CoinType>          assets held in the vault, not deployed
├── markets      : VecMap<address, MarketInfo>
│                     └── current_balance     cached position in one NAVI market (see §3)
├── user_states  : Table<address, UserState>  keyed by Receipt object address
└── total_shares : u64                        includes accrued, unclaimed fee shares
```

Total assets under management are defined as:

```
total_assets = idle_balance + Σ markets[i].current_balance
```

#### 1.2 Accounting units and scaling

All `CoinType` amounts in the public interface — `deposit.amount`, `withdraw.amount`, `MarketInfo.current_balance`, `get_total_assets`, `convert_to_assets`, `convert_to_shares` — are expressed in the token's **native decimals** (9 for SUI, 6 for USDC). `sync_market_balance` performs the conversion from NAVI's internal 9-decimal representation.

The single exception is `MarketInfo.loss`, and the `loss` argument of `set_loss`, which use NAVI's internal 9-decimal representation irrespective of the token's native decimals. One unit of a 6-decimal token corresponds to `1_000_000_000`, not `1_000_000`.

| Quantity                                                   | Scale                                              |
| ---------------------------------------------------------- | -------------------------------------------------- |
| `management_fee`, `_performance_fee`, `MarketInfo.penalty` | WAD — `1e18 = 100%`                                |
| `VaultRewardRule.vault_reward_index`, `reward_rate`        | RAY — `1e27`. `reward_rate` is per **millisecond** |
| `last_sync_at`, `last_harvest_at`                          | milliseconds                                       |
| `last_update_timestamp`, proposal timestamps               | seconds                                            |

The mixed timestamp units are a property of the contract, not a documentation artefact: freshness assertions compare against `clock.timestamp_ms()`, while fee accrual and timelocks operate in seconds.

#### 1.3 Identifiers

Mainnet values, verified on chain 2026-08-05. Hold them as configuration; do not compile them into a client.

**Package**

An upgrade publishes a new package but leaves type identity on the original. Both are required, and they are not interchangeable.

|               | Identifier                                                           | Use for                                                                                 |
| ------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Call target   | `0x13e1e0ddcf3a76cde006d530e98a0f985c446013cfedeae6dd067a2f1ea88ff5` | The `target` of every `moveCall`                                                        |
| Type identity | `0x51cecaacaed0bd436f04ebbd8ba0ca1627c9c4d0e54ad28eff095ca78591518c` | Every type string: owned-object filters, `MoveEventType` filters, `objectType` matching |

The call target changes on every upgrade; the type identity never changes. Both wrong-way errors are silent. Calling a superseded package runs superseded code until the vault's dependencies move underneath it, then aborts 1400 (§3.4). Filtering types by the call target returns zero results with no error.

**Shared objects**

| Object           | Identifier                                                           |
| ---------------- | -------------------------------------------------------------------- |
| `Clock`          | `0x6`                                                                |
| `SuiSystemState` | `0x5`                                                                |
| `PriceOracle`    | `0x1568865ed9a0b5ec414220e8f79b3d04c77acc82358f6e5ae4635687392ffbef` |
| `IncentiveV2`    | `0xf87a8acb8b81d14307894d12595541a73f19933f88e1326d5be349c7a6f7559c` |

**Vaults**

| Vault           | `Vault` object                                                       | `Timelocks` object                                                   |
| --------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- |
| SUI High Yield  | `0x864527a8ed2435aed828b46c6d9d0244506b418761cca25b7dd47a83c7797a29` | `0x04523a8d1f1a3019f9b8f5e61984544d5dfc467ec0e91d64f228a17e30737264` |
| SUI Prime       | `0x01236ff6c66c0c668950f9702629b42f372bf478793d055d2a7eca15e0b0d1e7` | `0x3f1533e62792cb686b3dd794d408a83a653a1f3b553224c70c7dfcc8b290dcf4` |
| USDC High Yield | `0x54359eb5d0e4364bd26989899fdb472f5594d1885e1f0d816ef4a066cab2ae4c` | `0x80696a1a627f9e02059c9dfed0e94ef7905424bc602ff85d3ad9fc47638887a6` |
| USDC Prime      | `0x908c978d1a007aec4bcdc8233a0273de27ab059b9e6611bdad083457abb7f062` | `0x0aaebd50fbc34041706d2e153fbfe31b475f38e6055bb788340aa9eecba45364` |

| `CoinType`                                                                       | Decimals | Vaults                      |
| -------------------------------------------------------------------------------- | -------: | --------------------------- |
| `0x2::sui::SUI`                                                                  |        9 | SUI High Yield, SUI Prime   |
| `0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC` |        6 | USDC High Yield, USDC Prime |

`Timelocks` is required only to read pending governance proposals, not for depositor operations.

Two vaults share each `CoinType`, so their `Vault<CoinType>` type strings are identical and the type does not identify the vault. A vault is identified by its object ID alone; see §1.1 on attributing a holder's positions. Newly created vaults are discoverable from `CreateVaultEvent` (§10.8).

**Oracle**

Required by `withdraw` and `deallocate` only (§3.3).

| Item                     | Identifier                                                           |
| ------------------------ | -------------------------------------------------------------------- |
| Package                  | `0x4837ae94425107554c8847721cf9954c1ad8e10520433b9e37dc11c507148bea` |
| Entrypoint               | `oracle_pro::update_single_price_v3`                                 |
| `OracleConfig`           | `0x1afe1cb83634f581606cc73c4487ddd8cc39a944b951283af23f7d69d5589478` |
| Supra `OracleHolder`     | `0xaa0315f0748c1f24ddb2b45f7939cff40f7a8104af5ccbc4a1d32f870c0b4105` |
| Switchboard `Aggregator` | `0x1fa7566f40f93cdbafd5a029a231e06664219444debb59beec2fe3f19ca08b7e` |

Per asset:

| Asset | Pyth `PriceInfoObject`                                               | NAVI price feed                                                      |
| ----- | -------------------------------------------------------------------- | -------------------------------------------------------------------- |
| SUI   | `0x89b2add829cb6fcd017153fff428bc9faec4d06d643ecfc435af5b55a9e987f0` | `0x2cab9b151ca1721624b09b421cc57d0bb26a1feb5da1f821492204b098ec35c9` |
| USDC  | `0x6ddfc6f9921e55998cbc2e68f78eba897981d79ac17f66c5277bc38954a2a3f8` | `0xe120611435395f144b4bcc4466a00b6b26d7a27318f96e148648852a9dd6b31c` |

Argument order of `update_single_price_v3`:

```
(Clock, OracleConfig, &mut PriceOracle, Supra OracleHolder,
 Pyth PriceInfoObject, Switchboard Aggregator, NAVI price feed)
```

The NAVI price feed is a **pure `address` argument**, not an object reference; the remaining six are objects. `PriceOracle` is taken by mutable reference here and by immutable reference by `withdraw` — passing the same shared object both ways within one block is correct.

The entrypoint revision advances when an upstream price provider is upgraded, and successive revisions accept different `PriceInfoObject` types. Resolve it from the oracle package the deployment currently links against rather than pinning the name.

**Reward funds**

Required for `collect_reward`, one per reward coin type (§4.3). A vault with no reward rules needs none.

| Reward coin                                                                      | `RewardFund` object                                                  |
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `0x549e8b69270defbfafd4f94e17ec44cdbdd99820b33bda2278dea3b9a32d3f55::cert::CERT` | `0x7093cf7549d5e5b35bfde2177223d1050f71655c7f676a5e610ee70eb4d93b5c` |

**Read from the vault, never configured**

Per-market `Pool`, `Storage`, `IncentiveV3`, `IncentiveV2` and `asset_id`, together with caps, penalties, fee rates, `paused`, `version` and reward-rule indices, are held in vault state and must be read at transaction-construction time (§4.1, §4.2). A curator can add a market or change these values at any time; hardcoding them produces the failures in §3.2.

***

### 2. Share accounting

```
shares_minted = floor( amount × (total_shares + VIRTUAL_SHARES) / (total_assets + VIRTUAL_SHARES) )

shares_burned = ceil( amount × (total_shares + VIRTUAL_SHARES) / (total_assets + VIRTUAL_SHARES) )
                + penalty_shares
```

`VIRTUAL_SHARES = 1_000_000` is applied to both numerator and denominator as an inflation-attack offset: displacing the share price by a factor of *N* requires donating `N × 1e6` native units.

Consequences relevant to integration:

* On an empty vault the first deposit mints shares one-to-one with the deposited amount.
* A deposit that would mint zero shares aborts with `E_DEPOSIT_TOO_SMALL` (10012). This is reachable for dust amounts once share price has appreciated significantly. Deposits should be priced client-side (§6.1) rather than relying on the abort.
* Minting floors and burning ceils, both in the vault's favour. A deposit followed immediately by a withdrawal of the same amount returns up to one share less than was minted; the round trip is not value-neutral and should not be presented as such.
* A holder of the entire share supply cannot redeem the entire asset balance in a single withdrawal: the formula requires `total_shares + VIRTUAL_SHARES` shares to withdraw `total_assets`. Full exits must use `from_default = true` (§5.2), which clamps the requested amount to the holder's true maximum, and must tolerate a dust remainder.

Both operations are priced from vault state as it stands **before** the operation: `accrue_interest` runs first, shares are computed against the resulting figures, and only then are balances moved.

Fee shares that have accrued but not yet been claimed are already included in `total_shares`. Share price therefore already reflects pending fee dilution, and no adjustment is required when converting shares to assets.

***

### 3. Snapshot accounting and same-PTB preconditions

`MarketInfo.current_balance` is a cached value. It is written in exactly one place: `sync_market_balance`, which reads the vault's NAVI position as `storage.get_user_balance × supply_index`, converts to native decimals, and subtracts recorded loss. Between calls the value is stale and understates the position by all interest accrued since the last refresh.

Likewise, `VaultRewardRule.vault_reward_index` for a market rule is advanced in exactly one place: `collect_reward`.

Operations whose result depends on those figures assert that they were refreshed at the current `clock.timestamp_ms()`. Because every call within a programmable transaction block observes the same clock reading, this assertion is equivalent to "refreshed in this transaction".

#### 3.1 Precondition matrix

| Entrypoint                                             | All markets synchronized | All active market rules harvested | Target market synchronized |
| ------------------------------------------------------ | :----------------------: | :-------------------------------: | :------------------------: |
| `sync_market_balance`                                  |             —            |                 —                 |              —             |
| `collect_reward`                                       |             —            |                 —                 |              —             |
| `deposit`                                              |         required         |              required             |              —             |
| `withdraw`                                             |         required         |              required             |              —             |
| `claim_reward`                                         |             —            |                 —                 |              —             |
| `allocate`                                             |             —            |                 —                 |          required          |
| `deallocate`                                           |             —            |                 —                 |          required          |
| `claim_management_fee`                                 |             —            |              required             |              —             |
| `claim_performance_fee`                                |             —            |              required             |              —             |
| `exec_management_fee`, `set_management_fee_by_admin`   |         required         |              required             |              —             |
| `exec_performance_fee`, `set_performance_fee_by_admin` |         required         |              required             |              —             |
| all other entrypoints                                  |             —            |                 —                 |              —             |

Definitions:

* **All markets** means every entry in `Vault.markets`, in both `Active` and `Disabled` status. Omitting a `Disabled` market aborts with `E_MARKET_NOT_READ` (10006). Disabled markets remain part of assets under management and remain withdrawable, and are therefore not exempt.
* **All active market rules** means every entry of `Vault.reward_rules` where `is_active == true && is_vault_native == false`. Vault-native rules are settled internally by `update_vault_native_indices` and are exempt. A missing harvest aborts with `E_REWARDS_NOT_COLLECTED` (10007).
* `collect_reward` returns without effect — including without updating `last_harvest_at` — when the rule is vault-native or inactive. Calling it for every rule index is therefore safe and costs only gas.

#### 3.2 Conditions that invalidate a synchronization

* `add_market` initializes the new market with `last_sync_at = 0`. Any deposit or withdrawal constructed against a market list obtained before the addition will abort.
* `set_loss` resets that market's `last_sync_at` to `0`, invalidating a synchronization performed earlier in the same transaction.

Market and reward-rule lists must be read from vault state at transaction-construction time and must not be cached across transactions.

#### 3.3 External preconditions imposed by the lending protocol

Deposits and withdrawals pass through NAVI's lending logic, which applies its own constraints on top of the vault's. These abort with lending and oracle package codes, which occupy a range distinct from the vault's `10001`–`10042` and are summarized in §10.7.

**Reserve supply ceiling.** `logic::execute_deposit` calls `validation::validate_deposit`, which asserts the reserve's total supply plus the deposit does not exceed NAVI's `supply_cap_ceiling` for that asset. A deposit can therefore abort with **1604** (`exceeded_maximum_deposit_cap`) while both the vault cap and the market cap still have headroom, because the constraint belongs to the underlying reserve and is shared with every other participant in that market. Deposit headroom presented to a user is the minimum of three bounds, not two.

The contention is closer than it appears: a single NAVI market may be registered by **more than one vault**, so two vaults with the same underlying asset can draw on one reserve. Reserve-level limits are therefore affected by activity on vaults other than the one being quoted, and cannot be derived from any single vault's state.

**Reserve liquidity.** `validation::validate_withdraw` asserts `total_supply ≥ total_borrow + amount` for the reserve. A withdrawal can abort with **1506** (`insufficient_balance`) even when the holder's share balance and the vault's recorded market position are both sufficient, because the underlying reserve is too heavily utilized to release the assets. This is a property of the lending market, not of the vault, and it is transient. Clients should distinguish it from the vault's own `E_INSUFFICIENT_BALANCE` (10002) and present it as a temporary liquidity condition. Routing the withdrawal to a different market, or drawing on the vault's idle balance, may succeed where one market cannot.

**Oracle price freshness.** `withdraw` and `deallocate` are the only entrypoints that accept a `PriceOracle`, because they are the only ones that reach NAVI's collateral valuation. `incentive_v3::withdraw_with_account_cap_v2` resolves to `logic::execute_withdraw`, which asserts `logic::is_health`. That path computes collateral value **before** the zero-debt short circuit, so it evaluates `oracle::get_token_price` and asserts price validity even though a vault holds no debt.

A price is valid only while

```
clock.timestamp_ms() − price.timestamp ≤ PriceOracle.update_interval
```

`update_interval` is configurable per oracle and defaults to 30 000 ms. When the price for the vault's asset is older than that window, `withdraw` and `deallocate` abort with **1502** (`lending_core::error::invalid_price`) — an abort raised by the lending package, not by `navi_vault`, and therefore absent from the error table in §10.7.

Deposits and allocations are unaffected: `logic::execute_deposit` takes no oracle and performs no price read.

Because the vault receives `&PriceOracle` by immutable reference, it cannot refresh the price. The withdrawal block must therefore **begin with a price update into the oracle package**, before the market synchronizations. The 30-second default window is short enough that relying on another party to have refreshed the price is not a viable strategy. The verified block shapes in §5.6 show the call in position.

Three properties of that update:

* **It is per asset.** The oracle holds one price per asset, refreshed by an entrypoint that takes that asset's feed identifier and price-source objects. Refresh the asset being withdrawn; a refresh of an unrelated asset does not satisfy the assertion.
* **The entrypoint is versioned, and the version advances.** Successive revisions have differed in the price-source object types they accept, as upstream price providers were themselves upgraded. Resolve the entrypoint from the oracle package the deployment currently links against rather than hardcoding a name; an integration pinned to a superseded revision stops working when the oracle is upgraded.
* The update takes the `PriceOracle` by **mutable** reference while `withdraw` takes it by immutable reference. Passing the same shared object both ways within one block is correct and required.

#### 3.4 External precondition: lending package version coupling

The vault package is statically linked against one published version of `lending_core`. That version is compiled into it and cannot be changed without upgrading the vault package. `lending_core` gates its own entrypoints on the version recorded in the shared `Storage` object:

```
lending_core::version::pre_check_version(Storage.version)
    → aborts 1400 (lending_core::error::incorrect_version) on mismatch
```

When NAVI upgrades `lending_core` and increments `Storage.version`, every vault call that reaches the lending package begins aborting with **1400**. `sync_market_balance` is such a call — it invokes `lending::update_state_of_user` — and because `deposit` and `withdraw` require synchronization in the same block, all depositor operations abort until the **vault package itself is upgraded** to link against the new `lending_core`.

This condition has three properties integrators must account for:

* It is not recoverable client-side. No change to block construction, gas, arguments or retry policy works around it. Only a vault package upgrade resolves it.
* It is invisible to the vault's own state. `is_paused` is `false`, `version` is unchanged, and every view function that does not touch `lending_core` continues to return values — from the last successful synchronization, which may be arbitrarily old (§6.1).
* Abort 1400 arises in a module named `version`, as does the vault's own `E_INCORRECT_VERSION` (10036). They are distinct conditions with distinct remedies. Discriminate on the abort code and the originating package, not on the module name.

Clients should surface abort 1400 as a protocol-level outage rather than a user error, and should not present a synced balance or accept a deposit while it persists.

***

### 4. Object discovery

Every object required by a transaction is derivable from vault state, with the single exception of `RewardFund` objects, which reside on the NAVI lending side.

#### 4.1 Markets

```move
public fun num_markets<CoinType>(&Vault<CoinType>): u64
public fun get_market_address_at_index<CoinType>(&Vault<CoinType>, idx: u64): address
public fun get_market_config<CoinType>(&Vault<CoinType>, pool_address: address)
    : (u64, u64, u64, u8, u64, address, u8, address, address)
```

Iterating `0 .. num_markets` yields each pool address; `get_market_config` then returns the `Storage`, `IncentiveV2` and `IncentiveV3` object identifiers registered for that market, together with its cap, penalty and status. The contract validates the objects passed to it against these recorded addresses and aborts with `E_MARKET_CONFIG_MISMATCH` (10017) on any divergence.

Return positions are specified in §10.5. `status` is `0` for `Active` and `1` for `Disabled`.

#### 4.2 Reward rules

```move
public fun num_reward_rules<CoinType>(&Vault<CoinType>): u64
public fun get_rule_info<CoinType>(&Vault<CoinType>, idx: u64)
    : (address, String, address, u256, u64, bool, u256, bool, u64, u64)
```

Each rule with `is_active && !is_vault_native` requires one `collect_reward` call, parameterized with:

* type arguments `<CoinType, RewardCoinType>`, where `RewardCoinType` is the rule's stored `reward_coin_type`. A mismatch aborts with `E_REWARD_COIN_TYPE_MISMATCH` (10029);
* the `Storage` and `IncentiveV3` objects of the market identified by `navi_pool_id`;
* the corresponding `RewardFund<RewardCoinType>` object;
* the rule's index.

`reward_rules` is append-only. Disabling a rule clears `is_active` but does not remove the entry, so indices are stable for the lifetime of the vault.

A vault may legitimately have **no reward rules at all**, in which case no `collect_reward` call is required in any block and `claim_reward` returns a zero-valued coin. Do not treat an empty rule set as a discovery failure.

#### 4.3 RewardFund objects

`RewardFund<T>` is a shared object belonging to NAVI's `incentive_v3`, one per (reward coin type, market). It is not recorded in vault state and must be resolved from the lending side. Three routes, in order of preference:

1. **NAVI's incentive configuration service**, keyed by reward coin type and market. Authoritative and the intended integration path.
2. **`RewardFundCreated` events** emitted by `incentive_v3` on fund creation. Each carries the coin type and market identifier; `incentive_v3::get_fund_market_id` and `verify_market_incentive_funds` allow the mapping to be checked on chain.
3. **A prior `collect_reward` transaction for the same rule.** The fund is argument 4 of `collect_reward` (§10.4). Querying the vault's transaction history for calls to `navi_vault::collect_reward` and reading argument 4 recovers the fund object for each rule without any off-chain dependency. This is the most direct route when bootstrapping against an existing vault.

Fund objects are stable and may be cached; the currently required one is listed in §1.3. A missing or wrong fund is not silently tolerated: the rule goes unharvested and the subsequent `deposit` or `withdraw` aborts with `E_REWARDS_NOT_COLLECTED` (10007).

#### 4.4 Objects held as configuration

`Clock`, `SuiSystemState`, the NAVI `PriceOracle`, the vault object and the package identifier are not discoverable from vault state and are held as configuration; see §1.3. The `PriceOracle` is required by `withdraw` and `deallocate` only.

***

### 5. Transaction construction

Throughout this section, *M* denotes the number of markets registered on the vault and *R* the number of active market reward rules.

#### 5.1 Deposit

```
for each market m in vault.markets:                       # all markets, Active and Disabled
    sync_market_balance<CoinType>(vault, m.storage, m.pool, clock)
for each active market rule r:
    collect_reward<CoinType, r.reward_coin>(vault, clock, r.storage, r.incentive_v3,
                                            r.reward_fund, r.index)
(receipt, shares) = deposit<CoinType>(vault, receipt_opt, clock,
                                      target.storage, target.pool, deposit_coin, amount,
                                      target.incentive_v2, target.incentive_v3, ctx)
transfer receipt → depositor
```

Total: `M + R + 1` Move calls.

**`receipt_opt: Option<Receipt>`.** Supply `option::none<Receipt>()` for a new position, in which case the vault mints and returns a Receipt, or `option::some(receipt)` to add to an existing position. Constructing the value requires an explicit `0x1::option::none` or `0x1::option::some` Move call within the block; see Appendix A.

**Amount.** `deposit_coin.value()` must equal `amount` exactly, otherwise the call aborts with `E_AMOUNT_MISMATCH` (10021). The coin must be split to the exact amount within the transaction.

**Target market.** Behaviour depends on `get_vault_default_market`:

* Default market set (`≠ 0x0`): the `pool`, `storage`, `incentive_v2` and `incentive_v3` arguments must be those of the default market, and the deposit is forwarded directly into NAVI. Any other pool aborts with `E_DEFAULT_MARKET_MISMATCH` (10022). The market must be `Active` (`E_MARKET_INVALID`, 10010) and the resulting balance must not exceed the market cap (`E_CAP_EXCEEDED`, 10005).
* Default market unset (`0x0`): assets are added to `idle_balance`. The pool, storage and incentive arguments are neither validated nor used for routing, but remain type-required; supplying a registered market's objects is recommended.

**Vault cap.** Enforced as `total_assets + amount ≤ vault_cap`, where `0` denotes no limit. Violation aborts with `E_VAULT_CAP_EXCEEDED` (10039).

**Returned values.** The returned `Receipt` must be consumed by the transaction — transferred to the depositor. The second return value is the number of shares minted.

#### 5.2 Withdrawal

```
for each market m in vault.markets:
    sync_market_balance<CoinType>(vault, m.storage, m.pool, clock)
for each active market rule r:
    collect_reward<CoinType, r.reward_coin>(...)
(coin, shares_burned) = withdraw<CoinType>(vault, receipt, clock, oracle,
                                           source.storage, source.pool,
                                           amount, max_shares, from_default,
                                           source.incentive_v2, source.incentive_v3,
                                           system_state, ctx)
transfer coin → depositor
```

Total: `M + R + 1` Move calls. `receipt` is passed by mutable reference and remains with its owner.

**Routing.** The idle balance is drawn down first; only the shortfall is withdrawn from the market identified by `pool`. The `pool` argument therefore designates the source of the shortfall, not of the whole withdrawal. When the idle balance covers the full amount, the pool argument is not validated and no penalty applies.

| `from_default` | Behaviour                                                                                                                                                                                                                                 |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `true`         | `pool` must be the vault's default market, otherwise `E_DEFAULT_MARKET_MISMATCH`. `amount` is clamped to the holder's maximum redeemable value, so a sufficiently large value — `u64::MAX` — requests a full exit. No penalty is applied. |
| `false`        | `pool` may be any registered market, `Active` or `Disabled`; market status is not checked on withdrawal, so a market being wound down remains drainable. If `pool` differs from the default market, that market's penalty applies.        |

**Penalty.** `penalty_shares = ceil(market_portion_shares × penalty / 1e18)`, bounded by `MAX_PENALTY = 30%`. It applies only to the portion actually withdrawn from a non-default market; the portion served from the idle balance is unpenalized.

**Slippage.** `max_shares` bounds the shares burned. The check is `shares_burned ≤ max_shares` and is **skipped entirely when `max_shares == 0`**; the value `0` denotes "no limit", not "no shares". It must be computed rather than defaulted (§6.1). Violation aborts with `E_SLIPPAGE_EXCEEDED` (10040).

**Returned amount.** NAVI may return marginally less than requested from the market leg. Shares are burned against the amount actually received, and the returned coin's value is authoritative. Clients must read the coin value or `WithdrawEvent.amount` rather than assuming `amount`.

#### 5.3 Reward claim

```
for each active market rule r paying RewardCoinType:      # recommended
    collect_reward<CoinType, RewardCoinType>(...)
coin = claim_reward<CoinType, RewardCoinType>(vault, receipt, clock, ctx)
transfer coin → depositor
```

`claim_reward` has no freshness preconditions and may be called in isolation, in which case only rewards already harvested into the vault are payable. Vault-native accrual is settled within the call. One call is required per reward coin type; entitlements are aggregated across all rules paying that coin.

The function returns a zero-valued coin rather than aborting when nothing is claimable. The returned object must still be consumed. Clients should query `get_user_claimable_reward_amount` and omit the call when the result is zero, to avoid creating zero-valued objects in the recipient's account.

#### 5.4 Allocation and deallocation

```
sync_market_balance<CoinType>(vault, storage, pool, clock)          # target market only
allocate<CoinType>(vault, allocators, cap, clock, storage, pool, amount,
                   incentive_v2, incentive_v3, ctx)
```

Only the target market requires synchronization; no reward harvest is required. `allocate` requires the target market to be `Active`. `deallocate` operates on `Disabled` markets as well and additionally requires the `PriceOracle` and `SuiSystemState` objects. Both require an `AllocatorCap` that is registered in the shared `Allocators` object and bound to the vault.

#### 5.5 Fee claim

```
for each active market rule r:
    collect_reward<CoinType, r.reward_coin>(...)
claim_management_fee<CoinType>(vault, cap, receipt, clock)
```

All active market rules must be harvested; market synchronization is not required. Fees become claimable only once `accrue_interest` has run, which occurs within `deposit`, `withdraw` and fee-rate changes. A claim in isolation therefore collects previously accrued fees only.

Fee shares are credited to a Receipt supplied by the caller. The Receipt's reward indices are anchored on first claim; reward index growth that occurred while shares were pending is not recoverable afterwards. Operators should create the Receipt and issue the first claim early, even when the pending amount is zero, and claim frequently thereafter.

***

#### 5.6 Verified block shapes

The following are the command sequences of executed mainnet transactions, reproduced to fix the ordering and argument positions unambiguously. Argument indices correspond to the signatures in §10.3 and §10.4.

**Deposit** — a vault with four markets and one active market reward rule:

```
 0  navi_vault::sync_market_balance<CoinType>    (vault, storage_A, pool_A, clock)
 1  navi_vault::sync_market_balance<CoinType>    (vault, storage_B, pool_B, clock)
 2  navi_vault::sync_market_balance<CoinType>    (vault, storage_C, pool_C, clock)
 3  navi_vault::sync_market_balance<CoinType>    (vault, storage_D, pool_D, clock)
 4  navi_vault::collect_reward<CoinType, RCoin>  (vault, clock, storage, incentive_v3,
                                                  reward_fund, rule_index)
 5  navi_vault::create_receipt<CoinType>         (vault)
 6  SplitCoins
 7  0x1::option::some<Receipt>
 8  navi_vault::deposit<CoinType>
 9  TransferObjects
```

No oracle call appears, consistent with §3.3. Commands 5 and 7 show the alternative to `option::none`: mint the Receipt explicitly, then wrap it. Both forms are valid; `option::none` collapses the two commands into one.

**Withdrawal** — a vault with two markets and one active market reward rule:

```
 0  oracle_pro::update_single_price_v2           (clock, …oracle objects…, price_oracle, …)
 1  navi_vault::sync_market_balance<CoinType>    (vault, storage_A, pool_A, clock)
 2  navi_vault::sync_market_balance<CoinType>    (vault, storage_B, pool_B, clock)
 3  navi_vault::collect_reward<CoinType, RCoin>  (vault, clock, storage, incentive_v3,
                                                  reward_fund, rule_index)
 4  navi_vault::withdraw<CoinType>               (vault, receipt, clock, price_oracle,
                                                  storage, pool, amount, max_shares,
                                                  from_default, incentive_v2, incentive_v3,
                                                  system_state)
 5  TransferObjects
```

The price update occupies position 0 and passes the same `PriceOracle` object that `withdraw` receives at argument 3. `system_state` is `0x5` and `clock` is `0x6`.

The exact signature and object set of the oracle update belong to the oracle package, not to this interface, and differ between oracle implementations. Resolve them from the oracle package version the target deployment links against.

***

### 6. State queries

All view functions are `public fun` and may be evaluated through `devInspectTransactionBlock`. None mutate state.

#### 6.1 Snapshot staleness

`get_total_assets`, `get_user_balance`, `convert_to_assets`, `convert_to_shares` and `get_market_info` all read `MarketInfo.current_balance`, whose freshness is bounded by the most recent `sync_market_balance` executed **on chain by any party**.

The bound is not a matter of seconds. Because synchronization happens only as a side effect of someone constructing a deposit, withdrawal or allocation block, a vault with no activity does not get synchronized at all. Observed mainnet vaults have carried snapshots more than a month old, and the condition in §3.4 halts synchronization entirely for as long as it persists. Any figure read without synchronizing must be treated as being of unknown age; `get_market_config` position 4 returns `last_sync_at` and is the only way to know it.

Pricing must therefore be performed against a synchronized snapshot, by evaluating a read-only block that synchronizes before reading:

```
for each market m: sync_market_balance<CoinType>(vault, m.storage, m.pool, clock)
get_total_assets<CoinType>(vault)
get_user_balance<CoinType>(vault, receipt)
convert_to_shares<CoinType>(vault, amount)
```

`devInspectTransactionBlock` simulates execution, so the mutable references and the resulting state changes are discarded; the reads observe synchronized values and nothing is written on chain. No signature, key or funds are required — only a sender address, which need not be funded or controlled.

**Transport.** `devInspectTransactionBlock` is a JSON-RPC method, and JSON-RPC has been deprecated on Sui's public fullnodes: `https://fullnode.mainnet.sui.io` answers it with `-32601 Method not found`. Simulation therefore requires either an RPC provider that still serves JSON-RPC, or the gRPC transport, whose equivalent is `simulateTransaction` (`SuiGrpcClient` in `@mysten/sui/grpc`). The block construction is identical either way; only the client and the result shape differ. New integrations should target gRPC.

**Deriving `max_shares`.** The reliable method is to simulate the withdrawal itself and read the `shares_burned` return value, which accounts for the non-default-market penalty, ceiling rounding, idle-first routing and same-transaction fee accrual — none of which `convert_to_shares` models. A tolerance should be added on top. The direction of drift between simulation and execution is favourable: interest accrual raises share price, so an identical amount costs marginally fewer shares at execution time. The tolerance therefore covers rounding and any fee accrual in the interval, for which a few tens of basis points is sufficient.

#### 6.2 Reward query staleness

`get_user_claimable_reward_amount` lags in two independent respects:

1. `vault_reward_index` advances only on `collect_reward` for market rules, or on an operation that triggers `update_vault_native_indices` for vault-native rules. Unharvested protocol rewards are not represented.
2. A holder's `reward_total` advances only when that holder interacts with the vault, through `deposit`, `withdraw` or `claim_reward`.

A holder who has not interacted since the last index growth is therefore understated. No view function reports the true entitlement, because resolving the second lag requires mutating that holder's state. An accurate figure is obtained by simulating the settlement path — `collect_reward` followed by `claim_reward` — and reading the amount from the resulting `ClaimRewardEvent`.

#### 6.3 Query index

| Quantity                     | Call                                                                      | Requires prior synchronization |
| ---------------------------- | ------------------------------------------------------------------------- | :----------------------------: |
| Holder's shares              | `get_user_shares(vault, receipt)`                                         |               no               |
| Holder's value in `CoinType` | `get_user_balance(vault, receipt)`                                        |               yes              |
| Assets under management      | `get_total_assets(vault)`                                                 |               yes              |
| Share price                  | `convert_to_assets(vault, unit)`                                          |               yes              |
| Total shares                 | `get_total_shares(vault)`                                                 |               no               |
| Idle balance                 | `get_idle_balance(vault)`                                                 |               no               |
| Position in one market       | `get_market_info(vault, pool)`                                            |               yes              |
| Vault deposit cap            | `get_vault_cap(vault)`                                                    |               no               |
| Deposit routing target       | `get_vault_default_market(vault)`                                         |               no               |
| Fee rates                    | `get_management_fee`, `get_performance_fee`                               |               no               |
| Pending fee dilution         | `get_pending_management_fee_shares`, `get_pending_performance_fee_shares` |               no               |
| Performance-fee baseline     | `get_baseline_total_assets(vault)`                                        |               no               |
| Pause state                  | `is_paused(vault)`                                                        |               no               |
| Contract version             | `version(vault)`                                                          |               no               |
| Claimable rewards            | `get_user_claimable_reward_amount<CoinType, RewardCoinType>`              |            see §6.2            |
| Undistributed reward balance | `get_collected_reward_balance<CoinType, RewardCoinType>`                  |               no               |

***

### 7. Fees

```
management_fee  = total_assets × rate × elapsed_seconds / (1e18 × 31_536_000)
performance_fee = (total_assets − baseline_total_assets) × rate / 1e18
```

The management fee is bounded at 20% annualized (`MAX_MANAGEMENT_FEE_APY_WAD = 0.2e18`); the performance fee at 40% of profit (`MAX_PERFORMANCE_FEE_WAD = 0.4e18`). The performance fee is charged only on growth above `Vault.total_assets`, the gross assets under management recorded at the previous accrual, which functions as a high-water mark and is readable via `get_baseline_total_assets`.

Both fees are taken as **newly minted shares** credited to `pending_management_fee_shares` and `pending_performance_fee_shares`. No assets leave the vault; `total_shares` increases and all holders are diluted proportionally. Any yield calculation derived from the growth of `total_assets` alone overstates the return to depositors.

Fees accrue when `accrue_interest` executes, which occurs within `deposit`, `withdraw` and fee-rate changes. An inactive vault accrues no fees until its next interaction, at which point the full elapsed interval is charged.

***

### 8. Governance

The following parameters are subject to a timelock when initiated by a curator: management fee, performance fee, default market, market addition, and market status. The sequence is `propose_*` → wait `TIMELOCK_DURATION` (24 hours) → `exec_*`, with a `TIMELOCK_GRACE_PERIOD` of 7 days after which the proposal expires and must be re-proposed. One live proposal is permitted per (type, subject) pair.

Two categories bypass the timelock:

* The admin path. `set_management_fee_by_admin`, `set_performance_fee_by_admin`, `set_default_market_by_admin`, `set_market_status_by_admin`, `add_market_by_admin` and `set_market_cap_and_penalty_by_admin` apply immediately.
* Curator-initiated changes to the vault cap (`set_vault_cap_by_curator`) and to a market's cap and penalty (`set_market_cap_and_penalty_by_curator`) apply immediately.

Integration consequences:

* Fee rates, caps, penalties, the default market and the market list must be read at transaction-construction time and must not be cached.
* Services presenting risk information should monitor `ProposalCreatedEvent`, whose `executable_at` field carries the 24-hour notice the timelock exists to provide, and the `Set*` / `AddMarket` events in §10.8 for changes already applied.

***

### 9. Pause and versioning

**Pause.** While `paused == true`, every depositor, allocator, block-support and fee-claim entrypoint aborts with `E_PAUSED` (10011), as do the two timelocked fee executions. Configuration and governance entrypoints are unaffected.

Two consequences are easy to miss. `sync_market_balance` is included, so a paused vault cannot be priced from a fresh snapshot at all. And the pause applies to every party including the admin — only the `AdminCap` holder can lift it, via `set_paused(vault, cap, false)`; a `PauseCap` holder can engage it but not lift it.

Clients should evaluate `is_paused` before constructing a transaction and represent the state explicitly.

**Versioning.** Every mutating entrypoint calls `version_verification`, which requires `Vault.version` to equal the current package's `VAULT_VERSION`. Following a package upgrade, the admin must call `version_migrate` for each vault; until then, mutating calls abort with `E_INCORRECT_VERSION` (10036).

View functions do **not** perform this check. While a migration is pending, balances and configuration remain readable and `sync_market_balance` — being a mutating call — does not, so reads are available but cannot be freshly synchronized. Clients should evaluate `version(vault)` against the package's current version to distinguish this condition from a generic failure and to suppress transaction construction until migration completes.

The package identifier must be supplied as configuration rather than compiled into a client, so that clients can be repointed at an upgraded package.

***

### 10. Interface reference

Module: `navi_vault::navi_vault`. Events: `navi_vault::events`. `String` denotes `std::ascii::String` throughout.

External types:

| Type             | Module                                   |
| ---------------- | ---------------------------------------- |
| `Storage`        | `lending_core::storage`                  |
| `Pool<CoinType>` | `lending_core::pool`                     |
| `IncentiveV2`    | `lending_core::incentive_v2::Incentive`  |
| `IncentiveV3`    | `lending_core::incentive_v3::Incentive`  |
| `RewardFund<T>`  | `lending_core::incentive_v3::RewardFund` |
| `PriceOracle`    | `oracle::oracle`                         |
| `SuiSystemState` | `sui_system::sui_system`                 |
| `Clock`          | `sui::clock`                             |

#### 10.1 Objects

**`Vault<phantom CoinType> has key` — shared**

| Field                            | Type                          | Description                                                                                                                      |
| -------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `version`                        | `u64`                         | Must equal the package's `VAULT_VERSION`.                                                                                        |
| `total_shares`                   | `u64`                         | All shares, including accrued but unclaimed fee shares.                                                                          |
| `idle_balance`                   | `Balance<CoinType>`           | Assets held in the vault, not deployed.                                                                                          |
| `default_market`                 | `address`                     | Pool to which deposits are routed; `0x0` routes deposits to the idle balance.                                                    |
| `management_fee`                 | `u64`                         | WAD-scaled annual rate. Bounded by `MAX_MANAGEMENT_FEE_APY_WAD`.                                                                 |
| `_performance_fee`               | `u64`                         | WAD-scaled share of profit. Bounded by `MAX_PERFORMANCE_FEE_WAD`.                                                                |
| `total_assets`                   | `u64`                         | Gross assets under management recorded at the last accrual; the performance-fee baseline. Not live AUM — use `get_total_assets`. |
| `last_update_timestamp`          | `u64`                         | Last accrual, in seconds.                                                                                                        |
| `markets`                        | `VecMap<address, MarketInfo>` | Keyed by pool address.                                                                                                           |
| `pending_management_fee_shares`  | `u64`                         | Minted, not yet assigned to a Receipt.                                                                                           |
| `pending_performance_fee_shares` | `u64`                         | As above.                                                                                                                        |
| `reward_rules`                   | `vector<VaultRewardRule>`     | Position is the `rule_index` argument. Append-only.                                                                              |
| `collected_rewards`              | `Bag`                         | Reward coin type string → `Balance<RewardCoinType>`.                                                                             |
| `user_states`                    | `Table<address, UserState>`   | Keyed by Receipt object address.                                                                                                 |
| `paused`                         | `bool`                        | See §9.                                                                                                                          |
| `vault_cap`                      | `u64`                         | Vault-level deposit cap; `0` denotes no limit.                                                                                   |

**`Receipt has key, store` — owned**

| Field           | Type      |
| --------------- | --------- |
| `vault_address` | `address` |

The Receipt's object address is the key into `user_states`. Transferring the object transfers the position in full, including reward state.

**`UserState has store`**

| Field            | Type                   | Description                      |
| ---------------- | ---------------------- | -------------------------------- |
| `shares`         | `u64`                  | Share balance.                   |
| `reward_indices` | `VecMap<String, u256>` | Per-rule index snapshot.         |
| `reward_total`   | `VecMap<String, u256>` | Per-rule cumulative entitlement. |
| `reward_claimed` | `VecMap<String, u256>` | Per-rule amount already paid.    |

Rule keys are `"{navi_pool_id}|{reward_coin_type}"`.

**`MarketInfo has store`**

| Field                  | Type                 | Description                                                                                   |
| ---------------------- | -------------------- | --------------------------------------------------------------------------------------------- |
| `cap`                  | `u64`                | Upper bound on `current_balance`; `0` denotes no limit.                                       |
| `penalty`              | `u64`                | WAD-scaled additional share burn on non-default-market withdrawals. Bounded by `MAX_PENALTY`. |
| `current_balance`      | `u64`                | Cached position in native decimals. Written only by `sync_market_balance`.                    |
| `loss`                 | `u64`                | Recorded bad debt, in NAVI's 9-decimal representation.                                        |
| `last_sync_at`         | `u64`                | Milliseconds. Reset to `0` by `add_market` and `set_loss`.                                    |
| `storage_address`      | `address`            | Required `Storage` object.                                                                    |
| `asset_id`             | `u8`                 | Reserve index within that `Storage`.                                                          |
| `incentive_v3_address` | `address`            | Required `IncentiveV3` object.                                                                |
| `incentive_v2_address` | `address`            | Required `IncentiveV2` object.                                                                |
| `account_cap`          | `Option<AccountCap>` | The vault's NAVI account for this market. Internal.                                           |
| `status`               | `MarketStatus`       | `Active` (discriminant 0) or `Disabled` (1).                                                  |

`Disabled` markets accept no deposits or allocations and cannot serve as the default market. Withdrawals and deallocations remain permitted, the balance continues to count toward assets under management, and synchronization remains required.

**`VaultRewardRule has drop, store`**

| Field                      | Type      | Description                                                                                                                              |
| -------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `navi_pool_id`             | `address` | Market the rule belongs to; `0x0` for vault-native rules.                                                                                |
| `reward_coin_type`         | `String`  | Fully qualified reward coin type.                                                                                                        |
| `incentive_rule_id`        | `address` | NAVI incentive rule identifier; `0x0` for vault-native rules.                                                                            |
| `vault_reward_index`       | `u256`    | RAY-scaled cumulative reward per share.                                                                                                  |
| `last_harvest_at`          | `u64`     | Milliseconds. Last `collect_reward` for market rules; last index settlement for vault-native rules.                                      |
| `is_vault_native`          | `bool`    | `true` for operator-funded rules streamed at a rate; `false` for rules harvested from NAVI.                                              |
| `reward_rate`              | `u256`    | RAY-scaled reward per millisecond. `0` for market rules.                                                                                 |
| `is_active`                | `bool`    | Soft-delete flag. Inactive rules are skipped by `collect_reward` and by the harvest assertion; historical entitlements remain claimable. |
| `total_reward_deposited`   | `u64`     | Vault-native budget.                                                                                                                     |
| `total_reward_distributed` | `u64`     | Vault-native amount streamed to date. Index growth halts at the budget.                                                                  |

**Capabilities and registries**

| Object                | Abilities             | Scope                                                                                                                                                  |
| --------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Timelocks<CoinType>` | `key` (shared)        | `vault_id`, `proposals: Table<ProposalKey, TimelockProposal<CoinType>>`.                                                                               |
| `Allocators`          | `key, store` (shared) | `valid_caps: Table<ID, bool>`.                                                                                                                         |
| `Curators`            | `key, store` (shared) | `valid_caps: Table<ID, bool>`.                                                                                                                         |
| `AdminCap`            | `key, store`          | Global. Minted once in `init`.                                                                                                                         |
| `EmergencyCap`        | `key, store`          | Global. `set_loss`, `withdraw_reward`.                                                                                                                 |
| `PauseCap`            | `key` only            | Global, pause-only; the absence of `store` makes it non-transferable by the holder. No admin revocation path; the holder may call `destroy_pause_cap`. |
| `AllocatorCap`        | `key, store`          | Bound to one `vault_id`.                                                                                                                               |
| `CuratorCap`          | `key, store`          | Bound to one `vault_id`.                                                                                                                               |
| `ManagementFeeCap`    | `key, store`          | Bound to one `vault_id`.                                                                                                                               |
| `PerformanceFeeCap`   | `key, store`          | Bound to one `vault_id`.                                                                                                                               |

#### 10.2 Constants

| Name                         | Value        | Description                                     |
| ---------------------------- | ------------ | ----------------------------------------------- |
| `TIMELOCK_DURATION`          | `86_400`     | 24 hours, in seconds.                           |
| `TIMELOCK_GRACE_PERIOD`      | `604_800`    | 7 days. Execution window after unlock.          |
| `WAD`                        | `1e18`       | Fee and penalty scale.                          |
| `MAX_PENALTY`                | `0.3e18`     | 30%.                                            |
| `MAX_MANAGEMENT_FEE_APY_WAD` | `0.2e18`     | 20% annualized.                                 |
| `MAX_PERFORMANCE_FEE_WAD`    | `0.4e18`     | 40% of profit.                                  |
| `VIRTUAL_SHARES`             | `1_000_000`  | Inflation-attack offset.                        |
| `SECONDS_PER_YEAR`           | `31_536_000` | Management-fee denominator.                     |
| RAY                          | `1e27`       | Reward index and rate scale (`math::ray_math`). |

#### 10.3 Depositor entrypoints

**`create_receipt`**

```move
public fun create_receipt<CoinType>(
    self: &Vault<CoinType>,
    ctx: &mut TxContext,
): Receipt
```

Optional; `deposit` mints a Receipt when passed `option::none`. Emits `CreateReceiptEvent`.

Receipts minted implicitly inside `deposit` do **not** emit `CreateReceiptEvent`; indexers must treat `DepositEvent.receipt_id` as the authoritative source for position creation.

**`deposit`**

```move
public fun deposit<CoinType>(
    self: &mut Vault<CoinType>,
    receipt_opt: Option<Receipt>,
    clock: &Clock,
    storage: &mut Storage,
    pool: &mut Pool<CoinType>,
    deposit_coin: Coin<CoinType>,
    amount: u64,
    incentive_v2: &mut IncentiveV2,
    incentive_v3: &mut IncentiveV3,
    ctx: &mut TxContext,
): (Receipt, u64)   // (receipt, shares_minted)
```

Preconditions: all markets synchronized and all active market rules harvested in the current block.

Execution order: version check → freshness assertions → `update_vault_native_indices` → `accrue_interest` → vault cap check → share computation against pre-deposit state → asset routing → share credit.

Emits `DepositEvent`, whose `pool_address` is `0x0` when assets were routed to the idle balance.

**`withdraw`**

```move
public fun withdraw<CoinType>(
    self: &mut Vault<CoinType>,
    receipt: &mut Receipt,
    clock: &Clock,
    oracle: &PriceOracle,
    storage: &mut Storage,
    pool: &mut Pool<CoinType>,
    amount: u64,
    max_shares: u64,
    from_default: bool,
    incentive_v2: &mut IncentiveV2,
    incentive_v3: &mut IncentiveV3,
    system_state: &mut SuiSystemState,
    ctx: &mut TxContext,
): (Coin<CoinType>, u64)   // (coin_out, shares_burned)
```

Preconditions: all markets synchronized and all active market rules harvested in the current block. Semantics of `amount`, `max_shares` and `from_default` are specified in §5.2.

Emits `WithdrawEvent`.

**`claim_reward`**

```move
public fun claim_reward<CoinType, RewardCoinType>(
    self: &mut Vault<CoinType>,
    receipt: &mut Receipt,
    clock: &Clock,
    ctx: &mut TxContext,
): Coin<RewardCoinType>
```

No freshness preconditions. Settles vault-native indices, then pays `reward_total − reward_claimed` summed across all rules denominated in `RewardCoinType`.

Returns a zero-valued coin, without aborting, when the Receipt has no `UserState`, the reward bag holds no entry for the coin type, the bag balance is zero, or nothing is claimable. If the bag holds less than the computed entitlement the payout is refused outright rather than partially settled. Emits `ClaimRewardEvent`.

#### 10.4 Block support entrypoints

Both are permissionless.

**`sync_market_balance`**

```move
public fun sync_market_balance<CoinType>(
    self: &mut Vault<CoinType>,
    storage: &mut Storage,
    pool: &Pool<CoinType>,
    clock: &Clock,
)
```

Calls `lending::update_state_of_user`, reads `get_user_balance × supply_index`, converts from NAVI's 9-decimal representation to native decimals, subtracts converted `loss` with a floor of zero, and writes `current_balance` and `last_sync_at = clock.timestamp_ms()`.

The market is identified by `object::id(pool)`; `storage` must match the market's recorded `storage_address`. Emits `SyncMarketBalanceEvent`.

**`collect_reward`**

```move
public fun collect_reward<CoinType, RewardCoinType>(
    self: &mut Vault<CoinType>,
    clock: &Clock,
    storage: &mut Storage,
    incentive_v3: &mut IncentiveV3,
    reward_fund: &mut incentive_v3::RewardFund<RewardCoinType>,
    rule_index: u64,
)
```

Harvests the NAVI incentive for one rule into `collected_rewards`, advances `vault_reward_index` by `harvested × RAY / total_shares`, and sets `last_harvest_at = clock.timestamp_ms()`.

Returns without effect when the rule is vault-native or inactive. When `total_shares == 0` the harvested amount enters the reward bag without index growth; the resulting dust is unclaimable. Emits `CollectRewardEvent`.

#### 10.5 View functions

```move
// Position
public fun get_user_shares<CoinType>(&Vault<CoinType>, receipt: &Receipt): u64
public fun get_user_balance<CoinType>(&Vault<CoinType>, receipt: &Receipt): u64
public fun get_user_claimable_reward_amount<CoinType, RewardCoinType>(&Vault<CoinType>, receipt: &Receipt): u64

// Conversion
public fun convert_to_assets<CoinType>(&Vault<CoinType>, shares: u64): u64   // floor
public fun convert_to_shares<CoinType>(&Vault<CoinType>, assets: u64): u64   // floor

// Vault state
public fun get_total_assets<CoinType>(&Vault<CoinType>): u64
public fun get_total_shares<CoinType>(&Vault<CoinType>): u64
public fun get_idle_balance<CoinType>(&Vault<CoinType>): u64
public fun get_vault_cap<CoinType>(&Vault<CoinType>): u64
public fun get_vault_default_market<CoinType>(&Vault<CoinType>): address
public fun get_management_fee<CoinType>(&Vault<CoinType>): u64
public fun get_performance_fee<CoinType>(&Vault<CoinType>): u64
public fun get_baseline_total_assets<CoinType>(&Vault<CoinType>): u64
public fun get_last_update_timestamp<CoinType>(&Vault<CoinType>): u64
public fun get_pending_management_fee_shares<CoinType>(&Vault<CoinType>): u64
public fun get_pending_performance_fee_shares<CoinType>(&Vault<CoinType>): u64
public fun is_paused<CoinType>(&Vault<CoinType>): bool
public fun version<CoinType>(&Vault<CoinType>): u64

// Market enumeration
public fun num_markets<CoinType>(&Vault<CoinType>): u64
public fun get_market_address_at_index<CoinType>(&Vault<CoinType>, idx: u64): address
public fun get_market_info<CoinType>(&Vault<CoinType>, pool_address: address): u64
public fun get_market_config<CoinType>(&Vault<CoinType>, pool_address: address)
    : (u64, u64, u64, u8, u64, address, u8, address, address)

// Reward enumeration
public fun num_reward_rules<CoinType>(&Vault<CoinType>): u64
public fun get_rule_info<CoinType>(&Vault<CoinType>, idx: u64)
    : (address, String, address, u256, u64, bool, u256, bool, u64, u64)
public fun get_collected_reward_balance<CoinType, RewardCoinType>(&Vault<CoinType>): u64
public fun num_allocator_caps(&Allocators): u64
```

`get_user_balance` and `get_user_claimable_reward_amount` return `0` for a Receipt belonging to a different vault rather than aborting. `convert_to_assets` and `convert_to_shares` return `0` when the input is `0` or when `total_shares == 0`. `get_market_info` returns `0` for an unregistered pool address.

`version_verification<CoinType>(&Vault<CoinType>)` is also public. It is an assertion helper, not a getter: it returns nothing and aborts with `E_INCORRECT_VERSION` on mismatch. No other view function performs a version check (§9).

`get_market_config` return positions:

| Position | Field                  | Type                            |
| -------: | ---------------------- | ------------------------------- |
|        0 | `cap`                  | `u64`                           |
|        1 | `penalty`              | `u64` (WAD)                     |
|        2 | `loss`                 | `u64` (9-decimal)               |
|        3 | `status`               | `u8` — 0 `Active`, 1 `Disabled` |
|        4 | `last_sync_at`         | `u64` (ms)                      |
|        5 | `storage_address`      | `address`                       |
|        6 | `asset_id`             | `u8`                            |
|        7 | `incentive_v3_address` | `address`                       |
|        8 | `incentive_v2_address` | `address`                       |

Aborts `E_MARKET_NOT_FOUND` for an unregistered pool address. `get_market_address_at_index` aborts `E_RULE_INDEX_OUT_OF_BOUNDS` when `idx ≥ num_markets`.

`get_rule_info` return positions:

| Position | Field                      | Type                |
| -------: | -------------------------- | ------------------- |
|        0 | `navi_pool_id`             | `address`           |
|        1 | `reward_coin_type`         | `String`            |
|        2 | `incentive_rule_id`        | `address`           |
|        3 | `vault_reward_index`       | `u256` (RAY)        |
|        4 | `last_harvest_at`          | `u64` (ms)          |
|        5 | `is_vault_native`          | `bool`              |
|        6 | `reward_rate`              | `u256` (RAY per ms) |
|        7 | `is_active`                | `bool`              |
|        8 | `total_reward_deposited`   | `u64`               |
|        9 | `total_reward_distributed` | `u64`               |

#### 10.6 Operator entrypoints

Not required for depositor-facing integration.

**Allocator** — `AllocatorCap` registered in the shared `Allocators` object and bound to the vault:

```move
public fun allocate<CoinType>(
    self: &mut Vault<CoinType>, allocators: &Allocators, cap: &AllocatorCap, clock: &Clock,
    storage: &mut Storage, pool: &mut Pool<CoinType>, amount: u64,
    incentive_v2: &mut IncentiveV2, incentive_v3: &mut IncentiveV3, ctx: &mut TxContext,
)

public fun deallocate<CoinType>(
    self: &mut Vault<CoinType>, allocators: &Allocators, cap: &AllocatorCap, clock: &Clock,
    oracle: &PriceOracle, storage: &mut Storage, pool: &mut Pool<CoinType>, amount: u64,
    incentive_v2: &mut IncentiveV2, incentive_v3: &mut IncentiveV3,
    system_state: &mut SuiSystemState, ctx: &mut TxContext,
)
```

Both require the target market to be synchronized in the current block (`E_MARKET_NOT_READ`) and the capability to be registered (`E_UNAUTHORIZED`) and bound to the vault (`E_VAULT_MISMATCH`). `allocate` additionally requires `Active` status and respects the market cap. `deallocate` credits the amount actually received to the idle balance.

**Fee recipient**:

```move
public fun claim_management_fee<CoinType>(&mut Vault<CoinType>, cap: &ManagementFeeCap, receipt: &Receipt, clock: &Clock)
public fun claim_performance_fee<CoinType>(&mut Vault<CoinType>, cap: &PerformanceFeeCap, receipt: &Receipt, clock: &Clock)
```

**Curator** — immediate effect:

```move
public fun set_vault_cap_by_curator<CoinType>(&mut Vault<CoinType>, &Curators, &CuratorCap, vault_cap: u64)
public fun set_market_cap_and_penalty_by_curator<CoinType>(&mut Vault<CoinType>, &Curators, &CuratorCap, pool_address: address, cap: u64, penalty: u64)
public fun create_reward_rule_by_curator<CoinType, RewardCoinType>(&mut Vault<CoinType>, &Curators, &CuratorCap, navi_pool_id: address, incentive_rule_id: address)
public fun disable_reward_rule_by_curator<CoinType, RewardCoinType>(&mut Vault<CoinType>, &Curators, &CuratorCap, clock: &Clock, navi_pool_id: address)
public fun deposit_reward_balance_by_curator<CoinType, RewardCoinType>(&mut Vault<CoinType>, &Curators, &CuratorCap, coin: Coin<RewardCoinType>, _ctx: &mut TxContext)
public fun set_reward_rate_by_curator<CoinType, RewardCoinType>(&mut Vault<CoinType>, &Curators, &CuratorCap, clock: &Clock, total_supply: u64, duration_ms: u64)
```

**Curator** — timelocked:

```move
public fun propose_management_fee<CoinType>(&Vault<CoinType>, &Curators, &CuratorCap, &mut Timelocks<CoinType>, &Clock, new_fee: u64)
public fun exec_management_fee<CoinType>(&mut Vault<CoinType>, &mut Timelocks<CoinType>, &Clock)

public fun propose_performance_fee<CoinType>(&Vault<CoinType>, &Curators, &CuratorCap, &mut Timelocks<CoinType>, &Clock, new_fee: u64)
public fun exec_performance_fee<CoinType>(&mut Vault<CoinType>, &mut Timelocks<CoinType>, &Clock)

public fun propose_default_market<CoinType>(&Vault<CoinType>, &Curators, &CuratorCap, &mut Timelocks<CoinType>, &Clock, pool_address: address)
public fun exec_default_market<CoinType>(&mut Vault<CoinType>, &mut Timelocks<CoinType>, &Clock)

public fun propose_set_market_status<CoinType>(&Vault<CoinType>, &Curators, &CuratorCap, &mut Timelocks<CoinType>, &Clock, pool_address: address, target_status: u8)
public fun exec_set_market_status<CoinType>(&mut Vault<CoinType>, &mut Timelocks<CoinType>, &Clock, pool_address: address)

public fun propose_add_market<CoinType>(&Vault<CoinType>, &Curators, &CuratorCap, &mut Timelocks<CoinType>, &Clock,
    pool_address: address, cap: u64, storage_address: address, asset_id: u8,
    incentive_v3_address: address, incentive_v2_address: address, penalty: u64)
public fun exec_add_market<CoinType>(&mut Vault<CoinType>, &mut Timelocks<CoinType>, &Clock,
    &Storage, &Pool<CoinType>, &IncentiveV2, &IncentiveV3, ctx: &mut TxContext)

public fun cancel_proposal<CoinType>(&Vault<CoinType>, &Curators, &CuratorCap, &mut Timelocks<CoinType>, proposal_type_code: u8, subject: address)
```

`exec_management_fee` and `exec_performance_fee` require all markets synchronized and all active market rules harvested in the same block, because they settle fees at the prior rate before applying the new one. `exec_add_market` requires no capability: all applied values originate from the stored proposal, and the supplied objects are verified against it.

**Admin** — `AdminCap`, immediate effect:

```move
public fun create_vault<CoinType>(&AdminCap, ctx: &mut TxContext)
public fun version_migrate<CoinType>(&AdminCap, &mut Vault<CoinType>)
public fun set_paused<CoinType>(&mut Vault<CoinType>, &AdminCap, paused: bool)
public fun mint_pause_cap(&AdminCap, recipient: address, ctx: &mut TxContext)
public fun set_default_market_by_admin<CoinType>(&mut Vault<CoinType>, &AdminCap, pool_address: address)
public fun set_market_status_by_admin<CoinType>(&mut Vault<CoinType>, &AdminCap, pool_address: address, target_status: u8)
public fun set_management_fee_by_admin<CoinType>(&mut Vault<CoinType>, &AdminCap, new_fee: u64, clock: &Clock)
public fun set_performance_fee_by_admin<CoinType>(&mut Vault<CoinType>, &AdminCap, new_fee: u64, clock: &Clock)
public fun set_market_cap_and_penalty_by_admin<CoinType>(&mut Vault<CoinType>, &AdminCap, pool_address: address, cap: u64, penalty: u64)
public fun add_market_by_admin<CoinType>(&mut Vault<CoinType>, &AdminCap, &Storage, &Pool<CoinType>, &IncentiveV2, &IncentiveV3, cap: u64, asset_id: u8, penalty: u64, ctx: &mut TxContext)
public fun cancel_proposal_by_admin<CoinType>(&Vault<CoinType>, &AdminCap, &mut Timelocks<CoinType>, proposal_type_code: u8, subject: address)
public fun create_allocators(&AdminCap, ctx: &mut TxContext)
public fun add_allocator<CoinType>(&AdminCap, &mut Allocators, &Vault<CoinType>, recipient: address, ctx: &mut TxContext)
public fun remove_allocator<CoinType>(&AdminCap, &mut Allocators, &Vault<CoinType>, cap_id: ID)
public fun create_curators<CoinType>(&AdminCap, &Vault<CoinType>, ctx: &mut TxContext)
public fun add_curator<CoinType>(&AdminCap, &mut Curators, &Vault<CoinType>, recipient: address, ctx: &mut TxContext)
public fun remove_curator(&AdminCap, &mut Curators, cap_id: ID)
public fun create_reward_rule_by_admin<CoinType, RewardCoinType>(&mut Vault<CoinType>, &AdminCap, navi_pool_id: address, incentive_rule_id: address)
public fun disable_reward_rule_by_admin<CoinType, RewardCoinType>(&mut Vault<CoinType>, &AdminCap, clock: &Clock, navi_pool_id: address, is_forced: bool)
public fun deposit_reward_balance_by_admin<CoinType, RewardCoinType>(&mut Vault<CoinType>, &AdminCap, coin: Coin<RewardCoinType>, _ctx: &mut TxContext)
public fun set_reward_rate_by_admin<CoinType, RewardCoinType>(&mut Vault<CoinType>, &AdminCap, clock: &Clock, total_supply: u64, duration_ms: u64)
```

`set_management_fee_by_admin` and `set_performance_fee_by_admin` carry the same freshness preconditions as the corresponding `exec_*` functions.

**Pause guardian** — `PauseCap`:

```move
public fun pause_by_cap<CoinType>(&mut Vault<CoinType>, &PauseCap)
public fun destroy_pause_cap(cap: PauseCap, ctx: &mut TxContext)
```

`pause_by_cap` can only set `paused = true`; lifting the pause requires `AdminCap`.

**Emergency** — `EmergencyCap`:

```move
public fun set_loss<CoinType>(&mut Vault<CoinType>, &EmergencyCap, pool_address: address, loss: u64)
public fun withdraw_reward<CoinType, RewardCoinType>(&mut Vault<CoinType>, &EmergencyCap, clock: &Clock, rule_index: u64, amount: u64, ctx: &mut TxContext): Coin<RewardCoinType>
```

`set_loss` takes `loss` in NAVI's 9-decimal representation (§1.2) and resets `last_sync_at` to `0`. `withdraw_reward` reaches only the undistributed remainder of a vault-native rule, `total_reward_deposited − total_reward_distributed`, and reduces `total_reward_deposited` accordingly.

The `*_for_testing` helpers present in the module source are annotated `#[test_only]` and are absent from the published package and its ABI.

#### 10.7 Error codes

|  Code | Name                               | Cause                                                                                   |
| ----: | ---------------------------------- | --------------------------------------------------------------------------------------- |
| 10001 | `E_UNAUTHORIZED`                   | `allocate` or `deallocate` with an `AllocatorCap` absent from the registry.             |
| 10002 | `E_INSUFFICIENT_BALANCE`           | Insufficient shares, market balance, idle balance, or reward bag balance.               |
| 10003 | `E_MARKET_NOT_FOUND`               | Pool address is not a registered market.                                                |
| 10004 | `E_TIMELOCK_NOT_EXPIRED`           | `exec_*` before the unlock time.                                                        |
| 10005 | `E_CAP_EXCEEDED`                   | Market deposit cap exceeded.                                                            |
| 10006 | `E_MARKET_NOT_READ`                | `sync_market_balance` missing for a market in the current block.                        |
| 10007 | `E_REWARDS_NOT_COLLECTED`          | `collect_reward` missing for an active market rule.                                     |
| 10008 | `E_RECEIPT_NOT_FOUND`              | Receipt has no `UserState`; it has never held a deposit.                                |
| 10009 | `E_VAULT_MISMATCH`                 | Receipt, capability or `Timelocks` object belongs to a different vault.                 |
| 10010 | `E_MARKET_INVALID`                 | Target market is not `Active`.                                                          |
| 10011 | `E_PAUSED`                         | Vault is paused.                                                                        |
| 10012 | `E_DEPOSIT_TOO_SMALL`              | Deposit would mint zero shares.                                                         |
| 10013 | `E_OVERFLOW`                       | Numeric bound exceeded.                                                                 |
| 10014 | `E_INVALID_PENALTY`                | Recorded penalty exceeds `MAX_PENALTY`.                                                 |
| 10015 | `E_ALLOCATOR_CAP_NOT_FOUND`        | `remove_allocator` for an unregistered capability.                                      |
| 10016 | `E_PROPOSAL_NOT_FOUND`             | No proposal for the given type and subject.                                             |
| 10017 | `E_MARKET_CONFIG_MISMATCH`         | `Storage`, `IncentiveV2` or `IncentiveV3` does not match the market's recorded address. |
| 10018 | `E_TIMELOCK_EXPIRED`               | Grace period elapsed.                                                                   |
| 10019 | `E_CURATOR_CAP_NOT_VALID`          | `CuratorCap` absent from the registry.                                                  |
| 10020 | `E_CURATOR_CAP_NOT_FOUND`          | `remove_curator` for an unregistered capability.                                        |
| 10021 | `E_AMOUNT_MISMATCH`                | `deposit_coin.value() ≠ amount`.                                                        |
| 10022 | `E_DEFAULT_MARKET_MISMATCH`        | Pool is not the default market on a path that requires it.                              |
| 10023 | `E_PROPOSAL_ALREADY_EXISTS`        | A live proposal already exists for the type and subject.                                |
| 10024 | `E_PROPOSAL_NOOP`                  | Proposal would produce no change.                                                       |
| 10025 | `E_WRONG_PROPOSAL_TYPE`            | Proposal type code does not match the stored proposal.                                  |
| 10026 | `E_RULE_NOT_FOUND`                 | Reward rule absent, or not vault-native where required.                                 |
| 10027 | `E_RULE_ALREADY_ACTIVE`            | Rule is already active, or collides with a vault-native rule.                           |
| 10028 | `E_RULE_ALREADY_INACTIVE`          | Rule is already disabled.                                                               |
| 10029 | `E_REWARD_COIN_TYPE_MISMATCH`      | `RewardCoinType` differs from the rule's recorded coin type.                            |
| 10030 | `E_RULE_INDEX_OUT_OF_BOUNDS`       | Rule or market index out of range.                                                      |
| 10031 | `E_FEE_OUT_OF_RANGE`               | Fee exceeds its maximum.                                                                |
| 10032 | `E_INVALID_STATUS`                 | `target_status` greater than 1.                                                         |
| 10033 | `E_FEE_UNCHANGED`                  | New fee equals the current fee.                                                         |
| 10034 | `E_INVALID_AMOUNT`                 | Amount is zero, or an internal amount invariant is violated.                            |
| 10035 | `E_MARKET_ALREADY_EXISTS`          | Market addition for an existing pool address.                                           |
| 10036 | `E_INCORRECT_VERSION`              | Vault not migrated after a package upgrade.                                             |
| 10037 | `E_VERSION_UPGRADE_NOT_NEEDED`     | `version_migrate` on an already-current vault.                                          |
| 10038 | `E_ACCOUNTING_ERROR`               | Internal invariant violation.                                                           |
| 10039 | `E_VAULT_CAP_EXCEEDED`             | Vault-level deposit cap exceeded.                                                       |
| 10040 | `E_SLIPPAGE_EXCEEDED`              | Shares burned exceed `max_shares`.                                                      |
| 10041 | `E_CANNOT_DISABLE_DEFAULT_MARKET`  | Attempt to disable the current default market.                                          |
| 10042 | `E_WITHDRAW_EXCEEDS_UNDISTRIBUTED` | `withdraw_reward` exceeds the undistributed remainder.                                  |

Aborts in the `10001`–`10042` range originate in `navi_vault`. Calls that reach the lending and oracle packages can also abort with their codes, which occupy a different range. Those an integrator encounters on normal paths are:

| Code | Origin                         | Cause                                                                                                                                                                                                                                                                                                        |
| ---: | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1400 | `incorrect_version`            | Any call reaching `lending_core` while the vault package is linked against an older `lending_core` than the live `Storage.version` (§3.4). Requires a vault package upgrade; not recoverable client-side. Raised in a module named `version`, like the vault's own 10036 — discriminate on code and package. |
| 1502 | `invalid_price`                | `withdraw` or `deallocate` while the oracle price for the asset is outside `update_interval` (§3.3). Transient; prevented by the price update in §5.6.                                                                                                                                                       |
| 1506 | `insufficient_balance`         | `withdraw` or `deallocate` while the reserve's utilization leaves insufficient free liquidity (§3.3). Transient; distinct from `E_INSUFFICIENT_BALANCE` (10002).                                                                                                                                             |
| 1604 | `exceeded_maximum_deposit_cap` | `deposit` or `allocate` while NAVI's reserve supply ceiling for the asset is reached (§3.3), independently of the vault and market caps.                                                                                                                                                                     |

Codes are from `lending_core::error`. This is not an exhaustive enumeration of lending-side aborts; it covers the constraints reachable on the deposit, withdrawal and allocation paths.

#### 10.8 Events

Module `navi_vault::events`. All events carry `vault: address` except where noted.

| Event                         | Fields                                                                                                                   |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `CreateVaultEvent`            | `vault`, `sender`                                                                                                        |
| `CreateReceiptEvent`          | `vault`, `sender`, `receipt_id`                                                                                          |
| `DepositEvent`                | `vault`, `sender`, `receipt_id`, `pool_address` (`0x0` when routed to idle), `amount`, `shares`                          |
| `WithdrawEvent`               | `vault`, `sender`, `receipt_id`, `pool_address` (`0x0` when served entirely from idle), `amount`, `shares_burned`        |
| `ClaimRewardEvent`            | `vault`, `sender`, `receipt_id`, `reward_coin_type`, `amount`                                                            |
| `CollectRewardEvent`          | `vault`, `rule_index`, `reward_coin_type`, `amount`                                                                      |
| `SyncMarketBalanceEvent`      | `vault`, `pool_address`, `current_balance`                                                                               |
| `AllocateEvent`               | `vault`, `sender`, `pool_address`, `amount`                                                                              |
| `DeallocateEvent`             | `vault`, `sender`, `pool_address`, `amount`                                                                              |
| `ClaimManagementFeeEvent`     | `vault`, `receipt_id`, `shares`                                                                                          |
| `ClaimPerformanceFeeEvent`    | `vault`, `receipt_id`, `shares`                                                                                          |
| `ProposalCreatedEvent`        | `vault`, `proposal_type`, `subject`, `executable_at` (seconds)                                                           |
| `ProposalExecutedEvent`       | `vault`, `proposal_type`, `subject`                                                                                      |
| `ProposalCancelledEvent`      | `vault`, `proposal_type`, `subject`                                                                                      |
| `SetDefaultMarketEvent`       | `vault`, `pool_address`                                                                                                  |
| `SetMarketStatusEvent`        | `vault`, `pool_address`, `target_status`                                                                                 |
| `SetManagementFeeEvent`       | `vault`, `old_fee`, `new_fee`                                                                                            |
| `SetPerformanceFeeEvent`      | `vault`, `old_fee`, `new_fee`                                                                                            |
| `AddMarketEvent`              | `vault`, `pool_address`, `cap`, `penalty`, `storage_address`, `asset_id`, `incentive_v3_address`, `incentive_v2_address` |
| `SetVaultCapEvent`            | `vault`, `vault_cap`                                                                                                     |
| `SetMarketCapAndPenaltyEvent` | `vault`, `pool_address`, `cap`, `penalty`                                                                                |
| `SetPausedEvent`              | `vault`, `paused`                                                                                                        |
| `SetLossEvent`                | `vault`, `pool_address`, `loss` (9-decimal)                                                                              |
| `AllocatorAddedEvent`         | `vault`, `cap_id`, `recipient`                                                                                           |
| `AllocatorRemovedEvent`       | `cap_id`                                                                                                                 |
| `CuratorAddedEvent`           | `vault`, `cap_id`, `recipient`                                                                                           |
| `CuratorRemovedEvent`         | `cap_id`                                                                                                                 |
| `PauseCapMintedEvent`         | `cap_id`, `recipient`, `sender`                                                                                          |
| `PauseCapDestroyedEvent`      | `cap_id`, `sender`                                                                                                       |
| `RewardRuleCreatedEvent`      | `vault`, `navi_pool_id`, `reward_coin_type`, `incentive_rule_id`                                                         |
| `RewardRuleReactivatedEvent`  | `vault`, `navi_pool_id`, `reward_coin_type`, `incentive_rule_id`                                                         |
| `RewardRuleDisabledEvent`     | `vault`, `navi_pool_id`, `reward_coin_type`                                                                              |
| `DepositRewardBalanceEvent`   | `vault`, `reward_coin_type`, `amount`                                                                                    |
| `SetRewardRateEvent`          | `vault`, `reward_coin_type`, `total_supply`, `duration_ms`, `rate` (RAY per ms)                                          |
| `WithdrawRewardEvent`         | `vault`, `reward_coin_type`, `amount`                                                                                    |

`proposal_type` codes: `1` management fee, `2` performance fee, `3` default market, `4` market addition, `5` market status.

***

### 11. Integration requirements

The following are mandatory for correct integration.

1. Market and reward-rule lists are read from vault state at transaction-construction time, not cached across transactions (§3.2).
2. Deposit and withdrawal blocks include `sync_market_balance` for every registered market, including those in `Disabled` status (§3.1).
3. Deposit and withdrawal blocks include `collect_reward` for every rule with `is_active && !is_vault_native` (§3.1).
4. Balances, share prices and quotes are produced from a simulated block that synchronizes before reading (§6.1).
5. `max_shares` is computed from a simulated withdrawal and is non-zero (§5.2).
6. Full exits use `from_default = true` with a sufficiently large amount, and tolerate a dust remainder (§2, §5.2).
7. The deposit coin is split to exactly `amount` (§5.1).
8. Returned `Receipt` and `Coin` values are consumed by the transaction; zero-valued reward coins are merged or destroyed (§5.1, §5.3).
9. `is_paused` and `version` are evaluated before constructing a transaction (§9).
10. Withdrawal and deallocation blocks begin with an oracle price update, before the market synchronizations (§3.3, §5.6).
11. Abort 1400 is surfaced as a protocol outage, not a user error, and no synced balance is displayed nor deposit accepted while it persists (§3.4).
12. Aborts 1502, 1506 and 1604 are distinguished from the vault's own codes and presented as transient or external, not as malformed requests (§3.3, §10.7).
13. Displayed yields account for fee-share dilution, and displayed withdrawal costs account for the market penalty (§5.2, §7).
14. Identifiers from §1.3 are supplied as configuration, with an update path for the call-target package across upgrades, and the call target and type identity are not interchanged (§1.3, §9).

***

### 12. Appendix A — TypeScript reference implementation

Reference implementation of discovery, transaction construction and simulation-based pricing. Requires `@mysten/sui` version 1; checked against 1.45.2 under `tsc --strict`. The code blocks in this appendix concatenate in order into a single compilable module.

Verification status, as of 2026-08-05 against mainnet:

| Function                                                  | Status                                                                                                                                                     |
| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `readVaultLayout`                                         | Executed against every live vault; market and reward-rule decoding confirmed                                                                               |
| `findReceipts`                                            | Executed; attribution by `vault_address` confirmed, including the silent empty result when the type filter uses the latest package instead of the original |
| `quoteVault`                                              | Executed against every live vault, synchronizing first                                                                                                     |
| `previewWithdraw`                                         | Executed with a live position, with and without `oraclePrologue`; omitting it aborts 1502 as specified in §3.3                                             |
| `previewClaimReward`                                      | Executed against a live position with an active reward rule                                                                                                |
| `buildDepositTx`, `buildWithdrawTx`, `buildClaimRewardTx` | Command order and argument positions match the executed mainnet transactions in §5.6; **never submitted as signed transactions**                           |

`buildWithdrawTx` requires an `oraclePrologue` callback for the price update described in §3.3. The oracle entrypoint is deliberately not hardcoded: its revision advances, and a pinned name breaks when the oracle is upgraded.

#### A.1 Types and configuration

```ts
import { bcs } from '@mysten/sui/bcs';
import type { SuiClient } from '@mysten/sui/client';
import { Transaction } from '@mysten/sui/transactions';
import type { TransactionObjectArgument, TransactionResult } from '@mysten/sui/transactions';

const CLOCK = '0x6';
const SUI_SYSTEM_STATE = '0x5';
const U64_MAX = 18446744073709551615n;

/** WAD = 1e18. Fee rates and market penalties are scaled by this factor. */
export const WAD = 1_000_000_000_000_000_000n;

export interface VaultConfig {
  /**
   * LATEST published navi_vault package. Used as the `target` of every moveCall.
   * Changes on every upgrade; calling a superseded package runs superseded code (§1.3).
   */
  packageId: string;
  /**
   * ORIGINAL navi_vault package. Used for every type string — owned-object filters, event
   * filters, objectType matching. Fixed for the lifetime of the deployment.
   * Using `packageId` here yields silent empty results (§1.3).
   */
  typePackageId: string;
  /** Shared Vault<CoinType> object identifier. */
  vaultId: string;
  /** Fully qualified CoinType, e.g. '0x2::sui::SUI'. */
  coinType: string;
  /** NAVI PriceOracle shared object; required by withdraw. */
  oracleId: string;
  /**
   * RewardFund<T> object per reward coin type. These objects belong to NAVI's incentive_v3 and
   * are not recorded in vault state; resolve them from the lending side and cache them.
   * Keys are canonicalized before lookup, so either `0x2::sui::SUI` or the padded form works.
   */
  rewardFunds: Record<string, string>;
}

export interface MarketLayout {
  poolId: string;
  cap: bigint;
  penalty: bigint;
  loss: bigint;
  /** 0 = Active, 1 = Disabled. Disabled markets still require synchronization. */
  status: number;
  lastSyncAtMs: bigint;
  storageId: string;
  assetId: number;
  incentiveV3Id: string;
  incentiveV2Id: string;
}

export interface RewardRuleLayout {
  index: number;
  naviPoolId: string;
  rewardCoinType: string;
  incentiveRuleId: string;
  vaultRewardIndex: bigint;
  lastHarvestAtMs: bigint;
  isVaultNative: boolean;
  rewardRate: bigint;
  isActive: boolean;
  totalRewardDeposited: bigint;
  totalRewardDistributed: bigint;
}

export interface VaultLayout {
  markets: MarketLayout[];
  rules: RewardRuleLayout[];
  /** 0x0 indicates deposits are routed to the idle balance. */
  defaultMarket: string;
  paused: boolean;
  version: bigint;
  vaultCap: bigint;
  managementFee: bigint;
  performanceFee: bigint;
}
```

#### A.2 Simulation helper

```ts
type Decoded = unknown[];

/**
 * Evaluate a read-only block and return the decoded return values of each Move call, in order.
 * Calls that return nothing yield an empty array.
 */
async function inspect(client: SuiClient, tx: Transaction, sender: string): Promise<Decoded[]> {
  const res = await client.devInspectTransactionBlock({ sender, transactionBlock: tx });
  if (res.error) throw new Error(`devInspect failed: ${res.error}`);
  return (res.results ?? []).map((r) =>
    (r.returnValues ?? []).map(([bytes, type]) => decode(Uint8Array.from(bytes), type)),
  );
}

function decode(bytes: Uint8Array, type: string): unknown {
  switch (type) {
    case 'bool':
      return bcs.Bool.parse(bytes);
    case 'u8':
      return bcs.U8.parse(bytes);
    case 'u64':
      return BigInt(bcs.U64.parse(bytes));
    case 'u256':
      return BigInt(bcs.U256.parse(bytes));
    case 'address':
      return bcs.Address.parse(bytes);
    default:
      // std::ascii::String and std::string::String are structs { bytes: vector<u8> }, which share
      // the BCS layout of a string. The reported address may be padded, so match on the suffix.
      if (type.endsWith('::ascii::String') || type.endsWith('::string::String')) {
        return bcs.string().parse(bytes);
      }
      throw new Error(`unhandled return type: ${type}`);
  }
}

/**
 * List an account's positions in one vault.
 *
 * `Receipt` is not generic: one type covers every vault, so the type filter alone cannot tell them
 * apart — each Receipt's `vault_address` must be matched against the vault of interest. This
 * matters concretely when two vaults share a CoinType, which is permitted (§1.3).
 *
 * The type filter uses `typePackageId`, not `packageId`. Using the latest package matches nothing
 * and reports no error.
 */
export async function findReceipts(
  client: SuiClient,
  cfg: VaultConfig,
  owner: string,
): Promise<string[]> {
  const receiptType = `${cfg.typePackageId}::navi_vault::Receipt`;
  const found: string[] = [];
  let cursor: string | null | undefined;

  do {
    const page = await client.getOwnedObjects({
      owner,
      cursor,
      filter: { StructType: receiptType },
      options: { showContent: true },
    });
    for (const obj of page.data) {
      const content = obj.data?.content;
      if (content?.dataType !== 'moveObject') continue;
      const fields = content.fields as { vault_address?: string };
      // Receipts for other vaults share this exact type — attribution is mandatory.
      if (fields.vault_address === cfg.vaultId && obj.data?.objectId) {
        found.push(obj.data.objectId);
      }
    }
    cursor = page.hasNextPage ? page.nextCursor : null;
  } while (cursor);

  return found;
}

/**
 * Canonicalize a Move type string for comparison against contract-stored values.
 *
 * `get_rule_info` returns `reward_coin_type` as produced by
 * `type_name::with_defining_ids<T>().into_string()`, whose address segment is zero-padded to 64
 * hexadecimal characters and carries no `0x` prefix. A caller-supplied abbreviation such as
 * `0x2::sui::SUI` therefore does not compare equal to the stored
 * `0000…0002::sui::SUI`. Comparing without canonicalizing causes reward rules to go unmatched,
 * which omits their `collect_reward` call and silently pays out only previously harvested
 * rewards.
 */
function normalizeType(t: string): string {
  const parts = t.split('::');
  if (parts.length < 2) return t;
  const addr = parts[0].replace(/^0x/, '').toLowerCase();
  if (!/^[0-9a-f]{1,64}$/.test(addr)) return t;
  return [`0x${addr.padStart(64, '0')}`, ...parts.slice(1)].join('::');
}

/** Look up a RewardFund object, canonicalizing configured keys so abbreviations resolve. */
function rewardFundFor(cfg: VaultConfig, coinType: string): string | undefined {
  const wanted = normalizeType(coinType);
  for (const [key, id] of Object.entries(cfg.rewardFunds)) {
    if (normalizeType(key) === wanted) return id;
  }
  return undefined;
}
```

#### A.3 Discovery

```ts
/**
 * Read the vault's market list, reward rules and headline configuration.
 *
 * The result must not be cached across transactions: a curator may add a market or change a
 * market's status at any time, and a newly added market has last_sync_at = 0, which causes any
 * deposit or withdrawal omitting it to abort with E_MARKET_NOT_READ (10006).
 */
export async function readVaultLayout(
  client: SuiClient,
  cfg: VaultConfig,
  sender: string,
): Promise<VaultLayout> {
  const { packageId: p, vaultId, coinType: T } = cfg;

  const head = new Transaction();
  const v = () => head.object(vaultId);
  head.moveCall({ target: `${p}::navi_vault::num_markets`, typeArguments: [T], arguments: [v()] });
  head.moveCall({ target: `${p}::navi_vault::num_reward_rules`, typeArguments: [T], arguments: [v()] });
  head.moveCall({ target: `${p}::navi_vault::get_vault_default_market`, typeArguments: [T], arguments: [v()] });
  head.moveCall({ target: `${p}::navi_vault::is_paused`, typeArguments: [T], arguments: [v()] });
  head.moveCall({ target: `${p}::navi_vault::version`, typeArguments: [T], arguments: [v()] });
  head.moveCall({ target: `${p}::navi_vault::get_vault_cap`, typeArguments: [T], arguments: [v()] });
  head.moveCall({ target: `${p}::navi_vault::get_management_fee`, typeArguments: [T], arguments: [v()] });
  head.moveCall({ target: `${p}::navi_vault::get_performance_fee`, typeArguments: [T], arguments: [v()] });

  const h = await inspect(client, head, sender);
  const numMarkets = Number(h[0][0] as bigint);
  const numRules = Number(h[1][0] as bigint);

  const detail = new Transaction();
  for (let i = 0; i < numMarkets; i++) {
    detail.moveCall({
      target: `${p}::navi_vault::get_market_address_at_index`,
      typeArguments: [T],
      arguments: [detail.object(vaultId), detail.pure.u64(i)],
    });
  }
  for (let i = 0; i < numRules; i++) {
    detail.moveCall({
      target: `${p}::navi_vault::get_rule_info`,
      typeArguments: [T],
      arguments: [detail.object(vaultId), detail.pure.u64(i)],
    });
  }
  const d = numMarkets + numRules > 0 ? await inspect(client, detail, sender) : [];

  const poolIds = d.slice(0, numMarkets).map((r) => r[0] as string);
  const rules: RewardRuleLayout[] = d.slice(numMarkets).map((r, index) => ({
    index,
    naviPoolId: r[0] as string,
    rewardCoinType: normalizeType(r[1] as string),
    incentiveRuleId: r[2] as string,
    vaultRewardIndex: r[3] as bigint,
    lastHarvestAtMs: r[4] as bigint,
    isVaultNative: r[5] as boolean,
    rewardRate: r[6] as bigint,
    isActive: r[7] as boolean,
    totalRewardDeposited: r[8] as bigint,
    totalRewardDistributed: r[9] as bigint,
  }));

  const cfgTx = new Transaction();
  for (const poolId of poolIds) {
    cfgTx.moveCall({
      target: `${p}::navi_vault::get_market_config`,
      typeArguments: [T],
      arguments: [cfgTx.object(vaultId), cfgTx.pure.address(poolId)],
    });
  }
  const c = poolIds.length ? await inspect(client, cfgTx, sender) : [];

  const markets: MarketLayout[] = poolIds.map((poolId, i) => {
    const r = c[i];
    return {
      poolId,
      cap: r[0] as bigint,
      penalty: r[1] as bigint,
      loss: r[2] as bigint,
      status: r[3] as number,
      lastSyncAtMs: r[4] as bigint,
      storageId: r[5] as string,
      assetId: r[6] as number,
      incentiveV3Id: r[7] as string,
      incentiveV2Id: r[8] as string,
    };
  });

  return {
    markets,
    rules,
    defaultMarket: h[2][0] as string,
    paused: h[3][0] as boolean,
    version: h[4][0] as bigint,
    vaultCap: h[5][0] as bigint,
    managementFee: h[6][0] as bigint,
    performanceFee: h[7][0] as bigint,
  };
}
```

#### A.4 Freshness prologue

```ts
/**
 * Append the synchronization and harvest calls that deposit and withdraw assert on:
 * sync_market_balance for every market, Active and Disabled alike, and collect_reward for every
 * active, non-vault-native reward rule.
 *
 * All calls in one block observe the same clock.timestamp_ms(), which is how the contract
 * establishes that the refresh occurred in the same transaction.
 */
export function appendFreshness(tx: Transaction, cfg: VaultConfig, layout: VaultLayout): void {
  const { packageId: p, vaultId, coinType: T } = cfg;

  for (const m of layout.markets) {
    tx.moveCall({
      target: `${p}::navi_vault::sync_market_balance`,
      typeArguments: [T],
      arguments: [tx.object(vaultId), tx.object(m.storageId), tx.object(m.poolId), tx.object(CLOCK)],
    });
  }

  for (const rule of layout.rules) {
    // collect_reward has no effect for these rules; skipping them saves gas.
    if (rule.isVaultNative || !rule.isActive) continue;

    const market = layout.markets.find((m) => m.poolId === rule.naviPoolId);
    if (!market) {
      throw new Error(`rule ${rule.index} references unknown market ${rule.naviPoolId}`);
    }
    const fund = rewardFundFor(cfg, rule.rewardCoinType);
    if (!fund) {
      // Without the RewardFund object the rule cannot be harvested and the operation would abort
      // with E_REWARDS_NOT_COLLECTED. Fail here instead, with a diagnosable message.
      throw new Error(`no RewardFund configured for ${rule.rewardCoinType} (rule ${rule.index})`);
    }

    tx.moveCall({
      target: `${p}::navi_vault::collect_reward`,
      typeArguments: [T, rule.rewardCoinType],
      arguments: [
        tx.object(vaultId),
        tx.object(CLOCK),
        tx.object(market.storageId),
        tx.object(market.incentiveV3Id),
        tx.object(fund),
        tx.pure.u64(rule.index),
      ],
    });
  }
}

const ZERO_ADDRESS = `0x${'0'.repeat(64)}`;

function isUnset(addr: string): boolean {
  return addr === '0x0' || addr === ZERO_ADDRESS;
}

/** The market whose objects a deposit or withdrawal should be pointed at. */
function resolveTargetMarket(layout: VaultLayout, poolId?: string): MarketLayout {
  const wanted =
    poolId ?? (!isUnset(layout.defaultMarket) ? layout.defaultMarket : layout.markets[0]?.poolId);
  const m = layout.markets.find((x) => x.poolId === wanted);
  if (!m) throw new Error(`market ${wanted} is not registered on this vault`);
  return m;
}
```

#### A.5 Deposit

```ts
export interface DepositArgs {
  /** Native token units. Must equal the deposit coin's value exactly. */
  amount: bigint;
  /** Existing Receipt to add to, or undefined to mint a new position. */
  receiptId?: string;
  /** Recipient of the Receipt. */
  recipient: string;
  /**
   * Source coins. Omit for SUI to split from the gas coin. Otherwise pass owned coin objects;
   * they are merged and split to `amount`.
   */
  coinObjectIds?: string[];
}

/**
 * Deposit block: M synchronizations, R harvests, then deposit.
 *
 * When the vault has a default market, the pool, storage and incentive arguments must be that
 * market's, otherwise the call aborts with E_DEFAULT_MARKET_MISMATCH. When the default market is
 * unset, assets are added to the idle balance and those arguments are not validated, but remain
 * type-required.
 */
export function buildDepositTx(
  cfg: VaultConfig,
  layout: VaultLayout,
  args: DepositArgs,
): Transaction {
  if (layout.paused) throw new Error('vault is paused');
  const { packageId: p, vaultId, coinType: T } = cfg;
  const target = resolveTargetMarket(layout);

  const tx = new Transaction();
  appendFreshness(tx, cfg, layout);

  // deposit asserts deposit_coin.value() == amount, so split exactly.
  let source: TransactionObjectArgument;
  if (args.coinObjectIds && args.coinObjectIds.length > 0) {
    const [first, ...rest] = args.coinObjectIds;
    source = tx.object(first);
    if (rest.length) tx.mergeCoins(source, rest.map((id) => tx.object(id)));
  } else {
    source = tx.gas;
  }
  const [depositCoin] = tx.splitCoins(source, [tx.pure.u64(args.amount)]);

  const receiptType = `${p}::navi_vault::Receipt`;
  const receiptOpt = args.receiptId
    ? tx.moveCall({
        target: '0x1::option::some',
        typeArguments: [receiptType],
        arguments: [tx.object(args.receiptId)],
      })
    : tx.moveCall({ target: '0x1::option::none', typeArguments: [receiptType], arguments: [] });

  const [receipt] = tx.moveCall({
    target: `${p}::navi_vault::deposit`,
    typeArguments: [T],
    arguments: [
      tx.object(vaultId),
      receiptOpt,
      tx.object(CLOCK),
      tx.object(target.storageId),
      tx.object(target.poolId),
      depositCoin,
      tx.pure.u64(args.amount),
      tx.object(target.incentiveV2Id),
      tx.object(target.incentiveV3Id),
    ],
  }) as TransactionResult;

  // The Receipt is returned by value and must be consumed. When adding to an existing position
  // this returns the same object to its owner.
  tx.transferObjects([receipt], args.recipient);
  return tx;
}
```

#### A.6 Withdrawal

```ts
export interface WithdrawArgs {
  receiptId: string;
  /** Native token units. With fromDefault, pass U64_MAX to request a full exit. */
  amount: bigint;
  /**
   * Bound on shares burned. Must not be 0: the contract interprets 0 as no limit rather than
   * zero shares. Derive it from previewWithdraw.
   */
  maxShares: bigint;
  /**
   * true:  pool must be the default market; no penalty; amount is clamped to the holder's
   *        maximum. Required for a full exit.
   * false: any registered market, Active or Disabled; the market's penalty applies to the
   *        portion drawn from it.
   */
  fromDefault: boolean;
  /** Source of the shortfall remaining after the idle balance. Defaults to the default market. */
  poolId?: string;
  recipient: string;
  /**
   * Appends the oracle price update that withdrawal requires (§3.3). Invoked first, before the
   * market synchronizations, mirroring the verified block shape in §5.6.
   *
   * This is injected rather than built here because the call belongs to the oracle package, whose
   * entrypoint signature and object set differ between oracle implementations and change when NAVI
   * upgrades the oracle. Supply a closure that issues the update for `cfg.oracleId` using the
   * oracle package the target deployment links against.
   *
   * Omitting it is permitted only when the caller has independently established that the price for
   * this asset is inside the oracle's update_interval — 30 s by default. Otherwise the withdrawal
   * aborts with 1502.
   */
  oraclePrologue?: (tx: Transaction) => void;
}

/**
 * Withdrawal block: oracle price update, M synchronizations, R harvests, then withdraw.
 */
export function buildWithdrawTx(
  cfg: VaultConfig,
  layout: VaultLayout,
  args: WithdrawArgs,
): Transaction {
  if (layout.paused) throw new Error('vault is paused');
  if (args.maxShares === 0n) throw new Error('maxShares of 0 disables slippage protection');
  const { packageId: p, vaultId, coinType: T } = cfg;

  const source = args.fromDefault
    ? resolveTargetMarket(layout, layout.defaultMarket)
    : resolveTargetMarket(layout, args.poolId);

  const tx = new Transaction();
  // Must precede the synchronizations: withdraw reaches NAVI's collateral valuation, which
  // asserts price validity even though the vault holds no debt (§3.3).
  args.oraclePrologue?.(tx);
  appendFreshness(tx, cfg, layout);

  const [coin] = tx.moveCall({
    target: `${p}::navi_vault::withdraw`,
    typeArguments: [T],
    arguments: [
      tx.object(vaultId),
      tx.object(args.receiptId),
      tx.object(CLOCK),
      tx.object(cfg.oracleId),
      tx.object(source.storageId),
      tx.object(source.poolId),
      tx.pure.u64(args.amount),
      tx.pure.u64(args.maxShares),
      tx.pure.bool(args.fromDefault),
      tx.object(source.incentiveV2Id),
      tx.object(source.incentiveV3Id),
      tx.object(SUI_SYSTEM_STATE),
    ],
  }) as TransactionResult;

  // The coin's actual value is authoritative; NAVI may return marginally less than requested.
  tx.transferObjects([coin], args.recipient);
  return tx;
}

/** Full exit. fromDefault clamps the amount to the holder's maximum redeemable value. */
export function buildExitAllTx(
  cfg: VaultConfig,
  layout: VaultLayout,
  a: {
    receiptId: string;
    maxShares: bigint;
    recipient: string;
    oraclePrologue?: (tx: Transaction) => void;
  },
): Transaction {
  return buildWithdrawTx(cfg, layout, {
    receiptId: a.receiptId,
    amount: U64_MAX,
    maxShares: a.maxShares,
    fromDefault: true,
    recipient: a.recipient,
    oraclePrologue: a.oraclePrologue,
  });
}
```

#### A.7 Reward claim

```ts
/**
 * Reward claim block for one reward coin type.
 *
 * claim_reward has no freshness preconditions, but a preceding collect_reward is what makes newly
 * accrued protocol rewards payable. Only rules denominated in this coin need harvesting.
 *
 * claim_reward returns a zero-valued coin rather than aborting when nothing is claimable, so
 * callers should consult get_user_claimable_reward_amount and omit the transaction in that case
 * rather than creating zero-valued objects.
 */
export function buildClaimRewardTx(
  cfg: VaultConfig,
  layout: VaultLayout,
  args: { receiptId: string; rewardCoinType: string; recipient: string },
): Transaction {
  if (layout.paused) throw new Error('vault is paused');
  const { packageId: p, vaultId, coinType: T } = cfg;
  const rewardType = normalizeType(args.rewardCoinType);

  const tx = new Transaction();

  for (const rule of layout.rules) {
    if (rule.isVaultNative || !rule.isActive) continue;
    if (rule.rewardCoinType !== rewardType) continue;
    const market = layout.markets.find((m) => m.poolId === rule.naviPoolId);
    const fund = rewardFundFor(cfg, rule.rewardCoinType);
    if (!market || !fund) continue;
    tx.moveCall({
      target: `${p}::navi_vault::collect_reward`,
      typeArguments: [T, rule.rewardCoinType],
      arguments: [
        tx.object(vaultId),
        tx.object(CLOCK),
        tx.object(market.storageId),
        tx.object(market.incentiveV3Id),
        tx.object(fund),
        tx.pure.u64(rule.index),
      ],
    });
  }

  const coin = tx.moveCall({
    target: `${p}::navi_vault::claim_reward`,
    typeArguments: [T, rewardType],
    arguments: [tx.object(vaultId), tx.object(args.receiptId), tx.object(CLOCK)],
  });

  tx.transferObjects([coin], args.recipient);
  return tx;
}
```

#### A.8 Pricing

```ts
export interface VaultQuote {
  totalAssets: bigint;
  totalShares: bigint;
  idleBalance: bigint;
  userShares?: bigint;
  userAssets?: bigint;
  /** Shares a deposit of the requested amount would mint. */
  sharesForDeposit?: bigint;
}

/**
 * Price the vault against a synchronized snapshot.
 *
 * get_total_assets, convert_to_* and get_user_balance read MarketInfo.current_balance, whose
 * freshness is bounded by the most recent on-chain sync_market_balance. Reading them directly
 * understates the position by all interest accrued since. This function synchronizes every market
 * first; devInspectTransactionBlock simulates execution, so nothing is written on chain.
 */
export async function quoteVault(
  client: SuiClient,
  cfg: VaultConfig,
  layout: VaultLayout,
  sender: string,
  opts: { receiptId?: string; depositAmount?: bigint } = {},
): Promise<VaultQuote> {
  const { packageId: p, vaultId, coinType: T } = cfg;
  const tx = new Transaction();

  for (const m of layout.markets) {
    tx.moveCall({
      target: `${p}::navi_vault::sync_market_balance`,
      typeArguments: [T],
      arguments: [tx.object(vaultId), tx.object(m.storageId), tx.object(m.poolId), tx.object(CLOCK)],
    });
  }
  const readsStart = layout.markets.length;

  tx.moveCall({ target: `${p}::navi_vault::get_total_assets`, typeArguments: [T], arguments: [tx.object(vaultId)] });
  tx.moveCall({ target: `${p}::navi_vault::get_total_shares`, typeArguments: [T], arguments: [tx.object(vaultId)] });
  tx.moveCall({ target: `${p}::navi_vault::get_idle_balance`, typeArguments: [T], arguments: [tx.object(vaultId)] });

  if (opts.receiptId) {
    tx.moveCall({
      target: `${p}::navi_vault::get_user_shares`,
      typeArguments: [T],
      arguments: [tx.object(vaultId), tx.object(opts.receiptId)],
    });
    tx.moveCall({
      target: `${p}::navi_vault::get_user_balance`,
      typeArguments: [T],
      arguments: [tx.object(vaultId), tx.object(opts.receiptId)],
    });
  }
  if (opts.depositAmount !== undefined) {
    tx.moveCall({
      target: `${p}::navi_vault::convert_to_shares`,
      typeArguments: [T],
      arguments: [tx.object(vaultId), tx.pure.u64(opts.depositAmount)],
    });
  }

  const r = await inspect(client, tx, sender);
  let i = readsStart;
  const quote: VaultQuote = {
    totalAssets: r[i++][0] as bigint,
    totalShares: r[i++][0] as bigint,
    idleBalance: r[i++][0] as bigint,
  };
  if (opts.receiptId) {
    quote.userShares = r[i++][0] as bigint;
    quote.userAssets = r[i++][0] as bigint;
  }
  if (opts.depositAmount !== undefined) {
    quote.sharesForDeposit = r[i++][0] as bigint;
  }
  return quote;
}

/**
 * Simulate the withdrawal and read back the shares it would burn, then apply a tolerance.
 *
 * This is the reliable way to derive maxShares: it accounts for the non-default-market penalty,
 * ceiling rounding, idle-first routing and same-transaction fee accrual, none of which
 * convert_to_shares models.
 *
 * The drift between simulation and execution is favourable: interest accrual raises share price,
 * so the same amount costs marginally fewer shares at execution time. The tolerance covers
 * rounding and any fee accrual in the interval.
 *
 * @param toleranceBps tolerance in basis points; 30n corresponds to 0.3%.
 */
export async function previewWithdraw(
  client: SuiClient,
  cfg: VaultConfig,
  layout: VaultLayout,
  sender: string,
  args: Omit<WithdrawArgs, 'maxShares'>,
  toleranceBps = 30n,
): Promise<{ sharesBurned: bigint; amountOut: bigint; maxShares: bigint }> {
  const tx = buildWithdrawTx(cfg, layout, { ...args, maxShares: U64_MAX });
  const res = await client.devInspectTransactionBlock({ sender, transactionBlock: tx });
  if (res.error) throw new Error(`withdraw simulation failed: ${res.error}`);

  // withdraw returns (Coin<CoinType>, u64) and is the last command returning a u64; the
  // synchronizations, harvests and trailing transferObjects return nothing.
  const results = res.results ?? [];
  let sharesBurned: bigint | undefined;
  for (let i = results.length - 1; i >= 0; i--) {
    const rv = results[i]?.returnValues?.find(([, t]) => t === 'u64');
    if (rv) {
      sharesBurned = BigInt(bcs.U64.parse(Uint8Array.from(rv[0])));
      break;
    }
  }
  if (sharesBurned === undefined) throw new Error('could not read shares_burned from simulation');

  // The coin value is an object rather than a BCS return value, so take the amount from the
  // emitted WithdrawEvent.
  const ev = res.events?.find((e) => e.type.endsWith('::events::WithdrawEvent'));
  const amountOut = ev ? BigInt((ev.parsedJson as { amount: string }).amount) : 0n;

  return {
    sharesBurned,
    amountOut,
    maxShares: (sharesBurned * (10_000n + toleranceBps)) / 10_000n,
  };
}

/**
 * A holder's true claimable reward for one coin type.
 *
 * get_user_claimable_reward_amount lags twice: the vault index advances only on collect_reward,
 * and the holder's reward_total advances only when that holder interacts. Simulating the
 * settlement path and reading the emitted ClaimRewardEvent resolves both.
 */
export async function previewClaimReward(
  client: SuiClient,
  cfg: VaultConfig,
  layout: VaultLayout,
  sender: string,
  args: { receiptId: string; rewardCoinType: string },
): Promise<bigint> {
  const tx = buildClaimRewardTx(cfg, layout, { ...args, recipient: sender });
  const res = await client.devInspectTransactionBlock({ sender, transactionBlock: tx });
  if (res.error) throw new Error(`claim simulation failed: ${res.error}`);
  const ev = res.events?.find((e) => e.type.endsWith('::events::ClaimRewardEvent'));
  return ev ? BigInt((ev.parsedJson as { amount: string }).amount) : 0n;
}
```

#### A.9 Derived quantities

```ts
/** Share price in assets per share. Accepts a quote produced by quoteVault. */
export function sharePrice(q: Pick<VaultQuote, 'totalAssets' | 'totalShares'>): number {
  const VIRTUAL_SHARES = 1_000_000n;
  return Number(q.totalAssets + VIRTUAL_SHARES) / Number(q.totalShares + VIRTUAL_SHARES);
}

/**
 * Deposit headroom in native units — the binding constraint of the vault-side caps.
 *
 * The vault cap is enforced as total_assets + amount <= vault_cap; a market cap as
 * current_balance + amount <= cap. A value of 0 denotes no limit in either case. Supply
 * `currentBalance` from a synchronized get_market_info read for the market a deposit is routed
 * into.
 *
 * This is NOT the full bound. NAVI applies a third constraint, the reserve's supply_cap_ceiling,
 * which is shared with every other participant in that lending market and can be reached while
 * both vault-side caps still have headroom; exceeding it aborts with 1604 (§3.3). Reading it
 * requires querying lending_core storage for the asset, which is outside this module's scope.
 * A caller that presents headroom to a user must take the minimum of this value and the reserve
 * headroom.
 */
export function vaultSideDepositHeadroom(
  layout: VaultLayout,
  q: VaultQuote,
  target?: { poolId: string; currentBalance: bigint },
): bigint {
  const vaultRoom = layout.vaultCap === 0n ? U64_MAX : max0(layout.vaultCap - q.totalAssets);
  if (!target) return vaultRoom;
  const m = layout.markets.find((x) => x.poolId === target.poolId);
  if (!m || m.cap === 0n) return vaultRoom;
  const marketRoom = max0(m.cap - target.currentBalance);
  return vaultRoom < marketRoom ? vaultRoom : marketRoom;
}

function max0(x: bigint): bigint {
  return x > 0n ? x : 0n;
}

/** Convert a WAD-scaled rate to a percentage. */
export function wadToPercent(wad: bigint): number {
  return Number((wad * 10_000n) / WAD) / 100;
}
```
