Using a vault
The oracle and pricing
Three prices feed every decision: the Chainlink feed, a 10 minute pool TWAP, and the pool's spot price. Each one has a job.
Three prices
Chainlink reports the stock's USD price from off-chain venues. It sets the band and the swap floor on deposits and exits, and it is one of the two prices that value a share. Deploy swaps floor at the TWAP instead. Chainlink is the slowest of the three, with a 24 hour heartbeat, so it can lag a fast move.
The pool TWAP is the average tick of the Stock Token / USDG pool over at least 10 minutes. It says what the pool itself believes the price is, and it cannot be moved by a single block. The spot price is the pool's current tick. It is the most current and the least trustworthy, which is why it is checked but never used alone.
| Source | What it is | Used for |
|---|---|---|
| Chainlink | AggregatorV3 answer in feed decimals, 24 hour heartbeat | Share value, deposit and exit swap floors, the centre of the band, and one side of every deviation check. |
| Pool TWAP | Average tick over twapWindow, at least 600 seconds | The composition of the position and the USDG leg's NAV price. |
| Pool spot | slot0 sqrtPriceX96 | A gate input and the third price in deposit NAV. Total assets does not use it. |
The gate's six checks
A single read produces one status. The checks run in order and the first failure is returned, while everything readable is still returned alongside it. A caller that only needs to know whether it may proceed reads the status.
| Status | Check | What it means |
|---|---|---|
| FeedInvalid | The call reverted, the answer is zero or negative, the timestamp is zero or in the future, or answeredInRound is behind roundId. | The feed is broken or mid-update. This is the only status that makes the lenient price reads revert. |
| CorporateAction | The Stock Token oracle is paused, or the token is inside its lead or lag window around a multiplier change. | A price-checked path closes until the window passes. redeemInKind does not read the gate. |
| FeedStale | block.timestamp minus updatedAt exceeds maxStaleness. | The answer is readable but too old to price a trade. Deployed limit is 90,000 seconds. |
| TwapUnavailable | pool.observe reverted for the window, usually because the pool's history is shorter than twapWindow. | The pool cannot supply the average. A newly created pool starts here. |
| TwapDeviates | The TWAP is more than maxDeviationBps away from Chainlink. | The pool and the feed disagree beyond tolerance. One of them is wrong, and the vault will not guess which. |
| SpotDeviates | The spot price is more than maxDeviationBps away from Chainlink. | The pool has been pushed away from the feed. Blocked even if the 10 minute average still agrees. |
| Ok | Every check passed. | Deposits, withdrawals and rebalances may proceed. |
What each path trusts
The gate is not applied uniformly. The paths that hand the vault a price need it; the path that hands back the actual tokens does not.
| Path | Gate | Price used |
|---|---|---|
| deposit, mint | Required | Chainlink sets the swap minimum and the target ratio. Deposit NAV uses the lowest of Chainlink, the TWAP and spot. |
| withdraw, redeem | Required | Position composition at the TWAP. Share value uses the lower of Chainlink and the TWAP. The preview applies the exit buffer. |
| rebalance | Required | The band is Chainlink plus or minus rangeBps. The swap minimum comes from Chainlink. |
| redeemInKind | Not read | A pro-rata slice of the actual balances and both legs. No swap, so no price. |
| totalAssets, pricePerShare, previews | Not enforced | Whatever is readable. Only FeedInvalid makes these revert. |
Why NAV takes the lower price
The vault holds Stock Token and USDG. The Stock Token side is already in the unit of account. The USDG side has to be converted, and the choice of price changes what a share is worth.
If the feed says $200 and the pool says $190, valuing USDG at $200 credits each USDG with fewer Stock Tokens than the pool would give for it. A depositor would buy shares below their redeemable value, and the difference would accrue to existing holders. Taking the minimum of the two closes that gap.
function _navPrice(Snapshot memory s) private view returns (uint256) {
return Math.min(s.price, _sqrtToUsd(s.sqrtTwap));
}The TWAP, not spot, is the pool side of that comparison, so a same-block push cannot move total assets. New shares are priced one step more conservatively, on the lowest of Chainlink, the TWAP and spot. The same two-way price converts fee value before the treasury cut is taken.
The 10 minute TWAP
The window is stored as twapWindow, at least 600 seconds and at most one day. The vault calls pool.observe for the window and divides the tick accumulator difference by the elapsed seconds, rounding towards negative infinity so the average is never above the true one.
A long enough window is what makes the average expensive to manipulate. It also means a pool that has just launched cannot serve it: the observe call reverts and the status is TwapUnavailable until the pool has the history.
Staleness and deviation
The feed's heartbeat is 24 hours. The deployed staleness limit is 25 hours, which leaves an hour of grace for a late update and still catches a feed that has stopped. The keeper's send-time guard is 87,000 seconds, tighter than the contract's 90,000, so it stops a transaction before the vault would reject it. Its universe filter is looser, allowing a feed up to 346,200 seconds old so a pool stays eligible through a long weekend.
Deviation is bounded at 100 bps on both the TWAP and spot comparisons. That is tight on purpose: the vault only needs to know that the pool is trading near the feed at the moment it prices a deposit or a rebalance. A wider gap is not a better price, it is a broken market.
| Bound | Deployed value | Allowed range |
|---|---|---|
| maxDeviationBps | 100 bps | 10 to 500 bps |
| maxStaleness | 90,000 seconds | 1 hour to 7 days |
| twapWindow | 600 seconds | 600 seconds to 1 day |
| rangeBps | 500 bps | 50 to 2,000 bps |
| exitBufferBps | 30 bps | 0 to 200 bps |
| perfFeeBps | 1,000 bps | 0 to 2,000 bps |
Reading health
health() is the one call that reports the gate without reverting. It returns the status, the feed price, the feed timestamp, and the three square-root prices the decision was made from.
const [status, price, updatedAt, sqrtOracle, sqrtTwap, sqrtSpot] =
await client.readContract({
address: vault,
abi: stockVaultAbi,
functionName: "health",
});
if (status !== 0) throw new Error(`oracle status ${status}`);A front end can show the status, the age of the answer and the three prices before a user signs anything. The transaction still runs the gate for itself; the read is a preview, not a permission.
The exit buffer
previewRedeem returns fair value less 30 bps, and previewWithdraw grosses the requested amount up by the same factor. The buffer covers the swap that turns the USDG leg into the Stock Token on the way out, so the preview is a floor rather than an optimistic estimate. The actual payout is what the swap produces.
Sources
contracts/src/StockVault.solThe Status enum, _read, _readLenient, _requireHealthy, _navPrice, _twapTick, _deviates and the parameter bounds.
contracts/script/Deploy.s.solThe deployed parameter set.
contracts/README.mdThe pricing model and the oracle gate in prose.
keeper/src/config.tsThe keeper's own feed age and deviation guards.