diff --git a/apps/price_pusher/package.json b/apps/price_pusher/package.json index c4c43553ce..ed11e91e41 100644 --- a/apps/price_pusher/package.json +++ b/apps/price_pusher/package.json @@ -1,6 +1,6 @@ { "name": "@pythnetwork/price-pusher", - "version": "7.1.0", + "version": "8.0.0-alpha", "description": "Pyth Price Pusher", "homepage": "https://pyth.network", "main": "lib/index.js", @@ -64,16 +64,13 @@ "@pythnetwork/pyth-sui-js": "workspace:*", "@pythnetwork/solana-utils": "workspace:*", "@solana/web3.js": "^1.93.0", - "@truffle/hdwallet-provider": "^2.1.3", "@types/pino": "^7.0.5", "aptos": "^1.8.5", "jito-ts": "^3.0.1", "joi": "^17.6.0", "near-api-js": "^3.0.2", "pino": "^9.2.0", - "web3": "^1.8.1", - "web3-core": "^1.8.1", - "web3-eth-contract": "^1.8.1", + "viem": "^2.19.4", "yaml": "^2.1.1", "yargs": "^17.5.1" } diff --git a/apps/price_pusher/src/evm/command.ts b/apps/price_pusher/src/evm/command.ts index 6d3c82b126..58126a92d9 100644 --- a/apps/price_pusher/src/evm/command.ts +++ b/apps/price_pusher/src/evm/command.ts @@ -5,9 +5,12 @@ import * as options from "../options"; import { readPriceConfigFile } from "../price-config"; import { PythPriceListener } from "../pyth-price-listener"; import { Controller } from "../controller"; -import { EvmPriceListener, EvmPricePusher, PythContractFactory } from "./evm"; +import { EvmPriceListener, EvmPricePusher } from "./evm"; import { getCustomGasStation } from "./custom-gas-station"; import pino from "pino"; +import { createClient } from "./super-wallet"; +import { createPythContract } from "./pyth-contract"; +import { isWsEndpoint } from "../utils"; export default { command: "evm", @@ -77,7 +80,7 @@ export default { ...options.priceServiceConnectionLogLevel, ...options.controllerLogLevel, }, - handler: function (argv: any) { + handler: async function (argv: any) { // FIXME: type checks for this const { endpoint, @@ -121,20 +124,22 @@ export default { logger.child({ module: "PythPriceListener" }) ); - const pythContractFactory = new PythContractFactory( - endpoint, - mnemonic, - pythContractAddress - ); + const client = await createClient(endpoint, mnemonic); + const pythContract = createPythContract(client, pythContractAddress); + logger.info( - `Pushing updates from wallet address: ${pythContractFactory - .createWeb3PayerProvider() - .getAddress()}` + `Pushing updates from wallet address: ${client.account.address}` ); + // It is possible to watch the events in the non-ws endpoints, either by getFilter + // or by getLogs, but it is very expensive and our polling mechanism does it + // in a more efficient way. So we only do it with ws endpoints. + const watchEvents = isWsEndpoint(endpoint); + const evmListener = new EvmPriceListener( - pythContractFactory, + pythContract, priceItems, + watchEvents, logger.child({ module: "EvmPriceListener" }), { pollingFrequency, @@ -148,7 +153,8 @@ export default { ); const evmPusher = new EvmPricePusher( priceServiceConnection, - pythContractFactory, + client, + pythContract, logger.child({ module: "EvmPricePusher" }), overrideGasPriceMultiplier, overrideGasPriceMultiplierCap, diff --git a/apps/price_pusher/src/evm/custom-gas-station.ts b/apps/price_pusher/src/evm/custom-gas-station.ts index 05f95e1f8a..0e84dc04ac 100644 --- a/apps/price_pusher/src/evm/custom-gas-station.ts +++ b/apps/price_pusher/src/evm/custom-gas-station.ts @@ -1,4 +1,3 @@ -import Web3 from "web3"; import { CustomGasChainId, TxSpeed, @@ -7,8 +6,9 @@ import { customGasChainIds, } from "../utils"; import { Logger } from "pino"; +import { parseGwei } from "viem"; -type chainMethods = Record Promise>; +type chainMethods = Record Promise>; export class CustomGasStation { private chain: CustomGasChainId; @@ -29,11 +29,10 @@ export class CustomGasStation { private async fetchMaticMainnetGasPrice() { try { - const res = await fetch("https://gasstation-mainnet.matic.network/v2"); + const res = await fetch("https://gasstation.polygon.technology/v2"); const jsonRes = await res.json(); const gasPrice = jsonRes[this.speed].maxFee; - const gweiGasPrice = Web3.utils.toWei(gasPrice.toFixed(2), "Gwei"); - return gweiGasPrice.toString(); + return parseGwei(gasPrice.toFixed(2)); } catch (err) { this.logger.error( err, diff --git a/apps/price_pusher/src/evm/evm.ts b/apps/price_pusher/src/evm/evm.ts index 3fc50e29ab..736c516f18 100644 --- a/apps/price_pusher/src/evm/evm.ts +++ b/apps/price_pusher/src/evm/evm.ts @@ -1,18 +1,17 @@ -import { Contract, EventData } from "web3-eth-contract"; import { IPricePusher, PriceInfo, ChainPriceListener, PriceItem, } from "../interface"; -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 Web3 from "web3"; -import { HttpProvider, WebsocketProvider } from "web3-core"; +import { + addLeading0x, + assertDefined, + DurationInSeconds, + removeLeading0x, +} from "../utils"; +import { PythAbi } from "./pyth-abi"; import { Logger } from "pino"; -import { isWsEndpoint } from "../utils"; import { PriceServiceConnection, HexString, @@ -20,36 +19,41 @@ import { } from "@pythnetwork/price-service-client"; import { CustomGasStation } from "./custom-gas-station"; import { PushAttempt } from "../common"; -import { ProviderOrUrl } from "@truffle/hdwallet-provider/dist/constructor/types"; +import { + WatchContractEventOnLogsParameter, + TransactionExecutionError, + BaseError, + ContractFunctionRevertedError, + FeeCapTooLowError, + InternalRpcError, + InsufficientFundsError, +} from "viem"; + +import { PythContract } from "./pyth-contract"; +import { SuperWalletClient } from "./super-wallet"; export class EvmPriceListener extends ChainPriceListener { - private pythContractFactory: PythContractFactory; - private pythContract: Contract; - private logger: Logger; - constructor( - pythContractFactory: PythContractFactory, + private pythContract: PythContract, priceItems: PriceItem[], - logger: Logger, + private watchEvents: boolean, + private logger: Logger, config: { pollingFrequency: DurationInSeconds; } ) { super(config.pollingFrequency, priceItems); - this.pythContractFactory = pythContractFactory; - this.pythContract = this.pythContractFactory.createPythContract(); + this.pythContract = pythContract; this.logger = logger; } // This method should be awaited on and once it finishes it has the latest value // for the given price feeds (if they exist). async start() { - if (this.pythContractFactory.hasWebsocketProvider()) { - this.logger.info( - "Subscribing to the target network pyth contract events..." - ); - this.startSubscription(); + if (this.watchEvents) { + this.logger.info("Watching target network pyth contract events..."); + this.startWatching(); } else { this.logger.info( "The target network RPC endpoint is not Websocket. " + @@ -61,53 +65,44 @@ export class EvmPriceListener extends ChainPriceListener { await super.start(); } - private async startSubscription() { - for (const { id: priceId } of this.priceItems) { - this.pythContract.events.PriceFeedUpdate( - { - filter: { - id: addLeading0x(priceId), - fresh: true, - }, - }, - this.onPriceFeedUpdate.bind(this) - ); - } + private async startWatching() { + this.pythContract.watchEvent.PriceFeedUpdate( + { id: this.priceItems.map((item) => addLeading0x(item.id)) }, + { strict: true, onLogs: this.onPriceFeedUpdate.bind(this) } + ); } - private onPriceFeedUpdate(err: Error | null, event: EventData) { - if (err !== null) { - this.logger.error( - err, - "PriceFeedUpdate EventEmitter received an error.." + private onPriceFeedUpdate( + logs: WatchContractEventOnLogsParameter + ) { + for (const log of logs) { + const priceId = removeLeading0x(assertDefined(log.args.id)); + + const priceInfo: PriceInfo = { + conf: assertDefined(log.args.conf).toString(), + price: assertDefined(log.args.price).toString(), + publishTime: Number(assertDefined(log.args.publishTime)), + }; + + this.logger.debug( + { priceInfo }, + `Received a new Evm PriceFeedUpdate event for price feed ${this.priceIdToAlias.get( + priceId + )} (${priceId}).` ); - throw err; - } - const priceId = removeLeading0x(event.returnValues.id); - this.logger.debug( - `Received a new Evm PriceFeedUpdate event for price feed ${this.priceIdToAlias.get( - priceId - )} (${priceId}).` - ); - - const priceInfo: PriceInfo = { - conf: event.returnValues.conf, - price: event.returnValues.price, - publishTime: Number(event.returnValues.publishTime), - }; - - this.updateLatestPriceInfo(priceId, priceInfo); + this.updateLatestPriceInfo(priceId, priceInfo); + } } async getOnChainPriceInfo( priceId: HexString ): Promise { - let priceRaw; + let priceRaw: any; try { - priceRaw = await this.pythContract.methods - .getPriceUnsafe(addLeading0x(priceId)) - .call(); + priceRaw = await this.pythContract.read.getPriceUnsafe([ + addLeading0x(priceId), + ]); } catch (err) { this.logger.error(err, `Polling on-chain price for ${priceId} failed.`); return undefined; @@ -128,26 +123,20 @@ export class EvmPriceListener extends ChainPriceListener { } export class EvmPricePusher implements IPricePusher { - private customGasStation?: CustomGasStation; - private pythContract: Contract; - private web3: Web3; - private pusherAddress: string | undefined; + private pusherAddress: `0x${string}` | undefined; private lastPushAttempt: PushAttempt | undefined; constructor( private connection: PriceServiceConnection, - pythContractFactory: PythContractFactory, + private client: SuperWalletClient, + private pythContract: PythContract, private logger: Logger, private overrideGasPriceMultiplier: number, private overrideGasPriceMultiplierCap: number, private updateFeeMultiplier: number, private gasLimit?: number, - customGasStation?: CustomGasStation - ) { - this.customGasStation = customGasStation; - this.pythContract = pythContractFactory.createPythContractWithPayer(); - this.web3 = new Web3(pythContractFactory.createWeb3PayerProvider() as any); - } + private customGasStation?: CustomGasStation + ) {} // 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) @@ -168,17 +157,19 @@ export class EvmPricePusher implements IPricePusher { const priceIdsWith0x = priceIds.map((priceId) => addLeading0x(priceId)); - const priceFeedUpdateData = await this.getPriceFeedsUpdateData( + const priceFeedUpdateData = (await this.getPriceFeedsUpdateData( priceIdsWith0x - ); + )) as `0x${string}`[]; let updateFee; try { - updateFee = await this.pythContract.methods - .getUpdateFee(priceFeedUpdateData) - .call(); - updateFee = Number(updateFee) * (this.updateFeeMultiplier || 1); + updateFee = await this.pythContract.read.getUpdateFee([ + priceFeedUpdateData, + ]); + updateFee = BigInt( + Math.round(Number(updateFee) * (this.updateFeeMultiplier || 1)) + ); this.logger.debug(`Update fee: ${updateFee}`); } catch (e: any) { this.logger.error( @@ -188,17 +179,24 @@ export class EvmPricePusher implements IPricePusher { throw e; } - let gasPrice = Number( - (await this.customGasStation?.getCustomGasPrice()) || - (await this.web3.eth.getGasPrice()) - ); + const fees = await this.client.estimateFeesPerGas(); + + this.logger.debug({ fees }, "Estimated fees"); + + let gasPrice = + Number(await this.customGasStation?.getCustomGasPrice()) || + Number(fees.gasPrice) || + Number(fees.maxFeePerGas); // 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]; + this.pusherAddress = this.client.account.address; } + const lastExecutedNonce = - (await this.web3.eth.getTransactionCount(this.pusherAddress)) - 1; + (await this.client.getTransactionCount({ + address: this.pusherAddress, + })) - 1; let gasPriceToOverride = undefined; @@ -206,13 +204,15 @@ export class EvmPricePusher implements IPricePusher { if (this.lastPushAttempt.nonce <= lastExecutedNonce) { this.lastPushAttempt = undefined; } else { - gasPriceToOverride = Math.ceil( - this.lastPushAttempt.gasPrice * this.overrideGasPriceMultiplier - ); + gasPriceToOverride = + this.lastPushAttempt.gasPrice * this.overrideGasPriceMultiplier; } } - if (gasPriceToOverride !== undefined && gasPriceToOverride > gasPrice) { + if ( + gasPriceToOverride !== undefined && + gasPriceToOverride > Number(gasPrice) + ) { gasPrice = Math.min( gasPriceToOverride, gasPrice * this.overrideGasPriceMultiplierCap @@ -223,42 +223,108 @@ export class EvmPricePusher implements IPricePusher { this.logger.debug(`Using gas price: ${gasPrice} and nonce: ${txNonce}`); - this.pythContract.methods - .updatePriceFeedsIfNecessary( - priceFeedUpdateData, - priceIdsWith0x, - pubTimesToPush - ) - .send({ - value: updateFee, - gasPrice, - nonce: txNonce, - gasLimit: this.gasLimit, - }) - .on("transactionHash", (hash: string) => { - this.logger.info({ hash }, "Price update successful"); - }) - .on("error", (err: Error, receipt?: TransactionReceipt) => { - if (err.message.includes("revert")) { - // Since we are using custom error structs on solidity the rejection - // doesn't return any information why the call has reverted. Assuming that - // the update data is valid there is no possible rejection cause other than - // the target chain price being already updated. + const pubTimesToPushParam = pubTimesToPush.map((pubTime) => + BigInt(pubTime) + ); + + try { + const { request } = + await this.pythContract.simulate.updatePriceFeedsIfNecessary( + [priceFeedUpdateData, priceIdsWith0x, pubTimesToPushParam], + { + value: updateFee, + gasPrice: BigInt(Math.ceil(gasPrice)), + nonce: txNonce, + gas: + this.gasLimit !== undefined + ? BigInt(Math.ceil(this.gasLimit)) + : undefined, + } + ); + + this.logger.debug({ request }, "Simulated request successfully"); + + const hash = await this.client.writeContract(request); + + this.logger.info({ hash }, "Price update sent"); + + this.waitForTransactionReceipt(hash); + } catch (err: any) { + this.logger.debug({ err }, "Simulating or sending transactions failed."); + + if (err instanceof BaseError) { + if ( + err.walk( + (e) => + e instanceof ContractFunctionRevertedError && + e.data?.errorName === "NoFreshUpdate" + ) + ) { this.logger.info( - { err, receipt }, - "Execution reverted. With high probability, the target chain price " + - "has already updated, Skipping this push." + "Simulation reverted because none of the updates are fresh. This is an expected behaviour to save gas. Skipping this push." + ); + return; + } + + if (err.walk((e) => e instanceof InsufficientFundsError)) { + this.logger.error( + { err }, + "Wallet doesn't have enough balance. In rare cases, there might be issues with gas price " + + "calculation in the RPC." + ); + throw err; + } + + if ( + err.walk((e) => e instanceof FeeCapTooLowError) || + err.walk( + (e) => + e instanceof InternalRpcError && + e.details.includes("replacement transaction underpriced") + ) + ) { + this.logger.warn( + "The gas price of the transaction is too low or there is an existing transaction with higher gas with the same nonce. " + + "The price will be increased in the next push. Skipping this push. " + + "If this keeps happening or transactions are not landing you need to increase the override gas price " + + "multiplier and the cap to increase the likelihood of the transaction landing on-chain." ); return; } + if ( + err.walk( + (e) => + e instanceof TransactionExecutionError && + (e.details.includes("nonce too low") || + e.message.includes("Nonce provided for the transaction")) + ) + ) { + this.logger.info( + "The nonce is incorrect. This is an expected behaviour in high frequency or multi-instance setup. Skipping this push." + ); + return; + } + + // We normally crash on unknown failures but we believe that this type of error is safe to skip. The other reason is that + // wometimes we see a TransactionExecutionError because of the nonce without any details and it is not catchable. + if (err.walk((e) => e instanceof TransactionExecutionError)) { + this.logger.error( + { err }, + "Transaction execution failed. This is an expected behaviour in high frequency or multi-instance setup. " + + "Please review this error and file an issue if it is a bug. Skipping this push." + ); + return; + } + + // The following errors are part of the legacy code and might not work as expected. + // We are keeping them in case they help with handling what is not covered above. if ( err.message.includes("the tx doesn't have the correct nonce.") || err.message.includes("nonce too low") || err.message.includes("invalid nonce") ) { this.logger.info( - { err, receipt }, "The nonce is incorrect (are multiple users using this account?). Skipping this push." ); return; @@ -269,9 +335,9 @@ export class EvmPricePusher implements IPricePusher { // LastPushAttempt was stored with the class // Next time the update will be executing, it will check the last attempt // and increase the gas price accordingly. - this.logger.info( - { err, receipt }, - "The transaction failed with error: max fee per gas less than block base fee " + this.logger.warn( + "The transaction failed with error: max fee per gas less than block base fee. " + + "The fee will be increased in the next push. Skipping this push." ); return; } @@ -279,37 +345,26 @@ export class EvmPricePusher implements IPricePusher { if ( err.message.includes("sender doesn't have enough funds to send tx.") ) { - this.logger.error( - { err, receipt }, - "Payer is out of balance, please top it up." - ); - throw err; - } - - if (err.message.includes("transaction underpriced")) { - this.logger.error( - { err, receipt }, - "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; + this.logger.error("Payer is out of balance, please top it up."); + throw new Error("Please top up the wallet"); } if (err.message.includes("could not replace existing tx")) { this.logger.error( - { err, receipt }, - "A transaction with the same nonce has been mined and this one is no longer needed." + "A transaction with the same nonce has been mined and this one is no longer needed. Skipping this push." ); return; } + } - this.logger.error( - { err, receipt }, - "An unidentified error has occured." - ); - throw err; - }); + // If the error is not handled, we will crash the process. + this.logger.error( + { err }, + "The transaction failed with an unhandled error. crashing the process. " + + "Please review this error and file an issue if it is a bug." + ); + throw err; + } // Update lastAttempt this.lastPushAttempt = { @@ -318,6 +373,29 @@ export class EvmPricePusher implements IPricePusher { }; } + private async waitForTransactionReceipt(hash: `0x${string}`): Promise { + try { + const receipt = await this.client.waitForTransactionReceipt({ + hash: hash, + }); + + switch (receipt.status) { + case "success": + this.logger.debug({ hash, receipt }, "Price update successful"); + this.logger.info({ hash }, "Price update successful"); + break; + default: + this.logger.info( + { hash, receipt }, + "Price update did not succeed or its transaction did not land. " + + "This is an expected behaviour in high frequency or multi-instance setup." + ); + } + } catch (err: any) { + this.logger.warn({ err }, "Failed to get transaction receipt"); + } + } + private async getPriceFeedsUpdateData( priceIds: HexString[] ): Promise { @@ -327,86 +405,3 @@ export class EvmPricePusher implements IPricePusher { ); } } - -export class PythContractFactory { - constructor( - private endpoint: string, - private mnemonic: string, - private pythContractAddress: string - ) {} - - /** - * This method creates a web3 Pyth contract with payer (based on HDWalletProvider). As this - * provider is an HDWalletProvider it does not support subscriptions even if the - * endpoint is a websocket endpoint. - * - * @returns Pyth contract - */ - createPythContractWithPayer(): Contract { - const provider = this.createWeb3PayerProvider(); - - const web3 = new Web3(provider as any); - - return new web3.eth.Contract( - AbstractPythAbi as any, - this.pythContractAddress, - { - from: provider.getAddress(0), - } - ); - } - - /** - * This method creates a web3 Pyth contract with the given endpoint as its provider. If - * the endpoint is a websocket endpoint the contract will support subscriptions. - * - * @returns Pyth contract - */ - createPythContract(): Contract { - const provider = this.createWeb3Provider(); - const web3 = new Web3(provider); - return new web3.eth.Contract( - AbstractPythAbi as any, - this.pythContractAddress - ); - } - - hasWebsocketProvider(): boolean { - return isWsEndpoint(this.endpoint); - } - - createWeb3Provider(): HttpProvider | WebsocketProvider { - if (isWsEndpoint(this.endpoint)) { - Web3.providers.WebsocketProvider.prototype.sendAsync = - Web3.providers.WebsocketProvider.prototype.send; - return new Web3.providers.WebsocketProvider(this.endpoint, { - clientConfig: { - keepalive: true, - keepaliveInterval: 30000, - }, - reconnect: { - auto: true, - delay: 1000, - onTimeout: true, - }, - timeout: 30000, - }); - } else { - Web3.providers.HttpProvider.prototype.sendAsync = - Web3.providers.HttpProvider.prototype.send; - return new Web3.providers.HttpProvider(this.endpoint, { - keepAlive: true, - timeout: 30000, - }); - } - } - - createWeb3PayerProvider() { - return new HDWalletProvider({ - mnemonic: { - phrase: this.mnemonic, - }, - providerOrUrl: this.createWeb3Provider() as ProviderOrUrl, - }); - } -} diff --git a/apps/price_pusher/src/evm/pyth-abi.ts b/apps/price_pusher/src/evm/pyth-abi.ts new file mode 100644 index 0000000000..fe6649c1f5 --- /dev/null +++ b/apps/price_pusher/src/evm/pyth-abi.ts @@ -0,0 +1,660 @@ +export const IPythAbi = [ + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + { + indexed: false, + internalType: "uint64", + name: "publishTime", + type: "uint64", + }, + { + indexed: false, + internalType: "int64", + name: "price", + type: "int64", + }, + { + indexed: false, + internalType: "uint64", + name: "conf", + type: "uint64", + }, + ], + name: "PriceFeedUpdate", + type: "event", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + ], + name: "getEmaPrice", + outputs: [ + { + components: [ + { + internalType: "int64", + name: "price", + type: "int64", + }, + { + internalType: "uint64", + name: "conf", + type: "uint64", + }, + { + internalType: "int32", + name: "expo", + type: "int32", + }, + { + internalType: "uint256", + name: "publishTime", + type: "uint256", + }, + ], + internalType: "struct PythStructs.Price", + name: "price", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + { + internalType: "uint256", + name: "age", + type: "uint256", + }, + ], + name: "getEmaPriceNoOlderThan", + outputs: [ + { + components: [ + { + internalType: "int64", + name: "price", + type: "int64", + }, + { + internalType: "uint64", + name: "conf", + type: "uint64", + }, + { + internalType: "int32", + name: "expo", + type: "int32", + }, + { + internalType: "uint256", + name: "publishTime", + type: "uint256", + }, + ], + internalType: "struct PythStructs.Price", + name: "price", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + ], + name: "getEmaPriceUnsafe", + outputs: [ + { + components: [ + { + internalType: "int64", + name: "price", + type: "int64", + }, + { + internalType: "uint64", + name: "conf", + type: "uint64", + }, + { + internalType: "int32", + name: "expo", + type: "int32", + }, + { + internalType: "uint256", + name: "publishTime", + type: "uint256", + }, + ], + internalType: "struct PythStructs.Price", + name: "price", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + ], + name: "getPrice", + outputs: [ + { + components: [ + { + internalType: "int64", + name: "price", + type: "int64", + }, + { + internalType: "uint64", + name: "conf", + type: "uint64", + }, + { + internalType: "int32", + name: "expo", + type: "int32", + }, + { + internalType: "uint256", + name: "publishTime", + type: "uint256", + }, + ], + internalType: "struct PythStructs.Price", + name: "price", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + { + internalType: "uint256", + name: "age", + type: "uint256", + }, + ], + name: "getPriceNoOlderThan", + outputs: [ + { + components: [ + { + internalType: "int64", + name: "price", + type: "int64", + }, + { + internalType: "uint64", + name: "conf", + type: "uint64", + }, + { + internalType: "int32", + name: "expo", + type: "int32", + }, + { + internalType: "uint256", + name: "publishTime", + type: "uint256", + }, + ], + internalType: "struct PythStructs.Price", + name: "price", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + ], + name: "getPriceUnsafe", + outputs: [ + { + components: [ + { + internalType: "int64", + name: "price", + type: "int64", + }, + { + internalType: "uint64", + name: "conf", + type: "uint64", + }, + { + internalType: "int32", + name: "expo", + type: "int32", + }, + { + internalType: "uint256", + name: "publishTime", + type: "uint256", + }, + ], + internalType: "struct PythStructs.Price", + name: "price", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes[]", + name: "updateData", + type: "bytes[]", + }, + ], + name: "getUpdateFee", + outputs: [ + { + internalType: "uint256", + name: "feeAmount", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getValidTimePeriod", + outputs: [ + { + internalType: "uint256", + name: "validTimePeriod", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes[]", + name: "updateData", + type: "bytes[]", + }, + { + internalType: "bytes32[]", + name: "priceIds", + type: "bytes32[]", + }, + { + internalType: "uint64", + name: "minPublishTime", + type: "uint64", + }, + { + internalType: "uint64", + name: "maxPublishTime", + type: "uint64", + }, + ], + name: "parsePriceFeedUpdates", + outputs: [ + { + components: [ + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + { + components: [ + { + internalType: "int64", + name: "price", + type: "int64", + }, + { + internalType: "uint64", + name: "conf", + type: "uint64", + }, + { + internalType: "int32", + name: "expo", + type: "int32", + }, + { + internalType: "uint256", + name: "publishTime", + type: "uint256", + }, + ], + internalType: "struct PythStructs.Price", + name: "price", + type: "tuple", + }, + { + components: [ + { + internalType: "int64", + name: "price", + type: "int64", + }, + { + internalType: "uint64", + name: "conf", + type: "uint64", + }, + { + internalType: "int32", + name: "expo", + type: "int32", + }, + { + internalType: "uint256", + name: "publishTime", + type: "uint256", + }, + ], + internalType: "struct PythStructs.Price", + name: "emaPrice", + type: "tuple", + }, + ], + internalType: "struct PythStructs.PriceFeed[]", + name: "priceFeeds", + type: "tuple[]", + }, + ], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes[]", + name: "updateData", + type: "bytes[]", + }, + { + internalType: "bytes32[]", + name: "priceIds", + type: "bytes32[]", + }, + { + internalType: "uint64", + name: "minPublishTime", + type: "uint64", + }, + { + internalType: "uint64", + name: "maxPublishTime", + type: "uint64", + }, + ], + name: "parsePriceFeedUpdatesUnique", + outputs: [ + { + components: [ + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + { + components: [ + { + internalType: "int64", + name: "price", + type: "int64", + }, + { + internalType: "uint64", + name: "conf", + type: "uint64", + }, + { + internalType: "int32", + name: "expo", + type: "int32", + }, + { + internalType: "uint256", + name: "publishTime", + type: "uint256", + }, + ], + internalType: "struct PythStructs.Price", + name: "price", + type: "tuple", + }, + { + components: [ + { + internalType: "int64", + name: "price", + type: "int64", + }, + { + internalType: "uint64", + name: "conf", + type: "uint64", + }, + { + internalType: "int32", + name: "expo", + type: "int32", + }, + { + internalType: "uint256", + name: "publishTime", + type: "uint256", + }, + ], + internalType: "struct PythStructs.Price", + name: "emaPrice", + type: "tuple", + }, + ], + internalType: "struct PythStructs.PriceFeed[]", + name: "priceFeeds", + type: "tuple[]", + }, + ], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes[]", + name: "updateData", + type: "bytes[]", + }, + ], + name: "updatePriceFeeds", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes[]", + name: "updateData", + type: "bytes[]", + }, + { + internalType: "bytes32[]", + name: "priceIds", + type: "bytes32[]", + }, + { + internalType: "uint64[]", + name: "publishTimes", + type: "uint64[]", + }, + ], + name: "updatePriceFeedsIfNecessary", + outputs: [], + stateMutability: "payable", + type: "function", + }, +] as const; + +export const IPythEventsAbi = [ + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + { + indexed: false, + internalType: "uint64", + name: "publishTime", + type: "uint64", + }, + { + indexed: false, + internalType: "int64", + name: "price", + type: "int64", + }, + { + indexed: false, + internalType: "uint64", + name: "conf", + type: "uint64", + }, + ], + name: "PriceFeedUpdate", + type: "event", + }, +] as const; + +export const PythErrorsAbi = [ + { + inputs: [], + name: "InsufficientFee", + type: "error", + }, + { + inputs: [], + name: "InvalidArgument", + type: "error", + }, + { + inputs: [], + name: "InvalidGovernanceDataSource", + type: "error", + }, + { + inputs: [], + name: "InvalidGovernanceMessage", + type: "error", + }, + { + inputs: [], + name: "InvalidGovernanceTarget", + type: "error", + }, + { + inputs: [], + name: "InvalidUpdateData", + type: "error", + }, + { + inputs: [], + name: "InvalidUpdateDataSource", + type: "error", + }, + { + inputs: [], + name: "InvalidWormholeAddressToSet", + type: "error", + }, + { + inputs: [], + name: "InvalidWormholeVaa", + type: "error", + }, + { + inputs: [], + name: "NoFreshUpdate", + type: "error", + }, + { + inputs: [], + name: "OldGovernanceMessage", + type: "error", + }, + { + inputs: [], + name: "PriceFeedNotFound", + type: "error", + }, + { + inputs: [], + name: "PriceFeedNotFoundWithinRange", + type: "error", + }, + { + inputs: [], + name: "StalePrice", + type: "error", + }, +] as const; + +export const PythAbi = [ + ...IPythAbi, + ...IPythEventsAbi, + ...PythErrorsAbi, +] as const; diff --git a/apps/price_pusher/src/evm/pyth-contract.ts b/apps/price_pusher/src/evm/pyth-contract.ts new file mode 100644 index 0000000000..f5092696e4 --- /dev/null +++ b/apps/price_pusher/src/evm/pyth-contract.ts @@ -0,0 +1,18 @@ +import { getContract, Address, GetContractReturnType } from "viem"; +import { PythAbi } from "./pyth-abi"; +import { SuperWalletClient } from "./super-wallet"; + +export type PythContract = GetContractReturnType< + typeof PythAbi, + SuperWalletClient +>; + +export const createPythContract = ( + client: SuperWalletClient, + address: Address +): PythContract => + getContract({ + client, + abi: PythAbi, + address, + }); diff --git a/apps/price_pusher/src/evm/super-wallet.ts b/apps/price_pusher/src/evm/super-wallet.ts new file mode 100644 index 0000000000..9462ec81fa --- /dev/null +++ b/apps/price_pusher/src/evm/super-wallet.ts @@ -0,0 +1,71 @@ +import { + createPublicClient, + createWalletClient, + defineChain, + http, + webSocket, + Account, + Chain, + publicActions, + Client, + RpcSchema, + WalletActions, + PublicActions, + WebSocketTransport, + HttpTransport, + Transport, +} from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import * as chains from "viem/chains"; +import { isWsEndpoint } from "../utils"; + +const UNKNOWN_CHAIN_CONFIG = { + name: "Unknown", + nativeCurrency: { + name: "Unknown", + symbol: "Unknown", + decimals: 18, + }, + rpcUrls: { + default: { + http: [], + }, + }, +}; + +export type SuperWalletClient = Client< + Transport, + Chain, + Account, + RpcSchema, + PublicActions & WalletActions +>; + +// Get the transport based on the endpoint +const getTransport = (endpoint: string): WebSocketTransport | HttpTransport => + isWsEndpoint(endpoint) ? webSocket(endpoint) : http(endpoint); + +// Get the chain corresponding to the chainId. If the chain is not found, it will return +// an unknown chain which should work fine in most of the cases. We might need to update +// the viem package to support new chains if they don't work as expected with the unknown +// chain. +const getChainById = (chainId: number): Chain => + Object.values(chains).find((chain) => chain.id === chainId) || + defineChain({ id: chainId, ...UNKNOWN_CHAIN_CONFIG }); + +export const createClient = async ( + endpoint: string, + mnemonic: string +): Promise => { + const transport = getTransport(endpoint); + + const chainId = await createPublicClient({ + transport, + }).getChainId(); + + return createWalletClient({ + transport, + account: mnemonicToAccount(mnemonic), + chain: getChainById(chainId), + }).extend(publicActions); +}; diff --git a/apps/price_pusher/src/utils.ts b/apps/price_pusher/src/utils.ts index a498188c10..d540acbfa5 100644 --- a/apps/price_pusher/src/utils.ts +++ b/apps/price_pusher/src/utils.ts @@ -18,12 +18,11 @@ export function removeLeading0x(id: HexString): HexString { return id; } -export function addLeading0x(id: HexString): HexString { - if (id.startsWith("0x")) { - return id; - } - return "0x" + id; -} +export const addLeading0x = (id: HexString): `0x${string}` => + hasLeading0x(id) ? id : `0x${id}`; + +const hasLeading0x = (input: string): input is `0x${string}` => + input.startsWith("0x"); export function isWsEndpoint(endpoint: string): boolean { const url = new URL(endpoint); @@ -47,3 +46,11 @@ export function verifyValidOption< option + " is not a valid option. Please choose between " + validOptions; throw new Error(errorString); } + +export const assertDefined = (value: T | undefined): T => { + if (value === undefined) { + throw new Error("Assertion failed: value was undefined"); + } else { + return value; + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a93f42bef..d5d8828f49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,9 +251,6 @@ importers: '@solana/web3.js': specifier: 1.92.3 version: 1.92.3(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10) - '@truffle/hdwallet-provider': - specifier: ^2.1.3 - version: 2.1.5(@babel/core@7.24.7)(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10) '@types/pino': specifier: ^7.0.5 version: 7.0.5 @@ -272,15 +269,9 @@ importers: pino: specifier: ^9.2.0 version: 9.2.0 - web3: - specifier: ^1.8.1 - version: 1.8.2(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10) - web3-core: - specifier: ^1.8.1 - version: 1.10.0(encoding@0.1.13) - web3-eth-contract: - specifier: ^1.8.1 - version: 1.8.2(encoding@0.1.13) + viem: + specifier: ^2.19.4 + version: 2.19.4(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8) yaml: specifier: ^2.1.1 version: 2.4.3 @@ -8350,6 +8341,17 @@ packages: zod: optional: true + abitype@1.0.5: + resolution: {integrity: sha512-YzDhti7cjlfaBhHutMaboYB21Ha3rXR9QTkNJFzYC4kC8YclaiwPBBBJY8ejFdu2wnJeZCVZSMlQJ7fi8S6hsw==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3 >=3.22.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -18105,6 +18107,14 @@ packages: typescript: optional: true + viem@2.19.4: + resolution: {integrity: sha512-JdhK3ui3uPD2tnpqGNkJaDQV4zTfOeKXcF+VrU8RG88Dn2e0lFjv6l7m0YNmYLsHm+n5vFFfCLcUrTk6xcYv5w==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + viem@2.8.13: resolution: {integrity: sha512-jEbRUjsiBwmoDr3fnKL1Bh1GhK5ERhmZcPLeARtEaQoBTPB6bcO2siKhNPVOF8qrYRnGHGQrZHncBWMQhTjGYg==} peerDependencies: @@ -18611,6 +18621,9 @@ packages: resolution: {integrity: sha512-kQSF2NlHk8yjS3SRiJW3S+U5ibkEmVRhB4/GYsVwGvdAkFC2b+EIE1Ob7J56OmqW9VBZgkx1+SuWqo5JTIJSYQ==} engines: {node: '>=14.0.0', npm: '>=6.12.0'} + webauthn-p256@0.0.5: + resolution: {integrity: sha512-drMGNWKdaixZNobeORVIqq7k5DsRC9FnG201K2QjeOoQLmtSDaSsVZdkg6n5jUALJKcAG++zBPJXmv6hy0nWFg==} + webextension-polyfill@0.10.0: resolution: {integrity: sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==} @@ -23699,7 +23712,7 @@ snapshots: '@fuel-ts/utils': 0.89.2 '@fuel-ts/versions': 0.89.2 '@fuels/vm-asm': 0.50.0 - '@noble/curves': 1.4.0 + '@noble/curves': 1.4.2 events: 3.3.0 graphql: 16.8.2 graphql-request: 5.0.0(encoding@0.1.13)(graphql@16.8.2) @@ -26547,10 +26560,10 @@ snapshots: '@mysten/sui.js@0.32.2(bufferutil@4.0.7)(utf-8-validate@6.0.3)': dependencies: '@mysten/bcs': 0.7.1 - '@noble/curves': 1.4.0 + '@noble/curves': 1.4.2 '@noble/hashes': 1.4.0 '@scure/bip32': 1.4.0 - '@scure/bip39': 1.2.2 + '@scure/bip39': 1.3.0 '@suchipi/femver': 1.0.0 jayson: 4.1.0(bufferutil@4.0.7)(utf-8-validate@6.0.3) rpc-websockets: 7.5.1 @@ -26563,10 +26576,10 @@ snapshots: '@mysten/sui.js@0.32.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@mysten/bcs': 0.7.1 - '@noble/curves': 1.4.0 + '@noble/curves': 1.4.2 '@noble/hashes': 1.4.0 '@scure/bip32': 1.4.0 - '@scure/bip39': 1.2.2 + '@scure/bip39': 1.3.0 '@suchipi/femver': 1.0.0 jayson: 4.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) rpc-websockets: 7.5.1 @@ -26579,10 +26592,10 @@ snapshots: '@mysten/sui.js@0.32.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)': dependencies: '@mysten/bcs': 0.7.1 - '@noble/curves': 1.4.0 + '@noble/curves': 1.4.2 '@noble/hashes': 1.4.0 '@scure/bip32': 1.4.0 - '@scure/bip39': 1.2.2 + '@scure/bip39': 1.3.0 '@suchipi/femver': 1.0.0 jayson: 4.1.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) rpc-websockets: 7.5.1 @@ -28267,9 +28280,9 @@ snapshots: '@scure/bip32@1.4.0': dependencies: - '@noble/curves': 1.4.0 + '@noble/curves': 1.4.2 '@noble/hashes': 1.4.0 - '@scure/base': 1.1.6 + '@scure/base': 1.1.7 '@scure/bip39@1.1.0': dependencies: @@ -28289,7 +28302,7 @@ snapshots: '@scure/bip39@1.2.2': dependencies: '@noble/hashes': 1.3.3 - '@scure/base': 1.1.6 + '@scure/base': 1.1.7 '@scure/bip39@1.3.0': dependencies: @@ -32064,6 +32077,11 @@ snapshots: typescript: 5.5.2 zod: 3.23.8 + abitype@1.0.5(typescript@5.4.5)(zod@3.23.8): + optionalDependencies: + typescript: 5.4.5 + zod: 3.23.8 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -46225,6 +46243,24 @@ snapshots: - utf-8-validate - zod + viem@2.19.4(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8): + dependencies: + '@adraffy/ens-normalize': 1.10.0 + '@noble/curves': 1.4.0 + '@noble/hashes': 1.4.0 + '@scure/bip32': 1.4.0 + '@scure/bip39': 1.3.0 + abitype: 1.0.5(typescript@5.4.5)(zod@3.23.8) + isows: 1.0.4(ws@8.17.1(bufferutil@4.0.8)(utf-8-validate@5.0.10)) + webauthn-p256: 0.0.5 + ws: 8.17.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.4.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + viem@2.8.13(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@6.0.4)(zod@3.23.8): dependencies: '@adraffy/ens-normalize': 1.10.0 @@ -47488,6 +47524,11 @@ snapshots: - utf-8-validate - zod + webauthn-p256@0.0.5: + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + webextension-polyfill@0.10.0: {} webidl-conversions@3.0.1: {}