diff --git a/.gitignore b/.gitignore index 3738c8564f..6cd0047e80 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ bigtable-writer.json .aptos tsconfig.tsbuildinfo *~ +*mnemonic* diff --git a/package-lock.json b/package-lock.json index 058d7929e2..b61bd5186d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47819,7 +47819,7 @@ }, "price_pusher": { "name": "@pythnetwork/pyth-price-pusher", - "version": "3.0.0", + "version": "4.0.0", "license": "Apache-2.0", "dependencies": { "@injectivelabs/sdk-ts": "^1.0.457", diff --git a/price_pusher/README.md b/price_pusher/README.md index 940dd46d1e..07ca64024a 100644 --- a/price_pusher/README.md +++ b/price_pusher/README.md @@ -54,16 +54,18 @@ npm run start -- evm --endpoint wss://example-rpc.com \ --price-service-endpoint https://example-pyth-price.com \ --price-config-file "path/to/price-config-file.yaml.testnet.sample.yaml" \ --mnemonic-file "path/to/mnemonic.txt" \ - [--cooldown-duration 10] \ - [--polling-frequency 5] + [--pushing-frequency 10] \ + [--polling-frequency 5] \ + [--override-gas-price-multiplier 1.1] # For Injective npm run start -- injective --grpc-endpoint https://grpc-endpoint.com \ --pyth-contract-address inj1z60tg0... --price-service-endpoint "https://example-pyth-price.com" \ --price-config-file "path/to/price-config-file.yaml.testnet.sample.yaml" \ --mnemonic-file "path/to/mnemonic.txt" \ - [--cooldown-duration 10] \ - [--polling-frequency 5] + [--pushing-frequency 10] \ + [--polling-frequency 5] \ + # Or, run the price pusher docker image instead of building from the source docker run public.ecr.aws/pyth-network/xc-price-pusher:v -- @@ -82,11 +84,11 @@ npm run start -- {network} --help ### Example -For example, to push `BTC/USD` and `BNB/USD` prices on BNB testnet, run the following command: +For example, to push `BTC/USD` and `BNB/USD` prices on Fantom testnet, run the following command: ```sh npm run dev -- evm --endpoint https://endpoints.omniatech.io/v1/fantom/testnet/public \ - --pyth-contract-address 0xd7308b14BF4008e7C7196eC35610B1427C5702EA --price-service-endpoint https://xc-testnet.pyth.network \ + --pyth-contract-address 0xff1a0f4744e8582DF1aE09D5611b887B6a12925C --price-service-endpoint https://xc-testnet.pyth.network \ --mnemonic-file "./mnemonic" --price-config-file "./price-config.testnet.sample.yaml" ``` @@ -125,3 +127,13 @@ docker-compose -f docker-compose.testnet.sample.yaml up It will take a few minutes until all the services are up and running. [pyth price service]: https://github.com/pyth-network/pyth-crosschain/tree/main/price_service/server + +## Reliability + +You can run multiple instances of the price pusher to increase the reliability. It is better to use +difference RPCs to get better reliability in case an RPC goes down. **If you use the same payer account +in different pushers, then due to blockchains nonce or sequence for accounts, a transaction won't be +pushed twiced and you won't pay additional costs most of the time.** However, there might be some race +condiitons in the RPCs because they are often behind a load balancer than can sometimes cause rejected +transactions land on-chain. You can reduce the chances of additional cost overhead by reducing the +pushing frequency. diff --git a/price_pusher/config.evm.testnet.sample.json b/price_pusher/config.evm.testnet.sample.json new file mode 100644 index 0000000000..6773acc426 --- /dev/null +++ b/price_pusher/config.evm.testnet.sample.json @@ -0,0 +1,7 @@ +{ + "endpoint": "https://endpoints.omniatech.io/v1/fantom/testnet/public", + "pyth-contract-address": "0xff1a0f4744e8582DF1aE09D5611b887B6a12925CZ", + "price-service-endpoint": "https://xc-testnet.pyth.network", + "mnemonic-file": "./mnemonic", + "price-config-file": "./price-config.testnet.sample.yaml" +} diff --git a/price_pusher/docker-compose.testnet.sample.yaml b/price_pusher/docker-compose.testnet.sample.yaml index d73d4aec3c..75833cd299 100644 --- a/price_pusher/docker-compose.testnet.sample.yaml +++ b/price_pusher/docker-compose.testnet.sample.yaml @@ -65,9 +65,17 @@ services: - "/command_config" configs: - command_config + - mnemonic + - price_config depends_on: price-service: condition: service_healthy configs: command_config: - file: ./config.injective.testnet.sample.json # Replace this with the path to the configuration file + # Replace this with the path to the configuration file. You need to update the paths defined in + # the config file + file: ./config.injective.testnet.sample.json + mnemonic: + file: ./mnemonic # Replace this with the path to the mnemonic file + price_config: + file: ./price-config.testnet.sample.yaml # Replace this with the path to the price configuration file diff --git a/price_pusher/package.json b/price_pusher/package.json index fa826629a8..a5629d5562 100644 --- a/price_pusher/package.json +++ b/price_pusher/package.json @@ -1,6 +1,6 @@ { "name": "@pythnetwork/pyth-price-pusher", - "version": "3.0.0", + "version": "4.0.0", "description": "Pyth Price Pusher", "homepage": "https://pyth.network", "main": "lib/index.js", diff --git a/price_pusher/src/controller.ts b/price_pusher/src/controller.ts index ee27c8a53b..3023ea9b2f 100644 --- a/price_pusher/src/controller.ts +++ b/price_pusher/src/controller.ts @@ -4,17 +4,17 @@ import { IPricePusher, IPriceListener } from "./interface"; import { PriceConfig, shouldUpdate } from "./price-config"; export class Controller { - private cooldownDuration: DurationInSeconds; + private pushingFrequency: DurationInSeconds; constructor( private priceConfigs: PriceConfig[], private sourcePriceListener: IPriceListener, private targetPriceListener: IPriceListener, private targetChainPricePusher: IPricePusher, config: { - cooldownDuration: DurationInSeconds; + pushingFrequency: DurationInSeconds; } ) { - this.cooldownDuration = config.cooldownDuration; + this.pushingFrequency = config.pushingFrequency; } async start() { @@ -25,7 +25,7 @@ export class Controller { // wait for the listeners to get updated. There could be a restart // before this run and we need to respect the cooldown duration as // their might be a message sent before. - await sleep(this.cooldownDuration * 1000); + await sleep(this.pushingFrequency * 1000); for (;;) { const pricesToPush: PriceConfig[] = []; @@ -58,7 +58,7 @@ export class Controller { ); } - await sleep(this.cooldownDuration * 1000); + await sleep(this.pushingFrequency * 1000); } } } diff --git a/price_pusher/src/evm/command.ts b/price_pusher/src/evm/command.ts index 5f7ed2a9a6..c87a0e8ce3 100644 --- a/price_pusher/src/evm/command.ts +++ b/price_pusher/src/evm/command.ts @@ -34,12 +34,19 @@ export default { choices: ["slow", "standard", "fast"], required: false, } as Options, + "override-gas-price-multiplier": { + description: + "Multiply the gas price by this number if the transaction is not landing to override it. Default to 1.1", + type: "number", + required: false, + default: 1.1, + } as Options, ...options.priceConfigFile, ...options.priceServiceEndpoint, ...options.mnemonicFile, ...options.pythContractAddress, ...options.pollingFrequency, - ...options.cooldownDuration, + ...options.pushingFrequency, }, handler: function (argv: any) { // FIXME: type checks for this @@ -49,17 +56,25 @@ export default { priceServiceEndpoint, mnemonicFile, pythContractAddress, - cooldownDuration, + pushingFrequency, pollingFrequency, customGasStation, txSpeed, + overrideGasPriceMultiplier, } = argv; const priceConfigs = readPriceConfigFile(priceConfigFile); const priceServiceConnection = new PriceServiceConnection( priceServiceEndpoint, { - logger: console, + logger: { + // Log only warnings and errors from the price service client + info: () => undefined, + warn: console.warn, + error: console.error, + debug: () => undefined, + trace: () => undefined, + }, } ); const mnemonic = fs.readFileSync(mnemonicFile, "utf-8").trim(); @@ -84,7 +99,8 @@ export default { const gasStation = getCustomGasStation(customGasStation, txSpeed); const evmPusher = new EvmPricePusher( priceServiceConnection, - pythContractFactory.createPythContractWithPayer(), + pythContractFactory, + overrideGasPriceMultiplier, gasStation ); @@ -93,7 +109,7 @@ export default { pythListener, evmListener, evmPusher, - { cooldownDuration } + { pushingFrequency } ); controller.start(); diff --git a/price_pusher/src/evm/custom-gas-station.ts b/price_pusher/src/evm/custom-gas-station.ts index 15d2faf4a9..42a289f029 100644 --- a/price_pusher/src/evm/custom-gas-station.ts +++ b/price_pusher/src/evm/custom-gas-station.ts @@ -7,7 +7,7 @@ import { customGasChainIds, } from "../utils"; -type chainMethods = Record Promise>; +type chainMethods = Record Promise>; export class CustomGasStation { private chain: CustomGasChainId; @@ -25,11 +25,19 @@ export class CustomGasStation { } private async fetchMaticMainnetGasPrice() { - const res = await fetch("https://gasstation-mainnet.matic.network/v2"); - const jsonRes = await res.json(); - const gasPrice = jsonRes[this.speed].maxFee; - const gweiGasPrice = Web3.utils.toWei(gasPrice.toFixed(2), "Gwei"); - return gweiGasPrice.toString(); + try { + const res = await fetch("https://gasstation-mainnet.matic.network/v2"); + const jsonRes = await res.json(); + const gasPrice = jsonRes[this.speed].maxFee; + const gweiGasPrice = Web3.utils.toWei(gasPrice.toFixed(2), "Gwei"); + return gweiGasPrice.toString(); + } catch (e) { + console.error( + "Failed to fetch gas price from Matic mainnet. Returning undefined" + ); + console.error(e); + return undefined; + } } } diff --git a/price_pusher/src/evm/evm.ts b/price_pusher/src/evm/evm.ts index 22e811b2e0..69b5062eb5 100644 --- a/price_pusher/src/evm/evm.ts +++ b/price_pusher/src/evm/evm.ts @@ -9,7 +9,6 @@ import { TransactionReceipt } from "ethereum-protocol"; import { addLeading0x, DurationInSeconds, removeLeading0x } from "../utils"; import AbstractPythAbi from "@pythnetwork/pyth-sdk-solidity/abis/AbstractPyth.json"; import HDWalletProvider from "@truffle/hdwallet-provider"; -import { Provider } from "web3/providers"; import Web3 from "web3"; import { isWsEndpoint } from "../utils"; import { @@ -18,6 +17,7 @@ import { UnixTimestamp, } from "@pythnetwork/price-service-client"; import { CustomGasStation } from "./custom-gas-station"; +import { Provider } from "web3/providers"; export class EvmPriceListener extends ChainPriceListener { private pythContractFactory: PythContractFactory; @@ -117,18 +117,35 @@ export class EvmPriceListener extends ChainPriceListener { } } +type PushAttempt = { + nonce: number; + gasPrice: number; +}; + export class EvmPricePusher implements IPricePusher { private customGasStation?: CustomGasStation; + private pythContract: Contract; + private web3: Web3; + private pusherAddress: string | undefined; + private lastPushAttempt: PushAttempt | undefined; + constructor( private connection: PriceServiceConnection, - private pythContract: Contract, + pythContractFactory: PythContractFactory, + private overrideGasPriceMultiplier: number, customGasStation?: CustomGasStation ) { this.customGasStation = customGasStation; + this.pythContract = pythContractFactory.createPythContractWithPayer(); + this.web3 = new Web3(pythContractFactory.createWeb3PayerProvider() as any); } + // The pubTimes are passed here to use the values that triggered the push. // This is an optimization to avoid getting a newer value (as an update comes) // and will help multiple price pushers to have consistent behaviour. + // To ensure that we transactions are landing and we are not pushing the prices twice + // we will re-use the same nonce (with a higher gas price) if the previous transaction + // is not landed yet. async updatePriceFeed( priceIds: string[], pubTimesToPush: UnixTimestamp[] @@ -146,10 +163,7 @@ export class EvmPricePusher implements IPricePusher { priceIdsWith0x ); - console.log( - "Pushing ", - priceIdsWith0x.map((priceIdWith0x) => `${priceIdWith0x}`) - ); + console.log("Pushing ", priceIdsWith0x); let updateFee; @@ -165,7 +179,37 @@ export class EvmPricePusher implements IPricePusher { throw e; } - const gasPrice = await this.customGasStation?.getCustomGasPrice(); + let gasPrice = Number( + (await this.customGasStation?.getCustomGasPrice()) || + (await this.web3.eth.getGasPrice()) + ); + + // Try to re-use the same nonce and increase the gas if the last tx is not landed yet. + if (this.pusherAddress === undefined) { + this.pusherAddress = (await this.web3.eth.getAccounts())[0]; + } + const lastExecutedNonce = + (await this.web3.eth.getTransactionCount(this.pusherAddress)) - 1; + + let gasPriceToOverride = undefined; + + if (this.lastPushAttempt !== undefined) { + if (this.lastPushAttempt.nonce <= lastExecutedNonce) { + this.lastPushAttempt = undefined; + } else { + gasPriceToOverride = Math.ceil( + this.lastPushAttempt.gasPrice * this.overrideGasPriceMultiplier + ); + } + } + + if (gasPriceToOverride !== undefined && gasPriceToOverride > gasPrice) { + gasPrice = gasPriceToOverride; + } + + const txNonce = lastExecutedNonce + 1; + + console.log(`Using gas price: ${gasPrice} and nonce: ${txNonce}`); this.pythContract.methods .updatePriceFeedsIfNecessary( @@ -173,7 +217,7 @@ export class EvmPricePusher implements IPricePusher { priceIdsWith0x, pubTimesToPush ) - .send({ value: updateFee, gasPrice }) + .send({ value: updateFee, gasPrice, nonce: txNonce }) .on("transactionHash", (hash: string) => { console.log(`Successful. Tx hash: ${hash}`); }) @@ -207,10 +251,32 @@ export class EvmPricePusher implements IPricePusher { throw err; } + if (err.message.includes("transaction underpriced")) { + console.error( + "The gas price of the transaction is too low. Skipping this push. " + + "You might want to use a custom gas station or increase the override gas price " + + "multiplier to increase the likelihood of the transaction landing on-chain." + ); + return; + } + + if (err.message.includes("could not replace existing tx")) { + console.log( + "A transaction with the same nonce has been mined and this one is no longer needed." + ); + return; + } + console.error("An unidentified error has occured:"); console.error(receipt); throw err; }); + + // Update lastAttempt + this.lastPushAttempt = { + nonce: txNonce, + gasPrice: gasPrice, + }; } private async getPriceFeedsUpdateData( @@ -238,12 +304,7 @@ export class PythContractFactory { * @returns Pyth contract */ createPythContractWithPayer(): Contract { - const provider = new HDWalletProvider({ - mnemonic: { - phrase: this.mnemonic, - }, - providerOrUrl: this.createWeb3Provider() as Provider, - }); + const provider = this.createWeb3PayerProvider(); const web3 = new Web3(provider as any); @@ -275,7 +336,7 @@ export class PythContractFactory { return isWsEndpoint(this.endpoint); } - private createWeb3Provider() { + createWeb3Provider() { if (isWsEndpoint(this.endpoint)) { Web3.providers.WebsocketProvider.prototype.sendAsync = Web3.providers.WebsocketProvider.prototype.send; @@ -300,4 +361,13 @@ export class PythContractFactory { }); } } + + createWeb3PayerProvider() { + return new HDWalletProvider({ + mnemonic: { + phrase: this.mnemonic, + }, + providerOrUrl: this.createWeb3Provider() as Provider, + }); + } } diff --git a/price_pusher/src/injective/command.ts b/price_pusher/src/injective/command.ts index 3a4141d9f6..8b0d04bd65 100644 --- a/price_pusher/src/injective/command.ts +++ b/price_pusher/src/injective/command.ts @@ -24,7 +24,7 @@ export default { ...options.mnemonicFile, ...options.pythContractAddress, ...options.pollingFrequency, - ...options.cooldownDuration, + ...options.pushingFrequency, }, handler: function (argv: any) { // FIXME: type checks for this @@ -34,7 +34,7 @@ export default { priceServiceEndpoint, mnemonicFile, pythContractAddress, - cooldownDuration, + pushingFrequency, pollingFrequency, } = argv; @@ -42,7 +42,14 @@ export default { const priceServiceConnection = new PriceServiceConnection( priceServiceEndpoint, { - logger: console, + logger: { + // Log only warnings and errors from the price service client + info: () => undefined, + warn: console.warn, + error: console.error, + debug: () => undefined, + trace: () => undefined, + }, } ); const mnemonic = fs.readFileSync(mnemonicFile, "utf-8").trim(); @@ -74,7 +81,7 @@ export default { pythListener, injectiveListener, injectivePusher, - { cooldownDuration } + { pushingFrequency } ); controller.start(); diff --git a/price_pusher/src/options.ts b/price_pusher/src/options.ts index 8096ca7932..fa741a7c44 100644 --- a/price_pusher/src/options.ts +++ b/price_pusher/src/options.ts @@ -37,11 +37,11 @@ export const pollingFrequency = { } as Options, }; -export const cooldownDuration = { - "cooldown-duration": { +export const pushingFrequency = { + "pushing-duration": { description: - "The amount of time (in seconds) to wait between pushing price updates. " + - "This value should be greater than the block time of the network, so this program confirms " + + "The frequency to push prices to the RPC. " + + "It is better that the value be greater than the block time of the network, so this program confirms " + "it is updated and does not push it twice.", type: "number", required: false,