From 7cacd5f46cc300541eb72185e5dc4f88c53b88ce Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:40:30 +0100 Subject: [PATCH] Add SafeAccount multi-account system - Add SafeAccount and SafeAccountAccess entities - Add SafeAccountService with legacy mode fallback - Add SafeAccountController with CRUD endpoints - Add SafeAccountReadGuard and SafeAccountWriteGuard - Extend User, CustodyBalance, CustodyOrder with safeAccount relations - Add database migration for new tables --- src/dto/safe.dto.ts | 36 ++++++++++ src/hooks/safe.hook.ts | 157 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 176 insertions(+), 17 deletions(-) diff --git a/src/dto/safe.dto.ts b/src/dto/safe.dto.ts index ac153338f..9715a0def 100644 --- a/src/dto/safe.dto.ts +++ b/src/dto/safe.dto.ts @@ -53,3 +53,39 @@ export interface CustodyHistoryEntry { export interface CustodyHistory { totalValue: CustodyHistoryEntry[]; } + +// SafeAccount types + +export enum SafeAccessLevel { + READ = 'Read', + WRITE = 'Write', +} + +export interface SafeAccountOwner { + id: number; +} + +export interface SafeAccount { + id: number | null; + title: string; + description?: string; + isLegacy: boolean; + accessLevel: SafeAccessLevel; + owner?: SafeAccountOwner; +} + +export interface SafeAccountAccess { + id: number; + userDataId: number; + accessLevel: SafeAccessLevel; +} + +export interface CreateSafeAccountDto { + title: string; + description?: string; +} + +export interface UpdateSafeAccountDto { + title?: string; + description?: string; +} diff --git a/src/hooks/safe.hook.ts b/src/hooks/safe.hook.ts index f6c61a07e..3484811dc 100644 --- a/src/hooks/safe.hook.ts +++ b/src/hooks/safe.hook.ts @@ -14,7 +14,17 @@ import { } from '@dfx.swiss/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CustodyOrderType, OrderPaymentInfo } from 'src/dto/order.dto'; -import { CustodyAsset, CustodyBalance, CustodyHistory, CustodyHistoryEntry } from 'src/dto/safe.dto'; +import { + CreateSafeAccountDto, + CustodyAsset, + CustodyBalance, + CustodyHistory, + CustodyHistoryEntry, + SafeAccessLevel, + SafeAccount, + SafeAccountAccess, + UpdateSafeAccountDto, +} from 'src/dto/safe.dto'; import { OrderFormData } from './order.hook'; import { downloadPdfFromString } from 'src/util/utils'; @@ -70,6 +80,15 @@ export interface UseSafeResult { confirmSend: () => Promise; pairMap: (asset: string) => Asset | Fiat | undefined; downloadPdf: (params: PdfDownloadParams) => Promise; + // SafeAccount + safeAccounts: SafeAccount[]; + selectedSafeAccount?: SafeAccount; + isLoadingSafeAccounts: boolean; + selectSafeAccount: (safeAccount: SafeAccount) => void; + createSafeAccount: (data: CreateSafeAccountDto) => Promise; + updateSafeAccount: (id: number, data: UpdateSafeAccountDto) => Promise; + getSafeAccountAccess: (id: number) => Promise; + canWrite: boolean; } export function useSafe(): UseSafeResult { @@ -93,6 +112,58 @@ export function useSafe(): UseSafeResult { const [isLoadingHistory, setIsLoadingHistory] = useState(true); const [selectedSourceAsset, setSelectedSourceAsset] = useState(); + // SafeAccount state + const [safeAccounts, setSafeAccounts] = useState([]); + const [selectedSafeAccount, setSelectedSafeAccount] = useState(); + const [isLoadingSafeAccounts, setIsLoadingSafeAccounts] = useState(true); + + // ---- API Calls (defined early for use in effects) ---- + + async function createCustodyUser(): Promise { + return call({ + url: 'custody', + method: 'POST', + data: { addressType: 'EVM' }, + }); + } + + async function getBalances(safeAccount?: SafeAccount): Promise { + // Use safe-account endpoint for non-legacy accounts + if (safeAccount && !safeAccount.isLegacy && safeAccount.id !== null) { + return call({ + url: `safe-account/${safeAccount.id}/balance`, + method: 'GET', + }); + } + // Legacy: use old custody endpoint + return call({ + url: `custody`, + method: 'GET', + }); + } + + async function getHistory(safeAccount?: SafeAccount): Promise { + // Use safe-account endpoint for non-legacy accounts + if (safeAccount && !safeAccount.isLegacy && safeAccount.id !== null) { + return call({ + url: `safe-account/${safeAccount.id}/history`, + method: 'GET', + }); + } + // Legacy: use old custody endpoint + return call({ + url: `custody/history`, + method: 'GET', + }); + } + + async function fetchSafeAccounts(): Promise { + return call({ + url: 'safe-account', + method: 'GET', + }); + } + // ---- Safe Screen Initialization ---- useEffect(() => { @@ -119,23 +190,39 @@ export function useSafe(): UseSafeResult { } }, [isUserLoading, user, isLoggedIn, session, reloadUser, changeUserAddress, tokenStore]); + // Load SafeAccounts + useEffect(() => { + if (!user || !isLoggedIn) return; + setIsLoadingSafeAccounts(true); + fetchSafeAccounts() + .then((accounts: SafeAccount[]) => { + setSafeAccounts(accounts); + // Auto-select first account if none selected + if (accounts.length > 0 && !selectedSafeAccount) { + setSelectedSafeAccount(accounts[0]); + } + }) + .catch((error: ApiError) => setError(error.message ?? 'Unknown error')) + .finally(() => setIsLoadingSafeAccounts(false)); + }, [user, isLoggedIn]); + useEffect(() => { if (!user || !isLoggedIn) return; setIsLoadingPortfolio(true); - getBalances() + getBalances(selectedSafeAccount) .then((portfolio) => setPortfolio(portfolio)) .catch((error: ApiError) => setError(error.message ?? 'Unknown error')) .finally(() => setIsLoadingPortfolio(false)); - }, [user, isLoggedIn]); + }, [user, isLoggedIn, selectedSafeAccount]); useEffect(() => { if (!user || !isLoggedIn) return; setIsLoadingHistory(true); - getHistory() + getHistory(selectedSafeAccount) .then(({ totalValue }) => setHistory(totalValue)) .catch((error: ApiError) => setError(error.message ?? 'Unknown error')) .finally(() => setIsLoadingHistory(false)); - }, [user, isLoggedIn]); + }, [user, isLoggedIn, selectedSafeAccount]); // ---- Available Deposit Pairs ---- @@ -194,30 +281,53 @@ export function useSafe(): UseSafeResult { [availableAssets, availableCurrencies], ); - // ---- API Calls ---- + // ---- SafeAccount API Calls ---- - async function createCustodyUser(): Promise { - return call({ - url: 'custody', + async function createSafeAccountApi(data: CreateSafeAccountDto): Promise { + const newAccount = await call({ + url: 'safe-account', method: 'POST', - data: { addressType: 'EVM' }, + data, }); + // Reload safe accounts after creation + const accounts = await fetchSafeAccounts(); + setSafeAccounts(accounts); + return newAccount; } - async function getBalances(): Promise { - return call({ - url: `custody`, - method: 'GET', + async function updateSafeAccountApi(id: number, data: UpdateSafeAccountDto): Promise { + const updatedAccount = await call({ + url: `safe-account/${id}`, + method: 'PUT', + data, }); + // Reload safe accounts after update + const accounts = await fetchSafeAccounts(); + setSafeAccounts(accounts); + // Update selected if it was the updated one + if (selectedSafeAccount?.id === id) { + setSelectedSafeAccount(updatedAccount); + } + return updatedAccount; } - async function getHistory(): Promise { - return call({ - url: `custody/history`, + async function getSafeAccountAccessApi(id: number): Promise { + return call({ + url: `safe-account/${id}/access`, method: 'GET', }); } + function selectSafeAccount(safeAccount: SafeAccount): void { + setSelectedSafeAccount(safeAccount); + } + + const canWrite = useMemo(() => { + return selectedSafeAccount?.accessLevel === SafeAccessLevel.WRITE; + }, [selectedSafeAccount]); + + // ---- Order API Calls ---- + async function fetchPaymentInfo(data: OrderFormData): Promise { const order = await call({ url: 'custody/order', @@ -382,6 +492,15 @@ export function useSafe(): UseSafeResult { confirmSend, pairMap, downloadPdf, + // SafeAccount + safeAccounts, + selectedSafeAccount, + isLoadingSafeAccounts, + selectSafeAccount, + createSafeAccount: createSafeAccountApi, + updateSafeAccount: updateSafeAccountApi, + getSafeAccountAccess: getSafeAccountAccessApi, + canWrite, }), [ isInitialized, @@ -402,6 +521,10 @@ export function useSafe(): UseSafeResult { swappableTargetAssets, selectedSourceAsset, pairMap, + safeAccounts, + selectedSafeAccount, + isLoadingSafeAccounts, + canWrite, ], ); }