diff --git a/apps/price_pusher/README.md b/apps/price_pusher/README.md index 4df9dadaf8..401f379560 100644 --- a/apps/price_pusher/README.md +++ b/apps/price_pusher/README.md @@ -83,6 +83,8 @@ Pyth hosts [public endpoints](https://docs.pyth.network/price-feeds/api-instance Hermes RPC providers for more reliability. Please refer to [this document](https://docs.pyth.network/documentation/pythnet-price-feeds/hermes) for more information. +The signing mnemonic can be supplied via either the `--mnemonic-file` flag (path to a file containing the mnemonic) or the `MNEMONIC` environment variable. The environment variable is convenient for platforms that inject secrets as encrypted env vars (e.g. DigitalOcean, Fly.io). If both are supplied, `--mnemonic-file` takes precedence. + To run the price pusher, please run the following commands, replacing the command line arguments as necessary: ```sh diff --git a/apps/price_pusher/package.json b/apps/price_pusher/package.json index 79abf56c0e..3bc5476643 100644 --- a/apps/price_pusher/package.json +++ b/apps/price_pusher/package.json @@ -215,9 +215,10 @@ "dev": "ts-node src/index.ts", "prepublishOnly": "pnpm run build", "start": "node dist/index.cjs", - "test:types": "tsc" + "test:types": "tsc", + "test:unit": "test-unit" }, "type": "module", "types": "./dist/index.d.ts", - "version": "10.4.0" + "version": "10.5.0" } diff --git a/apps/price_pusher/src/aptos/command.ts b/apps/price_pusher/src/aptos/command.ts index ab6928b873..2b384e6a21 100644 --- a/apps/price_pusher/src/aptos/command.ts +++ b/apps/price_pusher/src/aptos/command.ts @@ -3,8 +3,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import fs from "node:fs"; - import { HermesClient } from "@pythnetwork/hermes-client"; import { AptosAccount } from "aptos"; import pino from "pino"; @@ -15,7 +13,7 @@ import { PricePusherMetrics } from "../metrics.js"; import * as options from "../options.js"; import { readPriceConfigFile } from "../price-config.js"; import { PythPriceListener } from "../pyth-price-listener.js"; -import { filterInvalidPriceItems } from "../utils.js"; +import { filterInvalidPriceItems, readMnemonic } from "../utils.js"; import { APTOS_ACCOUNT_HD_PATH, AptosPriceListener, @@ -87,7 +85,7 @@ export default { logger.info(`Metrics server started on port ${metricsPort}`); } - const mnemonic = fs.readFileSync(mnemonicFile, "utf8").trim(); + const mnemonic = readMnemonic(mnemonicFile); const account = AptosAccount.fromDerivePath( APTOS_ACCOUNT_HD_PATH, mnemonic, diff --git a/apps/price_pusher/src/evm/command.ts b/apps/price_pusher/src/evm/command.ts index 281c9d9ab0..8370985f4d 100644 --- a/apps/price_pusher/src/evm/command.ts +++ b/apps/price_pusher/src/evm/command.ts @@ -3,8 +3,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import fs from "node:fs"; - import { HermesClient } from "@pythnetwork/hermes-client"; import pino from "pino"; import type { Options } from "yargs"; @@ -14,7 +12,11 @@ import { PricePusherMetrics } from "../metrics.js"; import * as options from "../options.js"; import { readPriceConfigFile } from "../price-config.js"; import { PythPriceListener } from "../pyth-price-listener.js"; -import { filterInvalidPriceItems, isWsEndpoint } from "../utils.js"; +import { + filterInvalidPriceItems, + isWsEndpoint, + readMnemonic, +} from "../utils.js"; import { createEvmBalanceTracker } from "./balance-tracker.js"; import { getCustomGasStation } from "./custom-gas-station.js"; import { EvmPriceListener, EvmPricePusher } from "./evm.js"; @@ -129,7 +131,7 @@ export default { accessToken: hermesAccessToken, }); - const mnemonic = fs.readFileSync(mnemonicFile, "utf8").trim(); + const mnemonic = readMnemonic(mnemonicFile); let priceItems = priceConfigs.map(({ id, alias }) => ({ alias, id })); diff --git a/apps/price_pusher/src/injective/command.ts b/apps/price_pusher/src/injective/command.ts index d9b5a03b9c..206000e4ac 100644 --- a/apps/price_pusher/src/injective/command.ts +++ b/apps/price_pusher/src/injective/command.ts @@ -1,8 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import fs from "node:fs"; - import { getNetworkInfo } from "@injectivelabs/networks"; import { HermesClient } from "@pythnetwork/hermes-client"; import { pino } from "pino"; @@ -11,7 +9,7 @@ import { Controller } from "../controller.js"; import * as options from "../options.js"; import { readPriceConfigFile } from "../price-config.js"; import { PythPriceListener } from "../pyth-price-listener.js"; -import { filterInvalidPriceItems } from "../utils.js"; +import { filterInvalidPriceItems, readMnemonic } from "../utils.js"; import { InjectivePriceListener, InjectivePricePusher } from "./injective.js"; export default { builder: { @@ -83,7 +81,7 @@ export default { const hermesClient = new HermesClient(priceServiceEndpoint, { accessToken: hermesAccessToken, }); - const mnemonic = fs.readFileSync(mnemonicFile, "utf8").trim(); + const mnemonic = readMnemonic(mnemonicFile); let priceItems = priceConfigs.map(({ id, alias }) => ({ alias, id })); diff --git a/apps/price_pusher/src/options.ts b/apps/price_pusher/src/options.ts index 294dbd8eca..19989b7bc9 100644 --- a/apps/price_pusher/src/options.ts +++ b/apps/price_pusher/src/options.ts @@ -60,9 +60,11 @@ export const pushingFrequency = { export const mnemonicFile = { "mnemonic-file": { - description: "Path to payer mnemonic (private key) file.", - required: true, + description: + "Path to payer mnemonic (private key) file. " + + "If omitted, the mnemonic is read from the `MNEMONIC` environment variable.", type: "string", + required: false, } as Options, }; diff --git a/apps/price_pusher/src/sui/command.ts b/apps/price_pusher/src/sui/command.ts index 270a1b936e..2441f0edf1 100644 --- a/apps/price_pusher/src/sui/command.ts +++ b/apps/price_pusher/src/sui/command.ts @@ -3,8 +3,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import fs from "node:fs"; - import { SuiClient } from "@mysten/sui/client"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; import { HermesClient } from "@pythnetwork/hermes-client"; @@ -16,7 +14,7 @@ import { PricePusherMetrics } from "../metrics.js"; import * as options from "../options.js"; import { readPriceConfigFile } from "../price-config"; import { PythPriceListener } from "../pyth-price-listener.js"; -import { filterInvalidPriceItems } from "../utils.js"; +import { filterInvalidPriceItems, readMnemonic } from "../utils.js"; import { createSuiBalanceTracker } from "./balance-tracker.js"; import { SuiPriceListener, SuiPricePusher } from "./sui.js"; @@ -114,7 +112,7 @@ export default { accessToken: hermesAccessToken, }); - const mnemonic = fs.readFileSync(mnemonicFile, "utf8").trim(); + const mnemonic = readMnemonic(mnemonicFile); const keypair = Ed25519Keypair.deriveKeypair( mnemonic, `m/44'/784'/${accountIndex}'/0'/0'`, diff --git a/apps/price_pusher/src/utils.ts b/apps/price_pusher/src/utils.ts index 5844d0eb81..48b2ec532b 100644 --- a/apps/price_pusher/src/utils.ts +++ b/apps/price_pusher/src/utils.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unnecessary-type-parameters */ /* eslint-disable @typescript-eslint/no-explicit-any */ +import fs from "node:fs"; + import type { HermesClient, HexString } from "@pythnetwork/hermes-client"; import type { PriceItem } from "./interface.js"; @@ -60,6 +62,21 @@ export const assertDefined = (value: T | undefined): T => { } }; +export function readMnemonic(mnemonicFile: string | undefined): string { + if (mnemonicFile !== undefined && mnemonicFile !== "") { + return fs.readFileSync(mnemonicFile, "utf8").trim(); + } + + const envMnemonic = process.env.MNEMONIC; + if (envMnemonic !== undefined && envMnemonic !== "") { + return envMnemonic.trim(); + } + + throw new Error( + "No mnemonic provided. Pass --mnemonic-file or set the MNEMONIC environment variable.", + ); +} + export async function filterInvalidPriceItems( hermesClient: HermesClient, priceItems: PriceItem[], diff --git a/apps/price_pusher/tests/utils.test.ts b/apps/price_pusher/tests/utils.test.ts new file mode 100644 index 0000000000..18dbbb4211 --- /dev/null +++ b/apps/price_pusher/tests/utils.test.ts @@ -0,0 +1,62 @@ +// biome-ignore-all lint/style/noProcessEnv: test file manipulates env vars + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { readMnemonic } from "../src/utils.js"; + +describe("readMnemonic", () => { + let tmpDir: string; + const originalMnemonic = process.env.MNEMONIC; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "price-pusher-test-")); + delete process.env.MNEMONIC; + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + if (originalMnemonic === undefined) { + delete process.env.MNEMONIC; + } else { + process.env.MNEMONIC = originalMnemonic; + } + }); + + it("reads mnemonic from file when path supplied", () => { + const path = join(tmpDir, "mnemonic"); + writeFileSync(path, "from file mnemonic\n"); + expect(readMnemonic(path)).toBe("from file mnemonic"); + }); + + it("falls back to MNEMONIC env var when no file path supplied", () => { + process.env.MNEMONIC = "from env mnemonic"; + expect(readMnemonic(undefined)).toBe("from env mnemonic"); + }); + + it("treats empty-string file path as not supplied", () => { + process.env.MNEMONIC = "from env mnemonic"; + expect(readMnemonic("")).toBe("from env mnemonic"); + }); + + it("file source takes precedence over env var", () => { + const path = join(tmpDir, "mnemonic"); + writeFileSync(path, "from file\n"); + process.env.MNEMONIC = "from env"; + expect(readMnemonic(path)).toBe("from file"); + }); + + it("throws when neither file nor env var supplied", () => { + expect(() => readMnemonic(undefined)).toThrow( + /No mnemonic provided/, + ); + }); + + it("throws when env var is empty string", () => { + process.env.MNEMONIC = ""; + expect(() => readMnemonic(undefined)).toThrow( + /No mnemonic provided/, + ); + }); +});