diff --git a/packages/features/src/common/hooks/use-account.ts b/packages/features/src/common/hooks/use-account.ts index 6022fc04..3c5343bb 100644 --- a/packages/features/src/common/hooks/use-account.ts +++ b/packages/features/src/common/hooks/use-account.ts @@ -1,3 +1,5 @@ +import type { AccountToken } from "@/common/types.ts" +import { formatMina } from "@mina-js/utils" import { Network, getAccountProperties } from "@palladco/pallad-core" import { sessionPersistence } from "@palladco/vault" import { getPublicKey, isDelegated, useVault } from "@palladco/vault" @@ -20,13 +22,16 @@ export const useAccount = () => { ) const fetchWallet = async () => { await _syncWallet() - const accountInfo = getAccountsInfo(currentNetworkId, publicKey) + const accountsInfo = getAccountsInfo( + currentNetworkId, + publicKey, + ).accountInfo const chain = currentWallet.credential.credential?.chain - const props = getAccountProperties( - accountInfo.accountInfo, - chain ?? Network.Mina, - ) - return props + const props = getAccountProperties(accountsInfo, chain ?? Network.Mina) + return { + ...props, + accountsInfo, + } } const publicKey = getPublicKey(currentWallet) const swr = useSWR( @@ -36,9 +41,27 @@ export const useAccount = () => { refreshInterval: 30000, }, ) - const rawBalance = swr.isLoading ? 0 : (swr.data?.balance ?? 0) - const minaBalance = - rawBalance && Number.parseInt(String(rawBalance)) / 1_000_000_000 + const rawBalance = useMemo( + () => (swr.isLoading ? 0 : (swr.data?.balance ?? 0)), + [swr], + ) + const minaBalance = useMemo( + () => rawBalance && formatMina(BigInt(rawBalance)), + [rawBalance], + ) + const tokens: AccountToken[] | undefined = useMemo(() => { + if (!swr.isLoading) { + const accountsInfo = swr.data?.accountsInfo + if (accountsInfo) { + return Object.keys(accountsInfo).map((tokenSymbol) => ({ + tokenSymbol, + balance: { + total: BigInt(accountsInfo[tokenSymbol].balance.total), + }, + })) + } + } + }, [swr]) const gradientBackground = useMemo( () => publicKey && @@ -67,6 +90,7 @@ export const useAccount = () => { ...swr, fetchWallet, minaBalance, + tokens, gradientBackground, copyWalletAddress, currentWallet, diff --git a/packages/features/src/common/types.ts b/packages/features/src/common/types.ts index d70f74f0..d7d0604f 100644 --- a/packages/features/src/common/types.ts +++ b/packages/features/src/common/types.ts @@ -64,6 +64,13 @@ export type Account = { } } +export type AccountToken = { + tokenSymbol: string + balance: { + total: bigint + } +} + export type Contact = { name: string address: string diff --git a/packages/features/src/lib/locales/en/en.json b/packages/features/src/lib/locales/en/en.json index 2ab3ec09..ccb2edd0 100644 --- a/packages/features/src/lib/locales/en/en.json +++ b/packages/features/src/lib/locales/en/en.json @@ -129,6 +129,8 @@ "detailsGetStarted": "Here you'll find details about your transactions. Fund your wallet to get started!" }, "wallet": { + "assets": "Assets", + "tokens": "Tokens", "openBeta": "Open Beta version", "onlyWorksForDevnet": "Only works for Devnet before Mainnet launch", "available": "Available", diff --git a/packages/features/src/lib/locales/tr/tr.json b/packages/features/src/lib/locales/tr/tr.json index 3e54a756..a1cbb92c 100644 --- a/packages/features/src/lib/locales/tr/tr.json +++ b/packages/features/src/lib/locales/tr/tr.json @@ -129,6 +129,8 @@ "detailsGetStarted": "İşlemlerinizle ilgili ayrıntıları burada bulabilirsiniz. Başlamak için cüzdanınıza para yatırın!" }, "wallet": { + "assets": "Varlıklar", + "tokens": "Tokenlar", "openBeta": "Açık Beta sürümü", "onlyWorksForDevnet": "Yalnızca Mainnet lansmanından önceki Devnet için çalışır", "available": "Kullanılabilir", diff --git a/packages/features/src/wallet/index.stories.tsx b/packages/features/src/wallet/index.stories.tsx index 2a81521c..76c517ae 100644 --- a/packages/features/src/wallet/index.stories.tsx +++ b/packages/features/src/wallet/index.stories.tsx @@ -9,6 +9,7 @@ export const Dashboard = () => { { minaBalance={200} setUseFiatBalance={action("Set Use Fiat Balance")} useFiatBalance={true} + setIsAssetsView={action("Set Is Assets View")} + isAssetsView={false} + tokens={[]} /> ) } diff --git a/packages/features/src/wallet/routes/overview.tsx b/packages/features/src/wallet/routes/overview.tsx index 0520d307..b9007ef4 100644 --- a/packages/features/src/wallet/routes/overview.tsx +++ b/packages/features/src/wallet/routes/overview.tsx @@ -42,12 +42,14 @@ export const OverviewRoute = () => { const dailyPriceDiffMina = ( Number(dailyPriceDiffFiat) / (minaPrice ?? 1) ).toFixed(2) + const minaDailyPriceDiffText = `${dailyPriceDiff >= 0 ? "+" : ""}${ + useFiatBalance ? dailyPriceDiffFiat : dailyPriceDiffMina + }` const chartLabel = typeof currentPriceIndex === "undefined" - ? `${dailyPriceDiff >= 0 ? "+" : ""}${ - useFiatBalance ? dailyPriceDiffFiat : dailyPriceDiffMina - } (24h)` + ? `${minaDailyPriceDiffText} (24h)` : dayjs(lastMonthPrices[currentPriceIndex]?.[0]).format("MMM D") + const [isAssetsView, setIsAssetsView] = useState(true) return ( { onReceive={() => navigate("/receive")} useFiatBalance={useFiatBalance} setUseFiatBalance={setUseFiatBalance} + isAssetsView={isAssetsView} + setIsAssetsView={setIsAssetsView} + tokens={account.tokens} + minaDailyPriceDiffText={minaDailyPriceDiffText} /> ) } diff --git a/packages/features/src/wallet/views/overview.tsx b/packages/features/src/wallet/views/overview.tsx index 2b06031a..38aaaf2c 100644 --- a/packages/features/src/wallet/views/overview.tsx +++ b/packages/features/src/wallet/views/overview.tsx @@ -1,7 +1,9 @@ import ArrowRightIcon from "@/common/assets/arrow-right.svg?react" +import type { AccountToken } from "@/common/types.ts" import { AppLayout } from "@/components/app-layout" import { MenuBar } from "@/components/menu-bar" import { Skeleton } from "@/components/skeleton" +import { formatMina } from "@mina-js/utils" import type { Tx } from "@palladco/pallad-core" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" @@ -23,6 +25,10 @@ type OverviewViewProps = { onReceive: () => void useFiatBalance: boolean setUseFiatBalance: (useFiatBalance: boolean) => void + isAssetsView: boolean + setIsAssetsView: (isAssetsView: boolean) => void + tokens: AccountToken[] | undefined + minaDailyPriceDiffText: string } export const OverviewView = ({ @@ -39,6 +45,10 @@ export const OverviewView = ({ onReceive, useFiatBalance, setUseFiatBalance, + isAssetsView, + setIsAssetsView, + tokens, + minaDailyPriceDiffText, }: OverviewViewProps) => { const [bucks, cents] = (useFiatBalance ? fiatBalance : minaBalance) .toFixed(2) @@ -102,34 +112,94 @@ export const OverviewView = ({
-
-
-

{t("wallet.recent")}

-

{t("wallet.transactions")}

-
- - {t("wallet.seeAll")} - - -
-
- {loading ? ( - <> - - - - ) : transactions.length > 0 ? ( - transactions.map((tx) => ( - - )) - ) : ( -

{t("wallet.noTransactionsYet")}

- )} +
+ +
+ {isAssetsView ? ( + <> +

{t("wallet.tokens")}

+
+ {tokens === undefined ? ( + <> + + + + ) : ( + tokens.map((token) => ( +
+
+
+ {token.tokenSymbol[0].toUpperCase()} +
+
+

{token.tokenSymbol}

+

+ {token.tokenSymbol === "MINA" + ? minaDailyPriceDiffText + : "-"} +

+
+
+
+

{formatMina(token.balance.total)}

+

+ {token.tokenSymbol === "MINA" + ? `$${fiatBalance.toFixed(2)}` + : "-"} +

+
+
+ )) + )} +
+ + ) : ( + <> +
+

{t("wallet.transactions")}

+ + {t("wallet.seeAll")} + + +
+
+ {loading ? ( + <> + + + + ) : transactions.length > 0 ? ( + transactions.map((tx) => ( + + )) + ) : ( +

{t("wallet.noTransactionsYet")}

+ )} +
+ + )}
) diff --git a/packages/pallad-core/src/Mina/Providers/account-info-provider/types.ts b/packages/pallad-core/src/Mina/Providers/account-info-provider/types.ts index e130a22a..ea3f429e 100644 --- a/packages/pallad-core/src/Mina/Providers/account-info-provider/types.ts +++ b/packages/pallad-core/src/Mina/Providers/account-info-provider/types.ts @@ -17,6 +17,7 @@ export interface AccountInfo { inferredNonce: number delegate: string publicKey: Mina.PublicKey + tokenId: string } export interface AccountInfoProvider extends Provider { diff --git a/packages/pallad-core/src/Mina/Providers/index.ts b/packages/pallad-core/src/Mina/Providers/index.ts index 78e95f0e..dd9135cd 100644 --- a/packages/pallad-core/src/Mina/Providers/index.ts +++ b/packages/pallad-core/src/Mina/Providers/index.ts @@ -1,4 +1,5 @@ export * from "./account-info-provider" +export * from "./token-info-provider" export * from "./chain-history-provider" export * from "./daemon-status-provider" export * from "./provider" diff --git a/packages/pallad-core/src/Mina/Providers/token-info-provider/index.ts b/packages/pallad-core/src/Mina/Providers/token-info-provider/index.ts new file mode 100644 index 00000000..51f739d0 --- /dev/null +++ b/packages/pallad-core/src/Mina/Providers/token-info-provider/index.ts @@ -0,0 +1 @@ +export * from "./types" diff --git a/packages/pallad-core/src/Mina/Providers/token-info-provider/types.ts b/packages/pallad-core/src/Mina/Providers/token-info-provider/types.ts new file mode 100644 index 00000000..01d76739 --- /dev/null +++ b/packages/pallad-core/src/Mina/Providers/token-info-provider/types.ts @@ -0,0 +1,19 @@ +import type { Provider } from "../.." + +export type TokenInfoArgs = { + tokenIds: string[] +} + +export interface TokenInfo { + tokenSymbol: string +} + +export interface TokenInfoProvider extends Provider { + /** + * Gets token info for the tokenIds provided in the arguments + * + * @param {string[]} tokenIds - tokenIds + * @returns {Record} - An object with token info objects + */ + getTokenInfo: (args: TokenInfoArgs) => Promise> +} diff --git a/packages/pallad-core/src/Mina/Providers/unified-mina-provider.ts b/packages/pallad-core/src/Mina/Providers/unified-mina-provider.ts index 2f3f8a2d..8d3aeba0 100644 --- a/packages/pallad-core/src/Mina/Providers/unified-mina-provider.ts +++ b/packages/pallad-core/src/Mina/Providers/unified-mina-provider.ts @@ -7,6 +7,7 @@ import type { } from "./chain-history-provider" import type { DaemonStatus } from "./daemon-status-provider" import type { HealthCheckResponse } from "./provider" +import type { TokenInfo, TokenInfoArgs } from "./token-info-provider" import type { TxStatus, TxStatusArgs } from "./tx-status-provider" import type { SubmitTxArgs, SubmitTxResult } from "./tx-submit-provider" @@ -26,6 +27,8 @@ export interface UnifiedMinaProviderType { getTransactionStatus?(args: TxStatusArgs): Promise submitTransaction(args: SubmitTxArgs): Promise + getTokenInfo(args: TokenInfoArgs): Promise> + // Methods related to ProviderArchive getTransactions( args: TransactionsByAddressesArgs, diff --git a/packages/pallad-core/src/Pallad/providers/account-info-provider/types.ts b/packages/pallad-core/src/Pallad/providers/account-info-provider/types.ts index 4ef41fec..dd9fa52b 100644 --- a/packages/pallad-core/src/Pallad/providers/account-info-provider/types.ts +++ b/packages/pallad-core/src/Pallad/providers/account-info-provider/types.ts @@ -19,6 +19,7 @@ export interface AccountInfo { inferredNonce: number delegate?: string publicKey: Mina.PublicKey | Address + tokenId: string } export interface AccountInfoProvider extends Provider { @@ -31,4 +32,12 @@ export interface AccountInfoProvider extends Provider { getAccountInfo: ( args: AccountInfoArgs, ) => Promise> + + /** + * Gets the account balance and information based for all accounts of a public key. + * + * @param {Mina.PublicKey | Address} publicKey - Public Key of the account + * @returns {AccountInfo[]} - An array of balance and account information + */ + getAccountsInfo: (args: AccountInfoArgs) => Promise } diff --git a/packages/pallad-core/src/Pallad/providers/index.ts b/packages/pallad-core/src/Pallad/providers/index.ts index 2e5e04f5..7b46bf59 100644 --- a/packages/pallad-core/src/Pallad/providers/index.ts +++ b/packages/pallad-core/src/Pallad/providers/index.ts @@ -1,4 +1,5 @@ export * from "./account-info-provider" +export * from "./token-info-provider" export * from "./chain-history-provider" export * from "./node-status-provider" export * from "./provider" diff --git a/packages/pallad-core/src/Pallad/providers/token-info-provider/index.ts b/packages/pallad-core/src/Pallad/providers/token-info-provider/index.ts new file mode 100644 index 00000000..51f739d0 --- /dev/null +++ b/packages/pallad-core/src/Pallad/providers/token-info-provider/index.ts @@ -0,0 +1 @@ +export * from "./types" diff --git a/packages/pallad-core/src/Pallad/providers/token-info-provider/types.ts b/packages/pallad-core/src/Pallad/providers/token-info-provider/types.ts new file mode 100644 index 00000000..01d76739 --- /dev/null +++ b/packages/pallad-core/src/Pallad/providers/token-info-provider/types.ts @@ -0,0 +1,19 @@ +import type { Provider } from "../.." + +export type TokenInfoArgs = { + tokenIds: string[] +} + +export interface TokenInfo { + tokenSymbol: string +} + +export interface TokenInfoProvider extends Provider { + /** + * Gets token info for the tokenIds provided in the arguments + * + * @param {string[]} tokenIds - tokenIds + * @returns {Record} - An object with token info objects + */ + getTokenInfo: (args: TokenInfoArgs) => Promise> +} diff --git a/packages/pallad-core/src/Pallad/providers/unified-provider.ts b/packages/pallad-core/src/Pallad/providers/unified-provider.ts index c9bcb786..92e8a174 100644 --- a/packages/pallad-core/src/Pallad/providers/unified-provider.ts +++ b/packages/pallad-core/src/Pallad/providers/unified-provider.ts @@ -5,6 +5,7 @@ import type { } from "./chain-history-provider" import type { NodeStatus } from "./node-status-provider" import type { HealthCheckResponse } from "./provider" +import type { TokenInfo, TokenInfoArgs } from "./token-info-provider" import type { TxStatus, TxStatusArgs } from "./tx-status-provider" import type { Tx } from "./types" @@ -21,8 +22,11 @@ export interface UnifiedChainProviderType { getAccountInfo( args: AccountInfoArgs, ): Promise | undefined> + getAccountsInfo(args: AccountInfoArgs): Promise getTransactionStatus?(args: TxStatusArgs): Promise + getTokenInfo(args: TokenInfoArgs): Promise> + // Methods related to ProviderArchive getTransactions(args: TransactionsByAddressesArgs): Promise getTransaction?(args: TransactionsByHashesArgs): Promise diff --git a/packages/providers/src/mina-node/account-info/account-info-provider.ts b/packages/providers/src/mina-node/account-info/account-info-provider.ts index 4318503c..5eb8d11a 100644 --- a/packages/providers/src/mina-node/account-info/account-info-provider.ts +++ b/packages/providers/src/mina-node/account-info/account-info-provider.ts @@ -6,7 +6,7 @@ import type { import { createGraphQLRequest } from "../utils/fetch-utils" import { healthCheck } from "../utils/health-check-utils" -import { getTokenAccountInfoQuery } from "./queries" +import { getAccountsInfoQuery, getTokenAccountInfoQuery } from "./queries" export const createAccountInfoProvider = (url: string): AccountInfoProvider => { const getAccountInfo = async ( @@ -32,6 +32,7 @@ export const createAccountInfoProvider = (url: string): AccountInfoProvider => { inferredNonce: 0, delegate: "", publicKey: args.publicKey, + tokenId: "", } } else { accountsInfo[key] = account as AccountInfo @@ -41,8 +42,24 @@ export const createAccountInfoProvider = (url: string): AccountInfoProvider => { return accountsInfo } + const getAccountsInfo = async ( + args: AccountInfoArgs, + ): Promise => { + const variables = { publicKey: args.publicKey } + const query = getAccountsInfoQuery + const fetchGraphQL = createGraphQLRequest(url) + const result = await fetchGraphQL(query, variables) + + if (!result.ok) { + throw new Error(result.message) + } + + return result.data.accounts + } + return { healthCheck: () => healthCheck(url), + getAccountsInfo, getAccountInfo, } } diff --git a/packages/providers/src/mina-node/account-info/queries.ts b/packages/providers/src/mina-node/account-info/queries.ts index 347f9867..a8d1c885 100644 --- a/packages/providers/src/mina-node/account-info/queries.ts +++ b/packages/providers/src/mina-node/account-info/queries.ts @@ -14,6 +14,22 @@ export const getAccountBalance = ` inferredNonce delegate publicKey + tokenId + } + } +` + +export const getAccountsInfoQuery = ` + query accountBalance($publicKey: PublicKey!) { + accounts(publicKey: $publicKey) { + balance { + total + }, + nonce + inferredNonce + delegate + publicKey + tokenId } } ` @@ -30,7 +46,7 @@ export function getTokenAccountInfoQuery(tokenIds: TokenIdMap): string { // Add the fragment definition queryString += - "}\n\nfragment AccountFields on Account {\n balance {\n total\n }\n tokenSymbol\n tokenId\n nonce\n inferredNonce\n publicKey\n delegate\n}" + "}\n\nfragment AccountFields on Account {\n balance {\n total\n }\n tokenId\n nonce\n inferredNonce\n publicKey\n delegate\n}" return queryString } diff --git a/packages/providers/src/mina-node/index.ts b/packages/providers/src/mina-node/index.ts index ba1b953e..4670ba45 100644 --- a/packages/providers/src/mina-node/index.ts +++ b/packages/providers/src/mina-node/index.ts @@ -1,4 +1,5 @@ export * from "./account-info" +export * from "./token-info" export * from "./chain-history" export * from "./node-status" export * from "./types" diff --git a/packages/providers/src/mina-node/token-info/index.ts b/packages/providers/src/mina-node/token-info/index.ts new file mode 100644 index 00000000..bc443556 --- /dev/null +++ b/packages/providers/src/mina-node/token-info/index.ts @@ -0,0 +1 @@ +export * from "./token-info-provider" diff --git a/packages/providers/src/mina-node/token-info/queries.ts b/packages/providers/src/mina-node/token-info/queries.ts new file mode 100644 index 00000000..634a6c6f --- /dev/null +++ b/packages/providers/src/mina-node/token-info/queries.ts @@ -0,0 +1,10 @@ +export function getTokenInfoQuery(tokenIds: string[]): string { + return `query { + ${tokenIds.map( + (tokenId) => `${tokenId}: tokenOwner(tokenId: "${tokenId}") { + tokenSymbol + } + `, + )} + }` +} diff --git a/packages/providers/src/mina-node/token-info/token-info-provider.ts b/packages/providers/src/mina-node/token-info/token-info-provider.ts new file mode 100644 index 00000000..4ccf91d3 --- /dev/null +++ b/packages/providers/src/mina-node/token-info/token-info-provider.ts @@ -0,0 +1,30 @@ +import type { + TokenInfo, + TokenInfoArgs, + TokenInfoProvider, +} from "@palladco/pallad-core" + +import { createGraphQLRequest } from "../utils/fetch-utils" +import { healthCheck } from "../utils/health-check-utils" +import { getTokenInfoQuery } from "./queries" + +export const createTokenInfoProvider = (url: string): TokenInfoProvider => { + const getTokenInfo = async ( + args: TokenInfoArgs, + ): Promise> => { + const query = getTokenInfoQuery(args.tokenIds) + const fetchGraphQL = createGraphQLRequest(url) + const result = await fetchGraphQL(query) + + if (!result.ok) { + throw new Error(result.message) + } + + return result.data + } + + return { + healthCheck: () => healthCheck(url), + getTokenInfo, + } +} diff --git a/packages/providers/src/unified-providers/account-info-provider.ts b/packages/providers/src/unified-providers/account-info-provider.ts index 47620fad..cca78560 100644 --- a/packages/providers/src/unified-providers/account-info-provider.ts +++ b/packages/providers/src/unified-providers/account-info-provider.ts @@ -18,19 +18,24 @@ export const createAccountInfoProvider = ( args: AccountInfoArgs, ): Promise> => { // Delegate the call to the underlying provider's getAccountInfo method - return (await underlyingProvider.getAccountInfo(args)) as Record< - string, - AccountInfo - > + return underlyingProvider.getAccountInfo(args) + } + + const getAccountsInfo = async ( + args: AccountInfoArgs, + ): Promise => { + // Delegate the call to the underlying provider's getAccountsInfo method + return underlyingProvider.getAccountsInfo(args) } const healthCheck = async (): Promise => { // Delegate the call to the underlying provider's healthCheck method - return await underlyingProvider.healthCheck() + return underlyingProvider.healthCheck() } return { getAccountInfo, + getAccountsInfo, healthCheck, } } diff --git a/packages/providers/src/unified-providers/token-info-provider.ts b/packages/providers/src/unified-providers/token-info-provider.ts new file mode 100644 index 00000000..c92611cf --- /dev/null +++ b/packages/providers/src/unified-providers/token-info-provider.ts @@ -0,0 +1,39 @@ +import type { + HealthCheckResponse, + TokenInfo, + TokenInfoArgs, + TokenInfoProvider, +} from "@palladco/pallad-core" + +import { createTokenInfoProvider as mn } from "../mina-node" +import type { ProviderConfig } from "./types" + +export const createTokenInfoProvider = ( + config: ProviderConfig, +): TokenInfoProvider => { + // TODO: make the underlyingProvider creation a util function + const underlyingProvider = mn(config.nodeEndpoint.url) + + const getTokenInfo = async ( + args: TokenInfoArgs, + ): Promise> => { + // Delegate the call to the underlying provider's getTokenInfo method + return underlyingProvider.getTokenInfo(args) + } + + const getTokensInfo = async (args: TokenInfoArgs): Promise => { + // Delegate the call to the underlying provider's getTokensInfo method + return underlyingProvider.getTokensInfo(args) + } + + const healthCheck = async (): Promise => { + // Delegate the call to the underlying provider's healthCheck method + return underlyingProvider.healthCheck() + } + + return { + getTokenInfo, + getTokensInfo, + healthCheck, + } +} diff --git a/packages/providers/src/unified-providers/unified-provider.ts b/packages/providers/src/unified-providers/unified-provider.ts index 25fa9db9..ed7a8478 100644 --- a/packages/providers/src/unified-providers/unified-provider.ts +++ b/packages/providers/src/unified-providers/unified-provider.ts @@ -1,42 +1,46 @@ import type { - AccountInfo, AccountInfoArgs, HealthCheckResponse, + TokenInfoArgs, TransactionsByAddressesArgs, - Tx, UnifiedChainProviderType, } from "@palladco/pallad-core" import { createAccountInfoProvider } from "./account-info-provider" import { createChainHistoryProvider } from "./chain-history-provider" import { createNodeStatusProvider } from "./node-status-provider" +import { createTokenInfoProvider } from "./token-info-provider" import type { ProviderConfig } from "./types" export const createChainProvider = ( config: ProviderConfig, ): UnifiedChainProviderType => { const getAccountInfo = async (args: AccountInfoArgs) => { - return (await createAccountInfoProvider(config).getAccountInfo( - args, - )) as Record + return createAccountInfoProvider(config).getAccountInfo(args) + } + + const getAccountsInfo = async (args: AccountInfoArgs) => { + return createAccountInfoProvider(config).getAccountsInfo(args) + } + + const getTokenInfo = async (args: TokenInfoArgs) => { + return createTokenInfoProvider(config).getTokenInfo(args) } const getTransactions = async (args: TransactionsByAddressesArgs) => { - return (await createChainHistoryProvider(config).transactionsByAddresses( - args, - )) as Tx[] + return createChainHistoryProvider(config).transactionsByAddresses(args) } const getNodeStatus = async () => { - return await createNodeStatusProvider(config).getNodeStatus() + return createNodeStatusProvider(config).getNodeStatus() } const healthCheckNode = async () => { - return await createAccountInfoProvider(config).healthCheck() + return createAccountInfoProvider(config).healthCheck() } const healthCheckArchive = async () => { - return await createChainHistoryProvider(config).healthCheck() + return createChainHistoryProvider(config).healthCheck() } const healthCheck = async () => { @@ -60,6 +64,8 @@ export const createChainProvider = ( return { getAccountInfo, + getAccountsInfo, + getTokenInfo, getTransactions, getNodeStatus, healthCheck, diff --git a/packages/vault/src/account/accountStore.ts b/packages/vault/src/account/accountStore.ts index edd1fd90..3dc91cca 100644 --- a/packages/vault/src/account/accountStore.ts +++ b/packages/vault/src/account/accountStore.ts @@ -95,6 +95,7 @@ export const accountSlice: StateCreator = (set, get) => ({ inferredNonce: 0, delegate: "", publicKey: "", + tokenId: "", } ) }, diff --git a/packages/vault/src/token-info/default.ts b/packages/vault/src/token-info/default.ts index dc81f392..bfeced47 100644 --- a/packages/vault/src/token-info/default.ts +++ b/packages/vault/src/token-info/default.ts @@ -1,5 +1,8 @@ +export const MINA_TOKEN_ID = + "wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf" + export const DEFAULT_TOKEN_INFO = { - "mina:mainnet": { MINA: "1" }, - "mina:devnet": { MINA: "1" }, - "zeko:devnet": { MINA: "1" }, + "mina:mainnet": { MINA: MINA_TOKEN_ID }, + "mina:devnet": { MINA: MINA_TOKEN_ID }, + "zeko:devnet": { MINA: MINA_TOKEN_ID }, } diff --git a/packages/vault/src/token-info/token-info-state.ts b/packages/vault/src/token-info/token-info-state.ts index ee0bd1d3..097bd7f6 100644 --- a/packages/vault/src/token-info/token-info-state.ts +++ b/packages/vault/src/token-info/token-info-state.ts @@ -15,6 +15,7 @@ export type TokenInfoState = { export type TokenInfoActions = { setTokenInfo: (networkId: string, tokenInfo: TokenInfo) => void getTokenInfo: (networkId: string, ticker: string) => TokenInfo | undefined + setTokensInfo: (networkId: string, tokensInfo: Record) => void getTokensInfo: (networkId: string) => Record removeTokenInfo: (networkId: string, ticker: string) => void clearTokenInfo: () => void diff --git a/packages/vault/src/token-info/token-info-store.ts b/packages/vault/src/token-info/token-info-store.ts index aa68bfe2..2fd6c772 100644 --- a/packages/vault/src/token-info/token-info-store.ts +++ b/packages/vault/src/token-info/token-info-store.ts @@ -10,7 +10,14 @@ export const tokenInfoSlice: StateCreator = (set, get) => ({ const { ticker, tokenId } = tokenInfo set( produce((state) => { - state.tokenInfo[networkId][ticker] = tokenId + state.tokenInfoV2[networkId][ticker] = tokenId + }), + ) + }, + setTokensInfo: (networkId, tokensInfo: Record) => { + set( + produce((state) => { + state.tokenInfoV2[networkId] = tokensInfo }), ) }, @@ -26,14 +33,14 @@ export const tokenInfoSlice: StateCreator = (set, get) => ({ removeTokenInfo: (networkId, ticker) => { set( produce((state) => { - delete state.tokenInfo[networkId][ticker] + delete state.tokenInfoV2[networkId][ticker] }), ) }, clearTokenInfo: () => { set( produce((state) => { - state.tokenInfo = DEFAULT_TOKEN_INFO + state.tokenInfoV2 = DEFAULT_TOKEN_INFO }), ) }, diff --git a/packages/vault/src/utils/persistence.ts b/packages/vault/src/utils/persistence.ts index 2b174f9d..a17ce8b7 100644 --- a/packages/vault/src/utils/persistence.ts +++ b/packages/vault/src/utils/persistence.ts @@ -1,7 +1,7 @@ import { Storage } from "@plasmohq/storage" -import { SecureStorage } from "@plasmohq/storage/secure" import superjson from "superjson" import type { StateStorage } from "zustand/middleware" +import { PalladSecureStorage } from "./secure-storage" superjson.registerCustom( { @@ -18,7 +18,7 @@ const sessionData = new Storage({ area: "session", }) -const secureStorage = new SecureStorage({ +const secureStorage = new PalladSecureStorage({ area: "local", }) diff --git a/packages/vault/src/utils/secure-storage.ts b/packages/vault/src/utils/secure-storage.ts new file mode 100644 index 00000000..5bee228a --- /dev/null +++ b/packages/vault/src/utils/secure-storage.ts @@ -0,0 +1,250 @@ +/** + * PalladSecureStorage: A modified version of SecureStorage from PlasmoHQ + *[](https://github.com/PlasmoHQ/storage/blob/02c6aeaf631ef71ac939be13a86f4f68fde84447/src/secure.ts). + * Modifications include a custom u8ToBase64 function to improve encryption compatibility. + * Original code licensed under MIT; modifications adhere to the same license. + */ + +import { BaseStorage } from "@plasmohq/storage" + +const { crypto } = globalThis + +const u8ToHex = (a: ArrayBufferLike) => + Array.from(new Uint8Array(a), (v) => v.toString(16).padStart(2, "0")).join("") + +const u8ToBase64 = (a: ArrayBuffer | Uint8Array) => { + const chunkSize = 10000 + const uint8Array = a instanceof ArrayBuffer ? new Uint8Array(a) : a + let str = "" + for (let i = 0; i < uint8Array.byteLength; i += chunkSize) { + str += String.fromCharCode.apply(null, uint8Array.slice(i, i + chunkSize)) + } + return globalThis.btoa(str) +} + +const base64ToU8 = (base64: string) => + Uint8Array.from(globalThis.atob(base64), (c) => c.charCodeAt(0)) + +const DEFAULT_ITERATIONS = 147_000 +const DEFAULT_SALT_SIZE = 16 +const DEFAULT_IV_SIZE = 32 +const DEFAULT_NS_SIZE = 8 + +export const DEFAULT_NS_SEPARATOR = "|:|" + +/** + * ALPHA API: This API is still in development and may change at any time. + */ +export class PalladSecureStorage extends BaseStorage { + encoder = new TextEncoder() + decoder = new TextDecoder() + + keyFx = "PBKDF2" + hashAlgo = "SHA-256" + cipherMode = "AES-GCM" + cipherSize = 256 + + iterations: number + saltSize: number + ivSize: number + + get prefixSize() { + return this.saltSize + this.ivSize + } + + private passwordKeyVar: CryptoKey + private get passwordKey() { + if (!this.passwordKeyVar) { + throw new Error("Password not set, please first call setPassword.") + } + return this.passwordKeyVar + } + + setPassword = async ( + password: string, + { + iterations = DEFAULT_ITERATIONS, + saltSize = DEFAULT_SALT_SIZE, + ivSize = DEFAULT_IV_SIZE, + namespace = "", + nsSize = DEFAULT_NS_SIZE, + nsSeparator = DEFAULT_NS_SEPARATOR, + } = {}, + ) => { + this.iterations = iterations + this.saltSize = saltSize + this.ivSize = ivSize + + const passwordBuffer = this.encoder.encode(password) + this.passwordKeyVar = await crypto.subtle.importKey( + "raw", + passwordBuffer, + { name: this.keyFx }, + false, // Not exportable + ["deriveKey"], + ) + + if (!namespace) { + const hashBuffer = await crypto.subtle.digest( + this.hashAlgo, + passwordBuffer, + ) + + this.keyNamespace = `${u8ToHex(hashBuffer).slice(-nsSize)}${nsSeparator}` + } else { + this.keyNamespace = `${namespace}${nsSeparator}` + } + } + + migrate = async (newInstance: PalladSecureStorage) => { + const storageMap = await this.getAll() + const baseKeyList = Object.keys(storageMap) + .filter((k) => this.isValidKey(k)) + .map((nsKey) => this.getUnnamespacedKey(nsKey)) + + await Promise.all( + baseKeyList.map(async (key) => { + const data = await this.get(key) + await newInstance.set(key, data) + }), + ) + + return newInstance + } + + /** + * + * @param boxBase64 A box contains salt, iv and encrypted data + * @returns decrypted data + */ + decrypt = async (boxBase64: string) => { + const passKey = this.passwordKey + const boxBuffer = base64ToU8(boxBase64) + + const salt = boxBuffer.slice(0, this.saltSize) + const iv = boxBuffer.slice(this.saltSize, this.prefixSize) + const encryptedDataBuffer = boxBuffer.slice(this.prefixSize) + const aesKey = await this.deriveKey(salt, passKey, ["decrypt"]) + + const decryptedDataBuffer = await crypto.subtle.decrypt( + { + name: this.cipherMode, + iv, + }, + aesKey, + encryptedDataBuffer, + ) + return this.decoder.decode(decryptedDataBuffer) + } + + encrypt = async (rawData: string) => { + const passKey = this.passwordKey + const salt = crypto.getRandomValues(new Uint8Array(this.saltSize)) + const iv = crypto.getRandomValues(new Uint8Array(this.ivSize)) + const aesKey = await this.deriveKey(salt, passKey, ["encrypt"]) + + const encryptedDataBuffer = new Uint8Array( + await crypto.subtle.encrypt( + { + name: this.cipherMode, + iv, + }, + aesKey, + this.encoder.encode(rawData), + ), + ) + + const boxBuffer = new Uint8Array( + this.prefixSize + encryptedDataBuffer.byteLength, + ) + + boxBuffer.set(salt, 0) + boxBuffer.set(iv, this.saltSize) + boxBuffer.set(encryptedDataBuffer, this.prefixSize) + + const boxBase64 = u8ToBase64(boxBuffer) + return boxBase64 + } + + get = async (key: string) => { + const nsKey = this.getNamespacedKey(key) + const boxBase64 = await this.rawGet(nsKey) + return this.parseValue(boxBase64) + } + + getMany = async (keys: string[]) => { + const nsKeys = keys.map(this.getNamespacedKey) + const rawValues = await this.rawGetMany(nsKeys) + const parsedValues = await Promise.all( + Object.values(rawValues).map(this.parseValue), + ) + return Object.keys(rawValues).reduce( + (results, key, i) => { + results[this.getUnnamespacedKey(key)] = parsedValues[i] + return results + }, + {} as Record, + ) + } + + set = async (key: string, rawValue: any) => { + const nsKey = this.getNamespacedKey(key) + const value = this.serde.serializer(rawValue) + const boxBase64 = await this.encrypt(value) + return await this.rawSet(nsKey, boxBase64) + } + + setMany = async (items: Record) => { + const encryptedValues = await Promise.all( + Object.values(items).map((rawValue) => + this.encrypt(this.serde.serializer(rawValue)), + ), + ) + + const nsItems = Object.keys(items).reduce((nsItems, key, i) => { + nsItems[this.getNamespacedKey(key)] = encryptedValues[i] + return nsItems + }, {}) + + return await this.rawSetMany(nsItems) + } + + remove = async (key: string) => { + const nsKey = this.getNamespacedKey(key) + return await this.rawRemove(nsKey) + } + + removeMany = async (keys: string[]) => { + const nsKeys = keys.map(this.getNamespacedKey) + return await this.rawRemoveMany(nsKeys) + } + + protected parseValue = async (boxBase64: string | null | undefined) => { + if (boxBase64 !== undefined && boxBase64 !== null) { + const rawValue = await this.decrypt(boxBase64) + return this.serde.deserializer(rawValue) + } + return undefined + } + + deriveKey = ( + salt: Uint8Array, + passwordKey: CryptoKey, + keyUsage: KeyUsage[], + ) => + crypto.subtle.deriveKey( + { + name: this.keyFx, + hash: this.hashAlgo, + salt, + iterations: this.iterations, + }, + passwordKey, + { + name: this.cipherMode, + length: this.cipherSize, + }, + false, + keyUsage, + ) +} diff --git a/packages/vault/src/vault/utils/sync-account-info.ts b/packages/vault/src/vault/utils/sync-account-info.ts index 6a5c358c..c34b9b4b 100644 --- a/packages/vault/src/vault/utils/sync-account-info.ts +++ b/packages/vault/src/vault/utils/sync-account-info.ts @@ -1,3 +1,4 @@ +import type { AccountInfo } from "@palladco/pallad-core" import { type ProviderConfig, createChainProvider } from "@palladco/providers" export async function syncAccountHelper( @@ -5,15 +6,40 @@ export async function syncAccountHelper( providerConfig: ProviderConfig, publicKey: string, ) { - const { setAccountInfo, getTokensInfo } = get() + const { setAccountInfo, getTokensInfo, setTokensInfo } = get() const provider = createChainProvider(providerConfig) - const tokenMap = getTokensInfo(providerConfig.networkId) - const accountInfo = await provider.getAccountInfo({ + const accountsInfo = await provider.getAccountsInfo({ publicKey: publicKey, - tokenMap: tokenMap, }) - if (accountInfo === undefined) { + if (accountsInfo === undefined) { throw new Error("accountInfo is undefined in _syncAccountInfo") } + + const tokensInfo = await provider.getTokenInfo({ + tokenIds: accountsInfo.map((a) => a.tokenId), + }) + let tokenMap = getTokensInfo(providerConfig.networkId) + for (const [tokenId, tokenInfo] of Object.entries(tokensInfo)) { + tokenMap = { + ...tokenMap, + [tokenInfo?.tokenSymbol ?? "MINA"]: tokenId, + } + } + setTokensInfo(providerConfig.networkId, tokenMap) + + const accountInfo: Record = {} + for (const ticker in tokenMap) { + accountInfo[ticker] = accountsInfo.find( + (a) => a.tokenId === tokenMap[ticker], + ) ?? { + balance: { total: 0 }, + nonce: 0, + inferredNonce: 0, + delegate: "", + publicKey: publicKey, + tokenId: "", + } + } + return setAccountInfo(providerConfig.networkId, publicKey, accountInfo) }