From 3bafb439bbedf2407e73bef2e8dc84fd2b83320b Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sat, 15 Feb 2025 21:44:43 -0300 Subject: [PATCH 01/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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], + ], + }, + }); +}); From 6a24ca126a937c9e0dea61cefd172652e0e74752 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 13 Mar 2025 17:26:40 -0300 Subject: [PATCH 32/56] refactor(cashu): move each fetch mock to its own test --- packages/ditto/controllers/api/cashu.test.ts | 29 ++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 431e91a7..72bcfef2 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -13,6 +13,10 @@ import { createTestDB } from '@/test.ts'; import { walletSchema } from '@/schema.ts'; Deno.test('PUT /wallet must be successful', async () => { + const mock = stub(globalThis, 'fetch', () => { + return Promise.resolve(new Response()); + }); + await using test = await createTestRoute(); const { route, signer, sk, relay } = test; @@ -75,9 +79,15 @@ Deno.test('PUT /wallet must be successful', async () => { assertEquals([nutzap_info.tags.find(([name]) => name === 'relay')?.[1]!], [ 'ws://localhost:4036/relay', ]); + + mock.restore(); }); Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async () => { + const mock = stub(globalThis, 'fetch', () => { + return Promise.resolve(new Response()); + }); + await using test = await createTestRoute(); const { route } = test; @@ -95,9 +105,15 @@ Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async assertEquals(response.status, 400); assertObjectMatch(body, { error: 'Bad schema' }); + + mock.restore(); }); Deno.test('PUT /wallet must NOT be successful: wallet already exists', async () => { + const mock = stub(globalThis, 'fetch', () => { + return Promise.resolve(new Response()); + }); + await using test = await createTestRoute(); const { route, sk, relay } = test; @@ -118,9 +134,15 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', async () assertEquals(response.status, 400); assertEquals(body2, { error: 'You already have a wallet 😏' }); + + mock.restore(); }); Deno.test('GET /wallet must be successful', async () => { + const mock = stub(globalThis, 'fetch', () => { + return Promise.resolve(new Response()); + }); + await using test = await createTestRoute(); const { route, sk, relay, signer } = test; @@ -217,6 +239,8 @@ Deno.test('GET /wallet must be successful', async () => { relays: ['ws://localhost:4036/relay'], balance: 100, }); + + mock.restore(); }); Deno.test('GET /mints must be successful', async () => { @@ -247,10 +271,6 @@ async function createTestRoute() { route.use(testUserMiddleware({ signer, relay })); route.route('/', cashuRoute); - const mock = stub(globalThis, 'fetch', () => { - return Promise.resolve(new Response()); - }); - return { route, db, @@ -259,7 +279,6 @@ async function createTestRoute() { signer, relay, [Symbol.asyncDispose]: async () => { - mock.restore(); await db[Symbol.asyncDispose](); }, }; From e1d2139c319b7c8652851077a9c7ea88846a955c Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 14 Mar 2025 19:30:50 -0300 Subject: [PATCH 33/56] test: POST nutzap endpoint, with full mocks --- packages/ditto/controllers/api/cashu.test.ts | 501 ++++++++++++++++++- 1 file changed, 498 insertions(+), 3 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 72bcfef2..fa738f83 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -1,16 +1,18 @@ +import { proofSchema } from '@ditto/cashu'; import { DittoConf } from '@ditto/conf'; import { type User } from '@ditto/mastoapi/middleware'; import { DittoApp, DittoMiddleware } from '@ditto/mastoapi/router'; -import { NSecSigner } from '@nostrify/nostrify'; +import { NSchema as n, NSecSigner } from '@nostrify/nostrify'; import { genEvent } from '@nostrify/nostrify/test'; import { bytesToString, stringToBytes } from '@scure/base'; import { stub } from '@std/testing/mock'; -import { assertEquals, assertExists, assertObjectMatch } from '@std/assert'; +import { assertArrayIncludes, assertEquals, assertExists, assertObjectMatch } from '@std/assert'; import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; import cashuRoute from '@/controllers/api/cashu.ts'; import { createTestDB } from '@/test.ts'; import { walletSchema } from '@/schema.ts'; +import { nostrNow } from '@/utils.ts'; Deno.test('PUT /wallet must be successful', async () => { const mock = stub(globalThis, 'fetch', () => { @@ -257,8 +259,501 @@ Deno.test('GET /mints must be successful', async () => { assertEquals(body, { mints: [] }); }); +Deno.test('POST /nutzap must be successful', async () => { + const mock = stub(globalThis, 'fetch', (input, init) => { + const req = new Request(input, init); + + if (req.url === 'https://cuiaba.mint.com/v1/info') { + return Promise.resolve( + new Response(JSON.stringify({ + 'name': 'Coinos', + 'pubkey': '029c5ca5c7fb73cbae4849b3120c01c7559796e2ca9a8938ff8a3ce57790abc7e8', + 'version': 'Nutshell/0.16.3', + 'description': 'Coinos cashu mint', + 'contact': [{ 'method': 'email', 'info': 'support@coinos.io' }, { + 'method': 'twitter', + 'info': '@coinoswallet', + }, { 'method': 'nostr', 'info': 'npub1h2qfjpnxau9k7ja9qkf50043xfpfy8j5v60xsqryef64y44puwnq28w8ch' }], + 'motd': '"Cypherpunks write code"', + 'icon_url': 'https://coinos.io/images/icon.png', + 'time': 1741964883, + 'nuts': { + '4': { 'methods': [{ 'method': 'bolt11', 'unit': 'sat', 'description': true }], 'disabled': false }, + '5': { 'methods': [{ 'method': 'bolt11', 'unit': 'sat' }], 'disabled': false }, + '7': { 'supported': true }, + '8': { 'supported': true }, + '9': { 'supported': true }, + '10': { 'supported': true }, + '11': { 'supported': true }, + '12': { 'supported': true }, + '14': { 'supported': true }, + '15': [{ 'method': 'bolt11', 'unit': 'sat', 'mpp': true }], + '17': { + 'supported': [{ + 'method': 'bolt11', + 'unit': 'sat', + 'commands': ['bolt11_melt_quote', 'proof_state', 'bolt11_mint_quote'], + }], + }, + }, + })), + ); + } + + if (req.url === 'https://cuiaba.mint.com/v1/keysets') { + return Promise.resolve( + new Response('{"keysets":[{"id":"004f7adf2a04356c","unit":"sat","active":true,"input_fee_ppk":0}]}'), + ); + } + + if (req.url === 'https://cuiaba.mint.com/v1/keys/004f7adf2a04356c') { + return Promise.resolve( + new Response(JSON.stringify({ + 'keysets': [{ + 'id': '004f7adf2a04356c', + 'unit': 'sat', + 'keys': { + '1': '02a1992d077c38c01a31b28f357b49009800940229ec2ce413ca5d89ff33df1a26', + '2': '0348cd466e687881c79c7a6ac605f84e5baad544baa8350bbb5a39635ba59a568e', + '4': '03d3c6e4726684b50ac19dec62f31468612134a646d586413bd659349b8fd0e661', + '8': '02e95e207ad0b943238cf519fc901b6a7d509dd6d44e450105844462f50e3bbb18', + '16': '03a8c412c63bc981bb5b230de73e843e8a807589ee8c394ef621dde3aac16193f2', + '32': '036ae412daa53e9f9506ab560642121a87e9ecd90025a44f75152b3f22991b8e2e', + '64': '029219d4e9cab24a43cf897f18cae060f02fd1c75b9147c24c0c31b8bf37a54a40', + '128': '026e19d170fa9c2230c78b667421093740535fa7150537edab3476f127ce52e7eb', + '256': '02f95d389782eb80055bb90e7af38dad3f15551cda6922c9a8ee92e56824ba5f44', + '512': '03d25e2e68dc5dadd165e0f696ff5ce29f86c7657e03c50edacf33c9546a11237e', + '1024': '02feefa2982377627edfe4706088a208c7f3a8beb87ea2975fc12413cfbea68e09', + '2048': '03fbff7c259b9c5c9bf4d515a7a3b745548f5c4f206c6cfa462f893ec8daa354f9', + '4096': '03e7655be00a7a085cb3540b5b6187a0b307b45f4ae0cceec2014bab535cf21cef', + '8192': '033e6369f3f4f6d73cb43ac2105d164a1070f1e741628644e7632c0d15c2436081', + '16384': '0300d453a54b705bba1ad3d254ca1c0ebebe5048d1a123b8001c8b85ca7907ec98', + '32768': '037bc5683d04c024ed35d11073d7b4fd8689bef93ad47ad5ed72f2bba9f83f1b27', + '65536': '02e96e6faae868f9b7dfbf2c0b7c92c7d0c3d70ca856884dbefd4ee353a7479649', + '131072': '0348f6f4d1f63b3c015c128ab925101320fe9844287b24d01e061699b0e8250033', + '262144': '021c89901fc1af82ea4dca85681de110cf8ed06027436bd54bea21abe9192d314e', + '524288': '03a9e813b4e6a59da692088f87ce6a1a42e1fd02d0ac0c3e7a0e4e09f3948a6402', + '1048576': '02f881f8c3b89857e221ec4f2d8c226f2e93ca86c151c74ed1e476384ccc2c5566', + '2097152': '03863100ca06632744fd9d8b23495112c938ed7c9e12a8abb21b15e74f2adb7ff9', + '4194304': '03295cea85458bb4c28df3f8aeaa0a786561b2cc872ccafa21f6d8820a49777895', + '8388608': '03d0ec289a0daf37b9c0913c2d5aba3dc9b49f6d07aaa6f9ef9ffbde7a47156a6b', + '16777216': '02a0ae8ea53dcf08184aea25c4c6dd493ef51acc439cf12a87c5cabc6668912968', + '33554432': '020cfb68db3d8401ba26534b0aefcf75782447eae5746b08f464496b0f70500d58', + '67108864': '03a27f513fed8ac28f388527f201e97f8c582b5770c1eaf9054bd7c6b09a3adc43', + '134217728': '03e36aaa4fdc1b0f9ec58c10f85c099ae15809252ae35df8f3597963151d854b34', + '268435456': '03e0f695df32b6b837f638fc1562066c33cfedd3e61dd828b9c27bd670b005e688', + '536870912': '022a9e88be755743da48c423030962c5f9023a2252f6e982e6a6cd70c229c9a4db', + '1073741824': '0391dffd17f79c713ecbc98ecc6673aa30ac5406dd6590650bae79df7c0735cc12', + '2147483648': '03c2293396a135061e3a049d2a0853b275e931342d3deb024f1472b4d0436f5637', + '4294967296': '02b8ceb6416ee9fc8b3010bb8e533939fe817235e38470b082c828fafaba1c0556', + '8589934592': '0349912225c038acdc1d12f286db0fd2d0e64973fa34b5dd04007e82ea74273e7e', + '17179869184': '03967e238044dd87f91949d95c851707925ca344e1947abd2a95d7861ba064c271', + '34359738368': '03748b6da67df0726c31b8241dcadb75ce866913f4ce19da9d268fb4aeed4ced62', + '68719476736': '023fe2cfc5c5c917b7c24b49657e11a91420a16347ab1f2fb23ba3fda2522a9a61', + '137438953472': '03b1f3924ee292dec1ff5106983d600997b8c7c6e595868adcf1675cca17bc7126', + '274877906944': '027a5c5fee35b5ef3d72785dd4688bb202205a209a967a8211f3a6214568e0b82c', + '549755813888': '02cf380a20bed1720ef3d0d9fc5ae18cf3ddf644b9376a1590b3387648b74c1d52', + '1099511627776': '02a0d1b95957c1fc8bb8772ce76ad614b586eb72f8c1838811c2efbfbc04ba557e', + '2199023255552': '0380aeabf8f223cc46d6e3f9f80703e1afd3038bea417dcec0bf4c7676fdbc0150', + '4398046511104': '02783814a014646f74c11510c49c3882278fa90716a68b1173a19e78e03d3db49b', + '8796093022208': '03ad177a508b0c2c7be6c7f818c2727f6807a5a2fc5c625fad00950fb8409e2c60', + '17592186044416': '038b40061c7b9446846a20ec2b8f7a004b907fb2200fe4c539bcb54d9bc0a8f5a4', + '35184372088832': '02c4196bd0e749f8e3f736458f423fa2a46f1bae6c292afe9aa1a808c8cdf5e51e', + '70368744177664': '02cb1f73960053aa1b9c41b433bf512bba0bfefbd493de0692984752cd2734c214', + '140737488355328': '03db3ee7515421f39e434ed3f089340e0651c20458fb1c6b43569f91657490eb55', + '281474976710656': '029ab08764876e019629a20385ef325139e8cf744cca54978efbf5fedb7930a99a', + '562949953421312': '0294f281ed25b3b1a0f7ea13584fb5fd563cab0b499b987ca74f9a80dbd0adfa83', + '1125899906842624': '0277810a391a74adbec086731d708d0f83900bec770120718063a60f208c9a43b5', + '2251799813685248': '03a5e565c5d1565f8bd7a8777095ef7121c048abc549beeb9bbb93302e6f526ac2', + '4503599627370496': '02b8af626bbdb342791f12828e68d662411f838be0cbb4f884f7bd64fce10dee2a', + '9007199254740992': '0347f20146430bcade5996727c2e3e909124a865fe96804e700764103ea1b16f95', + '18014398509481984': '024a816ecc2f4ec86eee15cb5011d74aa133d170a29f4230683b20fdb425ec4423', + '36028797018963968': '03858a056912d4bbd968d13fecc33dfcdd0b8177d9d7dbd9c3cb4c30f5e9f1f11c', + '72057594037927936': '034adf2dca33250962f1f68edbe02f4cef9cc09cdea6c969a9e83b3d2bd925e2ad', + '144115188075855872': '02d8add57508ef351e2e5e11e50fb36ac527a71e9bc43d8c179687e26d49e17e5b', + '288230376151711744': '024854f8bc8084e85e48c7b20de0e0028876900c7facfc3ae96b6b38f062e75671', + '576460752303423488': '021402153d9fc728c73f9bbe1a50b305da25e7aea8792ec70b19d8103dd5040395', + '1152921504606846976': '033bd2b0caa35a98fcdb41218b1cbdf9b392f52ee4f222d6e49b88c06485102fce', + '2305843009213693952': '0333868e7d7f15dde6dd147854227d2ec747b5b8be210f7f4c4d6ea0c05a2d30ab', + '4611686018427387904': '0226d990dfa39ff0ea31945d04dbe6a30f53bb76d880b810b98364c5a3fbdc90ff', + '9223372036854775808': '02ca0c02d00b2efcfb5cd0cc404795a95620f9bc819f967c0ddbb3d457f18b6970', + }, + }], + })), + ); + } + + if (req.url === 'https://cuiaba.mint.com/v1/swap') { + return Promise.resolve( + new Response(JSON.stringify( + { + 'signatures': [{ + 'id': '004f7adf2a04356c', + 'amount': 1, + 'C_': '0241624fa004a26c9d568284bbcbf6cc5e2f92cfd565327d58c8b2ec168db80be4', + 'dleq': { + 'e': 'c6ae7dfef601365999d99c1a5e3d85553b51b8bffade6902984b2e3953da223c', + 's': 'd2ce4c283cf3ed7ded4b61592ad71763e42e17ae7a33cb44ca05ff2b9df20f7e', + }, + }, { + 'id': '004f7adf2a04356c', + 'amount': 4, + 'C_': '03c3afe38e8f28fd17a391768e46db47eb0e4796e6802b8f7901f2dfc4c3f55a0b', + 'dleq': { + 'e': '07a0dcbdf5a5ba9db04bc52a8e39bc4bea94b32b0d866151f11b83801959c07b', + 's': '7c809a1a71e6ae38fefd42feba2c2867ca76b282302ef7b65234c0e8ea68686b', + }, + }, { + 'id': '004f7adf2a04356c', + 'amount': 8, + 'C_': '03e29372d0c0ba595c95fae0ad94c71ec039ce24b489e1d70e78fa4a148bf9ebac', + 'dleq': { + 'e': '152c20574fa57346204e9c9db71bb0ec0dfebd590e86f072bcb3044202fdbea4', + 's': '66803be90b934d10a7fc31e258c27511a24daf70fc6a32ecaa00769bea1ba7df', + }, + }, { + 'id': '004f7adf2a04356c', + 'amount': 16, + 'C_': '03dfd29cca5f977b71c8fb6824ecd77f12be3ab130ac5751c56f1b3ac82fc8d079', + 'dleq': { + 'e': 'cb5e70c580c16471bc2305dc3060be0dd76ac398efe068afb17424ee794b5ce6', + 's': '1c36cf770059d76011baebdb9b85895954e3137ceddc3d14cc8a3201d1ce42e6', + }, + }], + }, + )), + ); + } + + return Promise.resolve(new Response()); + }); + + await using test = await createTestRoute(); + const { route, sk, relay, signer } = test; + const pubkey = await signer.getPublicKey(); + + // create sender wallet + await route.request('/wallet', { + method: 'PUT', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + mints: [ + 'https://cuiaba.mint.com', + ], + }), + }); + + // cashu proofs of sender + await relay.event(genEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify({ + mint: 'https://cuiaba.mint.com', + proofs: [ + { + 'id': '004f7adf2a04356c', + 'amount': 1, + 'secret': 'f7655502b6f60855c71f3a004c3c7e9872d2d9d2fa11457ddb99de9ce12d0d29', + 'C': '0279e4b8d89af0796120402cc466e7e5487b4e444810dfdf15e1b1f4302b209fb2', + 'dleq': { + 'e': '6e5bb14aa7dbfa88273520b4dadaa9c95b58e79b9b3148ec44df2b0bc7882272', + 's': '19f011b88b577b521c33e33bb5f6c287294474761939f7a61d188a5f16c7d2e7', + 'r': '29757f7b49859b1603a3b0d80246d71976b73c5f0db48f51c4e3c0846ce95ec7', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 1, + 'secret': '5e7056406c7cf0191a7c0c9efd759328454dbac3a2adc64c7cb2393281101725', + 'C': '02d5e3aaf95e2ef02baf76174214dd2a71eb9b44edd4a43228877aa57e6a47bfa7', + 'dleq': { + 'e': 'cbd7becaf321d482d2694b3f2e4d1e4781f0443c78d7e8f984f4fe6c318167b8', + 's': 'f210869cc97b62e555a9f5252c190c70da5476e9cbece2a1295d1f95bfd89568', + 'r': '975beecbbe3cac9c3e2305003b390a63b028ae9838d1f8810b365c9f057474f1', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 1, + 'secret': '3ac124d1e5b7446f9c12e97aad6db28bbada327aa3cc59a76de3b370d5f1243e', + 'C': '02e6a543ba9d4464ee28be87e74a46970b540fe9c8996b18a5919f4773f1676c72', + 'dleq': { + 'e': '86974dafb2b654199e839a946bbdab46fcb1574b6dcf70ff877f3f76470ea415', + 's': '634df2c3fedc3a73ae7b9586b1b1fe21f772dd361ecd7f7b8a9b90c869a656e6', + 'r': '685cf6e711a6129bc5021dc799dbb68191c7da1448d360a74f3622392c4e8f19', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 1, + 'secret': '27a6a0560dccb118d7b9c1b103e86e42d4109c16b1eba4223aeeeb316127655e', + 'C': '037387955b2f758e504e65d612e5ec1b56688024737b030e48bbad1736bd3a8268', + 'dleq': { + 'e': 'c75c6bd0fa99f877f47ef79035935a888e704e0e78a922852693d3d5f3e9b57a', + 's': '10a200f7e2fb7df272aa95d15aa92e2328d7a9c693813f6894817bea4f7589d8', + 'r': '05a7bf2b9df7a40f578aeff1e440efcee005c84c821a3cff967c7b1ee52efade', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 2, + 'secret': 'c2e66d4cc6fe86006fe4850abdfcc5533391631080b14bf0741bbdc3e0d6fdcb', + 'C': '02f8414bfaa63fd53e19bebabc742749c820e8868e0489e69694f6c65f5e184c53', + 'dleq': { + 'e': '12f9a2655edfbec33a259a1e00c14822eba72955cdf32476341c5514582e0182', + 's': 'a07c6a45eb2cb0ce5ab3c09fdfbb457a560739bdf0f79ea07755f0f5c60c2c38', + 'r': '764bd15a10ce7175b112fcd884723f1e1355e9383baa271556194389f24d2e58', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 2, + 'secret': 'a31aed5251bc651429412242ce08ac7e1de8ec363c722d51ac6f9250720d5298', + 'C': '021d2c22a23118bf9a3343a85756ba9668ff2ea41cbb95002e4bbcd69ccd7e2a19', + 'dleq': { + 'e': '6db49bfe2d45b203fb7c110736719d30c2a756688eec91fefa7b9075768ca799', + 's': 'fd13e985a50491c34281fcf537f7bdaf38a0aa05f020fc5370081513ec8b6abc', + 'r': 'b602584d9349c7ce83a574cfb921e8434e2279079400ed72f1c15197dbefeb52', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 2, + 'secret': '700312ccba84cb15d6a008c1d01b0dbf00025d3f2cb01f030a756553aca52de3', + 'C': '02f0ff21fdd19a547d66d9ca09df5573ad88d28e4951825130708ba53cbed19561', + 'dleq': { + 'e': '9c44a58cb429be619c474b97216009bd96ff1b7dd145b35828a14f180c03a86f', + 's': 'a11b8f616dfee5157a2c7c36da0ee181fe71b28729bee56b789e472c027ceb3b', + 'r': 'c51b9ade8cfd3939b78d509c9723f86b43b432680f55a6791e3e252b53d4b465', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 2, + 'secret': '5f022ee38307d779567c86bc5bd0be8c861c157dbfd18dc2a4328286a4c73216', + 'C': '032d69930e616a2bc6f3582824b676275cc8c160167f2c5eae2d3d22c27e423aa2', + 'dleq': { + 'e': '8c7ca195d5d8930abd13f459966abebd94151cd3bd8734a2aab12e93ddd1aea4', + 's': '8b8f682e3d5dbddce2c9a32047156229aa69a73722e263468113cefa9b24606e', + 'r': 'd5b223616e620a4e8142c3026da1a7eb626add04ea774ff57f388349288e1810', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 4, + 'secret': '5936f22d486734c03bd50b89aaa34be8e99f20d199bcebc09da8716890e95fb3', + 'C': '039b55f92c02243e31b04e964f2ad0bcd2ed3229e334f4c7a81037392b8411d6e7', + 'dleq': { + 'e': '7b7be700f2515f1978ca27bc1045d50b9d146bb30d1fe0c0f48827c086412b9e', + 's': 'cf44b08c7e64fd2bd9199667327b10a29b7c699b10cb7437be518203b25fe3fa', + 'r': 'ec0cf54ce2d17fae5db1c6e5e5fd5f34d7c7df18798b8d92bcb7cb005ec2f93b', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 4, + 'secret': '1f46b395475b17f5594f4db198bbdfa96d7fe0f9022c85c86d03a2176f29eb37', + 'C': '0261c130affaa0013fec64f0b1a3657d94a0820de22079830a686d1bf082d4e30b', + 'dleq': { + 'e': '75d5ae01973d0261015f88c508ea2acd94391479e9218b839fa5fb14e603858a', + 's': 'd64b2119be317b901647eab693a282042784d91a4617ec12607ea2521d5e91c8', + 'r': '8664d66a5bcfe051dde67576f68137adf5594147dd58493213b209ffa82bea8a', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 4, + 'secret': 'efddba6efeda171f7c07f172d78cb2c6e14c076944fe3e36bba56df263b06e1b', + 'C': '02784b1c07a64f9efca4bd65b0797562bb7ca7c48a0f0f29a101dbd5353f3e7e91', + 'dleq': { + 'e': 'a725021d01889e39fb8cee7714a9af4ac87ca545a87bba539cdd57a1fedcc780', + 's': '0cbe9dbce55f6b0e865d209920a00be1125c35e98289d91674eb7ca551c42c97', + 'r': '97037f3270c0848469df412ebec7ff0d56673f3ea0d18441f3b18e2dbaf8327c', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 4, + 'secret': '6e5def60fdfdfb60b08735f364e9cb20da68b0e96594f16a66f5dd07fc0636ca', + 'C': '03379da3c4a001acf540f1e0baf52d5372357a86d00bb541bb6e7ac39f529fbbd7', + 'dleq': { + 'e': '4f008f32d26f2136a69a4aff2304e3472266bf5f1df2b69267e8281e0eb81c87', + 's': '61a24c316739408ae9062f9769f922ef3b2b685f3bd0329d4e7849de8c98f926', + 'r': '0195e7aceba8b0c256d5b2cc97aa9436931bf748fe7bead30e99e3f4e0727b9d', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 8, + 'secret': 'b78e3ce8fd573c7acda19e866431c5c3099eaa14f96112c42b223b2c8a21b84d', + 'C': '039af0ec06de95d0e853236fbe9195e1afa412cb9cb2f49bf3ab492209bdfb949d', + 'dleq': { + 'e': 'e4263230d71ccb6d624d000d443595869829c5ccbce11f929ac0e89fdfb57972', + 's': 'ae3e82dd4e6e6271573dd849aa8a3841e7b84e48ead485b7c1f80cd9120ca231', + 'r': '6673c5a553c77a7aece643e9c6b7b78ecbf92cbc40427097fbdf09ebd64b7349', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 8, + 'secret': '5a943d5d5c203b434f0977f5ace0ce8db4f34e4452dcceaec17e7fee6d14f60a', + 'C': '02e4361019d8c85d2ed814caf664aafc9c3e8768c10dae690879ba5d81986952ff', + 'dleq': { + 'e': '6209641b4bb455aaff06b2bf302e7392147d1b46bd70f4bcf4ba0266c5b916a9', + 's': '1ceb97a63123bd2e980fc20d009fa03f0b592aa936ac47a2ecc6df46a04c2aa9', + 'r': 'e83eee63066d1396981022fc1bf88993595478b100cdecf1a2ca3b49d67d1f86', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 8, + 'secret': '0b1e0a8a2c7ea8caa861eb31bead2f345badb199dc62b895a78c87dacb117ee9', + 'C': '02ac03020b7630a59b41fa844680a5595249c34010805fcf56177235cc68446937', + 'dleq': { + 'e': 'c17d4ea7d41b8ea87fd861acb5ed5d9a5a61d67093260ed6182fa92ae71811f6', + 's': '090aa5b38bba977469a081f02b3f531146792b55e92e30d9e5d40835acad2d7c', + 'r': 'a9f63f981e2ea1e5b0fa7999dbae598ebbfeb3b9b9a2304cc938ea644b163d7c', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 16, + 'secret': '503655408481c2b871cfdb0839abfbfed98ba5c196b1c63f9c2e838e12f3e984', + 'C': '02f657f8f0669ce23bb2e388f43ea1a336225a8afb7b5724c6ce572c97b40b7b3e', + 'dleq': { + 'e': '1e5f680baa7ec9e984cff7da8f09616a09cd3a09af1a7793edd6bc3e0b9b9cb4', + 's': '97b53918b42640b5818c4344ebab2332e3947727e913f8286e29540eb9273120', + 'r': 'e59e095b0347d7350376a3380d8c01ec2729f3e59c728404f23049f0b6d1e271', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 16, + 'secret': '89e2315c058f3a010972dc6d546b1a2e81142614d715c28d169c6afdba5326bd', + 'C': '02bc1c3756e77563fe6c7769fc9d9bc578ea0b84bf4bf045cf31c7e2d3f3ad0818', + 'dleq': { + 'e': '8dfa000c9e2a43d35d2a0b1c7f36a96904aed35457ca308c6e7d10f334f84e72', + 's': '9270a914b1a53e32682b1277f34c5cfa931a6fab701a5dbee5855b68ddf621ab', + 'r': 'ae71e572839a3273b0141ea2f626915592b4b3f5f91b37bbeacce0d3396332c9', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 16, + 'secret': '06f2209f313d92505ae5c72087263f711b7a97b1b29a71886870e672a1b180ac', + 'C': '02fa2ad933b62449e2765255d39593c48293f10b287cf7036b23570c8f01c27fae', + 'dleq': { + 'e': 'e696d61f6259ae97f8fe13a5af55d47f526eea62a7998bf888626fd1ae35e720', + 's': 'b9f1ef2a8aec0e73c1a4aaff67e28b3ca3bc4628a532113e0733643c697ed7ce', + 'r': 'b66ed62852811d14e9bf822baebfda92ba47c5c4babc4f2499d9ce81fbbbd3f2', + }, + }, + ], + del: [], + }), + ), + created_at: nostrNow(), + }, sk)); + + const recipientSk = generateSecretKey(); + const recipientPubkey = getPublicKey(recipientSk); + const privkey = bytesToString('hex', sk); + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + // profile of recipient + await relay.event(genEvent({ + kind: 0, + content: '{}', + created_at: nostrNow(), + }, recipientSk)); + + // post of recipient that will be nutzapped + const nutzappedPost = genEvent({ + kind: 1, + content: 'My post', + created_at: nostrNow(), + }, recipientSk); + + await relay.event(nutzappedPost); + + // Recipient wallet + await relay.event(genEvent({ + kind: 17375, + content: await signer.nip44.encrypt( + recipientPubkey, + JSON.stringify([ + ['privkey', privkey], + ['mint', 'https://mint.soul.com'], + ['mint', 'https://cuiaba.mint.com'], + ]), + ), + }, recipientSk)); + + // Recipient nutzap information + await relay.event(genEvent({ + kind: 10019, + tags: [ + ['pubkey', p2pk], + ['mint', 'https://mint.soul.com'], + ['mint', 'https://cuiaba.mint.com'], + ], + }, recipientSk)); + + const response = await route.request('/nutzap', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + account_id: recipientPubkey, + status_id: nutzappedPost.id, + amount: 29, + comment: "You gon' die", + }), + }); + + const body = await response.json(); + + assertEquals(response.status, 200); + + assertEquals(body, { + message: 'Nutzap with success!!!', + }); + + const nutzaps = await relay.query([{ kinds: [9321], authors: [pubkey] }]); + + assertEquals(nutzaps.length, 1); + + const nutzap = nutzaps[0]; + + assertEquals(nutzap.pubkey, pubkey); + assertEquals(nutzap.content, "You gon' die"); + assertArrayIncludes(nutzap.tags, [ + ['u', 'https://cuiaba.mint.com'], + ['p', recipientPubkey], + ['e', nutzappedPost.id, 'ws://localhost:4036/relay'], + ]); + + const proofs = n.json().pipe( + proofSchema, + ).array().parse(nutzap.tags.filter(([name]) => name === 'proof').map((tag) => tag[1]).filter(Boolean)); + + assertEquals(proofs.length, 4); + + const totalAmount = proofs.reduce((prev, current) => prev + current.amount, 0); + + assertEquals(totalAmount, 29); + + mock.restore(); +}); + async function createTestRoute() { - const conf = new DittoConf(new Map()); + const conf = new DittoConf( + new Map([['DITTO_NSEC', 'nsec14fg8xd04hvlznnvhaz77ms0k9kxy9yegdsgs2ar27czhh46xemuquqlv0m']]), + ); const db = await createTestDB(); const relay = db.store; From 0e0166419e886064e7bb472feedad21ce23dab34 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sat, 15 Mar 2025 20:32:20 -0300 Subject: [PATCH 34/56] test(nutzap): add more checks --- packages/ditto/controllers/api/cashu.test.ts | 31 ++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index fa738f83..9d50da93 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -13,6 +13,7 @@ import cashuRoute from '@/controllers/api/cashu.ts'; import { createTestDB } from '@/test.ts'; import { walletSchema } from '@/schema.ts'; import { nostrNow } from '@/utils.ts'; +import { Proof } from '@cashu/cashu-ts'; Deno.test('PUT /wallet must be successful', async () => { const mock = stub(globalThis, 'fetch', () => { @@ -446,7 +447,7 @@ Deno.test('POST /nutzap must be successful', async () => { }); // cashu proofs of sender - await relay.event(genEvent({ + const proofsOfSender = genEvent({ kind: 7375, content: await signer.nip44.encrypt( pubkey, @@ -656,7 +657,9 @@ Deno.test('POST /nutzap must be successful', async () => { }), ), created_at: nostrNow(), - }, sk)); + }, sk); + + await relay.event(proofsOfSender); const recipientSk = generateSecretKey(); const recipientPubkey = getPublicKey(recipientSk); @@ -747,6 +750,30 @@ Deno.test('POST /nutzap must be successful', async () => { assertEquals(totalAmount, 29); + const [history] = await relay.query([{ kinds: [7376], authors: [pubkey] }]); + + assertExists(history); + + const historyTags = JSON.parse(await signer.nip44.decrypt(pubkey, history.content)) as string[][]; + + const [newUnspentProof] = await relay.query([{ kinds: [7375], authors: [pubkey] }]); + + const newUnspentProofContent = JSON.parse(await signer.nip44.decrypt(pubkey, newUnspentProof.content)) as { + mint: string; + proofs: Proof[]; + del: string[]; + }; + + assertEquals(newUnspentProofContent.mint, 'https://cuiaba.mint.com'); + assertEquals(newUnspentProofContent.del, [proofsOfSender.id]); + + assertEquals(historyTags, [ + ['direction', 'out'], + ['amount', '29'], + ['e', proofsOfSender.id, 'ws://localhost:4036/relay', 'destroyed'], + ['e', newUnspentProof.id, 'ws://localhost:4036/relay', 'created'], + ]); + mock.restore(); }); From a719c2aabf1e2305053fca1a0a48ea4ba756fff8 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sat, 15 Mar 2025 20:33:01 -0300 Subject: [PATCH 35/56] fix: add swapNutzapsMiddleware to nutzap endpoint --- packages/ditto/controllers/api/cashu.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index e121f809..b4b4136a 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -21,14 +21,6 @@ type Wallet = z.infer; const route = new DittoRoute(); -// app.delete('/wallet') -> 204 - -// app.post(swapMiddleware, '/nutzap'); - -/* GET /api/v1/ditto/cashu/wallet -> Wallet, 404 */ -/* PUT /api/v1/ditto/cashu/wallet -> Wallet */ -/* DELETE /api/v1/ditto/cashu/wallet -> 204 */ - interface Nutzap { amount: number; event_id?: string; @@ -295,7 +287,7 @@ const nutzapSchema = z.object({ }); /** Nutzaps a post or a user. */ -route.post('/nutzap', userMiddleware({ enc: 'nip44' }), async (c) => { +route.post('/nutzap', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, async (c) => { const { conf, relay, user, signal } = c.var; const pubkey = await user.signer.getPublicKey(); const body = await parseBody(c.req.raw); From 0a05dd232c2bf6d335944f91c2f058d86493e706 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sat, 15 Mar 2025 20:48:33 -0300 Subject: [PATCH 36/56] fix: only create new kind 7375 if there is proofs to keep --- packages/ditto/controllers/api/cashu.test.ts | 324 ++++++++++++++++++- packages/ditto/controllers/api/cashu.ts | 39 ++- 2 files changed, 345 insertions(+), 18 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 9d50da93..0f93345b 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -260,7 +260,7 @@ Deno.test('GET /mints must be successful', async () => { assertEquals(body, { mints: [] }); }); -Deno.test('POST /nutzap must be successful', async () => { +Deno.test('POST /nutzap must be successful WITH proofs to keep', async () => { const mock = stub(globalThis, 'fetch', (input, init) => { const req = new Request(input, init); @@ -777,6 +777,328 @@ Deno.test('POST /nutzap must be successful', async () => { mock.restore(); }); +Deno.test('POST /nutzap must be successful WITHOUT proofs to keep', async () => { + const mock = stub(globalThis, 'fetch', (input, init) => { + const req = new Request(input, init); + + if (req.url === 'https://cuiaba.mint.com/v1/info') { + return Promise.resolve( + new Response(JSON.stringify({ + 'name': 'Coinos', + 'pubkey': '029c5ca5c7fb73cbae4849b3120c01c7559796e2ca9a8938ff8a3ce57790abc7e8', + 'version': 'Nutshell/0.16.3', + 'description': 'Coinos cashu mint', + 'contact': [{ 'method': 'email', 'info': 'support@coinos.io' }, { + 'method': 'twitter', + 'info': '@coinoswallet', + }, { 'method': 'nostr', 'info': 'npub1h2qfjpnxau9k7ja9qkf50043xfpfy8j5v60xsqryef64y44puwnq28w8ch' }], + 'motd': '"Cypherpunks write code"', + 'icon_url': 'https://coinos.io/images/icon.png', + 'time': 1741964883, + 'nuts': { + '4': { 'methods': [{ 'method': 'bolt11', 'unit': 'sat', 'description': true }], 'disabled': false }, + '5': { 'methods': [{ 'method': 'bolt11', 'unit': 'sat' }], 'disabled': false }, + '7': { 'supported': true }, + '8': { 'supported': true }, + '9': { 'supported': true }, + '10': { 'supported': true }, + '11': { 'supported': true }, + '12': { 'supported': true }, + '14': { 'supported': true }, + '15': [{ 'method': 'bolt11', 'unit': 'sat', 'mpp': true }], + '17': { + 'supported': [{ + 'method': 'bolt11', + 'unit': 'sat', + 'commands': ['bolt11_melt_quote', 'proof_state', 'bolt11_mint_quote'], + }], + }, + }, + })), + ); + } + + if (req.url === 'https://cuiaba.mint.com/v1/keysets') { + return Promise.resolve( + new Response('{"keysets":[{"id":"004f7adf2a04356c","unit":"sat","active":true,"input_fee_ppk":0}]}'), + ); + } + + if (req.url === 'https://cuiaba.mint.com/v1/keys/004f7adf2a04356c') { + return Promise.resolve( + new Response(JSON.stringify({ + 'keysets': [{ + 'id': '004f7adf2a04356c', + 'unit': 'sat', + 'keys': { + '1': '02a1992d077c38c01a31b28f357b49009800940229ec2ce413ca5d89ff33df1a26', + '2': '0348cd466e687881c79c7a6ac605f84e5baad544baa8350bbb5a39635ba59a568e', + '4': '03d3c6e4726684b50ac19dec62f31468612134a646d586413bd659349b8fd0e661', + '8': '02e95e207ad0b943238cf519fc901b6a7d509dd6d44e450105844462f50e3bbb18', + '16': '03a8c412c63bc981bb5b230de73e843e8a807589ee8c394ef621dde3aac16193f2', + '32': '036ae412daa53e9f9506ab560642121a87e9ecd90025a44f75152b3f22991b8e2e', + '64': '029219d4e9cab24a43cf897f18cae060f02fd1c75b9147c24c0c31b8bf37a54a40', + '128': '026e19d170fa9c2230c78b667421093740535fa7150537edab3476f127ce52e7eb', + '256': '02f95d389782eb80055bb90e7af38dad3f15551cda6922c9a8ee92e56824ba5f44', + '512': '03d25e2e68dc5dadd165e0f696ff5ce29f86c7657e03c50edacf33c9546a11237e', + '1024': '02feefa2982377627edfe4706088a208c7f3a8beb87ea2975fc12413cfbea68e09', + '2048': '03fbff7c259b9c5c9bf4d515a7a3b745548f5c4f206c6cfa462f893ec8daa354f9', + '4096': '03e7655be00a7a085cb3540b5b6187a0b307b45f4ae0cceec2014bab535cf21cef', + '8192': '033e6369f3f4f6d73cb43ac2105d164a1070f1e741628644e7632c0d15c2436081', + '16384': '0300d453a54b705bba1ad3d254ca1c0ebebe5048d1a123b8001c8b85ca7907ec98', + '32768': '037bc5683d04c024ed35d11073d7b4fd8689bef93ad47ad5ed72f2bba9f83f1b27', + '65536': '02e96e6faae868f9b7dfbf2c0b7c92c7d0c3d70ca856884dbefd4ee353a7479649', + '131072': '0348f6f4d1f63b3c015c128ab925101320fe9844287b24d01e061699b0e8250033', + '262144': '021c89901fc1af82ea4dca85681de110cf8ed06027436bd54bea21abe9192d314e', + '524288': '03a9e813b4e6a59da692088f87ce6a1a42e1fd02d0ac0c3e7a0e4e09f3948a6402', + '1048576': '02f881f8c3b89857e221ec4f2d8c226f2e93ca86c151c74ed1e476384ccc2c5566', + '2097152': '03863100ca06632744fd9d8b23495112c938ed7c9e12a8abb21b15e74f2adb7ff9', + '4194304': '03295cea85458bb4c28df3f8aeaa0a786561b2cc872ccafa21f6d8820a49777895', + '8388608': '03d0ec289a0daf37b9c0913c2d5aba3dc9b49f6d07aaa6f9ef9ffbde7a47156a6b', + '16777216': '02a0ae8ea53dcf08184aea25c4c6dd493ef51acc439cf12a87c5cabc6668912968', + '33554432': '020cfb68db3d8401ba26534b0aefcf75782447eae5746b08f464496b0f70500d58', + '67108864': '03a27f513fed8ac28f388527f201e97f8c582b5770c1eaf9054bd7c6b09a3adc43', + '134217728': '03e36aaa4fdc1b0f9ec58c10f85c099ae15809252ae35df8f3597963151d854b34', + '268435456': '03e0f695df32b6b837f638fc1562066c33cfedd3e61dd828b9c27bd670b005e688', + '536870912': '022a9e88be755743da48c423030962c5f9023a2252f6e982e6a6cd70c229c9a4db', + '1073741824': '0391dffd17f79c713ecbc98ecc6673aa30ac5406dd6590650bae79df7c0735cc12', + '2147483648': '03c2293396a135061e3a049d2a0853b275e931342d3deb024f1472b4d0436f5637', + '4294967296': '02b8ceb6416ee9fc8b3010bb8e533939fe817235e38470b082c828fafaba1c0556', + '8589934592': '0349912225c038acdc1d12f286db0fd2d0e64973fa34b5dd04007e82ea74273e7e', + '17179869184': '03967e238044dd87f91949d95c851707925ca344e1947abd2a95d7861ba064c271', + '34359738368': '03748b6da67df0726c31b8241dcadb75ce866913f4ce19da9d268fb4aeed4ced62', + '68719476736': '023fe2cfc5c5c917b7c24b49657e11a91420a16347ab1f2fb23ba3fda2522a9a61', + '137438953472': '03b1f3924ee292dec1ff5106983d600997b8c7c6e595868adcf1675cca17bc7126', + '274877906944': '027a5c5fee35b5ef3d72785dd4688bb202205a209a967a8211f3a6214568e0b82c', + '549755813888': '02cf380a20bed1720ef3d0d9fc5ae18cf3ddf644b9376a1590b3387648b74c1d52', + '1099511627776': '02a0d1b95957c1fc8bb8772ce76ad614b586eb72f8c1838811c2efbfbc04ba557e', + '2199023255552': '0380aeabf8f223cc46d6e3f9f80703e1afd3038bea417dcec0bf4c7676fdbc0150', + '4398046511104': '02783814a014646f74c11510c49c3882278fa90716a68b1173a19e78e03d3db49b', + '8796093022208': '03ad177a508b0c2c7be6c7f818c2727f6807a5a2fc5c625fad00950fb8409e2c60', + '17592186044416': '038b40061c7b9446846a20ec2b8f7a004b907fb2200fe4c539bcb54d9bc0a8f5a4', + '35184372088832': '02c4196bd0e749f8e3f736458f423fa2a46f1bae6c292afe9aa1a808c8cdf5e51e', + '70368744177664': '02cb1f73960053aa1b9c41b433bf512bba0bfefbd493de0692984752cd2734c214', + '140737488355328': '03db3ee7515421f39e434ed3f089340e0651c20458fb1c6b43569f91657490eb55', + '281474976710656': '029ab08764876e019629a20385ef325139e8cf744cca54978efbf5fedb7930a99a', + '562949953421312': '0294f281ed25b3b1a0f7ea13584fb5fd563cab0b499b987ca74f9a80dbd0adfa83', + '1125899906842624': '0277810a391a74adbec086731d708d0f83900bec770120718063a60f208c9a43b5', + '2251799813685248': '03a5e565c5d1565f8bd7a8777095ef7121c048abc549beeb9bbb93302e6f526ac2', + '4503599627370496': '02b8af626bbdb342791f12828e68d662411f838be0cbb4f884f7bd64fce10dee2a', + '9007199254740992': '0347f20146430bcade5996727c2e3e909124a865fe96804e700764103ea1b16f95', + '18014398509481984': '024a816ecc2f4ec86eee15cb5011d74aa133d170a29f4230683b20fdb425ec4423', + '36028797018963968': '03858a056912d4bbd968d13fecc33dfcdd0b8177d9d7dbd9c3cb4c30f5e9f1f11c', + '72057594037927936': '034adf2dca33250962f1f68edbe02f4cef9cc09cdea6c969a9e83b3d2bd925e2ad', + '144115188075855872': '02d8add57508ef351e2e5e11e50fb36ac527a71e9bc43d8c179687e26d49e17e5b', + '288230376151711744': '024854f8bc8084e85e48c7b20de0e0028876900c7facfc3ae96b6b38f062e75671', + '576460752303423488': '021402153d9fc728c73f9bbe1a50b305da25e7aea8792ec70b19d8103dd5040395', + '1152921504606846976': '033bd2b0caa35a98fcdb41218b1cbdf9b392f52ee4f222d6e49b88c06485102fce', + '2305843009213693952': '0333868e7d7f15dde6dd147854227d2ec747b5b8be210f7f4c4d6ea0c05a2d30ab', + '4611686018427387904': '0226d990dfa39ff0ea31945d04dbe6a30f53bb76d880b810b98364c5a3fbdc90ff', + '9223372036854775808': '02ca0c02d00b2efcfb5cd0cc404795a95620f9bc819f967c0ddbb3d457f18b6970', + }, + }], + })), + ); + } + + if (req.url === 'https://cuiaba.mint.com/v1/swap') { + return Promise.resolve( + new Response(JSON.stringify( + { + 'signatures': [{ + 'id': '004f7adf2a04356c', + 'amount': 1, + 'C_': '0241624fa004a26c9d568284bbcbf6cc5e2f92cfd565327d58c8b2ec168db80be4', + 'dleq': { + 'e': 'c6ae7dfef601365999d99c1a5e3d85553b51b8bffade6902984b2e3953da223c', + 's': 'd2ce4c283cf3ed7ded4b61592ad71763e42e17ae7a33cb44ca05ff2b9df20f7e', + }, + }, { + 'id': '004f7adf2a04356c', + 'amount': 4, + 'C_': '03c3afe38e8f28fd17a391768e46db47eb0e4796e6802b8f7901f2dfc4c3f55a0b', + 'dleq': { + 'e': '07a0dcbdf5a5ba9db04bc52a8e39bc4bea94b32b0d866151f11b83801959c07b', + 's': '7c809a1a71e6ae38fefd42feba2c2867ca76b282302ef7b65234c0e8ea68686b', + }, + }, { + 'id': '004f7adf2a04356c', + 'amount': 8, + 'C_': '03e29372d0c0ba595c95fae0ad94c71ec039ce24b489e1d70e78fa4a148bf9ebac', + 'dleq': { + 'e': '152c20574fa57346204e9c9db71bb0ec0dfebd590e86f072bcb3044202fdbea4', + 's': '66803be90b934d10a7fc31e258c27511a24daf70fc6a32ecaa00769bea1ba7df', + }, + }, { + 'id': '004f7adf2a04356c', + 'amount': 16, + 'C_': '03dfd29cca5f977b71c8fb6824ecd77f12be3ab130ac5751c56f1b3ac82fc8d079', + 'dleq': { + 'e': 'cb5e70c580c16471bc2305dc3060be0dd76ac398efe068afb17424ee794b5ce6', + 's': '1c36cf770059d76011baebdb9b85895954e3137ceddc3d14cc8a3201d1ce42e6', + }, + }], + }, + )), + ); + } + + return Promise.resolve(new Response()); + }); + + await using test = await createTestRoute(); + const { route, sk, relay, signer } = test; + const pubkey = await signer.getPublicKey(); + + // create sender wallet + await route.request('/wallet', { + method: 'PUT', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + mints: [ + 'https://cuiaba.mint.com', + ], + }), + }); + + // cashu proofs of sender + const proofsOfSender = genEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify({ + mint: 'https://cuiaba.mint.com', + proofs: [ + { + 'id': '004f7adf2a04356c', + 'amount': 1, + 'secret': 'f7655502b6f60855c71f3a004c3c7e9872d2d9d2fa11457ddb99de9ce12d0d29', + 'C': '0279e4b8d89af0796120402cc466e7e5487b4e444810dfdf15e1b1f4302b209fb2', + 'dleq': { + 'e': '6e5bb14aa7dbfa88273520b4dadaa9c95b58e79b9b3148ec44df2b0bc7882272', + 's': '19f011b88b577b521c33e33bb5f6c287294474761939f7a61d188a5f16c7d2e7', + 'r': '29757f7b49859b1603a3b0d80246d71976b73c5f0db48f51c4e3c0846ce95ec7', + }, + }, + ], + del: [], + }), + ), + created_at: nostrNow(), + }, sk); + + await relay.event(proofsOfSender); + + const recipientSk = generateSecretKey(); + const recipientPubkey = getPublicKey(recipientSk); + const privkey = bytesToString('hex', sk); + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + // profile of recipient + await relay.event(genEvent({ + kind: 0, + content: '{}', + created_at: nostrNow(), + }, recipientSk)); + + // post of recipient that will be nutzapped + const nutzappedPost = genEvent({ + kind: 1, + content: 'My post', + created_at: nostrNow(), + }, recipientSk); + + await relay.event(nutzappedPost); + + // Recipient wallet + await relay.event(genEvent({ + kind: 17375, + content: await signer.nip44.encrypt( + recipientPubkey, + JSON.stringify([ + ['privkey', privkey], + ['mint', 'https://mint.soul.com'], + ['mint', 'https://cuiaba.mint.com'], + ]), + ), + }, recipientSk)); + + // Recipient nutzap information + await relay.event(genEvent({ + kind: 10019, + tags: [ + ['pubkey', p2pk], + ['mint', 'https://mint.soul.com'], + ['mint', 'https://cuiaba.mint.com'], + ], + }, recipientSk)); + + const response = await route.request('/nutzap', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + account_id: recipientPubkey, + status_id: nutzappedPost.id, + amount: 1, + comment: "You gon' die", + }), + }); + + const body = await response.json(); + + assertEquals(response.status, 200); + + assertEquals(body, { + message: 'Nutzap with success!!!', + }); + + const nutzaps = await relay.query([{ kinds: [9321], authors: [pubkey] }]); + + assertEquals(nutzaps.length, 1); + + const nutzap = nutzaps[0]; + + assertEquals(nutzap.pubkey, pubkey); + assertEquals(nutzap.content, "You gon' die"); + assertArrayIncludes(nutzap.tags, [ + ['u', 'https://cuiaba.mint.com'], + ['p', recipientPubkey], + ['e', nutzappedPost.id, 'ws://localhost:4036/relay'], + ]); + + const proofs = n.json().pipe( + proofSchema, + ).array().parse(nutzap.tags.filter(([name]) => name === 'proof').map((tag) => tag[1]).filter(Boolean)); + + assertEquals(proofs.length, 1); + + const totalAmount = proofs.reduce((prev, current) => prev + current.amount, 0); + + assertEquals(totalAmount, 1); + + const [history] = await relay.query([{ kinds: [7376], authors: [pubkey] }]); + + assertExists(history); + + const historyTags = JSON.parse(await signer.nip44.decrypt(pubkey, history.content)) as string[][]; + + const [newUnspentProof] = await relay.query([{ kinds: [7375], authors: [pubkey] }]); + + assertEquals(newUnspentProof, undefined); + + assertEquals(historyTags, [ + ['direction', 'out'], + ['amount', '1'], + ['e', proofsOfSender.id, 'ws://localhost:4036/relay', 'destroyed'], + ]); + + mock.restore(); +}); + async function createTestRoute() { const conf = new DittoConf( new Map([['DITTO_NSEC', 'nsec14fg8xd04hvlznnvhaz77ms0k9kxy9yegdsgs2ar27czhh46xemuquqlv0m']]), diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index b4b4136a..9e80bb7f 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -382,28 +382,33 @@ route.post('/nutzap', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, a pubkey: p2pk.length === 64 ? '02' + p2pk : p2pk, }); - const newUnspentProof = await createEvent({ - kind: 7375, - content: await user.signer.nip44.encrypt( - pubkey, - JSON.stringify({ - mint: selectedMint, - proofs: proofsToKeep, - del: eventsToBeDeleted.map((e) => e.id), - }), - ), - }, c); + const historyTags: string[][] = [ + ['direction', 'out'], + ['amount', String(proofsToSend.reduce((accumulator, current) => accumulator + current.amount, 0))], + ...eventsToBeDeleted.map((e) => ['e', e.id, conf.relay, 'destroyed']), + ]; + + if (proofsToKeep.length) { + const newUnspentProof = await createEvent({ + kind: 7375, + content: await user.signer.nip44.encrypt( + pubkey, + JSON.stringify({ + mint: selectedMint, + proofs: proofsToKeep, + del: eventsToBeDeleted.map((e) => e.id), + }), + ), + }, c); + + historyTags.push(['e', newUnspentProof.id, conf.relay, 'created']); + } await createEvent({ kind: 7376, content: await user.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'], - ]), + JSON.stringify(historyTags), ), }, c); From 6afa0bb7f19d939aa12bc9efa52a24a17aed53ea Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 17 Mar 2025 11:01:11 -0300 Subject: [PATCH 37/56] refactor: simplify deletion of expired quote ids expiredQuoteIds --- packages/ditto/controllers/api/cashu.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 9e80bb7f..795a82b2 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -97,7 +97,12 @@ route.post('/mint/:quote_id', userMiddleware({ enc: 'nip44' }), async (c) => { const now = nostrNow(); try { - if (mintUrl && (expiration > now) && (quote_id === decryptedQuoteId)) { // TODO: organize order of operations of deleting expired quote ids + if (mintUrl && (quote_id === decryptedQuoteId)) { + if (expiration <= now) { + expiredQuoteIds.push(event.id); + continue; + } + const mint = new CashuMint(mintUrl); const wallet = new CashuWallet(mint); await wallet.loadMint(); @@ -131,7 +136,6 @@ route.post('/mint/:quote_id', userMiddleware({ enc: 'nip44' }), async (c) => { ), }, c); - expiredQuoteIds.push(event.id); await deleteExpiredQuotes(expiredQuoteIds); return c.json({ success: 'Minting successful!', state: MintQuoteState.ISSUED }, 200); @@ -145,8 +149,6 @@ route.post('/mint/:quote_id', userMiddleware({ enc: 'nip44' }), async (c) => { 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); From c344cb6b7499d20815f1d2b2b9cc1c7fede5fe7c Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 17 Mar 2025 11:10:08 -0300 Subject: [PATCH 38/56] refactor: call organizeProofs in a try-catch block --- packages/ditto/controllers/api/cashu.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 795a82b2..ed14cb5e 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -332,7 +332,13 @@ route.post('/nutzap', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, a } const unspentProofs = await relay.query([{ kinds: [7375], authors: [pubkey] }], { signal }); - const organizedProofs = await organizeProofs(unspentProofs, user.signer); + let organizedProofs; + try { + organizedProofs = await organizeProofs(unspentProofs, user.signer); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.nutzap', error: errorJson(e) }); + return c.json({ error: 'Failed to organize proofs' }, 500); + } const proofsToBeUsed: Proof[] = []; const eventsToBeDeleted: NostrEvent[] = []; From 07d0d4c4e5e0132a73fd746a135ed5d47bf5720d Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 17 Mar 2025 11:12:30 -0300 Subject: [PATCH 39/56] fix: only create kind 5 if there are events to delete --- packages/ditto/controllers/api/cashu.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index ed14cb5e..62772c8f 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -83,6 +83,8 @@ route.post('/mint/:quote_id', userMiddleware({ enc: 'nip44' }), async (c) => { const expiredQuoteIds: string[] = []; const deleteExpiredQuotes = async (ids: string[]) => { + if (ids.length === 0) return; + await createEvent({ kind: 5, tags: ids.map((id) => ['e', id, conf.relay]), From 355c53fd64aa156188aae8503fde9be39bda4576 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 17 Mar 2025 22:28:36 -0300 Subject: [PATCH 40/56] refactor: create getWallet function, with tests --- packages/cashu/cashu.test.ts | 114 ++++++++++++++++++- packages/cashu/cashu.ts | 57 +++++++++- packages/cashu/mod.ts | 4 +- packages/cashu/schemas.ts | 16 +++ packages/ditto/controllers/api/cashu.test.ts | 4 +- packages/ditto/controllers/api/cashu.ts | 43 ++----- packages/ditto/schema.ts | 14 --- 7 files changed, 196 insertions(+), 56 deletions(-) diff --git a/packages/cashu/cashu.test.ts b/packages/cashu/cashu.test.ts index a997f5ee..2e5aca5b 100644 --- a/packages/cashu/cashu.test.ts +++ b/packages/cashu/cashu.test.ts @@ -9,7 +9,7 @@ import { assertEquals } from '@std/assert'; import { DittoPolyPg, TestDB } from '@ditto/db'; import { DittoConf } from '@ditto/conf'; -import { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts'; +import { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; Deno.test('validateAndParseWallet function returns valid data', async () => { const conf = new DittoConf(Deno.env); @@ -46,6 +46,7 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { tags: [ ['pubkey', p2pk], ['mint', 'https://mint.soul.com'], + ['relay', conf.relay], ], }, sk); await store.event(nutzapInfo); @@ -59,6 +60,7 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { privkey, p2pk, mints: ['https://mint.soul.com'], + relays: [conf.relay], }); }); @@ -343,3 +345,113 @@ Deno.test('getMintsToProofs function is working', async () => { }, }); }); + +Deno.test('getWallet 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 sk = generateSecretKey(); + const signer = new NSecSigner(sk); + const pubkey = await signer.getPublicKey(); + + const privkey = bytesToString('hex', sk); + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + const relay = new NPostgres(orig.kysely); + + const proofs = genEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify({ + mint: 'https://cuiaba.mint.com', + proofs: [ + { + 'id': '004f7adf2a04356c', + 'amount': 2, + 'secret': '700312ccba84cb15d6a008c1d01b0dbf00025d3f2cb01f030a756553aca52de3', + 'C': '02f0ff21fdd19a547d66d9ca09df5573ad88d28e4951825130708ba53cbed19561', + 'dleq': { + 'e': '9c44a58cb429be619c474b97216009bd96ff1b7dd145b35828a14f180c03a86f', + 's': 'a11b8f616dfee5157a2c7c36da0ee181fe71b28729bee56b789e472c027ceb3b', + 'r': 'c51b9ade8cfd3939b78d509c9723f86b43b432680f55a6791e3e252b53d4b465', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 4, + 'secret': '5936f22d486734c03bd50b89aaa34be8e99f20d199bcebc09da8716890e95fb3', + 'C': '039b55f92c02243e31b04e964f2ad0bcd2ed3229e334f4c7a81037392b8411d6e7', + 'dleq': { + 'e': '7b7be700f2515f1978ca27bc1045d50b9d146bb30d1fe0c0f48827c086412b9e', + 's': 'cf44b08c7e64fd2bd9199667327b10a29b7c699b10cb7437be518203b25fe3fa', + 'r': 'ec0cf54ce2d17fae5db1c6e5e5fd5f34d7c7df18798b8d92bcb7cb005ec2f93b', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 16, + 'secret': '89e2315c058f3a010972dc6d546b1a2e81142614d715c28d169c6afdba5326bd', + 'C': '02bc1c3756e77563fe6c7769fc9d9bc578ea0b84bf4bf045cf31c7e2d3f3ad0818', + 'dleq': { + 'e': '8dfa000c9e2a43d35d2a0b1c7f36a96904aed35457ca308c6e7d10f334f84e72', + 's': '9270a914b1a53e32682b1277f34c5cfa931a6fab701a5dbee5855b68ddf621ab', + 'r': 'ae71e572839a3273b0141ea2f626915592b4b3f5f91b37bbeacce0d3396332c9', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 16, + 'secret': '06f2209f313d92505ae5c72087263f711b7a97b1b29a71886870e672a1b180ac', + 'C': '02fa2ad933b62449e2765255d39593c48293f10b287cf7036b23570c8f01c27fae', + 'dleq': { + 'e': 'e696d61f6259ae97f8fe13a5af55d47f526eea62a7998bf888626fd1ae35e720', + 's': 'b9f1ef2a8aec0e73c1a4aaff67e28b3ca3bc4628a532113e0733643c697ed7ce', + 'r': 'b66ed62852811d14e9bf822baebfda92ba47c5c4babc4f2499d9ce81fbbbd3f2', + }, + }, + ], + del: [], + }), + ), + created_at: Math.floor(Date.now() / 1000), // now + }, sk); + + await relay.event(proofs); + + await relay.event(genEvent({ + kind: 10019, + tags: [ + ['pubkey', p2pk], + ['mint', 'https://mint.soul.com'], + ['mint', 'https://cuiaba.mint.com'], + ['relay', conf.relay], + ], + }, sk)); + + const wallet = genEvent({ + kind: 17375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['privkey', privkey], + ['mint', 'https://mint.soul.com'], + ]), + ), + }, sk); + + await relay.event(wallet); + + const walletEntity = await getWallet(relay, pubkey, signer); + + assertEquals(walletEntity, { + balance: 38, + mints: ['https://mint.soul.com', 'https://cuiaba.mint.com'], + relays: [conf.relay], + pubkey_p2pk: p2pk, + }); +}); diff --git a/packages/cashu/cashu.ts b/packages/cashu/cashu.ts index 77c61c0c..aa1b3583 100644 --- a/packages/cashu/cashu.ts +++ b/packages/cashu/cashu.ts @@ -6,7 +6,7 @@ import { logi } from '@soapbox/logi'; import type { SetRequired } from 'type-fest'; import { z } from 'zod'; -import { proofSchema, tokenEventSchema } from './schemas.ts'; +import { proofSchema, tokenEventSchema, type Wallet } from './schemas.ts'; type Data = { wallet: NostrEvent; @@ -14,6 +14,7 @@ type Data = { privkey: string; p2pk: string; mints: string[]; + relays: string[]; }; type CustomError = @@ -96,7 +97,9 @@ async function validateAndParseWallet( }; } - return { data: { wallet, nutzapInfo, privkey, p2pk, mints }, error: null }; + const relays = [...new Set(nutzapInfo.tags.filter(([name]) => name === 'relay').map(([_, value]) => value))]; + + return { data: { wallet, nutzapInfo, privkey, p2pk, mints, relays }, error: null }; } type OrganizedProofs = { @@ -235,6 +238,54 @@ async function getMintsToProofs( return mintsToProofs; } +/** Returns a wallet entity with the latest balance. */ +async function getWallet( + store: NStore, + pubkey: string, + signer: SetRequired, + opts?: { signal?: AbortSignal }, +): Promise { + const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal: opts?.signal }); + + if (error) { + logi({ level: 'error', ns: 'ditto.cashu.get_wallet', error: errorJson(error) }); + return; + } + + const { p2pk, mints, relays } = data; + + let balance = 0; + + const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal: opts?.signal }); + for (const token of tokens) { + try { + const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse( + await signer.nip44.decrypt(pubkey, token.content), + ); + + if (!mints.includes(decryptedContent.mint)) { + mints.push(decryptedContent.mint); + } + + balance += decryptedContent.proofs.reduce((accumulator, current) => { + return accumulator + current.amount; + }, 0); + } catch (e) { + logi({ level: 'error', ns: 'dtto.cashu.get_wallet', error: errorJson(e) }); + } + } + + // TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint + const walletEntity: Wallet = { + pubkey_p2pk: p2pk, + mints, + relays, + balance, + }; + + return walletEntity; +} + /** Serialize an error into JSON for JSON logging. */ export function errorJson(error: unknown): Error | null { if (error instanceof Error) { @@ -248,4 +299,4 @@ function isNostrId(value: unknown): boolean { return n.id().safeParse(value).success; } -export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet }; +export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet }; diff --git a/packages/cashu/mod.ts b/packages/cashu/mod.ts index 47b2f9a0..5292dc15 100644 --- a/packages/cashu/mod.ts +++ b/packages/cashu/mod.ts @@ -1,2 +1,2 @@ -export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts'; -export { proofSchema, tokenEventSchema } from './schemas.ts'; +export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; +export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts'; diff --git a/packages/cashu/schemas.ts b/packages/cashu/schemas.ts index 5d8a187b..83ee0b87 100644 --- a/packages/cashu/schemas.ts +++ b/packages/cashu/schemas.ts @@ -1,3 +1,4 @@ +import { NSchema as n } from '@nostrify/nostrify'; import { z } from 'zod'; export const proofSchema: z.ZodType<{ @@ -26,3 +27,18 @@ export const tokenEventSchema: z.ZodType<{ proofs: proofSchema.array(), del: z.string().array().optional(), }); + +/** Ditto Cashu wallet */ +export const walletSchema = z.object({ + pubkey_p2pk: n.id(), + mints: z.array(z.string().url()).nonempty().transform((val) => { + return [...new Set(val)]; + }), + relays: z.array(z.string()).nonempty().transform((val) => { + return [...new Set(val)]; + }), + /** Unit in sats */ + balance: z.number(), +}); + +export type Wallet = z.infer; diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 0f93345b..b7ebdd16 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -1,4 +1,4 @@ -import { proofSchema } from '@ditto/cashu'; +import { proofSchema, walletSchema } from '@ditto/cashu'; import { DittoConf } from '@ditto/conf'; import { type User } from '@ditto/mastoapi/middleware'; import { DittoApp, DittoMiddleware } from '@ditto/mastoapi/router'; @@ -11,7 +11,6 @@ import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; import cashuRoute from '@/controllers/api/cashu.ts'; import { createTestDB } from '@/test.ts'; -import { walletSchema } from '@/schema.ts'; import { nostrNow } from '@/utils.ts'; import { Proof } from '@cashu/cashu-ts'; @@ -171,6 +170,7 @@ Deno.test('GET /wallet must be successful', async () => { tags: [ ['pubkey', p2pk], ['mint', 'https://mint.soul.com'], + ['relay', 'ws://localhost:4036/relay'], ], }, sk)); diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 62772c8f..e7cbc598 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,5 +1,5 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; -import { organizeProofs, tokenEventSchema, validateAndParseWallet } from '@ditto/cashu'; +import { getWallet, organizeProofs, tokenEventSchema, validateAndParseWallet, type Wallet } from '@ditto/cashu'; import { userMiddleware } from '@ditto/mastoapi/middleware'; import { DittoRoute } from '@ditto/mastoapi/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; @@ -10,15 +10,12 @@ import { z } from 'zod'; import { createEvent, parseBody } from '@/utils/api.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 { DittoEvent } from '@/interfaces/DittoEvent.ts'; -type Wallet = z.infer; - const route = new DittoRoute(); interface Nutzap { @@ -230,49 +227,27 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => { /** Gets a wallet, if it exists. */ route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, async (c) => { - const { conf, relay, user, signal, requestId } = c.var; + const { relay, user, signal } = c.var; const pubkey = await user.signer.getPublicKey(); - const { data, error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal }); + const { error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal }); if (error) { return c.json({ error: error.message }, 404); } - const { p2pk, mints } = data; + const walletEntity = await getWallet(relay, pubkey, user.signer); - let balance = 0; - - const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal }); - for (const token of tokens) { - try { - const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse( - await user.signer.nip44.decrypt(pubkey, token.content), - ); - - if (!mints.includes(decryptedContent.mint)) { - mints.push(decryptedContent.mint); - } - - balance += decryptedContent.proofs.reduce((accumulator, current) => { - return accumulator + current.amount; - }, 0); - } catch (e) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet', requestId, error: errorJson(e) }); - } + if (!walletEntity) { + return c.json({ 'error': 'Wallet not found' }, 404); } - // TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint - const walletEntity: Wallet = { - pubkey_p2pk: p2pk, - mints, - relays: [conf.relay], - balance, - }; - return c.json(walletEntity, 200); }); +// PUT wallet +// what errors to return? + /** Get mints set by the CASHU_MINTS environment variable. */ route.get('/mints', (c) => { const { conf } = c.var; diff --git a/packages/ditto/schema.ts b/packages/ditto/schema.ts index c67aa5f6..2fe19e70 100644 --- a/packages/ditto/schema.ts +++ b/packages/ditto/schema.ts @@ -59,19 +59,6 @@ const sizesSchema = z.string().refine((value) => value.split(' ').every((v) => /^[1-9]\d{0,3}[xX][1-9]\d{0,3}$/.test(v)) ); -/** Ditto Cashu wallet */ -const walletSchema = z.object({ - pubkey_p2pk: n.id(), - mints: z.array(z.string().url()).nonempty().transform((val) => { - return [...new Set(val)]; - }), - relays: z.array(z.string()).nonempty().transform((val) => { - return [...new Set(val)]; - }), - /** Unit in sats */ - balance: z.number(), -}); - export { booleanParamSchema, fileSchema, @@ -82,5 +69,4 @@ export { percentageSchema, safeUrlSchema, sizesSchema, - walletSchema, }; From 5ff8bf4479d55a800727250fb0d6e57b3b32dc62 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 17 Mar 2025 22:41:36 -0300 Subject: [PATCH 41/56] refactor: remove useless comments --- packages/ditto/controllers/api/cashu.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index e7cbc598..7c4f33f7 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -245,9 +245,6 @@ route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, as return c.json(walletEntity, 200); }); -// PUT wallet -// what errors to return? - /** Get mints set by the CASHU_MINTS environment variable. */ route.get('/mints', (c) => { const { conf } = c.var; From 46ab6005d1c8165afdedc294918997c9185a5ee8 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 17 Mar 2025 22:47:38 -0300 Subject: [PATCH 42/56] fix: https://jsr.io/go/slow-type-missing-explicit-type --- packages/cashu/schemas.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cashu/schemas.ts b/packages/cashu/schemas.ts index 83ee0b87..e2c6e8cd 100644 --- a/packages/cashu/schemas.ts +++ b/packages/cashu/schemas.ts @@ -13,7 +13,8 @@ export const proofSchema: z.ZodType<{ amount: z.number(), secret: z.string(), C: z.string(), - dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }).optional(), + dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }) + .optional(), dleqValid: z.boolean().optional(), }); @@ -29,7 +30,12 @@ export const tokenEventSchema: z.ZodType<{ }); /** Ditto Cashu wallet */ -export const walletSchema = z.object({ +export const walletSchema: z.ZodType<{ + pubkey_p2pk: string; + mints: string[]; + relays: string[]; + balance: number; +}> = z.object({ pubkey_p2pk: n.id(), mints: z.array(z.string().url()).nonempty().transform((val) => { return [...new Set(val)]; From 7c1297e865dbe0733b8cc018ec2d800fe753ed42 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 17 Mar 2025 22:48:43 -0300 Subject: [PATCH 43/56] fix: remove unused import --- packages/ditto/schema.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ditto/schema.ts b/packages/ditto/schema.ts index 2fe19e70..c5e7042a 100644 --- a/packages/ditto/schema.ts +++ b/packages/ditto/schema.ts @@ -1,5 +1,4 @@ import ISO6391, { LanguageCode } from 'iso-639-1'; -import { NSchema as n } from '@nostrify/nostrify'; import { z } from 'zod'; /** Validates individual items in an array, dropping any that aren't valid. */ From feff31f09477a6a21c5165ff817d08aadabcda0d Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 18 Mar 2025 18:30:52 -0300 Subject: [PATCH 44/56] feat: allow to edit the wallet mints and relays (with tests updated) --- packages/ditto/controllers/api/cashu.test.ts | 79 +++++++++++++++++--- packages/ditto/controllers/api/cashu.ts | 31 +++++--- 2 files changed, 91 insertions(+), 19 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index b7ebdd16..83944d81 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -35,6 +35,9 @@ Deno.test('PUT /wallet must be successful', async () => { 'https://houston.mint.com', // duplicate on purpose 'https://cuiaba.mint.com', ], + relays: [ + 'wss://manager.com/relay', + ], }), }); @@ -65,7 +68,7 @@ Deno.test('PUT /wallet must be successful', async () => { 'https://cuiaba.mint.com', ]); assertEquals(data.relays, [ - 'ws://localhost:4036/relay', + 'wss://manager.com/relay', ]); assertEquals(data.balance, 0); @@ -79,7 +82,7 @@ Deno.test('PUT /wallet must be successful', async () => { assertEquals(nutzap_p2pk, p2pk); assertEquals([nutzap_info.tags.find(([name]) => name === 'relay')?.[1]!], [ - 'ws://localhost:4036/relay', + 'wss://manager.com/relay', ]); mock.restore(); @@ -111,31 +114,87 @@ Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async mock.restore(); }); -Deno.test('PUT /wallet must NOT be successful: wallet already exists', async () => { +Deno.test('PUT /wallet must be successful: edit wallet', async () => { const mock = stub(globalThis, 'fetch', () => { return Promise.resolve(new Response()); }); await using test = await createTestRoute(); - const { route, sk, relay } = test; + const { route, sk, relay, signer } = test; - await relay.event(genEvent({ kind: 17375 }, sk)); + const pubkey = await signer.getPublicKey(); + const privkey = bytesToString('hex', generateSecretKey()); + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + // Wallet + await relay.event(genEvent({ + kind: 17375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['privkey', privkey], + ['mint', 'https://mint.soul.com'], + ]), + ), + }, sk)); + + // Nutzap information + await relay.event(genEvent({ + kind: 10019, + tags: [ + ['pubkey', p2pk], + ['mint', 'https://mint.soul.com'], + ['relay', 'ws://localhost:4036/relay'], + ], + }, sk)); const response = await route.request('/wallet', { method: 'PUT', headers: { - 'authorization': `Bearer ${nip19.nsecEncode(sk)}`, 'content-type': 'application/json', }, body: JSON.stringify({ - mints: ['https://mint.heart.com'], + mints: [ + 'https://new-vampire-mint.com', + 'https://new-age-mint.com', + ], + relays: [ + 'wss://law-of-the-universe/relay', + 'wss://law-of-the-universe/relay', + ], }), }); - const body2 = await response.json(); + const body = await response.json(); - assertEquals(response.status, 400); - assertEquals(body2, { error: 'You already have a wallet 😏' }); + const data = walletSchema.parse(body); + + assertEquals(response.status, 200); + + assertEquals(bytesToString('hex', sk) !== privkey, true); + + assertEquals(data.pubkey_p2pk, p2pk); + assertEquals(data.mints, [ + 'https://new-vampire-mint.com', + 'https://new-age-mint.com', + ]); + assertEquals(data.relays, [ + 'wss://law-of-the-universe/relay', + ]); + assertEquals(data.balance, 0); + + const [nutzap_info] = await relay.query([{ authors: [pubkey], kinds: [10019] }]); + + assertExists(nutzap_info); + assertEquals(nutzap_info.kind, 10019); + assertEquals(nutzap_info.tags.length, 4); + + const nutzap_p2pk = nutzap_info.tags.find(([value]) => value === 'pubkey')?.[1]!; + + assertEquals(nutzap_p2pk, p2pk); + assertEquals([nutzap_info.tags.find(([name]) => name === 'relay')?.[1]!], [ + 'wss://law-of-the-universe/relay', + ]); mock.restore(); }); diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 7c4f33f7..cdd0f911 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -4,7 +4,7 @@ import { userMiddleware } from '@ditto/mastoapi/middleware'; import { DittoRoute } from '@ditto/mastoapi/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; -import { bytesToString } from '@scure/base'; +import { bytesToString, stringToBytes } from '@scure/base'; import { logi } from '@soapbox/logi'; import { z } from 'zod'; @@ -159,6 +159,9 @@ const createWalletSchema = z.object({ mints: z.array(z.string().url()).nonempty().transform((val) => { return [...new Set(val)]; }), + relays: z.array(z.string().url()).transform((val) => { + return [...new Set(val)]; + }), }); /** @@ -167,7 +170,7 @@ const createWalletSchema = z.object({ * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event */ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => { - const { conf, user, relay, signal } = c.var; + const { user, relay, signal } = c.var; const pubkey = await user.signer.getPublicKey(); const body = await parseBody(c.req.raw); @@ -177,18 +180,28 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => { return c.json({ error: 'Bad schema', schema: result.error }, 400); } - const { mints } = result.data; + const { mints, relays } = result.data; + let previousPrivkey: string | undefined; const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal }); if (event) { - return c.json({ error: 'You already have a wallet 😏' }, 400); + const walletContentSchema = z.string().array().min(2).array(); + + const { data: walletContent, success, error } = n.json().pipe(walletContentSchema).safeParse( + await user.signer.nip44.decrypt(pubkey, event.content), + ); + + if (!success) { + return c.json({ error: 'Your wallet is in an invalid format', schema: error }, 400); + } + + previousPrivkey = walletContent.find(([name]) => name === 'privkey')?.[1]; } const walletContentTags: string[][] = []; - const sk = generateSecretKey(); - const privkey = bytesToString('hex', sk); - const p2pk = getPublicKey(sk); + const privkey = previousPrivkey ?? bytesToString('hex', generateSecretKey()); + const p2pk = getPublicKey(stringToBytes('hex', privkey)); walletContentTags.push(['privkey', privkey]); @@ -209,7 +222,7 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => { kind: 10019, tags: [ ...mints.map((mint) => ['mint', mint, 'sat']), - ['relay', conf.relay], // TODO: add more relays once things get more stable + ...relays.map((relay) => ['relay', relay]), ['pubkey', p2pk], ], }, c); @@ -218,7 +231,7 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => { const walletEntity: Wallet = { pubkey_p2pk: p2pk, mints, - relays: [conf.relay], + relays, balance: 0, // Newly created wallet, balance is zero. }; From 06ac326ec3f1862a33f7ea1a4c6397251af6e577 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 19 Mar 2025 13:01:06 -0300 Subject: [PATCH 45/56] refactor: add relay of conf.relay in case no relays are present --- packages/ditto/controllers/api/cashu.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index cdd0f911..bd2b2777 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -170,7 +170,7 @@ const createWalletSchema = z.object({ * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event */ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => { - const { user, relay, signal } = c.var; + const { user, relay, signal, conf } = c.var; const pubkey = await user.signer.getPublicKey(); const body = await parseBody(c.req.raw); @@ -209,6 +209,10 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => { walletContentTags.push(['mint', mint]); } + if (relays.length < 1) { + relays.push(conf.relay); + } + const encryptedWalletContentTags = await user.signer.nip44.encrypt(pubkey, JSON.stringify(walletContentTags)); // Wallet From f9c4ec5835ee50749fe744495921bb355b883b53 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 20 Mar 2025 14:00:14 -0300 Subject: [PATCH 46/56] feat: create GET transactions endpoint (with tests) --- packages/cashu/cashu.test.ts | 78 +++++++++++++++++++- packages/cashu/cashu.ts | 51 ++++++++++++- packages/cashu/mod.ts | 9 ++- packages/ditto/controllers/api/cashu.test.ts | 2 +- packages/ditto/controllers/api/cashu.ts | 22 +++++- 5 files changed, 157 insertions(+), 5 deletions(-) diff --git a/packages/cashu/cashu.test.ts b/packages/cashu/cashu.test.ts index 2e5aca5b..5bf88951 100644 --- a/packages/cashu/cashu.test.ts +++ b/packages/cashu/cashu.test.ts @@ -9,7 +9,14 @@ import { assertEquals } from '@std/assert'; import { DittoPolyPg, TestDB } from '@ditto/db'; import { DittoConf } from '@ditto/conf'; -import { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; +import { + getLastRedeemedNutzap, + getMintsToProofs, + getTransactions, + getWallet, + organizeProofs, + validateAndParseWallet, +} from './cashu.ts'; Deno.test('validateAndParseWallet function returns valid data', async () => { const conf = new DittoConf(Deno.env); @@ -455,3 +462,72 @@ Deno.test('getWallet function is working', async () => { pubkey_p2pk: p2pk, }); }); + +Deno.test('getTransactions 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 sk = generateSecretKey(); + const signer = new NSecSigner(sk); + const pubkey = await signer.getPublicKey(); + + const relay = new NPostgres(orig.kysely); + + const history1 = genEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'in'], + ['amount', '33'], + ]), + ), + created_at: Math.floor(Date.now() / 1000), // now + }, sk); + await relay.event(history1); + + const history2 = genEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'out'], + ['amount', '29'], + ]), + ), + created_at: Math.floor(Date.now() / 1000) - 1, // now - 1 second + }, sk); + await relay.event(history2); + + const history3 = genEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'ouch'], + ['amount', 'yolo'], + ]), + ), + created_at: Math.floor(Date.now() / 1000) - 2, // now - 2 second + }, sk); + await relay.event(history3); + + const transactions = await getTransactions(relay, pubkey, signer, {}); + + assertEquals(transactions, [ + { + direction: 'in', + amount: 33, + created_at: history1.created_at, + }, + { + direction: 'out', + amount: 29, + created_at: history2.created_at, + }, + ]); +}); diff --git a/packages/cashu/cashu.ts b/packages/cashu/cashu.ts index aa1b3583..b676d731 100644 --- a/packages/cashu/cashu.ts +++ b/packages/cashu/cashu.ts @@ -286,6 +286,55 @@ async function getWallet( return walletEntity; } +type Transactions = { + amount: number; + created_at: number; + direction: 'in' | 'out'; +}[]; + +/** Returns a history of transactions. */ +async function getTransactions( + store: NStore, + pubkey: string, + signer: SetRequired, + pagination: { limit?: number; until?: number; since?: number }, + opts?: { signal?: AbortSignal }, +): Promise { + const { since, until, limit } = pagination; + const transactions: Transactions = []; + + const events = await store.query([{ kinds: [7376], authors: [pubkey], since, until, limit }], { + signal: opts?.signal, + }); + + for (const event of events) { + const { data: contentTags, success } = n.json().pipe(z.string().array().min(2).array()).safeParse( + await signer.nip44.decrypt(pubkey, event.content), + ); + + if (!success) { + continue; + } + + const direction = contentTags.find(([name]) => name === 'direction')?.[1]; + if (direction !== 'out' && direction !== 'in') { + continue; + } + const amount = parseInt(contentTags.find(([name]) => name === 'amount')?.[1] ?? '', 10); + if (isNaN(amount)) { + continue; + } + + transactions.push({ + created_at: event.created_at, + direction, + amount, + }); + } + + return transactions; +} + /** Serialize an error into JSON for JSON logging. */ export function errorJson(error: unknown): Error | null { if (error instanceof Error) { @@ -299,4 +348,4 @@ function isNostrId(value: unknown): boolean { return n.id().safeParse(value).success; } -export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet }; +export { getLastRedeemedNutzap, getMintsToProofs, getTransactions, getWallet, organizeProofs, validateAndParseWallet }; diff --git a/packages/cashu/mod.ts b/packages/cashu/mod.ts index 5292dc15..392eeccb 100644 --- a/packages/cashu/mod.ts +++ b/packages/cashu/mod.ts @@ -1,2 +1,9 @@ -export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; +export { + getLastRedeemedNutzap, + getMintsToProofs, + getTransactions, + getWallet, + organizeProofs, + validateAndParseWallet, +} from './cashu.ts'; export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts'; diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 83944d81..44b74f10 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -7,7 +7,7 @@ import { genEvent } from '@nostrify/nostrify/test'; import { bytesToString, stringToBytes } from '@scure/base'; import { stub } from '@std/testing/mock'; import { assertArrayIncludes, assertEquals, assertExists, assertObjectMatch } from '@std/assert'; -import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; +import { generateSecretKey, getPublicKey } from 'nostr-tools'; import cashuRoute from '@/controllers/api/cashu.ts'; import { createTestDB } from '@/test.ts'; diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index bd2b2777..18cbb7a5 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,6 +1,14 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; -import { getWallet, organizeProofs, tokenEventSchema, validateAndParseWallet, type Wallet } from '@ditto/cashu'; +import { + getTransactions, + getWallet, + organizeProofs, + tokenEventSchema, + validateAndParseWallet, + type Wallet, +} from '@ditto/cashu'; import { userMiddleware } from '@ditto/mastoapi/middleware'; +import { paginationSchema } from '@ditto/mastoapi/pagination'; import { DittoRoute } from '@ditto/mastoapi/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; @@ -262,6 +270,18 @@ route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, as return c.json(walletEntity, 200); }); +/** Gets a history of transactions. */ +route.get('/transactions', userMiddleware({ enc: 'nip44' }), async (c) => { + const { relay, user, signal } = c.var; + const { limit, since, until } = paginationSchema.parse(c.req.query()); + + const pubkey = await user.signer.getPublicKey(); + + const transactions = await getTransactions(relay, pubkey, user.signer, { since, until, limit }, { signal }); + + return c.json(transactions, 200); +}); + /** Get mints set by the CASHU_MINTS environment variable. */ route.get('/mints', (c) => { const { conf } = c.var; From d69854fa3caeb4e2d856062ae6075c785b0ac145 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 21 Mar 2025 10:07:34 -0300 Subject: [PATCH 47/56] fix: save amount tag value as a string --- packages/ditto/controllers/api/cashu.ts | 2 +- packages/ditto/middleware/swapNutzapsMiddleware.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 18cbb7a5..d12fb01d 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -137,7 +137,7 @@ route.post('/mint/:quote_id', userMiddleware({ enc: 'nip44' }), async (c) => { pubkey, JSON.stringify([ ['direction', 'in'], - ['amount', amount], + ['amount', String(amount)], ['e', unspentProofs.id, conf.relay, 'created'], ]), ), diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index b7551d0b..8dd1510d 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -79,7 +79,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler = async (c, next) => { pubkey, JSON.stringify([ ['direction', 'in'], - ['amount', amount], + ['amount', String(amount)], ['e', unspentProofs.id, conf.relay, 'created'], ]), ), From 7226d503f51d3e72db24577a835ea8196dc8cfe9 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 21 Mar 2025 10:09:38 -0300 Subject: [PATCH 48/56] fix(getTransactions): coerce tags to string in schema This is not really needed but it doesn't hurt --- packages/cashu/cashu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cashu/cashu.ts b/packages/cashu/cashu.ts index b676d731..8bce4e48 100644 --- a/packages/cashu/cashu.ts +++ b/packages/cashu/cashu.ts @@ -308,7 +308,7 @@ async function getTransactions( }); for (const event of events) { - const { data: contentTags, success } = n.json().pipe(z.string().array().min(2).array()).safeParse( + const { data: contentTags, success } = n.json().pipe(z.coerce.string().array().min(2).array()).safeParse( await signer.nip44.decrypt(pubkey, event.content), ); From 1360484ae9804759642b875f1999d10dc7fe1254 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 21 Mar 2025 10:49:58 -0300 Subject: [PATCH 49/56] fix: call paginationSchema as a function --- 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 d12fb01d..f5c3bf78 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -273,7 +273,7 @@ route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, as /** Gets a history of transactions. */ route.get('/transactions', userMiddleware({ enc: 'nip44' }), async (c) => { const { relay, user, signal } = c.var; - const { limit, since, until } = paginationSchema.parse(c.req.query()); + const { limit, since, until } = paginationSchema().parse(c.req.query()); const pubkey = await user.signer.getPublicKey(); From 83c96c88b7125bf9b367d4022077264bcc4ebed6 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 24 Mar 2025 23:02:04 -0300 Subject: [PATCH 50/56] feat: support pagination in GET transactions remove getTransactions function and replace it with renderTransaction function (all tests updated) --- packages/cashu/cashu.test.ts | 78 +---------------------- packages/cashu/cashu.ts | 51 +-------------- packages/cashu/mod.ts | 10 +-- packages/cashu/views.test.ts | 85 +++++++++++++++++++++++++ packages/cashu/views.ts | 44 +++++++++++++ packages/ditto/controllers/api/cashu.ts | 20 ++++-- 6 files changed, 149 insertions(+), 139 deletions(-) create mode 100644 packages/cashu/views.test.ts create mode 100644 packages/cashu/views.ts diff --git a/packages/cashu/cashu.test.ts b/packages/cashu/cashu.test.ts index 5bf88951..2e5aca5b 100644 --- a/packages/cashu/cashu.test.ts +++ b/packages/cashu/cashu.test.ts @@ -9,14 +9,7 @@ import { assertEquals } from '@std/assert'; import { DittoPolyPg, TestDB } from '@ditto/db'; import { DittoConf } from '@ditto/conf'; -import { - getLastRedeemedNutzap, - getMintsToProofs, - getTransactions, - getWallet, - organizeProofs, - validateAndParseWallet, -} from './cashu.ts'; +import { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; Deno.test('validateAndParseWallet function returns valid data', async () => { const conf = new DittoConf(Deno.env); @@ -462,72 +455,3 @@ Deno.test('getWallet function is working', async () => { pubkey_p2pk: p2pk, }); }); - -Deno.test('getTransactions 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 sk = generateSecretKey(); - const signer = new NSecSigner(sk); - const pubkey = await signer.getPublicKey(); - - const relay = new NPostgres(orig.kysely); - - const history1 = genEvent({ - kind: 7376, - content: await signer.nip44.encrypt( - pubkey, - JSON.stringify([ - ['direction', 'in'], - ['amount', '33'], - ]), - ), - created_at: Math.floor(Date.now() / 1000), // now - }, sk); - await relay.event(history1); - - const history2 = genEvent({ - kind: 7376, - content: await signer.nip44.encrypt( - pubkey, - JSON.stringify([ - ['direction', 'out'], - ['amount', '29'], - ]), - ), - created_at: Math.floor(Date.now() / 1000) - 1, // now - 1 second - }, sk); - await relay.event(history2); - - const history3 = genEvent({ - kind: 7376, - content: await signer.nip44.encrypt( - pubkey, - JSON.stringify([ - ['direction', 'ouch'], - ['amount', 'yolo'], - ]), - ), - created_at: Math.floor(Date.now() / 1000) - 2, // now - 2 second - }, sk); - await relay.event(history3); - - const transactions = await getTransactions(relay, pubkey, signer, {}); - - assertEquals(transactions, [ - { - direction: 'in', - amount: 33, - created_at: history1.created_at, - }, - { - direction: 'out', - amount: 29, - created_at: history2.created_at, - }, - ]); -}); diff --git a/packages/cashu/cashu.ts b/packages/cashu/cashu.ts index 8bce4e48..aa1b3583 100644 --- a/packages/cashu/cashu.ts +++ b/packages/cashu/cashu.ts @@ -286,55 +286,6 @@ async function getWallet( return walletEntity; } -type Transactions = { - amount: number; - created_at: number; - direction: 'in' | 'out'; -}[]; - -/** Returns a history of transactions. */ -async function getTransactions( - store: NStore, - pubkey: string, - signer: SetRequired, - pagination: { limit?: number; until?: number; since?: number }, - opts?: { signal?: AbortSignal }, -): Promise { - const { since, until, limit } = pagination; - const transactions: Transactions = []; - - const events = await store.query([{ kinds: [7376], authors: [pubkey], since, until, limit }], { - signal: opts?.signal, - }); - - for (const event of events) { - const { data: contentTags, success } = n.json().pipe(z.coerce.string().array().min(2).array()).safeParse( - await signer.nip44.decrypt(pubkey, event.content), - ); - - if (!success) { - continue; - } - - const direction = contentTags.find(([name]) => name === 'direction')?.[1]; - if (direction !== 'out' && direction !== 'in') { - continue; - } - const amount = parseInt(contentTags.find(([name]) => name === 'amount')?.[1] ?? '', 10); - if (isNaN(amount)) { - continue; - } - - transactions.push({ - created_at: event.created_at, - direction, - amount, - }); - } - - return transactions; -} - /** Serialize an error into JSON for JSON logging. */ export function errorJson(error: unknown): Error | null { if (error instanceof Error) { @@ -348,4 +299,4 @@ function isNostrId(value: unknown): boolean { return n.id().safeParse(value).success; } -export { getLastRedeemedNutzap, getMintsToProofs, getTransactions, getWallet, organizeProofs, validateAndParseWallet }; +export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet }; diff --git a/packages/cashu/mod.ts b/packages/cashu/mod.ts index 392eeccb..9d939097 100644 --- a/packages/cashu/mod.ts +++ b/packages/cashu/mod.ts @@ -1,9 +1,3 @@ -export { - getLastRedeemedNutzap, - getMintsToProofs, - getTransactions, - getWallet, - organizeProofs, - validateAndParseWallet, -} from './cashu.ts'; +export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts'; +export { renderTransaction, type Transaction } from './views.ts'; diff --git a/packages/cashu/views.test.ts b/packages/cashu/views.test.ts new file mode 100644 index 00000000..6cff62ee --- /dev/null +++ b/packages/cashu/views.test.ts @@ -0,0 +1,85 @@ +import { NSecSigner } from '@nostrify/nostrify'; +import { NPostgres } from '@nostrify/db'; +import { genEvent } from '@nostrify/nostrify/test'; + +import { generateSecretKey } from 'nostr-tools'; +import { assertEquals } from '@std/assert'; + +import { DittoPolyPg, TestDB } from '@ditto/db'; +import { DittoConf } from '@ditto/conf'; +import { renderTransaction, type Transaction } from './views.ts'; + +Deno.test('renderTransaction 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 sk = generateSecretKey(); + const signer = new NSecSigner(sk); + const pubkey = await signer.getPublicKey(); + + const relay = new NPostgres(orig.kysely); + + const history1 = genEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'in'], + ['amount', '33'], + ]), + ), + created_at: Math.floor(Date.now() / 1000), // now + }, sk); + await relay.event(history1); + + const history2 = genEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'out'], + ['amount', '29'], + ]), + ), + created_at: Math.floor(Date.now() / 1000) - 1, // now - 1 second + }, sk); + await relay.event(history2); + + const history3 = genEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'ouch'], + ['amount', 'yolo'], + ]), + ), + created_at: Math.floor(Date.now() / 1000) - 2, // now - 2 second + }, sk); + await relay.event(history3); + + const events = await relay.query([{ kinds: [7376], authors: [pubkey], since: history2.created_at }]); + + const transactions = await Promise.all( + events.map((event) => { + return renderTransaction(event, pubkey, signer); + }), + ); + + assertEquals(transactions, [ + { + direction: 'in', + amount: 33, + created_at: history1.created_at, + }, + { + direction: 'out', + amount: 29, + created_at: history2.created_at, + }, + ]); +}); diff --git a/packages/cashu/views.ts b/packages/cashu/views.ts new file mode 100644 index 00000000..0b2467a8 --- /dev/null +++ b/packages/cashu/views.ts @@ -0,0 +1,44 @@ +import { type NostrEvent, type NostrSigner, NSchema as n } from '@nostrify/nostrify'; +import type { SetRequired } from 'type-fest'; +import { z } from 'zod'; + +type Transaction = { + amount: number; + created_at: number; + direction: 'in' | 'out'; +}; + +/** Renders one history of transaction. */ +async function renderTransaction( + event: NostrEvent, + viewerPubkey: string, + signer: SetRequired, +): Promise { + if (event.kind !== 7376) return; + + const { data: contentTags, success } = n.json().pipe(z.coerce.string().array().min(2).array()).safeParse( + await signer.nip44.decrypt(viewerPubkey, event.content), + ); + + if (!success) { + return; + } + + const direction = contentTags.find(([name]) => name === 'direction')?.[1]; + if (direction !== 'out' && direction !== 'in') { + return; + } + + const amount = parseInt(contentTags.find(([name]) => name === 'amount')?.[1] ?? '', 10); + if (isNaN(amount)) { + return; + } + + return { + created_at: event.created_at, + direction, + amount, + }; +} + +export { renderTransaction, type Transaction }; diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index f5c3bf78..f355d392 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,14 +1,14 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; import { - getTransactions, getWallet, organizeProofs, + renderTransaction, tokenEventSchema, validateAndParseWallet, type Wallet, } from '@ditto/cashu'; import { userMiddleware } from '@ditto/mastoapi/middleware'; -import { paginationSchema } from '@ditto/mastoapi/pagination'; +import { paginated, paginationSchema } from '@ditto/mastoapi/pagination'; import { DittoRoute } from '@ditto/mastoapi/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; @@ -277,9 +277,21 @@ route.get('/transactions', userMiddleware({ enc: 'nip44' }), async (c) => { const pubkey = await user.signer.getPublicKey(); - const transactions = await getTransactions(relay, pubkey, user.signer, { since, until, limit }, { signal }); + const events = await relay.query([{ kinds: [7376], authors: [pubkey], since, until, limit }], { + signal, + }); - return c.json(transactions, 200); + const transactions = await Promise.all( + events.map((event) => { + return renderTransaction(event, pubkey, user.signer); + }), + ); + + if (!transactions.length) { + return c.json([], 200); + } + + return paginated(c, events, transactions); }); /** Get mints set by the CASHU_MINTS environment variable. */ From 7dc56f594b6ae463cbdb580f1588e57a813cea3a Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 25 Mar 2025 18:19:40 -0300 Subject: [PATCH 51/56] feat: add zaps_amount_cashu to event_stats (with tests) add zapped_cashu and zaps_amount_cashu field to MastodonStatus --- packages/db/DittoTables.ts | 1 + .../054_event_stats_add_zap_cashu_count.ts | 12 ++++++ packages/ditto/interfaces/DittoEvent.ts | 1 + packages/ditto/storages/DittoRelayStore.ts | 1 + packages/ditto/storages/hydrate.ts | 1 + packages/ditto/utils/stats.test.ts | 42 +++++++++++++++++++ packages/ditto/utils/stats.ts | 31 ++++++++++++++ packages/ditto/views/mastodon/statuses.ts | 4 ++ packages/mastoapi/types/MastodonStatus.ts | 2 + 9 files changed, 95 insertions(+) create mode 100644 packages/db/migrations/054_event_stats_add_zap_cashu_count.ts diff --git a/packages/db/DittoTables.ts b/packages/db/DittoTables.ts index 12763c57..934720f5 100644 --- a/packages/db/DittoTables.ts +++ b/packages/db/DittoTables.ts @@ -36,6 +36,7 @@ interface EventStatsRow { quotes_count: number; reactions: string; zaps_amount: number; + zaps_amount_cashu: number; link_preview?: MastodonPreviewCard; } diff --git a/packages/db/migrations/054_event_stats_add_zap_cashu_count.ts b/packages/db/migrations/054_event_stats_add_zap_cashu_count.ts new file mode 100644 index 00000000..472a504d --- /dev/null +++ b/packages/db/migrations/054_event_stats_add_zap_cashu_count.ts @@ -0,0 +1,12 @@ +import type { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('event_stats') + .addColumn('zaps_amount_cashu', 'integer', (col) => col.notNull().defaultTo(0)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('event_stats').dropColumn('zaps_amount_cashu').execute(); +} diff --git a/packages/ditto/interfaces/DittoEvent.ts b/packages/ditto/interfaces/DittoEvent.ts index cdd4343d..d80a5418 100644 --- a/packages/ditto/interfaces/DittoEvent.ts +++ b/packages/ditto/interfaces/DittoEvent.ts @@ -24,6 +24,7 @@ export interface EventStats { quotes_count: number; reactions: Record; zaps_amount: number; + zaps_amount_cashu: number; link_preview?: MastodonPreviewCard; } diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index 40478eac..14a4969e 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -448,6 +448,7 @@ export class DittoRelayStore implements NRelay { quotes_count: 0, reactions: '{}', zaps_amount: 0, + zaps_amount_cashu: 0, link_preview: linkPreview, }) .onConflict((oc) => oc.column('event_id').doUpdateSet({ link_preview: linkPreview })) diff --git a/packages/ditto/storages/hydrate.ts b/packages/ditto/storages/hydrate.ts index d2c64e90..60ec89bd 100644 --- a/packages/ditto/storages/hydrate.ts +++ b/packages/ditto/storages/hydrate.ts @@ -411,6 +411,7 @@ async function gatherEventStats( quotes_count: Math.max(0, row.quotes_count), reactions: row.reactions, zaps_amount: Math.max(0, row.zaps_amount), + zaps_amount_cashu: Math.max(0, row.zaps_amount_cashu), link_preview: row.link_preview, })); } diff --git a/packages/ditto/utils/stats.test.ts b/packages/ditto/utils/stats.test.ts index 0fe7b0ae..52a197b8 100644 --- a/packages/ditto/utils/stats.test.ts +++ b/packages/ditto/utils/stats.test.ts @@ -161,6 +161,48 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => { assertEquals(stats!.reactions_count, 3); }); +Deno.test('updateStats with kind 9321 increments zaps_amount_cashu count', async () => { + await using test = await setupTest(); + const { kysely, relay } = test; + + const note = genEvent({ kind: 1 }); + await relay.event(note); + + await updateStats({ + ...test, + event: genEvent({ + kind: 9321, + content: 'Do you love me?', + tags: [ + ['e', note.id], + [ + 'proof', + '{"id":"004f7adf2a04356c","amount":29,"secret":"6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0","C":"03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3","dleq":{"e":"bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351","s":"a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67","r":"b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df"}}', + ], + ], + }), + }); + + await updateStats({ + ...test, + event: genEvent({ + kind: 9321, + content: 'Ultimatum', + tags: [ + ['e', note.id], + [ + 'proof', + '{"id":"004f7adf2a04356c","amount":100,"secret":"6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0","C":"03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3","dleq":{"e":"bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351","s":"a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67","r":"b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df"}}', + ], + ], + }), + }); + + const stats = await getEventStats(kysely, note.id); + + assertEquals(stats!.zaps_amount_cashu, 129); +}); + Deno.test('updateStats with kind 5 decrements reactions count', async () => { await using test = await setupTest(); const { kysely, relay } = test; diff --git a/packages/ditto/utils/stats.ts b/packages/ditto/utils/stats.ts index 576c6e01..a880c800 100644 --- a/packages/ditto/utils/stats.ts +++ b/packages/ditto/utils/stats.ts @@ -1,3 +1,5 @@ +import { type Proof } from '@cashu/cashu-ts'; +import { proofSchema } from '@ditto/cashu'; import { DittoTables } from '@ditto/db'; import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; import { Insertable, Kysely, UpdateObject } from 'kysely'; @@ -38,6 +40,8 @@ export async function updateStats(opts: UpdateStatsOpts): Promise { return handleEvent7(opts); case 9735: return handleEvent9735(opts); + case 9321: + return handleEvent9321(opts); } } @@ -232,6 +236,32 @@ async function handleEvent9735(opts: UpdateStatsOpts): Promise { ); } +/** Update stats for kind 9321 event. */ +async function handleEvent9321(opts: UpdateStatsOpts): Promise { + const { kysely, event } = opts; + + // https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-event + // It's possible to nutzap a profile without nutzapping a post, but we don't care about this case + const id = event.tags.find(([name]) => name === 'e')?.[1]; + if (!id) return; + + const proofs = (event.tags.filter(([name]) => name === 'proof').map(([_, proof]) => { + const { success, data } = n.json().pipe(proofSchema).safeParse(proof); + if (!success) return; + + return data; + }) + .filter(Boolean)) as Proof[]; + + const amount = proofs.reduce((prev, current) => prev + current.amount, 0); + + await updateEventStats( + kysely, + id, + ({ zaps_amount_cashu }) => ({ zaps_amount_cashu: Math.max(0, zaps_amount_cashu + amount) }), + ); +} + /** Get the pubkeys that were added and removed from a follow event. */ export function getFollowDiff( tags: string[][], @@ -318,6 +348,7 @@ export async function updateEventStats( reactions_count: 0, quotes_count: 0, zaps_amount: 0, + zaps_amount_cashu: 0, reactions: '{}', }; diff --git a/packages/ditto/views/mastodon/statuses.ts b/packages/ditto/views/mastodon/statuses.ts index ba2e8d86..1c91173c 100644 --- a/packages/ditto/views/mastodon/statuses.ts +++ b/packages/ditto/views/mastodon/statuses.ts @@ -69,6 +69,7 @@ async function renderStatus( ? await store.query([ { kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, + { kinds: [9321], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, @@ -80,6 +81,7 @@ async function renderStatus( const pinEvent = relatedEvents.find((event) => event.kind === 10001); const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); const zapEvent = relatedEvents.find((event) => event.kind === 9734); + const nutzapEvent = relatedEvents.find((event) => event.kind === 9321); const compatMentions = buildInlineRecipients(mentions.filter((m) => { if (m.id === account.id) return false; @@ -136,6 +138,7 @@ async function renderStatus( reblogs_count: event.event_stats?.reposts_count ?? 0, favourites_count: event.event_stats?.reactions['+'] ?? 0, zaps_amount: event.event_stats?.zaps_amount ?? 0, + zaps_amount_cashu: event.event_stats?.zaps_amount_cashu ?? 0, favourited: reactionEvent?.content === '+', reblogged: Boolean(repostEvent), muted: false, @@ -155,6 +158,7 @@ async function renderStatus( uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`), url: Conf.local(`/@${account.acct}/${event.id}`), zapped: Boolean(zapEvent), + zapped_cashu: Boolean(nutzapEvent), ditto: { external_url: Conf.external(nevent), }, diff --git a/packages/mastoapi/types/MastodonStatus.ts b/packages/mastoapi/types/MastodonStatus.ts index 019e5a7b..5196bfce 100644 --- a/packages/mastoapi/types/MastodonStatus.ts +++ b/packages/mastoapi/types/MastodonStatus.ts @@ -18,6 +18,7 @@ export interface MastodonStatus { reblogs_count: number; favourites_count: number; zaps_amount: number; + zaps_amount_cashu: number; favourited: boolean; reblogged: boolean; muted: boolean; @@ -35,6 +36,7 @@ export interface MastodonStatus { uri: string; url: string; zapped: boolean; + zapped_cashu: boolean; pleroma: { emoji_reactions: { name: string; count: number; me: boolean }[]; expires_at?: string; From cda51960079c7c751259b0e6aafd6d142ffb1e0c Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 25 Mar 2025 18:22:51 -0300 Subject: [PATCH 52/56] fix: remove unused import --- packages/cashu/views.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cashu/views.test.ts b/packages/cashu/views.test.ts index 6cff62ee..4ea3b41c 100644 --- a/packages/cashu/views.test.ts +++ b/packages/cashu/views.test.ts @@ -7,7 +7,7 @@ import { assertEquals } from '@std/assert'; import { DittoPolyPg, TestDB } from '@ditto/db'; import { DittoConf } from '@ditto/conf'; -import { renderTransaction, type Transaction } from './views.ts'; +import { renderTransaction } from './views.ts'; Deno.test('renderTransaction function is working', async () => { const conf = new DittoConf(Deno.env); From 8a75f9e944e1b26a6dbe4198f20db2e375dca90b Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 26 Mar 2025 15:55:59 -0300 Subject: [PATCH 53/56] feat: implement GET statuses/:id{[0-9a-f]{64}}/nutzapped_by (with tests) --- packages/ditto/controllers/api/cashu.test.ts | 133 ++++++++++++++++++- packages/ditto/controllers/api/cashu.ts | 44 ++++++ 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 44b74f10..c7216e34 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -1,3 +1,4 @@ +import { Proof } from '@cashu/cashu-ts'; import { proofSchema, walletSchema } from '@ditto/cashu'; import { DittoConf } from '@ditto/conf'; import { type User } from '@ditto/mastoapi/middleware'; @@ -10,9 +11,9 @@ import { assertArrayIncludes, assertEquals, assertExists, assertObjectMatch } fr import { generateSecretKey, getPublicKey } from 'nostr-tools'; import cashuRoute from '@/controllers/api/cashu.ts'; +import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { createTestDB } from '@/test.ts'; import { nostrNow } from '@/utils.ts'; -import { Proof } from '@cashu/cashu-ts'; Deno.test('PUT /wallet must be successful', async () => { const mock = stub(globalThis, 'fetch', () => { @@ -1158,6 +1159,136 @@ Deno.test('POST /nutzap must be successful WITHOUT proofs to keep', async () => mock.restore(); }); +Deno.test('GET /statuses/:id{[0-9a-f]{64}}/nutzapped_by must be successful', async () => { + const mock = stub(globalThis, 'fetch', () => { + return Promise.resolve(new Response()); + }); + + await using test = await createTestRoute(); + const { route, sk, relay, signer } = test; + + const pubkey = await signer.getPublicKey(); + + const post = genEvent({ + kind: 1, + content: 'Hello', + }, sk); + await relay.event(post); + + const senderSk = generateSecretKey(); + const sender = getPublicKey(senderSk); + + await relay.event(genEvent({ + created_at: nostrNow() - 1, + kind: 9321, + content: 'Who do I have?', + tags: [ + ['e', post.id], + ['p', pubkey], + ['u', 'https://mint.soul.com'], + [ + 'proof', + '{"amount":1,"C":"02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f","id":"000a93d6f8a1d2c4","secret":"[\\"P2PK\\",{\\"nonce\\":\\"b00bdd0467b0090a25bdf2d2f0d45ac4e355c482c1418350f273a04fedaaee83\\",\\"data\\":\\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\\"}]"}', + ], + [ + 'proof', + '{"amount":1,"C":"02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f","id":"000a93d6f8a1d2c4","secret":"[\\"P2PK\\",{\\"nonce\\":\\"b00bdd0467b0090a25bdf2d2f0d45ac4e355c482c1418350f273a04fedaaee83\\",\\"data\\":\\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\\"}]"}', + ], + ], + }, senderSk)); + + await relay.event(genEvent({ + created_at: nostrNow() - 3, + kind: 9321, + content: 'Want it all to end', + tags: [ + ['e', post.id], + ['p', pubkey], + ['u', 'https://mint.soul.com'], + [ + 'proof', + JSON.stringify({ + id: '005c2502034d4f12', + amount: 25, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }), + ], + ], + }, senderSk)); + + await relay.event(genEvent({ + created_at: nostrNow() - 5, + kind: 9321, + content: 'Evidence', + tags: [ + ['e', post.id], + ['p', pubkey], + ['u', 'https://mint.soul.com'], + [ + 'proof', + '{"amount":1,"C":"02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f","id":"000a93d6f8a1d2c4","secret":"[\\"P2PK\\",{\\"nonce\\":\\"b00bdd0467b0090a25bdf2d2f0d45ac4e355c482c1418350f273a04fedaaee83\\",\\"data\\":\\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\\"}]"}', + ], + ], + }, senderSk)); + + const sender2Sk = generateSecretKey(); + const sender2 = getPublicKey(sender2Sk); + + await relay.event(genEvent({ + created_at: nostrNow() + 10, + kind: 9321, + content: 'Reach out', + tags: [ + ['e', post.id], + ['p', pubkey], + ['u', 'https://mint.soul.com'], + [ + 'proof', + JSON.stringify({ + id: '005c2502034d4f12', + amount: 25, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }), + ], + ], + }, sender2Sk)); + + const response = await route.request(`/statuses/${post.id}/nutzapped_by`, { + method: 'GET', + }); + + const body = await response.json(); + + assertEquals(response.status, 200); + + assertEquals(body, [ + { + comment: 'Reach out', + amount: 25, + account: JSON.parse(JSON.stringify(accountFromPubkey(sender2))), + }, + { + comment: 'Who do I have?', + amount: 2, + account: JSON.parse(JSON.stringify(accountFromPubkey(sender))), + }, + { + comment: 'Want it all to end', + amount: 25, + account: JSON.parse(JSON.stringify(accountFromPubkey(sender))), + }, + { + comment: 'Evidence', + amount: 1, + account: JSON.parse(JSON.stringify(accountFromPubkey(sender))), + }, + ]); + + mock.restore(); +}); + async function createTestRoute() { const conf = new DittoConf( new Map([['DITTO_NSEC', 'nsec14fg8xd04hvlznnvhaz77ms0k9kxy9yegdsgs2ar27czhh46xemuquqlv0m']]), diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index f355d392..96b50916 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 { getWallet, organizeProofs, + proofSchema, renderTransaction, tokenEventSchema, validateAndParseWallet, @@ -14,6 +15,7 @@ import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { bytesToString, stringToBytes } from '@scure/base'; import { logi } from '@soapbox/logi'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { z } from 'zod'; import { createEvent, parseBody } from '@/utils/api.ts'; @@ -294,6 +296,48 @@ route.get('/transactions', userMiddleware({ enc: 'nip44' }), async (c) => { return paginated(c, events, transactions); }); +/** Gets the nutzaps that a post received. */ +route.get('statuses/:id{[0-9a-f]{64}}/nutzapped_by', async (c) => { + const id = c.req.param('id'); + const { relay, signal } = c.var; + const { limit, since, until } = paginationSchema().parse(c.req.query()); + + const events = await relay.query([{ kinds: [9321], '#e': [id], since, until, limit }], { + signal, + }); + + if (!events.length) { + return c.json([], 200); + } + + await hydrateEvents({ ...c.var, events }); + + const results = (await Promise.all( + events.map((event: DittoEvent) => { + const proofs = (event.tags.filter(([name]) => name === 'proof').map(([_, proof]) => { + const { success, data } = n.json().pipe(proofSchema).safeParse(proof); + if (!success) return; + + return data; + }) + .filter(Boolean)) as Proof[]; + + const amount = proofs.reduce((prev, current) => prev + current.amount, 0); + const comment = event.content; + + const account = event?.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey); + + return { + comment, + amount, + account, + }; + }), + )).filter(Boolean); + + return paginated(c, events, results); +}); + /** Get mints set by the CASHU_MINTS environment variable. */ route.get('/mints', (c) => { const { conf } = c.var; From 69fe86890fdddb3f27d3ba8daeb91001553e0d71 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 27 Mar 2025 09:48:29 -0300 Subject: [PATCH 54/56] feat: add accepts_zaps_cashu boolean field to DittoEvent and hydrate it --- packages/ditto/interfaces/DittoEvent.ts | 2 ++ packages/ditto/storages/hydrate.ts | 26 ++++++++++++++++++++++ packages/ditto/views/mastodon/accounts.ts | 1 + packages/mastoapi/types/MastodonAccount.ts | 1 + 4 files changed, 30 insertions(+) diff --git a/packages/ditto/interfaces/DittoEvent.ts b/packages/ditto/interfaces/DittoEvent.ts index d80a5418..cfa81c60 100644 --- a/packages/ditto/interfaces/DittoEvent.ts +++ b/packages/ditto/interfaces/DittoEvent.ts @@ -57,4 +57,6 @@ export interface DittoEvent extends NostrEvent { zap_message?: string; /** Language of the event (kind 1s are more accurate). */ language?: LanguageCode; + /** Whether or not pubkey accepts cashu. */ + accepts_zaps_cashu?: boolean; } diff --git a/packages/ditto/storages/hydrate.ts b/packages/ditto/storages/hydrate.ts index 60ec89bd..de6c3a82 100644 --- a/packages/ditto/storages/hydrate.ts +++ b/packages/ditto/storages/hydrate.ts @@ -50,6 +50,10 @@ async function hydrateEvents(opts: HydrateOpts): Promise { cache.push(event); } + for (const event of await gatherAcceptCashu({ ...opts, events: cache })) { + cache.push(event); + } + const authorStats = await gatherAuthorStats(cache, db.kysely); const eventStats = await gatherEventStats(cache, db.kysely); @@ -194,6 +198,10 @@ export function assembleEvents( event.zap_message = zapRequest?.content ?? ''; } + event.accepts_zaps_cashu = b.find((e) => matchFilter({ kinds: [10019], authors: [event.pubkey] }, e)) + ? true + : false; + event.author_stats = authorStats[event.pubkey]; event.event_stats = eventStats[event.id]; } @@ -353,6 +361,24 @@ async function gatherInfo({ conf, events, relay, signal }: HydrateOpts): Promise ); } +/** Collect nutzap informational events. */ +function gatherAcceptCashu({ events, relay, signal }: HydrateOpts): Promise { + const pubkeys = new Set(); + + for (const event of events) { + pubkeys.add(event.pubkey); + } + + if (!pubkeys.size) { + return Promise.resolve([]); + } + + return relay.query( + [{ kinds: [10019], authors: [...pubkeys], limit: pubkeys.size }], + { signal }, + ); +} + /** Collect author stats from the events. */ async function gatherAuthorStats( events: DittoEvent[], diff --git a/packages/ditto/views/mastodon/accounts.ts b/packages/ditto/views/mastodon/accounts.ts index 7f390d1a..949b5648 100644 --- a/packages/ditto/views/mastodon/accounts.ts +++ b/packages/ditto/views/mastodon/accounts.ts @@ -117,6 +117,7 @@ function renderAccount(event: Omit, opts: ToAccountOpt username: parsed05?.nickname || npub.substring(0, 8), ditto: { accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), + accepts_zaps_cashu: Boolean(event?.accepts_zaps_cashu), external_url: Conf.external(nprofile), streak: { days: streakDays, diff --git a/packages/mastoapi/types/MastodonAccount.ts b/packages/mastoapi/types/MastodonAccount.ts index 4ea6665b..ec17a7df 100644 --- a/packages/mastoapi/types/MastodonAccount.ts +++ b/packages/mastoapi/types/MastodonAccount.ts @@ -44,6 +44,7 @@ export interface MastodonAccount { username: string; ditto: { accepts_zaps: boolean; + accepts_zaps_cashu: boolean; external_url: string; streak: { days: number; From aadc3079fa55a188651298340dca077ff90c75b8 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 8 Apr 2025 12:14:47 -0300 Subject: [PATCH 55/56] refactor: make GET wallet a bit faster --- packages/cashu/cashu.test.ts | 2 +- packages/cashu/cashu.ts | 6 +- packages/ditto/cache/sessionCache.ts | 73 +++++++++++++ packages/ditto/cache/walletCache.ts | 133 ++++++++++++++++++++++++ packages/ditto/controllers/api/cashu.ts | 28 +---- 5 files changed, 214 insertions(+), 28 deletions(-) create mode 100644 packages/ditto/cache/sessionCache.ts create mode 100644 packages/ditto/cache/walletCache.ts diff --git a/packages/cashu/cashu.test.ts b/packages/cashu/cashu.test.ts index 2e5aca5b..954a274e 100644 --- a/packages/cashu/cashu.test.ts +++ b/packages/cashu/cashu.test.ts @@ -446,7 +446,7 @@ Deno.test('getWallet function is working', async () => { await relay.event(wallet); - const walletEntity = await getWallet(relay, pubkey, signer); + const { wallet: walletEntity } = await getWallet(relay, pubkey, signer); assertEquals(walletEntity, { balance: 38, diff --git a/packages/cashu/cashu.ts b/packages/cashu/cashu.ts index aa1b3583..a4998159 100644 --- a/packages/cashu/cashu.ts +++ b/packages/cashu/cashu.ts @@ -244,12 +244,12 @@ async function getWallet( pubkey: string, signer: SetRequired, opts?: { signal?: AbortSignal }, -): Promise { +): Promise<{ wallet: Wallet; error: null } | { wallet: null; error: CustomError }> { const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal: opts?.signal }); if (error) { logi({ level: 'error', ns: 'ditto.cashu.get_wallet', error: errorJson(error) }); - return; + return { wallet: null, error }; } const { p2pk, mints, relays } = data; @@ -283,7 +283,7 @@ async function getWallet( balance, }; - return walletEntity; + return { wallet: walletEntity, error: null }; } /** Serialize an error into JSON for JSON logging. */ diff --git a/packages/ditto/cache/sessionCache.ts b/packages/ditto/cache/sessionCache.ts new file mode 100644 index 00000000..602ecd26 --- /dev/null +++ b/packages/ditto/cache/sessionCache.ts @@ -0,0 +1,73 @@ +/** + * A simple in-memory session cache for storing small pieces of data + * with an optional TTL. + */ +export class SessionCache { + private cache = new Map(); + + /** + * Set a value in the cache + * @param key The cache key + * @param value The value to store + * @param ttlSec Optional TTL in seconds + */ + set(key: string, value: any, ttlSec?: number): void { + const expires = ttlSec ? Date.now() + (ttlSec * 1000) : undefined; + this.cache.set(key, { value, expires }); + } + + /** + * Get a value from the cache + * @param key The cache key + * @returns The cached value or undefined if not found or expired + */ + get(key: string): any { + const item = this.cache.get(key); + + if (!item) { + return undefined; + } + + if (item.expires && item.expires < Date.now()) { + this.cache.delete(key); + return undefined; + } + + return item.value; + } + + /** + * Remove a value from the cache + * @param key The cache key + */ + delete(key: string): void { + this.cache.delete(key); + } + + /** + * Clear all values from the cache + */ + clear(): void { + this.cache.clear(); + } + + /** + * Run cleanup to remove expired items + */ + cleanup(): void { + const now = Date.now(); + for (const [key, item] of this.cache.entries()) { + if (item.expires && item.expires < now) { + this.cache.delete(key); + } + } + } +} + +// Create and export a singleton instance +export const sessionCache = new SessionCache(); + +// Run cleanup every minute +setInterval(() => { + sessionCache.cleanup(); +}, 60 * 1000); diff --git a/packages/ditto/cache/walletCache.ts b/packages/ditto/cache/walletCache.ts new file mode 100644 index 00000000..335f6c55 --- /dev/null +++ b/packages/ditto/cache/walletCache.ts @@ -0,0 +1,133 @@ +import { Wallet } from '@ditto/cashu'; +import { logi } from '@soapbox/logi'; +import { errorJson } from '@/utils/log.ts'; + +/** + * A simple in-memory cache for wallet data + * - Keys are pubkeys + * - Values are wallet data and timestamp + */ +interface CachedWallet { + wallet: Wallet; + timestamp: number; + lastQueryTimestamp: number; +} + +export class WalletCache { + private cache = new Map(); + private ttlMs: number; + private queryTtlMs: number; + + /** + * @param ttlSec Cache TTL in seconds + * @param queryTtlSec How long we should wait between full queries in seconds + */ + constructor(ttlSec = 60, queryTtlSec = 5) { + this.ttlMs = ttlSec * 1000; + this.queryTtlMs = queryTtlSec * 1000; + + // Periodic cleanup + setInterval(() => this.cleanup(), 60 * 1000); + } + + /** + * Get wallet from cache + * @param pubkey User's pubkey + * @returns The cached wallet if present and valid, null otherwise + */ + get(pubkey: string): { wallet: Wallet; shouldRefresh: boolean } | null { + const entry = this.cache.get(pubkey); + if (!entry) { + return null; + } + + const now = Date.now(); + const age = now - entry.timestamp; + + // If cache entry is too old, consider it invalid + if (age > this.ttlMs) { + return null; + } + + // Check if we should refresh the data in the background + // This is determined by how long since the last full query + const queryAge = now - entry.lastQueryTimestamp; + const shouldRefresh = queryAge > this.queryTtlMs; + + return { wallet: entry.wallet, shouldRefresh }; + } + + /** + * Store wallet in cache + * @param pubkey User's pubkey + * @param wallet Wallet data + * @param isQueryResult Whether this is from a full query or just a balance update + */ + set(pubkey: string, wallet: Wallet, isQueryResult = true): void { + const now = Date.now(); + const existing = this.cache.get(pubkey); + + this.cache.set(pubkey, { + wallet, + timestamp: now, + // If this is just a balance update, preserve the lastQueryTimestamp + lastQueryTimestamp: isQueryResult ? now : (existing?.lastQueryTimestamp || now), + }); + } + + /** + * Update balance for a wallet without doing a full refresh + * @param pubkey User's pubkey + * @param deltaAmount Amount to add to balance (negative to subtract) + * @returns true if updated, false if wallet not in cache + */ + updateBalance(pubkey: string, deltaAmount: number): boolean { + const entry = this.cache.get(pubkey); + if (!entry) { + return false; + } + + const newWallet = { + ...entry.wallet, + balance: entry.wallet.balance + deltaAmount, + }; + + this.set(pubkey, newWallet, false); + return true; + } + + /** + * Remove expired entries from cache + */ + private cleanup(): void { + const now = Date.now(); + let deletedCount = 0; + + for (const [pubkey, entry] of this.cache.entries()) { + if (now - entry.timestamp > this.ttlMs) { + this.cache.delete(pubkey); + deletedCount++; + } + } + + if (deletedCount > 0) { + logi({ + level: 'debug', + ns: 'ditto.cache.wallet', + message: `Cleaned up ${deletedCount} expired wallet cache entries`, + }); + } + } + + /** + * Get cache statistics + */ + getStats(): { size: number } { + return { + size: this.cache.size, + }; + } +} + +// Singleton instance +export const walletCache = new WalletCache(); diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 96b50916..d6b1248e 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,13 +1,5 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; -import { - getWallet, - organizeProofs, - proofSchema, - renderTransaction, - tokenEventSchema, - validateAndParseWallet, - type Wallet, -} from '@ditto/cashu'; +import { getWallet, organizeProofs, proofSchema, renderTransaction, tokenEventSchema, type Wallet } from '@ditto/cashu'; import { userMiddleware } from '@ditto/mastoapi/middleware'; import { paginated, paginationSchema } from '@ditto/mastoapi/pagination'; import { DittoRoute } from '@ditto/mastoapi/router'; @@ -28,13 +20,6 @@ import { DittoEvent } from '@/interfaces/DittoEvent.ts'; const route = new DittoRoute(); -interface Nutzap { - amount: number; - event_id?: string; - mint: string; // mint the nutzap was created - recipient_pubkey: string; -} - const createMintQuoteSchema = z.object({ mint: z.string().url(), amount: z.number().int(), @@ -258,18 +243,13 @@ route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, as const pubkey = await user.signer.getPublicKey(); - const { error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal }); + const { wallet, error } = await getWallet(relay, pubkey, user.signer, { signal }); + if (error) { return c.json({ error: error.message }, 404); } - const walletEntity = await getWallet(relay, pubkey, user.signer); - - if (!walletEntity) { - return c.json({ 'error': 'Wallet not found' }, 404); - } - - return c.json(walletEntity, 200); + return c.json(wallet, 200); }); /** Gets a history of transactions. */ From 9d375f4afe051f0f0f4c7063ce4de0c115ec91d0 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 8 Apr 2025 12:23:12 -0300 Subject: [PATCH 56/56] goose created files remove --- packages/ditto/cache/sessionCache.ts | 73 --------------- packages/ditto/cache/walletCache.ts | 133 --------------------------- 2 files changed, 206 deletions(-) delete mode 100644 packages/ditto/cache/sessionCache.ts delete mode 100644 packages/ditto/cache/walletCache.ts diff --git a/packages/ditto/cache/sessionCache.ts b/packages/ditto/cache/sessionCache.ts deleted file mode 100644 index 602ecd26..00000000 --- a/packages/ditto/cache/sessionCache.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * A simple in-memory session cache for storing small pieces of data - * with an optional TTL. - */ -export class SessionCache { - private cache = new Map(); - - /** - * Set a value in the cache - * @param key The cache key - * @param value The value to store - * @param ttlSec Optional TTL in seconds - */ - set(key: string, value: any, ttlSec?: number): void { - const expires = ttlSec ? Date.now() + (ttlSec * 1000) : undefined; - this.cache.set(key, { value, expires }); - } - - /** - * Get a value from the cache - * @param key The cache key - * @returns The cached value or undefined if not found or expired - */ - get(key: string): any { - const item = this.cache.get(key); - - if (!item) { - return undefined; - } - - if (item.expires && item.expires < Date.now()) { - this.cache.delete(key); - return undefined; - } - - return item.value; - } - - /** - * Remove a value from the cache - * @param key The cache key - */ - delete(key: string): void { - this.cache.delete(key); - } - - /** - * Clear all values from the cache - */ - clear(): void { - this.cache.clear(); - } - - /** - * Run cleanup to remove expired items - */ - cleanup(): void { - const now = Date.now(); - for (const [key, item] of this.cache.entries()) { - if (item.expires && item.expires < now) { - this.cache.delete(key); - } - } - } -} - -// Create and export a singleton instance -export const sessionCache = new SessionCache(); - -// Run cleanup every minute -setInterval(() => { - sessionCache.cleanup(); -}, 60 * 1000); diff --git a/packages/ditto/cache/walletCache.ts b/packages/ditto/cache/walletCache.ts deleted file mode 100644 index 335f6c55..00000000 --- a/packages/ditto/cache/walletCache.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Wallet } from '@ditto/cashu'; -import { logi } from '@soapbox/logi'; -import { errorJson } from '@/utils/log.ts'; - -/** - * A simple in-memory cache for wallet data - * - Keys are pubkeys - * - Values are wallet data and timestamp - */ -interface CachedWallet { - wallet: Wallet; - timestamp: number; - lastQueryTimestamp: number; -} - -export class WalletCache { - private cache = new Map(); - private ttlMs: number; - private queryTtlMs: number; - - /** - * @param ttlSec Cache TTL in seconds - * @param queryTtlSec How long we should wait between full queries in seconds - */ - constructor(ttlSec = 60, queryTtlSec = 5) { - this.ttlMs = ttlSec * 1000; - this.queryTtlMs = queryTtlSec * 1000; - - // Periodic cleanup - setInterval(() => this.cleanup(), 60 * 1000); - } - - /** - * Get wallet from cache - * @param pubkey User's pubkey - * @returns The cached wallet if present and valid, null otherwise - */ - get(pubkey: string): { wallet: Wallet; shouldRefresh: boolean } | null { - const entry = this.cache.get(pubkey); - if (!entry) { - return null; - } - - const now = Date.now(); - const age = now - entry.timestamp; - - // If cache entry is too old, consider it invalid - if (age > this.ttlMs) { - return null; - } - - // Check if we should refresh the data in the background - // This is determined by how long since the last full query - const queryAge = now - entry.lastQueryTimestamp; - const shouldRefresh = queryAge > this.queryTtlMs; - - return { wallet: entry.wallet, shouldRefresh }; - } - - /** - * Store wallet in cache - * @param pubkey User's pubkey - * @param wallet Wallet data - * @param isQueryResult Whether this is from a full query or just a balance update - */ - set(pubkey: string, wallet: Wallet, isQueryResult = true): void { - const now = Date.now(); - const existing = this.cache.get(pubkey); - - this.cache.set(pubkey, { - wallet, - timestamp: now, - // If this is just a balance update, preserve the lastQueryTimestamp - lastQueryTimestamp: isQueryResult ? now : (existing?.lastQueryTimestamp || now), - }); - } - - /** - * Update balance for a wallet without doing a full refresh - * @param pubkey User's pubkey - * @param deltaAmount Amount to add to balance (negative to subtract) - * @returns true if updated, false if wallet not in cache - */ - updateBalance(pubkey: string, deltaAmount: number): boolean { - const entry = this.cache.get(pubkey); - if (!entry) { - return false; - } - - const newWallet = { - ...entry.wallet, - balance: entry.wallet.balance + deltaAmount, - }; - - this.set(pubkey, newWallet, false); - return true; - } - - /** - * Remove expired entries from cache - */ - private cleanup(): void { - const now = Date.now(); - let deletedCount = 0; - - for (const [pubkey, entry] of this.cache.entries()) { - if (now - entry.timestamp > this.ttlMs) { - this.cache.delete(pubkey); - deletedCount++; - } - } - - if (deletedCount > 0) { - logi({ - level: 'debug', - ns: 'ditto.cache.wallet', - message: `Cleaned up ${deletedCount} expired wallet cache entries`, - }); - } - } - - /** - * Get cache statistics - */ - getStats(): { size: number } { - return { - size: this.cache.size, - }; - } -} - -// Singleton instance -export const walletCache = new WalletCache();