Skip to content
Open
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
9 changes: 6 additions & 3 deletions app/features/accounts/account-hooks.ts
Original file line number Diff line number Diff line change
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
2 changes: 2 additions & 0 deletions 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 Down Expand Up @@ -192,6 +193,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
34 changes: 30 additions & 4 deletions app/features/accounts/account-service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { type QueryClient, useQueryClient } from '@tanstack/react-query';
import type { DistributedOmit } from 'type-fest';
import { checkIsTestMint } from '~/lib/cashu';
import { getOfferExpiresAt } from '~/lib/cashu';
import {
allMintKeysetsQueryOptions,
isTestMintQueryOptions,
mintInfoQueryOptions,
} from '../shared/cashu';
import type { User } from '../user/user';
import type { Account, CashuAccount, ExtendedAccount } from './account';
import {
Expand All @@ -8,7 +14,10 @@ import {
} from './account-repository';

export class AccountService {
constructor(private readonly accountRepository: AccountRepository) {}
constructor(
private readonly accountRepository: AccountRepository,
private readonly queryClient: QueryClient,
) {}

/**
* Returns true if the account is the user's default account for the respective currency.
Expand Down Expand Up @@ -50,24 +59,41 @@ export class AccountService {
| 'createdAt'
| 'isTestMint'
| 'keysetCounters'
| 'expiresAt'
| 'proofs'
| 'version'
| 'wallet'
| 'isOnline'
>;
}) {
const isTestMint = await checkIsTestMint(account.mintUrl);
const mintInfo = await this.queryClient.fetchQuery(
mintInfoQueryOptions(account.mintUrl),
);

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

let expiresAt: string | null = null;
if (account.purpose === 'offer') {
const { keysets } = await this.queryClient.fetchQuery(
allMintKeysetsQueryOptions(account.mintUrl),
);
expiresAt = getOfferExpiresAt(keysets, account.currency);
}

return this.accountRepository.create<CashuAccount>({
...account,
userId,
isTestMint,
expiresAt,
keysetCounters: {},
});
}
}

export function useAccountService() {
const accountRepository = useAccountRepository();
return new AccountService(accountRepository);
const queryClient = useQueryClient();
return new AccountService(accountRepository, queryClient);
}
29 changes: 21 additions & 8 deletions app/features/accounts/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ import type {
} from '@buildonspark/spark-sdk';
import type { DistributedOmit } from 'type-fest';
import { type ExtendedCashuWallet, getCashuUnit, sumProofs } from '~/lib/cashu';
import type { MintPurpose } 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
* Account purpose. Includes MintPurpose for cashu accounts,
* plus 'transactional' which also applies to non-cashu account types (e.g. Spark).
*/
export type AccountPurpose = 'transactional' | 'gift-card';
export type AccountPurpose = 'transactional' | MintPurpose;

export type Account = {
id: string;
Expand All @@ -38,6 +38,12 @@ export type Account = {
* Holds counter value for each mint keyset. Key is the keyset id, value is counter value.
*/
keysetCounters: Record<string, number>;
/**
* ISO 8601 timestamp when the account's ecash expires (for offer accounts).
* Converted from the active keyset's `final_expiry` unix epoch (NUT-02).
* Null for non-offer accounts.
*/
expiresAt: string | 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,21 +83,28 @@ 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 offline wallets, test mints, non-transactional accounts,
* and mints with melting disabled (NUT-05).
*/
export const canSendToLightning = (account: Account): boolean => {
if (account.type === 'spark') {
return true;
}
return !account.isTestMint && account.purpose === 'transactional';
if (!account.isOnline) return false;
if (account.isTestMint) return false;
if (account.purpose !== 'transactional') return false;
return !account.wallet.getMintInfo().isSupported(5).disabled;
};

/**
* 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()),
/**
* ISO 8601 timestamp when the account's ecash expires (for offer accounts).
* Converted from the active keyset's `final_expiry` unix epoch (NUT-02).
* Null for non-offer accounts or when the keyset has no expiry.
*/
expires_at: z.string().nullable().optional(),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking at this plan for adding account state here I'm wondering if expires_at should be a new column, not in the cashu account jsonb.

});

export type CashuAccountDetailsDbData = z.infer<
Expand Down
17 changes: 15 additions & 2 deletions app/features/receive/receive-cashu-token-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Token } from '@cashu/cashu-ts';
import { type QueryClient, useQueryClient } from '@tanstack/react-query';
import { areMintUrlsEqual, getCashuProtocolUnit } from '~/lib/cashu';
import {
areMintUrlsEqual,
getCashuProtocolUnit,
getOfferExpiresAt,
} from '~/lib/cashu';
import type { Currency } from '~/lib/money';
import {
type ExtendedAccount,
Expand Down Expand Up @@ -41,6 +45,14 @@ export class ReceiveCashuTokenService {
undefined,
);

const mintKeysets = wallet.keyChain
.getKeysets()
.map((ks) => ks.toMintKeyset());
const expiresAt =
wallet.purpose === 'offer'
? getOfferExpiresAt(mintKeysets, currency)
: null;

const baseAccount = {
id: 'cashu-account-placeholder-id',
type: 'cashu' as const,
Expand All @@ -51,6 +63,7 @@ export class ReceiveCashuTokenService {
currency,
version: 0,
keysetCounters: {},
expiresAt,
proofs: [],
isDefault: false,
isSource: true,
Expand All @@ -77,7 +90,7 @@ export class ReceiveCashuTokenService {
);

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

const isValid = validationResult === true;
Expand Down
2 changes: 1 addition & 1 deletion app/features/send/send-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export function SendInput() {
<Scan />
</LinkWithViewTransition>

{sendAccount.purpose !== 'gift-card' && (
{sendAccount.purpose === 'transactional' && (
<SelectDestinationDrawer
open={selectDestinationDrawerOpen}
onOpenChange={setSelectDestinationDrawerOpen}
Expand Down
13 changes: 9 additions & 4 deletions app/features/shared/cashu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,12 +253,17 @@ export const mintKeysQueryOptions = (mintUrl: string, keysetId?: string) =>
staleTime: 1000 * 60 * 60, // 1 hour
});

export const isTestMintQueryOptions = (mintUrl: string) =>
queryOptions({
queryKey: ['is-test-mint', mintUrl],
queryFn: async () => checkIsTestMint(mintUrl),
export const isTestMintQueryOptions = (
mintUrl: string,
mintInfo: ExtendedMintInfo,
) => {
return queryOptions({
queryKey: ['is-test-mint', mintUrl, mintInfo.isSupported(4).disabled],
queryFn: async () =>
checkIsTestMint(mintUrl, mintInfo.isSupported(4).disabled),
staleTime: Number.POSITIVE_INFINITY,
});
};

/**
* Initializes a Cashu wallet with offline handling.
Expand Down
2 changes: 2 additions & 0 deletions app/features/user/user-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const defaultAccounts = [
isTestMint: true,
isDefault: false,
purpose: 'transactional',
expiresAt: null,
},
{
type: 'cashu',
Expand All @@ -104,6 +105,7 @@ export const defaultAccounts = [
isTestMint: true,
isDefault: true,
purpose: 'transactional',
expiresAt: null,
},
] as const)
: []),
Expand Down
1 change: 1 addition & 0 deletions app/features/user/user-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export class ReadUserDefaultAccountRepository {
mintUrl: details.mint_url,
isTestMint: details.is_test_mint,
keysetCounters: details.keyset_counters,
expiresAt: details.expires_at ?? null,
wallet,
};
}
Expand Down
31 changes: 18 additions & 13 deletions app/lib/cashu/PROTOCOL_EXTENSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Example:
{
"...other fields",
"agicash": {
"closed_loop": true,
"purpose": "gift-card",
"minting_fee": {
"type": "basis_points",
"value": 100
Expand All @@ -26,19 +26,25 @@ Example:
}
```

## Closed Loop
### Purpose

The `closed_loop` field is a boolean that indicates whether the mint operates in closed-loop mode.
When `true`, the mint will only process payments to destinations within its loop. The loop may include
invoices generated by the mint itself or other configured destinations.
The `purpose` field signals to wallets what type of account to create for this mint.
Defaults to `"transactional"` when absent.

```json
{
"agicash": {
"closed_loop": true
}
}
```
| Value | Description |
|-------|-------------|
| `"transactional"` | Regular mint for sending and receiving payments. |
| `"gift-card"` | Closed-loop mint issuing gift cards. The mint only processes payments to destinations within its loop. The loop may include invoices generated by the mint itself or other configured destinations. |
| `"offer"` | Closed-loop promotional ecash with an expiry. Users cannot mint from these mints (`nuts.4.disabled: true`); ecash is distributed by the mint operator. The mint only processes payments to destinations within its loop. The keyset's `final_expiry` field indicates when the ecash expires. |

### Offer Mints

Offer mints are also closed-loop. They still include standard NUT-04 metadata in the NUT-06 info response, but user minting is disabled via `nuts.4.disabled: true`.
Wallets should:

- **Not** show Lightning receive options for offer accounts.
- Read `final_expiry` from the mint's active keyset (via `/v1/keysets`) to determine when the offer expires.
- Hide expired offer accounts from the UI.

## Minting Fees (extends NUT-23)

Expand Down Expand Up @@ -69,4 +75,3 @@ When the fee type is `basis_points`, the fee is calculated as:
```
fee = amount * (basis_points / 10000)
```

2 changes: 1 addition & 1 deletion app/lib/cashu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export * from './secret';
export * from './token';
export * from './utils';
export * from './error-codes';
export { ExtendedMintInfo } from './protocol-extensions';
export { ExtendedMintInfo, type MintPurpose } from './protocol-extensions';
export { ProofSchema } from './types';
export * from './payment-request';
export * from './melt-quote-subscription';
Expand Down
7 changes: 0 additions & 7 deletions app/lib/cashu/mint-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,6 @@ const validateBolt11Support = (
const nut = operation === 'minting' ? 4 : 5;
const status = info.isSupported(nut);

if (status.disabled) {
return {
isValid: false,
message: `${operation} is disabled on this mint`,
};
}

const hasBolt11Support = status.params.some(
(method) => method.method === 'bolt11' && method.unit === unit,
);
Expand Down
14 changes: 9 additions & 5 deletions app/lib/cashu/protocol-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,20 @@ type MintQuoteFee = {
fee?: number;
};

/**
* The purpose of a Cashu mint as advertised in its info response.
* - 'transactional': Regular mint for sending/receiving payments
* - 'gift-card': Closed-loop mint issuing gift cards
* - 'offer': Promotional ecash with an expiry
*/
export type MintPurpose = 'transactional' | 'gift-card' | 'offer';

/**
* Agicash-specific mint info extension.
* This is included in the mint's info response under the "agicash" key.
*/
export type AgicashMintExtension = {
/**
* When true, the mint operates in closed-loop mode and will only process
* payments to destinations within its loop.
*/
closed_loop?: boolean;
purpose?: MintPurpose;
};

/**
Expand Down
Loading
Loading