From 832190983c9039143a3b4342b6f1238437c719d4 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 8 Oct 2024 12:11:54 +0200 Subject: [PATCH] Deprecate `/transaction-confirmation` endpoint (#1973) Adds swap and staking test coverage to the `/preview` endpoint to ensure usage of it can replace the `/transaction-confirmation` one (migrated from the latter's suite), fixing any found issues: - Decode staking transaction data instead of using `dataDecoded` (because of batches). - Correct types for `/preview` endpoint. - Add swap/staking test coverage to `/preview` endpoint. - Update other tests according. --- .../entities/__tests__/configuration.ts | 2 +- .../contracts/decoders/kiln-decoder.helper.ts | 6 + ...n-cow-swap.transactions.controller.spec.ts | 1163 +++++++++ ...ction-kiln.transactions.controller.spec.ts | 2284 +++++++++++++++++ ...ransaction.transactions.controller.spec.ts | 8 +- .../entities/transaction-preview.entity.ts | 15 + .../kiln-native-staking.helper.spec.ts | 428 +-- .../helpers/kiln-native-staking.helper.ts | 177 +- .../transactions/helpers/swap-order.helper.ts | 6 +- .../transaction-data-finder.helper.spec.ts | 11 +- .../helpers/transaction-finder.helper.ts | 5 +- .../transactions/helpers/twap-order.helper.ts | 22 +- .../common/native-staking.mapper.spec.ts | 146 +- .../mappers/common/native-staking.mapper.ts | 83 +- .../mappers/common/transaction-info.mapper.ts | 51 +- .../transactions-view.controller.spec.ts | 6 +- .../transactions/transactions-view.service.ts | 66 +- 17 files changed, 3709 insertions(+), 770 deletions(-) create mode 100644 src/routes/transactions/__tests__/controllers/preview-transaction-cow-swap.transactions.controller.spec.ts create mode 100644 src/routes/transactions/__tests__/controllers/preview-transaction-kiln.transactions.controller.spec.ts diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 81623b2d97..05cefe7599 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -218,7 +218,7 @@ export default (): ReturnType => ({ 42161: faker.internet.url({ appendSlash: false }), 11155111: faker.internet.url({ appendSlash: false }), }, - explorerBaseUri: faker.internet.url(), + explorerBaseUri: faker.internet.url({ appendSlash: true }), restrictApps: false, allowedApps: [], maxNumberOfParts: faker.number.int(), diff --git a/src/domain/staking/contracts/decoders/kiln-decoder.helper.ts b/src/domain/staking/contracts/decoders/kiln-decoder.helper.ts index bf3e13aabe..526c14c0d8 100644 --- a/src/domain/staking/contracts/decoders/kiln-decoder.helper.ts +++ b/src/domain/staking/contracts/decoders/kiln-decoder.helper.ts @@ -29,6 +29,8 @@ export class KilnDecoder extends AbiDecoder { super(KilnAbi); } + // TODO: When confirmation view endpoint is removed, remove this + // and use this.helpers.isDeposit instead decodeDeposit( data: `0x${string}`, ): { method: string; parameters: [] } | null { @@ -50,6 +52,8 @@ export class KilnDecoder extends AbiDecoder { } } + // TODO: When confirmation view endpoint is removed, return only + // publicKeys and don't format it like DataDecoded decodeValidatorsExit(data: `0x${string}`): { method: string; parameters: KilnRequestValidatorsExitParameters[]; @@ -79,6 +83,8 @@ export class KilnDecoder extends AbiDecoder { } } + // TODO: When confirmation view endpoint is removed, return only + // publicKeys and don't format it like DataDecoded decodeBatchWithdrawCLFee(data: `0x${string}`): { method: string; parameters: KilnBatchWithdrawCLFeeParameters[]; diff --git a/src/routes/transactions/__tests__/controllers/preview-transaction-cow-swap.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/preview-transaction-cow-swap.transactions.controller.spec.ts new file mode 100644 index 0000000000..04d1b7f163 --- /dev/null +++ b/src/routes/transactions/__tests__/controllers/preview-transaction-cow-swap.transactions.controller.spec.ts @@ -0,0 +1,1163 @@ +import { faker } from '@faker-js/faker'; +import type { INestApplication } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { TestAppProvider } from '@/__tests__/test-app.provider'; +import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; +import { AppModule } from '@/app.module'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { contractBuilder } from '@/domain/contracts/entities/__tests__/contract.builder'; +import { dataDecodedBuilder } from '@/domain/data-decoder/entities/__tests__/data-decoded.builder'; +import { Operation } from '@/domain/safe/entities/operation.entity'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; +import configuration from '@/config/entities/__tests__/configuration'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import type { INetworkService } from '@/datasources/network/network.service.interface'; +import { NetworkService } from '@/datasources/network/network.service.interface'; +import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import { previewTransactionDtoBuilder } from '@/routes/transactions/entities/__tests__/preview-transaction.dto.builder'; +import { CacheModule } from '@/datasources/cache/cache.module'; +import { NetworkModule } from '@/datasources/network/network.module'; +import { getAddress } from 'viem'; +import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; +import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import type { Server } from 'net'; +import { setPreSignatureEncoder } from '@/domain/swaps/contracts/__tests__/encoders/gp-v2-encoder.builder'; +import { orderBuilder } from '@/domain/swaps/entities/__tests__/order.builder'; +import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; +import { + multiSendEncoder, + multiSendTransactionsEncoder, +} from '@/domain/contracts/__tests__/encoders/multi-send-encoder.builder'; + +describe('Preview transaction - CoW Swap - Transactions Controller (Unit)', () => { + let app: INestApplication; + let safeConfigUrl: string; + let networkService: jest.MockedObjectDeep; + let swapsChainId: string; + let swapsApiUrl: string; + let swapsExplorerUrl: string; + const swapsVerifiedApp = faker.company.buzzNoun(); + + beforeEach(async () => { + jest.resetAllMocks(); + + const baseConfig = configuration(); + const testConfiguration: typeof configuration = () => ({ + ...baseConfig, + features: { + ...baseConfig.features, + swapsDecoding: true, + twapsDecoding: true, + }, + swaps: { + ...baseConfig.swaps, + restrictApps: true, + allowedApps: [swapsVerifiedApp], + }, + }); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(testConfiguration)], + }) + .overrideModule(CacheModule) + .useModule(TestCacheModule) + .overrideModule(RequestScopedLoggingModule) + .useModule(TestLoggingModule) + .overrideModule(NetworkModule) + .useModule(TestNetworkModule) + .overrideModule(QueuesApiModule) + .useModule(TestQueuesApiModule) + .compile(); + + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); + const swapApiConfig = + configurationService.getOrThrow>('swaps.api'); + swapsChainId = faker.helpers.arrayElement(Object.keys(swapApiConfig)); + swapsApiUrl = swapApiConfig[swapsChainId]; + swapsExplorerUrl = configurationService.getOrThrow(`swaps.explorerBaseUri`); + networkService = moduleFixture.get(NetworkService); + + app = await new TestAppProvider().provide(moduleFixture); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Swaps', () => { + it('should preview a transaction', async () => { + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const safe = safeBuilder().build(); + const dataDecoded = dataDecodedBuilder().build(); + const preSignatureEncoder = setPreSignatureEncoder(); + const preSignature = preSignatureEncoder.build(); + const order = orderBuilder() + .with('uid', preSignature.orderUid) + .with('fullAppData', `{ "appCode": "${swapsVerifiedApp}" }`) + .build(); + const buyToken = tokenBuilder().with('address', order.buyToken).build(); + const sellToken = tokenBuilder().with('address', order.sellToken).build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', preSignatureEncoder.encode()) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/orders/${order.uid}`) { + return Promise.resolve({ data: order, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.buyToken}` + ) { + return Promise.resolve({ data: buyToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.sellToken}` + ) { + return Promise.resolve({ data: sellToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contractResponse.address}` + ) { + return Promise.resolve({ data: contractResponse, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect({ + txInfo: { + type: 'SwapOrder', + humanDescription: null, + richDecodedInfo: null, + uid: order.uid, + status: order.status, + kind: order.kind, + orderClass: order.class, + validUntil: order.validTo, + sellAmount: order.sellAmount.toString(), + buyAmount: order.buyAmount.toString(), + executedSellAmount: order.executedSellAmount.toString(), + executedBuyAmount: order.executedBuyAmount.toString(), + explorerUrl: `${swapsExplorerUrl}orders/${order.uid}`, + executedSurplusFee: order.executedSurplusFee?.toString() ?? null, + sellToken: { + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }, + buyToken: { + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }, + receiver: order.receiver, + owner: order.owner, + fullAppData: JSON.parse(order.fullAppData as string), + }, + txData: { + hexData: previewTransactionDto.data, + dataDecoded, + to: { + value: previewTransactionDto.to, + name: contractResponse.displayName, + logoUri: contractResponse.logoUri, + }, + value: previewTransactionDto.value, + operation: previewTransactionDto.operation, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + }); + }); + + it('should preview a batched transaction', async () => { + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const safe = safeBuilder().build(); + const preSignatureEncoder = setPreSignatureEncoder(); + const preSignature = preSignatureEncoder.build(); + const order = orderBuilder() + .with('uid', preSignature.orderUid) + .with('fullAppData', `{ "appCode": "${swapsVerifiedApp}" }`) + .build(); + const buyToken = tokenBuilder().with('address', order.buyToken).build(); + const sellToken = tokenBuilder().with('address', order.sellToken).build(); + const swapTransaction = { + operation: Operation.CALL, + data: preSignatureEncoder.encode(), + to: getAddress(faker.finance.ethereumAddress()), + value: BigInt(0), + }; + const multiSendTransaction = multiSendEncoder().with( + 'transactions', + multiSendTransactionsEncoder([swapTransaction]), + ); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', multiSendTransaction.encode()) + .with('operation', Operation.CALL) + .build(); + const dataDecoded = dataDecodedBuilder().build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', swapTransaction.to) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/orders/${order.uid}`) { + return Promise.resolve({ data: order, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.buyToken}` + ) { + return Promise.resolve({ data: buyToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.sellToken}` + ) { + return Promise.resolve({ data: sellToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contractResponse.address}` + ) { + return Promise.resolve({ data: contractResponse, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${swapTransaction.to}` + ) { + return Promise.resolve({ data: tokenResponse, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect({ + txInfo: { + type: 'SwapOrder', + humanDescription: null, + richDecodedInfo: null, + uid: order.uid, + status: order.status, + kind: order.kind, + orderClass: order.class, + validUntil: order.validTo, + sellAmount: order.sellAmount.toString(), + buyAmount: order.buyAmount.toString(), + executedSellAmount: order.executedSellAmount.toString(), + executedBuyAmount: order.executedBuyAmount.toString(), + explorerUrl: `${swapsExplorerUrl}orders/${order.uid}`, + executedSurplusFee: order.executedSurplusFee?.toString() ?? null, + sellToken: { + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }, + buyToken: { + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }, + receiver: order.receiver, + owner: order.owner, + fullAppData: JSON.parse(order.fullAppData as string), + }, + txData: { + hexData: previewTransactionDto.data, + dataDecoded, + to: { + value: previewTransactionDto.to, + name: contractResponse.displayName, + logoUri: contractResponse.logoUri, + }, + value: previewTransactionDto.value, + operation: previewTransactionDto.operation, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + }); + }); + + it('should return executedSurplusFee as null if not available', async () => { + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const safe = safeBuilder().build(); + const dataDecoded = dataDecodedBuilder().build(); + const preSignatureEncoder = setPreSignatureEncoder(); + const preSignature = preSignatureEncoder.build(); + const order = orderBuilder() + .with('uid', preSignature.orderUid) + .with('executedSurplusFee', null) + .with('fullAppData', `{ "appCode": "${swapsVerifiedApp}" }`) + .build(); + const buyToken = tokenBuilder().with('address', order.buyToken).build(); + const sellToken = tokenBuilder().with('address', order.sellToken).build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', preSignatureEncoder.encode()) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/orders/${order.uid}`) { + return Promise.resolve({ data: order, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.buyToken}` + ) { + return Promise.resolve({ data: buyToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.sellToken}` + ) { + return Promise.resolve({ data: sellToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contractResponse.address}` + ) { + return Promise.resolve({ data: contractResponse, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => + expect(body.txInfo).toMatchObject({ + type: 'SwapOrder', + executedSurplusFee: null, + }), + ); + }); + + it('should return a "standard" transaction preview if order data is not available', async () => { + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const safe = safeBuilder().build(); + const dataDecoded = dataDecodedBuilder().build(); + const preSignatureEncoder = setPreSignatureEncoder(); + const preSignature = preSignatureEncoder.build(); + const order = orderBuilder() + .with('uid', preSignature.orderUid) + .with('fullAppData', `{ "appCode": "${swapsVerifiedApp}" }`) + .build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', preSignatureEncoder.encode()) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/orders/${order.uid}`) { + return Promise.reject({ status: 500 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contractResponse.address}` + ) { + return Promise.resolve({ data: contractResponse, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if buy token is not available', async () => { + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const safe = safeBuilder().build(); + const dataDecoded = dataDecodedBuilder().build(); + const preSignatureEncoder = setPreSignatureEncoder(); + const preSignature = preSignatureEncoder.build(); + const order = orderBuilder() + .with('uid', preSignature.orderUid) + .with('fullAppData', `{ "appCode": "${swapsVerifiedApp}" }`) + .build(); + const sellToken = tokenBuilder().with('address', order.sellToken).build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', preSignatureEncoder.encode()) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/orders/${order.uid}`) { + return Promise.resolve({ data: order, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.buyToken}` + ) { + return Promise.reject({ status: 500 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.sellToken}` + ) { + return Promise.resolve({ data: sellToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contractResponse.address}` + ) { + return Promise.resolve({ data: contractResponse, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if sell token is not available', async () => { + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const safe = safeBuilder().build(); + const dataDecoded = dataDecodedBuilder().build(); + const preSignatureEncoder = setPreSignatureEncoder(); + const preSignature = preSignatureEncoder.build(); + const order = orderBuilder() + .with('uid', preSignature.orderUid) + .with('fullAppData', `{ "appCode": "${swapsVerifiedApp}" }`) + .build(); + const buyToken = tokenBuilder().with('address', order.sellToken).build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', preSignatureEncoder.encode()) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/orders/${order.uid}`) { + return Promise.resolve({ data: order, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.buyToken}` + ) { + return Promise.resolve({ data: buyToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.sellToken}` + ) { + return Promise.reject({ status: 500 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contractResponse.address}` + ) { + return Promise.resolve({ data: contractResponse, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the swap app is restricted', async () => { + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const safe = safeBuilder().build(); + const dataDecoded = dataDecodedBuilder().build(); + const preSignatureEncoder = setPreSignatureEncoder(); + const preSignature = preSignatureEncoder.build(); + const order = orderBuilder() + .with('uid', preSignature.orderUid) + // We don't use buzzNoun here as it can generate the same value as swapsVerifiedApp + .with('fullAppData', `{ "appCode": "restricted app code" }`) + .build(); + const buyToken = tokenBuilder().with('address', order.buyToken).build(); + const sellToken = tokenBuilder().with('address', order.sellToken).build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', preSignatureEncoder.encode()) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/orders/${order.uid}`) { + return Promise.resolve({ data: order, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.buyToken}` + ) { + return Promise.resolve({ data: buyToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${order.sellToken}` + ) { + return Promise.resolve({ data: sellToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contractResponse.address}` + ) { + return Promise.resolve({ data: contractResponse, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + }); + + describe('TWAPs', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const ComposableCowAddress = '0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74'; + + /** + * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 + */ + const safe = safeBuilder() + .with('address', '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381') + .build(); + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + const appDataHash = + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0'; + const appData = { appCode: swapsVerifiedApp }; + const fullAppData = { + fullAppData: JSON.stringify(appData), + }; + const buyToken = tokenBuilder() + .with('address', getAddress('0xfff9976782d46cc05630d1f6ebab18b2324d6b14')) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress('0xbe72e441bf55620febc26715db68d3494213d8cb')) + .build(); + + it('should preview a transaction', async () => { + const now = new Date(); + jest.setSystemTime(now); + + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const dataDecoded = dataDecodedBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('to', ComposableCowAddress) + .with('data', data) + .with('operation', Operation.CALL) + .build(); + const contract = contractBuilder() + .with('address', ComposableCowAddress) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${buyToken.address}` + ) { + return Promise.resolve({ data: buyToken, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${sellToken.address}` + ) { + return Promise.resolve({ data: sellToken, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/app_data/${appDataHash}`) { + return Promise.resolve({ data: fullAppData, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contract.address}` + ) { + return Promise.resolve({ data: contract, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect({ + txInfo: { + type: 'TwapOrder', + humanDescription: null, + richDecodedInfo: null, + status: 'presignaturePending', + kind: 'sell', + class: 'limit', + activeOrderUid: null, + validUntil: Math.ceil(now.getTime() / 1_000) + 3599, + sellAmount: '427173750967724283500', + buyAmount: '1222579021996502268', + executedSellAmount: '0', + executedBuyAmount: '0', + executedSurplusFee: '0', + sellToken: { + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }, + buyToken: { + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }, + receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + owner: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + fullAppData: appData, + numberOfParts: '2', + partSellAmount: '213586875483862141750', + minPartLimit: '611289510998251134', + timeBetweenParts: 1800, + durationOfPart: { durationType: 'AUTO' }, + startTime: { startType: 'AT_MINING_TIME' }, + }, + txData: { + hexData: previewTransactionDto.data, + dataDecoded, + to: { + value: ComposableCowAddress, + name: contract.displayName, + logoUri: contract.logoUri, + }, + value: previewTransactionDto.value, + operation: previewTransactionDto.operation, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + }); + }); + + it('should preview a batched transaction', async () => { + const now = new Date(); + jest.setSystemTime(now); + + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const twapTransaction = { + operation: Operation.CALL, + data, + to: ComposableCowAddress, + value: BigInt(0), + } as const; + const multiSendTransaction = multiSendEncoder().with( + 'transactions', + multiSendTransactionsEncoder([twapTransaction]), + ); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', multiSendTransaction.encode()) + .with('operation', Operation.CALL) + .build(); + const dataDecoded = dataDecodedBuilder().build(); + const contract = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', ComposableCowAddress) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${buyToken.address}` + ) { + return Promise.resolve({ data: buyToken, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${sellToken.address}` + ) { + return Promise.resolve({ data: sellToken, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/app_data/${appDataHash}`) { + return Promise.resolve({ data: fullAppData, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contract.address}` + ) { + return Promise.resolve({ data: contract, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${twapTransaction.to}` + ) { + return Promise.resolve({ data: tokenResponse, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect({ + txInfo: { + type: 'TwapOrder', + humanDescription: null, + richDecodedInfo: null, + status: 'presignaturePending', + kind: 'sell', + class: 'limit', + activeOrderUid: null, + validUntil: Math.ceil(now.getTime() / 1_000) + 3599, + sellAmount: '427173750967724283500', + buyAmount: '1222579021996502268', + executedSellAmount: '0', + executedBuyAmount: '0', + executedSurplusFee: '0', + sellToken: { + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }, + buyToken: { + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }, + receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + owner: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + fullAppData: appData, + numberOfParts: '2', + partSellAmount: '213586875483862141750', + minPartLimit: '611289510998251134', + timeBetweenParts: 1800, + durationOfPart: { durationType: 'AUTO' }, + startTime: { startType: 'AT_MINING_TIME' }, + }, + txData: { + hexData: previewTransactionDto.data, + dataDecoded, + to: { + value: previewTransactionDto.to, + name: contract.displayName, + logoUri: contract.logoUri, + }, + value: previewTransactionDto.value, + operation: previewTransactionDto.operation, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + }); + }); + + it('should return a "standard" transaction preview if buy token is not available', async () => { + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const dataDecoded = dataDecodedBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('to', ComposableCowAddress) + .with('data', data) + .with('operation', Operation.CALL) + .build(); + const contract = contractBuilder() + .with('address', ComposableCowAddress) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${buyToken.address}` + ) { + return Promise.reject({ status: 500 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${sellToken.address}` + ) { + return Promise.resolve({ data: sellToken, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/app_data/${appDataHash}`) { + return Promise.resolve({ data: fullAppData, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contract.address}` + ) { + return Promise.resolve({ data: contract, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if sell token is not available', async () => { + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const dataDecoded = dataDecodedBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('to', ComposableCowAddress) + .with('data', data) + .with('operation', Operation.CALL) + .build(); + const contract = contractBuilder() + .with('address', ComposableCowAddress) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${buyToken.address}` + ) { + return Promise.resolve({ data: buyToken, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${sellToken.address}` + ) { + return Promise.reject({ status: 500 }); + } + if (url === `${swapsApiUrl}/api/v1/app_data/${appDataHash}`) { + return Promise.resolve({ data: fullAppData, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contract.address}` + ) { + return Promise.resolve({ data: contract, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the swap app is restricted', async () => { + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const dataDecoded = dataDecodedBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('to', ComposableCowAddress) + .with('data', data) + .with('operation', Operation.CALL) + .build(); + const contract = contractBuilder() + .with('address', ComposableCowAddress) + .build(); + const fullAppData = { + fullAppData: JSON.stringify({ + appCode: + // We don't use buzzNoun here as it can generate the same value as swapsVerifiedApp + 'restricted app code', + }), + }; + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${buyToken.address}` + ) { + return Promise.resolve({ data: buyToken, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/tokens/${sellToken.address}` + ) { + return Promise.resolve({ data: sellToken, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/app_data/${appDataHash}`) { + return Promise.resolve({ data: fullAppData, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ data: safe, status: 200 }); + } + if ( + url === + `${chain.transactionService}/api/v1/contracts/${contract.address}` + ) { + return Promise.resolve({ data: contract, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + }); +}); diff --git a/src/routes/transactions/__tests__/controllers/preview-transaction-kiln.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/preview-transaction-kiln.transactions.controller.spec.ts new file mode 100644 index 0000000000..5e45c53f7c --- /dev/null +++ b/src/routes/transactions/__tests__/controllers/preview-transaction-kiln.transactions.controller.spec.ts @@ -0,0 +1,2284 @@ +import { faker } from '@faker-js/faker'; +import type { INestApplication } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { TestAppProvider } from '@/__tests__/test-app.provider'; +import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; +import { AppModule } from '@/app.module'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { contractBuilder } from '@/domain/contracts/entities/__tests__/contract.builder'; +import { dataDecodedBuilder } from '@/domain/data-decoder/entities/__tests__/data-decoded.builder'; +import { Operation } from '@/domain/safe/entities/operation.entity'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; +import configuration from '@/config/entities/__tests__/configuration'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import type { INetworkService } from '@/datasources/network/network.service.interface'; +import { NetworkService } from '@/datasources/network/network.service.interface'; +import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import { previewTransactionDtoBuilder } from '@/routes/transactions/entities/__tests__/preview-transaction.dto.builder'; +import { CacheModule } from '@/datasources/cache/cache.module'; +import { NetworkModule } from '@/datasources/network/network.module'; +import { concat } from 'viem'; +import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; +import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import type { Server } from 'net'; +import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; +import { deploymentBuilder } from '@/datasources/staking-api/entities/__tests__/deployment.entity.builder'; +import { dedicatedStakingStatsBuilder } from '@/datasources/staking-api/entities/__tests__/dedicated-staking-stats.entity.builder'; +import { networkStatsBuilder } from '@/datasources/staking-api/entities/__tests__/network-stats.entity.builder'; +import type { Stake } from '@/datasources/staking-api/entities/stake.entity'; +import { StakeState } from '@/datasources/staking-api/entities/stake.entity'; +import { getNumberString } from '@/domain/common/utils/utils'; +import { NULL_ADDRESS } from '@/routes/common/constants'; +import { KilnDecoder } from '@/domain/staking/contracts/decoders/kiln-decoder.helper'; +import { stakeBuilder } from '@/datasources/staking-api/entities/__tests__/stake.entity.builder'; +import { + batchWithdrawCLFeeEncoder, + depositEncoder, + requestValidatorsExitEncoder, +} from '@/domain/staking/contracts/decoders/__tests__/encoders/kiln-encoder.builder'; +import { + multiSendEncoder, + multiSendTransactionsEncoder, +} from '@/domain/contracts/__tests__/encoders/multi-send-encoder.builder'; + +describe('Preview transaction - Kiln - Transactions Controller (Unit)', () => { + let app: INestApplication; + let safeConfigUrl: string; + let networkService: jest.MockedObjectDeep; + let stakingApiUrl: string; + + beforeEach(async () => { + jest.resetAllMocks(); + + const baseConfig = configuration(); + const testConfiguration: typeof configuration = () => ({ + ...baseConfig, + features: { + ...baseConfig.features, + nativeStaking: true, + nativeStakingDecoding: true, + }, + }); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(testConfiguration)], + }) + .overrideModule(CacheModule) + .useModule(TestCacheModule) + .overrideModule(RequestScopedLoggingModule) + .useModule(TestLoggingModule) + .overrideModule(NetworkModule) + .useModule(TestNetworkModule) + .overrideModule(QueuesApiModule) + .useModule(TestQueuesApiModule) + .compile(); + + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); + stakingApiUrl = configurationService.getOrThrow('staking.mainnet.baseUri'); + networkService = moduleFixture.get(NetworkService); + + app = await new TestAppProvider().provide(moduleFixture); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Native (dedicated) staking', () => { + describe('deposit', () => { + it('should preview a transaction', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const dataDecoded = dataDecodedBuilder().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(); + // Transaction being proposed (no stakes exists) + const stakes: Array = []; + const safe = safeBuilder().build(); + const data = depositEncoder().encode(); + const value = getNumberString(64 * 10 ** 18 + 1); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .with('value', value) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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, + }); + case `${stakingApiUrl}/v1/eth/stakes`: + return Promise.resolve({ + data: { data: stakes }, + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + 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}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect({ + txInfo: { + type: 'NativeStakingDeposit', + humanDescription: null, + richDecodedInfo: null, + status: 'NOT_STAKED', + estimatedEntryTime: + networkStats.estimated_entry_time_seconds * 1_000, + estimatedExitTime: + networkStats.estimated_exit_time_seconds * 1_000, + estimatedWithdrawalTime: + networkStats.estimated_withdrawal_time_seconds * 1_000, + 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, + }, + validators: null, + }, + txData: { + hexData: previewTransactionDto.data, + dataDecoded, + to: { + value: contractResponse.address, + name: contractResponse.displayName, + logoUri: contractResponse.logoUri, + }, + value: previewTransactionDto.value, + operation: previewTransactionDto.operation, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + }); + }); + + it('should preview a batched transaction', 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 data = depositEncoder().encode(); + const value = getNumberString(64 * 10 ** 18 + 1); + const depositTransaction = { + operation: Operation.CALL, + data, + to: deployment.address, + value: BigInt(value), + }; + const multiSendTransaction = multiSendEncoder().with( + 'transactions', + multiSendTransactionsEncoder([depositTransaction]), + ); + const dedicatedStakingStats = dedicatedStakingStatsBuilder().build(); + const networkStats = networkStatsBuilder().build(); + // Transaction being proposed (no stakes exists) + const stakes: Array = []; + const safe = safeBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', multiSendTransaction.encode()) + .with('operation', Operation.CALL) + .build(); + const dataDecoded = dataDecodedBuilder().build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const depositContractResponse = contractBuilder() + .with('address', depositTransaction.to) + .build(); + const depositTokenResponse = tokenBuilder() + .with('address', depositTransaction.to) + .build(); + 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, + }); + case `${stakingApiUrl}/v1/eth/stakes`: + return Promise.resolve({ + data: { data: stakes }, + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${depositContractResponse.address}`: + return Promise.resolve({ + data: depositContractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${depositTokenResponse.address}`: + return Promise.resolve({ + data: depositTokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + 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}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect({ + txInfo: { + type: 'NativeStakingDeposit', + humanDescription: null, + richDecodedInfo: null, + status: 'NOT_STAKED', + estimatedEntryTime: + networkStats.estimated_entry_time_seconds * 1_000, + estimatedExitTime: + networkStats.estimated_exit_time_seconds * 1_000, + estimatedWithdrawalTime: + networkStats.estimated_withdrawal_time_seconds * 1_000, + 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, + }, + validators: null, + }, + txData: { + hexData: previewTransactionDto.data, + dataDecoded, + to: { + value: contractResponse.address, + name: contractResponse.displayName, + logoUri: contractResponse.logoUri, + }, + value: previewTransactionDto.value, + operation: previewTransactionDto.operation, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + }); + }); + + it('should return a "standard" transaction preview if the deployment is unavailable', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const dataDecoded = dataDecodedBuilder().build(); + const safe = safeBuilder().build(); + const data = depositEncoder().encode(); + const value = getNumberString(64 * 10 ** 18 + 1); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('value', value) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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.reject({ + status: 500, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${tokenResponse.address}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the deployment is not dedicated-specific', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const dataDecoded = dataDecodedBuilder().build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'defi') + .with('product_fee', faker.number.float().toString()) + .build(); + const safe = safeBuilder().build(); + const data = depositEncoder().encode(); + const value = getNumberString(64 * 10 ** 18 + 1); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .with('value', value) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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 `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${tokenResponse.address}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the deployment is on an unknown chain', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const dataDecoded = dataDecodedBuilder().build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('chain', 'unknown') + .with('product_type', 'dedicated') + .with('product_fee', faker.number.float().toString()) + .build(); + const safe = safeBuilder().build(); + const data = depositEncoder().encode(); + const value = getNumberString(64 * 10 ** 18 + 1); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .with('value', value) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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 `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${tokenResponse.address}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if not transacting with a deployment address', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const dataDecoded = dataDecodedBuilder().build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'dedicated') + .with('product_fee', faker.number.float().toString()) + .build(); + const safe = safeBuilder().build(); + const data = depositEncoder().encode(); + const value = getNumberString(64 * 10 ** 18 + 1); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('value', value) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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 `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${tokenResponse.address}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the deployment has no product fee', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const dataDecoded = dataDecodedBuilder().build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'dedicated') + .with('product_fee', null) + .build(); + const safe = safeBuilder().build(); + const data = depositEncoder().encode(); + const value = getNumberString(64 * 10 ** 18 + 1); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .with('value', value) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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 `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${tokenResponse.address}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the dedicated staking stats are not available', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const dataDecoded = dataDecodedBuilder().build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'dedicated') + .with('product_fee', faker.number.float().toString()) + .build(); + const networkStats = networkStatsBuilder().build(); + const safe = safeBuilder().build(); + const data = depositEncoder().encode(); + const value = getNumberString(64 * 10 ** 18 + 1); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .with('value', value) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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.reject({ + status: 500, + }); + case `${stakingApiUrl}/v1/eth/network-stats`: + return Promise.resolve({ + data: { data: networkStats }, + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${tokenResponse.address}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the network stats are not available', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const dataDecoded = dataDecodedBuilder().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 safe = safeBuilder().build(); + const data = depositEncoder().encode(); + const value = getNumberString(64 * 10 ** 18 + 1); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .with('value', value) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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.reject({ + status: 500, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${tokenResponse.address}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + }); + + describe('requestValidatorsExit', () => { + it('should preview a transaction', 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 safe = safeBuilder().build(); + const networkStats = networkStatsBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = requestValidatorsExitEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const stakes = [ + stakeBuilder().with('state', StakeState.ActiveOngoing).build(), + stakeBuilder().with('state', StakeState.ActiveOngoing).build(), + ]; + const contractResponse = contractBuilder() + .with('address', deployment.address) + .build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .build(); + 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, + }); + case `${stakingApiUrl}/v1/eth/stakes`: + return Promise.resolve({ + data: { data: stakes }, + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect({ + txInfo: { + type: 'NativeStakingValidatorsExit', + humanDescription: null, + richDecodedInfo: null, + status: 'ACTIVE', + estimatedExitTime: + networkStats.estimated_exit_time_seconds * 1_000, + estimatedWithdrawalTime: + networkStats.estimated_withdrawal_time_seconds * 1_000, + value: '64000000000000000000', // 2 x 32 ETH, + numValidators: 2, + tokenInfo: { + address: NULL_ADDRESS, + decimals: chain.nativeCurrency.decimals, + logoUri: chain.nativeCurrency.logoUri, + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + trusted: true, + }, + validators, + }, + txData: { + hexData: previewTransactionDto.data, + dataDecoded, + to: { + value: contractResponse.address, + name: contractResponse.displayName, + logoUri: contractResponse.logoUri, + }, + value: previewTransactionDto.value, + operation: previewTransactionDto.operation, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + }); + }); + + it('should preview a batched transaction', 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 safe = safeBuilder().build(); + const networkStats = networkStatsBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = requestValidatorsExitEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const stakes = [ + stakeBuilder().with('state', StakeState.ActiveOngoing).build(), + stakeBuilder().with('state', StakeState.ActiveOngoing).build(), + ]; + const requestValidatorsExitTransaction = { + operation: Operation.CALL, + data, + to: deployment.address, + value: BigInt(0), + }; + const multiSendTransaction = multiSendEncoder().with( + 'transactions', + multiSendTransactionsEncoder([requestValidatorsExitTransaction]), + ); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', multiSendTransaction.encode()) + .with('operation', Operation.CALL) + .build(); + const dataDecoded = dataDecodedBuilder().build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const requestValidatorsExiContractResponse = contractBuilder() + .with('address', deployment.address) + .build(); + const requestValidatorsExitTokenResponse = tokenBuilder() + .with('address', deployment.address) + .build(); + 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, + }); + case `${stakingApiUrl}/v1/eth/stakes`: + return Promise.resolve({ + data: { data: stakes }, + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${requestValidatorsExiContractResponse.address}`: + return Promise.resolve({ + data: requestValidatorsExiContractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${tokenResponse.address}`: + return Promise.resolve({ + data: tokenResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${requestValidatorsExitTokenResponse.address}`: + return Promise.resolve({ + data: requestValidatorsExitTokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect({ + txInfo: { + type: 'NativeStakingValidatorsExit', + humanDescription: null, + richDecodedInfo: null, + status: 'ACTIVE', + estimatedExitTime: + networkStats.estimated_exit_time_seconds * 1_000, + estimatedWithdrawalTime: + networkStats.estimated_withdrawal_time_seconds * 1_000, + value: '64000000000000000000', // 2 x 32 ETH, + numValidators: 2, + tokenInfo: { + address: NULL_ADDRESS, + decimals: chain.nativeCurrency.decimals, + logoUri: chain.nativeCurrency.logoUri, + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + trusted: true, + }, + validators, + }, + txData: { + hexData: previewTransactionDto.data, + dataDecoded, + to: { + value: contractResponse.address, + name: contractResponse.displayName, + logoUri: contractResponse.logoUri, + }, + value: previewTransactionDto.value, + operation: previewTransactionDto.operation, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + }); + }); + + it('should return a "standard" transaction preview if the deployment is unavailable', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const safe = safeBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = requestValidatorsExitEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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.reject({ + status: 500, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${previewTransactionDto.to}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the deployment is not dedicated-specific', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const safe = safeBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = requestValidatorsExitEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'defi') + .with('product_fee', faker.number.float().toString()) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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 `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${previewTransactionDto.to}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the deployment is on an unknown chain', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const safe = safeBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = requestValidatorsExitEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('chain', 'unknown') + .with('product_type', 'dedicated') + .with('product_fee', faker.number.float().toString()) + .build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('to', deployment.address) + .with('data', data) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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 `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${previewTransactionDto.to}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if not transacting with a deployment address', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const safe = safeBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = requestValidatorsExitEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'dedicated') + .with('product_fee', faker.number.float().toString()) + .build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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 `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${previewTransactionDto.to}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the network stats are not available', 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 safe = safeBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = requestValidatorsExitEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const stakes = [ + stakeBuilder().with('state', StakeState.ActiveOngoing).build(), + stakeBuilder().with('state', StakeState.ActiveOngoing).build(), + ]; + const contractResponse = contractBuilder() + .with('address', deployment.address) + .build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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.reject({ + status: 500, + }); + case `${stakingApiUrl}/v1/eth/stakes`: + return Promise.resolve({ + data: { data: stakes }, + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${previewTransactionDto.to}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the stakes are not available', 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 safe = safeBuilder().build(); + const networkStats = networkStatsBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = requestValidatorsExitEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const contractResponse = contractBuilder() + .with('address', deployment.address) + .build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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, + }); + case `${stakingApiUrl}/v1/eth/stakes`: + return Promise.reject({ + status: 500, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${previewTransactionDto.to}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + }); + + describe('batchWithdrawCLFee', () => { + it('should preview a transaction', 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 safe = safeBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = batchWithdrawCLFeeEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const stakes = [ + stakeBuilder() + .with('net_claimable_consensus_rewards', '1000000') + .build(), + stakeBuilder() + .with('net_claimable_consensus_rewards', '2000000') + .build(), + ]; + const contractResponse = contractBuilder() + .with('address', deployment.address) + .build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .build(); + 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/stakes`: + return Promise.resolve({ + data: { data: stakes }, + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect({ + txInfo: { + type: 'NativeStakingWithdraw', + humanDescription: null, + richDecodedInfo: null, + value: ( + +stakes[0].net_claimable_consensus_rewards! + + +stakes[1].net_claimable_consensus_rewards! + ).toString(), + tokenInfo: { + address: NULL_ADDRESS, + decimals: chain.nativeCurrency.decimals, + logoUri: chain.nativeCurrency.logoUri, + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + trusted: true, + }, + validators, + }, + txData: { + hexData: previewTransactionDto.data, + dataDecoded, + to: { + value: contractResponse.address, + name: contractResponse.displayName, + logoUri: contractResponse.logoUri, + }, + value: previewTransactionDto.value, + operation: previewTransactionDto.operation, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + }); + }); + + it('should preview a batched transaction', 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 safe = safeBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = batchWithdrawCLFeeEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const batchWithdrawCLFeeTransaction = { + operation: Operation.CALL, + data, + to: deployment.address, + value: BigInt(0), + }; + const multiSendTransaction = multiSendEncoder().with( + 'transactions', + multiSendTransactionsEncoder([batchWithdrawCLFeeTransaction]), + ); + const stakes = [ + stakeBuilder() + .with('net_claimable_consensus_rewards', '1000000') + .build(), + stakeBuilder() + .with('net_claimable_consensus_rewards', '2000000') + .build(), + ]; + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', multiSendTransaction.encode()) + .with('operation', Operation.CALL) + .build(); + const dataDecoded = dataDecodedBuilder().build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + const batchWithdrawCLFeeContractResponse = contractBuilder() + .with('address', batchWithdrawCLFeeTransaction.to) + .build(); + const batchWithdrawCLFeeTokenResponse = tokenBuilder() + .with('address', batchWithdrawCLFeeTransaction.to) + .build(); + 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/stakes`: + return Promise.resolve({ + data: { data: stakes }, + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${tokenResponse.address}`: + return Promise.resolve({ + data: tokenResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${batchWithdrawCLFeeContractResponse.address}`: + return Promise.resolve({ + data: batchWithdrawCLFeeContractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${batchWithdrawCLFeeTokenResponse.address}`: + return Promise.resolve({ + data: batchWithdrawCLFeeTokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect({ + txInfo: { + type: 'NativeStakingWithdraw', + humanDescription: null, + richDecodedInfo: null, + value: ( + +stakes[0].net_claimable_consensus_rewards! + + +stakes[1].net_claimable_consensus_rewards! + ).toString(), + tokenInfo: { + address: NULL_ADDRESS, + decimals: chain.nativeCurrency.decimals, + logoUri: chain.nativeCurrency.logoUri, + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + trusted: true, + }, + validators, + }, + txData: { + hexData: previewTransactionDto.data, + dataDecoded, + to: { + value: contractResponse.address, + name: contractResponse.displayName, + logoUri: contractResponse.logoUri, + }, + value: previewTransactionDto.value, + operation: previewTransactionDto.operation, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + }); + }); + + it('should return a "standard" transaction preview if the deployment is unavailable', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const safe = safeBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = batchWithdrawCLFeeEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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.reject({ + status: 500, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${previewTransactionDto.to}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the deployment is not dedicated-specific', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const safe = safeBuilder().build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'defi') + .with('product_fee', faker.number.float().toString()) + .build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = batchWithdrawCLFeeEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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 `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${previewTransactionDto.to}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the deployment is on an unknown chain', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const safe = safeBuilder().build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('chain', 'unknown') + .with('product_type', 'dedicated') + .with('product_fee', faker.number.float().toString()) + .build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = batchWithdrawCLFeeEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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 `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${previewTransactionDto.to}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if not transacting with a deployment address', async () => { + const chain = chainBuilder().with('isTestnet', false).build(); + const safe = safeBuilder().build(); + const deployment = deploymentBuilder() + .with('chain_id', +chain.chainId) + .with('product_type', 'dedicated') + .with('product_fee', faker.number.float().toString()) + .build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = batchWithdrawCLFeeEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .build(); + const contractResponse = contractBuilder() + .with('address', previewTransactionDto.to) + .build(); + const tokenResponse = tokenBuilder() + .with('address', previewTransactionDto.to) + .build(); + 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 `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + status: 200, + }); + case `${chain.transactionService}/api/v1/tokens/${previewTransactionDto.to}`: + return Promise.resolve({ + data: tokenResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + + it('should return a "standard" transaction preview if the stakes are not available', 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 safe = safeBuilder().build(); + const validators = [ + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const data = batchWithdrawCLFeeEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const dataDecoded = dataDecodedBuilder().build(); + const contractResponse = contractBuilder() + .with('address', deployment.address) + .build(); + const previewTransactionDto = previewTransactionDtoBuilder() + .with('data', data) + .with('operation', Operation.CALL) + .with('to', deployment.address) + .build(); + 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/stakes`: + return Promise.reject({ + status: 500, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ + data: safe, + status: 200, + }); + case `${chain.transactionService}/api/v1/contracts/${contractResponse.address}`: + return Promise.resolve({ + data: contractResponse, + 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.resolve({ data: dataDecoded, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/transactions/${safe.address}/preview`, + ) + .send(previewTransactionDto) + .expect(200) + .expect(({ body }) => expect(body.txInfo.type).toBe('Custom')); + }); + }); + }); +}); diff --git a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts index b3585fef41..c0c0b3164c 100644 --- a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts @@ -82,7 +82,7 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); - it('should preview a transaction', async () => { + it('should preview a "standard" transaction', async () => { const previewTransactionDto = previewTransactionDtoBuilder() .with('operation', Operation.CALL) .build(); @@ -150,7 +150,7 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); - it('should preview a transaction with an unknown "to" address', async () => { + it('should preview a "standard" transaction with an unknown "to" address', async () => { const previewTransactionDto = previewTransactionDtoBuilder() .with('operation', Operation.CALL) .build(); @@ -217,7 +217,7 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); - it('should preview a transaction even if the data cannot be decoded', async () => { + it('should preview a "standard" transaction even if the data cannot be decoded', async () => { const previewTransactionDto = previewTransactionDtoBuilder() .with('operation', Operation.CALL) .build(); @@ -283,7 +283,7 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); - it('should preview a transaction with a nested call', async () => { + it('should preview a "standard" transaction with a nested call', async () => { const previewTransactionDto = previewTransactionDtoBuilder() .with('operation', Operation.DELEGATE) .build(); diff --git a/src/routes/transactions/entities/transaction-preview.entity.ts b/src/routes/transactions/entities/transaction-preview.entity.ts index 1ca432e8b6..33e93fece0 100644 --- a/src/routes/transactions/entities/transaction-preview.entity.ts +++ b/src/routes/transactions/entities/transaction-preview.entity.ts @@ -5,12 +5,22 @@ import { SettingsChangeTransaction } from '@/routes/transactions/entities/settin import { TransactionData } from '@/routes/transactions/entities/transaction-data.entity'; import { TransactionInfo } from '@/routes/transactions/entities/transaction-info.entity'; import { TransferTransactionInfo } from '@/routes/transactions/entities/transfer-transaction-info.entity'; +import { SwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/swap-order-info.entity'; +import { TwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/twap-order-info.entity'; +import { NativeStakingDepositTransactionInfo } from '@/routes/transactions/entities/staking/native-staking-deposit-info.entity'; +import { NativeStakingWithdrawTransactionInfo } from '@/routes/transactions/entities/staking/native-staking-withdraw-info.entity'; +import { NativeStakingValidatorsExitTransactionInfo } from '@/routes/transactions/entities/staking/native-staking-validators-exit-info.entity'; @ApiExtraModels( CreationTransactionInfo, CustomTransactionInfo, SettingsChangeTransaction, TransferTransactionInfo, + SwapOrderTransactionInfo, + TwapOrderTransactionInfo, + NativeStakingDepositTransactionInfo, + NativeStakingValidatorsExitTransactionInfo, + NativeStakingWithdrawTransactionInfo, ) export class TransactionPreview { @ApiProperty({ @@ -19,6 +29,11 @@ export class TransactionPreview { { $ref: getSchemaPath(CustomTransactionInfo) }, { $ref: getSchemaPath(SettingsChangeTransaction) }, { $ref: getSchemaPath(TransferTransactionInfo) }, + { $ref: getSchemaPath(SwapOrderTransactionInfo) }, + { $ref: getSchemaPath(TwapOrderTransactionInfo) }, + { $ref: getSchemaPath(NativeStakingDepositTransactionInfo) }, + { $ref: getSchemaPath(NativeStakingValidatorsExitTransactionInfo) }, + { $ref: getSchemaPath(NativeStakingWithdrawTransactionInfo) }, ], }) txInfo: TransactionInfo; diff --git a/src/routes/transactions/helpers/kiln-native-staking.helper.spec.ts b/src/routes/transactions/helpers/kiln-native-staking.helper.spec.ts index 5e50c321f0..2ed0d42af3 100644 --- a/src/routes/transactions/helpers/kiln-native-staking.helper.spec.ts +++ b/src/routes/transactions/helpers/kiln-native-staking.helper.spec.ts @@ -1,29 +1,20 @@ -import { deploymentBuilder } from '@/datasources/staking-api/entities/__tests__/deployment.entity.builder'; -import { stakeBuilder } from '@/datasources/staking-api/entities/__tests__/stake.entity.builder'; +import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; import { multiSendEncoder, multiSendTransactionsEncoder, } from '@/domain/contracts/__tests__/encoders/multi-send-encoder.builder'; -import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; -import { dataDecodedBuilder } from '@/domain/data-decoder/entities/__tests__/data-decoded.builder'; import { batchWithdrawCLFeeEncoder, depositEncoder, requestValidatorsExitEncoder, } from '@/domain/staking/contracts/decoders/__tests__/encoders/kiln-encoder.builder'; import { KilnDecoder } from '@/domain/staking/contracts/decoders/kiln-decoder.helper'; -import type { StakingRepository } from '@/domain/staking/staking.repository'; import type { ILoggingService } from '@/logging/logging.interface'; import { KilnNativeStakingHelper } from '@/routes/transactions/helpers/kiln-native-staking.helper'; import { TransactionFinder } from '@/routes/transactions/helpers/transaction-finder.helper'; import { faker } from '@faker-js/faker'; import { concat, getAddress } from 'viem'; -const mockStakingRepository = jest.mocked({ - getStakes: jest.fn(), - getDeployment: jest.fn(), -} as jest.MockedObjectDeep); - const mockLoggingService = { warn: jest.fn(), } as jest.MockedObjectDeep; @@ -36,10 +27,7 @@ describe('KilnNativeStakingHelper', () => { const multiSendDecoder = new MultiSendDecoder(mockLoggingService); const transactionFinder = new TransactionFinder(multiSendDecoder); - target = new KilnNativeStakingHelper( - transactionFinder, - mockStakingRepository, - ); + target = new KilnNativeStakingHelper(transactionFinder); }); describe.each([ @@ -53,422 +41,51 @@ describe('KilnNativeStakingHelper', () => { encoder: batchWithdrawCLFeeEncoder, } as const, ])('$name', ({ name, encoder }) => { - it('should return a transaction', async () => { - const deployment = deploymentBuilder() - .with('product_type', 'dedicated') - .with('chain', 'eth') - .build(); + it('should return a transaction', () => { + const to = getAddress(faker.finance.ethereumAddress()); const data = encoder().encode(); - mockStakingRepository.getDeployment.mockResolvedValue(deployment); + const value = faker.string.numeric(); - const result = await target[name]({ - chainId: deployment.chain_id.toString(), - to: deployment.address, + const result = target[name]({ + to, data, + value, }); - expect(result).toStrictEqual({ to: deployment.address, data }); + expect(result).toStrictEqual({ to, data, value }); }); - it('should return a batched transaction', async () => { - const deployment = deploymentBuilder() - .with('product_type', 'dedicated') - .with('chain', 'eth') - .build(); + it('should return a batched transaction', () => { + const depositTo = getAddress(faker.finance.ethereumAddress()); const depositData = encoder().encode(); - const data = multiSendEncoder() + const depositValue = faker.number.bigInt(); + const multiSendTo = getAddress(faker.finance.ethereumAddress()); + const mulitSendData = multiSendEncoder() .with( 'transactions', multiSendTransactionsEncoder([ { operation: 0, data: depositData, - to: deployment.address, - value: faker.number.bigInt(0), + to: depositTo, + value: depositValue, }, ]), ) .encode(); - mockStakingRepository.getDeployment.mockResolvedValue(deployment); - const result = await target[name]({ - chainId: deployment.chain_id.toString(), - // MultiSend address mock - to: getAddress(faker.finance.ethereumAddress()), - data, + const result = target[name]({ + to: multiSendTo, + data: mulitSendData, + value: faker.string.numeric(), }); expect(result).toStrictEqual({ - to: deployment.address, + to: depositTo, data: depositData, + value: depositValue.toString(), }); }); - - it('should return null if a transaction is not from a known staking contract', async () => { - const chainId = faker.string.numeric(); - const data = encoder().encode(); - mockStakingRepository.getDeployment.mockRejectedValue( - new Error('Deployment not found'), - ); - - const result = await target[name]({ - chainId, - to: getAddress(faker.finance.ethereumAddress()), - data, - }); - - expect(result).toBe(null); - }); - - it('should return null if a batched transaction is not from a known staking contract', async () => { - const chainId = faker.string.numeric(); - const to = getAddress(faker.finance.ethereumAddress()); - const data = multiSendEncoder() - .with( - 'transactions', - multiSendTransactionsEncoder([ - { - operation: 0, - data: encoder().encode(), - to: getAddress(faker.finance.ethereumAddress()), - value: faker.number.bigInt(0), - }, - ]), - ) - .encode(); - mockStakingRepository.getDeployment.mockRejectedValue( - new Error('Deployment not found'), - ); - - const result = await target[name]({ - chainId, - to, - data, - }); - - expect(result).toBe(null); - }); - - it('should return a is from a known non-dedicated staking contract', async () => { - const deployment = deploymentBuilder() - .with('product_type', 'defi') - .with('chain', 'eth') - .build(); - const data = encoder().encode(); - mockStakingRepository.getDeployment.mockResolvedValue(deployment); - - const result = await target[name]({ - chainId: deployment.chain_id.toString(), - to: deployment.address, - data, - }); - - expect(result).toBe(null); - }); - - it('should return a is from an unknown chain', async () => { - const deployment = deploymentBuilder() - .with('product_type', 'dedicated') - .with('chain', 'unknown') - .build(); - const data = encoder().encode(); - mockStakingRepository.getDeployment.mockResolvedValue(deployment); - - const result = await target[name]({ - chainId: deployment.chain_id.toString(), - to: deployment.address, - data, - }); - - expect(result).toBe(null); - }); - - it('should return null if a batched transaction is from a known non-dedicated staking contract', async () => { - const deployment = deploymentBuilder() - .with('product_type', 'defi') - .with('chain', 'eth') - .build(); - const depositData = encoder().encode(); - const data = multiSendEncoder() - .with( - 'transactions', - multiSendTransactionsEncoder([ - { - operation: 0, - data: depositData, - to: deployment.address, - value: faker.number.bigInt(0), - }, - ]), - ) - .encode(); - mockStakingRepository.getDeployment.mockResolvedValue(deployment); - - const result = await target[name]({ - chainId: deployment.chain_id.toString(), - // MultiSend address mock - to: getAddress(faker.finance.ethereumAddress()), - data, - }); - - expect(result).toBe(null); - }); - - it('should return null if a batched transaction is from an unknown chain', async () => { - const deployment = deploymentBuilder() - .with('product_type', 'dedicated') - .with('chain', 'unknown') - .build(); - const depositData = encoder().encode(); - const data = multiSendEncoder() - .with( - 'transactions', - multiSendTransactionsEncoder([ - { - operation: 0, - data: depositData, - to: deployment.address, - value: faker.number.bigInt(0), - }, - ]), - ) - .encode(); - mockStakingRepository.getDeployment.mockResolvedValue(deployment); - - const result = await target[name]({ - chainId: deployment.chain_id.toString(), - // MultiSend address mock - to: getAddress(faker.finance.ethereumAddress()), - data, - }); - - expect(result).toBe(null); - }); - - it('should return null if the transaction is not a transaction', async () => { - const deployment = deploymentBuilder() - .with('product_type', 'dedicated') - .with('chain', 'eth') - .build(); - const data = faker.string.hexadecimal() as `0x${string}`; - - const result = await target[name]({ - chainId: deployment.chain_id.toString(), - to: deployment.address, - data, - }); - - expect(result).toBe(null); - }); - - it('should return null if the transaction batch contains no transaction', async () => { - const deployment = deploymentBuilder() - .with('product_type', 'dedicated') - .with('chain', 'eth') - .build(); - const data = multiSendEncoder() - .with( - 'transactions', - multiSendTransactionsEncoder([ - { - operation: 0, - data: faker.string.hexadecimal() as `0x${string}`, - to: deployment.address, - value: faker.number.bigInt(0), - }, - ]), - ) - .encode(); - - const result = await target[name]({ - chainId: deployment.chain_id.toString(), - to: deployment.address, - data, - }); - - expect(result).toBe(null); - }); - }); - - describe('getValueFromDataDecoded', () => { - it('should throw if the decoded data is not of a `requestValidatorsExit` or `batchWithdrawCLFee` transaction', async () => { - const chainId = faker.string.numeric(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const dataDecoded = dataDecodedBuilder() - .with('method', 'deposit') - .with('parameters', []) - .build(); - - await expect(() => - target.getValueFromDataDecoded({ - chainId, - safeAddress, - dataDecoded, - }), - ).rejects.toThrow('deposit does not contain _publicKeys'); - }); - - it('should return 0 if no public keys are found in the decoded data', async () => { - const method = faker.helpers.arrayElement([ - 'requestValidatorsExit', - 'batchWithdrawCLFee', - ]); - const dataDecoded = dataDecodedBuilder() - .with('method', method) - .with('parameters', []) - .build(); - - const result = await target.getValueFromDataDecoded({ - chainId: faker.string.numeric(), - safeAddress: getAddress(faker.finance.ethereumAddress()), - dataDecoded, - }); - - expect(result).toBe(0); - }); - - it('should return the total claimable value for all public keys', async () => { - const method = faker.helpers.arrayElement([ - 'requestValidatorsExit', - 'batchWithdrawCLFee', - ]); - const validators = [ - faker.string.hexadecimal({ - length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase - casing: 'lower', - }), - faker.string.hexadecimal({ - length: KilnDecoder.KilnPublicKeyLength, - casing: 'lower', - }), - faker.string.hexadecimal({ - length: KilnDecoder.KilnPublicKeyLength, - casing: 'lower', - }), - ] as Array<`0x${string}`>; - const _publicKeys = concat(validators); - const dataDecoded = dataDecodedBuilder() - .with('method', method) - .with('parameters', [ - { - name: '_publicKeys', - type: 'bytes', - value: _publicKeys, - valueDecoded: null, - }, - ]) - .build(); - const stakes = [ - stakeBuilder().build(), - stakeBuilder().build(), - stakeBuilder().build(), - ]; - mockStakingRepository.getStakes.mockResolvedValue(stakes); - - const result = await target.getValueFromDataDecoded({ - chainId: faker.string.numeric(), - safeAddress: getAddress(faker.finance.ethereumAddress()), - dataDecoded, - }); - - expect(result).toBe( - +stakes[0].net_claimable_consensus_rewards! + - +stakes[1].net_claimable_consensus_rewards! + - +stakes[2].net_claimable_consensus_rewards!, - ); - }); - }); - - describe('getPublicKeysFromDataDecoded', () => { - it('should throw if the decoded data is not of a `requestValidatorsExit` or `batchWithdrawCLFee` transaction', () => { - const dataDecoded = dataDecodedBuilder() - .with('method', 'deposit') - .with('parameters', []) - .build(); - - expect(() => target.getPublicKeysFromDataDecoded(dataDecoded)).toThrow( - 'deposit does not contain _publicKeys', - ); - }); - - it('should return an empty array if no parameters are found', () => { - const method = faker.helpers.arrayElement([ - 'requestValidatorsExit', - 'batchWithdrawCLFee', - ]); - const dataDecoded = dataDecodedBuilder() - .with('method', method) - .with('parameters', []) - .build(); - - const result = target.getPublicKeysFromDataDecoded(dataDecoded); - - expect(result).toStrictEqual([]); - }); - - it('should return an array of split public keys if hex _publicKeys parameter is found', () => { - const method = faker.helpers.arrayElement([ - 'requestValidatorsExit', - 'batchWithdrawCLFee', - ]); - const validators = [ - faker.string.hexadecimal({ - length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase - casing: 'lower', - }), - faker.string.hexadecimal({ - length: KilnDecoder.KilnPublicKeyLength, - casing: 'lower', - }), - faker.string.hexadecimal({ - length: KilnDecoder.KilnPublicKeyLength, - casing: 'lower', - }), - ] as Array<`0x${string}`>; - const _publicKeys = concat(validators); - const dataDecoded = dataDecodedBuilder() - .with('method', method) - .with('parameters', [ - { - name: '_publicKeys', - type: 'bytes', - value: _publicKeys, - valueDecoded: null, - }, - ]) - .build(); - - const result = target.getPublicKeysFromDataDecoded(dataDecoded); - - expect(result).toStrictEqual(validators); - }); - - it('should return an empty array if non-hex _publicKeys is found', () => { - const method = faker.helpers.arrayElement([ - 'requestValidatorsExit', - 'batchWithdrawCLFee', - ]); - const _publicKeys = faker.string.alpha({ - length: KilnDecoder.KilnPublicKeyLength, - }) as `0x${string}`; - const dataDecoded = dataDecodedBuilder() - .with('method', method) - .with('parameters', [ - { - name: '_publicKeys', - type: 'bytes', - value: _publicKeys, - valueDecoded: null, - }, - ]) - .build(); - - const result = target.getPublicKeysFromDataDecoded(dataDecoded); - - expect(result).toStrictEqual([]); - }); }); describe('splitPublicKeys', () => { @@ -476,7 +93,6 @@ describe('KilnNativeStakingHelper', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ diff --git a/src/routes/transactions/helpers/kiln-native-staking.helper.ts b/src/routes/transactions/helpers/kiln-native-staking.helper.ts index 094f67577f..6568a8f4e2 100644 --- a/src/routes/transactions/helpers/kiln-native-staking.helper.ts +++ b/src/routes/transactions/helpers/kiln-native-staking.helper.ts @@ -1,21 +1,13 @@ -import { DataDecoded } from '@/domain/data-decoder/entities/data-decoded.entity'; import { KilnAbi, KilnDecoder, } from '@/domain/staking/contracts/decoders/kiln-decoder.helper'; -import { IStakingRepository } from '@/domain/staking/staking.repository.interface'; -import { StakingRepositoryModule } from '@/domain/staking/staking.repository.module'; import { TransactionFinder, TransactionFinderModule, } from '@/routes/transactions/helpers/transaction-finder.helper'; -import { - Inject, - Injectable, - Module, - UnprocessableEntityException, -} from '@nestjs/common'; -import { AbiFunction, getAbiItem, isHex, toFunctionSelector } from 'viem'; +import { Injectable, Module } from '@nestjs/common'; +import { getAbiItem, toFunctionSelector } from 'viem'; @Injectable() export class KilnNativeStakingHelper { @@ -32,153 +24,48 @@ export class KilnNativeStakingHelper { name: 'batchWithdrawCLFee', }); - constructor( - private readonly transactionFinder: TransactionFinder, - @Inject(IStakingRepository) - private readonly stakingRepository: IStakingRepository, - ) {} - - public async findDepositTransaction(args: { - chainId: string; - to?: `0x${string}`; - data: `0x${string}`; - }): Promise<{ to: `0x${string}`; data: `0x${string}` } | null> { - return this.findNativeStakingTransaction({ - item: KilnNativeStakingHelper.DEPOSIT_SIGNATURE, - ...args, - }); - } + constructor(private readonly transactionFinder: TransactionFinder) {} - public async findValidatorsExitTransaction(args: { - chainId: string; + public findDepositTransaction(args: { to?: `0x${string}`; data: `0x${string}`; - }): Promise<{ to: `0x${string}`; data: `0x${string}` } | null> { - return this.findNativeStakingTransaction({ - item: KilnNativeStakingHelper.VALIDATORS_EXIT_SIGNATURE, - ...args, - }); + value: string; + }): { to?: `0x${string}`; data: `0x${string}`; value: string } | null { + const selector = toFunctionSelector( + KilnNativeStakingHelper.DEPOSIT_SIGNATURE, + ); + return this.transactionFinder.findTransaction( + (transaction) => transaction.data.startsWith(selector), + args, + ); } - public async findWithdrawTransaction(args: { - chainId: string; + public findValidatorsExitTransaction(args: { to?: `0x${string}`; data: `0x${string}`; - }): Promise<{ to: `0x${string}`; data: `0x${string}` } | null> { - return this.findNativeStakingTransaction({ - item: KilnNativeStakingHelper.WITHDRAW_SIGNATURE, - ...args, - }); + value: string; + }): { to?: `0x${string}`; data: `0x${string}`; value: string } | null { + const selector = toFunctionSelector( + KilnNativeStakingHelper.VALIDATORS_EXIT_SIGNATURE, + ); + return this.transactionFinder.findTransaction( + (transaction) => transaction.data.startsWith(selector), + args, + ); } - private async findNativeStakingTransaction(args: { - item: AbiFunction; - chainId: string; + public findWithdrawTransaction(args: { to?: `0x${string}`; data: `0x${string}`; - }): Promise<{ to: `0x${string}`; data: `0x${string}` } | null> { - const transaction = this.transactionFinder.findTransaction( - (transaction) => - transaction.data.startsWith(toFunctionSelector(args.item)), + value: string; + }): { to?: `0x${string}`; data: `0x${string}`; value: string } | null { + const selector = toFunctionSelector( + KilnNativeStakingHelper.WITHDRAW_SIGNATURE, + ); + return this.transactionFinder.findTransaction( + (transaction) => transaction.data.startsWith(selector), args, ); - - if (!transaction?.to) { - return null; - } - return this.checkDeployment({ - chainId: args.chainId, - transaction: { to: transaction.to, data: transaction.data }, - }); - } - - /** - * Check the deployment to see if it is a valid staking transaction. - * We need to check against the deployment as some function signatures have common function names, e.g. deposit. - */ - private async checkDeployment(args: { - chainId: string; - transaction: { to: `0x${string}`; data: `0x${string}` }; - }): Promise<{ to: `0x${string}`; data: `0x${string}` } | null> { - const deployment = await this.stakingRepository - .getDeployment({ - chainId: args.chainId, - address: args.transaction.to, - }) - .catch(() => null); - - if ( - deployment?.product_type !== 'dedicated' || - deployment?.chain === 'unknown' - ) { - return null; - } - - return { - to: args.transaction.to, - data: args.transaction.data, - }; - } - - /** - * Gets the net value (staked + rewards) to withdraw from the native staking deployment - * based on the length of the publicKeys field in the transaction data. - * - * Note: this can only be used with `validatorsExit` or `batchWithdrawCLFee` transactions - * as the have `_publicKeys` field in the decoded data. - * - * Each {@link KilnDecoder.KilnPublicKeyLength} characters represent a validator to withdraw, - * and each native staking validator has a fixed amount of 32 ETH to withdraw. - * - * @param dataDecoded - the decoded data of the transaction - * @param chainId - the ID of the chain where the native staking deployment lives - * @param safeAddress - the Safe staking - * @returns the net value to withdraw from the native staking deployment - */ - public async getValueFromDataDecoded(args: { - chainId: string; - safeAddress: `0x${string}`; - dataDecoded: DataDecoded; - }): Promise { - const publicKeys = this.getPublicKeysFromDataDecoded(args.dataDecoded); - if (publicKeys.length === 0) { - return 0; - } - const stakes = await this.stakingRepository.getStakes({ - chainId: args.chainId, - safeAddress: args.safeAddress, - validatorsPublicKeys: publicKeys, - }); - return stakes.reduce((acc, stake) => { - const netValue = stake.net_claimable_consensus_rewards ?? '0'; - return acc + Number(netValue); - }, 0); - } - - /** - * Gets public keys from decoded `requestValidatorsExit` or `batchWithdrawCLFee` transactions - * @param dataDecoded - the transaction decoded data. - * @returns the public keys from the transaction decoded data. - */ - public getPublicKeysFromDataDecoded( - dataDecoded: DataDecoded, - ): Array<`0x${string}`> { - if ( - !['requestValidatorsExit', 'batchWithdrawCLFee'].includes( - dataDecoded.method, - ) - ) { - throw new UnprocessableEntityException( - `${dataDecoded.method} does not contain _publicKeys`, - ); - } - - const publicKeys = dataDecoded.parameters?.find((parameter) => { - return parameter.name === '_publicKeys'; - }); - return isHex(publicKeys?.value) - ? this.splitPublicKeys(publicKeys.value) - : []; } /** @@ -208,7 +95,7 @@ export class KilnNativeStakingHelper { } @Module({ - imports: [TransactionFinderModule, StakingRepositoryModule], + imports: [TransactionFinderModule], providers: [KilnNativeStakingHelper, KilnDecoder], exports: [KilnNativeStakingHelper, KilnDecoder], }) diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index 7936bc13ee..4a15948805 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -58,7 +58,11 @@ export class SwapOrderHelper { this.transactionFinder.findTransaction( (transaction) => this.gpv2Decoder.helpers.isSetPreSignature(transaction.data), - { data }, + { + data, + // Placeholder as we are only interested in the data + value: '0', + }, )?.data ?? null ); } diff --git a/src/routes/transactions/helpers/transaction-data-finder.helper.spec.ts b/src/routes/transactions/helpers/transaction-data-finder.helper.spec.ts index 4b23f7090b..989a2bfad4 100644 --- a/src/routes/transactions/helpers/transaction-data-finder.helper.spec.ts +++ b/src/routes/transactions/helpers/transaction-data-finder.helper.spec.ts @@ -28,13 +28,17 @@ describe('TransactionFinder', () => { functionName: 'transfer', args: [getAddress(faker.finance.ethereumAddress()), BigInt(0)], }), + value: faker.string.numeric(), }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const isTransactionData = (_: unknown): boolean => true; const result = target.findTransaction(isTransactionData, transaction); - expect(result).toStrictEqual({ data: transaction.data }); + expect(result).toStrictEqual({ + data: transaction.data, + value: transaction.value, + }); }); it('should return the transaction data if it is found in a MultiSend transaction', () => { @@ -46,7 +50,7 @@ describe('TransactionFinder', () => { }), to: getAddress(faker.finance.ethereumAddress()), operation: 0, - value: BigInt(0), + value: faker.number.bigInt(), }; const multiSend = multiSendEncoder().with( 'transactions', @@ -58,11 +62,13 @@ describe('TransactionFinder', () => { const result = target.findTransaction(isTransactionData, { data: multiSend.encode(), + value: faker.string.numeric(), }); expect(result).toStrictEqual({ to: transaction.to, data: transaction.data, + value: transaction.value.toString(), }); }); @@ -73,6 +79,7 @@ describe('TransactionFinder', () => { functionName: 'transfer', args: [getAddress(faker.finance.ethereumAddress()), BigInt(0)], }), + value: faker.string.numeric(), }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const isTransactionData = (_: unknown): boolean => false; diff --git a/src/routes/transactions/helpers/transaction-finder.helper.ts b/src/routes/transactions/helpers/transaction-finder.helper.ts index ae545e1c9e..1ae9931ffc 100644 --- a/src/routes/transactions/helpers/transaction-finder.helper.ts +++ b/src/routes/transactions/helpers/transaction-finder.helper.ts @@ -17,8 +17,8 @@ export class TransactionFinder { to?: `0x${string}`; data: `0x${string}`; }) => boolean, - transaction: { to?: `0x${string}`; data: `0x${string}` }, - ): { to?: `0x${string}`; data: `0x${string}` } | null { + transaction: { to?: `0x${string}`; data: `0x${string}`; value: string }, + ): { to?: `0x${string}`; data: `0x${string}`; value: string } | null { if (isTransactionData(transaction)) { return transaction; } @@ -32,6 +32,7 @@ export class TransactionFinder { return { to: batchedTransaction.to, data: batchedTransaction.data, + value: batchedTransaction.value.toString(), }; } } diff --git a/src/routes/transactions/helpers/twap-order.helper.ts b/src/routes/transactions/helpers/twap-order.helper.ts index e8a5e04180..0691f93349 100644 --- a/src/routes/transactions/helpers/twap-order.helper.ts +++ b/src/routes/transactions/helpers/twap-order.helper.ts @@ -49,13 +49,21 @@ export class TwapOrderHelper { data: `0x${string}`; }): `0x${string}` | null { return ( - this.transactionFinder.findTransaction(({ to, data }) => { - return ( - !!to && - isAddressEqual(to, TwapOrderHelper.ComposableCowAddress) && - this.composableCowDecoder.helpers.isCreateWithContext(data) - ); - }, args)?.data ?? null + this.transactionFinder.findTransaction( + ({ to, data }) => { + return ( + !!to && + isAddressEqual(to, TwapOrderHelper.ComposableCowAddress) && + this.composableCowDecoder.helpers.isCreateWithContext(data) + ); + }, + { + to: args.to, + data: args.data, + // Placeholder as we are only interested in the data + value: '0', + }, + )?.data ?? null ); } diff --git a/src/routes/transactions/mappers/common/native-staking.mapper.spec.ts b/src/routes/transactions/mappers/common/native-staking.mapper.spec.ts index 917335cf28..36e835de9b 100644 --- a/src/routes/transactions/mappers/common/native-staking.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/native-staking.mapper.spec.ts @@ -14,13 +14,11 @@ import { StakeState } from '@/datasources/staking-api/entities/stake.entity'; import type { ChainsRepository } from '@/domain/chains/chains.repository'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; -import { - dataDecodedBuilder, - dataDecodedParameterBuilder, -} from '@/domain/data-decoder/entities/__tests__/data-decoded.builder'; import { multisigTransactionBuilder } from '@/domain/safe/entities/__tests__/multisig-transaction.builder'; import { + batchWithdrawCLFeeEncoder, depositEventEventBuilder, + requestValidatorsExitEncoder, withdrawalEventBuilder, } from '@/domain/staking/contracts/decoders/__tests__/encoders/kiln-encoder.builder'; import { KilnDecoder } from '@/domain/staking/contracts/decoders/kiln-decoder.helper'; @@ -80,7 +78,6 @@ describe('NativeStakingMapper', () => { const transactionFinder = new TransactionFinder(multiSendDecoder); const kilnNativeStakingHelper = new KilnNativeStakingHelper( transactionFinder, - mockStakingRepository, ); const kilnDecoder = new KilnDecoder(mockLoggingService); target = new NativeStakingMapper( @@ -125,7 +122,7 @@ describe('NativeStakingMapper', () => { chainId: chain.chainId, to: deployment.address, value: '64000000000000000000', - transaction: null, + txHash: null, }); expect(actual).toEqual( @@ -180,16 +177,12 @@ describe('NativeStakingMapper', () => { dedicatedStakingStats, ); mockStakingRepository.getStakes.mockResolvedValue([]); - const transaction = multisigTransactionBuilder() - .with('executionDate', null) - .with('transactionHash', null) - .build(); const actual = await target.mapDepositInfo({ chainId: chain.chainId, to: deployment.address, value: '64000000000000000000', - transaction, + txHash: null, }); expect(actual).toEqual( @@ -243,7 +236,6 @@ describe('NativeStakingMapper', () => { ]; const pubkey = faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }); const depositEventEvent = depositEventEventBuilder() @@ -262,7 +254,7 @@ describe('NativeStakingMapper', () => { .build(), ) .build(); - const transaction = multisigTransactionBuilder().build(); + const txHash = faker.string.hexadecimal() as `0x${string}`; mockChainsRepository.getChain.mockResolvedValue(chain); mockStakingRepository.getDeployment.mockResolvedValue(deployment); mockStakingRepository.getNetworkStats.mockResolvedValue(networkStats); @@ -278,7 +270,7 @@ describe('NativeStakingMapper', () => { chainId: chain.chainId, to: deployment.address, value: '64000000000000000000', - transaction, + txHash, }); expect(actual).toEqual( @@ -330,7 +322,7 @@ describe('NativeStakingMapper', () => { chainId: chain.chainId, to: deployment.address, value: '64000000000000000000', - transaction: null, + txHash: null, }), ).rejects.toThrow('Native staking deployment not found'); }); @@ -356,7 +348,7 @@ describe('NativeStakingMapper', () => { chainId: chain.chainId, to: deployment.address, value: '64000000000000000000', - transaction: null, + txHash: null, }), ).rejects.toThrow('Native staking deployment not found'); }); @@ -382,7 +374,7 @@ describe('NativeStakingMapper', () => { chainId: chain.chainId, to: deployment.address, value: '64000000000000000000', - transaction: null, + txHash: null, }), ).rejects.toThrow('Native staking deployment not found'); }); @@ -399,7 +391,6 @@ describe('NativeStakingMapper', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -411,20 +402,10 @@ describe('NativeStakingMapper', () => { casing: 'lower', }), ] as Array<`0x${string}`>; - const validatorPublicKey = concat(validators); - const dataDecoded = dataDecodedBuilder() - .with('method', 'requestValidatorsExit') - .with('parameters', [ - dataDecodedParameterBuilder() - .with('name', '_publicKeys') - .with('type', 'bytes') - .with('value', validatorPublicKey) - .build(), - ]) - .build(); - const transaction = multisigTransactionBuilder() - .with('dataDecoded', dataDecoded) - .build(); + const transaction = multisigTransactionBuilder().build(); + const data = requestValidatorsExitEncoder() + .with('_publicKeys', concat(validators)) + .encode(); mockChainsRepository.getChain.mockResolvedValue(chain); mockStakingRepository.getDeployment.mockResolvedValue(deployment); mockStakingRepository.getNetworkStats.mockResolvedValue(networkStats); @@ -434,8 +415,7 @@ describe('NativeStakingMapper', () => { chainId: chain.chainId, safeAddress: transaction.safe, to: deployment.address, - transaction, - dataDecoded, + data, }); expect(actual).toEqual( @@ -466,10 +446,8 @@ describe('NativeStakingMapper', () => { .with('product_type', 'defi') .build(); const networkStats = networkStatsBuilder().build(); - const dataDecoded = dataDecodedBuilder().build(); - const transaction = multisigTransactionBuilder() - .with('dataDecoded', dataDecoded) - .build(); + const data = requestValidatorsExitEncoder().encode(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); mockChainsRepository.getChain.mockResolvedValue(chain); mockStakingRepository.getDeployment.mockResolvedValue(deployment); mockStakingRepository.getNetworkStats.mockResolvedValue(networkStats); @@ -477,10 +455,9 @@ describe('NativeStakingMapper', () => { await expect( target.mapValidatorsExitInfo({ chainId: chain.chainId, - safeAddress: transaction.safe, + safeAddress, to: deployment.address, - transaction, - dataDecoded, + data, }), ).rejects.toThrow('Native staking deployment not found'); }); @@ -492,10 +469,8 @@ describe('NativeStakingMapper', () => { .with('chain', 'unknown') .build(); const networkStats = networkStatsBuilder().build(); - const dataDecoded = dataDecodedBuilder().build(); - const transaction = multisigTransactionBuilder() - .with('dataDecoded', dataDecoded) - .build(); + const data = requestValidatorsExitEncoder().encode(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); mockChainsRepository.getChain.mockResolvedValue(chain); mockStakingRepository.getDeployment.mockResolvedValue(deployment); mockStakingRepository.getNetworkStats.mockResolvedValue(networkStats); @@ -503,10 +478,9 @@ describe('NativeStakingMapper', () => { await expect( target.mapValidatorsExitInfo({ chainId: chain.chainId, - safeAddress: transaction.safe, + safeAddress, to: deployment.address, - transaction, - dataDecoded, + data, }), ).rejects.toThrow('Native staking deployment not found'); }); @@ -518,10 +492,8 @@ describe('NativeStakingMapper', () => { .with('status', 'unknown') .build(); const networkStats = networkStatsBuilder().build(); - const dataDecoded = dataDecodedBuilder().build(); - const transaction = multisigTransactionBuilder() - .with('dataDecoded', dataDecoded) - .build(); + const data = requestValidatorsExitEncoder().encode(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); mockChainsRepository.getChain.mockResolvedValue(chain); mockStakingRepository.getDeployment.mockResolvedValue(deployment); mockStakingRepository.getNetworkStats.mockResolvedValue(networkStats); @@ -529,10 +501,9 @@ describe('NativeStakingMapper', () => { await expect( target.mapValidatorsExitInfo({ chainId: chain.chainId, - safeAddress: transaction.safe, + safeAddress, to: deployment.address, - transaction, - dataDecoded, + data, }), ).rejects.toThrow('Native staking deployment not found'); }); @@ -548,7 +519,6 @@ describe('NativeStakingMapper', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -556,17 +526,9 @@ describe('NativeStakingMapper', () => { casing: 'lower', }), ] as Array<`0x${string}`>; - const validatorPublicKey = concat(validators); - const dataDecoded = dataDecodedBuilder() - .with('method', 'requestValidatorsExit') - .with('parameters', [ - dataDecodedParameterBuilder() - .with('name', '_publicKeys') - .with('type', 'bytes') - .with('value', validatorPublicKey) - .build(), - ]) - .build(); + const data = batchWithdrawCLFeeEncoder() + .with('_publicKeys', concat(validators)) + .encode(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const stakes = [ stakeBuilder() @@ -591,8 +553,8 @@ describe('NativeStakingMapper', () => { chainId: chain.chainId, safeAddress: safeAddress, to: deployment.address, - transaction: null, - dataDecoded, + data, + txHash: null, }); expect(actual).toEqual( @@ -631,7 +593,6 @@ describe('NativeStakingMapper', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -639,17 +600,6 @@ describe('NativeStakingMapper', () => { casing: 'lower', }), ] as Array<`0x${string}`>; - const validatorPublicKey = concat(validators); - const dataDecoded = dataDecodedBuilder() - .with('method', 'requestValidatorsExit') - .with('parameters', [ - dataDecodedParameterBuilder() - .with('name', '_publicKeys') - .with('type', 'bytes') - .with('value', validatorPublicKey) - .build(), - ]) - .build(); const withdrawalEvent = withdrawalEventBuilder(); const withdrawalEventParams = withdrawalEvent.build(); const withdrawalEventEncoded = withdrawalEvent.encode(); @@ -666,9 +616,6 @@ describe('NativeStakingMapper', () => { .build(), ) .build(); - const transaction = multisigTransactionBuilder() - .with('dataDecoded', dataDecoded) - .build(); const stakes = [ stakeBuilder() .with('net_claimable_consensus_rewards', '3.25') @@ -683,6 +630,11 @@ describe('NativeStakingMapper', () => { .with('state', StakeState.WithdrawalDone) .build(), ]; + const data = batchWithdrawCLFeeEncoder() + .with('_publicKeys', concat(validators)) + .encode(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const txHash = faker.string.hexadecimal() as `0x${string}`; mockChainsRepository.getChain.mockResolvedValue(chain); mockStakingRepository.getDeployment.mockResolvedValue(deployment); mockStakingRepository.getNetworkStats.mockResolvedValue(networkStats); @@ -693,10 +645,10 @@ describe('NativeStakingMapper', () => { const actual = await target.mapWithdrawInfo({ chainId: chain.chainId, - safeAddress: transaction.safe, + safeAddress, to: deployment.address, - transaction, - dataDecoded, + data, + txHash, }); expect(actual).toEqual( @@ -718,7 +670,7 @@ describe('NativeStakingMapper', () => { expect(mockStakingRepository.getStakes).not.toHaveBeenCalled(); expect(mockStakingRepository.getTransactionStatus).toHaveBeenCalledWith({ chainId: chain.chainId, - txHash: transaction.transactionHash, + txHash, }); }); @@ -729,7 +681,7 @@ describe('NativeStakingMapper', () => { .build(); const networkStats = networkStatsBuilder().build(); const transaction = multisigTransactionBuilder().build(); - const dataDecoded = dataDecodedBuilder().build(); + const data = batchWithdrawCLFeeEncoder().encode(); mockChainsRepository.getChain.mockResolvedValue(chain); mockStakingRepository.getDeployment.mockResolvedValue(deployment); mockStakingRepository.getNetworkStats.mockResolvedValue(networkStats); @@ -739,8 +691,8 @@ describe('NativeStakingMapper', () => { chainId: chain.chainId, safeAddress: transaction.safe, to: deployment.address, - transaction, - dataDecoded, + data, + txHash: transaction.transactionHash, }), ).rejects.toThrow('Native staking deployment not found'); }); @@ -752,8 +704,8 @@ describe('NativeStakingMapper', () => { .with('chain', 'unknown') .build(); const networkStats = networkStatsBuilder().build(); + const data = batchWithdrawCLFeeEncoder().encode(); const transaction = multisigTransactionBuilder().build(); - const dataDecoded = dataDecodedBuilder().build(); mockChainsRepository.getChain.mockResolvedValue(chain); mockStakingRepository.getDeployment.mockResolvedValue(deployment); mockStakingRepository.getNetworkStats.mockResolvedValue(networkStats); @@ -763,8 +715,8 @@ describe('NativeStakingMapper', () => { chainId: chain.chainId, safeAddress: transaction.safe, to: deployment.address, - transaction, - dataDecoded, + data, + txHash: transaction.transactionHash, }), ).rejects.toThrow('Native staking deployment not found'); }); @@ -777,7 +729,7 @@ describe('NativeStakingMapper', () => { .build(); const networkStats = networkStatsBuilder().build(); const transaction = multisigTransactionBuilder().build(); - const dataDecoded = dataDecodedBuilder().build(); + const data = batchWithdrawCLFeeEncoder().encode(); mockChainsRepository.getChain.mockResolvedValue(chain); mockStakingRepository.getDeployment.mockResolvedValue(deployment); mockStakingRepository.getNetworkStats.mockResolvedValue(networkStats); @@ -787,8 +739,8 @@ describe('NativeStakingMapper', () => { chainId: chain.chainId, safeAddress: transaction.safe, to: deployment.address, - transaction, - dataDecoded, + data, + txHash: transaction.transactionHash, }), ).rejects.toThrow('Native staking deployment not found'); }); diff --git a/src/routes/transactions/mappers/common/native-staking.mapper.ts b/src/routes/transactions/mappers/common/native-staking.mapper.ts index e38da4c8b7..a51f1eba51 100644 --- a/src/routes/transactions/mappers/common/native-staking.mapper.ts +++ b/src/routes/transactions/mappers/common/native-staking.mapper.ts @@ -5,9 +5,6 @@ import { IChainsRepository, } from '@/domain/chains/chains.repository.interface'; import { getNumberString } from '@/domain/common/utils/utils'; -import { DataDecoded } from '@/domain/data-decoder/entities/data-decoded.entity'; -import { ModuleTransaction } from '@/domain/safe/entities/module-transaction.entity'; -import { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; import { KilnDecoder } from '@/domain/staking/contracts/decoders/kiln-decoder.helper'; import { IStakingRepository } from '@/domain/staking/staking.repository.interface'; import { StakingRepositoryModule } from '@/domain/staking/staking.repository.module'; @@ -45,8 +42,7 @@ export class NativeStakingMapper { * @param args.chainId - the chain ID of the native staking deployment * @param args.to - the address of the native staking deployment * @param args.value - the value of the deposit transaction - * @param args.isConfirmed - whether the deposit transaction is confirmed - * @param args.depositExecutionDate - the date when the deposit transaction was executed + * @param args.txHash - the transaction hash of the deposit transaction * * @returns {@link NativeStakingDepositTransactionInfo} for the given native staking deployment */ @@ -54,7 +50,7 @@ export class NativeStakingMapper { chainId: string; to: `0x${string}`; value: string | null; - transaction: MultisigTransaction | ModuleTransaction | null; + txHash: `0x${string}` | null; }): Promise { const [chain, deployment] = await Promise.all([ this.chainsRepository.getChain(args.chainId), @@ -117,29 +113,29 @@ export class NativeStakingMapper { symbol: chain.nativeCurrency.symbol, trusted: true, }), - validators: args.transaction?.executionDate ? publicKeys : null, + validators: publicKeys, }); } /** * Gets the validator public keys from logs of `deposit` transaction. * - * @param args.transaction - the transaction object for the deposit + * @param args.txHash - the transaction hash of the deposit transaction * @param args.chainId - the chain ID of the native staking deployment * - * @returns {Array<`0x${string}`>} the validator public keys + * @returns {Array<`0x${string}`> | null} the public keys of the validators */ private async getDepositPublicKeys(args: { - transaction: MultisigTransaction | ModuleTransaction | null; + txHash: `0x${string}` | null; chainId: string; - }): Promise> { - if (!args.transaction?.transactionHash) { - return []; + }): Promise | null> { + if (!args.txHash) { + return null; } const txStatus = await this.stakingRepository.getTransactionStatus({ chainId: args.chainId, - txHash: args.transaction.transactionHash, + txHash: args.txHash, }); const depositEvents = txStatus.receipt.logs @@ -167,16 +163,14 @@ export class NativeStakingMapper { * @param args.chainId - the chain ID of the native staking deployment * @param args.safeAddress - the Safe staking * @param args.to - the address of the native staking deployment - * @param args.transaction - the transaction object for the validators exit - * @param args.dataDecoded - the decoded data of the transaction + * @param args.data - the data of the `requestValidatorsExit` transaction * @returns {@link NativeStakingValidatorsExitTransactionInfo} for the given native staking deployment */ public async mapValidatorsExitInfo(args: { chainId: string; safeAddress: `0x${string}`; to: `0x${string}`; - transaction: MultisigTransaction | ModuleTransaction | null; - dataDecoded: DataDecoded; + data: `0x${string}`; }): Promise { const [chain, deployment] = await Promise.all([ this.chainsRepository.getChain(args.chainId), @@ -186,10 +180,9 @@ export class NativeStakingMapper { }), ]); this.validateDeployment(deployment); - const publicKeys = - this.kilnNativeStakingHelper.getPublicKeysFromDataDecoded( - args.dataDecoded, - ); + const publicKeys = this.kilnNativeStakingHelper.splitPublicKeys( + this.kilnDecoder.decodeValidatorsExit(args.data)!.parameters[0].value, + ); const value = publicKeys.length * @@ -231,15 +224,16 @@ export class NativeStakingMapper { * @param args.safeAddress - the Safe staking * @param args.to - the address of the native staking deployment * @param args.transaction - the transaction object for the withdraw - * @param args.dataDecoded - the decoded data of the transaction + * @param args.txHash - the transaction hash of the withdraw transaction + * @param args.data - the data of the `batchWithdrawCLFee` transaction * @returns {@link NativeStakingWithdrawTransactionInfo} for the given native staking deployment */ public async mapWithdrawInfo(args: { chainId: string; safeAddress: `0x${string}`; to: `0x${string}`; - transaction: MultisigTransaction | ModuleTransaction | null; - dataDecoded: DataDecoded; + txHash: `0x${string}` | null; + data: `0x${string}`; }): Promise { const [chain, deployment] = await Promise.all([ this.chainsRepository.getChain(args.chainId), @@ -249,16 +243,16 @@ export class NativeStakingMapper { }), ]); this.validateDeployment(deployment); + + const publicKeys = this.kilnNativeStakingHelper.splitPublicKeys( + this.kilnDecoder.decodeBatchWithdrawCLFee(args.data)!.parameters[0].value, + ); const value = await this.getWithdrawValue({ - transaction: args.transaction, - dataDecoded: args.dataDecoded, + txHash: args.txHash, + publicKeys: publicKeys, chainId: args.chainId, safeAddress: args.safeAddress, }); - const publicKeys = - this.kilnNativeStakingHelper.getPublicKeysFromDataDecoded( - args.dataDecoded, - ); return new NativeStakingWithdrawTransactionInfo({ value: getNumberString(value), @@ -283,29 +277,34 @@ export class NativeStakingMapper { * and after execution it returns 0. Therefore, if the transaction was executed * we return the value get the exact value from the transaction logs instead. * - * @param {MultisigTransaction | ModuleTransaction | null} args.transaction - the `batchWithdrawCLFee` transaction - * @param {DataDecoded} args.dataDecoded - the decoded data of the transaction + * @param {string | nulle} args.txHash - the transaction hash of the withdraw transaction + * @param {Array<`0x${string}`>} args.publicKeys - the public keys to get the value for * @param {string} args.chainId - the chain ID of the native staking deployment + * @param {string} args.safeAddress - the Safe staking * * @returns {number} the value to withdraw or withdrawn */ private async getWithdrawValue(args: { - transaction: MultisigTransaction | ModuleTransaction | null; - dataDecoded: DataDecoded; + txHash: `0x${string}` | null; + publicKeys: Array<`0x${string}`>; chainId: string; safeAddress: `0x${string}`; }): Promise { - if (!args.transaction?.transactionHash) { - return this.kilnNativeStakingHelper.getValueFromDataDecoded({ + if (!args.txHash) { + const stakes = await this.stakingRepository.getStakes({ chainId: args.chainId, safeAddress: args.safeAddress, - dataDecoded: args.dataDecoded, + validatorsPublicKeys: args.publicKeys, }); + return stakes.reduce((acc, stake) => { + const netValue = stake.net_claimable_consensus_rewards ?? '0'; + return acc + Number(netValue); + }, 0); } const txStatus = await this.stakingRepository.getTransactionStatus({ chainId: args.chainId, - txHash: args.transaction.transactionHash, + txHash: args.txHash, }); const value = txStatus.receipt.logs @@ -341,7 +340,7 @@ export class NativeStakingMapper { * * @param {string} args.chainId - the chain ID of the native staking deployment * @param {string} args.safeAddress - the Safe staking - * @param {Array} args.publicKeys - the public keys to get the status for + * @param {Array | null} args.publicKeys - the public keys to get the status for * * @returns {Promise} the status of the given {@link publicKeys} * @@ -350,9 +349,9 @@ export class NativeStakingMapper { public async _getStatus(args: { chainId: string; safeAddress: `0x${string}`; - publicKeys: Array<`0x${string}`>; + publicKeys: Array<`0x${string}`> | null; }): Promise { - if (args.publicKeys.length === 0) { + if (!args.publicKeys || args.publicKeys.length === 0) { return StakingStatus.NotStaked; } diff --git a/src/routes/transactions/mappers/common/transaction-info.mapper.ts b/src/routes/transactions/mappers/common/transaction-info.mapper.ts index af955409d6..e0024ec87f 100644 --- a/src/routes/transactions/mappers/common/transaction-info.mapper.ts +++ b/src/routes/transactions/mappers/common/transaction-info.mapper.ts @@ -326,18 +326,18 @@ export class MultisigTransactionInfoMapper { chainId: string, transaction: MultisigTransaction | ModuleTransaction, ): Promise { - if (!transaction?.data) { + if (!transaction?.data || !transaction.value) { return null; } const nativeStakingDepositTransaction = - await this.kilnNativeStakingHelper.findDepositTransaction({ - chainId, + this.kilnNativeStakingHelper.findDepositTransaction({ to: transaction.to, data: transaction.data, + value: transaction.value, }); - if (!nativeStakingDepositTransaction) { + if (!nativeStakingDepositTransaction?.to) { return null; } @@ -345,8 +345,8 @@ export class MultisigTransactionInfoMapper { return await this.nativeStakingMapper.mapDepositInfo({ chainId, to: nativeStakingDepositTransaction.to, - value: transaction.value, - transaction, + value: nativeStakingDepositTransaction.value, + txHash: transaction.transactionHash, }); } catch (error) { this.loggingService.warn(error); @@ -358,26 +358,18 @@ export class MultisigTransactionInfoMapper { chainId: string, transaction: MultisigTransaction | ModuleTransaction, ): Promise { - if (!transaction?.data) { - return null; - } - - const dataDecoded = transaction?.dataDecoded - ? transaction.dataDecoded - : this.kilnDecoder.decodeValidatorsExit(transaction.data); - - if (!dataDecoded) { + if (!transaction?.data || !transaction.value) { return null; } const nativeStakingValidatorsExitTransaction = - await this.kilnNativeStakingHelper.findValidatorsExitTransaction({ - chainId, + this.kilnNativeStakingHelper.findValidatorsExitTransaction({ to: transaction.to, data: transaction.data, + value: transaction.value, }); - if (!nativeStakingValidatorsExitTransaction) { + if (!nativeStakingValidatorsExitTransaction?.to) { return null; } @@ -386,8 +378,7 @@ export class MultisigTransactionInfoMapper { chainId, safeAddress: transaction.safe, to: nativeStakingValidatorsExitTransaction.to, - transaction, - dataDecoded, + data: nativeStakingValidatorsExitTransaction.data, }); } catch (error) { this.loggingService.warn(error); @@ -399,26 +390,18 @@ export class MultisigTransactionInfoMapper { chainId: string, transaction: MultisigTransaction | ModuleTransaction, ): Promise { - if (!transaction?.data) { - return null; - } - - const dataDecoded = transaction?.dataDecoded - ? transaction.dataDecoded - : this.kilnDecoder.decodeBatchWithdrawCLFee(transaction.data); - - if (!dataDecoded) { + if (!transaction?.data || !transaction.value) { return null; } const nativeStakingWithdrawTransaction = - await this.kilnNativeStakingHelper.findWithdrawTransaction({ - chainId, + this.kilnNativeStakingHelper.findWithdrawTransaction({ to: transaction.to, data: transaction.data, + value: transaction.value, }); - if (!nativeStakingWithdrawTransaction) { + if (!nativeStakingWithdrawTransaction?.to) { return null; } @@ -427,8 +410,8 @@ export class MultisigTransactionInfoMapper { chainId, safeAddress: transaction.safe, to: nativeStakingWithdrawTransaction.to, - transaction, - dataDecoded, + txHash: transaction.transactionHash, + data: nativeStakingWithdrawTransaction.data, }); } catch (error) { this.loggingService.warn(error); diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index 2c275bc29e..b376dabfb2 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -1353,7 +1353,6 @@ describe('TransactionsViewController tests', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -1467,6 +1466,7 @@ describe('TransactionsViewController tests', () => { const validatorPublicKey = faker.string .hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', }) .toLowerCase(); const data = encodeFunctionData({ @@ -1848,7 +1848,6 @@ describe('TransactionsViewController tests', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -1940,7 +1939,6 @@ describe('TransactionsViewController tests', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -2036,7 +2034,6 @@ describe('TransactionsViewController tests', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -2336,7 +2333,6 @@ describe('TransactionsViewController tests', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index 7bf58be414..5b1a737212 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -63,6 +63,7 @@ export class TransactionsViewService { to: args.transactionDataDto.to, }) .catch(() => { + // TODO: Remove after Kiln has verified contracts // Fallback for unverified contracts return { method: '', @@ -83,24 +84,30 @@ export class TransactionsViewService { const nativeStakingDepositTransaction = this.isNativeStakingEnabled && - (await this.kilnNativeStakingHelper.findDepositTransaction({ - chainId: args.chainId, - ...args.transactionDataDto, - })); + this.kilnNativeStakingHelper.findDepositTransaction({ + to: args.transactionDataDto.to, + data: args.transactionDataDto.data, + // Value is always defined + value: args.transactionDataDto.value ?? '0', + }); const nativeStakingValidatorsExitTransaction = this.isNativeStakingEnabled && - (await this.kilnNativeStakingHelper.findValidatorsExitTransaction({ - chainId: args.chainId, - ...args.transactionDataDto, - })); + this.kilnNativeStakingHelper.findValidatorsExitTransaction({ + to: args.transactionDataDto.to, + data: args.transactionDataDto.data, + // Value is always defined + value: args.transactionDataDto.value ?? '0', + }); const nativeStakingWithdrawTransaction = this.isNativeStakingEnabled && - (await this.kilnNativeStakingHelper.findWithdrawTransaction({ - chainId: args.chainId, - ...args.transactionDataDto, - })); + this.kilnNativeStakingHelper.findWithdrawTransaction({ + to: args.transactionDataDto.to, + data: args.transactionDataDto.data, + // Value is always defined + value: args.transactionDataDto.value ?? '0', + }); if ( !swapOrderData && @@ -129,23 +136,35 @@ export class TransactionsViewService { data: twapSwapOrderData, dataDecoded, }); - } else if (nativeStakingDepositTransaction) { + } else if ( + nativeStakingDepositTransaction && + nativeStakingDepositTransaction.to + ) { return await this.getNativeStakingDepositConfirmationView({ - ...nativeStakingDepositTransaction, + to: nativeStakingDepositTransaction.to, + data: nativeStakingDepositTransaction.data, chainId: args.chainId, dataDecoded, - value: args.transactionDataDto.value ?? null, + value: nativeStakingDepositTransaction.value, }); - } else if (nativeStakingValidatorsExitTransaction) { + } else if ( + nativeStakingValidatorsExitTransaction && + nativeStakingValidatorsExitTransaction.to + ) { return await this.getNativeStakingValidatorsExitConfirmationView({ - ...nativeStakingValidatorsExitTransaction, + to: nativeStakingValidatorsExitTransaction.to, + data: nativeStakingValidatorsExitTransaction.data, chainId: args.chainId, safeAddress: args.safeAddress, dataDecoded, }); - } else if (nativeStakingWithdrawTransaction) { + } else if ( + nativeStakingWithdrawTransaction && + nativeStakingWithdrawTransaction.to + ) { return await this.getNativeStakingWithdrawConfirmationView({ - ...nativeStakingWithdrawTransaction, + to: nativeStakingWithdrawTransaction.to, + data: nativeStakingWithdrawTransaction.data, safeAddress: args.safeAddress, chainId: args.chainId, dataDecoded, @@ -328,7 +347,7 @@ export class TransactionsViewService { chainId: args.chainId, to: args.to, value: args.value, - transaction: null, + txHash: null, }); return new NativeStakingDepositConfirmationView({ method: dataDecoded.method, @@ -356,8 +375,7 @@ export class TransactionsViewService { chainId: args.chainId, safeAddress: args.safeAddress, to: args.to, - transaction: null, - dataDecoded, + data: args.data, }); return new NativeStakingValidatorsExitConfirmationView({ @@ -385,8 +403,8 @@ export class TransactionsViewService { chainId: args.chainId, safeAddress: args.safeAddress, to: args.to, - transaction: null, - dataDecoded, + data: args.data, + txHash: null, }); return new NativeStakingWithdrawConfirmationView({ method: dataDecoded.method,