diff --git a/package.json b/package.json index 3b659c0..489a330 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@gearbox-protocol/eslint-config": "2.0.0-next.2", "@gearbox-protocol/liquidator-v2-contracts": "^2.1.0", "@gearbox-protocol/prettier-config": "2.0.0-next.0", - "@gearbox-protocol/sdk-gov": "^2.18.2", + "@gearbox-protocol/sdk-gov": "^2.18.5", "@gearbox-protocol/types": "^1.12.1", "@redstone-finance/evm-connector": "^0.6.1", "@types/node": "^22.5.0", @@ -52,9 +52,9 @@ "pino": "^9.3.2", "prettier": "^3.3.3", "redstone-protocol": "^1.0.5", - "tsx": "^4.18.0", + "tsx": "^4.19.0", "typescript": "^5.5.4", - "viem": "^2.20.0", + "viem": "^2.20.1", "vitest": "^2.0.5" }, "prettier": "@gearbox-protocol/prettier-config", diff --git a/src/services/OracleServiceV3.ts b/src/services/OracleServiceV3.ts index fe943cb..f19db5f 100644 --- a/src/services/OracleServiceV3.ts +++ b/src/services/OracleServiceV3.ts @@ -33,6 +33,9 @@ interface PriceFeedEntry { } export interface RedstoneFeed { + /** + * Can be real token or ticker address + */ token: Address; dataFeedId: string; reserve: boolean; diff --git a/src/services/RedstoneServiceV3.ts b/src/services/RedstoneServiceV3.ts index 53b064b..cd3b5c8 100644 --- a/src/services/RedstoneServiceV3.ts +++ b/src/services/RedstoneServiceV3.ts @@ -8,6 +8,7 @@ import { } from "@gearbox-protocol/sdk-gov"; import { iCreditFacadeV3MulticallAbi } from "@gearbox-protocol/types/abi"; import { DataServiceWrapper } from "@redstone-finance/evm-connector"; +import type { SignedDataPackage } from "redstone-protocol"; import { RedstonePayload } from "redstone-protocol"; import type { Address } from "viem"; import { @@ -34,6 +35,18 @@ import type { PriceOnDemandExtras, PriceUpdate } from "./liquidate/index.js"; import type { RedstoneFeed } from "./OracleServiceV3.js"; import type OracleServiceV3 from "./OracleServiceV3.js"; +interface RedstoneRequest { + originalToken: Address; + tokenOrTicker: Address; + reserve: boolean; + dataFeedId: string; +} + +interface TimestampedCalldata { + callData: `0x${string}`; + ts: number; +} + interface RedstoneUpdate extends RedstoneFeed { /** * In case when Redstone feed is using ticker to updates, this will be the original token @@ -113,7 +126,7 @@ export class RedstoneServiceV3 { } public async updatesForTokens( - tokens: string[], + tokens: Address[], activeOnly: boolean, logContext: Record = {}, ): Promise { @@ -168,18 +181,11 @@ export class RedstoneServiceV3 { logger.debug( `need to update ${redstoneUpdates.length} redstone feeds: ${printFeeds(redstoneUpdates)}`, ); - const result = await Promise.all( - redstoneUpdates.map(({ originalToken, token, dataFeedId, reserve }) => - this.#getRedstonePayloadForManualUsage( - originalToken, - token, - reserve, - "redstone-primary-prod", - dataFeedId, - REDSTONE_SIGNERS.signersThreshold, - logContext, - ), - ), + + const result = await this.#getRedstonePayloadForManualUsage( + redstoneUpdates, + "redstone-primary-prod", + REDSTONE_SIGNERS.signersThreshold, ); if (this.config.optimistic && result.length > 0) { @@ -245,7 +251,7 @@ export class RedstoneServiceV3 { ca: CreditAccountData, activeOnly = false, ): Promise { - const accTokens: string[] = []; + const accTokens: Address[] = []; for (const { token, balance, isEnabled } of ca.allBalances) { if (isEnabled && balance > 10n) { accTokens.push(token); @@ -313,96 +319,96 @@ export class RedstoneServiceV3 { } async #getRedstonePayloadForManualUsage( - originalToken: Address, - tokenOrTicker: Address, - reserve: boolean, + updates: RedstoneUpdate[], dataServiceId: string, - dataFeedId: string, uniqueSignersCount: number, logContext: Record = {}, - ): Promise { + ): Promise { const logger = this.logger.child(logContext); const cacheAllowed = this.config.optimistic; - const key = redstoneCacheKey( - tokenOrTicker, - reserve, + + const networkUpdates: RedstoneUpdate[] = []; + const cachedResponses: PriceOnDemandExtras[] = []; + + for (const upd of updates) { + const key = redstoneCacheKey(upd, dataServiceId, uniqueSignersCount); + if (cacheAllowed && this.#optimisticCache.has(key)) { + logger.debug(`using cached response for ${key}`); + cachedResponses.push(this.#optimisticCache.get(key)!); + } else { + networkUpdates.push(upd); + } + } + + const networkResponses = await this.#fetchRedstonePayloadForManualUsage( + networkUpdates, dataServiceId, - dataFeedId, uniqueSignersCount, ); + if (cacheAllowed) { - if (this.#optimisticCache.has(key)) { - logger.debug(`using cached response for ${key}`); - return this.#optimisticCache.get(key)!; + for (const resp of networkResponses) { + const key = redstoneCacheKey(resp, dataServiceId, uniqueSignersCount); + this.#optimisticCache.set(key, resp); } } + this.logger.debug( + `got ${networkResponses.length} updates from redstone and ${cachedResponses.length} from cache`, + ); + + return [...networkResponses, ...cachedResponses]; + } + + async #fetchRedstonePayloadForManualUsage( + updates: RedstoneUpdate[], + dataServiceId: string, + uniqueSignersCount: number, + ): Promise { const dataPayload = await new DataServiceWrapper({ dataServiceId, - dataPackagesIds: [dataFeedId], + dataPackagesIds: Array.from(new Set(updates.map(t => t.dataFeedId))), uniqueSignersCount, - historicalTimestamp: HISTORICAL_BLOCKLIST.has(dataFeedId) - ? undefined - : this.#optimisticTimestamp, + historicalTimestamp: this.#optimisticTimestamp, }).prepareRedstonePayload(true); - const { signedDataPackages, unsignedMetadata } = RedstonePayload.parse( - toBytes(`0x${dataPayload}`), - ); - - const dataPackagesList = splitResponse( - signedDataPackages, - uniqueSignersCount, - ); - - const result = dataPackagesList.map(list => { - const payload = new RedstonePayload( - list, - bytesToString(unsignedMetadata), + // unsigned metadata looks like + // "1724772413180#0.6.1#redstone-primary-prod___" + // where 0.6.1 is @redstone-finance/evm-connector version + // and 1724772413180 is current timestamp + const parsed = RedstonePayload.parse(toBytes(`0x${dataPayload}`)); + const packagesByDataFeedId = groupDataPackages(parsed.signedDataPackages); + + const result: PriceOnDemandExtras[] = []; + for (const t of updates) { + const { dataFeedId, originalToken, reserve, token } = t; + const signedDataPackages = packagesByDataFeedId[dataFeedId]; + if (!signedDataPackages) { + throw new Error(`cannot find data package for ${dataFeedId}`); + } + const calldataWithTs = getCalldataWithTimestamp( + signedDataPackages, + parsed.unsignedMetadata, ); - - let ts = 0; - list.forEach(p => { - const newTimestamp = p.dataPackage.timestampMilliseconds / 1000; - if (ts === 0) { - ts = newTimestamp; - } else if (ts !== newTimestamp) { - throw new Error("Timestamps are not equal"); - } + result.push({ + dataFeedId, + originalToken, + token, + reserve, + ...calldataWithTs, }); - - return [ - encodeAbiParameters(parseAbiParameters("uint256, bytes"), [ - BigInt(ts), - `0x${payload.toBytesHexWithout0xPrefix()}`, - ]), - ts, - ] as const; - }); - - const response: PriceOnDemandExtras = { - originalToken, - token: tokenOrTicker, - reserve, - callData: result[0][0], - ts: result[0][1], - }; - - if (cacheAllowed) { - this.#optimisticCache.set(key, response); } - return response; + return result; } } function redstoneCacheKey( - token: Address, - reserve: boolean, + update: RedstoneUpdate, dataServiceId: string, - dataFeedId: string, uniqueSignersCount: number, ): string { + const { token, dataFeedId, reserve } = update; return [ getTokenSymbolOrTicker(token), reserve ? "reserve" : "main", @@ -412,6 +418,61 @@ function redstoneCacheKey( ].join("|"); } +function groupDataPackages( + signedDataPackages: SignedDataPackage[], +): Record { + const packagesByDataFeedId: Record = {}; + for (const p of signedDataPackages) { + const { dataPoints } = p.dataPackage; + + // Check if all data points have the same dataFeedId + const dataFeedId0 = dataPoints[0].dataFeedId; + for (const dp of dataPoints) { + if (dp.dataFeedId !== dataFeedId0) { + throw new Error( + `data package contains data points with different dataFeedIds: ${dp.dataFeedId} and ${dataFeedId0}`, + ); + } + } + + // Group data packages by dataFeedId + if (!packagesByDataFeedId[dataFeedId0]) { + packagesByDataFeedId[dataFeedId0] = []; + } + packagesByDataFeedId[dataFeedId0].push(p); + } + + return packagesByDataFeedId; +} + +function getCalldataWithTimestamp( + packages: SignedDataPackage[], + unsignedMetadata: Uint8Array, +): TimestampedCalldata { + const payload = new RedstonePayload( + packages, + bytesToString(unsignedMetadata), + ); + + let ts = 0; + packages.forEach(p => { + const newTimestamp = p.dataPackage.timestampMilliseconds / 1000; + if (ts === 0) { + ts = newTimestamp; + } else if (ts !== newTimestamp) { + throw new Error("Timestamps are not equal"); + } + }); + + return { + callData: encodeAbiParameters(parseAbiParameters("uint256, bytes"), [ + BigInt(ts), + `0x${payload.toBytesHexWithout0xPrefix()}`, + ]), + ts, + }; +} + function splitResponse(arr: T[], size: number): T[][] { const chunks = []; diff --git a/src/services/liquidate/types.ts b/src/services/liquidate/types.ts index b3b024d..f50caf3 100644 --- a/src/services/liquidate/types.ts +++ b/src/services/liquidate/types.ts @@ -8,6 +8,7 @@ import type { } from "../../data/index.js"; export interface PriceOnDemandExtras extends PriceOnDemand { + dataFeedId: string; /** * In case when token in PriceOnDemand is ticker, this will be the original token * Otherwise they are the same diff --git a/yarn.lock b/yarn.lock index e975ec1..4e4970f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1586,10 +1586,10 @@ resolved "https://registry.yarnpkg.com/@gearbox-protocol/prettier-config/-/prettier-config-2.0.0-next.0.tgz#8183cfa8c6ee538543089961ecb4d03fe77045de" integrity sha512-hDokre6TjEkpNdf+tTk/Gh2dTJpkJFgMPTpE7KS4KFddUqGLqDKMaE4/ZzBA8kvYNm5gSXytCwWrxPXO8kFKYA== -"@gearbox-protocol/sdk-gov@^2.18.2": - version "2.18.2" - resolved "https://registry.yarnpkg.com/@gearbox-protocol/sdk-gov/-/sdk-gov-2.18.2.tgz#0be92c2c36dc3824ebe748fb4c2ce74114099ac4" - integrity sha512-THIMyeHl7V9K8GPCGFHoC12NMjq+/A1IWKj0uNn/KT6KIQeaA8AvfSANLj21CaKeLWVVmSrecT5yXTixydoZNg== +"@gearbox-protocol/sdk-gov@^2.18.5": + version "2.18.5" + resolved "https://registry.yarnpkg.com/@gearbox-protocol/sdk-gov/-/sdk-gov-2.18.5.tgz#325d1fcc3d941a70bd740efdcf9acfd6e2b4df93" + integrity sha512-zsXlvPSh/rAyGw022o+iz73uBHzWENI/dMOc7N2theEDmMiU1vx7gXZofpZa9Frofk24U1EyFtRZmpd22g+O+Q== dependencies: ethers "6.12.1" humanize-duration-ts "^2.1.1" @@ -5856,10 +5856,10 @@ tslib@^2.6.2: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== -tsx@^4.18.0: - version "4.18.0" - resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.18.0.tgz#c5c6e8af9e7d162446ed22dc7b53dc4792bf920b" - integrity sha512-a1jaKBSVQkd6yEc1/NI7G6yHFfefIcuf3QJST7ZEyn4oQnxLYrZR5uZAM8UrwUa3Ge8suiZHcNS1gNrEvmobqg== +tsx@^4.19.0: + version "4.19.0" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.19.0.tgz#6166cb399b17d14d125e6158d23384045cfdf4f6" + integrity sha512-bV30kM7bsLZKZIOCHeMNVMJ32/LuJzLVajkQI/qf92J2Qr08ueLQvW00PUZGiuLPP760UINwupgUj8qrSCPUKg== dependencies: esbuild "~0.23.0" get-tsconfig "^4.7.5" @@ -5969,10 +5969,10 @@ uuid@^9.0.1: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== -viem@^2.20.0: - version "2.20.0" - resolved "https://registry.yarnpkg.com/viem/-/viem-2.20.0.tgz#abff4c2cf733bcc20978e662ea17db117a2881ef" - integrity sha512-cM4vs81HnSNbfceI1MLkx4pCVzbVjl9xiNSv5SCutYjUyFFOVSPDlEyhpg2iHinxx1NM4Qne3END5eLT8rvUdg== +viem@^2.20.1: + version "2.20.1" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.20.1.tgz#91742e19c24e6294cf5c4015f1cba46afd0b95d7" + integrity sha512-a/BSe25TSfkc423GTSKYl1O0ON2J5huoQeOLkylHT1WS8wh3JFqb8nfAq7vg+aZ+W06BCTn36bbi47yp4D92Cg== dependencies: "@adraffy/ens-normalize" "1.10.0" "@noble/curves" "1.4.0"