diff --git a/app/features/gift-cards/gift-card-details.tsx b/app/features/gift-cards/gift-card-details.tsx
index 5c019ed8..8959a1e1 100644
--- a/app/features/gift-cards/gift-card-details.tsx
+++ b/app/features/gift-cards/gift-card-details.tsx
@@ -152,8 +152,7 @@ export default function GiftCardDetails({ cardId }: GiftCardDetailsProps) {
{
+ return (
+
+ );
+};
+
+type TransferConfirmationProps = {
+ transferQuote: TransferQuote;
+};
+
+export default function TransferConfirmation({
+ transferQuote,
+}: TransferConfirmationProps) {
+ const { toast } = useToast();
+ const navigate = useNavigateWithViewTransition();
+
+ const destinationAccountId = useTransferStore((s) => s.destinationAccountId);
+ const sourceAccountId = useTransferStore((s) => s.sourceAccountId);
+
+ const destinationAccount = useAccount(destinationAccountId);
+ const sourceAccount = useAccount(sourceAccountId);
+
+ const { mutate: initiateTransfer, status: transferStatus } =
+ useInitiateTransfer({
+ onSuccess: ({ receiveTransactionId }) => {
+ const params = new URLSearchParams({
+ showOkButton: 'true',
+ redirectTo: `/gift-cards/${destinationAccount.id}`,
+ });
+ navigate(
+ {
+ pathname: `/transactions/${receiveTransactionId}`,
+ search: params.toString(),
+ },
+ {
+ transition: 'slideLeft',
+ applyTo: 'newView',
+ },
+ );
+ },
+ onError: (error) => {
+ if (error instanceof DomainError) {
+ toast({ description: error.message });
+ } else {
+ console.error('Failed to initiate transfer', { cause: error });
+ toast({
+ description: 'Transfer failed. Please try again.',
+ variant: 'destructive',
+ });
+ }
+ },
+ });
+
+ const handleConfirm = () => {
+ initiateTransfer({
+ sourceAccount,
+ destinationAccount,
+ transferQuote,
+ });
+ };
+
+ const isPending = ['pending', 'success'].includes(transferStatus);
+
+ return (
+ <>
+
+
+ Confirm
+
+
+
+
+
+
+
+ }
+ />
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/features/transfer/transfer-hooks.ts b/app/features/transfer/transfer-hooks.ts
new file mode 100644
index 00000000..e54976d5
--- /dev/null
+++ b/app/features/transfer/transfer-hooks.ts
@@ -0,0 +1,78 @@
+import { useMutation } from '@tanstack/react-query';
+import type { Money } from '~/lib/money';
+import type { Account } from '../accounts/account';
+import { ConcurrencyError, DomainError } from '../shared/error';
+import { useUser } from '../user/user-hooks';
+import type { TransferQuote } from './transfer-service';
+import { useTransferService } from './transfer-service';
+
+export function useCreateTransferQuote() {
+ const transferService = useTransferService();
+
+ return useMutation({
+ scope: { id: 'create-transfer-quote' },
+ mutationFn: ({
+ sourceAccount,
+ destinationAccount,
+ amount,
+ }: {
+ sourceAccount: Account;
+ destinationAccount: Account;
+ amount: Money;
+ }) =>
+ transferService.getTransferQuote({
+ sourceAccount,
+ destinationAccount,
+ amount,
+ }),
+ retry: (failureCount, error) => {
+ if (error instanceof DomainError) return false;
+ return failureCount < 1;
+ },
+ });
+}
+
+export function useInitiateTransfer({
+ onSuccess,
+ onError,
+}: {
+ onSuccess: (data: {
+ sendTransactionId: string;
+ receiveTransactionId: string;
+ }) => void;
+ onError: (error: Error) => void;
+}) {
+ const userId = useUser((user) => user.id);
+ const transferService = useTransferService();
+
+ return useMutation({
+ scope: { id: 'initiate-transfer' },
+ mutationFn: ({
+ sourceAccount,
+ destinationAccount,
+ transferQuote,
+ }: {
+ sourceAccount: Account;
+ destinationAccount: Account;
+ transferQuote: TransferQuote;
+ }) =>
+ transferService.initiateTransfer({
+ userId,
+ sourceAccount,
+ destinationAccount,
+ transferQuote,
+ }),
+ onSuccess: (data) => {
+ onSuccess({
+ sendTransactionId: data.sendTransactionId,
+ receiveTransactionId: data.receiveTransactionId,
+ });
+ },
+ onError,
+ retry: (failureCount, error) => {
+ if (error instanceof ConcurrencyError) return true;
+ if (error instanceof DomainError) return false;
+ return failureCount < 1;
+ },
+ });
+}
diff --git a/app/features/transfer/transfer-input.tsx b/app/features/transfer/transfer-input.tsx
new file mode 100644
index 00000000..2016e494
--- /dev/null
+++ b/app/features/transfer/transfer-input.tsx
@@ -0,0 +1,194 @@
+import { getEncodedToken } from '@cashu/cashu-ts';
+import { Clipboard, Scan } from 'lucide-react';
+import { MoneyInputDisplay } from '~/components/money-display';
+import { Numpad } from '~/components/numpad';
+import {
+ ClosePageButton,
+ PageContent,
+ PageFooter,
+ PageHeader,
+ PageHeaderTitle,
+} from '~/components/page';
+import { Button } from '~/components/ui/button';
+import { ConvertedMoneySwitcher } from '~/features/shared/converted-money-switcher';
+import { getDefaultUnit } from '~/features/shared/currencies';
+import { DomainError } from '~/features/shared/error';
+import useAnimation from '~/hooks/use-animation';
+import { useMoneyInput } from '~/hooks/use-money-input';
+import { useToast } from '~/hooks/use-toast';
+import { extractCashuToken } from '~/lib/cashu';
+import type { Money } from '~/lib/money';
+import { readClipboard } from '~/lib/read-clipboard';
+import {
+ LinkWithViewTransition,
+ useNavigateWithViewTransition,
+} from '~/lib/transitions';
+import { useAccount } from '../accounts/account-hooks';
+import { useTransferStore } from './transfer-provider';
+
+export default function TransferInput() {
+ const navigate = useNavigateWithViewTransition();
+ const { toast } = useToast();
+ const { animationClass: shakeAnimationClass, start: startShakeAnimation } =
+ useAnimation({ name: 'shake' });
+
+ const destinationAccountId = useTransferStore((s) => s.destinationAccountId);
+ const getTransferQuote = useTransferStore((s) => s.getTransferQuote);
+ const status = useTransferStore((s) => s.status);
+
+ const destinationAccount = useAccount(destinationAccountId);
+
+ const {
+ rawInputValue,
+ maxInputDecimals,
+ inputValue,
+ convertedValue,
+ exchangeRateError,
+ handleNumberInput,
+ switchInputCurrency,
+ } = useMoneyInput({
+ initialRawInputValue: '0',
+ initialInputCurrency: destinationAccount.currency,
+ initialOtherCurrency: destinationAccount.currency === 'BTC' ? 'USD' : 'BTC',
+ });
+
+ const handleContinue = async () => {
+ let amount: Money;
+ if (inputValue.currency === destinationAccount.currency) {
+ amount = inputValue;
+ } else {
+ if (!convertedValue) {
+ return;
+ }
+ amount = convertedValue;
+ }
+
+ if (amount.isZero()) return;
+
+ const result = await getTransferQuote(amount);
+
+ if (!result.success) {
+ if (result.error instanceof DomainError) {
+ toast({ description: result.error.message });
+ } else {
+ console.error('Failed to get transfer quote', { cause: result.error });
+ toast({
+ description: 'Failed to get quote. Please try again.',
+ variant: 'destructive',
+ });
+ }
+ return;
+ }
+
+ navigate('confirm', {
+ transition: 'slideLeft',
+ applyTo: 'newView',
+ });
+ };
+
+ const handlePaste = async () => {
+ const clipboardContent = await readClipboard();
+ if (!clipboardContent) {
+ return;
+ }
+
+ const token = extractCashuToken(clipboardContent);
+ if (!token) {
+ toast({
+ title: 'Invalid input',
+ description: 'Please paste a valid cashu token',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ const encodedToken = getEncodedToken(token);
+ const hash = `#${encodedToken}`;
+
+ window.history.replaceState(null, '', hash);
+ navigate(
+ {
+ pathname: '/receive/cashu/token',
+ search: new URLSearchParams({
+ selectedAccountId: destinationAccount.id,
+ redirectTo: `/gift-cards/${destinationAccount.id}`,
+ }).toString(),
+ hash,
+ },
+ { transition: 'slideLeft', applyTo: 'newView' },
+ );
+ };
+
+ return (
+ <>
+
+
+ Add
+
+
+
+
+
+
+
+
+ {!exchangeRateError && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0}
+ onButtonClick={(value) => {
+ handleNumberInput(value, startShakeAnimation);
+ }}
+ />
+
+ >
+ );
+}
diff --git a/app/features/transfer/transfer-provider.tsx b/app/features/transfer/transfer-provider.tsx
new file mode 100644
index 00000000..a41d8f60
--- /dev/null
+++ b/app/features/transfer/transfer-provider.tsx
@@ -0,0 +1,53 @@
+import {
+ type PropsWithChildren,
+ createContext,
+ useContext,
+ useState,
+} from 'react';
+import { useStore } from 'zustand';
+import type { Account } from '../accounts/account';
+import { useGetAccount } from '../accounts/account-hooks';
+import { useCreateTransferQuote } from './transfer-hooks';
+import type { TransferState, TransferStore } from './transfer-store';
+import { createTransferStore } from './transfer-store';
+
+const TransferContext = createContext(null);
+
+type Props = PropsWithChildren<{
+ sourceAccount: Account;
+ destinationAccount: Account;
+}>;
+
+export const TransferProvider = ({
+ children,
+ sourceAccount,
+ destinationAccount,
+}: Props) => {
+ const getAccount = useGetAccount();
+ const { mutateAsync: createTransferQuote } = useCreateTransferQuote();
+
+ const [store] = useState(() =>
+ createTransferStore({
+ sourceAccount,
+ destinationAccount,
+ getAccount,
+ createTransferQuote,
+ }),
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useTransferStore = (
+ selector: (state: TransferState) => T,
+): T => {
+ const store = useContext(TransferContext);
+ if (!store) {
+ throw new Error('Missing TransferProvider in the tree');
+ }
+ return useStore(store, selector);
+};
diff --git a/app/features/transfer/transfer-service.ts b/app/features/transfer/transfer-service.ts
new file mode 100644
index 00000000..e0f149c8
--- /dev/null
+++ b/app/features/transfer/transfer-service.ts
@@ -0,0 +1,317 @@
+import { Money } from '~/lib/money';
+import type { Account, CashuAccount } from '../accounts/account';
+import {
+ canReceiveFromLightning,
+ canSendToLightning,
+} from '../accounts/account';
+import type { AgicashDb } from '../agicash-db/database';
+import { agicashDbClient } from '../agicash-db/database.client';
+import type { CashuReceiveQuote } from '../receive/cashu-receive-quote';
+import type { CashuReceiveLightningQuote } from '../receive/cashu-receive-quote-core';
+import {
+ type CashuReceiveQuoteService,
+ useCashuReceiveQuoteService,
+} from '../receive/cashu-receive-quote-service';
+import type { SparkReceiveQuote } from '../receive/spark-receive-quote';
+import {
+ type SparkReceiveLightningQuote,
+ getLightningQuote as getSparkLightningQuote,
+} from '../receive/spark-receive-quote-core';
+import {
+ type SparkReceiveQuoteService,
+ useSparkReceiveQuoteService,
+} from '../receive/spark-receive-quote-service';
+import type { CashuSendQuote } from '../send/cashu-send-quote';
+import {
+ type CashuLightningQuote,
+ type CashuSendQuoteService,
+ useCashuSendQuoteService,
+} from '../send/cashu-send-quote-service';
+import type { SparkSendQuote } from '../send/spark-send-quote';
+import {
+ type SparkLightningQuote,
+ type SparkSendQuoteService,
+ useSparkSendQuoteService,
+} from '../send/spark-send-quote-service';
+import { DomainError } from '../shared/error';
+
+export type ReceiveLightningQuote =
+ | { type: 'cashu'; quote: CashuReceiveLightningQuote }
+ | { type: 'spark'; quote: SparkReceiveLightningQuote };
+
+export type TransferQuote = {
+ receiveLightningQuote: ReceiveLightningQuote;
+ sendQuote: SparkLightningQuote | CashuLightningQuote;
+ sourceAccountId: string;
+ destinationAccountId: string;
+ amount: Money;
+ estimatedFee: Money;
+ estimatedTotal: Money;
+};
+
+type GetTransferQuoteParams = {
+ sourceAccount: Account;
+ destinationAccount: Account;
+ amount: Money;
+};
+
+type InitiateTransferParams = {
+ userId: string;
+ sourceAccount: Account;
+ destinationAccount: Account;
+ transferQuote: TransferQuote;
+};
+
+type InitiateTransferResult = {
+ sendTransactionId: string;
+ receiveTransactionId: string;
+ sendQuote: SparkSendQuote | CashuSendQuote;
+ receiveQuote: CashuReceiveQuote | SparkReceiveQuote;
+};
+
+export class TransferService {
+ constructor(
+ private readonly db: AgicashDb,
+ private readonly cashuReceiveQuoteService: CashuReceiveQuoteService,
+ private readonly sparkReceiveQuoteService: SparkReceiveQuoteService,
+ private readonly sparkSendQuoteService: SparkSendQuoteService,
+ private readonly cashuSendQuoteService: CashuSendQuoteService,
+ ) {}
+
+ async getTransferQuote({
+ sourceAccount,
+ destinationAccount,
+ amount,
+ }: GetTransferQuoteParams): Promise {
+ if (!canSendToLightning(sourceAccount)) {
+ throw new DomainError('Source account cannot send to Lightning');
+ }
+
+ if (!canReceiveFromLightning(destinationAccount)) {
+ throw new DomainError(
+ 'Destination account cannot receive from Lightning',
+ );
+ }
+
+ if (sourceAccount.currency !== destinationAccount.currency) {
+ throw new DomainError(
+ 'Source and destination accounts must have the same currency',
+ );
+ }
+
+ const receiveLightningQuote = await this.getReceiveQuote(
+ destinationAccount,
+ amount,
+ );
+
+ const paymentRequest = getPaymentRequest(receiveLightningQuote);
+ const receiveFee = getReceiveFee(receiveLightningQuote, amount.currency);
+
+ const sendQuote = await this.getSendQuote(sourceAccount, paymentRequest);
+
+ const estimatedFee = receiveFee.add(sendQuote.estimatedTotalFee);
+ const estimatedTotal = amount.add(estimatedFee);
+
+ return {
+ receiveLightningQuote,
+ sendQuote,
+ sourceAccountId: sourceAccount.id,
+ destinationAccountId: destinationAccount.id,
+ amount,
+ estimatedFee,
+ estimatedTotal,
+ };
+ }
+
+ async initiateTransfer({
+ userId,
+ sourceAccount,
+ destinationAccount,
+ transferQuote,
+ }: InitiateTransferParams): Promise {
+ const { receiveLightningQuote, sendQuote } = transferQuote;
+
+ const receiveExpiresAt = getReceiveQuoteExpiry(receiveLightningQuote);
+ if (new Date(receiveExpiresAt) < new Date()) {
+ throw new DomainError('Transfer quote has expired. Please try again.');
+ }
+
+ if (sendQuote.expiresAt && sendQuote.expiresAt < new Date()) {
+ throw new DomainError('Transfer quote has expired. Please try again.');
+ }
+
+ const receiveQuote = await this.createReceiveQuote(
+ userId,
+ destinationAccount,
+ receiveLightningQuote,
+ );
+
+ let createdSendQuote: SparkSendQuote | CashuSendQuote;
+ try {
+ createdSendQuote = await this.createSendQuote(
+ userId,
+ sourceAccount,
+ sendQuote,
+ );
+ } catch (error) {
+ await this.failReceiveTransaction(receiveQuote.transactionId);
+ throw error;
+ }
+
+ return {
+ sendTransactionId: createdSendQuote.transactionId,
+ receiveTransactionId: receiveQuote.transactionId,
+ sendQuote: createdSendQuote,
+ receiveQuote,
+ };
+ }
+
+ private async getReceiveQuote(
+ destinationAccount: Account,
+ amount: Money,
+ ): Promise {
+ if (destinationAccount.type === 'spark') {
+ const quote = await getSparkLightningQuote({
+ wallet: destinationAccount.wallet,
+ amount,
+ });
+ return { type: 'spark', quote };
+ }
+
+ const quote = await this.cashuReceiveQuoteService.getLightningQuote({
+ wallet: destinationAccount.wallet,
+ amount,
+ });
+ return { type: 'cashu', quote };
+ }
+
+ private async createReceiveQuote(
+ userId: string,
+ destinationAccount: Account,
+ receiveLightningQuote: ReceiveLightningQuote,
+ ): Promise {
+ if (
+ destinationAccount.type === 'spark' &&
+ receiveLightningQuote.type === 'spark'
+ ) {
+ return this.sparkReceiveQuoteService.createReceiveQuote({
+ userId,
+ account: destinationAccount,
+ lightningQuote: receiveLightningQuote.quote,
+ receiveType: 'TRANSFER',
+ });
+ }
+
+ if (
+ destinationAccount.type === 'cashu' &&
+ receiveLightningQuote.type === 'cashu'
+ ) {
+ return this.cashuReceiveQuoteService.createReceiveQuote({
+ userId,
+ account: destinationAccount,
+ lightningQuote: receiveLightningQuote.quote,
+ receiveType: 'TRANSFER',
+ });
+ }
+
+ throw new Error(
+ `Mismatched destination account type (${destinationAccount.type}) and receive quote type (${receiveLightningQuote.type})`,
+ );
+ }
+
+ private async getSendQuote(
+ sourceAccount: Account,
+ paymentRequest: string,
+ ): Promise {
+ if (sourceAccount.type === 'spark') {
+ return this.sparkSendQuoteService.getLightningSendQuote({
+ account: sourceAccount,
+ paymentRequest,
+ });
+ }
+
+ return this.cashuSendQuoteService.getLightningQuote({
+ account: sourceAccount,
+ paymentRequest,
+ });
+ }
+
+ private async createSendQuote(
+ userId: string,
+ sourceAccount: Account,
+ sendQuote: SparkLightningQuote | CashuLightningQuote,
+ ): Promise {
+ if (sourceAccount.type === 'spark') {
+ return this.sparkSendQuoteService.createSendQuote({
+ userId,
+ account: sourceAccount,
+ quote: sendQuote as SparkLightningQuote,
+ });
+ }
+
+ const cashuQuote = sendQuote as CashuLightningQuote;
+ return this.cashuSendQuoteService.createSendQuote({
+ userId,
+ account: sourceAccount as CashuAccount,
+ sendQuote: {
+ paymentRequest: cashuQuote.paymentRequest,
+ amountRequested: cashuQuote.amountRequested,
+ amountRequestedInBtc: cashuQuote.amountRequestedInBtc,
+ meltQuote: cashuQuote.meltQuote,
+ },
+ });
+ }
+
+ private async failReceiveTransaction(transactionId: string): Promise {
+ const { error } = await this.db
+ .from('transactions')
+ .update({ state: 'FAILED', failed_at: new Date().toISOString() })
+ .eq('id', transactionId)
+ .eq('state', 'PENDING');
+
+ if (error) {
+ console.error(
+ `Failed to mark receive transaction ${transactionId} as FAILED`,
+ { cause: error },
+ );
+ }
+ }
+}
+
+function getPaymentRequest(quote: ReceiveLightningQuote): string {
+ if (quote.type === 'spark') {
+ return quote.quote.invoice.encodedInvoice;
+ }
+ return quote.quote.mintQuote.request;
+}
+
+function getReceiveFee(
+ quote: ReceiveLightningQuote,
+ currency: Money['currency'],
+): Money {
+ if (quote.type === 'spark') {
+ return Money.zero(currency);
+ }
+ return quote.quote.mintingFee ?? Money.zero(currency);
+}
+
+function getReceiveQuoteExpiry(quote: ReceiveLightningQuote): string {
+ if (quote.type === 'spark') {
+ return quote.quote.invoice.expiresAt;
+ }
+ return quote.quote.expiresAt;
+}
+
+export function useTransferService() {
+ const cashuReceiveQuoteService = useCashuReceiveQuoteService();
+ const sparkReceiveQuoteService = useSparkReceiveQuoteService();
+ const sparkSendQuoteService = useSparkSendQuoteService();
+ const cashuSendQuoteService = useCashuSendQuoteService();
+ return new TransferService(
+ agicashDbClient,
+ cashuReceiveQuoteService,
+ sparkReceiveQuoteService,
+ sparkSendQuoteService,
+ cashuSendQuoteService,
+ );
+}
diff --git a/app/features/transfer/transfer-store.ts b/app/features/transfer/transfer-store.ts
new file mode 100644
index 00000000..317e0de2
--- /dev/null
+++ b/app/features/transfer/transfer-store.ts
@@ -0,0 +1,69 @@
+import { create } from 'zustand';
+import type { Money } from '~/lib/money';
+import type { Account } from '../accounts/account';
+import type { TransferQuote } from './transfer-service';
+
+type GetTransferQuoteResult =
+ | { success: true; quote: TransferQuote }
+ | { success: false; error: unknown };
+
+export type TransferState = {
+ status: 'idle' | 'quoting' | 'success';
+ /** ID of the account to send from. */
+ sourceAccountId: string;
+ /** ID of the account to receive into. */
+ destinationAccountId: string;
+ /** The amount to transfer. */
+ amount: Money | null;
+ /** Quote for the transfer (bundled receive + send quotes). */
+ transferQuote: TransferQuote | null;
+ /** Create a transfer quote for the given amount. */
+ getTransferQuote: (amount: Money) => Promise;
+};
+
+type CreateTransferStoreProps = {
+ sourceAccount: Account;
+ destinationAccount: Account;
+ getAccount: (id: string) => Account;
+ createTransferQuote: (params: {
+ sourceAccount: Account;
+ destinationAccount: Account;
+ amount: Money;
+ }) => Promise;
+};
+
+export const createTransferStore = ({
+ sourceAccount,
+ destinationAccount,
+ getAccount,
+ createTransferQuote,
+}: CreateTransferStoreProps) => {
+ return create((set, get) => ({
+ status: 'idle',
+ sourceAccountId: sourceAccount.id,
+ destinationAccountId: destinationAccount.id,
+ amount: null,
+ transferQuote: null,
+ getTransferQuote: async (amount) => {
+ const source = getAccount(get().sourceAccountId);
+ const dest = getAccount(get().destinationAccountId);
+ set({ status: 'quoting', amount });
+
+ try {
+ const quote = await createTransferQuote({
+ sourceAccount: source,
+ destinationAccount: dest,
+ amount,
+ });
+
+ set({ status: 'success', transferQuote: quote });
+ return { success: true, quote };
+ } catch (error) {
+ set({ status: 'idle' });
+ return { success: false, error };
+ }
+ },
+ }));
+};
+
+export type TransferStore = ReturnType;
diff --git a/app/routes/_protected.gift-cards_.$cardId.transfer._index.tsx b/app/routes/_protected.gift-cards_.$cardId.transfer._index.tsx
new file mode 100644
index 00000000..afeb9f13
--- /dev/null
+++ b/app/routes/_protected.gift-cards_.$cardId.transfer._index.tsx
@@ -0,0 +1,5 @@
+import TransferInput from '~/features/transfer/transfer-input';
+
+export default function GiftCardTransferInputRoute() {
+ return ;
+}
diff --git a/app/routes/_protected.gift-cards_.$cardId.transfer.confirm.tsx b/app/routes/_protected.gift-cards_.$cardId.transfer.confirm.tsx
new file mode 100644
index 00000000..22ef819a
--- /dev/null
+++ b/app/routes/_protected.gift-cards_.$cardId.transfer.confirm.tsx
@@ -0,0 +1,15 @@
+import { Redirect } from '~/components/redirect';
+import TransferConfirmation from '~/features/transfer/transfer-confirmation';
+import { useTransferStore } from '~/features/transfer/transfer-provider';
+
+export default function GiftCardTransferConfirmRoute() {
+ const transferQuote = useTransferStore((s) => s.transferQuote);
+
+ if (!transferQuote) {
+ return (
+
+ );
+ }
+
+ return ;
+}
diff --git a/app/routes/_protected.gift-cards_.$cardId.transfer.tsx b/app/routes/_protected.gift-cards_.$cardId.transfer.tsx
new file mode 100644
index 00000000..d63297ea
--- /dev/null
+++ b/app/routes/_protected.gift-cards_.$cardId.transfer.tsx
@@ -0,0 +1,41 @@
+import { Outlet } from 'react-router';
+import { Page } from '~/components/page';
+import { canSendToLightning } from '~/features/accounts/account';
+import {
+ useDefaultAccount,
+ useGetAccount,
+} from '~/features/accounts/account-hooks';
+import { DomainError } from '~/features/shared/error';
+import { TransferProvider } from '~/features/transfer/transfer-provider';
+import type { Route } from './+types/_protected.gift-cards_.$cardId.transfer';
+
+export default function GiftCardTransferLayout({
+ params,
+}: Route.ComponentProps) {
+ const getAccount = useGetAccount('cashu');
+ const destinationAccount = getAccount(params.cardId);
+ const sourceAccount = useDefaultAccount();
+
+ if (!canSendToLightning(sourceAccount)) {
+ throw new DomainError(
+ 'Your default account cannot send Lightning payments. Please change your default account.',
+ );
+ }
+
+ if (sourceAccount.currency !== destinationAccount.currency) {
+ throw new DomainError(
+ 'Your default account currency does not match the gift card currency.',
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+}