From 3bafb439bbedf2407e73bef2e8dc84fd2b83320b Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sat, 15 Feb 2025 21:44:43 -0300 Subject: [PATCH 01/31] feat: create POST '/api/v1/ditto/cashu/quote' endpoint also create GET /quote/:quote_id and POST /mint/:quote_id endpoints (they are not implemented) --- packages/ditto/controllers/api/cashu.ts | 60 ++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 19a29658..4e79d518 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,4 +1,4 @@ -import { Proof } from '@cashu/cashu-ts'; +import { CashuMint, CashuWallet, Proof } from '@cashu/cashu-ts'; import { Hono } from '@hono/hono'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; @@ -33,6 +33,64 @@ interface Nutzap { recipient_pubkey: string; } +const createMintQuoteSchema = z.object({ + mint: z.string().url(), + amount: z.number().int(), +}); + +/** + * Creates a new mint quote in a specific mint. + * https://github.com/cashubtc/nuts/blob/main/04.md#mint-quote + */ +app.post('/quote', requireNip44Signer, async (c) => { + const signer = c.var.signer; + const pubkey = await signer.getPublicKey(); + const body = await parseBody(c.req.raw); + const result = createMintQuoteSchema.safeParse(body); + + if (!result.success) { + return c.json({ error: 'Bad schema', schema: result.error }, 400); + } + + const { mint: mintUrl, amount } = result.data; + + try { + const mint = new CashuMint(mintUrl); + const wallet = new CashuWallet(mint); + await wallet.loadMint(); + + const mintQuote = await wallet.createMintQuote(amount); + + await createEvent({ + kind: 7374, + content: await signer.nip44.encrypt(pubkey, mintQuote.quote), + tags: [ + ['expiration', String(mintQuote.expiry)], + ['mint', mintUrl], + ], + }, c); + + return c.json(mintQuote, 200); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.quote', error: errorJson(e) }); + return c.json({ error: 'Could not create mint quote' }, 500); + } +}); + +/** + * Returns the state of the mint quote. + * https://github.com/cashubtc/nuts/blob/main/04.md#check-mint-quote-state + */ +app.get('/quote/:quote_id', requireNip44Signer, async (c) => { +}); + +/** + * Mint new tokens. + * https://github.com/cashubtc/nuts/blob/main/04.md#minting-tokens + */ +app.post('/mint/:quote_id', requireNip44Signer, async (c) => { +}); + const createCashuWalletAndNutzapInfoSchema = z.object({ mints: z.array(z.string().url()).nonempty().transform((val) => { return [...new Set(val)]; From b9d813804edabdede82c1192f276a0aeb1d13ec2 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sun, 16 Feb 2025 20:00:14 -0300 Subject: [PATCH 02/31] feat: implement GET '/api/v1/ditto/cashu/quote/:quote_id' (not tested) --- packages/ditto/controllers/api/cashu.ts | 26 ++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 1c5af55f..1b8ce403 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -10,7 +10,7 @@ import { requireNip44Signer } from '@/middleware/requireSigner.ts'; import { requireStore } from '@/middleware/storeMiddleware.ts'; import { walletSchema } from '@/schema.ts'; import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; -import { isNostrId } from '@/utils.ts'; +import { isNostrId, nostrNow } from '@/utils.ts'; import { logi } from '@soapbox/logi'; import { errorJson } from '@/utils/log.ts'; @@ -82,6 +82,30 @@ app.post('/quote', requireNip44Signer, async (c) => { * https://github.com/cashubtc/nuts/blob/main/04.md#check-mint-quote-state */ app.get('/quote/:quote_id', requireNip44Signer, async (c) => { + const signer = c.var.signer; + const { signal } = c.req.raw; + const store = c.get('store'); + const pubkey = await signer.getPublicKey(); + const quote_id = c.req.param('quote_id'); + + const events = await store.query([{ kinds: [7374], authors: [pubkey] }], { signal }); + for (const event of events) { + const decryptedQuoteId = await signer.nip44.decrypt(pubkey, event.content); + const mintUrl = event.tags.find(([name]) => name === 'mint')?.[1]; + const expiration = Number(event.tags.find(([name]) => name === 'expiration')?.[1]); + const now = nostrNow(); + + if (mintUrl && (expiration > now) && (quote_id === decryptedQuoteId)) { + const mint = new CashuMint(mintUrl); + const wallet = new CashuWallet(mint); + await wallet.loadMint(); + + const mintQuote = await wallet.checkMintQuote(quote_id); + return c.json(mintQuote, 200); + } + } + + return c.json({ error: 'Quote not found' }, 404); }); /** From fb7203837329890ef3ab1ca3dbe5ed5549bb724a Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 17 Feb 2025 15:16:16 -0300 Subject: [PATCH 03/31] feat: implement POST '/api/v1/ditto/cashu/mint/:quote_id' --- packages/ditto/controllers/api/cashu.ts | 87 ++++++++++++++++++- .../ditto/middleware/swapNutzapsMiddleware.ts | 2 +- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 1b8ce403..e92dd2d4 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,4 +1,4 @@ -import { CashuMint, CashuWallet, Proof } from '@cashu/cashu-ts'; +import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; import { confRequiredMw } from '@ditto/api/middleware'; import { Hono } from '@hono/hono'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; @@ -13,6 +13,7 @@ import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; import { isNostrId, nostrNow } from '@/utils.ts'; import { logi } from '@soapbox/logi'; import { errorJson } from '@/utils/log.ts'; +import { getAmount } from '@/utils/bolt11.ts'; type Wallet = z.infer; @@ -109,13 +110,91 @@ app.get('/quote/:quote_id', requireNip44Signer, async (c) => { }); /** - * Mint new tokens. + * Checks if the quote has been paid, if it has then mint new tokens. * https://github.com/cashubtc/nuts/blob/main/04.md#minting-tokens */ app.post('/mint/:quote_id', requireNip44Signer, async (c) => { + const { conf } = c.var; + const signer = c.var.signer; + const { signal } = c.req.raw; + const store = c.get('store'); + const pubkey = await signer.getPublicKey(); + const quote_id = c.req.param('quote_id'); + + const expiredQuoteIds: string[] = []; + const deleteExpiredQuotes = async (ids: string[]) => { + await createEvent({ + kind: 5, + tags: ids.map((id) => ['e', id, conf.relay]), + }, c); + }; + + const events = await store.query([{ kinds: [7374], authors: [pubkey] }], { signal }); + for (const event of events) { + const decryptedQuoteId = await signer.nip44.decrypt(pubkey, event.content); + const mintUrl = event.tags.find(([name]) => name === 'mint')?.[1]; + const expiration = Number(event.tags.find(([name]) => name === 'expiration')?.[1]); + const now = nostrNow(); + + try { + if (mintUrl && (expiration > now) && (quote_id === decryptedQuoteId)) { + const mint = new CashuMint(mintUrl); + const wallet = new CashuWallet(mint); + await wallet.loadMint(); + + const mintQuote = await wallet.checkMintQuote(quote_id); + const amount = Number(getAmount(mintQuote.request)) / 1000; + + if ((mintQuote.state === MintQuoteState.PAID) && amount) { + const proofs = await wallet.mintProofs(amount, mintQuote.quote); + + const unspentProofs = await createEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify({ + mint, + proofs, + }), + ), + }, c); + + await createEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'in'], + ['amount', amount], + ['e', unspentProofs.id, conf.relay, 'created'], + ]), + ), + }, c); + + expiredQuoteIds.push(event.id); + await deleteExpiredQuotes(expiredQuoteIds); + + return c.json({ success: 'Minting successful!' }, 200); + } else { + await deleteExpiredQuotes(expiredQuoteIds); + + return c.json(mintQuote, 200); + } + } + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.mint', error: errorJson(e) }); + return c.json({ error: 'Server error' }, 500); + } + + expiredQuoteIds.push(event.id); + } + + await deleteExpiredQuotes(expiredQuoteIds); + + return c.json({ error: 'Quote not found' }, 404); }); -const createCashuWalletAndNutzapInfoSchema = z.object({ +const createWalletSchema = z.object({ mints: z.array(z.string().url()).nonempty().transform((val) => { return [...new Set(val)]; }), @@ -132,7 +211,7 @@ app.put('/wallet', requireNip44Signer, async (c) => { const pubkey = await signer.getPublicKey(); const body = await parseBody(c.req.raw); const { signal } = c.req.raw; - const result = createCashuWalletAndNutzapInfoSchema.safeParse(body); + const result = createWalletSchema.safeParse(body); if (!result.success) { return c.json({ error: 'Bad schema', schema: result.error }, 400); diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index aa68c1c1..76d34049 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -57,7 +57,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler< let contentTags: string[][]; try { - contentTags = JSON.parse(decryptedContent); + contentTags = JSON.parse(decryptedContent); // TODO: use zod } catch { return c.json({ error: 'Could not JSON parse the decrypted wallet content.' }, 400); } From 4b49dd0ddf3b7bd7685ca7eac252b9eb9d638152 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 17 Feb 2025 21:05:25 -0300 Subject: [PATCH 04/31] refactor: delete get quote id state --- packages/ditto/controllers/api/cashu.ts | 33 +------------------------ 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index e92dd2d4..e003b00a 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -78,37 +78,6 @@ app.post('/quote', requireNip44Signer, async (c) => { } }); -/** - * Returns the state of the mint quote. - * https://github.com/cashubtc/nuts/blob/main/04.md#check-mint-quote-state - */ -app.get('/quote/:quote_id', requireNip44Signer, async (c) => { - const signer = c.var.signer; - const { signal } = c.req.raw; - const store = c.get('store'); - const pubkey = await signer.getPublicKey(); - const quote_id = c.req.param('quote_id'); - - const events = await store.query([{ kinds: [7374], authors: [pubkey] }], { signal }); - for (const event of events) { - const decryptedQuoteId = await signer.nip44.decrypt(pubkey, event.content); - const mintUrl = event.tags.find(([name]) => name === 'mint')?.[1]; - const expiration = Number(event.tags.find(([name]) => name === 'expiration')?.[1]); - const now = nostrNow(); - - if (mintUrl && (expiration > now) && (quote_id === decryptedQuoteId)) { - const mint = new CashuMint(mintUrl); - const wallet = new CashuWallet(mint); - await wallet.loadMint(); - - const mintQuote = await wallet.checkMintQuote(quote_id); - return c.json(mintQuote, 200); - } - } - - return c.json({ error: 'Quote not found' }, 404); -}); - /** * Checks if the quote has been paid, if it has then mint new tokens. * https://github.com/cashubtc/nuts/blob/main/04.md#minting-tokens @@ -174,7 +143,7 @@ app.post('/mint/:quote_id', requireNip44Signer, async (c) => { expiredQuoteIds.push(event.id); await deleteExpiredQuotes(expiredQuoteIds); - return c.json({ success: 'Minting successful!' }, 200); + return c.json({ success: 'Minting successful!', state: MintQuoteState.ISSUED }, 200); } else { await deleteExpiredQuotes(expiredQuoteIds); From 839aaca179c9738f1fd5435503edd7f199524255 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 18 Feb 2025 17:13:16 -0300 Subject: [PATCH 05/31] refactor: use zod --- packages/ditto/middleware/swapNutzapsMiddleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index 76d34049..b49e3aa4 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -57,7 +57,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler< let contentTags: string[][]; try { - contentTags = JSON.parse(decryptedContent); // TODO: use zod + contentTags = z.string().array().array().parse(decryptedContent); } catch { return c.json({ error: 'Could not JSON parse the decrypted wallet content.' }, 400); } From 8890311e2ddb5e1144d4cdfe02e6cb357d6e1857 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 18 Feb 2025 21:03:12 -0300 Subject: [PATCH 06/31] refactor: create getLastRedeemedNutzap function --- .../ditto/middleware/swapNutzapsMiddleware.ts | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index b49e3aa4..2395b915 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -3,7 +3,7 @@ import { type DittoConf } from '@ditto/conf'; import { MiddlewareHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; import { getPublicKey } from 'nostr-tools'; -import { NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify'; +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'; @@ -59,7 +59,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler< try { contentTags = z.string().array().array().parse(decryptedContent); } catch { - return c.json({ error: 'Could not JSON parse the decrypted wallet content.' }, 400); + return c.json({ error: 'Could not parse the decrypted wallet content.' }, 400); } const privkey = contentTags.find(([value]) => value === 'privkey')?.[1]; @@ -88,12 +88,12 @@ export const swapNutzapsMiddleware: MiddlewareHandler< const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints }; - const [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); - if (nutzapHistory) { - nutzapsFilter.since = nutzapHistory.created_at; + const lastRedeemedNutzap = await getLastRedeemedNutzap(store, pubkey, { signal }); + if (lastRedeemedNutzap) { + nutzapsFilter.since = lastRedeemedNutzap.created_at; } - const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; + const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; // 'key' is the mint url const nutzaps = await store.query([nutzapsFilter], { signal }); @@ -187,3 +187,21 @@ export const swapNutzapsMiddleware: MiddlewareHandler< await next(); }; + +/** Returns a spending history event that contains the last redeemed nutzap. */ +async function getLastRedeemedNutzap( + store: NStore, + pubkey: string, + opts?: { signal?: AbortSignal }, +): Promise { + const events = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal: opts?.signal }); + + for (const event of events) { + const nutzap = event.tags.find(([name]) => name === 'e'); + const redeemed = nutzap?.[3]; + if (redeemed === 'redeemed') { + return event; + } + } +} +async function getMintsToProofs() {} From a2165f0918b589229c758619d22ca5ebb91be563 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 18 Feb 2025 22:19:41 -0300 Subject: [PATCH 07/31] refactor: implement getMintsToProofs function --- .../ditto/middleware/swapNutzapsMiddleware.ts | 129 +++++++++++------- 1 file changed, 78 insertions(+), 51 deletions(-) diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index 2395b915..66127c4c 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -93,56 +93,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler< nutzapsFilter.since = lastRedeemedNutzap.created_at; } - const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; // 'key' is the mint url - - const nutzaps = await store.query([nutzapsFilter], { signal }); - - for (const event of nutzaps) { - try { - const mint = event.tags.find(([name]) => name === 'u')?.[1]; - if (!mint) { - continue; - } - - const proof = event.tags.find(([name]) => name === 'proof')?.[1]; - if (!proof) { - continue; - } - - if (!mintsToProofs[mint]) { - mintsToProofs[mint] = { proofs: [], redeemed: [] }; - } - - const parsed = n.json().pipe( - z.object({ - id: z.string(), - amount: z.number(), - secret: z.string(), - C: z.string(), - dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }).optional(), - dleqValid: z.boolean().optional(), - }).array(), - ).safeParse(proof); - - if (!parsed.success) { - continue; - } - - mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data]; - mintsToProofs[mint].redeemed = [ - ...mintsToProofs[mint].redeemed, - [ - 'e', // nutzap event that has been redeemed - event.id, - conf.relay, - 'redeemed', - ], - ['p', event.pubkey], // pubkey of the author of the 9321 event (nutzap sender) - ]; - } catch (e) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); - } - } + 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)) { @@ -204,4 +155,80 @@ async function getLastRedeemedNutzap( } } } -async function getMintsToProofs() {} + +/** + * Gets proofs from nutzaps that have not been redeemed yet. + * Each proof is associated with a specific mint. + * @param store Store used to query for the nutzaps + * @param nutzapsFilter Filter used to query for the nutzaps, most useful when + * it contains a 'since' field so it saves time and resources + * @param relay Relay hint where the new kind 7376 will be saved + * @returns MintsToProofs An object where each key is a mint url and the values are an array of proofs + * and an array of redeemed tags in this format: + * ``` + * [ + * ..., + * [ "e", "<9321-event-id>", "", "redeemed" ], // nutzap event that has been redeemed + * [ "p", "" ] // pubkey of the author of the 9321 event (nutzap sender) + * ] + * ``` + */ +async function getMintsToProofs( + store: NStore, + nutzapsFilter: NostrFilter, + relay: string, + opts?: { signal?: AbortSignal }, +): Promise<{ [key: string]: { proofs: Proof[]; redeemed: string[][] } }> { + const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; + + const nutzaps = await store.query([nutzapsFilter], { signal: opts?.signal }); + + for (const event of nutzaps) { + try { + const mint = event.tags.find(([name]) => name === 'u')?.[1]; + if (!mint) { + continue; + } + + const proof = event.tags.find(([name]) => name === 'proof')?.[1]; + if (!proof) { + continue; + } + + if (!mintsToProofs[mint]) { + mintsToProofs[mint] = { proofs: [], redeemed: [] }; + } + + const parsed = n.json().pipe( + z.object({ + id: z.string(), + amount: z.number(), + secret: z.string(), + C: z.string(), + dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }).optional(), + dleqValid: z.boolean().optional(), + }).array(), + ).safeParse(proof); + + if (!parsed.success) { + continue; + } + + mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data]; + mintsToProofs[mint].redeemed = [ + ...mintsToProofs[mint].redeemed, + [ + 'e', // nutzap event that has been redeemed + event.id, + relay, + 'redeemed', + ], + ['p', event.pubkey], // pubkey of the author of the 9321 event (nutzap sender) + ]; + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); + } + } + + return mintsToProofs; +} From d055c80abe120df92c82169a2967fa246a89bff7 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 18 Feb 2025 22:29:39 -0300 Subject: [PATCH 08/31] fix: pipe json payload into desired schema --- packages/ditto/middleware/swapNutzapsMiddleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index 66127c4c..c81ebdba 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -57,7 +57,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler< let contentTags: string[][]; try { - contentTags = z.string().array().array().parse(decryptedContent); + contentTags = n.json().pipe(z.string().array().array()).parse(decryptedContent); } catch { return c.json({ error: 'Could not parse the decrypted wallet content.' }, 400); } From 04d710b1b808c8b0637cdcb98b68f0bc46b5b6d2 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 19 Feb 2025 11:14:05 -0300 Subject: [PATCH 09/31] fix: return mints from the wallet, not just the ones from kind 7375 --- packages/ditto/controllers/api/cashu.ts | 15 ++++++++++++++- .../ditto/middleware/swapNutzapsMiddleware.ts | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index e003b00a..122fc667 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -2,6 +2,7 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; import { confRequiredMw } from '@ditto/api/middleware'; import { Hono } from '@hono/hono'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; +import { NSchema as n } from '@nostrify/nostrify'; import { bytesToString, stringToBytes } from '@scure/base'; import { z } from 'zod'; @@ -246,7 +247,12 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => { return c.json({ error: 'Wallet not found' }, 404); } - const decryptedContent: string[][] = JSON.parse(await signer.nip44.decrypt(pubkey, event.content)); + const { data: decryptedContent, success } = n.json().pipe(z.string().array().array()).safeParse( + await signer.nip44.decrypt(pubkey, event.content), + ); + if (!success) { + return c.json({ error: 'Could not decrypt wallet content' }, 422); + } const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1]; if (!privkey || !isNostrId(privkey)) { @@ -258,6 +264,13 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => { let balance = 0; const mints: string[] = []; + for (const tag of decryptedContent) { + const isMint = tag[0] === 'mint'; + if (isMint) { + mints.push(tag[1]); + } + } + const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal }); for (const token of tokens) { try { diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index c81ebdba..c70682fb 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -7,11 +7,11 @@ import { NostrEvent, NostrFilter, NostrSigner, NSchema as n, NStore } from '@nos 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 { z } from 'zod'; /** * Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough. From e9210118b0fc91c35b6ff96779dbe4ff8379bd97 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 19 Feb 2025 11:34:33 -0300 Subject: [PATCH 10/31] fix: pass mintUrl rather than mint instance --- packages/ditto/controllers/api/cashu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 122fc667..00b78179 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -123,7 +123,7 @@ app.post('/mint/:quote_id', requireNip44Signer, async (c) => { content: await signer.nip44.encrypt( pubkey, JSON.stringify({ - mint, + mint: mintUrl, proofs, }), ), From 741df0bee6e0aced1d1435569c1344218568438f Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 19 Feb 2025 21:21:52 -0300 Subject: [PATCH 11/31] reminder: fix not getting multiple proofs --- packages/ditto/middleware/swapNutzapsMiddleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index c70682fb..f2968171 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -190,7 +190,7 @@ async function getMintsToProofs( continue; } - const proof = event.tags.find(([name]) => name === 'proof')?.[1]; + const proof = event.tags.find(([name]) => name === 'proof')?.[1]; // TODO: fix, multiple 'proof' tags be exist if (!proof) { continue; } From c530aa310db2ab130a2915955e617a327fb39e2b Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 20 Feb 2025 11:27:50 -0300 Subject: [PATCH 12/31] fix: get multiple proofs from nutzap event --- packages/ditto/middleware/swapNutzapsMiddleware.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index f2968171..91f29603 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -190,8 +190,8 @@ async function getMintsToProofs( continue; } - const proof = event.tags.find(([name]) => name === 'proof')?.[1]; // TODO: fix, multiple 'proof' tags be exist - if (!proof) { + const proofs = event.tags.filter(([name]) => name === 'proof').map((tag) => tag[1]).filter(Boolean); + if (proofs.length < 1) { continue; } @@ -207,8 +207,8 @@ async function getMintsToProofs( C: z.string(), dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }).optional(), dleqValid: z.boolean().optional(), - }).array(), - ).safeParse(proof); + }), + ).array().safeParse(proofs); if (!parsed.success) { continue; From 71fd6ef965258d86cd4e92b1657dcfc8d922ac1d Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 20 Feb 2025 19:12:35 -0300 Subject: [PATCH 13/31] 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 }; From 36640dd400f0bf8344d8fb9d6cc9ab5793f93181 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 20 Feb 2025 19:21:20 -0300 Subject: [PATCH 14/31] refactor: explain better the 'toBeRedeemed' field --- packages/ditto/middleware/swapNutzapsMiddleware.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index e490a571..322187de 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -119,8 +119,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" ] ] + * toBeRedeemed are the nutzaps that will be redeemed into a kind 7375 and saved in the kind 7376 tags + * The tags format is: [ + * [ "e", "<9321-event-id>", "", "redeemed" ], // nutzap event that has been redeemed + * [ "p", "" ] // pubkey of the author of the 9321 event (nutzap sender) + * ] + * https://github.com/nostr-protocol/nips/blob/master/61.md#updating-nutzap-redemption-history */ type MintsToProofs = { [key: string]: { proofs: Proof[]; toBeRedeemed: string[][] } }; From d98a4bd2630cb424c624589b68f21b6ff1cadb58 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 20 Feb 2025 19:33:25 -0300 Subject: [PATCH 15/31] refactor: use validateAndParseWallet function in GET /wallet --- packages/ditto/controllers/api/cashu.ts | 43 ++++++++----------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 00b78179..fbae8b95 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -2,19 +2,22 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; import { confRequiredMw } from '@ditto/api/middleware'; import { Hono } from '@hono/hono'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; -import { NSchema as n } from '@nostrify/nostrify'; +import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { bytesToString, stringToBytes } from '@scure/base'; +import { logi } from '@soapbox/logi'; import { z } from 'zod'; import { createEvent, parseBody } from '@/utils/api.ts'; import { requireNip44Signer } from '@/middleware/requireSigner.ts'; import { requireStore } from '@/middleware/storeMiddleware.ts'; -import { walletSchema } from '@/schema.ts'; import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; +import { walletSchema } from '@/schema.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; import { isNostrId, nostrNow } from '@/utils.ts'; -import { logi } from '@soapbox/logi'; import { errorJson } from '@/utils/log.ts'; import { getAmount } from '@/utils/bolt11.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { validateAndParseWallet } from '@/utils/cashu.ts'; type Wallet = z.infer; @@ -107,7 +110,7 @@ app.post('/mint/:quote_id', requireNip44Signer, async (c) => { const now = nostrNow(); try { - if (mintUrl && (expiration > now) && (quote_id === decryptedQuoteId)) { + if (mintUrl && (expiration > now) && (quote_id === decryptedQuoteId)) { // TODO: organize order of operations of deleting expired quote ids const mint = new CashuMint(mintUrl); const wallet = new CashuWallet(mint); await wallet.loadMint(); @@ -198,7 +201,7 @@ app.put('/wallet', requireNip44Signer, async (c) => { const sk = generateSecretKey(); const privkey = bytesToString('hex', sk); - const p2pk = getPublicKey(stringToBytes('hex', privkey)); + const p2pk = getPublicKey(sk); walletContentTags.push(['privkey', privkey]); @@ -242,34 +245,14 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => { const pubkey = await signer.getPublicKey(); const { signal } = c.req.raw; - const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); - if (!event) { - return c.json({ error: 'Wallet not found' }, 404); + const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal }); + if (error) { + return c.json({ error: error.message }, 404); } - const { data: decryptedContent, success } = n.json().pipe(z.string().array().array()).safeParse( - await signer.nip44.decrypt(pubkey, event.content), - ); - if (!success) { - return c.json({ error: 'Could not decrypt wallet content' }, 422); - } - - const privkey = decryptedContent.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.' }, 422); - } - - const p2pk = getPublicKey(stringToBytes('hex', privkey)); + const { p2pk, mints } = data; let balance = 0; - const mints: string[] = []; - - for (const tag of decryptedContent) { - const isMint = tag[0] === 'mint'; - if (isMint) { - mints.push(tag[1]); - } - } const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal }); for (const token of tokens) { @@ -286,7 +269,7 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => { return accumulator + current.amount; }, 0); } catch (e) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); + logi({ level: 'error', ns: 'ditto.api.cashu.wallet', error: errorJson(e) }); } } From a6fba9c8f198b619d1ab484b39f52b4ec0326aef Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 20 Feb 2025 19:34:58 -0300 Subject: [PATCH 16/31] fix: remove unused imports --- packages/ditto/controllers/api/cashu.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index fbae8b95..84389809 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -3,7 +3,7 @@ import { confRequiredMw } from '@ditto/api/middleware'; import { Hono } from '@hono/hono'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; -import { bytesToString, stringToBytes } from '@scure/base'; +import { bytesToString } from '@scure/base'; import { logi } from '@soapbox/logi'; import { z } from 'zod'; @@ -12,11 +12,9 @@ import { requireNip44Signer } from '@/middleware/requireSigner.ts'; import { requireStore } from '@/middleware/storeMiddleware.ts'; import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; import { walletSchema } from '@/schema.ts'; -import { hydrateEvents } from '@/storages/hydrate.ts'; -import { isNostrId, nostrNow } from '@/utils.ts'; +import { nostrNow } from '@/utils.ts'; import { errorJson } from '@/utils/log.ts'; import { getAmount } from '@/utils/bolt11.ts'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { validateAndParseWallet } from '@/utils/cashu.ts'; type Wallet = z.infer; From d87f650da3f1b0d59df6348ca0084977b3d4e723 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 20 Feb 2025 19:36:02 -0300 Subject: [PATCH 17/31] fix: remove unused imports --- packages/ditto/controllers/api/cashu.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 84389809..6d758c6f 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -2,7 +2,6 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; import { confRequiredMw } from '@ditto/api/middleware'; import { Hono } from '@hono/hono'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; -import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { bytesToString } from '@scure/base'; import { logi } from '@soapbox/logi'; import { z } from 'zod'; From 08d2f7fe3f4f7f74122b45631955a5bd26bf3acc Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 21 Feb 2025 11:41:45 -0300 Subject: [PATCH 18/31] refactor: add cashu proof schema --- packages/ditto/middleware/swapNutzapsMiddleware.ts | 11 ++--------- packages/ditto/schemas/cashu.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 packages/ditto/schemas/cashu.ts diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index 322187de..9bf8c640 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -5,11 +5,11 @@ import { HTTPException } from '@hono/hono/http-exception'; import { NostrEvent, NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify'; import { SetRequired } from 'type-fest'; import { logi } from '@soapbox/logi'; -import { z } from 'zod'; import { errorJson } from '@/utils/log.ts'; import { createEvent } from '@/utils/api.ts'; import { validateAndParseWallet } from '@/utils/cashu.ts'; +import { proofSchema } from '@/schemas/cashu.ts'; /** * Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough. @@ -172,14 +172,7 @@ async function getMintsToProofs( } const parsed = n.json().pipe( - z.object({ - id: z.string(), - amount: z.number(), - secret: z.string(), - C: z.string(), - dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }).optional(), - dleqValid: z.boolean().optional(), - }), + proofSchema, ).array().safeParse(proofs); if (!parsed.success) { diff --git a/packages/ditto/schemas/cashu.ts b/packages/ditto/schemas/cashu.ts new file mode 100644 index 00000000..4e3599e5 --- /dev/null +++ b/packages/ditto/schemas/cashu.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const proofSchema = z.object({ + id: z.string(), + amount: z.number(), + secret: z.string(), + C: z.string(), + dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }).optional(), + dleqValid: z.boolean().optional(), +}); From ac684194a0d0d2d5dd4437f39536d9e5bc239be5 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 21 Feb 2025 16:48:44 -0300 Subject: [PATCH 19/31] feat: create tokenEventSchema and add tests for cashu schemas --- packages/ditto/schemas/cashu.test.ts | 38 ++++++++++++++++++++++++++++ packages/ditto/schemas/cashu.ts | 7 +++++ 2 files changed, 45 insertions(+) create mode 100644 packages/ditto/schemas/cashu.test.ts diff --git a/packages/ditto/schemas/cashu.test.ts b/packages/ditto/schemas/cashu.test.ts new file mode 100644 index 00000000..57749c7a --- /dev/null +++ b/packages/ditto/schemas/cashu.test.ts @@ -0,0 +1,38 @@ +import { NSchema as n } from '@nostrify/nostrify'; +import { assertEquals } from '@std/assert'; +import { proofSchema } from '@/schemas/cashu.ts'; +import { tokenEventSchema } from '@/schemas/cashu.ts'; + +Deno.test('Parse proof', () => { + const proof = + '{"id":"004f7adf2a04356c","amount":1,"secret":"6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0","C":"03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3","dleq":{"e":"bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351","s":"a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67","r":"b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df"}}'; + + assertEquals(n.json().pipe(proofSchema).safeParse(proof).success, true); + assertEquals(n.json().pipe(proofSchema).safeParse(JSON.parse(proof)).success, false); + assertEquals(proofSchema.safeParse(JSON.parse(proof)).success, true); + assertEquals(proofSchema.safeParse(proof).success, false); +}); + +Deno.test('Parse token', () => { + const proof = { + 'id': '004f7adf2a04356c', + 'amount': 1, + 'secret': '6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0', + 'C': '03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3', + 'dleq': { + 'e': 'bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351', + 's': 'a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67', + 'r': 'b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df', + }, + }; + const token = JSON.stringify({ + mint: 'https://mint-fashion.com', + proofs: [proof], + del: [], + }); + + assertEquals(n.json().pipe(tokenEventSchema).safeParse(token).success, true); + assertEquals(n.json().pipe(tokenEventSchema).safeParse(JSON.parse(token)).success, false); + assertEquals(tokenEventSchema.safeParse(JSON.parse(token)).success, true); + assertEquals(tokenEventSchema.safeParse(tokenEventSchema).success, false); +}); diff --git a/packages/ditto/schemas/cashu.ts b/packages/ditto/schemas/cashu.ts index 4e3599e5..1860f874 100644 --- a/packages/ditto/schemas/cashu.ts +++ b/packages/ditto/schemas/cashu.ts @@ -8,3 +8,10 @@ export const proofSchema = z.object({ dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }).optional(), dleqValid: z.boolean().optional(), }); + +/** Decrypted content of a kind 7375 */ +export const tokenEventSchema = z.object({ + mint: z.string().url(), + proofs: proofSchema.array(), + del: z.string().array().optional(), +}); From 61cc7c335aa602200a29edfd13dacabb471e5780 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sun, 23 Feb 2025 21:06:34 -0300 Subject: [PATCH 20/31] feat: create organizedProofs function test: organizedProofs function --- packages/ditto/utils/cashu.test.ts | 118 ++++++++++++++++++++++++++++- packages/ditto/utils/cashu.ts | 42 +++++++++- 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/packages/ditto/utils/cashu.test.ts b/packages/ditto/utils/cashu.test.ts index 9a0621e8..14f14900 100644 --- a/packages/ditto/utils/cashu.test.ts +++ b/packages/ditto/utils/cashu.test.ts @@ -5,7 +5,7 @@ import { assertEquals } from '@std/assert'; import { createTestDB, genEvent } from '@/test.ts'; -import { validateAndParseWallet } from '@/utils/cashu.ts'; +import { organizeProofs, validateAndParseWallet } from '@/utils/cashu.ts'; Deno.test('validateAndParseWallet function returns valid data', async () => { await using db = await createTestDB({ pure: true }); @@ -51,3 +51,119 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { mints: ['https://mint.soul.com'], }); }); + +Deno.test('organizeProofs function is working', 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 event1 = genEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify({ + mint: 'https://mint.soul.com', + proofs: [ + { + id: '005c2502034d4f12', + amount: 25, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }, + { + id: '005c2502034d4f12', + amount: 25, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }, + { + id: '005c2502034d4f12', + amount: 25, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }, + { + id: '005c2502034d4f12', + amount: 25, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }, + ], + del: [], + }), + ), + }, sk); + await db.store.event(event1); + + const proof1 = { + 'id': '004f7adf2a04356c', + 'amount': 1, + 'secret': '6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0', + 'C': '03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3', + 'dleq': { + 'e': 'bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351', + 's': 'a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67', + 'r': 'b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df', + }, + }; + const token1 = JSON.stringify({ + mint: 'https://mint-fashion.com', + proofs: [proof1], + del: [], + }); + + const event2 = genEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + token1, + ), + }, sk); + await db.store.event(event2); + + const proof2 = { + 'id': '004f7adf2a04356c', + 'amount': 123, + 'secret': '6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0', + 'C': '03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3', + 'dleq': { + 'e': 'bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351', + 's': 'a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67', + 'r': 'b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df', + }, + }; + + const token2 = JSON.stringify({ + mint: 'https://mint-fashion.com', + proofs: [proof2], + del: [], + }); + + const event3 = genEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + token2, + ), + }, sk); + await db.store.event(event3); + + const unspentProofs = await store.query([{ kinds: [7375], authors: [pubkey] }]); + + const organizedProofs = await organizeProofs(unspentProofs, signer); + + assertEquals(organizedProofs, { + 'https://mint.soul.com': { + totalBalance: 100, + [event1.id]: { event: event1, balance: 100 }, + }, + 'https://mint-fashion.com': { + totalBalance: 124, + [event2.id]: { event: event2, balance: 1 }, + [event3.id]: { event: event3, balance: 123 }, + }, + }); +}); diff --git a/packages/ditto/utils/cashu.ts b/packages/ditto/utils/cashu.ts index 05a88272..625c86f3 100644 --- a/packages/ditto/utils/cashu.ts +++ b/packages/ditto/utils/cashu.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import { errorJson } from '@/utils/log.ts'; import { isNostrId } from '@/utils.ts'; +import { tokenEventSchema } from '@/schemas/cashu.ts'; type Data = { wallet: NostrEvent; @@ -99,4 +100,43 @@ async function validateAndParseWallet( return { data: { wallet, nutzapInfo, privkey, p2pk, mints }, error: null }; } -export { validateAndParseWallet }; +type OrganizedProofs = { + [mintUrl: string]: { + /** Total balance in this mint */ + totalBalance: number; + /** Event id */ + [eventId: string]: { + event: NostrEvent; + /** Total balance in this event */ + balance: number; + } | number; + }; +}; +async function organizeProofs( + events: NostrEvent[], + signer: SetRequired, +): Promise { + const organizedProofs: OrganizedProofs = {}; + const pubkey = await signer.getPublicKey(); + + for (const event of events) { + const decryptedContent = await signer.nip44.decrypt(pubkey, event.content); + const { data: token, success } = n.json().pipe(tokenEventSchema).safeParse(decryptedContent); + if (!success) { + continue; + } + const { mint, proofs } = token; + + const balance = proofs.reduce((prev, current) => prev + current.amount, 0); + + if (!organizedProofs[mint]) { + organizedProofs[mint] = { totalBalance: 0 }; + } + + organizedProofs[mint] = { ...organizedProofs[mint], [event.id]: { event, balance } }; + organizedProofs[mint].totalBalance += balance; + } + return organizedProofs; +} + +export { organizeProofs, validateAndParseWallet }; From 1d2a3170294c82f1d18854431b90cd91a218c743 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 24 Feb 2025 17:34:14 -0300 Subject: [PATCH 21/31] feat: nutzap a post or user (no tests) --- packages/ditto/controllers/api/cashu.ts | 160 +++++++++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 6d758c6f..6c629629 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -2,6 +2,7 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; import { confRequiredMw } from '@ditto/api/middleware'; import { Hono } from '@hono/hono'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; +import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { bytesToString } from '@scure/base'; import { logi } from '@soapbox/logi'; import { z } from 'zod'; @@ -11,10 +12,13 @@ import { requireNip44Signer } from '@/middleware/requireSigner.ts'; import { requireStore } from '@/middleware/storeMiddleware.ts'; import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; import { walletSchema } from '@/schema.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; import { nostrNow } from '@/utils.ts'; import { errorJson } from '@/utils/log.ts'; import { getAmount } from '@/utils/bolt11.ts'; -import { validateAndParseWallet } from '@/utils/cashu.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { organizeProofs, validateAndParseWallet } from '@/utils/cashu.ts'; +import { tokenEventSchema } from '@/schemas/cashu.ts'; type Wallet = z.infer; @@ -291,4 +295,158 @@ app.get('/mints', (c) => { return c.json({ mints }, 200); }); +const nutzapSchema = z.object({ + account_id: n.id(), + status_id: n.id().optional(), + amount: z.number().int().positive(), + comment: z.string().optional(), +}); + +/** Nutzaps a post or a user. */ +app.post('/nutzap', requireNip44Signer, async (c) => { + const store = c.get('store'); + const { signal } = c.req.raw; + const { conf, signer } = c.var; + const pubkey = await signer.getPublicKey(); + const body = await parseBody(c.req.raw); + const result = nutzapSchema.safeParse(body); + + if (!result.success) { + return c.json({ error: 'Bad schema', schema: result.error }, 400); + } + + const { account_id, status_id, amount, comment } = result.data; + let event: DittoEvent; + + if (status_id) { + [event] = await store.query([{ kinds: [1], ids: [status_id] }], { signal }); + if (!event) { + return c.json({ error: 'Status not found' }, 404); + } + await hydrateEvents({ events: [event], store, signal }); + } else { + [event] = await store.query([{ kinds: [0], authors: [account_id] }], { signal }); + if (!event) { + return c.json({ error: 'Account not found' }, 404); + } + } + + if (event.kind === 1 && (event.author?.pubkey !== account_id)) { + return c.json({ error: 'Post author does not match author' }, 422); + } + + const [nutzapInfo] = await store.query([{ kinds: [10019], authors: [account_id] }], { signal }); + if (!nutzapInfo) { + return c.json({ error: 'Target user does not have a nutzap information event' }, 404); + } + + const recipientMints = event.tags.filter(([name]) => name === 'mint').map((tag) => tag[1]).filter(Boolean); + if (recipientMints.length < 1) { + return c.json({ error: 'Target user does not have any mints setup' }, 422); + } + + const p2pk = event.tags.find(([name]) => name === 'pubkey')?.[1]; + if (!p2pk) { + return c.json({ error: 'Target user does not have a cashu pubkey' }, 422); + } + + const unspentProofs = await store.query([{ kinds: [7375], authors: [pubkey] }], { signal }); + const organizedProofs = await organizeProofs(unspentProofs, signer); + + const proofsToBeUsed: Proof[] = []; + const eventsToBeDeleted: NostrEvent[] = []; + let selectedMint: string | undefined; + + for (const mint of recipientMints) { + if (organizedProofs[mint].totalBalance > amount) { + selectedMint = mint; + let minimumRequiredBalance = 0; + + for (const key of Object.keys(organizedProofs[mint])) { + if (key === 'totalBalance' || typeof organizedProofs[mint][key] === 'number') { + continue; + } + + if (minimumRequiredBalance > amount) { + break; + } + + const event = organizedProofs[mint][key].event; + const decryptedContent = await signer.nip44.decrypt(pubkey, event.content); + + const { data: token, success } = n.json().pipe(tokenEventSchema).safeParse(decryptedContent); + + if (!success) { + continue; // TODO: maybe abort everything + } + + const { proofs } = token; + + proofsToBeUsed.push(...proofs); + eventsToBeDeleted.push(event); + minimumRequiredBalance += organizedProofs[mint][key].balance; + } + break; + } + } + + if (!selectedMint) { + return c.json({ error: 'You do not have mints in common with enough balance' }, 422); + } + + const mint = new CashuMint(selectedMint); + const wallet = new CashuWallet(mint); + await wallet.loadMint(); + + const { keep: proofsToKeep, send: proofsToSend } = await wallet.send(amount, proofsToBeUsed, { includeFees: true }); + + const newUnspentProof = await createEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify({ + mint: selectedMint, + proofs: proofsToKeep, + del: eventsToBeDeleted.map((e) => e.id), + }), + ), + }, c); + + await createEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'out'], + ['amount', String(proofsToSend.reduce((accumulator, current) => accumulator + current.amount, 0))], + ...eventsToBeDeleted.map((e) => ['e', e.id, conf.relay, 'destroyed']), + ['e', newUnspentProof.id, conf.relay, 'created'], + ]), + ), + }, c); + + await createEvent({ + kind: 5, + tags: eventsToBeDeleted.map((e) => ['e', e.id, conf.relay]), + }, c); + + const nutzapTags: string[][] = [ + ...proofsToSend.map((proof) => ['proof', JSON.stringify(proof)]), + ['u', selectedMint], + ['p', account_id], // recipient of nutzap + ]; + if (status_id) { + nutzapTags.push(['e', status_id, conf.relay]); + } + + // nutzap + await createEvent({ + kind: 9321, + content: comment ?? '', + tags: nutzapTags, + }, c); + + return c.json({ message: 'Nutzap with success!!!' }, 200); // TODO: return wallet entity +}); + export default app; From 33b8078fc60801dfc4b65d36bbc5853e6aaf00a2 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 25 Feb 2025 13:58:57 -0300 Subject: [PATCH 22/31] fix: add pubkey lock to proofs --- packages/ditto/controllers/api/cashu.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 145bac6c..1a68d647 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -388,7 +388,10 @@ route.post('/nutzap', userMiddleware({ enc: 'nip44' }), async (c) => { const wallet = new CashuWallet(mint); await wallet.loadMint(); - const { keep: proofsToKeep, send: proofsToSend } = await wallet.send(amount, proofsToBeUsed, { includeFees: true }); + const { keep: proofsToKeep, send: proofsToSend } = await wallet.send(amount, proofsToBeUsed, { + includeFees: true, + pubkey: p2pk.length === 64 ? '02' + p2pk : p2pk, + }); const newUnspentProof = await createEvent({ kind: 7375, From 51faffc9e2605fd9805c7dfc2dce4ae35ae2ecd0 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 25 Feb 2025 15:56:41 -0300 Subject: [PATCH 23/31] fix: get mints from nutzap info --- packages/ditto/controllers/api/cashu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 1a68d647..05e12203 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -330,7 +330,7 @@ route.post('/nutzap', userMiddleware({ enc: 'nip44' }), async (c) => { return c.json({ error: 'Target user does not have a nutzap information event' }, 404); } - const recipientMints = event.tags.filter(([name]) => name === 'mint').map((tag) => tag[1]).filter(Boolean); + const recipientMints = nutzapInfo.tags.filter(([name]) => name === 'mint').map((tag) => tag[1]).filter(Boolean); if (recipientMints.length < 1) { return c.json({ error: 'Target user does not have any mints setup' }, 422); } From e08603a42ad48823e3656b2c6c03b0aeb55b923a Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 25 Feb 2025 15:59:40 -0300 Subject: [PATCH 24/31] fix: get lock pubkey from nutzap info --- packages/ditto/controllers/api/cashu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 05e12203..5effd45c 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -335,7 +335,7 @@ route.post('/nutzap', userMiddleware({ enc: 'nip44' }), async (c) => { return c.json({ error: 'Target user does not have any mints setup' }, 422); } - const p2pk = event.tags.find(([name]) => name === 'pubkey')?.[1]; + const p2pk = nutzapInfo.tags.find(([name]) => name === 'pubkey')?.[1]; if (!p2pk) { return c.json({ error: 'Target user does not have a cashu pubkey' }, 422); } From cf4d888ad58839ed6c5f692d740ef3f2df5b8b09 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 25 Feb 2025 16:20:40 -0300 Subject: [PATCH 25/31] fix: total balance can be equal to the amount, access undefined with ? --- packages/ditto/controllers/api/cashu.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 5effd45c..249c5b60 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -348,7 +348,7 @@ route.post('/nutzap', userMiddleware({ enc: 'nip44' }), async (c) => { let selectedMint: string | undefined; for (const mint of recipientMints) { - if (organizedProofs[mint].totalBalance > amount) { + if (organizedProofs[mint]?.totalBalance >= amount) { selectedMint = mint; let minimumRequiredBalance = 0; @@ -357,7 +357,7 @@ route.post('/nutzap', userMiddleware({ enc: 'nip44' }), async (c) => { continue; } - if (minimumRequiredBalance > amount) { + if (minimumRequiredBalance >= amount) { break; } From 8479143ed88f135589d3cd6db998dc9b69aa2ce9 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 26 Feb 2025 20:45:02 -0300 Subject: [PATCH 26/31] refactor: minimalist check of status_id and account_id --- packages/ditto/controllers/api/cashu.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 249c5b60..03b3a280 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -14,7 +14,6 @@ import { hydrateEvents } from '@/storages/hydrate.ts'; import { nostrNow } from '@/utils.ts'; import { errorJson } from '@/utils/log.ts'; import { getAmount } from '@/utils/bolt11.ts'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { organizeProofs, validateAndParseWallet } from '@/utils/cashu.ts'; import { tokenEventSchema } from '@/schemas/cashu.ts'; @@ -306,19 +305,16 @@ route.post('/nutzap', userMiddleware({ enc: 'nip44' }), async (c) => { } const { account_id, status_id, amount, comment } = result.data; - let event: DittoEvent; + + const filter = status_id ? [{ kinds: [1], ids: [status_id] }] : [{ kinds: [0], authors: [account_id] }]; + const [event] = await relay.query(filter, { signal }); + + if (!event) { + return c.json({ error: status_id ? 'Status not found' : 'Account not found' }, 404); + } if (status_id) { - [event] = await relay.query([{ kinds: [1], ids: [status_id] }], { signal }); - if (!event) { - return c.json({ error: 'Status not found' }, 404); - } await hydrateEvents({ ...c.var, events: [event] }); - } else { - [event] = await relay.query([{ kinds: [0], authors: [account_id] }], { signal }); - if (!event) { - return c.json({ error: 'Account not found' }, 404); - } } if (event.kind === 1 && (event.author?.pubkey !== account_id)) { From 7e4e6635adce640d76ba7051bab2b32d09dac465 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 26 Feb 2025 20:54:11 -0300 Subject: [PATCH 27/31] fix: type error --- packages/ditto/controllers/api/cashu.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 03b3a280..9115173a 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -16,6 +16,7 @@ import { errorJson } from '@/utils/log.ts'; import { getAmount } from '@/utils/bolt11.ts'; import { organizeProofs, validateAndParseWallet } from '@/utils/cashu.ts'; import { tokenEventSchema } from '@/schemas/cashu.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; type Wallet = z.infer; @@ -317,7 +318,7 @@ route.post('/nutzap', userMiddleware({ enc: 'nip44' }), async (c) => { await hydrateEvents({ ...c.var, events: [event] }); } - if (event.kind === 1 && (event.author?.pubkey !== account_id)) { + if (event.kind === 1 && ((event as DittoEvent)?.author?.pubkey !== account_id)) { return c.json({ error: 'Post author does not match author' }, 422); } From a002b1a005fde0d7485bba0c7940dedc745e2d9f Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 11 Mar 2025 11:34:42 -0300 Subject: [PATCH 28/31] refactor: create @ditto/cashu package --- deno.json | 3 +- packages/{ditto/utils => cashu}/cashu.test.ts | 95 ++++++++++++-- packages/{ditto/utils => cashu}/cashu.ts | 123 +++++++++++++++++- packages/cashu/deno.json | 7 + packages/cashu/mod.ts | 2 + .../cashu.test.ts => cashu/schemas.test.ts} | 5 +- .../schemas/cashu.ts => cashu/schemas.ts} | 0 packages/ditto/controllers/api/cashu.ts | 3 +- .../ditto/middleware/swapNutzapsMiddleware.ts | 104 +-------------- 9 files changed, 218 insertions(+), 124 deletions(-) rename packages/{ditto/utils => cashu}/cashu.test.ts (65%) rename packages/{ditto/utils => cashu}/cashu.ts (54%) create mode 100644 packages/cashu/deno.json create mode 100644 packages/cashu/mod.ts rename packages/{ditto/schemas/cashu.test.ts => cashu/schemas.test.ts} (94%) rename packages/{ditto/schemas/cashu.ts => cashu/schemas.ts} (100%) diff --git a/deno.json b/deno.json index 33cb119c..efd53e63 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,8 @@ "./packages/ratelimiter", "./packages/transcode", "./packages/translators", - "./packages/uploaders" + "./packages/uploaders", + "./packages/cashu" ], "tasks": { "start": "deno run -A --env-file --deny-read=.env packages/ditto/server.ts", diff --git a/packages/ditto/utils/cashu.test.ts b/packages/cashu/cashu.test.ts similarity index 65% rename from packages/ditto/utils/cashu.test.ts rename to packages/cashu/cashu.test.ts index 6e3c80f6..a9d24d1a 100644 --- a/packages/ditto/utils/cashu.test.ts +++ b/packages/cashu/cashu.test.ts @@ -1,17 +1,25 @@ import { NSecSigner } from '@nostrify/nostrify'; +import { NPostgres } from '@nostrify/db'; import { genEvent } from '@nostrify/nostrify/test'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; import { assertEquals } from '@std/assert'; -import { createTestDB } from '@/test.ts'; +import { DittoPolyPg, TestDB } from '@ditto/db'; +import { DittoConf } from '@ditto/conf'; -import { organizeProofs, validateAndParseWallet } from '@/utils/cashu.ts'; +import { getLastRedeemedNutzap, organizeProofs, validateAndParseWallet } from './cashu.ts'; Deno.test('validateAndParseWallet function returns valid data', async () => { - await using db = await createTestDB({ pure: true }); - const store = db.store; + const conf = new DittoConf(Deno.env); + const orig = new DittoPolyPg(conf.databaseUrl); + + await using db = new TestDB(orig); + await db.migrate(); + await db.clear(); + + const store = new NPostgres(orig.kysely); const sk = generateSecretKey(); const signer = new NSecSigner(sk); @@ -30,7 +38,7 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { ]), ), }, sk); - await db.store.event(wallet); + await store.event(wallet); // Nutzap information const nutzapInfo = genEvent({ @@ -40,7 +48,7 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { ['mint', 'https://mint.soul.com'], ], }, sk); - await db.store.event(nutzapInfo); + await store.event(nutzapInfo); const { data, error } = await validateAndParseWallet(store, signer, pubkey); @@ -55,8 +63,14 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { }); Deno.test('organizeProofs function is working', async () => { - await using db = await createTestDB({ pure: true }); - const store = db.store; + const conf = new DittoConf(Deno.env); + const orig = new DittoPolyPg(conf.databaseUrl); + + await using db = new TestDB(orig); + await db.migrate(); + await db.clear(); + + const store = new NPostgres(orig.kysely); const sk = generateSecretKey(); const signer = new NSecSigner(sk); @@ -98,7 +112,7 @@ Deno.test('organizeProofs function is working', async () => { }), ), }, sk); - await db.store.event(event1); + await store.event(event1); const proof1 = { 'id': '004f7adf2a04356c', @@ -124,7 +138,7 @@ Deno.test('organizeProofs function is working', async () => { token1, ), }, sk); - await db.store.event(event2); + await store.event(event2); const proof2 = { 'id': '004f7adf2a04356c', @@ -151,7 +165,7 @@ Deno.test('organizeProofs function is working', async () => { token2, ), }, sk); - await db.store.event(event3); + await store.event(event3); const unspentProofs = await store.query([{ kinds: [7375], authors: [pubkey] }]); @@ -169,3 +183,62 @@ Deno.test('organizeProofs function is working', async () => { }, }); }); + +Deno.test('getLastRedeemedNutzap function is working', async () => { + const conf = new DittoConf(Deno.env); + const orig = new DittoPolyPg(conf.databaseUrl); + + await using db = new TestDB(orig); + await db.migrate(); + await db.clear(); + + const store = new NPostgres(orig.kysely); + + const sk = generateSecretKey(); + const signer = new NSecSigner(sk); + const pubkey = await signer.getPublicKey(); + + const event1 = genEvent({ + kind: 7376, + content: '', + created_at: Math.floor(Date.now() / 1000), // now + tags: [ + ['e', '', '', 'redeemed'], + ], + }, sk); + await store.event(event1); + + const event2 = genEvent({ + kind: 7376, + content: '', + created_at: Math.floor((Date.now() - 86400000) / 1000), // yesterday + tags: [ + ['e', '', '', 'redeemed'], + ], + }, sk); + await store.event(event2); + + const event3 = genEvent({ + kind: 7376, + content: '', + created_at: Math.floor((Date.now() - 86400000) / 1000), // yesterday + tags: [ + ['e', '', '', 'redeemed'], + ], + }, sk); + await store.event(event3); + + const event4 = genEvent({ + kind: 7376, + content: '', + created_at: Math.floor((Date.now() + 86400000) / 1000), // tomorrow + tags: [ + ['e', '', '', 'redeemed'], + ], + }, sk); + await store.event(event4); + + const event = await getLastRedeemedNutzap(store, pubkey); + + assertEquals(event, event4); +}); diff --git a/packages/ditto/utils/cashu.ts b/packages/cashu/cashu.ts similarity index 54% rename from packages/ditto/utils/cashu.ts rename to packages/cashu/cashu.ts index 625c86f3..77c61c0c 100644 --- a/packages/ditto/utils/cashu.ts +++ b/packages/cashu/cashu.ts @@ -1,13 +1,12 @@ -import { SetRequired } from 'type-fest'; +import type { Proof } from '@cashu/cashu-ts'; +import { type NostrEvent, type NostrFilter, type NostrSigner, NSchema as n, type NStore } from '@nostrify/nostrify'; 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 { logi } from '@soapbox/logi'; +import type { SetRequired } from 'type-fest'; import { z } from 'zod'; -import { errorJson } from '@/utils/log.ts'; -import { isNostrId } from '@/utils.ts'; -import { tokenEventSchema } from '@/schemas/cashu.ts'; +import { proofSchema, tokenEventSchema } from './schemas.ts'; type Data = { wallet: NostrEvent; @@ -139,4 +138,114 @@ async function organizeProofs( return organizedProofs; } -export { organizeProofs, validateAndParseWallet }; +/** Returns a spending history event that contains the last redeemed nutzap. */ +async function getLastRedeemedNutzap( + store: NStore, + pubkey: string, + opts?: { signal?: AbortSignal }, +): Promise { + const events = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal: opts?.signal }); + + for (const event of events) { + const nutzap = event.tags.find(([name]) => name === 'e'); + const redeemed = nutzap?.[3]; + if (redeemed === 'redeemed') { + return event; + } + } +} + +/** + * toBeRedeemed are the nutzaps that will be redeemed into a kind 7375 and saved in the kind 7376 tags + * The tags format is: [ + * [ "e", "<9321-event-id>", "", "redeemed" ], // nutzap event that has been redeemed + * [ "p", "" ] // pubkey of the author of the 9321 event (nutzap sender) + * ] + * https://github.com/nostr-protocol/nips/blob/master/61.md#updating-nutzap-redemption-history + */ +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. + * @param store Store used to query for the nutzaps + * @param nutzapsFilter Filter used to query for the nutzaps, most useful when + * it contains a 'since' field so it saves time and resources + * @param relay Relay hint where the new kind 7376 will be saved + * @returns MintsToProofs An object where each key is a mint url and the values are an array of proofs + * and an array of redeemed tags in this format: + * ``` + * [ + * ..., + * [ "e", "<9321-event-id>", "", "redeemed" ], // nutzap event that has been redeemed + * [ "p", "" ] // pubkey of the author of the 9321 event (nutzap sender) + * ] + * ``` + */ +async function getMintsToProofs( + store: NStore, + nutzapsFilter: NostrFilter, + relay: string, + opts?: { signal?: AbortSignal }, +): Promise { + const mintsToProofs: MintsToProofs = {}; + + const nutzaps = await store.query([nutzapsFilter], { signal: opts?.signal }); + + for (const event of nutzaps) { + try { + const mint = event.tags.find(([name]) => name === 'u')?.[1]; + if (!mint) { + continue; + } + + const proofs = event.tags.filter(([name]) => name === 'proof').map((tag) => tag[1]).filter(Boolean); + if (proofs.length < 1) { + continue; + } + + if (!mintsToProofs[mint]) { + mintsToProofs[mint] = { proofs: [], toBeRedeemed: [] }; + } + + const parsed = n.json().pipe( + proofSchema, + ).array().safeParse(proofs); + + if (!parsed.success) { + continue; + } + + mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data]; + mintsToProofs[mint].toBeRedeemed = [ + ...mintsToProofs[mint].toBeRedeemed, + [ + 'e', // nutzap event that has been redeemed + event.id, + relay, + 'redeemed', + ], + ['p', event.pubkey], // pubkey of the author of the 9321 event (nutzap sender) + ]; + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); + } + } + + return mintsToProofs; +} + +/** Serialize an error into JSON for JSON logging. */ +export function errorJson(error: unknown): Error | null { + if (error instanceof Error) { + return error; + } else { + return null; + } +} + +function isNostrId(value: unknown): boolean { + return n.id().safeParse(value).success; +} + +export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet }; diff --git a/packages/cashu/deno.json b/packages/cashu/deno.json new file mode 100644 index 00000000..ce73c269 --- /dev/null +++ b/packages/cashu/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@ditto/cashu", + "version": "0.1.0", + "exports": { + ".": "./mod.ts" + } +} diff --git a/packages/cashu/mod.ts b/packages/cashu/mod.ts new file mode 100644 index 00000000..47b2f9a0 --- /dev/null +++ b/packages/cashu/mod.ts @@ -0,0 +1,2 @@ +export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts'; +export { proofSchema, tokenEventSchema } from './schemas.ts'; diff --git a/packages/ditto/schemas/cashu.test.ts b/packages/cashu/schemas.test.ts similarity index 94% rename from packages/ditto/schemas/cashu.test.ts rename to packages/cashu/schemas.test.ts index 57749c7a..5c42534a 100644 --- a/packages/ditto/schemas/cashu.test.ts +++ b/packages/cashu/schemas.test.ts @@ -1,7 +1,8 @@ import { NSchema as n } from '@nostrify/nostrify'; import { assertEquals } from '@std/assert'; -import { proofSchema } from '@/schemas/cashu.ts'; -import { tokenEventSchema } from '@/schemas/cashu.ts'; + +import { proofSchema } from './schemas.ts'; +import { tokenEventSchema } from './schemas.ts'; Deno.test('Parse proof', () => { const proof = diff --git a/packages/ditto/schemas/cashu.ts b/packages/cashu/schemas.ts similarity index 100% rename from packages/ditto/schemas/cashu.ts rename to packages/cashu/schemas.ts diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 57a53722..e121f809 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,4 +1,5 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; +import { organizeProofs, tokenEventSchema, validateAndParseWallet } from '@ditto/cashu'; import { userMiddleware } from '@ditto/mastoapi/middleware'; import { DittoRoute } from '@ditto/mastoapi/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; @@ -14,8 +15,6 @@ import { hydrateEvents } from '@/storages/hydrate.ts'; import { nostrNow } from '@/utils.ts'; import { errorJson } from '@/utils/log.ts'; import { getAmount } from '@/utils/bolt11.ts'; -import { organizeProofs, validateAndParseWallet } from '@/utils/cashu.ts'; -import { tokenEventSchema } from '@/schemas/cashu.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; type Wallet = z.infer; diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index ea410d5a..b7551d0b 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -1,12 +1,11 @@ -import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; +import { CashuMint, CashuWallet, getEncodedToken } from '@cashu/cashu-ts'; +import { getLastRedeemedNutzap, getMintsToProofs, validateAndParseWallet } from '@ditto/cashu'; import { HTTPException } from '@hono/hono/http-exception'; -import { NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify'; +import { NostrFilter } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; import { errorJson } from '@/utils/log.ts'; import { createEvent } from '@/utils/api.ts'; -import { validateAndParseWallet } from '@/utils/cashu.ts'; -import { proofSchema } from '@/schemas/cashu.ts'; import { MiddlewareHandler } from '@hono/hono/types'; /** @@ -93,100 +92,3 @@ export const swapNutzapsMiddleware: MiddlewareHandler = async (c, next) => { await next(); }; - -/** Returns a spending history event that contains the last redeemed nutzap. */ -async function getLastRedeemedNutzap( - store: NStore, - pubkey: string, - opts?: { signal?: AbortSignal }, -): Promise { - const events = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal: opts?.signal }); - - for (const event of events) { - const nutzap = event.tags.find(([name]) => name === 'e'); - const redeemed = nutzap?.[3]; - if (redeemed === 'redeemed') { - return event; - } - } -} - -/** - * toBeRedeemed are the nutzaps that will be redeemed into a kind 7375 and saved in the kind 7376 tags - * The tags format is: [ - * [ "e", "<9321-event-id>", "", "redeemed" ], // nutzap event that has been redeemed - * [ "p", "" ] // pubkey of the author of the 9321 event (nutzap sender) - * ] - * https://github.com/nostr-protocol/nips/blob/master/61.md#updating-nutzap-redemption-history - */ -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. - * @param store Store used to query for the nutzaps - * @param nutzapsFilter Filter used to query for the nutzaps, most useful when - * it contains a 'since' field so it saves time and resources - * @param relay Relay hint where the new kind 7376 will be saved - * @returns MintsToProofs An object where each key is a mint url and the values are an array of proofs - * and an array of redeemed tags in this format: - * ``` - * [ - * ..., - * [ "e", "<9321-event-id>", "", "redeemed" ], // nutzap event that has been redeemed - * [ "p", "" ] // pubkey of the author of the 9321 event (nutzap sender) - * ] - * ``` - */ -async function getMintsToProofs( - store: NStore, - nutzapsFilter: NostrFilter, - relay: string, - opts?: { signal?: AbortSignal }, -): Promise { - const mintsToProofs: MintsToProofs = {}; - - const nutzaps = await store.query([nutzapsFilter], { signal: opts?.signal }); - - for (const event of nutzaps) { - try { - const mint = event.tags.find(([name]) => name === 'u')?.[1]; - if (!mint) { - continue; - } - - const proofs = event.tags.filter(([name]) => name === 'proof').map((tag) => tag[1]).filter(Boolean); - if (proofs.length < 1) { - continue; - } - - if (!mintsToProofs[mint]) { - mintsToProofs[mint] = { proofs: [], toBeRedeemed: [] }; - } - - const parsed = n.json().pipe( - proofSchema, - ).array().safeParse(proofs); - - if (!parsed.success) { - continue; - } - - mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data]; - mintsToProofs[mint].toBeRedeemed = [ - ...mintsToProofs[mint].toBeRedeemed, - [ - 'e', // nutzap event that has been redeemed - event.id, - relay, - 'redeemed', - ], - ['p', event.pubkey], // pubkey of the author of the 9321 event (nutzap sender) - ]; - } catch (e) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); - } - } - - return mintsToProofs; -} From dc711bce6af39578aa5c2248caa734b39f9fcd41 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 11 Mar 2025 11:49:39 -0300 Subject: [PATCH 29/31] fix: add explicit return type --- packages/cashu/schemas.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cashu/schemas.ts b/packages/cashu/schemas.ts index 1860f874..f631464d 100644 --- a/packages/cashu/schemas.ts +++ b/packages/cashu/schemas.ts @@ -1,6 +1,13 @@ import { z } from 'zod'; -export const proofSchema = z.object({ +export const proofSchema: z.ZodType<{ + id: string; + amount: number; + secret: string; + C: string; + dleq?: { s: string; e: string; r?: string }; + dleqValid?: boolean; +}> = z.object({ id: z.string(), amount: z.number(), secret: z.string(), From 803b2abacce61baada5fb3e076ba4a5da05b49e4 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 11 Mar 2025 12:23:04 -0300 Subject: [PATCH 30/31] fix: add explicit return type - tokenEventSchema --- packages/cashu/schemas.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cashu/schemas.ts b/packages/cashu/schemas.ts index f631464d..5d8a187b 100644 --- a/packages/cashu/schemas.ts +++ b/packages/cashu/schemas.ts @@ -17,7 +17,11 @@ export const proofSchema: z.ZodType<{ }); /** Decrypted content of a kind 7375 */ -export const tokenEventSchema = z.object({ +export const tokenEventSchema: z.ZodType<{ + mint: string; + proofs: Array>; + del?: string[]; +}> = z.object({ mint: z.string().url(), proofs: proofSchema.array(), del: z.string().array().optional(), From 8d0b5ba078cd4a5a542cfa691398585c1bc7335a Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 12 Mar 2025 13:15:52 -0300 Subject: [PATCH 31/31] test: getMintsToProofs works --- packages/cashu/cashu.test.ts | 105 ++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/packages/cashu/cashu.test.ts b/packages/cashu/cashu.test.ts index a9d24d1a..a997f5ee 100644 --- a/packages/cashu/cashu.test.ts +++ b/packages/cashu/cashu.test.ts @@ -1,4 +1,4 @@ -import { NSecSigner } from '@nostrify/nostrify'; +import { type NostrFilter, NSecSigner } from '@nostrify/nostrify'; import { NPostgres } from '@nostrify/db'; import { genEvent } from '@nostrify/nostrify/test'; @@ -9,7 +9,7 @@ import { assertEquals } from '@std/assert'; import { DittoPolyPg, TestDB } from '@ditto/db'; import { DittoConf } from '@ditto/conf'; -import { getLastRedeemedNutzap, organizeProofs, validateAndParseWallet } from './cashu.ts'; +import { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts'; Deno.test('validateAndParseWallet function returns valid data', async () => { const conf = new DittoConf(Deno.env); @@ -242,3 +242,104 @@ Deno.test('getLastRedeemedNutzap function is working', async () => { assertEquals(event, event4); }); + +Deno.test('getMintsToProofs function is working', async () => { + const conf = new DittoConf(Deno.env); + const orig = new DittoPolyPg(conf.databaseUrl); + + await using db = new TestDB(orig); + await db.migrate(); + await db.clear(); + + const store = new NPostgres(orig.kysely); + + const sk = generateSecretKey(); + const signer = new NSecSigner(sk); + const pubkey = await signer.getPublicKey(); + + const redeemedNutzap = genEvent({ + created_at: Math.floor(Date.now() / 1000), // now + kind: 9321, + content: 'Thanks buddy! Nice idea.', + tags: [ + [ + 'proof', + JSON.stringify({ + id: '005c2502034d4f12', + amount: 25, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }), + ], + ['u', 'https://mint.soul.com'], + ['e', 'nutzapped-post'], + ['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'], + ], + }, sk); + + await store.event(redeemedNutzap); + + await new Promise((r) => setTimeout(r, 1000)); + + const history = genEvent({ + created_at: Math.floor(Date.now() / 1000), // now + kind: 7376, + content: 'nip-44-encrypted', + tags: [ + ['e', redeemedNutzap.id, conf.relay, 'redeemed'], + ['p', redeemedNutzap.pubkey], + ], + }, sk); + + await store.event(history); + + const nutzap = genEvent({ + created_at: Math.floor(Date.now() / 1000), // now + kind: 9321, + content: 'Thanks buddy! Nice idea.', + tags: [ + [ + 'proof', + JSON.stringify({ + id: '005c2502034d4f12', + amount: 50, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }), + ], + ['u', 'https://mint.soul.com'], + ['e', 'nutzapped-post'], + ['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'], + ], + }, sk); + + await store.event(nutzap); + + const nutzapsFilter: NostrFilter = { + kinds: [9321], + '#p': ['47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'], + '#u': ['https://mint.soul.com'], + }; + + const lastRedeemedNutzap = await getLastRedeemedNutzap(store, pubkey); + if (lastRedeemedNutzap) { + nutzapsFilter.since = lastRedeemedNutzap.created_at; + } + + const mintsToProofs = await getMintsToProofs(store, nutzapsFilter, conf.relay); + + assertEquals(mintsToProofs, { + 'https://mint.soul.com': { + proofs: [{ + id: '005c2502034d4f12', + amount: 50, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }], + toBeRedeemed: [ + ['e', nutzap.id, conf.relay, 'redeemed'], + ['p', nutzap.pubkey], + ], + }, + }); +});