Gully Core

Build

Price oracle

A price one trade can't move: a time-weighted average recorded by the pool's own hook, which any contract can read for free.

What it is

A Gully Core pool can be born on a hook that keeps a price history. Core itself keeps none. The history lives in the pool's hook, and the hook writes it as the pool trades.

  • One observation a second, at most. The first swap of each second records the price as it stood before that swap: the price every trade since the last observation saw.
  • Each step clamped. An observation moves the recorded price by at most ORACLE_MAX_TICK_DELTA ticks from the last one (one tick is a 0.01% step), however far the pool moved.
  • A move made and undone inside a second is never seen. A flash loan that pushes the price and puts it back in one transaction leaves no trace in the history.
  • The answer is an average over a window you choose, at least ORACLE_MIN_WINDOW_SECONDS long: the mean tick, which is the geometric mean price (token1 per token0).

The hook has no owner, no pause and no setter, takes nothing from a swap and charges nothing to read.

Why it matters

A contract that reads a pool's spot price can be fooled by one trade: borrow, push the price, call the contract, push it back, repay, all in one transaction. An average that one trade cannot move is what a contract needs when it has to ask a pool what something is worth.

  • Vaults that keep liquidity around the price. Gully Core's own vaults follow this average, never the spot price.
  • Lending and collateral that need a reference price.
  • Anything else that must price a token from its pool: limits, payouts, settlement against a second source.

What it is not

  • Not a spot price. It lags real moves on purpose. A move of N ticks needs about N divided by ORACLE_MAX_TICK_DELTA observations to be fully recorded.
  • Not proof a price is right. A thin pool's average is still a thin pool's price. An average resists a short attack, not someone who can hold a thin pool's price for the whole window. It reports no liquidity, so check depth yourself before valuing collateral against it.
  • Not a clock of its own. Time is block.timestamp. On a layer-2 chain the sequencer sets it within bounds, which is why the minimum window is at least 60 seconds. Use 30 minutes or more for anything that holds value.
  • Not fresh by itself. A quiet pool answers its last price, extrapolated. consult returns how old the last observation is. Refusing an answer that is too old is your decision.

Read it from a contract

Build against IGullyCoreOracle, the stable read interface every oracle hook implements. It changes only by addition. The pool id is the keccak256 of its PoolKey, and the oracle's address is the key's hooks.

FunctionWhat it returns
consult(id, window)The mean tick over the last window seconds and lastWriteAge, the seconds since the last observation. Refuses a window under the minimum (WindowTooShort) and a window longer than the recorded history (HistoryTooShort). It never shortens one. Rounds toward negative infinity.
observe(id, secondsAgos)The raw cumulative ticks (the usual TWAP form) for any secondsAgo, including under the minimum window. A contract that uses it owns the minimum-window decision.
lastObservation(id)The newest observation: its time, cumulative tick and the clamped tick it recorded.
oldestObservationAge(id)How many seconds of history exist now: the longest window consult will answer.
stateOf(id)The ring: the newest slot, its live size and the size it grows to at the next wrap.
observationAt(id, slot)One slot of the ring.
increaseCardinalityNext(id, next)Pre-pays more slots so the pool keeps more history. Anyone may call it and pays the gas. The ring grows when it next wraps.
kind()keccak256("gullycore.oracle.truncated-twap.v1"). Check it before trusting an answer.

Rules a consumer must know:

  • Minimum window, never shortened. consult refuses a window under ORACLE_MIN_WINDOW_SECONDS(), and a window longer than the recorded history. It never answers for a shorter window than you asked.
  • Staleness is yours to judge. Refuse an answer whose lastWriteAge is too old for your use.
  • History depth is the ring. A pool keeps one observation per slot, so its history is the ring's size times how often it trades. oldestObservationAge(id) says how much there is now. Anyone can buy more with increaseCardinalityNext.
  • A swap in your own transaction cannot move the answer. From the last observation to now the oracle extrapolates with the pool's current tick, clamped the same way. In a second that already has a swap there is nothing to extrapolate.
ImmutableWhat it isBounds in the contractAdvised
ORACLE_MAX_TICK_DELTALargest move of the recorded tick per observation1 to 1,000 ticks100 (about 1%)
ORACLE_MIN_WINDOW_SECONDSShortest window consult answers60 to 86,400 s600 s
ORACLE_INITIAL_CARDINALITYHistory slots paid for when a pool is created1 to 1,02416

Each hook fixes its three values when it is deployed. Nothing can change them. Read them from the hook.

The read, step by step

  1. Take the pool's key and check that its hooks address is one of Gully Core's oracle hooks in the address book. See Contracts.
  2. Call kind() and compare it with keccak256("gullycore.oracle.truncated-twap.v1").
  3. Read ORACLE_MIN_WINDOW_SECONDS() and oldestObservationAge(id). Stop if your window is shorter than the minimum or longer than the history. A 30-minute window is 1,800 seconds.
  4. Call consult(id, window). Reject the answer if lastWriteAge is too old for your use.
  5. The mean tick is the geometric mean price over the window: 1.0001 ** meanTick, token1 per token0, in raw units.

Any client library works. The SDK has the address book and a pool-key helper, and it runs from a checkout of the repository. See SDK.

Which pools have one

A pool's hook is part of its identity. It is fixed when the pool is created and can never change. So a pool has the oracle only if it was created on an oracle hook, and an existing pool can never gain one.

HookWhat the pool gets
GullyCoreOracleHookThe oracle alone: no fee of its own, no delta, nothing held. Liquidity on it can be locked.
GullyCoreDexHookThe dynamic fee and the same oracle in one hook, for a DEX pool that also wants a price other contracts can read.
  • Concentrated-liquidity (CL) pools only.
  • A launch pool cannot have one. Its hook is the launch guard.
  • Create the pool on the hook from the start. To add an oracle to a pair that already trades, create a second pool of the same pair on an oracle hook.

Addresses

The oracle hook addresses on each chain are in Contracts. A chain shows none until the hooks are deployed there. See Chains.

Wrong or missing? Tell us at @Gully_Labs.