diff --git a/app/features/agicash-db/database.ts b/app/features/agicash-db/database.ts index 42cebde4c..fa1021b87 100644 --- a/app/features/agicash-db/database.ts +++ b/app/features/agicash-db/database.ts @@ -222,7 +222,17 @@ export const agicashDb = createClient(supabaseUrl, supabaseAnonKey, { }, }); -export type AgicashDb = typeof agicashDb; +export const anonAgicashDb = createClient( + supabaseUrl, + supabaseAnonKey, + { + db: { + schema: 'wallet', + }, + }, +); + +export type AgicashDb = typeof agicashDb | typeof anonAgicashDb; export type AgicashDbUser = Database['wallet']['Tables']['users']['Row']; export type AgicashDbAccount = Database['wallet']['Tables']['accounts']['Row']; @@ -237,3 +247,5 @@ export type AgicashDbTransaction = export type AgicashDbContact = Database['wallet']['Tables']['contacts']['Row']; export type AgicashDbCashuSendSwap = Database['wallet']['Tables']['cashu_send_swaps']['Row']; +export type AgicashDbLockedToken = + Database['wallet']['Tables']['locked_tokens']['Row']; diff --git a/app/features/locked-tokens/index.ts b/app/features/locked-tokens/index.ts new file mode 100644 index 000000000..cbef0e439 --- /dev/null +++ b/app/features/locked-tokens/index.ts @@ -0,0 +1,2 @@ +export * from './locked-token-repository'; +export * from './locked-token-hooks'; diff --git a/app/features/locked-tokens/locked-token-hooks.ts b/app/features/locked-tokens/locked-token-hooks.ts new file mode 100644 index 000000000..92df86e5e --- /dev/null +++ b/app/features/locked-tokens/locked-token-hooks.ts @@ -0,0 +1,63 @@ +import type { Token } from '@cashu/cashu-ts'; +import { + queryOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; +import { getTokenHash } from '../shared/cashu'; +import { + type AnonLockedTokenRepository, + useAnonLockedTokenRepository, + useLockedTokenRepository, +} from './locked-token-repository'; + +type CreateProps = { + token: Token; + userId: string; + accessCode?: string; +}; + +export function useCreateLockedToken() { + const repository = useLockedTokenRepository(); + + return useMutation({ + mutationFn: async (props: CreateProps) => { + const { token, accessCode, userId } = props; + + const tokenHash = await getTokenHash(token); + + return repository.createLockedToken({ + tokenHash, + token, + accessCode, + userId, + }); + }, + }); +} + +export const lockedTokenQueryOptions = ({ + tokenHash, + accessCode, + repository, +}: { + tokenHash: string; + accessCode?: string; + repository: AnonLockedTokenRepository; +}) => { + return queryOptions({ + queryKey: ['lockedToken', tokenHash, accessCode], + queryFn: () => repository.getLockedToken({ tokenHash, accessCode }), + staleTime: Number.POSITIVE_INFINITY, + enabled: !!tokenHash, + }); +}; + +export const useGetLockedToken = () => { + const queryClient = useQueryClient(); + const repository = useAnonLockedTokenRepository(); + return async (tokenHash: string, accessCode?: string) => + queryClient.fetchQuery( + lockedTokenQueryOptions({ tokenHash, accessCode, repository }), + ); +}; diff --git a/app/features/locked-tokens/locked-token-repository.ts b/app/features/locked-tokens/locked-token-repository.ts new file mode 100644 index 000000000..4ffad3059 --- /dev/null +++ b/app/features/locked-tokens/locked-token-repository.ts @@ -0,0 +1,154 @@ +import { type Token, getDecodedToken, getEncodedToken } from '@cashu/cashu-ts'; +import type { + AgicashDb, + AgicashDbLockedToken, +} from '~/features/agicash-db/database'; +import { agicashDb, anonAgicashDb } from '~/features/agicash-db/database'; +import { computeSHA256 } from '~/lib/sha256'; +import type { LockedToken } from './locked-token'; + +type CreateLockedTokenParams = { + /** The unique hash identifier for the token */ + tokenHash: string; + /** The locked token to store */ + token: Token; + /** Optional access code to protect the token. If not provided, token will be public */ + accessCode?: string; + /** The user ID who owns this token */ + userId: string; +}; + +export class LockedTokenRepository { + constructor(private readonly db: AgicashDb) {} + + /** + * Creates a new locked token in the database. + * This method is idempotent - if a token with the same hash already exists, it will return the existing one. + * @returns The created or existing locked token. + */ + async createLockedToken({ + tokenHash, + token, + accessCode, + userId, + }: CreateLockedTokenParams): Promise { + const existing = await this.getExistingTokenByHash(tokenHash); + if (existing) { + return existing; + } + + const accessCodeHash = accessCode ? await computeSHA256(accessCode) : null; + + const query = this.db + .from('locked_tokens') + .insert({ + token_hash: tokenHash, + token: getEncodedToken(token), + access_code_hash: accessCodeHash, + user_id: userId, + }) + .select() + .single(); + + const { data, error } = await query; + + if (error) { + console.error('Failed to create locked token', { + cause: error, + tokenHash, + userId, + }); + throw new Error('Failed to create locked token', { cause: error }); + } + + if (!data) { + throw new Error('No data returned from create locked token'); + } + + return this.toLockedToken(data); + } + + /** + * Private method to check if a token exists by hash. + * This bypasses access code verification and is only used internally. + * This method can only be called by the user that owns the token. + */ + private async getExistingTokenByHash( + tokenHash: string, + ): Promise { + const { data, error } = await this.db + .from('locked_tokens') + .select() + .eq('token_hash', tokenHash) + .single(); + + if (error || !data) { + return null; + } + + return this.toLockedToken(data); + } + + async toLockedToken(data: AgicashDbLockedToken): Promise { + return { + tokenHash: data.token_hash, + token: getDecodedToken(data.token), + createdAt: data.created_at, + updatedAt: data.updated_at, + }; + } +} + +/** + * Repository for getting locked tokens that can be accessed by anyone. + */ +export class AnonLockedTokenRepository { + constructor(private readonly db: AgicashDb) {} + + /** + * Retrieves a locked token by hash and optional access code. + * @returns The locked token data if access code is correct or token is public. + */ + async getLockedToken({ + tokenHash, + accessCode, + }: { + /** The hash of the token to retrieve */ + tokenHash: string; + /** Optional access code to verify access. Not needed for public tokens. */ + accessCode?: string; + }): Promise { + const accessCodeHash = accessCode ? await computeSHA256(accessCode) : null; + + const { data, error } = await this.db.rpc('get_locked_token', { + p_token_hash: tokenHash, + p_access_code_hash: accessCodeHash ?? undefined, + }); + + if (error) { + throw new Error('Failed to get locked token', { cause: error }); + } + + console.log('getLockedToken data', data); + + // TODO: make this return null instead of null values on each column + if (!data || !data.token_hash) { + return null; + } + + return { + tokenHash: data.token_hash, + token: getDecodedToken(data.token), + createdAt: data.created_at, + updatedAt: data.updated_at, + }; + } +} + +export function useLockedTokenRepository() { + return new LockedTokenRepository(agicashDb); +} + +export function useAnonLockedTokenRepository() { + return new AnonLockedTokenRepository(anonAgicashDb); +} diff --git a/app/features/locked-tokens/locked-token.ts b/app/features/locked-tokens/locked-token.ts new file mode 100644 index 000000000..266ba85de --- /dev/null +++ b/app/features/locked-tokens/locked-token.ts @@ -0,0 +1,12 @@ +import type { Token } from '@cashu/cashu-ts'; + +export type LockedToken = { + /** Hash of the locked token that is used as the primary key */ + tokenHash: string; + /** The locked token */ + token: Token; + /** Date and time the token was created in ISO 8601 format. */ + createdAt: string; + /** Date and time the token was updated in ISO 8601 format. */ + updatedAt: string; +}; diff --git a/app/features/merchant/index.ts b/app/features/merchant/index.ts new file mode 100644 index 000000000..fb9f27381 --- /dev/null +++ b/app/features/merchant/index.ts @@ -0,0 +1,3 @@ +export { MerchantProvider, useMerchantStore } from './merchant-provider'; +export type { MerchantState } from './merchant-store'; +export { MerchantShareCashuToken } from './merchant-share-cashu-token'; diff --git a/app/features/merchant/merchant-provider.tsx b/app/features/merchant/merchant-provider.tsx new file mode 100644 index 000000000..ccfd2aaa6 --- /dev/null +++ b/app/features/merchant/merchant-provider.tsx @@ -0,0 +1,56 @@ +import { + type PropsWithChildren, + createContext, + useContext, + useState, +} from 'react'; +import { useStore } from 'zustand'; +import type { Account } from '~/features/accounts/account'; +import { + useAccountsCache, + useGetLatestAccount, +} from '../accounts/account-hooks'; +import { useGetCashuSendSwapQuote } from '../send/cashu-send-swap-hooks'; +import { + type MerchantState, + type MerchantStore, + createMerchantStore, +} from './merchant-store'; + +const MerchantContext = createContext(null); + +type Props = PropsWithChildren<{ + /** Usually the user's default account. This sets the initial account to send from. */ + initialAccount: Account; +}>; + +export const MerchantProvider = ({ initialAccount, children }: Props) => { + const accountsCache = useAccountsCache(); + const getLatestAccount = useGetLatestAccount(); + const { mutateAsync: getCashuSendSwapQuote } = useGetCashuSendSwapQuote(); + + const [store] = useState(() => + createMerchantStore({ + initialAccount, + accountsCache, + getLatestAccount, + getCashuSendSwapQuote, + }), + ); + + return ( + + {children} + + ); +}; + +export const useMerchantStore = ( + selector?: (state: MerchantState) => T, +): T => { + const store = useContext(MerchantContext); + if (!store) { + throw new Error('Missing MerchantProvider in the tree'); + } + return useStore(store, selector ?? ((state) => state as T)); +}; diff --git a/app/features/merchant/merchant-share-cashu-token.tsx b/app/features/merchant/merchant-share-cashu-token.tsx new file mode 100644 index 000000000..03777a87e --- /dev/null +++ b/app/features/merchant/merchant-share-cashu-token.tsx @@ -0,0 +1,135 @@ +import { Copy } from 'lucide-react'; +import { useState } from 'react'; +import { useCopyToClipboard } from 'usehooks-ts'; +import { MoneyInputDisplay } from '~/components/money-display'; +import { + ClosePageButton, + Page, + PageContent, + PageFooter, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { Button } from '~/components/ui/button'; +import { Card, CardContent } from '~/components/ui/card'; +import { Separator } from '~/components/ui/separator'; +import useLocationData from '~/hooks/use-location'; +import { useToast } from '~/hooks/use-toast'; +import { LinkWithViewTransition } from '~/lib/transitions'; +import type { CashuSendSwap } from '../send/cashu-send-swap'; +import { getDefaultUnit } from '../shared/currencies'; +import { useMerchantStore } from './merchant-provider'; + +type Props = { + tokenHash: string; + cardCode?: string; + privateKey: string; + swap: CashuSendSwap; +}; + +/** + * Custom component for merchant created tokens + * Shows copyable link with tokenHash for secure token retrieval + */ +export function MerchantShareCashuToken({ + tokenHash, + cardCode, + privateKey, + swap, +}: Props) { + const { toast } = useToast(); + const { origin } = useLocationData(); + const [, copyToClipboard] = useCopyToClipboard(); + const [linkCopied, setLinkCopied] = useState(false); + const resetMerchantStore = useMerchantStore((s) => s.reset); + + const shareableLink = `${origin}/locked-token/${tokenHash}#unlockingKey=${privateKey}`; + const shortShareableLink = `${origin}/locked-token/${tokenHash.slice(0, 8)}...${tokenHash.slice(-8)}&unlockingKey=${privateKey.slice(0, 20)}...${privateKey.slice(-4)}`; + + const handleCopyLink = () => { + copyToClipboard(shareableLink); + setLinkCopied(true); + toast({ + title: 'Link copied to clipboard', + description: 'Share this link to send the payment', + duration: 2000, + }); + }; + + const unit = getDefaultUnit(swap.totalAmount.currency); + + return ( + + + + Success + + +
+ +
+
+ + + {/* Payment Details - Minimal */} + {cardCode && ( + <> +
+
+ Card Code + + {cardCode} + +
+
+ + + + )} + + {/* Gift Link Section - Main CTA */} +
+
+

Share Gift Link

+

+ Send this link to complete the payment +

+
+
+ {shortShareableLink} +
+ +
+
+
+
+
+ + {linkCopied && ( + + + + )} +
+ ); +} diff --git a/app/features/merchant/merchant-store.ts b/app/features/merchant/merchant-store.ts new file mode 100644 index 000000000..a37f238f8 --- /dev/null +++ b/app/features/merchant/merchant-store.ts @@ -0,0 +1,182 @@ +import { create } from 'zustand'; +import type { Account } from '~/features/accounts/account'; +import type { Money } from '~/lib/money'; +import type { AccountsCache } from '../accounts/account-hooks'; +import type { CashuSwapQuote } from '../send/cashu-send-swap-service'; + +export const CARD_CODE_LENGTH = 4; + +type State = { + /** + * Amount to send. + */ + amount: Money | null; + /** + * The code printed on the card that will be required to fetch the token from the database + */ + cardCode: string; + /** + * ID of the account to send from. + */ + accountId: string; + /** + * Quote for the swap (includes fees and validation) + */ + quote: CashuSwapQuote | null; + /** + * Status of quote fetching + */ + status: 'idle' | 'quoting'; + /** + * Private key for the created token (P2PK spending condition) + */ + privateKey: string | null; +}; + +type Actions = { + setAmount: (amount: Money) => void; + setCode: (code: string) => void; + handleCodeInput: (input: string, onInvalidInput?: () => void) => void; + setQuote: (quote: CashuSwapQuote | null) => void; + setStatus: (status: 'idle' | 'quoting') => void; + setPrivateKey: (privateKey: string) => void; + setAccount: (account: Account) => void; + getSourceAccount: () => Account; + getQuote: ( + amount: Money, + requireSwap: boolean, + ) => Promise<{ success: true } | { success: false; error: unknown }>; + reset: () => void; +}; + +export type MerchantState = State & Actions; + +type CreateMerchantStoreProps = { + initialAccount: Account; + accountsCache: AccountsCache; + getLatestAccount: (accountId: string) => Promise; + getCashuSendSwapQuote: (params: { + accountId: string; + amount: Money; + requireSwap: boolean; + senderPaysFee?: boolean; + }) => Promise; +}; + +export const createMerchantStore = ({ + initialAccount, + accountsCache, + getLatestAccount, + getCashuSendSwapQuote, +}: CreateMerchantStoreProps) => { + return create()((set, get) => ({ + amount: null, + cardCode: '', + accountId: initialAccount.id, + quote: null, + status: 'idle', + privateKey: null, + + setAmount: (amount) => set({ amount }), + + setCode: (code) => { + if (code.length > CARD_CODE_LENGTH) { + throw new Error(`Code must be less than ${CARD_CODE_LENGTH} digits`); + } + if (!Number.isInteger(Number(code))) { + throw new Error('Code must be an integer'); + } + set({ cardCode: code }); + }, + + handleCodeInput: (input, onInvalidInput) => { + const currentCardCode = get().cardCode; + + if (input === 'Backspace') { + if (currentCardCode.length === 0) { + onInvalidInput?.(); + return; + } + const newCardCode = currentCardCode.slice(0, -1); + set({ cardCode: newCardCode }); + return; + } + + if (currentCardCode.length >= CARD_CODE_LENGTH) { + onInvalidInput?.(); + return; + } + + if (!Number.isInteger(Number(input))) { + onInvalidInput?.(); + return; + } + + const newCardCode = currentCardCode + input; + set({ cardCode: newCardCode }); + }, + + setQuote: (quote) => set({ quote }), + + setStatus: (status) => set({ status }), + + setPrivateKey: (privateKey) => set({ privateKey }), + + setAccount: (account) => set({ accountId: account.id }), + + getSourceAccount: () => { + const accountId = get().accountId; + const account = accountsCache.get(accountId); + if (!account) { + throw new Error(`Account with id ${accountId} not found`); + } + return account; + }, + + getQuote: async (amount, requireSwap) => { + const { accountId } = get(); + const account = await getLatestAccount(accountId); + + if (account.type !== 'cashu') { + return { + success: false, + error: new Error('Only cashu accounts supported'), + }; + } + + set({ status: 'quoting', amount }); + + try { + const quote = await getCashuSendSwapQuote({ + accountId: account.id, + amount, + requireSwap, + senderPaysFee: true, + }); + + set({ quote, status: 'idle' }); + return { success: true }; + } catch (error) { + console.error('Error getting merchant quote:', { + cause: error, + amount, + accountId, + }); + set({ status: 'idle' }); + return { success: false, error }; + } + }, + + reset: () => + set({ + amount: null, + cardCode: '', + accountId: initialAccount.id, + quote: null, + status: 'idle', + privateKey: null, + }), + })); +}; + +export type MerchantStore = ReturnType; diff --git a/app/features/receive/receive-cashu-token.tsx b/app/features/receive/receive-cashu-token.tsx index 63b6223de..82009d0d9 100644 --- a/app/features/receive/receive-cashu-token.tsx +++ b/app/features/receive/receive-cashu-token.tsx @@ -292,7 +292,10 @@ export default function ReceiveToken({ ); } -export function PublicReceiveCashuToken({ token }: { token: Token }) { +export function PublicReceiveCashuToken({ + token, + unlockingKey, +}: { token: Token; unlockingKey: string | undefined }) { const [signingUpGuest, setSigningUpGuest] = useState(false); const { signUpGuest } = useAuthActions(); const navigate = useNavigate(); @@ -303,6 +306,9 @@ export function PublicReceiveCashuToken({ token }: { token: Token }) { const { claimableToken, cannotClaimReason } = useCashuTokenWithClaimableProofs({ token, + cashuPubKey: unlockingKey + ? getPublicKeyFromPrivateKey(unlockingKey, { asBytes: false }) + : undefined, }); const location = useLocation(); diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx index 25ca50304..49f867eb5 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -125,7 +125,7 @@ export default function ReceiveInput() { } const encodedToken = getEncodedToken(token); - const hash = `#${encodedToken}`; + const hash = `#token=${encodedToken}`; // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it // See https://github.com/remix-run/remix/discussions/10721 diff --git a/app/features/receive/scan.tsx b/app/features/receive/scan.tsx index 8219e16d0..33c9a6164 100644 --- a/app/features/receive/scan.tsx +++ b/app/features/receive/scan.tsx @@ -40,7 +40,7 @@ export default function Scan() { } const encodedToken = getEncodedToken(token); - const hash = `#${encodedToken}`; + const hash = `#token=${encodedToken}`; // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it // See https://github.com/remix-run/remix/discussions/10721 diff --git a/app/features/send/cashu-send-swap-hooks.ts b/app/features/send/cashu-send-swap-hooks.ts index 53febeb28..797acd73c 100644 --- a/app/features/send/cashu-send-swap-hooks.ts +++ b/app/features/send/cashu-send-swap-hooks.ts @@ -21,6 +21,7 @@ import { import { type AgicashDbCashuSendSwap, agicashDb } from '../agicash-db/database'; import { useEncryption } from '../shared/encryption'; import { NotFoundError } from '../shared/error'; +import type { CashuSendSwapType } from '../transactions/transaction'; import { useUser } from '../user/user-hooks'; import type { CashuSendSwap, PendingCashuSendSwap } from './cashu-send-swap'; import { @@ -139,12 +140,14 @@ export function useCreateCashuSendSwap({ spendingConditionData, unlockingData, senderPaysFee = true, + type = 'CASHU_TOKEN', }: { amount: Money; accountId: string; spendingConditionData?: SpendingConditionData; unlockingData?: UnlockingData; senderPaysFee?: boolean; + type?: CashuSendSwapType; }) => { const account = await getLatestCashuAccount(accountId); return cashuSendSwapService.create({ @@ -152,6 +155,7 @@ export function useCreateCashuSendSwap({ amount, account, senderPaysFee, + type, spendingConditionData, unlockingData, }); @@ -275,7 +279,9 @@ export function useCashuSendSwap(id: string) { type UseTrackCashuSendSwapProps = { id?: string; - onPending?: (swap: CashuSendSwap) => void; + onPending?: ( + swap: CashuSendSwap & { state: 'PENDING' } & { account: CashuAccount }, + ) => void; onCompleted?: (swap: CashuSendSwap) => void; onFailed?: (swap: CashuSendSwap) => void; }; @@ -287,7 +293,7 @@ type UseTrackCashuSendSwapResponse = } | { status: CashuSendSwap['state']; - swap: CashuSendSwap; + swap: CashuSendSwap & { account: CashuAccount }; }; export function useTrackCashuSendSwap({ @@ -311,29 +317,36 @@ export function useTrackCashuSendSwap({ enabled, }); + const account = useAccount(data?.accountId ?? '') as CashuAccount | undefined; + useEffect(() => { if (!data) return; if (data.state === 'PENDING') { - onPendingRef.current?.(data); + onPendingRef.current?.({ ...data, account } as CashuSendSwap & { + state: 'PENDING'; + } & { account: CashuAccount }); } else if (data.state === 'COMPLETED') { onCompletedRef.current?.(data); } else if (data.state === 'FAILED') { onFailedRef.current?.(data); } - }, [data]); + }, [data, account]); if (!enabled) { return { status: 'DISABLED' }; } - if (!data) { + if (!data || !account) { return { status: 'LOADING' }; } return { status: data.state, - swap: data, + swap: { + ...data, + account, + }, }; } diff --git a/app/features/send/cashu-send-swap-repository.ts b/app/features/send/cashu-send-swap-repository.ts index 68f8887c7..6d7a7fef9 100644 --- a/app/features/send/cashu-send-swap-repository.ts +++ b/app/features/send/cashu-send-swap-repository.ts @@ -14,7 +14,10 @@ import { } from '../agicash-db/database'; import { getDefaultUnit } from '../shared/currencies'; import { useEncryption } from '../shared/encryption'; -import type { CashuTokenSendTransactionDetails } from '../transactions/transaction'; +import type { + CashuSendSwapType, + CashuTokenSendTransactionDetails, +} from '../transactions/transaction'; import type { CashuSendSwap } from './cashu-send-swap'; type Options = { @@ -100,6 +103,10 @@ type CreateSendSwap = { * The unlocking data to reverse the swap. */ unlockingData?: UnlockingData; + /** + * The type of the swap. + */ + type: CashuSendSwapType; }; export class CashuSendSwapRepository { @@ -127,6 +134,7 @@ export class CashuSendSwapRepository { outputAmounts, accountVersion, unlockingData, + type, }: CreateSendSwap, options?: Options, ) { @@ -191,6 +199,7 @@ export class CashuSendSwapRepository { p_token_hash: tokenHash, p_spending_condition_data: encryptedSpendingConditionData, p_unlocking_data: encryptedUnlockingData, + p_type: type, }); if (options?.abortSignal) { diff --git a/app/features/send/cashu-send-swap-service.ts b/app/features/send/cashu-send-swap-service.ts index c1b0e6bad..a385ab2c1 100644 --- a/app/features/send/cashu-send-swap-service.ts +++ b/app/features/send/cashu-send-swap-service.ts @@ -1,4 +1,5 @@ import { + HttpResponseError, MintOperationError, OutputData, type Proof, @@ -25,6 +26,7 @@ import { import { getTokenHash } from '../shared/cashu'; import { getDefaultUnit } from '../shared/currencies'; import { DomainError } from '../shared/error'; +import type { CashuSendSwapType } from '../transactions/transaction'; import type { CashuSendSwap } from './cashu-send-swap'; import { type CashuSendSwapRepository, @@ -113,6 +115,7 @@ export class CashuSendSwapService { account, amount, senderPaysFee, + type, spendingConditionData, unlockingData, }: { @@ -124,6 +127,10 @@ export class CashuSendSwapService { amount: Money; /** Whether the sender pays the fee for the swap by including the fee in the proofs to send */ senderPaysFee: boolean; + /** + * The type of the swap. Can be CASHU_TOKEN or GIFT. + */ + type: CashuSendSwapType; spendingConditionData?: SpendingConditionData; unlockingData?: UnlockingData; }): Promise { @@ -217,6 +224,7 @@ export class CashuSendSwapService { send: amountsFromOutputData(sendOutputData), keep: amountsFromOutputData(keepOutputData), }, + type, }); } @@ -455,15 +463,14 @@ export class CashuSendSwapService { ); try { - await wallet.swap(amountToSend, swap.inputProofs, { + return await wallet.swap(amountToSend, swap.inputProofs, { outputData, keysetId: swap.keysetId, }); - // throw for now to trigger the restore path - throw new MintOperationError(CashuErrorCodes.OUTPUT_ALREADY_SIGNED, ''); } catch (error) { if ( - error instanceof MintOperationError && + (error instanceof MintOperationError || + error instanceof HttpResponseError) && isCashuError(error, [ CashuErrorCodes.OUTPUT_ALREADY_SIGNED, CashuErrorCodes.TOKEN_ALREADY_SPENT, diff --git a/app/features/send/share-cashu-token.tsx b/app/features/send/share-cashu-token.tsx index 90038c1f6..c92871d7a 100644 --- a/app/features/send/share-cashu-token.tsx +++ b/app/features/send/share-cashu-token.tsx @@ -37,7 +37,7 @@ export function ShareCashuToken({ token }: Props) { const [showOk, setShowOk] = useState(false); const encodedToken = getEncodedToken(token); - const shareableLink = `${origin}/receive-cashu-token#${encodedToken}`; + const shareableLink = `${origin}/receive-cashu-token#token=${encodedToken}`; const shortToken = `${encodedToken.slice(0, 6)}...${encodedToken.slice(-5)}`; const shortShareableLink = `${origin}/receive-cashu-token#${shortToken}`; diff --git a/app/features/shared/cashu.ts b/app/features/shared/cashu.ts index eac45dcf6..917008166 100644 --- a/app/features/shared/cashu.ts +++ b/app/features/shared/cashu.ts @@ -17,9 +17,11 @@ import { useQueryClient, } from '@tanstack/react-query'; import { useMemo } from 'react'; +import { z } from 'zod'; import { type MintInfo, checkIsTestMint, + extractCashuToken, getCashuWallet, sumProofs, } from '~/lib/cashu'; @@ -201,3 +203,14 @@ export const isTestMintQuery = ( staleTime: Number.POSITIVE_INFINITY, retry: 3, }); + +export const sharableCashuTokenSchema = z.object({ + token: z.string().transform((tokenString) => { + const token = extractCashuToken(tokenString); + if (!token) { + throw new Error('Invalid token'); + } + return token; + }), + unlockingKey: z.string().optional(), +}); diff --git a/app/features/transactions/transaction-hooks.ts b/app/features/transactions/transaction-hooks.ts index 948c26850..32c3a0188 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(types?: Transaction['type'][]) { const userId = useUser((user) => user.id); const transactionRepository = useTransactionRepository(); const result = useInfiniteQuery({ - queryKey: [allTransactionsQueryKey], + queryKey: [allTransactionsQueryKey, { types }], initialPageParam: null, queryFn: async ({ pageParam }: { pageParam: Cursor | null }) => { const result = await transactionRepository.list({ userId, cursor: pageParam, pageSize: PAGE_SIZE, + types, }); return { transactions: result.transactions, @@ -175,12 +176,13 @@ const acknowledgeTransactionInHistoryCache = ( queryClient: QueryClient, transaction: Transaction, ) => { - queryClient.setQueryData< + // Update all transaction queries regardless of type filter + queryClient.setQueriesData< InfiniteData<{ transactions: Transaction[]; nextCursor: Cursor | null; }> - >([allTransactionsQueryKey], (old) => { + >({ queryKey: [allTransactionsQueryKey] }, (old) => { if (!old) return old; return { ...old, diff --git a/app/features/transactions/transaction-list.tsx b/app/features/transactions/transaction-list.tsx index 118572286..4075ca19f 100644 --- a/app/features/transactions/transaction-list.tsx +++ b/app/features/transactions/transaction-list.tsx @@ -1,4 +1,10 @@ -import { AlertCircle, BanknoteIcon, UserIcon, ZapIcon } from 'lucide-react'; +import { + AlertCircle, + BanknoteIcon, + GiftIcon, + UserIcon, + ZapIcon, +} from 'lucide-react'; import { type Ref, useCallback, @@ -7,6 +13,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'; @@ -132,6 +139,7 @@ const transactionTypeIconMap = { CASHU_LIGHTNING: , CASHU_TOKEN: , AGICASH_CONTACT: , + GIFT: , }; const getTransactionTypeIcon = (transaction: Transaction) => { @@ -153,6 +161,7 @@ function TransactionRow({ const { mutate: acknowledgeTransaction } = useAcknowledgeTransaction(); const { setAckStatus, statuses: ackStatuses } = useTransactionAckStatusStore(); + const location = useLocation(); const { ref } = useIsVisible({ threshold: 0.5, // Consider visible when 50% of the element is in view @@ -168,7 +177,9 @@ 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..b785ad9a5 100644 --- a/app/features/transactions/transaction-repository.ts +++ b/app/features/transactions/transaction-repository.ts @@ -33,6 +33,7 @@ type ListOptions = Options & { userId: string; cursor?: Cursor; pageSize?: number; + types?: Transaction['type'][]; }; type UnifiedTransactionDetails = @@ -68,6 +69,7 @@ export class TransactionRepository { userId, cursor = null, pageSize = 25, + types, abortSignal, }: ListOptions) { const query = this.db.rpc('list_transactions', { @@ -76,6 +78,7 @@ export class TransactionRepository { p_cursor_created_at: cursor?.createdAt, p_cursor_id: cursor?.id, p_page_size: pageSize, + p_types: types, }); if (abortSignal) { @@ -228,12 +231,12 @@ export class TransactionRepository { return createTransaction(receiveDetails.amountReceived, receiveDetails); } - if (type === 'CASHU_TOKEN' && direction === 'SEND') { + if (['CASHU_TOKEN', 'GIFT'].includes(type) && direction === 'SEND') { const sendDetails = details as CashuTokenSendTransactionDetails; return createTransaction(sendDetails.amountSpent, sendDetails); } - if (type === 'CASHU_TOKEN' && direction === 'RECEIVE') { + if (['CASHU_TOKEN', 'GIFT'].includes(type) && direction === 'RECEIVE') { const receiveDetails = details as CashuTokenReceiveTransactionDetails; return createTransaction(receiveDetails.amountReceived, receiveDetails); } diff --git a/app/features/transactions/transaction.ts b/app/features/transactions/transaction.ts index 409e6d0d7..632730359 100644 --- a/app/features/transactions/transaction.ts +++ b/app/features/transactions/transaction.ts @@ -170,7 +170,7 @@ export type Transaction = { /** * Type of the transaction. */ - type: 'CASHU_LIGHTNING' | 'CASHU_TOKEN'; + type: 'CASHU_LIGHTNING' | 'CASHU_TOKEN' | 'GIFT'; /** * State of the transaction. * Transaction states are: @@ -229,12 +229,12 @@ export type Transaction = { reversedAt?: string | null; } & ( | { - type: 'CASHU_TOKEN'; + type: CashuSendSwapType; direction: 'SEND'; details: CashuTokenSendTransactionDetails; } | { - type: 'CASHU_TOKEN'; + type: CashuSendSwapType; direction: 'RECEIVE'; details: CashuTokenReceiveTransactionDetails; } @@ -256,3 +256,5 @@ export type Transaction = { details: CashuLightningReceiveTransactionDetails; } ); + +export type CashuSendSwapType = 'CASHU_TOKEN' | 'GIFT'; diff --git a/app/lib/cashu/error-codes.ts b/app/lib/cashu/error-codes.ts index e06875ab0..ef219cf9e 100644 --- a/app/lib/cashu/error-codes.ts +++ b/app/lib/cashu/error-codes.ts @@ -181,6 +181,7 @@ export const CashuErrorMessageMappings: Record = { // https://github.com/cashubtc/nutshell/pull/693 'outputs have already been signed before': CashuErrorCodes.OUTPUT_ALREADY_SIGNED, + 'Blinded Message is already signed': CashuErrorCodes.OUTPUT_ALREADY_SIGNED, 'mint quote already issued.': CashuErrorCodes.QUOTE_ALREADY_ISSUED, 'witness is missing for p2pk signature': CashuErrorCodes.WITNESS_MISSING_P2PK, 'signature missing or invalid': CashuErrorCodes.WITNESS_MISSING_P2PK, diff --git a/app/lib/utils.ts b/app/lib/utils.ts index baae08454..81b05e466 100644 --- a/app/lib/utils.ts +++ b/app/lib/utils.ts @@ -1,5 +1,6 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import type { ZodType, ZodTypeDef } from 'zod'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -39,3 +40,37 @@ export function isSubset(subset: Set, superset: Set): boolean { } return true; } + +/** + * Parses hash parameters from a URL hash string and validates/transforms them using a zod schema + * @param hash - The hash string (e.g., "#key=value&other=param") + * @param schema - Zod schema for validation and transformation + * @returns Parsed and transformed object or null if parsing/validation fails + */ +export function parseHashParams>( + hash: string, + schema: ZodType, +): T | null { + const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash; + + if (!cleanHash) { + return null; + } + + const params: Record = {}; + const urlParams = new URLSearchParams(cleanHash); + + for (const [key, value] of urlParams.entries()) { + params[key] = value; + } + + const result = schema.safeParse(params); + + if (result.success) { + return result.data; + } + + console.error('Invalid hash params', { hash, error: result.error }); + + return null; +} diff --git a/app/routes/_protected._index.tsx b/app/routes/_protected._index.tsx index e1a508993..82a82eb05 100644 --- a/app/routes/_protected._index.tsx +++ b/app/routes/_protected._index.tsx @@ -4,6 +4,7 @@ import { ChartSpline, Clock, Cog, + Store, } from 'lucide-react'; import { useState } from 'react'; import type { LinksFunction } from 'react-router'; @@ -70,6 +71,13 @@ export default function Index() {
+ + + void; + money?: Money; +}; + +const ConvertedMoneySwitcher = ({ + onSwitchInputCurrency, + money, +}: ConvertedMoneySwitcherProps) => { + if (!money) { + return ; + } + + return ( + + ); +}; + +export default function MerchantAmountInput() { + const navigate = useNavigateWithViewTransition(); + const { toast } = useToast(); + const { animationClass: shakeAnimationClass, start: startShakeAnimation } = + useAnimation({ name: 'shake' }); + const { data: accounts } = useAccounts(); + + const status = useMerchantStore((s) => s.status); + const getQuote = useMerchantStore((s) => s.getQuote); + const receiveAccount = useMerchantStore((s) => s.getSourceAccount()); + const setReceiveAccount = useMerchantStore((s) => s.setAccount); + + const { + rawInputValue, + maxInputDecimals, + inputValue, + convertedValue, + exchangeRateError, + handleNumberInput, + switchInputCurrency, + } = useMoneyInput({ + initialRawInputValue: '0', + initialInputCurrency: receiveAccount.currency, + initialOtherCurrency: receiveAccount.currency === 'BTC' ? 'USD' : 'BTC', + }); + + const handleNext = async () => { + if (inputValue.isZero()) return; + + // Determine the amount to use for the quote + let amountToQuote = inputValue; + if (inputValue.currency !== receiveAccount.currency) { + if (!convertedValue) { + // Can't happen because when there is no converted value, the toggle will not be shown so input currency and receive currency must be the same + return; + } + amountToQuote = convertedValue; + } + + // validate the input and that we have sufficient funds + const result = await getQuote(amountToQuote, true); + if (!result.success) { + const toastOptions = + result.error instanceof DomainError + ? { description: result.error.message } + : { + title: 'Error', + description: getErrorMessage( + result.error, + 'Failed to get quote. Please try again.', + ), + variant: 'destructive' as const, + }; + toast(toastOptions); + return; + } + + navigate('/merchant/card-code', { + transition: 'slideLeft', + applyTo: 'newView', + }); + }; + + return ( + + + + Merchant + + + + + + +
+
+ +
+ + {!exchangeRateError && ( + + )} +
+ +
+ { + setReceiveAccount(account); + if (account.currency !== inputValue.currency) { + switchInputCurrency(); + } + }} + /> +
+ +
+
+
{/* spacer */} +
{/* spacer */} + +
+
+ + + 0} + onButtonClick={(value) => { + handleNumberInput(value, startShakeAnimation); + }} + /> + + + ); +} diff --git a/app/routes/_protected.merchant.card-code.tsx b/app/routes/_protected.merchant.card-code.tsx new file mode 100644 index 000000000..6cc67776d --- /dev/null +++ b/app/routes/_protected.merchant.card-code.tsx @@ -0,0 +1,159 @@ +import { MoneyInputDisplay } from '~/components/money-display'; +import { Numpad } from '~/components/numpad'; +import { + Page, + PageBackButton, + PageContent, + PageFooter, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { Redirect } from '~/components/redirect'; +import { Button } from '~/components/ui/button'; +import { Input } from '~/components/ui/input'; +import { useMerchantStore } from '~/features/merchant'; +import { CARD_CODE_LENGTH } from '~/features/merchant/merchant-store'; +import { useCreateCashuSendSwap } from '~/features/send/cashu-send-swap-hooks'; +import { getDefaultUnit } from '~/features/shared/currencies'; +import { DomainError, getErrorMessage } from '~/features/shared/error'; +import useAnimation from '~/hooks/use-animation'; +import { useToast } from '~/hooks/use-toast'; +import useUserAgent from '~/hooks/use-user-agent'; +import { generateRandomKeyPair } from '~/lib/secp256k1'; +import { useNavigateWithViewTransition } from '~/lib/transitions'; + +function MerchantCardCode() { + const navigate = useNavigateWithViewTransition(); + const { animationClass: shakeAnimationClass, start: startShakeAnimation } = + useAnimation({ name: 'shake' }); + const { toast } = useToast(); + const { isMobile } = useUserAgent(); + + const amount = useMerchantStore((s) => s.amount); + const quote = useMerchantStore((s) => s.quote); + const cardCode = useMerchantStore((s) => s.cardCode); + const setCode = useMerchantStore((s) => s.setCode); + const handleCodeInput = useMerchantStore((s) => s.handleCodeInput); + const account = useMerchantStore((s) => s.getSourceAccount()); + + const { mutate: createCashuSendSwap, status: createSwapStatus } = + useCreateCashuSendSwap({ + onSuccess: (swap) => { + navigate(`/merchant/share/${swap.id}`, { + transition: 'slideLeft', + applyTo: 'newView', + }); + }, + onError: (error) => { + const toastOptions = + error instanceof DomainError + ? { description: error.message } + : { + title: 'Error', + description: getErrorMessage( + error, + 'Failed to create cashu send swap. Please try again.', + ), + variant: 'destructive' as const, + }; + toast(toastOptions); + }, + }); + + if (!amount || !quote) { + return ( + + ); + } + + const handleGenerate = () => { + if (!quote || cardCode.length !== CARD_CODE_LENGTH) return; + + const { privateKey, publicKey } = generateRandomKeyPair({ asBytes: false }); + createCashuSendSwap({ + amount: quote.amountRequested, + accountId: account.id, + type: 'GIFT', + spendingConditionData: { + kind: 'P2PK', + data: publicKey, + conditions: null, + }, + unlockingData: { + kind: 'P2PK', + signingKeys: [privateKey], + }, + }); + }; + + const unit = getDefaultUnit(amount.currency); + + return ( + + + + Code + + + +
+ +
+ +
+
+ setCode(e.target.value)} + /> +
+
+ +
+
+
{/* spacer */} +
{/* spacer */} + +
+
+ + + {isMobile && ( + { + handleCodeInput(value, startShakeAnimation); + }} + /> + )} + + + ); +} + +export default MerchantCardCode; diff --git a/app/routes/_protected.merchant.share.$swapId.tsx b/app/routes/_protected.merchant.share.$swapId.tsx new file mode 100644 index 000000000..b17c15b3d --- /dev/null +++ b/app/routes/_protected.merchant.share.$swapId.tsx @@ -0,0 +1,195 @@ +import { getEncodedToken } from '@cashu/cashu-ts'; +import { useState } from 'react'; +import { + ClosePageButton, + Page, + PageContent, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { Card, CardContent } from '~/components/ui/card'; +import { Skeleton } from '~/components/ui/skeleton'; +import { useCreateLockedToken } from '~/features/locked-tokens'; +import { MerchantShareCashuToken, useMerchantStore } from '~/features/merchant'; +import { + useCashuSendSwap, + useTrackCashuSendSwap, +} from '~/features/send/cashu-send-swap-hooks'; +import { useUser } from '~/features/user/user-hooks'; +import { useEffectNoStrictMode } from '~/hooks/use-effect-no-strict-mode'; +import { getCashuProtocolUnit } from '~/lib/cashu'; +import { useNavigateWithViewTransition } from '~/lib/transitions'; +import type { Route } from './+types/_protected.merchant.share.$swapId'; + +/** + * Loading skeleton component shown while locked token is being created + */ +function LoadingSkeleton() { + return ( + + + + Merchant Payment + + +
+ + +
+

+ Securing payment... +

+
+ + {/* Payment Details Skeleton */} +
+
+ + +
+
+ + +
+
+ + + + {/* Gift Link Section Skeleton */} +
+
+ + +
+ + +
+
+
+
+
+
+ ); +} + +/** + * Main merchant share component with simplified state management + */ +export default function MerchantShare({ params }: Route.ComponentProps) { + const navigate = useNavigateWithViewTransition(); + const user = useUser(); + const { data: swap } = useCashuSendSwap(params.swapId); + const cardCode = useMerchantStore((s) => s.cardCode); + + const { + mutate: createLockedToken, + data: lockedTokenData, + isPending: isCreatingLockedToken, + status: createLockedTokenStatus, + } = useCreateLockedToken(); + const [hasInitiatedTokenCreation, setHasInitiatedTokenCreation] = + useState(false); + + const { swap: trackingSwap, status } = useTrackCashuSendSwap({ + id: params.swapId, + onCompleted: (swap) => { + navigate(`/transactions/${swap.transactionId}?redirectTo=/`, { + transition: 'fade', + applyTo: 'newView', + }); + }, + }); + + const privateKey = + swap.unlockingData && swap.unlockingData.kind === 'P2PK' + ? swap.unlockingData.signingKeys[0] + : undefined; + + // Create locked token when swap is pending + useEffectNoStrictMode(() => { + if (status === 'DISABLED') return; + if (!privateKey) return; + + if ( + trackingSwap?.state === 'PENDING' && + !hasInitiatedTokenCreation && + createLockedTokenStatus === 'idle' + ) { + const token = { + mint: trackingSwap.account.mintUrl, + proofs: trackingSwap.proofsToSend, + unit: getCashuProtocolUnit(trackingSwap.inputAmount.currency), + }; + + console.log(getEncodedToken(token)); + + setHasInitiatedTokenCreation(true); + createLockedToken({ + token, + accessCode: cardCode, + userId: user.id, + }); + } + }, [ + trackingSwap, + cardCode, + user.id, + status, + createLockedTokenStatus, + hasInitiatedTokenCreation, + createLockedToken, + privateKey, + ]); + + // Show loading skeleton while locked token is being created or swap is not ready + if ( + (swap.state !== 'PENDING' && swap.state !== 'COMPLETED') || + !privateKey || + (swap.state === 'PENDING' && (!lockedTokenData || isCreatingLockedToken)) + ) { + return ; + } + + // Show error if locked token creation failed + if (!lockedTokenData) { + return ( + + + + Merchant Payment + + +
+ + +
+

+ Payment setup incomplete. Please try again. +

+
+
+
+
+
+
+ ); + } + + // Show the merchant share screen + return ( + + ); +} diff --git a/app/routes/_protected.merchant.transactions.$transactionId_.tsx b/app/routes/_protected.merchant.transactions.$transactionId_.tsx new file mode 100644 index 000000000..ef37aee90 --- /dev/null +++ b/app/routes/_protected.merchant.transactions.$transactionId_.tsx @@ -0,0 +1,41 @@ +import { useSearchParams } from 'react-router'; +import { + ClosePageButton, + Page, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { TransactionDetails } from '~/features/transactions/transaction-details'; +import { useSuspenseTransaction } from '~/features/transactions/transaction-hooks'; +import type { Route } from './+types/_protected.transactions.$transactionId_'; + +export default function MerchantTransactionDetailsPage({ + params: { transactionId }, +}: Route.ComponentProps) { + const { data: transaction } = useSuspenseTransaction(transactionId); + const [searchParams] = useSearchParams(); + const redirectTo = searchParams.get('redirectTo'); + + return ( + + + + + {transaction.state === 'REVERSED' + ? 'Reclaimed' + : transaction.direction === 'RECEIVE' + ? 'Received' + : 'Sent'} + + + + + ); +} diff --git a/app/routes/_protected.merchant.transactions._index.tsx b/app/routes/_protected.merchant.transactions._index.tsx new file mode 100644 index 000000000..9dd7110b2 --- /dev/null +++ b/app/routes/_protected.merchant.transactions._index.tsx @@ -0,0 +1,26 @@ +import { + ClosePageButton, + Page, + PageContent, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { TransactionList } from '~/features/transactions/transaction-list'; + +export default function MerchantGiftsListPage() { + return ( + + + + Gifts + + + + + + ); +} diff --git a/app/routes/_protected.merchant.transactions.tsx b/app/routes/_protected.merchant.transactions.tsx new file mode 100644 index 000000000..b063cd2ab --- /dev/null +++ b/app/routes/_protected.merchant.transactions.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 MerchantTransactionsLayout() { + const [store] = useState(() => createTransactionAckStatusStore()); + + return ; +} diff --git a/app/routes/_protected.merchant.tsx b/app/routes/_protected.merchant.tsx new file mode 100644 index 000000000..36a04d2d2 --- /dev/null +++ b/app/routes/_protected.merchant.tsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router'; +import { useDefaultAccount } from '~/features/accounts/account-hooks'; +import { MerchantProvider } from '~/features/merchant'; + +export default function MerchantLayout() { + const defaultAccount = useDefaultAccount(); + + return ( + + + + ); +} diff --git a/app/routes/_protected.receive.cashu_.token.tsx b/app/routes/_protected.receive.cashu_.token.tsx index 7e139cf37..07f183744 100644 --- a/app/routes/_protected.receive.cashu_.token.tsx +++ b/app/routes/_protected.receive.cashu_.token.tsx @@ -3,29 +3,17 @@ import { redirect } from 'react-router'; import { Page } from '~/components/page'; import { LoadingScreen } from '~/features/loading/LoadingScreen'; import { ReceiveCashuToken } from '~/features/receive'; -import { extractCashuToken } from '~/lib/cashu'; +import { sharableCashuTokenSchema } from '~/features/shared/cashu'; +import { parseHashParams } from '~/lib/utils'; import type { Route } from './+types/_protected.receive.cashu_.token'; import { ReceiveCashuTokenSkeleton } from './receive-cashu-token-skeleton'; -function parseHashParams(hash: string): URLSearchParams | null { - const cleaned = hash.startsWith('#') ? hash.slice(1) : hash; - - // Only parse as params if it contains = (parameter format) - if (!cleaned.includes('=')) { - return null; - } - - return new URLSearchParams(cleaned); -} - export async function clientLoader({ request }: Route.ClientLoaderArgs) { // Request url doesn't include hash so we need to read it from the window location instead const hash = window.location.hash; - const hashParams = parseHashParams(hash); - - const token = extractCashuToken(hash); + const hashParams = parseHashParams(hash, sharableCashuTokenSchema); - if (!token) { + if (!hashParams) { throw redirect('/receive'); } @@ -33,9 +21,13 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { const selectedAccountId = location.searchParams.get('selectedAccountId') ?? undefined; const autoClaim = location.searchParams.get('autoClaim') === 'true'; - const unlockingKey = hashParams?.get('unlockingKey'); - return { token, autoClaim, selectedAccountId, unlockingKey }; + return { + token: hashParams.token, + autoClaim, + selectedAccountId, + unlockingKey: hashParams.unlockingKey, + }; } clientLoader.hydrate = true as const; diff --git a/app/routes/_protected.transactions._index.tsx b/app/routes/_protected.transactions._index.tsx index 0383a2fe9..68e7709c1 100644 --- a/app/routes/_protected.transactions._index.tsx +++ b/app/routes/_protected.transactions._index.tsx @@ -15,7 +15,7 @@ export default function TransactionsPage() { Transactions - + ); diff --git a/app/routes/_public.locked-token.$tokenHash.tsx b/app/routes/_public.locked-token.$tokenHash.tsx new file mode 100644 index 000000000..98c1febb5 --- /dev/null +++ b/app/routes/_public.locked-token.$tokenHash.tsx @@ -0,0 +1,252 @@ +import { getEncodedToken } from '@cashu/cashu-ts'; +import { useState } from 'react'; +import { type SubmitHandler, useForm } from 'react-hook-form'; +import { redirect } from 'react-router'; +import { z } from 'zod'; +import { Numpad } from '~/components/numpad'; +import { + ClosePageButton, + Page, + PageContent, + PageFooter, + PageHeader, +} from '~/components/page'; +import { Redirect } from '~/components/redirect'; +import { Button } from '~/components/ui/button'; +import { Card, CardContent } from '~/components/ui/card'; +import { Input } from '~/components/ui/input'; +import { anonAgicashDb } from '~/features/agicash-db/database'; +import { AnonLockedTokenRepository } from '~/features/locked-tokens'; +import { + lockedTokenQueryOptions, + useGetLockedToken, +} from '~/features/locked-tokens/locked-token-hooks'; +import useAnimation from '~/hooks/use-animation'; +import { useToast } from '~/hooks/use-toast'; +import useUserAgent from '~/hooks/use-user-agent'; +import { useNavigateWithViewTransition } from '~/lib/transitions'; +import { parseHashParams } from '~/lib/utils'; +import { getQueryClient } from '~/query-client'; +import type { Route } from './+types/_public.locked-token.$tokenHash'; + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { tokenHash } = params; + // Request url doesn't include hash so we need to read it from the window location instead + const hash = window.location.hash; + const hashParams = parseHashParams( + hash, + z.object({ + unlockingKey: z.string(), + }), + ); + + if (!hashParams) { + throw redirect('/'); + } + + const queryClient = getQueryClient(); + + // Try to fetch the token without an access code first + const repository = new AnonLockedTokenRepository(anonAgicashDb); + const lockedTokenData = await queryClient.fetchQuery( + lockedTokenQueryOptions({ + tokenHash, + repository, + }), + ); + + return { + tokenHash, + unlockingKey: hashParams.unlockingKey, + lockedTokenData, + }; +} + +const ACCESS_CODE_LENGTH = 4; + +type FormValues = { cardCode: string }; + +export default function LockedTokenPage({ loaderData }: Route.ComponentProps) { + const { + tokenHash, + unlockingKey, + lockedTokenData: initialTokenData, + } = loaderData; + + const { toast } = useToast(); + const navigate = useNavigateWithViewTransition(); + const getLockedToken = useGetLockedToken(); + const { isMobile } = useUserAgent(); + const { animationClass: shakeAnimationClass, start: startShakeAnimation } = + useAnimation({ name: 'shake' }); + + const [cardCode, setAccessCode] = useState(''); + const [isRedeeming, setIsRedeeming] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + setValue, + } = useForm(); + + const handleCodeInput = (input: string) => { + if (input === 'Backspace') { + if (cardCode.length === 0) { + startShakeAnimation(); + return; + } + const newCode = cardCode.slice(0, -1); + setAccessCode(newCode); + setValue('cardCode', newCode); + return; + } + + if (cardCode.length >= ACCESS_CODE_LENGTH) { + startShakeAnimation(); + return; + } + + if (!Number.isInteger(Number(input))) { + startShakeAnimation(); + return; + } + + const newCode = cardCode + input; + setAccessCode(newCode); + setValue('cardCode', newCode); + }; + + if (initialTokenData) { + const hashContent = `token=${getEncodedToken(initialTokenData.token)}&unlockingKey=${unlockingKey}`; + window.history.replaceState(null, '', `#${hashContent}`); + return ( + + ); + } + + const onSubmit: SubmitHandler = async (data) => { + setIsRedeeming(true); + try { + const lockedTokenData = await getLockedToken(tokenHash, data.cardCode); + + if (lockedTokenData) { + const hashContent = `token=${getEncodedToken(lockedTokenData.token)}&unlockingKey=${unlockingKey}`; + window.history.replaceState(null, '', `#${hashContent}`); + navigate( + { + pathname: '/receive-cashu-token', + hash: hashContent, + }, + { + transition: 'slideLeft', + applyTo: 'newView', + }, + ); + // Don't set isRedeeming to false here since we're navigating away + } else { + setIsRedeeming(false); + toast({ + title: 'Invalid Access Code', + description: 'Please try again', + duration: 2000, + variant: 'destructive', + }); + } + } catch (error) { + setIsRedeeming(false); + console.error('Failed to redeem token', { cause: error, tokenHash }); + toast({ + title: 'Error', + description: 'Failed to redeem gift card. Please try again.', + duration: 2000, + variant: 'destructive', + }); + } + }; + + return ( + + + + + + +
{/* spacer */} +
+ + +
+

Redeem Gift Card

+

+ Enter the code on the back of the card +

+
+
+
+
+ { + setAccessCode(e.target.value); + setValue('cardCode', e.target.value); + } + } + autoFocus={!isMobile} + disabled={isRedeeming} + /> +
+ {errors.cardCode && ( +

+ {errors.cardCode.message} +

+ )} +
+
+
+
+
+
+
+
{/* spacer */} +
{/* spacer */} + +
+
+ + + {isMobile && ( + + )} + + + ); +} diff --git a/app/routes/_public.receive-cashu-token.tsx b/app/routes/_public.receive-cashu-token.tsx index 4944e2c8b..5b18bc386 100644 --- a/app/routes/_public.receive-cashu-token.tsx +++ b/app/routes/_public.receive-cashu-token.tsx @@ -2,8 +2,9 @@ import { redirect } from 'react-router'; import { Page } from '~/components/page'; import { LoadingScreen } from '~/features/loading/LoadingScreen'; import { PublicReceiveCashuToken } from '~/features/receive/receive-cashu-token'; +import { sharableCashuTokenSchema } from '~/features/shared/cashu'; import { authQuery } from '~/features/user/auth'; -import { extractCashuToken } from '~/lib/cashu'; +import { parseHashParams } from '~/lib/utils'; import { getQueryClient } from '~/query-client'; import type { Route } from './+types/_public.receive-cashu-token'; @@ -11,6 +12,11 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { const location = new URL(request.url); // We have to use window.location.hash because location that comes from the request does not have the hash const hash = window.location.hash; + const hashParams = parseHashParams(hash, sharableCashuTokenSchema); + if (!hashParams) { + throw redirect('/signup'); + } + const queryClient = getQueryClient(); const { isLoggedIn } = await queryClient.ensureQueryData(authQuery()); @@ -18,13 +24,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { throw redirect(`/receive/cashu/token${location.search}${hash}`); } - const token = extractCashuToken(hash); - - if (!token) { - throw redirect('/signup'); - } - - return { token }; + return { token: hashParams.token, unlockingKey: hashParams.unlockingKey }; } clientLoader.hydrate = true as const; @@ -36,11 +36,11 @@ export function HydrateFallback() { export default function ReceiveCashuTokenPage({ loaderData, }: Route.ComponentProps) { - const { token } = loaderData; + const { token, unlockingKey } = loaderData; return ( - + ); } diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 0e56e899c..957e35d7a 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -474,6 +474,41 @@ export type Database = { }, ] } + locked_tokens: { + Row: { + access_code_hash: string | null + created_at: string + token: string + token_hash: string + updated_at: string + user_id: string + } + Insert: { + access_code_hash?: string | null + created_at?: string + token: string + token_hash: string + updated_at?: string + user_id: string + } + Update: { + access_code_hash?: string | null + created_at?: string + token?: string + token_hash?: string + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "locked_tokens_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } task_processing_locks: { Row: { expires_at: string @@ -798,6 +833,7 @@ export type Database = { p_state: string p_token_hash?: string p_total_amount: number + p_type: string p_unit: string p_unlocking_data?: string p_updated_keyset_counter?: number @@ -943,12 +979,24 @@ export type Database = { username: string }[] } + get_locked_token: { + Args: { p_access_code_hash?: string; p_token_hash: string } + Returns: { + access_code_hash: string | null + created_at: string + token: string + token_hash: string + updated_at: string + user_id: string + } + } list_transactions: { Args: { p_cursor_created_at?: string p_cursor_id?: string p_cursor_state_sort_order?: number p_page_size?: number + p_types?: string[] p_user_id: string } Returns: { diff --git a/supabase/migrations/20250912211654_add-locked-tokens.sql b/supabase/migrations/20250912211654_add-locked-tokens.sql new file mode 100644 index 000000000..cc0ca5478 --- /dev/null +++ b/supabase/migrations/20250912211654_add-locked-tokens.sql @@ -0,0 +1,80 @@ +-- Migration: Add locked_tokens table with access code security +-- Purpose: Create a table for storing tokens that are protected by access code authentication +-- Security: Uses secure functions for access verification with RLS policies as additional security layer + +-- Create the locked_tokens table +create table wallet.locked_tokens ( + token_hash text primary key, + token text not null, + user_id uuid not null, + access_code_hash text default null, + created_at timestamptz default now() not null, + updated_at timestamptz default now() not null +); + +comment on table wallet.locked_tokens is ' +Stores plaintext locked cashutokens that are optionally protected by access code authentication.'; + +-- Enable Row Level Security +alter table wallet.locked_tokens enable row level security; + +-- Add foreign key constraint for user_id +alter table wallet.locked_tokens add constraint "locked_tokens_user_id_fkey" FOREIGN KEY (user_id) REFERENCES wallet.users(id) not valid; +alter table wallet.locked_tokens validate constraint "locked_tokens_user_id_fkey"; + +-- Function to retrieve a token with access code authentication +create or replace function wallet.get_locked_token( + p_token_hash text, + p_access_code_hash text default null +) +returns wallet.locked_tokens +language plpgsql +security invoker +as $$ +declare + result_record wallet.locked_tokens; +begin + -- Set the session access code hash for RLS (transaction-scoped for security) + -- true makes the setting only persist for the current transaction + perform set_config('app.current_access_code_hash', coalesce(p_access_code_hash, ''), true); + + -- Return the token data if accessible (RLS policy handles access code verification) + select * + into result_record + from wallet.locked_tokens lt + where lt.token_hash = p_token_hash + limit 1; + + if not found then + return null; + end if; + + return result_record; +end; +$$; + +-- RLS Policies + +-- Select policy: Allow if access code hash matches session access code hash +-- OR if no access code is required (for both authenticated and anonymous users) +-- Note: Primary access should be through the secure functions above, but this provides additional security +create policy "Users can select locked tokens with correct access code" +on wallet.locked_tokens +for select +to authenticated, anon +using ( + access_code_hash is null OR access_code_hash = current_setting('app.current_access_code_hash', true) +); + +-- User-based CRUD policy: Allow full access to tokens owned by the authenticated user +create policy "Enable CRUD for locked tokens based on user_id" +on wallet.locked_tokens +as permissive +for all +to authenticated +using ((( SELECT auth.uid() AS uid) = user_id)) +with check ((( SELECT auth.uid() AS uid) = user_id)); + +-- Grant necessary permissions +grant select, insert, update, delete on wallet.locked_tokens to anon, authenticated, service_role; +grant execute on function wallet.get_locked_token(text, text) to anon, authenticated, service_role; diff --git a/supabase/migrations/20250917170802_send-swap-tx-types.sql b/supabase/migrations/20250917170802_send-swap-tx-types.sql new file mode 100644 index 000000000..c7e145249 --- /dev/null +++ b/supabase/migrations/20250917170802_send-swap-tx-types.sql @@ -0,0 +1,212 @@ +-- Drop the previous version of the function (without type parameter) +drop function if exists wallet.create_cashu_send_swap( + uuid, uuid, numeric, numeric, text, text, text, text, text, integer, numeric, numeric, + numeric, numeric, text, text, integer, integer, text, text, integer[], integer[], + text, text +); + +-- Recreate function with type parameter for CASHU_TOKEN vs GIFT transactions +create function wallet.create_cashu_send_swap( + p_user_id uuid, + p_account_id uuid, + p_amount_requested numeric, + p_amount_to_send numeric, + p_input_proofs text, + p_account_proofs text, + p_currency text, + p_unit text, + p_state text, + p_account_version integer, + p_input_amount numeric, + p_send_swap_fee numeric, + p_receive_swap_fee numeric, + p_total_amount numeric, + p_encrypted_transaction_details text, + p_type text, -- CASHU_TOKEN or GIFT + p_keyset_id text DEFAULT NULL::text, + p_keyset_counter integer DEFAULT NULL::integer, + p_updated_keyset_counter integer DEFAULT NULL::integer, + p_token_hash text DEFAULT NULL::text, + p_proofs_to_send text DEFAULT NULL::text, + p_send_output_amounts integer[] DEFAULT NULL::integer[], + p_keep_output_amounts integer[] DEFAULT NULL::integer[], + p_spending_condition_data text DEFAULT NULL::text, + p_unlocking_data text DEFAULT NULL::text +) RETURNS wallet.cashu_send_swaps +LANGUAGE plpgsql +AS $function$ +declare + v_transaction_id uuid; + v_swap wallet.cashu_send_swaps; +begin + -- Validate p_state is one of the allowed values + IF p_state NOT IN ('DRAFT', 'PENDING') THEN + RAISE EXCEPTION 'Invalid state: %. State must be either DRAFT or PENDING.', p_state; + END IF; + + -- Validate input parameters based on the state + IF p_state = 'PENDING' THEN + -- For PENDING state, proofs_to_send and token_hash must be defined + IF p_proofs_to_send IS NULL OR p_token_hash IS NULL THEN + RAISE EXCEPTION 'When state is PENDING, proofs_to_send and token_hash must be provided'; + END IF; + ELSIF p_state = 'DRAFT' THEN + -- For DRAFT state, keyset_id, keyset_counter, updated_keyset_counter, send_output_amounts, and keep_output_amounts must be defined + IF p_keyset_id IS NULL OR p_keyset_counter IS NULL OR p_updated_keyset_counter IS NULL OR p_send_output_amounts IS NULL OR p_keep_output_amounts IS NULL THEN + RAISE EXCEPTION 'When state is DRAFT, keyset_id, keyset_counter, updated_keyset_counter, send_output_amounts, and keep_output_amounts must be provided'; + END IF; + END IF; + + -- Create transaction record with the determined state + insert into wallet.transactions ( + user_id, + account_id, + direction, + type, + state, + currency, + pending_at, + encrypted_transaction_details + ) values ( + p_user_id, + p_account_id, + 'SEND', + p_type, + 'PENDING', + p_currency, + now(), + p_encrypted_transaction_details + ) returning id into v_transaction_id; + + -- Create send swap record + insert into wallet.cashu_send_swaps ( + user_id, + account_id, + transaction_id, + amount_requested, + amount_to_send, + send_swap_fee, + receive_swap_fee, + total_amount, + input_proofs, + input_amount, + proofs_to_send, + keyset_id, + keyset_counter, + send_output_amounts, + keep_output_amounts, + token_hash, + spending_condition_data, + unlocking_data, + currency, + unit, + state + ) values ( + p_user_id, + p_account_id, + v_transaction_id, + p_amount_requested, + p_amount_to_send, + p_send_swap_fee, + p_receive_swap_fee, + p_total_amount, + p_input_proofs, + p_input_amount, + p_proofs_to_send, + p_keyset_id, + p_keyset_counter, + p_send_output_amounts, + p_keep_output_amounts, + p_token_hash, + p_spending_condition_data, + p_unlocking_data, + p_currency, + p_unit, + p_state + ) returning * into v_swap; + + if p_updated_keyset_counter is not null then + update wallet.accounts + set details = jsonb_set( + jsonb_set(details, '{proofs}', to_jsonb(p_account_proofs)), + array['keyset_counters', p_keyset_id], + to_jsonb(p_updated_keyset_counter), + true + ), + version = version + 1 + where id = v_swap.account_id and version = p_account_version; + else + update wallet.accounts + set details = jsonb_set(details, '{proofs}', to_jsonb(p_account_proofs)), + version = version + 1 + where id = v_swap.account_id and version = p_account_version; + end if; + + if not found then + raise exception 'Concurrency error: Account % was modified by another transaction. Expected version %, but found different one', v_swap.account_id, p_account_version; + end if; + + return v_swap; +end; +$function$ +; + +drop function if exists wallet.list_transactions( + uuid, integer, timestamptz, uuid, integer +); + +-- Function to list user transactions with pagination and type filtering +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_types text[] default null +) +returns setof wallet.transactions +language plpgsql +stable +security definer +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_types is null or t.type = any(p_types)) + 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_types is null or t.type = any(p_types)) + 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; +$$; + +-- This index optimizes queries that filter by user_id, type, and state while maintaining efficient ordering +create index idx_user_type_filtered_state_ordered +on wallet.transactions ( + user_id, + type, + state_sort_order desc, + created_at desc, + id desc +) +where state in ('PENDING', 'COMPLETED', 'REVERSED');