Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions app/features/accounts/account-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ import type { AgicashDbAccountWithProofs } from '../agicash-db/database';
import { useUser } from '../user/user-hooks';
import {
type Account,
type AccountPurpose,
type AccountType,
type CashuAccount,
type ExtendedAccount,
type SparkAccount,
getAccountBalance,
} from './account';
import type { AccountPurpose } from '~/lib/cashu/protocol-extensions';
import {
type AccountRepository,
useAccountRepository,
Expand Down Expand Up @@ -165,15 +165,15 @@ export const accountsQueryOptions = ({
type UseAccountsSelect<
T extends AccountType = AccountType,
P extends AccountPurpose = AccountPurpose,
> = P extends 'gift-card'
> = P extends 'gift-card' | 'offer'
? {
/** Filter by currency (e.g., 'BTC', 'USD') */
currency?: Currency;
/** Must be 'cashu' when purpose is 'gift-card'. */
/** Must be 'cashu' when purpose is 'gift-card' or 'offer'. */
type?: 'cashu';
/** Filter by online status */
isOnline?: boolean;
/** Filter for gift-card accounts. Returns `CashuAccount[]` since gift cards are always cashu. */
/** Filter for gift-card or offer accounts. Returns `CashuAccount[]` since these are always cashu. */
purpose: P;
}
: {
Expand All @@ -190,6 +190,9 @@ type UseAccountsSelect<
export function useAccounts(
select: UseAccountsSelect<'cashu', 'gift-card'>,
): UseSuspenseQueryResult<ExtendedAccount<'cashu'>[]>;
export function useAccounts(
select: UseAccountsSelect<'cashu', 'offer'>,
): UseSuspenseQueryResult<ExtendedAccount<'cashu'>[]>;
export function useAccounts<
T extends AccountType = AccountType,
P extends AccountPurpose = AccountPurpose,
Expand Down Expand Up @@ -366,10 +369,15 @@ export function useAddCashuAccount() {
const accountCache = useAccountsCache();
const accountService = useAccountService();

type AddAccountParams = Parameters<typeof accountService.addCashuAccount>[0];
const { mutateAsync } = useMutation({
mutationFn: async (
account: Parameters<typeof accountService.addCashuAccount>[0]['account'],
) => accountService.addCashuAccount({ userId, account }),
mutationFn: async ({
account,
mintInfo,
}: {
account: AddAccountParams['account'];
mintInfo?: AddAccountParams['mintInfo'];
}) => accountService.addCashuAccount({ userId, account, mintInfo }),
onSuccess: (account) => {
// We add the account as soon as it is created so that it is available in the cache immediately.
// This is important when using other hooks that are trying to use the account immediately after it is created.
Expand Down
5 changes: 4 additions & 1 deletion app/features/accounts/account-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export class AccountRepository {
mint_url: accountInput.mintUrl,
is_test_mint: accountInput.isTestMint,
keyset_counters: accountInput.keysetCounters,
expires_at: accountInput.expiresAt,
} satisfies z.input<typeof CashuAccountDetailsDbDataSchema>);
} else {
details = SparkAccountDetailsDbDataSchema.parse({
Expand All @@ -134,7 +135,8 @@ export class AccountRepository {
currency: accountInput.currency,
details,
user_id: accountInput.userId,
purpose: accountInput.purpose,
// Cast needed until migration adds 'offer' to DB enum and types are regenerated
purpose: accountInput.purpose as 'transactional' | 'gift-card',
};

const query = this.db
Expand Down Expand Up @@ -192,6 +194,7 @@ export class AccountRepository {
mintUrl: details.mint_url,
isTestMint: details.is_test_mint,
keysetCounters: details.keyset_counters,
expiresAt: details.expires_at ?? null,
proofs,
wallet,
} as T;
Expand Down
6 changes: 4 additions & 2 deletions app/features/accounts/account-service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { DistributedOmit } from 'type-fest';
import { checkIsTestMint } from '~/lib/cashu';
import { type ExtendedMintInfo, checkIsTestMint } from '~/lib/cashu';
import type { User } from '../user/user';
import type { Account, CashuAccount, ExtendedAccount } from './account';
import {
Expand Down Expand Up @@ -42,6 +42,7 @@ export class AccountService {
async addCashuAccount({
userId,
account,
mintInfo,
}: {
userId: string;
account: DistributedOmit<
Expand All @@ -55,8 +56,9 @@ export class AccountService {
| 'wallet'
| 'isOnline'
>;
mintInfo?: ExtendedMintInfo;
}) {
const isTestMint = await checkIsTestMint(account.mintUrl);
const isTestMint = await checkIsTestMint(account.mintUrl, mintInfo);

return this.accountRepository.create<CashuAccount>({
...account,
Expand Down
22 changes: 12 additions & 10 deletions app/features/accounts/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,12 @@ import type {
} from '@buildonspark/spark-sdk';
import type { DistributedOmit } from 'type-fest';
import { type ExtendedCashuWallet, getCashuUnit, sumProofs } from '~/lib/cashu';
import type { AccountPurpose } from '~/lib/cashu/protocol-extensions';
import { type Currency, Money } from '~/lib/money';
import type { CashuProof } from './cashu-account';

export type AccountType = 'cashu' | 'spark';

/**
* The purpose of this account.
* - 'transactional': Regular accounts for sending/receiving payments
* - 'gift-card': Closed-loop accounts for mints that are issuing gift cards
*/
export type AccountPurpose = 'transactional' | 'gift-card';

export type Account = {
id: string;
name: string;
Expand All @@ -38,6 +32,11 @@ export type Account = {
* Holds counter value for each mint keyset. Key is the keyset id, value is counter value.
*/
keysetCounters: Record<string, number>;
/**
* Unix timestamp when the account's ecash expires (for offer accounts).
* Derived from the active keyset's `final_expiry` field.
*/
expiresAt: number | null;
/**
* Holds all cashu proofs for the account.
* Amounts are denominated in the cashu units (e.g. sats for BTC accounts, cents for USD accounts).
Expand Down Expand Up @@ -77,7 +76,7 @@ export type RedactedCashuAccount = Extract<RedactedAccount, { type: 'cashu' }>;

/**
* Returns true if the account can send payments through the Lightning network.
* Returns false for test mints and gift-card accounts.
* Returns false for test mints, gift-card accounts, and offer accounts.
*/
export const canSendToLightning = (account: Account): boolean => {
if (account.type === 'spark') {
Expand All @@ -88,10 +87,13 @@ export const canSendToLightning = (account: Account): boolean => {

/**
* Returns true if the account can receive payments via the Lightning network.
* Returns false for test mints only.
* Returns false for offline wallets, test mints, and mints with minting disabled (NUT-04).
*/
export const canReceiveFromLightning = (account: Account): boolean => {
return account.type === 'spark' || !account.isTestMint;
if (account.type === 'spark') return true;
if (!account.isOnline) return false;
if (account.isTestMint) return false;
return !account.wallet.getMintInfo().isSupported(4).disabled;
};

export const getAccountBalance = (account: Account) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export const CashuAccountDetailsDbDataSchema = z.object({
* Holds counter value for each mint keyset. Key is the keyset id, value is counter value.
*/
keyset_counters: z.record(z.string(), z.number()),
/**
* Unix timestamp when the account's ecash expires (for offer accounts).
* Derived from the active keyset's `final_expiry` field.
* Null for non-offer accounts or when the keyset has no expiry.
*/
expires_at: z.number().nullable().optional(),
});

export type CashuAccountDetailsDbData = z.infer<
Expand Down
13 changes: 8 additions & 5 deletions app/features/gift-cards/add-gift-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ function useAddGiftCard() {

return ({ name, currency, url }: AddGiftCardParams) =>
addCashuAccount({
name,
currency,
mintUrl: url,
type: 'cashu',
purpose: 'gift-card',
account: {
name,
currency,
mintUrl: url,
type: 'cashu',
purpose: 'gift-card',
expiresAt: null,
},
});
}

Expand Down
26 changes: 25 additions & 1 deletion app/features/gift-cards/gift-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@ import {
import { DiscoverGiftCards } from './discover-gift-cards';
import { EmptyState } from './empty-state';
import { GiftCardItem } from './gift-card-item';
import { OfferItem } from './offer-item';
import {
getGiftCardImageByUrl,
useDiscoverGiftCards,
} from './use-discover-cards';

function useActiveOffers() {
const { data: offerAccounts } = useAccounts({ purpose: 'offer' });
const now = Date.now() / 1000;
return offerAccounts.filter(
(account) => !account.expiresAt || account.expiresAt > now,
);
}

/**
* Gift cards view with discover section and card stack.
* Gift cards view with discover section, card stack, and offers.
* Clicking a card navigates to the card details page with view transitions.
*/
export function GiftCards() {
Expand All @@ -37,6 +46,7 @@ export function GiftCards() {
const stackedHeight =
CARD_HEIGHT + (accounts.length - 1) * VERTICAL_CARD_OFFSET_IN_STACK;
const giftCardsToDiscover = useDiscoverGiftCards();
const activeOffers = useActiveOffers();

const handleCardClick = (account: CashuAccount) => {
navigate(`/gift-cards/${account.id}`, { viewTransition: true });
Expand Down Expand Up @@ -91,6 +101,20 @@ export function GiftCards() {
) : (
<EmptyState />
)}

{activeOffers.length > 0 && (
<div className="flex w-full shrink-0 flex-col items-center px-4 pb-8">
<h2 className="mb-3 w-full text-white">Offers</h2>
<div
className="flex w-full flex-col gap-3"
style={{ maxWidth: CARD_WIDTH }}
>
{activeOffers.map((account) => (
<OfferItem key={account.id} account={account} />
))}
</div>
</div>
)}
</div>
</PageContent>
</Page>
Expand Down
56 changes: 56 additions & 0 deletions app/features/gift-cards/offer-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { MoneyDisplay } from '~/components/money-display';
import {
WalletCard,
WalletCardBlank,
WalletCardOverlay,
} from '~/components/wallet-card';
import {
type CashuAccount,
getAccountBalance,
} from '~/features/accounts/account';
import { getDefaultUnit } from '../shared/currencies';

type OfferItemProps = {
account: CashuAccount;
};

function formatExpiryDate(expiresAt: number): string {
const date = new Date(expiresAt * 1000);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}

export function OfferItem({ account }: OfferItemProps) {
const balance = getAccountBalance(account);

return (
<WalletCard className="w-full max-w-none">
<WalletCardBlank />
<WalletCardOverlay>
<div className="flex h-full flex-col justify-between p-5">
<div className="flex items-center justify-between">
<span className="text-lg text-white drop-shadow-md">
{account.name}
</span>
{balance && (
<MoneyDisplay
money={balance}
size="sm"
unit={getDefaultUnit(account.currency)}
className="text-white drop-shadow-md"
/>
)}
</div>
{account.expiresAt && (
<p className="text-sm text-white/60">
Expires {formatExpiryDate(account.expiresAt)}
</p>
)}
</div>
</WalletCardOverlay>
</WalletCard>
);
}
2 changes: 1 addition & 1 deletion app/features/receive/receive-cashu-token-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ export function useReceiveCashuTokenAccounts(
): Promise<Account> => {
let newAccount: Account;
if (accountToAdd.type === 'cashu') {
newAccount = await addCashuAccount(accountToAdd);
newAccount = await addCashuAccount({ account: accountToAdd });
} else {
// Only cashu accounts can be unknown, this should never happen
throw new Error('Invalid account type');
Expand Down
15 changes: 13 additions & 2 deletions app/features/receive/receive-cashu-token-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,27 @@ export class ReceiveCashuTokenService {
undefined,
);

const purpose = wallet.purpose;
let expiresAt: number | null = null;
if (purpose === 'offer' && isOnline) {
const unit = getCashuProtocolUnit(currency);
const activeKeyset = wallet.keyChain
.getKeysets()
.find((ks) => ks.unit === unit && ks.isActive);
expiresAt = activeKeyset?.expiry ?? null;
}

const baseAccount = {
id: 'cashu-account-placeholder-id',
type: 'cashu' as const,
purpose: wallet.purpose,
purpose,
name: mintUrl.replace('https://', '').replace('http://', ''),
mintUrl,
createdAt: new Date().toISOString(),
currency,
version: 0,
keysetCounters: {},
expiresAt,
proofs: [],
isDefault: false,
isSource: true,
Expand All @@ -77,7 +88,7 @@ export class ReceiveCashuTokenService {
);

const isTestMint = await this.queryClient.fetchQuery(
isTestMintQueryOptions(mintUrl),
isTestMintQueryOptions(mintUrl, mintInfo),
);

const isValid = validationResult === true;
Expand Down
10 changes: 4 additions & 6 deletions app/features/receive/receive-input.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getEncodedToken } from '@cashu/cashu-ts';
import { Clipboard, Scan } from 'lucide-react';
import { MoneyInputDisplay } from '~/components/money-display';
import { Numpad } from '~/components/numpad';
Expand All @@ -22,7 +21,7 @@ import { useMoneyInput } from '~/hooks/use-money-input';
import { useRedirectTo } from '~/hooks/use-redirect-to';
import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link';
import { useToast } from '~/hooks/use-toast';
import { extractCashuToken } from '~/lib/cashu';
import { extractCashuTokenString } from '~/lib/cashu';
import { readClipboard } from '~/lib/read-clipboard';
import {
LinkWithViewTransition,
Expand Down Expand Up @@ -91,8 +90,8 @@ export default function ReceiveInput() {
return;
}

const token = extractCashuToken(clipboardContent);
if (!token) {
const tokenString = extractCashuTokenString(clipboardContent);
if (!tokenString) {
toast({
title: 'Invalid input',
description: 'Please paste a valid cashu token',
Expand All @@ -101,8 +100,7 @@ export default function ReceiveInput() {
return;
}

const encodedToken = getEncodedToken(token);
const hash = `#${encodedToken}`;
const hash = `#${tokenString}`;

// 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
Expand Down
Loading
Loading