diff --git a/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts b/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts index 3c5ddaa91d..4c8949c01b 100644 --- a/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts +++ b/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts @@ -185,6 +185,12 @@ export class CowSwapTwapConfirmationView implements Baseline, TwapOrderInfo { @ApiProperty({ enum: OrderClass }) class: OrderClass.Limit; + @ApiProperty({ + description: + 'The order UID of the active order, null as it is not an active order', + }) + activeOrderUid: null; + @ApiProperty({ description: 'The timestamp when the TWAP expires' }) validUntil: number; @@ -278,6 +284,7 @@ export class CowSwapTwapConfirmationView implements Baseline, TwapOrderInfo { status: OrderStatus; kind: OrderKind.Sell; class: OrderClass.Limit; + activeOrderUid: null; validUntil: number; sellAmount: string; buyAmount: string; @@ -301,6 +308,7 @@ export class CowSwapTwapConfirmationView implements Baseline, TwapOrderInfo { this.status = args.status; this.kind = args.kind; this.class = args.class; + this.activeOrderUid = args.activeOrderUid; this.validUntil = args.validUntil; this.sellAmount = args.sellAmount; this.buyAmount = args.buyAmount; diff --git a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts index baf5911d5d..b5558206f7 100644 --- a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts +++ b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts @@ -35,6 +35,7 @@ export type StartTime = export type TwapOrderInfo = { status: OrderStatus; kind: OrderKind.Sell; + activeOrderUid: `0x${string}` | null; class: OrderClass.Limit; validUntil: number; sellAmount: string; @@ -71,9 +72,15 @@ export class TwapOrderTransactionInfo @ApiProperty({ enum: OrderKind }) kind: OrderKind.Sell; - @ApiProperty({ enum: OrderClass }) + @ApiPropertyOptional({ enum: OrderClass }) class: OrderClass.Limit; + @ApiProperty({ + nullable: true, + description: 'The order UID of the active order, or null if none is active', + }) + activeOrderUid: `0x${string}` | null; + @ApiProperty({ description: 'The timestamp when the TWAP expires' }) validUntil: number; @@ -164,6 +171,7 @@ export class TwapOrderTransactionInfo constructor(args: { status: OrderStatus; kind: OrderKind.Sell; + activeOrderUid: `0x${string}` | null; class: OrderClass.Limit; validUntil: number; sellAmount: string; @@ -187,6 +195,7 @@ export class TwapOrderTransactionInfo this.status = args.status; this.kind = args.kind; this.class = args.class; + this.activeOrderUid = args.activeOrderUid; this.validUntil = args.validUntil; this.sellAmount = args.sellAmount; this.buyAmount = args.buyAmount; diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 04745b684e..431a9a8ca8 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -18,6 +18,7 @@ import { getAddress } from 'viem'; import { fullAppDataBuilder } from '@/domain/swaps/entities/__tests__/full-app-data.builder'; import { TransactionDataFinder } from '@/routes/transactions/helpers/transaction-data-finder.helper'; import { SwapAppsHelper } from '@/routes/transactions/helpers/swap-apps.helper'; +import { NotFoundException } from '@nestjs/common'; const loggingService = { debug: jest.fn(), @@ -131,6 +132,7 @@ describe('TwapOrderMapper', () => { }); expect(result).toEqual({ + activeOrderUid: null, buyAmount: '51576509680023161648', buyToken: { address: buyToken.address, @@ -323,6 +325,7 @@ describe('TwapOrderMapper', () => { trusted: buyToken.trusted, }, class: 'limit', + activeOrderUid: null, durationOfPart: { durationType: 'AUTO', }, @@ -427,7 +430,14 @@ describe('TwapOrderMapper', () => { .build(); const fullAppData = JSON.parse(fakeJson()); - mockSwapsRepository.getOrder.mockResolvedValueOnce(part2); + mockSwapsRepository.getOrder.mockImplementation( + async (_chainId: string, orderUid: string) => { + if (orderUid === part2.uid) { + return Promise.resolve(part2); + } + return Promise.reject(new NotFoundException()); + }, + ); mockTokenRepository.getToken.mockImplementation(async ({ address }) => { // We only need mock part1 addresses as all parts use the same tokens switch (address) { @@ -460,6 +470,7 @@ describe('TwapOrderMapper', () => { trusted: buyToken.trusted, }, class: 'limit', + activeOrderUid: null, durationOfPart: { durationType: 'AUTO', }, @@ -680,6 +691,7 @@ describe('TwapOrderMapper', () => { }); it('should map the TWAP order if source apps are restricted and a part order fullAppData matches any of the allowed apps', async () => { + configurationService.set('swaps.maxNumberOfParts', 2); configurationService.set('swaps.restrictApps', true); // We instantiate in tests to be able to set maxNumberOfParts @@ -755,7 +767,14 @@ describe('TwapOrderMapper', () => { .with('fullAppData', { appCode: 'Safe Wallet Swaps' }) .build(); - mockSwapsRepository.getOrder.mockResolvedValueOnce(part1); + mockSwapsRepository.getOrder.mockImplementation( + async (_chainId: string, orderUid: string) => { + if (orderUid === part1.uid) { + return Promise.resolve(part1); + } + return Promise.reject(new NotFoundException()); + }, + ); mockTokenRepository.getToken.mockImplementation(async ({ address }) => { // We only need mock part1 addresses as all parts use the same tokens switch (address) { @@ -841,6 +860,7 @@ describe('TwapOrderMapper', () => { }); expect(result).toEqual({ + activeOrderUid: null, buyAmount: '51576509680023161648', buyToken: { address: buyToken.address, @@ -884,4 +904,810 @@ describe('TwapOrderMapper', () => { validUntil: Math.ceil(now.getTime() / 1_000) + 17999, }); }); + + describe('specific cases for status - testing activeOrderUid and status', () => { + beforeEach(() => { + configurationService.set('swaps.restrictApps', false); + }); + + it('should map a cancelled status (3 parts for testing) if the first part is active', async () => { + /** + * Third transaction in multiSend + * @see https://sepolia.etherscan.io/tx/0x8b27d05e760d3a17b12934ffc5d678144fed649d46e3425c1dbec62c36267232 + */ + const chainId = '11155111'; + const owner = '0xF979f34D16d865f51e2eC7baDEde4f3735DaFb7d'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date('2024-07-26T10:17:24Z'); + const orders = [ + /** + * @see https://explorer.cow.fi/sepolia/orders/0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63?tab=overview + */ + { + creationDate: '2024-07-26T10:17:26.572430Z', + owner: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + uid: '0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63', + availableBalance: null, + executedBuyAmount: '147966574407179274396', + executedSellAmount: '388694804521426831', + executedSellAmountBeforeFees: '388694804521426831', + executedFeeAmount: '0', + executedSurplusFee: '3713410339758625', + invalidated: false, + // Note: status modified from 'fulfilled' for the sake of this test + status: 'open', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"partnerFee":{"bps":35,"recipient":"0x63695Eee2c3141BDE314C5a6f89B98E62808d716"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + buyToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + receiver: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + sellAmount: '388694804521426831', + buyAmount: '146908804750330871784', + validTo: 1721990243, + appData: + '0x7eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a6', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000066a37c637eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + }, + ] as unknown as Array; + + // Order #1 is still active for 1 second + jest.setSystemTime(new Date((orders[0].validTo - 1) * 1_000)); + + configurationService.set('swaps.maxNumberOfParts', orders.length); + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + mockLoggingService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + new TwapOrderHelper(transactionDataFinder, composableCowDecoder), + new SwapAppsHelper( + configurationService, + new Set(['Safe Wallet Swaps']), + ), + ); + + const buyToken = tokenBuilder() + .with('address', getAddress(orders[0].buyToken)) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress(orders[0].sellToken)) + .build(); + const fullAppData = JSON.parse(fakeJson()); + + mockSwapsRepository.getOrder.mockImplementation( + async (_chainId: string, orderUid: string) => { + const order = orders.find((order) => order.uid === orderUid); + if (order) { + return Promise.resolve(order); + } + return Promise.reject(new NotFoundException()); + }, + ); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case buyToken.address: { + return Promise.resolve(buyToken); + } + case sellToken.address: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + const result = await mapper.mapTwapOrder(chainId, owner, { + data, + executionDate, + }); + + expect(result).toStrictEqual( + expect.objectContaining({ + activeOrderUid: + '0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63', + status: 'open', + }), + ); + }); + + it("should map a cancelled status (3 parts for testing) if the first part exists and the second is active but it doesn't exist", async () => { + /** + * Third transaction in multiSend + * @see https://sepolia.etherscan.io/tx/0x8b27d05e760d3a17b12934ffc5d678144fed649d46e3425c1dbec62c36267232 + */ + const chainId = '11155111'; + const owner = '0xF979f34D16d865f51e2eC7baDEde4f3735DaFb7d'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date('2024-07-26T10:17:24Z'); + const orders = [ + /** + * @see https://explorer.cow.fi/sepolia/orders/0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63?tab=overview + */ + { + creationDate: '2024-07-26T10:17:26.572430Z', + owner: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + uid: '0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63', + availableBalance: null, + executedBuyAmount: '147966574407179274396', + executedSellAmount: '388694804521426831', + executedSellAmountBeforeFees: '388694804521426831', + executedFeeAmount: '0', + executedSurplusFee: '3713410339758625', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"partnerFee":{"bps":35,"recipient":"0x63695Eee2c3141BDE314C5a6f89B98E62808d716"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + buyToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + receiver: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + sellAmount: '388694804521426831', + buyAmount: '146908804750330871784', + validTo: 1721990243, + appData: + '0x7eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a6', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000066a37c637eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + }, + ] as unknown as Array; + + // Order #2 has been active for 1 second + jest.setSystemTime(new Date((orders[0].validTo + 1) * 1_000)); + + configurationService.set('swaps.maxNumberOfParts', orders.length); + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + mockLoggingService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + new TwapOrderHelper(transactionDataFinder, composableCowDecoder), + new SwapAppsHelper( + configurationService, + new Set(['Safe Wallet Swaps']), + ), + ); + + const buyToken = tokenBuilder() + .with('address', getAddress(orders[0].buyToken)) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress(orders[0].sellToken)) + .build(); + const fullAppData = JSON.parse(fakeJson()); + + mockSwapsRepository.getOrder.mockImplementation( + async (_chainId: string, orderUid: string) => { + const order = orders.find((order) => order.uid === orderUid); + if (order) { + return Promise.resolve(order); + } + return Promise.reject(new NotFoundException()); + }, + ); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case buyToken.address: { + return Promise.resolve(buyToken); + } + case sellToken.address: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + const result = await mapper.mapTwapOrder(chainId, owner, { + data, + executionDate, + }); + + expect(result).toStrictEqual( + expect.objectContaining({ + activeOrderUid: + '0x5ac23f4cc9d6b46d3eb3e4fb24e57cb7d7029d95522e3515548375fb76b67979f979f34d16d865f51e2ec7badede4f3735dafb7d66a38113', + status: 'cancelled', + }), + ); + }); + + it("should map a cancelled status (3 parts for testing) if the last part is active but doesn't exist", async () => { + /** + * Third transaction in multiSend + * @see https://sepolia.etherscan.io/tx/0x8b27d05e760d3a17b12934ffc5d678144fed649d46e3425c1dbec62c36267232 + */ + const chainId = '11155111'; + const owner = '0xF979f34D16d865f51e2eC7baDEde4f3735DaFb7d'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date('2024-07-26T10:17:24Z'); + const orders = [ + /** + * @see https://explorer.cow.fi/sepolia/orders/0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63?tab=overview + */ + { + creationDate: '2024-07-26T10:17:26.572430Z', + owner: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + uid: '0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63', + availableBalance: null, + executedBuyAmount: '147966574407179274396', + executedSellAmount: '388694804521426831', + executedSellAmountBeforeFees: '388694804521426831', + executedFeeAmount: '0', + executedSurplusFee: '3713410339758625', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"partnerFee":{"bps":35,"recipient":"0x63695Eee2c3141BDE314C5a6f89B98E62808d716"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + buyToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + receiver: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + sellAmount: '388694804521426831', + buyAmount: '146908804750330871784', + validTo: 1721990243, + appData: + '0x7eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a6', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000066a37c637eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + }, + /** + * @see https://explorer.cow.fi/sepolia/orders/0x5ac23f4cc9d6b46d3eb3e4fb24e57cb7d7029d95522e3515548375fb76b67979f979f34d16d865f51e2ec7badede4f3735dafb7d66a38113?tab=overview + */ + { + creationDate: '2024-07-26T10:37:38.678515Z', + owner: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + uid: '0x5ac23f4cc9d6b46d3eb3e4fb24e57cb7d7029d95522e3515548375fb76b67979f979f34d16d865f51e2ec7badede4f3735dafb7d66a38113', + availableBalance: null, + executedBuyAmount: '147526334327050716675', + executedSellAmount: '388694804521426831', + executedSellAmountBeforeFees: '388694804521426831', + executedFeeAmount: '0', + executedSurplusFee: '3835585092662741', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"partnerFee":{"bps":35,"recipient":"0x63695Eee2c3141BDE314C5a6f89B98E62808d716"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + buyToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + receiver: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + sellAmount: '388694804521426831', + buyAmount: '146908804750330871784', + validTo: 1721991443, + appData: + '0x7eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a6', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000066a381137eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + }, + /** + * @see https://explorer.cow.fi/sepolia/orders/0x3a807a76eb7c17b840f881d0c50cbe4e9f42510becec2589c31733dc5974512231eac7f0141837b266de30f4dc9af15629bd538166a385c3?tab=overview + */ + ] as unknown as Array; + + // Order #3 has been active for 1 second + jest.setSystemTime(new Date((orders[1].validTo + 1) * 1_000)); + + configurationService.set('swaps.maxNumberOfParts', orders.length); + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + mockLoggingService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + new TwapOrderHelper(transactionDataFinder, composableCowDecoder), + new SwapAppsHelper( + configurationService, + new Set(['Safe Wallet Swaps']), + ), + ); + + const buyToken = tokenBuilder() + .with('address', getAddress(orders[0].buyToken)) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress(orders[0].sellToken)) + .build(); + const fullAppData = JSON.parse(fakeJson()); + + mockSwapsRepository.getOrder.mockImplementation( + async (_chainId: string, orderUid: string) => { + const order = orders.find((order) => order.uid === orderUid); + if (order) { + return Promise.resolve(order); + } + return Promise.reject(new NotFoundException()); + }, + ); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case buyToken.address: { + return Promise.resolve(buyToken); + } + case sellToken.address: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + const result = await mapper.mapTwapOrder(chainId, owner, { + data, + executionDate, + }); + + expect(result).toStrictEqual( + expect.objectContaining({ + activeOrderUid: + '0x3a807a76eb7c17b840f881d0c50cbe4e9f42510becec2589c31733dc59745122f979f34d16d865f51e2ec7badede4f3735dafb7d66a385c3', + status: 'cancelled', + }), + ); + }); + + it('should map a fulfilled status (3 parts for testing) if the last part is active and exists', async () => { + /** + * Third transaction in multiSend + * @see https://sepolia.etherscan.io/tx/0x8b27d05e760d3a17b12934ffc5d678144fed649d46e3425c1dbec62c36267232 + */ + const chainId = '11155111'; + const owner = '0xF979f34D16d865f51e2eC7baDEde4f3735DaFb7d'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date('2024-07-26T10:17:24Z'); + const orders = [ + /** + * @see https://explorer.cow.fi/sepolia/orders/0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63?tab=overview + */ + { + creationDate: '2024-07-26T10:17:26.572430Z', + owner: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + uid: '0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63', + availableBalance: null, + executedBuyAmount: '147966574407179274396', + executedSellAmount: '388694804521426831', + executedSellAmountBeforeFees: '388694804521426831', + executedFeeAmount: '0', + executedSurplusFee: '3713410339758625', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"partnerFee":{"bps":35,"recipient":"0x63695Eee2c3141BDE314C5a6f89B98E62808d716"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + buyToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + receiver: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + sellAmount: '388694804521426831', + buyAmount: '146908804750330871784', + validTo: 1721990243, + appData: + '0x7eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a6', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000066a37c637eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + }, + /** + * @see https://explorer.cow.fi/sepolia/orders/0x5ac23f4cc9d6b46d3eb3e4fb24e57cb7d7029d95522e3515548375fb76b67979f979f34d16d865f51e2ec7badede4f3735dafb7d66a38113?tab=overview + */ + { + creationDate: '2024-07-26T10:37:38.678515Z', + owner: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + uid: '0x5ac23f4cc9d6b46d3eb3e4fb24e57cb7d7029d95522e3515548375fb76b67979f979f34d16d865f51e2ec7badede4f3735dafb7d66a38113', + availableBalance: null, + executedBuyAmount: '147526334327050716675', + executedSellAmount: '388694804521426831', + executedSellAmountBeforeFees: '388694804521426831', + executedFeeAmount: '0', + executedSurplusFee: '3835585092662741', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"partnerFee":{"bps":35,"recipient":"0x63695Eee2c3141BDE314C5a6f89B98E62808d716"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + buyToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + receiver: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + sellAmount: '388694804521426831', + buyAmount: '146908804750330871784', + validTo: 1721991443, + appData: + '0x7eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a6', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000066a381137eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + }, + /** + * @see https://explorer.cow.fi/sepolia/orders/0x3a807a76eb7c17b840f881d0c50cbe4e9f42510becec2589c31733dc59745122f979f34d16d865f51e2ec7badede4f3735dafb7d66a385c3?tab=overview?tab=overview + */ + { + creationDate: '2024-07-26T10:57:26.210553Z', + owner: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + uid: '0x3a807a76eb7c17b840f881d0c50cbe4e9f42510becec2589c31733dc59745122f979f34d16d865f51e2ec7badede4f3735dafb7d66a385c3', + availableBalance: null, + executedBuyAmount: '0', + executedSellAmount: '0', + executedSellAmountBeforeFees: '0', + executedFeeAmount: '0', + executedSurplusFee: '0', + invalidated: false, + // Note: changed from expired to open for testing purposes + status: 'open', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"partnerFee":{"bps":35,"recipient":"0x63695Eee2c3141BDE314C5a6f89B98E62808d716"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + buyToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + receiver: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + sellAmount: '388694804521426831', + buyAmount: '146908804750330871784', + validTo: 1721992643, + appData: + '0x7eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a6', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000066a385c37eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + }, + ] as unknown as Array; + + // Order #3 has been active for 1 second + jest.setSystemTime(new Date((orders[1].validTo + 1) * 1_000)); + + configurationService.set('swaps.maxNumberOfParts', orders.length); + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + mockLoggingService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + new TwapOrderHelper(transactionDataFinder, composableCowDecoder), + new SwapAppsHelper( + configurationService, + new Set(['Safe Wallet Swaps']), + ), + ); + + const buyToken = tokenBuilder() + .with('address', getAddress(orders[0].buyToken)) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress(orders[0].sellToken)) + .build(); + const fullAppData = JSON.parse(fakeJson()); + + mockSwapsRepository.getOrder.mockImplementation( + async (_chainId: string, orderUid: string) => { + const order = orders.find((order) => order.uid === orderUid); + if (order) { + return Promise.resolve(order); + } + console.log('Order not found', orderUid); + return Promise.reject(new NotFoundException()); + }, + ); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case buyToken.address: { + return Promise.resolve(buyToken); + } + case sellToken.address: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + const result = await mapper.mapTwapOrder(chainId, owner, { + data, + executionDate, + }); + + expect(result).toStrictEqual( + expect.objectContaining({ + activeOrderUid: + '0x3a807a76eb7c17b840f881d0c50cbe4e9f42510becec2589c31733dc59745122f979f34d16d865f51e2ec7badede4f3735dafb7d66a385c3', + status: 'fulfilled', + }), + ); + }); + + it('should map a fulfilled status (3 parts for testing) if the TWAP expired', async () => { + /** + * Third transaction in multiSend + * @see https://sepolia.etherscan.io/tx/0x8b27d05e760d3a17b12934ffc5d678144fed649d46e3425c1dbec62c36267232 + */ + const chainId = '11155111'; + const owner = '0xF979f34D16d865f51e2eC7baDEde4f3735DaFb7d'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date('2024-07-26T10:17:24Z'); + const orders = [ + /** + * @see https://explorer.cow.fi/sepolia/orders/0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63?tab=overview + */ + { + creationDate: '2024-07-26T10:17:26.572430Z', + owner: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + uid: '0x8dd7580ce9c791ade023b0f6c89c55b2089d819bf5986ec3b1e9540abcf5b52ef979f34d16d865f51e2ec7badede4f3735dafb7d66a37c63', + availableBalance: null, + executedBuyAmount: '147966574407179274396', + executedSellAmount: '388694804521426831', + executedSellAmountBeforeFees: '388694804521426831', + executedFeeAmount: '0', + executedSurplusFee: '3713410339758625', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"partnerFee":{"bps":35,"recipient":"0x63695Eee2c3141BDE314C5a6f89B98E62808d716"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + buyToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + receiver: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + sellAmount: '388694804521426831', + buyAmount: '146908804750330871784', + validTo: 1721990243, + appData: + '0x7eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a6', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000066a37c637eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + }, + /** + * @see https://explorer.cow.fi/sepolia/orders/0x5ac23f4cc9d6b46d3eb3e4fb24e57cb7d7029d95522e3515548375fb76b67979f979f34d16d865f51e2ec7badede4f3735dafb7d66a38113?tab=overview + */ + { + creationDate: '2024-07-26T10:37:38.678515Z', + owner: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + uid: '0x5ac23f4cc9d6b46d3eb3e4fb24e57cb7d7029d95522e3515548375fb76b67979f979f34d16d865f51e2ec7badede4f3735dafb7d66a38113', + availableBalance: null, + executedBuyAmount: '147526334327050716675', + executedSellAmount: '388694804521426831', + executedSellAmountBeforeFees: '388694804521426831', + executedFeeAmount: '0', + executedSurplusFee: '3835585092662741', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"partnerFee":{"bps":35,"recipient":"0x63695Eee2c3141BDE314C5a6f89B98E62808d716"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + buyToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + receiver: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + sellAmount: '388694804521426831', + buyAmount: '146908804750330871784', + validTo: 1721991443, + appData: + '0x7eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a6', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000066a381137eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + }, + /** + * @see https://explorer.cow.fi/sepolia/orders/0x3a807a76eb7c17b840f881d0c50cbe4e9f42510becec2589c31733dc59745122f979f34d16d865f51e2ec7badede4f3735dafb7d66a385c3?tab=overview?tab=overview + */ + { + creationDate: '2024-07-26T10:57:26.210553Z', + owner: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + uid: '0x3a807a76eb7c17b840f881d0c50cbe4e9f42510becec2589c31733dc59745122f979f34d16d865f51e2ec7badede4f3735dafb7d66a385c3', + availableBalance: null, + executedBuyAmount: '0', + executedSellAmount: '0', + executedSellAmountBeforeFees: '0', + executedFeeAmount: '0', + executedSurplusFee: '0', + invalidated: false, + status: 'expired', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"partnerFee":{"bps":35,"recipient":"0x63695Eee2c3141BDE314C5a6f89B98E62808d716"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + buyToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + receiver: '0xf979f34d16d865f51e2ec7badede4f3735dafb7d', + sellAmount: '388694804521426831', + buyAmount: '146908804750330871784', + validTo: 1721992643, + appData: + '0x7eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a6', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000066a385c37eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000190ee8afcb00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f979f34d16d865f51e2ec7badede4f3735dafb7d0000000000000000000000000000000000000000000000000564ebdd858a1f8f000000000000000000000000000000000000000000000007f6c4eb9070b0bfe80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004b000000000000000000000000000000000000000000000000000000000000000007eda228d5bb9d713863d5bfa596eeb9c8fa7c9da9d4ca889e1457b0cb30010a60000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + }, + ] as unknown as Array; + + // Order #3 exired 1 second ago + jest.setSystemTime(new Date((orders[2].validTo + 1) * 1_000)); + + configurationService.set('swaps.maxNumberOfParts', orders.length); + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + mockLoggingService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + new TwapOrderHelper(transactionDataFinder, composableCowDecoder), + new SwapAppsHelper( + configurationService, + new Set(['Safe Wallet Swaps']), + ), + ); + + const buyToken = tokenBuilder() + .with('address', getAddress(orders[0].buyToken)) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress(orders[0].sellToken)) + .build(); + const fullAppData = JSON.parse(fakeJson()); + + mockSwapsRepository.getOrder.mockImplementation( + async (_chainId: string, orderUid: string) => { + const order = orders.find((order) => order.uid === orderUid); + if (order) { + return Promise.resolve(order); + } + return Promise.reject(new NotFoundException()); + }, + ); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case buyToken.address: { + return Promise.resolve(buyToken); + } + case sellToken.address: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + const result = await mapper.mapTwapOrder(chainId, owner, { + data, + executionDate, + }); + + expect(result).toStrictEqual( + expect.objectContaining({ + activeOrderUid: null, + status: 'fulfilled', + }), + ); + }); + }); }); diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.ts b/src/routes/transactions/mappers/common/twap-order.mapper.ts index 69d47f3022..4093e8cd59 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.ts @@ -28,6 +28,7 @@ import { SwapAppsHelper, SwapAppsHelperModule, } from '@/routes/transactions/helpers/swap-apps.helper'; +import { GPv2OrderParameters } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; @Injectable() export class TwapOrderMapper { @@ -93,58 +94,54 @@ export class TwapOrderMapper { // Fetch all order parts if the transaction has been executed, otherwise none const partsToFetch = transaction.executionDate ? hasAbundantParts - ? // We use the last part (and only one) to get the status of the entire + ? // We use the last part (and only one) to get the amounts/fees of the entire // order and we only need one to get the token info twapParts.slice(-1) : twapParts : []; - const orders: Array = []; - - for (const part of partsToFetch) { - const partFullAppData = await this.swapsRepository.getFullAppData( - chainId, - part.appData, - ); - - if (!this.swapAppsHelper.isAppAllowed(partFullAppData)) { - throw new Error( - `Unsupported App: ${partFullAppData.fullAppData?.appCode}`, - ); - } - - const orderUid = this.gpv2OrderHelper.computeOrderUid({ - chainId, - owner: safeAddress, - order: part, - }); - const order = await this.swapsRepository - .getOrder(chainId, orderUid) - .catch(() => { - this.loggingService.warn( - `Error getting orderUid ${orderUid} from SwapsRepository`, - ); - }); + const activePart = this.getActivePart({ + twapParts, + executionDate: transaction.executionDate, + }); - if (!order || order.kind == OrderKind.Unknown) { - continue; - } + const activeOrderUid = activePart + ? this.gpv2OrderHelper.computeOrderUid({ + chainId: chainId, + owner: safeAddress, + order: activePart, + }) + : null; - if (!this.swapAppsHelper.isAppAllowed(order)) { - throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); - } + const partOrders = await this.getPartOrders({ + partsToFetch, + chainId, + safeAddress, + }); - orders.push(order as KnownOrder); - } + const status = await this.getOrderStatus({ + chainId, + safeAddress, + twapParts, + partOrders, + activeOrderUid, + executionDate: transaction.executionDate, + }); const executedSellAmount: TwapOrderInfo['executedSellAmount'] = - hasAbundantParts ? null : this.getExecutedSellAmount(orders).toString(); + hasAbundantParts || !partOrders + ? null + : this.getExecutedSellAmount(partOrders).toString(); const executedBuyAmount: TwapOrderInfo['executedBuyAmount'] = - hasAbundantParts ? null : this.getExecutedBuyAmount(orders).toString(); + hasAbundantParts || !partOrders + ? null + : this.getExecutedBuyAmount(partOrders).toString(); const executedSurplusFee: TwapOrderInfo['executedSurplusFee'] = - hasAbundantParts ? null : this.getExecutedSurplusFee(orders).toString(); + hasAbundantParts || !partOrders + ? null + : this.getExecutedSurplusFee(partOrders).toString(); const [sellToken, buyToken] = await Promise.all([ this.swapOrderHelper.getToken({ @@ -158,9 +155,10 @@ export class TwapOrderMapper { ]); return new TwapOrderTransactionInfo({ - status: this.getOrderStatus(orders), + status, kind: twapOrderData.kind, class: twapOrderData.class, + activeOrderUid, validUntil: Math.max(...twapParts.map((order) => order.validTo)), sellAmount: twapOrderData.sellAmount, buyAmount: twapOrderData.buyAmount, @@ -195,37 +193,117 @@ export class TwapOrderMapper { }); } - private getOrderStatus( - orders: Array>>, - ): OrderStatus { - if (orders.length === 0) { - return OrderStatus.PreSignaturePending; + private getActivePart(args: { + twapParts: Array; + executionDate: Date | null; + }): GPv2OrderParameters | null { + if (!args.executionDate) { + return null; } - // If an order is fulfilled, cancelled or expired, the part is "complete" - const completeStatuses = [ - OrderStatus.Fulfilled, - OrderStatus.Cancelled, - OrderStatus.Expired, - ]; + const now = new Date(); + const activePart = args.twapParts.find((part) => { + return part.validTo > Math.floor(now.getTime() / 1_000); + }); + + return activePart ?? null; + } + + private async getPartOrders(args: { + partsToFetch: Array; + chainId: string; + safeAddress: `0x${string}`; + }): Promise | null> { + const orders: Array = []; - for (let i = 0; i < orders.length; i++) { - const order = orders[i]; + for (const part of args.partsToFetch) { + const partFullAppData = await this.swapsRepository.getFullAppData( + args.chainId, + part.appData, + ); - // Return the status of the last part - if (i === orders.length - 1) { - return order.status; + if (!this.swapAppsHelper.isAppAllowed(partFullAppData)) { + throw new Error( + `Unsupported App: ${partFullAppData.fullAppData?.appCode}`, + ); } - // If the part is complete, continue to the next part - if (completeStatuses.includes(order.status)) { - continue; + const orderUid = this.gpv2OrderHelper.computeOrderUid({ + chainId: args.chainId, + owner: args.safeAddress, + order: part, + }); + const order = await this.swapsRepository + .getOrder(args.chainId, orderUid) + .catch(() => { + this.loggingService.warn( + `Error getting orderUid ${orderUid} from SwapsRepository`, + ); + }); + + if (!order || order.kind == OrderKind.Unknown) { + // Without every order it's not possible to determine executed amounts/fees + return null; } - return order.status; + if (!this.swapAppsHelper.isAppAllowed(order)) { + throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); + } + + orders.push(order as KnownOrder); + } + + return orders; + } + + private async getOrderStatus(args: { + chainId: string; + safeAddress: `0x${string}`; + twapParts: Array; + partOrders: Array | null; + activeOrderUid: `0x${string}` | null; + executionDate: Date | null; + }): Promise { + if (!args.executionDate) { + return OrderStatus.PreSignaturePending; } - return OrderStatus.Unknown; + const finalPart = args.twapParts.slice(-1)[0]; + const finalOrderUid = this.gpv2OrderHelper.computeOrderUid({ + chainId: args.chainId, + owner: args.safeAddress, + order: finalPart, + }); + + // Check active or final order order + const orderUidToCheck = args.activeOrderUid ?? finalOrderUid; + + try { + // If already fetched (and exists), we don't need fetch it again + const orderAlreadyFetched = args.partOrders?.find((order) => { + return order.uid === orderUidToCheck; + }); + if (!orderAlreadyFetched) { + // Check if we can get the order (if it "exists") + await this.swapsRepository.getOrder(args.chainId, orderUidToCheck); + } + + // We successfully fetched the final order OR the active order matches + // that of the final order meaning that the final order was created and + // we therefore know the TWAP was fulfilled + // Note: the final order of a TWAP can expire but as it would inherently + // be partially filled, we consider expired final orders as fulfilled + if (!args.activeOrderUid || args.activeOrderUid === finalOrderUid) { + return OrderStatus.Fulfilled; + } + + // We successfully fetched the active order and it is not the final one + // meaning that the TWAP is still open + return OrderStatus.Open; + } catch { + // The order doesn't exist, so it was cancelled + return OrderStatus.Cancelled; + } } private getExecutedSellAmount(orders: Array): bigint { diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index c9c389d3fa..597b9fce58 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -201,6 +201,7 @@ export class TransactionsViewService { status: OrderStatus.PreSignaturePending, kind: twapOrderData.kind, class: twapOrderData.class, + activeOrderUid: null, validUntil: Math.max(...twapParts.map((order) => order.validTo)), sellAmount: twapOrderData.sellAmount, buyAmount: twapOrderData.buyAmount,