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.
Chainlink Price Feeds
Each basket token has a registered Chainlink AggregatorV3Interface feed on Arbitrum. When sampling, the oracle:
- Calls
latestRoundData()on the feed - Checks that
updatedAt > block.timestamp - STALE_THRESHOLD(3600 seconds / 1 hour) - Normalizes the price: Chainlink returns 8-decimal USD prices → multiplied by 1e10 to reach 18 decimals
- 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:
| Parameter | Value |
|---|---|
Buffer size (BUFFER_SIZE) | 8 samples |
Sample interval (SAMPLE_INTERVAL) | 300 seconds (5 minutes) |
| TWAP window | 4 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/100orpreTWAP * 70/100) CircuitBreakerClamped(token, originalSpot, clampedSpot, preTWAP)is emittedsamplePrices()continues to the next token — no revert- When
indexVaultis configured, the oracle best-effort callsIndexVault.notifyCircuitBreakerMintExclude(token)insidetry/catch; the vault excludes the token from new mint routing ifcircuitBreakerAutoExcludesMintis 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:
- Exclusion: CB fires → token excluded from mint routing (new USDC is not deployed into it)
- 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.
- 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 emittedIndexVault.notifyTWAPVelocityAlert()called viatry/catchIndexVaultsetscrisisFeeExpiry = now + 4 hoursRedeemEnginereadsisCrisisFeeActive()and appliesMAX_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 thresholdlastUpdate: timestamp of last Chainlink updateageSeconds: 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:
| Source | Native Decimals | Normalization |
|---|---|---|
| Chainlink USD feeds | 8 decimals | × 1e10 |
| USDC balances | 6 decimals | × 1e12 |
| Basket token balances | 18 decimals | No change |
| NAV per $GNDX | 18 decimals | Stored as-is |
Normalization happens at write time (when storing into the circular buffer), not at read time.
See also: Smart Contracts · Security