Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
442 changes: 442 additions & 0 deletions app/api/agent/route.ts

Large diffs are not rendered by default.

259 changes: 17 additions & 242 deletions app/api/oracle-update/route.ts
Original file line number Diff line number Diff line change
@@ -1,248 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import * as fcl from '@onflow/fcl'
import * as t from '@onflow/types'

// ── Oracle Update API Route — Phase 4 ──
// Fetches live APY data from public sources and submits a batch oracle update
// transaction to the Flow blockchain.
//
// Security: requires CRON_SECRET header — only Netlify cron or admin can call this.
// Auth: uses ORACLE_ADMIN_PRIVATE_KEY env var to sign the oracle update transaction.
//
// Data sources:
// 1. FlowIDTableStaking (on-chain) — liquid staking real APY
// 2. IncrementFi public API — DeFi lending rates
// 3. Calculated spread estimate — arbitrage opportunity rate
//
// Called by: Netlify cron every 6 hours (see netlify.toml)

const SENTINEL_VAULT_ADDRESS = process.env.NEXT_PUBLIC_SENTINEL_VAULT_ADDRESS ?? '0xc13b08053be24e87'
const FLOW_ACCESS_NODE = process.env.NEXT_PUBLIC_FLOW_ACCESS_NODE ?? 'https://rest-testnet.onflow.org'
const ORACLE_ADMIN_ADDRESS = process.env.ORACLE_ADMIN_ADDRESS ?? ''
const ORACLE_ADMIN_PRIVATE_KEY = process.env.ORACLE_ADMIN_PRIVATE_KEY ?? ''
const ORACLE_ADMIN_KEY_INDEX = parseInt(process.env.ORACLE_ADMIN_KEY_INDEX ?? '0')

// Configure FCL for server-side use
fcl.config({
'accessNode.api': FLOW_ACCESS_NODE,
'flow.network': process.env.NEXT_PUBLIC_FLOW_NETWORK ?? 'testnet',
})

// ── Data fetchers ──

async function fetchStakingAPY(): Promise<{ apy: number; source: string; confidence: number }> {
try {
// Query FlowIDTableStaking directly via FCL script
const epochInfo = await fcl.query({
cadence: `
import FlowIDTableStaking from 0x9eca2b38b3c3b55a
access(all) fun main(): {String: AnyStruct} {
let info = FlowIDTableStaking.getEpochTokenInfo()
return {
"weeklyPayoutPct": info.weeklyPayoutPercentage,
"epochCounter": info.currentEpochCounter
}
}
`,
}) as Record<string, string>

const weeklyRate = parseFloat(String(epochInfo.weeklyPayoutPct ?? 0.125))
const annualizedAPY = weeklyRate * 52
return {
apy: Math.round(annualizedAPY * 100) / 100,
source: `FlowIDTableStaking.epoch-${epochInfo.epochCounter}`,
confidence: 0.97,
}
} catch (err) {
console.error('[oracle-update] FlowIDTableStaking query failed:', err)
// Conservative fallback based on historical Flow staking rates
return { apy: 6.5, source: 'fallback-historical', confidence: 0.70 }
}
}

async function fetchIncrementFiAPY(): Promise<{ apy: number; source: string; confidence: number }> {
try {
const res = await fetch('https://api.increment.fi/v1/markets', {
next: { revalidate: 300 }, // 5 min cache
headers: { 'User-Agent': 'FlowSentinel/1.0' },
})
if (!res.ok) throw new Error(`IncrementFi API ${res.status}`)
const data = await res.json() as { markets?: Array<{ symbol: string; supplyApy?: number }> }
const flowMarket = data.markets?.find(m => m.symbol === 'FLOW')
if (!flowMarket?.supplyApy) throw new Error('FLOW market not found')
return {
apy: Math.round(flowMarket.supplyApy * 100) / 100,
source: 'incrementfi-api-v1',
confidence: 0.85,
}
} catch (err) {
console.error('[oracle-update] IncrementFi API failed:', err)
return { apy: 8.2, source: 'fallback-historical', confidence: 0.65 }
}
}

function estimateArbitrageAPY(stakingAPY: number): { apy: number; source: string; confidence: number } {
// Arbitrage APY is opportunity-dependent, typically 60-80% of staking APY
const baseArb = stakingAPY * 0.85
return {
apy: Math.round(baseArb * 100) / 100,
source: 'calculated-from-staking',
confidence: 0.65,
}
import { NextResponse } from 'next/server'

// Yield integration is intentionally fail-closed. The previous route accepted
// fallback APYs and wrote them into YieldOracle even though no external
// protocol position was created. That behavior is not safe for production.

function unavailable() {
return NextResponse.json(
{
enabled: false,
error: 'Yield integrations are not enabled. No synthetic APY is published.',
},
{ status: 503 },
)
}

// ── FCL server-side authorization (signs oracle update tx) ──
function createOracleAuthorization() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return async (account: any) => {
const EC = (await import('elliptic')).ec
const { sha3_256 } = await import('js-sha3')
const ec = new EC('p256')
const key = ec.keyFromPrivate(Buffer.from(ORACLE_ADMIN_PRIVATE_KEY, 'hex'))
return {
...account,
addr: ORACLE_ADMIN_ADDRESS,
keyId: ORACLE_ADMIN_KEY_INDEX,
signingFunction: async (signable: { message: string }) => {
const msgBuffer = Buffer.from(signable.message, 'hex')
const hash = Buffer.from(sha3_256.arrayBuffer(msgBuffer))
const sig = key.sign(hash)
const n = 32
const r = sig.r.toArrayLike(Buffer, 'be', n)
const s = sig.s.toArrayLike(Buffer, 'be', n)
return {
addr: ORACLE_ADMIN_ADDRESS,
keyId: ORACLE_ADMIN_KEY_INDEX,
signature: Buffer.concat([r, s]).toString('hex'),
}
},
}
}
}

// ── POST handler — called by Netlify cron ──
export async function POST(req: NextRequest) {
// Authenticate: only Netlify cron or admin with correct secret
const authHeader = req.headers.get('authorization')
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

if (!ORACLE_ADMIN_ADDRESS || !ORACLE_ADMIN_PRIVATE_KEY) {
return NextResponse.json(
{ error: 'Oracle admin credentials not configured. Set ORACLE_ADMIN_ADDRESS and ORACLE_ADMIN_PRIVATE_KEY env vars.' },
{ status: 500 }
)
}

try {
// Fetch all APY data in parallel
const [stakingData, incrementData] = await Promise.all([
fetchStakingAPY(),
fetchIncrementFiAPY(),
])
const arbData = estimateArbitrageAPY(stakingData.apy)
const highYieldAPY = Math.round(incrementData.apy * 1.9 * 100) / 100 // 190% of DeFi rate

// Submit batch oracle update transaction
const txId = await (fcl.mutate as (opts: Record<string, unknown>) => Promise<string>)({
cadence: `
import YieldOracle from ${SENTINEL_VAULT_ADDRESS}
import FlowIDTableStaking from 0x9eca2b38b3c3b55a

transaction(
liquidStakingAPY: UFix64, yieldFarmingAPY: UFix64,
arbitrageAPY: UFix64, highYieldAPY: UFix64, useRealStakingData: Bool
) {
let adminResource: auth(YieldOracle.OracleAdmin) &YieldOracle.OracleAdminResource
prepare(signer: auth(BorrowValue) &Account) {
self.adminResource = signer.storage
.borrow<auth(YieldOracle.OracleAdmin) &YieldOracle.OracleAdminResource>(
from: YieldOracle.OracleAdminStoragePath
) ?? panic("Not authorized")
}
execute {
var lsAPY = liquidStakingAPY
var lsSource = "off-chain-fcl"
var lsConfidence = 0.85 as UFix64
if useRealStakingData {
let epochInfo = FlowIDTableStaking.getEpochTokenInfo()
lsAPY = epochInfo.weeklyPayoutPercentage * 52.0
lsSource = "FlowIDTableStaking.epoch-".concat(epochInfo.currentEpochCounter.toString())
lsConfidence = 0.97
}
let updates: [{String: AnyStruct}] = [
{"strategyId": "liquid-staking-pro", "apy": lsAPY, "source": lsSource, "confidence": lsConfidence},
{"strategyId": "defi-yield-maximizer", "apy": yieldFarmingAPY,"source": "incrementfi-api", "confidence": 0.82 as UFix64},
{"strategyId": "arbitrage-hunter", "apy": arbitrageAPY, "source": "dex-aggregator", "confidence": 0.70 as UFix64},
{"strategyId": "high-yield-farming", "apy": highYieldAPY, "source": "defi-aggregator", "confidence": 0.65 as UFix64}
]
self.adminResource.batchSetAPY(updates: updates)
}
}
`,
args: (arg: typeof fcl.arg, ty: typeof t) => [
arg(stakingData.apy.toFixed(8), ty.UFix64),
arg(incrementData.apy.toFixed(8), ty.UFix64),
arg(arbData.apy.toFixed(8), ty.UFix64),
arg(highYieldAPY.toFixed(8), ty.UFix64),
arg(true, ty.Bool),
],
authorizations: [createOracleAuthorization()],
proposer: createOracleAuthorization(),
payer: createOracleAuthorization(),
limit: 200,
})

await fcl.tx(txId).onceSealed()

const result = {
success: true,
txId,
updatedAt: new Date().toISOString(),
apyData: {
liquidStaking: stakingData,
yieldFarming: incrementData,
arbitrage: arbData,
highYield: { apy: highYieldAPY, source: 'calculated', confidence: 0.65 },
},
}

console.log('[oracle-update] Success:', JSON.stringify(result, null, 2))
return NextResponse.json(result)

} catch (err) {
console.error('[oracle-update] Failed:', err)
return NextResponse.json(
{ error: 'Oracle update failed', details: err instanceof Error ? err.message : String(err) },
{ status: 500 }
)
}
export async function POST() {
return unavailable()
}

// ── GET handler — returns current oracle state (public, no auth required) ──
export async function GET() {
try {
const apyData = await fcl.query({
cadence: `
import YieldOracle from ${SENTINEL_VAULT_ADDRESS}
access(all) fun main(): {String: {String: AnyStruct}} {
let allAPYs = YieldOracle.readAllAPYs()
let result: {String: {String: AnyStruct}} = {}
for strategyId in allAPYs.keys {
let data = allAPYs[strategyId]!
result[strategyId] = {
"apy": data.apy, "source": data.source,
"updatedAt": data.updatedAt, "confidence": data.confidence,
"ageSeconds": getCurrentBlock().timestamp - data.updatedAt
}
}
return result
}
`,
})
return NextResponse.json({ success: true, data: apyData, queriedAt: new Date().toISOString() })
} catch (err) {
return NextResponse.json({ error: 'Failed to fetch oracle data', details: String(err) }, { status: 500 })
}
return unavailable()
}
Loading
Loading