From 79fe7cda096e440a99921403db4aab40619de898 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Sat, 13 Sep 2025 14:52:43 -0700 Subject: [PATCH] add unlocking data to send-swaps for optional reversal --- app/features/send/cashu-send-swap-hooks.ts | 5 +- .../send/cashu-send-swap-repository.ts | 30 +++- app/features/send/cashu-send-swap-service.ts | 34 +++- app/features/send/cashu-send-swap.ts | 7 +- app/routes/_protected.demo.tsx | 4 + supabase/database.types.ts | 5 + ...913203243_add-send-swap-unlocking-data.sql | 155 ++++++++++++++++++ 7 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 supabase/migrations/20250913203243_add-send-swap-unlocking-data.sql diff --git a/app/features/send/cashu-send-swap-hooks.ts b/app/features/send/cashu-send-swap-hooks.ts index 6cfeae37b..53febeb28 100644 --- a/app/features/send/cashu-send-swap-hooks.ts +++ b/app/features/send/cashu-send-swap-hooks.ts @@ -8,7 +8,7 @@ import { useSuspenseQuery, } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; -import type { SpendingConditionData } from '~/lib/cashu/types'; +import type { SpendingConditionData, UnlockingData } from '~/lib/cashu/types'; import type { Money } from '~/lib/money'; import { useSupabaseRealtimeSubscription } from '~/lib/supabase/supabase-realtime'; import { useLatest } from '~/lib/use-latest'; @@ -137,11 +137,13 @@ export function useCreateCashuSendSwap({ amount, accountId, spendingConditionData, + unlockingData, senderPaysFee = true, }: { amount: Money; accountId: string; spendingConditionData?: SpendingConditionData; + unlockingData?: UnlockingData; senderPaysFee?: boolean; }) => { const account = await getLatestCashuAccount(accountId); @@ -151,6 +153,7 @@ export function useCreateCashuSendSwap({ account, senderPaysFee, spendingConditionData, + unlockingData, }); }, onSuccess: (swap) => { diff --git a/app/features/send/cashu-send-swap-repository.ts b/app/features/send/cashu-send-swap-repository.ts index 6f8376d85..68f8887c7 100644 --- a/app/features/send/cashu-send-swap-repository.ts +++ b/app/features/send/cashu-send-swap-repository.ts @@ -3,6 +3,8 @@ import { sumProofs } from '~/lib/cashu'; import { type SpendingConditionData, SpendingConditionDataSchema, + type UnlockingData, + UnlockingDataSchema, } from '~/lib/cashu/types'; import { Money } from '~/lib/money'; import { @@ -94,6 +96,10 @@ type CreateSendSwap = { * The version seen by the client for optimistic concurrency control. */ accountVersion: number; + /** + * The unlocking data to reverse the swap. + */ + unlockingData?: UnlockingData; }; export class CashuSendSwapRepository { @@ -120,6 +126,7 @@ export class CashuSendSwapRepository { keysetCounter, outputAmounts, accountVersion, + unlockingData, }: CreateSendSwap, options?: Options, ) { @@ -139,6 +146,7 @@ export class CashuSendSwapRepository { encryptedProofsToSend, encryptedTransactionDetails, encryptedSpendingConditionData, + encryptedUnlockingData, ] = await Promise.all([ this.encryption.encrypt(inputProofs), this.encryption.encrypt(accountProofs), @@ -147,6 +155,7 @@ export class CashuSendSwapRepository { spendingConditionData ? this.encryption.encrypt(spendingConditionData) : undefined, + unlockingData ? this.encryption.encrypt(unlockingData) : undefined, ]); const updatedKeysetCounter = @@ -181,6 +190,7 @@ export class CashuSendSwapRepository { p_proofs_to_send: encryptedProofsToSend, p_token_hash: tokenHash, p_spending_condition_data: encryptedSpendingConditionData, + p_unlocking_data: encryptedUnlockingData, }); if (options?.abortSignal) { @@ -368,13 +378,16 @@ export class CashuSendSwapRepository { data: AgicashDbCashuSendSwap, decrypt: Encryption['decrypt'], ): Promise { - const [inputProofs, proofsToSend, spendingConditionData] = + const [inputProofs, proofsToSend, spendingConditionData, unlockingData] = 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, + data.unlocking_data + ? decrypt(data.unlocking_data) + : undefined, ]); let validatedSpendingConditionData: SpendingConditionData | null = null; @@ -393,6 +406,20 @@ export class CashuSendSwapRepository { validatedSpendingConditionData = validationResult.data; } + let validatedUnlockingData: UnlockingData | null = null; + if (unlockingData) { + const validationResult = UnlockingDataSchema.safeParse(unlockingData); + if (!validationResult.success) { + throw new Error('Invalid unlocking data', { + cause: { + data: unlockingData, + errors: validationResult.error.errors, + }, + }); + } + validatedUnlockingData = validationResult.data; + } + const toMoney = (amount: number) => { return new Money({ amount, @@ -414,6 +441,7 @@ export class CashuSendSwapRepository { inputProofs, inputAmount: toMoney(data.input_amount), spendingConditionData: validatedSpendingConditionData, + unlockingData: validatedUnlockingData, 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 bc72daf7f..c1b0e6bad 100644 --- a/app/features/send/cashu-send-swap-service.ts +++ b/app/features/send/cashu-send-swap-service.ts @@ -1,4 +1,9 @@ -import { MintOperationError, OutputData, type Proof } from '@cashu/cashu-ts'; +import { + MintOperationError, + OutputData, + type Proof, + type Token, +} from '@cashu/cashu-ts'; import type { CashuAccount } from '~/features/accounts/account'; import { CashuErrorCodes, @@ -8,9 +13,10 @@ import { getCashuUnit, isCashuError, sumProofs, + validateTokenSpendingConditions, } from '~/lib/cashu'; import { createDeterministicP2PKData } from '~/lib/cashu/crypto'; -import type { SpendingConditionData } from '~/lib/cashu/types'; +import type { SpendingConditionData, UnlockingData } from '~/lib/cashu/types'; import { Money } from '~/lib/money'; import { type CashuTokenSwapService, @@ -108,6 +114,7 @@ export class CashuSendSwapService { amount, senderPaysFee, spendingConditionData, + unlockingData, }: { /** The id of the user creating the swap */ userId: string; @@ -118,6 +125,7 @@ export class CashuSendSwapService { /** Whether the sender pays the fee for the swap by including the fee in the proofs to send */ senderPaysFee: boolean; spendingConditionData?: SpendingConditionData; + unlockingData?: UnlockingData; }): Promise { if (account.currency !== amount.currency) { throw new Error( @@ -204,6 +212,7 @@ export class CashuSendSwapService { keysetCounter: sendKeysetCounter, tokenHash, spendingConditionData, + unlockingData, outputAmounts: { send: amountsFromOutputData(sendOutputData), keep: amountsFromOutputData(keepOutputData), @@ -300,15 +309,26 @@ export class CashuSendSwapService { throw new Error('Swap does not belong to account'); } + const token: Token = { + mint: account.mintUrl, + proofs: swap.proofsToSend, + unit: getCashuProtocolUnit(swap.currency), + }; + + const unlockingData = swap.unlockingData ?? undefined; + if (unlockingData) { + const result = validateTokenSpendingConditions(token, unlockingData); + if (!result.success) { + throw new Error(result.error); + } + } + return this.cashuTokenSwapService.create({ account, userId: swap.userId, - token: { - mint: account.mintUrl, - proofs: swap.proofsToSend, - unit: getCashuProtocolUnit(swap.currency), - }, + token, reversedTransactionId: swap.transactionId, + unlockingData, }); } diff --git a/app/features/send/cashu-send-swap.ts b/app/features/send/cashu-send-swap.ts index 835539e95..2425c23e6 100644 --- a/app/features/send/cashu-send-swap.ts +++ b/app/features/send/cashu-send-swap.ts @@ -1,5 +1,5 @@ import type { Proof } from '@cashu/cashu-ts'; -import type { SpendingConditionData } from '~/lib/cashu/types'; +import type { SpendingConditionData, UnlockingData } from '~/lib/cashu/types'; import type { Currency, Money } from '~/lib/money'; /** @@ -68,6 +68,11 @@ export type CashuSendSwap = { * All the data required to encumber the proofs with the specified spending conditions. */ spendingConditionData: SpendingConditionData | null; + /** + * All the data required to reclaim the proofs in the case that the user wants to reverse the swap. + * ie. refund keys + */ + unlockingData: UnlockingData | 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/routes/_protected.demo.tsx b/app/routes/_protected.demo.tsx index 98224beb1..5604db397 100644 --- a/app/routes/_protected.demo.tsx +++ b/app/routes/_protected.demo.tsx @@ -273,6 +273,10 @@ function useDemoActions( amount: amountMoney, accountId: account.id, spendingConditionData, + unlockingData: { + kind: 'P2PK', + signingKeys: [state.privateKey], + }, }); }; diff --git a/supabase/database.types.ts b/supabase/database.types.ts index e63664ebc..0e56e899c 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -271,6 +271,7 @@ export type Database = { total_amount: number transaction_id: string unit: string + unlocking_data: string | null user_id: string version: number } @@ -297,6 +298,7 @@ export type Database = { total_amount: number transaction_id: string unit: string + unlocking_data?: string | null user_id: string version?: number } @@ -323,6 +325,7 @@ export type Database = { total_amount?: number transaction_id?: string unit?: string + unlocking_data?: string | null user_id?: string version?: number } @@ -796,6 +799,7 @@ export type Database = { p_token_hash?: string p_total_amount: number p_unit: string + p_unlocking_data?: string p_updated_keyset_counter?: number p_user_id: string } @@ -822,6 +826,7 @@ export type Database = { total_amount: number transaction_id: string unit: string + unlocking_data: string | null user_id: string version: number } diff --git a/supabase/migrations/20250913203243_add-send-swap-unlocking-data.sql b/supabase/migrations/20250913203243_add-send-swap-unlocking-data.sql new file mode 100644 index 000000000..fc5c7997a --- /dev/null +++ b/supabase/migrations/20250913203243_add-send-swap-unlocking-data.sql @@ -0,0 +1,155 @@ +-- Add column to store optional unlocking_data as encrypted text +alter table wallet.cashu_send_swaps + add column if not exists unlocking_data text; + +-- Drop the previous version of the function (without unlocking_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[], + text +); + +-- Recreate function with optional unlocking_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, + p_unlocking_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, + unlocking_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_unlocking_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$ +;