diff --git a/cloud_functions/scripts/deploy.sh b/cloud_functions/scripts/deploy.sh index 847d671a..df1d31b1 100755 --- a/cloud_functions/scripts/deploy.sh +++ b/cloud_functions/scripts/deploy.sh @@ -156,3 +156,4 @@ gcloud functions deploy compute-tvl-tvm --entry-point computeTvlTvm --runtime no gcloud functions deploy tvl-history --entry-point getTVLHistory --runtime nodejs16 --trigger-http --allow-unauthenticated --timeout 300 --memory 256MB --region europe-west3 --set-env-vars FIRESTORE_TVL_HISTORY_COLLECTION=$FIRESTORE_TVL_HISTORY_COLLECTION gcloud functions deploy message-count-history --entry-point getMessageCountHistory --runtime nodejs16 --trigger-http --allow-unauthenticated --timeout 300 --memory 256MB --region europe-west3 --set-env-vars FIRESTORE_MESSAGE_COUNT_HISTORY_COLLECTION=$FIRESTORE_MESSAGE_COUNT_HISTORY_COLLECTION gcloud functions deploy compute-message-count-history --entry-point computeMessageCountHistory --runtime nodejs16 --trigger-http --no-allow-unauthenticated --timeout 300 --memory 1GB --region europe-west3 --set-env-vars BIGTABLE_INSTANCE_ID=$BIGTABLE_INSTANCE_ID,BIGTABLE_SIGNED_VAAS_TABLE_ID=$BIGTABLE_SIGNED_VAAS_TABLE_ID,FIRESTORE_MESSAGE_COUNT_HISTORY_COLLECTION=$FIRESTORE_MESSAGE_COUNT_HISTORY_COLLECTION +gcloud functions deploy update-token-metadata --entry-point updateTokenMetadata --runtime nodejs16 --trigger-http --no-allow-unauthenticated --timeout 300 --memory 256MB --region europe-west3 --set-env-vars PG_USER=$PG_USER,PG_PASSWORD=$PG_PASSWORD,PG_DATABASE=$PG_DATABASE,PG_HOST=$PG_HOST,PG_TOKEN_METADATA_TABLE=$PG_TOKEN_METADATA_TABLE diff --git a/cloud_functions/src/index.ts b/cloud_functions/src/index.ts index e977a740..c7d653dc 100644 --- a/cloud_functions/src/index.ts +++ b/cloud_functions/src/index.ts @@ -20,6 +20,7 @@ export const { getTVLHistory } = require('./getTVLHistory'); export const { getMessageCountHistory } = require('./getMessageCountHistory'); export const { computeMessageCountHistory } = require('./computeMessageCountHistory'); export const { computeTvlTvm } = require('./computeTvlTvm'); +export const { updateTokenMetadata } = require('./updateTokenMetadata'); // Register an HTTP function with the Functions Framework that will be executed // when you make an HTTP request to the deployed function's endpoint. @@ -41,3 +42,4 @@ functions.http('getTVLHistory', getTVLHistory); functions.http('getMessageCountHistory', getMessageCountHistory); functions.http('computeMessageCountHistory', computeMessageCountHistory); functions.http('computeTvlTvm', computeTvlTvm); +functions.http('updateTokenMetadata', updateTokenMetadata); diff --git a/cloud_functions/src/updateTokenMetadata.ts b/cloud_functions/src/updateTokenMetadata.ts new file mode 100644 index 00000000..3ab2e4eb --- /dev/null +++ b/cloud_functions/src/updateTokenMetadata.ts @@ -0,0 +1,125 @@ +import { + assertEnvironmentVariable, + chunkArray, +} from '@wormhole-foundation/wormhole-monitor-common'; +import knex, { Knex } from 'knex'; +import { ChainId, assertChain, toChainName } from '@certusone/wormhole-sdk'; +import { + COINGECKO_PLATFORM_BY_CHAIN, + CoinGeckoCoin, + TokenMetadata, + fetchCoins, + getNativeAddress, +} from '@wormhole-foundation/wormhole-monitor-database'; + +const coinGeckoCoinIdCache = new Map(); + +const findCoinGeckoCoinId = ( + chainId: ChainId, + nativeAddress: string, + coinGeckoCoins: CoinGeckoCoin[] +): string | null => { + const key = `${chainId}/${nativeAddress}`; + const coinId = coinGeckoCoinIdCache.get(key); + if (coinId !== undefined) { + return coinId; + } + const chainName = toChainName(chainId); + const platform = COINGECKO_PLATFORM_BY_CHAIN[chainName]; + if (platform === undefined) { + return null; + } + for (const coin of coinGeckoCoins) { + if (coin.platforms[platform] === nativeAddress) { + coinGeckoCoinIdCache.set(key, coin.id); + return coin.id; + } + } + return null; +}; + +export async function updateTokenMetadata(req: any, res: any) { + res.set('Access-Control-Allow-Origin', '*'); + if (req.method === 'OPTIONS') { + // Send response to OPTIONS requests + res.set('Access-Control-Allow-Methods', 'GET'); + res.set('Access-Control-Allow-Headers', 'Content-Type'); + res.set('Access-Control-Max-Age', '3600'); + res.sendStatus(204); + return; + } + let pg: Knex | undefined; + try { + pg = knex({ + client: 'pg', + connection: { + host: assertEnvironmentVariable('PG_HOST'), + // port: 5432, // default + user: assertEnvironmentVariable('PG_USER'), + password: assertEnvironmentVariable('PG_PASSWORD'), + database: assertEnvironmentVariable('PG_DATABASE'), + }, + }); + const table = assertEnvironmentVariable('PG_TOKEN_METADATA_TABLE'); + const result = await pg(table) + .select() + .whereNull('native_address') + .orWhereNull('coin_gecko_coin_id'); + const coinGeckoCoins = await fetchCoins(); + const toUpdate: TokenMetadata[] = []; + for (let { + token_chain, + token_address, + native_address, + coin_gecko_coin_id, + name, + symbol, + decimals, + } of result) { + assertChain(token_chain); + let shouldUpdate = false; + if (native_address === null) { + native_address = await getNativeAddress(token_chain, token_address); + shouldUpdate ||= native_address !== null; + } + if (coin_gecko_coin_id === null && native_address !== null) { + coin_gecko_coin_id = findCoinGeckoCoinId(token_chain, native_address, coinGeckoCoins); + shouldUpdate ||= coin_gecko_coin_id !== null; + } + if (shouldUpdate) { + const tokenMetadata: TokenMetadata = { + token_chain, + token_address, + native_address: native_address?.replace('\x00', '') || null, // postgres complains about invalid utf8 byte sequence + coin_gecko_coin_id, + name, + symbol, + decimals, + }; + toUpdate.push(tokenMetadata); + console.log('will update', tokenMetadata); + } + } + if (toUpdate.length > 0) { + const chunks = chunkArray(toUpdate, 100); + let numUpdated = 0; + for (const chunk of chunks) { + const result: any = await pg(table) + .insert(chunk) + .onConflict(['token_chain', 'token_address']) + .merge(['native_address', 'coin_gecko_coin_id']); + numUpdated += result.rowCount; + } + console.log(`updated ${numUpdated} rows`); + } else { + console.log(`nothing to update`); + } + res.sendStatus('200'); + } catch (e) { + console.error(e); + res.sendStatus(500); + } + if (pg) { + await pg.destroy(); + } +} diff --git a/database/scripts/updateTokenMetadata.ts b/database/scripts/updateTokenMetadata.ts index 22badb17..e4d49fee 100644 --- a/database/scripts/updateTokenMetadata.ts +++ b/database/scripts/updateTokenMetadata.ts @@ -4,38 +4,15 @@ import { assertEnvironmentVariable, chunkArray, } from '@wormhole-foundation/wormhole-monitor-common'; +import { ChainId, assertChain, toChainName } from '@certusone/wormhole-sdk'; import { - CHAIN_ID_ALGORAND, - CHAIN_ID_APTOS, - CHAIN_ID_INJECTIVE, - CHAIN_ID_NEAR, - CHAIN_ID_SOLANA, - CHAIN_ID_SUI, - CHAIN_ID_TERRA, - CHAIN_ID_TERRA2, - CHAIN_ID_XPLA, - CONTRACTS, - ChainId, - ChainName, - assertChain, - getTypeFromExternalAddress, - hexToUint8Array, - isEVMChain, - queryExternalId, - queryExternalIdInjective, - toChainName, - tryHexToNativeAssetString, - tryHexToNativeStringNear, -} from '@certusone/wormhole-sdk'; -import { CoinGeckoCoin, TokenMetadata, fetchCoins } from '../src'; + COINGECKO_PLATFORM_BY_CHAIN, + CoinGeckoCoin, + TokenMetadata, + fetchCoins, + getNativeAddress, +} from '../src'; import knex from 'knex'; -import { ChainGrpcWasmApi } from '@injectivelabs/sdk-ts'; -import { Network, getNetworkInfo } from '@injectivelabs/networks'; -import { LCDClient } from '@xpla/xpla.js'; -import { connect } from 'near-api-js'; -import { AptosClient } from 'aptos'; -import { Connection, JsonRpcProvider } from '@mysten/sui.js'; -import { getTokenCoinType } from '@certusone/wormhole-sdk/lib/cjs/sui'; const PG_USER = assertEnvironmentVariable('PG_USER'); const PG_PASSWORD = assertEnvironmentVariable('PG_PASSWORD'); @@ -43,110 +20,6 @@ const PG_DATABASE = assertEnvironmentVariable('PG_DATABASE'); const PG_HOST = assertEnvironmentVariable('PG_HOST'); const TOKEN_METADATA_TABLE = assertEnvironmentVariable('PG_TOKEN_METADATA_TABLE'); -const getNativeAddress = async ( - tokenChain: ChainId, - tokenAddress: string -): Promise => { - try { - if ( - isEVMChain(tokenChain) || - tokenChain === CHAIN_ID_SOLANA || - tokenChain === CHAIN_ID_ALGORAND || - tokenChain === CHAIN_ID_TERRA - ) { - return tryHexToNativeAssetString(tokenAddress, tokenChain); - } else if (tokenChain === CHAIN_ID_XPLA) { - const client = new LCDClient({ - URL: 'https://dimension-lcd.xpla.dev', - chainID: 'dimension_37-1', - }); - return ( - (await queryExternalId(client, CONTRACTS.MAINNET.xpla.token_bridge, tokenAddress)) || null - ); - } else if (tokenChain === CHAIN_ID_TERRA2) { - const client = new LCDClient({ - URL: 'https://phoenix-lcd.terra.dev', - chainID: 'phoenix-1', - }); - return ( - (await queryExternalId(client, CONTRACTS.MAINNET.terra2.token_bridge, tokenAddress)) || null - ); - } else if (tokenChain === CHAIN_ID_INJECTIVE) { - const client = new ChainGrpcWasmApi(getNetworkInfo(Network.MainnetK8s).grpc); - return await queryExternalIdInjective( - client, - CONTRACTS.MAINNET.injective.token_bridge, - tokenAddress - ); - } else if (tokenChain === CHAIN_ID_APTOS) { - const client = new AptosClient('https://fullnode.mainnet.aptoslabs.com'); - return await getTypeFromExternalAddress( - client, - CONTRACTS.MAINNET.aptos.token_bridge, - tokenAddress - ); - } else if (tokenChain === CHAIN_ID_NEAR) { - const NATIVE_NEAR_WH_ADDRESS = - '0000000000000000000000000000000000000000000000000000000000000000'; - const NATIVE_NEAR_PLACEHOLDER = 'near'; - if (tokenAddress === NATIVE_NEAR_WH_ADDRESS) { - return NATIVE_NEAR_PLACEHOLDER; - } else { - const connection = await connect({ - nodeUrl: 'https://rpc.mainnet.near.org', - networkId: 'mainnet', - }); - return await tryHexToNativeStringNear( - connection.connection.provider, - CONTRACTS.MAINNET.near.token_bridge, - tokenAddress - ); - } - } else if (tokenChain === CHAIN_ID_SUI) { - const provider = new JsonRpcProvider( - new Connection({ fullnode: 'https://fullnode.mainnet.sui.io' }) - ); - return await getTokenCoinType( - provider, - CONTRACTS.MAINNET.sui.token_bridge, - hexToUint8Array(tokenAddress), - CHAIN_ID_SUI - ); - } - } catch (e) { - console.error(e); - } - return null; -}; - -// https://api.coingecko.com/api/v3/asset_platforms -const COINGECKO_PLATFORM_BY_CHAIN: { [key in ChainName]?: string } = { - solana: 'solana', - ethereum: 'ethereum', - terra: 'terra', - terra2: 'terra-2', - bsc: 'binance-smart-chain', - polygon: 'polygon-pos', - avalanche: 'avalanche', - oasis: 'oasis', - algorand: 'algorand', - aptos: 'aptos', - aurora: 'aurora', - fantom: 'fantom', - karura: 'karura', - acala: 'acala', - klaytn: 'klay-token', - celo: 'celo', - near: 'near-protocol', - moonbeam: 'moonbeam', - arbitrum: 'arbitrum-one', - optimism: 'optimistic-ethereum', - xpla: undefined, - injective: undefined, - sui: 'sui', - base: 'base', -}; - const coinGeckoCoinIdCache = new Map(); const findCoinGeckoCoinId = ( diff --git a/database/src/coingecko.ts b/database/src/coingecko.ts index 59587361..db69025f 100644 --- a/database/src/coingecko.ts +++ b/database/src/coingecko.ts @@ -1,10 +1,39 @@ +import { ChainName } from '@certusone/wormhole-sdk'; import { chunkArray, sleep } from '@wormhole-foundation/wormhole-monitor-common'; -import axios, { AxiosError, isAxiosError } from 'axios'; +import axios, { isAxiosError } from 'axios'; const COIN_GECKO_API_BASE_URL = 'https://api.coingecko.com/api/v3'; const COIN_GECKO_PRO_API_BASE_URL = 'https://pro-api.coingecko.com/api/v3'; const COIN_GECKO_API_SLEEP_MS = 200; +// https://api.coingecko.com/api/v3/asset_platforms +export const COINGECKO_PLATFORM_BY_CHAIN: { [key in ChainName]?: string } = { + solana: 'solana', + ethereum: 'ethereum', + terra: 'terra', + terra2: 'terra-2', + bsc: 'binance-smart-chain', + polygon: 'polygon-pos', + avalanche: 'avalanche', + oasis: 'oasis', + algorand: 'algorand', + aptos: 'aptos', + aurora: 'aurora', + fantom: 'fantom', + karura: 'karura', + acala: 'acala', + klaytn: 'klay-token', + celo: 'celo', + near: 'near-protocol', + moonbeam: 'moonbeam', + arbitrum: 'arbitrum-one', + optimism: 'optimistic-ethereum', + xpla: undefined, + injective: 'injective', + sui: 'sui', + base: 'base', +}; + export interface CoinGeckoPrices { [coinId: string]: { usd: number | null }; } diff --git a/database/src/token_bridge/address.ts b/database/src/token_bridge/address.ts new file mode 100644 index 00000000..c939cd3f --- /dev/null +++ b/database/src/token_bridge/address.ts @@ -0,0 +1,103 @@ +import { + CHAIN_ID_ALGORAND, + CHAIN_ID_APTOS, + CHAIN_ID_INJECTIVE, + CHAIN_ID_NEAR, + CHAIN_ID_SOLANA, + CHAIN_ID_SUI, + CHAIN_ID_TERRA, + CHAIN_ID_TERRA2, + CHAIN_ID_XPLA, + CONTRACTS, + ChainId, + getTypeFromExternalAddress, + hexToUint8Array, + isEVMChain, + queryExternalId, + queryExternalIdInjective, + tryHexToNativeAssetString, + tryHexToNativeStringNear, +} from '@certusone/wormhole-sdk'; +import { getTokenCoinType } from '@certusone/wormhole-sdk/lib/cjs/sui'; +import { getNetworkInfo, Network } from '@injectivelabs/networks'; +import { ChainGrpcWasmApi } from '@injectivelabs/sdk-ts'; +import { Connection, JsonRpcProvider } from '@mysten/sui.js'; +import { LCDClient } from '@terra-money/terra.js'; +import { AptosClient } from 'aptos'; +import { connect } from 'near-api-js'; + +export const getNativeAddress = async ( + tokenChain: ChainId, + tokenAddress: string +): Promise => { + try { + if ( + isEVMChain(tokenChain) || + tokenChain === CHAIN_ID_SOLANA || + tokenChain === CHAIN_ID_ALGORAND || + tokenChain === CHAIN_ID_TERRA + ) { + return tryHexToNativeAssetString(tokenAddress, tokenChain); + } else if (tokenChain === CHAIN_ID_XPLA) { + const client = new LCDClient({ + URL: 'https://dimension-lcd.xpla.dev', + chainID: 'dimension_37-1', + }); + return ( + (await queryExternalId(client, CONTRACTS.MAINNET.xpla.token_bridge, tokenAddress)) || null + ); + } else if (tokenChain === CHAIN_ID_TERRA2) { + const client = new LCDClient({ + URL: 'https://phoenix-lcd.terra.dev', + chainID: 'phoenix-1', + }); + return ( + (await queryExternalId(client, CONTRACTS.MAINNET.terra2.token_bridge, tokenAddress)) || null + ); + } else if (tokenChain === CHAIN_ID_INJECTIVE) { + const client = new ChainGrpcWasmApi(getNetworkInfo(Network.MainnetK8s).grpc); + return await queryExternalIdInjective( + client, + CONTRACTS.MAINNET.injective.token_bridge, + tokenAddress + ); + } else if (tokenChain === CHAIN_ID_APTOS) { + const client = new AptosClient('https://fullnode.mainnet.aptoslabs.com'); + return await getTypeFromExternalAddress( + client, + CONTRACTS.MAINNET.aptos.token_bridge, + tokenAddress + ); + } else if (tokenChain === CHAIN_ID_NEAR) { + const NATIVE_NEAR_WH_ADDRESS = + '0000000000000000000000000000000000000000000000000000000000000000'; + const NATIVE_NEAR_PLACEHOLDER = 'near'; + if (tokenAddress === NATIVE_NEAR_WH_ADDRESS) { + return NATIVE_NEAR_PLACEHOLDER; + } else { + const connection = await connect({ + nodeUrl: 'https://rpc.mainnet.near.org', + networkId: 'mainnet', + }); + return await tryHexToNativeStringNear( + connection.connection.provider, + CONTRACTS.MAINNET.near.token_bridge, + tokenAddress + ); + } + } else if (tokenChain === CHAIN_ID_SUI) { + const provider = new JsonRpcProvider( + new Connection({ fullnode: 'https://fullnode.mainnet.sui.io' }) + ); + return await getTokenCoinType( + provider, + CONTRACTS.MAINNET.sui.token_bridge, + hexToUint8Array(tokenAddress), + CHAIN_ID_SUI + ); + } + } catch (e) { + console.error(e); + } + return null; +}; diff --git a/database/src/token_bridge/index.ts b/database/src/token_bridge/index.ts index fcb073fe..12bad58e 100644 --- a/database/src/token_bridge/index.ts +++ b/database/src/token_bridge/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './address';