diff --git a/app/assets/star-cards/blockandbean.agi.cash.png b/app/assets/star-cards/blockandbean.agi.cash.png new file mode 100644 index 000000000..af84f1027 Binary files /dev/null and b/app/assets/star-cards/blockandbean.agi.cash.png differ diff --git a/app/assets/star-cards/fake.agi.cash.png b/app/assets/star-cards/fake.agi.cash.png new file mode 100644 index 000000000..86a987215 Binary files /dev/null and b/app/assets/star-cards/fake.agi.cash.png differ diff --git a/app/assets/star-cards/fake2.agi.cash.png b/app/assets/star-cards/fake2.agi.cash.png new file mode 100644 index 000000000..6737f12bf Binary files /dev/null and b/app/assets/star-cards/fake2.agi.cash.png differ diff --git a/app/assets/star-cards/fake4.agi.cash.png b/app/assets/star-cards/fake4.agi.cash.png new file mode 100644 index 000000000..4f2eaeead Binary files /dev/null and b/app/assets/star-cards/fake4.agi.cash.png differ diff --git a/app/components/page.tsx b/app/components/page.tsx index a62cd0458..93690209e 100644 --- a/app/components/page.tsx +++ b/app/components/page.tsx @@ -1,5 +1,6 @@ import { ChevronLeft, X } from 'lucide-react'; import React from 'react'; +import { useLocation } from 'react-router'; import { LinkWithViewTransition, type ViewTransitionLinkProps, @@ -27,8 +28,10 @@ export function Page({ children, className, ...props }: PageProps) { interface ClosePageButtonProps extends ViewTransitionLinkProps {} export function ClosePageButton({ className, ...props }: ClosePageButtonProps) { + const location = useLocation(); + const redirectTo = new URLSearchParams(location.search).get('redirectTo'); return ( - + ); diff --git a/app/features/accounts/account-hooks.ts b/app/features/accounts/account-hooks.ts index b6b1d465e..1d5b7745e 100644 --- a/app/features/accounts/account-hooks.ts +++ b/app/features/accounts/account-hooks.ts @@ -8,6 +8,7 @@ import { useSuspenseQuery, } from '@tanstack/react-query'; import { useCallback, useMemo, useRef } from 'react'; +import { useSearchParams } from 'react-router'; import { type Currency, Money } from '~/lib/money'; import { useSupabaseRealtime } from '~/lib/supabase'; import { useLatest } from '~/lib/use-latest'; @@ -19,6 +20,7 @@ import { type CashuAccount, type ExtendedAccount, getAccountBalance, + isStarAccount, } from './account'; import { type AccountRepository, @@ -277,6 +279,8 @@ export const accountsQueryOptions = ({ export function useAccounts(select?: { currency?: Currency; type?: T; + excludeStarAccounts?: boolean; + starAccountsOnly?: boolean; }): UseSuspenseQueryResult[]> { const user = useUser(); const accountRepository = useAccountRepository(); @@ -301,13 +305,25 @@ export function useAccounts(select?: { if (select.type && account.type !== select.type) { return false; } + if (select.excludeStarAccounts && isStarAccount(account)) { + return false; + } + if (select.starAccountsOnly && !isStarAccount(account)) { + return false; + } return true; }, ); return filteredData; }, - [select?.currency, select?.type, user], + [ + select?.currency, + select?.type, + select?.excludeStarAccounts, + select?.starAccountsOnly, + user, + ], ), }); } @@ -430,14 +446,29 @@ export function useAddCashuAccount() { return mutateAsync; } +/** + * @returns the total balance of all accounts for the given currency excluding Star accounts. + */ export function useBalance(currency: Currency) { - const { data: accounts } = useAccounts({ currency }); - const balance = accounts.reduce( - (acc, account) => { - const accountBalance = getAccountBalance(account); - return acc.add(accountBalance); - }, - new Money({ amount: 0, currency }), - ); + const { data: accounts } = useAccounts({ + currency, + excludeStarAccounts: true, + }); + const balance = accounts.reduce((acc, account) => { + const accountBalance = getAccountBalance(account); + return acc.add(accountBalance); + }, Money.zero(currency)); return balance; } + +/** + * Returns the account specified by the account ID in the URL. + * @param select - The type of the account to get. + */ +export function useGetAccountFromLocation(select?: { type?: AccountType }) { + const [searchParams] = useSearchParams(); + const accountId = searchParams.get('accountId'); + const { data: accounts } = useAccounts({ type: select?.type }); + const account = accounts.find((account) => account.id === accountId); + return account; +} diff --git a/app/features/accounts/account-icons.tsx b/app/features/accounts/account-icons.tsx index afd0c86f8..d4882df8b 100644 --- a/app/features/accounts/account-icons.tsx +++ b/app/features/accounts/account-icons.tsx @@ -1,15 +1,17 @@ -import { LandmarkIcon, Zap } from 'lucide-react'; +import { LandmarkIcon, StarIcon, Zap } from 'lucide-react'; import type { ReactNode } from 'react'; import type { AccountType } from './account'; const CashuIcon = () => ; const NWCIcon = () => ; +const StarsIcon = () => ; -const iconsByAccountType: Record = { +const iconsByAccountType: Record = { cashu: , nwc: , + star: , }; -export function AccountTypeIcon({ type }: { type: AccountType }) { +export function AccountTypeIcon({ type }: { type: AccountType | 'star' }) { return iconsByAccountType[type]; } diff --git a/app/features/accounts/account-repository.ts b/app/features/accounts/account-repository.ts index 6946dca60..b8713592d 100644 --- a/app/features/accounts/account-repository.ts +++ b/app/features/accounts/account-repository.ts @@ -186,45 +186,53 @@ export class AccountRepository { private async getPreloadedWallet(mintUrl: string, currency: Currency) { const seed = await this.getCashuWalletSeed?.(); - // TODO: handle fetching errors. If the mint is unreachable these will throw, - // and the error will bubble up to the user and brick the app. - const [mintInfo, allMintKeysets, mintActiveKeys] = await Promise.all([ - this.queryClient.fetchQuery(mintInfoQueryOptions(mintUrl)), - this.queryClient.fetchQuery(allMintKeysetsQueryOptions(mintUrl)), - this.queryClient.fetchQuery(mintKeysQueryOptions(mintUrl)), - ]); - - const unitKeysets = allMintKeysets.keysets.filter( - (ks) => ks.unit === getCashuProtocolUnit(currency), - ); - const activeKeyset = unitKeysets.find((ks) => ks.active); - - if (!activeKeyset) { - throw new Error(`No active keyset found for ${currency} on ${mintUrl}`); - } - - const activeKeysForUnit = mintActiveKeys.keysets.find( - (ks) => ks.id === activeKeyset.id, - ); - - if (!activeKeysForUnit) { - throw new Error( - `Got active keyset ${activeKeyset.id} from ${mintUrl} but could not find keys for it`, + try { + const [mintInfo, allMintKeysets, mintActiveKeys] = await Promise.all([ + this.queryClient.fetchQuery(mintInfoQueryOptions(mintUrl)), + this.queryClient.fetchQuery(allMintKeysetsQueryOptions(mintUrl)), + this.queryClient.fetchQuery(mintKeysQueryOptions(mintUrl)), + ]); + + const unitKeysets = allMintKeysets.keysets.filter( + (ks) => ks.unit === getCashuProtocolUnit(currency), ); - } + const activeKeyset = unitKeysets.find((ks) => ks.active); - const wallet = getCashuWallet(mintUrl, { - unit: getCashuUnit(currency), - bip39seed: seed ?? undefined, - mintInfo, - keys: activeKeysForUnit, - keysets: unitKeysets, - }); + if (!activeKeyset) { + throw new Error(`No active keyset found for ${currency} on ${mintUrl}`); + } - // The constructor does not set the keysetId, so we need to set it manually - wallet.keysetId = activeKeyset.id; + const activeKeysForUnit = mintActiveKeys.keysets.find( + (ks) => ks.id === activeKeyset.id, + ); - return wallet; + if (!activeKeysForUnit) { + throw new Error( + `Got active keyset ${activeKeyset.id} from ${mintUrl} but could not find keys for it`, + ); + } + + const wallet = getCashuWallet(mintUrl, { + unit: getCashuUnit(currency), + bip39seed: seed ?? undefined, + mintInfo, + keys: activeKeysForUnit, + keysets: unitKeysets, + }); + + // The constructor does not set the keysetId, so we need to set it manually + wallet.keysetId = activeKeyset.id; + + return wallet; + } catch { + // TODO: This is a quick fix to prevent the app from bricking when a mint is unreachable + // We should disable the account completley in this case because if the mint is offline, + // the account won't be usable. + return getCashuWallet(mintUrl, { + unit: getCashuUnit(currency), + bip39seed: seed ?? undefined, + }); + } } } diff --git a/app/features/accounts/account-selector.tsx b/app/features/accounts/account-selector.tsx index 672e412ab..19cbade9b 100644 --- a/app/features/accounts/account-selector.tsx +++ b/app/features/accounts/account-selector.tsx @@ -12,6 +12,7 @@ import { ScrollArea } from '~/components/ui/scroll-area'; import { cn } from '~/lib/utils'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; import { type Account, getAccountBalance } from './account'; +import { isStarAccount } from './account'; import { AccountTypeIcon } from './account-icons'; export type AccountWithBadges = T & { @@ -26,7 +27,7 @@ function AccountItem({ account }: { account: AccountWithBadges }) { return (
- +
{account.name}
diff --git a/app/features/accounts/account.ts b/app/features/accounts/account.ts index d05b48072..c8f9bed1e 100644 --- a/app/features/accounts/account.ts +++ b/app/features/accounts/account.ts @@ -57,3 +57,6 @@ export const getAccountBalance = (account: Account) => { // TODO: implement balance logic for other account types return new Money({ amount: 0, currency: account.currency }); }; + +export const isStarAccount = (account: Account) => + account.type === 'cashu' && account.wallet.cachedMintInfo.internalMeltsOnly; diff --git a/app/features/receive/receive-cashu-token-service.ts b/app/features/receive/receive-cashu-token-service.ts index 61d66b15f..990156b72 100644 --- a/app/features/receive/receive-cashu-token-service.ts +++ b/app/features/receive/receive-cashu-token-service.ts @@ -6,7 +6,7 @@ import { getCashuUnit, getCashuWallet, } from '~/lib/cashu'; -import type { ExtendedCashuAccount } from '../accounts/account'; +import { type ExtendedCashuAccount, isStarAccount } from '../accounts/account'; import { allMintKeysetsQueryOptions, cashuMintValidator, @@ -198,8 +198,9 @@ export class ReceiveCashuTokenService { sourceAccount: CashuAccountWithTokenFlags, otherAccounts: CashuAccountWithTokenFlags[], ): CashuAccountWithTokenFlags[] { - if (sourceAccount.isTestMint) { + if (sourceAccount.isTestMint || isStarAccount(sourceAccount)) { // Tokens sourced from test mint can only be claimed to the same mint + // Tokens sourced from Star accounts cannot pay external invoices return sourceAccount.isSelectable ? [sourceAccount] : []; } return [sourceAccount, ...otherAccounts].filter( diff --git a/app/features/receive/receive-cashu-token.tsx b/app/features/receive/receive-cashu-token.tsx index 571116577..af5a6bcc8 100644 --- a/app/features/receive/receive-cashu-token.tsx +++ b/app/features/receive/receive-cashu-token.tsx @@ -28,10 +28,12 @@ import { LinkWithViewTransition, useNavigateWithViewTransition, } from '~/lib/transitions'; +import { isStarAccount } from '../accounts/account'; import { AccountSelector } from '../accounts/account-selector'; import { tokenToMoney } from '../shared/cashu'; import { getErrorMessage } from '../shared/error'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; +import { WalletCard } from '../stars/wallet-card'; import { useAuthActions } from '../user/auth'; import { useFailCashuReceiveQuote } from './cashu-receive-quote-hooks'; import { useCreateCashuTokenSwap } from './cashu-token-swap-hooks'; @@ -204,14 +206,22 @@ export default function ReceiveToken({
{claimableToken && receiveAccount ? ( -
- -
+ isStarAccount(receiveAccount) ? ( +
+ +
+ ) : ( +
+ +
+ ) ) : ( {claimableToken ? ( -
- -
+ isStarAccount(sourceAccount) ? ( +
+ +
+ ) : ( +
+ +
+ ) ) : ( )} diff --git a/app/features/settings/accounts/all-accounts.tsx b/app/features/settings/accounts/all-accounts.tsx index 7dc849506..af97a32f5 100644 --- a/app/features/settings/accounts/all-accounts.tsx +++ b/app/features/settings/accounts/all-accounts.tsx @@ -14,7 +14,10 @@ import type { Currency } from '~/lib/money'; import { LinkWithViewTransition } from '~/lib/transitions'; function CurrencyAccounts({ currency }: { currency: Currency }) { - const { data: accounts } = useAccounts({ currency }); + const { data: accounts } = useAccounts({ + currency, + excludeStarAccounts: true, + }); return (
diff --git a/app/features/settings/settings.tsx b/app/features/settings/settings.tsx index a0d4f61d9..00998127b 100644 --- a/app/features/settings/settings.tsx +++ b/app/features/settings/settings.tsx @@ -18,6 +18,7 @@ import { useToast } from '~/hooks/use-toast'; import { canShare, shareContent } from '~/lib/share'; import { LinkWithViewTransition } from '~/lib/transitions'; import { cn } from '~/lib/utils'; +import { isStarAccount } from '../accounts/account'; import { useDefaultAccount } from '../accounts/account-hooks'; import { AccountTypeIcon } from '../accounts/account-icons'; import { ColorModeToggle } from '../theme/color-mode-toggle'; @@ -111,7 +112,9 @@ export default function Settings() { - + {defaultAccount.name} diff --git a/app/features/shared/cashu.ts b/app/features/shared/cashu.ts index 685b6cbdb..0440f47df 100644 --- a/app/features/shared/cashu.ts +++ b/app/features/shared/cashu.ts @@ -152,7 +152,7 @@ export const cashuMintValidator = buildMintValidator({ export const mintInfoQueryOptions = (mintUrl: string) => queryOptions({ queryKey: ['mint-info', mintUrl], - queryFn: async () => getCashuWallet(mintUrl).getMintInfo(), + queryFn: async () => getCashuWallet(mintUrl).getExtendedMintInfo(), staleTime: 1000 * 60 * 60, // 1 hour }); diff --git a/app/features/stars/animation-constants.ts b/app/features/stars/animation-constants.ts new file mode 100644 index 000000000..f2ca67e07 --- /dev/null +++ b/app/features/stars/animation-constants.ts @@ -0,0 +1,18 @@ +// Duration constants (in ms) +export const ANIMATION_DURATION = 400; +export const DETAIL_VIEW_DELAY = 300; // Delay before detail content starts animating +export const OPACITY_ANIMATION_RATIO = 0.5; // Multiplier for opacity animation duration + +export const EASE_IN_OUT = 'cubic-bezier(0.25, 0.1, 0.25, 1)'; +export const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)'; // Decelerating + +// Layout constants (in px) +export const CARD_STACK_OFFSET = 64; // Space between cards in collapsed stack +export const CARD_ASPECT_RATIO = 1.586; // Credit card aspect ratio + +/** + * Get the off-screen Y offset for sliding cards out + */ +export function getOffScreenOffset(): number { + return typeof window !== 'undefined' ? window.innerHeight + 100 : 900; // Fallback for SSR +} diff --git a/app/features/stars/card-stack.tsx b/app/features/stars/card-stack.tsx new file mode 100644 index 000000000..563f51849 --- /dev/null +++ b/app/features/stars/card-stack.tsx @@ -0,0 +1,52 @@ +import type { CashuAccount } from '~/features/accounts/account'; +import { + ANIMATION_DURATION, + CARD_ASPECT_RATIO, + CARD_STACK_OFFSET, + EASE_IN_OUT, +} from './animation-constants'; +import { SelectableWalletCard } from './wallet-card'; + +interface CardStackProps { + accounts: CashuAccount[]; + selectedCardIndex: number | null; + onCardSelect: (accountId: string, event?: React.MouseEvent) => void; +} + +/** + * Displays a stack of wallet cards + */ +export function CardStack({ + accounts, + selectedCardIndex, + onCardSelect, +}: CardStackProps) { + const hasSelection = selectedCardIndex !== null; + + return ( +
+ {/* Spacer element that determines container height based on card stack */} +
+ + {accounts.map((account, index) => ( + + ))} +
+ ); +} diff --git a/app/features/stars/selected-card-details.tsx b/app/features/stars/selected-card-details.tsx new file mode 100644 index 000000000..e4bf80530 --- /dev/null +++ b/app/features/stars/selected-card-details.tsx @@ -0,0 +1,88 @@ +import { Button } from '~/components/ui/button'; +import { + type CashuAccount, + getAccountBalance, +} from '~/features/accounts/account'; +import { TransactionList } from '~/features/transactions/transaction-list'; +import { LinkWithViewTransition } from '~/lib/transitions'; +import { cn } from '~/lib/utils'; +import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; +import { + ANIMATION_DURATION, + DETAIL_VIEW_DELAY, + EASE_OUT, + OPACITY_ANIMATION_RATIO, +} from './animation-constants'; + +interface SelectedCardDetailsProps { + account: CashuAccount; + isVisible: boolean; +} + +/** + * Displays send/receive buttons and transaction history for the selected card. + * This component shows the interactive elements below the card stack. + */ +export function SelectedCardDetails({ + account, + isVisible, +}: SelectedCardDetailsProps) { + const balance = getAccountBalance(account); + + const transitionStyle = `opacity ${ANIMATION_DURATION * OPACITY_ANIMATION_RATIO}ms ${EASE_OUT} ${DETAIL_VIEW_DELAY}ms`; + + return ( +
+ {/* Balance and Actions Section */} +
+
+ + + {/* Send and Receive Buttons */} +
+ + + + + + +
+
+
+ + {/* Transaction List Section */} +
+ +
+
+ ); +} diff --git a/app/features/stars/wallet-card.tsx b/app/features/stars/wallet-card.tsx new file mode 100644 index 000000000..77a2091d3 --- /dev/null +++ b/app/features/stars/wallet-card.tsx @@ -0,0 +1,220 @@ +import { useEffect, useState } from 'react'; +import { MoneyDisplay } from '~/components/money-display'; +import { Card, CardContent } from '~/components/ui/card'; +import { + type CashuAccount, + getAccountBalance, +} from '~/features/accounts/account'; +import { getDefaultUnit } from '~/features/shared/currencies'; +import { cn } from '~/lib/utils'; +import { + ANIMATION_DURATION, + CARD_ASPECT_RATIO, + CARD_STACK_OFFSET, + EASE_IN_OUT, + getOffScreenOffset, +} from './animation-constants'; + +/** + * Lazy import functions for card assets. + * These are cached by the browser's module system after first import. + */ +const cardAssetLoaders = import.meta.glob<{ default: string }>( + '../../assets/star-cards/*.png', +); + +/** + * Extracts the domain name from a file path. + * e.g., '../../assets/star-cards/fake.agi.cash.png' -> 'fake.agi.cash' + */ +function extractDomainFromPath(path: string): string { + return path.split('/').pop()?.replace('.png', '') || ''; +} + +/** + * Gets the domain from a mint URL + */ +function getDomainFromMintUrl(mintUrl: string): string { + return mintUrl.replace(/^https?:\/\//, ''); +} + +/** + * Loads a card asset dynamically if it exists. + */ +async function loadCardAsset(mintUrl: string): Promise { + const domain = getDomainFromMintUrl(mintUrl); + const loaderEntry = Object.entries(cardAssetLoaders).find( + ([path]) => extractDomainFromPath(path) === domain, + ); + if (!loaderEntry) { + return null; + } + try { + const [, loader] = loaderEntry; + const module = await loader(); + return module.default; + } catch (error) { + console.error(`Failed to load card asset for ${domain}:`, error); + return null; + } +} + +interface WalletCardProps { + account: CashuAccount; + hideHeader?: boolean; +} + +export function WalletCard({ account, hideHeader = false }: WalletCardProps) { + const [customDesignPath, setCustomDesignPath] = useState(null); + + useEffect(() => { + loadCardAsset(account.mintUrl).then(setCustomDesignPath); + }, [account.mintUrl]); + + const cardName = account.wallet.cachedMintInfo.name ?? account.name; + const cardLogo = account.wallet.cachedMintInfo.iconUrl ?? null; + + const balance = getAccountBalance(account); + + return ( + + {/* Custom card design - always visible if available */} + {customDesignPath ? ( + {`${cardName} + ) : ( +
+ {cardLogo ? ( + {`${cardName} + ) : ( +
+ {cardName.charAt(0)} +
+ )} +

{cardName}

+
+ )} + + {/* Default card content */} + + {/* Card Header with Logo, Vendor, and Balance */} +
+ {/* Logo */} +
+ {cardLogo ? ( + {`${cardName} + ) : ( +
+ {cardName.charAt(0)} +
+ )} +
+ + {/* Card Info */} +
+

{cardName}

+
+ + {/* Balance */} +
+ +
+
+
+
+ ); +} + +interface SelectableWalletCardProps { + account: CashuAccount; + isSelected: boolean; + index: number; + onSelect: (accountId: string, event?: React.MouseEvent) => void; + selectedCardIndex: number | null; +} + +/** + * A selectable wallet card component with animations. + * Wraps WalletCard with selection logic, click handlers, and animation behavior. + * Use this in contexts where cards need selection/animation behavior (e.g., card stack). + */ +export function SelectableWalletCard({ + account, + isSelected, + index, + onSelect, + selectedCardIndex, +}: SelectableWalletCardProps) { + const handleCardClick = (e: React.MouseEvent) => { + onSelect(account.id, e); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(account.id); + } + }; + + // Calculate Y position based on state + let yOffset: number; + if (isSelected) { + // This card is selected - move to top position + yOffset = 0; + } else if (selectedCardIndex !== null) { + // Another card is selected - slide this card off screen + yOffset = getOffScreenOffset(); + } else { + // No selection - normal stacked position + yOffset = index * CARD_STACK_OFFSET; + } + + // Fixed z-index based on position + const zIndex = 100 + index; + + const transition = `all ${ANIMATION_DURATION}ms ${EASE_IN_OUT}`; + + return ( + // biome-ignore lint/a11y/useSemanticElements: div needed for absolute positioning wrapper +
+ +
+ ); +} diff --git a/app/features/transactions/transaction-hooks.ts b/app/features/transactions/transaction-hooks.ts index 715ebc306..0e2caed36 100644 --- a/app/features/transactions/transaction-hooks.ts +++ b/app/features/transactions/transaction-hooks.ts @@ -125,18 +125,19 @@ export function useSuspenseTransaction(id: string) { const PAGE_SIZE = 25; -export function useTransactions() { +export function useTransactions(select?: { accountId?: string }) { const userId = useUser((user) => user.id); const transactionRepository = useTransactionRepository(); const result = useInfiniteQuery({ - queryKey: [allTransactionsQueryKey], + queryKey: [allTransactionsQueryKey, select], initialPageParam: null, queryFn: async ({ pageParam }: { pageParam: Cursor | null }) => { const result = await transactionRepository.list({ userId, cursor: pageParam, pageSize: PAGE_SIZE, + select, }); return { transactions: result.transactions, diff --git a/app/features/transactions/transaction-list.tsx b/app/features/transactions/transaction-list.tsx index 118572286..c3d046537 100644 --- a/app/features/transactions/transaction-list.tsx +++ b/app/features/transactions/transaction-list.tsx @@ -7,6 +7,7 @@ import { useMemo, useRef, } from 'react'; +import { useLocation } from 'react-router'; import { Card } from '~/components/ui/card'; import { ScrollArea } from '~/components/ui/scroll-area'; import { useTransactionAckStatusStore } from '~/features/transactions/transaction-ack-status-store'; @@ -151,6 +152,7 @@ function TransactionRow({ transaction: Transaction; }) { const { mutate: acknowledgeTransaction } = useAcknowledgeTransaction(); + const location = useLocation(); const { setAckStatus, statuses: ackStatuses } = useTransactionAckStatusStore(); @@ -168,7 +170,7 @@ function TransactionRow({ return ( data?.pages.flatMap((page) => page.transactions) ?? [], diff --git a/app/features/transactions/transaction-repository.ts b/app/features/transactions/transaction-repository.ts index 401a7a7ae..618600fa5 100644 --- a/app/features/transactions/transaction-repository.ts +++ b/app/features/transactions/transaction-repository.ts @@ -33,6 +33,10 @@ type ListOptions = Options & { userId: string; cursor?: Cursor; pageSize?: number; + /** + * Optional filters to apply to the transaction list. + bun */ + select?: { accountId?: string }; }; type UnifiedTransactionDetails = @@ -68,6 +72,7 @@ export class TransactionRepository { userId, cursor = null, pageSize = 25, + select, abortSignal, }: ListOptions) { const query = this.db.rpc('list_transactions', { @@ -76,6 +81,7 @@ export class TransactionRepository { p_cursor_created_at: cursor?.createdAt, p_cursor_id: cursor?.id, p_page_size: pageSize, + p_account_id: select?.accountId, }); if (abortSignal) { diff --git a/app/features/user/user-hooks.tsx b/app/features/user/user-hooks.tsx index 6aaf21d49..1344e3c20 100644 --- a/app/features/user/user-hooks.tsx +++ b/app/features/user/user-hooks.tsx @@ -95,29 +95,22 @@ export const defaultAccounts = [ { type: 'cashu', currency: 'BTC', - name: 'Testnut BTC (nofees)', - mintUrl: 'https://nofees.testnut.cashu.space', - isTestMint: true, - }, - { - type: 'cashu', - currency: 'USD', - name: 'Testnut USD (nofees)', - mintUrl: 'https://nofees.testnut.cashu.space', + name: 'CafeRX', + mintUrl: 'https://fake3.agi.cash', isTestMint: true, }, { type: 'cashu', currency: 'BTC', - name: 'Testnut BTC', - mintUrl: 'https://testnut.cashu.space', + name: 'NYTimes', + mintUrl: 'https://fake2.agi.cash', isTestMint: true, }, { type: 'cashu', - currency: 'USD', - name: 'Testnut USD', - mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + name: 'Maple', + mintUrl: 'https://fake4.agi.cash', isTestMint: true, }, ] as const) diff --git a/app/lib/cashu/index.ts b/app/lib/cashu/index.ts index f71b5caea..d9faf8f4b 100644 --- a/app/lib/cashu/index.ts +++ b/app/lib/cashu/index.ts @@ -3,5 +3,5 @@ export * from './secret'; export * from './token'; export * from './utils'; export * from './error-codes'; -export type { MintInfo } from './types'; export * from './payment-request'; +export * from './mint-info'; diff --git a/app/lib/cashu/mint-info.ts b/app/lib/cashu/mint-info.ts new file mode 100644 index 000000000..b69e1fb36 --- /dev/null +++ b/app/lib/cashu/mint-info.ts @@ -0,0 +1,178 @@ +/** + * This class was copied from cashu-ts v2.7.2 and extended with the following methods: + * - get iconUrl + * - get internalMeltsOnly + * + * As of cashu-ts v2.7.2, the MintInfo class is not exported, so we need to copy it here in order to extend it. + */ + +import type { + GetInfoResponse, + MPPMethod, + SwapMethod, + WebSocketSupport, +} from '@cashu/cashu-ts'; + +/** + * A class that represents the data fetched from the mint's + * [NUT-06 info endpoint](https://github.com/cashubtc/nuts/blob/main/06.md) + */ +export class MintInfo { + private readonly _mintInfo: GetInfoResponse; + private readonly _protectedEnpoints?: { + cache: { + [url: string]: boolean; + }; + apiReturn: Array<{ + method: 'GET' | 'POST'; + regex: RegExp; + cachedValue?: boolean; + }>; + }; + + constructor(info: GetInfoResponse) { + this._mintInfo = info; + if (info.nuts[22]) { + this._protectedEnpoints = { + cache: {}, + apiReturn: info.nuts[22].protected_endpoints.map((o) => ({ + method: o.method, + regex: new RegExp(o.path), + })), + }; + } + } + + isSupported(num: 4 | 5): { disabled: boolean; params: SwapMethod[] }; + isSupported(num: 7 | 8 | 9 | 10 | 11 | 12 | 14 | 20): { supported: boolean }; + isSupported(num: 17): { supported: boolean; params?: WebSocketSupport[] }; + isSupported(num: 15): { supported: boolean; params?: MPPMethod[] }; + isSupported(num: number) { + switch (num) { + case 4: + case 5: { + return this.checkMintMelt(num); + } + case 7: + case 8: + case 9: + case 10: + case 11: + case 12: + case 14: + case 20: { + return this.checkGenericNut(num); + } + case 17: { + return this.checkNut17(); + } + case 15: { + return this.checkNut15(); + } + default: { + throw new Error('nut is not supported by cashu-ts'); + } + } + } + + requiresBlindAuthToken(path: string) { + if (!this._protectedEnpoints) { + return false; + } + if (typeof this._protectedEnpoints.cache[path] === 'boolean') { + return this._protectedEnpoints.cache[path]; + } + const isProtectedEndpoint = this._protectedEnpoints.apiReturn.some((e) => + e.regex.test(path), + ); + this._protectedEnpoints.cache[path] = isProtectedEndpoint; + return isProtectedEndpoint; + } + + private checkGenericNut(num: 7 | 8 | 9 | 10 | 11 | 12 | 14 | 20) { + if (this._mintInfo.nuts[num]?.supported) { + return { supported: true }; + } + return { supported: false }; + } + private checkMintMelt(num: 4 | 5) { + const mintMeltInfo = this._mintInfo.nuts[num]; + if ( + mintMeltInfo && + mintMeltInfo.methods.length > 0 && + !mintMeltInfo.disabled + ) { + return { disabled: false, params: mintMeltInfo.methods }; + } + return { disabled: true, params: mintMeltInfo.methods }; + } + private checkNut17() { + if ( + this._mintInfo.nuts[17] && + this._mintInfo.nuts[17].supported.length > 0 + ) { + return { supported: true, params: this._mintInfo.nuts[17].supported }; + } + return { supported: false }; + } + private checkNut15() { + if (this._mintInfo.nuts[15] && this._mintInfo.nuts[15].methods.length > 0) { + return { supported: true, params: this._mintInfo.nuts[15].methods }; + } + return { supported: false }; + } + + get contact() { + return this._mintInfo.contact; + } + + get description() { + return this._mintInfo.description; + } + + get description_long() { + return this._mintInfo.description_long; + } + + get name() { + return this._mintInfo.name; + } + + get pubkey() { + return this._mintInfo.pubkey; + } + + get nuts() { + return this._mintInfo.nuts; + } + + get version() { + return this._mintInfo.version; + } + + get motd() { + return this._mintInfo.motd; + } + + // Below methods are added in addition to what the cashu-ts MintInfo class provides + + get iconUrl() { + return this._mintInfo.icon_url; + } + + /** + * Whether the mint only allows internal melts. + * + * NOTE: This flag is not currently defined in the NUTs. + * Internal melts only is a feature that we have added to agicash mints + * for creating a closed-loop mint. + */ + get internalMeltsOnly() { + const methods = this._mintInfo.nuts[5].methods as (SwapMethod & { + options?: { internal_melts_only?: boolean }; + })[]; + return methods.some( + (method) => method.options?.internal_melts_only === true, + ); + } +} diff --git a/app/lib/cashu/mint-validation.ts b/app/lib/cashu/mint-validation.ts index 125392f40..034f8857e 100644 --- a/app/lib/cashu/mint-validation.ts +++ b/app/lib/cashu/mint-validation.ts @@ -1,10 +1,6 @@ import type { MintKeyset, WebSocketSupport } from '@cashu/cashu-ts'; -import type { - CashuProtocolUnit, - MintInfo, - NUT, - NUT17WebSocketCommand, -} from './types'; +import type { MintInfo } from './mint-info'; +import type { CashuProtocolUnit, NUT, NUT17WebSocketCommand } from './types'; type NutValidationResult = | { isValid: false; message: string } diff --git a/app/lib/cashu/types.ts b/app/lib/cashu/types.ts index 5ee7ffea0..b6902bb58 100644 --- a/app/lib/cashu/types.ts +++ b/app/lib/cashu/types.ts @@ -1,4 +1,3 @@ -import type { CashuWallet } from '@cashu/cashu-ts'; import { z } from 'zod'; /** @@ -140,12 +139,6 @@ export type ProofSecret = */ export type P2PKSecret = NUT10Secret & { kind: 'P2PK' }; -/** - * A class that represents the data fetched from the mint's - * [NUT-06 info endpoint](https://github.com/cashubtc/nuts/blob/main/06.md) - */ -export type MintInfo = Awaited>; - /** * The units that are determined by the soft-consensus of cashu mints and wallets. * These units are not definite as they are not defined in NUTs directly. diff --git a/app/lib/cashu/utils.ts b/app/lib/cashu/utils.ts index 31ce75db2..00cb2b737 100644 --- a/app/lib/cashu/utils.ts +++ b/app/lib/cashu/utils.ts @@ -9,6 +9,7 @@ import Big from 'big.js'; import type { DistributedOmit } from 'type-fest'; import { decodeBolt11 } from '~/lib/bolt11'; import type { Currency, CurrencyUnit } from '../money'; +import { MintInfo } from './mint-info'; import { sumProofs } from './proof'; import type { CashuProtocolUnit } from './types'; @@ -83,13 +84,17 @@ export const getWalletCurrency = (wallet: CashuWallet) => { */ export class ExtendedCashuWallet extends CashuWallet { private _bip39Seed: Uint8Array | undefined; + private _cachedMintInfo: MintInfo | undefined; constructor( mint: CashuMint, - options: ConstructorParameters[1], + options?: ConstructorParameters[1] & { + mintInfo?: MintInfo; + }, ) { super(mint, options); this._bip39Seed = options?.bip39seed; + this._cachedMintInfo = options?.mintInfo; } get seed() { @@ -99,6 +104,15 @@ export class ExtendedCashuWallet extends CashuWallet { return this._bip39Seed; } + get cachedMintInfo() { + if (!this._cachedMintInfo) { + throw new Error( + 'Mint info not cached. Initialize the wallet with a MintInfo or call getMintInfo first.', + ); + } + return this._cachedMintInfo; + } + /** * Override selectProofsToSend to allow postprocessing of the result. * @param proofs - The available proofs to select from @@ -156,6 +170,15 @@ export class ExtendedCashuWallet extends CashuWallet { return fee; } + async getExtendedMintInfo() { + if (this._cachedMintInfo) { + return this._cachedMintInfo; + } + const info = new MintInfo(await this.mint.getInfo()); + this._cachedMintInfo = info; + return info; + } + private getMinNumberOfProofsForAmount(keys: Keys, amount: Big) { const availableDenominations = Object.keys(keys).map((x) => new Big(x)); const biggestDenomination = availableDenominations.reduce( @@ -205,6 +228,7 @@ export const getCashuWallet = ( 'unit' > & { unit?: CurrencyUnit; + mintInfo?: MintInfo; } = {}, ) => { const { unit, ...rest } = options; diff --git a/app/routes/_protected._index.tsx b/app/routes/_protected._index.tsx index e1a508993..a96a3317e 100644 --- a/app/routes/_protected._index.tsx +++ b/app/routes/_protected._index.tsx @@ -4,11 +4,12 @@ import { ChartSpline, Clock, Cog, + Star, } from 'lucide-react'; import { useState } from 'react'; import type { LinksFunction } from 'react-router'; import agicashIcon192 from '~/assets/icon-192x192.png'; -import { Page, PageContent, PageHeader } from '~/components/page'; +import { Page, PageContent } from '~/components/page'; import { Button } from '~/components/ui/button'; import { Skeleton } from '~/components/ui/skeleton'; import { @@ -68,7 +69,15 @@ export default function Index() { return ( - +
+ + + +
- +
diff --git a/app/routes/_protected.cards._index.tsx b/app/routes/_protected.cards._index.tsx new file mode 100644 index 000000000..48ec48f7a --- /dev/null +++ b/app/routes/_protected.cards._index.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { + Page, + PageBackButton, + PageContent, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { useAccounts } from '~/features/accounts/account-hooks'; +import { CardStack } from '~/features/stars/card-stack'; +import { SelectedCardDetails } from '~/features/stars/selected-card-details'; +export default function Cards() { + const { data: starAccounts } = useAccounts({ + type: 'cashu', + starAccountsOnly: true, + }); + + const [selectedAccountId, setSelectedAccountId] = useState( + starAccounts.length === 1 ? starAccounts[0].id : null, + ); + const selectedCardIndex = starAccounts.findIndex( + (acc) => acc.id === selectedAccountId, + ); + const selectedAccount = + selectedCardIndex >= 0 ? starAccounts[selectedCardIndex] : null; + + const handleCardSelect = (accountId: string, event?: React.MouseEvent) => { + event?.stopPropagation(); + + // If only one card, keep it selected + if (starAccounts.length === 1) { + return; + } + + // Toggle selection: if already selected, deselect it + setSelectedAccountId((prev) => (prev === accountId ? null : accountId)); + }; + + const handleBackButtonClick = (event: React.MouseEvent) => { + // If only one card, don't prevent back navigation + if (starAccounts.length === 1) { + return; + } + + // If a card is selected, deselect it instead of navigating + if (selectedAccount) { + event.preventDefault(); + setSelectedAccountId(null); + } + }; + + return ( + + + + Loyalty + + + +
+ {/* Cards Stack */} +
+ = 0 ? selectedCardIndex : null + } + onCardSelect={handleCardSelect} + /> +
+ + {/* Selected Card Details: Send/Receive buttons and transaction list */} + {selectedAccount && ( + + )} +
+
+
+ ); +} diff --git a/app/routes/_protected.cards.tsx b/app/routes/_protected.cards.tsx new file mode 100644 index 000000000..8fbb343aa --- /dev/null +++ b/app/routes/_protected.cards.tsx @@ -0,0 +1,9 @@ +import { useState } from 'react'; +import { Outlet } from 'react-router'; +import { createTransactionAckStatusStore } from '~/features/transactions/transaction-ack-status-store'; + +export default function CardsLayout() { + const [store] = useState(() => createTransactionAckStatusStore()); + + return ; +} diff --git a/app/routes/_protected.receive.tsx b/app/routes/_protected.receive.tsx index 30b2343d9..e5cf9d64e 100644 --- a/app/routes/_protected.receive.tsx +++ b/app/routes/_protected.receive.tsx @@ -1,12 +1,16 @@ import { Outlet } from 'react-router'; -import { useDefaultAccount } from '~/features/accounts/account-hooks'; +import { + useDefaultAccount, + useGetAccountFromLocation, +} from '~/features/accounts/account-hooks'; import { ReceiveProvider } from '~/features/receive'; export default function ReceiveLayout() { const defaultAccount = useDefaultAccount(); + const specifiedAccount = useGetAccountFromLocation({ type: 'cashu' }); return ( - + ); diff --git a/app/routes/_protected.send.tsx b/app/routes/_protected.send.tsx index d0c44418d..dbc41ba1b 100644 --- a/app/routes/_protected.send.tsx +++ b/app/routes/_protected.send.tsx @@ -1,12 +1,16 @@ import { Outlet } from 'react-router'; -import { useDefaultAccount } from '~/features/accounts/account-hooks'; +import { + useDefaultAccount, + useGetAccountFromLocation, +} from '~/features/accounts/account-hooks'; import { SendProvider } from '~/features/send'; export default function SendLayout() { const defaultAccount = useDefaultAccount(); + const specifiedAccount = useGetAccountFromLocation({ type: 'cashu' }); return ( - + ); diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 0a6c6656b..c51ca0d1a 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -909,6 +909,7 @@ export type Database = { } list_transactions: { Args: { + p_account_id?: string p_cursor_created_at?: string p_cursor_id?: string p_cursor_state_sort_order?: number diff --git a/supabase/migrations/20251007204919_list-transactions-by-accountId.sql b/supabase/migrations/20251007204919_list-transactions-by-accountId.sql new file mode 100644 index 000000000..81cf4fb65 --- /dev/null +++ b/supabase/migrations/20251007204919_list-transactions-by-accountId.sql @@ -0,0 +1,72 @@ +-- Description: +-- This migration adds optional filtering by account_id to the list_transactions function. +-- +-- Affected: wallet.list_transactions function +-- +-- Changes: +-- - Adds optional p_account_id parameter to filter transactions by account +-- - Maintains backward compatibility (parameter is optional) +-- - Preserves existing pagination and sorting behavior +-- ======================================== + +-- Add composite index for account_id filtering +-- This index optimizes queries that filter by both user_id and account_id +-- It covers the full query pattern: filter by user_id + account_id, then sort by state_sort_order, created_at, id +create index if not exists idx_user_account_filtered_state_ordered +on wallet.transactions ( + user_id, + account_id, + state_sort_order desc, + created_at desc, + id desc +) +where state in ('PENDING', 'COMPLETED', 'REVERSED'); + +-- Drop the existing function to recreate it with the new signature +drop function if exists wallet.list_transactions(uuid, integer, timestamptz, uuid, integer); + +-- Recreate function with optional account_id filter parameter +create or replace function wallet.list_transactions( + p_user_id uuid, + p_cursor_state_sort_order integer default null, + p_cursor_created_at timestamptz default null, + p_cursor_id uuid default null, + p_page_size integer default 25, + p_account_id uuid default null -- New optional filter parameter +) +returns setof wallet.transactions +language plpgsql +stable +security definer +set search_path = '' +as $$ +begin + -- Check if cursor data is provided + if p_cursor_created_at is null then + -- Initial page load (no cursor) + return query + select t.* + from wallet.transactions t + where t.user_id = p_user_id + and t.state in ('PENDING', 'COMPLETED', 'REVERSED') + and (p_account_id is null or t.account_id = p_account_id) -- Apply account filter if provided + order by t.state_sort_order desc, t.created_at desc, t.id desc + limit p_page_size; + else + -- Subsequent pages (with cursor) + return query + select t.* + from wallet.transactions t + where t.user_id = p_user_id + and t.state in ('PENDING', 'COMPLETED', 'REVERSED') + and (p_account_id is null or t.account_id = p_account_id) -- Apply account filter if provided + and (t.state_sort_order, t.created_at, t.id) < ( + p_cursor_state_sort_order, + p_cursor_created_at, + p_cursor_id + ) + order by t.state_sort_order desc, t.created_at desc, t.id desc + limit p_page_size; + end if; +end; +$$;