From 2ce4166cd4028f4aff9cb7d3a796d301a20518e7 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Sat, 13 Sep 2025 01:00:06 -0700 Subject: [PATCH] introduce spending conditions --- .cursor/rules/core.mdc | 4 + .../receive/cashu-receive-quote-service.ts | 11 +- .../receive/cashu-token-swap-hooks.ts | 5 +- .../receive/cashu-token-swap-repository.ts | 39 +- .../receive/cashu-token-swap-service.ts | 90 +- app/features/receive/cashu-token-swap.ts | 3 + app/features/receive/receive-cashu-token.tsx | 13 + app/features/send/cashu-send-swap-hooks.ts | 7 + .../send/cashu-send-swap-repository.ts | 43 +- app/features/send/cashu-send-swap-service.ts | 157 +- app/features/send/cashu-send-swap.ts | 6 + app/features/send/send-store.ts | 2 + app/lib/cashu/error-codes.ts | 55 + app/lib/cashu/index.ts | 1 + app/lib/cashu/secret.test.ts | 4 +- .../spending-conditions-validation.test.ts | 483 ++++++ .../cashu/spending-conditions-validation.ts | 176 +++ app/lib/cashu/types.ts | 279 +++- app/lib/secp256k1.ts | 59 + app/routes/_protected.demo.tsx | 1308 +++++++++++++++++ .../_protected.receive.cashu_.token.tsx | 22 +- supabase/database.types.ts | 10 + ...10231020_add-cashu-spending-conditions.sql | 267 ++++ 23 files changed, 2943 insertions(+), 101 deletions(-) create mode 100644 app/lib/cashu/spending-conditions-validation.test.ts create mode 100644 app/lib/cashu/spending-conditions-validation.ts create mode 100644 app/lib/secp256k1.ts create mode 100644 app/routes/_protected.demo.tsx create mode 100644 supabase/migrations/20250910231020_add-cashu-spending-conditions.sql diff --git a/.cursor/rules/core.mdc b/.cursor/rules/core.mdc index 9f90128b3..6449e0795 100644 --- a/.cursor/rules/core.mdc +++ b/.cursor/rules/core.mdc @@ -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} diff --git a/app/features/receive/cashu-receive-quote-service.ts b/app/features/receive/cashu-receive-quote-service.ts index bf536e0a6..8b1fa5d3d 100644 --- a/app/features/receive/cashu-receive-quote-service.ts +++ b/app/features/receive/cashu-receive-quote-service.ts @@ -11,6 +11,7 @@ import { CashuErrorCodes, amountsFromOutputData, getCashuUnit, + isCashuError, } from '~/lib/cashu'; import type { Money } from '~/lib/money'; import type { CashuAccount } from '../accounts/account'; @@ -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, diff --git a/app/features/receive/cashu-token-swap-hooks.ts b/app/features/receive/cashu-token-swap-hooks.ts index ef9787ef5..735ba8975 100644 --- a/app/features/receive/cashu-token-swap-hooks.ts +++ b/app/features/receive/cashu-token-swap-hooks.ts @@ -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'; @@ -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. @@ -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) => { diff --git a/app/features/receive/cashu-token-swap-repository.ts b/app/features/receive/cashu-token-swap-repository.ts index 23cfc7cb1..08d6838c2 100644 --- a/app/features/receive/cashu-token-swap-repository.ts +++ b/app/features/receive/cashu-token-swap-repository.ts @@ -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, @@ -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 { @@ -88,6 +94,7 @@ export class CashuTokenSwapRepository { outputAmounts, accountVersion, reversedTransactionId, + unlockingData, }: CreateTokenSwap, options?: Options, ): Promise { @@ -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', { @@ -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, }); @@ -310,9 +323,30 @@ export class CashuTokenSwapRepository { data: AgicashDbCashuTokenSwap, decryptData: Encryption['decrypt'], ): Promise { + const [tokenProofs, unlockingData] = await Promise.all([ + decryptData(data.token_proofs), + data.unlocking_data + ? decryptData(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(data.token_proofs), + token_proofs: tokenProofs, }; return { @@ -332,6 +366,7 @@ export class CashuTokenSwapRepository { state: data.state as CashuTokenSwap['state'], version: data.version, transactionId: data.transaction_id, + unlockingData: validatedUnlockingData, }; } } diff --git a/app/features/receive/cashu-token-swap-service.ts b/app/features/receive/cashu-token-swap-service.ts index 1ec4291cb..f129a0b5e 100644 --- a/app/features/receive/cashu-token-swap-service.ts +++ b/app/features/receive/cashu-token-swap-service.ts @@ -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'; @@ -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) { @@ -73,6 +83,7 @@ export class CashuTokenSwapService { cashuReceiveFee: fee, accountVersion: account.version, reversedTransactionId, + unlockingData, }); return tokenSwap; @@ -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; } @@ -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; } diff --git a/app/features/receive/cashu-token-swap.ts b/app/features/receive/cashu-token-swap.ts index 8701420cc..53a62aeba 100644 --- a/app/features/receive/cashu-token-swap.ts +++ b/app/features/receive/cashu-token-swap.ts @@ -1,4 +1,5 @@ import type { Proof } from '@cashu/cashu-ts'; +import type { UnlockingData } from '~/lib/cashu/types'; import type { Money } from '~/lib/money'; /** @@ -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; }; diff --git a/app/features/receive/receive-cashu-token.tsx b/app/features/receive/receive-cashu-token.tsx index 491e4785c..63b6223de 100644 --- a/app/features/receive/receive-cashu-token.tsx +++ b/app/features/receive/receive-cashu-token.tsx @@ -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, @@ -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; }; /** @@ -106,6 +109,7 @@ export default function ReceiveToken({ token, autoClaimToken, preferredReceiveAccountId, + unlockingKey, }: Props) { const { toast } = useToast(); const navigate = useNavigateWithViewTransition(); @@ -115,6 +119,9 @@ export default function ReceiveToken({ const { claimableToken, cannotClaimReason } = useCashuTokenWithClaimableProofs({ token, + cashuPubKey: unlockingKey + ? getPublicKeyFromPrivateKey(unlockingKey, { asBytes: false }) + : undefined, }); const { selectableAccounts, @@ -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 { diff --git a/app/features/send/cashu-send-swap-hooks.ts b/app/features/send/cashu-send-swap-hooks.ts index d0bde1648..6cfeae37b 100644 --- a/app/features/send/cashu-send-swap-hooks.ts +++ b/app/features/send/cashu-send-swap-hooks.ts @@ -8,6 +8,7 @@ import { useSuspenseQuery, } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; +import type { SpendingConditionData } from '~/lib/cashu/types'; import type { Money } from '~/lib/money'; import { useSupabaseRealtimeSubscription } from '~/lib/supabase/supabase-realtime'; import { useLatest } from '~/lib/use-latest'; @@ -100,10 +101,12 @@ export function useGetCashuSendSwapQuote() { mutationFn: async ({ amount, accountId, + requireSwap, senderPaysFee = true, }: { amount: Money; accountId: string; + requireSwap: boolean; senderPaysFee?: boolean; }) => { const account = await getLatestCashuAccount(accountId); @@ -111,6 +114,7 @@ export function useGetCashuSendSwapQuote() { amount, account, senderPaysFee, + requireSwap, }); }, }); @@ -132,10 +136,12 @@ export function useCreateCashuSendSwap({ mutationFn: async ({ amount, accountId, + spendingConditionData, senderPaysFee = true, }: { amount: Money; accountId: string; + spendingConditionData?: SpendingConditionData; senderPaysFee?: boolean; }) => { const account = await getLatestCashuAccount(accountId); @@ -144,6 +150,7 @@ export function useCreateCashuSendSwap({ amount, account, senderPaysFee, + spendingConditionData, }); }, onSuccess: (swap) => { diff --git a/app/features/send/cashu-send-swap-repository.ts b/app/features/send/cashu-send-swap-repository.ts index 01be4b6d7..6f8376d85 100644 --- a/app/features/send/cashu-send-swap-repository.ts +++ b/app/features/send/cashu-send-swap-repository.ts @@ -1,5 +1,9 @@ import type { Proof } from '@cashu/cashu-ts'; import { sumProofs } from '~/lib/cashu'; +import { + type SpendingConditionData, + SpendingConditionDataSchema, +} from '~/lib/cashu/types'; import { Money } from '~/lib/money'; import { type AgicashDb, @@ -63,6 +67,10 @@ type CreateSendSwap = { * The hash of the token being sent */ tokenHash?: string; + /** + * All the data required to encumber the proofs with the specified spending conditions. + */ + spendingConditionData?: SpendingConditionData; /** * All remaining proofs to keep in the account. */ @@ -106,6 +114,7 @@ export class CashuSendSwapRepository { inputProofs, proofsToSend, tokenHash, + spendingConditionData, accountProofs, keysetId, keysetCounter, @@ -129,11 +138,15 @@ export class CashuSendSwapRepository { encryptedAccountProofs, encryptedProofsToSend, encryptedTransactionDetails, + encryptedSpendingConditionData, ] = await Promise.all([ this.encryption.encrypt(inputProofs), this.encryption.encrypt(accountProofs), proofsToSend ? this.encryption.encrypt(proofsToSend) : undefined, this.encryption.encrypt(details), + spendingConditionData + ? this.encryption.encrypt(spendingConditionData) + : undefined, ]); const updatedKeysetCounter = @@ -167,6 +180,7 @@ export class CashuSendSwapRepository { p_encrypted_transaction_details: encryptedTransactionDetails, p_proofs_to_send: encryptedProofsToSend, p_token_hash: tokenHash, + p_spending_condition_data: encryptedSpendingConditionData, }); if (options?.abortSignal) { @@ -354,10 +368,30 @@ export class CashuSendSwapRepository { data: AgicashDbCashuSendSwap, decrypt: Encryption['decrypt'], ): Promise { - const [inputProofs, proofsToSend] = await Promise.all([ - decrypt(data.input_proofs), - data.proofs_to_send ? decrypt(data.proofs_to_send) : undefined, - ]); + const [inputProofs, proofsToSend, spendingConditionData] = + await Promise.all([ + decrypt(data.input_proofs), + data.proofs_to_send ? decrypt(data.proofs_to_send) : undefined, + data.spending_condition_data + ? decrypt(data.spending_condition_data) + : undefined, + ]); + + let validatedSpendingConditionData: SpendingConditionData | null = null; + if (spendingConditionData) { + const validationResult = SpendingConditionDataSchema.safeParse( + spendingConditionData, + ); + if (!validationResult.success) { + throw new Error('Invalid spending condition data', { + cause: { + data: spendingConditionData, + errors: validationResult.error.errors, + }, + }); + } + validatedSpendingConditionData = validationResult.data; + } const toMoney = (amount: number) => { return new Money({ @@ -379,6 +413,7 @@ export class CashuSendSwapRepository { cashuSendFee: toMoney(data.send_swap_fee), inputProofs, inputAmount: toMoney(data.input_amount), + spendingConditionData: validatedSpendingConditionData, currency: data.currency, version: data.version, state: data.state, diff --git a/app/features/send/cashu-send-swap-service.ts b/app/features/send/cashu-send-swap-service.ts index 0123fd163..a1f52b0fd 100644 --- a/app/features/send/cashu-send-swap-service.ts +++ b/app/features/send/cashu-send-swap-service.ts @@ -1,9 +1,4 @@ -import { - type CashuWallet, - MintOperationError, - OutputData, - type Proof, -} from '@cashu/cashu-ts'; +import { MintOperationError, OutputData, type Proof } from '@cashu/cashu-ts'; import type { CashuAccount } from '~/features/accounts/account'; import { CashuErrorCodes, @@ -11,8 +6,10 @@ import { amountsFromOutputData, getCashuProtocolUnit, getCashuUnit, + isCashuError, sumProofs, } from '~/lib/cashu'; +import type { SpendingConditionData } from '~/lib/cashu/types'; import { Money } from '~/lib/money'; import { type CashuTokenSwapService, @@ -51,6 +48,7 @@ export class CashuSendSwapService { account, amount, senderPaysFee, + requireSwap, }: { /** The account to send from. */ account: CashuAccount; @@ -58,6 +56,11 @@ 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; + /** + * Whether this operatioon will require a swap whether there are exact proofs or not. + * If true, then always perform a swap to get the proofs to send. + */ + requireSwap: boolean; }): Promise { if (account.currency !== amount.currency) { throw new Error( @@ -74,6 +77,7 @@ export class CashuSendSwapService { account.proofs, amount, senderPaysFee, + requireSwap, ); const toMoney = (num: number) => @@ -102,6 +106,7 @@ export class CashuSendSwapService { account, amount, senderPaysFee, + spendingConditionData, }: { /** The id of the user creating the swap */ userId: string; @@ -111,6 +116,7 @@ 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; + spendingConditionData?: SpendingConditionData; }): Promise { if (account.currency !== amount.currency) { throw new Error( @@ -133,6 +139,7 @@ export class CashuSendSwapService { account.proofs, amount, senderPaysFee, + spendingConditionData !== undefined, ); const totalAmountToSend = amountNumber + cashuReceiveFee; @@ -145,32 +152,32 @@ export class CashuSendSwapService { let keysetId: string | undefined; const haveExactProofs = sumProofs(send) === totalAmountToSend; - if (haveExactProofs) { - proofsToSend = send; - tokenHash = await getTokenHash({ - mint: account.mintUrl, - proofs: proofsToSend, - unit: getCashuProtocolUnit(amount.currency), - }); - } else { + const requiresSwap = !haveExactProofs || spendingConditionData; + + if (requiresSwap) { const keys = await wallet.getKeys(); keysetId = keys.id; sendKeysetCounter = account.keysetCounters[keysetId] ?? 0; - sendOutputData = OutputData.createDeterministicData( - totalAmountToSend, - wallet.seed, - sendKeysetCounter, - keys, - ); + sendOutputData = await this.createOutputData(wallet, { + amount: totalAmountToSend, + counter: sendKeysetCounter, + spendingConditionData, + }); const amountToKeep = sumProofs(send) - totalAmountToSend - cashuSendFee; const keepKeysetCounter = sendKeysetCounter + sendOutputData.length; - keepOutputData = OutputData.createDeterministicData( - amountToKeep, - wallet.seed, - keepKeysetCounter, - keys, - ); + keepOutputData = await this.createOutputData(wallet, { + amount: amountToKeep, + counter: keepKeysetCounter, + spendingConditionData, + }); + } else { + proofsToSend = send; + tokenHash = await getTokenHash({ + mint: account.mintUrl, + proofs: proofsToSend, + unit: getCashuProtocolUnit(amount.currency), + }); } const toMoney = (num: number) => @@ -195,6 +202,7 @@ export class CashuSendSwapService { keysetId, keysetCounter: sendKeysetCounter, tokenHash, + spendingConditionData, outputAmounts: { send: amountsFromOutputData(sendOutputData), keep: amountsFromOutputData(keepOutputData), @@ -215,27 +223,23 @@ export class CashuSendSwapService { const wallet = account.wallet; - const keys = await wallet.getKeys(swap.keysetId); const sendAmount = swap.amountToSend.toNumber(getCashuUnit(swap.currency)); - const sendOutputData = OutputData.createDeterministicData( - sendAmount, - wallet.seed, - swap.keysetCounter, - keys, - swap.outputAmounts.send, - ); + const sendOutputData = await this.createOutputData(wallet, { + amount: sendAmount, + counter: swap.keysetCounter, + spendingConditionData: swap.spendingConditionData, + customSplit: swap.outputAmounts.send, + }); const amountToKeep = sumProofs(swap.inputProofs) - sendAmount - swap.cashuSendFee.toNumber(getCashuUnit(swap.currency)); - const keepOutputData = OutputData.createDeterministicData( - amountToKeep, - wallet.seed, - swap.keysetCounter + sendOutputData.length, - keys, - swap.outputAmounts.keep, - ); + const keepOutputData = await this.createOutputData(wallet, { + amount: amountToKeep, + counter: swap.keysetCounter + sendOutputData.length, + customSplit: swap.outputAmounts.keep, + }); const { send: proofsToSend, keep: newProofsToKeep } = await this.swapProofs( wallet, @@ -312,6 +316,7 @@ export class CashuSendSwapService { allProofs: Proof[], requestedAmount: Money, includeFeesInSendAmount: boolean, + requireSwap: boolean, ): Promise<{ keep: Proof[]; send: Proof[]; @@ -321,6 +326,10 @@ export class CashuSendSwapService { if (includeFeesInSendAmount) { // If we want to do fee calculation, then the keys are required await wallet.getKeys(); + } else { + throw new Error( + 'Sender must pay fees - this feature is not yet implemented', + ); } const currency = requestedAmount.currency; @@ -334,12 +343,6 @@ export class CashuSendSwapService { ); const feeToSwapSelectedProofs = wallet.getFeesForProofs(send); - if (!includeFeesInSendAmount) { - throw new Error( - 'Sender must pay fees - this feature is not yet implemented', - ); - } - let proofAmountSelected = sumProofs(send); const amountToSend = requestedAmountNumber + feeToSwapSelectedProofs; @@ -351,6 +354,15 @@ export class CashuSendSwapService { }); if (proofAmountSelected === amountToSend) { + if (requireSwap && feeToSwapSelectedProofs > 0) { + // This is a current limitation of the selectProofsToSend function. + // selectProofsToSend will correctly consider receiveSwapFees, but + // there is no wasy to make it select enough proofs to swap in the case + // of adding spending conditions to the proofs where a swap is required. + throw new DomainError( + 'Unable to select proofs to swap. Try a different amount or use amint with no fees.', + ); + } return { keep, send, @@ -410,7 +422,7 @@ export class CashuSendSwapService { } private async swapProofs( - wallet: CashuWallet, + wallet: ExtendedCashuWallet, swap: CashuSendSwap & { state: 'DRAFT' }, outputData: { keep: OutputData[]; @@ -429,15 +441,10 @@ export class CashuSendSwapService { } catch (error) { if ( error instanceof MintOperationError && - ([ + isCashuError(error, [ 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 totalOutputCount = outputData.send.length + outputData.keep.length; @@ -467,6 +474,46 @@ export class CashuSendSwapService { throw error; } } + + private async createOutputData( + wallet: ExtendedCashuWallet, + { + amount, + counter, + customSplit, + spendingConditionData, + }: { + amount: number; + counter: number; + customSplit?: number[]; + spendingConditionData?: SpendingConditionData | null; + }, + ) { + const keys = await wallet.getKeys(); + if (!spendingConditionData) { + return OutputData.createDeterministicData( + amount, + wallet.seed, + counter, + keys, + customSplit, + ); + } + if (spendingConditionData.kind === 'P2PK') { + return OutputData.createP2PKData( + { + pubkey: spendingConditionData.data, + ...spendingConditionData.conditions, + }, + amount, + keys, + customSplit, + ); + } + throw new Error('Unsupported spending condition data', { + cause: spendingConditionData, + }); + } } export function useCashuSendSwapService() { diff --git a/app/features/send/cashu-send-swap.ts b/app/features/send/cashu-send-swap.ts index 985730b88..835539e95 100644 --- a/app/features/send/cashu-send-swap.ts +++ b/app/features/send/cashu-send-swap.ts @@ -1,4 +1,5 @@ import type { Proof } from '@cashu/cashu-ts'; +import type { SpendingConditionData } from '~/lib/cashu/types'; import type { Currency, Money } from '~/lib/money'; /** @@ -62,6 +63,11 @@ export type CashuSendSwap = { * The currency of the account and all amounts. */ currency: Currency; + + /** + * All the data required to encumber the proofs with the specified spending conditions. + */ + spendingConditionData: SpendingConditionData | null; /** * - DRAFT: The swap entity has been created, but there are no proofs to send yet. At this point, * we have only taken the inputProofs from the account diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index 67be7863a..548b0dc4d 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -193,6 +193,7 @@ type CreateSendStoreProps = { accountId: string; amount: Money; senderPaysFee?: boolean; + requireSwap: boolean; }) => Promise; }; @@ -334,6 +335,7 @@ export const createSendStore = ({ const quote = await getCashuSendSwapQuote({ accountId: account.id, amount: amountToSend, + requireSwap: false, // NOTE: this should be set to true when adding spending condition data }); set({ quote }); diff --git a/app/lib/cashu/error-codes.ts b/app/lib/cashu/error-codes.ts index 44037207f..e06875ab0 100644 --- a/app/lib/cashu/error-codes.ts +++ b/app/lib/cashu/error-codes.ts @@ -163,4 +163,59 @@ export enum CashuErrorCodes { * Relevant nuts: @see [NUT-22](https://github.com/cashubtc/nuts/blob/main/22.md) */ BAT_MINT_RATE_LIMIT_EXCEEDED = 31004, + + /** + * Witness is missing for p2pk signature + * Relevant nuts: @see [NUT-11](https://github.com/cashubtc/nuts/blob/main/11.md) + */ + WITNESS_MISSING_P2PK = 20008, +} + +/** + * Custom error message mappings to standard error codes. + * These handle cases where different mint implementations return varying error messages + * for the same underlying error condition. + */ +export const CashuErrorMessageMappings: Record = { + // Nutshell mint implementation did not conform to the spec up until version 0.16.5 + // https://github.com/cashubtc/nutshell/pull/693 + 'outputs have already been signed before': + 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, +} as const; + +/** + * Checks if an error matches any of the specified error codes, either by direct code comparison + * or by matching known error message patterns. + * + * @param error The error to check (should be a MintOperationError) + * @param codes Array of CashuErrorCodes to match against + * @returns true if the error matches any of the specified codes + */ +export function isCashuError( + error: { code?: number; message?: string }, + codes: CashuErrorCodes[], +): boolean { + if (error.code && codes.includes(error.code)) { + return true; + } + + if (error.message) { + const normalizedMessage = error.message.toLowerCase(); + + for (const [pattern, errorCode] of Object.entries( + CashuErrorMessageMappings, + )) { + if ( + normalizedMessage.includes(pattern.toLowerCase()) && + codes.includes(errorCode) + ) { + return true; + } + } + } + + return false; } diff --git a/app/lib/cashu/index.ts b/app/lib/cashu/index.ts index f71b5caea..06b4048cf 100644 --- a/app/lib/cashu/index.ts +++ b/app/lib/cashu/index.ts @@ -3,5 +3,6 @@ export * from './secret'; export * from './token'; export * from './utils'; export * from './error-codes'; +export * from './spending-conditions-validation'; export type { MintInfo } from './types'; export * from './payment-request'; diff --git a/app/lib/cashu/secret.test.ts b/app/lib/cashu/secret.test.ts index 287bed230..99b385eef 100644 --- a/app/lib/cashu/secret.test.ts +++ b/app/lib/cashu/secret.test.ts @@ -73,7 +73,9 @@ describe('parseSecret', () => { describe('should return failure for invalid NUT10 secret if', () => { test('secret is not in WELL_KNOWN_SECRET_KINDS', () => { - const result = parseSecret('["HTLC",{"nonce":"0","data":"0","tags":[]}]'); + const result = parseSecret( + '["UNKNOWN",{"nonce":"0","data":"0","tags":[]}]', + ); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toBeDefined(); diff --git a/app/lib/cashu/spending-conditions-validation.test.ts b/app/lib/cashu/spending-conditions-validation.test.ts new file mode 100644 index 000000000..147ce5da4 --- /dev/null +++ b/app/lib/cashu/spending-conditions-validation.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, test } from 'bun:test'; +import type { Token } from '@cashu/cashu-ts'; +import { validateTokenSpendingConditions } from './spending-conditions-validation'; +import type { UnlockingData } from './types'; + +// Test vectors based on NUT-11 specification +// https://github.com/cashubtc/nuts/blob/main/tests/11-test.md + +describe('validateTokenSpendingConditions - NUT-11 Test Vectors', () => { + describe('Plain Secrets', () => { + test('should validate token with plain hex secret', () => { + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: + 'daf4dd00814ac2dc6cd2c5f8b8ba9bc57b6ab3c094a84a169c4fa4c48523c0ba', // Plain hex string + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ success: true }); + }); + + test('should validate token with multiple plain secrets', () => { + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: + 'daf4dd00814ac2dc6cd2c5f8b8ba9bc57b6ab3c094a84a169c4fa4c48523c0ba', + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + { + id: '009a1f293253e41e', + amount: 2, + secret: + 'abc123def456789abcdef0123456789abcdef0123456789abcdef0123456789ab', + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ success: true }); + }); + }); + + describe('P2PK Conditions', () => { + // Test vector: Valid P2PK condition with correct signature + test('should validate P2PK token with correct private key', () => { + // Known private key for testing + const privateKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + const expectedPubkey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; // Pubkey for private key 0x01 + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: `["P2PK",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"${expectedPubkey}"}]`, + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const unlockingData: UnlockingData = { + kind: 'P2PK', + signingKeys: [privateKey], + }; + + const result = validateTokenSpendingConditions(token, unlockingData); + expect(result).toEqual({ success: true }); + }); + + test('should reject P2PK token with incorrect private key', () => { + const wrongPrivateKey = + '0000000000000000000000000000000000000000000000000000000000000002'; + const expectedPubkey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; // Pubkey for private key 0x01 + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: `["P2PK",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"${expectedPubkey}"}]`, + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const unlockingData: UnlockingData = { + kind: 'P2PK', + signingKeys: [wrongPrivateKey], + }; + + const result = validateTokenSpendingConditions(token, unlockingData); + expect(result).toEqual({ + success: false, + error: 'Provided signing key does not match required public key', + }); + }); + + test('should reject P2PK token without unlocking data', () => { + const expectedPubkey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: `["P2PK",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"${expectedPubkey}"}]`, + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ + success: false, + error: 'P2PK spending condition requires unlocking data', + }); + }); + + test('should reject P2PK token with empty signing keys', () => { + const expectedPubkey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: `["P2PK",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"${expectedPubkey}"}]`, + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const unlockingData: UnlockingData = { + kind: 'P2PK', + signingKeys: [], + }; + + const result = validateTokenSpendingConditions(token, unlockingData); + expect(result).toEqual({ + success: false, + error: 'P2PK unlocking data must provide signing keys', + }); + }); + + test('should reject P2PK token with wrong unlocking data kind', () => { + const expectedPubkey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: `["P2PK",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"${expectedPubkey}"}]`, + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const unlockingData: UnlockingData = { + kind: 'HTLC', + preimages: ['test'], + }; + + const result = validateTokenSpendingConditions(token, unlockingData); + expect(result).toEqual({ + success: false, + error: 'Expected P2PK unlocking data, got HTLC', + }); + }); + }); + + describe('Timelock Conditions', () => { + test('should validate P2PK token with expired timelock and no refund keys', () => { + const expiredTimestamp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const expectedPubkey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: `["P2PK",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"${expectedPubkey}","tags":[["locktime","${expiredTimestamp}"]]}]`, + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ success: true }); + }); + + test('should reject P2PK token with expired timelock but with refund keys', () => { + const expiredTimestamp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const expectedPubkey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const refundKey = + '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0'; + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: `["P2PK",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"${expectedPubkey}","tags":[["locktime","${expiredTimestamp}"],["refund","${refundKey}"]]}]`, + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ + success: false, + error: 'P2PK spending condition requires unlocking data', + }); + }); + + test('should require unlocking data for non-expired timelock', () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + const privateKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + const expectedPubkey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: `["P2PK",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"${expectedPubkey}","tags":[["locktime","${futureTimestamp}"]]}]`, + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + // Without unlocking data, should fail + const resultWithoutUnlocking = validateTokenSpendingConditions(token); + expect(resultWithoutUnlocking).toEqual({ + success: false, + error: 'P2PK spending condition requires unlocking data', + }); + + // With correct unlocking data, should succeed + const unlockingData: UnlockingData = { + kind: 'P2PK', + signingKeys: [privateKey], + }; + + const resultWithUnlocking = validateTokenSpendingConditions( + token, + unlockingData, + ); + expect(resultWithUnlocking).toEqual({ success: true }); + }); + + test('should handle invalid locktime tag format', () => { + const expectedPubkey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: `["P2PK",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"${expectedPubkey}","tags":[["locktime","invalid_timestamp"]]}]`, + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ + success: false, + error: 'P2PK spending condition requires unlocking data', + }); + }); + }); + + describe('Unsupported Conditions', () => { + test('should reject HTLC tokens', () => { + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: + '["HTLC",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"}]', + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ + success: false, + error: "Spending condition 'HTLC' is not currently supported", + }); + }); + + test('should reject tokens with unknown spending conditions', () => { + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: + '["UNKNOWN",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"somedata"}]', + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ + success: false, + error: 'Invalid secret format', + }); + }); + }); + + describe('Invalid Secret Formats', () => { + test('should reject malformed JSON secrets', () => { + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: '["P2PK",{invalid json}]', // Malformed JSON + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ + success: false, + error: 'Invalid secret', + }); + }); + + test('should reject non-hex plain secrets', () => { + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: 'not_valid_hex_string!@#', // Contains non-hex characters + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ + success: false, + error: 'Invalid secret', + }); + }); + + test('should reject empty secrets', () => { + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: '', // Empty secret + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const result = validateTokenSpendingConditions(token); + expect(result).toEqual({ + success: false, + error: 'Invalid secret', + }); + }); + }); + + describe('Mixed Token Conditions', () => { + test('should validate token with mix of plain and valid P2PK proofs', () => { + const privateKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + const expectedPubkey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: + 'daf4dd00814ac2dc6cd2c5f8b8ba9bc57b6ab3c094a84a169c4fa4c48523c0ba', // Plain secret + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + { + id: '009a1f293253e41f', + amount: 2, + secret: `["P2PK",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"${expectedPubkey}"}]`, + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const unlockingData: UnlockingData = { + kind: 'P2PK', + signingKeys: [privateKey], + }; + + const result = validateTokenSpendingConditions(token, unlockingData); + expect(result).toEqual({ success: true }); + }); + + test('should reject token if any proof is invalid', () => { + const privateKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + + const token: Token = { + mint: 'https://testnut.cashu.space', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: + 'daf4dd00814ac2dc6cd2c5f8b8ba9bc57b6ab3c094a84a169c4fa4c48523c0ba', // Valid plain secret + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + { + id: '009a1f293253e41f', + amount: 2, + secret: + '["HTLC",{"nonce":"c00000000000000000000000000000000000000000000000000000000000000000000000000000000000","data":"somedata"}]', // Unsupported condition + C: '02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0', + }, + ], + }; + + const unlockingData: UnlockingData = { + kind: 'P2PK', + signingKeys: [privateKey], + }; + + const result = validateTokenSpendingConditions(token, unlockingData); + expect(result).toEqual({ + success: false, + error: "Spending condition 'HTLC' is not currently supported", + }); + }); + }); +}); diff --git a/app/lib/cashu/spending-conditions-validation.ts b/app/lib/cashu/spending-conditions-validation.ts new file mode 100644 index 000000000..c6976a72c --- /dev/null +++ b/app/lib/cashu/spending-conditions-validation.ts @@ -0,0 +1,176 @@ +import type { Token } from '@cashu/cashu-ts'; +import { getPublicKeyFromPrivateKey } from '../secp256k1'; +import { parseSecret } from './secret'; +import type { UnlockingData } from './types'; + +/** + * Result of validating token spending conditions. + * True if the token is spendable with the given unlocking data. + * False if the token cannot be spent with the given unlocking data. + */ +export type SpendingConditionsValidationResult = + | { success: true } + | { success: false; error: string }; + +/** + * Parse timelock value from NUT-10 secret tags + * @param tags - The tags array from a NUT-10 secret + * @returns Unix timestamp if locktime tag is found, null otherwise + */ +const parseLocktimeFromTags = (tags?: string[][]): number | null => { + if (!tags) return null; + + const locktimeTag = tags.find((tag) => tag[0] === 'locktime'); + if (!locktimeTag || locktimeTag.length < 2) return null; + + const timestamp = Number.parseInt(locktimeTag[1], 10); + return Number.isNaN(timestamp) ? null : timestamp; +}; + +/** + * Parse refund keys from NUT-10 secret tags + * @param tags - The tags array from a NUT-10 secret + * @returns Array of refund keys if refund tags are found, empty array otherwise + */ +const parseRefundKeysFromTags = (tags?: string[][]): string[] => { + if (!tags) return []; + + return tags + .filter((tag) => tag[0] === 'refund' && tag.length >= 2) + .map((tag) => tag[1]); +}; + +/** + * Check if a timelock has expired + * @param locktime - Unix timestamp of the lock expiration + * @returns True if the current time is past the locktime + */ +const isTimelockExpired = (locktime: number): boolean => { + const currentTime = Math.floor(Date.now() / 1000); + return currentTime > locktime; +}; + +/** + * Validate P2PK spending conditions with unlocking data + * @param requiredPubkey - The public key required by the spending condition + * @param unlockingData - The unlocking data provided by the spender + * @returns Validation result + */ +const validateP2PKCondition = ( + requiredPubkey: string, + unlockingData?: UnlockingData, +): SpendingConditionsValidationResult => { + if (!unlockingData) { + return { + success: false, + error: 'P2PK spending condition requires unlocking data', + }; + } + + if (unlockingData.kind !== 'P2PK') { + return { + success: false, + error: `Expected P2PK unlocking data, got ${unlockingData.kind}`, + }; + } + + if (!unlockingData.signingKeys?.length) { + return { + success: false, + error: 'P2PK unlocking data must provide signing keys', + }; + } + + const hasValidKey = unlockingData.signingKeys.some(() => { + const pubkey = getPublicKeyFromPrivateKey(unlockingData.signingKeys[0], { + asBytes: false, + }); + return pubkey === requiredPubkey; + }); + + if (!hasValidKey) { + return { + success: false, + error: 'Provided signing key does not match required public key', + }; + } + + return { success: true }; +}; + +/** + * Validate spending conditions for a single proof's secret + * @param secret - The proof's secret string + * @param unlockingData - Optional unlocking data for conditional spending + * @returns Validation result + */ +const validateProofSpendingConditions = ( + secret: string, + unlockingData?: UnlockingData, +): SpendingConditionsValidationResult => { + const parsedSecret = parseSecret(secret); + if (!parsedSecret.success) { + return { + success: false, + error: parsedSecret.error, + }; + } + + // Plain secrets are always spendable + if (parsedSecret.data.type === 'plain') { + return { success: true }; + } + + const nut10Secret = parsedSecret.data.secret; + + // Handle P2PK conditions + if (nut10Secret.kind === 'P2PK') { + const locktime = parseLocktimeFromTags(nut10Secret.tags); + const refundKeys = parseRefundKeysFromTags(nut10Secret.tags); + + // If there's a locktime and it has expired with no refund keys, it's spendable + if ( + locktime !== null && + isTimelockExpired(locktime) && + refundKeys.length === 0 + ) { + return { success: true }; + } + + // Otherwise, validate P2PK condition + return validateP2PKCondition(nut10Secret.data, unlockingData); + } + + return { + success: false, + error: `Spending condition '${nut10Secret.kind}' is not currently supported`, + }; +}; + +/** + * Validate spending conditions for a Cashu token. + * + * Validates the following conditions: + * - Plain secrets: Always valid + * - P2PK with expired timelock and no refund keys: Valid + * - P2PK with valid unlocking data: Valid if public key matches + * - Other conditions: Invalid (not supported) + * + * @param token - The Cashu token to validate + * @param unlockingData - Optional unlocking data for conditional spending + * @returns Validation result indicating if token is spendable and why + */ +export const validateTokenSpendingConditions = ( + token: Token, + unlockingData?: UnlockingData, +): SpendingConditionsValidationResult => { + // Validate each proof in the token + for (const proof of token.proofs) { + const result = validateProofSpendingConditions(proof.secret, unlockingData); + if (!result.success) { + return result; + } + } + + return { success: true }; +}; diff --git a/app/lib/cashu/types.ts b/app/lib/cashu/types.ts index 5ee7ffea0..24ace3ba6 100644 --- a/app/lib/cashu/types.ts +++ b/app/lib/cashu/types.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; * Applications need to make sure to check whether the mint supports a specific kind of * spending condition by checking the mint's info endpoint. */ -export const WELL_KNOWN_SECRET_KINDS = ['P2PK'] as const; +export const WELL_KNOWN_SECRET_KINDS = ['P2PK', 'HTLC'] as const; const WellKnownSecretKindSchema = z.enum(WELL_KNOWN_SECRET_KINDS); @@ -140,6 +140,283 @@ export type ProofSecret = */ export type P2PKSecret = NUT10Secret & { kind: 'P2PK' }; +const AdditionalP2PKConditionsSchema = z.object({ + /** + * Unix timestamp after which refund keys can be used to spend the proof + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK locktime + * @example 1672531200 // January 1, 2023 00:00:00 UTC + */ + locktime: z.number().optional(), + /** + * Additional public keys for multi-signature scenarios (33-byte hex strings) + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK multi-sig + * @example ["0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7"] + */ + pubkeys: z.array(z.string()).optional(), + /** + * Refund public keys that can exclusively spend after locktime expires (33-byte hex strings) + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK refund mechanism + * @example ["02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0"] + */ + refundKeys: z.array(z.string()).optional(), + /** + * Minimum number of valid signatures required for multi-sig scenarios + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK threshold signatures + * @example 2 // Requires 2 out of n signatures + */ + requiredSigs: z.number().optional(), + /** + * Determines what part of the transaction must be signed + * - SIG_INPUTS: Only inputs need to be signed + * - SIG_ALL: Both inputs and outputs need to be signed + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK signature flags + * @example "SIG_INPUTS" + */ + sigFlag: z.enum(['SIG_INPUTS', 'SIG_ALL']).optional(), +}); + +/** + * Additional P2PK conditions that can be applied to spending conditions. + * These conditions extend the basic P2PK functionality with features like: + * - Multi-signature requirements + * - Time-based refund mechanisms + * - Signature flags + * + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK specification + */ +export type AdditionalP2PKConditions = z.infer< + typeof AdditionalP2PKConditionsSchema +>; + +const P2PKSpendingConditionDataSchema = z.object({ + /** + * Well-known secret kind for Pay-to-Public-Key spending conditions + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK specification + */ + kind: z.literal('P2PK'), + /** + * 33-byte hex-encoded public key of the recipient who can spend this proof. + * Only the holder of the corresponding private key can create valid signatures to unlock this proof. + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK data format + * @example "0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7" + */ + data: z.string(), + /** + * Additional optional conditions that extend P2PK functionality. + * Can include multi-sig requirements, timelocks, and refund mechanisms. + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK conditions + */ + conditions: AdditionalP2PKConditionsSchema.nullable(), +}); + +const HTLCSpendingConditionDataSchema = z.object({ + /** + * Well-known secret kind for Hash Time Lock Contract spending conditions + * @see https://github.com/cashubtc/nuts/blob/main/14.md for NUT-14 HTLC specification + */ + kind: z.literal('HTLC'), + /** + * SHA256 hash of the preimage that must be revealed to unlock this proof. + * The spender must provide the preimage that hashes to this value. + * @see https://github.com/cashubtc/nuts/blob/main/14.md for NUT-14 HTLC hash locks + * @example "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" // SHA256 of "hello" + */ + data: z.string(), + /** + * Additional optional conditions that can be applied to HTLCs. + * These include the same conditions as P2PK: multi-sig, timelocks, refund mechanisms. + * @see https://github.com/cashubtc/nuts/blob/main/14.md for NUT-14 HTLC conditions + */ + conditions: AdditionalP2PKConditionsSchema.nullable(), +}); + +export const SpendingConditionDataSchema = z.union([ + P2PKSpendingConditionDataSchema, + HTLCSpendingConditionDataSchema, +]); + +/** + * P2PK spending condition data for locking proofs to a specific public key. + * This creates proofs that can only be spent by providing a valid signature + * from the corresponding private key. + * + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK specification + * + * @example + * ```typescript + * const p2pkCondition: P2PKSpendingConditionData = { + * kind: 'P2PK', + * data: '0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7', + * conditions: { + * locktime: 1672531200, + * requiredSigs: 1, + * sigFlag: 'SIG_INPUTS' + * } + * }; + * ``` + */ +export type P2PKSpendingConditionData = z.infer< + typeof P2PKSpendingConditionDataSchema +>; + +/** + * HTLC spending condition data for creating hash time lock contracts. + * This creates proofs that can be spent by revealing a preimage or + * by refund keys after a timelock expires. + * + * @see https://github.com/cashubtc/nuts/blob/main/14.md for NUT-14 HTLC specification + * + * @example + * ```typescript + * const htlcCondition: HTLCSpendingConditionData = { + * kind: 'HTLC', + * data: 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', + * conditions: { + * locktime: 1672531200, + * refundKeys: ['02c020067db727d586bc3183ed7d04a5f7d2f25329b2f825a38292e2e28d47a59b0'] + * } + * }; + * ``` + */ +export type HTLCSpendingConditionData = z.infer< + typeof HTLCSpendingConditionDataSchema +>; + +/** + * Union type for all supported spending condition data types. + * Currently supports P2PK and HTLC spending conditions as defined in NUTs 11 and 14. + * + * @see https://github.com/cashubtc/nuts/blob/main/10.md for NUT-10 spending conditions overview + */ +export type SpendingConditionData = z.infer; + +/** + * P2PK unlocking data schema for claiming P2PK proofs. + * Contains the witness data needed to satisfy P2PK spending conditions. + * + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK unlocking + */ +const P2PKUnlockingDataSchema = z.object({ + /** + * Unlocking data kind matching the spending condition type + */ + kind: z.literal('P2PK'), + /** + * Private keys used to create signatures for unlocking P2PK proofs (33-byte hex). + * Each key corresponds to a public key in the spending condition. + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK signature requirements + * @example ["1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"] + */ + signingKeys: z.array(z.string()), + /** + * Additional signatures required for multi-signature scenarios. + * Used when conditions specify multiple pubkeys or threshold signatures. + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK multi-sig unlocking + * @example ["3045022100...", "3044022000..."] // DER-encoded signatures + */ + additionalSignatures: z.array(z.string()).optional(), +}); + +/** + * HTLC unlocking data schema for claiming HTLC proofs. + * Contains witness data for both preimage-based and refund-based unlocking. + * + * @see https://github.com/cashubtc/nuts/blob/main/14.md for NUT-14 HTLC unlocking + */ +const HTLCUnlockingDataSchema = z.object({ + /** + * Unlocking data kind matching the spending condition type + */ + kind: z.literal('HTLC'), + /** + * Preimages that hash to the values specified in HTLC spending conditions. + * Required for the primary unlock path (before locktime expiration). + * @see https://github.com/cashubtc/nuts/blob/main/14.md for NUT-14 HTLC preimage unlock + * @example ["hello"] // String that when SHA256 hashed matches the HTLC data + */ + preimages: z.array(z.string()), + /** + * Signatures required along with preimages for the main unlock path. + * Used when conditions require both preimage revelation and signature verification. + * @see https://github.com/cashubtc/nuts/blob/main/14.md for NUT-14 HTLC signature requirements + * @example ["3045022100...", "3044022000..."] // DER-encoded signatures + */ + preimageSignatures: z.array(z.string()).optional(), + /** + * Signatures from refund keys used after locktime expires. + * Enables the refund path when the primary unlock conditions cannot be met. + * @see https://github.com/cashubtc/nuts/blob/main/14.md for NUT-14 HTLC refund mechanism + * @example ["3045022100...", "3044022000..."] // DER-encoded signatures from refund keys + */ + refundSignatures: z.array(z.string()).optional(), +}); + +/** + * Union schema for all unlocking data types. + * Supports witness data for P2PK and HTLC spending conditions. + * + * @see https://github.com/cashubtc/nuts/blob/main/10.md for NUT-10 witness data overview + */ +export const UnlockingDataSchema = z.union([ + P2PKUnlockingDataSchema, + HTLCUnlockingDataSchema, +]); + +/** + * P2PK unlocking data for claiming proofs locked to public keys. + * Contains the private keys and signatures needed to satisfy P2PK spending conditions. + * + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK unlocking specification + * + * @example + * ```typescript + * const p2pkUnlock: P2PKUnlockingData = { + * kind: 'P2PK', + * signingKeys: ['1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12'], + * additionalSignatures: ['3045022100...'] // For multi-sig scenarios + * }; + * ``` + */ +export type P2PKUnlockingData = z.infer; + +/** + * HTLC unlocking data for claiming hash time lock contract proofs. + * Contains preimages and signatures for both primary and refund unlock paths. + * + * @see https://github.com/cashubtc/nuts/blob/main/14.md for NUT-14 HTLC unlocking specification + * + * @example + * ```typescript + * // Primary unlock path (with preimage) + * const htlcUnlock: HTLCUnlockingData = { + * kind: 'HTLC', + * preimages: ['hello'], // Preimage that hashes to HTLC condition + * preimageSignatures: ['3045022100...'] // If signature also required + * }; + * + * // Refund path (after locktime) + * const htlcRefund: HTLCUnlockingData = { + * kind: 'HTLC', + * preimages: [], // Empty for refund path + * refundSignatures: ['3045022100...'] // Signatures from refund keys + * }; + * ``` + */ +export type HTLCUnlockingData = z.infer; + +/** + * Union type for all unlocking data needed to claim/spend proofs with spending conditions. + * This witness data proves that the spender satisfies the conditions specified in the proof's secret. + * + * The mint validates this unlocking data against the spending conditions before authorizing + * the transaction. Each proof in a transaction must provide valid unlocking data. + * + * @see https://github.com/cashubtc/nuts/blob/main/10.md for NUT-10 spending conditions and witness data + * @see https://github.com/cashubtc/nuts/blob/main/11.md for NUT-11 P2PK unlocking + * @see https://github.com/cashubtc/nuts/blob/main/14.md for NUT-14 HTLC unlocking + */ +export type UnlockingData = z.infer; + /** * A class that represents the data fetched from the mint's * [NUT-06 info endpoint](https://github.com/cashubtc/nuts/blob/main/06.md) diff --git a/app/lib/secp256k1.ts b/app/lib/secp256k1.ts new file mode 100644 index 000000000..c11e66d3c --- /dev/null +++ b/app/lib/secp256k1.ts @@ -0,0 +1,59 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { bytesToHex } from '@noble/hashes/utils'; + +/** + * Generate a random secp256k1 key pair. + * + * - When `asBytes` is `true`, returns raw `Uint8Array` keys. + * - Otherwise returns hex-encoded string keys. + */ +export function generateRandomKeyPair({ asBytes }: { asBytes: true }): { + privateKey: Uint8Array; + publicKey: Uint8Array; +}; +export function generateRandomKeyPair({ asBytes }: { asBytes: false }): { + privateKey: string; + publicKey: string; +}; +export function generateRandomKeyPair({ asBytes }: { asBytes: boolean }) { + const { secretKey, publicKey } = secp256k1.keygen(); + + if (asBytes) { + return { privateKey: secretKey, publicKey }; + } + + return { + privateKey: bytesToHex(secretKey), + publicKey: bytesToHex(publicKey), + }; +} + +/** + * Get the compressed public key from a private key. + * + * @param privateKey The secp256k1 private key to get the public key from. + * @param asBytes Whether to return the public key as bytes or hex-encoded string. + * @returns The compressed public key in the specified format. + */ +export function getPublicKeyFromPrivateKey( + privateKey: string | Uint8Array, + { asBytes }: { asBytes: true }, +): Uint8Array; +export function getPublicKeyFromPrivateKey( + privateKey: string | Uint8Array, + { asBytes }: { asBytes: false }, +): string; +export function getPublicKeyFromPrivateKey( + privateKey: string | Uint8Array, + { asBytes }: { asBytes: boolean }, +): Uint8Array | string { + const isCompressed = true; + const publicKey = secp256k1.getPublicKey( + typeof privateKey === 'string' ? privateKey : bytesToHex(privateKey), + isCompressed, + ); + if (asBytes) { + return publicKey; + } + return bytesToHex(publicKey); +} diff --git a/app/routes/_protected.demo.tsx b/app/routes/_protected.demo.tsx new file mode 100644 index 000000000..98224beb1 --- /dev/null +++ b/app/routes/_protected.demo.tsx @@ -0,0 +1,1308 @@ +import type { Proof, Token } from '@cashu/cashu-ts'; +import { getEncodedToken } from '@cashu/cashu-ts'; +import { ChevronDown, ChevronRight, Copy } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { + ClosePageButton, + Page, + PageContent, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { Button } from '~/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '~/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '~/components/ui/dialog'; +import { Input } from '~/components/ui/input'; +import { Label } from '~/components/ui/label'; +import { useDefaultAccount } from '~/features/accounts/account-hooks'; +import { useCreateCashuTokenSwap } from '~/features/receive/cashu-token-swap-hooks'; +import { + useCreateCashuSendSwap, + useTrackCashuSendSwap, +} from '~/features/send/cashu-send-swap-hooks'; +import { + UniqueConstraintError, + getErrorMessage, +} from '~/features/shared/error'; +import { useToast } from '~/hooks/use-toast'; +import { parseSecret } from '~/lib/cashu/secret'; +import type { + P2PKSpendingConditionData, + P2PKUnlockingData, +} from '~/lib/cashu/types'; +import { safeJsonParse } from '~/lib/json'; +import { Money } from '~/lib/money'; +import { generateRandomKeyPair } from '~/lib/secp256k1'; + +const RESET_DELAY = 2000; + +/** + * Hook for managing demo state + */ +function useDemoState() { + const [step, setStep] = useState<'setup' | 'created' | 'unlocked'>('setup'); + const [amount, setAmount] = useState('10'); + const [privateKey, setPrivateKey] = useState(''); + const [publicKey, setPublicKey] = useState(''); + const [locktime, setLocktime] = useState(''); + const [enableTimelock, setEnableTimelock] = useState(false); + const [createdSwapId, setCreatedSwapId] = useState(''); + const [encodedToken, setEncodedToken] = useState(''); + const [tokenToUnlock, setTokenToUnlock] = useState(null); + const [showInspector, setShowInspector] = useState(false); + + const resetState = () => { + setStep('setup'); + setAmount('10'); + setPrivateKey(''); + setPublicKey(''); + setLocktime(''); + setEnableTimelock(false); + setCreatedSwapId(''); + setEncodedToken(''); + setTokenToUnlock(null); + setShowInspector(false); + }; + + return { + step, + setStep, + amount, + setAmount, + privateKey, + setPrivateKey, + publicKey, + setPublicKey, + locktime, + setLocktime, + enableTimelock, + setEnableTimelock, + createdSwapId, + setCreatedSwapId, + encodedToken, + setEncodedToken, + tokenToUnlock, + setTokenToUnlock, + showInspector, + setShowInspector, + resetState, + }; +} + +/** + * Hook for timelock countdown functionality + */ +function useTimelockCountdown( + enableTimelock: boolean, + locktime: string, + step: string, +) { + const [timeRemaining, setTimeRemaining] = useState(null); + const [isTimelockExpired, setIsTimelockExpired] = useState(false); + + useEffect(() => { + if (!enableTimelock || !locktime || step !== 'created') { + setTimeRemaining(null); + setIsTimelockExpired(false); + return; + } + + const targetTime = new Date(locktime).getTime(); + + const updateCountdown = () => { + const now = Date.now(); + const remaining = targetTime - now; + + if (remaining <= 0) { + setTimeRemaining(0); + setIsTimelockExpired(true); + } else { + setTimeRemaining(remaining); + setIsTimelockExpired(false); + } + }; + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + return () => clearInterval(interval); + }, [enableTimelock, locktime, step]); + + return { timeRemaining, isTimelockExpired }; +} + +/** + * Hook for utility formatting functions + */ +function useDemoFormatters() { + const toLocalDateTimeString = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + }; + + const formatCountdown = (milliseconds: number): string => { + if (milliseconds <= 0) return 'Expired'; + + const seconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days}d ${hours % 24}h ${minutes % 60}m ${seconds % 60}s`; + } + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s`; + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; + }; + + return { toLocalDateTimeString, formatCountdown }; +} + +/** + * Hook for demo actions and business logic + */ +function useDemoActions( + state: ReturnType, + account: ReturnType, + isTimelockExpired: boolean, + toLocalDateTimeString: (date: Date) => string, +) { + const { toast } = useToast(); + + const { mutateAsync: createCashuSendSwap, isPending: isCreating } = + useCreateCashuSendSwap({ + onSuccess: (swap) => { + state.setCreatedSwapId(swap.id); + }, + onError: (error) => { + console.error('Failed to create send swap:', error); + toast({ + title: 'Failed to create locked token', + description: error.message, + variant: 'destructive', + }); + }, + }); + + const { mutateAsync: createCashuTokenSwap, isPending: isUnlocking } = + useCreateCashuTokenSwap(); + + const { swap: trackedSwap } = useTrackCashuSendSwap({ + id: state.createdSwapId, + onPending: (swap) => { + if (swap.state === 'PENDING' && 'proofsToSend' in swap) { + const token: Token = { + mint: account.type === 'cashu' ? account.mintUrl : '', + proofs: swap.proofsToSend, + unit: account.currency === 'BTC' ? 'sat' : 'usd', + }; + state.setTokenToUnlock(token); + state.setEncodedToken(getEncodedToken(token)); + state.setStep('created'); + } + }, + onCompleted: () => { + state.setStep('unlocked'); + + // Auto reset after 2 seconds + setTimeout(() => { + state.resetState(); + }, RESET_DELAY); + }, + }); + + const handleGenerateKeypair = () => { + const { privateKey: priv, publicKey: pub } = generateRandomKeyPair({ + asBytes: false, + }); + state.setPrivateKey(priv); + state.setPublicKey(pub); + }; + + const handleCreateToken = async () => { + if (!state.publicKey) { + toast({ + title: 'Missing Public Key', + description: 'Please generate a keypair first', + variant: 'destructive', + }); + return; + } + + const amountMoney = new Money({ + amount: Number.parseFloat(state.amount), + currency: account.currency, + unit: account.currency === 'BTC' ? 'sat' : 'usd', + }); + + const conditions = + state.enableTimelock && state.locktime + ? { + locktime: Math.floor(new Date(state.locktime).getTime() / 1000), + } + : null; + + const spendingConditionData: P2PKSpendingConditionData = { + kind: 'P2PK', + data: state.publicKey, + conditions, + }; + + await createCashuSendSwap({ + amount: amountMoney, + accountId: account.id, + spendingConditionData, + }); + }; + + const handleUnlockToken = async () => { + if (!state.tokenToUnlock) { + toast({ + title: 'Missing Data', + description: 'Token not available', + variant: 'destructive', + }); + return; + } + + try { + const unlockingData: P2PKUnlockingData = { + kind: 'P2PK', + signingKeys: [state.privateKey], + }; + + await createCashuTokenSwap({ + token: state.tokenToUnlock, + accountId: account.id, + unlockingData, + }); + } catch (error) { + if (isTimelockExpired) { + console.warn('Timelock expired:', { cause: error }); + } else { + console.error('Failed to unlock token:', error); + } + + const toastOptions = { + title: 'Failed to unlock token', + description: + error instanceof UniqueConstraintError + ? 'We have a bug where if you try to claim a token with invalid unlocking data, then you can never try again.. Refresh :)' + : getErrorMessage(error), + variant: 'destructive' as const, + duration: 5000, + }; + toast(toastOptions); + } + }; + + const handleUnlockWithTimelock = async () => { + if (!state.tokenToUnlock) { + toast({ + title: 'Missing Token', + description: 'Token not available', + variant: 'destructive', + }); + return; + } + + try { + await createCashuTokenSwap({ + token: state.tokenToUnlock, + accountId: account.id, + }); + + if (!isTimelockExpired) { + toast({ + title: 'Timelock not expired', + description: 'Token has been unlocked with your private key', + }); + } + } catch (error) { + console.error('Failed to unlock token with timelock:', { cause: error }); + const toastOptions = { + title: 'Failed to unlock token', + description: + error instanceof UniqueConstraintError + ? 'We have a bug where if you try to claim a token with invalid unlocking data, then you can never try again.. Refresh :)' + : getErrorMessage(error), + variant: 'destructive' as const, + duration: 5000, + }; + toast(toastOptions); + } + }; + + const handleToggleTimelock = (e: React.ChangeEvent) => { + const checked = e.target.checked; + state.setEnableTimelock(checked); + if (checked) { + const futureTime = new Date(); + futureTime.setMinutes(futureTime.getMinutes() + 1); + state.setLocktime(toLocalDateTimeString(futureTime)); + } else { + state.setLocktime(''); + } + }; + + return { + handleGenerateKeypair, + handleCreateToken, + handleUnlockToken, + handleUnlockWithTimelock, + handleToggleTimelock, + isCreating, + isUnlocking, + trackedSwap, + }; +} + +type CollapsibleState = Record; + +/** + * JSON formatter component with syntax highlighting and collapsible sections + */ +function CollapsibleJsonFormatter({ + json, + indent = 0, + path = '', + collapsedState, + onToggle, +}: { + json: unknown; + indent?: number; + path?: string; + collapsedState: CollapsibleState; + onToggle: (path: string) => void; +}) { + const indentStr = ' '.repeat(indent); + const isCollapsed = collapsedState[path] ?? false; + + if (json === null) { + return null; + } + + if (typeof json === 'string') { + // Check if this string might be a NUT-10 secret (JSON string) + if (json.startsWith('[') && json.endsWith(']')) { + const parsedJson = safeJsonParse(json); + if (parsedJson.success && Array.isArray(parsedJson.data)) { + // This looks like a NUT-10 secret, display it as formatted JSON + return ( + + ); + } + } + + // Color specific fields differently + const isAmount = !Number.isNaN(Number(json)) && json.length < 10; + const isId = json.length === 16 && /^[a-fA-F0-9]+$/.test(json); + const isPubKey = json.length === 66 && json.startsWith('02'); + const isSecret = json.includes('[') || json.length > 20; + const isUrl = json.startsWith('http'); + + let className = 'text-green-400'; // default string color + if (isAmount) className = 'text-yellow-400'; + else if (isId) className = 'text-purple-400'; + else if (isPubKey) className = 'text-blue-400'; + else if (isSecret) className = 'text-orange-400'; + else if (isUrl) className = 'text-cyan-400'; + + return ( + <> + " + {json} + " + + ); + } + + if (typeof json === 'number') { + return {json}; + } + + if (typeof json === 'boolean') { + return {json.toString()}; + } + + if (Array.isArray(json)) { + if (json.length === 0) { + return []; + } + + const isProofsArray = path.includes('proofs'); + + return ( + <> + [ + {isProofsArray ? ( + + ) : null} + {!isCollapsed && ( + <> + {json.map((item, index) => ( + +
+ {indentStr}{' '} + + {index < json.length - 1 && ( + , + )} +
+ ))} +
+ {indentStr} + + )} + ] + + ); + } + + if (typeof json === 'object') { + const keys = Object.keys(json); + if (keys.length === 0) { + return {'{}'}; + } + + const isProofObject = + path.includes('[') && + keys.some((k) => ['amount', 'C', 'secret', 'id'].includes(k)); + + return ( + <> + {'{'} + {isProofObject ? ( + + ) : null} + {!isCollapsed && ( + <> + {keys.map((key, index) => { + const propertyPath = `${path}.${key}`; + const isCollapsibleProperty = ['dleq', 'secret'].includes(key); + const isPropertyCollapsed = collapsedState[propertyPath] ?? false; + + return ( + +
+ {indentStr} "{key}" + : + {isCollapsibleProperty ? ( + <> + + {!isPropertyCollapsed && ( + <> +
+ {indentStr}{' '} + )[key]} + indent={indent + 1} + path={propertyPath} + collapsedState={collapsedState} + onToggle={onToggle} + /> + + )} + + ) : ( + )[key]} + indent={indent + 1} + path={propertyPath} + collapsedState={collapsedState} + onToggle={onToggle} + /> + )} + {index < keys.length - 1 && ( + , + )} +
+ ); + })} +
+ {indentStr} + + )} + {'}'} + + ); + } + + return {String(json)}; +} + +/** + * TokenJson component for rendering collapsible token JSON + */ +function TokenJson({ + token, + initialCollapsed = false, +}: { + token: Token; + initialCollapsed?: boolean; +}) { + const { toast } = useToast(); + + // Initialize collapsed state - proofs are collapsed by default + const initializeCollapsedState = useCallback((): CollapsibleState => { + const state: CollapsibleState = {}; + + // Collapse proofs array by default + state.proofs = initialCollapsed; + + // Collapse individual proof objects by default + token.proofs.forEach((_, index) => { + state[`proofs[${index}]`] = initialCollapsed; + + // Collapse dleq and secret properties by default + state[`proofs[${index}].dleq`] = true; + state[`proofs[${index}].secret`] = true; + }); + + return state; + }, [token.proofs, initialCollapsed]); + + const [collapsedState, setCollapsedState] = useState( + initializeCollapsedState, + ); + + const handleToggle = useCallback((path: string) => { + setCollapsedState((prev) => ({ + ...prev, + [path]: !prev[path], + })); + }, []); + + const handleCopyJson = () => { + navigator.clipboard.writeText(JSON.stringify(token, null, 2)); + toast({ + title: 'JSON copied to clipboard', + duration: 1000, + }); + }; + + const handleExpandAll = () => { + const newState: CollapsibleState = {}; + + // Expand proofs array + newState.proofs = false; + + // Expand all individual proofs and their properties + token.proofs.forEach((_, index) => { + newState[`proofs[${index}]`] = false; + newState[`proofs[${index}].dleq`] = false; + newState[`proofs[${index}].secret`] = false; + }); + + setCollapsedState(newState); + }; + + const handleCollapseAll = () => { + const newState: CollapsibleState = {}; + + // Collapse proofs array + newState.proofs = true; + + // Collapse all individual proofs and their properties + token.proofs.forEach((_, index) => { + newState[`proofs[${index}]`] = true; + newState[`proofs[${index}].dleq`] = true; + newState[`proofs[${index}].secret`] = true; + }); + + setCollapsedState(newState); + }; + + return ( +
+
+ Token JSON: +
+ + + +
+
+ +
+
+          
+            
+          
+        
+
+
+ ); +} + +export default function Demo() { + const account = useDefaultAccount(); + + const state = useDemoState(); + const { timeRemaining, isTimelockExpired } = useTimelockCountdown( + state.enableTimelock, + state.locktime, + state.step, + ); + const { toLocalDateTimeString, formatCountdown } = useDemoFormatters(); + const { + handleGenerateKeypair, + handleCreateToken, + handleUnlockToken, + handleUnlockWithTimelock, + handleToggleTimelock, + isCreating, + isUnlocking, + trackedSwap, + } = useDemoActions(state, account, isTimelockExpired, toLocalDateTimeString); + + return ( + + + + P2PK Demo + + + {/* Step 1: Setup */} + {state.step === 'setup' && ( + <> + + + Step 1: Generate Keypair + + Generate a secp256k1 keypair for P2PK locking + + + + + + {state.publicKey && ( +
+
+ +
+ {state.publicKey} +
+
+ +
+ +
+ {state.privateKey} +
+
+
+ )} +
+
+ + + + Step 2: Configure Token + + Set amount and optional timelock + + + +
+ + state.setAmount(e.target.value)} + placeholder="10" + min="1" + /> +
+ +
+
+ + +
+ + {state.enableTimelock && ( +
+ + state.setLocktime(e.target.value)} + min={toLocalDateTimeString(new Date())} + /> + {state.locktime && ( +

+ Token will be locked until:{' '} + {new Date(state.locktime).toLocaleString()} +

+ )} +
+ )} +
+ + +
+
+ + )} + {/* Step 2: Token Created */} + {state.step === 'created' && ( + + + βœ… P2PK Token Created! + + Your token has been locked with P2PK conditions + + + + {/* Token Details */} + {trackedSwap && ( +
+

Token Details

+
+
+ Amount: + + {state.amount}{' '} + {account.currency === 'BTC' ? 'sats' : 'USD'} + +
+
+ + Locked with: + + P2PK +
+ {state.enableTimelock && state.locktime && ( +
+ Timelock: +
+
+ {new Date(state.locktime).toLocaleString()} +
+ {timeRemaining !== null && ( +
+ {isTimelockExpired + ? 'βœ… Unlocked' + : `πŸ”’ ${formatCountdown(timeRemaining)}`} +
+ )} +
+
+ )} +
+ State: + + {trackedSwap.state} + +
+
+
+ )} + +
+ + + {state.enableTimelock && ( + + )} + +
+ + + + + + + + + πŸ” Proof Inspector + + Decode and inspect the token details + + +
+ {state.tokenToUnlock && ( +
+ {/* JSON Display */} + + +
+
+ +
+ {state.tokenToUnlock.mint} +
+
+ +
+ +
+ {state.tokenToUnlock.unit} +
+
+ +
+ +
+ {state.tokenToUnlock.proofs.map( + (proof, i) => { + const secretResult = parseSecret( + proof.secret, + ); + + return ( +
+ + Proof #{i + 1} ({proof.amount}{' '} + {state.tokenToUnlock?.unit}) + +
+
+ + Amount: + {' '} + {proof.amount} +
+
+ + Keyset ID: + +
+ {proof.id} +
+
+ + {secretResult.success ? ( + <> +
+ + Secret Type: + {' '} + {secretResult.data.type === + 'nut10' + ? secretResult.data.secret + .kind + : 'plain'} +
+ + {/* Plain Secret Display */} + {secretResult.data.type === + 'plain' && ( +
+ + Secret: + +
+ {secretResult.data.secret} +
+
+ )} + + {/* NUT-10 P2PK Secret Display */} + {secretResult.data.type === + 'nut10' && + secretResult.data.secret + .kind === 'P2PK' && ( + <> +
+ + Public Key: + +
+ { + secretResult.data + .secret.data + } +
+
+
+ + Nonce: + +
+ { + secretResult.data + .secret.nonce + } +
+
+ {secretResult.data.secret + .tags && + secretResult.data.secret + .tags.length > 0 && ( +
+ + Tags: + +
+ {secretResult.data.secret.tags.map( + ( + tag, + tagIndex, + ) => ( +
+ [ + {tag.map( + ( + item, + itemIndex, + ) => ( + + " + {item} + " + {itemIndex < + tag.length - + 1 + ? ', ' + : ''} + + ), + )} + ] +
+ ), + )} +
+
+ )} + + )} + + {/* Other NUT-10 Secret Types */} + {secretResult.data.type === + 'nut10' && + secretResult.data.secret + .kind !== 'P2PK' && ( + <> +
+ + Data: + +
+ { + secretResult.data + .secret.data + } +
+
+
+ + Nonce: + +
+ { + secretResult.data + .secret.nonce + } +
+
+ + )} + + ) : ( +
+ + Parse Error: + +
+ {secretResult.error} +
+
+ + Raw Secret: + +
+ {proof.secret} +
+
+
+ )} + +
+ + C (unblinded sig): + +
+ {proof.C} +
+
+
+
+ ); + }, + )} +
+
+
+
+ )} +
+
+
+
+
+
+
+ )} + {/* Step 3: Token Unlocked */} + {state.step === 'unlocked' && ( +
+ {/* Animated Success Icon */} +
+
+
+ πŸŽ‰ +
+
+ + {/* Sparkle animations around the icon */} +
+ ✨ +
+
+ ⭐ +
+
+ πŸ’« +
+
+ 🌟 +
+
+ + {/* Animated Text */} +
+

+ AMAZING! +

+
+

+ Token Successfully Unlocked! πŸ”“ +

+

+ Your funds are now in your wallet! πŸ’° +

+
+
+ + {/* Celebration Elements */} +
+ πŸš€ + πŸ’Ž + ⚑ + πŸ”₯ +
+ + {/* Auto-reset indicator */} +
+

+ Automatically resetting in {RESET_DELAY / 1000} seconds... +

+
+
+ )} +
+
+ ); +} diff --git a/app/routes/_protected.receive.cashu_.token.tsx b/app/routes/_protected.receive.cashu_.token.tsx index a270a3d59..7e139cf37 100644 --- a/app/routes/_protected.receive.cashu_.token.tsx +++ b/app/routes/_protected.receive.cashu_.token.tsx @@ -7,9 +7,23 @@ import { extractCashuToken } from '~/lib/cashu'; 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 token = extractCashuToken(window.location.hash); + const hash = window.location.hash; + const hashParams = parseHashParams(hash); + + const token = extractCashuToken(hash); if (!token) { throw redirect('/receive'); @@ -19,8 +33,9 @@ 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 }; + return { token, autoClaim, selectedAccountId, unlockingKey }; } clientLoader.hydrate = true as const; @@ -32,13 +47,14 @@ export function HydrateFallback() { export default function ProtectedReceiveCashuToken({ loaderData, }: Route.ComponentProps) { - const { token, autoClaim, selectedAccountId } = loaderData; + const { token, autoClaim, selectedAccountId, unlockingKey } = loaderData; return ( }> diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 22c5b97ed..e63664ebc 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -265,6 +265,7 @@ export type Database = { receive_swap_fee: number send_output_amounts: number[] | null send_swap_fee: number + spending_condition_data: string | null state: string token_hash: string | null total_amount: number @@ -290,6 +291,7 @@ export type Database = { receive_swap_fee: number send_output_amounts?: number[] | null send_swap_fee: number + spending_condition_data?: string | null state: string token_hash?: string | null total_amount: number @@ -315,6 +317,7 @@ export type Database = { receive_swap_fee?: number send_output_amounts?: number[] | null send_swap_fee?: number + spending_condition_data?: string | null state?: string token_hash?: string | null total_amount?: number @@ -364,6 +367,7 @@ export type Database = { token_proofs: string transaction_id: string unit: string + unlocking_data: string | null user_id: string version: number } @@ -383,6 +387,7 @@ export type Database = { token_proofs: string transaction_id: string unit: string + unlocking_data?: string | null user_id: string version?: number } @@ -402,6 +407,7 @@ export type Database = { token_proofs?: string transaction_id?: string unit?: string + unlocking_data?: string | null user_id?: string version?: number } @@ -785,6 +791,7 @@ export type Database = { p_receive_swap_fee: number p_send_output_amounts?: number[] p_send_swap_fee: number + p_spending_condition_data?: string p_state: string p_token_hash?: string p_total_amount: number @@ -809,6 +816,7 @@ export type Database = { receive_swap_fee: number send_output_amounts: number[] | null send_swap_fee: number + spending_condition_data: string | null state: string token_hash: string | null total_amount: number @@ -834,6 +842,7 @@ export type Database = { p_token_hash: string p_token_proofs: string p_unit: string + p_unlocking_data?: string p_user_id: string } Returns: { @@ -852,6 +861,7 @@ export type Database = { token_proofs: string transaction_id: string unit: string + unlocking_data: string | null user_id: string version: number } diff --git a/supabase/migrations/20250910231020_add-cashu-spending-conditions.sql b/supabase/migrations/20250910231020_add-cashu-spending-conditions.sql new file mode 100644 index 000000000..fff342a0d --- /dev/null +++ b/supabase/migrations/20250910231020_add-cashu-spending-conditions.sql @@ -0,0 +1,267 @@ +-- Add column to store optional spending conditions as JSON string +alter table wallet.cashu_send_swaps + add column if not exists spending_condition_data text; + +-- Drop the previous version of the function (without spending_condition_data) +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[] +); + +-- Recreate function with optional spending_condition_data parameter +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_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 +) 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', + 'CASHU_TOKEN', + '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, + 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_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$ +; + +-- Add unlocking_data column to cashu_token_swaps table +alter table "wallet"."cashu_token_swaps" add column "unlocking_data" text; + +-- Drop the old function +DROP FUNCTION IF EXISTS wallet.create_cashu_token_swap( + text, text, uuid, uuid, text, text, text, integer, integer[], numeric, numeric, numeric, integer, text, uuid +); + +-- Recreate function with optional unlocking_data parameter +CREATE OR REPLACE FUNCTION wallet.create_cashu_token_swap( + p_token_hash text, + p_token_proofs text, + p_account_id uuid, + p_user_id uuid, + p_currency text, + p_unit text, + p_keyset_id text, + p_keyset_counter integer, + p_output_amounts integer[], + p_input_amount numeric, + p_receive_amount numeric, + p_fee_amount numeric, + p_account_version integer, + p_encrypted_transaction_details text, + p_reversed_transaction_id uuid DEFAULT NULL, + p_unlocking_data text DEFAULT NULL) + RETURNS wallet.cashu_token_swaps + LANGUAGE plpgsql +AS $function$ +declare + v_token_swap wallet.cashu_token_swaps; + v_updated_counter integer; + v_transaction_id uuid; +begin + + -- Create transaction record + insert into wallet.transactions ( + user_id, + account_id, + direction, + type, + state, + currency, + reversed_transaction_id, + pending_at, + encrypted_transaction_details + ) values ( + p_user_id, + p_account_id, + 'RECEIVE', + 'CASHU_TOKEN', + 'PENDING', + p_currency, + p_reversed_transaction_id, + now(), + p_encrypted_transaction_details + ) returning id into v_transaction_id; + + -- Calculate new counter + v_updated_counter := p_keyset_counter + array_length(p_output_amounts, 1); + + -- Update the account with optimistic concurrency + update wallet.accounts a + set + details = jsonb_set( + details, + array['keyset_counters', p_keyset_id], + to_jsonb(v_updated_counter), + true + ), + version = version + 1 + where a.id = p_account_id and a.version = p_account_version; + + if not found then + raise exception 'Concurrency error: Account % was modified by another transaction. Expected version %, but found different one', p_account_id, p_account_version; + end if; + + insert into wallet.cashu_token_swaps ( + token_hash, + token_proofs, + account_id, + user_id, + currency, + unit, + keyset_id, + keyset_counter, + output_amounts, + input_amount, + receive_amount, + fee_amount, + state, + transaction_id, + unlocking_data + ) values ( + p_token_hash, + p_token_proofs, + p_account_id, + p_user_id, + p_currency, + p_unit, + p_keyset_id, + p_keyset_counter, + p_output_amounts, + p_input_amount, + p_receive_amount, + p_fee_amount, + 'PENDING', + v_transaction_id, + p_unlocking_data + ) returning * into v_token_swap; + + return v_token_swap; +end; +$function$ +; \ No newline at end of file