From affa1d90071d3a97589758fc3aad5bf4c95a641c Mon Sep 17 00:00:00 2001
From: Kevin Peters <kevin@w7.xyz>
Date: Mon, 25 Sep 2023 20:57:45 -0500
Subject: [PATCH] cloud_functions: Added updateTokenMetadata cloud function

---
 cloud_functions/scripts/deploy.sh          |   1 +
 cloud_functions/src/index.ts               |   2 +
 cloud_functions/src/updateTokenMetadata.ts | 125 ++++++++++++++++++
 database/scripts/updateTokenMetadata.ts    | 141 +--------------------
 database/src/coingecko.ts                  |  31 ++++-
 database/src/token_bridge/address.ts       | 103 +++++++++++++++
 database/src/token_bridge/index.ts         |   1 +
 7 files changed, 269 insertions(+), 135 deletions(-)
 create mode 100644 cloud_functions/src/updateTokenMetadata.ts
 create mode 100644 database/src/token_bridge/address.ts

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<string, string>();
+
+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<TokenMetadata>(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<TokenMetadata>(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<string | null> => {
-  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<string, string>();
 
 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<string | null> => {
+  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';