From 2b795096c0de15e2d84dd875e1ee46048b33886c Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Fri, 6 Oct 2023 18:34:14 +0300 Subject: [PATCH 01/65] refactor: drop dependabot.yml --- .github/dependabot.yml | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index d869120c92..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,27 +0,0 @@ -version: 2 - -updates: - - package-ecosystem: 'npm' - directory: '/' - schedule: - interval: 'weekly' - day: 'monday' - groups: - nest-js-core: - patterns: - - '@nestjs/common' - - '@nestjs/core' - - '@nestjs/platform-express' - - '@nestjs/testing' - - - package-ecosystem: 'docker' - directory: '/' - schedule: - interval: 'weekly' - day: 'monday' - - - package-ecosystem: 'github-actions' - directory: '/' - schedule: - interval: 'weekly' - day: 'monday' From 61d92e288289f613e4d8efd3e63d75cb32204297 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Tue, 10 Oct 2023 17:26:36 +0300 Subject: [PATCH 02/65] chore: set 1.9.0 version --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index d37021a79a..10ef867c60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "safe-client-gateway", "description": "", + "version": "1.9.0", "private": true, "license": "MIT", "scripts": { From d6c12a79a154474d3baba89da079bf34b1cfd16e Mon Sep 17 00:00:00 2001 From: nick8319 Date: Mon, 12 Feb 2024 16:49:59 +0800 Subject: [PATCH 03/65] fix: set correct version --- package.json | 2 +- src/config/entities/configuration.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 76a2f72831..eb0a198fb1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "safe-client-gateway", "description": "", - "version": "1.9.0", + "version": "1.26.0", "private": true, "license": "MIT", "scripts": { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 9185acbcc4..e0b6ea8e86 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -3,7 +3,7 @@ export default () => ({ about: { name: 'safe-client-gateway', - version: process.env.APPLICATION_VERSION, + version: process.env.APPLICATION_VERSION || 'v1.26.0', buildNumber: process.env.APPLICATION_BUILD_NUMBER, }, alerts: { From 20a6fdeca587bf844af9043dc4a484ce515437b5 Mon Sep 17 00:00:00 2001 From: nick8319 Date: Mon, 12 Feb 2024 18:41:52 +0800 Subject: [PATCH 04/65] feat: add price support for: Moonbeam Linea Neon Scroll Berachain Holesky Kroma OpStack (OP Sepolia, Zora, Mode) Wemix Zeta Blast Harmony Oasis Rootstock --- src/config/entities/configuration.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index e0b6ea8e86..becdeaa81e 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -188,6 +188,34 @@ export default () => ({ 8453: { nativeCoin: 'ethereum', chainName: 'base' }, 84531: { nativeCoin: 'ethereum', chainName: 'base' }, 84532: { nativeCoin: 'ethereum', chainName: 'base' }, + 1284: { nativeCoin: 'moonbeam', chainName: 'moonbeam' }, + 1285: { nativeCoin: 'moonriver', chainName: 'moonriver' }, + 1287: { nativeCoin: 'moonbeam', chainName: 'moonbeam' }, + 59144: { nativeCoin: 'ethereum', chainName: 'linea' }, + 59140: { nativeCoin: 'ethereum', chainName: 'linea' }, + 245022934: { nativeCoin: 'neon', chainName: 'neon-evm' }, + 534352: { nativeCoin: 'ethereum', chainName: 'scroll' }, + 534351: { nativeCoin: 'ethereum', chainName: 'scroll' }, + 80085: { nativeCoin: 'berachain-bera', chainName: 'berachain' }, + 17000: { nativeCoin: 'ethereum', chainName: 'ethereum' }, + 255: { nativeCoin: 'ethereum', chainName: 'kroma' }, + 2358: { nativeCoin: 'ethereum', chainName: 'kroma' }, + 11155420: { nativeCoin: 'ethereum', chainName: 'ethereum' }, + 7777777: { nativeCoin: 'ethereum', chainName: 'zorachain' }, + 999999999: { nativeCoin: 'ethereum', chainName: 'zorachain' }, + 34443: { nativeCoin: 'ethereum', chainName: 'modechain' }, + 919: { nativeCoin: 'ethereum', chainName: 'modechain' }, + 1111: { nativeCoin: 'wemix-token', chainName: 'wemix-network' }, + 1112: { nativeCoin: 'wemix-token', chainName: 'wemix-network' }, + 7000: { nativeCoin: 'zetachain', chainName: 'zetachain' }, + 7001: { nativeCoin: 'zetachain', chainName: 'zetachain' }, + 168587773: { nativeCoin: 'ethereum', chainName: 'blast' }, + 1666600000: { nativeCoin: 'harmony', chainName: 'harmony-shard-0' }, + 1666700000: { nativeCoin: 'harmony', chainName: 'harmony-shard-0' }, + 23294: { nativeCoin: 'oasis', chainName: 'oasis-network' }, + 23295: { nativeCoin: 'oasis', chainName: 'oasis-network' }, + 30: { nativeCoin: 'rootstock', chainName: 'rootstock' }, + 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, }, }, redis: { From 20123da45124ac10d2426e9f69d8ff1d13c5aa8e Mon Sep 17 00:00:00 2001 From: nick8319 Date: Tue, 13 Feb 2024 21:02:32 +0800 Subject: [PATCH 05/65] fix: version --- package.json | 2 +- src/config/entities/configuration.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index fbbfbfdca7..50962d439f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "safe-client-gateway", "description": "", - "version": "1.26.0", + "version": "1.27.0", "private": true, "license": "MIT", "scripts": { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index becdeaa81e..2cd1def352 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -3,7 +3,7 @@ export default () => ({ about: { name: 'safe-client-gateway', - version: process.env.APPLICATION_VERSION || 'v1.26.0', + version: process.env.APPLICATION_VERSION || 'v1.27.0', buildNumber: process.env.APPLICATION_BUILD_NUMBER, }, alerts: { From 0f71fb6733d03313ce3a8211bc8296446b5ac9a5 Mon Sep 17 00:00:00 2001 From: nick8319 Date: Wed, 14 Feb 2024 16:16:07 +0800 Subject: [PATCH 06/65] fix: price service for oasis --- src/config/entities/configuration.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 2cd1def352..51be9fce72 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -212,8 +212,8 @@ export default () => ({ 168587773: { nativeCoin: 'ethereum', chainName: 'blast' }, 1666600000: { nativeCoin: 'harmony', chainName: 'harmony-shard-0' }, 1666700000: { nativeCoin: 'harmony', chainName: 'harmony-shard-0' }, - 23294: { nativeCoin: 'oasis', chainName: 'oasis-network' }, - 23295: { nativeCoin: 'oasis', chainName: 'oasis-network' }, + 23294: { nativeCoin: 'oasis-network', chainName: 'oasis' }, + 23295: { nativeCoin: 'oasis-network', chainName: 'oasis' }, 30: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, }, From 281f64aad0eebdd6dd2b9ce97598695ce076b3cb Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Wed, 14 Feb 2024 15:19:45 +0700 Subject: [PATCH 07/65] feat: add eth price for tangible unreal testnet eth --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 51be9fce72..e79ec905e9 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -216,6 +216,7 @@ export default () => ({ 23295: { nativeCoin: 'oasis-network', chainName: 'oasis' }, 30: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, + 18231: { nativeCoin: 'ethereum', chainName: 'unreal' }, }, }, redis: { From 6a8a78fede95e98081a2bb375bb7af8b0b7b9e02 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Wed, 14 Feb 2024 16:16:21 +0700 Subject: [PATCH 08/65] feat: add eth price for taiko katla testnet eth --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index e79ec905e9..2bf638190e 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -217,6 +217,7 @@ export default () => ({ 30: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 18231: { nativeCoin: 'ethereum', chainName: 'unreal' }, + 167008: { nativeCoin: 'ethereum', chainName: 'tko-katla' }, }, }, redis: { From 171acf18fbfcd9cbce48a06cbffe5dedada1844b Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Mon, 19 Feb 2024 17:30:27 +0700 Subject: [PATCH 09/65] feat: add sei price on devnet --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 2bf638190e..b3a39dfdb7 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -218,6 +218,7 @@ export default () => ({ 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 18231: { nativeCoin: 'ethereum', chainName: 'unreal' }, 167008: { nativeCoin: 'ethereum', chainName: 'tko-katla' }, + 713715: { nativeCoin: 'sei-network', chainName: 'sei-network' }, }, }, redis: { From ef6e4040d9958b1749d89e0e8458f001e2e61352 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Fri, 23 Feb 2024 18:18:05 +0700 Subject: [PATCH 10/65] feat: add eth price for lisk sepolia testnet eth --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index b3a39dfdb7..196f52357b 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -219,6 +219,7 @@ export default () => ({ 18231: { nativeCoin: 'ethereum', chainName: 'unreal' }, 167008: { nativeCoin: 'ethereum', chainName: 'tko-katla' }, 713715: { nativeCoin: 'sei-network', chainName: 'sei-network' }, + 4202: { nativeCoin: 'ethereum', chainName: 'lisksep' }, }, }, redis: { From 8ad1a9510642a2e0a5ab02b88f08782b5cd1f158 Mon Sep 17 00:00:00 2001 From: nick8319 Date: Tue, 27 Feb 2024 18:13:38 -0500 Subject: [PATCH 11/65] feat: add blast mainnet price service --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 196f52357b..d75dbcba7b 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -220,6 +220,7 @@ export default () => ({ 167008: { nativeCoin: 'ethereum', chainName: 'tko-katla' }, 713715: { nativeCoin: 'sei-network', chainName: 'sei-network' }, 4202: { nativeCoin: 'ethereum', chainName: 'lisksep' }, + 81457: { nativeCoin: 'ethereum', chainName: 'blast' }, }, }, redis: { From 4f5c2de423031b57fbeeb10a14c7667f21c642e3 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Mon, 11 Mar 2024 21:11:44 +0700 Subject: [PATCH 12/65] feat: add eth price for Fraxtal Mainnet eth --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index d75dbcba7b..9a59b9d474 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -221,6 +221,7 @@ export default () => ({ 713715: { nativeCoin: 'sei-network', chainName: 'sei-network' }, 4202: { nativeCoin: 'ethereum', chainName: 'lisksep' }, 81457: { nativeCoin: 'ethereum', chainName: 'blast' }, + 252: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, }, }, redis: { From dd564bef92ab806fa6bc210041824f7f9f054e49 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Fri, 15 Mar 2024 16:37:54 +0700 Subject: [PATCH 13/65] feat: add eth price for PGN Mainnet eth --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 9a59b9d474..c420cdd3ea 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -222,6 +222,7 @@ export default () => ({ 4202: { nativeCoin: 'ethereum', chainName: 'lisksep' }, 81457: { nativeCoin: 'ethereum', chainName: 'blast' }, 252: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, + 424: { nativeCoin: 'ethereum', chainName: 'PGN' }, }, }, redis: { From e48e63b2d1cfa7261ef652b1a4ac7df3ae21e7e3 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Fri, 15 Mar 2024 20:19:51 +0700 Subject: [PATCH 14/65] fix: update unreal chain id --- src/config/entities/configuration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index c420cdd3ea..401124803d 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -216,7 +216,7 @@ export default () => ({ 23295: { nativeCoin: 'oasis-network', chainName: 'oasis' }, 30: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, - 18231: { nativeCoin: 'ethereum', chainName: 'unreal' }, + 18233: { nativeCoin: 'ethereum', chainName: 'unreal' }, 167008: { nativeCoin: 'ethereum', chainName: 'tko-katla' }, 713715: { nativeCoin: 'sei-network', chainName: 'sei-network' }, 4202: { nativeCoin: 'ethereum', chainName: 'lisksep' }, From 2128fe6817edf6fab52a31833f6da3a9f090e2ea Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Mon, 18 Mar 2024 19:33:23 +0700 Subject: [PATCH 15/65] feat: add eth price for re.al Mainnet eth --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 401124803d..d6d8ab2e2b 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -217,6 +217,7 @@ export default () => ({ 30: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 18233: { nativeCoin: 'ethereum', chainName: 'unreal' }, + 111188: { nativeCoin: 'ethereum', chainName: 're-al' }, 167008: { nativeCoin: 'ethereum', chainName: 'tko-katla' }, 713715: { nativeCoin: 'sei-network', chainName: 'sei-network' }, 4202: { nativeCoin: 'ethereum', chainName: 'lisksep' }, From f41cc251642702b76a7bc7fbac27af77f31cb5d8 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Tue, 19 Mar 2024 15:58:17 +0700 Subject: [PATCH 16/65] feat: add eth price for Reya Mainnet eth --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index d6d8ab2e2b..a1d1084f29 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -224,6 +224,7 @@ export default () => ({ 81457: { nativeCoin: 'ethereum', chainName: 'blast' }, 252: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, 424: { nativeCoin: 'ethereum', chainName: 'PGN' }, + 1729: { nativeCoin: 'ethereum', chainName: 'reya' }, }, }, redis: { From dffa9b1a43da7408a13c1b57a7e01f83affe4d11 Mon Sep 17 00:00:00 2001 From: nick8319 Date: Sat, 23 Mar 2024 15:52:12 +0100 Subject: [PATCH 17/65] feat: add missing price configuration --- src/config/entities/configuration.ts | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 12dec40a22..62deb5a10f 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -50,6 +50,43 @@ export default () => ({ 8453: { nativeCoin: 'ethereum', chainName: 'base' }, 84531: { nativeCoin: 'ethereum', chainName: 'base' }, 84532: { nativeCoin: 'ethereum', chainName: 'base' }, + 1284: { nativeCoin: 'moonbeam', chainName: 'moonbeam' }, + 1285: { nativeCoin: 'moonriver', chainName: 'moonriver' }, + 1287: { nativeCoin: 'moonbeam', chainName: 'moonbeam' }, + 59144: { nativeCoin: 'ethereum', chainName: 'linea' }, + 59140: { nativeCoin: 'ethereum', chainName: 'linea' }, + 245022934: { nativeCoin: 'neon', chainName: 'neon-evm' }, + 534352: { nativeCoin: 'ethereum', chainName: 'scroll' }, + 534351: { nativeCoin: 'ethereum', chainName: 'scroll' }, + 80085: { nativeCoin: 'berachain-bera', chainName: 'berachain' }, + 17000: { nativeCoin: 'ethereum', chainName: 'ethereum' }, + 255: { nativeCoin: 'ethereum', chainName: 'kroma' }, + 2358: { nativeCoin: 'ethereum', chainName: 'kroma' }, + 11155420: { nativeCoin: 'ethereum', chainName: 'ethereum' }, + 7777777: { nativeCoin: 'ethereum', chainName: 'zorachain' }, + 999999999: { nativeCoin: 'ethereum', chainName: 'zorachain' }, + 34443: { nativeCoin: 'ethereum', chainName: 'modechain' }, + 919: { nativeCoin: 'ethereum', chainName: 'modechain' }, + 1111: { nativeCoin: 'wemix-token', chainName: 'wemix-network' }, + 1112: { nativeCoin: 'wemix-token', chainName: 'wemix-network' }, + 7000: { nativeCoin: 'zetachain', chainName: 'zetachain' }, + 7001: { nativeCoin: 'zetachain', chainName: 'zetachain' }, + 168587773: { nativeCoin: 'ethereum', chainName: 'blast' }, + 1666600000: { nativeCoin: 'harmony', chainName: 'harmony-shard-0' }, + 1666700000: { nativeCoin: 'harmony', chainName: 'harmony-shard-0' }, + 23294: { nativeCoin: 'oasis-network', chainName: 'oasis' }, + 23295: { nativeCoin: 'oasis-network', chainName: 'oasis' }, + 30: { nativeCoin: 'rootstock', chainName: 'rootstock' }, + 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, + 18233: { nativeCoin: 'ethereum', chainName: 'unreal' }, + 111188: { nativeCoin: 'ethereum', chainName: 're-al' }, + 167008: { nativeCoin: 'ethereum', chainName: 'tko-katla' }, + 713715: { nativeCoin: 'sei-network', chainName: 'sei-network' }, + 4202: { nativeCoin: 'ethereum', chainName: 'lisksep' }, + 81457: { nativeCoin: 'ethereum', chainName: 'blast' }, + 252: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, + 424: { nativeCoin: 'ethereum', chainName: 'PGN' }, + 1729: { nativeCoin: 'ethereum', chainName: 'reya' }, }, }, }, From a5770af202118a3a2c36d303f51f15b3d2380148 Mon Sep 17 00:00:00 2001 From: nick8319 Date: Sat, 23 Mar 2024 15:53:05 +0100 Subject: [PATCH 18/65] fix: version --- package.json | 2 +- src/config/entities/configuration.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8032e9d279..f7d01d734d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "safe-client-gateway", "description": "", - "version": "1.27.0", + "version": "1.31.0", "private": true, "license": "MIT", "scripts": { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 62deb5a10f..cff7b2390a 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -3,7 +3,7 @@ export default () => ({ about: { name: 'safe-client-gateway', - version: process.env.APPLICATION_VERSION || 'v1.27.0', + version: process.env.APPLICATION_VERSION || 'v1.31.0', buildNumber: process.env.APPLICATION_BUILD_NUMBER, }, alerts: { From f53904891a452973572bf05325488837b65ebb3e Mon Sep 17 00:00:00 2001 From: nick8319 Date: Sat, 23 Mar 2024 20:52:23 +0100 Subject: [PATCH 19/65] fix: version --- package.json | 2 +- src/config/entities/configuration.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f7d01d734d..962810e76e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "safe-client-gateway", "description": "", - "version": "1.31.0", + "version": "1.30.0", "private": true, "license": "MIT", "scripts": { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index cff7b2390a..1d1c5aa296 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -3,7 +3,7 @@ export default () => ({ about: { name: 'safe-client-gateway', - version: process.env.APPLICATION_VERSION || 'v1.31.0', + version: process.env.APPLICATION_VERSION || 'v1.30.0', buildNumber: process.env.APPLICATION_BUILD_NUMBER, }, alerts: { From ebf041a1f8653d14560ee5ce879bc029778f0824 Mon Sep 17 00:00:00 2001 From: nick8319 Date: Tue, 26 Mar 2024 15:31:12 +0100 Subject: [PATCH 20/65] feat: add zklink price configuration --- src/config/entities/configuration.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 1d1c5aa296..035b2d1dc7 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -87,6 +87,8 @@ export default () => ({ 252: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, 424: { nativeCoin: 'ethereum', chainName: 'PGN' }, 1729: { nativeCoin: 'ethereum', chainName: 'reya' }, + 810180: { nativeCoin: 'ethereum', chainName: 'zklink' }, + 810182: { nativeCoin: 'ethereum', chainName: 'zklink' }, }, }, }, From 54e96e6ab0e9659eb813fece1fd22efa7b210760 Mon Sep 17 00:00:00 2001 From: nick8319 Date: Thu, 4 Apr 2024 18:08:18 +0200 Subject: [PATCH 21/65] feat: add crossfi price configuration --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 035b2d1dc7..4698a0d9fe 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -89,6 +89,7 @@ export default () => ({ 1729: { nativeCoin: 'ethereum', chainName: 'reya' }, 810180: { nativeCoin: 'ethereum', chainName: 'zklink' }, 810182: { nativeCoin: 'ethereum', chainName: 'zklink' }, + 4157: { nativeCoin: 'crossfi-2', chainName: 'crossfi-2' }, }, }, }, From 07e2359127e17b84e97ef1acef6b8aa626b56d17 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Mon, 15 Apr 2024 18:59:59 +0800 Subject: [PATCH 22/65] feat: add eth price for Fraxtal Sepolia eth --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 4698a0d9fe..af45fca3a4 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -90,6 +90,7 @@ export default () => ({ 810180: { nativeCoin: 'ethereum', chainName: 'zklink' }, 810182: { nativeCoin: 'ethereum', chainName: 'zklink' }, 4157: { nativeCoin: 'crossfi-2', chainName: 'crossfi-2' }, + 2523: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, }, }, }, From 42ef7c0c83564765508c26b2fdeb2ae6e99638a1 Mon Sep 17 00:00:00 2001 From: nick8319 Date: Tue, 16 Apr 2024 13:20:07 +0200 Subject: [PATCH 23/65] feat: add price for BOB --- src/config/entities/configuration.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index af45fca3a4..164338695c 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -91,6 +91,8 @@ export default () => ({ 810182: { nativeCoin: 'ethereum', chainName: 'zklink' }, 4157: { nativeCoin: 'crossfi-2', chainName: 'crossfi-2' }, 2523: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, + 60808: { nativeCoin: 'ethereum', chainName: 'gobob' }, + 111: { nativeCoin: 'ethereum', chainName: 'gobob' }, }, }, }, From f2328c42085f82fc08ddeccf699786040077cab9 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Wed, 17 Apr 2024 23:34:46 +0800 Subject: [PATCH 24/65] feat: add eth price for OP Celestia Raspberry, Arbitrum Blueberry and Polygon Blackberry --- src/config/entities/configuration.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 164338695c..fb9527578c 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -93,6 +93,9 @@ export default () => ({ 2523: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, 60808: { nativeCoin: 'ethereum', chainName: 'gobob' }, 111: { nativeCoin: 'ethereum', chainName: 'gobob' }, + 123420111: { nativeCoin: 'ethereum', chainName: 'opcelestia-raspberry' }, + 88153591557: { nativeCoin: 'ethereum', chainName: 'arb-blueberry' }, + 94204209: { nativeCoin: 'ethereum', chainName: 'polygon-blackberry' }, }, }, }, From 0f9424920d6e07f3f070cc46b89e8bd5a9df3c59 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Mon, 29 Apr 2024 19:24:33 +0800 Subject: [PATCH 25/65] feat: add eth price for Redstone Mainnet and Garnet --- src/config/entities/configuration.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index fb9527578c..6a3b2da9e3 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -96,6 +96,8 @@ export default () => ({ 123420111: { nativeCoin: 'ethereum', chainName: 'opcelestia-raspberry' }, 88153591557: { nativeCoin: 'ethereum', chainName: 'arb-blueberry' }, 94204209: { nativeCoin: 'ethereum', chainName: 'polygon-blackberry' }, + 690: { nativeCoin: 'ethereum', chainName: 'redstone-mainnet' }, + 17069: { nativeCoin: 'ethereum', chainName: 'redstone-garnet' }, }, }, }, From e89a7a03e1442999e6108f2036116bb993b93083 Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Tue, 30 Apr 2024 12:26:49 +0800 Subject: [PATCH 26/65] fix: update a few chain names --- src/config/entities/configuration.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 6a3b2da9e3..7cb59bd6a1 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -63,10 +63,10 @@ export default () => ({ 255: { nativeCoin: 'ethereum', chainName: 'kroma' }, 2358: { nativeCoin: 'ethereum', chainName: 'kroma' }, 11155420: { nativeCoin: 'ethereum', chainName: 'ethereum' }, - 7777777: { nativeCoin: 'ethereum', chainName: 'zorachain' }, - 999999999: { nativeCoin: 'ethereum', chainName: 'zorachain' }, - 34443: { nativeCoin: 'ethereum', chainName: 'modechain' }, - 919: { nativeCoin: 'ethereum', chainName: 'modechain' }, + 7777777: { nativeCoin: 'ethereum', chainName: 'zora-network' }, + 999999999: { nativeCoin: 'ethereum', chainName: 'zora-network' }, + 34443: { nativeCoin: 'ethereum', chainName: 'mode' }, + 919: { nativeCoin: 'ethereum', chainName: 'mode' }, 1111: { nativeCoin: 'wemix-token', chainName: 'wemix-network' }, 1112: { nativeCoin: 'wemix-token', chainName: 'wemix-network' }, 7000: { nativeCoin: 'zetachain', chainName: 'zetachain' }, @@ -74,8 +74,8 @@ export default () => ({ 168587773: { nativeCoin: 'ethereum', chainName: 'blast' }, 1666600000: { nativeCoin: 'harmony', chainName: 'harmony-shard-0' }, 1666700000: { nativeCoin: 'harmony', chainName: 'harmony-shard-0' }, - 23294: { nativeCoin: 'oasis-network', chainName: 'oasis' }, - 23295: { nativeCoin: 'oasis-network', chainName: 'oasis' }, + 23294: { nativeCoin: 'oasis-network', chainName: 'oasis-sapphire' }, + 23295: { nativeCoin: 'oasis-network', chainName: 'oasis-sapphire' }, 30: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 18233: { nativeCoin: 'ethereum', chainName: 'unreal' }, From 5f68654155f45cd4fb5188e1f87947b7c5d810d0 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 7 May 2024 14:36:13 +0200 Subject: [PATCH 27/65] Add logging of headers to auth nonce route (#1520) Adds temporary `info` logging of `Request['headers' | 'originalUrl']` to `/v1/auth/nonce` route --- src/routes/auth/auth.controller.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/routes/auth/auth.controller.ts b/src/routes/auth/auth.controller.ts index 2af698d04a..92d14851d0 100644 --- a/src/routes/auth/auth.controller.ts +++ b/src/routes/auth/auth.controller.ts @@ -1,4 +1,13 @@ -import { Body, Controller, Get, Post, HttpCode, Res } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Post, + HttpCode, + Res, + Inject, + Req, +} from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { AuthService } from '@/routes/auth/auth.service'; @@ -6,8 +15,9 @@ import { VerifyAuthMessageDto, VerifyAuthMessageDtoSchema, } from '@/routes/auth/entities/verify-auth-message.dto.entity'; -import { Response } from 'express'; +import { Request, Response } from 'express'; import { getMillisecondsUntil } from '@/domain/common/utils/time'; +import { LoggingService, ILoggingService } from '@/logging/logging.interface'; /** * The AuthController is responsible for handling authentication: @@ -23,12 +33,18 @@ import { getMillisecondsUntil } from '@/domain/common/utils/time'; export class AuthController { static readonly ACCESS_TOKEN_COOKIE_NAME = 'access_token'; - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + @Inject(LoggingService) private readonly loggingService: ILoggingService, + ) {} @Get('nonce') - async getNonce(): Promise<{ + async getNonce(@Req() req: Request): Promise<{ nonce: string; }> { + // TODO: Remove after debugging + this.loggingService.info(req.originalUrl); + this.loggingService.info(req.headers); return this.authService.getNonce(); } From b5306a498c8238c062b7f0d66f12ea23f6f1e90c Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 7 May 2024 14:51:56 +0200 Subject: [PATCH 28/65] Only return awaiting confirmations count if it is required (#1507) Takes the `confirmationsRequired` into account when increasing the awaiting confirmation count of Safe overviews. --- .../safes/safes.controller.overview.spec.ts | 178 ++++++++++++++++++ src/routes/safes/safes.service.ts | 21 ++- 2 files changed, 193 insertions(+), 6 deletions(-) diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index d944cb70c1..c199c39a3c 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -131,6 +131,7 @@ describe('Safes Controller Overview (Unit)', () => { const multisigTransactions = [ multisigTransactionToJson( multisigTransactionBuilder() + .with('confirmationsRequired', 0) .with('confirmations', [ // Signature provided confirmationBuilder().with('owner', walletAddress).build(), @@ -251,6 +252,183 @@ describe('Safes Controller Overview (Unit)', () => { ); }); + it('should not return awaiting confirmations if no more confirmations are required', async () => { + const chain = chainBuilder().with('chainId', '10').build(); + const safeInfo = safeBuilder().build(); + const tokenAddress = faker.finance.ethereumAddress(); + const secondTokenAddress = faker.finance.ethereumAddress(); + const transactionApiBalancesResponse = [ + balanceBuilder() + .with('tokenAddress', null) + .with('balance', '3000000000000000000') + .with('token', null) + .build(), + balanceBuilder() + .with('tokenAddress', getAddress(tokenAddress)) + .with('balance', '4000000000000000000') + .with('token', balanceTokenBuilder().with('decimals', 17).build()) + .build(), + balanceBuilder() + .with('tokenAddress', getAddress(secondTokenAddress)) + .with('balance', '3000000000000000000') + .with('token', balanceTokenBuilder().with('decimals', 17).build()) + .build(), + ]; + const nativeCoinId = app + .get(IConfigurationService) + .getOrThrow( + `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, + ); + const chainName = app + .get(IConfigurationService) + .getOrThrow( + `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, + ); + const currency = faker.finance.currencyCode(); + const nativeCoinPriceProviderResponse = { + [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + }; + const tokenPriceProviderResponse = { + [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, + [secondTokenAddress]: { [currency.toLowerCase()]: 10 }, + }; + const walletAddress = getAddress(faker.finance.ethereumAddress()); + const multisigTransactions = [ + multisigTransactionToJson( + multisigTransactionBuilder() + .with('confirmationsRequired', 0) + .with('confirmations', [ + // Not wallet address + confirmationBuilder() + .with('owner', getAddress(faker.finance.ethereumAddress())) + .build(), + ]) + .build(), + ), + multisigTransactionToJson( + multisigTransactionBuilder() + .with('confirmationsRequired', 0) + .with('confirmations', [ + // Not wallet address + confirmationBuilder() + .with('owner', getAddress(faker.finance.ethereumAddress())) + .build(), + ]) + .build(), + ), + ]; + const queuedTransactions = pageBuilder() + .with('results', multisigTransactions) + .with('count', multisigTransactions.length) + .build(); + + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: { + return Promise.resolve({ data: chain, status: 200 }); + } + case `${chain.transactionService}/api/v1/safes/${safeInfo.address}`: { + return Promise.resolve({ data: safeInfo, status: 200 }); + } + case `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`: { + return Promise.resolve({ + data: transactionApiBalancesResponse, + status: 200, + }); + } + case `${pricesProviderUrl}/simple/price`: { + return Promise.resolve({ + data: nativeCoinPriceProviderResponse, + status: 200, + }); + } + case `${pricesProviderUrl}/simple/token_price/${chainName}`: { + return Promise.resolve({ + data: tokenPriceProviderResponse, + status: 200, + }); + } + case `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`: { + return Promise.resolve({ + data: queuedTransactions, + status: 200, + }); + } + default: { + return Promise.reject(`No matching rule for url: ${url}`); + } + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/safes?currency=${currency}&safes=${chain.chainId}:${safeInfo.address}&wallet_address=${walletAddress}`, + ) + .expect(200) + .expect(({ body }) => + expect(body).toMatchObject([ + { + address: { + value: safeInfo.address, + name: null, + logoUri: null, + }, + chainId: chain.chainId, + threshold: safeInfo.threshold, + owners: safeInfo.owners.map((owner) => ({ + value: owner, + name: null, + logoUri: null, + })), + fiatTotal: '5410.25', + queued: 2, + awaitingConfirmation: 0, + }, + ]), + ); + + expect(networkService.get.mock.calls.length).toBe(7); + + expect(networkService.get.mock.calls[0][0].url).toBe( + `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, + ); + expect(networkService.get.mock.calls[1][0].url).toBe( + `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, + ); + expect(networkService.get.mock.calls[2][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, + ); + expect(networkService.get.mock.calls[3][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, + ); + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + params: { trusted: false, exclude_spam: true }, + }); + expect(networkService.get.mock.calls[4][0].url).toBe( + `${pricesProviderUrl}/simple/token_price/${chainName}`, + ); + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ + headers: { 'x-cg-pro-api-key': pricesApiKey }, + params: { + vs_currencies: currency.toLowerCase(), + contract_addresses: [ + tokenAddress.toLowerCase(), + secondTokenAddress.toLowerCase(), + ].join(','), + }, + }); + expect(networkService.get.mock.calls[5][0].url).toBe( + `${pricesProviderUrl}/simple/price`, + ); + expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ + headers: { 'x-cg-pro-api-key': pricesApiKey }, + params: { ids: nativeCoinId, vs_currencies: currency.toLowerCase() }, + }); + expect(networkService.get.mock.calls[6][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, + ); + }); + it('overview of multiple Safes across different chains is correctly serialised', async () => { const chain1 = chainBuilder().with('chainId', '1').build(); const chain2 = chainBuilder().with('chainId', '10').build(); diff --git a/src/routes/safes/safes.service.ts b/src/routes/safes/safes.service.ts index 35e24a9c95..fcba202934 100644 --- a/src/routes/safes/safes.service.ts +++ b/src/routes/safes/safes.service.ts @@ -206,12 +206,21 @@ export class SafesService { transactions: Array; walletAddress: string; }): number { - return args.transactions.reduce((acc, { confirmations }) => { - const isConfirmed = !!confirmations?.some((confirmation) => { - return confirmation.owner === args.walletAddress; - }); - return isConfirmed ? acc - 1 : acc; - }, args.transactions.length); + return args.transactions.reduce( + (acc, { confirmationsRequired, confirmations }) => { + const isConfirmed = confirmationsRequired === 0; + const isSignable = + !isConfirmed && + !confirmations?.some((confirmation) => { + return confirmation.owner === args.walletAddress; + }); + if (isSignable) { + acc++; + } + return acc; + }, + 0, + ); } private toUnixTimestampInSecondsOrNull(date: Date | null): string | null { From 30f8ada4d14f2a2c5a3e552a7236a40597a227a7 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 7 May 2024 14:52:41 +0200 Subject: [PATCH 29/65] Validate overview `walletAddress` query (#1509) Adds optional `AddressSchema` validation of the `walletAddress` query param on the overview endpoint. --- src/routes/safes/safes.controller.ts | 6 ++++-- src/routes/safes/safes.service.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/routes/safes/safes.controller.ts b/src/routes/safes/safes.controller.ts index 5771236996..fd47bad51d 100644 --- a/src/routes/safes/safes.controller.ts +++ b/src/routes/safes/safes.controller.ts @@ -12,6 +12,8 @@ import { SafesService } from '@/routes/safes/safes.service'; import { SafeNonces } from '@/routes/safes/entities/nonces.entity'; import { SafeOverview } from '@/routes/safes/entities/safe-overview.entity'; import { Caip10AddressesPipe } from '@/routes/safes/pipes/caip-10-addresses.pipe'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('safes') @Controller({ @@ -48,8 +50,8 @@ export class SafesController { trusted: boolean, @Query('exclude_spam', new DefaultValuePipe(true), ParseBoolPipe) excludeSpam: boolean, - @Query('wallet_address') - walletAddress?: string, + @Query('wallet_address', new ValidationPipe(AddressSchema.optional())) + walletAddress?: `0x${string}`, ): Promise> { return this.service.getSafeOverview({ currency, diff --git a/src/routes/safes/safes.service.ts b/src/routes/safes/safes.service.ts index fcba202934..6fd343f549 100644 --- a/src/routes/safes/safes.service.ts +++ b/src/routes/safes/safes.service.ts @@ -132,7 +132,7 @@ export class SafesService { addresses: Array<{ chainId: string; address: string }>; trusted: boolean; excludeSpam: boolean; - walletAddress?: string; + walletAddress?: `0x${string}`; }): Promise> { const limitedSafes = args.addresses.slice(0, this.maxOverviews); @@ -204,7 +204,7 @@ export class SafesService { private computeAwaitingConfirmation(args: { transactions: Array; - walletAddress: string; + walletAddress: `0x${string}`; }): number { return args.transactions.reduce( (acc, { confirmationsRequired, confirmations }) => { From d51ccf6837a81a64fc81911f684e5cb442bad624 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 7 May 2024 15:16:48 +0200 Subject: [PATCH 30/65] Log `Origin` on auth nonce route (#1521) Adds logging of `Origin` to `/v1/auth/nonce` route --- src/routes/auth/auth.controller.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/routes/auth/auth.controller.ts b/src/routes/auth/auth.controller.ts index 92d14851d0..c8c64148ae 100644 --- a/src/routes/auth/auth.controller.ts +++ b/src/routes/auth/auth.controller.ts @@ -43,8 +43,7 @@ export class AuthController { nonce: string; }> { // TODO: Remove after debugging - this.loggingService.info(req.originalUrl); - this.loggingService.info(req.headers); + this.loggingService.info(`/v1/auth/nonce, Origin: ${req.header('Origin')}`); return this.authService.getNonce(); } From 257894c2e3a949d64dbaf6852243acadbbfc06c1 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 7 May 2024 17:13:45 +0200 Subject: [PATCH 31/65] Authorise email editing relative to JWT authentication (#1470) Migrates the suboptiomal authentication/authorisation for editing emails to the new JWT/SIWE-based approach: - Remove `EmailEditGuard` and replace it with `AuthGuard` - Add `EditEmailDto` - Checksum incoming `signer` address on deletion request - Pass `AuthPayload` to domain and assert chain and signer before editing an email address - Add/update associated test coverage --- .../account/account.repository.interface.ts | 4 +- src/domain/account/account.repository.ts | 11 +- .../email/email.controller.edit-email.spec.ts | 475 ++++++++++++------ src/routes/email/email.controller.ts | 11 +- src/routes/email/email.service.ts | 3 +- .../__tests__/edit-email-dto.entity.spec.ts | 34 ++ .../email/entities/edit-email-dto.entity.ts | 7 +- .../email/guards/email-edit.guard.spec.ts | 204 -------- src/routes/email/guards/email-edit.guard.ts | 71 --- 9 files changed, 391 insertions(+), 429 deletions(-) create mode 100644 src/routes/email/entities/__tests__/edit-email-dto.entity.spec.ts delete mode 100644 src/routes/email/guards/email-edit.guard.spec.ts delete mode 100644 src/routes/email/guards/email-edit.guard.ts diff --git a/src/domain/account/account.repository.interface.ts b/src/domain/account/account.repository.interface.ts index ec414ddef3..15f78a4171 100644 --- a/src/domain/account/account.repository.interface.ts +++ b/src/domain/account/account.repository.interface.ts @@ -92,6 +92,7 @@ export interface IAccountRepository { * @param args.safeAddress - the Safe address to which we should store the email address * @param args.emailAddress - the email address to store * @param args.signer - the owner address to which we should link the email address to + * @param args.authPayload - the payload to use for authorization * * @throws {EmailEditMatchesError} - if trying to apply edit with same email address as current one */ @@ -99,6 +100,7 @@ export interface IAccountRepository { chainId: string; safeAddress: string; emailAddress: string; - signer: string; + signer: `0x${string}`; + authPayload: AuthPayload; }): Promise; } diff --git a/src/domain/account/account.repository.ts b/src/domain/account/account.repository.ts index 4899f355e2..9e074696dc 100644 --- a/src/domain/account/account.repository.ts +++ b/src/domain/account/account.repository.ts @@ -325,10 +325,19 @@ export class AccountRepository implements IAccountRepository { chainId: string; safeAddress: string; emailAddress: string; - signer: string; + signer: `0x${string}`; + authPayload: AuthPayload; }): Promise { const safeAddress = getAddress(args.safeAddress); const signer = getAddress(args.signer); + + if ( + !args.authPayload.isForChain(args.chainId) || + !args.authPayload.isForSigner(signer) + ) { + throw new UnauthorizedException(); + } + const account = await this.accountDataSource.getAccount({ chainId: args.chainId, safeAddress, diff --git a/src/routes/email/email.controller.edit-email.spec.ts b/src/routes/email/email.controller.edit-email.spec.ts index d459852c6e..8515e4959e 100644 --- a/src/routes/email/email.controller.edit-email.spec.ts +++ b/src/routes/email/email.controller.edit-email.spec.ts @@ -12,7 +12,6 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import * as request from 'supertest'; import { faker } from '@faker-js/faker'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { INetworkService, @@ -35,6 +34,9 @@ import { JWT_CONFIGURATION_MODULE, JwtConfigurationModule, } from '@/datasources/jwt/configuration/jwt.configuration.module'; +import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; +import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; +import { getSecondsUntil } from '@/domain/common/utils/time'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -45,6 +47,7 @@ describe('Email controller edit email tests', () => { let safeConfigUrl: string; let accountDataSource: jest.MockedObjectDeep; let networkService: jest.MockedObjectDeep; + let jwtService: IJwtService; beforeEach(async () => { jest.resetAllMocks(); @@ -86,6 +89,7 @@ describe('Email controller edit email tests', () => { safeConfigUrl = configurationService.get('safeConfig.baseUri'); accountDataSource = moduleFixture.get(IAccountDataSource); networkService = moduleFixture.get(NetworkService); + jwtService = moduleFixture.get(IJwtService); app = await new TestAppProvider().provide(moduleFixture); await app.init(); @@ -108,16 +112,16 @@ describe('Email controller edit email tests', () => { const chain = chainBuilder().build(); const prevEmailAddress = faker.internet.email(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; const safe = safeBuilder() // Allow test of non-checksummed address by casting .with('address', safeAddress as `0x${string}`) .build(); - const message = `email-edit-${chain.chainId}-${safe.address}-${emailAddress}-${signerAddress}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: @@ -133,7 +137,7 @@ describe('Email controller edit email tests', () => { .with('chainId', chain.chainId) .with('signer', signerAddress) .with('isVerified', true) - .with('safeAddress', getAddress(safe.address)) + .with('safeAddress', safe.address) .with('emailAddress', new EmailAddress(prevEmailAddress)) .build(), ); @@ -145,10 +149,9 @@ describe('Email controller edit email tests', () => { await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signer.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) @@ -188,146 +191,312 @@ describe('Email controller edit email tests', () => { // TODO: validate that `IEmailApi.createMessage` is triggered with the correct code }); - it('should return 409 if trying to edit with the same email', async () => { + it('returns 403 if no token is present', async () => { const chain = chainBuilder().build(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; const safe = safeBuilder().build(); - const message = `email-edit-${chain.chainId}-${safe.address}-${emailAddress}-${signerAddress}-${timestamp}`; - const signature = await signer.signMessage({ message }); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - accountDataSource.getAccount.mockResolvedValue({ - emailAddress: new EmailAddress(emailAddress), - } as Account); + const signerAddress = safe.owners[0]; await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signer.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) .send({ emailAddress, }) - .expect(409) - .expect({ - statusCode: 409, - message: 'Email address matches that of the Safe owner.', - }); + .expect(403); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); expect( accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(0); + ).not.toHaveBeenCalled(); }); - it('returns 422 if Safe address is not a valid Ethereum address', async () => { + it('returns 403 if token is not a valid JWT', async () => { const chain = chainBuilder().build(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; - const invalidSafeAddress = faker.word.sample(); - const message = `email-edit-${chain.chainId}-${invalidSafeAddress}-${emailAddress}-${signerAddress}-${timestamp}`; - const signature = await signer.signMessage({ message }); - accountDataSource.getAccount.mockResolvedValue({ - emailAddress: new EmailAddress(emailAddress), - } as Account); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const accessToken = faker.string.alphanumeric(); + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${invalidSafeAddress}/emails/${signer.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) - .expect(422) - .expect({ - message: `Address "${invalidSafeAddress}" is invalid.`, - error: 'Unprocessable Entity', - statusCode: 422, - }); + .expect(403); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); expect( accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(0); + ).not.toHaveBeenCalled(); }); - it('should return 404 if trying to edit a non-existent email entry', async () => { + it('returns 403 if token is not yet valid', async () => { const chain = chainBuilder().build(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; const safe = safeBuilder().build(); - const message = `email-edit-${chain.chainId}-${safe.address}-${emailAddress}-${signerAddress}-${timestamp}`; - const signature = await signer.signMessage({ message }); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const notBefore = faker.date.future(); + const accessToken = jwtService.sign(authPayloadDto, { + notBefore: getSecondsUntil(notBefore), }); - accountDataSource.getAccount.mockRejectedValue( - new AccountDoesNotExistError(chain.chainId, safe.address, signerAddress), - ); + expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signer.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) - .expect(404) - .expect({ - statusCode: 404, - message: `No email address was found for the provided signer ${signerAddress}.`, - }); + .expect(403); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); expect( accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(0); + ).not.toHaveBeenCalled(); }); - it('return 500 if updating fails in general', async () => { + it('returns 403 if token has expired', async () => { + const chain = chainBuilder().build(); + const emailAddress = faker.internet.email(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { + expiresIn: 0, // Now + }); + jest.advanceTimersByTime(1_000); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt expired'); + await request(app.getHttpServer()) + .put( + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ + emailAddress, + }) + .expect(403); + + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect( + accountDataSource.setEmailVerificationSentDate, + ).not.toHaveBeenCalled(); + }); + + it('returns 403 if signer_address is not a valid Ethereum address', async () => { + const chain = chainBuilder().build(); + const emailAddress = faker.internet.email(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', faker.string.numeric() as `0x${string}`) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .put( + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ + emailAddress, + }) + .expect(403); + + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect( + accountDataSource.setEmailVerificationSentDate, + ).not.toHaveBeenCalled(); + }); + + it('returns 403 if chain_id is not a valid chain ID', async () => { + const chain = chainBuilder().build(); + const emailAddress = faker.internet.email(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', faker.string.alpha()) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .put( + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ + emailAddress, + }) + .expect(403); + + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect( + accountDataSource.setEmailVerificationSentDate, + ).not.toHaveBeenCalled(); + }); + + // Note: this could be removed as we checksum the :signer but for robustness we should keep it + it.each([ + // non-checksummed address + { + signer_address: faker.finance.ethereumAddress().toLowerCase(), + }, + // checksummed address + { + signer_address: getAddress(faker.finance.ethereumAddress()), + }, + ])( + 'returns 401 if signer_address does not match a checksummed signer request', + async ({ signer_address }) => { + const chain = chainBuilder().build(); + const emailAddress = faker.internet.email(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signer_address as `0x${string}`) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .put( + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${ + // non-checksummed + signerAddress.toLowerCase() + }`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ + emailAddress, + }) + .expect(401); + + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect( + accountDataSource.setEmailVerificationSentDate, + ).not.toHaveBeenCalled(); + }, + ); + + it.each([ + // non-checksummed address + { + signer_address: faker.finance.ethereumAddress().toLowerCase(), + }, + // checksummed address + { + signer_address: getAddress(faker.finance.ethereumAddress()), + }, + ])( + 'returns 401 if signer_address does not match a non-checksummed signer request', + async ({ signer_address }) => { + const chain = chainBuilder().build(); + const emailAddress = faker.internet.email(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signer_address as `0x${string}`) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .put( + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${ + // checksummed + getAddress(signerAddress) + }`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ + emailAddress, + }) + .expect(401); + + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect( + accountDataSource.setEmailVerificationSentDate, + ).not.toHaveBeenCalled(); + }, + ); + + it('returns 401 if chain_id does not match the request', async () => { + const chain = chainBuilder().build(); + const emailAddress = faker.internet.email(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', faker.string.numeric({ exclude: [chain.chainId] })) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .put( + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ + emailAddress, + }) + .expect(401); + + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect( + accountDataSource.setEmailVerificationSentDate, + ).not.toHaveBeenCalled(); + }); + + it('should return 409 if trying to edit with the same email', async () => { const chain = chainBuilder().build(); - const prevEmailAddress = faker.internet.email(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; const safe = safeBuilder().build(); - const message = `email-edit-${chain.chainId}-${safe.address}-${emailAddress}-${signerAddress}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: @@ -339,59 +508,66 @@ describe('Email controller edit email tests', () => { } }); accountDataSource.getAccount.mockResolvedValue({ - emailAddress: new EmailAddress(prevEmailAddress), + emailAddress: new EmailAddress(emailAddress), } as Account); - accountDataSource.updateAccountEmail.mockRejectedValue(new Error()); await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signer.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) - .expect(500) + .expect(409) .expect({ - code: 500, - message: 'Internal server error', + statusCode: 409, + message: 'Email address matches that of the Safe owner.', }); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(1); + expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); expect( accountDataSource.setEmailVerificationSentDate, ).toHaveBeenCalledTimes(0); }); - it('returns 403 is message was signed with a timestamp older than 5 minutes', async () => { + it('should return 404 if trying to edit a non-existent email entry', async () => { const chain = chainBuilder().build(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const account = privateKeyToAccount(privateKey); - const accountAddress = account.address; const safe = safeBuilder().build(); - const message = `email-edit-${chain.chainId}-${safe.address}-${emailAddress}-${accountAddress}-${timestamp}`; - const signature = await account.signMessage({ message }); - - jest.advanceTimersByTime(5 * 60 * 1000); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ data: safe, status: 200 }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + accountDataSource.getAccount.mockRejectedValue( + new AccountDoesNotExistError(chain.chainId, safe.address, signerAddress), + ); await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${account.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) - .expect(403) + .expect(404) .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, + statusCode: 404, + message: `No email address was found for the provided signer ${signerAddress}.`, }); expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); @@ -401,34 +577,47 @@ describe('Email controller edit email tests', () => { ).toHaveBeenCalledTimes(0); }); - it('returns 403 on wrong message signature', async () => { + it('return 500 if updating fails in general', async () => { const chain = chainBuilder().build(); + const prevEmailAddress = faker.internet.email(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const account = privateKeyToAccount(privateKey); - const accountAddress = account.address; const safe = safeBuilder().build(); - const message = `some-action-${chain.chainId}-${safe.address}-${emailAddress}-${accountAddress}-${timestamp}`; - const signature = await account.signMessage({ message }); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ data: safe, status: 200 }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + accountDataSource.getAccount.mockResolvedValue({ + emailAddress: new EmailAddress(prevEmailAddress), + } as Account); + accountDataSource.updateAccountEmail.mockRejectedValue(new Error()); await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${account.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) - .expect(403) + .expect(500) .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, + code: 500, + message: 'Internal server error', }); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); + expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(1); expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); expect( accountDataSource.setEmailVerificationSentDate, diff --git a/src/routes/email/email.controller.ts b/src/routes/email/email.controller.ts index ac179c17a4..38d555a2fa 100644 --- a/src/routes/email/email.controller.ts +++ b/src/routes/email/email.controller.ts @@ -12,7 +12,6 @@ import { UseGuards, } from '@nestjs/common'; import { EmailService } from '@/routes/email/email.service'; -import { TimestampGuard } from '@/routes/email/guards/timestamp.guard'; import { SaveEmailDto, SaveEmailDtoSchema, @@ -21,7 +20,6 @@ import { ApiExcludeController, ApiTags } from '@nestjs/swagger'; import { VerifyEmailDto } from '@/routes/email/entities/verify-email-dto.entity'; import { AccountDoesNotExistExceptionFilter } from '@/routes/email/exception-filters/account-does-not-exist.exception-filter'; import { EditEmailDto } from '@/routes/email/entities/edit-email-dto.entity'; -import { EmailEditGuard } from '@/routes/email/guards/email-edit.guard'; import { EmailEditMatchesExceptionFilter } from '@/routes/email/exception-filters/email-edit-matches.exception-filter'; import { AuthGuard } from '@/routes/auth/guards/auth.guard'; import { Email } from '@/routes/email/entities/email.entity'; @@ -125,10 +123,7 @@ export class EmailController { } @Put(':signer') - @UseGuards( - EmailEditGuard, - TimestampGuard(5 * 60 * 1000), // 5 minutes - ) + @UseGuards(AuthGuard) @UseFilters( EmailEditMatchesExceptionFilter, AccountDoesNotExistExceptionFilter, @@ -137,14 +132,16 @@ export class EmailController { async editEmail( @Param('chainId') chainId: string, @Param('safeAddress') safeAddress: string, - @Param('signer') signer: string, + @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, @Body() editEmailDto: EditEmailDto, + @Auth() authPayload: AuthPayload, ): Promise { await this.service.editEmail({ chainId, safeAddress, signer, emailAddress: editEmailDto.emailAddress, + authPayload, }); } } diff --git a/src/routes/email/email.service.ts b/src/routes/email/email.service.ts index 5c6aae13f3..e5036be692 100644 --- a/src/routes/email/email.service.ts +++ b/src/routes/email/email.service.ts @@ -68,8 +68,9 @@ export class EmailService { async editEmail(args: { chainId: string; safeAddress: string; - signer: string; + signer: `0x${string}`; emailAddress: string; + authPayload: AuthPayload; }): Promise { return this.repository .editEmail(args) diff --git a/src/routes/email/entities/__tests__/edit-email-dto.entity.spec.ts b/src/routes/email/entities/__tests__/edit-email-dto.entity.spec.ts new file mode 100644 index 0000000000..bf80850c0c --- /dev/null +++ b/src/routes/email/entities/__tests__/edit-email-dto.entity.spec.ts @@ -0,0 +1,34 @@ +import { + EditEmailDto, + EditEmailDtoSchema, +} from '@/routes/email/entities/edit-email-dto.entity'; +import { faker } from '@faker-js/faker'; + +describe('EditEmailDtoSchema', () => { + it('should allow a valid EditEmailDto', () => { + const editEmailDto: EditEmailDto = { + emailAddress: faker.internet.email(), + }; + + const result = EditEmailDtoSchema.safeParse(editEmailDto); + + expect(result.success).toBe(true); + }); + + it('should not allow a non-email emailAddress', () => { + const editEmailDto: EditEmailDto = { + emailAddress: faker.lorem.word(), + }; + + const result = EditEmailDtoSchema.safeParse(editEmailDto); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_string', + message: 'Invalid email', + path: ['emailAddress'], + validation: 'email', + }, + ]); + }); +}); diff --git a/src/routes/email/entities/edit-email-dto.entity.ts b/src/routes/email/entities/edit-email-dto.entity.ts index 8eb9c11f31..20564a4f5b 100644 --- a/src/routes/email/entities/edit-email-dto.entity.ts +++ b/src/routes/email/entities/edit-email-dto.entity.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; +import { z } from 'zod'; -export class EditEmailDto { +export class EditEmailDto implements z.infer { @ApiProperty() emailAddress!: string; } + +export const EditEmailDtoSchema = z.object({ + emailAddress: z.string().email(), +}); diff --git a/src/routes/email/guards/email-edit.guard.spec.ts b/src/routes/email/guards/email-edit.guard.spec.ts deleted file mode 100644 index 34c152cc79..0000000000 --- a/src/routes/email/guards/email-edit.guard.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { - Controller, - HttpCode, - INestApplication, - Post, - UseGuards, -} from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { ConfigurationModule } from '@/config/configuration.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import { Hash } from 'viem'; -import { EmailEditGuard } from '@/routes/email/guards/email-edit.guard'; - -@Controller() -class TestController { - @Post('test/:chainId/:safeAddress/:signer') - @HttpCode(200) - @UseGuards(EmailEditGuard) - async validRoute(): Promise {} - - @Post('test/invalid/1/chains/:safeAddress/:signer') - @HttpCode(200) - @UseGuards(EmailEditGuard) - async invalidRouteWithoutChainId(): Promise {} - - @Post('test/invalid/2/chains/:chainId/:signer') - @HttpCode(200) - @UseGuards(EmailEditGuard) - async invalidRouteWithoutSafeAddress(): Promise {} - - @Post('test/invalid/3/chains/:chainId/:safeAddress') - @HttpCode(200) - @UseGuards(EmailEditGuard) - async invalidRouteWithoutSigner(): Promise {} -} - -describe('EmailEdit guard tests', () => { - let app: INestApplication; - - const chainId = faker.string.numeric(); - const safe = faker.finance.ethereumAddress(); - const emailAddress = faker.internet.email(); - const timestamp = faker.date.recent().getTime(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; - let signature: Hash; - - beforeAll(async () => { - const message = `email-edit-${chainId}-${safe}-${emailAddress}-${signerAddress}-${timestamp}`; - signature = await signer.signMessage({ message }); - }); - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [TestLoggingModule, ConfigurationModule.register(configuration)], - controllers: [TestController], - }).compile(); - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - - it('returns 403 on empty body', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}/${signer.address}`) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 200 on a valid signature', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress, - }) - .expect(200); - }); - - it('returns 403 on an invalid signature', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress: faker.internet.email(), // different email should have different signature - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the email address is missing from payload', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({}) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the signature is missing from headers', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}/${signer.address}`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the timestamp is missing from headers', async () => { - const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .send({ - emailAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without chain id', async () => { - await request(app.getHttpServer()) - .post(`/test/invalid/1/chains/${safe}}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without safe address', async () => { - await request(app.getHttpServer()) - .post(`/test/invalid/2/chains/${chainId}}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without signer', async () => { - await request(app.getHttpServer()) - .post(`/test/invalid/3/chains/${chainId}}/${safe}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); -}); diff --git a/src/routes/email/guards/email-edit.guard.ts b/src/routes/email/guards/email-edit.guard.ts deleted file mode 100644 index d21bc0ce65..0000000000 --- a/src/routes/email/guards/email-edit.guard.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { verifyMessage } from 'viem'; - -/** - * The EmailEditGuard guard should be used on routes that require - * authenticated actions on updating email addresses. - * - * This guard therefore validates if the message came from the specified signer. - * - * The following message should be signed: - * email-edit-${chainId}-${safe}-${emailAddress}-${signer}-${timestamp} - * - * (where ${} represents placeholder values for the respective data) - * - * To use this guard, the route should have: - * - the 'chainId' as part of the path parameters - * - the 'safeAddress' as part of the path parameters - * - the 'signer' as part of the path parameters - * - the 'emailAddress' as part of the JSON body (top level) - * - the 'Safe-Wallet-Signature' header set to the signature - * - the 'Safe-Wallet-Signature-Timestamp' header set to the signature timestamp - */ -@Injectable() -export class EmailEditGuard implements CanActivate { - constructor( - @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} - - private static readonly ACTION_PREFIX = 'email-edit'; - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - - const chainId = request.params['chainId']; - const safe = request.params['safeAddress']; - const signer = request.params['signer']; - const emailAddress = request.body['emailAddress']; - const signature = request.headers['safe-wallet-signature']; - const timestamp = request.headers['safe-wallet-signature-timestamp']; - - // Required fields - if ( - !chainId || - !safe || - !signature || - !emailAddress || - !signer || - !timestamp - ) - return false; - - const message = `${EmailEditGuard.ACTION_PREFIX}-${chainId}-${safe}-${emailAddress}-${signer}-${timestamp}`; - - try { - return await verifyMessage({ - address: signer, - message, - signature, - }); - } catch (e) { - this.loggingService.debug(e); - return false; - } - } -} From e251227b95167dfe6327d56db19f789360274f3a Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 8 May 2024 07:38:15 +0200 Subject: [PATCH 32/65] Remove `Origin` debug log (#1523) Removes the debug logging from `/v1/auth/nonce`. --- src/routes/auth/auth.controller.ts | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/src/routes/auth/auth.controller.ts b/src/routes/auth/auth.controller.ts index c8c64148ae..2af698d04a 100644 --- a/src/routes/auth/auth.controller.ts +++ b/src/routes/auth/auth.controller.ts @@ -1,13 +1,4 @@ -import { - Body, - Controller, - Get, - Post, - HttpCode, - Res, - Inject, - Req, -} from '@nestjs/common'; +import { Body, Controller, Get, Post, HttpCode, Res } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { AuthService } from '@/routes/auth/auth.service'; @@ -15,9 +6,8 @@ import { VerifyAuthMessageDto, VerifyAuthMessageDtoSchema, } from '@/routes/auth/entities/verify-auth-message.dto.entity'; -import { Request, Response } from 'express'; +import { Response } from 'express'; import { getMillisecondsUntil } from '@/domain/common/utils/time'; -import { LoggingService, ILoggingService } from '@/logging/logging.interface'; /** * The AuthController is responsible for handling authentication: @@ -33,17 +23,12 @@ import { LoggingService, ILoggingService } from '@/logging/logging.interface'; export class AuthController { static readonly ACCESS_TOKEN_COOKIE_NAME = 'access_token'; - constructor( - private readonly authService: AuthService, - @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} + constructor(private readonly authService: AuthService) {} @Get('nonce') - async getNonce(@Req() req: Request): Promise<{ + async getNonce(): Promise<{ nonce: string; }> { - // TODO: Remove after debugging - this.loggingService.info(`/v1/auth/nonce, Origin: ${req.header('Origin')}`); return this.authService.getNonce(); } From 3735158a169a6295a26a7c537cb74cfa2c971a8f Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 8 May 2024 08:47:31 +0200 Subject: [PATCH 33/65] Map historical transactions before grouping them (#1505) Maps domain transactions before grouping them, as well as simplifying the grouping logic. (This will allow for simpler filtering of address poisoning attempts as all transactions will be the same type.) - Change `mapGroupTransactions` to only map singular transaction (`mapTransaction`) - Map previous transaction (for date label calculation) if it exists - Map domain transactions before grouping them - Simplify grouping logic - Update tests accordingly (the mock data has always been wrong) --- .../mappers/transactions-history.mapper.ts | 235 +++++++----------- .../transactions-history.controller.spec.ts | 11 +- 2 files changed, 100 insertions(+), 146 deletions(-) diff --git a/src/routes/transactions/mappers/transactions-history.mapper.ts b/src/routes/transactions/mappers/transactions-history.mapper.ts index 0705e242ce..7c947cffef 100644 --- a/src/routes/transactions/mappers/transactions-history.mapper.ts +++ b/src/routes/transactions/mappers/transactions-history.mapper.ts @@ -1,9 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { groupBy } from 'lodash'; -import { CreationTransaction } from '@/domain/safe/entities/creation-transaction.entity'; -import { EthereumTransaction } from '@/domain/safe/entities/ethereum-transaction.entity'; -import { ModuleTransaction } from '@/domain/safe/entities/module-transaction.entity'; -import { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; import { Safe } from '@/domain/safe/entities/safe.entity'; import { isCreationTransaction, @@ -21,16 +17,6 @@ import { MultisigTransactionMapper } from '@/routes/transactions/mappers/multisi import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper'; import { IConfigurationService } from '@/config/configuration.service.interface'; -class TransactionDomainGroup { - timestamp!: number; - transactions!: ( - | MultisigTransaction - | ModuleTransaction - | EthereumTransaction - | CreationTransaction - )[]; -} - @Injectable() export class TransactionsHistoryMapper { private readonly maxNestedTransfers: number; @@ -58,110 +44,83 @@ export class TransactionsHistoryMapper { if (transactionsDomain.length == 0) { return []; } - const previousTransaction = this.getPreviousItem( + // Must be retrieved before mapping others as we remove it from transactionsDomain + const previousTransaction = await this.getPreviousTransaction({ offset, transactionsDomain, - ); - let prevPageTimestamp = 0; - if (previousTransaction !== null) { - prevPageTimestamp = this.getDayFromTransactionDate( - previousTransaction, - timezoneOffset, - ).getTime(); - // Remove first transaction that was requested to get previous day timestamp - transactionsDomain = transactionsDomain.slice(1); - } + chainId, + safe, + onlyTrusted, + }); - const transactionsDomainGroups = this.groupByDay( - transactionsDomain, - timezoneOffset, + const mappedTransactions = await Promise.all( + transactionsDomain.map((transaction) => { + return this.mapTransaction(transaction, chainId, safe, onlyTrusted); + }), ); - - const transactionList = await Promise.all( - transactionsDomainGroups.map(async (transactionGroup) => { - const items: (TransactionItem | DateLabel)[] = []; - const groupTransactions = ( - await this.mapGroupTransactions( - transactionGroup, - chainId, - safe, - onlyTrusted, - ) - ) - .filter((x: T | undefined): x is T => x != null) - .flat(); + const transactions = mappedTransactions + .filter((x: T): x is NonNullable => x != null) + .flat(); + + // The groups respect timezone offset – this was done for grouping only. + const transactionsByDay = this.groupByDay(transactions, timezoneOffset); + return transactionsByDay.reduce>( + (transactionList, transactionsOnDay) => { + // The actual value of the group should be in the UTC timezone instead + // A group should always have at least one transaction. + const { timestamp } = transactionsOnDay[0].transaction; // If the current group is a follow-up from the previous page, // or the group is empty, the date label shouldn't be added. - const isFollowUp = transactionGroup.timestamp == prevPageTimestamp; - if (!isFollowUp && groupTransactions.length) { - items.push(new DateLabel(transactionGroup.timestamp)); + const isFollowUp = + timestamp == previousTransaction?.transaction.timestamp; + if (!isFollowUp && transactionsOnDay.length > 0 && timestamp) { + transactionList.push(new DateLabel(timestamp)); } - items.push(...groupTransactions); - return items; - }), + return transactionList.concat(transactionsOnDay); + }, + [], ); - - return transactionList.flat(); } - private getTransactionTimestamp(transaction: TransactionDomain): Date { - let timestamp: Date | null; - if (isMultisigTransaction(transaction)) { - const executionDate = transaction.executionDate; - timestamp = executionDate ?? transaction.submissionDate; - } else if (isEthereumTransaction(transaction)) { - timestamp = transaction.executionDate; - } else if (isModuleTransaction(transaction)) { - timestamp = transaction.executionDate; - } else if (isCreationTransaction(transaction)) { - timestamp = transaction.created; - } else { - throw Error('Unknown transaction type'); + private async getPreviousTransaction(args: { + offset: number; + transactionsDomain: TransactionDomain[]; + chainId: string; + safe: Safe; + onlyTrusted: boolean; + }): Promise { + // More than 1 element is required to get the previous transaction + if (args.offset <= 0 || args.transactionsDomain.length <= 1) { + return; } - if (timestamp == null) { - throw Error('ExecutionDate cannot be null'); - } - return timestamp; - } - - private getPreviousItem( - offset: number, - transactions: TransactionDomain[], - ): TransactionDomain | null { - // More than 1 element is required to get the previous page date - if (offset <= 0 || transactions.length <= 1) return null; - return transactions[0]; - } + const prevDomainTransaction = args.transactionsDomain[0]; + // We map in order to filter last list item against it + const mappedPreviousTransaction = await this.mapTransaction( + prevDomainTransaction, + args.chainId, + args.safe, + args.onlyTrusted, + ); + // Remove first transaction that was requested to get previous day timestamp + args.transactionsDomain = args.transactionsDomain.slice(1); - private getDayFromTransactionDate( - transaction: TransactionDomain, - timezoneOffset: number, - ): Date { - const timestamp = this.getTransactionTimestamp(transaction); - return this.getDayStartForDate(timestamp, timezoneOffset); + return Array.isArray(mappedPreviousTransaction) + ? // All transfers should have same execution date but the last is "true" previous + mappedPreviousTransaction.at(-1) + : mappedPreviousTransaction; } private groupByDay( - transactions: TransactionDomain[], + transactions: TransactionItem[], timezoneOffset: number, - ): TransactionDomainGroup[] { - return Object.entries( - groupBy(transactions, (transaction) => { - return this.getDayFromTransactionDate( - transaction, - timezoneOffset, - ).getTime(); - }), - ).map(([, transactions]): TransactionDomainGroup => { - // The groups respect the timezone offset – this was done for grouping only. - // The actual value of the group should be in the UTC timezone instead - // A group should always have at least one transaction. - return { - timestamp: this.getTransactionTimestamp(transactions[0]).getTime(), - transactions: transactions, - }; + ): TransactionItem[][] { + const grouped = groupBy(transactions, ({ transaction }) => { + // timestamp will always be defined for historical transactions + const date = new Date(transaction.timestamp ?? 0); + return this.getDayStartForDate(date, timezoneOffset).getTime(); }); + return Object.values(grouped); } /** @@ -198,52 +157,40 @@ export class TransactionsHistoryMapper { ); } - private mapGroupTransactions( - transactionGroup: TransactionDomainGroup, + private async mapTransaction( + transaction: TransactionDomain, chainId: string, safe: Safe, onlyTrusted: boolean, - ): Promise<(TransactionItem | TransactionItem[] | undefined)[]> { - return Promise.all( - transactionGroup.transactions.map(async (transaction) => { - if (isMultisigTransaction(transaction)) { - return new TransactionItem( - await this.multisigTransactionMapper.mapTransaction( - chainId, - transaction, - safe, - ), - ); - } else if (isModuleTransaction(transaction)) { - return new TransactionItem( - await this.moduleTransactionMapper.mapTransaction( - chainId, - transaction, - ), - ); - } else if (isEthereumTransaction(transaction)) { - const transfers = transaction.transfers; - if (transfers != null) { - return await this.mapTransfers( - transfers, - chainId, - safe, - onlyTrusted, - ); - } - } else if (isCreationTransaction(transaction)) { - return new TransactionItem( - await this.creationTransactionMapper.mapTransaction( - chainId, - transaction, - safe, - ), - ); - } else { - // This should never happen as Zod would not allow an unknown transaction to get to this stage - throw Error('Unrecognized transaction type'); - } - }), - ); + ): Promise { + if (isMultisigTransaction(transaction)) { + return new TransactionItem( + await this.multisigTransactionMapper.mapTransaction( + chainId, + transaction, + safe, + ), + ); + } else if (isModuleTransaction(transaction)) { + return new TransactionItem( + await this.moduleTransactionMapper.mapTransaction(chainId, transaction), + ); + } else if (isEthereumTransaction(transaction)) { + const transfers = transaction.transfers; + if (transfers != null) { + return await this.mapTransfers(transfers, chainId, safe, onlyTrusted); + } + } else if (isCreationTransaction(transaction)) { + return new TransactionItem( + await this.creationTransactionMapper.mapTransaction( + chainId, + transaction, + safe, + ), + ); + } else { + // This should never happen as Zod would not allow an unknown transaction to get to this stage + throw Error('Unrecognized transaction type'); + } } } diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 14b0fb6687..e9af8c7d68 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -251,10 +251,12 @@ describe('Transactions History Controller (Unit)', () => { .with('executionDate', new Date('2022-12-25T00:00:00Z')) .build(), ); - const nativeTokenTransfer = nativeTokenTransferBuilder().build(); + const nativeTokenTransfer = nativeTokenTransferBuilder() + .with('executionDate', new Date('2022-12-31T00:00:00Z')) + .build(); const incomingTransaction = ethereumTransactionToJson( ethereumTransactionBuilder() - .with('executionDate', new Date('2022-12-31T00:00:00Z')) + .with('executionDate', nativeTokenTransfer.executionDate) .with('transfers', [ nativeTokenTransferToJson(nativeTokenTransfer) as Transfer, ]) @@ -996,12 +998,14 @@ describe('Transactions History Controller (Unit)', () => { erc20TransferBuilder() .with('tokenAddress', untrustedToken.address) .with('value', faker.string.numeric({ exclude: ['0'] })) + .with('executionDate', date) .build(), ) as Transfer, erc20TransferToJson( erc20TransferBuilder() .with('tokenAddress', trustedToken.address) .with('value', faker.string.numeric({ exclude: ['0'] })) + .with('executionDate', date) .build(), ) as Transfer, ]) @@ -1015,12 +1019,14 @@ describe('Transactions History Controller (Unit)', () => { erc20TransferBuilder() .with('tokenAddress', untrustedToken.address) .with('value', faker.string.numeric({ exclude: ['0'] })) + .with('executionDate', oneDayAfter) .build(), ) as Transfer, erc20TransferToJson( erc20TransferBuilder() .with('tokenAddress', untrustedToken.address) .with('value', faker.string.numeric({ exclude: ['0'] })) + .with('executionDate', oneDayAfter) .build(), ) as Transfer, ]) @@ -1034,6 +1040,7 @@ describe('Transactions History Controller (Unit)', () => { erc20TransferBuilder() .with('tokenAddress', trustedToken.address) .with('value', faker.string.numeric({ exclude: ['0'] })) + .with('executionDate', twoDaysAfter) .build(), ) as Transfer, ]) From ade8d69ce84e9093254e129b4ab4ff757f9b02fe Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 8 May 2024 12:01:36 +0200 Subject: [PATCH 34/65] Remove legacy relay routes (#1516) Removes legacy relay routes: - Remove `RelayLegacyDtoSchema`, inferred type and tests - Remove `RelayDtoSchema['version']` fallback value - Remove `RelayLegacyController` and tests - `POST` `relay` - `GET` `relay/:chainId/:safeAddress` - Update `RelayController` tests --- .../relay/entities/relay.legacy.dto.entity.ts | 18 -- .../__tests__/relay.legacy.dto.schema.spec.ts | 127 ----------- .../entities/schemas/relay.dto.schema.ts | 12 +- src/routes/relay/relay.controller.module.ts | 3 +- src/routes/relay/relay.controller.spec.ts | 204 ++---------------- .../relay/relay.legacy.controller.spec.ts | 84 -------- src/routes/relay/relay.legacy.controller.ts | 36 ---- 7 files changed, 17 insertions(+), 467 deletions(-) delete mode 100644 src/routes/relay/entities/relay.legacy.dto.entity.ts delete mode 100644 src/routes/relay/entities/schemas/__tests__/relay.legacy.dto.schema.spec.ts delete mode 100644 src/routes/relay/relay.legacy.controller.spec.ts delete mode 100644 src/routes/relay/relay.legacy.controller.ts diff --git a/src/routes/relay/entities/relay.legacy.dto.entity.ts b/src/routes/relay/entities/relay.legacy.dto.entity.ts deleted file mode 100644 index 246a68e651..0000000000 --- a/src/routes/relay/entities/relay.legacy.dto.entity.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AddressSchema } from '@/validation/entities/schemas/address.schema'; -import { HexSchema } from '@/validation/entities/schemas/hex.schema'; -import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; -import { z } from 'zod'; - -export class RelayLegacyDto implements z.infer { - chainId!: string; - to!: `0x${string}`; - data!: `0x${string}`; - gasLimit!: string | null; -} - -export const RelayLegacyDtoSchema = z.object({ - chainId: NumericStringSchema, - to: AddressSchema, - data: HexSchema, - gasLimit: z.string().nullish().default(null), -}); diff --git a/src/routes/relay/entities/schemas/__tests__/relay.legacy.dto.schema.spec.ts b/src/routes/relay/entities/schemas/__tests__/relay.legacy.dto.schema.spec.ts deleted file mode 100644 index b231ef94bf..0000000000 --- a/src/routes/relay/entities/schemas/__tests__/relay.legacy.dto.schema.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { RelayLegacyDtoSchema } from '@/routes/relay/entities/relay.legacy.dto.entity'; -import { faker } from '@faker-js/faker'; -import { getAddress } from 'viem'; - -describe('RelayLegacyDtoSchema', () => { - it('should validate a valid legacy relay DTO with a gasLimit', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: getAddress(faker.finance.ethereumAddress()), - data: faker.string.hexadecimal(), - gasLimit: faker.string.numeric(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(result.success && result.data.chainId).toBe(relayLegacyDto.chainId); - expect(result.success && result.data.to).toBe(relayLegacyDto.to); - expect(result.success && result.data.data).toBe(relayLegacyDto.data); - expect(result.success && result.data.gasLimit).toBe( - relayLegacyDto.gasLimit, - ); - }); - - it('should validate a valid legacy relay DTO without a gasLimit and coerce it to null', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: getAddress(faker.finance.ethereumAddress()), - data: faker.string.hexadecimal(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(result.success && result.data.chainId).toBe(relayLegacyDto.chainId); - expect(result.success && result.data.to).toBe(relayLegacyDto.to); - expect(result.success && result.data.data).toBe(relayLegacyDto.data); - expect(result.success && result.data.gasLimit).toBeNull(); // Coerced to null - }); - - it('should checksum a non-checksummed to address', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: faker.finance.ethereumAddress().toLowerCase(), - data: faker.string.hexadecimal(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(result.success && result.data.to).toBe( - getAddress(relayLegacyDto.to), - ); - }); - - it('should throw for a non-numeric chainId', () => { - const relayLegacyDto = { - chainId: faker.string.alpha(), - to: getAddress(faker.finance.ethereumAddress()), - data: faker.string.hexadecimal(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'custom', - message: 'Invalid base-10 numeric string', - path: ['chainId'], - }, - ]); - }); - - it('should throw for a non-address to address', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: faker.string.numeric(), - data: faker.string.hexadecimal(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'custom', - message: 'Invalid address', - path: ['to'], - }, - ]); - }); - - it('should throw for non-hex data', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: getAddress(faker.finance.ethereumAddress()), - data: faker.string.numeric(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'custom', - message: 'Invalid "0x" notated hex string', - path: ['data'], - }, - ]); - }); - - it('should throw for an invalid gasLimit', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: getAddress(faker.finance.ethereumAddress()), - data: faker.string.hexadecimal(), - gasLimit: faker.number.int(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'invalid_type', - expected: 'string', - message: 'Expected string, received number', - path: ['gasLimit'], - received: 'number', - }, - ]); - }); -}); diff --git a/src/routes/relay/entities/schemas/relay.dto.schema.ts b/src/routes/relay/entities/schemas/relay.dto.schema.ts index 72703f6380..2c4aa48649 100644 --- a/src/routes/relay/entities/schemas/relay.dto.schema.ts +++ b/src/routes/relay/entities/schemas/relay.dto.schema.ts @@ -3,18 +3,12 @@ import * as semver from 'semver'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { HexSchema } from '@/validation/entities/schemas/hex.schema'; -// TODO: Remove default when legacy support is removed -const LEGACY_SUPPORTED_VERSION = '1.3.0'; - export const RelayDtoSchema = z.object({ to: AddressSchema, data: HexSchema, - version: z - .string() - .refine((value) => semver.parse(value) !== null, { - message: 'Invalid semver string', - }) - .default(LEGACY_SUPPORTED_VERSION), + version: z.string().refine((value) => semver.parse(value) !== null, { + message: 'Invalid semver string', + }), gasLimit: z .string() .optional() diff --git a/src/routes/relay/relay.controller.module.ts b/src/routes/relay/relay.controller.module.ts index b75730f445..dd0514fb70 100644 --- a/src/routes/relay/relay.controller.module.ts +++ b/src/routes/relay/relay.controller.module.ts @@ -2,11 +2,10 @@ import { Module } from '@nestjs/common'; import { RelayDomainModule } from '@/domain/relay/relay.domain.module'; import { RelayService } from '@/routes/relay/relay.service'; import { RelayController } from '@/routes/relay/relay.controller'; -import { RelayLegacyController } from '@/routes/relay/relay.legacy.controller'; @Module({ imports: [RelayDomainModule], providers: [RelayService], - controllers: [RelayController, RelayLegacyController], + controllers: [RelayController], }) export class RelayControllerModule {} diff --git a/src/routes/relay/relay.controller.spec.ts b/src/routes/relay/relay.controller.spec.ts index 7b75e1500a..260e15e135 100644 --- a/src/routes/relay/relay.controller.spec.ts +++ b/src/routes/relay/relay.controller.spec.ts @@ -416,65 +416,6 @@ describe('Relay controller', () => { }); }, ); - - it('should otherwise default to version 1.3.0', async () => { - const version = '1.3.0'; - const chain = chainBuilder().with('chainId', chainId).build(); - const safe = safeBuilder().build(); - const safeAddress = getAddress(safe.address); - const transactions = [ - execTransactionEncoder() - .with('data', addOwnerWithThresholdEncoder().encode()) - .encode(), - execTransactionEncoder() - .with('data', changeThresholdEncoder().encode()) - .encode(), - ].map((data) => ({ - operation: faker.number.int({ min: 0, max: 1 }), - data, - to: safeAddress, - value: faker.number.bigInt(), - })); - const data = multiSendEncoder() - .with('transactions', multiSendTransactionsEncoder(transactions)) - .encode(); - const to = getMultiSendCallOnlyDeployment({ - version, - network: chainId, - })!.networkAddresses[chainId]; - const taskId = faker.string.uuid(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safeAddress}`: - // Official mastercopy - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - networkService.post.mockImplementation(({ url }) => { - switch (url) { - case `${relayUrl}/relays/v2/sponsored-call`: - return Promise.resolve({ data: { taskId }, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/relay`) - .send({ - // No version - to, - data, - }) - .expect(201) - .expect({ - taskId, - }); - }); }); describe('MultiSend', () => { @@ -543,66 +484,6 @@ describe('Relay controller', () => { }); }, ); - - // TODO: Remove when legacy support is removed - it('should otherwise default to version 1.3.0', async () => { - const version = '1.3.0'; - const chain = chainBuilder().with('chainId', chainId).build(); - const safe = safeBuilder().build(); - const safeAddress = getAddress(safe.address); - const transactions = [ - execTransactionEncoder() - .with('data', addOwnerWithThresholdEncoder().encode()) - .encode(), - execTransactionEncoder() - .with('data', changeThresholdEncoder().encode()) - .encode(), - ].map((data) => ({ - operation: faker.number.int({ min: 0, max: 1 }), - data, - to: safeAddress, - value: faker.number.bigInt(), - })); - const data = multiSendEncoder() - .with('transactions', multiSendTransactionsEncoder(transactions)) - .encode(); - const to = getMultiSendDeployment({ - version, - network: chainId, - })!.networkAddresses[chainId]; - const taskId = faker.string.uuid(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safeAddress}`: - // Official mastercopy - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - networkService.post.mockImplementation(({ url }) => { - switch (url) { - case `${relayUrl}/relays/v2/sponsored-call`: - return Promise.resolve({ data: { taskId }, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/relay`) - .send({ - // No version - to, - data, - }) - .expect(201) - .expect({ - taskId, - }); - }); }); describe('ProxyFactory', () => { @@ -850,78 +731,6 @@ describe('Relay controller', () => { } }, ); - - // TODO: Remove when legacy support is removed - it.each([ - [ - 'creating an official Safe', - (chainId: string): string => - getSafeSingletonDeployment({ - version: '1.3.0', - network: chainId, - })!.networkAddresses[chainId], - ], - [ - 'creating an official L2 Safe', - (chainId: string): string => - getSafeL2SingletonDeployment({ - version: '1.3.0', - network: chainId, - })!.networkAddresses[chainId], - ], - ])( - 'should otherwise default to version 1.3.0 singletons when %s', - async (_, getSingleton) => { - const version = '1.3.0'; - const singleton = getSingleton(chainId); - const chain = chainBuilder().with('chainId', chainId).build(); - const owners = [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - ]; - const proxyFactory = getProxyFactoryDeployment({ - version, - network: chainId, - })!.networkAddresses[chainId]; - const to = getAddress(proxyFactory); - const data = createProxyWithNonceEncoder() - .with('singleton', getAddress(singleton)) - .with( - 'initializer', - setupEncoder().with('owners', owners).encode(), - ) - .encode(); - const taskId = faker.string.uuid(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - networkService.post.mockImplementation(({ url }) => { - switch (url) { - case `${relayUrl}/relays/v2/sponsored-call`: - return Promise.resolve({ data: { taskId }, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/relay`) - .send({ - version, - to, - data, - }) - .expect(201) - .expect({ - taskId, - }); - }, - ); }); }); @@ -1678,6 +1487,8 @@ describe('Relay controller', () => { }); it('should handle both checksummed and non-checksummed addresses', async () => { + // Version supported by all contracts + const version = '1.3.0'; const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const nonChecksummedAddress = safe.address.toLowerCase(); @@ -1716,6 +1527,7 @@ describe('Relay controller', () => { .send({ to: address, data, + version, }); } @@ -1781,6 +1593,8 @@ describe('Relay controller', () => { }); it('should return 429 if the rate limit is reached', async () => { + // Version supported by all contracts + const version = '1.3.0'; const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); @@ -1815,6 +1629,7 @@ describe('Relay controller', () => { .send({ to: safeAddress, data, + version, }); } @@ -1823,6 +1638,7 @@ describe('Relay controller', () => { .send({ to: safeAddress, data, + version, }) .expect(429) .expect({ @@ -1833,6 +1649,8 @@ describe('Relay controller', () => { }); it('should return 503 if the relayer throws', async () => { + // Version supported by all contracts + const version = '1.3.0'; const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const data = execTransactionEncoder().encode() as Hex; @@ -1861,6 +1679,7 @@ describe('Relay controller', () => { .send({ to: safe.address, data, + version, }) .expect(503); }); @@ -1876,6 +1695,8 @@ describe('Relay controller', () => { }); it('should not return negative limits if more requests were made than the limit', async () => { + // Version supported by all contracts + const version = '1.3.0'; const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); @@ -1910,6 +1731,7 @@ describe('Relay controller', () => { .send({ to: safeAddress, data, + version, }); } diff --git a/src/routes/relay/relay.legacy.controller.spec.ts b/src/routes/relay/relay.legacy.controller.spec.ts deleted file mode 100644 index bd84a93617..0000000000 --- a/src/routes/relay/relay.legacy.controller.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; -import { AppModule } from '@/app.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { INestApplication } from '@nestjs/common'; -import { faker } from '@faker-js/faker'; -import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; -import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; - -describe('Relay controller', () => { - let app: INestApplication; - - beforeEach(async () => { - jest.resetAllMocks(); - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(configuration)], - }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) - .overrideModule(CacheModule) - .useModule(TestCacheModule) - .overrideModule(RequestScopedLoggingModule) - .useModule(TestLoggingModule) - .overrideModule(NetworkModule) - .useModule(TestNetworkModule) - .overrideModule(QueuesApiModule) - .useModule(TestQueuesApiModule) - .compile(); - - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - const supportedChainIds = Object.keys(configuration().relay.apiKey); - - describe.each(supportedChainIds)('Chain %s', (chainId) => { - describe('POST /v1/chains/:chainId/relay', () => { - it('should return 302 and redirect to the new endpoint', async () => { - const safeAddress = faker.finance.ethereumAddress(); - const data = faker.string.hexadecimal(); - - await request(app.getHttpServer()) - .post(`/v1/relay`) - .send({ - chainId, - to: safeAddress, - data, - }) - .expect(308) - .expect((res) => { - expect(res.get('location')).toBe(`/v1/chains/${chainId}/relay`); - }); - }); - }); - describe('GET /v1/relay/:chainId/:safeAddress', () => { - it('should return 302 and redirect to the new endpoint', async () => { - const safeAddress = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .get(`/v1/relay/${chainId}/${safeAddress}`) - .expect(301) - .expect((res) => { - expect(res.get('location')).toBe( - `/v1/chains/${chainId}/relay/${safeAddress}`, - ); - }); - }); - }); - }); -}); diff --git a/src/routes/relay/relay.legacy.controller.ts b/src/routes/relay/relay.legacy.controller.ts deleted file mode 100644 index 022e386395..0000000000 --- a/src/routes/relay/relay.legacy.controller.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { RelayLegacyDto } from '@/routes/relay/entities/relay.legacy.dto.entity'; -import { RelayLegacyDtoSchema } from '@/routes/relay/entities/relay.legacy.dto.entity'; -import { ValidationPipe } from '@/validation/pipes/validation.pipe'; -import { - Body, - Controller, - Get, - HttpStatus, - Param, - Post, - Redirect, -} from '@nestjs/common'; - -@Controller({ - version: '1', - path: 'relay', -}) -export class RelayLegacyController { - @Post() - @Redirect(undefined, HttpStatus.PERMANENT_REDIRECT) - relay( - @Body(new ValidationPipe(RelayLegacyDtoSchema)) - relayLegacyDto: RelayLegacyDto, - ): { url: string } { - return { url: `/v1/chains/${relayLegacyDto.chainId}/relay` }; - } - - @Get('/:chainId/:safeAddress') - @Redirect(undefined, HttpStatus.MOVED_PERMANENTLY) - getRelaysRemaining( - @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, - ): { url: string } { - return { url: `/v1/chains/${chainId}/relay/${safeAddress}` }; - } -} From 533d5eca4940902e74d6e74f6df5c6fbd26c862d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 8 May 2024 13:31:42 +0200 Subject: [PATCH 35/65] Add origin field to HTTP requests logger (#1525) Add origin field to the routes logger --- src/logging/utils.ts | 9 ++++++--- .../common/interceptors/route-logger.interceptor.spec.ts | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/logging/utils.ts b/src/logging/utils.ts index 5bdfae1d86..ce35eaa973 100644 --- a/src/logging/utils.ts +++ b/src/logging/utils.ts @@ -3,6 +3,7 @@ import { get } from 'lodash'; const HEADER_IP_ADDRESS = 'X-Real-IP'; const HEADER_SAFE_APP_USER_AGENT = 'Safe-App-User-Agent'; +const HEADER_ORIGIN = 'Origin'; export function formatRouteLogMessage( statusCode: number, @@ -19,11 +20,12 @@ export function formatRouteLogMessage( safe_app_user_agent: string | null; status_code: number; detail: string | null; + origin: string | null; } { const clientIp = request.header(HEADER_IP_ADDRESS) ?? null; - const safe_app_user_agent = - request.header(HEADER_SAFE_APP_USER_AGENT) ?? null; + const safeAppUserAgent = request.header(HEADER_SAFE_APP_USER_AGENT) ?? null; const chainId = request.params['chainId'] ?? null; + const origin = request.header(HEADER_ORIGIN) ?? null; return { chain_id: chainId, @@ -32,9 +34,10 @@ export function formatRouteLogMessage( response_time_ms: performance.now() - startTimeMs, route: request.route.path, path: request.url, - safe_app_user_agent: safe_app_user_agent, + safe_app_user_agent: safeAppUserAgent, status_code: statusCode, detail: detail ?? null, + origin, }; } diff --git a/src/routes/common/interceptors/route-logger.interceptor.spec.ts b/src/routes/common/interceptors/route-logger.interceptor.spec.ts index 615ed0426a..7911b2a6f9 100644 --- a/src/routes/common/interceptors/route-logger.interceptor.spec.ts +++ b/src/routes/common/interceptors/route-logger.interceptor.spec.ts @@ -88,6 +88,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/server-error', safe_app_user_agent: null, status_code: 500, + origin: null, }); expect(mockLoggingService.info).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -115,6 +116,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/server-data-source-error', safe_app_user_agent: null, status_code: 501, + origin: null, }); expect(mockLoggingService.info).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -135,6 +137,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/client-error', safe_app_user_agent: null, status_code: 405, + origin: null, }); expect(mockLoggingService.error).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -155,6 +158,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/success', safe_app_user_agent: null, status_code: 200, + origin: null, }); expect(mockLoggingService.error).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -178,6 +182,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/success/:chainId', safe_app_user_agent: null, status_code: 200, + origin: null, }); expect(mockLoggingService.error).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -200,6 +205,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/server-error-non-http', safe_app_user_agent: null, status_code: 500, + origin: null, }); expect(mockLoggingService.info).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -224,6 +230,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/success', safe_app_user_agent: safeAppUserAgentHeader, status_code: 200, + origin: null, }); }); }); From ff1f629ce641df712bb95384ea3b9e3b841e3080 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 8 May 2024 15:14:52 +0200 Subject: [PATCH 36/65] Compare submitted confirmations against those required (#1526) Changes the logic to compare the number of submitted confirmations against the number required for Safe overview transactions awaiting confirmation: - Change confirmation flag to compare number of submitted confirmations against the number of those required --- src/routes/safes/safes.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/safes/safes.service.ts b/src/routes/safes/safes.service.ts index 6fd343f549..5bd29dfe5b 100644 --- a/src/routes/safes/safes.service.ts +++ b/src/routes/safes/safes.service.ts @@ -208,7 +208,8 @@ export class SafesService { }): number { return args.transactions.reduce( (acc, { confirmationsRequired, confirmations }) => { - const isConfirmed = confirmationsRequired === 0; + const isConfirmed = + !!confirmations && confirmations.length >= confirmationsRequired; const isSignable = !isConfirmed && !confirmations?.some((confirmation) => { From efd7798da1cb789d58fae4a7acbc80ee2cbf7e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 9 May 2024 23:29:06 +0200 Subject: [PATCH 37/65] Remove PreExecutionLogGuard (#1528) Removes PreExecutionLogGuard --- .../cache-hooks/cache-hooks.controller.ts | 3 +- .../cache-hooks/guards/pre-execution.guard.ts | 30 ------------------- 2 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 src/routes/cache-hooks/guards/pre-execution.guard.ts diff --git a/src/routes/cache-hooks/cache-hooks.controller.ts b/src/routes/cache-hooks/cache-hooks.controller.ts index 6a67823e7e..df077e9ef7 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.ts +++ b/src/routes/cache-hooks/cache-hooks.controller.ts @@ -12,7 +12,6 @@ import { CacheHooksService } from '@/routes/cache-hooks/cache-hooks.service'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { BasicAuthGuard } from '@/routes/common/auth/basic-auth.guard'; import { Event } from '@/routes/cache-hooks/entities/event.entity'; -import { PreExecutionLogGuard } from '@/routes/cache-hooks/guards/pre-execution.guard'; import { WebHookSchema } from '@/routes/cache-hooks/entities/schemas/web-hook.schema'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { IConfigurationService } from '@/config/configuration.service.interface'; @@ -43,7 +42,7 @@ export class CacheHooksController { ); } - @UseGuards(PreExecutionLogGuard, BasicAuthGuard) + @UseGuards(BasicAuthGuard) @Post('/hooks/events') @UseFilters(EventProtocolChangedFilter) @HttpCode(202) diff --git a/src/routes/cache-hooks/guards/pre-execution.guard.ts b/src/routes/cache-hooks/guards/pre-execution.guard.ts deleted file mode 100644 index 4f62033cb8..0000000000 --- a/src/routes/cache-hooks/guards/pre-execution.guard.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; - -/** - * The PreExecutionLogGuard guard outputs a log line containing parts of the request data. - * Currently only the request path is being logged. - */ -@Injectable() -export class PreExecutionLogGuard implements CanActivate { - private static readonly PRE_EXECUTION_LOGGING_DETAIL = 'pre-execution-log'; - - constructor( - @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} - - canActivate(context: ExecutionContext): boolean { - const httpContext = context.switchToHttp(); - const request = httpContext.getRequest(); - this.loggingService.info({ - type: PreExecutionLogGuard.PRE_EXECUTION_LOGGING_DETAIL, - route: request.route.path, - }); - return true; - } -} From ceebd7d82873423d73c5bb3c116e418130698cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Mon, 13 May 2024 10:59:59 +0200 Subject: [PATCH 38/65] Fix @typescript-eslint/no-misused-promises instances (#1530) Enable `@typescript-eslint/no-misused-promises` ESLint rule --- eslint.config.mjs | 1 - src/datasources/cache/redis.cache.service.ts | 7 +++---- src/datasources/queues/queues-api.service.ts | 13 +++++-------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 39e52ef3e8..f986d3ca68 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,7 +31,6 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-floating-promises': 'warn', // TODO: Address these rules: (added to update to ESLint 9) - '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', '@typescript-eslint/no-unnecessary-type-assertion': 'off', '@typescript-eslint/no-unsafe-argument': 'off', diff --git a/src/datasources/cache/redis.cache.service.ts b/src/datasources/cache/redis.cache.service.ts index dbf14d6d8e..ef74c58a99 100644 --- a/src/datasources/cache/redis.cache.service.ts +++ b/src/datasources/cache/redis.cache.service.ts @@ -108,10 +108,9 @@ export class RedisCacheService */ async onModuleDestroy(): Promise { this.loggingService.info('Closing Redis connection'); - const forceQuitTimeout = setTimeout( - this.forceQuit.bind(this), - this.quitTimeoutInSeconds * 1000, - ); + const forceQuitTimeout = setTimeout(() => { + this.forceQuit.bind(this); + }, this.quitTimeoutInSeconds * 1000); await this.client.quit(); clearTimeout(forceQuitTimeout); this.loggingService.info('Redis connection closed'); diff --git a/src/datasources/queues/queues-api.service.ts b/src/datasources/queues/queues-api.service.ts index 3b3d265790..ae387e2a04 100644 --- a/src/datasources/queues/queues-api.service.ts +++ b/src/datasources/queues/queues-api.service.ts @@ -36,14 +36,11 @@ export class QueueApiService implements IQueuesApiService, IQueueReadiness { `Cannot subscribe to queue: ${queueName}. AMQP consumer is disabled`, ); } - await this.consumer.channel.consume( - queueName, - async (msg: ConsumeMessage) => { - await fn(msg); - // Note: each message is explicitly acknowledged at this point, only after a success callback execution. - this.consumer.channel.ack(msg); - }, - ); + await this.consumer.channel.consume(queueName, (msg: ConsumeMessage) => { + // Note: each message is explicitly acknowledged at this point, regardless the callback execution result. + // The callback is expected to handle any errors and/or retries. Messages are not re-enqueued on error. + void fn(msg).finally(() => this.consumer.channel.ack(msg)); + }); this.loggingService.info(`Subscribed to queue: ${queueName}`); } } From 8bd8bc9fe8dae6408ffd06a6d381157bc97ac883 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 13 May 2024 11:10:10 +0200 Subject: [PATCH 39/65] Filter out imitation transfers (#1524) Adds `trusted` param-specific filtering of imitation transfers behind a feature flag. It removes outgoing, ERC-20 transfers that imitate the value and recipient in vanity of their neighbour. It maps over the transaction history, keeping the transaction if the following conditions are met: 1. Was transaction executed by Safe? 2. Is there no previous transactions? 3. Is (previous transaction... - ..._not_ an ERC-20 transfer? - ...incoming? - ...of different value? - ...to same recipient? - ..._not_ an imitation address? --- .../entities/__tests__/configuration.ts | 4 + src/config/entities/configuration.ts | 5 + .../helpers/imitation-transactions.helper.ts | 107 +++ .../mappers/transactions-history.mapper.ts | 62 +- .../transactions-history.controller.spec.ts | 652 ++++++++++++++++++ .../transactions/transactions.module.ts | 2 + 6 files changed, 821 insertions(+), 11 deletions(-) create mode 100644 src/routes/transactions/helpers/imitation-transactions.helper.ts diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 0628f9c054..b1628e2c1b 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -185,6 +185,7 @@ export default (): ReturnType => ({ zerionBalancesChainIds: ['137'], swapsDecoding: true, historyDebugLogs: false, + imitationFiltering: false, auth: false, confirmationView: false, eventsQueue: false, @@ -199,6 +200,9 @@ export default (): ReturnType => ({ silent: process.env.LOG_SILENT?.toLowerCase() === 'true', }, mappings: { + imitationTransactions: { + vanityAddressChars: faker.number.int(), + }, history: { maxNestedTransfers: faker.number.int({ min: 1, max: 5 }), }, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index cf73a2d41d..44bb036968 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -193,6 +193,8 @@ export default () => ({ swapsDecoding: process.env.FF_SWAPS_DECODING?.toLowerCase() === 'true', historyDebugLogs: process.env.FF_HISTORY_DEBUG_LOGS?.toLowerCase() === 'true', + imitationFiltering: + process.env.FF_IMITATION_FILTERING?.toLowerCase() === 'true', auth: process.env.FF_AUTH?.toLowerCase() === 'true', confirmationView: process.env.FF_CONFIRMATION_VIEW?.toLowerCase() === 'true', @@ -220,6 +222,9 @@ export default () => ({ ownersTtlSeconds: parseInt(process.env.OWNERS_TTL_SECONDS ?? `${0}`), }, mappings: { + imitationTransactions: { + vanityAddressChars: parseInt(process.env.VANITY_ADDRESS_CHARS ?? `${4}`), + }, history: { maxNestedTransfers: parseInt( process.env.MAX_NESTED_TRANSFERS ?? `${100}`, diff --git a/src/routes/transactions/helpers/imitation-transactions.helper.ts b/src/routes/transactions/helpers/imitation-transactions.helper.ts new file mode 100644 index 0000000000..a54eb77e00 --- /dev/null +++ b/src/routes/transactions/helpers/imitation-transactions.helper.ts @@ -0,0 +1,107 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { TransactionItem } from '@/routes/transactions/entities/transaction-item.entity'; +import { + isTransferTransactionInfo, + TransferDirection, +} from '@/routes/transactions/entities/transfer-transaction-info.entity'; +import { isErc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; +import { Inject } from '@nestjs/common'; + +export class ImitationTransactionsHelper { + private readonly vanityAddressChars: number; + + constructor( + @Inject(IConfigurationService) configurationService: IConfigurationService, + ) { + this.vanityAddressChars = configurationService.getOrThrow( + 'mappings.imitationTransactions.vanityAddressChars', + ); + } + + /** + * Filters out outgoing ERC20 transfers that imitate their direct predecessor in + * value and have a recipient address that is not the same but matches in vanity. + * + * @param transactions - list of transactions to filter + * @param previousTransaction - transaction to compare last {@link transactions} against + * + * Note: this only handles singular imitation transfers. It does not handle multiple + * imitation transfers in a row, nor does it compare batched multiSend transactions + * as the "distance" between those batched and their imitation may not be immediate. + */ + filterOutgoingErc20ImitationTransfers( + transactions: Array, + previousTransaction: TransactionItem | undefined, + ): Array { + // TODO: Instead of filtering, mark transactions so client can display them differently + return transactions.filter((item, i, arr) => { + // Executed by Safe - cannot be imitation + if (item.transaction.executionInfo) { + return true; + } + + // Transaction list is in date-descending order. We compare each transaction with the next + // unless we are comparing the last transaction, in which case we compare it with the + // "previous transaction" (the first transaction of the subsequent page). + const prevItem = i === arr.length - 1 ? previousTransaction : arr[i + 1]; + + // No reference transaction to filter against + if (!prevItem) { + return true; + } + + const txInfo = item.transaction.txInfo; + const prevTxInfo = prevItem.transaction.txInfo; + + if ( + // Only consider transfers... + !isTransferTransactionInfo(txInfo) || + !isTransferTransactionInfo(prevTxInfo) || + // ...of ERC20s... + !isErc20Transfer(txInfo.transferInfo) || + !isErc20Transfer(prevTxInfo.transferInfo) + ) { + return true; + } + + // ...that are outgoing + const isOutgoing = txInfo.direction === TransferDirection.Outgoing; + const isPrevOutgoing = + prevTxInfo.direction === TransferDirection.Outgoing; + if (!isOutgoing || !isPrevOutgoing) { + return true; + } + + // Imitation transfers are of the same value... + const isSameValue = + txInfo.transferInfo.value === prevTxInfo.transferInfo.value; + if (!isSameValue) { + return true; + } + + // ...from recipient that has the same vanity but is not the same address + return !this.isImitatorAddress( + txInfo.recipient.value, + prevTxInfo.recipient.value, + ); + }); + } + + private isImitatorAddress(address1: string, address2: string): boolean { + const a1 = address1.toLowerCase(); + const a2 = address2.toLowerCase(); + + // Same address is not an imitation + if (a1 === a2) { + return false; + } + + const isSamePrefix = + // Ignore `0x` prefix + a1.slice(2, 2 + this.vanityAddressChars) === + a2.slice(2, 2 + this.vanityAddressChars); + const isSameSuffix = + a1.slice(-this.vanityAddressChars) === a2.slice(-this.vanityAddressChars); + return isSamePrefix && isSameSuffix; + } +} diff --git a/src/routes/transactions/mappers/transactions-history.mapper.ts b/src/routes/transactions/mappers/transactions-history.mapper.ts index 7c947cffef..a02137547a 100644 --- a/src/routes/transactions/mappers/transactions-history.mapper.ts +++ b/src/routes/transactions/mappers/transactions-history.mapper.ts @@ -16,19 +16,26 @@ import { ModuleTransactionMapper } from '@/routes/transactions/mappers/module-tr import { MultisigTransactionMapper } from '@/routes/transactions/mappers/multisig-transactions/multisig-transaction.mapper'; import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper'; import { IConfigurationService } from '@/config/configuration.service.interface'; +import { ImitationTransactionsHelper } from '@/routes/transactions/helpers/imitation-transactions.helper'; @Injectable() export class TransactionsHistoryMapper { + private readonly isImitationFilteringEnabled: boolean; private readonly maxNestedTransfers: number; constructor( - @Inject(IConfigurationService) configurationService: IConfigurationService, + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, private readonly multisigTransactionMapper: MultisigTransactionMapper, private readonly moduleTransactionMapper: ModuleTransactionMapper, private readonly transferMapper: TransferMapper, private readonly creationTransactionMapper: CreationTransactionMapper, + private readonly imitationTransactionsHelper: ImitationTransactionsHelper, ) { - this.maxNestedTransfers = configurationService.getOrThrow( + this.isImitationFilteringEnabled = this.configurationService.getOrThrow( + 'features.imitationFiltering', + ); + this.maxNestedTransfers = this.configurationService.getOrThrow( 'mappings.history.maxNestedTransfers', ); } @@ -53,17 +60,19 @@ export class TransactionsHistoryMapper { onlyTrusted, }); - const mappedTransactions = await Promise.all( - transactionsDomain.map((transaction) => { - return this.mapTransaction(transaction, chainId, safe, onlyTrusted); - }), - ); - const transactions = mappedTransactions - .filter((x: T): x is NonNullable => x != null) - .flat(); + const mappedTransactions = await this.getMappedTransactions({ + transactionsDomain, + chainId, + safe, + previousTransaction, + onlyTrusted, + }); // The groups respect timezone offset – this was done for grouping only. - const transactionsByDay = this.groupByDay(transactions, timezoneOffset); + const transactionsByDay = this.groupByDay( + mappedTransactions, + timezoneOffset, + ); return transactionsByDay.reduce>( (transactionList, transactionsOnDay) => { // The actual value of the group should be in the UTC timezone instead @@ -111,6 +120,37 @@ export class TransactionsHistoryMapper { : mappedPreviousTransaction; } + private async getMappedTransactions(args: { + transactionsDomain: TransactionDomain[]; + chainId: string; + safe: Safe; + previousTransaction: TransactionItem | undefined; + onlyTrusted: boolean; + }): Promise { + const mappedTransactions = await Promise.all( + args.transactionsDomain.map((transaction) => { + return this.mapTransaction( + transaction, + args.chainId, + args.safe, + args.onlyTrusted, + ); + }), + ); + const transactionItems = mappedTransactions + .filter((x: T): x is NonNullable => x != null) + .flat(); + + if (!this.isImitationFilteringEnabled || !args.onlyTrusted) { + return transactionItems; + } + + return this.imitationTransactionsHelper.filterOutgoingErc20ImitationTransfers( + transactionItems, + args.previousTransaction, + ); + } + private groupByDay( transactions: TransactionItem[], timezoneOffset: number, diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index e9af8c7d68..906c19187a 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -67,6 +67,7 @@ describe('Transactions History Controller (Unit)', () => { let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; + const vanityAddressChars = 4; beforeEach(async () => { jest.resetAllMocks(); @@ -75,6 +76,12 @@ describe('Transactions History Controller (Unit)', () => { ...configuration(), mappings: { ...configuration().mappings, + imitationTransactions: { + vanityAddressChars, + }, + features: { + imitationFiltering: true, + }, history: { maxNestedTransfers: 5, }, @@ -1342,4 +1349,649 @@ describe('Transactions History Controller (Unit)', () => { }); }); }); + + describe('Address poisoning', () => { + it('should filter out outgoing ERC-20 transfers that imitate a predecessor', async () => { + // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 + const chain = chainBuilder().build(); + const safe = safeBuilder() + .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') + .with('owners', [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]) + .build(); + + const results = [ + { + executionDate: '2024-03-20T09:42:58Z', + to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', + data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', + txHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + blockNumber: 192295013, + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:42:58Z', + blockNumber: 192295013, + transactionHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + transferId: + 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + tokenInfo: { + type: 'ERC20', + address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + trusted: false, + }, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + ], + txType: 'ETHEREUM_TRANSACTION', + from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', + }, + { + safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + to: '0x912CE59144191C1204E64559FE8253a0e49E6548', + value: '0', + data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', + operation: 0, + gasToken: '0x0000000000000000000000000000000000000000', + safeTxGas: 0, + baseGas: 0, + gasPrice: '0', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 3, + executionDate: '2024-03-20T09:41:25Z', + submissionDate: '2024-03-20T09:38:11.447366Z', + modified: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + safeTxHash: + '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + isExecuted: true, + isSuccessful: true, + ethGasPrice: '10946000', + maxFeePerGas: null, + maxPriorityFeePerGas: null, + gasUsed: 249105, + fee: '2726703330000', + origin: '{}', + dataDecoded: { + method: 'transfer', + parameters: [ + { + name: 'to', + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + { + name: 'value', + type: 'uint256', + value: '40000000000000000000000', + }, + ], + }, + confirmationsRequired: 2, + confirmations: [ + { + owner: safe.owners[0], + submissionDate: '2024-03-20T09:38:11.479197Z', + transactionHash: null, + signature: + '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + signatureType: 'EOA', + }, + { + owner: safe.owners[1], + submissionDate: '2024-03-20T09:41:25Z', + transactionHash: null, + signature: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', + signatureType: 'APPROVED_HASH', + }, + ], + trusted: true, + signatures: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', + transferId: + 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', + tokenInfo: { + type: 'ERC20', + address: '0x912CE59144191C1204E64559FE8253a0e49E6548', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + trusted: false, + }, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + ], + txType: 'MULTISIG_TRANSACTION', + }, + { + executionDate: '2024-03-20T09:18:32Z', + to: '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D', + data: '0x8d80ff0a00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000d0b00b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003046a761202000000000000000000000000912ce59144191c1204e64559fe8253a0e49e65480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000044a9059cbb00000000000000000000000088103c9fa3ce4ff45e4c1ea3688f40d1dfda6b020000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010458560018b40606d8124f87297277bbaba0a90b1698370a3803003e716faffa5e443a38e4cec966ae7c438b3e1eebc2a1ad123ea3f8c5380226e879dc5d5818c61b2bd037179d9b51523878f40d3ce1e962d0678daee85ecb0eebdeb569431069c0061e6e079e6d96d1ada98486c9afc99b3b628c459b6fe6d325b38d717ccc3dfa1be5d55404dc15e26effef3d0062dda6c8ac3dd03cdeb2895b2843d78dd1ab5f462db04cff3862a657b12c0667939b6787c57e3df495063542cbb7ce9c2d260f921c98bcb9b2f48e105e7b16dc6099ab0bbbc1daea62e4c76037865cba25fafec3916bbb49ca21866837adb6c1edcc52b820375f359b8d35cb595bd09c26ceda99a01c0000000000000000000000000000000000000000000000000000000000b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006046a76120200000000000000000000000040a2accbd92bca938b02010e17a5b8929b49130d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004c000000000000000000000000000000000000000000000000000000000000003448d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000002fd00912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000009aab7a39e7e022666283454fbd0769f93d1e1d4e000000000000000000000000000000000000000000000cb49b44ba602d80000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000d3fb3ed59e5a7674003625241551a6ffa63d2c5000000000000000000000000000000000000000000000054b40b1f852bda0000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000000000000000000000000878678326eac900000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000e7eb925300075e49fc5caad5d408a50dd22f92d60000000000000000000000000000000000000000000007695a92c20d6fe0000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000bc22b72b0408f66316cc4ee562b858f612776f1400000000000000000000000000000000000000000000054b40b1f852bda0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010425a901b70e415dac6bb78079fc99542d64f9bd5c48823ca0e477d1520d9432254543fa9b814ae03f0f9b74455ee56369d84af999c8462836dd8f39c1b897492f1b3ea25052c6862fc7dfb50b4413a45c14192db85953ee526c022ed35fa43f09c41bb42349464e2e6b2065eda02b88ea80ddc6a2ea3e7125a482fc2ee926661f481c0f25744eb8f7eacf544d17ff8478154e6ab368f4ca0ba0e0f476d5357b28704b4d1263a4c32980cbb013ce893ca9532035e3d2f1c70b6e47e2af49bafda4e9261b023b000c217058732b3bdb2ca73012e2cac856bcd0c9d89336086787dbcaa1985c81f89812e7addd5d7dcd807e14e4bb986d24dbc6ad152796937882d626e6cf1b0000000000000000000000000000000000000000000000000000000000b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003046a761202000000000000000000000000912ce59144191c1204e64559fe8253a0e49e65480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000008704ee9ab8622bbc25410c7d4717ed51f776c7f600000000000000000000000000000000000000000000a1fd44f903579c9400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001042f486e77e4441a2849f295d7615a237417cb9aa94b86cab0c506df9ff8bf18bb13b14a3eb00eb0be707251e21e63283345c951aebd5b9d4eda11ba80223bee861cbd125cbd373edfe7bde69d5382c83d731a587313c473eb8db1ef508f38fb73f5783f09bfa4fe2101c6aec6a22d680edc0e4c13c27dc3eb8ace6ac5a9ac415a781ba117d4d16c977474f442ae6ceb64e3256e31a3acdd9b5bbdc407595a66d521b24458ec59290deaa2fd5e5d68257e653f4ae9d13b22689e90626911324aa1c7631c80b42babdb8705106ac64f3229e18738926786cef4df39831148f330bb5fd78f6be889ba310c7f346d82a62b00cd0acc5a404731e0713a32839077746701a45b1c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + txHash: + '0x85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f6', + blockNumber: 192289077, + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:18:32Z', + blockNumber: 192289077, + transactionHash: + '0x85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f6', + to: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', + transferId: + 'e85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f66', + tokenInfo: { + type: 'ERC20', + address: '0x912CE59144191C1204E64559FE8253a0e49E6548', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + trusted: false, + }, + from: '0xB6fd0BDb1432b2c77170933120079f436F3bB4fa', + }, + ], + txType: 'ETHEREUM_TRANSACTION', + from: '0xFb390aC2028B47031FA4561994fd3abc9FD60a7f', + }, + ]; + + networkService.get.mockImplementation(({ url }) => { + const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; + const getAllTransactions = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; + const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; + const getImitationTokenAddress = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; + const getLegitTokenAddress = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + if (url === getChainUrl) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactions) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getImitationTokenAddress) { + return Promise.resolve({ + data: results[0].transfers[0].tokenInfo, + status: 200, + }); + } + if (url === getLegitTokenAddress) { + return Promise.resolve({ + data: results[1].transfers[0].tokenAddress, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=true`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { + type: 'DATE_LABEL', + timestamp: 1710927685000, + }, + // Only the legitimate transaction (results[1]) should be included as results[0] is imitation + // and results[2] is fetched in order to calculate conflict DATE_LABEL + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 2, + confirmationsSubmitted: 2, + missingSigners: null, + nonce: 3, + type: 'MULTISIG', + }, + id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + safeAppInfo: null, + timestamp: 1710927685000, + txInfo: { + actionCount: null, + dataSize: '68', + humanDescription: + 'Send 40000000000000000000000 to 0xFd7e...A512', + isCancellation: false, + methodName: 'transfer', + richDecodedInfo: { + fragments: [ + { + type: 'text', + value: 'Send', + }, + { + logoUri: null, + symbol: null, + type: 'tokenValue', + value: '40000000000000000000000', + }, + { + type: 'text', + value: 'to', + }, + { + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + ], + }, + to: { + logoUri: null, + name: null, + value: '0x912CE59144191C1204E64559FE8253a0e49E6548', + }, + type: 'Custom', + value: '0', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + it('should not filter imitation transfers if untrusted those untrusted are requested', async () => { + // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 + const chain = chainBuilder().build(); + const safe = safeBuilder() + .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') + .with('owners', [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]) + .build(); + + const results = [ + { + executionDate: '2024-03-20T09:42:58Z', + to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', + data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', + txHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + blockNumber: 192295013, + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:42:58Z', + blockNumber: 192295013, + transactionHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + transferId: + 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + tokenInfo: { + type: 'ERC20', + address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + trusted: false, + }, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + ], + txType: 'ETHEREUM_TRANSACTION', + from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', + }, + { + safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + to: '0x912CE59144191C1204E64559FE8253a0e49E6548', + value: '0', + data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', + operation: 0, + gasToken: '0x0000000000000000000000000000000000000000', + safeTxGas: 0, + baseGas: 0, + gasPrice: '0', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 3, + executionDate: '2024-03-20T09:41:25Z', + submissionDate: '2024-03-20T09:38:11.447366Z', + modified: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + safeTxHash: + '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + isExecuted: true, + isSuccessful: true, + ethGasPrice: '10946000', + maxFeePerGas: null, + maxPriorityFeePerGas: null, + gasUsed: 249105, + fee: '2726703330000', + origin: '{}', + dataDecoded: { + method: 'transfer', + parameters: [ + { + name: 'to', + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + { + name: 'value', + type: 'uint256', + value: '40000000000000000000000', + }, + ], + }, + confirmationsRequired: 2, + confirmations: [ + { + owner: safe.owners[0], + submissionDate: '2024-03-20T09:38:11.479197Z', + transactionHash: null, + signature: + '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + signatureType: 'EOA', + }, + { + owner: safe.owners[1], + submissionDate: '2024-03-20T09:41:25Z', + transactionHash: null, + signature: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', + signatureType: 'APPROVED_HASH', + }, + ], + trusted: true, + signatures: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', + transferId: + 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', + tokenInfo: { + type: 'ERC20', + address: '0x912CE59144191C1204E64559FE8253a0e49E6548', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + trusted: false, + }, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + ], + txType: 'MULTISIG_TRANSACTION', + }, + { + executionDate: '2024-03-20T09:18:32Z', + to: '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D', + data: '0x8d80ff0a00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000d0b00b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003046a761202000000000000000000000000912ce59144191c1204e64559fe8253a0e49e65480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000044a9059cbb00000000000000000000000088103c9fa3ce4ff45e4c1ea3688f40d1dfda6b020000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010458560018b40606d8124f87297277bbaba0a90b1698370a3803003e716faffa5e443a38e4cec966ae7c438b3e1eebc2a1ad123ea3f8c5380226e879dc5d5818c61b2bd037179d9b51523878f40d3ce1e962d0678daee85ecb0eebdeb569431069c0061e6e079e6d96d1ada98486c9afc99b3b628c459b6fe6d325b38d717ccc3dfa1be5d55404dc15e26effef3d0062dda6c8ac3dd03cdeb2895b2843d78dd1ab5f462db04cff3862a657b12c0667939b6787c57e3df495063542cbb7ce9c2d260f921c98bcb9b2f48e105e7b16dc6099ab0bbbc1daea62e4c76037865cba25fafec3916bbb49ca21866837adb6c1edcc52b820375f359b8d35cb595bd09c26ceda99a01c0000000000000000000000000000000000000000000000000000000000b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006046a76120200000000000000000000000040a2accbd92bca938b02010e17a5b8929b49130d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004c000000000000000000000000000000000000000000000000000000000000003448d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000002fd00912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000009aab7a39e7e022666283454fbd0769f93d1e1d4e000000000000000000000000000000000000000000000cb49b44ba602d80000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000d3fb3ed59e5a7674003625241551a6ffa63d2c5000000000000000000000000000000000000000000000054b40b1f852bda0000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000000000000000000000000878678326eac900000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000e7eb925300075e49fc5caad5d408a50dd22f92d60000000000000000000000000000000000000000000007695a92c20d6fe0000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000bc22b72b0408f66316cc4ee562b858f612776f1400000000000000000000000000000000000000000000054b40b1f852bda0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010425a901b70e415dac6bb78079fc99542d64f9bd5c48823ca0e477d1520d9432254543fa9b814ae03f0f9b74455ee56369d84af999c8462836dd8f39c1b897492f1b3ea25052c6862fc7dfb50b4413a45c14192db85953ee526c022ed35fa43f09c41bb42349464e2e6b2065eda02b88ea80ddc6a2ea3e7125a482fc2ee926661f481c0f25744eb8f7eacf544d17ff8478154e6ab368f4ca0ba0e0f476d5357b28704b4d1263a4c32980cbb013ce893ca9532035e3d2f1c70b6e47e2af49bafda4e9261b023b000c217058732b3bdb2ca73012e2cac856bcd0c9d89336086787dbcaa1985c81f89812e7addd5d7dcd807e14e4bb986d24dbc6ad152796937882d626e6cf1b0000000000000000000000000000000000000000000000000000000000b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003046a761202000000000000000000000000912ce59144191c1204e64559fe8253a0e49e65480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000008704ee9ab8622bbc25410c7d4717ed51f776c7f600000000000000000000000000000000000000000000a1fd44f903579c9400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001042f486e77e4441a2849f295d7615a237417cb9aa94b86cab0c506df9ff8bf18bb13b14a3eb00eb0be707251e21e63283345c951aebd5b9d4eda11ba80223bee861cbd125cbd373edfe7bde69d5382c83d731a587313c473eb8db1ef508f38fb73f5783f09bfa4fe2101c6aec6a22d680edc0e4c13c27dc3eb8ace6ac5a9ac415a781ba117d4d16c977474f442ae6ceb64e3256e31a3acdd9b5bbdc407595a66d521b24458ec59290deaa2fd5e5d68257e653f4ae9d13b22689e90626911324aa1c7631c80b42babdb8705106ac64f3229e18738926786cef4df39831148f330bb5fd78f6be889ba310c7f346d82a62b00cd0acc5a404731e0713a32839077746701a45b1c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + txHash: + '0x85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f6', + blockNumber: 192289077, + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:18:32Z', + blockNumber: 192289077, + transactionHash: + '0x85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f6', + to: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', + transferId: + 'e85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f66', + tokenInfo: { + type: 'ERC20', + address: '0x912CE59144191C1204E64559FE8253a0e49E6548', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + trusted: false, + }, + from: '0xB6fd0BDb1432b2c77170933120079f436F3bB4fa', + }, + ], + txType: 'ETHEREUM_TRANSACTION', + from: '0xFb390aC2028B47031FA4561994fd3abc9FD60a7f', + }, + ]; + + networkService.get.mockImplementation(({ url }) => { + const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; + const getAllTransactions = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; + const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; + const getImitationTokenAddress = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; + const getLegitTokenAddress = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + if (url === getChainUrl) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactions) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getImitationTokenAddress) { + return Promise.resolve({ + data: results[0].transfers[0].tokenInfo, + status: 200, + }); + } + if (url === getLegitTokenAddress) { + return Promise.resolve({ + data: results[1].transfers[0].tokenAddress, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=false`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { + timestamp: 1710927778000, + type: 'DATE_LABEL', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + id: 'transfer_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: false, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 2, + confirmationsSubmitted: 2, + missingSigners: null, + nonce: 3, + type: 'MULTISIG', + }, + id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + safeAppInfo: null, + timestamp: 1710927685000, + txInfo: { + actionCount: null, + dataSize: '68', + humanDescription: + 'Send 40000000000000000000000 to 0xFd7e...A512', + isCancellation: false, + methodName: 'transfer', + richDecodedInfo: { + fragments: [ + { + type: 'text', + value: 'Send', + }, + { + logoUri: null, + symbol: null, + type: 'tokenValue', + value: '40000000000000000000000', + }, + { + type: 'text', + value: 'to', + }, + { + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + ], + }, + to: { + logoUri: null, + name: null, + value: '0x912CE59144191C1204E64559FE8253a0e49E6548', + }, + type: 'Custom', + value: '0', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + id: 'transfer_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_e85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f66', + safeAppInfo: null, + timestamp: 1710926312000, + txInfo: { + direction: 'INCOMING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: '0xB6fd0BDb1432b2c77170933120079f436F3bB4fa', + }, + transferInfo: { + decimals: null, + logoUri: null, + tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', + tokenName: null, + tokenSymbol: null, + trusted: null, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + }); }); diff --git a/src/routes/transactions/transactions.module.ts b/src/routes/transactions/transactions.module.ts index aa5c9e8791..30d8802c3c 100644 --- a/src/routes/transactions/transactions.module.ts +++ b/src/routes/transactions/transactions.module.ts @@ -5,6 +5,7 @@ import { DataDecodedParamHelper } from '@/routes/transactions/mappers/common/dat import { Erc20TransferMapper } from '@/routes/transactions/mappers/common/erc20-transfer.mapper'; import { Erc721TransferMapper } from '@/routes/transactions/mappers/common/erc721-transfer.mapper'; import { HumanDescriptionMapper } from '@/routes/transactions/mappers/common/human-description.mapper'; +import { ImitationTransactionsHelper } from '@/routes/transactions/helpers/imitation-transactions.helper'; import { NativeCoinTransferMapper } from '@/routes/transactions/mappers/common/native-coin-transfer.mapper'; import { SafeAppInfoMapper } from '@/routes/transactions/mappers/common/safe-app-info.mapper'; import { SettingsChangeMapper } from '@/routes/transactions/mappers/common/settings-change.mapper'; @@ -57,6 +58,7 @@ import { SwapOrderHelperModule } from '@/routes/transactions/helpers/swap-order. DataDecodedParamHelper, Erc20TransferMapper, Erc721TransferMapper, + ImitationTransactionsHelper, TransferMapper, ModuleTransactionDetailsMapper, ModuleTransactionMapper, From 6770f438d1ee8b98b1d7e1e19684a8e69a1777f3 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 13 May 2024 13:03:02 +0200 Subject: [PATCH 40/65] Decrease prefix length of imitation recipient check (#1534) * Decrease prefix length of imitation recipient check * Increase test length --- src/config/entities/__tests__/configuration.ts | 3 ++- src/config/entities/configuration.ts | 3 ++- .../helpers/imitation-transactions.helper.ts | 15 +++++++++------ .../transactions-history.controller.spec.ts | 6 ++++-- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index b1628e2c1b..961946ad31 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -201,7 +201,8 @@ export default (): ReturnType => ({ }, mappings: { imitationTransactions: { - vanityAddressChars: faker.number.int(), + prefixLength: faker.number.int(), + suffixLength: faker.number.int(), }, history: { maxNestedTransfers: faker.number.int({ min: 1, max: 5 }), diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 44bb036968..f4c4153b30 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -223,7 +223,8 @@ export default () => ({ }, mappings: { imitationTransactions: { - vanityAddressChars: parseInt(process.env.VANITY_ADDRESS_CHARS ?? `${4}`), + prefixLength: parseInt(process.env.IMITATION_PREFIX_LENGTH ?? `${3}`), + suffixLength: parseInt(process.env.VANITY_ADDRESS_CHARS ?? `${4}`), }, history: { maxNestedTransfers: parseInt( diff --git a/src/routes/transactions/helpers/imitation-transactions.helper.ts b/src/routes/transactions/helpers/imitation-transactions.helper.ts index a54eb77e00..4908ea265e 100644 --- a/src/routes/transactions/helpers/imitation-transactions.helper.ts +++ b/src/routes/transactions/helpers/imitation-transactions.helper.ts @@ -8,13 +8,17 @@ import { isErc20Transfer } from '@/routes/transactions/entities/transfers/erc20- import { Inject } from '@nestjs/common'; export class ImitationTransactionsHelper { - private readonly vanityAddressChars: number; + private readonly prefixLength: number; + private readonly suffixLength: number; constructor( @Inject(IConfigurationService) configurationService: IConfigurationService, ) { - this.vanityAddressChars = configurationService.getOrThrow( - 'mappings.imitationTransactions.vanityAddressChars', + this.prefixLength = configurationService.getOrThrow( + 'mappings.imitationTransactions.prefixLength', + ); + this.suffixLength = configurationService.getOrThrow( + 'mappings.imitationTransactions.suffixLength', ); } @@ -98,10 +102,9 @@ export class ImitationTransactionsHelper { const isSamePrefix = // Ignore `0x` prefix - a1.slice(2, 2 + this.vanityAddressChars) === - a2.slice(2, 2 + this.vanityAddressChars); + a1.slice(2, 2 + this.prefixLength) === a2.slice(2, 2 + this.prefixLength); const isSameSuffix = - a1.slice(-this.vanityAddressChars) === a2.slice(-this.vanityAddressChars); + a1.slice(-this.suffixLength) === a2.slice(-this.suffixLength); return isSamePrefix && isSameSuffix; } } diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 906c19187a..27ab3eb2b5 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -67,7 +67,8 @@ describe('Transactions History Controller (Unit)', () => { let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; - const vanityAddressChars = 4; + const prefixLength = 3; + const suffixLength = 4; beforeEach(async () => { jest.resetAllMocks(); @@ -77,7 +78,8 @@ describe('Transactions History Controller (Unit)', () => { mappings: { ...configuration().mappings, imitationTransactions: { - vanityAddressChars, + prefixLength, + suffixLength, }, features: { imitationFiltering: true, From 0fdd01058392e1394e40f0eec6a7ea488e7d2763 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 13 May 2024 13:03:23 +0200 Subject: [PATCH 41/65] Remove `onlyTrusted` condition for filtering (#1535) "emoves the conditional filtering of imitation transactions based on the `trusted` param. --- .../transactions/mappers/transactions-history.mapper.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/transactions/mappers/transactions-history.mapper.ts b/src/routes/transactions/mappers/transactions-history.mapper.ts index a02137547a..b31b6680b2 100644 --- a/src/routes/transactions/mappers/transactions-history.mapper.ts +++ b/src/routes/transactions/mappers/transactions-history.mapper.ts @@ -141,7 +141,9 @@ export class TransactionsHistoryMapper { .filter((x: T): x is NonNullable => x != null) .flat(); - if (!this.isImitationFilteringEnabled || !args.onlyTrusted) { + // TODO: Make conditional according to args.onlyTrusted if clients fetch + // trusted regardless of token lists + if (!this.isImitationFilteringEnabled) { return transactionItems; } From 726c49a8d9f12af8ec9d2c4ea832213833b28af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Mon, 13 May 2024 13:17:57 +0200 Subject: [PATCH 42/65] Fix @typescript-eslint/no-redundant-type-constituents instances (#1531) Enable `@typescript-eslint/no-redundant-type-constituents` ESLint rule --- eslint.config.mjs | 1 - src/domain/interfaces/transaction-api.interface.ts | 2 +- src/logging/__tests__/test.logging.module.ts | 8 ++++---- src/logging/logging.interface.ts | 8 ++++---- src/logging/logging.service.ts | 10 +++++----- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index f986d3ca68..f294835034 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,7 +31,6 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-floating-promises': 'warn', // TODO: Address these rules: (added to update to ESLint 9) - '@typescript-eslint/no-redundant-type-constituents': 'off', '@typescript-eslint/no-unnecessary-type-assertion': 'off', '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index 36587230f4..017965bf72 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -212,7 +212,7 @@ export interface ITransactionApi { postMessage(args: { safeAddress: string; - message: string | unknown; + message: unknown; safeAppId: number | null; signature: string; }): Promise; diff --git a/src/logging/__tests__/test.logging.module.ts b/src/logging/__tests__/test.logging.module.ts index c4c1cd94b1..5ddb101aa0 100644 --- a/src/logging/__tests__/test.logging.module.ts +++ b/src/logging/__tests__/test.logging.module.ts @@ -12,22 +12,22 @@ class TestLoggingService implements ILoggingService { this.isSilent = configurationService.getOrThrow('log.silent'); } - debug(message: string | unknown): void { + debug(message: unknown): void { if (this.isSilent) return; console.debug(message); } - error(message: string | unknown): void { + error(message: unknown): void { if (this.isSilent) return; console.error(message); } - info(message: string | unknown): void { + info(message: unknown): void { if (this.isSilent) return; console.info(message); } - warn(message: string | unknown): void { + warn(message: unknown): void { if (this.isSilent) return; console.warn(message); } diff --git a/src/logging/logging.interface.ts b/src/logging/logging.interface.ts index ae21663424..a06317745f 100644 --- a/src/logging/logging.interface.ts +++ b/src/logging/logging.interface.ts @@ -1,11 +1,11 @@ export const LoggingService = Symbol('ILoggingService'); export interface ILoggingService { - info(message: string | unknown): void; + info(message: unknown): void; - debug(message: string | unknown): void; + debug(message: unknown): void; - error(message: string | unknown): void; + error(message: unknown): void; - warn(message: string | unknown): void; + warn(message: unknown): void; } diff --git a/src/logging/logging.service.ts b/src/logging/logging.service.ts index 2a5a04292c..5e55fde79b 100644 --- a/src/logging/logging.service.ts +++ b/src/logging/logging.service.ts @@ -25,23 +25,23 @@ export class RequestScopedLoggingService implements ILoggingService { this.buildNumber = configurationService.get('about.buildNumber'); } - info(message: string | unknown): void { + info(message: unknown): void { this.logger.log('info', this.formatMessage(message)); } - error(message: string | unknown): void { + error(message: unknown): void { this.logger.log('error', this.formatMessage(message)); } - warn(message: string | unknown): void { + warn(message: unknown): void { this.logger.log('warn', this.formatMessage(message)); } - debug(message: string | unknown): void { + debug(message: unknown): void { this.logger.log('debug', this.formatMessage(message)); } - private formatMessage(message: string | unknown): { + private formatMessage(message: unknown): { message: unknown; build_number: string | undefined; request_id: string; From 8e110139ddc9d3e2927c247019bce88347873bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Mon, 13 May 2024 13:52:20 +0200 Subject: [PATCH 43/65] Fix @typescript-eslint/no-unnecessary-type-assertion instances (#1532) * Fix @typescript-eslint/no-unnecessary-type-assertion instances --- eslint.config.mjs | 1 - .../delay-modifier-encoder.builder.ts | 6 +-- .../relay/limit-addresses.mapper.spec.ts | 44 +++++++++---------- src/routes/alerts/alerts.controller.spec.ts | 6 +-- src/routes/relay/relay.controller.spec.ts | 36 +++++++-------- .../helpers/swap-order.helper.spec.ts | 7 +-- 6 files changed, 47 insertions(+), 53 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index f294835034..7aef3a0df5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,7 +31,6 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-floating-promises': 'warn', // TODO: Address these rules: (added to update to ESLint 9) - '@typescript-eslint/no-unnecessary-type-assertion': 'off', '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-call': 'off', diff --git a/src/domain/alerts/contracts/__tests__/encoders/delay-modifier-encoder.builder.ts b/src/domain/alerts/contracts/__tests__/encoders/delay-modifier-encoder.builder.ts index dbd8048746..73f16b31b2 100644 --- a/src/domain/alerts/contracts/__tests__/encoders/delay-modifier-encoder.builder.ts +++ b/src/domain/alerts/contracts/__tests__/encoders/delay-modifier-encoder.builder.ts @@ -44,15 +44,15 @@ class TransactionAddedEventBuilder const data = encodeAbiParameters( parseAbiParameters(TransactionAddedEventBuilder.NON_INDEXED_PARAMS), - [args.to!, args.value!, args.data!, args.operation!], + [args.to, args.value, args.data, args.operation], ); const topics = encodeEventTopics({ abi, eventName: 'TransactionAdded', args: { - queueNonce: args.queueNonce!, - txHash: args.txHash!, + queueNonce: args.queueNonce, + txHash: args.txHash, }, }) as TransactionAddedEvent['topics']; diff --git a/src/domain/relay/limit-addresses.mapper.spec.ts b/src/domain/relay/limit-addresses.mapper.spec.ts index 5673ca0559..fdbb197212 100644 --- a/src/domain/relay/limit-addresses.mapper.spec.ts +++ b/src/domain/relay/limit-addresses.mapper.spec.ts @@ -35,7 +35,7 @@ import { getSafeL2SingletonDeployment, getSafeSingletonDeployment, } from '@safe-global/safe-deployments'; -import { Hex, getAddress } from 'viem'; +import { getAddress } from 'viem'; import configuration from '@/config/entities/configuration'; import { getDeploymentVersionsByChainIds } from '@/__tests__/deployments.helper'; @@ -97,7 +97,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -116,7 +116,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('data', erc20TransferEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -135,7 +135,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('data', erc20TransferFromEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -154,7 +154,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('data', erc20ApproveEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -174,7 +174,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', '0x') - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -194,7 +194,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', addOwnerWithThresholdEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -214,7 +214,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', changeThresholdEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -234,7 +234,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', enableModuleEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -254,7 +254,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', disableModuleEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -274,7 +274,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', removeOwnerEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -294,7 +294,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', setFallbackHandlerEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -314,7 +314,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', setGuardEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -334,7 +334,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', swapOwnerEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -353,7 +353,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('data', execTransactionEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -373,7 +373,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockRejectedValue(true); @@ -398,7 +398,7 @@ describe('LimitAddressesMapper', () => { 'data', erc20TransferEncoder().with('to', safeAddress).encode(), ) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -425,7 +425,7 @@ describe('LimitAddressesMapper', () => { .with('recipient', safeAddress) .encode(), ) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -453,7 +453,7 @@ describe('LimitAddressesMapper', () => { .with('recipient', recipient) .encode(), ) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -476,7 +476,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', erc20ApproveEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -498,7 +498,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('to', safeAddress) - .encode() as Hex; + .encode(); // Unofficial mastercopy mockSafeRepository.getSafe.mockRejectedValue( new Error('Not official mastercopy'), @@ -523,7 +523,7 @@ describe('LimitAddressesMapper', () => { const version = '0.0.1'; const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); - const data = execTransactionEncoder().encode() as Hex; + const data = execTransactionEncoder().encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); diff --git a/src/routes/alerts/alerts.controller.spec.ts b/src/routes/alerts/alerts.controller.spec.ts index 8e0ded493e..e565d3b634 100644 --- a/src/routes/alerts/alerts.controller.spec.ts +++ b/src/routes/alerts/alerts.controller.spec.ts @@ -630,7 +630,7 @@ describe('Alerts (Unit)', () => { .with('data', multiSend.encode()) .with( 'to', - getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress!), + getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress), ) .encode(); @@ -1171,7 +1171,7 @@ describe('Alerts (Unit)', () => { .with('data', multiSend.encode()) .with( 'to', - getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress!), + getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress), ) .encode(); @@ -1286,7 +1286,7 @@ describe('Alerts (Unit)', () => { .with('data', multiSend.encode()) .with( 'to', - getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress!), + getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress), ) .encode(); diff --git a/src/routes/relay/relay.controller.spec.ts b/src/routes/relay/relay.controller.spec.ts index 260e15e135..22deb8adfc 100644 --- a/src/routes/relay/relay.controller.spec.ts +++ b/src/routes/relay/relay.controller.spec.ts @@ -20,7 +20,7 @@ import { INestApplication } from '@nestjs/common'; import { faker } from '@faker-js/faker'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; -import { Hex, getAddress } from 'viem'; +import { getAddress } from 'viem'; import { addOwnerWithThresholdEncoder, changeThresholdEncoder, @@ -137,7 +137,7 @@ describe('Relay controller', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -177,7 +177,7 @@ describe('Relay controller', () => { const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); const gasLimit = faker.string.numeric({ exclude: '0' }); - const data = execTransactionEncoder().encode() as Hex; + const data = execTransactionEncoder().encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -262,7 +262,7 @@ describe('Relay controller', () => { const safe = safeBuilder().build(); const data = execTransactionEncoder() .with('data', execTransactionData) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -312,7 +312,7 @@ describe('Relay controller', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', execTransactionEncoder().encode()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -747,7 +747,7 @@ describe('Relay controller', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -785,7 +785,7 @@ describe('Relay controller', () => { 'data', erc20TransferEncoder().with('to', safeAddress).encode(), ) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -825,7 +825,7 @@ describe('Relay controller', () => { .with('recipient', safeAddress) .encode(), ) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -866,7 +866,7 @@ describe('Relay controller', () => { .with('recipient', recipient) .encode(), ) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -902,7 +902,7 @@ describe('Relay controller', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', erc20ApproveEncoder().encode()) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -936,7 +936,7 @@ describe('Relay controller', () => { const safeAddress = faker.finance.ethereumAddress(); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -1245,7 +1245,7 @@ describe('Relay controller', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const gasLimit = 'invalid'; await request(app.getHttpServer()) @@ -1310,7 +1310,7 @@ describe('Relay controller', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -1495,7 +1495,7 @@ describe('Relay controller', () => { const checksummedSafeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -1554,7 +1554,7 @@ describe('Relay controller', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -1600,7 +1600,7 @@ describe('Relay controller', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -1653,7 +1653,7 @@ describe('Relay controller', () => { const version = '1.3.0'; const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); - const data = execTransactionEncoder().encode() as Hex; + const data = execTransactionEncoder().encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -1702,7 +1702,7 @@ describe('Relay controller', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { diff --git a/src/routes/transactions/helpers/swap-order.helper.spec.ts b/src/routes/transactions/helpers/swap-order.helper.spec.ts index 47f3476e12..b5d853f9e5 100644 --- a/src/routes/transactions/helpers/swap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/swap-order.helper.spec.ts @@ -162,12 +162,7 @@ describe('Swap Order Helper tests', () => { const error = new Error('Order not found'); swapsRepositoryMock.getOrder.mockRejectedValue(error); - await expect( - target.getOrder({ - chainId, - orderUid: orderUid as `0x${string}`, - }), - ).rejects.toThrow(error); + await expect(target.getOrder({ chainId, orderUid })).rejects.toThrow(error); expect(swapsRepositoryMock.getOrder).toHaveBeenCalledTimes(1); expect(swapsRepositoryMock.getOrder).toHaveBeenCalledWith( From 2a189f6ef4bf54aa42dab13847ab1c4ff978053b Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 13 May 2024 14:04:40 +0200 Subject: [PATCH 44/65] Authorise recovery module adding/deleting with JWT authentication (#1527) Migrates the suboptiomal authentication/authorisation for adding/removing recovery module addresses to the new JWT/SIWE-based approach: - Remove `OnlySafeOwner`, `TimestampGuard`, `DisableRecoveryAlertsGuard` and `EnableRecoveryAlertsGuard` and associated tests - Replace above guard usage `AuthGuard` - Checksum incoming require Safe/module addresses - Pass `AuthPayload` to domain and assert chain, signer and ownership before adding/deleting module - Add/update associated test coverage --- .../alerts-api/tenderly-api.service.spec.ts | 9 +- .../alerts/entities/alerts-deletion.entity.ts | 2 +- .../entities/alerts-registration.entity.ts | 3 +- .../guards/only-safe-owner.guard.spec.ts | 161 ----- .../common/guards/only-safe-owner.guard.ts | 42 -- .../email/guards/timestamp.guard.spec.ts | 89 --- src/routes/email/guards/timestamp.guard.ts | 27 - .../disable-recovery-alerts.guard.spec.ts | 235 ------- .../guards/disable-recovery-alerts.guard.ts | 84 --- .../enable-recovery-alerts.guard.spec.ts | 218 ------- .../guards/enable-recovery-alerts.guard.ts | 84 --- .../recovery/recovery.controller.spec.ts | 580 +++++++++--------- src/routes/recovery/recovery.controller.ts | 33 +- src/routes/recovery/recovery.module.ts | 3 +- src/routes/recovery/recovery.service.ts | 69 ++- 15 files changed, 369 insertions(+), 1270 deletions(-) delete mode 100644 src/routes/common/guards/only-safe-owner.guard.spec.ts delete mode 100644 src/routes/common/guards/only-safe-owner.guard.ts delete mode 100644 src/routes/email/guards/timestamp.guard.spec.ts delete mode 100644 src/routes/email/guards/timestamp.guard.ts delete mode 100644 src/routes/recovery/guards/disable-recovery-alerts.guard.spec.ts delete mode 100644 src/routes/recovery/guards/disable-recovery-alerts.guard.ts delete mode 100644 src/routes/recovery/guards/enable-recovery-alerts.guard.spec.ts delete mode 100644 src/routes/recovery/guards/enable-recovery-alerts.guard.ts diff --git a/src/datasources/alerts-api/tenderly-api.service.spec.ts b/src/datasources/alerts-api/tenderly-api.service.spec.ts index cd08e11deb..7e708c3479 100644 --- a/src/datasources/alerts-api/tenderly-api.service.spec.ts +++ b/src/datasources/alerts-api/tenderly-api.service.spec.ts @@ -7,6 +7,7 @@ import { AlertsRegistration } from '@/domain/alerts/entities/alerts-registration import { DataSourceError } from '@/domain/errors/data-source.error'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; import { AlertsDeletion } from '@/domain/alerts/entities/alerts-deletion.entity'; +import { getAddress } from 'viem'; const networkService = { post: jest.fn(), @@ -71,7 +72,7 @@ describe('TenderlyApi', () => { }; const contract: AlertsRegistration = { - address: faker.finance.ethereumAddress(), + address: getAddress(faker.finance.ethereumAddress()), displayName: fakeDisplayName(), chainId: faker.string.numeric(), }; @@ -108,7 +109,7 @@ describe('TenderlyApi', () => { await expect( service.addContract({ - address: faker.finance.ethereumAddress(), + address: getAddress(faker.finance.ethereumAddress()), chainId: faker.string.numeric(), }), ).rejects.toThrow(new DataSourceError('Unexpected error', status)); @@ -120,7 +121,7 @@ describe('TenderlyApi', () => { describe('deleteContract', () => { it('should delete a contract', async () => { const contract: AlertsDeletion = { - address: faker.finance.ethereumAddress(), + address: getAddress(faker.finance.ethereumAddress()), chainId: faker.string.numeric(), }; @@ -151,7 +152,7 @@ describe('TenderlyApi', () => { await expect( service.deleteContract({ - address: faker.finance.ethereumAddress(), + address: getAddress(faker.finance.ethereumAddress()), chainId: faker.string.numeric(), }), ).rejects.toThrow(new DataSourceError('Unexpected error', status)); diff --git a/src/domain/alerts/entities/alerts-deletion.entity.ts b/src/domain/alerts/entities/alerts-deletion.entity.ts index 45138f79cf..4e24b7884e 100644 --- a/src/domain/alerts/entities/alerts-deletion.entity.ts +++ b/src/domain/alerts/entities/alerts-deletion.entity.ts @@ -1,4 +1,4 @@ export type AlertsDeletion = { chainId: string; - address: string; + address: `0x${string}`; }; diff --git a/src/domain/alerts/entities/alerts-registration.entity.ts b/src/domain/alerts/entities/alerts-registration.entity.ts index c8ad97e0a1..d211518098 100644 --- a/src/domain/alerts/entities/alerts-registration.entity.ts +++ b/src/domain/alerts/entities/alerts-registration.entity.ts @@ -1,5 +1,6 @@ export type AlertsRegistration = { - address: string; + address: `0x${string}`; chainId: string; + // {chainId}:{safeAddress}:{moduleAddress} displayName?: `${string}:${string}:${string}`; }; diff --git a/src/routes/common/guards/only-safe-owner.guard.spec.ts b/src/routes/common/guards/only-safe-owner.guard.spec.ts deleted file mode 100644 index 357ab9697c..0000000000 --- a/src/routes/common/guards/only-safe-owner.guard.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - Controller, - HttpCode, - INestApplication, - Post, - UseGuards, -} from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { ConfigurationModule } from '@/config/configuration.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { OnlySafeOwnerGuard } from '@/routes/common/guards/only-safe-owner.guard'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -const safeRepository = { - isOwner: jest.fn(), -} as jest.MockedObjectDeep; - -const safeRepositoryMock = jest.mocked(safeRepository); - -@Controller() -class TestController { - @Post('test/:chainId/:safeAddress') - @HttpCode(200) - @UseGuards(OnlySafeOwnerGuard) - async validRoute(): Promise {} - - @Post('test/invalid/chains/:chainId') - @HttpCode(200) - @UseGuards(OnlySafeOwnerGuard) - async invalidRouteWithChainId(): Promise {} - - @Post('test/invalid/safes/:safeAddress') - @HttpCode(200) - @UseGuards(OnlySafeOwnerGuard) - async invalidRouteWithSafeAddress(): Promise {} -} - -describe('OnlySafeOwner guard tests', () => { - let app: INestApplication; - - beforeEach(async () => { - jest.resetAllMocks(); - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [TestLoggingModule, ConfigurationModule.register(configuration)], - controllers: [TestController], - providers: [ - { - provide: ISafeRepository, - useValue: safeRepositoryMock, - }, - ], - }).compile(); - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - - it('returns 403 on empty body', async () => { - const chainId = faker.string.numeric(); - const safe = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}`) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 200 if account is an owner of the safe', async () => { - const chainId = faker.string.numeric(); - const safe = faker.finance.ethereumAddress(); - const signer = faker.finance.ethereumAddress(); - safeRepositoryMock.isOwner.mockImplementation((args) => { - if ( - args.chainId !== chainId || - args.address !== signer || - args.safeAddress !== safe - ) - return Promise.reject(); - else return Promise.resolve(true); - }); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}`) - .send({ - signer: signer, - }) - .expect(200); - }); - - it('returns 403 if account is not an owner of the safe', async () => { - const chainId = faker.string.numeric(); - const safe = faker.finance.ethereumAddress(); - const account = faker.finance.ethereumAddress(); - safeRepositoryMock.isOwner.mockImplementation((args) => { - if ( - args.chainId !== chainId || - args.address !== account || - args.safeAddress !== safe - ) - return Promise.reject(); - else return Promise.resolve(false); - }); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}`) - .send({ - account: account, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without safe address', async () => { - const chainId = faker.string.numeric(); - const account = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .post(`/test/invalid/chains/${chainId}`) - .send({ - account: account, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without chain id', async () => { - const safeAddress = faker.finance.ethereumAddress(); - const account = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .post(`/test/invalid/safes/${safeAddress}`) - .send({ - account: account, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); -}); diff --git a/src/routes/common/guards/only-safe-owner.guard.ts b/src/routes/common/guards/only-safe-owner.guard.ts deleted file mode 100644 index 19da62e3c0..0000000000 --- a/src/routes/common/guards/only-safe-owner.guard.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -/** - * The OnlySafeOwner guard can be applied to any route that requires - * that a provided 'signer' (owner) is part of a Safe - * - * This guard does not validate that a message came from said owner. - * - * To use this guard, the route should have: - * - the 'chainId' as part of the path parameters - * - the 'safeAddress' as part of the path parameters - * - the 'signer' as part of the path parameters or as part of the JSON body (top level) - */ -@Injectable() -export class OnlySafeOwnerGuard implements CanActivate { - constructor( - @Inject(ISafeRepository) private readonly safeRepository: ISafeRepository, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - - const chainId = request.params['chainId']; - const safe = request.params['safeAddress']; - const signer = request.params['signer'] ?? request.body['signer']; - - // Required fields - if (!chainId || !safe || !signer) return false; - - return await this.safeRepository.isOwner({ - chainId, - safeAddress: safe, - address: signer, - }); - } -} diff --git a/src/routes/email/guards/timestamp.guard.spec.ts b/src/routes/email/guards/timestamp.guard.spec.ts deleted file mode 100644 index a4cad899a3..0000000000 --- a/src/routes/email/guards/timestamp.guard.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { ConfigurationModule } from '@/config/configuration.module'; -import configuration from '@/config/entities/configuration'; -import { - Controller, - HttpCode, - INestApplication, - Post, - UseGuards, -} from '@nestjs/common'; -import { TimestampGuard } from '@/routes/email/guards/timestamp.guard'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; - -const MAX_ELAPSED_TIME_MS = 5_000; - -@Controller() -class TestController { - @Post('test') - @HttpCode(200) - @UseGuards(TimestampGuard(MAX_ELAPSED_TIME_MS)) - async validRoute(): Promise {} -} - -describe('TimestampGuard tests', () => { - let app: INestApplication; - - beforeEach(async () => { - jest.useFakeTimers(); - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [TestLoggingModule, ConfigurationModule.register(configuration)], - controllers: [TestController], - }).compile(); - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(async () => { - jest.useRealTimers(); - await app.close(); - }); - - it('returns 403 on empty Safe-Wallet-Signature-Timestamp header', async () => { - await request(app.getHttpServer()).post(`/test`).expect(403).expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if timestamp is not a number', async () => { - await request(app.getHttpServer()) - .post(`/test`) - .set('Safe-Wallet-Signature-Timestamp', faker.word.sample()) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 200 with 1ms to go', async () => { - const timestamp = jest.now(); - jest.advanceTimersByTime(MAX_ELAPSED_TIME_MS - 1); - - await request(app.getHttpServer()) - .post(`/test`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .expect(200); - }); - - it('returns 403 with 0ms to go', async () => { - const timestamp = jest.now(); - jest.advanceTimersByTime(MAX_ELAPSED_TIME_MS); - - await request(app.getHttpServer()) - .post(`/test`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); -}); diff --git a/src/routes/email/guards/timestamp.guard.ts b/src/routes/email/guards/timestamp.guard.ts deleted file mode 100644 index cf349dbc31..0000000000 --- a/src/routes/email/guards/timestamp.guard.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CanActivate, ExecutionContext, mixin, Type } from '@nestjs/common'; - -/** - * Returns a guard mixin that can be used to check if a 'timestamp' - * provided in the body of the HTTP request is within maxElapsedTimeMs - * from the current system time in UTC. - * - * @param maxElapsedTimeMs - the amount in ms to which this guard should allow - * the request to go through - */ -export const TimestampGuard = (maxElapsedTimeMs: number): Type => { - class TimestampGuardMixin implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - - const timestampRaw = request.headers['safe-wallet-signature-timestamp']; - const timestamp = parseInt(timestampRaw); - if (isNaN(timestamp)) return false; - - // UTC timezone - const now = Date.now(); - return now - timestamp < maxElapsedTimeMs; - } - } - - return mixin(TimestampGuardMixin); -}; diff --git a/src/routes/recovery/guards/disable-recovery-alerts.guard.spec.ts b/src/routes/recovery/guards/disable-recovery-alerts.guard.spec.ts deleted file mode 100644 index c354afa40b..0000000000 --- a/src/routes/recovery/guards/disable-recovery-alerts.guard.spec.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { - Controller, - Delete, - INestApplication, - UseGuards, -} from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { ConfigurationModule } from '@/config/configuration.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import { Hash, getAddress } from 'viem'; -import { DisableRecoveryAlertsGuard } from '@/routes/recovery/guards/disable-recovery-alerts.guard'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -const safeRepository = { - getSafesByModule: jest.fn(), -} as jest.MockedObjectDeep; -const safeRepositoryMock = jest.mocked(safeRepository); - -@Controller() -class TestController { - @Delete('test/:chainId/:safeAddress/:moduleAddress') - @UseGuards(DisableRecoveryAlertsGuard) - async validRouteWithSigner(): Promise {} - - @Delete('test/invalid/1/chains/:safeAddress/:moduleAddress') - @UseGuards(DisableRecoveryAlertsGuard) - async invalidRouteWithoutChainId(): Promise {} - - @Delete('test/invalid/2/:chainId/:moduleAddress') - @UseGuards(DisableRecoveryAlertsGuard) - async invalidRouteWithoutSafeAddress(): Promise {} - - @Delete('test/invalid/3/:chainId/:safeAddress') - @UseGuards(DisableRecoveryAlertsGuard) - async invalidRouteWithoutModuleAddress(): Promise {} -} - -describe('DisableRecoveryAlertsGuard guard tests', () => { - let app: INestApplication; - - const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); - const timestamp = faker.date.recent().getTime(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const moduleAddress = faker.finance.ethereumAddress(); - let signature: Hash; - - beforeAll(async () => { - const message = `disable-recovery-alerts-${chainId}-${safeAddress}-${moduleAddress}-${signer.address}-${timestamp}`; - signature = await signer.signMessage({ message }); - }); - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [TestLoggingModule, ConfigurationModule.register(configuration)], - controllers: [TestController], - providers: [ - { - provide: ISafeRepository, - useValue: safeRepositoryMock, - }, - ], - }).compile(); - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - - it('returns 200 for a valid signature for module on given Safe', async () => { - safeRepositoryMock.getSafesByModule.mockResolvedValue({ - safes: [getAddress(safeAddress)], - }); - - await request(app.getHttpServer()) - .delete(`/test/${chainId}/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(200); - }); - - it('returns 403 for a valid signature for module not on given Safe', async () => { - safeRepositoryMock.getSafesByModule.mockResolvedValue({ - safes: [], - }); - - await request(app.getHttpServer()) - .delete(`/test/${chainId}/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403); - }); - - it('returns 403 for an invalid signature', async () => { - const invalidSignature = await signer.signMessage({ - message: 'some invalid message', - }); - - await request(app.getHttpServer()) - .delete(`/test/${chainId}/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature', invalidSignature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without the moduleAddress', async () => { - const invalidSignature = await signer.signMessage({ - message: 'some invalid message', - }); - - await request(app.getHttpServer()) - .delete(`/test/invalid/3/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', invalidSignature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the signature is missing from payload', async () => { - await request(app.getHttpServer()) - .delete(`/test/${chainId}/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the timestamp is missing from payload', async () => { - const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .delete(`/test/${chainId}/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature', signature) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without chain id', async () => { - await request(app.getHttpServer()) - .delete(`/test/invalid/1/chains/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without safe address', async () => { - await request(app.getHttpServer()) - .delete(`/test/invalid/2/${chainId}/${moduleAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the signer is missing from the payload', async () => { - await request(app.getHttpServer()) - .delete(`/test/invalid/3/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); -}); diff --git a/src/routes/recovery/guards/disable-recovery-alerts.guard.ts b/src/routes/recovery/guards/disable-recovery-alerts.guard.ts deleted file mode 100644 index 549663fc27..0000000000 --- a/src/routes/recovery/guards/disable-recovery-alerts.guard.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { getAddress, verifyMessage } from 'viem'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -/** - * The DisableRecoveryAlertsGuard guard should be used on routes that require - * authenticated actions for disabling recovery alerts. - * - * This guard therefore validates if the message came from the specified signer. - * - * The following message should be signed: - * disable-recovery-alerts-${chainId}-${safeAddress}-${moduleAddress}-${signer}-${timestamp} - * - * (where ${} represents placeholder values for the respective data) - * - * To use this guard, the route should have: - * - the 'chainId' as part of the path parameters - * - the 'safeAddress' as part of the path parameters - * - the 'moduleAddress' as part of the path parameters - * - the 'signer' as part of the JSON body (top level) - * - the 'Safe-Wallet-Signature' header set to the signature - * - the 'Safe-Wallet-Signature-Timestamp' header set to the signature timestamp - */ -@Injectable() -export class DisableRecoveryAlertsGuard implements CanActivate { - constructor( - @Inject(ISafeRepository) private readonly safeRepository: ISafeRepository, - @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} - - private static readonly ACTION_PREFIX = 'disable-recovery-alerts'; - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - - const chainId = request.params['chainId']; - const safeAddress = request.params['safeAddress']; - const moduleAddress = request.params['moduleAddress']; - const signer = request.body['signer']; - const signature = request.headers['safe-wallet-signature']; - const timestamp = request.headers['safe-wallet-signature-timestamp']; - - // Required fields - if ( - !chainId || - !safeAddress || - !moduleAddress || - !signer || - !signature || - !timestamp - ) - return false; - - const message = `${DisableRecoveryAlertsGuard.ACTION_PREFIX}-${chainId}-${safeAddress}-${moduleAddress}-${signer}-${timestamp}`; - - try { - const isValid = await verifyMessage({ - address: signer, - message, - signature, - }); - - if (!isValid) { - return false; - } - - const { safes } = await this.safeRepository.getSafesByModule({ - chainId, - moduleAddress, - }); - - return safes.some((safe) => getAddress(safe) === getAddress(safeAddress)); - } catch (e) { - this.loggingService.debug(e); - return false; - } - } -} diff --git a/src/routes/recovery/guards/enable-recovery-alerts.guard.spec.ts b/src/routes/recovery/guards/enable-recovery-alerts.guard.spec.ts deleted file mode 100644 index 53908e26f5..0000000000 --- a/src/routes/recovery/guards/enable-recovery-alerts.guard.spec.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { Controller, INestApplication, Post, UseGuards } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { ConfigurationModule } from '@/config/configuration.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import { Hash, getAddress } from 'viem'; -import { EnableRecoveryAlertsGuard } from '@/routes/recovery/guards/enable-recovery-alerts.guard'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -const safeRepository = { - getSafesByModule: jest.fn(), -} as jest.MockedObjectDeep; -const safeRepositoryMock = jest.mocked(safeRepository); - -@Controller() -class TestController { - @Post('test/:chainId/:safeAddress') - @UseGuards(EnableRecoveryAlertsGuard) - async validRoute(): Promise {} - - @Post('test/invalid/1/chains/:safeAddress') - @UseGuards(EnableRecoveryAlertsGuard) - async invalidRouteWithoutChainId(): Promise {} - - @Post('test/invalid/2/:chainId') - @UseGuards(EnableRecoveryAlertsGuard) - async invalidRouteWithoutSafeAddress(): Promise {} -} - -describe('EnableRecoveryAlertsGuard guard tests', () => { - let app: INestApplication; - - const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); - const timestamp = faker.date.recent().getTime(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const moduleAddress = faker.finance.ethereumAddress(); - let signature: Hash; - - beforeAll(async () => { - const message = `enable-recovery-alerts-${chainId}-${safeAddress}-${moduleAddress}-${signer.address}-${timestamp}`; - signature = await signer.signMessage({ message }); - }); - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [TestLoggingModule, ConfigurationModule.register(configuration)], - controllers: [TestController], - providers: [ - { - provide: ISafeRepository, - useValue: safeRepositoryMock, - }, - ], - }).compile(); - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - - it('returns 201 for a valid signature for module on given Safe', async () => { - safeRepositoryMock.getSafesByModule.mockResolvedValue({ - safes: [getAddress(safeAddress)], - }); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(201); - }); - - it('returns 403 for a valid signature for module not on given Safe', async () => { - safeRepositoryMock.getSafesByModule.mockResolvedValue({ - safes: [], - }); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403); - }); - - it('returns 403 for an invalid signature', async () => { - const invalidSignature = await signer.signMessage({ - message: 'some invalid message', - }); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', invalidSignature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the moduleAddress is missing from payload', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the signature is missing from payload', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the timestamp is missing from payload', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without chain id', async () => { - await request(app.getHttpServer()) - .post(`/test/invalid/1/chains/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without safeAddress', async () => { - await request(app.getHttpServer()) - .post(`/test/invalid/2/${chainId}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the signer is missing from the payload', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); -}); diff --git a/src/routes/recovery/guards/enable-recovery-alerts.guard.ts b/src/routes/recovery/guards/enable-recovery-alerts.guard.ts deleted file mode 100644 index 793fd2a463..0000000000 --- a/src/routes/recovery/guards/enable-recovery-alerts.guard.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { getAddress, verifyMessage } from 'viem'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -/** - * The EnableRecoveryAlertsGuard guard should be used on routes that require - * authenticated actions for enabling recovery alerts. - * - * This guard therefore validates if the message came from the specified signer. - * - * The following message should be signed: - * enable-recovery-alerts-${chainId}-${safeAddress}-${moduleAddress}-${signer}-${timestamp} - * - * (where ${} represents placeholder values for the respective data) - * - * To use this guard, the route should have: - * - the 'chainId' as part of the path parameters - * - the 'safeAddress' as part of the path parameters - * - the 'moduleAddress' as part of the JSON body (top level) - * - the 'signer' as part of the JSON body (top level) - * - the 'Safe-Wallet-Signature' header set to the signature - * - the 'Safe-Wallet-Signature-Timestamp' header set to the signature timestamp - */ -@Injectable() -export class EnableRecoveryAlertsGuard implements CanActivate { - constructor( - @Inject(ISafeRepository) private readonly safeRepository: ISafeRepository, - @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} - - private static readonly ACTION_PREFIX = 'enable-recovery-alerts'; - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - - const chainId = request.params['chainId']; - const safeAddress = request.params['safeAddress']; - const moduleAddress = request.body['moduleAddress']; - const signer = request.body['signer']; - const signature = request.headers['safe-wallet-signature']; - const timestamp = request.headers['safe-wallet-signature-timestamp']; - - // Required fields - if ( - !chainId || - !safeAddress || - !moduleAddress || - !signer || - !signature || - !timestamp - ) - return false; - - const message = `${EnableRecoveryAlertsGuard.ACTION_PREFIX}-${chainId}-${safeAddress}-${moduleAddress}-${signer}-${timestamp}`; - - try { - const isValid = await verifyMessage({ - address: signer, - message, - signature, - }); - - if (!isValid) { - return false; - } - - const { safes } = await this.safeRepository.getSafesByModule({ - chainId, - moduleAddress, - }); - - return safes.some((safe) => getAddress(safe) === getAddress(safeAddress)); - } catch (e) { - this.loggingService.debug(e); - return false; - } - } -} diff --git a/src/routes/recovery/recovery.controller.spec.ts b/src/routes/recovery/recovery.controller.spec.ts index 016c66f70a..60bd648d3d 100644 --- a/src/routes/recovery/recovery.controller.spec.ts +++ b/src/routes/recovery/recovery.controller.spec.ts @@ -40,6 +40,10 @@ import { } from '@/datasources/jwt/configuration/jwt.configuration.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; +import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; +import { getSecondsUntil } from '@/domain/common/utils/time'; +import { getAddress } from 'viem'; describe('Recovery (Unit)', () => { let app: INestApplication; @@ -48,6 +52,7 @@ describe('Recovery (Unit)', () => { let alertsProject: string; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; + let jwtService: IJwtService; beforeEach(async () => { jest.resetAllMocks(); @@ -89,6 +94,7 @@ describe('Recovery (Unit)', () => { alertsProject = configurationService.get('alerts-api.project'); safeConfigUrl = configurationService.get('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); + jwtService = moduleFixture.get(IJwtService); app = await new TestAppProvider().provide(moduleFixture); await app.init(); @@ -105,13 +111,14 @@ describe('Recovery (Unit)', () => { describe('POST add recovery module for a Safe', () => { it('Success', async () => { const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `enable-recovery-alerts-${chain.chainId}-${safe.address}-${addRecoveryModuleDto.moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { @@ -142,73 +149,102 @@ describe('Recovery (Unit)', () => { await request(app.getHttpServer()) .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) .expect(200); }); - it('should prevent requests for modules not on specified Safe', async () => { + it('should return 403 if no token is present', async () => { const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `enable-recovery-alerts-${chain.chainId}-${safe.address}-${addRecoveryModuleDto.moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); - networkService.get.mockImplementation(({ url }) => { - if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { - return Promise.resolve({ status: 200, data: chain }); - } - if ( - url === `${chain.transactionService}/api/v1/safes/${safe.address}` - ) { - return Promise.resolve({ status: 200, data: safe }); - } - if ( - url === - `${chain.transactionService}/api/v1/modules/${addRecoveryModuleDto.moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [] }, - }); - } - return Promise.reject(`No matching rule for url: ${url}`); + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) + .send(addRecoveryModuleDto) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.post).not.toHaveBeenCalled(); + }); + + it('should return 403 if token is not a JWT', async () => { + const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); + const chain = chainBuilder().build(); + const safe = safeBuilder().build(); + const accessToken = faker.string.alphanumeric(); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.post).not.toHaveBeenCalled(); + }); + + it('should return 403 if token is not yet valid', async () => { + const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); + const chain = chainBuilder().build(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const notBefore = faker.date.future(); + const accessToken = jwtService.sign(authPayloadDto, { + notBefore: getSecondsUntil(notBefore), }); - networkService.post.mockImplementation(({ url }) => - url === - `${alertsUrl}/api/v1/account/${alertsAccount}/project/${alertsProject}/address` - ? Promise.resolve({ status: 200, data: {} }) - : Promise.reject(`No matching rule for url: ${url}`), - ); + expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); await request(app.getHttpServer()) .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.post).not.toHaveBeenCalled(); }); - it('should prevent requests older than 5 minutes', async () => { + it('should return 403 if token has expired', async () => { const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `enable-recovery-alerts-${chain.chainId}-${safe.address}-${addRecoveryModuleDto.moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { + expiresIn: 0, // Now + }); + jest.advanceTimersByTime(1_000); + expect(() => jwtService.verify(accessToken)).toThrow('jwt expired'); + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.post).not.toHaveBeenCalled(); + }); + + it('should return 401 if chain_id does not match that of the request', async () => { + const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); + const chain = chainBuilder().build(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', faker.string.numeric({ exclude: [chain.chainId] })) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { return Promise.resolve({ status: 200, data: chain }); @@ -218,41 +254,27 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${addRecoveryModuleDto.moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); - jest.advanceTimersByTime(5 * 60 * 1000); - + expect(() => jwtService.verify(accessToken)).not.toThrow(); await request(app.getHttpServer()) .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }) - .expect(403); + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) + .expect(401); + + expect(networkService.post).not.toHaveBeenCalled(); }); - it('should prevent non-Safe owner requests', async () => { + it('should return 401 if token is not from that of a Safe owner', async () => { const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); const chain = chainBuilder().build(); - const safe = safeBuilder().build(); // Signer is not an owner - const timestamp = jest.now(); - const message = `enable-recovery-alerts-${chain.chainId}-${safe.address}-${addRecoveryModuleDto.moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); - + const safe = safeBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { return Promise.resolve({ status: 200, data: chain }); @@ -262,40 +284,29 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${addRecoveryModuleDto.moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); + expect(() => jwtService.verify(accessToken)).not.toThrow(); await request(app.getHttpServer()) .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }) - .expect(403); + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) + .expect(401); + + expect(networkService.post).not.toHaveBeenCalled(); }); - it('should get a validation error', async () => { - const addRecoveryModuleDto = { - moduleAddress: faker.number.int(), // Invalid address - }; - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + it('should return 401 if module is not enabled on the Safe', async () => { + const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `enable-recovery-alerts-${chain.chainId}-${safe.address}-${addRecoveryModuleDto.moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { @@ -312,7 +323,7 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, - data: { safes: [safe.address] }, + data: { safes: [] }, }); } return Promise.reject(`No matching rule for url: ${url}`); @@ -326,12 +337,30 @@ describe('Recovery (Unit)', () => { await request(app.getHttpServer()) .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) + .expect(401); + + expect(networkService.post).not.toHaveBeenCalled(); + }); + + it('should get a validation error', async () => { + const addRecoveryModuleDto = { + moduleAddress: faker.number.int(), // Invalid address + }; + const chain = chainBuilder().build(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) .expect(422) .expect({ statusCode: 422, @@ -341,6 +370,9 @@ describe('Recovery (Unit)', () => { path: ['moduleAddress'], message: 'Expected string, received number', }); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.post).not.toHaveBeenCalled(); }); it('Should return the alerts provider error message', async () => { @@ -374,15 +406,6 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${addRecoveryModuleDto.moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); networkService.post.mockImplementation(({ url }) => @@ -396,10 +419,7 @@ describe('Recovery (Unit)', () => { .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) .set('Safe-Wallet-Signature', signature) .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }); + .send(addRecoveryModuleDto); }); it('Should fail with An error occurred', async () => { @@ -432,15 +452,6 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${addRecoveryModuleDto.moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); networkService.post.mockImplementation(({ url }) => @@ -454,23 +465,21 @@ describe('Recovery (Unit)', () => { .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) .set('Safe-Wallet-Signature', signature) .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }); + .send(addRecoveryModuleDto); }); }); describe('DELETE remove recovery module for a Safe', () => { it('Success', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { @@ -481,15 +490,6 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); networkService.delete.mockImplementation(({ url }) => @@ -503,73 +503,105 @@ describe('Recovery (Unit)', () => { .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) .expect(204); }); - it('should prevent requests for modules not on specified Safe', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + it('should return 403 if no token is present', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); - networkService.get.mockImplementation(({ url }) => { - if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { - return Promise.resolve({ status: 200, data: chain }); - } - if ( - url === `${chain.transactionService}/api/v1/safes/${safe.address}` - ) { - return Promise.resolve({ status: 200, data: safe }); - } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [] }, - }); - } - return Promise.reject(`No matching rule for url: ${url}`); + await request(app.getHttpServer()) + .delete( + `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, + ) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.delete).not.toHaveBeenCalled(); + }); + + it('should return 403 if token is not a JWT', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const safe = safeBuilder().build(); + const accessToken = faker.string.alphanumeric(); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); + await request(app.getHttpServer()) + .delete( + `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.delete).not.toHaveBeenCalled(); + }); + + it('should return 403 if token is not yet valid', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const notBefore = faker.date.future(); + const accessToken = jwtService.sign(authPayloadDto, { + notBefore: getSecondsUntil(notBefore), }); - networkService.delete.mockImplementation(({ url }) => - url === - `${alertsUrl}/api/v1/account/${alertsAccount}/project/${alertsProject}/contract/${chain.chainId}/${moduleAddress}` - ? Promise.resolve({ status: 204, data: {} }) - : Promise.reject(`No matching rule for url: ${url}`), - ); + expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); await request(app.getHttpServer()) .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.delete).not.toHaveBeenCalled(); }); - it('should prevent requests older than 5 minutes', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + it('should return 403 if token has expired', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { + expiresIn: 0, // Now + }); + jest.advanceTimersByTime(1_000); + expect(() => jwtService.verify(accessToken)).toThrow('jwt expired'); + await request(app.getHttpServer()) + .delete( + `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.delete).not.toHaveBeenCalled(); + }); + + it('should return 401 if chain_id does not match that of the request', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', faker.string.numeric({ exclude: [chain.chainId] })) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { return Promise.resolve({ status: 200, data: chain }); @@ -579,48 +611,28 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); - networkService.delete.mockImplementation(({ url }) => - url === - `${alertsUrl}/api/v1/account/${alertsAccount}/project/${alertsProject}/contract/${chain.chainId}/${moduleAddress}` - ? Promise.resolve({ status: 204, data: {} }) - : Promise.reject(`No matching rule for url: ${url}`), - ); - - jest.advanceTimersByTime(5 * 60 * 1000); + expect(() => jwtService.verify(accessToken)).not.toThrow(); await request(app.getHttpServer()) .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) - .expect(403); + .set('Cookie', [`access_token=${accessToken}`]) + .expect(401); + + expect(networkService.delete).not.toHaveBeenCalled(); }); - it('should prevent non-Safe owner requests', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + it('should return 401 if token is not from that of a Safe owner', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().build(); // Signer is not an owner - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); - + const safe = safeBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { return Promise.resolve({ status: 200, data: chain }); @@ -630,45 +642,30 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); - networkService.delete.mockImplementation(({ url }) => - url === - `${alertsUrl}/api/v1/account/${alertsAccount}/project/${alertsProject}/contract/${chain.chainId}/${moduleAddress}` - ? Promise.resolve({ status: 204, data: {} }) - : Promise.reject(`No matching rule for url: ${url}`), - ); + expect(() => jwtService.verify(accessToken)).not.toThrow(); await request(app.getHttpServer()) .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) - .expect(403); + .set('Cookie', [`access_token=${accessToken}`]) + .expect(401); + + expect(networkService.delete).not.toHaveBeenCalled(); }); it('Should return the alerts provider error message', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); const error = new NetworkResponseError( new URL( `${alertsUrl}/api/v1/account/${alertsAccount}/project/${alertsProject}/contract/${chain.chainId}/${moduleAddress}`, @@ -691,15 +688,6 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); networkService.delete.mockImplementation(({ url }) => @@ -713,11 +701,7 @@ describe('Recovery (Unit)', () => { .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) .expect(400) .expect({ message: 'Malformed body', @@ -726,14 +710,15 @@ describe('Recovery (Unit)', () => { }); it('Should fail with An error occurred', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); + const signerAddress = safe.owners[0]; + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); const statusCode = faker.internet.httpStatusCode({ types: ['clientError', 'serverError'], }); @@ -755,15 +740,6 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); networkService.delete.mockImplementation(({ url }) => @@ -777,11 +753,7 @@ describe('Recovery (Unit)', () => { .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) .expect(statusCode); }); }); diff --git a/src/routes/recovery/recovery.controller.ts b/src/routes/recovery/recovery.controller.ts index 65cdb2d666..6b1c04e653 100644 --- a/src/routes/recovery/recovery.controller.ts +++ b/src/routes/recovery/recovery.controller.ts @@ -11,10 +11,10 @@ import { ApiTags } from '@nestjs/swagger'; import { AddRecoveryModuleDto } from '@/routes/recovery/entities/add-recovery-module.dto.entity'; import { RecoveryService } from '@/routes/recovery/recovery.service'; import { AddRecoveryModuleDtoSchema } from '@/routes/recovery/entities/schemas/add-recovery-module.dto.schema'; -import { EnableRecoveryAlertsGuard } from '@/routes/recovery/guards/enable-recovery-alerts.guard'; -import { OnlySafeOwnerGuard } from '@/routes/common/guards/only-safe-owner.guard'; -import { TimestampGuard } from '@/routes/email/guards/timestamp.guard'; -import { DisableRecoveryAlertsGuard } from '@/routes/recovery/guards/disable-recovery-alerts.guard'; +import { AuthGuard } from '@/routes/auth/guards/auth.guard'; +import { Auth } from '@/routes/auth/decorators/auth.decorator'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; @ApiTags('recovery') @@ -27,38 +27,39 @@ export class RecoveryController { @HttpCode(200) @Post() - @UseGuards( - EnableRecoveryAlertsGuard, - TimestampGuard(5 * 60 * 1000), // 5 minutes - OnlySafeOwnerGuard, - ) + @UseGuards(AuthGuard) async addRecoveryModule( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Body(new ValidationPipe(AddRecoveryModuleDtoSchema)) addRecoveryModuleDto: AddRecoveryModuleDto, + @Auth() authPayload: AuthPayload, ): Promise { return this.recoveryService.addRecoveryModule({ chainId, safeAddress, addRecoveryModuleDto, + authPayload, }); } @HttpCode(204) @Delete('/:moduleAddress') - @UseGuards( - DisableRecoveryAlertsGuard, - TimestampGuard(5 * 60 * 1000), // 5 minutes - OnlySafeOwnerGuard, - ) + @UseGuards(AuthGuard) async deleteRecoveryModule( @Param('chainId') chainId: string, - @Param('moduleAddress') moduleAddress: string, + @Param('moduleAddress', new ValidationPipe(AddressSchema)) + moduleAddress: `0x${string}`, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, + @Auth() authPayload: AuthPayload, ): Promise { return this.recoveryService.deleteRecoveryModule({ chainId, moduleAddress, + safeAddress, + authPayload, }); } } diff --git a/src/routes/recovery/recovery.module.ts b/src/routes/recovery/recovery.module.ts index 967fbef033..585f66100f 100644 --- a/src/routes/recovery/recovery.module.ts +++ b/src/routes/recovery/recovery.module.ts @@ -3,9 +3,10 @@ import { RecoveryController } from '@/routes/recovery/recovery.controller'; import { RecoveryService } from '@/routes/recovery/recovery.service'; import { AlertsDomainModule } from '@/domain/alerts/alerts.domain.module'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; +import { AuthRepositoryModule } from '@/domain/auth/auth.repository.interface'; @Module({ - imports: [AlertsDomainModule, SafeRepositoryModule], + imports: [AlertsDomainModule, SafeRepositoryModule, AuthRepositoryModule], controllers: [RecoveryController], providers: [RecoveryService], }) diff --git a/src/routes/recovery/recovery.service.ts b/src/routes/recovery/recovery.service.ts index 6c48d3b8b8..f6670de5d5 100644 --- a/src/routes/recovery/recovery.service.ts +++ b/src/routes/recovery/recovery.service.ts @@ -1,21 +1,62 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { IAlertsRepository } from '@/domain/alerts/alerts.repository.interface'; import { AlertsRepository } from '@/domain/alerts/alerts.repository'; import { AddRecoveryModuleDto } from '@/routes/recovery/entities/add-recovery-module.dto.entity'; import { AlertsRegistration } from '@/domain/alerts/entities/alerts-registration.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; @Injectable() export class RecoveryService { constructor( @Inject(IAlertsRepository) private readonly alertsRepository: AlertsRepository, + @Inject(ISafeRepository) + private readonly safeRepository: ISafeRepository, ) {} async addRecoveryModule(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; addRecoveryModuleDto: AddRecoveryModuleDto; + authPayload: AuthPayload; }): Promise { + if ( + !args.authPayload.isForChain(args.chainId) || + !args.authPayload.signer_address + ) { + throw new UnauthorizedException(); + } + + // Check after AuthPayload check to avoid unnecessary request + const isOwner = await this.safeRepository + .isOwner({ + safeAddress: args.safeAddress, + chainId: args.chainId, + address: args.authPayload.signer_address, + }) + // Swallow error to avoid leaking information + .catch(() => false); + if (!isOwner) { + throw new UnauthorizedException(); + } + + // After after owner check to avoid unnecessary request + const isEnabled = await this.safeRepository + .getSafesByModule({ + chainId: args.chainId, + moduleAddress: args.addRecoveryModuleDto.moduleAddress, + }) + .then(({ safes }) => { + return safes.some((safe) => safe === args.safeAddress); + }) + + // Swallow error to avoid leaking information + .catch(() => false); + if (!isEnabled) { + throw new UnauthorizedException(); + } + const contract: AlertsRegistration = { chainId: args.chainId, address: args.addRecoveryModuleDto.moduleAddress, @@ -26,8 +67,30 @@ export class RecoveryService { async deleteRecoveryModule(args: { chainId: string; - moduleAddress: string; + moduleAddress: `0x${string}`; + safeAddress: `0x${string}`; + authPayload: AuthPayload; }): Promise { + if ( + !args.authPayload.isForChain(args.chainId) || + !args.authPayload.signer_address + ) { + throw new UnauthorizedException(); + } + + // Check after AuthPayload check to avoid unnecessary request + const isOwner = await this.safeRepository + .isOwner({ + safeAddress: args.safeAddress, + chainId: args.chainId, + address: args.authPayload.signer_address, + }) + // Swallow error to avoid leaking information + .catch(() => false); + if (!isOwner) { + throw new UnauthorizedException(); + } + await this.alertsRepository.deleteContract({ chainId: args.chainId, address: args.moduleAddress, From 3918686bfb04877f687f81da885ea1f261740564 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 08:41:40 +0200 Subject: [PATCH 45/65] build(deps-dev): bump @types/node from 20.12.10 to 20.12.11 (#1537) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.12.10 to 20.12.11. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index dc41ff108b..f221b05bf3 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@types/jest": "29.5.12", "@types/jsonwebtoken": "^9", "@types/lodash": "^4.17.1", - "@types/node": "^20.12.10", + "@types/node": "^20.12.11", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", "eslint": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index 67df0691fc..6bdf0b0842 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1918,12 +1918,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.12.10": - version: 20.12.10 - resolution: "@types/node@npm:20.12.10" +"@types/node@npm:^20.12.11": + version: 20.12.11 + resolution: "@types/node@npm:20.12.11" dependencies: undici-types: "npm:~5.26.4" - checksum: 10/b3ab044969880084e4da22a0173fc234239de81c22fb9418bd992de98369a3065c0c6119216476711af69ff520331cf06711acdeb1959554de6bbf0912a0494e + checksum: 10/c6afe7c2c4504a4f488814d7b306ebad16bf42cbb43bf9db9fe1aed8c5fb99235593c3be5088979a64526b106cf022256688e2f002811be8273d87dc2e0d484f languageName: node linkType: hard @@ -7270,7 +7270,7 @@ __metadata: "@types/jest": "npm:29.5.12" "@types/jsonwebtoken": "npm:^9" "@types/lodash": "npm:^4.17.1" - "@types/node": "npm:^20.12.10" + "@types/node": "npm:^20.12.11" "@types/semver": "npm:^7.5.8" "@types/supertest": "npm:^6.0.2" amqp-connection-manager: "npm:^4.1.14" From fbc84f652eb98366f3117a63118c892d153ed152 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 08:44:01 +0200 Subject: [PATCH 46/65] build(deps): bump coverallsapp/github-action from 2.2.3 to 2.3.0 (#1536) Bumps [coverallsapp/github-action](https://github.com/coverallsapp/github-action) from 2.2.3 to 2.3.0. - [Release notes](https://github.com/coverallsapp/github-action/releases) - [Commits](https://github.com/coverallsapp/github-action/compare/v2.2.3...v2.3.0) --- updated-dependencies: - dependency-name: coverallsapp/github-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 578b36e9dd..6c2f756d23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,7 @@ jobs: LOG_SILENT: true - name: Coveralls Parallel continue-on-error: true - uses: coverallsapp/github-action@v2.2.3 + uses: coverallsapp/github-action@v2.3.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} flag-name: run-${{ matrix.task }} @@ -106,7 +106,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Coveralls Finished - uses: coverallsapp/github-action@v2.2.3 + uses: coverallsapp/github-action@v2.3.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true From a52a8db10d15c140e629b11a55d1bab4102f292c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 08:45:07 +0200 Subject: [PATCH 47/65] build(deps): bump viem from 2.10.1 to 2.10.5 (#1539) Bumps [viem](https://github.com/wevm/viem) from 2.10.1 to 2.10.5. - [Release notes](https://github.com/wevm/viem/releases) - [Commits](https://github.com/wevm/viem/compare/viem@2.10.1...viem@2.10.5) --- updated-dependencies: - dependency-name: viem dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index f221b05bf3..c15f75fc74 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.0", - "viem": "^2.10.1", + "viem": "^2.10.5", "winston": "^3.13.0", "zod": "^3.23.6" }, diff --git a/yarn.lock b/yarn.lock index 6bdf0b0842..1ecfb3a440 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7298,7 +7298,7 @@ __metadata: tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" typescript-eslint: "npm:^7.8.0" - viem: "npm:^2.10.1" + viem: "npm:^2.10.5" winston: "npm:^3.13.0" zod: "npm:^3.23.6" languageName: unknown @@ -8314,9 +8314,9 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.10.1": - version: 2.10.1 - resolution: "viem@npm:2.10.1" +"viem@npm:^2.10.5": + version: 2.10.5 + resolution: "viem@npm:2.10.5" dependencies: "@adraffy/ens-normalize": "npm:1.10.0" "@noble/curves": "npm:1.2.0" @@ -8331,7 +8331,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/4c3cff33c826913e942ab663bf914dde651324cad672dd106788a765d102a92f18e981054d20cb2969d4f4c74199e230d59053f8b231b24297d831b9b82c8312 + checksum: 10/8ef40085caf77a2414c6d5d8b14c49d086534f8300d0a47645722a062deede12a0b22d8d9a18597a25f8892e4c479e7b9ebb3f7387f58aff9854fb0508e30a3b languageName: node linkType: hard From 7d013488050145aed097429bba58c19c5dfe7f75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 08:46:01 +0200 Subject: [PATCH 48/65] build(deps): bump zod from 3.23.6 to 3.23.8 (#1540) Bumps [zod](https://github.com/colinhacks/zod) from 3.23.6 to 3.23.8. - [Release notes](https://github.com/colinhacks/zod/releases) - [Changelog](https://github.com/colinhacks/zod/blob/master/CHANGELOG.md) - [Commits](https://github.com/colinhacks/zod/compare/v3.23.6...v3.23.8) --- updated-dependencies: - dependency-name: zod dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index c15f75fc74..3b18d3dc7d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "semver": "^7.6.0", "viem": "^2.10.5", "winston": "^3.13.0", - "zod": "^3.23.6" + "zod": "^3.23.8" }, "devDependencies": { "@faker-js/faker": "^8.4.1", diff --git a/yarn.lock b/yarn.lock index 1ecfb3a440..4d17739e1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7300,7 +7300,7 @@ __metadata: typescript-eslint: "npm:^7.8.0" viem: "npm:^2.10.5" winston: "npm:^3.13.0" - zod: "npm:^3.23.6" + zod: "npm:^3.23.8" languageName: unknown linkType: soft @@ -8610,9 +8610,9 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.23.6": - version: 3.23.6 - resolution: "zod@npm:3.23.6" - checksum: 10/a3b0ea904f0615c67ef01ab3abc4e917d3bb87d46fc39515bcb68e59450b54f175dc9d29ddc7e13217dd456099729afa0b76609db135ca158e3ccebcac6153d9 +"zod@npm:^3.23.8": + version: 3.23.8 + resolution: "zod@npm:3.23.8" + checksum: 10/846fd73e1af0def79c19d510ea9e4a795544a67d5b34b7e1c4d0425bf6bfd1c719446d94cdfa1721c1987d891321d61f779e8236fde517dc0e524aa851a6eff1 languageName: node linkType: hard From 72a35f230f8454164121da724659450fd21cbe8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 10:20:15 +0200 Subject: [PATCH 49/65] build(deps-dev): bump eslint from 9.0.0 to 9.2.0 (#1541) Bumps [eslint](https://github.com/eslint/eslint) from 9.0.0 to 9.2.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v9.0.0...v9.2.0) --- updated-dependencies: - dependency-name: eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 39 +++++++++++++++++++++++---------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 3b18d3dc7d..c9361a315d 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@types/node": "^20.12.11", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", - "eslint": "^9.0.0", + "eslint": "^9.2.0", "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "jest": "29.7.0", diff --git a/yarn.lock b/yarn.lock index 4d17739e1b..6f9d2a0877 100644 --- a/yarn.lock +++ b/yarn.lock @@ -699,10 +699,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.0.0": - version: 9.0.0 - resolution: "@eslint/js@npm:9.0.0" - checksum: 10/b14b20af72410ef53e3e77e7d83cc1d6e6554b0092ceb9f969d25d765f4d775b4be32b0cd99bbfd6ce72eb2e4fb6b39b42a159b31909fbe1b3a5e88d75211687 +"@eslint/js@npm:9.2.0": + version: 9.2.0 + resolution: "@eslint/js@npm:9.2.0" + checksum: 10/4e9fec5395a8f6797bfa57b28b67c3b1c63ebcaf665e457546a34a42b14ebbf992d3617a64ae65addf32ab89cd7448008a275a4d73f9bcb1829f4eae67301841 languageName: node linkType: hard @@ -720,14 +720,14 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/config-array@npm:^0.12.3": - version: 0.12.3 - resolution: "@humanwhocodes/config-array@npm:0.12.3" +"@humanwhocodes/config-array@npm:^0.13.0": + version: 0.13.0 + resolution: "@humanwhocodes/config-array@npm:0.13.0" dependencies: "@humanwhocodes/object-schema": "npm:^2.0.3" debug: "npm:^4.3.1" minimatch: "npm:^3.0.5" - checksum: 10/b05f528c110aa1657d95d213e4ad2662f4161e838806af01a4d3f3b6ee3878d9b6f87d1b10704917f5c2f116757cb5c818480c32c4c4c6f84fe775a170b5f758 + checksum: 10/524df31e61a85392a2433bf5d03164e03da26c03d009f27852e7dcfdafbc4a23f17f021dacf88e0a7a9fe04ca032017945d19b57a16e2676d9114c22a53a9d11 languageName: node linkType: hard @@ -745,6 +745,13 @@ __metadata: languageName: node linkType: hard +"@humanwhocodes/retry@npm:^0.2.3": + version: 0.2.4 + resolution: "@humanwhocodes/retry@npm:0.2.4" + checksum: 10/14f2f797d89e01787dcb372211788a258dfd7875caa4e051b5110d9d9da46466921a313ef2366abc167d88e4ca8422e701bca334c1259794023f3a8bb48e8d7f + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -3866,16 +3873,17 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.0.0": - version: 9.0.0 - resolution: "eslint@npm:9.0.0" +"eslint@npm:^9.2.0": + version: 9.2.0 + resolution: "eslint@npm:9.2.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.6.1" "@eslint/eslintrc": "npm:^3.0.2" - "@eslint/js": "npm:9.0.0" - "@humanwhocodes/config-array": "npm:^0.12.3" + "@eslint/js": "npm:9.2.0" + "@humanwhocodes/config-array": "npm:^0.13.0" "@humanwhocodes/module-importer": "npm:^1.0.1" + "@humanwhocodes/retry": "npm:^0.2.3" "@nodelib/fs.walk": "npm:^1.2.8" ajv: "npm:^6.12.4" chalk: "npm:^4.0.0" @@ -3891,7 +3899,6 @@ __metadata: file-entry-cache: "npm:^8.0.0" find-up: "npm:^5.0.0" glob-parent: "npm:^6.0.2" - graphemer: "npm:^1.4.0" ignore: "npm:^5.2.0" imurmurhash: "npm:^0.1.4" is-glob: "npm:^4.0.0" @@ -3906,7 +3913,7 @@ __metadata: text-table: "npm:^0.2.0" bin: eslint: bin/eslint.js - checksum: 10/5cf03e14eb114f95bc4e553c8ae2da65ec09d519779beb08e326d98518bce647ce9c8bf3467bcea4cab35a2657cc3a8e945717e784afa4b1bdb9d1ecd9173ba0 + checksum: 10/691626f7e6059966338d00bc11d232190974e10b701048fcbd2c34031ac80b6eed0e0c5612fc4e32205b56bdbf7d0be34f5c19b8f61ff655b67ad4fd2c0515d3 languageName: node linkType: hard @@ -7276,7 +7283,7 @@ __metadata: amqp-connection-manager: "npm:^4.1.14" amqplib: "npm:^0.10.4" cookie-parser: "npm:^1.4.6" - eslint: "npm:^9.0.0" + eslint: "npm:^9.2.0" eslint-config-prettier: "npm:^9.1.0" husky: "npm:^9.0.11" jest: "npm:29.7.0" From 93dc8763996235fcc3f808011427d93aeeecb849 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 10:44:28 +0200 Subject: [PATCH 50/65] build(deps): bump semver from 7.6.0 to 7.6.2 (#1538) Bumps [semver](https://github.com/npm/node-semver) from 7.6.0 to 7.6.2. - [Release notes](https://github.com/npm/node-semver/releases) - [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md) - [Commits](https://github.com/npm/node-semver/compare/v7.6.0...v7.6.2) --- updated-dependencies: - dependency-name: semver dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c9361a315d..2f3cf8cb9a 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "redis": "^4.6.13", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "semver": "^7.6.0", + "semver": "^7.6.2", "viem": "^2.10.5", "winston": "^3.13.0", "zod": "^3.23.8" diff --git a/yarn.lock b/yarn.lock index 6f9d2a0877..aa0cb319b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7296,7 +7296,7 @@ __metadata: redis: "npm:^4.6.13" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" - semver: "npm:^7.6.0" + semver: "npm:^7.6.2" source-map-support: "npm:^0.5.20" supertest: "npm:^7.0.0" ts-jest: "npm:29.1.2" @@ -7367,6 +7367,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.6.2": + version: 7.6.2 + resolution: "semver@npm:7.6.2" + bin: + semver: bin/semver.js + checksum: 10/296b17d027f57a87ef645e9c725bff4865a38dfc9caf29b26aa084b85820972fbe7372caea1ba6857162fa990702c6d9c1d82297cecb72d56c78ab29070d2ca2 + languageName: node + linkType: hard + "send@npm:0.18.0": version: 0.18.0 resolution: "send@npm:0.18.0" From 3f5226c376088f98987e7745d61e6a6a5aebd2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 14 May 2024 10:50:10 +0200 Subject: [PATCH 51/65] Get prices provider configuration from Safe Config Service (#1529) Changes the primary source for both nativeCoinId and chainName properties in CoingeckoApi to the Chain entity passed. --- .../coingecko-api.service.spec.ts | 205 +++++++---- .../balances-api/coingecko-api.service.ts | 33 +- .../balances-api/prices-api.interface.ts | 5 +- .../balances-api/safe-balances-api.service.ts | 9 +- .../balances/balances.repository.interface.ts | 3 +- src/domain/balances/balances.repository.ts | 7 +- .../entities/__tests__/chain.builder.ts | 2 + .../__tests__/prices-provider.builder.ts | 9 + .../chains/entities/prices-provider.entity.ts | 4 + .../schemas/__tests__/chain.schema.spec.ts | 52 +++ .../chains/entities/schemas/chain.schema.ts | 6 + .../interfaces/balances-api.interface.ts | 3 +- .../zerion-balances.controller.spec.ts | 16 +- .../balances/balances.controller.spec.ts | 62 +--- src/routes/balances/balances.service.ts | 9 +- .../safes/safes.controller.overview.spec.ts | 330 ++++++------------ src/routes/safes/safes.service.ts | 3 +- 17 files changed, 394 insertions(+), 364 deletions(-) create mode 100644 src/domain/chains/entities/__tests__/prices-provider.builder.ts create mode 100644 src/domain/chains/entities/prices-provider.entity.ts diff --git a/src/datasources/balances-api/coingecko-api.service.spec.ts b/src/datasources/balances-api/coingecko-api.service.spec.ts index 477974de53..cd55dd72f2 100644 --- a/src/datasources/balances-api/coingecko-api.service.spec.ts +++ b/src/datasources/balances-api/coingecko-api.service.spec.ts @@ -9,6 +9,7 @@ import { INetworkService } from '@/datasources/network/network.service.interface import { sortBy } from 'lodash'; import { ILoggingService } from '@/logging/logging.interface'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { pricesProviderBuilder } from '@/domain/chains/entities/__tests__/prices-provider.builder'; const mockCacheFirstDataSource = jest.mocked({ get: jest.fn(), @@ -147,7 +148,6 @@ describe('CoingeckoAPI', () => { it('should return and cache one token price (using an API key)', async () => { const chain = chainBuilder().build(); - const chainName = faker.string.sample(); const tokenAddress = faker.finance.ethereumAddress(); const fiatCode = faker.finance.currencyCode(); const lowerCaseFiatCode = fiatCode.toLowerCase(); @@ -160,26 +160,22 @@ describe('CoingeckoAPI', () => { data: coingeckoPrice, status: 200, }); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); const assetPrice = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [tokenAddress], fiatCode, }); const expectedCacheDir = new CacheDir( - `${chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, '', ); expect(assetPrice).toEqual([ { [tokenAddress]: { [lowerCaseFiatCode]: price } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { 'x-cg-pro-api-key': coingeckoApiKey, @@ -203,6 +199,67 @@ describe('CoingeckoAPI', () => { it('should return and cache one token price (with no API key)', async () => { fakeConfigurationService.set('balances.providers.safe.prices.apiKey', null); const chain = chainBuilder().build(); + const tokenAddress = faker.finance.ethereumAddress(); + const fiatCode = faker.finance.currencyCode(); + const lowerCaseFiatCode = fiatCode.toLowerCase(); + const price = faker.number.float({ min: 0.01, multipleOf: 0.01 }); + const coingeckoPrice: AssetPrice = { + [tokenAddress]: { [lowerCaseFiatCode]: price }, + }; + mockCacheService.get.mockResolvedValue(undefined); + mockNetworkService.get.mockResolvedValue({ + data: coingeckoPrice, + status: 200, + }); + const service = new CoingeckoApi( + fakeConfigurationService, + mockCacheFirstDataSource, + mockNetworkService, + mockCacheService, + mockLoggingService, + ); + + const assetPrice = await service.getTokenPrices({ + chain, + tokenAddresses: [tokenAddress], + fiatCode, + }); + + const expectedCacheDir = new CacheDir( + `${chain.pricesProvider.chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, + '', + ); + expect(assetPrice).toEqual([ + { [tokenAddress]: { [lowerCaseFiatCode]: price } }, + ]); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, + networkRequest: { + params: { + contract_addresses: tokenAddress, + vs_currencies: lowerCaseFiatCode, + }, + }, + }); + expect(mockCacheService.get).toHaveBeenCalledTimes(1); + expect(mockCacheService.get).toHaveBeenCalledWith(expectedCacheDir); + expect(mockCacheService.set).toHaveBeenCalledTimes(1); + expect(mockCacheService.set).toHaveBeenCalledWith( + expectedCacheDir, + JSON.stringify({ [tokenAddress]: { [lowerCaseFiatCode]: price } }), + pricesTtlSeconds, + ); + }); + + // TODO: remove this after the prices provider data is migrated to the Config Service + it('should return and cache one token price (using the fallback configuration)', async () => { + fakeConfigurationService.set('balances.providers.safe.prices.apiKey', null); + const chain = chainBuilder() + .with( + 'pricesProvider', + pricesProviderBuilder().with('chainName', null).build(), + ) + .build(); const chainName = faker.string.sample(); const tokenAddress = faker.finance.ethereumAddress(); const fiatCode = faker.finance.currencyCode(); @@ -229,7 +286,7 @@ describe('CoingeckoAPI', () => { ); const assetPrice = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [tokenAddress], fiatCode, }); @@ -261,7 +318,6 @@ describe('CoingeckoAPI', () => { }); it('should return and cache multiple token prices', async () => { - const chainName = faker.string.sample(); const chain = chainBuilder().build(); const fiatCode = faker.finance.currencyCode(); const lowerCaseFiatCode = fiatCode.toLowerCase(); @@ -276,10 +332,6 @@ describe('CoingeckoAPI', () => { [secondTokenAddress]: { [lowerCaseFiatCode]: secondPrice }, [thirdTokenAddress]: { [lowerCaseFiatCode]: thirdPrice }, }; - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); mockCacheService.get.mockResolvedValue(undefined); mockNetworkService.get.mockResolvedValue({ data: coingeckoPrice, @@ -287,7 +339,7 @@ describe('CoingeckoAPI', () => { }); const assetPrice = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [ firstTokenAddress, secondTokenAddress, @@ -302,7 +354,7 @@ describe('CoingeckoAPI', () => { { [thirdTokenAddress]: { [lowerCaseFiatCode]: thirdPrice } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { 'x-cg-pro-api-key': coingeckoApiKey, @@ -320,26 +372,26 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.set).toHaveBeenCalledTimes(3); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -349,7 +401,7 @@ describe('CoingeckoAPI', () => { ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -359,7 +411,7 @@ describe('CoingeckoAPI', () => { ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -371,7 +423,6 @@ describe('CoingeckoAPI', () => { it('should return and cache with low TTL one high-refresh-rate token price', async () => { const chain = chainBuilder().build(); - const chainName = faker.string.sample(); const highRefreshRateTokenAddress = faker.finance.ethereumAddress(); const anotherTokenAddress = faker.finance.ethereumAddress(); const fiatCode = faker.finance.currencyCode(); @@ -387,10 +438,6 @@ describe('CoingeckoAPI', () => { data: coingeckoPrice, status: 200, }); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); fakeConfigurationService.set( `balances.providers.safe.prices.highRefreshRateTokens`, [ @@ -408,7 +455,7 @@ describe('CoingeckoAPI', () => { ); const assetPrice = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [highRefreshRateTokenAddress, anotherTokenAddress], fiatCode, }); @@ -418,7 +465,7 @@ describe('CoingeckoAPI', () => { { [anotherTokenAddress]: { [lowerCaseFiatCode]: anotherPrice } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { 'x-cg-pro-api-key': coingeckoApiKey, @@ -437,13 +484,13 @@ describe('CoingeckoAPI', () => { // high-refresh-rate token price is cached with highRefreshRateTokensTtlSeconds expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -454,13 +501,13 @@ describe('CoingeckoAPI', () => { // another token price is cached with pricesCacheTtlSeconds expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -471,7 +518,6 @@ describe('CoingeckoAPI', () => { }); it('should cache new token prices only', async () => { - const chainName = faker.string.sample(); const chain = chainBuilder().build(); const fiatCode = faker.finance.currencyCode(); const lowerCaseFiatCode = fiatCode.toLowerCase(); @@ -496,13 +542,9 @@ describe('CoingeckoAPI', () => { data: coingeckoPrice, status: 200, }); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); const assetPrices = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [ firstTokenAddress, secondTokenAddress, @@ -522,7 +564,7 @@ describe('CoingeckoAPI', () => { ), ); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { 'x-cg-pro-api-key': coingeckoApiKey, @@ -536,19 +578,19 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), ); @@ -556,7 +598,7 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.set).toHaveBeenNthCalledWith( 1, new CacheDir( - `${chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -567,7 +609,7 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.set).toHaveBeenNthCalledWith( 2, new CacheDir( - `${chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -578,7 +620,6 @@ describe('CoingeckoAPI', () => { }); it('should cache not found token prices with an extended TTL', async () => { - const chainName = faker.string.sample(); const chain = chainBuilder().build(); const fiatCode = faker.finance.currencyCode(); const lowerCaseFiatCode = fiatCode.toLowerCase(); @@ -603,13 +644,9 @@ describe('CoingeckoAPI', () => { data: coingeckoPrice, status: 200, }); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); const assetPrices = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [ firstTokenAddress, secondTokenAddress, @@ -629,7 +666,7 @@ describe('CoingeckoAPI', () => { ), ); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { 'x-cg-pro-api-key': coingeckoApiKey, @@ -643,19 +680,19 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, + `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), ); @@ -676,25 +713,17 @@ describe('CoingeckoAPI', () => { }); it('should return the native coin price (using an API key)', async () => { - const nativeCoinId = faker.string.sample(); const chain = chainBuilder().build(); const fiatCode = faker.finance.currencyCode(); const lowerCaseFiatCode = fiatCode.toLowerCase(); const expectedAssetPrice: AssetPrice = { gnosis: { eur: 98.86 } }; mockCacheFirstDataSource.get.mockResolvedValue(expectedAssetPrice); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - nativeCoinId, - ); - await service.getNativeCoinPrice({ - chainId: chain.chainId, - fiatCode, - }); + await service.getNativeCoinPrice({ chain, fiatCode }); expect(mockCacheFirstDataSource.get).toHaveBeenCalledWith({ cacheDir: new CacheDir( - `${nativeCoinId}_native_coin_price_${lowerCaseFiatCode}`, + `${chain.pricesProvider.nativeCoin}_native_coin_price_${lowerCaseFiatCode}`, '', ), url: `${coingeckoBaseUri}/simple/price`, @@ -703,7 +732,7 @@ describe('CoingeckoAPI', () => { 'x-cg-pro-api-key': coingeckoApiKey, }, params: { - ids: nativeCoinId, + ids: chain.pricesProvider.nativeCoin, vs_currencies: lowerCaseFiatCode, }, }, @@ -713,13 +742,53 @@ describe('CoingeckoAPI', () => { }); it('should return the native coin price (with no API key)', async () => { - const nativeCoinId = faker.string.sample(); const chain = chainBuilder().build(); const fiatCode = faker.finance.currencyCode(); const lowerCaseFiatCode = fiatCode.toLowerCase(); const expectedAssetPrice: AssetPrice = { gnosis: { eur: 98.86 } }; mockCacheFirstDataSource.get.mockResolvedValue(expectedAssetPrice); fakeConfigurationService.set('balances.providers.safe.prices.apiKey', null); + const service = new CoingeckoApi( + fakeConfigurationService, + mockCacheFirstDataSource, + mockNetworkService, + mockCacheService, + mockLoggingService, + ); + + await service.getNativeCoinPrice({ chain, fiatCode }); + + expect(mockCacheFirstDataSource.get).toHaveBeenCalledWith({ + cacheDir: new CacheDir( + `${chain.pricesProvider.nativeCoin}_native_coin_price_${lowerCaseFiatCode}`, + '', + ), + url: `${coingeckoBaseUri}/simple/price`, + networkRequest: { + params: { + ids: chain.pricesProvider.nativeCoin, + vs_currencies: lowerCaseFiatCode, + }, + }, + notFoundExpireTimeSeconds: notFoundExpirationTimeInSeconds, + expireTimeSeconds: nativeCoinPricesTtlSeconds, + }); + }); + + // TODO: remove this after the prices provider data is migrated to the Config Service + it('should return the native coin price (using the fallback configuration)', async () => { + const chain = chainBuilder() + .with( + 'pricesProvider', + pricesProviderBuilder().with('nativeCoin', null).build(), + ) + .build(); + const nativeCoinId = faker.string.sample(); + const fiatCode = faker.finance.currencyCode(); + const lowerCaseFiatCode = fiatCode.toLowerCase(); + const expectedAssetPrice: AssetPrice = { gnosis: { eur: 98.86 } }; + mockCacheFirstDataSource.get.mockResolvedValue(expectedAssetPrice); + fakeConfigurationService.set('balances.providers.safe.prices.apiKey', null); fakeConfigurationService.set( `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, nativeCoinId, @@ -732,7 +801,7 @@ describe('CoingeckoAPI', () => { mockLoggingService, ); - await service.getNativeCoinPrice({ chainId: chain.chainId, fiatCode }); + await service.getNativeCoinPrice({ chain, fiatCode }); expect(mockCacheFirstDataSource.get).toHaveBeenCalledWith({ cacheDir: new CacheDir( diff --git a/src/datasources/balances-api/coingecko-api.service.ts b/src/datasources/balances-api/coingecko-api.service.ts index c2c01d02a3..77eac09345 100644 --- a/src/datasources/balances-api/coingecko-api.service.ts +++ b/src/datasources/balances-api/coingecko-api.service.ts @@ -17,6 +17,7 @@ import { difference, get } from 'lodash'; import { LoggingService, ILoggingService } from '@/logging/logging.interface'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; import { asError } from '@/logging/utils'; +import { Chain } from '@/domain/chains/entities/chain.entity'; @Injectable() export class CoingeckoApi implements IPricesApi { @@ -99,15 +100,26 @@ export class CoingeckoApi implements IPricesApi { ); } + /** + * Gets prices for a chain's native coin, trying to get it from cache first. + * If it's not found in the cache, it tries to retrieve it from the Coingecko API. + * + * @param args.chain Chain entity containing the chain-specific configuration + * @param args.fiatCode + * @returns number representing the native coin price, or null if not found. + */ async getNativeCoinPrice(args: { - chainId: string; + chain: Chain; fiatCode: string; }): Promise { try { const lowerCaseFiatCode = args.fiatCode.toLowerCase(); - const nativeCoinId = this.configurationService.getOrThrow( - `balances.providers.safe.prices.chains.${args.chainId}.nativeCoin`, - ); + // TODO: remove configurationService fallback when fully migrated. + const nativeCoinId = + args.chain.pricesProvider.nativeCoin ?? + this.configurationService.getOrThrow( + `balances.providers.safe.prices.chains.${args.chain.chainId}.nativeCoin`, + ); const cacheDir = CacheRouter.getNativeCoinPriceCacheDir({ nativeCoinId, fiatCode: lowerCaseFiatCode, @@ -145,13 +157,13 @@ export class CoingeckoApi implements IPricesApi { * Gets prices for a set of token addresses, trying to get them from cache first. * For those not found in the cache, it tries to retrieve them from the Coingecko API. * - * @param args.chainName Coingecko's name for the chain (see configuration) + * @param args.chain Chain entity containing the chain-specific configuration * @param args.tokenAddresses Array of token addresses which prices are being retrieved * @param args.fiatCode * @returns Array of {@link AssetPrice} */ async getTokenPrices(args: { - chainId: string; + chain: Chain; tokenAddresses: string[]; fiatCode: string; }): Promise { @@ -160,9 +172,12 @@ export class CoingeckoApi implements IPricesApi { const lowerCaseTokenAddresses = args.tokenAddresses.map((address) => address.toLowerCase(), ); - const chainName = this.configurationService.getOrThrow( - `balances.providers.safe.prices.chains.${args.chainId}.chainName`, - ); + // TODO: remove configurationService fallback when fully migrated. + const chainName = + args.chain.pricesProvider.chainName ?? + this.configurationService.getOrThrow( + `balances.providers.safe.prices.chains.${args.chain.chainId}.chainName`, + ); const pricesFromCache = await this._getTokenPricesFromCache({ chainName, tokenAddresses: lowerCaseTokenAddresses, diff --git a/src/datasources/balances-api/prices-api.interface.ts b/src/datasources/balances-api/prices-api.interface.ts index de92f88682..a6cf452d99 100644 --- a/src/datasources/balances-api/prices-api.interface.ts +++ b/src/datasources/balances-api/prices-api.interface.ts @@ -1,15 +1,16 @@ import { AssetPrice } from '@/datasources/balances-api/entities/asset-price.entity'; +import { Chain } from '@/domain/chains/entities/chain.entity'; export const IPricesApi = Symbol('IPricesApi'); export interface IPricesApi { getNativeCoinPrice(args: { - chainId: string; + chain: Chain; fiatCode: string; }): Promise; getTokenPrices(args: { - chainId: string; + chain: Chain; tokenAddresses: string[]; fiatCode: string; }): Promise; diff --git a/src/datasources/balances-api/safe-balances-api.service.ts b/src/datasources/balances-api/safe-balances-api.service.ts index f0a46fc85d..a528335d97 100644 --- a/src/datasources/balances-api/safe-balances-api.service.ts +++ b/src/datasources/balances-api/safe-balances-api.service.ts @@ -10,6 +10,7 @@ import { Page } from '@/domain/entities/page.entity'; import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; import { IPricesApi } from '@/datasources/balances-api/prices-api.interface'; import { Injectable } from '@nestjs/common'; +import { Chain } from '@/domain/chains/entities/chain.entity'; @Injectable() export class SafeBalancesApi implements IBalancesApi { @@ -39,6 +40,7 @@ export class SafeBalancesApi implements IBalancesApi { async getBalances(args: { safeAddress: string; fiatCode: string; + chain: Chain; trusted?: boolean; excludeSpam?: boolean; }): Promise { @@ -61,7 +63,7 @@ export class SafeBalancesApi implements IBalancesApi { expireTimeSeconds: this.defaultExpirationTimeInSeconds, }); - return this._mapBalances(data, args.fiatCode); + return this._mapBalances(data, args.fiatCode, args.chain); } catch (error) { throw this.httpErrorFactory.from(error); } @@ -122,13 +124,14 @@ export class SafeBalancesApi implements IBalancesApi { private async _mapBalances( balances: Balance[], fiatCode: string, + chain: Chain, ): Promise { const tokenAddresses = balances .map((balance) => balance.tokenAddress) .filter((address): address is `0x${string}` => address !== null); const assetPrices = await this.coingeckoApi.getTokenPrices({ - chainId: this.chainId, + chain, fiatCode, tokenAddresses, }); @@ -145,7 +148,7 @@ export class SafeBalancesApi implements IBalancesApi { let price: number | null; if (tokenAddress === null) { price = await this.coingeckoApi.getNativeCoinPrice({ - chainId: this.chainId, + chain, fiatCode, }); } else { diff --git a/src/domain/balances/balances.repository.interface.ts b/src/domain/balances/balances.repository.interface.ts index 2ea8f1fea9..471e526c42 100644 --- a/src/domain/balances/balances.repository.interface.ts +++ b/src/domain/balances/balances.repository.interface.ts @@ -2,6 +2,7 @@ import { Balance } from '@/domain/balances/entities/balance.entity'; import { Module } from '@nestjs/common'; import { BalancesRepository } from '@/domain/balances/balances.repository'; import { BalancesApiModule } from '@/datasources/balances-api/balances-api.module'; +import { Chain } from '@/domain/chains/entities/chain.entity'; export const IBalancesRepository = Symbol('IBalancesRepository'); @@ -11,7 +12,7 @@ export interface IBalancesRepository { * on {@link chainId} */ getBalances(args: { - chainId: string; + chain: Chain; safeAddress: string; fiatCode: string; trusted?: boolean; diff --git a/src/domain/balances/balances.repository.ts b/src/domain/balances/balances.repository.ts index 668867e8fc..4d2cd1e017 100644 --- a/src/domain/balances/balances.repository.ts +++ b/src/domain/balances/balances.repository.ts @@ -3,6 +3,7 @@ import { IBalancesRepository } from '@/domain/balances/balances.repository.inter import { Balance } from '@/domain/balances/entities/balance.entity'; import { BalanceSchema } from '@/domain/balances/entities/balance.entity'; import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface'; +import { Chain } from '@/domain/chains/entities/chain.entity'; @Injectable() export class BalancesRepository implements IBalancesRepository { @@ -12,13 +13,15 @@ export class BalancesRepository implements IBalancesRepository { ) {} async getBalances(args: { - chainId: string; + chain: Chain; safeAddress: string; fiatCode: string; trusted?: boolean; excludeSpam?: boolean; }): Promise { - const api = await this.balancesApiManager.getBalancesApi(args.chainId); + const api = await this.balancesApiManager.getBalancesApi( + args.chain.chainId, + ); const balances = await api.getBalances(args); return balances.map((balance) => BalanceSchema.parse(balance)); } diff --git a/src/domain/chains/entities/__tests__/chain.builder.ts b/src/domain/chains/entities/__tests__/chain.builder.ts index edfd6d4786..26c2ab59a0 100644 --- a/src/domain/chains/entities/__tests__/chain.builder.ts +++ b/src/domain/chains/entities/__tests__/chain.builder.ts @@ -8,6 +8,7 @@ import { nativeCurrencyBuilder } from '@/domain/chains/entities/__tests__/native import { rpcUriBuilder } from '@/domain/chains/entities/__tests__/rpc-uri.builder'; import { themeBuilder } from '@/domain/chains/entities/__tests__/theme.builder'; import { Chain } from '@/domain/chains/entities/chain.entity'; +import { pricesProviderBuilder } from '@/domain/chains/entities/__tests__/prices-provider.builder'; export function chainBuilder(): IBuilder { return new Builder() @@ -23,6 +24,7 @@ export function chainBuilder(): IBuilder { .with('publicRpcUri', rpcUriBuilder().build()) .with('blockExplorerUriTemplate', blockExplorerUriTemplateBuilder().build()) .with('nativeCurrency', nativeCurrencyBuilder().build()) + .with('pricesProvider', pricesProviderBuilder().build()) .with('transactionService', faker.internet.url({ appendSlash: false })) .with('vpcTransactionService', faker.internet.url({ appendSlash: false })) .with('theme', themeBuilder().build()) diff --git a/src/domain/chains/entities/__tests__/prices-provider.builder.ts b/src/domain/chains/entities/__tests__/prices-provider.builder.ts new file mode 100644 index 0000000000..ebac95fb78 --- /dev/null +++ b/src/domain/chains/entities/__tests__/prices-provider.builder.ts @@ -0,0 +1,9 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { PricesProvider } from '@/domain/chains/entities/prices-provider.entity'; +import { faker } from '@faker-js/faker'; + +export function pricesProviderBuilder(): IBuilder { + return new Builder() + .with('chainName', faker.company.name()) + .with('nativeCoin', faker.finance.currencyName()); +} diff --git a/src/domain/chains/entities/prices-provider.entity.ts b/src/domain/chains/entities/prices-provider.entity.ts new file mode 100644 index 0000000000..ddce9fa206 --- /dev/null +++ b/src/domain/chains/entities/prices-provider.entity.ts @@ -0,0 +1,4 @@ +import { PricesProviderSchema } from '@/domain/chains/entities/schemas/chain.schema'; +import { z } from 'zod'; + +export type PricesProvider = z.infer; diff --git a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts index 6d91877ee0..2db67a0cda 100644 --- a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts +++ b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts @@ -3,6 +3,7 @@ import { gasPriceFixedEIP1559Builder } from '@/domain/chains/entities/__tests__/ import { gasPriceFixedBuilder } from '@/domain/chains/entities/__tests__/gas-price-fixed.builder'; import { gasPriceOracleBuilder } from '@/domain/chains/entities/__tests__/gas-price-oracle.builder'; import { nativeCurrencyBuilder } from '@/domain/chains/entities/__tests__/native.currency.builder'; +import { pricesProviderBuilder } from '@/domain/chains/entities/__tests__/prices-provider.builder'; import { rpcUriBuilder } from '@/domain/chains/entities/__tests__/rpc-uri.builder'; import { themeBuilder } from '@/domain/chains/entities/__tests__/theme.builder'; import { @@ -12,6 +13,7 @@ import { GasPriceOracleSchema, GasPriceSchema, NativeCurrencySchema, + PricesProviderSchema, RpcUriSchema, ThemeSchema, } from '@/domain/chains/entities/schemas/chain.schema'; @@ -302,6 +304,56 @@ describe('Chain schemas', () => { }); }); + describe('PricesProviderSchema', () => { + it('should validate a valid prices provider', () => { + const pricesProvider = pricesProviderBuilder().build(); + + const result = PricesProviderSchema.safeParse(pricesProvider); + + expect(result.success).toBe(true); + }); + + it('should not validate an invalid prices provider chainName', () => { + const pricesProvider = { + chainName: 1, + }; + + const result = PricesProviderSchema.safeParse(pricesProvider); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['chainName'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it('should not validate an invalid prices provider nativeCoin', () => { + const pricesProvider = { + nativeCoin: 1, + }; + + const result = PricesProviderSchema.safeParse(pricesProvider); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['nativeCoin'], + message: 'Expected string, received number', + }, + ]), + ); + }); + }); + describe('ChainSchema', () => { it('should validate a valid chain', () => { const chain = chainBuilder().build(); diff --git a/src/domain/chains/entities/schemas/chain.schema.ts b/src/domain/chains/entities/schemas/chain.schema.ts index 2095e34c4a..c589b100c0 100644 --- a/src/domain/chains/entities/schemas/chain.schema.ts +++ b/src/domain/chains/entities/schemas/chain.schema.ts @@ -54,6 +54,11 @@ export const GasPriceSchema = z.array( ]), ); +export const PricesProviderSchema = z.object({ + chainName: z.string().nullish().default(null), + nativeCoin: z.string().nullish().default(null), +}); + export const ChainSchema = z.object({ chainId: z.string(), chainName: z.string(), @@ -67,6 +72,7 @@ export const ChainSchema = z.object({ publicRpcUri: RpcUriSchema, blockExplorerUriTemplate: BlockExplorerUriTemplateSchema, nativeCurrency: NativeCurrencySchema, + pricesProvider: PricesProviderSchema, transactionService: z.string().url(), vpcTransactionService: z.string().url(), theme: ThemeSchema, diff --git a/src/domain/interfaces/balances-api.interface.ts b/src/domain/interfaces/balances-api.interface.ts index 5678a87ab0..529a8cc572 100644 --- a/src/domain/interfaces/balances-api.interface.ts +++ b/src/domain/interfaces/balances-api.interface.ts @@ -1,4 +1,5 @@ import { Balance } from '@/domain/balances/entities/balance.entity'; +import { Chain } from '@/domain/chains/entities/chain.entity'; import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; import { Page } from '@/domain/entities/page.entity'; @@ -6,7 +7,7 @@ export interface IBalancesApi { getBalances(args: { safeAddress: string; fiatCode: string; - chainId?: string; + chain?: Chain; trusted?: boolean; excludeSpam?: boolean; }): Promise; diff --git a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts index 7266631011..995e3cd212 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -227,10 +227,13 @@ describe('Balances Controller (Unit)', () => { expect(networkService.get.mock.calls.length).toBe(2); expect(networkService.get.mock.calls[0][0].url).toBe( + `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, + ); + expect(networkService.get.mock.calls[1][0].url).toBe( `${zerionBaseUri}/v1/wallets/${safeAddress}/positions`, ); expect( - networkService.get.mock.calls[0][0].networkRequest, + networkService.get.mock.calls[1][0].networkRequest, ).toStrictEqual({ headers: { Authorization: `Basic ${apiKey}` }, params: { @@ -239,9 +242,6 @@ describe('Balances Controller (Unit)', () => { sort: 'value', }, }); - expect(networkService.get.mock.calls[1][0].url).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); }); it('returns large numbers as is (not in scientific notation)', async () => { @@ -376,10 +376,13 @@ describe('Balances Controller (Unit)', () => { expect(networkService.get.mock.calls.length).toBe(2); expect(networkService.get.mock.calls[0][0].url).toBe( + `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, + ); + expect(networkService.get.mock.calls[1][0].url).toBe( `${zerionBaseUri}/v1/wallets/${safeAddress}/positions`, ); expect( - networkService.get.mock.calls[0][0].networkRequest, + networkService.get.mock.calls[1][0].networkRequest, ).toStrictEqual({ headers: { Authorization: `Basic ${apiKey}` }, params: { @@ -388,9 +391,6 @@ describe('Balances Controller (Unit)', () => { sort: 'value', }, }); - expect(networkService.get.mock.calls[1][0].url).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); }); }); diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index 67655bc76a..4470f352c3 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -89,22 +89,14 @@ describe('Balances Controller (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - ); const apiKey = app .get(IConfigurationService) .getOrThrow('balances.providers.safe.prices.apiKey'); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, @@ -124,7 +116,7 @@ describe('Balances Controller (Unit)', () => { data: nativeCoinPriceProviderResponse, status: 200, }); - case `${pricesProviderUrl}/simple/token_price/${chainName}`: + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -203,7 +195,7 @@ describe('Balances Controller (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[2][0].url).toBe( - `${pricesProviderUrl}/simple/token_price/${chainName}`, + `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': apiKey }, @@ -220,7 +212,10 @@ describe('Balances Controller (Unit)', () => { ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': apiKey }, - params: { ids: nativeCoinId, vs_currencies: currency.toLowerCase() }, + params: { + ids: chain.pricesProvider.nativeCoin, + vs_currencies: currency.toLowerCase(), + }, }); }); @@ -237,11 +232,6 @@ describe('Balances Controller (Unit)', () => { ]; const excludeSpam = true; const trusted = true; - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 2.5 }, @@ -255,7 +245,7 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - case `${pricesProviderUrl}/simple/token_price/${chainName}`: + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -291,13 +281,10 @@ describe('Balances Controller (Unit)', () => { .build(), ]; const currency = faker.finance.currencyCode(); - const nativeCoinId = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - ); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -356,11 +343,6 @@ describe('Balances Controller (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 2.5 }, @@ -374,7 +356,7 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - case `${pricesProviderUrl}/simple/token_price/${chainName}`: + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -423,7 +405,7 @@ describe('Balances Controller (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[2][0].url).toBe( - `${pricesProviderUrl}/simple/token_price/${chainName}`, + `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); }); @@ -465,11 +447,6 @@ describe('Balances Controller (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: @@ -479,7 +456,7 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - case `${pricesProviderUrl}/simple/token_price/${chainName}`: + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.reject(); default: return Promise.reject(new Error(`Could not match ${url}`)); @@ -528,11 +505,6 @@ describe('Balances Controller (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const tokenPriceProviderResponse = 'notAnObject'; networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -543,7 +515,7 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - case `${pricesProviderUrl}/simple/token_price/${chainName}`: + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, diff --git a/src/routes/balances/balances.service.ts b/src/routes/balances/balances.service.ts index 4b8840eb42..d019697ac7 100644 --- a/src/routes/balances/balances.service.ts +++ b/src/routes/balances/balances.service.ts @@ -27,10 +27,13 @@ export class BalancesService { excludeSpam: boolean; }): Promise { const { chainId } = args; - const domainBalances = await this.balancesRepository.getBalances(args); - const { nativeCurrency } = await this.chainsRepository.getChain(chainId); + const chain = await this.chainsRepository.getChain(chainId); + const domainBalances = await this.balancesRepository.getBalances({ + ...args, + chain, + }); const balances: Balance[] = domainBalances.map((balance) => - this._mapBalance(balance, nativeCurrency), + this._mapBalance(balance, chain.nativeCurrency), ); const fiatTotal = balances .filter((b) => b.fiatBalance !== null) diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index c199c39a3c..577886a3fa 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -109,19 +109,11 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - ); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, @@ -165,7 +157,7 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName}`: { + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -210,27 +202,24 @@ describe('Safes Controller Overview (Unit)', () => { ]), ); - expect(networkService.get.mock.calls.length).toBe(7); + expect(networkService.get.mock.calls.length).toBe(6); expect(networkService.get.mock.calls[0][0].url).toBe( `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, ); expect(networkService.get.mock.calls[1][0].url).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); - expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, ); - expect(networkService.get.mock.calls[3][0].url).toBe( + expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ params: { trusted: false, exclude_spam: true }, }); - expect(networkService.get.mock.calls[4][0].url).toBe( - `${pricesProviderUrl}/simple/token_price/${chainName}`, + expect(networkService.get.mock.calls[3][0].url).toBe( + `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); - expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -240,14 +229,17 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, - params: { ids: nativeCoinId, vs_currencies: currency.toLowerCase() }, + params: { + ids: chain.pricesProvider.nativeCoin, + vs_currencies: currency.toLowerCase(), + }, }); - expect(networkService.get.mock.calls[6][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); @@ -274,19 +266,11 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - ); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, @@ -342,7 +326,7 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName}`: { + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -387,27 +371,24 @@ describe('Safes Controller Overview (Unit)', () => { ]), ); - expect(networkService.get.mock.calls.length).toBe(7); + expect(networkService.get.mock.calls.length).toBe(6); expect(networkService.get.mock.calls[0][0].url).toBe( `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, ); expect(networkService.get.mock.calls[1][0].url).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); - expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, ); - expect(networkService.get.mock.calls[3][0].url).toBe( + expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ params: { trusted: false, exclude_spam: true }, }); - expect(networkService.get.mock.calls[4][0].url).toBe( - `${pricesProviderUrl}/simple/token_price/${chainName}`, + expect(networkService.get.mock.calls[3][0].url).toBe( + `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); - expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -417,14 +398,17 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, - params: { ids: nativeCoinId, vs_currencies: currency.toLowerCase() }, + params: { + ids: chain.pricesProvider.nativeCoin, + vs_currencies: currency.toLowerCase(), + }, }); - expect(networkService.get.mock.calls[6][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); @@ -491,30 +475,14 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.nativeCoin`, - ); - const nativeCoinId2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.nativeCoin`, - ); - const chainName1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.chainName`, - ); - const chainName2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId1]: { [currency.toLowerCase()]: 1536.75 }, - [nativeCoinId2]: { [currency.toLowerCase()]: 1536.75 }, + [chain1.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + [chain2.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress1]: { [currency.toLowerCase()]: 12.5 }, @@ -568,13 +536,13 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName1}`: { + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName2}`: { + case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -730,30 +698,14 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.nativeCoin`, - ); - const nativeCoinId2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.nativeCoin`, - ); - const chainName1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.chainName`, - ); - const chainName2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId1]: { [currency.toLowerCase()]: 1536.75 }, - [nativeCoinId2]: { [currency.toLowerCase()]: 1536.75 }, + [chain1.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + [chain2.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress1]: { [currency.toLowerCase()]: 12.5 }, @@ -807,13 +759,13 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName1}`: { + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName2}`: { + case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -927,19 +879,11 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - ); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, @@ -974,7 +918,7 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName}`: { + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -1018,27 +962,24 @@ describe('Safes Controller Overview (Unit)', () => { }, ]); - expect(networkService.get.mock.calls.length).toBe(7); + expect(networkService.get.mock.calls.length).toBe(6); expect(networkService.get.mock.calls[0][0].url).toBe( `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, ); expect(networkService.get.mock.calls[1][0].url).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); - expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, ); - expect(networkService.get.mock.calls[3][0].url).toBe( + expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ params: { trusted: false, exclude_spam: true }, }); - expect(networkService.get.mock.calls[4][0].url).toBe( - `${pricesProviderUrl}/simple/token_price/${chainName}`, + expect(networkService.get.mock.calls[3][0].url).toBe( + `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); - expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -1048,19 +989,22 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, - params: { ids: nativeCoinId, vs_currencies: currency.toLowerCase() }, + params: { + ids: chain.pricesProvider.nativeCoin, + vs_currencies: currency.toLowerCase(), + }, }); - expect(networkService.get.mock.calls[6][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); - it('forwards trusted and exlude spam queries', async () => { + it('forwards trusted and exclude spam queries', async () => { const chain = chainBuilder().with('chainId', '10').build(); const safeInfo = safeBuilder().build(); const tokenAddress = faker.finance.ethereumAddress(); @@ -1082,19 +1026,11 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - ); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, @@ -1130,7 +1066,7 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName}`: { + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -1173,28 +1109,25 @@ describe('Safes Controller Overview (Unit)', () => { }, ]); - expect(networkService.get.mock.calls.length).toBe(7); + expect(networkService.get.mock.calls.length).toBe(6); expect(networkService.get.mock.calls[0][0].url).toBe( `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, ); expect(networkService.get.mock.calls[1][0].url).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); - expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, ); - expect(networkService.get.mock.calls[3][0].url).toBe( + expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ // Forwarded params params: { trusted: true, exclude_spam: false }, }); - expect(networkService.get.mock.calls[4][0].url).toBe( - `${pricesProviderUrl}/simple/token_price/${chainName}`, + expect(networkService.get.mock.calls[3][0].url).toBe( + `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); - expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -1204,14 +1137,17 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, - params: { ids: nativeCoinId, vs_currencies: currency.toLowerCase() }, + params: { + ids: chain.pricesProvider.nativeCoin, + vs_currencies: currency.toLowerCase(), + }, }); - expect(networkService.get.mock.calls[6][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); @@ -1280,30 +1216,14 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.nativeCoin`, - ); - const nativeCoinId2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.nativeCoin`, - ); - const chainName1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.chainName`, - ); - const chainName2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId1]: { [currency.toLowerCase()]: 1536.75 }, - [nativeCoinId2]: { [currency.toLowerCase()]: 1536.75 }, + [chain1.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + [chain2.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress1]: { [currency.toLowerCase()]: 12.5 }, @@ -1363,13 +1283,13 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName1}`: { + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName2}`: { + case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -1490,30 +1410,14 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.nativeCoin`, - ); - const nativeCoinId2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.nativeCoin`, - ); - const chainName1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.chainName`, - ); - const chainName2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId1]: { [currency.toLowerCase()]: 1536.75 }, - [nativeCoinId2]: { [currency.toLowerCase()]: 1536.75 }, + [chain1.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + [chain2.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress1]: { [currency.toLowerCase()]: 12.5 }, @@ -1568,13 +1472,13 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName1}`: { + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName2}`: { + case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -1706,30 +1610,14 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.nativeCoin`, - ); - const nativeCoinId2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.nativeCoin`, - ); - const chainName1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.chainName`, - ); - const chainName2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId1]: { [currency.toLowerCase()]: 1536.75 }, - [nativeCoinId2]: { [currency.toLowerCase()]: 1536.75 }, + [chain1.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + [chain2.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress1]: { [currency.toLowerCase()]: 12.5 }, @@ -1783,13 +1671,13 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName1}`: { + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName2}`: { + case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, diff --git a/src/routes/safes/safes.service.ts b/src/routes/safes/safes.service.ts index 5bd29dfe5b..c4f4930e25 100644 --- a/src/routes/safes/safes.service.ts +++ b/src/routes/safes/safes.service.ts @@ -138,13 +138,14 @@ export class SafesService { const settledOverviews = await Promise.allSettled( limitedSafes.map(async ({ chainId, address }) => { + const chain = await this.chainsRepository.getChain(chainId); const [safe, balances] = await Promise.all([ this.safeRepository.getSafe({ chainId, address, }), this.balancesRepository.getBalances({ - chainId, + chain, safeAddress: address, trusted: args.trusted, fiatCode: args.currency, From f77b05be8a4f4949da18e5184287bde0cac5f02c Mon Sep 17 00:00:00 2001 From: Den Smalonski Date: Tue, 14 May 2024 14:13:02 +0200 Subject: [PATCH 52/65] feat: remove deprecated chain configuration --- src/config/entities/configuration.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 7cb59bd6a1..29bd53f1a3 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -80,7 +80,6 @@ export default () => ({ 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, 18233: { nativeCoin: 'ethereum', chainName: 'unreal' }, 111188: { nativeCoin: 'ethereum', chainName: 're-al' }, - 167008: { nativeCoin: 'ethereum', chainName: 'tko-katla' }, 713715: { nativeCoin: 'sei-network', chainName: 'sei-network' }, 4202: { nativeCoin: 'ethereum', chainName: 'lisksep' }, 81457: { nativeCoin: 'ethereum', chainName: 'blast' }, From f67d71e3bb6548485ae1736c168bb1120978b11e Mon Sep 17 00:00:00 2001 From: Nikita Zasimuk Date: Tue, 14 May 2024 23:36:09 +0300 Subject: [PATCH 53/65] feat: add lisk, cyber and cyber testnet to the prices api --- src/config/entities/configuration.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 29bd53f1a3..6ba0e1ca91 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -81,6 +81,7 @@ export default () => ({ 18233: { nativeCoin: 'ethereum', chainName: 'unreal' }, 111188: { nativeCoin: 'ethereum', chainName: 're-al' }, 713715: { nativeCoin: 'sei-network', chainName: 'sei-network' }, + 1135: { nativeCoin: 'ethereum', chainName: 'lisk' }, 4202: { nativeCoin: 'ethereum', chainName: 'lisksep' }, 81457: { nativeCoin: 'ethereum', chainName: 'blast' }, 252: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, @@ -97,6 +98,8 @@ export default () => ({ 94204209: { nativeCoin: 'ethereum', chainName: 'polygon-blackberry' }, 690: { nativeCoin: 'ethereum', chainName: 'redstone-mainnet' }, 17069: { nativeCoin: 'ethereum', chainName: 'redstone-garnet' }, + 7560: { nativeCoin: 'ethereum', chainName: 'cyber' }, + 111557560: { nativeCoin: 'ethereum', chainName: 'cyber' }, }, }, }, From decfb505bd9b08902cc151bb682b98734d2702ac Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 15 May 2024 11:59:30 +0200 Subject: [PATCH 54/65] Add flagging (alongside filtering) of imitation transfers (#1542) This either flags _or_ filters imitation transactions depending on an `imitation` query parameter. When requesting the transaction history, setting the query parameter to `false` filters out dusting attacks and to `true` (the default) returns them. The new flag is added _only_ to outgoing ERC-20 transfers that imitate their direct predecessor in terms of value and recipient vanity: - Adjust configuration naming accordingly: - `features.imitationFiltering` -> `features.imitationMapping` - (`process.env.FF_IMITATION_FILTERING` -> `FF_IMITATION_MAPPING`) - `mapping.imitationTransactions` -> `mappings.imitation` - Add `imitation` flag to `Erc20TransferEntity` (`transaction.txInfo.transferInfo.imitation`) - Rename `ImitationTransactionsHelper` to `TransferImitationMapper` - Add flagging logic to `TransferImitationMapper`, separate from filtering - Add `imitation` query param. and pass it to mapper accordingly --- .../entities/__tests__/configuration.ts | 4 +- src/config/entities/configuration.ts | 8 +- .../transfers/erc20-transfer.entity.ts | 4 + .../mappers/transactions-history.mapper.ts | 26 +- .../transfers/transfer-imitation.mapper.ts} | 82 +- .../transactions-history.controller.spec.ts | 1677 +++++++++++------ .../transactions/transactions.controller.ts | 3 + .../transactions/transactions.module.ts | 4 +- .../transactions/transactions.service.ts | 2 + 9 files changed, 1166 insertions(+), 644 deletions(-) rename src/routes/transactions/{helpers/imitation-transactions.helper.ts => mappers/transfers/transfer-imitation.mapper.ts} (58%) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 961946ad31..07c01d2265 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -185,7 +185,7 @@ export default (): ReturnType => ({ zerionBalancesChainIds: ['137'], swapsDecoding: true, historyDebugLogs: false, - imitationFiltering: false, + imitationMapping: false, auth: false, confirmationView: false, eventsQueue: false, @@ -200,7 +200,7 @@ export default (): ReturnType => ({ silent: process.env.LOG_SILENT?.toLowerCase() === 'true', }, mappings: { - imitationTransactions: { + imitation: { prefixLength: faker.number.int(), suffixLength: faker.number.int(), }, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index f4c4153b30..5d551ecd39 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -193,8 +193,8 @@ export default () => ({ swapsDecoding: process.env.FF_SWAPS_DECODING?.toLowerCase() === 'true', historyDebugLogs: process.env.FF_HISTORY_DEBUG_LOGS?.toLowerCase() === 'true', - imitationFiltering: - process.env.FF_IMITATION_FILTERING?.toLowerCase() === 'true', + imitationMapping: + process.env.FF_IMITATION_MAPPING?.toLowerCase() === 'true', auth: process.env.FF_AUTH?.toLowerCase() === 'true', confirmationView: process.env.FF_CONFIRMATION_VIEW?.toLowerCase() === 'true', @@ -222,9 +222,9 @@ export default () => ({ ownersTtlSeconds: parseInt(process.env.OWNERS_TTL_SECONDS ?? `${0}`), }, mappings: { - imitationTransactions: { + imitation: { prefixLength: parseInt(process.env.IMITATION_PREFIX_LENGTH ?? `${3}`), - suffixLength: parseInt(process.env.VANITY_ADDRESS_CHARS ?? `${4}`), + suffixLength: parseInt(process.env.IMITATION_SUFFIX_LENGTH ?? `${4}`), }, history: { maxNestedTransfers: parseInt( diff --git a/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts b/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts index ca048a5ae6..d8c5c9d68b 100644 --- a/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts +++ b/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts @@ -19,6 +19,8 @@ export class Erc20Transfer extends Transfer { decimals: number | null; @ApiPropertyOptional({ type: Boolean, nullable: true }) trusted: boolean | null; + @ApiPropertyOptional({ type: Boolean, nullable: true }) + imitation: boolean | null; constructor( tokenAddress: `0x${string}`, @@ -28,6 +30,7 @@ export class Erc20Transfer extends Transfer { logoUri: string | null = null, decimals: number | null = null, trusted: boolean | null = null, + imitation: boolean | null = null, ) { super(TransferType.Erc20); this.tokenAddress = tokenAddress; @@ -37,6 +40,7 @@ export class Erc20Transfer extends Transfer { this.logoUri = logoUri; this.decimals = decimals; this.trusted = trusted; + this.imitation = imitation; } } diff --git a/src/routes/transactions/mappers/transactions-history.mapper.ts b/src/routes/transactions/mappers/transactions-history.mapper.ts index b31b6680b2..cf8992c34e 100644 --- a/src/routes/transactions/mappers/transactions-history.mapper.ts +++ b/src/routes/transactions/mappers/transactions-history.mapper.ts @@ -16,11 +16,11 @@ import { ModuleTransactionMapper } from '@/routes/transactions/mappers/module-tr import { MultisigTransactionMapper } from '@/routes/transactions/mappers/multisig-transactions/multisig-transaction.mapper'; import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper'; import { IConfigurationService } from '@/config/configuration.service.interface'; -import { ImitationTransactionsHelper } from '@/routes/transactions/helpers/imitation-transactions.helper'; +import { TransferImitationMapper } from '@/routes/transactions/mappers/transfers/transfer-imitation.mapper'; @Injectable() export class TransactionsHistoryMapper { - private readonly isImitationFilteringEnabled: boolean; + private readonly isImitationMappingEnabled: boolean; private readonly maxNestedTransfers: number; constructor( @@ -29,11 +29,11 @@ export class TransactionsHistoryMapper { private readonly multisigTransactionMapper: MultisigTransactionMapper, private readonly moduleTransactionMapper: ModuleTransactionMapper, private readonly transferMapper: TransferMapper, + private readonly transferImitationMapper: TransferImitationMapper, private readonly creationTransactionMapper: CreationTransactionMapper, - private readonly imitationTransactionsHelper: ImitationTransactionsHelper, ) { - this.isImitationFilteringEnabled = this.configurationService.getOrThrow( - 'features.imitationFiltering', + this.isImitationMappingEnabled = this.configurationService.getOrThrow( + 'features.imitationMapping', ); this.maxNestedTransfers = this.configurationService.getOrThrow( 'mappings.history.maxNestedTransfers', @@ -47,6 +47,7 @@ export class TransactionsHistoryMapper { offset: number, timezoneOffset: number, onlyTrusted: boolean, + showImitations: boolean, ): Promise> { if (transactionsDomain.length == 0) { return []; @@ -66,6 +67,7 @@ export class TransactionsHistoryMapper { safe, previousTransaction, onlyTrusted, + showImitations, }); // The groups respect timezone offset – this was done for grouping only. @@ -126,6 +128,7 @@ export class TransactionsHistoryMapper { safe: Safe; previousTransaction: TransactionItem | undefined; onlyTrusted: boolean; + showImitations: boolean; }): Promise { const mappedTransactions = await Promise.all( args.transactionsDomain.map((transaction) => { @@ -141,16 +144,15 @@ export class TransactionsHistoryMapper { .filter((x: T): x is NonNullable => x != null) .flat(); - // TODO: Make conditional according to args.onlyTrusted if clients fetch - // trusted regardless of token lists - if (!this.isImitationFilteringEnabled) { + if (!this.isImitationMappingEnabled) { return transactionItems; } - return this.imitationTransactionsHelper.filterOutgoingErc20ImitationTransfers( - transactionItems, - args.previousTransaction, - ); + return this.transferImitationMapper.mapImitations({ + transactions: transactionItems, + previousTransaction: args.previousTransaction, + showImitations: args.showImitations, + }); } private groupByDay( diff --git a/src/routes/transactions/helpers/imitation-transactions.helper.ts b/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts similarity index 58% rename from src/routes/transactions/helpers/imitation-transactions.helper.ts rename to src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts index 4908ea265e..64312afe1f 100644 --- a/src/routes/transactions/helpers/imitation-transactions.helper.ts +++ b/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts @@ -7,7 +7,7 @@ import { import { isErc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; import { Inject } from '@nestjs/common'; -export class ImitationTransactionsHelper { +export class TransferImitationMapper { private readonly prefixLength: number; private readonly suffixLength: number; @@ -15,33 +15,57 @@ export class ImitationTransactionsHelper { @Inject(IConfigurationService) configurationService: IConfigurationService, ) { this.prefixLength = configurationService.getOrThrow( - 'mappings.imitationTransactions.prefixLength', + 'mappings.imitation.prefixLength', ); this.suffixLength = configurationService.getOrThrow( - 'mappings.imitationTransactions.suffixLength', + 'mappings.imitation.suffixLength', ); } + mapImitations(args: { + transactions: Array; + previousTransaction: TransactionItem | undefined; + showImitations: boolean; + }): Array { + const transactions = this.mapTransferInfoImitation( + args.transactions, + args.previousTransaction, + ); + + if (args.showImitations) { + return transactions; + } + + return transactions.filter(({ transaction }) => { + const { txInfo } = transaction; + return ( + !isTransferTransactionInfo(txInfo) || + !isErc20Transfer(txInfo.transferInfo) || + // null by default or explicitly false if not imitation + txInfo.transferInfo?.imitation !== true + ); + }); + } + /** - * Filters out outgoing ERC20 transfers that imitate their direct predecessor in - * value and have a recipient address that is not the same but matches in vanity. + * Flags outgoing ERC20 transfers that imitate their direct predecessor in value + * and have a recipient address that is not the same but matches in vanity. * - * @param transactions - list of transactions to filter + * @param transactions - list of transactions to map * @param previousTransaction - transaction to compare last {@link transactions} against * * Note: this only handles singular imitation transfers. It does not handle multiple * imitation transfers in a row, nor does it compare batched multiSend transactions * as the "distance" between those batched and their imitation may not be immediate. */ - filterOutgoingErc20ImitationTransfers( + private mapTransferInfoImitation( transactions: Array, - previousTransaction: TransactionItem | undefined, + previousTransaction?: TransactionItem, ): Array { - // TODO: Instead of filtering, mark transactions so client can display them differently - return transactions.filter((item, i, arr) => { + return transactions.map((item, i, arr) => { // Executed by Safe - cannot be imitation if (item.transaction.executionInfo) { - return true; + return item; } // Transaction list is in date-descending order. We compare each transaction with the next @@ -51,43 +75,43 @@ export class ImitationTransactionsHelper { // No reference transaction to filter against if (!prevItem) { - return true; + return item; } - const txInfo = item.transaction.txInfo; - const prevTxInfo = prevItem.transaction.txInfo; - if ( // Only consider transfers... - !isTransferTransactionInfo(txInfo) || - !isTransferTransactionInfo(prevTxInfo) || + !isTransferTransactionInfo(item.transaction.txInfo) || + !isTransferTransactionInfo(prevItem.transaction.txInfo) || // ...of ERC20s... - !isErc20Transfer(txInfo.transferInfo) || - !isErc20Transfer(prevTxInfo.transferInfo) + !isErc20Transfer(item.transaction.txInfo.transferInfo) || + !isErc20Transfer(prevItem.transaction.txInfo.transferInfo) ) { - return true; + return item; } // ...that are outgoing - const isOutgoing = txInfo.direction === TransferDirection.Outgoing; + const isOutgoing = + item.transaction.txInfo.direction === TransferDirection.Outgoing; const isPrevOutgoing = - prevTxInfo.direction === TransferDirection.Outgoing; + prevItem.transaction.txInfo.direction === TransferDirection.Outgoing; if (!isOutgoing || !isPrevOutgoing) { - return true; + return item; } // Imitation transfers are of the same value... const isSameValue = - txInfo.transferInfo.value === prevTxInfo.transferInfo.value; + item.transaction.txInfo.transferInfo.value === + prevItem.transaction.txInfo.transferInfo.value; if (!isSameValue) { - return true; + return item; } - // ...from recipient that has the same vanity but is not the same address - return !this.isImitatorAddress( - txInfo.recipient.value, - prevTxInfo.recipient.value, + item.transaction.txInfo.transferInfo.imitation = this.isImitatorAddress( + item.transaction.txInfo.recipient.value, + prevItem.transaction.txInfo.recipient.value, ); + + return item; }); } diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 27ab3eb2b5..51c881e8f9 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -77,16 +77,17 @@ describe('Transactions History Controller (Unit)', () => { ...configuration(), mappings: { ...configuration().mappings, - imitationTransactions: { - prefixLength, - suffixLength, - }, - features: { - imitationFiltering: true, - }, history: { maxNestedTransfers: 5, }, + imitation: { + prefixLength, + suffixLength, + }, + }, + features: { + ...configuration().features, + imitationMapping: true, }, }); @@ -1353,647 +1354,1133 @@ describe('Transactions History Controller (Unit)', () => { }); describe('Address poisoning', () => { - it('should filter out outgoing ERC-20 transfers that imitate a predecessor', async () => { - // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 - const chain = chainBuilder().build(); - const safe = safeBuilder() - .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') - .with('owners', [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - ]) - .build(); + // TODO: Add tests with a mixture of (non-)trusted tokens, as well add builder-based tests + describe('Trusted tokens', () => { + it('should flag outgoing ERC-20 transfers that imitate a direct predecessor', async () => { + // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 - marked as trusted + const chain = chainBuilder().build(); + const safe = safeBuilder() + .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') + .with('owners', [ + '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + ]) + .build(); - const results = [ - { - executionDate: '2024-03-20T09:42:58Z', - to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', - data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', - txHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - blockNumber: 192295013, - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:42:58Z', - blockNumber: 192295013, - transactionHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - transferId: - 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', - tokenInfo: { - type: 'ERC20', - address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', - trusted: false, + const results = [ + { + executionDate: '2024-03-20T09:42:58Z', + to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', + data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', + txHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + blockNumber: 192295013, + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:42:58Z', + blockNumber: 192295013, + transactionHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + transferId: + 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + tokenInfo: { + type: 'ERC20', + address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + trusted: true, + }, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + ], + txType: 'ETHEREUM_TRANSACTION', + from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', + }, + { + safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + to: '0x912CE59144191C1204E64559FE8253a0e49E6548', + value: '0', + data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', + operation: 0, + gasToken: '0x0000000000000000000000000000000000000000', + safeTxGas: 0, + baseGas: 0, + gasPrice: '0', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 3, + executionDate: '2024-03-20T09:41:25Z', + submissionDate: '2024-03-20T09:38:11.447366Z', + modified: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + safeTxHash: + '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + isExecuted: true, + isSuccessful: true, + ethGasPrice: '10946000', + maxFeePerGas: null, + maxPriorityFeePerGas: null, + gasUsed: 249105, + fee: '2726703330000', + origin: '{}', + dataDecoded: { + method: 'transfer', + parameters: [ + { + name: 'to', + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + { + name: 'value', + type: 'uint256', + value: '40000000000000000000000', + }, + ], }, - ], - txType: 'ETHEREUM_TRANSACTION', - from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', - }, - { - safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - to: '0x912CE59144191C1204E64559FE8253a0e49E6548', - value: '0', - data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', - operation: 0, - gasToken: '0x0000000000000000000000000000000000000000', - safeTxGas: 0, - baseGas: 0, - gasPrice: '0', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 3, - executionDate: '2024-03-20T09:41:25Z', - submissionDate: '2024-03-20T09:38:11.447366Z', - modified: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - safeTxHash: - '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', - proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - isExecuted: true, - isSuccessful: true, - ethGasPrice: '10946000', - maxFeePerGas: null, - maxPriorityFeePerGas: null, - gasUsed: 249105, - fee: '2726703330000', - origin: '{}', - dataDecoded: { - method: 'transfer', - parameters: [ + confirmationsRequired: 2, + confirmations: [ { - name: 'to', - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + owner: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + submissionDate: '2024-03-20T09:38:11.479197Z', + transactionHash: null, + signature: + '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + signatureType: 'EOA', }, { - name: 'value', - type: 'uint256', + owner: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + submissionDate: '2024-03-20T09:41:25Z', + transactionHash: null, + signature: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', + signatureType: 'APPROVED_HASH', + }, + ], + trusted: true, + signatures: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', + transferId: + 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', + tokenInfo: { + type: 'ERC20', + address: '0x912CE59144191C1204E64559FE8253a0e49E6548', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + trusted: true, + }, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', }, ], + txType: 'MULTISIG_TRANSACTION', }, - confirmationsRequired: 2, - confirmations: [ - { - owner: safe.owners[0], - submissionDate: '2024-03-20T09:38:11.479197Z', - transactionHash: null, - signature: - '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - signatureType: 'EOA', - }, - { - owner: safe.owners[1], - submissionDate: '2024-03-20T09:41:25Z', - transactionHash: null, - signature: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', - signatureType: 'APPROVED_HASH', - }, - ], - trusted: true, - signatures: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', - transferId: - 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', - tokenInfo: { - type: 'ERC20', - address: '0x912CE59144191C1204E64559FE8253a0e49E6548', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - trusted: false, - }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - ], - txType: 'MULTISIG_TRANSACTION', - }, - { - executionDate: '2024-03-20T09:18:32Z', - to: '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D', - data: '0x8d80ff0a00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000d0b00b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003046a761202000000000000000000000000912ce59144191c1204e64559fe8253a0e49e65480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000044a9059cbb00000000000000000000000088103c9fa3ce4ff45e4c1ea3688f40d1dfda6b020000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010458560018b40606d8124f87297277bbaba0a90b1698370a3803003e716faffa5e443a38e4cec966ae7c438b3e1eebc2a1ad123ea3f8c5380226e879dc5d5818c61b2bd037179d9b51523878f40d3ce1e962d0678daee85ecb0eebdeb569431069c0061e6e079e6d96d1ada98486c9afc99b3b628c459b6fe6d325b38d717ccc3dfa1be5d55404dc15e26effef3d0062dda6c8ac3dd03cdeb2895b2843d78dd1ab5f462db04cff3862a657b12c0667939b6787c57e3df495063542cbb7ce9c2d260f921c98bcb9b2f48e105e7b16dc6099ab0bbbc1daea62e4c76037865cba25fafec3916bbb49ca21866837adb6c1edcc52b820375f359b8d35cb595bd09c26ceda99a01c0000000000000000000000000000000000000000000000000000000000b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006046a76120200000000000000000000000040a2accbd92bca938b02010e17a5b8929b49130d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004c000000000000000000000000000000000000000000000000000000000000003448d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000002fd00912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000009aab7a39e7e022666283454fbd0769f93d1e1d4e000000000000000000000000000000000000000000000cb49b44ba602d80000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000d3fb3ed59e5a7674003625241551a6ffa63d2c5000000000000000000000000000000000000000000000054b40b1f852bda0000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000000000000000000000000878678326eac900000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000e7eb925300075e49fc5caad5d408a50dd22f92d60000000000000000000000000000000000000000000007695a92c20d6fe0000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000bc22b72b0408f66316cc4ee562b858f612776f1400000000000000000000000000000000000000000000054b40b1f852bda0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010425a901b70e415dac6bb78079fc99542d64f9bd5c48823ca0e477d1520d9432254543fa9b814ae03f0f9b74455ee56369d84af999c8462836dd8f39c1b897492f1b3ea25052c6862fc7dfb50b4413a45c14192db85953ee526c022ed35fa43f09c41bb42349464e2e6b2065eda02b88ea80ddc6a2ea3e7125a482fc2ee926661f481c0f25744eb8f7eacf544d17ff8478154e6ab368f4ca0ba0e0f476d5357b28704b4d1263a4c32980cbb013ce893ca9532035e3d2f1c70b6e47e2af49bafda4e9261b023b000c217058732b3bdb2ca73012e2cac856bcd0c9d89336086787dbcaa1985c81f89812e7addd5d7dcd807e14e4bb986d24dbc6ad152796937882d626e6cf1b0000000000000000000000000000000000000000000000000000000000b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003046a761202000000000000000000000000912ce59144191c1204e64559fe8253a0e49e65480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000008704ee9ab8622bbc25410c7d4717ed51f776c7f600000000000000000000000000000000000000000000a1fd44f903579c9400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001042f486e77e4441a2849f295d7615a237417cb9aa94b86cab0c506df9ff8bf18bb13b14a3eb00eb0be707251e21e63283345c951aebd5b9d4eda11ba80223bee861cbd125cbd373edfe7bde69d5382c83d731a587313c473eb8db1ef508f38fb73f5783f09bfa4fe2101c6aec6a22d680edc0e4c13c27dc3eb8ace6ac5a9ac415a781ba117d4d16c977474f442ae6ceb64e3256e31a3acdd9b5bbdc407595a66d521b24458ec59290deaa2fd5e5d68257e653f4ae9d13b22689e90626911324aa1c7631c80b42babdb8705106ac64f3229e18738926786cef4df39831148f330bb5fd78f6be889ba310c7f346d82a62b00cd0acc5a404731e0713a32839077746701a45b1c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', - txHash: - '0x85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f6', - blockNumber: 192289077, - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:18:32Z', - blockNumber: 192289077, - transactionHash: - '0x85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f6', - to: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', - transferId: - 'e85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f66', - tokenInfo: { - type: 'ERC20', - address: '0x912CE59144191C1204E64559FE8253a0e49E6548', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - trusted: false, - }, - from: '0xB6fd0BDb1432b2c77170933120079f436F3bB4fa', - }, - ], - txType: 'ETHEREUM_TRANSACTION', - from: '0xFb390aC2028B47031FA4561994fd3abc9FD60a7f', - }, - ]; + ]; - networkService.get.mockImplementation(({ url }) => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; - const getAllTransactions = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; + const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; - const getImitationTokenAddress = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; - const getLegitTokenAddress = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; - if (url === getChainUrl) { - return Promise.resolve({ data: chain, status: 200 }); - } - if (url === getAllTransactions) { - return Promise.resolve({ - data: pageBuilder().with('results', results).build(), - status: 200, - }); - } - if (url === getSafeUrl) { - return Promise.resolve({ data: safe, status: 200 }); - } - if (url === getImitationTokenAddress) { - return Promise.resolve({ - data: results[0].transfers[0].tokenInfo, - status: 200, - }); - } - if (url === getLegitTokenAddress) { - return Promise.resolve({ - data: results[1].transfers[0].tokenAddress, - status: 200, + const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + networkService.get.mockImplementation(({ url }) => { + if (url === getChainUrl) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactionsUrl) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getImitationTokenAddressUrl) { + return Promise.resolve({ + data: results[0].transfers[0].tokenInfo, + status: 200, + }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: results[1].transfers[0].tokenInfo, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=true`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { + timestamp: 1710927778000, + type: 'DATE_LABEL', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + id: 'transfer_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: true, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + tokenAddress: + '0xcDB94376E0330B13F5Becaece169602cbB14399c', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: true, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 2, + confirmationsSubmitted: 2, + missingSigners: null, + nonce: 3, + type: 'MULTISIG', + }, + id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + safeAppInfo: null, + timestamp: 1710927685000, + txInfo: { + direction: 'OUTGOING', + humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + recipient: { + logoUri: null, + name: null, + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + richDecodedInfo: { + fragments: [ + { + type: 'text', + value: 'Send', + }, + { + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + symbol: 'ARB', + type: 'tokenValue', + value: '40000', + }, + { + type: 'text', + value: 'to', + }, + { + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + ], + }, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: null, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + tokenAddress: + '0x912CE59144191C1204E64559FE8253a0e49E6548', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: null, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + ]); }); - } - return Promise.reject(new Error(`Could not match ${url}`)); }); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=true`, - ) - .expect(200) - .then(({ body }) => { - expect(body.results).toStrictEqual([ - { - type: 'DATE_LABEL', - timestamp: 1710927685000, + it('should filter out outgoing ERC-20 transfers that imitate a direct predecessor', async () => { + // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 - marked as trusted + const chain = chainBuilder().build(); + const safe = safeBuilder() + .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') + .with('owners', [ + '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + ]) + .build(); + + const results = [ + { + executionDate: '2024-03-20T09:42:58Z', + to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', + data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', + txHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + blockNumber: 192295013, + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:42:58Z', + blockNumber: 192295013, + transactionHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + transferId: + 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + tokenInfo: { + type: 'ERC20', + address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + trusted: true, + }, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + ], + txType: 'ETHEREUM_TRANSACTION', + from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', + }, + { + safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + to: '0x912CE59144191C1204E64559FE8253a0e49E6548', + value: '0', + data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', + operation: 0, + gasToken: '0x0000000000000000000000000000000000000000', + safeTxGas: 0, + baseGas: 0, + gasPrice: '0', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 3, + executionDate: '2024-03-20T09:41:25Z', + submissionDate: '2024-03-20T09:38:11.447366Z', + modified: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + safeTxHash: + '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + isExecuted: true, + isSuccessful: true, + ethGasPrice: '10946000', + maxFeePerGas: null, + maxPriorityFeePerGas: null, + gasUsed: 249105, + fee: '2726703330000', + origin: '{}', + dataDecoded: { + method: 'transfer', + parameters: [ + { + name: 'to', + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + { + name: 'value', + type: 'uint256', + value: '40000000000000000000000', + }, + ], }, - // Only the legitimate transaction (results[1]) should be included as results[0] is imitation - // and results[2] is fetched in order to calculate conflict DATE_LABEL - { - conflictType: 'None', - transaction: { - executionInfo: { - confirmationsRequired: 2, - confirmationsSubmitted: 2, - missingSigners: null, - nonce: 3, - type: 'MULTISIG', + confirmationsRequired: 2, + confirmations: [ + { + owner: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + submissionDate: '2024-03-20T09:38:11.479197Z', + transactionHash: null, + signature: + '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + signatureType: 'EOA', + }, + { + owner: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + submissionDate: '2024-03-20T09:41:25Z', + transactionHash: null, + signature: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', + signatureType: 'APPROVED_HASH', + }, + ], + trusted: true, + signatures: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', + transferId: + 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', + tokenInfo: { + type: 'ERC20', + address: '0x912CE59144191C1204E64559FE8253a0e49E6548', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + trusted: true, }, - id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', - safeAppInfo: null, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + ], + txType: 'MULTISIG_TRANSACTION', + }, + ]; + + const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; + const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; + const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; + const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + networkService.get.mockImplementation(({ url }) => { + if (url === getChainUrl) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactionsUrl) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getImitationTokenAddressUrl) { + return Promise.resolve({ + data: results[0].transfers[0].tokenInfo, + status: 200, + }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: results[1].transfers[0].tokenInfo, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=true&imitation=false`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { timestamp: 1710927685000, - txInfo: { - actionCount: null, - dataSize: '68', - humanDescription: - 'Send 40000000000000000000000 to 0xFd7e...A512', - isCancellation: false, - methodName: 'transfer', - richDecodedInfo: { - fragments: [ - { - type: 'text', - value: 'Send', - }, - { - logoUri: null, - symbol: null, - type: 'tokenValue', - value: '40000000000000000000000', - }, - { - type: 'text', - value: 'to', - }, - { - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - ], + type: 'DATE_LABEL', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 2, + confirmationsSubmitted: 2, + missingSigners: null, + nonce: 3, + type: 'MULTISIG', }, - to: { - logoUri: null, - name: null, - value: '0x912CE59144191C1204E64559FE8253a0e49E6548', + id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + safeAppInfo: null, + timestamp: 1710927685000, + txInfo: { + direction: 'OUTGOING', + humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + recipient: { + logoUri: null, + name: null, + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + richDecodedInfo: { + fragments: [ + { + type: 'text', + value: 'Send', + }, + { + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + symbol: 'ARB', + type: 'tokenValue', + value: '40000', + }, + { + type: 'text', + value: 'to', + }, + { + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + ], + }, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: null, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + tokenAddress: + '0x912CE59144191C1204E64559FE8253a0e49E6548', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: null, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', }, - type: 'Custom', - value: '0', + txStatus: 'SUCCESS', }, - txStatus: 'SUCCESS', + type: 'TRANSACTION', }, - type: 'TRANSACTION', - }, - ]); - }); + ]); + }); + }); }); - it('should not filter imitation transfers if untrusted those untrusted are requested', async () => { - // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 - const chain = chainBuilder().build(); - const safe = safeBuilder() - .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') - .with('owners', [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - ]) - .build(); + describe('Non-trusted tokens', () => { + it('should flag outgoing ERC-20 transfers that imitate a direct predecessor', async () => { + // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 + const chain = chainBuilder().build(); + const safe = safeBuilder() + .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') + .with('owners', [ + '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + ]) + .build(); - const results = [ - { - executionDate: '2024-03-20T09:42:58Z', - to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', - data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', - txHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - blockNumber: 192295013, - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:42:58Z', - blockNumber: 192295013, - transactionHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - transferId: - 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', - tokenInfo: { - type: 'ERC20', - address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', - trusted: false, + const results = [ + { + executionDate: '2024-03-20T09:42:58Z', + to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', + data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', + txHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + blockNumber: 192295013, + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:42:58Z', + blockNumber: 192295013, + transactionHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + transferId: + 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + tokenInfo: { + type: 'ERC20', + address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + trusted: false, + }, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + ], + txType: 'ETHEREUM_TRANSACTION', + from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', + }, + { + safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + to: '0x912CE59144191C1204E64559FE8253a0e49E6548', + value: '0', + data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', + operation: 0, + gasToken: '0x0000000000000000000000000000000000000000', + safeTxGas: 0, + baseGas: 0, + gasPrice: '0', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 3, + executionDate: '2024-03-20T09:41:25Z', + submissionDate: '2024-03-20T09:38:11.447366Z', + modified: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + safeTxHash: + '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + isExecuted: true, + isSuccessful: true, + ethGasPrice: '10946000', + maxFeePerGas: null, + maxPriorityFeePerGas: null, + gasUsed: 249105, + fee: '2726703330000', + origin: '{}', + dataDecoded: { + method: 'transfer', + parameters: [ + { + name: 'to', + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + { + name: 'value', + type: 'uint256', + value: '40000000000000000000000', + }, + ], }, - ], - txType: 'ETHEREUM_TRANSACTION', - from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', - }, - { - safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - to: '0x912CE59144191C1204E64559FE8253a0e49E6548', - value: '0', - data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', - operation: 0, - gasToken: '0x0000000000000000000000000000000000000000', - safeTxGas: 0, - baseGas: 0, - gasPrice: '0', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 3, - executionDate: '2024-03-20T09:41:25Z', - submissionDate: '2024-03-20T09:38:11.447366Z', - modified: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - safeTxHash: - '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', - proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - isExecuted: true, - isSuccessful: true, - ethGasPrice: '10946000', - maxFeePerGas: null, - maxPriorityFeePerGas: null, - gasUsed: 249105, - fee: '2726703330000', - origin: '{}', - dataDecoded: { - method: 'transfer', - parameters: [ + confirmationsRequired: 2, + confirmations: [ { - name: 'to', - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + owner: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + submissionDate: '2024-03-20T09:38:11.479197Z', + transactionHash: null, + signature: + '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + signatureType: 'EOA', }, { - name: 'value', - type: 'uint256', + owner: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + submissionDate: '2024-03-20T09:41:25Z', + transactionHash: null, + signature: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', + signatureType: 'APPROVED_HASH', + }, + ], + trusted: true, + signatures: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', + transferId: + 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', + tokenInfo: { + type: 'ERC20', + address: '0x912CE59144191C1204E64559FE8253a0e49E6548', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + trusted: false, + }, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', }, ], + txType: 'MULTISIG_TRANSACTION', }, - confirmationsRequired: 2, - confirmations: [ - { - owner: safe.owners[0], - submissionDate: '2024-03-20T09:38:11.479197Z', - transactionHash: null, - signature: - '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - signatureType: 'EOA', - }, - { - owner: safe.owners[1], - submissionDate: '2024-03-20T09:41:25Z', - transactionHash: null, - signature: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', - signatureType: 'APPROVED_HASH', - }, - ], - trusted: true, - signatures: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', - transferId: - 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', - tokenInfo: { - type: 'ERC20', - address: '0x912CE59144191C1204E64559FE8253a0e49E6548', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - trusted: false, - }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - ], - txType: 'MULTISIG_TRANSACTION', - }, - { - executionDate: '2024-03-20T09:18:32Z', - to: '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D', - data: '0x8d80ff0a00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000d0b00b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003046a761202000000000000000000000000912ce59144191c1204e64559fe8253a0e49e65480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000044a9059cbb00000000000000000000000088103c9fa3ce4ff45e4c1ea3688f40d1dfda6b020000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010458560018b40606d8124f87297277bbaba0a90b1698370a3803003e716faffa5e443a38e4cec966ae7c438b3e1eebc2a1ad123ea3f8c5380226e879dc5d5818c61b2bd037179d9b51523878f40d3ce1e962d0678daee85ecb0eebdeb569431069c0061e6e079e6d96d1ada98486c9afc99b3b628c459b6fe6d325b38d717ccc3dfa1be5d55404dc15e26effef3d0062dda6c8ac3dd03cdeb2895b2843d78dd1ab5f462db04cff3862a657b12c0667939b6787c57e3df495063542cbb7ce9c2d260f921c98bcb9b2f48e105e7b16dc6099ab0bbbc1daea62e4c76037865cba25fafec3916bbb49ca21866837adb6c1edcc52b820375f359b8d35cb595bd09c26ceda99a01c0000000000000000000000000000000000000000000000000000000000b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006046a76120200000000000000000000000040a2accbd92bca938b02010e17a5b8929b49130d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004c000000000000000000000000000000000000000000000000000000000000003448d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000002fd00912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000009aab7a39e7e022666283454fbd0769f93d1e1d4e000000000000000000000000000000000000000000000cb49b44ba602d80000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000d3fb3ed59e5a7674003625241551a6ffa63d2c5000000000000000000000000000000000000000000000054b40b1f852bda0000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000000000000000000000000878678326eac900000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000e7eb925300075e49fc5caad5d408a50dd22f92d60000000000000000000000000000000000000000000007695a92c20d6fe0000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000bc22b72b0408f66316cc4ee562b858f612776f1400000000000000000000000000000000000000000000054b40b1f852bda0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010425a901b70e415dac6bb78079fc99542d64f9bd5c48823ca0e477d1520d9432254543fa9b814ae03f0f9b74455ee56369d84af999c8462836dd8f39c1b897492f1b3ea25052c6862fc7dfb50b4413a45c14192db85953ee526c022ed35fa43f09c41bb42349464e2e6b2065eda02b88ea80ddc6a2ea3e7125a482fc2ee926661f481c0f25744eb8f7eacf544d17ff8478154e6ab368f4ca0ba0e0f476d5357b28704b4d1263a4c32980cbb013ce893ca9532035e3d2f1c70b6e47e2af49bafda4e9261b023b000c217058732b3bdb2ca73012e2cac856bcd0c9d89336086787dbcaa1985c81f89812e7addd5d7dcd807e14e4bb986d24dbc6ad152796937882d626e6cf1b0000000000000000000000000000000000000000000000000000000000b6fd0bdb1432b2c77170933120079f436f3bb4fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003046a761202000000000000000000000000912ce59144191c1204e64559fe8253a0e49e65480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000008704ee9ab8622bbc25410c7d4717ed51f776c7f600000000000000000000000000000000000000000000a1fd44f903579c9400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001042f486e77e4441a2849f295d7615a237417cb9aa94b86cab0c506df9ff8bf18bb13b14a3eb00eb0be707251e21e63283345c951aebd5b9d4eda11ba80223bee861cbd125cbd373edfe7bde69d5382c83d731a587313c473eb8db1ef508f38fb73f5783f09bfa4fe2101c6aec6a22d680edc0e4c13c27dc3eb8ace6ac5a9ac415a781ba117d4d16c977474f442ae6ceb64e3256e31a3acdd9b5bbdc407595a66d521b24458ec59290deaa2fd5e5d68257e653f4ae9d13b22689e90626911324aa1c7631c80b42babdb8705106ac64f3229e18738926786cef4df39831148f330bb5fd78f6be889ba310c7f346d82a62b00cd0acc5a404731e0713a32839077746701a45b1c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', - txHash: - '0x85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f6', - blockNumber: 192289077, - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:18:32Z', - blockNumber: 192289077, - transactionHash: - '0x85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f6', - to: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', - transferId: - 'e85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f66', - tokenInfo: { - type: 'ERC20', - address: '0x912CE59144191C1204E64559FE8253a0e49E6548', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - trusted: false, - }, - from: '0xB6fd0BDb1432b2c77170933120079f436F3bB4fa', - }, - ], - txType: 'ETHEREUM_TRANSACTION', - from: '0xFb390aC2028B47031FA4561994fd3abc9FD60a7f', - }, - ]; + ]; - networkService.get.mockImplementation(({ url }) => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; - const getAllTransactions = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; + const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; - const getImitationTokenAddress = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; - const getLegitTokenAddress = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; - if (url === getChainUrl) { - return Promise.resolve({ data: chain, status: 200 }); - } - if (url === getAllTransactions) { - return Promise.resolve({ - data: pageBuilder().with('results', results).build(), - status: 200, - }); - } - if (url === getSafeUrl) { - return Promise.resolve({ data: safe, status: 200 }); - } - if (url === getImitationTokenAddress) { - return Promise.resolve({ - data: results[0].transfers[0].tokenInfo, - status: 200, - }); - } - if (url === getLegitTokenAddress) { - return Promise.resolve({ - data: results[1].transfers[0].tokenAddress, - status: 200, - }); - } - return Promise.reject(new Error(`Could not match ${url}`)); - }); + const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + networkService.get.mockImplementation(({ url }) => { + if (url === getChainUrl) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactionsUrl) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getImitationTokenAddressUrl) { + return Promise.resolve({ + data: results[0].transfers[0].tokenInfo, + status: 200, + }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: results[1].transfers[0].tokenInfo, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=false`, - ) - .expect(200) - .then(({ body }) => { - expect(body.results).toStrictEqual([ - { - timestamp: 1710927778000, - type: 'DATE_LABEL', - }, - { - conflictType: 'None', - transaction: { - executionInfo: null, - id: 'transfer_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', - safeAppInfo: null, + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=false`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { timestamp: 1710927778000, - txInfo: { - direction: 'OUTGOING', - humanDescription: null, - recipient: { - logoUri: null, - name: null, - value: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + type: 'DATE_LABEL', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + id: 'transfer_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: true, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + tokenAddress: + '0xcDB94376E0330B13F5Becaece169602cbB14399c', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: false, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', }, - richDecodedInfo: null, - sender: { - logoUri: null, - name: null, - value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 2, + confirmationsSubmitted: 2, + missingSigners: null, + nonce: 3, + type: 'MULTISIG', }, - transferInfo: { - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', - tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - tokenName: 'Arbitrum', - tokenSymbol: 'ARB', - trusted: false, - type: 'ERC20', - value: '40000000000000000000000', + id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + safeAppInfo: null, + timestamp: 1710927685000, + txInfo: { + direction: 'OUTGOING', + humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + recipient: { + logoUri: null, + name: null, + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + richDecodedInfo: { + fragments: [ + { + type: 'text', + value: 'Send', + }, + { + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + symbol: 'ARB', + type: 'tokenValue', + value: '40000', + }, + { + type: 'text', + value: 'to', + }, + { + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + ], + }, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: null, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + tokenAddress: + '0x912CE59144191C1204E64559FE8253a0e49E6548', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: null, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', }, - type: 'Transfer', + txStatus: 'SUCCESS', }, - txStatus: 'SUCCESS', + type: 'TRANSACTION', }, - type: 'TRANSACTION', + ]); + }); + }); + + it('should filter out outgoing ERC-20 transfers that imitate a direct predecessor', async () => { + // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 + const chain = chainBuilder().build(); + const safe = safeBuilder() + .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') + .with('owners', [ + '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + ]) + .build(); + + const results = [ + { + executionDate: '2024-03-20T09:42:58Z', + to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', + data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', + txHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + blockNumber: 192295013, + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:42:58Z', + blockNumber: 192295013, + transactionHash: + '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', + to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + transferId: + 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + tokenInfo: { + type: 'ERC20', + address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + trusted: false, + }, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + ], + txType: 'ETHEREUM_TRANSACTION', + from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', + }, + { + safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + to: '0x912CE59144191C1204E64559FE8253a0e49E6548', + value: '0', + data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', + operation: 0, + gasToken: '0x0000000000000000000000000000000000000000', + safeTxGas: 0, + baseGas: 0, + gasPrice: '0', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 3, + executionDate: '2024-03-20T09:41:25Z', + submissionDate: '2024-03-20T09:38:11.447366Z', + modified: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + safeTxHash: + '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + isExecuted: true, + isSuccessful: true, + ethGasPrice: '10946000', + maxFeePerGas: null, + maxPriorityFeePerGas: null, + gasUsed: 249105, + fee: '2726703330000', + origin: '{}', + dataDecoded: { + method: 'transfer', + parameters: [ + { + name: 'to', + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + { + name: 'value', + type: 'uint256', + value: '40000000000000000000000', + }, + ], }, - { - conflictType: 'None', - transaction: { - executionInfo: { - confirmationsRequired: 2, - confirmationsSubmitted: 2, - missingSigners: null, - nonce: 3, - type: 'MULTISIG', + confirmationsRequired: 2, + confirmations: [ + { + owner: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + submissionDate: '2024-03-20T09:38:11.479197Z', + transactionHash: null, + signature: + '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + signatureType: 'EOA', + }, + { + owner: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', + submissionDate: '2024-03-20T09:41:25Z', + transactionHash: null, + signature: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', + signatureType: 'APPROVED_HASH', + }, + ], + trusted: true, + signatures: + '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', + transfers: [ + { + type: 'ERC20_TRANSFER', + executionDate: '2024-03-20T09:41:25Z', + blockNumber: 192294646, + transactionHash: + '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', + to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + value: '40000000000000000000000', + tokenId: null, + tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', + transferId: + 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', + tokenInfo: { + type: 'ERC20', + address: '0x912CE59144191C1204E64559FE8253a0e49E6548', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + trusted: false, }, - id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', - safeAppInfo: null, + from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + ], + txType: 'MULTISIG_TRANSACTION', + }, + ]; + + const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; + const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; + const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; + const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + networkService.get.mockImplementation(({ url }) => { + if (url === getChainUrl) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactionsUrl) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getImitationTokenAddressUrl) { + return Promise.resolve({ + data: results[0].transfers[0].tokenInfo, + status: 200, + }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: results[1].transfers[0].tokenInfo, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=false&imitation=false`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { timestamp: 1710927685000, - txInfo: { - actionCount: null, - dataSize: '68', - humanDescription: - 'Send 40000000000000000000000 to 0xFd7e...A512', - isCancellation: false, - methodName: 'transfer', - richDecodedInfo: { - fragments: [ - { - type: 'text', - value: 'Send', - }, - { - logoUri: null, - symbol: null, - type: 'tokenValue', - value: '40000000000000000000000', - }, - { - type: 'text', - value: 'to', - }, - { - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - ], - }, - to: { - logoUri: null, - name: null, - value: '0x912CE59144191C1204E64559FE8253a0e49E6548', - }, - type: 'Custom', - value: '0', - }, - txStatus: 'SUCCESS', + type: 'DATE_LABEL', }, - type: 'TRANSACTION', - }, - { - conflictType: 'None', - transaction: { - executionInfo: null, - id: 'transfer_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_e85f22020966cb63356fedb6e12210474eb42052fa252cbf4d64182a2607169f66', - safeAppInfo: null, - timestamp: 1710926312000, - txInfo: { - direction: 'INCOMING', - humanDescription: null, - recipient: { - logoUri: null, - name: null, - value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - richDecodedInfo: null, - sender: { - logoUri: null, - name: null, - value: '0xB6fd0BDb1432b2c77170933120079f436F3bB4fa', + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 2, + confirmationsSubmitted: 2, + missingSigners: null, + nonce: 3, + type: 'MULTISIG', }, - transferInfo: { - decimals: null, - logoUri: null, - tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', - tokenName: null, - tokenSymbol: null, - trusted: null, - type: 'ERC20', - value: '40000000000000000000000', + id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + safeAppInfo: null, + timestamp: 1710927685000, + txInfo: { + direction: 'OUTGOING', + humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + recipient: { + logoUri: null, + name: null, + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + richDecodedInfo: { + fragments: [ + { + type: 'text', + value: 'Send', + }, + { + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + symbol: 'ARB', + type: 'tokenValue', + value: '40000', + }, + { + type: 'text', + value: 'to', + }, + { + type: 'address', + value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', + }, + ], + }, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: null, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + tokenAddress: + '0x912CE59144191C1204E64559FE8253a0e49E6548', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: null, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', }, - type: 'Transfer', + txStatus: 'SUCCESS', }, - txStatus: 'SUCCESS', + type: 'TRANSACTION', }, - type: 'TRANSACTION', - }, - ]); - }); + ]); + }); + }); }); }); }); diff --git a/src/routes/transactions/transactions.controller.ts b/src/routes/transactions/transactions.controller.ts index 0323e0e48c..c8a95d0a2a 100644 --- a/src/routes/transactions/transactions.controller.ts +++ b/src/routes/transactions/transactions.controller.ts @@ -233,6 +233,8 @@ export class TransactionsController { timezoneOffsetMs: number, @Query('trusted', new DefaultValuePipe(true), ParseBoolPipe) trusted: boolean, + @Query('imitation', new DefaultValuePipe(true), ParseBoolPipe) + imitation: boolean, ): Promise> { return this.transactionsService.getTransactionHistory({ chainId, @@ -241,6 +243,7 @@ export class TransactionsController { paginationData, timezoneOffsetMs, onlyTrusted: trusted, + showImitations: imitation, }); } diff --git a/src/routes/transactions/transactions.module.ts b/src/routes/transactions/transactions.module.ts index 30d8802c3c..d47031b25e 100644 --- a/src/routes/transactions/transactions.module.ts +++ b/src/routes/transactions/transactions.module.ts @@ -5,7 +5,6 @@ import { DataDecodedParamHelper } from '@/routes/transactions/mappers/common/dat import { Erc20TransferMapper } from '@/routes/transactions/mappers/common/erc20-transfer.mapper'; import { Erc721TransferMapper } from '@/routes/transactions/mappers/common/erc721-transfer.mapper'; import { HumanDescriptionMapper } from '@/routes/transactions/mappers/common/human-description.mapper'; -import { ImitationTransactionsHelper } from '@/routes/transactions/helpers/imitation-transactions.helper'; import { NativeCoinTransferMapper } from '@/routes/transactions/mappers/common/native-coin-transfer.mapper'; import { SafeAppInfoMapper } from '@/routes/transactions/mappers/common/safe-app-info.mapper'; import { SettingsChangeMapper } from '@/routes/transactions/mappers/common/settings-change.mapper'; @@ -26,6 +25,7 @@ import { TransactionsHistoryMapper } from '@/routes/transactions/mappers/transac import { TransferDetailsMapper } from '@/routes/transactions/mappers/transfers/transfer-details.mapper'; import { TransferInfoMapper } from '@/routes/transactions/mappers/transfers/transfer-info.mapper'; import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper'; +import { TransferImitationMapper } from '@/routes/transactions/mappers/transfers/transfer-imitation.mapper'; import { TransactionsController } from '@/routes/transactions/transactions.controller'; import { TransactionsService } from '@/routes/transactions/transactions.service'; import { SwapOrderMapperModule } from '@/routes/transactions/mappers/common/swap-order.mapper'; @@ -58,7 +58,6 @@ import { SwapOrderHelperModule } from '@/routes/transactions/helpers/swap-order. DataDecodedParamHelper, Erc20TransferMapper, Erc721TransferMapper, - ImitationTransactionsHelper, TransferMapper, ModuleTransactionDetailsMapper, ModuleTransactionMapper, @@ -79,6 +78,7 @@ import { SwapOrderHelperModule } from '@/routes/transactions/helpers/swap-order. TransactionsService, TransferDetailsMapper, TransferInfoMapper, + TransferImitationMapper, HumanDescriptionMapper, ], }) diff --git a/src/routes/transactions/transactions.service.ts b/src/routes/transactions/transactions.service.ts index 0270750bf3..7e7ab7c136 100644 --- a/src/routes/transactions/transactions.service.ts +++ b/src/routes/transactions/transactions.service.ts @@ -364,6 +364,7 @@ export class TransactionsService { paginationData: PaginationData; timezoneOffsetMs: number; onlyTrusted: boolean; + showImitations: boolean; }): Promise { const paginationDataAdjusted = this.getAdjustedPaginationForHistory( args.paginationData, @@ -395,6 +396,7 @@ export class TransactionsService { args.paginationData.offset, args.timezoneOffsetMs, args.onlyTrusted, + args.showImitations, ); return { From f0bc73811f9b3b374fb8b953faa98a05db2150f9 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 15 May 2024 12:11:57 +0200 Subject: [PATCH 55/65] Make `Chain['pricesProvider']` optional (#1544) Makes the `Chain['pricesProvider']` of the `ChainSchema` optional: - Make `ChainSchema['pricesProvider']` `optional` and add test coverage - Add relevant optional chaining to `CoingeckoApi` --- .../coingecko-api.service.spec.ts | 30 ++++++++++++++++ .../balances-api/coingecko-api.service.ts | 4 +-- .../schemas/__tests__/chain.schema.spec.ts | 9 +++++ .../chains/entities/schemas/chain.schema.ts | 3 +- .../balances/balances.controller.spec.ts | 10 ++++++ .../safes/safes.controller.overview.spec.ts | 36 +++++++++++++++++++ 6 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/datasources/balances-api/coingecko-api.service.spec.ts b/src/datasources/balances-api/coingecko-api.service.spec.ts index cd55dd72f2..a78e7c4da7 100644 --- a/src/datasources/balances-api/coingecko-api.service.spec.ts +++ b/src/datasources/balances-api/coingecko-api.service.spec.ts @@ -168,6 +168,7 @@ describe('CoingeckoAPI', () => { }); const expectedCacheDir = new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, '', ); @@ -175,6 +176,7 @@ describe('CoingeckoAPI', () => { { [tokenAddress]: { [lowerCaseFiatCode]: price } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ + // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { @@ -226,6 +228,7 @@ describe('CoingeckoAPI', () => { }); const expectedCacheDir = new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, '', ); @@ -233,6 +236,7 @@ describe('CoingeckoAPI', () => { { [tokenAddress]: { [lowerCaseFiatCode]: price } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ + // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { params: { @@ -354,6 +358,7 @@ describe('CoingeckoAPI', () => { { [thirdTokenAddress]: { [lowerCaseFiatCode]: thirdPrice } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ + // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { @@ -372,18 +377,21 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -391,6 +399,7 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.set).toHaveBeenCalledTimes(3); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -401,6 +410,7 @@ describe('CoingeckoAPI', () => { ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -411,6 +421,7 @@ describe('CoingeckoAPI', () => { ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -465,6 +476,7 @@ describe('CoingeckoAPI', () => { { [anotherTokenAddress]: { [lowerCaseFiatCode]: anotherPrice } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ + // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { @@ -484,12 +496,14 @@ describe('CoingeckoAPI', () => { // high-refresh-rate token price is cached with highRefreshRateTokensTtlSeconds expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -501,12 +515,14 @@ describe('CoingeckoAPI', () => { // another token price is cached with pricesCacheTtlSeconds expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -564,6 +580,7 @@ describe('CoingeckoAPI', () => { ), ); expect(mockNetworkService.get).toHaveBeenCalledWith({ + // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { @@ -578,18 +595,21 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -598,6 +618,7 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.set).toHaveBeenNthCalledWith( 1, new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -609,6 +630,7 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.set).toHaveBeenNthCalledWith( 2, new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -666,6 +688,7 @@ describe('CoingeckoAPI', () => { ), ); expect(mockNetworkService.get).toHaveBeenCalledWith({ + // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { @@ -680,18 +703,21 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -723,6 +749,7 @@ describe('CoingeckoAPI', () => { expect(mockCacheFirstDataSource.get).toHaveBeenCalledWith({ cacheDir: new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.nativeCoin}_native_coin_price_${lowerCaseFiatCode}`, '', ), @@ -732,6 +759,7 @@ describe('CoingeckoAPI', () => { 'x-cg-pro-api-key': coingeckoApiKey, }, params: { + // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: lowerCaseFiatCode, }, @@ -760,12 +788,14 @@ describe('CoingeckoAPI', () => { expect(mockCacheFirstDataSource.get).toHaveBeenCalledWith({ cacheDir: new CacheDir( + // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.nativeCoin}_native_coin_price_${lowerCaseFiatCode}`, '', ), url: `${coingeckoBaseUri}/simple/price`, networkRequest: { params: { + // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: lowerCaseFiatCode, }, diff --git a/src/datasources/balances-api/coingecko-api.service.ts b/src/datasources/balances-api/coingecko-api.service.ts index 77eac09345..c58b467540 100644 --- a/src/datasources/balances-api/coingecko-api.service.ts +++ b/src/datasources/balances-api/coingecko-api.service.ts @@ -116,7 +116,7 @@ export class CoingeckoApi implements IPricesApi { const lowerCaseFiatCode = args.fiatCode.toLowerCase(); // TODO: remove configurationService fallback when fully migrated. const nativeCoinId = - args.chain.pricesProvider.nativeCoin ?? + args.chain.pricesProvider?.nativeCoin ?? this.configurationService.getOrThrow( `balances.providers.safe.prices.chains.${args.chain.chainId}.nativeCoin`, ); @@ -174,7 +174,7 @@ export class CoingeckoApi implements IPricesApi { ); // TODO: remove configurationService fallback when fully migrated. const chainName = - args.chain.pricesProvider.chainName ?? + args.chain.pricesProvider?.chainName ?? this.configurationService.getOrThrow( `balances.providers.safe.prices.chains.${args.chain.chainId}.chainName`, ); diff --git a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts index 2db67a0cda..eeda718f77 100644 --- a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts +++ b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts @@ -363,6 +363,15 @@ describe('Chain schemas', () => { expect(result.success).toBe(true); }); + // TODO: remove when fully migrated. + it('should allow optional pricesProvider', () => { + const chain = chainBuilder().with('pricesProvider', undefined).build(); + + const result = ChainSchema.safeParse(chain); + + expect(result.success).toBe(true); + }); + it.each([['chainLogoUri' as const], ['ensRegistryAddress' as const]])( 'should allow undefined %s and default to null', (field) => { diff --git a/src/domain/chains/entities/schemas/chain.schema.ts b/src/domain/chains/entities/schemas/chain.schema.ts index c589b100c0..9855e2505e 100644 --- a/src/domain/chains/entities/schemas/chain.schema.ts +++ b/src/domain/chains/entities/schemas/chain.schema.ts @@ -72,7 +72,8 @@ export const ChainSchema = z.object({ publicRpcUri: RpcUriSchema, blockExplorerUriTemplate: BlockExplorerUriTemplateSchema, nativeCurrency: NativeCurrencySchema, - pricesProvider: PricesProviderSchema, + // TODO: remove optionality when fully migrated. + pricesProvider: PricesProviderSchema.optional(), transactionService: z.string().url(), vpcTransactionService: z.string().url(), theme: ThemeSchema, diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index 4470f352c3..c9a950fa75 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -94,6 +94,7 @@ describe('Balances Controller (Unit)', () => { .getOrThrow('balances.providers.safe.prices.apiKey'); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -116,6 +117,7 @@ describe('Balances Controller (Unit)', () => { data: nativeCoinPriceProviderResponse, status: 200, }); + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, @@ -195,6 +197,7 @@ describe('Balances Controller (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[2][0].url).toBe( + // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ @@ -213,6 +216,7 @@ describe('Balances Controller (Unit)', () => { expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': apiKey }, params: { + // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, @@ -245,6 +249,7 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, @@ -282,6 +287,7 @@ describe('Balances Controller (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -356,6 +362,7 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, @@ -405,6 +412,7 @@ describe('Balances Controller (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[2][0].url).toBe( + // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); }); @@ -456,6 +464,7 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.reject(); default: @@ -515,6 +524,7 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 577886a3fa..7446889cc0 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -111,6 +111,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -157,6 +158,7 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -217,6 +219,7 @@ describe('Safes Controller Overview (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[3][0].url).toBe( + // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ @@ -235,6 +238,7 @@ describe('Safes Controller Overview (Unit)', () => { expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { + // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, @@ -268,6 +272,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -326,6 +331,7 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -386,6 +392,7 @@ describe('Safes Controller Overview (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[3][0].url).toBe( + // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ @@ -404,6 +411,7 @@ describe('Safes Controller Overview (Unit)', () => { expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { + // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, @@ -477,9 +485,11 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain1.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, + // @ts-expect-error - TODO: remove after migration [chain2.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -536,12 +546,14 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -700,9 +712,11 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain1.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, + // @ts-expect-error - TODO: remove after migration [chain2.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -759,12 +773,14 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -881,6 +897,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -918,6 +935,7 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -977,6 +995,7 @@ describe('Safes Controller Overview (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[3][0].url).toBe( + // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ @@ -995,6 +1014,7 @@ describe('Safes Controller Overview (Unit)', () => { expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { + // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, @@ -1028,6 +1048,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -1066,6 +1087,7 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -1125,6 +1147,7 @@ describe('Safes Controller Overview (Unit)', () => { params: { trusted: true, exclude_spam: false }, }); expect(networkService.get.mock.calls[3][0].url).toBe( + // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ @@ -1143,6 +1166,7 @@ describe('Safes Controller Overview (Unit)', () => { expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { + // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, @@ -1218,9 +1242,11 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain1.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, + // @ts-expect-error - TODO: remove after migration [chain2.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -1283,12 +1309,14 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -1412,9 +1440,11 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain1.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, + // @ts-expect-error - TODO: remove after migration [chain2.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -1472,12 +1502,14 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -1612,9 +1644,11 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration [chain1.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, + // @ts-expect-error - TODO: remove after migration [chain2.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, @@ -1671,12 +1705,14 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } + // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, From d1eb863eb84815d1465fab1ecd29e739ba0db0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 15 May 2024 13:51:46 +0200 Subject: [PATCH 56/65] Add Campaigns retrieval to LockingApi datasource (#1543) Adds a Campaign entity and schema. Adds a new getCampaigns function to the LockingApi datasource. --- .../locking-api/locking-api.service.spec.ts | 111 ++++++++++++++++++ .../locking-api/locking-api.service.ts | 32 +++++ .../interfaces/locking-api.interface.ts | 8 ++ .../entities/__tests__/campaign.builder.ts | 13 ++ .../locking/entities/campaign.entity.ts | 13 ++ .../schemas/__tests__/campaign.schema.spec.ts | 74 ++++++++++++ 6 files changed, 251 insertions(+) create mode 100644 src/domain/locking/entities/__tests__/campaign.builder.ts create mode 100644 src/domain/locking/entities/campaign.entity.ts create mode 100644 src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts diff --git a/src/datasources/locking-api/locking-api.service.spec.ts b/src/datasources/locking-api/locking-api.service.spec.ts index 2c26bed3fd..9c82ae8a51 100644 --- a/src/datasources/locking-api/locking-api.service.spec.ts +++ b/src/datasources/locking-api/locking-api.service.spec.ts @@ -13,6 +13,7 @@ import { } from '@/domain/locking/entities/__tests__/locking-event.builder'; import { getAddress } from 'viem'; import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder'; +import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder'; const networkService = { get: jest.fn(), @@ -43,6 +44,116 @@ describe('LockingApi', () => { ); }); + describe('getCampaignById', () => { + it('should get a campaign by campaignId', async () => { + const campaign = campaignBuilder().build(); + + mockNetworkService.get.mockResolvedValueOnce({ + data: campaign, + status: 200, + }); + + const result = await service.getCampaignById(campaign.campaignId); + + expect(result).toEqual(campaign); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`, + }); + }); + + it('should forward error', async () => { + const status = faker.internet.httpStatusCode({ types: ['serverError'] }); + const campaign = campaignBuilder().build(); + const error = new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`), + { + status, + } as Response, + { + message: 'Unexpected error', + }, + ); + mockNetworkService.get.mockRejectedValueOnce(error); + + await expect( + service.getCampaignById(campaign.campaignId), + ).rejects.toThrow(new DataSourceError('Unexpected error', status)); + + expect(mockNetworkService.get).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCampaigns', () => { + it('should get campaigns', async () => { + const campaignsPage = pageBuilder() + .with('results', [campaignBuilder().build(), campaignBuilder().build()]) + .build(); + + mockNetworkService.get.mockResolvedValueOnce({ + data: campaignsPage, + status: 200, + }); + + const result = await service.getCampaigns({}); + + expect(result).toEqual(campaignsPage); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns`, + networkRequest: { + params: { + limit: undefined, + offset: undefined, + }, + }, + }); + }); + + it('should forward pagination queries', async () => { + const limit = faker.number.int(); + const offset = faker.number.int(); + const campaignsPage = pageBuilder() + .with('results', [campaignBuilder().build(), campaignBuilder().build()]) + .build(); + + mockNetworkService.get.mockResolvedValueOnce({ + data: campaignsPage, + status: 200, + }); + + await service.getCampaigns({ limit, offset }); + + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns`, + networkRequest: { + params: { + limit, + offset, + }, + }, + }); + }); + + it('should forward error', async () => { + const status = faker.internet.httpStatusCode({ types: ['serverError'] }); + const error = new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v1/campaigns`), + { + status, + } as Response, + { + message: 'Unexpected error', + }, + ); + mockNetworkService.get.mockRejectedValueOnce(error); + + await expect(service.getCampaigns({})).rejects.toThrow( + new DataSourceError('Unexpected error', status), + ); + + expect(mockNetworkService.get).toHaveBeenCalledTimes(1); + }); + }); + describe('getRank', () => { it('should get rank', async () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); diff --git a/src/datasources/locking-api/locking-api.service.ts b/src/datasources/locking-api/locking-api.service.ts index 94b383f553..8ce27f5a7c 100644 --- a/src/datasources/locking-api/locking-api.service.ts +++ b/src/datasources/locking-api/locking-api.service.ts @@ -6,6 +6,7 @@ import { } from '@/datasources/network/network.service.interface'; import { Page } from '@/domain/entities/page.entity'; import { ILockingApi } from '@/domain/interfaces/locking-api.interface'; +import { Campaign } from '@/domain/locking/entities/campaign.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; import { Inject } from '@nestjs/common'; @@ -24,6 +25,37 @@ export class LockingApi implements ILockingApi { this.configurationService.getOrThrow('locking.baseUri'); } + async getCampaignById(campaignId: string): Promise { + try { + const url = `${this.baseUri}/api/v1/campaigns/${campaignId}`; + const { data } = await this.networkService.get({ url }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + + async getCampaigns(args: { + limit?: number; + offset?: number; + }): Promise> { + try { + const url = `${this.baseUri}/api/v1/campaigns`; + const { data } = await this.networkService.get>({ + url, + networkRequest: { + params: { + limit: args.limit, + offset: args.offset, + }, + }, + }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + async getRank(safeAddress: `0x${string}`): Promise { try { const url = `${this.baseUri}/api/v1/leaderboard/${safeAddress}`; diff --git a/src/domain/interfaces/locking-api.interface.ts b/src/domain/interfaces/locking-api.interface.ts index d3c136524b..f790d2cf2c 100644 --- a/src/domain/interfaces/locking-api.interface.ts +++ b/src/domain/interfaces/locking-api.interface.ts @@ -1,10 +1,18 @@ import { Page } from '@/domain/entities/page.entity'; +import { Campaign } from '@/domain/locking/entities/campaign.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; export const ILockingApi = Symbol('ILockingApi'); export interface ILockingApi { + getCampaignById(campaignId: string): Promise; + + getCampaigns(args: { + limit?: number; + offset?: number; + }): Promise>; + getRank(safeAddress: `0x${string}`): Promise; getLeaderboard(args: { diff --git a/src/domain/locking/entities/__tests__/campaign.builder.ts b/src/domain/locking/entities/__tests__/campaign.builder.ts new file mode 100644 index 0000000000..fdaf65ca95 --- /dev/null +++ b/src/domain/locking/entities/__tests__/campaign.builder.ts @@ -0,0 +1,13 @@ +import { IBuilder, Builder } from '@/__tests__/builder'; +import { Campaign } from '@/domain/locking/entities/campaign.entity'; +import { faker } from '@faker-js/faker'; + +export function campaignBuilder(): IBuilder { + return new Builder() + .with('campaignId', faker.string.uuid()) + .with('name', faker.word.words()) + .with('description', faker.lorem.sentence()) + .with('periodStart', faker.date.recent()) + .with('periodEnd', faker.date.future()) + .with('lastUpdated', faker.date.recent()); +} diff --git a/src/domain/locking/entities/campaign.entity.ts b/src/domain/locking/entities/campaign.entity.ts new file mode 100644 index 0000000000..00f35c85fd --- /dev/null +++ b/src/domain/locking/entities/campaign.entity.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export type Campaign = z.infer; + +export const CampaignSchema = z.object({ + campaignId: z.string(), + name: z.string(), + description: z.string(), + periodStart: z.coerce.date(), + periodEnd: z.coerce.date(), + lastUpdated: z.coerce.date(), + // TODO: include 'activities' field once the structure is defined. +}); diff --git a/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts b/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts new file mode 100644 index 0000000000..d0412dacfd --- /dev/null +++ b/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts @@ -0,0 +1,74 @@ +import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder'; +import { CampaignSchema } from '@/domain/locking/entities/campaign.entity'; +import { ZodError } from 'zod'; + +describe('CampaignSchema', () => { + it('should validate a valid campaign', () => { + const campaign = campaignBuilder().build(); + + const result = CampaignSchema.safeParse(campaign); + + expect(result.success).toBe(true); + }); + + it.each([ + 'periodStart' as const, + 'periodEnd' as const, + 'lastUpdated' as const, + ])(`should coerce %s to a date`, (field) => { + const campaign = campaignBuilder().build(); + + const result = CampaignSchema.safeParse(campaign); + + expect(result.success && result.data[field]).toStrictEqual( + new Date(campaign[field]), + ); + }); + + it('should not validate an invalid campaign', () => { + const campaign = { invalid: 'campaign' }; + + const result = CampaignSchema.safeParse(campaign); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['campaignId'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['name'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['description'], + message: 'Required', + }, + { + code: 'invalid_date', + path: ['periodStart'], + message: 'Invalid date', + }, + { + code: 'invalid_date', + path: ['periodEnd'], + message: 'Invalid date', + }, + { + code: 'invalid_date', + path: ['lastUpdated'], + message: 'Invalid date', + }, + ]), + ); + }); +}); From 2c0d9354f7333cf7e6074a5b5d38573642789fa4 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 15 May 2024 16:05:09 +0200 Subject: [PATCH 57/65] Migrate to `multisig-transactions` deletion endpoint (#1549) Migrates from the deprecated `DELETE` `/transactions/` endpoint to the `/multisig-transactions/` equivalent: - Change `TransactionApi['deleteTransaction']` to use `/api/v1/multisig-transactions/${safeTxHash}` of the Transaction Service. --- .../transaction-api/transaction-api.service.spec.ts | 4 ++-- src/datasources/transaction-api/transaction-api.service.ts | 2 +- .../delete-transaction.transactions.controller.spec.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index 1f7b97ac3e..7b889e5666 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -1487,7 +1487,7 @@ describe('TransactionApi', () => { it('should delete a transaction', async () => { const safeTxHash = faker.string.hexadecimal(); const signature = faker.string.hexadecimal(); - const deleteTransactionUrl = `${baseUrl}/api/v1/transactions/${safeTxHash}`; + const deleteTransactionUrl = `${baseUrl}/api/v1/multisig-transactions/${safeTxHash}`; networkService.delete.mockResolvedValueOnce({ status: 200, data: {}, @@ -1514,7 +1514,7 @@ describe('TransactionApi', () => { ])(`should forward a %s error`, async (_, error) => { const safeTxHash = faker.string.hexadecimal(); const signature = faker.string.hexadecimal(); - const deleteTransactionUrl = `${baseUrl}/api/v1/transactions/${safeTxHash}`; + const deleteTransactionUrl = `${baseUrl}/api/v1/multisig-transactions/${safeTxHash}`; const statusCode = faker.internet.httpStatusCode({ types: ['clientError', 'serverError'], }); diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index c5ca71d656..21767eb2c1 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -619,7 +619,7 @@ export class TransactionApi implements ITransactionApi { signature: string; }): Promise { try { - const url = `${this.baseUrl}/api/v1/transactions/${args.safeTxHash}`; + const url = `${this.baseUrl}/api/v1/multisig-transactions/${args.safeTxHash}`; await this.networkService.delete({ url, data: { diff --git a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts index 85b808aa7e..cf9e0a075f 100644 --- a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts @@ -111,7 +111,7 @@ describe('Delete Transaction - Transactions Controller (Unit', () => { networkService.delete.mockImplementation(({ url }) => { if ( url === - `${chain.transactionService}/api/v1/transactions/${tx.safeTxHash}` + `${chain.transactionService}/api/v1/multisig-transactions/${tx.safeTxHash}` ) { return Promise.resolve({ data: {}, status: 204 }); } @@ -146,7 +146,7 @@ describe('Delete Transaction - Transactions Controller (Unit', () => { networkService.delete.mockImplementation(({ url }) => { if ( url === - `${chain.transactionService}/api/v1/transactions/${tx.safeTxHash}` + `${chain.transactionService}/api/v1/multisig-transactions/${tx.safeTxHash}` ) { return Promise.resolve({ data: {}, status: 204 }); } @@ -195,7 +195,7 @@ describe('Delete Transaction - Transactions Controller (Unit', () => { networkService.delete.mockImplementation(({ url }) => { if ( url === - `${chain.transactionService}/api/v1/transactions/${tx.safeTxHash}` + `${chain.transactionService}/api/v1/multisig-transactions/${tx.safeTxHash}` ) { return Promise.reject( new NetworkResponseError( From 355babaa4334af86de77907b92135c201d2ff73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 15 May 2024 16:10:27 +0200 Subject: [PATCH 58/65] Add ActivityMetadata to Campaign entity (#1546) Adds ActivityMetadata entity and validation schema. Adds activities array to the Campaign entity. --- .../__tests__/activity-metadata.builder.ts | 11 +++ .../entities/activity-metadata.entity.ts | 11 +++ .../locking/entities/campaign.entity.ts | 3 +- .../activity-metadata.schema.spec.ts | 71 +++++++++++++++++++ .../entities/schemas/campaign.schema.ts | 12 ++++ 5 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/domain/locking/entities/__tests__/activity-metadata.builder.ts create mode 100644 src/domain/locking/entities/activity-metadata.entity.ts create mode 100644 src/domain/locking/entities/schemas/__tests__/activity-metadata.schema.spec.ts create mode 100644 src/domain/locking/entities/schemas/campaign.schema.ts diff --git a/src/domain/locking/entities/__tests__/activity-metadata.builder.ts b/src/domain/locking/entities/__tests__/activity-metadata.builder.ts new file mode 100644 index 0000000000..1b500f0e4d --- /dev/null +++ b/src/domain/locking/entities/__tests__/activity-metadata.builder.ts @@ -0,0 +1,11 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { ActivityMetadata } from '@/domain/locking/entities/activity-metadata.entity'; +import { faker } from '@faker-js/faker'; + +export function activityMetadataBuilder(): IBuilder { + return new Builder() + .with('campaignId', faker.string.uuid()) + .with('name', faker.word.words()) + .with('description', faker.lorem.sentence()) + .with('maxPoints', faker.string.numeric()); +} diff --git a/src/domain/locking/entities/activity-metadata.entity.ts b/src/domain/locking/entities/activity-metadata.entity.ts new file mode 100644 index 0000000000..84f80be0ef --- /dev/null +++ b/src/domain/locking/entities/activity-metadata.entity.ts @@ -0,0 +1,11 @@ +import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; +import { z } from 'zod'; + +export type ActivityMetadata = z.infer; + +export const ActivityMetadataSchema = z.object({ + campaignId: z.string(), + name: z.string(), + description: z.string(), + maxPoints: NumericStringSchema, +}); diff --git a/src/domain/locking/entities/campaign.entity.ts b/src/domain/locking/entities/campaign.entity.ts index 00f35c85fd..4fdd0b8963 100644 --- a/src/domain/locking/entities/campaign.entity.ts +++ b/src/domain/locking/entities/campaign.entity.ts @@ -1,3 +1,4 @@ +import { ActivityMetadataSchema } from '@/domain/locking/entities/activity-metadata.entity'; import { z } from 'zod'; export type Campaign = z.infer; @@ -9,5 +10,5 @@ export const CampaignSchema = z.object({ periodStart: z.coerce.date(), periodEnd: z.coerce.date(), lastUpdated: z.coerce.date(), - // TODO: include 'activities' field once the structure is defined. + activities: z.array(ActivityMetadataSchema).nullish().default(null), }); diff --git a/src/domain/locking/entities/schemas/__tests__/activity-metadata.schema.spec.ts b/src/domain/locking/entities/schemas/__tests__/activity-metadata.schema.spec.ts new file mode 100644 index 0000000000..0b1e3272e7 --- /dev/null +++ b/src/domain/locking/entities/schemas/__tests__/activity-metadata.schema.spec.ts @@ -0,0 +1,71 @@ +import { activityMetadataBuilder } from '@/domain/locking/entities/__tests__/activity-metadata.builder'; +import { ActivityMetadataSchema } from '@/domain/locking/entities/activity-metadata.entity'; +import { faker } from '@faker-js/faker'; +import { ZodError } from 'zod'; + +describe('ActivityMetadataSchema', () => { + it('should validate a valid activity metadata', () => { + const activityMetadata = activityMetadataBuilder().build(); + + const result = ActivityMetadataSchema.safeParse(activityMetadata); + + expect(result.success).toBe(true); + }); + + it('should not allow a non-numeric string for maxPoints', () => { + const activityMetadata = activityMetadataBuilder() + .with('maxPoints', faker.string.alpha()) + .build(); + + const result = ActivityMetadataSchema.safeParse(activityMetadata); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'custom', + message: 'Invalid base-10 numeric string', + path: ['maxPoints'], + }, + ]), + ); + }); + + it('should not validate an invalid activity metadata', () => { + const activityMetadata = { invalid: 'activity metadata' }; + + const result = ActivityMetadataSchema.safeParse(activityMetadata); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['campaignId'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['name'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['description'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['maxPoints'], + message: 'Required', + }, + ]), + ); + }); +}); diff --git a/src/domain/locking/entities/schemas/campaign.schema.ts b/src/domain/locking/entities/schemas/campaign.schema.ts new file mode 100644 index 0000000000..dc17ac7f99 --- /dev/null +++ b/src/domain/locking/entities/schemas/campaign.schema.ts @@ -0,0 +1,12 @@ +import { ActivityMetadataSchema } from '@/domain/locking/entities/activity-metadata.entity'; +import { z } from 'zod'; + +export const CampaignSchema = z.object({ + campaignId: z.string(), + name: z.string(), + description: z.string(), + periodStart: z.coerce.date(), + periodEnd: z.coerce.date(), + lastUpdated: z.coerce.date(), + activities: z.array(ActivityMetadataSchema).nullish().default(null), +}); From 0e72cfeb04b77a581561c49b9d1c3fc588354a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 15 May 2024 17:11:40 +0200 Subject: [PATCH 59/65] Add LockingApi.getLeaderBoardV2 (#1550) Adds LockingApi.getLeaderBoardV2 datasource function. Adds Holder entity. --- .../locking-api/locking-api.service.spec.ts | 73 ++++++++++++++++++ .../locking-api/locking-api.service.ts | 23 ++++++ .../entities/__tests__/holder.builder.ts | 13 ++++ src/domain/locking/entities/holder.entity.ts | 16 ++++ .../schemas/__tests__/holder.schema.spec.ts | 76 +++++++++++++++++++ 5 files changed, 201 insertions(+) create mode 100644 src/domain/locking/entities/__tests__/holder.builder.ts create mode 100644 src/domain/locking/entities/holder.entity.ts create mode 100644 src/domain/locking/entities/schemas/__tests__/holder.schema.spec.ts diff --git a/src/datasources/locking-api/locking-api.service.spec.ts b/src/datasources/locking-api/locking-api.service.spec.ts index 9c82ae8a51..dc00ca2ab5 100644 --- a/src/datasources/locking-api/locking-api.service.spec.ts +++ b/src/datasources/locking-api/locking-api.service.spec.ts @@ -14,6 +14,7 @@ import { import { getAddress } from 'viem'; import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder'; import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder'; +import { holderBuilder } from '@/domain/locking/entities/__tests__/holder.builder'; const networkService = { get: jest.fn(), @@ -264,6 +265,78 @@ describe('LockingApi', () => { }); }); + describe('getLeaderboardV2', () => { + it('should get leaderboard v2', async () => { + const campaignId = faker.string.uuid(); + const leaderboardV2Page = pageBuilder() + .with('results', [holderBuilder().build(), holderBuilder().build()]) + .build(); + mockNetworkService.get.mockResolvedValueOnce({ + data: leaderboardV2Page, + status: 200, + }); + + const result = await service.getLeaderboardV2({ campaignId }); + + expect(result).toEqual(leaderboardV2Page); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v2/leaderboard/${campaignId}`, + networkRequest: { + params: { + limit: undefined, + offset: undefined, + }, + }, + }); + }); + + it('should forward pagination queries', async () => { + const limit = faker.number.int(); + const offset = faker.number.int(); + const campaignId = faker.string.uuid(); + const leaderboardV2Page = pageBuilder() + .with('results', [holderBuilder().build(), holderBuilder().build()]) + .build(); + mockNetworkService.get.mockResolvedValueOnce({ + data: leaderboardV2Page, + status: 200, + }); + + await service.getLeaderboardV2({ campaignId, limit, offset }); + + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v2/leaderboard/${campaignId}`, + networkRequest: { + params: { + limit, + offset, + }, + }, + }); + }); + + it('should forward error', async () => { + const status = faker.internet.httpStatusCode({ types: ['serverError'] }); + const campaignId = faker.string.uuid(); + const error = new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v2/leaderboard/${campaignId}`), + { + status, + } as Response, + { + message: 'Unexpected error', + }, + ); + mockNetworkService.get.mockRejectedValueOnce(error); + + await expect(service.getLeaderboardV2({ campaignId })).rejects.toThrow( + new DataSourceError('Unexpected error', status), + ); + + expect(mockNetworkService.get).toHaveBeenCalledTimes(1); + }); + }); + describe('getLockingHistory', () => { it('should get locking history', async () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); diff --git a/src/datasources/locking-api/locking-api.service.ts b/src/datasources/locking-api/locking-api.service.ts index 8ce27f5a7c..f6d21d0c75 100644 --- a/src/datasources/locking-api/locking-api.service.ts +++ b/src/datasources/locking-api/locking-api.service.ts @@ -7,6 +7,7 @@ import { import { Page } from '@/domain/entities/page.entity'; import { ILockingApi } from '@/domain/interfaces/locking-api.interface'; import { Campaign } from '@/domain/locking/entities/campaign.entity'; +import { Holder } from '@/domain/locking/entities/holder.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; import { Inject } from '@nestjs/common'; @@ -87,6 +88,28 @@ export class LockingApi implements ILockingApi { } } + async getLeaderboardV2(args: { + campaignId: string; + limit?: number; + offset?: number; + }): Promise> { + try { + const url = `${this.baseUri}/api/v2/leaderboard/${args.campaignId}`; + const { data } = await this.networkService.get>({ + url, + networkRequest: { + params: { + limit: args.limit, + offset: args.offset, + }, + }, + }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + async getLockingHistory(args: { safeAddress: `0x${string}`; limit?: number; diff --git a/src/domain/locking/entities/__tests__/holder.builder.ts b/src/domain/locking/entities/__tests__/holder.builder.ts new file mode 100644 index 0000000000..c991559942 --- /dev/null +++ b/src/domain/locking/entities/__tests__/holder.builder.ts @@ -0,0 +1,13 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { Holder } from '@/domain/locking/entities/holder.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +export function holderBuilder(): IBuilder { + return new Builder() + .with('holder', getAddress(faker.finance.ethereumAddress())) + .with('position', faker.number.int()) + .with('boost', faker.string.numeric()) + .with('points', faker.string.numeric()) + .with('boostedPoints', faker.string.numeric()); +} diff --git a/src/domain/locking/entities/holder.entity.ts b/src/domain/locking/entities/holder.entity.ts new file mode 100644 index 0000000000..a34084b745 --- /dev/null +++ b/src/domain/locking/entities/holder.entity.ts @@ -0,0 +1,16 @@ +import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; +import { z } from 'zod'; + +export const HolderSchema = z.object({ + holder: AddressSchema, + position: z.number(), + boost: NumericStringSchema, + points: NumericStringSchema, + boostedPoints: NumericStringSchema, +}); + +export const HolderPageSchema = buildPageSchema(HolderSchema); + +export type Holder = z.infer; diff --git a/src/domain/locking/entities/schemas/__tests__/holder.schema.spec.ts b/src/domain/locking/entities/schemas/__tests__/holder.schema.spec.ts new file mode 100644 index 0000000000..3364e1e2e1 --- /dev/null +++ b/src/domain/locking/entities/schemas/__tests__/holder.schema.spec.ts @@ -0,0 +1,76 @@ +import { holderBuilder } from '@/domain/locking/entities/__tests__/holder.builder'; +import { HolderSchema } from '@/domain/locking/entities/holder.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { ZodError } from 'zod'; + +describe('HolderSchema', () => { + it('should validate a valid holder', () => { + const holder = holderBuilder().build(); + + const result = HolderSchema.safeParse(holder); + + expect(result.success).toBe(true); + }); + + it('should checksum the holder address', () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase() as `0x${string}`; + const holder = holderBuilder() + .with('holder', nonChecksummedAddress) + .build(); + + const result = HolderSchema.safeParse(holder); + + expect(result.success && result.data.holder).toBe( + getAddress(nonChecksummedAddress), + ); + }); + + it('should not validate an invalid holder', () => { + const holder = { invalid: 'holder' }; + + const result = HolderSchema.safeParse(holder); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['holder'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'number', + received: 'undefined', + path: ['position'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['boost'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['points'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['boostedPoints'], + message: 'Required', + }, + ]), + ); + }); +}); From e90c46fdbdb1e3c242ceebaceb2883e5c43d3b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 16 May 2024 11:01:07 +0200 Subject: [PATCH 60/65] Add campaigns routes (#1551) Adds the following endpoints to the service: GET /api/v1/locking/campaigns GET /api/v1/locking/campaigns/:campaignId --- .../entities/__tests__/campaign.builder.ts | 9 +- .../locking/entities/campaign.entity.ts | 3 + .../locking/locking.repository.interface.ts | 8 + src/domain/locking/locking.repository.ts | 16 ++ .../entities/activity-metadata.entity.ts | 13 ++ .../locking/entities/campaign.entity.ts | 20 +++ .../locking/entities/campaign.page.entity.ts | 8 + src/routes/locking/locking.controller.spec.ts | 161 ++++++++++++++++++ src/routes/locking/locking.controller.ts | 24 +++ src/routes/locking/locking.service.ts | 27 +++ 10 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 src/routes/locking/entities/activity-metadata.entity.ts create mode 100644 src/routes/locking/entities/campaign.entity.ts create mode 100644 src/routes/locking/entities/campaign.page.entity.ts diff --git a/src/domain/locking/entities/__tests__/campaign.builder.ts b/src/domain/locking/entities/__tests__/campaign.builder.ts index fdaf65ca95..55386abc31 100644 --- a/src/domain/locking/entities/__tests__/campaign.builder.ts +++ b/src/domain/locking/entities/__tests__/campaign.builder.ts @@ -1,4 +1,5 @@ import { IBuilder, Builder } from '@/__tests__/builder'; +import { activityMetadataBuilder } from '@/domain/locking/entities/__tests__/activity-metadata.builder'; import { Campaign } from '@/domain/locking/entities/campaign.entity'; import { faker } from '@faker-js/faker'; @@ -9,5 +10,11 @@ export function campaignBuilder(): IBuilder { .with('description', faker.lorem.sentence()) .with('periodStart', faker.date.recent()) .with('periodEnd', faker.date.future()) - .with('lastUpdated', faker.date.recent()); + .with('lastUpdated', faker.date.recent()) + .with( + 'activities', + Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () => + activityMetadataBuilder().build(), + ), + ); } diff --git a/src/domain/locking/entities/campaign.entity.ts b/src/domain/locking/entities/campaign.entity.ts index 4fdd0b8963..70b2d6491f 100644 --- a/src/domain/locking/entities/campaign.entity.ts +++ b/src/domain/locking/entities/campaign.entity.ts @@ -1,3 +1,4 @@ +import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory'; import { ActivityMetadataSchema } from '@/domain/locking/entities/activity-metadata.entity'; import { z } from 'zod'; @@ -12,3 +13,5 @@ export const CampaignSchema = z.object({ lastUpdated: z.coerce.date(), activities: z.array(ActivityMetadataSchema).nullish().default(null), }); + +export const CampaignPageSchema = buildPageSchema(CampaignSchema); diff --git a/src/domain/locking/locking.repository.interface.ts b/src/domain/locking/locking.repository.interface.ts index 5a9d22e94c..88d1613a16 100644 --- a/src/domain/locking/locking.repository.interface.ts +++ b/src/domain/locking/locking.repository.interface.ts @@ -1,10 +1,18 @@ import { Page } from '@/domain/entities/page.entity'; +import { Campaign } from '@/domain/locking/entities/campaign.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; export const ILockingRepository = Symbol('ILockingRepository'); export interface ILockingRepository { + getCampaignById(campaignId: string): Promise; + + getCampaigns(args: { + limit?: number; + offset?: number; + }): Promise>; + getRank(safeAddress: `0x${string}`): Promise; getLeaderboard(args: { diff --git a/src/domain/locking/locking.repository.ts b/src/domain/locking/locking.repository.ts index a7520d3571..67531b3c49 100644 --- a/src/domain/locking/locking.repository.ts +++ b/src/domain/locking/locking.repository.ts @@ -1,5 +1,9 @@ import { Page } from '@/domain/entities/page.entity'; import { ILockingApi } from '@/domain/interfaces/locking-api.interface'; +import { + Campaign, + CampaignPageSchema, +} from '@/domain/locking/entities/campaign.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; import { LockingEventPageSchema } from '@/domain/locking/entities/schemas/locking-event.schema'; @@ -17,6 +21,18 @@ export class LockingRepository implements ILockingRepository { private readonly lockingApi: ILockingApi, ) {} + async getCampaignById(campaignId: string): Promise { + return this.lockingApi.getCampaignById(campaignId); + } + + async getCampaigns(args: { + limit?: number | undefined; + offset?: number | undefined; + }): Promise> { + const page = await this.lockingApi.getCampaigns(args); + return CampaignPageSchema.parse(page); + } + async getRank(safeAddress: `0x${string}`): Promise { const rank = await this.lockingApi.getRank(safeAddress); return RankSchema.parse(rank); diff --git a/src/routes/locking/entities/activity-metadata.entity.ts b/src/routes/locking/entities/activity-metadata.entity.ts new file mode 100644 index 0000000000..cbce0edacf --- /dev/null +++ b/src/routes/locking/entities/activity-metadata.entity.ts @@ -0,0 +1,13 @@ +import { ActivityMetadata as DomainActivityMetadata } from '@/domain/locking/entities/activity-metadata.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ActivityMetadata implements DomainActivityMetadata { + @ApiProperty() + campaignId!: string; + @ApiProperty() + name!: string; + @ApiProperty() + description!: string; + @ApiProperty() + maxPoints!: string; +} diff --git a/src/routes/locking/entities/campaign.entity.ts b/src/routes/locking/entities/campaign.entity.ts new file mode 100644 index 0000000000..c9b323b32e --- /dev/null +++ b/src/routes/locking/entities/campaign.entity.ts @@ -0,0 +1,20 @@ +import { Campaign as DomainCampaign } from '@/domain/locking/entities/campaign.entity'; +import { ActivityMetadata } from '@/routes/locking/entities/activity-metadata.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class Campaign implements DomainCampaign { + @ApiProperty() + campaignId!: string; + @ApiProperty() + name!: string; + @ApiProperty() + description!: string; + @ApiProperty({ type: String }) + periodStart!: Date; + @ApiProperty({ type: String }) + periodEnd!: Date; + @ApiProperty({ type: String }) + lastUpdated!: Date; + @ApiProperty({ type: [ActivityMetadata] }) + activities!: ActivityMetadata[] | null; +} diff --git a/src/routes/locking/entities/campaign.page.entity.ts b/src/routes/locking/entities/campaign.page.entity.ts new file mode 100644 index 0000000000..efa8cf9b50 --- /dev/null +++ b/src/routes/locking/entities/campaign.page.entity.ts @@ -0,0 +1,8 @@ +import { Page } from '@/routes/common/entities/page.entity'; +import { Campaign } from '@/routes/locking/entities/campaign.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CampaignPage extends Page { + @ApiProperty({ type: [Campaign] }) + results!: Array; +} diff --git a/src/routes/locking/locking.controller.spec.ts b/src/routes/locking/locking.controller.spec.ts index 4ee62d6c6d..3a04077634 100644 --- a/src/routes/locking/locking.controller.spec.ts +++ b/src/routes/locking/locking.controller.spec.ts @@ -31,6 +31,8 @@ import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder'; +import { Campaign } from '@/domain/locking/entities/campaign.entity'; describe('Locking (Unit)', () => { let app: INestApplication; @@ -67,6 +69,165 @@ describe('Locking (Unit)', () => { await app.close(); }); + describe('GET campaign', () => { + it('should get the campaign', async () => { + const campaign = campaignBuilder().build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`: + return Promise.resolve({ data: campaign, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/locking/campaigns/${campaign.campaignId}`) + .expect(200) + .expect({ + ...campaign, + periodStart: campaign.periodStart.toISOString(), + periodEnd: campaign.periodEnd.toISOString(), + lastUpdated: campaign.lastUpdated.toISOString(), + }); + }); + + it('should get the list of campaigns', async () => { + const campaignsPage = pageBuilder() + .with('results', [campaignBuilder().build()]) + .with('count', 1) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns`: + return Promise.resolve({ data: campaignsPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/locking/campaigns`) + .expect(200) + .expect({ + count: 1, + next: null, + previous: null, + results: campaignsPage.results.map((campaign) => ({ + ...campaign, + periodStart: campaign.periodStart.toISOString(), + periodEnd: campaign.periodEnd.toISOString(), + lastUpdated: campaign.lastUpdated.toISOString(), + })), + }); + }); + + it('should validate the list of campaigns', async () => { + const invalidCampaigns = [{ invalid: 'campaign' }]; + const campaignsPage = pageBuilder() + .with('results', invalidCampaigns) + .with('count', 1) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns`: + return Promise.resolve({ data: campaignsPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/locking/campaigns`) + .expect(500) + .expect({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('should forward the pagination parameters', async () => { + const limit = faker.number.int({ min: 1, max: 10 }); + const offset = faker.number.int({ min: 1, max: 10 }); + const campaignsPage = pageBuilder() + .with('results', [campaignBuilder().build()]) + .with('count', 1) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns`: + return Promise.resolve({ data: campaignsPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/locking/campaigns?cursor=limit%3D${limit}%26offset%3D${offset}`, + ) + .expect(200) + .expect({ + count: 1, + next: null, + previous: null, + results: campaignsPage.results.map((campaign) => ({ + ...campaign, + periodStart: campaign.periodStart.toISOString(), + periodEnd: campaign.periodEnd.toISOString(), + lastUpdated: campaign.lastUpdated.toISOString(), + })), + }); + + expect(networkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns`, + networkRequest: { + params: { + limit, + offset, + }, + }, + }); + }); + + it('should forward errors from the service', async () => { + const statusCode = faker.internet.httpStatusCode({ + types: ['clientError', 'serverError'], + }); + const errorMessage = faker.word.words(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns`: + return Promise.reject( + new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v1/campaigns`), + { + status: statusCode, + } as Response, + { message: errorMessage, status: statusCode }, + ), + ); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/locking/campaigns`) + .expect(statusCode) + .expect({ + message: errorMessage, + code: statusCode, + }); + }); + }); + describe('GET rank', () => { it('should get the rank', async () => { const rank = rankBuilder().build(); diff --git a/src/routes/locking/locking.controller.ts b/src/routes/locking/locking.controller.ts index 067883e4c9..fede794622 100644 --- a/src/routes/locking/locking.controller.ts +++ b/src/routes/locking/locking.controller.ts @@ -9,6 +9,8 @@ import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { Controller, Get, Param } from '@nestjs/common'; import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { Campaign } from '@/routes/locking/entities/campaign.entity'; +import { CampaignPage } from '@/routes/locking/entities/campaign.page.entity'; @ApiTags('locking') @Controller({ @@ -18,6 +20,28 @@ import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; export class LockingController { constructor(private readonly lockingService: LockingService) {} + @ApiOkResponse({ type: Campaign }) + @Get('/campaigns/:campaignId') + async getCampaignById( + @Param('campaignId') campaignId: string, + ): Promise { + return this.lockingService.getCampaignById(campaignId); + } + + @ApiOkResponse({ type: CampaignPage }) + @ApiQuery({ + name: 'cursor', + required: false, + type: String, + }) + @Get('/campaigns') + async getCampaigns( + @RouteUrlDecorator() routeUrl: URL, + @PaginationDataDecorator() paginationData: PaginationData, + ): Promise { + return this.lockingService.getCampaigns({ routeUrl, paginationData }); + } + @ApiOkResponse({ type: Rank }) @Get('/leaderboard/rank/:safeAddress') async getRank( diff --git a/src/routes/locking/locking.service.ts b/src/routes/locking/locking.service.ts index e7205ca2cb..625135b763 100644 --- a/src/routes/locking/locking.service.ts +++ b/src/routes/locking/locking.service.ts @@ -1,4 +1,5 @@ import { Page } from '@/domain/entities/page.entity'; +import { Campaign } from '@/domain/locking/entities/campaign.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; import { ILockingRepository } from '@/domain/locking/locking.repository.interface'; @@ -15,6 +16,32 @@ export class LockingService { private readonly lockingRepository: ILockingRepository, ) {} + async getCampaignById(campaignId: string): Promise { + return this.lockingRepository.getCampaignById(campaignId); + } + + async getCampaigns(args: { + routeUrl: URL; + paginationData: PaginationData; + }): Promise> { + const result = await this.lockingRepository.getCampaigns( + args.paginationData, + ); + + const nextUrl = cursorUrlFromLimitAndOffset(args.routeUrl, result.next); + const previousUrl = cursorUrlFromLimitAndOffset( + args.routeUrl, + result.previous, + ); + + return { + count: result.count, + next: nextUrl?.toString() ?? null, + previous: previousUrl?.toString() ?? null, + results: result.results, + }; + } + async getRank(safeAddress: `0x${string}`): Promise { return this.lockingRepository.getRank(safeAddress); } From b2fc9025d3c9caa7c399e1cc3ef40d446cbfca74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 16 May 2024 11:31:17 +0200 Subject: [PATCH 61/65] Rename campaigns date fields (#1552) Renames periodStart as startDate. Renames periodEnd as endDate. Renames activities as activitiesMetadata. --- .../entities/__tests__/campaign.builder.ts | 6 ++--- .../locking/entities/campaign.entity.ts | 6 ++--- .../schemas/__tests__/campaign.schema.spec.ts | 25 +++++++++---------- .../entities/schemas/campaign.schema.ts | 6 ++--- .../locking/entities/campaign.entity.ts | 6 ++--- src/routes/locking/locking.controller.spec.ts | 12 ++++----- 6 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/domain/locking/entities/__tests__/campaign.builder.ts b/src/domain/locking/entities/__tests__/campaign.builder.ts index 55386abc31..454c1471a1 100644 --- a/src/domain/locking/entities/__tests__/campaign.builder.ts +++ b/src/domain/locking/entities/__tests__/campaign.builder.ts @@ -8,11 +8,11 @@ export function campaignBuilder(): IBuilder { .with('campaignId', faker.string.uuid()) .with('name', faker.word.words()) .with('description', faker.lorem.sentence()) - .with('periodStart', faker.date.recent()) - .with('periodEnd', faker.date.future()) + .with('startDate', faker.date.recent()) + .with('endDate', faker.date.future()) .with('lastUpdated', faker.date.recent()) .with( - 'activities', + 'activitiesMetadata', Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () => activityMetadataBuilder().build(), ), diff --git a/src/domain/locking/entities/campaign.entity.ts b/src/domain/locking/entities/campaign.entity.ts index 70b2d6491f..5132f31644 100644 --- a/src/domain/locking/entities/campaign.entity.ts +++ b/src/domain/locking/entities/campaign.entity.ts @@ -8,10 +8,10 @@ export const CampaignSchema = z.object({ campaignId: z.string(), name: z.string(), description: z.string(), - periodStart: z.coerce.date(), - periodEnd: z.coerce.date(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), lastUpdated: z.coerce.date(), - activities: z.array(ActivityMetadataSchema).nullish().default(null), + activitiesMetadata: z.array(ActivityMetadataSchema).nullish().default(null), }); export const CampaignPageSchema = buildPageSchema(CampaignSchema); diff --git a/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts b/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts index d0412dacfd..5c3024613c 100644 --- a/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts +++ b/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts @@ -11,19 +11,18 @@ describe('CampaignSchema', () => { expect(result.success).toBe(true); }); - it.each([ - 'periodStart' as const, - 'periodEnd' as const, - 'lastUpdated' as const, - ])(`should coerce %s to a date`, (field) => { - const campaign = campaignBuilder().build(); + it.each(['startDate' as const, 'endDate' as const, 'lastUpdated' as const])( + `should coerce %s to a date`, + (field) => { + const campaign = campaignBuilder().build(); - const result = CampaignSchema.safeParse(campaign); + const result = CampaignSchema.safeParse(campaign); - expect(result.success && result.data[field]).toStrictEqual( - new Date(campaign[field]), - ); - }); + expect(result.success && result.data[field]).toStrictEqual( + new Date(campaign[field]), + ); + }, + ); it('should not validate an invalid campaign', () => { const campaign = { invalid: 'campaign' }; @@ -55,12 +54,12 @@ describe('CampaignSchema', () => { }, { code: 'invalid_date', - path: ['periodStart'], + path: ['startDate'], message: 'Invalid date', }, { code: 'invalid_date', - path: ['periodEnd'], + path: ['endDate'], message: 'Invalid date', }, { diff --git a/src/domain/locking/entities/schemas/campaign.schema.ts b/src/domain/locking/entities/schemas/campaign.schema.ts index dc17ac7f99..8b0d81db3d 100644 --- a/src/domain/locking/entities/schemas/campaign.schema.ts +++ b/src/domain/locking/entities/schemas/campaign.schema.ts @@ -5,8 +5,8 @@ export const CampaignSchema = z.object({ campaignId: z.string(), name: z.string(), description: z.string(), - periodStart: z.coerce.date(), - periodEnd: z.coerce.date(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), lastUpdated: z.coerce.date(), - activities: z.array(ActivityMetadataSchema).nullish().default(null), + activitiesMetadata: z.array(ActivityMetadataSchema).nullish().default(null), }); diff --git a/src/routes/locking/entities/campaign.entity.ts b/src/routes/locking/entities/campaign.entity.ts index c9b323b32e..aee07bbb8e 100644 --- a/src/routes/locking/entities/campaign.entity.ts +++ b/src/routes/locking/entities/campaign.entity.ts @@ -10,11 +10,11 @@ export class Campaign implements DomainCampaign { @ApiProperty() description!: string; @ApiProperty({ type: String }) - periodStart!: Date; + startDate!: Date; @ApiProperty({ type: String }) - periodEnd!: Date; + endDate!: Date; @ApiProperty({ type: String }) lastUpdated!: Date; @ApiProperty({ type: [ActivityMetadata] }) - activities!: ActivityMetadata[] | null; + activitiesMetadata!: ActivityMetadata[] | null; } diff --git a/src/routes/locking/locking.controller.spec.ts b/src/routes/locking/locking.controller.spec.ts index 3a04077634..e28d946bbc 100644 --- a/src/routes/locking/locking.controller.spec.ts +++ b/src/routes/locking/locking.controller.spec.ts @@ -86,8 +86,8 @@ describe('Locking (Unit)', () => { .expect(200) .expect({ ...campaign, - periodStart: campaign.periodStart.toISOString(), - periodEnd: campaign.periodEnd.toISOString(), + startDate: campaign.startDate.toISOString(), + endDate: campaign.endDate.toISOString(), lastUpdated: campaign.lastUpdated.toISOString(), }); }); @@ -117,8 +117,8 @@ describe('Locking (Unit)', () => { previous: null, results: campaignsPage.results.map((campaign) => ({ ...campaign, - periodStart: campaign.periodStart.toISOString(), - periodEnd: campaign.periodEnd.toISOString(), + startDate: campaign.startDate.toISOString(), + endDate: campaign.endDate.toISOString(), lastUpdated: campaign.lastUpdated.toISOString(), })), }); @@ -179,8 +179,8 @@ describe('Locking (Unit)', () => { previous: null, results: campaignsPage.results.map((campaign) => ({ ...campaign, - periodStart: campaign.periodStart.toISOString(), - periodEnd: campaign.periodEnd.toISOString(), + startDate: campaign.startDate.toISOString(), + endDate: campaign.endDate.toISOString(), lastUpdated: campaign.lastUpdated.toISOString(), })), }); From f1aca3d6e0f5afe424da5414fac406182dc2593a Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 16 May 2024 11:37:27 +0200 Subject: [PATCH 62/65] Ensure `CreateMessageDto['safeAppId']` is an integer (#1553) Increases the strictness of message creation validation to only allow `null`, `0` or positive integers: - Increase strictness of `CreateMessageDtoSchema['safeAppId']` and add appropriate test coverage - Mirror strictness in `createMessageDtoBuilder` --- .../__tests__/create-message.dto.builder.ts | 2 +- .../create-message.dto.schema.spec.ts | 233 ++++++++++++------ .../schemas/create-message.dto.schema.ts | 2 +- 3 files changed, 153 insertions(+), 84 deletions(-) diff --git a/src/routes/messages/entities/__tests__/create-message.dto.builder.ts b/src/routes/messages/entities/__tests__/create-message.dto.builder.ts index 821e22fa2e..8fd3e374bb 100644 --- a/src/routes/messages/entities/__tests__/create-message.dto.builder.ts +++ b/src/routes/messages/entities/__tests__/create-message.dto.builder.ts @@ -5,6 +5,6 @@ import { CreateMessageDto } from '@/routes/messages/entities/create-message.dto. export function createMessageDtoBuilder(): IBuilder { return new Builder() .with('message', faker.word.words({ count: { min: 1, max: 5 } })) - .with('safeAppId', faker.number.int()) + .with('safeAppId', faker.number.int({ min: 0 })) .with('signature', faker.string.hexadecimal({ length: 32 })); } diff --git a/src/routes/messages/entities/schemas/__tests__/create-message.dto.schema.spec.ts b/src/routes/messages/entities/schemas/__tests__/create-message.dto.schema.spec.ts index f59323b73f..2a8deefd4b 100644 --- a/src/routes/messages/entities/schemas/__tests__/create-message.dto.schema.spec.ts +++ b/src/routes/messages/entities/schemas/__tests__/create-message.dto.schema.spec.ts @@ -5,98 +5,167 @@ import { faker } from '@faker-js/faker'; import { ZodError } from 'zod'; describe('CreateMessageDtoSchema', () => { - it('should validate a valid record message', () => { - const createMessageDto = createMessageDtoBuilder() - .with('message', JSON.parse(fakeJson())) - .build(); + describe('message', () => { + it('should validate a valid record message', () => { + const createMessageDto = createMessageDtoBuilder() + .with('message', JSON.parse(fakeJson())) + .build(); - const result = CreateMessageDtoSchema.safeParse(createMessageDto); + const result = CreateMessageDtoSchema.safeParse(createMessageDto); - expect(result.success).toBe(true); - }); + expect(result.success).toBe(true); + }); - it('should validate a valid string message', () => { - const createMessageDto = createMessageDtoBuilder() - .with('message', faker.word.words()) - .build(); + it('should validate a valid string message', () => { + const createMessageDto = createMessageDtoBuilder() + .with('message', faker.word.words()) + .build(); - const result = CreateMessageDtoSchema.safeParse(createMessageDto); + const result = CreateMessageDtoSchema.safeParse(createMessageDto); - expect(result.success).toBe(true); - }); + expect(result.success).toBe(true); + }); - it('should allow optional safeAppId, defaulting to null', () => { - const createMessageDto = createMessageDtoBuilder().build(); - // @ts-expect-error - inferred type doesn't allow optional properties - delete createMessageDto.safeAppId; + it('should not validate without a message', () => { + const createMessageDto = createMessageDtoBuilder().build(); + // @ts-expect-error - inferred type doesn't allow optional properties + delete createMessageDto.message; - const result = CreateMessageDtoSchema.safeParse(createMessageDto); + const result = CreateMessageDtoSchema.safeParse(createMessageDto); - expect(result.success && result.data.safeAppId).toBe(null); + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_union', + unionErrors: [ + // @ts-expect-error - inferred type doesn't allow optional properties + { + issues: [ + { + code: 'invalid_type', + expected: 'object', + received: 'undefined', + path: ['message'], + message: 'Required', + }, + ], + name: 'ZodError', + }, + // @ts-expect-error - inferred type doesn't allow optional properties + { + issues: [ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['message'], + message: 'Required', + }, + ], + name: 'ZodError', + }, + ], + path: ['message'], + message: 'Invalid input', + }, + ]), + ); + }); }); - it('should not validated without a message', () => { - const createMessageDto = createMessageDtoBuilder().build(); - // @ts-expect-error - inferred type doesn't allow optional properties - delete createMessageDto.message; - - const result = CreateMessageDtoSchema.safeParse(createMessageDto); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'invalid_union', - unionErrors: [ - // @ts-expect-error - inferred type doesn't allow optional properties - { - issues: [ - { - code: 'invalid_type', - expected: 'object', - received: 'undefined', - path: ['message'], - message: 'Required', - }, - ], - name: 'ZodError', - }, - // @ts-expect-error - inferred type doesn't allow optional properties - { - issues: [ - { - code: 'invalid_type', - expected: 'string', - received: 'undefined', - path: ['message'], - message: 'Required', - }, - ], - name: 'ZodError', - }, - ], - path: ['message'], - message: 'Invalid input', - }, - ]), - ); + describe('safeAppId', () => { + it('should validate a safeAppId of 0', () => { + const createMessageDto = createMessageDtoBuilder() + .with('safeAppId', 0) + .build(); + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(result.success).toBe(true); + }); + + it('should validate a positive integer safeAppId', () => { + const createMessageDto = createMessageDtoBuilder() + .with('safeAppId', faker.number.int({ min: 1 })) + .build(); + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(result.success).toBe(true); + }); + + it('should not validate a negative safeAppId', () => { + const createMessageDto = createMessageDtoBuilder() + .with('safeAppId', -1) + .build(); + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'too_small', + minimum: 0, + type: 'number', + inclusive: true, + exact: false, + message: 'Number must be greater than or equal to 0', + path: ['safeAppId'], + }, + ]), + ); + }); + + it('should not validate a float safeAppId', () => { + const createMessageDto = createMessageDtoBuilder() + .with('safeAppId', faker.number.float()) + .build(); + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'integer', + received: 'float', + message: 'Expected integer, received float', + path: ['safeAppId'], + }, + ]), + ); + }); + + it('should validate without safeAppId, defaulting to null', () => { + const createMessageDto = createMessageDtoBuilder().build(); + // @ts-expect-error - inferred type doesn't allow optional properties + delete createMessageDto.safeAppId; + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(result.success && result.data.safeAppId).toBe(null); + }); }); - it('should not validated without a signature', () => { - const createMessageDto = createMessageDtoBuilder().build(); - // @ts-expect-error - inferred type doesn't allow optional properties - delete createMessageDto.signature; - - const result = CreateMessageDtoSchema.safeParse(createMessageDto); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'invalid_type', - expected: 'string', - received: 'undefined', - path: ['signature'], - message: 'Required', - }, - ]), - ); + + describe('signature', () => { + it('should not validate without a signature', () => { + const createMessageDto = createMessageDtoBuilder().build(); + // @ts-expect-error - inferred type doesn't allow optional properties + delete createMessageDto.signature; + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['signature'], + message: 'Required', + }, + ]), + ); + }); }); }); diff --git a/src/routes/messages/entities/schemas/create-message.dto.schema.ts b/src/routes/messages/entities/schemas/create-message.dto.schema.ts index 0432aeb498..2cba821a83 100644 --- a/src/routes/messages/entities/schemas/create-message.dto.schema.ts +++ b/src/routes/messages/entities/schemas/create-message.dto.schema.ts @@ -2,6 +2,6 @@ import { z } from 'zod'; export const CreateMessageDtoSchema = z.object({ message: z.union([z.record(z.unknown()), z.string()]), - safeAppId: z.number().nullish().default(null), + safeAppId: z.number().int().gte(0).nullish().default(null), signature: z.string(), }); From 44e8e8ef2450f657a36ecade1689fda388982027 Mon Sep 17 00:00:00 2001 From: Den Smalonski Date: Thu, 16 May 2024 13:14:13 +0200 Subject: [PATCH 63/65] chore: update package version to v1.39.0 --- package.json | 2 +- src/config/entities/configuration.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b6a043aab9..a49ff2ddbf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "safe-client-gateway", "description": "", - "version": "1.30.0", + "version": "1.39.0", "private": true, "license": "MIT", "scripts": { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 32595a0436..b505896359 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -3,7 +3,7 @@ export default () => ({ about: { name: 'safe-client-gateway', - version: process.env.APPLICATION_VERSION || 'v1.30.0', + version: process.env.APPLICATION_VERSION || 'v1.39.0', buildNumber: process.env.APPLICATION_BUILD_NUMBER, }, amqp: { From 4a1cc03f5147df488c8bba943938d1730d5ae8a5 Mon Sep 17 00:00:00 2001 From: Den Smalonski Date: Thu, 16 May 2024 13:14:28 +0200 Subject: [PATCH 64/65] Remove deprecated chain configuration --- src/config/entities/configuration.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index b505896359..eef0febce8 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -73,7 +73,6 @@ export default () => ({ 59144: { nativeCoin: 'ethereum', chainName: 'linea' }, 59140: { nativeCoin: 'ethereum', chainName: 'linea' }, 245022934: { nativeCoin: 'neon', chainName: 'neon-evm' }, - 534352: { nativeCoin: 'ethereum', chainName: 'scroll' }, 534351: { nativeCoin: 'ethereum', chainName: 'scroll' }, 80085: { nativeCoin: 'berachain-bera', chainName: 'berachain' }, 17000: { nativeCoin: 'ethereum', chainName: 'ethereum' }, From d3edda50c7f028e829bfc53b0911b0e1c2a4d83e Mon Sep 17 00:00:00 2001 From: Den Smalonski Date: Mon, 20 May 2024 16:20:41 +0200 Subject: [PATCH 65/65] chore: update application version to 1.40.0 --- .env.protofire | 1 + package.json | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 .env.protofire diff --git a/.env.protofire b/.env.protofire new file mode 100644 index 0000000000..f542a06c26 --- /dev/null +++ b/.env.protofire @@ -0,0 +1 @@ +APPLICATION_VERSION=1.40.0 \ No newline at end of file diff --git a/package.json b/package.json index fa4aabe341..2f3cf8cb9a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "safe-client-gateway", "description": "", - "version": "1.39.0", "private": true, "license": "MIT", "scripts": {