From 1c17e9af99f1c119c2ebeea1026bf28934838b7d Mon Sep 17 00:00:00 2001 From: Yunus Date: Mon, 1 Jun 2026 17:09:04 +0100 Subject: [PATCH] feat(vault): parse on-chain harvest events and add 30d annualized realized APY --- frontend/src/defindex.ts | 103 +++++++++++++++++++++++++++ frontend/src/main.ts | 11 ++- frontend/tmp_event_check.cjs | 26 +++++++ frontend/tmp_event_check.js | 26 +++++++ frontend/tmp_event_check_filter.cjs | 29 ++++++++ frontend/tmp_event_check_horizon.cjs | 10 +++ frontend/tmp_inspect.cjs | 4 ++ package-lock.json | 6 ++ 8 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 frontend/tmp_event_check.cjs create mode 100644 frontend/tmp_event_check.js create mode 100644 frontend/tmp_event_check_filter.cjs create mode 100644 frontend/tmp_event_check_horizon.cjs create mode 100644 frontend/tmp_inspect.cjs create mode 100644 package-lock.json diff --git a/frontend/src/defindex.ts b/frontend/src/defindex.ts index a499c42..e13f068 100644 --- a/frontend/src/defindex.ts +++ b/frontend/src/defindex.ts @@ -95,6 +95,9 @@ export interface VaultStats { netApy: number | null; // Estimated leveraged APY (null if unavailable) supplyApr: number | null; borrowApr: number | null; + // Realized harvest annualized APR (derived from on-chain harvest events) + realizedHarvestApy?: number; // expressed as APR percent (e.g. 1.23 => 1.23%) + realizedHarvest30d?: number; // sum of underlying realized in last 30 days (units = underlying) } export interface UserVaultPosition { @@ -180,6 +183,19 @@ export async function fetchVaultStats( } } + // Fetch realized harvest revenue from on-chain events (last 30 days) + let realizedHarvestApy: number | undefined = undefined; + let realizedHarvest30d: number | undefined = undefined; + try { + const res = await fetchHarvestRealized30d(vault, 30); + realizedHarvest30d = res.sum30; + if (realizedHarvest30d > 0 && totalEquity > 0) { + const annualized = (realizedHarvest30d / totalEquity) * (365 / 30); + // Keep same units as netApy (APR percent) + realizedHarvestApy = annualized * 100; + } + } catch { /* ignore event failures */ } + return { totalEquity, totalShares, @@ -195,6 +211,8 @@ export async function fetchVaultStats( netApy, supplyApr, borrowApr, + realizedHarvestApy, + realizedHarvest30d, }; } catch { return null; @@ -367,3 +385,88 @@ export function formatHf(hf: number): { text: string; cls: string } { if (hf >= 1.1) return { text, cls: "hf-warn" }; return { text, cls: "hf-bad" }; } + +// ── Harvest event helpers (on-chain realized revenue) ─────────────────────── + +function b64ToBytes(b64: string): Uint8Array { + // browser-friendly atob + try { + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; + } catch (e) { + return new Uint8Array(); + } +} + +function bytesToBigIntBE(b: Uint8Array): bigint { + let v = 0n; + for (let i = 0; i < b.length; i++) { + v = (v << 8n) + BigInt(b[i]); + } + // interpret as signed if high bit set + if (b.length > 0 && (b[0] & 0x80) !== 0) { + const bits = BigInt(b.length * 8); + v = v - (1n << bits); + } + return v; +} + +/** + * Fetch harvest events for a vault and sum realized underlying over `days`. + * Returns sum in underlying units (float) and raw count. + */ +export async function fetchHarvestRealized30d(vault: VaultConfig, days = 30): Promise<{ sum30: number; count: number }> +{ + try { + const rpcBase = getActiveNetwork() === "testnet" + ? "https://soroban-testnet.stellar.org" + : "https://soroban-rpc.creit.tech"; + const url = rpcBase.replace(/\/$/, "") + "/soroban/events?contract_id=" + encodeURIComponent(vault.vaultId) + "&limit=1000"; + const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); + if (!res.ok) return { sum30: 0, count: 0 }; + const data = await res.json(); + const events = data.events ?? []; + const cutoff = Date.now() - days * 24 * 3600 * 1000; + + let sumRaw = 0n; + let count = 0; + for (const ev of events) { + try { + if (!ev.ledgerClosedAt) continue; + const ts = Date.parse(ev.ledgerClosedAt); + if (isNaN(ts) || ts < cutoff) continue; + + // Try to identify harvest-like topics: decode first topic and look for 'harv' or 'fee' + const topics: string[] = ev.topic ?? []; + if (!topics.length) continue; + const t0 = topics[0]; + const t0Bytes = b64ToBytes(t0); + const t0Text = new TextDecoder().decode(t0Bytes).toLowerCase(); + if (!(t0Text.includes("harv") || t0Text.includes("fee") || t0Text.includes("harvest"))) continue; + + // Decode value as big-endian i128/unsigned integer + const vB64 = ev.value as string; + if (!vB64) continue; + const vb = b64ToBytes(vB64); + if (vb.length === 0) continue; + const vBig = bytesToBigIntBE(vb); + + sumRaw += vBig; + count += 1; + } catch (e) { + // ignore parse errors for individual events + continue; + } + } + + // Convert raw (assumed stroops with vault.decimals) to float underlying + const scalar = BigInt(10) ** BigInt(vault.decimals); + const sum30 = Number(sumRaw) / Number(scalar || 1n); + return { sum30, count }; + } catch (e) { + return { sum30: 0, count: 0 }; + } +} + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..4988b3b 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2522,12 +2522,17 @@ async function refreshVaultView() { // Net APY (stats.netApy is actually APR — convert for display) const apyEl = $("vault-apy"); if (stats.netApy !== null) { - const vaultApy = aprToApy(stats.netApy); + const realizedApr = stats.realizedHarvestApy ?? 0; + const combinedApr = stats.netApy + realizedApr; + const vaultApy = aprToApy(combinedApr); apyEl.textContent = (vaultApy >= 0 ? "+" : "") + vaultApy.toFixed(2) + "%"; apyEl.className = "stat-value mono " + (vaultApy > 0 ? "hf-ok" : "hf-bad"); const vaultTip = $("vault-apy-tip"); - if (vaultTip) vaultTip.setAttribute("data-tip", - `Approximate APY — Blend interest does not auto-compound. Actual net APR: ${fmt(stats.netApy, 2)}%`); + if (vaultTip) { + const harvestLine = stats.realizedHarvestApy ? ` + realized harvest APR: ${fmt(stats.realizedHarvestApy,2)}% (30d sum: ${stats.realizedHarvest30d?.toFixed(4)} ${vault.assetSymbol})` : ""; + vaultTip.setAttribute("data-tip", + `Approximate APY — Blend interest does not auto-compound. Net APR: ${fmt(stats.netApy, 2)}%${harvestLine}`); + } } else { apyEl.textContent = "--"; apyEl.className = "stat-value mono"; diff --git a/frontend/tmp_event_check.cjs b/frontend/tmp_event_check.cjs new file mode 100644 index 0000000..6efa4ca --- /dev/null +++ b/frontend/tmp_event_check.cjs @@ -0,0 +1,26 @@ +const https = require('https'); +const data = JSON.stringify({ + method: 'getEvents', + id: 1, + jsonrpc: '2.0', + params: { + contractId: 'CDOETIUHCETALQMBMYUXGFJFA34KDTV74AMHTWXJLY2XUVNZ23JDLJZA', + startLedger: 2860000, + endLedger: 2863010, + limit: 50, + }, +}); +const req = https.request('https://soroban-testnet.stellar.org', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + }, +}, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => console.log(body)); +}); +req.on('error', (e) => { console.error(e); process.exit(1); }); +req.write(data); +req.end(); diff --git a/frontend/tmp_event_check.js b/frontend/tmp_event_check.js new file mode 100644 index 0000000..2976781 --- /dev/null +++ b/frontend/tmp_event_check.js @@ -0,0 +1,26 @@ +const https = require('https'); +const data = JSON.stringify({ + method: 'getEvents', + id: 1, + jsonrpc: '2.0', + params: [{ + contractIds: ['CDOETIUHCETALQMBMYUXGFJFA34KDTV74AMHTWXJLY2XUVNZ23JDLJZA'], + fromLedger: 2860000, + toLedger: 2863010, + limit: 50, + }], +}); +const req = https.request('https://soroban-testnet.stellar.org', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + }, +}, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => console.log(body)); +}); +req.on('error', (e) => { console.error(e); process.exit(1); }); +req.write(data); +req.end(); diff --git a/frontend/tmp_event_check_filter.cjs b/frontend/tmp_event_check_filter.cjs new file mode 100644 index 0000000..aa050c5 --- /dev/null +++ b/frontend/tmp_event_check_filter.cjs @@ -0,0 +1,29 @@ +const https = require('https'); +const vaultId = 'CDOETIUHCETALQMBMYUXGFJFA34KDTV74AMHTWXJLY2XUVNZ23JDLJZA'; +const data = JSON.stringify({ + method: 'getEvents', + id: 1, + jsonrpc: '2.0', + params: { + filters: [ + { type: 'contract', value: vaultId }, + ], + startLedger: 2860000, + endLedger: 2863010, + limit: 5, + }, +}); +const req = https.request('https://soroban-testnet.stellar.org', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + }, +}, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => console.log(body)); +}); +req.on('error', (e) => { console.error(e); process.exit(1); }); +req.write(data); +req.end(); diff --git a/frontend/tmp_event_check_horizon.cjs b/frontend/tmp_event_check_horizon.cjs new file mode 100644 index 0000000..f52ab0b --- /dev/null +++ b/frontend/tmp_event_check_horizon.cjs @@ -0,0 +1,10 @@ +const https = require('https'); +const vaultId = 'CDOETIUHCETALQMBMYUXGFJFA34KDTV74AMHTWXJLY2XUVNZ23JDLJZA'; +const url = `https://soroban-testnet.stellar.org/soroban/events?contract_id=${vaultId}&limit=5`; +https.get(url, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + console.log(body); + }); +}).on('error', (e) => { console.error(e); process.exit(1); }); diff --git a/frontend/tmp_inspect.cjs b/frontend/tmp_inspect.cjs new file mode 100644 index 0000000..1602d5b --- /dev/null +++ b/frontend/tmp_inspect.cjs @@ -0,0 +1,4 @@ +(async () => { + const { rpc: SorobanRpc } = await import('@stellar/stellar-sdk'); + console.log(Object.getOwnPropertyNames(SorobanRpc.Server.prototype).sort()); +})(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..efdeb1f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "TurboLong", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}