-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Chain assets 2 #11394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Chain assets 2 #11394
Changes from all commits
992c9f4
5293627
acca2ea
2c7c964
76c639d
deca4bf
ed2446f
563959d
7a140e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
|
|
||
| const CACHE_VERSION = "v1.0"; | ||
| const CACHE_DIR = process.env.CHAIN_ASSETS_CACHE_DIR || path.join(__dirname, ".chain-assets-cache"); | ||
| const VERSIONED_CACHE_DIR = path.join(CACHE_DIR, CACHE_VERSION); | ||
|
|
||
| const pathExistsMap: { [key: string]: Promise<void> } = {}; | ||
|
|
||
| async function ensureDirExists(folder: string): Promise<void> { | ||
| if (!pathExistsMap[folder]) { | ||
| pathExistsMap[folder] = (async () => { | ||
| try { | ||
| await fs.promises.access(folder); | ||
| } catch { | ||
| try { | ||
| await fs.promises.mkdir(folder, { recursive: true }); | ||
| } catch (e) { | ||
| console.error("Error creating directory:", (e as any)?.message); | ||
| } | ||
| } | ||
| })(); | ||
| } | ||
| return pathExistsMap[folder]; | ||
| } | ||
|
|
||
| async function storeData(subPath: string, data: any): Promise<void> { | ||
| const filePath = path.join(VERSIONED_CACHE_DIR, subPath); | ||
| await ensureDirExists(path.dirname(filePath)); | ||
| // Write to a temp file in the same directory and rename, so a crash mid-write | ||
| // can't leave a truncated/empty cache file in place. | ||
| const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; | ||
| try { | ||
| await fs.promises.writeFile(tmpPath, JSON.stringify(data)); | ||
| await fs.promises.rename(tmpPath, filePath); | ||
| } catch (e) { | ||
| console.error(`Error storing cache ${filePath}:`, (e as any)?.message); | ||
| fs.promises.unlink(tmpPath).catch(() => {}); | ||
| } | ||
| } | ||
|
|
||
| async function readData(subPath: string): Promise<any> { | ||
| const filePath = path.join(VERSIONED_CACHE_DIR, subPath); | ||
| try { | ||
| const raw = await fs.promises.readFile(filePath, "utf8"); | ||
| return JSON.parse(raw); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| // Strip anything that could escape the cache directory (slashes, dots, control | ||
| // chars) before the chain name is interpolated into a file path. Dashes and | ||
| // underscores stay so existing chain keys like "polygon-zkevm" round-trip. | ||
| function normalizeChain(chain: string): string { | ||
| const cleaned = (chain ?? "").replace(/[^a-zA-Z0-9_-]/g, "").toLowerCase(); | ||
| return cleaned || "unknown"; | ||
| } | ||
|
|
||
| export async function storeChainHistory(chain: string, data: any[]): Promise<void> { | ||
| await storeData(`history/${normalizeChain(chain)}.json`, data); | ||
| } | ||
|
|
||
| export async function readChainHistory(chain: string): Promise<any[] | null> { | ||
| return readData(`history/${normalizeChain(chain)}.json`); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| export async function storeAllChainsHistory(data: any[]): Promise<void> { | ||
| await storeData("history/all.json", data); | ||
| } | ||
|
|
||
| export async function readAllChainsHistory(): Promise<any[] | null> { | ||
| return readData("history/all.json"); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,7 @@ import { additional, excluded } from "../adapters/manual"; | |
| import { storeHistoricalToDB } from "./storeToDb"; | ||
| import { stablecoins } from "../../src/getProtocols"; | ||
| import { metadata as rwaMetadata } from "../../src/rwa/protocols"; | ||
| import { verifyChanges } from "../verifyChanges"; | ||
| import { verifyChangesV2 } from "./verifyChanges"; | ||
| import { fetchTokensList } from "../../src/utils/coinsApi"; | ||
| import { getClosestProtocolItem, getLatestProtocolItems, initializeTVLCacheDB } from "../../src/api2/db"; | ||
| import { hourlyRawTokensTvl } from "../../src/utils/getLastRecord"; | ||
|
|
@@ -208,21 +208,26 @@ async function fetchOutgoingAmountsFromDB(timestamp: number): Promise<{ | |
| const sourceChainAmounts: { [chain: Chain]: { [token: string]: BigNumber } } = {}; | ||
| const protocolAmounts: { [chain: Chain]: { [token: string]: BigNumber } } = {}; | ||
| const destinationChainAmounts: { [chain: Chain]: { [token: string]: BigNumber } } = {}; | ||
|
|
||
| tvls.map(({ data, id }: any) => { | ||
| if (!ids.includes(id)) return; | ||
| Object.keys(data).map((chain: string) => { | ||
| if (excludedTvlKeys.includes(chain)) return; | ||
| Object.keys(data).map((rawChain: string) => { | ||
| if (excludedTvlKeys.includes(rawChain)) return; | ||
| // Some canonical-bridge adapters (xion, noble, echelon_initia, inertia, ...) | ||
| // report their source chain in display case ("XION", "Noble"), which would | ||
| // never match our lowercase chain keys when we later look up sourceChainAmounts[chain]. | ||
| const chain = rawChain.toLowerCase(); | ||
| if (!sourceChainAmounts[chain]) sourceChainAmounts[chain] = {}; | ||
| Object.keys(data[chain]).map((token: string) => { | ||
| Object.keys(data[rawChain]).map((token: string) => { | ||
| const key = normalizeKey(token); | ||
| if (!sourceChainAmounts[chain][key]) sourceChainAmounts[chain][key] = zero; | ||
| sourceChainAmounts[chain][key] = sourceChainAmounts[chain][key].plus(data[chain][token]); | ||
| sourceChainAmounts[chain][key] = sourceChainAmounts[chain][key].plus(data[rawChain][token]); | ||
|
|
||
| if (Object.keys(protocolBridgeIds).includes(id)) { | ||
| const protocolSlug = protocolBridgeIds[id]; | ||
| if (!protocolAmounts[protocolSlug]) protocolAmounts[protocolSlug] = {}; | ||
| if (!protocolAmounts[protocolSlug][key]) protocolAmounts[protocolSlug][key] = zero; | ||
| protocolAmounts[protocolSlug][key] = protocolAmounts[protocolSlug][key].plus(data[chain][token]); | ||
| protocolAmounts[protocolSlug][key] = protocolAmounts[protocolSlug][key].plus(data[rawChain][token]); | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -231,7 +236,7 @@ async function fetchOutgoingAmountsFromDB(timestamp: number): Promise<{ | |
| if (!destinationChainAmounts[destinationChain]) destinationChainAmounts[destinationChain] = {}; | ||
| if (!destinationChainAmounts[destinationChain][key]) destinationChainAmounts[destinationChain][key] = zero; | ||
| destinationChainAmounts[destinationChain][key] = destinationChainAmounts[destinationChain][key].plus( | ||
| data[chain][token] | ||
| data[rawChain][token] | ||
| ); | ||
| }); | ||
| }); | ||
|
|
@@ -283,22 +288,45 @@ async function fetchExcludedAmounts(timestamp: number) { | |
| return excludedAmounts; | ||
| } | ||
|
|
||
| // The open endpoints (yields.llama.fi/lsdRates, stablecoins.llama.fi/stablecoins) | ||
| // are paywalled for some hosts. Use the authenticated pro-api path when | ||
| // INTERNAL_API_KEY is set — same base used throughout the rest of the codebase | ||
| // (see src/rwa/index.ts, src/updateSearch.ts, etc.). | ||
| function proApi(path: string, publicFallback: string): string { | ||
| const key = process.env.INTERNAL_API_KEY; | ||
| return key ? `https://pro-api.llama.fi/${key}/${path.replace(/^\//, "")}` : publicFallback; | ||
| } | ||
|
|
||
| // fetch stablecoin symbols | ||
| async function fetchStablecoinSymbols() { | ||
| const { peggedAssets } = await cachedFetch({ | ||
| key: "stablecoin-symbols", | ||
| endpoint: "https://stablecoins.llama.fi/stablecoins", | ||
| }); | ||
| const symbols = peggedAssets.map((s: any) => s.symbol); | ||
| let symbols: string[] = []; | ||
| try { | ||
| const res: any = await cachedFetch({ | ||
| key: "stablecoin-symbols", | ||
| endpoint: proApi("stablecoins/stablecoins", "https://stablecoins.llama.fi/stablecoins"), | ||
| }); | ||
| if (Array.isArray(res?.peggedAssets)) symbols = res.peggedAssets.map((s: any) => s.symbol); | ||
| else console.warn("fetchStablecoinSymbols: unexpected response shape, falling back to hardcoded list"); | ||
| } catch (e: any) { | ||
| console.warn("fetchStablecoinSymbols failed, falling back to hardcoded list:", e?.message); | ||
| } | ||
| const allSymbols = [...new Set([...symbols, ...stablecoins].map((t) => t.toUpperCase()))]; | ||
| return allSymbols; | ||
| } | ||
|
|
||
| // fetch lst symbols | ||
| async function fetchLstSymbols() { | ||
| const assets = await cachedFetch({ key: "lst-symbols", endpoint: "https://yields.llama.fi/lsdRates" }); | ||
| const symbols = assets.map((s: any) => s.symbol.toUpperCase()); | ||
| return symbols; | ||
| try { | ||
| const assets: any = await cachedFetch({ | ||
| key: "lst-symbols", | ||
| endpoint: proApi("yields/lsdRates", "https://yields.llama.fi/lsdRates"), | ||
| }); | ||
| if (Array.isArray(assets)) return assets.map((s: any) => s.symbol?.toUpperCase()).filter(Boolean); | ||
| console.warn("fetchLstSymbols: non-array response, skipping LST classification"); | ||
| } catch (e: any) { | ||
| console.warn("fetchLstSymbols failed, skipping LST classification:", e?.message); | ||
| } | ||
| return [] as string[]; | ||
| } | ||
|
|
||
| // fetch rwa symbols | ||
|
|
@@ -356,7 +384,7 @@ const newChainAssets = () => ({ | |
| }); | ||
|
|
||
| // main function | ||
| export async function storeChainAssetsV2(override: boolean = false) { | ||
| export async function storeChainAssetsV2(override: boolean = false, dryRun: boolean = false) { | ||
| if (!process.env.COINS_V4_API_URL) { | ||
| throw new Error("storeChainAssetsV2 requires COINS_V4_API_URL — run with coins v4 API configured"); | ||
| } | ||
|
|
@@ -369,6 +397,60 @@ export async function storeChainAssetsV2(override: boolean = false) { | |
| const lstSymbols = await fetchLstSymbols(); | ||
| const rwaSymbols = fetchRwaSymbols(); | ||
|
|
||
| // Bridge / protocol-bridge adapters (noble, xion, echelon_initia, inertia, ...) | ||
| // emit tokens keyed by CoinGecko slug ("usd-coin", "xion-2", "celestia", "initia") | ||
| // or sometimes by bare symbol ("osmo", "axl"). `fetchNativeAndMcaps` only prices | ||
| // tokens it saw on per-chain native lists, so keys like `coingecko:usd-coin` | ||
| // are absent from allPrices. Collect everything the bridge-fetch step produced, | ||
| // pick out whatever allPrices is missing, and resolve it in one batch. | ||
| const allBridgeKeys = new Set<string>(); | ||
| Object.values(destinationChainAmounts).forEach((m) => Object.keys(m).forEach((k) => allBridgeKeys.add(k))); | ||
| Object.values(protocolAmounts).forEach((m) => Object.keys(m).forEach((k) => allBridgeKeys.add(k))); | ||
| const missingBridgeKeys = [...allBridgeKeys].filter((k) => !allPrices[k]); | ||
| if (missingBridgeKeys.length) { | ||
| console.log(`[bridge-price-fill] resolving ${missingBridgeKeys.length} missing bridge/protocol keys via coins API`); | ||
| try { | ||
| const extra = await coins.getPrices(missingBridgeKeys, timestamp); | ||
| const resolvedKeys = Object.keys(extra); | ||
| console.log(`[bridge-price-fill] resolved ${resolvedKeys.length}/${missingBridgeKeys.length} (sample:`, resolvedKeys.slice(0, 5), ")"); | ||
| resolvedKeys.forEach((k) => { | ||
| if (k.startsWith("coingecko:")) (extra[k] as any).decimals = 0; // match native-side convention | ||
| allPrices[k] = extra[k]; | ||
| }); | ||
| // nativeDataAfterMcaps drops keys that have no mcap entry, so the bridge | ||
| // fill must include mcaps for the same keys or every newly-priced bridge | ||
| // amount silently disappears in the next stage. | ||
| if (resolvedKeys.length) { | ||
| try { | ||
| const extraMcaps = await coins.getMcaps(resolvedKeys, timestamp); | ||
| Object.keys(extraMcaps).forEach((k) => { | ||
| allMcaps[k] = extraMcaps[k]; | ||
| }); | ||
| } catch (e: any) { | ||
| console.warn("[bridge-price-fill] mcap fetch failed:", e?.message); | ||
| } | ||
| } | ||
| } catch (e: any) { | ||
| console.warn("[bridge-price-fill] failed:", e?.message); | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // Some canonical-bridge adapters emit tokens keyed by uppercase symbol | ||
| // ("AXLUSDC", "ATOM"). normalizeKey turns those into "coingecko:axlusdc", which | ||
| // almost never matches a real coingecko id. Build a reverse symbol → pricing | ||
| // index from allPrices so those can still be resolved in the destination loop. | ||
| // We also keep the resolved key alongside the data so the destination loop | ||
| // can re-key the amount under a key that actually exists in allMcaps; storing | ||
| // under the unresolved "coingecko:axlusdc" would cause it to be dropped by | ||
| // the mcap stage downstream. | ||
| const symbolToPrice: { [symbol: string]: { key: string; data: CoinsApiData } } = {}; | ||
| Object.entries(allPrices).forEach(([priceKey, p]) => { | ||
| const sym = p?.symbol?.toUpperCase(); | ||
| if (!sym) return; | ||
| // Prefer the first resolution; symbol collisions are ambiguous by construction. | ||
| if (!symbolToPrice[sym]) symbolToPrice[sym] = { key: priceKey, data: p }; | ||
| }); | ||
|
Comment on lines
+438
to
+452
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make the reverse symbol resolver deterministic and use it for protocol balances too. This map currently keeps the first symbol match from Suggested direction- const symbolToPrice: { [symbol: string]: { key: string; data: CoinsApiData } } = {};
+ const symbolToPrice: { [symbol: string]: { key: string; data: CoinsApiData } | null } = {};
Object.entries(allPrices).forEach(([priceKey, p]) => {
const sym = p?.symbol?.toUpperCase();
if (!sym) return;
- if (!symbolToPrice[sym]) symbolToPrice[sym] = { key: priceKey, data: p };
+ if (!symbolToPrice[sym]) {
+ symbolToPrice[sym] = { key: priceKey, data: p };
+ return;
+ }
+ if (symbolToPrice[sym]?.key !== priceKey) symbolToPrice[sym] = null;
});
+
+ function resolveBridgeToken(key: string) {
+ const direct = allPrices[key];
+ if (direct?.price) return { coinData: direct, outputKey: key, isWholeTokenAmount: false };
+ if (!key.startsWith("coingecko:")) return null;
+ const symbol = key.slice("coingecko:".length).toUpperCase();
+ const resolved = symbolToPrice[symbol];
+ if (!resolved) return null;
+ return { coinData: resolved.data, outputKey: resolved.key, isWholeTokenAmount: true };
+ }Then reuse 🤖 Prompt for AI Agents |
||
|
|
||
| // adjust native asset balances by excluded and outgoing amounts | ||
| const nativeDataAfterDeductions: { [chain: Chain]: { [token: string]: BigNumber } } = {}; | ||
| allChainKeys.map((chain: Chain) => { | ||
|
|
@@ -398,10 +480,28 @@ export async function storeChainAssetsV2(override: boolean = false) { | |
| const destinationChainAmount = destinationChainAmounts[chain]; | ||
| Object.keys(destinationChainAmount).map((token: string) => { | ||
| const key = normalizeKey(token); | ||
| const coinData = allPrices[key]; | ||
| let coinData = allPrices[key]; | ||
| let outputKey = key; | ||
| // When the adapter was symbol-keyed, `key` looks like "coingecko:axlusdc" but | ||
| // there is no such coingecko id. Fall back to a symbol reverse lookup. | ||
| // Symbol-keyed adapter output is also typically in whole tokens, not base | ||
| // units, so we must skip the `/ 10^decimals` step for these. | ||
| let isWholeTokenAmount = false; | ||
| if ((!coinData || !coinData.price) && key.startsWith("coingecko:")) { | ||
| const symbol = key.slice("coingecko:".length).toUpperCase(); | ||
| const resolved = symbolToPrice[symbol]; | ||
| if (resolved?.data?.price) { | ||
| coinData = resolved.data; | ||
| // Re-key the amount under the resolved key so allMcaps lookups | ||
| // downstream actually find an entry. | ||
| outputKey = resolved.key; | ||
| isWholeTokenAmount = true; | ||
| } | ||
| } | ||
| if (!coinData || !coinData.price) return; | ||
| const usdAmount = destinationChainAmount[token].times(coinData.price).div(BigNumber(10).pow(coinData.decimals)); | ||
| nativeDataAfterDeductions[chain][key] = usdAmount; | ||
| const divisor = isWholeTokenAmount ? BigNumber(1) : BigNumber(10).pow(coinData.decimals); | ||
| const usdAmount = destinationChainAmount[token].times(coinData.price).div(divisor); | ||
| nativeDataAfterDeductions[chain][outputKey] = usdAmount; | ||
|
Comment on lines
+483
to
+504
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Accumulate when multiple inputs resolve to the same After the new re-keying step, distinct adapter tokens can collapse onto one canonical key. Line 504 overwrites any earlier USD amount instead of adding to it, so part of the bridge TVL disappears whenever two entries resolve to the same asset. Suggested fix- nativeDataAfterDeductions[chain][outputKey] = usdAmount;
+ nativeDataAfterDeductions[chain][outputKey] =
+ (nativeDataAfterDeductions[chain][outputKey] ?? zero).plus(usdAmount);🤖 Prompt for AI Agents |
||
| }); | ||
| } | ||
| }); | ||
|
|
@@ -485,8 +585,9 @@ export async function storeChainAssetsV2(override: boolean = false) { | |
| }); | ||
| }); | ||
|
|
||
| // create symbol key data | ||
| const symbolMapPromise = storeR2JSONString("chainAssetsSymbolMap", JSON.stringify(symbolMap)); | ||
| // The symbol-map R2 write is deferred to the post-validation Promise.all | ||
| // below so we never persist an updated map for a snapshot that | ||
| // verifyChangesV2 ends up rejecting. | ||
| [rawData, symbolData].map((allData) => { | ||
| Object.keys(allData).map((chain: Chain) => { | ||
| let totalTotal = zero; | ||
|
|
@@ -524,10 +625,15 @@ export async function storeChainAssetsV2(override: boolean = false) { | |
| }); | ||
| }); | ||
|
|
||
| if (!override) await verifyChanges(symbolData); | ||
| if (!override) await verifyChangesV2(symbolData); | ||
|
|
||
| if (dryRun) { | ||
| console.log("[dryRun] skipping prod writes (chainAssetsSymbolMap R2, chainassets2 DB, chainAssets2 R2)"); | ||
| return { rawData, symbolData }; | ||
| } | ||
|
|
||
| await Promise.all([ | ||
| symbolMapPromise, | ||
| storeR2JSONString("chainAssetsSymbolMap", JSON.stringify(symbolMap)), | ||
| storeHistoricalToDB({ timestamp: getCurrentUnixTimestamp(), value: rawData }), | ||
| storeR2JSONString("chainAssets2", JSON.stringify({ timestamp: getCurrentUnixTimestamp(), value: symbolData })), | ||
| ]); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.