From 5d7c61998645430b1509824795fe62d94845652f Mon Sep 17 00:00:00 2001 From: Noisekit Date: Tue, 2 Jul 2024 15:20:51 +1000 Subject: [PATCH] Port price updaters from arbitrum-lp app --- liquidity/lib/useAllPriceFeeds/index.ts | 1 + liquidity/lib/useAllPriceFeeds/package.json | 12 +++ .../lib/useAllPriceFeeds/useAllPriceFeeds.ts | 26 +++++ .../lib/useErrorParser/decodeBuiltinErrors.ts | 45 ++++++++ .../lib/useErrorParser/decodePythErrors.ts | 59 ++++++++++ liquidity/lib/useErrorParser/index.ts | 4 + liquidity/lib/useErrorParser/package.json | 12 +++ liquidity/lib/useErrorParser/parseError.ts | 102 ++++++++++++++++++ .../lib/useErrorParser/useErrorParser.ts | 17 +++ .../usePriceUpdateTxn/fetchPriceUpdateTxn.ts | 63 +++++++++++ liquidity/lib/usePriceUpdateTxn/index.ts | 1 + liquidity/lib/usePriceUpdateTxn/package.json | 15 +++ .../usePriceUpdateTxn/usePriceUpdateTxn.ts | 55 ++++++++++ yarn.lock | 36 +++++++ 14 files changed, 448 insertions(+) create mode 100644 liquidity/lib/useAllPriceFeeds/index.ts create mode 100644 liquidity/lib/useAllPriceFeeds/package.json create mode 100644 liquidity/lib/useAllPriceFeeds/useAllPriceFeeds.ts create mode 100644 liquidity/lib/useErrorParser/decodeBuiltinErrors.ts create mode 100644 liquidity/lib/useErrorParser/decodePythErrors.ts create mode 100644 liquidity/lib/useErrorParser/index.ts create mode 100644 liquidity/lib/useErrorParser/package.json create mode 100644 liquidity/lib/useErrorParser/parseError.ts create mode 100644 liquidity/lib/useErrorParser/useErrorParser.ts create mode 100644 liquidity/lib/usePriceUpdateTxn/fetchPriceUpdateTxn.ts create mode 100644 liquidity/lib/usePriceUpdateTxn/index.ts create mode 100644 liquidity/lib/usePriceUpdateTxn/package.json create mode 100644 liquidity/lib/usePriceUpdateTxn/usePriceUpdateTxn.ts diff --git a/liquidity/lib/useAllPriceFeeds/index.ts b/liquidity/lib/useAllPriceFeeds/index.ts new file mode 100644 index 00000000..db5ab83d --- /dev/null +++ b/liquidity/lib/useAllPriceFeeds/index.ts @@ -0,0 +1 @@ +export * from './useAllPriceFeeds'; diff --git a/liquidity/lib/useAllPriceFeeds/package.json b/liquidity/lib/useAllPriceFeeds/package.json new file mode 100644 index 00000000..ca9bd988 --- /dev/null +++ b/liquidity/lib/useAllPriceFeeds/package.json @@ -0,0 +1,12 @@ +{ + "name": "@snx-v3/useAllPriceFeeds", + "private": true, + "main": "index.ts", + "version": "0.0.2", + "dependencies": { + "@snx-v3/contracts": "workspace:*", + "@snx-v3/useBlockchain": "workspace:*", + "@tanstack/react-query": "^5.8.3", + "react": "^18.2.0" + } +} diff --git a/liquidity/lib/useAllPriceFeeds/useAllPriceFeeds.ts b/liquidity/lib/useAllPriceFeeds/useAllPriceFeeds.ts new file mode 100644 index 00000000..950c7b95 --- /dev/null +++ b/liquidity/lib/useAllPriceFeeds/useAllPriceFeeds.ts @@ -0,0 +1,26 @@ +import { importExtras } from '@snx-v3/contracts'; +import { Network, useNetwork } from '@snx-v3/useBlockchain'; +import { useQuery } from '@tanstack/react-query'; + +export function useAllPriceFeeds(customNetwork?: Network) { + const { network } = useNetwork(); + const targetNetwork = customNetwork || network; + return useQuery({ + enabled: Boolean(targetNetwork?.id && targetNetwork?.preset), + queryKey: [targetNetwork?.id, 'AllPriceFeeds'], + queryFn: async () => { + if (!(targetNetwork?.id && targetNetwork?.preset)) { + throw 'OMFG'; + } + const extras = await importExtras(targetNetwork.id, targetNetwork.preset); + return Object.entries(extras) + .filter( + ([key, value]) => + String(value).length === 66 && + (key.startsWith('pyth_feed_id_') || (key.startsWith('pyth') && key.endsWith('FeedId'))) + ) + .map(([, value]) => value); + }, + staleTime: 60 * 60 * 1000, + }); +} diff --git a/liquidity/lib/useErrorParser/decodeBuiltinErrors.ts b/liquidity/lib/useErrorParser/decodeBuiltinErrors.ts new file mode 100644 index 00000000..400813f6 --- /dev/null +++ b/liquidity/lib/useErrorParser/decodeBuiltinErrors.ts @@ -0,0 +1,45 @@ +import { ethers } from 'ethers'; + +export function decodeBuiltinErrors(data: string) { + let sighash = ethers.utils.id('Panic(uint256)').slice(0, 10); + if (data.startsWith(sighash)) { + // this is the `Panic` builtin opcode + const reason = ethers.utils.defaultAbiCoder.decode(['uint256'], '0x' + data.slice(10))[0]; + switch (reason.toNumber()) { + case 0x00: + return { name: 'Panic("generic/unknown error")', sighash, args: [{ reason }] }; + case 0x01: + return { name: 'Panic("assertion failed")', sighash, args: [{ reason }] }; + case 0x11: + return { name: 'Panic("unchecked underflow/overflow")', sighash, args: [{ reason }] }; + case 0x12: + return { name: 'Panic("division by zero")', sighash, args: [{ reason }] }; + case 0x21: + return { name: 'Panic("invalid number to enum conversion")', sighash, args: [{ reason }] }; + case 0x22: + return { + name: 'Panic("access to incorrect storage byte array")', + sighash, + args: [{ reason }], + }; + case 0x31: + return { name: 'Panic("pop() empty array")', sighash, args: [{ reason }] }; + case 0x32: + return { name: 'Panic("out of bounds array access")', sighash, args: [{ reason }] }; + case 0x41: + return { name: 'Panic("out of memory")', sighash, args: [{ reason }] }; + case 0x51: + return { name: 'Panic("invalid internal function")', sighash, args: [{ reason }] }; + default: + return { name: 'Panic("unknown")', sighash, args: [{ reason }] }; + } + } + sighash = ethers.utils.id('Error(string)').slice(0, 10); + if (data.startsWith(sighash)) { + // this is the `Error` builtin opcode + const reason = ethers.utils.defaultAbiCoder.decode(['string'], '0x' + data.slice(10)); + return { name: `Error("${reason}")`, sighash, args: [{ reason }] }; + } + + return; +} diff --git a/liquidity/lib/useErrorParser/decodePythErrors.ts b/liquidity/lib/useErrorParser/decodePythErrors.ts new file mode 100644 index 00000000..170cc729 --- /dev/null +++ b/liquidity/lib/useErrorParser/decodePythErrors.ts @@ -0,0 +1,59 @@ +import { ethers } from 'ethers'; + +export const PythErrorsABI = [ + // Function arguments are invalid (e.g., the arguments lengths mismatch) + // Signature: 0xa9cb9e0d + 'error InvalidArgument()', + // Update data is coming from an invalid data source. + // Signature: 0xe60dce71 + 'error InvalidUpdateDataSource()', + // Update data is invalid (e.g., deserialization error) + // Signature: 0xe69ffece + 'error InvalidUpdateData()', + // Insufficient fee is paid to the method. + // Signature: 0x025dbdd4 + 'error InsufficientFee()', + // There is no fresh update, whereas expected fresh updates. + // Signature: 0xde2c57fa + 'error NoFreshUpdate()', + // There is no price feed found within the given range or it does not exists. + // Signature: 0x45805f5d + 'error PriceFeedNotFoundWithinRange()', + // Price feed not found or it is not pushed on-chain yet. + // Signature: 0x14aebe68 + 'error PriceFeedNotFound()', + // Requested price is stale. + // Signature: 0x19abf40e + 'error StalePrice()', + // Given message is not a valid Wormhole VAA. + // Signature: 0x2acbe915 + 'error InvalidWormholeVaa()', + // Governance message is invalid (e.g., deserialization error). + // Signature: 0x97363b35 + 'error InvalidGovernanceMessage()', + // Governance message is not for this contract. + // Signature: 0x63daeb77 + 'error InvalidGovernanceTarget()', + // Governance message is coming from an invalid data source. + // Signature: 0x360f2d87 + 'error InvalidGovernanceDataSource()', + // Governance message is old. + // Signature: 0x88d1b847 + 'error OldGovernanceMessage()', + // The wormhole address to set in SetWormholeAddress governance is invalid. + // Signature: 0x13d3ed82 + 'error InvalidWormholeAddressToSet()', +]; + +export function decodePythErrors(data: string) { + const PythInterface = new ethers.utils.Interface(PythErrorsABI); + try { + const decodedError = PythInterface.parseError(data); + Object.assign(decodedError, { + name: `PythError.${decodedError.name}`, + }); + return decodedError; + } catch (e) { + // whatever + } +} diff --git a/liquidity/lib/useErrorParser/index.ts b/liquidity/lib/useErrorParser/index.ts new file mode 100644 index 00000000..69e9930a --- /dev/null +++ b/liquidity/lib/useErrorParser/index.ts @@ -0,0 +1,4 @@ +export * from './useErrorParser'; +export * from './parseError'; +export * from './decodeBuiltinErrors'; +export * from './decodePythErrors'; diff --git a/liquidity/lib/useErrorParser/package.json b/liquidity/lib/useErrorParser/package.json new file mode 100644 index 00000000..a3168389 --- /dev/null +++ b/liquidity/lib/useErrorParser/package.json @@ -0,0 +1,12 @@ +{ + "name": "@snx-v3/useErrorParser", + "private": true, + "main": "index.ts", + "version": "0.0.2", + "dependencies": { + "@snx-v3/contracts": "workspace:*", + "@snx-v3/useBlockchain": "workspace:*", + "ethers": "^5.7.2", + "react": "^18.2.0" + } +} diff --git a/liquidity/lib/useErrorParser/parseError.ts b/liquidity/lib/useErrorParser/parseError.ts new file mode 100644 index 00000000..fd396a87 --- /dev/null +++ b/liquidity/lib/useErrorParser/parseError.ts @@ -0,0 +1,102 @@ +/* eslint-disable no-console */ +import { importAllErrors } from '@snx-v3/contracts'; +import { ethers } from 'ethers'; +import { decodeBuiltinErrors } from './decodeBuiltinErrors'; +import { decodePythErrors } from './decodePythErrors'; + +export async function parseError({ + error, + chainId, + preset, +}: { + error: Error | any; + chainId: number; + preset: string; +}) { + const errorData = + error?.error?.error?.error?.data || + error?.error?.error?.data || + error?.error?.data?.data || + error?.error?.data || + error?.data?.data || + error?.data; + + if (typeof errorData !== 'string') { + console.log('Error data missing', { error }); + throw error; + } + const AllErrorsContract = await importAllErrors(chainId, preset); + const errorParsed = (() => { + try { + const panic = decodeBuiltinErrors(errorData); + if (panic) { + return panic; + } + const pythError = decodePythErrors(errorData); + if (pythError) { + return pythError; + } + const AllErrorsInterface = new ethers.utils.Interface(AllErrorsContract.abi); + const data = AllErrorsInterface.parseError(errorData); + console.log({ decodedError: data }); + + if ( + data?.name === 'OracleDataRequired' && + data?.args?.oracleContract && + data?.args?.oracleQuery + ) { + const oracleAddress = data?.args?.oracleContract; + const oracleQueryRaw = data?.args?.oracleQuery; + + let updateType, publishTime, stalenessTolerance, feedIds, priceId; + // eslint-disable-next-line prefer-const + [updateType, publishTime, priceId] = ethers.utils.defaultAbiCoder.decode( + ['uint8', 'uint64', 'bytes32'], + oracleQueryRaw + ); + + if (updateType === 1) { + [updateType, stalenessTolerance, feedIds] = ethers.utils.defaultAbiCoder.decode( + ['uint8', 'uint64', 'bytes32[]'], + oracleQueryRaw + ); + publishTime = undefined; + } else { + feedIds = [priceId]; + } + Object.assign(error, { + name: data.name, + error, + args: { + oracleAddress, + oracleQuery: { + updateType, + publishTime: Number(publishTime), + stalenessTolerance: Number(stalenessTolerance), + feedIds, + }, + oracleQueryRaw, + }, + signature: data.signature, + sighash: data.sighash, + errorFragment: data.errorFragment, + }); + return error; + } + return data; + } catch (e) { + console.log(e); + } + return error; + })(); + if (!errorParsed.name) { + throw error; + } + const args = errorParsed?.args + ? Object.fromEntries( + Object.entries(errorParsed.args).filter(([key]) => `${parseInt(key)}` !== key) + ) + : {}; + error.message = `${errorParsed?.name}, ${errorParsed?.sighash} (${JSON.stringify(args)})`; + throw error; +} diff --git a/liquidity/lib/useErrorParser/useErrorParser.ts b/liquidity/lib/useErrorParser/useErrorParser.ts new file mode 100644 index 00000000..d3fa081f --- /dev/null +++ b/liquidity/lib/useErrorParser/useErrorParser.ts @@ -0,0 +1,17 @@ +import { Network, useNetwork } from '@snx-v3/useBlockchain'; +import React from 'react'; +import { parseError } from './parseError'; + +export function useErrorParser(customNetwork?: Network) { + const { network: walletNetwork } = useNetwork(); + const network = customNetwork ? customNetwork : walletNetwork; + return React.useCallback( + async (error: Error) => { + if (network?.id && network?.preset) { + return await parseError({ error, chainId: network?.id, preset: network?.preset }); + } + throw error; + }, + [network?.id, network?.preset] + ); +} diff --git a/liquidity/lib/usePriceUpdateTxn/fetchPriceUpdateTxn.ts b/liquidity/lib/usePriceUpdateTxn/fetchPriceUpdateTxn.ts new file mode 100644 index 00000000..798eac36 --- /dev/null +++ b/liquidity/lib/usePriceUpdateTxn/fetchPriceUpdateTxn.ts @@ -0,0 +1,63 @@ +/* eslint-disable no-console */ +import { EvmPriceServiceConnection } from '@pythnetwork/pyth-evm-js'; +import { ethers } from 'ethers'; + +export async function fetchPriceUpdateTxn({ + provider, + MulticallContract, + PythERC7412WrapperContract, + priceIds, +}: { + provider: ethers.providers.BaseProvider; + MulticallContract: { address: string; abi: string[] }; + PythERC7412WrapperContract: { address: string; abi: string[] }; + priceIds: string[]; +}) { + console.time('fetchPriceUpdateTxn'); + const stalenessTolerance = 1800; // half of 3600 required tolerance + + const MulticallInterface = new ethers.utils.Interface(MulticallContract.abi); + const PythERC7412WrapperInterface = new ethers.utils.Interface(PythERC7412WrapperContract.abi); + const txs = priceIds.map((priceId) => ({ + target: PythERC7412WrapperContract.address, + callData: PythERC7412WrapperInterface.encodeFunctionData('getLatestPrice', [ + priceId, + stalenessTolerance, + ]), + value: 0, + requireSuccess: false, + })); + + const result = await provider.call({ + to: MulticallContract.address, + data: MulticallInterface.encodeFunctionData('aggregate3Value', [txs]), + }); + const [latestPrices] = MulticallInterface.decodeFunctionResult('aggregate3Value', result); + const stalePriceIds = priceIds.filter((_priceId, i) => !latestPrices[i].success); + if (stalePriceIds.length < 1) { + return { + target: PythERC7412WrapperContract.address, + callData: ethers.constants.HashZero, + value: 0, + requireSuccess: false, + }; + } + console.log({ stalePriceIds }); + + const priceService = new EvmPriceServiceConnection('https://hermes.pyth.network'); + const signedOffchainData = await priceService.getPriceFeedsUpdateData(stalePriceIds); + const updateType = 1; + const data = ethers.utils.defaultAbiCoder.encode( + ['uint8', 'uint64', 'bytes32[]', 'bytes[]'], + [updateType, stalenessTolerance, stalePriceIds, signedOffchainData] + ); + console.timeEnd('fetchPriceUpdateTxn'); + const priceUpdateTxn = { + target: PythERC7412WrapperContract.address, + callData: PythERC7412WrapperInterface.encodeFunctionData('fulfillOracleQuery', [data]), + value: stalePriceIds.length, + requireSuccess: true, + }; + console.log({ priceUpdateTxn }); + return priceUpdateTxn; +} diff --git a/liquidity/lib/usePriceUpdateTxn/index.ts b/liquidity/lib/usePriceUpdateTxn/index.ts new file mode 100644 index 00000000..b6fd9f32 --- /dev/null +++ b/liquidity/lib/usePriceUpdateTxn/index.ts @@ -0,0 +1 @@ +export * from './usePriceUpdateTxn'; diff --git a/liquidity/lib/usePriceUpdateTxn/package.json b/liquidity/lib/usePriceUpdateTxn/package.json new file mode 100644 index 00000000..d6ed2093 --- /dev/null +++ b/liquidity/lib/usePriceUpdateTxn/package.json @@ -0,0 +1,15 @@ +{ + "name": "@snx-v3/usePriceUpdateTxn", + "private": true, + "main": "index.ts", + "version": "0.0.2", + "dependencies": { + "@pythnetwork/pyth-evm-js": "^1.42.0", + "@snx-v3/contracts": "workspace:*", + "@snx-v3/useBlockchain": "workspace:*", + "@snx-v3/useErrorParser": "workspace:*", + "@tanstack/react-query": "^5.8.3", + "ethers": "^5.7.2", + "react": "^18.2.0" + } +} diff --git a/liquidity/lib/usePriceUpdateTxn/usePriceUpdateTxn.ts b/liquidity/lib/usePriceUpdateTxn/usePriceUpdateTxn.ts new file mode 100644 index 00000000..8e307314 --- /dev/null +++ b/liquidity/lib/usePriceUpdateTxn/usePriceUpdateTxn.ts @@ -0,0 +1,55 @@ +import { importMulticall3, importPythERC7412Wrapper } from '@snx-v3/contracts'; +import { Network, useNetwork, useProvider } from '@snx-v3/useBlockchain'; +import { useErrorParser } from '@snx-v3/useErrorParser'; +import { useQuery } from '@tanstack/react-query'; +import { ethers } from 'ethers'; +import { fetchPriceUpdateTxn } from './fetchPriceUpdateTxn'; + +export function usePriceUpdateTxn(customNetwork?: Network, priceIds?: string[]) { + const { network: walletNetwork } = useNetwork(); + const network = customNetwork ? customNetwork : walletNetwork; + const walletProvider = useProvider(); + const errorParser = useErrorParser(); + + return useQuery({ + enabled: Boolean(network && priceIds), + queryKey: [network?.id, 'PriceUpdateTxn', { priceIds: priceIds?.map((p) => p.slice(0, 8)) }], + queryFn: async (): Promise<{ + target: string; + callData: string; + value: number; + requireSuccess: boolean; + }> => { + if (!(network && priceIds)) { + throw 'OMFG'; + } + const provider = customNetwork + ? new ethers.providers.JsonRpcProvider(customNetwork.rpcUrl()) + : walletProvider; + + if (!provider) { + throw 'OMFG'; + } + + const MulticallContract = await importMulticall3(network.id, network.preset); + const PythERC7412WrapperContract = await importPythERC7412Wrapper(network.id, network.preset); + + return await fetchPriceUpdateTxn({ + provider, + MulticallContract, + PythERC7412WrapperContract, + priceIds, + }); + }, + throwOnError: (error) => { + // TODO: show toast + errorParser(error); + return false; + }, + + // considering real staleness tolerance at 3_600s, + // refetching price updates every 10m should be way more than enough + staleTime: 10 * 60 * 1000, + refetchInterval: 10 * 60 * 1000, + }); +} diff --git a/yarn.lock b/yarn.lock index 2d10c95c..2e164c1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5363,6 +5363,17 @@ __metadata: languageName: unknown linkType: soft +"@snx-v3/useAllPriceFeeds@workspace:liquidity/lib/useAllPriceFeeds": + version: 0.0.0-use.local + resolution: "@snx-v3/useAllPriceFeeds@workspace:liquidity/lib/useAllPriceFeeds" + dependencies: + "@snx-v3/contracts": "workspace:*" + "@snx-v3/useBlockchain": "workspace:*" + "@tanstack/react-query": "npm:^5.8.3" + react: "npm:^18.2.0" + languageName: unknown + linkType: soft + "@snx-v3/useAllowance@workspace:*, @snx-v3/useAllowance@workspace:liquidity/lib/useAllowance": version: 0.0.0-use.local resolution: "@snx-v3/useAllowance@workspace:liquidity/lib/useAllowance" @@ -5658,6 +5669,17 @@ __metadata: languageName: unknown linkType: soft +"@snx-v3/useErrorParser@workspace:*, @snx-v3/useErrorParser@workspace:liquidity/lib/useErrorParser": + version: 0.0.0-use.local + resolution: "@snx-v3/useErrorParser@workspace:liquidity/lib/useErrorParser" + dependencies: + "@snx-v3/contracts": "workspace:*" + "@snx-v3/useBlockchain": "workspace:*" + ethers: "npm:^5.7.2" + react: "npm:^18.2.0" + languageName: unknown + linkType: soft + "@snx-v3/useEthBalance@workspace:*, @snx-v3/useEthBalance@workspace:liquidity/lib/useEthBalance": version: 0.0.0-use.local resolution: "@snx-v3/useEthBalance@workspace:liquidity/lib/useEthBalance" @@ -5958,6 +5980,20 @@ __metadata: languageName: unknown linkType: soft +"@snx-v3/usePriceUpdateTxn@workspace:liquidity/lib/usePriceUpdateTxn": + version: 0.0.0-use.local + resolution: "@snx-v3/usePriceUpdateTxn@workspace:liquidity/lib/usePriceUpdateTxn" + dependencies: + "@pythnetwork/pyth-evm-js": "npm:^1.42.0" + "@snx-v3/contracts": "workspace:*" + "@snx-v3/useBlockchain": "workspace:*" + "@snx-v3/useErrorParser": "workspace:*" + "@tanstack/react-query": "npm:^5.8.3" + ethers: "npm:^5.7.2" + react: "npm:^18.2.0" + languageName: unknown + linkType: soft + "@snx-v3/useRates@workspace:*, @snx-v3/useRates@workspace:liquidity/lib/useRates": version: 0.0.0-use.local resolution: "@snx-v3/useRates@workspace:liquidity/lib/useRates"