From 6b855e9c0d89de33690271eba0618d29f5286971 Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Thu, 19 Jun 2025 17:13:33 +0200 Subject: [PATCH 01/11] Add canister method to store favorit projects --- rs/backend/nns-dapp-exports-production.txt | 2 + rs/backend/nns-dapp-exports-test.txt | 2 + rs/backend/nns-dapp.did | 25 ++++++++++ rs/backend/src/accounts_store.rs | 54 ++++++++++++++++++++++ rs/backend/src/main.rs | 21 +++++++-- 5 files changed, 101 insertions(+), 3 deletions(-) diff --git a/rs/backend/nns-dapp-exports-production.txt b/rs/backend/nns-dapp-exports-production.txt index 19476c4862f..eccbf0c2946 100644 --- a/rs/backend/nns-dapp-exports-production.txt +++ b/rs/backend/nns-dapp-exports-production.txt @@ -7,6 +7,7 @@ canister_query get_account canister_query get_canisters canister_query get_histogram canister_query get_imported_tokens +canister_query get_fav_projects canister_query get_stats canister_query get_tvl canister_query http_request @@ -21,4 +22,5 @@ canister_update register_hardware_wallet canister_update rename_canister canister_update rename_sub_account canister_update set_imported_tokens +canister_update set_fav_projects main diff --git a/rs/backend/nns-dapp-exports-test.txt b/rs/backend/nns-dapp-exports-test.txt index 0ae90b89931..40f8dce7f17 100644 --- a/rs/backend/nns-dapp-exports-test.txt +++ b/rs/backend/nns-dapp-exports-test.txt @@ -7,6 +7,7 @@ canister_query get_account canister_query get_canisters canister_query get_histogram canister_query get_imported_tokens +canister_query get_fav_projects canister_query get_stats canister_query get_toy_account canister_query get_tvl @@ -23,4 +24,5 @@ canister_update register_hardware_wallet canister_update rename_canister canister_update rename_sub_account canister_update set_imported_tokens +canister_update set_fav_projects main diff --git a/rs/backend/nns-dapp.did b/rs/backend/nns-dapp.did index 4cc4bafdbf2..17971d4ef44 100644 --- a/rs/backend/nns-dapp.did +++ b/rs/backend/nns-dapp.did @@ -207,6 +207,29 @@ type GetImportedTokensResponse = AccountNotFound; }; +type FavProject = + record { + root_canister_id: principal; + }; + +type FavProjects = + record { + fav_projects: vec FavProject; + }; + +type SetFavProjectsResponse = + variant { + Ok; + AccountNotFound; + TooManyFavProjects: record{limit: int32}; + }; + +type GetFavProjectsResponse = + variant { + Ok: FavProjects; + AccountNotFound; + }; + type TvlResult = record { tvl : nat; @@ -230,6 +253,8 @@ service: (opt Config) -> { detach_canister: (DetachCanisterRequest) -> (DetachCanisterResponse); set_imported_tokens: (ImportedTokens) -> (SetImportedTokensResponse); get_imported_tokens: () -> (GetImportedTokensResponse) query; + set_fav_projects: (FavProjects) -> (SetFavProjectsResponse); + get_fav_projects: () -> (GetFavProjectsResponse) query; get_proposal_payload: (nat64) -> (GetProposalPayloadResponse); get_stats: () -> (Stats) query; get_histogram: () -> (Histogram) query; diff --git a/rs/backend/src/accounts_store.rs b/rs/backend/src/accounts_store.rs index 7b8d607ed56..48f340a0721 100644 --- a/rs/backend/src/accounts_store.rs +++ b/rs/backend/src/accounts_store.rs @@ -31,6 +31,9 @@ const MAX_SUB_ACCOUNT_ID: u8 = u8::MAX - 1; // Can be revisited if users find this too restrictive. const MAX_IMPORTED_TOKENS: i32 = 20; +// Conservatively limit the number of favorite projects to prevent using too much memory. +const MAX_FAVORITE_PROJECTS: i32 = 20; + /// Accounts and related data. pub struct AccountsStore { // TODO(NNS1-720): Use AccountIdentifier directly as the key for this HashMap @@ -85,6 +88,7 @@ pub struct Account { hardware_wallet_accounts: Vec, canisters: Vec, imported_tokens: Option, + fav_projects: Option, // default_account_transactions: Do not reuse this field. There are still accounts in stable memor with this unused field. } @@ -172,6 +176,29 @@ pub enum GetImportedTokensResponse { AccountNotFound, } +#[derive(CandidType, Clone, Copy, Default, Deserialize, Debug, Eq, PartialEq)] +pub struct FavProject { + root_canister_id: PrincipalId, +} + +#[derive(CandidType, Clone, Default, Deserialize, Debug, Eq, PartialEq)] +pub struct FavProjects { + fav_projects: Vec, +} + +#[derive(CandidType, Debug, PartialEq)] +pub enum SetFavProjectsResponse { + Ok, + AccountNotFound, + TooManyFavProjects { limit: i32 }, +} + +#[derive(CandidType, Debug, PartialEq)] +pub enum GetFavProjectsResponse { + Ok(FavProjects), + AccountNotFound, +} + #[derive(CandidType, Debug, PartialEq)] pub enum CreateSubAccountResponse { Ok(SubAccountDetails), @@ -627,6 +654,32 @@ impl AccountsStore { GetImportedTokensResponse::Ok(account.imported_tokens.unwrap_or_default()) } + pub fn set_fav_projects(&mut self, caller: PrincipalId, new_fav_projects: FavProjects) -> SetFavProjectsResponse { + if new_fav_projects.fav_projects.len() > (MAX_FAVORITE_PROJECTS as usize) { + return SetFavProjectsResponse::TooManyFavProjects { + limit: MAX_FAVORITE_PROJECTS, + }; + } + let account_identifier = AccountIdentifier::from(caller).to_vec(); + let Some(mut account) = self.accounts_db.get(&account_identifier) else { + return SetFavProjectsResponse::AccountNotFound; + }; + + account.fav_projects = Some(new_fav_projects); + + self.accounts_db.insert(account_identifier, account); + SetFavProjectsResponse::Ok + } + + pub fn get_fav_projects(&mut self, caller: PrincipalId) -> GetFavProjectsResponse { + let account_identifier = AccountIdentifier::from(caller).to_vec(); + let Some(account) = self.accounts_db.get(&account_identifier) else { + return GetFavProjectsResponse::AccountNotFound; + }; + + GetFavProjectsResponse::Ok(account.fav_projects.unwrap_or_default()) + } + pub fn get_stats(&self, stats: &mut Stats) { stats.accounts_count = self.accounts_db.len(); stats.sub_accounts_count = self.accounts_db_stats.sub_accounts_count; @@ -754,6 +807,7 @@ impl Account { hardware_wallet_accounts: Vec::new(), canisters: Vec::new(), imported_tokens: None, + fav_projects: None, } } } diff --git a/rs/backend/src/main.rs b/rs/backend/src/main.rs index dd995505ad2..902bb682959 100644 --- a/rs/backend/src/main.rs +++ b/rs/backend/src/main.rs @@ -1,9 +1,10 @@ use crate::accounts_store::histogram::AccountsStoreHistogram; use crate::accounts_store::{ AccountDetails, AttachCanisterRequest, AttachCanisterResponse, CreateSubAccountResponse, DetachCanisterRequest, - DetachCanisterResponse, GetImportedTokensResponse, ImportedTokens, NamedCanister, RegisterHardwareWalletRequest, - RegisterHardwareWalletResponse, RenameCanisterRequest, RenameCanisterResponse, RenameSubAccountRequest, - RenameSubAccountResponse, SetImportedTokensResponse, + DetachCanisterResponse, FavProjects, GetFavProjectsResponse, GetImportedTokensResponse, ImportedTokens, + NamedCanister, RegisterHardwareWalletRequest, RegisterHardwareWalletResponse, RenameCanisterRequest, + RenameCanisterResponse, RenameSubAccountRequest, RenameSubAccountResponse, SetFavProjectsResponse, + SetImportedTokensResponse, }; use crate::arguments::{set_canister_arguments, CanisterArguments}; use crate::assets::{hash_bytes, insert_asset, Asset}; @@ -204,6 +205,20 @@ pub fn get_imported_tokens() -> GetImportedTokensResponse { with_state_mut(|s| s.accounts_store.get_imported_tokens(principal)) } +#[must_use] +#[ic_cdk::update] +pub fn set_fav_projects(settings: FavProjects) -> SetFavProjectsResponse { + let principal = get_caller(); + with_state_mut(|s| s.accounts_store.set_fav_projects(principal, settings)) +} + +#[must_use] +#[ic_cdk::query] +pub fn get_fav_projects() -> GetFavProjectsResponse { + let principal = get_caller(); + with_state_mut(|s| s.accounts_store.get_fav_projects(principal)) +} + #[ic_cdk::update] pub async fn get_proposal_payload(proposal_id: u64) -> Result { proposals::get_proposal_payload(proposal_id).await From 3740cdbdad1f4ae68b3d41e1b7fa2e0c537b5e20 Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Thu, 19 Jun 2025 17:13:46 +0200 Subject: [PATCH 02/11] Add tests --- rs/backend/src/accounts_store/tests.rs | 102 ++++++++++++++++++++++ rs/backend/src/accounts_store/toy_data.rs | 1 + 2 files changed, 103 insertions(+) diff --git a/rs/backend/src/accounts_store/tests.rs b/rs/backend/src/accounts_store/tests.rs index d841cbec60f..170195e46d8 100644 --- a/rs/backend/src/accounts_store/tests.rs +++ b/rs/backend/src/accounts_store/tests.rs @@ -1081,6 +1081,108 @@ fn get_imported_tokens_account_not_found() { ); } +// same but for fav_projects + +#[test] +fn set_and_get_fav_projects() { + let mut store = setup_test_store(); + let principal = PrincipalId::from_str(TEST_ACCOUNT_1).unwrap(); + let root_canister_id = PrincipalId::new_user_test_id(101); + + assert_eq!( + store.get_fav_projects(principal), + GetFavProjectsResponse::Ok(FavProjects::default()) + ); + + let fav_project = FavProject { root_canister_id }; + + assert_eq!( + store.set_fav_projects( + principal, + FavProjects { + fav_projects: vec![fav_project.clone()], + }, + ), + SetFavProjectsResponse::Ok + ); + + assert_eq!( + store.get_fav_projects(principal), + GetFavProjectsResponse::Ok(FavProjects { + fav_projects: vec![fav_project], + }) + ); +} + +fn get_unique_fav_projects(count: u64) -> Vec { + (0..count) + .map(|i| FavProject { + root_canister_id: PrincipalId::new_user_test_id(i), + }) + .collect() +} + +#[test] +fn set_and_get_20_fav_projects() { + let mut store = setup_test_store(); + let principal = PrincipalId::from_str(TEST_ACCOUNT_1).unwrap(); + + assert_eq!( + store.get_fav_projects(principal), + GetFavProjectsResponse::Ok(FavProjects::default()) + ); + + let fav_projects = get_unique_fav_projects(20); + + assert_eq!( + store.set_fav_projects( + principal, + FavProjects { + fav_projects: fav_projects.clone() + }, + ), + SetFavProjectsResponse::Ok + ); + + assert_eq!( + store.get_fav_projects(principal), + GetFavProjectsResponse::Ok(FavProjects { fav_projects }) + ); +} + +#[test] +fn set_fav_projects_account_not_found() { + let mut store = setup_test_store(); + let non_existing_principal = PrincipalId::from_str(TEST_ACCOUNT_3).unwrap(); + assert_eq!( + store.set_fav_projects(non_existing_principal, FavProjects::default()), + SetFavProjectsResponse::AccountNotFound + ); +} + +#[test] +fn set_fav_projects_too_many() { + let mut store = setup_test_store(); + let principal = PrincipalId::from_str(TEST_ACCOUNT_1).unwrap(); + + let fav_projects = get_unique_fav_projects(21); + + assert_eq!( + store.set_fav_projects(principal, FavProjects { fav_projects },), + SetFavProjectsResponse::TooManyFavProjects { limit: 20 } + ); +} + +#[test] +fn get_fav_projects_account_not_found() { + let mut store = setup_test_store(); + let non_existing_principal = PrincipalId::from_str(TEST_ACCOUNT_3).unwrap(); + assert_eq!( + store.get_fav_projects(non_existing_principal), + GetFavProjectsResponse::AccountNotFound + ); +} + #[test] fn sub_account_name_too_long() { let mut store = setup_test_store(); diff --git a/rs/backend/src/accounts_store/toy_data.rs b/rs/backend/src/accounts_store/toy_data.rs index cc49f248702..4515bdcf327 100644 --- a/rs/backend/src/accounts_store/toy_data.rs +++ b/rs/backend/src/accounts_store/toy_data.rs @@ -61,6 +61,7 @@ pub fn toy_account(account_index: u64, size: ToyAccountSize) -> Account { hardware_wallet_accounts: Vec::new(), canisters: Vec::new(), imported_tokens: None, + fav_projects: None, }; // Creates linked sub-accounts: // Note: Successive accounts have 0, 1, 2 ... MAX_SUB_ACCOUNTS_PER_ACCOUNT-1 sub accounts, restarting at 0. From 65681d4cd5b5a7cad0beb08fe1d33ffa0deb6416 Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Fri, 20 Jun 2025 10:47:11 +0200 Subject: [PATCH 03/11] Add related types --- .../canisters/nns-dapp/nns-dapp.certified.idl.js | 13 +++++++++++++ .../src/lib/canisters/nns-dapp/nns-dapp.idl.js | 13 +++++++++++++ .../src/lib/canisters/nns-dapp/nns-dapp.types.ts | 16 ++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/frontend/src/lib/canisters/nns-dapp/nns-dapp.certified.idl.js b/frontend/src/lib/canisters/nns-dapp/nns-dapp.certified.idl.js index 417336a6eb7..33e55814d93 100644 --- a/frontend/src/lib/canisters/nns-dapp/nns-dapp.certified.idl.js +++ b/frontend/src/lib/canisters/nns-dapp/nns-dapp.certified.idl.js @@ -50,6 +50,12 @@ export const idlFactory = ({ IDL }) => { canister_id: IDL.Principal, block_index: IDL.Opt(IDL.Nat64), }); + const FavProject = IDL.Record({ root_canister_id: IDL.Principal }); + const FavProjects = IDL.Record({ fav_projects: IDL.Vec(FavProject) }); + const GetFavProjectsResponse = IDL.Variant({ + Ok: FavProjects, + AccountNotFound: IDL.Null, + }); const ImportedToken = IDL.Record({ index_canister_id: IDL.Opt(IDL.Principal), ledger_canister_id: IDL.Principal, @@ -120,6 +126,11 @@ export const idlFactory = ({ IDL }) => { SubAccountNotFound: IDL.Null, NameTooLong: IDL.Null, }); + const SetFavProjectsResponse = IDL.Variant({ + Ok: IDL.Null, + AccountNotFound: IDL.Null, + TooManyFavProjects: IDL.Record({ limit: IDL.Int32 }), + }); const SetImportedTokensResponse = IDL.Variant({ Ok: IDL.Null, AccountNotFound: IDL.Null, @@ -146,6 +157,7 @@ export const idlFactory = ({ IDL }) => { ), get_account: IDL.Func([], [GetAccountResponse], []), get_canisters: IDL.Func([], [IDL.Vec(CanisterDetails)], []), + get_fav_projects: IDL.Func([], [GetFavProjectsResponse], []), get_imported_tokens: IDL.Func([], [GetImportedTokensResponse], []), get_proposal_payload: IDL.Func( [IDL.Nat64], @@ -164,6 +176,7 @@ export const idlFactory = ({ IDL }) => { [RenameSubAccountResponse], [] ), + set_fav_projects: IDL.Func([FavProjects], [SetFavProjectsResponse], []), set_imported_tokens: IDL.Func( [ImportedTokens], [SetImportedTokensResponse], diff --git a/frontend/src/lib/canisters/nns-dapp/nns-dapp.idl.js b/frontend/src/lib/canisters/nns-dapp/nns-dapp.idl.js index 0eba256932f..23768a549e9 100644 --- a/frontend/src/lib/canisters/nns-dapp/nns-dapp.idl.js +++ b/frontend/src/lib/canisters/nns-dapp/nns-dapp.idl.js @@ -50,6 +50,12 @@ export const idlFactory = ({ IDL }) => { canister_id: IDL.Principal, block_index: IDL.Opt(IDL.Nat64), }); + const FavProject = IDL.Record({ root_canister_id: IDL.Principal }); + const FavProjects = IDL.Record({ fav_projects: IDL.Vec(FavProject) }); + const GetFavProjectsResponse = IDL.Variant({ + Ok: FavProjects, + AccountNotFound: IDL.Null, + }); const ImportedToken = IDL.Record({ index_canister_id: IDL.Opt(IDL.Principal), ledger_canister_id: IDL.Principal, @@ -120,6 +126,11 @@ export const idlFactory = ({ IDL }) => { SubAccountNotFound: IDL.Null, NameTooLong: IDL.Null, }); + const SetFavProjectsResponse = IDL.Variant({ + Ok: IDL.Null, + AccountNotFound: IDL.Null, + TooManyFavProjects: IDL.Record({ limit: IDL.Int32 }), + }); const SetImportedTokensResponse = IDL.Variant({ Ok: IDL.Null, AccountNotFound: IDL.Null, @@ -146,6 +157,7 @@ export const idlFactory = ({ IDL }) => { ), get_account: IDL.Func([], [GetAccountResponse], ["query"]), get_canisters: IDL.Func([], [IDL.Vec(CanisterDetails)], ["query"]), + get_fav_projects: IDL.Func([], [GetFavProjectsResponse], ["query"]), get_imported_tokens: IDL.Func([], [GetImportedTokensResponse], ["query"]), get_proposal_payload: IDL.Func( [IDL.Nat64], @@ -164,6 +176,7 @@ export const idlFactory = ({ IDL }) => { [RenameSubAccountResponse], [] ), + set_fav_projects: IDL.Func([FavProjects], [SetFavProjectsResponse], []), set_imported_tokens: IDL.Func( [ImportedTokens], [SetImportedTokensResponse], diff --git a/frontend/src/lib/canisters/nns-dapp/nns-dapp.types.ts b/frontend/src/lib/canisters/nns-dapp/nns-dapp.types.ts index 673242828f4..fdb43bf41ab 100644 --- a/frontend/src/lib/canisters/nns-dapp/nns-dapp.types.ts +++ b/frontend/src/lib/canisters/nns-dapp/nns-dapp.types.ts @@ -38,6 +38,9 @@ export type DetachCanisterResponse = { Ok: null } | { CanisterNotFound: null }; export type GetAccountResponse = | { Ok: AccountDetails } | { AccountNotFound: null }; +export type GetFavProjectsResponse = + | { Ok: FavProjects } + | { AccountNotFound: null }; export type GetImportedTokensResponse = | { Ok: ImportedTokens } | { AccountNotFound: null }; @@ -66,6 +69,13 @@ export interface ImportedToken { export interface ImportedTokens { imported_tokens: Array; } +export interface FavProject { + root_canister_id: Principal; +} +export interface FavProjects { + fav_projects: Array; +} + export interface RegisterHardwareWalletRequest { principal: Principal; name: string; @@ -99,6 +109,10 @@ export type SetImportedTokensResponse = | { Ok: null } | { AccountNotFound: null } | { TooManyImportedTokens: { limit: number } }; +export type SetFavProjectsResponse = + | { Ok: null } + | { AccountNotFound: null } + | { TooManyFavProjects: { limit: number } }; export interface Stats { seconds_since_last_ledger_sync: bigint; sub_accounts_count: bigint; @@ -132,6 +146,7 @@ export default interface _SERVICE { get_account: () => Promise; get_canisters: () => Promise>; get_imported_tokens: () => Promise; + get_fav_projects: () => Promise; get_proposal_payload: (arg_0: bigint) => Promise; get_stats: () => Promise; http_request: (arg_0: HttpRequest) => Promise; @@ -142,4 +157,5 @@ export default interface _SERVICE { arg_0: RenameSubAccountRequest ) => Promise; set_imported_tokens: ActorMethod<[ImportedTokens], SetImportedTokensResponse>; + set_fav_projects: ActorMethod<[FavProjects], SetFavProjectsResponse>; } From 1bea82c4ee9014c0ddf75ab305b655167618df99 Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Fri, 20 Jun 2025 10:48:31 +0200 Subject: [PATCH 04/11] Add getFavProjects/setFavProjects api --- frontend/src/lib/api/fav-projects.api.ts | 39 ++++++++++++++++++ .../canisters/nns-dapp/nns-dapp.canister.ts | 40 +++++++++++++++++++ .../lib/canisters/nns-dapp/nns-dapp.errors.ts | 8 ++++ frontend/src/lib/i18n/en.json | 5 +++ frontend/src/lib/types/i18n.d.ts | 7 ++++ 5 files changed, 99 insertions(+) create mode 100644 frontend/src/lib/api/fav-projects.api.ts diff --git a/frontend/src/lib/api/fav-projects.api.ts b/frontend/src/lib/api/fav-projects.api.ts new file mode 100644 index 00000000000..c7fb4724759 --- /dev/null +++ b/frontend/src/lib/api/fav-projects.api.ts @@ -0,0 +1,39 @@ +import { nnsDappCanister } from "$lib/api/nns-dapp.api"; +import type { + FavProject, + FavProjects, +} from "$lib/canisters/nns-dapp/nns-dapp.types"; +import { logWithTimestamp } from "$lib/utils/dev.utils"; +import type { Identity } from "@dfinity/agent"; + +export const getFavProjects = async ({ + identity, + certified, +}: { + identity: Identity; + certified: boolean; +}): Promise => { + logWithTimestamp(`Getting favorite projects:${certified} call...`); + + const { canister: nnsDapp } = await nnsDappCanister({ identity }); + const response = await nnsDapp.getFavProjects({ certified }); + + logWithTimestamp(`Getting favorite projects:${certified} complete`); + + return response; +}; + +export const setFavProjects = async ({ + identity, + favProjects, +}: { + identity: Identity; + favProjects: Array; +}): Promise => { + logWithTimestamp("Setting favorite projects call..."); + + const { canister: nnsDapp } = await nnsDappCanister({ identity }); + await nnsDapp.setFavProjects(favProjects); + + logWithTimestamp("Setting favorite projects call complete."); +}; diff --git a/frontend/src/lib/canisters/nns-dapp/nns-dapp.canister.ts b/frontend/src/lib/canisters/nns-dapp/nns-dapp.canister.ts index 42670364ca1..88673ec5049 100644 --- a/frontend/src/lib/canisters/nns-dapp/nns-dapp.canister.ts +++ b/frontend/src/lib/canisters/nns-dapp/nns-dapp.canister.ts @@ -12,6 +12,7 @@ import { ProposalPayloadNotFoundError, ProposalPayloadTooLargeError, SubAccountLimitExceededError, + TooManyFavProjectsError, TooManyImportedTokensError, UnknownProposalPayloadError, } from "$lib/canisters/nns-dapp/nns-dapp.errors"; @@ -21,6 +22,8 @@ import type { AccountDetails, CanisterDetails, CreateSubAccountResponse, + FavProject, + FavProjects, GetAccountResponse, ImportedToken, ImportedTokens, @@ -373,4 +376,41 @@ export class NNSDappCanister { `Error setting imported tokens ${JSON.stringify(response)}` ); }; + + public getFavProjects = async ({ + certified, + }: { + certified: boolean; + }): Promise => { + const response = await this.getNNSDappService(certified).get_fav_projects(); + if ("Ok" in response) { + return response.Ok; + } + if ("AccountNotFound" in response) { + throw new AccountNotFoundError("error__account.not_found"); + } + // Edge case + throw new Error(`Error getting fav projects ${JSON.stringify(response)}`); + }; + + public setFavProjects = async ( + favProjects: Array + ): Promise => { + const response = await this.certifiedService.set_fav_projects({ + fav_projects: favProjects, + }); + if ("Ok" in response) { + return; + } + if ("AccountNotFound" in response) { + throw new AccountNotFoundError("error__account.not_found"); + } + if ("TooManyFavProjects" in response) { + throw new TooManyFavProjectsError("error__fav_projects.too_many", { + $limit: response.TooManyFavProjects?.limit.toString(), + }); + } + // Edge case + throw new Error(`Error setting fav projects ${JSON.stringify(response)}`); + }; } diff --git a/frontend/src/lib/canisters/nns-dapp/nns-dapp.errors.ts b/frontend/src/lib/canisters/nns-dapp/nns-dapp.errors.ts index 0a970c66e3a..633a91ecba3 100644 --- a/frontend/src/lib/canisters/nns-dapp/nns-dapp.errors.ts +++ b/frontend/src/lib/canisters/nns-dapp/nns-dapp.errors.ts @@ -21,6 +21,14 @@ export class TooManyImportedTokensError extends AccountTranslateError { } } +export class TooManyFavProjectsError extends AccountTranslateError { + constructor(message: string, substitutions?: I18nSubstitutions) { + super(message); + + this.substitutions = substitutions; + } +} + export class SubAccountLimitExceededError extends Error {} export class NameTooLongError extends AccountTranslateError { diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 1bad64b21b7..6827b0d3293 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -1034,6 +1034,11 @@ "is_icp": "You cannot import ICP.", "invalid_canister_id": "Importing the token was unsuccessful because \"$canisterId\" is not a valid canister ID. Please verify the ID and retry." }, + "error__fav_projects": { + "load_fav_projects": "There was an unexpected issue while loading favorite projects.", + "update_fav_project": "There was an unexpected issue while updating the favorite project.", + "too_many": "You can't add more than $limit favorite projects." + }, "error__sns": { "undefined_project": "The requested project is invalid or throws an error.", "list_summaries": "There was an unexpected error while loading the summaries of all deployed projects.", diff --git a/frontend/src/lib/types/i18n.d.ts b/frontend/src/lib/types/i18n.d.ts index 8b500817795..fd4ec5bd40e 100644 --- a/frontend/src/lib/types/i18n.d.ts +++ b/frontend/src/lib/types/i18n.d.ts @@ -1080,6 +1080,12 @@ interface I18nError__imported_tokens { invalid_canister_id: string; } +interface I18nError__fav_projects { + load_fav_projects: string; + update_fav_project: string; + too_many: string; +} + interface I18nError__sns { undefined_project: string; list_summaries: string; @@ -1640,6 +1646,7 @@ interface I18n { error__account: I18nError__account; error__canister: I18nError__canister; error__imported_tokens: I18nError__imported_tokens; + error__fav_projects: I18nError__fav_projects; error__sns: I18nError__sns; auth_accounts: I18nAuth_accounts; auth_report: I18nAuth_report; From a6bf493db6391a93533909e8f6995e29ca419351 Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Fri, 20 Jun 2025 17:01:21 +0200 Subject: [PATCH 05/11] test: set/get fav projects --- .../tests/lib/api/fav-projects.api.spec.ts | 56 +++++++ .../lib/canisters/nns-dapp.canister.spec.ts | 142 +++++++++++++++++- .../src/tests/mocks/icrc-accounts.mock.ts | 10 +- 3 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 frontend/src/tests/lib/api/fav-projects.api.spec.ts diff --git a/frontend/src/tests/lib/api/fav-projects.api.spec.ts b/frontend/src/tests/lib/api/fav-projects.api.spec.ts new file mode 100644 index 00000000000..e614f5f2f4a --- /dev/null +++ b/frontend/src/tests/lib/api/fav-projects.api.spec.ts @@ -0,0 +1,56 @@ +import { getFavProjects, setFavProjects } from "$lib/api/fav-projects.api"; +import { NNSDappCanister } from "$lib/canisters/nns-dapp/nns-dapp.canister"; +import { mockCreateAgent } from "$tests/mocks/agent.mock"; +import { mockIdentity } from "$tests/mocks/auth.store.mock"; +import { mockFavProject } from "$tests/mocks/icrc-accounts.mock"; +import * as dfinityUtils from "@dfinity/utils"; +import { mock } from "vitest-mock-extended"; + +describe("fav-projects-api", () => { + const mockNNSDappCanister = mock(); + + beforeEach(() => { + vi.spyOn(NNSDappCanister, "create").mockImplementation( + (): NNSDappCanister => mockNNSDappCanister + ); + // Prevent HttpAgent.create(), which is called by createAgent, from making a + // real network request via agent.syncTime(). + vi.spyOn(dfinityUtils, "createAgent").mockImplementation(mockCreateAgent); + }); + + describe("getFavProjects", () => { + it("should call the nns dapp canister to get the fav projects", async () => { + mockNNSDappCanister.getFavProjects.mockResolvedValue({ + fav_projects: [mockFavProject], + }); + expect(mockNNSDappCanister.getFavProjects).not.toBeCalled(); + const result = await getFavProjects({ + identity: mockIdentity, + certified: true, + }); + + expect(mockNNSDappCanister.getFavProjects).toBeCalledTimes(1); + expect(mockNNSDappCanister.getFavProjects).toBeCalledWith({ + certified: true, + }); + expect(result).toEqual({ + fav_projects: [mockFavProject], + }); + }); + }); + + describe("setFavProjects", () => { + it("should call the nns dapp canister to set fav projects", async () => { + expect(mockNNSDappCanister.setFavProjects).not.toBeCalled(); + await setFavProjects({ + identity: mockIdentity, + favProjects: [mockFavProject], + }); + + expect(mockNNSDappCanister.setFavProjects).toBeCalledTimes(1); + expect(mockNNSDappCanister.setFavProjects).toBeCalledWith([ + mockFavProject, + ]); + }); + }); +}); diff --git a/frontend/src/tests/lib/canisters/nns-dapp.canister.spec.ts b/frontend/src/tests/lib/canisters/nns-dapp.canister.spec.ts index c58f16b5ed2..2dc39e6357c 100644 --- a/frontend/src/tests/lib/canisters/nns-dapp.canister.spec.ts +++ b/frontend/src/tests/lib/canisters/nns-dapp.canister.spec.ts @@ -11,6 +11,7 @@ import { ProposalPayloadNotFoundError, ProposalPayloadTooLargeError, SubAccountLimitExceededError, + TooManyFavProjectsError, TooManyImportedTokensError, UnknownProposalPayloadError, } from "$lib/canisters/nns-dapp/nns-dapp.errors"; @@ -18,7 +19,9 @@ import type { NNSDappService } from "$lib/canisters/nns-dapp/nns-dapp.idl"; import type { CreateSubAccountResponse, GetAccountResponse, + GetFavProjectsResponse, GetImportedTokensResponse, + SetFavProjectsResponse, SetImportedTokensResponse, } from "$lib/canisters/nns-dapp/nns-dapp.types"; import { mockPrincipal } from "$tests/mocks/auth.store.mock"; @@ -27,7 +30,10 @@ import { mockAccountDetails, mockSubAccountDetails, } from "$tests/mocks/icp-accounts.store.mock"; -import { mockImportedToken } from "$tests/mocks/icrc-accounts.mock"; +import { + mockFavProject, + mockImportedToken, +} from "$tests/mocks/icrc-accounts.mock"; import type { HttpAgent } from "@dfinity/agent"; import { AccountIdentifier } from "@dfinity/ledger-icp"; import { Principal } from "@dfinity/principal"; @@ -623,4 +629,138 @@ describe("NNSDapp", () => { ); }); }); + + describe("NNSDapp.getFavProjects", () => { + it("should call get_fav_projects", async () => { + const service = mock(); + service.get_fav_projects.mockResolvedValue({ + Ok: { + fav_projects: [], + }, + }); + const nnsDapp = await createNnsDapp(service); + + expect(service.get_fav_projects).not.toBeCalled(); + + await nnsDapp.getFavProjects({ certified: true }); + + expect(service.get_fav_projects).toBeCalledTimes(1); + }); + + it("should return fav projects", async () => { + const service = mock(); + service.get_fav_projects.mockResolvedValue({ + Ok: { + fav_projects: [mockFavProject], + }, + }); + const nnsDapp = await createNnsDapp(service); + const result = await nnsDapp.getFavProjects({ certified: true }); + + expect(result).toEqual({ + fav_projects: [mockFavProject], + }); + }); + + it("throws error if account not found", async () => { + const response: GetFavProjectsResponse = { + AccountNotFound: null, + }; + const service = mock(); + service.get_fav_projects.mockResolvedValue(response); + + const nnsDapp = await createNnsDapp(service); + + const call = async () => nnsDapp.getFavProjects({ certified: true }); + + await expect(call).rejects.toThrow(AccountNotFoundError); + }); + + it("should provide generic error message", async () => { + const response = { + UnexpectedError: "message", + }; + const service = mock(); + service.get_fav_projects.mockResolvedValue( + response as unknown as GetFavProjectsResponse + ); + + const nnsDapp = await createNnsDapp(service); + + const call = async () => nnsDapp.getFavProjects({ certified: true }); + + await expect(call).rejects.toThrow( + 'Error getting fav projects {"UnexpectedError":"message"}' + ); + }); + }); + + describe("NNSDapp.setFavProjects", () => { + it("should call set_fav_projects", async () => { + const service = mock(); + service.set_fav_projects.mockResolvedValue({ + Ok: null, + }); + const nnsDapp = await createNnsDapp(service); + + expect(service.set_fav_projects).not.toBeCalled(); + + await nnsDapp.setFavProjects([mockFavProject]); + + expect(service.set_fav_projects).toBeCalledTimes(1); + expect(service.set_fav_projects).toBeCalledWith({ + fav_projects: [mockFavProject], + }); + }); + + it("throws error if account not found", async () => { + const response: SetFavProjectsResponse = { + AccountNotFound: null, + }; + const service = mock(); + service.set_fav_projects.mockResolvedValue(response); + + const nnsDapp = await createNnsDapp(service); + + const call = async () => nnsDapp.setFavProjects([]); + + await expect(call).rejects.toThrow(AccountNotFoundError); + }); + + it("throws error if the limit has been reached", async () => { + const response: SetFavProjectsResponse = { + TooManyFavProjects: { limit: 20 }, + }; + const service = mock(); + service.set_fav_projects.mockResolvedValue(response); + + const nnsDapp = await createNnsDapp(service); + + const call = async () => nnsDapp.setFavProjects([]); + + await expect(call).rejects.toThrowError( + new TooManyFavProjectsError("error__fav_projects.too_many", { + $limit: "20", + }) + ); + }); + + it("should provide generic error message", async () => { + const response = { + UnexpectedError: "message", + }; + const service = mock(); + service.set_fav_projects.mockResolvedValue( + response as unknown as SetFavProjectsResponse + ); + + const nnsDapp = await createNnsDapp(service); + + const call = async () => nnsDapp.setFavProjects([]); + + await expect(call).rejects.toThrow( + 'Error setting fav projects {"UnexpectedError":"message"}' + ); + }); + }); }); diff --git a/frontend/src/tests/mocks/icrc-accounts.mock.ts b/frontend/src/tests/mocks/icrc-accounts.mock.ts index 13f2326f2c9..e69115648aa 100644 --- a/frontend/src/tests/mocks/icrc-accounts.mock.ts +++ b/frontend/src/tests/mocks/icrc-accounts.mock.ts @@ -1,9 +1,13 @@ -import type { ImportedToken } from "$lib/canisters/nns-dapp/nns-dapp.types"; +import type { + FavProject, + ImportedToken, +} from "$lib/canisters/nns-dapp/nns-dapp.types"; import type { Account } from "$lib/types/account"; import { mockPrincipal } from "$tests/mocks/auth.store.mock"; import { indexCanisterIdMock, ledgerCanisterIdMock, + rootCanisterIdMock, } from "$tests/mocks/sns.api.mock"; import { encodeIcrcAccount } from "@dfinity/ledger-icrc"; @@ -20,3 +24,7 @@ export const mockImportedToken: ImportedToken = { ledger_canister_id: ledgerCanisterIdMock, index_canister_id: [indexCanisterIdMock], }; + +export const mockFavProject: FavProject = { + root_canister_id: rootCanisterIdMock, +}; From fb1edcac4ec6a796441960290a6539ed28acc045 Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Thu, 17 Jul 2025 09:07:14 +0200 Subject: [PATCH 06/11] remove duplication --- rs/backend/src/accounts_store/tests.rs | 102 ------------------------- 1 file changed, 102 deletions(-) diff --git a/rs/backend/src/accounts_store/tests.rs b/rs/backend/src/accounts_store/tests.rs index 1f7289b6211..ee6790fff34 100644 --- a/rs/backend/src/accounts_store/tests.rs +++ b/rs/backend/src/accounts_store/tests.rs @@ -1081,108 +1081,6 @@ fn get_imported_tokens_account_not_found() { ); } -// same but for fav_projects - -#[test] -fn set_and_get_fav_projects() { - let mut store = setup_test_store(); - let principal = PrincipalId::from_str(TEST_ACCOUNT_1).unwrap(); - let root_canister_id = PrincipalId::new_user_test_id(101); - - assert_eq!( - store.get_fav_projects(principal), - GetFavProjectsResponse::Ok(FavProjects::default()) - ); - - let fav_project = FavProject { root_canister_id }; - - assert_eq!( - store.set_fav_projects( - principal, - FavProjects { - fav_projects: vec![fav_project.clone()], - }, - ), - SetFavProjectsResponse::Ok - ); - - assert_eq!( - store.get_fav_projects(principal), - GetFavProjectsResponse::Ok(FavProjects { - fav_projects: vec![fav_project], - }) - ); -} - -fn get_unique_fav_projects(count: u64) -> Vec { - (0..count) - .map(|i| FavProject { - root_canister_id: PrincipalId::new_user_test_id(i), - }) - .collect() -} - -#[test] -fn set_and_get_20_fav_projects() { - let mut store = setup_test_store(); - let principal = PrincipalId::from_str(TEST_ACCOUNT_1).unwrap(); - - assert_eq!( - store.get_fav_projects(principal), - GetFavProjectsResponse::Ok(FavProjects::default()) - ); - - let fav_projects = get_unique_fav_projects(20); - - assert_eq!( - store.set_fav_projects( - principal, - FavProjects { - fav_projects: fav_projects.clone() - }, - ), - SetFavProjectsResponse::Ok - ); - - assert_eq!( - store.get_fav_projects(principal), - GetFavProjectsResponse::Ok(FavProjects { fav_projects }) - ); -} - -#[test] -fn set_fav_projects_account_not_found() { - let mut store = setup_test_store(); - let non_existing_principal = PrincipalId::from_str(TEST_ACCOUNT_3).unwrap(); - assert_eq!( - store.set_fav_projects(non_existing_principal, FavProjects::default()), - SetFavProjectsResponse::AccountNotFound - ); -} - -#[test] -fn set_fav_projects_too_many() { - let mut store = setup_test_store(); - let principal = PrincipalId::from_str(TEST_ACCOUNT_1).unwrap(); - - let fav_projects = get_unique_fav_projects(21); - - assert_eq!( - store.set_fav_projects(principal, FavProjects { fav_projects },), - SetFavProjectsResponse::TooManyFavProjects { limit: 20 } - ); -} - -#[test] -fn get_fav_projects_account_not_found() { - let mut store = setup_test_store(); - let non_existing_principal = PrincipalId::from_str(TEST_ACCOUNT_3).unwrap(); - assert_eq!( - store.get_fav_projects(non_existing_principal), - GetFavProjectsResponse::AccountNotFound - ); -} - #[test] fn set_and_get_fav_projects() { let mut store = setup_test_store(); From 30d1ba08cd6208f0d6dfb4615acefd28615c146a Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Thu, 17 Jul 2025 09:12:34 +0200 Subject: [PATCH 07/11] cleanup --- frontend/src/lib/i18n/en.json | 2 -- frontend/src/lib/types/i18n.d.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 96f2d1dd16d..e1852cd5756 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -1062,8 +1062,6 @@ "invalid_canister_id": "Importing the token was unsuccessful because \"$canisterId\" is not a valid canister ID. Please verify the ID and retry." }, "error__fav_projects": { - "load_fav_projects": "There was an unexpected issue while loading favorite projects.", - "update_fav_project": "There was an unexpected issue while updating the favorite project.", "too_many": "You can't add more than $limit favorite projects." }, "error__sns": { diff --git a/frontend/src/lib/types/i18n.d.ts b/frontend/src/lib/types/i18n.d.ts index 08f8d4f0761..9146d9ab4f4 100644 --- a/frontend/src/lib/types/i18n.d.ts +++ b/frontend/src/lib/types/i18n.d.ts @@ -1110,8 +1110,6 @@ interface I18nError__imported_tokens { } interface I18nError__fav_projects { - load_fav_projects: string; - update_fav_project: string; too_many: string; } From 7314118bbd02c596e8deba2eebfcc6797869a1ee Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Thu, 17 Jul 2025 12:20:29 +0200 Subject: [PATCH 08/11] Add fav projects services --- frontend/src/lib/constants/sns.constants.ts | 4 + frontend/src/lib/i18n/en.json | 8 +- .../lib/services/sns.fav-projects.services.ts | 145 ++++++++++++++++++ frontend/src/lib/stores/busy.store.ts | 2 + .../src/lib/stores/sns-fav-projects.store.ts | 52 +++++++ frontend/src/lib/types/i18n.d.ts | 8 + .../src/lib/utils/sns-fav-projects.utils.ts | 10 ++ 7 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/services/sns.fav-projects.services.ts create mode 100644 frontend/src/lib/stores/sns-fav-projects.store.ts create mode 100644 frontend/src/lib/utils/sns-fav-projects.utils.ts diff --git a/frontend/src/lib/constants/sns.constants.ts b/frontend/src/lib/constants/sns.constants.ts index 33f99965bb3..c61917dd8f9 100644 --- a/frontend/src/lib/constants/sns.constants.ts +++ b/frontend/src/lib/constants/sns.constants.ts @@ -8,3 +8,7 @@ export const WATCH_SALE_STATE_EVERY_MILLISECONDS = 10_000; * ref: https://github.com/dfinity/nns-dapp/blob/38c66bafd1130be921fe9b27296b9d8e5338b6ff/rs/sns_aggregator/src/upstream.rs#L19 */ export const AGGREGATOR_METRICS_TIME_WINDOW_SECONDS = 2 * 30 * 24 * 3600; + +// Should be in sync with the backend: +// https://github.com/dfinity/nns-dapp/blob/645f1bf762fed70230ed8779b5dbd92cc35ac023/rs/backend/src/accounts_store.rs#L35 +export const MAX_SNS_FAV_PROJECTS = 20; diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index e1852cd5756..9a8449e14d2 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -912,6 +912,10 @@ "create_sns_proposal_title": "Create Service Nervous System (SNS)", "create_sns_proposal_vote": "Vote" }, + "fav_projects": { + "adding": "Adding project to favorites", + "removing": "Removing project from favorites" + }, "sns_project_detail": { "swap_proposal": "Swap Proposal", "link_to_dashboard": "ICP Dashboard", @@ -1062,7 +1066,9 @@ "invalid_canister_id": "Importing the token was unsuccessful because \"$canisterId\" is not a valid canister ID. Please verify the ID and retry." }, "error__fav_projects": { - "too_many": "You can't add more than $limit favorite projects." + "too_many": "You can't add more than $limit favorite projects.", + "adding_error": "There was an error while adding the project to favorites.", + "removing_error": "There was an error while removing the project from favorites." }, "error__sns": { "undefined_project": "The requested project is invalid or throws an error.", diff --git a/frontend/src/lib/services/sns.fav-projects.services.ts b/frontend/src/lib/services/sns.fav-projects.services.ts new file mode 100644 index 00000000000..0eea5d3b6e3 --- /dev/null +++ b/frontend/src/lib/services/sns.fav-projects.services.ts @@ -0,0 +1,145 @@ +import { getFavProjects, setFavProjects } from "$lib/api/fav-projects.api"; +import { AccountNotFoundError } from "$lib/canisters/nns-dapp/nns-dapp.errors"; +import type { FavProjects } from "$lib/canisters/nns-dapp/nns-dapp.types"; +import { FORCE_CALL_STRATEGY } from "$lib/constants/mockable.constants"; +import { getAuthenticatedIdentity } from "$lib/services/auth.services"; +import { queryAndUpdate } from "$lib/services/utils.services"; +import { startBusy, stopBusy } from "$lib/stores/busy.store"; +import { snsFavProjectsStore } from "$lib/stores/sns-fav-projects.store"; +import { toastsError } from "$lib/stores/toasts.store"; +import { isLastCall } from "$lib/utils/env.utils"; +import { + fromSnsFavProject, + toSnsFavProject, +} from "$lib/utils/sns-fav-projects.utils"; +import type { Principal } from "@dfinity/principal"; +import { isNullish } from "@dfinity/utils"; +import { get } from "svelte/store"; + +export const loadSnsFavProjects = async () => { + return queryAndUpdate({ + request: (options) => getFavProjects(options), + strategy: FORCE_CALL_STRATEGY, + onLoad: ({ response: { fav_projects: favProjects }, certified }) => { + snsFavProjectsStore.set({ + rootCanisterIds: favProjects.map(fromSnsFavProject), + certified, + }); + }, + onError: ({ error: err, certified, strategy }) => { + console.error(err); + + if (err instanceof AccountNotFoundError) { + // When you log in with a new account for the first time, the account is created in the NNS dapp. + // If you request favorite projects before the account is created, an `AccountNotFound` error will be thrown. + // In this case, we can be sure that the user has no favorite projects. + snsFavProjectsStore.set({ + rootCanisterIds: [], + certified, + }); + return; + } + + if (!isLastCall({ strategy, certified })) { + return; + } + + // Explicitly handle only UPDATE errors + // Reset the store to avoid showing stale data. + snsFavProjectsStore.reset(); + + // Failing to load favorite projects is not a critical error, + // so we just log in console. + }, + logMessage: "Get Favorite Projects", + }); +}; + +// Save favorite projects to the nns-dapp backend. +// Returns an error if the operation fails. +const saveSnsFavProject = async ({ + projects, +}: { + projects: Principal[]; +}): Promise<{ err: Error | undefined }> => { + try { + const identity = await getAuthenticatedIdentity(); + await setFavProjects({ + identity, + favProjects: projects.map(toSnsFavProject), + }); + } catch (err) { + return { err: err as Error }; + } + + return { err: undefined }; +}; + +/** + * Add new favorite project and reload favorite projects from the `nns-dapp` backend to update the `snsFavProjectsStore`. + * No success toast is shown, as the state will be reflected on the UI immediately. + */ +export const addSnsFavProject = async ( + projectToAdd: Principal +): Promise<{ success: boolean }> => { + try { + startBusy({ + initiator: "fav-project-adding", + labelKey: "fav_projects.adding", + }); + + const projects = [ + ...(get(snsFavProjectsStore).rootCanisterIds ?? []), + projectToAdd, + ]; + const { err } = await saveSnsFavProject({ projects }); + + if (isNullish(err)) { + // Update the store. + await loadSnsFavProjects(); + return { success: true }; + } + + toastsError({ + labelKey: "error__fav_projects.adding_error", + }); + + return { success: false }; + } finally { + stopBusy("fav-project-adding"); + } +}; + +/** + * Remove favorite project and reload favorite projects from the `nns-dapp` backend to update the `snsFavProjectsStore`. + * No success toast is shown, as the state will be reflected on the UI immediately. + */ +export const removeSnsFavProject = async ( + rootCanisterId: Principal +): Promise<{ success: boolean }> => { + try { + startBusy({ + initiator: "fav-project-removing", + labelKey: "fav_projects.removing", + }); + + const remainingProjects = ( + get(snsFavProjectsStore).rootCanisterIds ?? [] + ).filter((id) => id.toText() !== rootCanisterId.toText()); + const { err } = await saveSnsFavProject({ projects: remainingProjects }); + + if (isNullish(err)) { + snsFavProjectsStore.remove(rootCanisterId); + + return { success: true }; + } + + toastsError({ + labelKey: "error__fav_projects.removing_error", + }); + + return { success: false }; + } finally { + stopBusy("fav-project-removing"); + } +}; diff --git a/frontend/src/lib/stores/busy.store.ts b/frontend/src/lib/stores/busy.store.ts index f63b40c1009..ed74b29f253 100644 --- a/frontend/src/lib/stores/busy.store.ts +++ b/frontend/src/lib/stores/busy.store.ts @@ -58,6 +58,8 @@ export type BusyStateInitiatorType = | "refresh-voting-power" | "change-neuron-visibility" | "reporting-neurons" + | "fav-project-adding" + | "fav-project-removing" | "reporting-transactions"; export interface BusyState { diff --git a/frontend/src/lib/stores/sns-fav-projects.store.ts b/frontend/src/lib/stores/sns-fav-projects.store.ts new file mode 100644 index 00000000000..fa5cc6e731a --- /dev/null +++ b/frontend/src/lib/stores/sns-fav-projects.store.ts @@ -0,0 +1,52 @@ +import { Principal } from "@dfinity/principal"; +import { writable } from "svelte/store"; + +export interface SnsFavProjectsStoreData { + rootCanisterIds: Principal[] | undefined; + certified: boolean | undefined; +} + +/** + * A store that contains user favorite projects + */ +const initSnsFavProjectsStore = () => { + const { update, subscribe, set } = writable({ + rootCanisterIds: undefined, + certified: undefined, + }); + + return { + subscribe, + + set({ + rootCanisterIds, + certified, + }: { + rootCanisterIds: Principal[]; + certified: boolean; + }) { + set({ + rootCanisterIds, + certified, + }); + }, + + remove(rootCanisterId: Principal) { + update(({ rootCanisterIds, certified }) => ({ + rootCanisterIds: rootCanisterIds?.filter( + (id) => id.toText() !== rootCanisterId.toText() + ), + certified, + })); + }, + + reset() { + set({ + rootCanisterIds: undefined, + certified: undefined, + }); + }, + }; +}; + +export const snsFavProjectsStore = initSnsFavProjectsStore(); diff --git a/frontend/src/lib/types/i18n.d.ts b/frontend/src/lib/types/i18n.d.ts index 9146d9ab4f4..350154b6162 100644 --- a/frontend/src/lib/types/i18n.d.ts +++ b/frontend/src/lib/types/i18n.d.ts @@ -949,6 +949,11 @@ interface I18nLaunchpad_cards { create_sns_proposal_vote: string; } +interface I18nFav_projects { + adding: string; + removing: string; +} + interface I18nSns_project_detail { swap_proposal: string; link_to_dashboard: string; @@ -1111,6 +1116,8 @@ interface I18nError__imported_tokens { interface I18nError__fav_projects { too_many: string; + adding_error: string; + removing_error: string; } interface I18nError__sns { @@ -1677,6 +1684,7 @@ interface I18n { sns_launchpad: I18nSns_launchpad; launchpad: I18nLaunchpad; launchpad_cards: I18nLaunchpad_cards; + fav_projects: I18nFav_projects; sns_project_detail: I18nSns_project_detail; sns_sale: I18nSns_sale; sns_neuron_detail: I18nSns_neuron_detail; diff --git a/frontend/src/lib/utils/sns-fav-projects.utils.ts b/frontend/src/lib/utils/sns-fav-projects.utils.ts new file mode 100644 index 00000000000..6f836b785c3 --- /dev/null +++ b/frontend/src/lib/utils/sns-fav-projects.utils.ts @@ -0,0 +1,10 @@ +import type { FavProject } from "$lib/canisters/nns-dapp/nns-dapp.types"; +import type { Principal } from "@dfinity/principal"; + +export const toSnsFavProject = (rootCanisterId: Principal): FavProject => ({ + root_canister_id: rootCanisterId, +}); + +export const fromSnsFavProject = ({ + root_canister_id, +}: FavProject): Principal => root_canister_id; From 27911a64e8a0476442fb3eea2a1f2d2c0a099651 Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Thu, 17 Jul 2025 12:20:41 +0200 Subject: [PATCH 09/11] test: sns fav project store --- .../lib/stores/sns-fav-projects.store.spec.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 frontend/src/tests/lib/stores/sns-fav-projects.store.spec.ts diff --git a/frontend/src/tests/lib/stores/sns-fav-projects.store.spec.ts b/frontend/src/tests/lib/stores/sns-fav-projects.store.spec.ts new file mode 100644 index 00000000000..4306f8f761f --- /dev/null +++ b/frontend/src/tests/lib/stores/sns-fav-projects.store.spec.ts @@ -0,0 +1,73 @@ +import { snsFavProjectsStore } from "$lib/stores/sns-fav-projects.store"; +import { principal } from "$tests/mocks/sns-projects.mock"; +import type { Principal } from "@dfinity/principal"; +import { get } from "svelte/store"; + +describe("sns-fav-projects-store", () => { + describe("snsFavProjectsStore", () => { + const projectA: Principal = principal(0); + const projectB: Principal = principal(2); + + it("should set fav projects", () => { + const rootCanisterIds = [projectA, projectB]; + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: undefined, + certified: undefined, + }); + + snsFavProjectsStore.set({ rootCanisterIds, certified: true }); + + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds, + certified: true, + }); + }); + + it("should remove from fav projects", () => { + snsFavProjectsStore.set({ + rootCanisterIds: [projectA, projectB], + certified: true, + }); + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: [projectA, projectB], + certified: true, + }); + + snsFavProjectsStore.remove(projectA); + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: [projectB], + certified: true, + }); + + snsFavProjectsStore.remove(projectB); + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: [], + certified: true, + }); + }); + + it("should reset fav projects", () => { + const favProjects = [projectA, projectB]; + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: undefined, + certified: undefined, + }); + + snsFavProjectsStore.set({ + rootCanisterIds: favProjects, + certified: true, + }); + + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: favProjects, + certified: true, + }); + snsFavProjectsStore.reset(); + + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: undefined, + certified: undefined, + }); + }); + }); +}); From 0bda5c4447132d75bf8cd43615d07767eea85058 Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Thu, 17 Jul 2025 12:20:55 +0200 Subject: [PATCH 10/11] test: fav projects services --- .../sns-fav-projects.services.spec.ts | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 frontend/src/tests/lib/services/sns-fav-projects.services.spec.ts diff --git a/frontend/src/tests/lib/services/sns-fav-projects.services.spec.ts b/frontend/src/tests/lib/services/sns-fav-projects.services.spec.ts new file mode 100644 index 00000000000..e9c49615326 --- /dev/null +++ b/frontend/src/tests/lib/services/sns-fav-projects.services.spec.ts @@ -0,0 +1,306 @@ +import * as favProjectsApi from "$lib/api/fav-projects.api"; +import { AccountNotFoundError } from "$lib/canisters/nns-dapp/nns-dapp.errors"; +import type { FavProject } from "$lib/canisters/nns-dapp/nns-dapp.types"; +import { + addSnsFavProject, + loadSnsFavProjects, + removeSnsFavProject, +} from "$lib/services/sns.fav-projects.services"; +import { snsFavProjectsStore } from "$lib/stores/sns-fav-projects.store"; +import { mockIdentity, resetIdentity } from "$tests/mocks/auth.store.mock"; +import { principal } from "$tests/mocks/sns-projects.mock"; +import { runResolvedPromises } from "$tests/utils/timers.test-utils"; +import { busyStore, toastsStore } from "@dfinity/gix-components"; +import * as dfinityUtils from "@dfinity/utils"; +import { get } from "svelte/store"; + +describe("sns-fav-projects-services", () => { + const rootCanisterIdA = principal(1); + const rootCanisterIdB = principal(2); + const favProjectA: FavProject = { + root_canister_id: rootCanisterIdA, + }; + const favProjectB: FavProject = { + root_canister_id: rootCanisterIdB, + }; + const testError = new Error("test"); + + beforeEach(() => { + resetIdentity(); + vi.spyOn(dfinityUtils, "createAgent").mockReturnValue(undefined); + }); + + describe("loadSnsFavProjects", () => { + it("should call getFavProjects and load favorite projects in store", async () => { + const spyGetFavProjects = vi + .spyOn(favProjectsApi, "getFavProjects") + .mockResolvedValue({ + fav_projects: [favProjectA, favProjectB], + }); + + expect(spyGetFavProjects).toBeCalledTimes(0); + + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: undefined, + certified: undefined, + }); + + await loadSnsFavProjects(); + + expect(spyGetFavProjects).toBeCalledTimes(2); + expect(spyGetFavProjects).toBeCalledWith({ + certified: false, + identity: mockIdentity, + }); + expect(spyGetFavProjects).toBeCalledWith({ + certified: true, + identity: mockIdentity, + }); + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: [rootCanisterIdA, rootCanisterIdB], + certified: true, + }); + }); + + it("should not display toast on error", async () => { + vi.spyOn(console, "error").mockReturnValue(); + + const spyGetFavProjects = vi + .spyOn(favProjectsApi, "getFavProjects") + .mockRejectedValue(testError); + + expect(spyGetFavProjects).toBeCalledTimes(0); + expect(get(toastsStore)).toEqual([]); + + await loadSnsFavProjects(); + + expect(get(toastsStore)).toEqual([]); + // Should not display toast - it's logged to console only + expect(spyGetFavProjects).toBeCalledTimes(2); + }); + + it("should reset store on error", async () => { + vi.spyOn(console, "error").mockReturnValue(); + vi.spyOn(favProjectsApi, "getFavProjects").mockRejectedValue(testError); + + snsFavProjectsStore.set({ + rootCanisterIds: [rootCanisterIdA], + certified: true, + }); + + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: [rootCanisterIdA], + certified: true, + }); + + await loadSnsFavProjects(); + + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: undefined, + certified: undefined, + }); + }); + + it("should handle AccountNotFoundError (no error, empty favorite projects)", async () => { + const accountNotFoundError = new AccountNotFoundError("test"); + vi.spyOn(favProjectsApi, "getFavProjects").mockRejectedValue( + accountNotFoundError + ); + vi.spyOn(console, "error").mockReturnValue(); + + expect(get(toastsStore)).toEqual([]); + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: undefined, + certified: undefined, + }); + + await loadSnsFavProjects(); + + expect(get(toastsStore)).toEqual([]); + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: [], + certified: true, + }); + }); + }); + + describe("addSnsFavProject", () => { + it("should call setFavProjects with updated project list", async () => { + const spySetFavProjects = vi + .spyOn(favProjectsApi, "setFavProjects") + .mockResolvedValue(undefined); + const spyGetFavProjects = vi + .spyOn(favProjectsApi, "getFavProjects") + .mockResolvedValue({ + fav_projects: [favProjectA, favProjectB], + }); + + snsFavProjectsStore.set({ + rootCanisterIds: [rootCanisterIdA], + certified: true, + }); + + expect(spySetFavProjects).toBeCalledTimes(0); + + const { success } = await addSnsFavProject(principal(2)); + + expect(success).toEqual(true); + expect(spySetFavProjects).toBeCalledTimes(1); + expect(spySetFavProjects).toBeCalledWith({ + identity: mockIdentity, + favProjects: [favProjectA, favProjectB], + }); + // Should reload projects + expect(spyGetFavProjects).toBeCalledTimes(2); + }); + + it("should display busy store", async () => { + let resolveSetFavProjects; + const spyOnSetFavProjects = vi + .spyOn(favProjectsApi, "setFavProjects") + .mockImplementation( + () => + new Promise((resolve) => (resolveSetFavProjects = resolve)) + ); + vi.spyOn(favProjectsApi, "getFavProjects").mockResolvedValue({ + fav_projects: [favProjectA, favProjectB], + }); + + snsFavProjectsStore.set({ + rootCanisterIds: [rootCanisterIdA], + certified: true, + }); + + expect(spyOnSetFavProjects).toBeCalledTimes(0); + expect(get(busyStore)).toEqual([]); + + addSnsFavProject(rootCanisterIdB); + await runResolvedPromises(); + + expect(spyOnSetFavProjects).toBeCalledTimes(1); + expect(get(busyStore)).toEqual([ + { + initiator: "fav-project-adding", + text: "Adding project to favorites", + }, + ]); + + resolveSetFavProjects(); + await runResolvedPromises(); + + expect(get(busyStore)).toEqual([]); + }); + + it("should display toast on error", async () => { + vi.spyOn(favProjectsApi, "setFavProjects").mockRejectedValue(testError); + + snsFavProjectsStore.set({ + rootCanisterIds: [rootCanisterIdA], + certified: true, + }); + + expect(get(toastsStore)).toEqual([]); + + const { success } = await addSnsFavProject(rootCanisterIdB); + + expect(success).toEqual(false); + expect(get(toastsStore)).toMatchObject([ + { + level: "error", + text: "There was an error while adding the project to favorites.", + }, + ]); + }); + }); + + describe("removeSnsFavProject", () => { + it("should call setFavProjects with updated project list", async () => { + const spyGetFavProjects = vi + .spyOn(favProjectsApi, "getFavProjects") + .mockResolvedValue({ + fav_projects: [favProjectA, favProjectB], + }); + const spySetFavProjects = vi + .spyOn(favProjectsApi, "setFavProjects") + .mockResolvedValue(undefined); + + snsFavProjectsStore.set({ + rootCanisterIds: [rootCanisterIdA, rootCanisterIdB], + certified: true, + }); + + expect(spySetFavProjects).toBeCalledTimes(0); + + const { success } = await removeSnsFavProject(rootCanisterIdA); + + expect(success).toEqual(true); + expect(spySetFavProjects).toBeCalledTimes(1); + expect(spySetFavProjects).toBeCalledWith({ + identity: mockIdentity, + favProjects: [favProjectB], + }); + + expect(get(snsFavProjectsStore)).toEqual({ + rootCanisterIds: [rootCanisterIdB], + certified: true, + }); + // Should not reload projects + expect(spyGetFavProjects).toBeCalledTimes(0); + }); + + it("should display busy store", async () => { + let resolveSetFavProjects; + const spyOnSetFavProjects = vi + .spyOn(favProjectsApi, "setFavProjects") + .mockImplementation( + () => + new Promise((resolve) => (resolveSetFavProjects = resolve)) + ); + + snsFavProjectsStore.set({ + rootCanisterIds: [rootCanisterIdA, rootCanisterIdB], + certified: true, + }); + + expect(spyOnSetFavProjects).toBeCalledTimes(0); + expect(get(busyStore)).toEqual([]); + + removeSnsFavProject(rootCanisterIdA); + await runResolvedPromises(); + + expect(spyOnSetFavProjects).toBeCalledTimes(1); + expect(get(busyStore)).toEqual([ + { + initiator: "fav-project-removing", + text: "Removing project from favorites", + }, + ]); + + resolveSetFavProjects(); + await runResolvedPromises(); + + expect(get(busyStore)).toEqual([]); + }); + + it("should display toast on error", async () => { + vi.spyOn(favProjectsApi, "setFavProjects").mockRejectedValue(testError); + + snsFavProjectsStore.set({ + rootCanisterIds: [rootCanisterIdA, rootCanisterIdB], + certified: true, + }); + + expect(get(toastsStore)).toEqual([]); + + const { success } = await removeSnsFavProject(rootCanisterIdA); + + expect(success).toEqual(false); + expect(get(toastsStore)).toMatchObject([ + { + level: "error", + text: "There was an error while removing the project from favorites.", + }, + ]); + }); + }); +}); From 374dd55aaad1f9e46aef5f4059d3cf3a46624ac2 Mon Sep 17 00:00:00 2001 From: Max Strasinsky Date: Thu, 17 Jul 2025 19:49:59 +0200 Subject: [PATCH 11/11] Load fav projects on app init --- frontend/src/lib/services/app.services.ts | 9 ++-- .../tests/lib/services/app.services.spec.ts | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/services/app.services.ts b/frontend/src/lib/services/app.services.ts index 5a3e132bca7..093b1d4338e 100644 --- a/frontend/src/lib/services/app.services.ts +++ b/frontend/src/lib/services/app.services.ts @@ -6,6 +6,9 @@ import { loadImportedTokens } from "$lib/services/imported-tokens.services"; import { loadNetworkEconomicsParameters } from "$lib/services/network-economics.services"; import { loadNnsTotalVotingPower } from "$lib/services/nns-total-voting-power.service"; import { loadSnsProjects } from "$lib/services/public/sns.services"; +import { loadSnsFavProjects } from "$lib/services/sns.fav-projects.services"; +import { ENABLE_LAUNCHPAD_REDESIGN } from "$lib/stores/feature-flags.store"; +import { get } from "svelte/store"; export const initAppPrivateData = async (): Promise => { const initNetworkEconomicsParameters: Promise[] = [ @@ -15,9 +18,9 @@ export const initAppPrivateData = async (): Promise => { // Reload the SNS projects even if they were loaded. // Get latest data and create wrapper caches for the logged in identity. const initSns: Promise[] = [loadSnsProjects()]; - // Load imported tokens - const initImportedTokens: Promise[] = [ + const initNnsDappUserData = (): Promise[] => [ loadImportedTokens({ ignoreAccountNotFoundError: true }), + ...(get(ENABLE_LAUNCHPAD_REDESIGN) ? [loadSnsFavProjects()] : []), ]; const initGovernanceMetrics: Promise[] = [loadGovernanceMetrics()]; /** @@ -27,7 +30,7 @@ export const initAppPrivateData = async (): Promise => { Promise.all(initNetworkEconomicsParameters), Promise.all(initGovernanceMetrics), Promise.all(initNns), - Promise.all(initImportedTokens), + Promise.all(initNnsDappUserData()), Promise.all(initSns), ]); diff --git a/frontend/src/tests/lib/services/app.services.spec.ts b/frontend/src/tests/lib/services/app.services.spec.ts index 8f14e61d8bf..2814a8b7d96 100644 --- a/frontend/src/tests/lib/services/app.services.spec.ts +++ b/frontend/src/tests/lib/services/app.services.spec.ts @@ -1,5 +1,6 @@ import { clearSnsAggregatorCache } from "$lib/api-services/sns-aggregator.api-service"; import * as agent from "$lib/api/agent.api"; +import * as favProjectsApi from "$lib/api/fav-projects.api"; import * as governanceApi from "$lib/api/governance.api"; import * as aggregatorApi from "$lib/api/sns-aggregator.api"; import { NNSDappCanister } from "$lib/canisters/nns-dapp/nns-dapp.canister"; @@ -19,6 +20,8 @@ import type { HttpAgent } from "@dfinity/agent"; import { toastsStore } from "@dfinity/gix-components"; import { LedgerCanister } from "@dfinity/ledger-icp"; import { get } from "svelte/store"; + +import { overrideFeatureFlagsStore } from "$lib/stores/feature-flags.store"; import { mock } from "vitest-mock-extended"; vi.mock("$lib/api/sns-aggregator.api"); @@ -148,6 +151,46 @@ describe("app-services", () => { }); }); + it("should load fav projects", async () => { + overrideFeatureFlagsStore.setFlag("ENABLE_LAUNCHPAD_REDESIGN", true); + const spyGetFavProjects = vi + .spyOn(favProjectsApi, "getFavProjects") + .mockResolvedValue({ + fav_projects: [], + }); + + expect(spyGetFavProjects).toBeCalledTimes(0); + + initAppPrivateData(); + await runResolvedPromises(); + + expect(spyGetFavProjects).toHaveBeenCalledTimes(2); + expect(spyGetFavProjects).toHaveBeenCalledWith({ + certified: false, + identity: mockIdentity, + }); + expect(spyGetFavProjects).toHaveBeenCalledWith({ + certified: true, + identity: mockIdentity, + }); + }); + + it("should not load fav projects without feature flag", async () => { + overrideFeatureFlagsStore.setFlag("ENABLE_LAUNCHPAD_REDESIGN", false); + const spyGetFavProjects = vi + .spyOn(favProjectsApi, "getFavProjects") + .mockResolvedValue({ + fav_projects: [], + }); + + expect(spyGetFavProjects).toBeCalledTimes(0); + + initAppPrivateData(); + await runResolvedPromises(); + + expect(spyGetFavProjects).toHaveBeenCalledTimes(0); + }); + it("should load network economics", async () => { const spyGetNetworkEconomicsParameters = vi .spyOn(governanceApi, "getNetworkEconomicsParameters")