From dd4018bfcdff70fc8ea4bc860395dbd34608c8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 10 Sep 2024 17:55:36 +0200 Subject: [PATCH] Add Native Staking transaction data decoders (#1903) - Adds `KilnDecoder` along with the Kiln Native Staking contract ABIs. - Adds a fallback to `TransactionsViewService` that tries to decode the data in case the Transaction Service cannot. --- .../__tests__/kiln-decoder.helper.spec.ts | 126 +++++++++ .../contracts/decoders/kiln-decoder.helper.ts | 124 +++++++++ .../transactions-view.controller.spec.ts | 252 +++++++++++++++++- .../transactions-view.controller.ts | 3 +- .../transactions/transactions-view.service.ts | 36 ++- 5 files changed, 532 insertions(+), 9 deletions(-) create mode 100644 src/domain/staking/contracts/decoders/__tests__/kiln-decoder.helper.spec.ts create mode 100644 src/domain/staking/contracts/decoders/kiln-decoder.helper.ts diff --git a/src/domain/staking/contracts/decoders/__tests__/kiln-decoder.helper.spec.ts b/src/domain/staking/contracts/decoders/__tests__/kiln-decoder.helper.spec.ts new file mode 100644 index 0000000000..2f68773269 --- /dev/null +++ b/src/domain/staking/contracts/decoders/__tests__/kiln-decoder.helper.spec.ts @@ -0,0 +1,126 @@ +import { + KilnAbi, + KilnDecoder, +} from '@/domain/staking/contracts/decoders/kiln-decoder.helper'; +import { ILoggingService } from '@/logging/logging.interface'; +import { faker } from '@faker-js/faker'; +import { encodeFunctionData } from 'viem'; + +const mockLoggingService = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +} as jest.MockedObjectDeep; + +describe('KilnDecoder', () => { + let kilnDecoder: KilnDecoder; + + beforeEach(() => { + kilnDecoder = new KilnDecoder(mockLoggingService); + }); + + describe('decodeDeposit', () => { + it('decodes a deposit function call correctly', () => { + const data = encodeFunctionData({ + abi: KilnAbi, + functionName: 'deposit', + args: [], + }); + expect(kilnDecoder.decodeDeposit(data)).toEqual({ + method: 'deposit', + parameters: [], + }); + }); + + it('returns null if the data is not a deposit function call', () => { + const data = faker.string.hexadecimal({ length: 1 }) as `0x${string}`; + expect(kilnDecoder.decodeDeposit(data)).toBeNull(); + }); + + it('returns null if the data is another Kiln function call', () => { + const data = encodeFunctionData({ + abi: KilnAbi, + functionName: 'requestValidatorsExit', + args: [faker.string.hexadecimal({ length: 1 }) as `0x${string}`], + }); + expect(kilnDecoder.decodeDeposit(data)).toBeNull(); + }); + }); + + describe('decodeValidatorsExit', () => { + it('decodes a requestValidatorsExit function call correctly', () => { + const validatorsPublicKeys = faker.string.hexadecimal({ + length: 96, + }) as `0x${string}`; + const data = encodeFunctionData({ + abi: KilnAbi, + functionName: 'requestValidatorsExit', + args: [validatorsPublicKeys], + }); + expect(kilnDecoder.decodeValidatorsExit(data)).toEqual({ + method: 'requestValidatorsExit', + parameters: [ + { + name: '_publicKeys', + type: 'bytes', + value: validatorsPublicKeys.toLocaleLowerCase(), + valueDecoded: null, + }, + ], + }); + }); + + it('returns null if the data is not a requestValidatorsExit function call', () => { + const data = faker.string.hexadecimal({ length: 1 }) as `0x${string}`; + expect(kilnDecoder.decodeValidatorsExit(data)).toBeNull(); + }); + + it('returns null if the data is another Kiln function call', () => { + const data = encodeFunctionData({ + abi: KilnAbi, + functionName: 'batchWithdrawCLFee', + args: [faker.string.hexadecimal({ length: 1 }) as `0x${string}`], + }); + expect(kilnDecoder.decodeValidatorsExit(data)).toBeNull(); + }); + }); + + describe('decodeBatchWithdrawCLFee', () => { + it('decodes a batchWithdrawCLFee function call correctly', () => { + const validatorsPublicKeys = faker.string.hexadecimal({ + length: 96, + }) as `0x${string}`; + const data = encodeFunctionData({ + abi: KilnAbi, + functionName: 'batchWithdrawCLFee', + args: [validatorsPublicKeys], + }); + expect(kilnDecoder.decodeBatchWithdrawCLFee(data)).toEqual({ + method: 'batchWithdrawCLFee', + parameters: [ + { + name: '_publicKeys', + type: 'bytes', + value: validatorsPublicKeys.toLocaleLowerCase(), + valueDecoded: null, + }, + ], + }); + }); + + it('returns null if the data is not a batchWithdrawCLFee function call', () => { + const data = faker.string.hexadecimal({ length: 1 }) as `0x${string}`; + expect(kilnDecoder.decodeBatchWithdrawCLFee(data)).toBeNull(); + }); + + it('returns null if the data is another Kiln function call', () => { + const data = encodeFunctionData({ + abi: KilnAbi, + functionName: 'requestValidatorsExit', + args: [faker.string.hexadecimal({ length: 1 }) as `0x${string}`], + }); + expect(kilnDecoder.decodeBatchWithdrawCLFee(data)).toBeNull(); + }); + }); +}); diff --git a/src/domain/staking/contracts/decoders/kiln-decoder.helper.ts b/src/domain/staking/contracts/decoders/kiln-decoder.helper.ts new file mode 100644 index 0000000000..8ae01216e4 --- /dev/null +++ b/src/domain/staking/contracts/decoders/kiln-decoder.helper.ts @@ -0,0 +1,124 @@ +import { AbiDecoder } from '@/domain/contracts/decoders/abi-decoder.helper'; +import { LoggingService, ILoggingService } from '@/logging/logging.interface'; +import { Inject, Injectable } from '@nestjs/common'; + +export const KilnAbi = [ + { + inputs: [], + name: 'deposit', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes', name: '_publicKeys', type: 'bytes' }], + name: 'requestValidatorsExit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes', name: '_publicKeys', type: 'bytes' }], + name: 'batchWithdrawCLFee', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; + +export type KilnRequestValidatorsExitParameters = { + name: '_publicKeys'; + type: 'bytes'; + value: `0x${string}`; + valueDecoded: null; +}; +export type KilnBatchWithdrawCLFeeParameters = + KilnRequestValidatorsExitParameters; + +@Injectable() +export class KilnDecoder extends AbiDecoder { + constructor( + @Inject(LoggingService) private readonly loggingService: ILoggingService, + ) { + super(KilnAbi); + } + + decodeDeposit( + data: `0x${string}`, + ): { method: string; parameters: [] } | null { + if (!this.helpers.isDeposit(data)) { + return null; + } + try { + const decoded = this.decodeFunctionData({ data }); + if (decoded.functionName !== 'deposit') { + throw new Error('Data is not of deposit type'); + } + return { + method: decoded.functionName, + parameters: [], + }; + } catch (e) { + this.loggingService.debug(e); + return null; + } + } + + decodeValidatorsExit(data: `0x${string}`): { + method: string; + parameters: KilnRequestValidatorsExitParameters[]; + } | null { + if (!this.helpers.isRequestValidatorsExit(data)) { + return null; + } + try { + const decoded = this.decodeFunctionData({ data }); + if (decoded.functionName !== 'requestValidatorsExit') { + throw new Error('Data is not of requestValidatorsExit type'); + } + return { + method: decoded.functionName, + parameters: [ + { + name: '_publicKeys', + type: 'bytes', + value: decoded.args[0], + valueDecoded: null, + }, + ], + }; + } catch (e) { + this.loggingService.debug(e); + return null; + } + } + + decodeBatchWithdrawCLFee(data: `0x${string}`): { + method: string; + parameters: KilnBatchWithdrawCLFeeParameters[]; + } | null { + if (!this.helpers.isBatchWithdrawCLFee(data)) { + return null; + } + try { + const decoded = this.decodeFunctionData({ data }); + if (decoded.functionName !== 'batchWithdrawCLFee') { + throw new Error('Data is not of batchWithdrawCLFee type'); + } + return { + method: decoded.functionName, + parameters: [ + { + name: '_publicKeys', + type: 'bytes', + value: decoded.args[0], + valueDecoded: null, + }, + ], + }; + } catch (e) { + this.loggingService.debug(e); + return null; + } + } +} diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index e5e1487a0f..53d82a2839 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -30,7 +30,11 @@ import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { NULL_ADDRESS } from '@/routes/common/constants'; import { faker } from '@faker-js/faker'; -import { INestApplication, NotFoundException } from '@nestjs/common'; +import { + INestApplication, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Server } from 'net'; import request from 'supertest'; @@ -659,6 +663,100 @@ describe('TransactionsViewController tests', () => { }); }); + it('returns the native staking `deposit` confirmation view using local decoding', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'dedicated') + .with('product_fee', faker.number.float().toString()) + .build(); + const dedicatedStakingStats = dedicatedStakingStatsBuilder().build(); + const networkStats = networkStatsBuilder().build(); + const safeAddress = faker.finance.ethereumAddress(); + const data = encodeFunctionData({ + abi: parseAbi(['function deposit() external payable']), + }); + const value = getNumberString(64 * 10 ** 18 + 1); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); + case `${stakingApiUrl}/v1/deployments`: + return Promise.resolve({ + data: { data: [deployment] }, + status: 200, + }); + case `${stakingApiUrl}/v1/eth/kiln-stats`: + return Promise.resolve({ + data: { data: dedicatedStakingStats }, + status: 200, + }); + case `${stakingApiUrl}/v1/eth/network-stats`: + return Promise.resolve({ + data: { data: networkStats }, + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.reject(new ServiceUnavailableException()); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + const annualNrr = + dedicatedStakingStats.gross_apy.last_30d * + (1 - Number(deployment.product_fee)); + const monthlyNrr = annualNrr / 12; + const expectedAnnualReward = (annualNrr / 100) * Number(value); + const expectedMonthlyReward = expectedAnnualReward / 12; + const expectedFiatAnnualReward = + (expectedAnnualReward * networkStats.eth_price_usd) / + Math.pow(10, chain.nativeCurrency.decimals); + const expectedFiatMonthlyReward = expectedFiatAnnualReward / 12; + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/safes/${safeAddress}/views/transaction-confirmation`, + ) + .send({ + to: deployment.address, + data, + value, + }) + .expect(200) + .expect({ + type: 'KILN_NATIVE_STAKING_DEPOSIT', + method: 'deposit', + status: 'SIGNATURE_NEEDED', + parameters: [], + estimatedEntryTime: networkStats.estimated_entry_time_seconds, + estimatedExitTime: networkStats.estimated_exit_time_seconds, + estimatedWithdrawalTime: + networkStats.estimated_withdrawal_time_seconds, + fee: +deployment.product_fee!, + monthlyNrr, + annualNrr, + value, + numValidators: 2, + expectedAnnualReward: getNumberString(expectedAnnualReward), + expectedMonthlyReward: getNumberString(expectedMonthlyReward), + expectedFiatAnnualReward, + expectedFiatMonthlyReward, + tokenInfo: { + address: NULL_ADDRESS, + decimals: chain.nativeCurrency.decimals, + logoUri: chain.nativeCurrency.logoUri, + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + trusted: true, + }, + }); + }); + it('returns the dedicated staking `deposit` confirmation view from batch', async () => { const chain = chainBuilder().with('isTestnet', false).build(); const dataDecoded = dataDecodedBuilder().build(); @@ -1184,6 +1282,85 @@ describe('TransactionsViewController tests', () => { }); }); + it('returns the native staking `validators exit` confirmation view using local decoding', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'dedicated') + .with('product_fee', faker.number.float().toString()) + .build(); + const safeAddress = faker.finance.ethereumAddress(); + const networkStats = networkStatsBuilder().build(); + const validatorPublicKey = faker.string.hexadecimal({ length: 96 }); + const data = encodeFunctionData({ + abi: parseAbi(['function requestValidatorsExit(bytes)']), + functionName: 'requestValidatorsExit', + args: [validatorPublicKey as `0x${string}`], + }); + const value = getNumberString(64 * 10 ** 18 + 1); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); + case `${stakingApiUrl}/v1/deployments`: + return Promise.resolve({ + data: { data: [deployment] }, + status: 200, + }); + case `${stakingApiUrl}/v1/eth/network-stats`: + return Promise.resolve({ + data: { data: networkStats }, + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.reject(new ServiceUnavailableException()); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/safes/${safeAddress}/views/transaction-confirmation`, + ) + .send({ + to: deployment.address, + data, + value, + }) + .expect(200) + .expect({ + type: 'KILN_NATIVE_STAKING_VALIDATORS_EXIT', + method: 'requestValidatorsExit', + parameters: [ + { + name: '_publicKeys', + type: 'bytes', + value: validatorPublicKey.toLowerCase(), + valueDecoded: null, + }, + ], + status: 'SIGNATURE_NEEDED', + estimatedExitTime: networkStats.estimated_exit_time_seconds, + estimatedWithdrawalTime: + networkStats.estimated_withdrawal_time_seconds, + value, + numValidators: 2, + tokenInfo: { + address: NULL_ADDRESS, + decimals: chain.nativeCurrency.decimals, + logoUri: chain.nativeCurrency.logoUri, + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + trusted: true, + }, + }); + }); + it('returns the generic confirmation view if the deployment is not available', async () => { const chain = chainBuilder().with('isTestnet', false).build(); const dataDecoded = dataDecodedBuilder().build(); @@ -1526,6 +1703,79 @@ describe('TransactionsViewController tests', () => { }); }); + it('returns the native staking `withdraw` confirmation view using local decoding', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const validatorPublicKeys = [ + faker.string.hexadecimal({ length: 96 }), + faker.string.hexadecimal({ length: 96 }), + ]; + // Case where several validators holdings are being withdrawn + const concatPublicKeys = `0x${validatorPublicKeys[0].slice(2)}${validatorPublicKeys[1].slice(2)}`; + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'dedicated') + .with('product_fee', faker.number.float().toString()) + .build(); + const safeAddress = faker.finance.ethereumAddress(); + const data = encodeFunctionData({ + abi: parseAbi(['function batchWithdrawCLFee(bytes)']), + functionName: 'batchWithdrawCLFee', + args: [`${concatPublicKeys}` as `0x${string}`], + }); + const value = faker.string.numeric(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); + case `${stakingApiUrl}/v1/deployments`: + return Promise.resolve({ + data: { data: [deployment] }, + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.reject(new ServiceUnavailableException()); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/safes/${safeAddress}/views/transaction-confirmation`, + ) + .send({ + to: deployment.address, + value, + data, + }) + .expect(200) + .expect({ + type: 'KILN_NATIVE_STAKING_WITHDRAW', + method: 'batchWithdrawCLFee', + parameters: [ + { + name: '_publicKeys', + type: 'bytes', + value: concatPublicKeys.toLowerCase(), + valueDecoded: null, + }, + ], + value, + tokenInfo: { + address: NULL_ADDRESS, + decimals: chain.nativeCurrency.decimals, + logoUri: chain.nativeCurrency.logoUri, + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + trusted: true, + }, + }); + }); + it('returns the generic confirmation view if the deployment is not available', async () => { const chain = chainBuilder().with('isTestnet', false).build(); const validatorPublicKey = faker.string.hexadecimal(); diff --git a/src/routes/transactions/transactions-view.controller.ts b/src/routes/transactions/transactions-view.controller.ts index 14ee7c7414..ed68e9ba45 100644 --- a/src/routes/transactions/transactions-view.controller.ts +++ b/src/routes/transactions/transactions-view.controller.ts @@ -1,4 +1,5 @@ import { DataDecodedRepositoryModule } from '@/domain/data-decoder/data-decoded.repository.interface'; +import { KilnDecoder } from '@/domain/staking/contracts/decoders/kiln-decoder.helper'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; import { GPv2DecoderModule } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; @@ -94,7 +95,7 @@ export class TransactionsViewController { SwapsRepositoryModule, TwapOrderHelperModule, ], - providers: [TransactionsViewService, ComposableCowDecoder], + providers: [TransactionsViewService, ComposableCowDecoder, KilnDecoder], controllers: [TransactionsViewController], }) export class TransactionsViewControllerModule {} diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index d0ea57ea1d..7796821589 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -1,6 +1,7 @@ import { IConfigurationService } from '@/config/configuration.service.interface'; import { IDataDecodedRepository } from '@/domain/data-decoder/data-decoded.repository.interface'; import { DataDecoded } from '@/domain/data-decoder/entities/data-decoded.entity'; +import { KilnDecoder } from '@/domain/staking/contracts/decoders/kiln-decoder.helper'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { OrderStatus } from '@/domain/swaps/entities/order.entity'; @@ -43,6 +44,7 @@ export class TransactionsViewService { private readonly configurationService: IConfigurationService, private readonly kilnNativeStakingHelper: KilnNativeStakingHelper, private readonly nativeStakingMapper: NativeStakingMapper, + private readonly kilnDecoder: KilnDecoder, ) { this.isNativeStakingEnabled = this.configurationService.getOrThrow( 'features.nativeStaking', @@ -61,7 +63,6 @@ export class TransactionsViewService { to: args.transactionDataDto.to, }) .catch(() => { - // TODO: Get Kiln to verify all deployments // Fallback for unverified contracts return { method: '', @@ -316,6 +317,13 @@ export class TransactionsViewService { dataDecoded: DataDecoded; value: string | null; }): Promise { + const dataDecoded = + args.dataDecoded.method !== '' + ? args.dataDecoded + : this.kilnDecoder.decodeDeposit(args.data); + if (!dataDecoded) { + throw new Error('Transaction data could not be decoded'); + } const depositInfo = await this.nativeStakingMapper.mapDepositInfo({ chainId: args.chainId, to: args.to, @@ -324,8 +332,8 @@ export class TransactionsViewService { depositExecutionDate: null, }); return new NativeStakingDepositConfirmationView({ - method: args.dataDecoded.method, - parameters: args.dataDecoded.parameters, + method: dataDecoded.method, + parameters: dataDecoded.parameters, ...depositInfo, }); } @@ -337,6 +345,13 @@ export class TransactionsViewService { dataDecoded: DataDecoded; value: string | null; }): Promise { + const dataDecoded = + args.dataDecoded.method !== '' + ? args.dataDecoded + : this.kilnDecoder.decodeValidatorsExit(args.data); + if (!dataDecoded) { + throw new Error('Transaction data could not be decoded'); + } const validatorsExitInfo = await this.nativeStakingMapper.mapValidatorsExitInfo({ chainId: args.chainId, @@ -345,8 +360,8 @@ export class TransactionsViewService { transaction: null, }); return new NativeStakingValidatorsExitConfirmationView({ - method: args.dataDecoded.method, - parameters: args.dataDecoded.parameters, + method: dataDecoded.method, + parameters: dataDecoded.parameters, ...validatorsExitInfo, }); } @@ -358,6 +373,13 @@ export class TransactionsViewService { dataDecoded: DataDecoded; value: string | null; }): Promise { + const dataDecoded = + args.dataDecoded.method !== '' + ? args.dataDecoded + : this.kilnDecoder.decodeBatchWithdrawCLFee(args.data); + if (!dataDecoded) { + throw new Error('Transaction data could not be decoded'); + } const withdrawInfo = await this.nativeStakingMapper.mapWithdrawInfo({ chainId: args.chainId, to: args.to, @@ -365,8 +387,8 @@ export class TransactionsViewService { transaction: null, }); return new NativeStakingWithdrawConfirmationView({ - method: args.dataDecoded.method, - parameters: args.dataDecoded.parameters, + method: dataDecoded.method, + parameters: dataDecoded.parameters, ...withdrawInfo, }); }