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
4 changes: 4 additions & 0 deletions .cursor/rules/core.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,7 @@ When responding to questions, use the Chain of Thought method. Outline a detaile
**Code change = potential for bugs and technical debt.**

Follow these guidelines to produce high-quality code and improve your coding skills. If you have any questions or need clarification, don’t hesitate to ask!

## Error logging

When adding console.error logs create a short and meaningful error message, then an object with { cause: error , ...otherRelevantData}
11 changes: 3 additions & 8 deletions app/features/receive/cashu-receive-quote-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CashuErrorCodes,
amountsFromOutputData,
getCashuUnit,
isCashuError,
} from '~/lib/cashu';
import type { Money } from '~/lib/money';
import type { CashuAccount } from '../accounts/account';
Expand Down Expand Up @@ -316,16 +317,10 @@ export class CashuReceiveQuoteService {
} catch (error) {
if (
error instanceof MintOperationError &&
([
isCashuError(error, [
CashuErrorCodes.OUTPUT_ALREADY_SIGNED,
CashuErrorCodes.QUOTE_ALREADY_ISSUED,
].includes(error.code) ||
// Nutshell mint implementation did not conform to the spec up until version 0.16.5 (see https://github.com/cashubtc/nutshell/pull/693)
// so for earlier versions we need to check the message.
error.message
.toLowerCase()
.includes('outputs have already been signed before') ||
error.message.toLowerCase().includes('mint quote already issued.'))
])
) {
const { proofs } = await wallet.restore(
quote.keysetCounter,
Expand Down
5 changes: 4 additions & 1 deletion app/features/receive/cashu-token-swap-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useQueryClient,
} from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import type { UnlockingData } from '~/lib/cashu/types';
import { useSupabaseRealtimeSubscription } from '~/lib/supabase/supabase-realtime';
import { useLatest } from '~/lib/use-latest';
import { useGetLatestCashuAccount } from '../accounts/account-hooks';
Expand All @@ -27,6 +28,7 @@ import { useCashuTokenSwapService } from './cashu-token-swap-service';
type CreateProps = {
token: Token;
accountId: string;
unlockingData?: UnlockingData;
};

// Query to track the active token swap for a given token hash. The active swap is the one that user created in current browser session, and we track it in order to show the current state of the swap on the receive page.
Expand Down Expand Up @@ -101,12 +103,13 @@ export function useCreateCashuTokenSwap() {
scope: {
id: 'create-cashu-token-swap',
},
mutationFn: async ({ token, accountId }: CreateProps) => {
mutationFn: async ({ token, accountId, unlockingData }: CreateProps) => {
const account = await getLatestAccount(accountId);
return tokenSwapService.create({
userId,
token,
account,
unlockingData,
});
},
onSuccess: async (data) => {
Expand Down
39 changes: 37 additions & 2 deletions app/features/receive/cashu-token-swap-repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Proof, Token } from '@cashu/cashu-ts';
import { getCashuUnit } from '~/lib/cashu';
import type { UnlockingData } from '~/lib/cashu/types';
import { UnlockingDataSchema } from '~/lib/cashu/types';
import { Money } from '~/lib/money';
import {
type AgicashDb,
Expand Down Expand Up @@ -63,6 +65,10 @@ type CreateTokenSwap = {
* ID of the transaction that this swap is reversing.
*/
reversedTransactionId?: string;
/**
* Data that will be used to satisfy the spending condition of the token proofs (signing keys, preimages, etc)
*/
unlockingData?: UnlockingData;
};

export class CashuTokenSwapRepository {
Expand All @@ -88,6 +94,7 @@ export class CashuTokenSwapRepository {
outputAmounts,
accountVersion,
reversedTransactionId,
unlockingData,
}: CreateTokenSwap,
options?: Options,
): Promise<CashuTokenSwap> {
Expand All @@ -108,9 +115,14 @@ export class CashuTokenSwapRepository {
tokenAmount: amount,
};

const [encryptedTransactionDetails, encryptedProofs] = await Promise.all([
const [
encryptedTransactionDetails,
encryptedProofs,
encryptedUnlockingData,
] = await Promise.all([
this.encryption.encrypt(details),
this.encryption.encrypt(token.proofs),
this.encryption.encrypt(unlockingData),
]);

const query = this.db.rpc('create_cashu_token_swap', {
Expand All @@ -128,6 +140,7 @@ export class CashuTokenSwapRepository {
p_fee_amount: cashuReceiveFee,
p_account_version: accountVersion,
p_reversed_transaction_id: reversedTransactionId,
p_unlocking_data: encryptedUnlockingData,
p_encrypted_transaction_details: encryptedTransactionDetails,
});

Expand Down Expand Up @@ -310,9 +323,30 @@ export class CashuTokenSwapRepository {
data: AgicashDbCashuTokenSwap,
decryptData: Encryption['decrypt'],
): Promise<CashuTokenSwap> {
const [tokenProofs, unlockingData] = await Promise.all([
decryptData<Proof[]>(data.token_proofs),
data.unlocking_data
? decryptData<UnlockingData>(data.unlocking_data)
: null,
]);

let validatedUnlockingData: UnlockingData | null = null;
if (unlockingData) {
const validationResult = UnlockingDataSchema.safeParse(unlockingData);
if (!validationResult.success) {
throw new Error('Invalid unlocking data structure', {
cause: {
data: unlockingData,
errors: validationResult.error.errors,
},
});
}
validatedUnlockingData = validationResult.data;
}

const decryptedData = {
...data,
token_proofs: await decryptData<Proof[]>(data.token_proofs),
token_proofs: tokenProofs,
};

return {
Expand All @@ -332,6 +366,7 @@ export class CashuTokenSwapRepository {
state: data.state as CashuTokenSwap['state'],
version: data.version,
transactionId: data.transaction_id,
unlockingData: validatedUnlockingData,
};
}
}
Expand Down
90 changes: 64 additions & 26 deletions app/features/receive/cashu-token-swap-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import {
CashuErrorCodes,
amountsFromOutputData,
areMintUrlsEqual,
isCashuError,
sumProofs,
validateTokenSpendingConditions,
} from '~/lib/cashu';
import type { UnlockingData } from '~/lib/cashu/types';
import { sum } from '~/lib/utils';
import type { CashuAccount } from '../accounts/account';
import { tokenToMoney } from '../shared/cashu';
Expand All @@ -27,16 +30,23 @@ export class CashuTokenSwapService {
token,
account,
reversedTransactionId,
unlockingData,
}: {
userId: string;
token: Token;
account: CashuAccount;
reversedTransactionId?: string;
unlockingData?: UnlockingData;
}) {
if (!areMintUrlsEqual(account.mintUrl, token.mint)) {
throw new Error('Cannot swap a token to a different mint');
}

const result = validateTokenSpendingConditions(token, unlockingData);
if (!result.success) {
throw new Error(result.error);
}

const amount = tokenToMoney(token);

if (amount.currency !== account.currency) {
Expand Down Expand Up @@ -73,6 +83,7 @@ export class CashuTokenSwapService {
cashuReceiveFee: fee,
accountVersion: account.version,
reversedTransactionId,
unlockingData,
});

return tokenSwap;
Expand Down Expand Up @@ -119,6 +130,20 @@ export class CashuTokenSwapService {
version: tokenSwap.version,
reason: 'Token already claimed',
});
} else if (
error instanceof Error &&
error.message === 'TOKEN_UNSPENDABLE'
) {
// TODO: we should delete the swap from the database here or chang ehow we hand the
// unique constraint on the token hash. Currenlty, now that this token hash
// is in the database, we can never try to claim it again
await this.tokenSwapRepository.fail({
tokenHash: tokenSwap.tokenHash,
userId: tokenSwap.userId,
version: tokenSwap.version,
reason: 'Token is unspendable',
});
throw error;
} else {
throw error;
}
Expand All @@ -132,45 +157,58 @@ export class CashuTokenSwapService {
) {
try {
const amountToReceive = sum(tokenSwap.outputAmounts);
const privkey =
tokenSwap.unlockingData?.kind === 'P2PK'
? tokenSwap.unlockingData.signingKeys[0]
: undefined;

const { send: newProofs } = await wallet.swap(
amountToReceive,
tokenSwap.tokenProofs,
{
outputData: { send: outputData },
privkey,
},
);

return newProofs;
} catch (error) {
if (
error instanceof MintOperationError &&
([
CashuErrorCodes.OUTPUT_ALREADY_SIGNED,
CashuErrorCodes.TOKEN_ALREADY_SPENT,
].includes(error.code) ||
// Nutshell mint implementation did not conform to the spec up until version 0.16.5 (see https://github.com/cashubtc/nutshell/pull/693)
// so for earlier versions we need to check the message.
error.message
.toLowerCase()
.includes('outputs have already been signed before'))
) {
const { proofs } = await wallet.restore(
tokenSwap.keysetCounter,
tokenSwap.outputAmounts.length,
{
keysetId: tokenSwap.keysetId,
},
);
if (error instanceof MintOperationError) {
if (isCashuError(error, [CashuErrorCodes.WITNESS_MISSING_P2PK])) {
// The swap was created with invalid unlocking data. In the current state,
// the token cannot be claimed.
throw new Error('TOKEN_UNSPENDABLE');
}

if (
error.code === CashuErrorCodes.TOKEN_ALREADY_SPENT &&
proofs.length === 0
isCashuError(error, [
CashuErrorCodes.OUTPUT_ALREADY_SIGNED,
CashuErrorCodes.TOKEN_ALREADY_SPENT,
])
) {
// If token is spent and we could not restore proofs, then we know someone else has claimed this token.
throw new Error('TOKEN_ALREADY_CLAIMED');
// The swap failed because the mint already issued signatures for our
// specified outputs. We will now try to recover the swap by restoring the
// blinded signatures from the mint.
const { proofs } = await wallet.restore(
tokenSwap.keysetCounter,
tokenSwap.outputAmounts.length,
{
keysetId: tokenSwap.keysetId,
},
);

if (
error.code === CashuErrorCodes.TOKEN_ALREADY_SPENT &&
proofs.length === 0
) {
// If token is spent and we could not restore proofs, then we know someone else has claimed this token.
throw new Error('TOKEN_ALREADY_CLAIMED');
}

// TODO: make sure these proofs are not already in our balance and that they are in the UNSPENT state.
// We should never put pending nor spent proofs in our main wallet balance.
return proofs;
}

// TODO: make sure these proofs are not already in our balance and that they are not spent
return proofs;
}
throw error;
}
Expand Down
3 changes: 3 additions & 0 deletions app/features/receive/cashu-token-swap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Proof } from '@cashu/cashu-ts';
import type { UnlockingData } from '~/lib/cashu/types';
import type { Money } from '~/lib/money';

/**
Expand Down Expand Up @@ -46,4 +47,6 @@ export type CashuTokenSwap = {
version: number;
/** Timestamp when the token swap was created */
createdAt: string;
/** Data that will be used to satisfy the spending condition of the token proofs (signing keys, preimages, etc) */
unlockingData: UnlockingData | null;
};
13 changes: 13 additions & 0 deletions app/features/receive/receive-cashu-token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useEffectNoStrictMode } from '~/hooks/use-effect-no-strict-mode';
import { useToast } from '~/hooks/use-toast';
import { areMintUrlsEqual } from '~/lib/cashu';
import type { Currency } from '~/lib/money';
import { getPublicKeyFromPrivateKey } from '~/lib/secp256k1';
import {
LinkWithViewTransition,
useNavigateWithViewTransition,
Expand Down Expand Up @@ -49,6 +50,8 @@ type Props = {
autoClaimToken: boolean;
/** The initially selected receive account will be set to this account if it exists.*/
preferredReceiveAccountId?: string;
/** A private key that will unlock the token proofs */
unlockingKey?: string | null;
};

/**
Expand Down Expand Up @@ -106,6 +109,7 @@ export default function ReceiveToken({
token,
autoClaimToken,
preferredReceiveAccountId,
unlockingKey,
}: Props) {
const { toast } = useToast();
const navigate = useNavigateWithViewTransition();
Expand All @@ -115,6 +119,9 @@ export default function ReceiveToken({
const { claimableToken, cannotClaimReason } =
useCashuTokenWithClaimableProofs({
token,
cashuPubKey: unlockingKey
? getPublicKeyFromPrivateKey(unlockingKey, { asBytes: false })
: undefined,
});
const {
selectableAccounts,
Expand Down Expand Up @@ -168,6 +175,12 @@ export default function ReceiveToken({
const { transactionId } = await createCashuTokenSwap({
token,
accountId: account.id,
unlockingData: unlockingKey
? {
kind: 'P2PK',
signingKeys: [unlockingKey],
}
: undefined,
});
onTransactionCreated(transactionId);
} else {
Expand Down
Loading