diff --git a/Dockerfile b/Dockerfile index 5fce710..5e8a52a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,9 @@ FROM base AS release COPY --from=install /temp/prod/node_modules node_modules COPY package.json package.json COPY src src +COPY chains.example.json /config/chains.json + +ENV CHAIN_CONFIG_PATH=/config/chains.json USER bun EXPOSE 3000/tcp diff --git a/Tiltfile b/Tiltfile index fed2166..86fe322 100644 --- a/Tiltfile +++ b/Tiltfile @@ -21,7 +21,7 @@ docker_build( dockerfile = "./Dockerfile", target="test", only=[ - "package.json", "bun.lock", "src", ".env.test" + "package.json", "bun.lock", "src", ".env.test", "chains.example.json" ] ) diff --git a/chains.example.json b/chains.example.json new file mode 100644 index 0000000..7ce102f --- /dev/null +++ b/chains.example.json @@ -0,0 +1,40 @@ +{ + "10002": { + "wormholeChainId": 10002, + "evmChainId": 11155111, + "rpc": "http://anvil-eth-sepolia:8545", + "name": "Ethereum Sepolia", + "viemChainName": "sepolia", + "viemTokenName": "Ether", + "viemTokenSymbol": "ETH", + "gasPriceDecimals": 18, + "nativeDecimals": 18, + "executorAddress": "0xD0fb39f5a3361F21457653cB70F9D0C9bD86B66B", + "coreContractAddress": "0x4a8bc80Ed5a4067f1CCf107057b8270E0cC11A78", + "capabilities": { + "requestPrefixes": ["ERV1"], + "gasDropOffLimit": "100000000000", + "maxGasLimit": "1000000", + "maxMsgValue": "200000000000" + } + }, + "10004": { + "wormholeChainId": 10004, + "evmChainId": 84532, + "rpc": "http://anvil-base-sepolia:8545", + "name": "Base Sepolia", + "viemChainName": "baseSepolia", + "viemTokenName": "Ether", + "viemTokenSymbol": "ETH", + "gasPriceDecimals": 18, + "nativeDecimals": 18, + "coreContractAddress": "0x79A1027a6A159502049F10906D333EC57E95F083", + "executorAddress": "0x51B47D493CBA7aB97e3F8F163D6Ce07592CE4482", + "capabilities": { + "requestPrefixes": ["ERV1"], + "gasDropOffLimit": "100000000000", + "maxGasLimit": "1000000", + "maxMsgValue": "200000000000" + } + } +} diff --git a/src/chains.test.ts b/src/chains.test.ts new file mode 100644 index 0000000..ac7239a --- /dev/null +++ b/src/chains.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, test, mock, spyOn } from "bun:test"; +import { existsSync, readFileSync } from "fs"; +import { RequestPrefix } from "./types"; +import { chainToChainId } from "@wormhole-foundation/sdk-base"; + +const mockExistsSync = mock(existsSync); +const mockReadFileSync = mock(readFileSync); + +mock.module("fs", () => ({ + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, +})); + +describe("Custom Chain Loading", () => { + beforeEach(() => { + mockExistsSync.mockClear(); + mockReadFileSync.mockClear(); + + delete process.env.CHAIN_CONFIG_PATH; + delete process.env.CUSTOM_CHAINS; + }); + + test("loads custom chains from file when file exists", async () => { + const mockCustomChainData = { + "10003": { + wormholeChainId: 10003, + evmChainId: 421614, + rpc: "https://sepolia-rollup.arbitrum.io/rpc", + name: "Arbitrum Sepolia", + gasPriceDecimals: 18, + nativeDecimals: 18, + executorAddress: "0x1234567890123456789012345678901234567890", + coreContractAddress: "0x0987654321098765432109876543210987654321", + viemChainName: "arbitrumSepolia", + viemTokenName: "Ether", + viemTokenSymbol: "ETH", + capabilities: { + requestPrefixes: ["ERV1"], + gasDropOffLimit: "100000000000", + maxGasLimit: "1000000", + maxMsgValue: "200000000000", + }, + }, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(mockCustomChainData)); + + delete require.cache[require.resolve("./chains")]; + const { enabledChains } = await import("./chains"); + + expect(mockExistsSync).toHaveBeenCalledWith("/config/chains.json"); + expect(mockReadFileSync).toHaveBeenCalledWith( + "/config/chains.json", + "utf-8", + ); + + expect(enabledChains[10003]).toBeDefined(); + expect(enabledChains[10003]!.name).toBe("Arbitrum Sepolia"); + expect(enabledChains[10003]!.evmChainId).toBe(421614); + + expect(enabledChains[10003]!.capabilities.gasDropOffLimit).toBe( + 100000000000n, + ); + expect(enabledChains[10003]!.capabilities.maxGasLimit).toBe(1000000n); + expect(enabledChains[10003]!.capabilities.maxMsgValue).toBe(200000000000n); + expect(enabledChains[10003]!.capabilities.requestPrefixes).toEqual([ + RequestPrefix.ERV1, + ]); + + expect(enabledChains[10003]!.viemChain).toBeDefined(); + expect(enabledChains[10003]!.viemChain?.name).toBe("arbitrumSepolia"); + expect(enabledChains[10003]!.viemChain?.id).toBe(421614); + expect(enabledChains[10003]!.viemChain?.nativeCurrency.name).toBe("Ether"); + expect(enabledChains[10003]!.viemChain?.nativeCurrency.symbol).toBe("ETH"); + }); + + test("loads custom chains from custom path when CHAIN_CONFIG_PATH is set", async () => { + const customPath = "/custom/path/chains.json"; + process.env.CHAIN_CONFIG_PATH = customPath; + + chainToChainId("ArbitrumSepolia"); + const mockCustomChainData = { + "10003": { + wormholeChainId: 10003, + evmChainId: 1, + rpc: "https://eth-mainnet.example.com", + name: "Ethereum Mainnet", + gasPriceDecimals: 18, + nativeDecimals: 18, + executorAddress: "0x1111111111111111111111111111111111111111", + coreContractAddress: "0x2222222222222222222222222222222222222222", + viemChainName: "mainnet", + viemTokenName: "Ether", + viemTokenSymbol: "ETH", + capabilities: { + requestPrefixes: ["ERV1"], + gasDropOffLimit: "50000000000", + maxGasLimit: "2000000", + maxMsgValue: "100000000000", + }, + }, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(mockCustomChainData)); + + delete require.cache[require.resolve("./chains")]; + const { enabledChains } = await import("./chains"); + + expect(mockExistsSync).toHaveBeenCalledWith(customPath); + expect(mockReadFileSync).toHaveBeenCalledWith(customPath, "utf-8"); + expect(enabledChains[10003]).toBeDefined(); + expect(enabledChains[10003]!.name).toBe("Ethereum Mainnet"); + }); + + test("handles file reading errors gracefully", async () => { + const consoleSpy = spyOn(console, "error").mockImplementation(() => {}); + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation(() => { + throw new Error("File read error"); + }); + + delete require.cache[require.resolve("./chains")]; + const { enabledChains } = await import("./chains"); + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to load custom chains from", + "/config/chains.json", + expect.any(Error), + ); + }); + + test("handles invalid JSON in file gracefully", async () => { + const consoleSpy = spyOn(console, "error").mockImplementation(() => {}); + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue("invalid json content"); + + delete require.cache[require.resolve("./chains")]; + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to load custom chains from", + "/config/chains.json", + expect.any(Error), + ); + }); +}); diff --git a/src/chains.ts b/src/chains.ts index 356434d..b58240a 100644 --- a/src/chains.ts +++ b/src/chains.ts @@ -1,6 +1,6 @@ -import type { Chain } from "viem"; -import { RequestPrefix, type Capabilities } from "./types"; -import { baseSepolia, sepolia } from "viem/chains"; +import { defineChain, type Chain } from "viem"; +import { type Capabilities } from "./types"; +import { readFileSync, existsSync } from "fs"; export interface ChainConfig { wormholeChainId: number; @@ -15,39 +15,61 @@ export interface ChainConfig { viemChain?: Chain; } +export interface CustomChainConfig extends Omit { + viemChainName: string; + viemTokenName: string; + viemTokenSymbol: string; +} + +function loadChainsConfig(): Record { + const chains: Record = {}; + + const configPath = process.env.CHAIN_CONFIG_PATH || "/config/chains.json"; + + if (existsSync(configPath)) { + try { + const fileContent = readFileSync(configPath, "utf-8"); + const customChainsData = JSON.parse(fileContent) as Record< + string, + CustomChainConfig + >; + + for (const [chainId, config] of Object.entries(customChainsData)) { + const capabilities = { + ...config.capabilities, + gasDropOffLimit: BigInt(config.capabilities.gasDropOffLimit), + maxGasLimit: BigInt(config.capabilities.maxGasLimit), + maxMsgValue: BigInt(config.capabilities.maxMsgValue), + }; + + const chainConfig: ChainConfig = { + ...config, + capabilities, + viemChain: defineChain({ + id: config.evmChainId, + nativeCurrency: { + decimals: config.nativeDecimals, + name: config.viemTokenName, + symbol: config.viemTokenSymbol, + }, + name: config.viemChainName, + rpcUrls: { + default: { + http: [config.rpc], + }, + }, + }), + }; + chains[Number(chainId)] = chainConfig; + } + } catch (error) { + console.error("Failed to load custom chains from", configPath, error); + } + } + + return chains; +} + export const enabledChains: Record = { - 10002: { - wormholeChainId: 10002, - evmChainId: 11155111, - rpc: "http://anvil-eth-sepolia:8545", - name: "Ethereum Sepolia", - gasPriceDecimals: 18, - nativeDecimals: 18, - executorAddress: "0xD0fb39f5a3361F21457653cB70F9D0C9bD86B66B", - coreContractAddress: "0x4a8bc80Ed5a4067f1CCf107057b8270E0cC11A78", - viemChain: sepolia, - capabilities: { - requestPrefixes: [RequestPrefix.ERV1], - gasDropOffLimit: 100_000_000_000n, - maxGasLimit: 1_000_000n, - maxMsgValue: 100_000_000_000n * 2n, - }, - }, - 10004: { - wormholeChainId: 10004, - evmChainId: 84532, - rpc: "http://anvil-base-sepolia:8545", - name: "Base Sepolia", - gasPriceDecimals: 18, - nativeDecimals: 18, - viemChain: baseSepolia, - coreContractAddress: "0x79A1027a6A159502049F10906D333EC57E95F083", - executorAddress: "0x51B47D493CBA7aB97e3F8F163D6Ce07592CE4482", - capabilities: { - requestPrefixes: [RequestPrefix.ERV1], - gasDropOffLimit: 100_000_000_000n, - maxGasLimit: 1_000_000n, - maxMsgValue: 100_000_000_000n * 2n, - }, - }, + ...loadChainsConfig(), }; diff --git a/src/index.ts b/src/index.ts index 634e3fa..9592dc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,21 +2,21 @@ import express from "express"; import cors from "cors"; import { overrideGuardianSet } from "./overrideGuardianSet"; import { quoteHandler, statusHandler, capabilitiesHandler } from "./api"; +import { enabledChains } from "./chains"; +import { isHex } from "viem"; // @ts-ignore BigInt.prototype.toJSON = function () { - // Can also be JSON.rawJSON(this.toString()); return this.toString(); }; -await overrideGuardianSet( - "http://anvil-eth-sepolia:8545", - "0x4a8bc80Ed5a4067f1CCf107057b8270E0cC11A78", -); -await overrideGuardianSet( - "http://anvil-base-sepolia:8545", - "0x79A1027a6A159502049F10906D333EC57E95F083", -); +for (const chain of Object.values(enabledChains)) { + if (!isHex(chain.coreContractAddress)) { + throw new Error(`Invalid hex address for wormhole core contract`); + } + + await overrideGuardianSet(chain.rpc, chain.coreContractAddress); +} const app = express();