From 71fd6ef965258d86cd4e92b1657dcfc8d922ac1d Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 20 Feb 2025 19:12:35 -0300 Subject: [PATCH] refactor: extract repetitive validation and put it into a new function called 'validateAndParseWallet', tests included --- .../ditto/middleware/swapNutzapsMiddleware.ts | 168 +++++++----------- packages/ditto/utils/cashu.test.ts | 53 ++++++ packages/ditto/utils/cashu.ts | 102 +++++++++++ 3 files changed, 223 insertions(+), 100 deletions(-) create mode 100644 packages/ditto/utils/cashu.test.ts create mode 100644 packages/ditto/utils/cashu.ts diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index 91f29603..e490a571 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -2,16 +2,14 @@ import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cash import { type DittoConf } from '@ditto/conf'; import { MiddlewareHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; -import { getPublicKey } from 'nostr-tools'; import { NostrEvent, NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify'; import { SetRequired } from 'type-fest'; -import { stringToBytes } from '@scure/base'; import { logi } from '@soapbox/logi'; import { z } from 'zod'; -import { isNostrId } from '@/utils.ts'; import { errorJson } from '@/utils/log.ts'; import { createEvent } from '@/utils/api.ts'; +import { validateAndParseWallet } from '@/utils/cashu.ts'; /** * Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough. @@ -38,101 +36,65 @@ export const swapNutzapsMiddleware: MiddlewareHandler< const { signal } = c.req.raw; const pubkey = await signer.getPublicKey(); - const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); - if (wallet) { - let decryptedContent: string; + const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal }); + + if (error && error.code === 'wallet-not-found') { + await next(); + return; + } + + if (error) { + return c.json({ error: error.message }, 400); + } + + const { mints, privkey } = data; + + const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints }; + + const lastRedeemedNutzap = await getLastRedeemedNutzap(store, pubkey, { signal }); + if (lastRedeemedNutzap) { + nutzapsFilter.since = lastRedeemedNutzap.created_at; + } + + const mintsToProofs = await getMintsToProofs(store, nutzapsFilter, conf.relay, { signal }); + + for (const mint of Object.keys(mintsToProofs)) { try { - decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content); + const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs }); + + const cashuWallet = new CashuWallet(new CashuMint(mint)); + const receiveProofs = await cashuWallet.receive(token, { privkey }); + + const unspentProofs = await createEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify({ + mint, + proofs: receiveProofs, + }), + ), + }, c); + + const amount = receiveProofs.reduce((accumulator, current) => { + return accumulator + current.amount; + }, 0); + + await createEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'in'], + ['amount', amount], + ['e', unspentProofs.id, conf.relay, 'created'], + ]), + ), + tags: mintsToProofs[mint].toBeRedeemed, + }, c); } catch (e) { - logi({ - level: 'error', - ns: 'ditto.api.cashu.wallet.swap', - id: wallet.id, - kind: wallet.kind, - error: errorJson(e), - }); - return c.json({ error: 'Could not decrypt wallet content.' }, 400); - } - - let contentTags: string[][]; - try { - contentTags = n.json().pipe(z.string().array().array()).parse(decryptedContent); - } catch { - return c.json({ error: 'Could not parse the decrypted wallet content.' }, 400); - } - - const privkey = contentTags.find(([value]) => value === 'privkey')?.[1]; - if (!privkey || !isNostrId(privkey)) { - return c.json({ error: 'Wallet does not contain privkey or privkey is not a valid nostr id.' }, 400); - } - const p2pk = getPublicKey(stringToBytes('hex', privkey)); - - const [nutzapInformation] = await store.query([{ authors: [pubkey], kinds: [10019] }], { signal }); - if (!nutzapInformation) { - return c.json({ error: 'You need to have a nutzap information event so we can get the mints.' }, 400); - } - - const nutzapInformationPubkey = nutzapInformation.tags.find(([name]) => name === 'pubkey')?.[1]; - if (!nutzapInformationPubkey || (nutzapInformationPubkey !== p2pk)) { - return c.json({ - error: - "You do not have a 'pubkey' tag in your nutzap information event or the one you have does not match the one derivated from the wallet.", - }, 400); - } - - const mints = [...new Set(nutzapInformation.tags.filter(([name]) => name === 'mint').map(([_, value]) => value))]; - if (mints.length < 1) { - return c.json({ error: 'You do not have any mints in your nutzap information event.' }, 400); - } - - const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints }; - - const lastRedeemedNutzap = await getLastRedeemedNutzap(store, pubkey, { signal }); - if (lastRedeemedNutzap) { - nutzapsFilter.since = lastRedeemedNutzap.created_at; - } - - const mintsToProofs = await getMintsToProofs(store, nutzapsFilter, conf.relay, { signal }); - - // TODO: throw error if mintsToProofs is an empty object? - for (const mint of Object.keys(mintsToProofs)) { - try { - const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs }); - - const cashuWallet = new CashuWallet(new CashuMint(mint)); - const receiveProofs = await cashuWallet.receive(token, { privkey }); - - const unspentProofs = await createEvent({ - kind: 7375, - content: await signer.nip44.encrypt( - pubkey, - JSON.stringify({ - mint, - proofs: receiveProofs, - }), - ), - }, c); - - const amount = receiveProofs.reduce((accumulator, current) => { - return accumulator + current.amount; - }, 0); - - await createEvent({ - kind: 7376, - content: await signer.nip44.encrypt( - pubkey, - JSON.stringify([ - ['direction', 'in'], - ['amount', amount], - ['e', unspentProofs.id, conf.relay, 'created'], - ]), - ), - tags: mintsToProofs[mint].redeemed, - }, c); - } catch (e) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); - } + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); } } @@ -156,6 +118,12 @@ async function getLastRedeemedNutzap( } } +/** + * toBeRedeemed are the nutzaps that will be redeemed and saved in the kind 7376 - https://github.com/nostr-protocol/nips/blob/master/60.md#spending-history-event + * The tags format is: [ [ "e", "", "", "redeemed" ] ] + */ +type MintsToProofs = { [key: string]: { proofs: Proof[]; toBeRedeemed: string[][] } }; + /** * Gets proofs from nutzaps that have not been redeemed yet. * Each proof is associated with a specific mint. @@ -178,8 +146,8 @@ async function getMintsToProofs( nutzapsFilter: NostrFilter, relay: string, opts?: { signal?: AbortSignal }, -): Promise<{ [key: string]: { proofs: Proof[]; redeemed: string[][] } }> { - const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; +): Promise { + const mintsToProofs: MintsToProofs = {}; const nutzaps = await store.query([nutzapsFilter], { signal: opts?.signal }); @@ -196,7 +164,7 @@ async function getMintsToProofs( } if (!mintsToProofs[mint]) { - mintsToProofs[mint] = { proofs: [], redeemed: [] }; + mintsToProofs[mint] = { proofs: [], toBeRedeemed: [] }; } const parsed = n.json().pipe( @@ -215,8 +183,8 @@ async function getMintsToProofs( } mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data]; - mintsToProofs[mint].redeemed = [ - ...mintsToProofs[mint].redeemed, + mintsToProofs[mint].toBeRedeemed = [ + ...mintsToProofs[mint].toBeRedeemed, [ 'e', // nutzap event that has been redeemed event.id, diff --git a/packages/ditto/utils/cashu.test.ts b/packages/ditto/utils/cashu.test.ts new file mode 100644 index 00000000..9a0621e8 --- /dev/null +++ b/packages/ditto/utils/cashu.test.ts @@ -0,0 +1,53 @@ +import { NSecSigner } from '@nostrify/nostrify'; +import { generateSecretKey, getPublicKey } from 'nostr-tools'; +import { bytesToString, stringToBytes } from '@scure/base'; +import { assertEquals } from '@std/assert'; + +import { createTestDB, genEvent } from '@/test.ts'; + +import { validateAndParseWallet } from '@/utils/cashu.ts'; + +Deno.test('validateAndParseWallet function returns valid data', async () => { + await using db = await createTestDB({ pure: true }); + const store = db.store; + + const sk = generateSecretKey(); + const signer = new NSecSigner(sk); + const pubkey = await signer.getPublicKey(); + const privkey = bytesToString('hex', sk); + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + // Wallet + const wallet = genEvent({ + kind: 17375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['privkey', privkey], + ['mint', 'https://mint.soul.com'], + ]), + ), + }, sk); + await db.store.event(wallet); + + // Nutzap information + const nutzapInfo = genEvent({ + kind: 10019, + tags: [ + ['pubkey', p2pk], + ['mint', 'https://mint.soul.com'], + ], + }, sk); + await db.store.event(nutzapInfo); + + const { data, error } = await validateAndParseWallet(store, signer, pubkey); + + assertEquals(error, null); + assertEquals(data, { + wallet, + nutzapInfo, + privkey, + p2pk, + mints: ['https://mint.soul.com'], + }); +}); diff --git a/packages/ditto/utils/cashu.ts b/packages/ditto/utils/cashu.ts new file mode 100644 index 00000000..05a88272 --- /dev/null +++ b/packages/ditto/utils/cashu.ts @@ -0,0 +1,102 @@ +import { SetRequired } from 'type-fest'; +import { getPublicKey } from 'nostr-tools'; +import { NostrEvent, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify'; +import { logi } from '@soapbox/logi'; +import { stringToBytes } from '@scure/base'; +import { z } from 'zod'; + +import { errorJson } from '@/utils/log.ts'; +import { isNostrId } from '@/utils.ts'; + +type Data = { + wallet: NostrEvent; + nutzapInfo: NostrEvent; + privkey: string; + p2pk: string; + mints: string[]; +}; + +type CustomError = + | { message: 'Wallet not found'; code: 'wallet-not-found' } + | { message: 'Could not decrypt wallet content'; code: 'fail-decrypt-wallet' } + | { message: 'Could not parse wallet content'; code: 'fail-parse-wallet' } + | { message: 'Wallet does not contain privkey or privkey is not a valid nostr id'; code: 'privkey-missing' } + | { message: 'Nutzap information event not found'; code: 'nutzap-info-not-found' } + | { + message: + "You do not have a 'pubkey' tag in your nutzap information event or the one you have does not match the one derivated from the wallet."; + code: 'pubkey-mismatch'; + } + | { message: 'You do not have any mints in your nutzap information event.'; code: 'mints-missing' }; + +/** Ensures that the wallet event and nutzap information event are correct. */ +async function validateAndParseWallet( + store: NStore, + signer: SetRequired, + pubkey: string, + opts?: { signal?: AbortSignal }, +): Promise<{ data: Data; error: null } | { data: null; error: CustomError }> { + const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal: opts?.signal }); + if (!wallet) { + return { error: { message: 'Wallet not found', code: 'wallet-not-found' }, data: null }; + } + + let decryptedContent: string; + try { + decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content); + } catch (e) { + logi({ + level: 'error', + ns: 'ditto.api.cashu.wallet', + id: wallet.id, + kind: wallet.kind, + error: errorJson(e), + }); + return { data: null, error: { message: 'Could not decrypt wallet content', code: 'fail-decrypt-wallet' } }; + } + + let contentTags: string[][]; + try { + contentTags = n.json().pipe(z.string().array().array()).parse(decryptedContent); + } catch { + return { data: null, error: { message: 'Could not parse wallet content', code: 'fail-parse-wallet' } }; + } + + const privkey = contentTags.find(([value]) => value === 'privkey')?.[1]; + if (!privkey || !isNostrId(privkey)) { + return { + data: null, + error: { message: 'Wallet does not contain privkey or privkey is not a valid nostr id', code: 'privkey-missing' }, + }; + } + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + const [nutzapInfo] = await store.query([{ authors: [pubkey], kinds: [10019] }], { signal: opts?.signal }); + if (!nutzapInfo) { + return { data: null, error: { message: 'Nutzap information event not found', code: 'nutzap-info-not-found' } }; + } + + const nutzapInformationPubkey = nutzapInfo.tags.find(([name]) => name === 'pubkey')?.[1]; + if (!nutzapInformationPubkey || (nutzapInformationPubkey !== p2pk)) { + return { + data: null, + error: { + message: + "You do not have a 'pubkey' tag in your nutzap information event or the one you have does not match the one derivated from the wallet.", + code: 'pubkey-mismatch', + }, + }; + } + + const mints = [...new Set(nutzapInfo.tags.filter(([name]) => name === 'mint').map(([_, value]) => value))]; + if (mints.length < 1) { + return { + data: null, + error: { message: 'You do not have any mints in your nutzap information event.', code: 'mints-missing' }, + }; + } + + return { data: { wallet, nutzapInfo, privkey, p2pk, mints }, error: null }; +} + +export { validateAndParseWallet };