Skip to main content

Oracle Infrastructure

GNDX Protocol uses a layered oracle system combining Chainlink price feeds and an on-chain TWAP buffer to produce manipulation-resistant NAV calculations.

Each basket token has a registered Chainlink AggregatorV3Interface feed on Arbitrum. When sampling, the oracle:

  1. Calls latestRoundData() on the feed
  2. Checks that updatedAt > block.timestamp - STALE_THRESHOLD (3600 seconds / 1 hour)
  3. Normalizes the price: Chainlink returns 8-decimal USD prices → multiplied by 1e10 to reach 18 decimals
  4. Stores the normalized price in the circular buffer

If a feed is stale, samplePrices() continues to the next token (does not revert the entire call). The stale token is flagged in allFeedsHealthy().

TWAP Circular Buffer

For each basket token, NAVOracle maintains a circular buffer of 8 price samples:

ParameterValue
Buffer size (BUFFER_SIZE)8 samples
Sample interval (SAMPLE_INTERVAL)300 seconds (5 minutes)
TWAP window4 most recent samples = 20 minutes
Stale threshold (STALE_THRESHOLD)3600 seconds (1 hour)

Calling samplePrices() is permissionless. Any address (or Gelato keeper) can sample. The function writes the current Chainlink price for all registered tokens into their circular buffers.

TWAP calculation: The arithmetic mean of the 4 most recent valid samples for each token.

TWAP = (price[n] + price[n-1] + price[n-2] + price[n-3]) / 4

If any of the 4 samples is older than STALE_THRESHOLD, getTokenTWAP() reverts with StalePrice.

Circuit Breaker

The circuit breaker prevents flash loan-driven spot price manipulation from corrupting NAV:

Condition: If |newSpot - preTWAP| / preTWAP > 30% (compared to the live TWAP computed before writing the new sample)

Action:

  • The new spot is clamped to +/-30% of the preTWAP (i.e., stored as preTWAP * 130/100 or preTWAP * 70/100)
  • CircuitBreakerClamped(token, originalSpot, clampedSpot, preTWAP) is emitted
  • samplePrices() continues to the next token — no revert
  • When indexVault is configured, the oracle best-effort calls IndexVault.notifyCircuitBreakerMintExclude(token) inside try/catch; the vault excludes the token from new mint routing if circuitBreakerAutoExcludesMint is true
  • The oracle tracks consecutive clean samples per token (samples where no CB fires). This counter is used by the stability gate (see below)

Flash loans cannot move the 20-minute TWAP — they can only move the spot in the current block. Comparing spot against the live TWAP catches manipulation at 30% divergence rather than waiting for a 50% move against a frozen snapshot. The 30% threshold is calibrated for gaming tokens, which legitimately move 15-25% in short windows during exchange listings and macro events.

Stability-Gated CB Re-entry

When the circuit breaker fires and excludes a token from mint routing, the exclusion does not auto-expire after a flat timer. Instead, the token must demonstrate price stability before being re-included:

  1. Exclusion: CB fires → token excluded from mint routing (new USDC is not deployed into it)
  2. Stability gate: The token is re-included only after 3 consecutive clean oracle samples (samples where the CB does not fire). At 5-minute intervals, this means ~15 minutes of stable pricing.
  3. Safety valve: If the token remains excluded for 24 hours with no clean samples, the exclusion is automatically lifted (prevents permanently stuck tokens).

This replaces a simpler flat 24-hour timer. The old design created a race window at expiry where mints could flow into a still-crashing token before the next oracle sample. The stability gate ensures the token's price has genuinely stabilized before allowing new exposure.

// Token re-included when:
NAVOracle.getConsecutiveCleanSamples(token) >= 3
// OR safety valve after 24 hours:
block.timestamp > cbTriggerTime + 86400

TWAP Velocity Alert

A second layer detects real crashes (where both spot and TWAP are falling together):

Condition: If (lastTWAPSnapshot - postTWAP) / lastTWAPSnapshot > 7% — only fires on DECLINE

Action:

  • TWAPVelocityAlert(token, postTWAP, lastTWAPSnapshot, declineBps) is emitted
  • IndexVault.notifyTWAPVelocityAlert() called via try/catch
  • IndexVault sets crisisFeeExpiry = now + 4 hours
  • RedeemEngine reads isCrisisFeeActive() and applies MAX_EXIT_FEE_BPS (50 bps) on all redemptions until expiry — making exit arbitrage uneconomical during genuine market stress

Genesis NAV

When GNDXToken.totalSupply() == 0 (protocol genesis, before any deposits):

getNAVPerToken() returns exactly 1e18  // $1.00

This is hardcoded in NAVOracle.sol and not configurable. The first depositor mints $GNDX at exactly $1.00 per token.

Feed Registration

Adding a new basket token requires registering a Chainlink feed before the governance proposal to add the token executes:

function registerPriceFeed(address token, address chainlinkFeed) external;

This validates the feed by calling latestRoundData() at registration time. If the feed is down or returns an invalid price, registration reverts.

Feed Health Monitoring

function allFeedsHealthy() external view returns (bool healthy, address[] memory staleTokens);
function getFeedHealth(address token) external view returns (FeedHealth memory);

FeedHealth includes:

  • isHealthy: whether the feed is within the stale threshold
  • lastUpdate: timestamp of last Chainlink update
  • ageSeconds: seconds since last update

Monitoring infrastructure should alert if any feed exceeds 45 minutes without an update (approaching the 60-minute stale threshold).

Decimal Normalization

All prices in the system use 18-decimal precision internally:

SourceNative DecimalsNormalization
Chainlink USD feeds8 decimals× 1e10
USDC balances6 decimals× 1e12
Basket token balances18 decimalsNo change
NAV per $GNDX18 decimalsStored as-is

Normalization happens at write time (when storing into the circular buffer), not at read time.


See also: Smart Contracts · Security