Uptick

Connect a wallet

Uptick reads your balance and asks you to sign each transaction. It never holds your keys.

Docs ยท The oracle and pricing

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.

SourceWhat it isUsed for
ChainlinkAggregatorV3 answer in feed decimals, 24 hour heartbeatShare value, deposit and exit swap floors, the centre of the band, and one side of every deviation check.
Pool TWAPAverage tick over twapWindow, at least 600 secondsThe composition of the position and the USDG leg's NAV price.
Pool spotslot0 sqrtPriceX96A gate input and the third price in deposit NAV. Total assets does not use it.
All three are read in the same call, so a decision cannot mix prices from different blocks.

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.

StatusCheckWhat it means
FeedInvalidThe 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.
CorporateActionThe 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.
FeedStaleblock.timestamp minus updatedAt exceeds maxStaleness.The answer is readable but too old to price a trade. Deployed limit is 90,000 seconds.
TwapUnavailablepool.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.
TwapDeviatesThe 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.
SpotDeviatesThe 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.
OkEvery check passed.Deposits, withdrawals and rebalances may proceed.
Status is an enum on the vault. health() returns it as the first of seven values and never reverts for a bad feed.

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.

PathGatePrice used
deposit, mintRequiredChainlink sets the swap minimum and the target ratio. Deposit NAV uses the lowest of Chainlink, the TWAP and spot.
withdraw, redeemRequiredPosition composition at the TWAP. Share value uses the lower of Chainlink and the TWAP. The preview applies the exit buffer.
rebalanceRequiredThe band is Chainlink plus or minus rangeBps. The swap minimum comes from Chainlink.
redeemInKindNot readA pro-rata slice of the actual balances and both legs. No swap, so no price.
totalAssets, pricePerShare, previewsNot enforcedWhatever is readable. Only FeedInvalid makes these revert.
The in-kind path is the escape hatch: it works while the gate is failing because it never converts anything.

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.

contracts/src/StockVault.sol
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.

BoundDeployed valueAllowed range
maxDeviationBps100 bps10 to 500 bps
maxStaleness90,000 seconds1 hour to 7 days
twapWindow600 seconds600 seconds to 1 day
rangeBps500 bps50 to 2,000 bps
exitBufferBps30 bps0 to 200 bps
perfFeeBps1,000 bps0 to 2,000 bps
Deployed values come from the deploy script. Bounds are enforced by setParams, so a configuration outside them cannot be written, and every change is an owner action that emits ParamsUpdated.

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.

a viem read
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.