From 95fa5e80c002fea847d0d8f7965c880bff6bc88d Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 27 Sep 2024 17:00:44 +0200 Subject: [PATCH 1/6] Add swap and staking tests to preview endpoint --- ...ransaction.transactions.controller.spec.ts | 871 +++++++++++++++++- .../entities/transaction-preview.entity.ts | 15 + 2 files changed, 880 insertions(+), 6 deletions(-) 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 06b30ddd0a..18f92459f6 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 @@ -25,21 +25,48 @@ 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 { concat, encodeFunctionData, getAddress, parseAbi } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { 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 { 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 { + Stake, + 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'; describe('Preview transaction - Transactions Controller (Unit)', () => { let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; + let swapsChainId: string; + let swapsApiUrl: string; + let swapsExplorerUrl: string; + 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(configuration)], + imports: [AppModule.register(testConfiguration)], }) .overrideModule(CacheModule) .useModule(TestCacheModule) @@ -55,6 +82,12 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { 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`); + stakingApiUrl = configurationService.getOrThrow('staking.mainnet.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); @@ -83,7 +116,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(); @@ -151,7 +184,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(); @@ -218,7 +251,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(); @@ -284,7 +317,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(); @@ -364,4 +397,830 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }, }); }); + + describe('CoW Swap', () => { + 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": "${faker.company.buzzNoun()}" }`) + .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.todo('should preview a transaction from a batch'); + + it.todo('should return executedSurplusFee as null if not available'); + + it.todo( + 'should return a "standard" transaction preview if order data is not available', + ); + + it.todo( + 'should return a "standard" transaction preview if buy token is not available', + ); + + it.todo( + 'should return a "standard" transaction preview if sell token is not available', + ); + + it.todo( + 'should return a "standard" transaction preview if the swap app is restricted', + ); + }); + + describe('TWAPs', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should preview a transaction', async () => { + const now = new Date(); + jest.setSystemTime(now); + + const ComposableCowAddress = + '0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74'; + /** + * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 + */ + const chain = chainBuilder().with('chainId', swapsChainId).build(); + const safe = safeBuilder() + .with('address', '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381') + .build(); + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + const appDataHash = + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0'; + const appData = { appCode: faker.company.buzzNoun() }; + const fullAppData = { + fullAppData: JSON.stringify(appData), + }; + const dataDecoded = dataDecodedBuilder().build(); + const buyToken = tokenBuilder() + .with( + 'address', + getAddress('0xfff9976782d46cc05630d1f6ebab18b2324d6b14'), + ) + .build(); + const sellToken = tokenBuilder() + .with( + 'address', + getAddress('0xbe72e441bf55620febc26715db68d3494213d8cb'), + ) + .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.todo('should preview a transaction from a batch'); + + it.todo( + 'should return a "standard" transaction preview if order data is not available', + ); + + it.todo( + 'should return a "standard" transaction preview if buy token is not available', + ); + + it.todo( + 'should return a "standard" transaction preview if sell token is not available', + ); + + it.todo( + 'should return a "standard" transaction preview if the swap app is restricted', + ); + }); + }); + + describe('Kiln', () => { + 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 = encodeFunctionData({ + abi: parseAbi(['function deposit() external payable']), + }); + 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.todo('should preview a transaction with local data decoding'); + + it.todo('should preview a transaction from a batch'); + + it.todo( + 'should preview a transaction from a batch with local data decoding', + ); + + it.todo( + 'should return a "standard" transaction preview if the deployment is unavailable', + ); + + it.todo( + 'should return a "standard" transaction preview if the deployment is not dedicated-specific', + ); + + it.todo( + 'should return a "standard" transaction preview if the deployment is on an unknown chain', + ); + + it.todo( + 'should return a "standard" transaction preview if not transacting with a deployment address', + ); + + it.todo( + 'should return a "standard" transaction preview if the deployment has no product fee', + ); + + it.todo( + 'should return a "standard" transaction preview if the dedicated staking stats are not available', + ); + + it.todo( + 'should return a "standard" transaction preview if the network stats are not available', + ); + }); + + 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, + // Transaction Service returns _publicKeys lowercase + casing: 'lower', + }), + faker.string.hexadecimal({ + length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', + }), + ] as Array<`0x${string}`>; + const validatorPublicKey = concat(validators); + const data = encodeFunctionData({ + abi: parseAbi(['function requestValidatorsExit(bytes)']), + functionName: 'requestValidatorsExit', + args: [validatorPublicKey], + }); + const dataDecoded = dataDecodedBuilder() + .with('method', 'requestValidatorsExit') + .with('parameters', [ + { + name: '_publicKeys', + type: 'bytes', + value: validatorPublicKey, + valueDecoded: null, + }, + ]) + .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.todo('should preview a transaction with local data decoding'); + + it.todo('should preview a transaction from a batch'); + + it.todo( + 'should preview a transaction from a batch with local data decoding', + ); + + it.todo( + 'should return a "standard" transaction preview if the deployment is unavailable', + ); + + it.todo( + 'should return a "standard" transaction preview if the deployment is not dedicated-specific', + ); + + it.todo( + 'should return a "standard" transaction preview if the deployment is on an unknown chain', + ); + + it.todo( + 'should return a "standard" transaction preview if not transacting with a deployment address', + ); + + it.todo( + 'should return a "standard" transaction preview if the network stats are not available', + ); + + it.todo( + 'should return a "standard" transaction preview if the stakes are not available', + ); + }); + + 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, + // 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 validatorPublicKey = concat(validators); + const data = encodeFunctionData({ + abi: parseAbi(['function batchWithdrawCLFee(bytes)']), + functionName: 'batchWithdrawCLFee', + args: [validatorPublicKey], + }); + const dataDecoded = dataDecodedBuilder() + .with('method', 'batchWithdrawCLFee') + .with('parameters', [ + { + name: '_publicKeys', + type: 'bytes', + value: validatorPublicKey, + valueDecoded: null, + }, + ]) + .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.todo('should preview a transaction with local data decoding'); + + it.todo('should preview a transaction from a batch'); + + it.todo( + 'should preview a transaction from a batch with local data decoding', + ); + + it.todo( + 'should return a "standard" transaction preview if the deployment is unavailable', + ); + + it.todo( + 'should return a "standard" transaction preview if the deployment is not dedicated-specific', + ); + + it.todo( + 'should return a "standard" transaction preview if the deployment is on an unknown chain', + ); + + it.todo( + 'should return a "standard" transaction preview if not transacting with a deployment address', + ); + + it.todo( + 'should return a "standard" transaction preview if the stakes are not available', + ); + }); + }); + }); }); 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; From 3ebbc0741d610bb43bb0eb53401b795e57fae711 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 27 Sep 2024 17:39:59 +0200 Subject: [PATCH 2/6] Add full test coverage for swaps --- .../entities/__tests__/configuration.ts | 2 +- ...ransaction.transactions.controller.spec.ts | 387 ++++++++++++++++-- 2 files changed, 352 insertions(+), 37 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 5b80a65b91..803db45306 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -210,7 +210,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/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts index 18f92459f6..8b048c7b4f 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 @@ -52,6 +52,7 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { let swapsApiUrl: string; let swapsExplorerUrl: string; let stakingApiUrl: string; + const swapsVerifiedApp = faker.company.buzzNoun(); beforeEach(async () => { jest.resetAllMocks(); @@ -64,6 +65,11 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { nativeStaking: true, nativeStakingDecoding: true, }, + swaps: { + ...baseConfig.swaps, + restrictApps: true, + allowedApps: [swapsVerifiedApp], + }, }); const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], @@ -408,7 +414,7 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { const preSignature = preSignatureEncoder.build(); const order = orderBuilder() .with('uid', preSignature.orderUid) - .with('fullAppData', `{ "appCode": "${faker.company.buzzNoun()}" }`) + .with('fullAppData', `{ "appCode": "${swapsVerifiedApp}" }`) .build(); const buyToken = tokenBuilder().with('address', order.buyToken).build(); const sellToken = tokenBuilder() @@ -521,25 +527,354 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); - it.todo('should preview a transaction from a batch'); + 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}`)); + }); - it.todo('should return executedSurplusFee as null if not available'); + 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.todo( - 'should return a "standard" transaction preview if order data is not available', - ); + 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}`)); + }); - it.todo( - 'should return a "standard" transaction preview if buy token is not available', - ); + 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.todo( - 'should return a "standard" transaction preview if sell token is not available', - ); + 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}`)); + }); - it.todo( - 'should return a "standard" transaction preview if the swap app is restricted', - ); + 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', () => { @@ -568,7 +903,7 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; const appDataHash = '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0'; - const appData = { appCode: faker.company.buzzNoun() }; + const appData = { appCode: swapsVerifiedApp }; const fullAppData = { fullAppData: JSON.stringify(appData), }; @@ -698,8 +1033,6 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); - it.todo('should preview a transaction from a batch'); - it.todo( 'should return a "standard" transaction preview if order data is not available', ); @@ -858,12 +1191,6 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { it.todo('should preview a transaction with local data decoding'); - it.todo('should preview a transaction from a batch'); - - it.todo( - 'should preview a transaction from a batch with local data decoding', - ); - it.todo( 'should return a "standard" transaction preview if the deployment is unavailable', ); @@ -1029,12 +1356,6 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { it.todo('should preview a transaction with local data decoding'); - it.todo('should preview a transaction from a batch'); - - it.todo( - 'should preview a transaction from a batch with local data decoding', - ); - it.todo( 'should return a "standard" transaction preview if the deployment is unavailable', ); @@ -1195,12 +1516,6 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { it.todo('should preview a transaction with local data decoding'); - it.todo('should preview a transaction from a batch'); - - it.todo( - 'should preview a transaction from a batch with local data decoding', - ); - it.todo( 'should return a "standard" transaction preview if the deployment is unavailable', ); From 6be590a3c46bcecd718f902a317dc3e9fd1b8138 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 27 Sep 2024 17:47:39 +0200 Subject: [PATCH 3/6] Add full test coverage for TWAPs --- ...ransaction.transactions.controller.spec.ts | 258 +++++++++++++++--- 1 file changed, 218 insertions(+), 40 deletions(-) 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 8b048c7b4f..bb17fb60e0 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 @@ -886,40 +886,41 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { 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 ComposableCowAddress = - '0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74'; - /** - * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 - */ const chain = chainBuilder().with('chainId', swapsChainId).build(); - const safe = safeBuilder() - .with('address', '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381') - .build(); - const data = - '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; - const appDataHash = - '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0'; - const appData = { appCode: swapsVerifiedApp }; - const fullAppData = { - fullAppData: JSON.stringify(appData), - }; const dataDecoded = dataDecodedBuilder().build(); - const buyToken = tokenBuilder() - .with( - 'address', - getAddress('0xfff9976782d46cc05630d1f6ebab18b2324d6b14'), - ) - .build(); - const sellToken = tokenBuilder() - .with( - 'address', - getAddress('0xbe72e441bf55620febc26715db68d3494213d8cb'), - ) - .build(); const previewTransactionDto = previewTransactionDtoBuilder() .with('to', ComposableCowAddress) .with('data', data) @@ -1033,21 +1034,198 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); - it.todo( - 'should return a "standard" transaction preview if order data is not available', - ); + 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}`)); + }); - it.todo( - 'should return a "standard" transaction preview if buy token is not available', - ); + 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.todo( - 'should return a "standard" transaction preview if sell token is not available', - ); + 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}`)); + }); - it.todo( - 'should return a "standard" transaction preview if the swap app is restricted', - ); + 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')); + }); }); }); From 74a6ecd718a745a48c66521872372d7b1b6d653f Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 30 Sep 2024 09:17:28 +0200 Subject: [PATCH 4/6] Add full test coverage for staking --- .../contracts/decoders/kiln-decoder.helper.ts | 6 + ...ransaction.transactions.controller.spec.ts | 2339 +++++++++++++++-- .../kiln-native-staking.helper.spec.ts | 196 +- .../helpers/kiln-native-staking.helper.ts | 174 +- .../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 | 48 +- .../transactions-view.controller.spec.ts | 6 +- .../transactions/transactions-view.service.ts | 63 +- 13 files changed, 2388 insertions(+), 717 deletions(-) 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.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts index bb17fb60e0..1c5ab717b9 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 @@ -25,7 +25,7 @@ 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, encodeFunctionData, getAddress, parseAbi } from 'viem'; +import { concat, getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; @@ -43,6 +43,15 @@ 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 - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -64,6 +73,8 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { ...baseConfig.features, nativeStaking: true, nativeStakingDecoding: true, + swapsDecoding: true, + twapsDecoding: true, }, swaps: { ...baseConfig.swaps, @@ -527,6 +538,146 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); + 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(); @@ -1034,6 +1185,143 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); + 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(); @@ -1245,9 +1533,7 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { // Transaction being proposed (no stakes exists) const stakes: Array = []; const safe = safeBuilder().build(); - const data = encodeFunctionData({ - abi: parseAbi(['function deposit() external payable']), - }); + const data = depositEncoder().encode(); const value = getNumberString(64 * 10 ** 18 + 1); const previewTransactionDto = previewTransactionDtoBuilder() .with('data', data) @@ -1367,86 +1653,43 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); - it.todo('should preview a transaction with local data decoding'); - - it.todo( - 'should return a "standard" transaction preview if the deployment is unavailable', - ); - - it.todo( - 'should return a "standard" transaction preview if the deployment is not dedicated-specific', - ); - - it.todo( - 'should return a "standard" transaction preview if the deployment is on an unknown chain', - ); - - it.todo( - 'should return a "standard" transaction preview if not transacting with a deployment address', - ); - - it.todo( - 'should return a "standard" transaction preview if the deployment has no product fee', - ); - - it.todo( - 'should return a "standard" transaction preview if the dedicated staking stats are not available', - ); - - it.todo( - 'should return a "standard" transaction preview if the network stats are not available', - ); - }); - - describe('requestValidatorsExit', () => { - it('should preview a transaction', async () => { + 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 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(); - const validators = [ - faker.string.hexadecimal({ - length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase - casing: 'lower', - }), - faker.string.hexadecimal({ - length: KilnDecoder.KilnPublicKeyLength, - casing: 'lower', - }), - ] as Array<`0x${string}`>; - const validatorPublicKey = concat(validators); - const data = encodeFunctionData({ - abi: parseAbi(['function requestValidatorsExit(bytes)']), - functionName: 'requestValidatorsExit', - args: [validatorPublicKey], - }); - const dataDecoded = dataDecodedBuilder() - .with('method', 'requestValidatorsExit') - .with('parameters', [ - { - name: '_publicKeys', - type: 'bytes', - value: validatorPublicKey, - valueDecoded: null, - }, - ]) + // 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 stakes = [ - stakeBuilder().with('state', StakeState.ActiveOngoing).build(), - stakeBuilder().with('state', StakeState.ActiveOngoing).build(), - ]; + const dataDecoded = dataDecodedBuilder().build(); const contractResponse = contractBuilder() - .with('address', deployment.address) + .with('address', previewTransactionDto.to) .build(); - const previewTransactionDto = previewTransactionDtoBuilder() - .with('data', data) - .with('operation', Operation.CALL) - .with('to', deployment.address) + const depositContractResponse = contractBuilder() + .with('address', depositTransaction.to) + .build(); + const depositTokenResponse = tokenBuilder() + .with('address', depositTransaction.to) .build(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -1457,6 +1700,11 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { 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 }, @@ -1477,6 +1725,16 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { 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}`)); } @@ -1488,6 +1746,17 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { 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`, @@ -1496,16 +1765,25 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { .expect(200) .expect({ txInfo: { - type: 'NativeStakingValidatorsExit', + type: 'NativeStakingDeposit', humanDescription: null, richDecodedInfo: null, - status: 'ACTIVE', + 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, - value: '64000000000000000000', // 2 x 32 ETH, + 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, @@ -1514,7 +1792,7 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { symbol: chain.nativeCurrency.symbol, trusted: true, }, - validators, + validators: null, }, txData: { hexData: previewTransactionDto.data, @@ -1532,89 +1810,88 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); - it.todo('should preview a transaction with local data decoding'); - - it.todo( - 'should return a "standard" transaction preview if the deployment is unavailable', - ); - - it.todo( - 'should return a "standard" transaction preview if the deployment is not dedicated-specific', - ); - - it.todo( - 'should return a "standard" transaction preview if the deployment is on an unknown chain', - ); - - it.todo( - 'should return a "standard" transaction preview if not transacting with a deployment address', - ); - - it.todo( - 'should return a "standard" transaction preview if the network stats are not available', - ); + 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}`)); + }); - it.todo( - 'should return a "standard" transaction preview if the stakes are not available', - ); - }); + 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 () => { + 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', 'dedicated') + .with('product_type', 'defi') .with('product_fee', faker.number.float().toString()) .build(); const safe = safeBuilder().build(); - 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 validatorPublicKey = concat(validators); - const data = encodeFunctionData({ - abi: parseAbi(['function batchWithdrawCLFee(bytes)']), - functionName: 'batchWithdrawCLFee', - args: [validatorPublicKey], - }); - const dataDecoded = dataDecodedBuilder() - .with('method', 'batchWithdrawCLFee') - .with('parameters', [ - { - name: '_publicKeys', - type: 'bytes', - value: validatorPublicKey, - valueDecoded: null, - }, - ]) - .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 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) { @@ -1625,9 +1902,72 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { data: { data: [deployment] }, status: 200, }); - case `${stakingApiUrl}/v1/eth/stakes`: + case `${chain.transactionService}/api/v1/safes/${safe.address}`: return Promise.resolve({ - data: { data: stakes }, + 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}`: @@ -1640,6 +1980,11 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { 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}`)); } @@ -1657,12 +2002,1288 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { ) .send(previewTransactionDto) .expect(200) - .expect({ - txInfo: { - type: 'NativeStakingWithdraw', - humanDescription: null, - richDecodedInfo: null, - value: ( + .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(), @@ -1692,27 +3313,399 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { }); }); - it.todo('should preview a transaction with local data decoding'); + 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}`)); + }); - it.todo( - 'should return a "standard" transaction preview if the deployment is unavailable', - ); + 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.todo( - 'should return a "standard" transaction preview if the deployment is not dedicated-specific', - ); + 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}`)); + }); - it.todo( - 'should return a "standard" transaction preview if the deployment is on an unknown chain', - ); + 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.todo( - 'should return a "standard" transaction preview if not transacting with a deployment address', - ); + 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}`)); + }); - it.todo( - 'should return a "standard" transaction preview if the stakes are not available', - ); + 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/helpers/kiln-native-staking.helper.spec.ts b/src/routes/transactions/helpers/kiln-native-staking.helper.spec.ts index 76bf9cd8bf..da5fff1dfe 100644 --- a/src/routes/transactions/helpers/kiln-native-staking.helper.spec.ts +++ b/src/routes/transactions/helpers/kiln-native-staking.helper.spec.ts @@ -1,16 +1,9 @@ -import { stakeBuilder } from '@/datasources/staking-api/entities/__tests__/stake.entity.builder'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; -import { dataDecodedBuilder } from '@/domain/data-decoder/entities/__tests__/data-decoded.builder'; import { KilnDecoder } from '@/domain/staking/contracts/decoders/kiln-decoder.helper'; -import { StakingRepository } from '@/domain/staking/staking.repository'; 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(), -} as jest.MockedObjectDeep); +import { concat } from 'viem'; describe('KilnNativeStakingHelper', () => { let target: KilnNativeStakingHelper; @@ -20,10 +13,7 @@ describe('KilnNativeStakingHelper', () => { const multiSendDecoder = new MultiSendDecoder(); const transactionFinder = new TransactionFinder(multiSendDecoder); - target = new KilnNativeStakingHelper( - transactionFinder, - mockStakingRepository, - ); + target = new KilnNativeStakingHelper(transactionFinder); }); describe('findDepositTransaction', () => { @@ -92,193 +82,11 @@ describe('KilnNativeStakingHelper', () => { ); }); - 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', () => { it('should split the _publicKeys into an array of strings of correct length', () => { 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 16eef111db..f201af0cd8 100644 --- a/src/routes/transactions/helpers/kiln-native-staking.helper.ts +++ b/src/routes/transactions/helpers/kiln-native-staking.helper.ts @@ -1,18 +1,10 @@ -import { DataDecoded } from '@/domain/data-decoder/entities/data-decoded.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'; import { TransactionFinder, TransactionFinderModule, } from '@/routes/transactions/helpers/transaction-finder.helper'; -import { - Inject, - Injectable, - Module, - UnprocessableEntityException, -} from '@nestjs/common'; -import { isHex, toFunctionSelector } from 'viem'; +import { Injectable, Module } from '@nestjs/common'; +import { toFunctionSelector } from 'viem'; @Injectable() export class KilnNativeStakingHelper { @@ -24,153 +16,51 @@ export class KilnNativeStakingHelper { private static readonly WITHDRAW_SIGNATURE = 'function batchWithdrawCLFee(bytes) external'; - 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({ - signature: KilnNativeStakingHelper.DEPOSIT_SIGNATURE, - ...args, - }); - } + constructor(private readonly transactionFinder: TransactionFinder) {} - public async findValidatorsExitTransaction(args: { + public findDepositTransaction(args: { chainId: string; to?: `0x${string}`; data: `0x${string}`; - }): Promise<{ to: `0x${string}`; data: `0x${string}` } | null> { - return this.findNativeStakingTransaction({ - signature: 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: { + public findValidatorsExitTransaction(args: { chainId: string; to?: `0x${string}`; data: `0x${string}`; - }): Promise<{ to: `0x${string}`; data: `0x${string}` } | null> { - return this.findNativeStakingTransaction({ - signature: 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: { - signature: string; + public findWithdrawTransaction(args: { chainId: string; 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.signature)), + 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) - : []; } /** @@ -200,7 +90,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 3556edddb1..4f077c12ba 100644 --- a/src/routes/transactions/helpers/transaction-data-finder.helper.spec.ts +++ b/src/routes/transactions/helpers/transaction-data-finder.helper.spec.ts @@ -23,13 +23,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', () => { @@ -41,7 +45,7 @@ describe('TransactionFinder', () => { }), to: getAddress(faker.finance.ethereumAddress()), operation: 0, - value: BigInt(0), + value: faker.number.bigInt(), }; const multiSend = multiSendEncoder().with( 'transactions', @@ -53,11 +57,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(), }); }); @@ -68,6 +74,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 6d1e98a69d..af416dcce2 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 { 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..5f31f16e31 100644 --- a/src/routes/transactions/mappers/common/transaction-info.mapper.ts +++ b/src/routes/transactions/mappers/common/transaction-info.mapper.ts @@ -326,18 +326,19 @@ 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({ + this.kilnNativeStakingHelper.findDepositTransaction({ chainId, to: transaction.to, data: transaction.data, + value: transaction.value, }); - if (!nativeStakingDepositTransaction) { + if (!nativeStakingDepositTransaction?.to) { return null; } @@ -345,8 +346,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 +359,19 @@ 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({ + this.kilnNativeStakingHelper.findValidatorsExitTransaction({ chainId, to: transaction.to, data: transaction.data, + value: transaction.value, }); - if (!nativeStakingValidatorsExitTransaction) { + if (!nativeStakingValidatorsExitTransaction?.to) { return null; } @@ -386,8 +380,7 @@ export class MultisigTransactionInfoMapper { chainId, safeAddress: transaction.safe, to: nativeStakingValidatorsExitTransaction.to, - transaction, - dataDecoded, + data: nativeStakingValidatorsExitTransaction.data, }); } catch (error) { this.loggingService.warn(error); @@ -399,26 +392,19 @@ 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({ + this.kilnNativeStakingHelper.findWithdrawTransaction({ chainId, to: transaction.to, data: transaction.data, + value: transaction.value, }); - if (!nativeStakingWithdrawTransaction) { + if (!nativeStakingWithdrawTransaction?.to) { return null; } @@ -427,8 +413,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 4937686290..142e6e8e4a 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -1359,7 +1359,6 @@ describe('TransactionsViewController tests', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -1473,6 +1472,7 @@ describe('TransactionsViewController tests', () => { const validatorPublicKey = faker.string .hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, + casing: 'lower', }) .toLowerCase(); const data = encodeFunctionData({ @@ -1854,7 +1854,6 @@ describe('TransactionsViewController tests', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -1946,7 +1945,6 @@ describe('TransactionsViewController tests', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -2042,7 +2040,6 @@ describe('TransactionsViewController tests', () => { const validators = [ faker.string.hexadecimal({ length: KilnDecoder.KilnPublicKeyLength, - // Transaction Service returns _publicKeys lowercase casing: 'lower', }), faker.string.hexadecimal({ @@ -2342,7 +2339,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..bec99d9ff2 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,33 @@ export class TransactionsViewService { const nativeStakingDepositTransaction = this.isNativeStakingEnabled && - (await this.kilnNativeStakingHelper.findDepositTransaction({ + this.kilnNativeStakingHelper.findDepositTransaction({ chainId: args.chainId, - ...args.transactionDataDto, - })); + 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({ + this.kilnNativeStakingHelper.findValidatorsExitTransaction({ chainId: args.chainId, - ...args.transactionDataDto, - })); + 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({ + this.kilnNativeStakingHelper.findWithdrawTransaction({ chainId: args.chainId, - ...args.transactionDataDto, - })); + to: args.transactionDataDto.to, + data: args.transactionDataDto.data, + // Value is always defined + value: args.transactionDataDto.value ?? '0', + }); if ( !swapOrderData && @@ -129,23 +139,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 +350,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 +378,7 @@ export class TransactionsViewService { chainId: args.chainId, safeAddress: args.safeAddress, to: args.to, - transaction: null, - dataDecoded, + data: args.data, }); return new NativeStakingValidatorsExitConfirmationView({ @@ -385,8 +406,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, From 6167981fd2c0e093abcd46b7b163fda0b791ddb2 Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 30 Sep 2024 09:25:35 +0200 Subject: [PATCH 5/6] Mark endpoint as deprecated --- src/routes/transactions/transactions-view.controller.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/routes/transactions/transactions-view.controller.ts b/src/routes/transactions/transactions-view.controller.ts index 52ca8a3346..c0faab1d9a 100644 --- a/src/routes/transactions/transactions-view.controller.ts +++ b/src/routes/transactions/transactions-view.controller.ts @@ -72,8 +72,9 @@ export class TransactionsViewController { NativeStakingWithdrawConfirmationView, ) @ApiOperation({ - summary: 'Confirm Transaction View', - description: 'This endpoint is experimental and may change.', + description: + 'Deprecated in favour of /v1/chains/:chainId/transactions/:safeAddress/preview.', + deprecated: true, }) @Post('chains/:chainId/safes/:safeAddress/views/transaction-confirmation') async getTransactionConfirmationView( From 739ab638535d747ba8dd1c237e5dac53ea6a9cb2 Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 30 Sep 2024 14:44:45 +0200 Subject: [PATCH 6/6] Remove unnecessary argument --- .../transactions/mappers/common/transaction-info.mapper.ts | 3 --- src/routes/transactions/transactions-view.service.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/routes/transactions/mappers/common/transaction-info.mapper.ts b/src/routes/transactions/mappers/common/transaction-info.mapper.ts index 5f31f16e31..e0024ec87f 100644 --- a/src/routes/transactions/mappers/common/transaction-info.mapper.ts +++ b/src/routes/transactions/mappers/common/transaction-info.mapper.ts @@ -332,7 +332,6 @@ export class MultisigTransactionInfoMapper { const nativeStakingDepositTransaction = this.kilnNativeStakingHelper.findDepositTransaction({ - chainId, to: transaction.to, data: transaction.data, value: transaction.value, @@ -365,7 +364,6 @@ export class MultisigTransactionInfoMapper { const nativeStakingValidatorsExitTransaction = this.kilnNativeStakingHelper.findValidatorsExitTransaction({ - chainId, to: transaction.to, data: transaction.data, value: transaction.value, @@ -398,7 +396,6 @@ export class MultisigTransactionInfoMapper { const nativeStakingWithdrawTransaction = this.kilnNativeStakingHelper.findWithdrawTransaction({ - chainId, to: transaction.to, data: transaction.data, value: transaction.value, diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index bec99d9ff2..5b1a737212 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -85,7 +85,6 @@ export class TransactionsViewService { const nativeStakingDepositTransaction = this.isNativeStakingEnabled && this.kilnNativeStakingHelper.findDepositTransaction({ - chainId: args.chainId, to: args.transactionDataDto.to, data: args.transactionDataDto.data, // Value is always defined @@ -95,7 +94,6 @@ export class TransactionsViewService { const nativeStakingValidatorsExitTransaction = this.isNativeStakingEnabled && this.kilnNativeStakingHelper.findValidatorsExitTransaction({ - chainId: args.chainId, to: args.transactionDataDto.to, data: args.transactionDataDto.data, // Value is always defined @@ -105,7 +103,6 @@ export class TransactionsViewService { const nativeStakingWithdrawTransaction = this.isNativeStakingEnabled && this.kilnNativeStakingHelper.findWithdrawTransaction({ - chainId: args.chainId, to: args.transactionDataDto.to, data: args.transactionDataDto.data, // Value is always defined