From daedf24ca8ca180c960e2211ca9b774b99ff8160 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 4 Feb 2025 12:34:41 -0300 Subject: [PATCH 01/29] fix: add missing endpoint createNutzapInformationController --- src/app.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app.ts b/src/app.ts index 6929757f..8b3da2c3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -44,6 +44,7 @@ import { adminRelaysController, adminSetRelaysController, createCashuWalletController, + createNutzapInformationController, deleteZapSplitsController, getZapSplitsController, nameRequestController, @@ -407,6 +408,7 @@ app.post('/api/v1/ditto/zap', requireSigner, zapController); app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController); app.post('/api/v1/ditto/wallet/create', requireSigner, createCashuWalletController); +app.post('/api/v1/ditto/nutzap_information/create', requireSigner, createNutzapInformationController); app.post('/api/v1/reports', requireSigner, reportController); app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController); From e9696b8a2a1fd6a481b6926a517859e8b55b5dfd Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 4 Feb 2025 12:48:56 -0300 Subject: [PATCH 02/29] refactor(createCashuWalletController): implement new NIP 60 cashu wallet --- src/controllers/api/ditto.ts | 52 ++++++++++++++---------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index b8476608..b2108c28 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -346,14 +346,11 @@ export const updateInstanceController: AppController = async (c) => { }; const createCashuWalletSchema = z.object({ - description: z.string(), - relays: z.array(z.string().url()), mints: z.array(z.string().url()).nonempty(), // must contain at least one item - name: z.string(), }); /** - * Creates an addressable Cashu wallet. + * Creates a replaceable Cashu wallet. * https://github.com/nostr-protocol/nips/blob/master/60.md */ export const createCashuWalletController: AppController = async (c) => { @@ -365,50 +362,41 @@ export const createCashuWalletController: AppController = async (c) => { const result = createCashuWalletSchema.safeParse(body); if (!result.success) { - return c.json({ error: 'Bad request', schema: result.error }, 400); + return c.json({ error: 'Bad schema', schema: result.error }, 400); } - const [event] = await store.query([{ authors: [pubkey], kinds: [37375] }], { signal }); + const nip44 = signer.nip44; + if (!nip44) { + return c.json({ error: 'Signer does not have nip 44' }, 400); + } + + const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); if (event) { return c.json({ error: 'You already have a wallet 😏' }, 400); } - const { description, relays, mints, name } = result.data; - relays.push(Conf.relay); - - const tags: string[][] = []; - - const wallet_id = Math.random().toString(36).substring(3); - - tags.push(['d', wallet_id]); - tags.push(['name', name]); - tags.push(['description', description]); - tags.push(['unit', 'sat']); - - for (const mint of new Set(mints)) { - tags.push(['mint', mint]); - } - - for (const relay of new Set(relays)) { - tags.push(['relay', relay]); - } + const contentTags: string[][] = []; const sk = generateSecretKey(); const privkey = bytesToString('hex', sk); - const contentTags = [ - ['privkey', privkey], - ]; - const encryptedContentTags = await signer.nip44?.encrypt(pubkey, JSON.stringify(contentTags)); + contentTags.push(['privkey', privkey]); + + const { mints } = result.data; + + for (const mint of new Set(mints)) { + contentTags.push(['mint', mint]); + } + + const encryptedContentTags = await nip44.encrypt(pubkey, JSON.stringify(contentTags)); // Wallet await createEvent({ - kind: 37375, + kind: 17375, content: encryptedContentTags, - tags, }, c); - return c.json({ wallet_id }, 200); + return c.json(201); }; const createNutzapInformationSchema = z.object({ From 236a9284ca908688fd1ed72ee2a35f7fe92d052b Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 4 Feb 2025 15:12:56 -0300 Subject: [PATCH 03/29] refactor(createNutzapInformationController): implement new NIP 60 cashu wallet --- src/controllers/api/ditto.ts | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index b2108c28..def73ffa 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,17 +1,20 @@ import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; +import { logi } from '@soapbox/logi'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { addTag } from '@/utils/tags.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { getAuthor } from '@/queries.ts'; +import { isNostrId } from '@/utils.ts'; +import { addTag } from '@/utils/tags.ts'; import { createEvent, paginated, parseBody, updateAdminEvent } from '@/utils/api.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; import { deleteTag } from '@/utils/tags.ts'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts'; +import { errorJson } from '@/utils/log.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { screenshotsSchema } from '@/schemas/nostr.ts'; import { booleanParamSchema, percentageSchema, wsUrlSchema } from '@/schema.ts'; @@ -402,7 +405,6 @@ export const createCashuWalletController: AppController = async (c) => { const createNutzapInformationSchema = z.object({ relays: z.array(z.string().url()), mints: z.array(z.string().url()).nonempty(), // must contain at least one item - wallet_id: z.string(), }); /** @@ -418,7 +420,7 @@ export const createNutzapInformationController: AppController = async (c) => { const result = createNutzapInformationSchema.safeParse(body); if (!result.success) { - return c.json({ error: 'Bad request', schema: result.error }, 400); + return c.json({ error: 'Bad schema', schema: result.error }, 400); } const nip44 = signer.nip44; @@ -426,11 +428,11 @@ export const createNutzapInformationController: AppController = async (c) => { return c.json({ error: 'Signer does not have nip 44' }, 400); } - const { relays, mints, wallet_id } = result.data; + const { relays, mints } = result.data; - const [event] = await store.query([{ authors: [pubkey], kinds: [37375], '#d': [wallet_id] }], { signal }); + const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); if (!event) { - return c.json({ error: 'Could not find a wallet with the id: ' + wallet_id }, 400); + return c.json({ error: 'You need to have a wallet to create a nutzap information event.' }, 400); } relays.push(Conf.relay); @@ -445,11 +447,24 @@ export const createNutzapInformationController: AppController = async (c) => { tags.push(['relay', relay]); } - const contentTags: string[][] = JSON.parse(await nip44.decrypt(pubkey, event.content)); + let decryptedContent: string; + try { + decryptedContent = await nip44.decrypt(pubkey, event.content); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api', id: event.id, kind: event.kind, error: errorJson(e) }); + return c.json({ error: 'Could not decrypt wallet content.' }, 400); + } + + let contentTags: string[][]; + try { + contentTags = JSON.parse(decryptedContent); + } catch { + return c.json({ error: 'Could not JSON parse the decrypted wallet content.' }, 400); + } const privkey = contentTags.find(([value]) => value === 'privkey')?.[1]; - if (!privkey) { - return c.json({ error: 'Wallet does not contain privkey' }, 400); + 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)); From 870847127ba0284c8d23414e16d3b5a30fc15e5f Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 4 Feb 2025 21:33:02 -0300 Subject: [PATCH 04/29] checkpoint: implement swapNutzapsToWalletController --- src/app.ts | 2 + src/controllers/api/ditto.ts | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/app.ts b/src/app.ts index 8b3da2c3..dfb07240 100644 --- a/src/app.ts +++ b/src/app.ts @@ -50,6 +50,7 @@ import { nameRequestController, nameRequestsController, statusZapSplitsController, + swapNutzapsToWalletController, updateInstanceController, updateZapSplitsController, } from '@/controllers/api/ditto.ts'; @@ -409,6 +410,7 @@ app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController app.post('/api/v1/ditto/wallet/create', requireSigner, createCashuWalletController); app.post('/api/v1/ditto/nutzap_information/create', requireSigner, createNutzapInformationController); +app.get('/api/v1/ditto/nutzap/swap_to_wallet', requireSigner, swapNutzapsToWalletController); app.post('/api/v1/reports', requireSigner, reportController); app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController); diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index def73ffa..9f48f219 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -479,3 +479,75 @@ export const createNutzapInformationController: AppController = async (c) => { return c.json(201); }; + +/** + * Swaps all nutzaps (NIP-61) to the user's wallet (NIP-60) + */ +export const swapNutzapsToWalletController: AppController = async (c) => { + const signer = c.get('signer')!; + const store = c.get('store'); + const pubkey = await signer.getPublicKey(); + const { signal } = c.req.raw; + + const nip44 = signer.nip44; + if (!nip44) { + return c.json({ error: 'Signer does not have nip 44.' }, 400); + } + + const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); + if (!wallet) { + return c.json({ error: 'You need to have a wallet to swap the nutzaps into it.' }, 400); + } + + let decryptedContent: string; + try { + decryptedContent = await nip44.decrypt(pubkey, wallet.content); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api', id: wallet.id, kind: wallet.kind, error: errorJson(e) }); + return c.json({ error: 'Could not decrypt wallet content.' }, 400); + } + + let contentTags: string[][]; + try { + contentTags = JSON.parse(decryptedContent); + } catch { + return c.json({ error: 'Could not JSON 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 [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); + if (nutzapHistory) { + nutzapsFilter.since = nutzapHistory.created_at; + } + + const nutzaps = await store.query([nutzapsFilter], { signal }); + + // TODO: finally start doing the swap + + return c.json(201); +}; From df1a3fe84263bbdf0421e1f90e6811c7aa6226de Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 5 Feb 2025 19:32:48 -0300 Subject: [PATCH 05/29] dependency: add cashu-ts --- deno.json | 1 + deno.lock | 61 ++++++++++++++++++++++++++++++++++++ src/controllers/api/ditto.ts | 1 + 3 files changed, 63 insertions(+) diff --git a/deno.json b/deno.json index 80c58382..ce334b87 100644 --- a/deno.json +++ b/deno.json @@ -38,6 +38,7 @@ "@/": "./src/", "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47", "@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4", + "@cashu/cashu-ts": "npm:@cashu/cashu-ts@^2.2.0", "@electric-sql/pglite": "npm:@electric-sql/pglite@^0.2.8", "@esroyo/scoped-performance": "jsr:@esroyo/scoped-performance@^3.1.0", "@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2", diff --git a/deno.lock b/deno.lock index 5e4134da..9ff5ee11 100644 --- a/deno.lock +++ b/deno.lock @@ -84,6 +84,7 @@ "jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1", "jsr:@std/path@~0.213.1": "0.213.1", "jsr:@std/streams@0.223": "0.223.0", + "npm:@cashu/cashu-ts@^2.2.0": "2.2.0", "npm:@electric-sql/pglite@~0.2.8": "0.2.8", "npm:@isaacs/ttlcache@^1.4.1": "1.4.1", "npm:@noble/hashes@^1.4.0": "1.4.0", @@ -724,6 +725,25 @@ } }, "npm": { + "@cashu/cashu-ts@2.2.0": { + "integrity": "sha512-7b6pGyjjpm3uAJvmOL+ztpRxqp1qnmzGpydp+Pu30pOjxj93EhejPTJVrZMDJ0P35y6u5+5jIjHF4k0fpovvmg==", + "dependencies": [ + "@cashu/crypto", + "@noble/curves@1.4.0", + "@noble/hashes@1.4.0", + "buffer" + ] + }, + "@cashu/crypto@0.3.4": { + "integrity": "sha512-mfv1Pj4iL1PXzUj9NKIJbmncCLMqYfnEDqh/OPxAX0nNBt6BOnVJJLjLWFlQeYxlnEfWABSNkrqPje1t5zcyhA==", + "dependencies": [ + "@noble/curves@1.8.1", + "@noble/hashes@1.7.1", + "@scure/bip32@1.6.2", + "@scure/bip39@1.5.4", + "buffer" + ] + }, "@electric-sql/pglite@0.2.8": { "integrity": "sha512-0wSmQu22euBRzR5ghqyIHnBH4MfwlkL5WstOrrA3KOsjEWEglvoL/gH92JajEUA6Ufei/+qbkB2hVloC/K/RxQ==" }, @@ -841,6 +861,12 @@ "@noble/hashes@1.4.0" ] }, + "@noble/curves@1.8.1": { + "integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==", + "dependencies": [ + "@noble/hashes@1.7.1" + ] + }, "@noble/hashes@1.3.1": { "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==" }, @@ -850,6 +876,9 @@ "@noble/hashes@1.4.0": { "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==" }, + "@noble/hashes@1.7.1": { + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==" + }, "@noble/secp256k1@2.1.0": { "integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw==" }, @@ -862,6 +891,9 @@ "@scure/base@1.1.6": { "integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==" }, + "@scure/base@1.2.4": { + "integrity": "sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ==" + }, "@scure/bip32@1.3.1": { "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", "dependencies": [ @@ -878,6 +910,14 @@ "@scure/base@1.1.6" ] }, + "@scure/bip32@1.6.2": { + "integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==", + "dependencies": [ + "@noble/curves@1.8.1", + "@noble/hashes@1.7.1", + "@scure/base@1.2.4" + ] + }, "@scure/bip39@1.2.1": { "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", "dependencies": [ @@ -892,6 +932,13 @@ "@scure/base@1.1.6" ] }, + "@scure/bip39@1.5.4": { + "integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==", + "dependencies": [ + "@noble/hashes@1.7.1", + "@scure/base@1.2.4" + ] + }, "@types/dompurify@3.0.5": { "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", "dependencies": [ @@ -928,6 +975,9 @@ "asynckit@0.4.0": { "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "bintrees@1.0.2": { "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" }, @@ -940,6 +990,13 @@ "fill-range" ] }, + "buffer@6.0.3": { + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dependencies": [ + "base64-js", + "ieee754" + ] + }, "chalk@5.3.0": { "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==" }, @@ -1178,6 +1235,9 @@ "safer-buffer" ] }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, "image-size@1.1.1": { "integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==", "dependencies": [ @@ -2371,6 +2431,7 @@ "jsr:@std/json@0.223", "jsr:@std/media-types@~0.224.1", "jsr:@std/streams@0.223", + "npm:@cashu/cashu-ts@^2.2.0", "npm:@electric-sql/pglite@~0.2.8", "npm:@isaacs/ttlcache@^1.4.1", "npm:@noble/secp256k1@2", diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 9f48f219..3e070c04 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,3 +1,4 @@ +import { CashuMint, CashuWallet } from '@cashu/cashu-ts'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; From d61f0d1d4baacca610d619000c9a1b68a326b601 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 5 Feb 2025 23:34:56 -0300 Subject: [PATCH 06/29] checkpoint: swap tokens into user controlled wallet TODO: create the 7376 history kind, reemded marker, etc --- src/controllers/api/ditto.ts | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 3e070c04..e2351656 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,4 +1,4 @@ -import { CashuMint, CashuWallet } from '@cashu/cashu-ts'; +import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; @@ -548,7 +548,39 @@ export const swapNutzapsToWalletController: AppController = async (c) => { const nutzaps = await store.query([nutzapsFilter], { signal }); - // TODO: finally start doing the swap + const mintsToProofs: { [key: string]: Proof[] } = {}; + nutzaps.forEach(async (event) => { + try { + const { mint, proofs }: { mint: string; proofs: Proof[] } = JSON.parse( // TODO: create a merge request in nostr tools or Nostrify to do this in a nice way? + await nip44.decrypt(pubkey, event.content), + ); + if (typeof mint === 'string') { + mintsToProofs[mint] = [...(mintsToProofs[mint] || []), ...proofs]; + } + } catch { + // do nothing, for now... (maybe print errors) + } + }); + + for (const mint of Object.keys(mintsToProofs)) { + const token = getEncodedToken({ mint, proofs: mintsToProofs[mint] }, { version: 3 }); + + const cashuWallet = new CashuWallet(new CashuMint(mint)); + const receiveProofs = await cashuWallet.receive(token); + + await createEvent({ + kind: 7375, + content: await nip44.encrypt( + pubkey, + JSON.stringify({ + mint, + proofs: receiveProofs, + }), + ), + }, c); + + // TODO: create the 7376 history kind, reemded marker, etc + } return c.json(201); }; From f7e49cd5ec180918909f08911b96c012ada93b4b Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 6 Feb 2025 12:28:09 -0300 Subject: [PATCH 07/29] checkpoint: implement nutzap redemption history (kind 7376) --- src/controllers/api/ditto.ts | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index e2351656..d9850163 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -546,16 +546,27 @@ export const swapNutzapsToWalletController: AppController = async (c) => { nutzapsFilter.since = nutzapHistory.created_at; } + const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; + const nutzaps = await store.query([nutzapsFilter], { signal }); - const mintsToProofs: { [key: string]: Proof[] } = {}; nutzaps.forEach(async (event) => { try { const { mint, proofs }: { mint: string; proofs: Proof[] } = JSON.parse( // TODO: create a merge request in nostr tools or Nostrify to do this in a nice way? await nip44.decrypt(pubkey, event.content), ); if (typeof mint === 'string') { - mintsToProofs[mint] = [...(mintsToProofs[mint] || []), ...proofs]; + mintsToProofs[mint].proofs = [...(mintsToProofs[mint].proofs || []), ...proofs]; + 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 { // do nothing, for now... (maybe print errors) @@ -563,12 +574,12 @@ export const swapNutzapsToWalletController: AppController = async (c) => { }); for (const mint of Object.keys(mintsToProofs)) { - const token = getEncodedToken({ mint, proofs: mintsToProofs[mint] }, { version: 3 }); + const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs }, { version: 3 }); const cashuWallet = new CashuWallet(new CashuMint(mint)); const receiveProofs = await cashuWallet.receive(token); - await createEvent({ + const unspentProofs = await createEvent({ kind: 7375, content: await nip44.encrypt( pubkey, @@ -579,7 +590,22 @@ export const swapNutzapsToWalletController: AppController = async (c) => { ), }, c); - // TODO: create the 7376 history kind, reemded marker, etc + const amount = receiveProofs.reduce((accumulator, current) => { + return accumulator + current.amount; + }, 0); + + await createEvent({ + kind: 7376, + content: await nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'in'], + ['amount', amount], + ['e', unspentProofs.id, Conf.relay, 'created'], + ]), + ), + tags: mintsToProofs[mint].redeemed, + }, c); } return c.json(201); From a6c7bbd751b85516d7b2f6ad315376d14264be79 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 7 Feb 2025 18:11:39 -0300 Subject: [PATCH 08/29] createNutzapInformationController: add TODO message --- src/controllers/api/ditto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index d9850163..2ab3ceb6 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -429,7 +429,7 @@ export const createNutzapInformationController: AppController = async (c) => { return c.json({ error: 'Signer does not have nip 44' }, 400); } - const { relays, mints } = result.data; + const { relays, mints } = result.data; // TODO: get those mints and replace the mints specified in wallet, so 'nutzap information event' and the wallet always have the same mints const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); if (!event) { From f9da10093613ea4ecb202d6bfd7b9d6e694b0c5e Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 7 Feb 2025 22:41:39 -0300 Subject: [PATCH 09/29] refactor(swapNutzapsToWalletController): change to POST method --- src/app.ts | 2 +- src/controllers/api/ditto.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index dfb07240..8960e972 100644 --- a/src/app.ts +++ b/src/app.ts @@ -410,7 +410,7 @@ app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController app.post('/api/v1/ditto/wallet/create', requireSigner, createCashuWalletController); app.post('/api/v1/ditto/nutzap_information/create', requireSigner, createNutzapInformationController); -app.get('/api/v1/ditto/nutzap/swap_to_wallet', requireSigner, swapNutzapsToWalletController); +app.post('/api/v1/ditto/nutzap/swap_to_wallet', requireSigner, swapNutzapsToWalletController); app.post('/api/v1/reports', requireSigner, reportController); app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController); diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 2ab3ceb6..c8098122 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -573,6 +573,7 @@ export const swapNutzapsToWalletController: AppController = async (c) => { } }); + // TODO: throw error if mintsToProofs is an empty object? for (const mint of Object.keys(mintsToProofs)) { const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs }, { version: 3 }); From 361ef9a6005526912ab20e3c1b9a61580feb17fd Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 7 Feb 2025 23:32:21 -0300 Subject: [PATCH 10/29] fix: stop trying to decrypt kind 7376 content (lol), log errors --- src/controllers/api/ditto.ts | 114 ++++++++++++++++++++--------------- 1 file changed, 64 insertions(+), 50 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index c8098122..fca34645 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -452,7 +452,7 @@ export const createNutzapInformationController: AppController = async (c) => { try { decryptedContent = await nip44.decrypt(pubkey, event.content); } catch (e) { - logi({ level: 'error', ns: 'ditto.api', id: event.id, kind: event.kind, error: errorJson(e) }); + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', id: event.id, kind: event.kind, error: errorJson(e) }); return c.json({ error: 'Could not decrypt wallet content.' }, 400); } @@ -504,7 +504,7 @@ export const swapNutzapsToWalletController: AppController = async (c) => { try { decryptedContent = await nip44.decrypt(pubkey, wallet.content); } catch (e) { - logi({ level: 'error', ns: 'ditto.api', id: wallet.id, kind: wallet.kind, error: errorJson(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); } @@ -539,7 +539,8 @@ export const swapNutzapsToWalletController: AppController = async (c) => { 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 nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints }; + const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey] }; const [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); if (nutzapHistory) { @@ -550,63 +551,76 @@ export const swapNutzapsToWalletController: AppController = async (c) => { const nutzaps = await store.query([nutzapsFilter], { signal }); - nutzaps.forEach(async (event) => { + for (const event of nutzaps) { try { - const { mint, proofs }: { mint: string; proofs: Proof[] } = JSON.parse( // TODO: create a merge request in nostr tools or Nostrify to do this in a nice way? - await nip44.decrypt(pubkey, event.content), - ); - if (typeof mint === 'string') { - mintsToProofs[mint].proofs = [...(mintsToProofs[mint].proofs || []), ...proofs]; - 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) - ]; + const mint = event.tags.find(([name]) => name === 'u')?.[1]; + if (!mint) { + continue; } - } catch { - // do nothing, for now... (maybe print errors) + + const proof = event.tags.find(([name]) => name === 'proof')?.[1]; + if (!proof) { + continue; + } + + if (!mintsToProofs[mint]) { + mintsToProofs[mint] = { proofs: [], redeemed: [] }; + } + + mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...JSON.parse(proof)]; + 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: any) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); } - }); + } // TODO: throw error if mintsToProofs is an empty object? for (const mint of Object.keys(mintsToProofs)) { - const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs }, { version: 3 }); + try { + const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs }); - const cashuWallet = new CashuWallet(new CashuMint(mint)); - const receiveProofs = await cashuWallet.receive(token); + const cashuWallet = new CashuWallet(new CashuMint(mint)); + const receiveProofs = await cashuWallet.receive(token); - const unspentProofs = await createEvent({ - kind: 7375, - content: await nip44.encrypt( - pubkey, - JSON.stringify({ - mint, - proofs: receiveProofs, - }), - ), - }, c); + const unspentProofs = await createEvent({ + kind: 7375, + content: await nip44.encrypt( + pubkey, + JSON.stringify({ + mint, + proofs: receiveProofs, + }), + ), + }, c); - const amount = receiveProofs.reduce((accumulator, current) => { - return accumulator + current.amount; - }, 0); + const amount = receiveProofs.reduce((accumulator, current) => { + return accumulator + current.amount; + }, 0); - await createEvent({ - kind: 7376, - content: await nip44.encrypt( - pubkey, - JSON.stringify([ - ['direction', 'in'], - ['amount', amount], - ['e', unspentProofs.id, Conf.relay, 'created'], - ]), - ), - tags: mintsToProofs[mint].redeemed, - }, c); + await createEvent({ + kind: 7376, + content: await nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'in'], + ['amount', amount], + ['e', unspentProofs.id, Conf.relay, 'created'], + ]), + ), + tags: mintsToProofs[mint].redeemed, + }, c); + } catch (e: any) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); + } } return c.json(201); From efceee505ac2ee80fbe76d32417beecb924a280e Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 7 Feb 2025 23:49:57 -0300 Subject: [PATCH 11/29] fix: pass privkey to cashuWallet.receive --- src/controllers/api/ditto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index fca34645..f014adc4 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -589,7 +589,7 @@ export const swapNutzapsToWalletController: AppController = async (c) => { const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs }); const cashuWallet = new CashuWallet(new CashuMint(mint)); - const receiveProofs = await cashuWallet.receive(token); + const receiveProofs = await cashuWallet.receive(token, { privkey }); const unspentProofs = await createEvent({ kind: 7375, From cde091132e428746ae7bf0158538974ac66a57b0 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sun, 9 Feb 2025 11:54:12 -0300 Subject: [PATCH 12/29] fix: remove comment --- src/controllers/api/ditto.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index f014adc4..7251f3fa 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -539,8 +539,7 @@ export const swapNutzapsToWalletController: AppController = async (c) => { 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 nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey] }; + const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints }; // TODO: index 'u' tags const [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); if (nutzapHistory) { From 00d10c7f9b9d9cb474e106891c5652de6b73aa15 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 10 Feb 2025 13:24:19 -0300 Subject: [PATCH 13/29] refactor: TODO comments --- src/controllers/api/ditto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 7251f3fa..da905127 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -429,7 +429,7 @@ export const createNutzapInformationController: AppController = async (c) => { return c.json({ error: 'Signer does not have nip 44' }, 400); } - const { relays, mints } = result.data; // TODO: get those mints and replace the mints specified in wallet, so 'nutzap information event' and the wallet always have the same mints + const { relays, mints } = result.data; // TODO: MAYBE get those mints and replace the mints specified in wallet, so 'nutzap information event' and the wallet always have the same mints const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); if (!event) { @@ -539,7 +539,7 @@ export const swapNutzapsToWalletController: AppController = async (c) => { 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 }; // TODO: index 'u' tags + const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints }; const [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); if (nutzapHistory) { From 1368304d250ca09645893708b72af10742434111 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 10 Feb 2025 12:04:38 -0600 Subject: [PATCH 14/29] Add cashuApp (rough draft) --- src/app.ts | 5 +- src/controllers/api/cashu.ts | 326 +++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 src/controllers/api/cashu.ts diff --git a/src/app.ts b/src/app.ts index 8960e972..a6a4981a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -39,11 +39,11 @@ import { import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts'; import { blocksController } from '@/controllers/api/blocks.ts'; import { bookmarksController } from '@/controllers/api/bookmarks.ts'; +import cashuApp from '@/controllers/api/cashu.ts'; import { captchaController, captchaVerifyController } from '@/controllers/api/captcha.ts'; import { adminRelaysController, adminSetRelaysController, - createCashuWalletController, createNutzapInformationController, deleteZapSplitsController, getZapSplitsController, @@ -408,7 +408,8 @@ app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSpli app.post('/api/v1/ditto/zap', requireSigner, zapController); app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController); -app.post('/api/v1/ditto/wallet/create', requireSigner, createCashuWalletController); +app.route('/api/v1/ditto/cashu', cashuApp); + app.post('/api/v1/ditto/nutzap_information/create', requireSigner, createNutzapInformationController); app.post('/api/v1/ditto/nutzap/swap_to_wallet', requireSigner, swapNutzapsToWalletController); diff --git a/src/controllers/api/cashu.ts b/src/controllers/api/cashu.ts new file mode 100644 index 00000000..8219d8c7 --- /dev/null +++ b/src/controllers/api/cashu.ts @@ -0,0 +1,326 @@ +import { Hono } from '@hono/hono'; +import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; +import { NostrFilter } from '@nostrify/nostrify'; +import { logi } from '@soapbox/logi'; +import { generateSecretKey, getPublicKey } from 'nostr-tools'; +import { bytesToString, stringToBytes } from '@scure/base'; +import { z } from 'zod'; + +import { Conf } from '@/config.ts'; +import { isNostrId } from '@/utils.ts'; +import { createEvent, parseBody } from '@/utils/api.ts'; +import { errorJson } from '@/utils/log.ts'; +import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; +import { requireSigner } from '@/middleware/requireSigner.ts'; +import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; + +const app = new Hono(); + +// CASHU_MINTS = ['https://mint.cashu.io/1', 'https://mint.cashu.io/2', 'https://mint.cashu.io/3'] + +// Mint: https://github.com/cashubtc/nuts/blob/main/06.md + +// src/controllers/api/cashu.ts + +// app.get('/mints') -> Mint[] + +// app.get(swapMiddleware, '/wallet') -> Wallet, 404 +// app.put('/wallet') -> Wallet +// app.delete('/wallet') -> 204 + +// app.post('/swap') Maybe make this a middleware? Also pipeline interaction. + +// 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 Wallet { + pubkey: string; + mints: string[]; + relays: string[]; + balance: number; +} + +interface NutZap { + // ??? +} + +const createCashuWalletSchema = z.object({ + mints: z.array(z.string().url()).nonempty(), // must contain at least one item +}); + +/** + * Creates a replaceable Cashu wallet. + * https://github.com/nostr-protocol/nips/blob/master/60.md + */ +app.post('/wallet', storeMiddleware, signerMiddleware, requireSigner, async (c) => { + const signer = c.get('signer'); + const store = c.get('store'); + const pubkey = await signer.getPublicKey(); + const body = await parseBody(c.req.raw); + const { signal } = c.req.raw; + const result = createCashuWalletSchema.safeParse(body); + + if (!result.success) { + return c.json({ error: 'Bad schema', schema: result.error }, 400); + } + + const nip44 = signer.nip44; + if (!nip44) { + return c.json({ error: 'Signer does not have nip 44' }, 400); + } + + const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); + if (event) { + return c.json({ error: 'You already have a wallet 😏' }, 400); + } + + const contentTags: string[][] = []; + + const sk = generateSecretKey(); + const privkey = bytesToString('hex', sk); + + contentTags.push(['privkey', privkey]); + + const { mints } = result.data; + + for (const mint of new Set(mints)) { + contentTags.push(['mint', mint]); + } + + const encryptedContentTags = await nip44.encrypt(pubkey, JSON.stringify(contentTags)); + + // Wallet + await createEvent({ + kind: 17375, + content: encryptedContentTags, + }, c); + + return c.json(wallet); +}); + +const createNutzapInformationSchema = z.object({ + mints: z.array(z.string().url()).nonempty(), // must contain at least one item +}); + +/** + * Creates a replaceable Nutzap information for a specific wallet. + * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event + */ +// TODO: Remove this, combine logic with `app.post('/wallet')` +app.post('/wallet/info', async (c) => { + const signer = c.get('signer')!; + const store = c.get('store'); + const pubkey = await signer.getPublicKey(); + const body = await parseBody(c.req.raw); + const { signal } = c.req.raw; + const result = createNutzapInformationSchema.safeParse(body); + + if (!result.success) { + return c.json({ error: 'Bad schema', schema: result.error }, 400); + } + + const nip44 = signer.nip44; + if (!nip44) { + return c.json({ error: 'Signer does not have nip 44' }, 400); + } + + const { relays, mints } = result.data; // TODO: MAYBE get those mints and replace the mints specified in wallet, so 'nutzap information event' and the wallet always have the same mints + + const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); + if (!event) { + return c.json({ error: 'You need to have a wallet to create a nutzap information event.' }, 400); + } + + relays.push(Conf.relay); + + const tags: string[][] = []; + + for (const mint of new Set(mints)) { + tags.push(['mint', mint, 'sat']); + } + + for (const relay of new Set(relays)) { + tags.push(['relay', relay]); + } + + let decryptedContent: string; + try { + decryptedContent = await nip44.decrypt(pubkey, event.content); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', id: event.id, kind: event.kind, error: errorJson(e) }); + return c.json({ error: 'Could not decrypt wallet content.' }, 400); + } + + let contentTags: string[][]; + try { + contentTags = JSON.parse(decryptedContent); + } catch { + return c.json({ error: 'Could not JSON 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)); + + tags.push(['pubkey', p2pk]); + + // Nutzap information + await createEvent({ + kind: 10019, + tags, + }, c); + + return c.json(201); +}); + +/** + * Swaps all nutzaps (NIP-61) to the user's wallet (NIP-60) + */ +app.post('/swap', async (c) => { + const signer = c.get('signer')!; + const store = c.get('store'); + const pubkey = await signer.getPublicKey(); + const { signal } = c.req.raw; + + const nip44 = signer.nip44; + if (!nip44) { + return c.json({ error: 'Signer does not have nip 44.' }, 400); + } + + const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); + if (!wallet) { + return c.json({ error: 'You need to have a wallet to swap the nutzaps into it.' }, 400); + } + + let decryptedContent: string; + try { + decryptedContent = await nip44.decrypt(pubkey, wallet.content); + } 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 = JSON.parse(decryptedContent); + } catch { + return c.json({ error: 'Could not JSON 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 [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); + if (nutzapHistory) { + nutzapsFilter.since = nutzapHistory.created_at; + } + + const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; + + 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: [] }; + } + + mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...JSON.parse(proof)]; + 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: any) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); + } + } + + // 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 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 nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'in'], + ['amount', amount], + ['e', unspentProofs.id, Conf.relay, 'created'], + ]), + ), + tags: mintsToProofs[mint].redeemed, + }, c); + } catch (e: any) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); + } + } + + return c.json(201); +}); + +export default app; From 425edf2174ac9e4f45c7970969918cde1c380588 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 10 Feb 2025 12:41:41 -0600 Subject: [PATCH 15/29] Add controller test, refactor some middlewares --- src/controllers/api/cashu.test.ts | 26 ++++++++++++++++++++++++++ src/controllers/api/cashu.ts | 13 ++++--------- src/middleware/requireSigner.ts | 17 +++++++++++++++++ src/middleware/signerMiddleware.ts | 6 +++--- src/middleware/storeMiddleware.ts | 9 +++++++-- src/utils/api.ts | 2 +- 6 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 src/controllers/api/cashu.test.ts diff --git a/src/controllers/api/cashu.test.ts b/src/controllers/api/cashu.test.ts new file mode 100644 index 00000000..9d9f37f2 --- /dev/null +++ b/src/controllers/api/cashu.test.ts @@ -0,0 +1,26 @@ +// deno-lint-ignore-file require-await +import { NSecSigner } from '@nostrify/nostrify'; +import { assertEquals } from '@std/assert'; +import { generateSecretKey } from 'nostr-tools'; + +import { createTestDB } from '@/test.ts'; + +import cashuApp from './cashu.ts'; + +Deno.test('PUT /wallet', async () => { + await using db = await createTestDB(); + const store = db.store; + + const sk = generateSecretKey(); + const signer = new NSecSigner(sk); + + const app = cashuApp.use( + '*', + async (c) => c.set('store', store), + async (c) => c.set('signer', signer), + ); + + const response = await app.request('/wallet', { method: 'PUT' }); + + assertEquals(response.status, 200); +}); diff --git a/src/controllers/api/cashu.ts b/src/controllers/api/cashu.ts index 8219d8c7..7ac0dfe6 100644 --- a/src/controllers/api/cashu.ts +++ b/src/controllers/api/cashu.ts @@ -11,10 +11,10 @@ import { isNostrId } from '@/utils.ts'; import { createEvent, parseBody } from '@/utils/api.ts'; import { errorJson } from '@/utils/log.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; -import { requireSigner } from '@/middleware/requireSigner.ts'; +import { requireNip44Signer } from '@/middleware/requireSigner.ts'; import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; -const app = new Hono(); +const app = new Hono().use('*', storeMiddleware, signerMiddleware); // CASHU_MINTS = ['https://mint.cashu.io/1', 'https://mint.cashu.io/2', 'https://mint.cashu.io/3'] @@ -55,7 +55,7 @@ const createCashuWalletSchema = z.object({ * Creates a replaceable Cashu wallet. * https://github.com/nostr-protocol/nips/blob/master/60.md */ -app.post('/wallet', storeMiddleware, signerMiddleware, requireSigner, async (c) => { +app.post('/wallet', requireNip44Signer, async (c) => { const signer = c.get('signer'); const store = c.get('store'); const pubkey = await signer.getPublicKey(); @@ -67,11 +67,6 @@ app.post('/wallet', storeMiddleware, signerMiddleware, requireSigner, async (c) return c.json({ error: 'Bad schema', schema: result.error }, 400); } - const nip44 = signer.nip44; - if (!nip44) { - return c.json({ error: 'Signer does not have nip 44' }, 400); - } - const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); if (event) { return c.json({ error: 'You already have a wallet 😏' }, 400); @@ -90,7 +85,7 @@ app.post('/wallet', storeMiddleware, signerMiddleware, requireSigner, async (c) contentTags.push(['mint', mint]); } - const encryptedContentTags = await nip44.encrypt(pubkey, JSON.stringify(contentTags)); + const encryptedContentTags = await signer.nip44.encrypt(pubkey, JSON.stringify(contentTags)); // Wallet await createEvent({ diff --git a/src/middleware/requireSigner.ts b/src/middleware/requireSigner.ts index e360ab42..7733b26f 100644 --- a/src/middleware/requireSigner.ts +++ b/src/middleware/requireSigner.ts @@ -1,6 +1,7 @@ import { MiddlewareHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; import { NostrSigner } from '@nostrify/nostrify'; +import { SetRequired } from 'type-fest'; /** Throw a 401 if a signer isn't set. */ export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => { @@ -10,3 +11,19 @@ export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner await next(); }; + +/** Throw a 401 if a NIP-44 signer isn't set. */ +export const requireNip44Signer: MiddlewareHandler<{ Variables: { signer: SetRequired } }> = + async (c, next) => { + const signer = c.get('signer'); + + if (!signer) { + throw new HTTPException(401, { message: 'No pubkey provided' }); + } + + if (!signer.nip44) { + throw new HTTPException(401, { message: 'No NIP-44 signer provided' }); + } + + await next(); + }; diff --git a/src/middleware/signerMiddleware.ts b/src/middleware/signerMiddleware.ts index 8fca06a3..aa7b537f 100644 --- a/src/middleware/signerMiddleware.ts +++ b/src/middleware/signerMiddleware.ts @@ -1,8 +1,8 @@ +import { MiddlewareHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; -import { NSecSigner } from '@nostrify/nostrify'; +import { NostrSigner, NSecSigner } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; -import { AppMiddleware } from '@/app.ts'; import { Conf } from '@/config.ts'; import { ConnectSigner } from '@/signers/ConnectSigner.ts'; import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; @@ -14,7 +14,7 @@ import { getTokenHash } from '@/utils/auth.ts'; const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); /** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */ -export const signerMiddleware: AppMiddleware = async (c, next) => { +export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => { const header = c.req.header('authorization'); const match = header?.match(BEARER_REGEX); diff --git a/src/middleware/storeMiddleware.ts b/src/middleware/storeMiddleware.ts index 4e24ab05..37d04856 100644 --- a/src/middleware/storeMiddleware.ts +++ b/src/middleware/storeMiddleware.ts @@ -1,9 +1,14 @@ -import { AppMiddleware } from '@/app.ts'; +import { MiddlewareHandler } from '@hono/hono'; +import { NostrSigner, NStore } from '@nostrify/nostrify'; + import { UserStore } from '@/storages/UserStore.ts'; import { Storages } from '@/storages.ts'; /** Store middleware. */ -export const storeMiddleware: AppMiddleware = async (c, next) => { +export const storeMiddleware: MiddlewareHandler<{ Variables: { signer?: NostrSigner; store: NStore } }> = async ( + c, + next, +) => { const pubkey = await c.get('signer')?.getPublicKey(); if (pubkey) { diff --git a/src/utils/api.ts b/src/utils/api.ts index 29304cbd..a01cf277 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -19,7 +19,7 @@ import { purifyEvent } from '@/utils/purify.ts'; type EventStub = TypeFest.SetOptional; /** Publish an event through the pipeline. */ -async function createEvent(t: EventStub, c: AppContext): Promise { +async function createEvent(t: EventStub, c: Context): Promise { const signer = c.get('signer'); if (!signer) { From b74a0ffac00af6b55dfb337e43b9a55c0c4b9873 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 11 Feb 2025 10:59:04 -0300 Subject: [PATCH 16/29] refactor: create NIP-60 wallet and NIP-61 nutzap information event in the same endpoint --- src/controllers/api/cashu.ts | 131 ++++++++++------------------------- src/storages/EventsDB.ts | 2 +- 2 files changed, 39 insertions(+), 94 deletions(-) diff --git a/src/controllers/api/cashu.ts b/src/controllers/api/cashu.ts index 7ac0dfe6..3dbc4031 100644 --- a/src/controllers/api/cashu.ts +++ b/src/controllers/api/cashu.ts @@ -20,8 +20,6 @@ const app = new Hono().use('*', storeMiddleware, signerMiddleware); // Mint: https://github.com/cashubtc/nuts/blob/main/06.md -// src/controllers/api/cashu.ts - // app.get('/mints') -> Mint[] // app.get(swapMiddleware, '/wallet') -> Wallet, 404 @@ -36,142 +34,89 @@ const app = new Hono().use('*', storeMiddleware, signerMiddleware); /* PUT /api/v1/ditto/cashu/wallet -> Wallet */ /* DELETE /api/v1/ditto/cashu/wallet -> 204 */ -interface Wallet { - pubkey: string; +export interface Wallet { + pubkey_p2pk: string; mints: string[]; relays: string[]; balance: number; } -interface NutZap { - // ??? +interface Nutzap { + amount: number; + event_id?: string; + mint: string; // mint the nutzap was created + recipient_pubkey: string; } -const createCashuWalletSchema = z.object({ - mints: z.array(z.string().url()).nonempty(), // must contain at least one item +const createCashuWalletAndNutzapInfoSchema = z.object({ + mints: z.array(z.string().url()).nonempty().transform((val) => { + return [...new Set(val)]; + }), }); /** - * Creates a replaceable Cashu wallet. + * Creates a replaceable Cashu wallet and a replaceable nutzap information event. * https://github.com/nostr-protocol/nips/blob/master/60.md + * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event */ -app.post('/wallet', requireNip44Signer, async (c) => { +app.put('/wallet', requireNip44Signer, async (c) => { const signer = c.get('signer'); const store = c.get('store'); const pubkey = await signer.getPublicKey(); const body = await parseBody(c.req.raw); const { signal } = c.req.raw; - const result = createCashuWalletSchema.safeParse(body); + const result = createCashuWalletAndNutzapInfoSchema.safeParse(body); if (!result.success) { return c.json({ error: 'Bad schema', schema: result.error }, 400); } + const { mints } = result.data; + const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); if (event) { return c.json({ error: 'You already have a wallet 😏' }, 400); } - const contentTags: string[][] = []; + const walletContentTags: string[][] = []; const sk = generateSecretKey(); const privkey = bytesToString('hex', sk); + const p2pk = getPublicKey(stringToBytes('hex', privkey)); - contentTags.push(['privkey', privkey]); + walletContentTags.push(['privkey', privkey]); - const { mints } = result.data; - - for (const mint of new Set(mints)) { - contentTags.push(['mint', mint]); + for (const mint of mints) { + walletContentTags.push(['mint', mint]); } - const encryptedContentTags = await signer.nip44.encrypt(pubkey, JSON.stringify(contentTags)); + const encryptedWalletContentTags = await signer.nip44.encrypt(pubkey, JSON.stringify(walletContentTags)); // Wallet await createEvent({ kind: 17375, - content: encryptedContentTags, + content: encryptedWalletContentTags, }, c); - return c.json(wallet); -}); - -const createNutzapInformationSchema = z.object({ - mints: z.array(z.string().url()).nonempty(), // must contain at least one item -}); - -/** - * Creates a replaceable Nutzap information for a specific wallet. - * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event - */ -// TODO: Remove this, combine logic with `app.post('/wallet')` -app.post('/wallet/info', async (c) => { - const signer = c.get('signer')!; - const store = c.get('store'); - const pubkey = await signer.getPublicKey(); - const body = await parseBody(c.req.raw); - const { signal } = c.req.raw; - const result = createNutzapInformationSchema.safeParse(body); - - if (!result.success) { - return c.json({ error: 'Bad schema', schema: result.error }, 400); - } - - const nip44 = signer.nip44; - if (!nip44) { - return c.json({ error: 'Signer does not have nip 44' }, 400); - } - - const { relays, mints } = result.data; // TODO: MAYBE get those mints and replace the mints specified in wallet, so 'nutzap information event' and the wallet always have the same mints - - const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); - if (!event) { - return c.json({ error: 'You need to have a wallet to create a nutzap information event.' }, 400); - } - - relays.push(Conf.relay); - - const tags: string[][] = []; - - for (const mint of new Set(mints)) { - tags.push(['mint', mint, 'sat']); - } - - for (const relay of new Set(relays)) { - tags.push(['relay', relay]); - } - - let decryptedContent: string; - try { - decryptedContent = await nip44.decrypt(pubkey, event.content); - } catch (e) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', id: event.id, kind: event.kind, error: errorJson(e) }); - return c.json({ error: 'Could not decrypt wallet content.' }, 400); - } - - let contentTags: string[][]; - try { - contentTags = JSON.parse(decryptedContent); - } catch { - return c.json({ error: 'Could not JSON 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)); - - tags.push(['pubkey', p2pk]); - // Nutzap information await createEvent({ kind: 10019, - tags, + tags: [ + ...mints.map((mint) => ['mint', mint, 'sat']), + ['relay', Conf.relay], // TODO: add more relays once things get more stable + ['pubkey', p2pk], + ], }, c); - return c.json(201); + // TODO: hydrate wallet and add a 'balance' field when a 'renderWallet' view function is created + const walletEntity: Wallet = { + pubkey_p2pk: p2pk, + mints, + relays: [Conf.relay], + balance: 0, // Newly created wallet, balance is zero. + }; + + return c.json(walletEntity, 200); }); /** diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index fd2323a8..2625c6b2 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -63,7 +63,7 @@ class EventsDB extends NPostgres { 't': ({ event, count, value }) => (value === value.toLowerCase()) && (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50, 'u': ({ count, value }) => { - const { success } = z.string().url().safeParse(value); // maybe find a better library specific for validating web urls + const { success } = z.string().url().safeParse(value); // TODO: maybe find a better library specific for validating web urls return count < 15 && success; }, }; From 1ff6511b39a3806c0fa6389f974c588b601d2a46 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 11 Feb 2025 11:02:47 -0300 Subject: [PATCH 17/29] test: PUT '/api/v1/ditto/cashu/wallet' endpoint --- src/controllers/api/cashu.test.ts | 103 ++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/src/controllers/api/cashu.test.ts b/src/controllers/api/cashu.test.ts index 9d9f37f2..9908b9d0 100644 --- a/src/controllers/api/cashu.test.ts +++ b/src/controllers/api/cashu.test.ts @@ -1,26 +1,105 @@ -// deno-lint-ignore-file require-await -import { NSecSigner } from '@nostrify/nostrify'; -import { assertEquals } from '@std/assert'; -import { generateSecretKey } from 'nostr-tools'; +import { Env as HonoEnv, Hono } from '@hono/hono'; +import { NostrSigner, NSchema as n, NSecSigner, NStore } from '@nostrify/nostrify'; +import { generateSecretKey, getPublicKey } from 'nostr-tools'; +import { bytesToString, stringToBytes } from '@scure/base'; +import { assertEquals, assertExists } from '@std/assert'; +import { z } from 'zod'; import { createTestDB } from '@/test.ts'; -import cashuApp from './cashu.ts'; +import cashuApp from '@/controllers/api/cashu.ts'; -Deno.test('PUT /wallet', async () => { +interface AppEnv extends HonoEnv { + Variables: { + /** Signer to get the logged-in user's pubkey, relays, and to sign events. */ + signer: NostrSigner; + /** Storage for the user, might filter out unwanted content. */ + store: NStore; + }; +} + +Deno.test('PUT /wallet must be successful', { + sanitizeOps: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' + sanitizeResources: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' +}, async () => { await using db = await createTestDB(); const store = db.store; const sk = generateSecretKey(); const signer = new NSecSigner(sk); + const nostrPrivateKey = bytesToString('hex', sk); - const app = cashuApp.use( - '*', - async (c) => c.set('store', store), - async (c) => c.set('signer', signer), - ); + const expectedResponseSchema = z.object({ + pubkey_p2pk: n.id(), + mints: z.array(z.string()).nonempty(), + relays: z.array(z.string()).nonempty(), + balance: z.number(), + }); - const response = await app.request('/wallet', { method: 'PUT' }); + const app = new Hono().use( + async (c, next) => { + c.set('signer', signer); + await next(); + }, + async (c, next) => { + c.set('store', store); + await next(); + }, + ).route('/', cashuApp); + + const response = await app.request('/wallet', { + method: 'PUT', + headers: [['content-type', 'application/json']], + body: JSON.stringify({ + mints: [ + 'https://houston.mint.com', + 'https://houston.mint.com', // duplicate on purpose + 'https://cuiaba.mint.com', + ], + }), + }); assertEquals(response.status, 200); + + const pubkey = await signer.getPublicKey(); + + const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }]); + + assertExists(wallet); + assertEquals(wallet.kind, 17375); + + const { data, success } = expectedResponseSchema.safeParse(await response.json()); + + assertEquals(success, true); + if (!data) return; // get rid of typescript error possibly undefined + + const decryptedContent: string[][] = JSON.parse(await signer.nip44.decrypt(pubkey, wallet.content)); + + const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1]!; + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + assertEquals(nostrPrivateKey !== privkey, true); + + assertEquals(data.pubkey_p2pk, p2pk); + assertEquals(data.mints, [ + 'https://houston.mint.com', + 'https://cuiaba.mint.com', + ]); + assertEquals(data.relays, [ + 'ws://localhost:4036/relay', + ]); + assertEquals(data.balance, 0); + + const [nutzap_info] = await store.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]!], [ + 'ws://localhost:4036/relay', + ]); }); From 89840eb279a88e5389a45d0d4e79c06b8e4a6d55 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 11 Feb 2025 11:29:58 -0300 Subject: [PATCH 18/29] refactor: create walletSchema and use it where required --- src/controllers/api/cashu.test.ts | 13 +++---------- src/controllers/api/cashu.ts | 10 +++------- src/schema.ts | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/controllers/api/cashu.test.ts b/src/controllers/api/cashu.test.ts index 9908b9d0..2f1161d3 100644 --- a/src/controllers/api/cashu.test.ts +++ b/src/controllers/api/cashu.test.ts @@ -1,13 +1,13 @@ import { Env as HonoEnv, Hono } from '@hono/hono'; -import { NostrSigner, NSchema as n, NSecSigner, NStore } from '@nostrify/nostrify'; +import { NostrSigner, NSecSigner, NStore } from '@nostrify/nostrify'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; import { assertEquals, assertExists } from '@std/assert'; -import { z } from 'zod'; import { createTestDB } from '@/test.ts'; import cashuApp from '@/controllers/api/cashu.ts'; +import { walletSchema } from '@/schema.ts'; interface AppEnv extends HonoEnv { Variables: { @@ -29,13 +29,6 @@ Deno.test('PUT /wallet must be successful', { const signer = new NSecSigner(sk); const nostrPrivateKey = bytesToString('hex', sk); - const expectedResponseSchema = z.object({ - pubkey_p2pk: n.id(), - mints: z.array(z.string()).nonempty(), - relays: z.array(z.string()).nonempty(), - balance: z.number(), - }); - const app = new Hono().use( async (c, next) => { c.set('signer', signer); @@ -68,7 +61,7 @@ Deno.test('PUT /wallet must be successful', { assertExists(wallet); assertEquals(wallet.kind, 17375); - const { data, success } = expectedResponseSchema.safeParse(await response.json()); + const { data, success } = walletSchema.safeParse(await response.json()); assertEquals(success, true); if (!data) return; // get rid of typescript error possibly undefined diff --git a/src/controllers/api/cashu.ts b/src/controllers/api/cashu.ts index 3dbc4031..67dcda1d 100644 --- a/src/controllers/api/cashu.ts +++ b/src/controllers/api/cashu.ts @@ -13,6 +13,9 @@ import { errorJson } from '@/utils/log.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; import { requireNip44Signer } from '@/middleware/requireSigner.ts'; import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; +import { walletSchema } from '@/schema.ts'; + +type Wallet = z.infer; const app = new Hono().use('*', storeMiddleware, signerMiddleware); @@ -34,13 +37,6 @@ const app = new Hono().use('*', storeMiddleware, signerMiddleware); /* PUT /api/v1/ditto/cashu/wallet -> Wallet */ /* DELETE /api/v1/ditto/cashu/wallet -> 204 */ -export interface Wallet { - pubkey_p2pk: string; - mints: string[]; - relays: string[]; - balance: number; -} - interface Nutzap { amount: number; event_id?: string; diff --git a/src/schema.ts b/src/schema.ts index 0fce60d4..6658bdbe 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,4 +1,5 @@ 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. */ @@ -80,6 +81,18 @@ 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)]; + }), + balance: z.number(), +}); + export { booleanParamSchema, decode64Schema, @@ -91,5 +104,6 @@ export { percentageSchema, safeUrlSchema, sizesSchema, + walletSchema, wsUrlSchema, }; From edd9512b01fb2d7872c31a44217fae4a926ca5ef Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 11 Feb 2025 11:45:14 -0300 Subject: [PATCH 19/29] test: PUT '/api/v1/ditto/cashu/wallet' endpoint must NOT be successful --- src/controllers/api/cashu.test.ts | 54 +++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/controllers/api/cashu.test.ts b/src/controllers/api/cashu.test.ts index 2f1161d3..90b62e1b 100644 --- a/src/controllers/api/cashu.test.ts +++ b/src/controllers/api/cashu.test.ts @@ -2,9 +2,9 @@ import { Env as HonoEnv, Hono } from '@hono/hono'; import { NostrSigner, NSecSigner, NStore } from '@nostrify/nostrify'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; -import { assertEquals, assertExists } from '@std/assert'; +import { assertEquals, assertExists, assertObjectMatch } from '@std/assert'; -import { createTestDB } from '@/test.ts'; +import { createTestDB, genEvent } from '@/test.ts'; import cashuApp from '@/controllers/api/cashu.ts'; import { walletSchema } from '@/schema.ts'; @@ -96,3 +96,53 @@ Deno.test('PUT /wallet must be successful', { 'ws://localhost:4036/relay', ]); }); + +Deno.test('PUT /wallet must NOT be successful', { + sanitizeOps: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' + sanitizeResources: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' +}, async () => { + await using db = await createTestDB(); + const store = db.store; + + const sk = generateSecretKey(); + const signer = new NSecSigner(sk); + + const app = new Hono().use( + async (c, next) => { + c.set('signer', signer); + await next(); + }, + async (c, next) => { + c.set('store', store); + await next(); + }, + ).route('/', cashuApp); + + const response = await app.request('/wallet', { + method: 'PUT', + headers: [['content-type', 'application/json']], + body: JSON.stringify({ + mints: [], // no mints should throw an error + }), + }); + + const body = await response.json(); + + assertEquals(response.status, 400); + assertObjectMatch(body, { error: 'Bad schema' }); + + await db.store.event(genEvent({ kind: 17375 }, sk)); + + const response2 = await app.request('/wallet', { + method: 'PUT', + headers: [['content-type', 'application/json']], + body: JSON.stringify({ + mints: ['https://mint.heart.com'], + }), + }); + + const body2 = await response2.json(); + + assertEquals(response2.status, 400); + assertEquals(body2, { error: 'You already have a wallet 😏' }); +}); From 76f91687bdaeac64489383ba41eaf89c5731b83f Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 11 Feb 2025 12:58:02 -0300 Subject: [PATCH 20/29] test: split test into 2 test functions --- src/controllers/api/cashu.test.ts | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/controllers/api/cashu.test.ts b/src/controllers/api/cashu.test.ts index 90b62e1b..cc30709d 100644 --- a/src/controllers/api/cashu.test.ts +++ b/src/controllers/api/cashu.test.ts @@ -97,7 +97,7 @@ Deno.test('PUT /wallet must be successful', { ]); }); -Deno.test('PUT /wallet must NOT be successful', { +Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', { sanitizeOps: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' sanitizeResources: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' }, async () => { @@ -130,10 +130,32 @@ Deno.test('PUT /wallet must NOT be successful', { assertEquals(response.status, 400); assertObjectMatch(body, { error: 'Bad schema' }); +}); + +Deno.test('PUT /wallet must NOT be successful: wallet already exists', { + sanitizeOps: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' + sanitizeResources: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' +}, async () => { + await using db = await createTestDB(); + const store = db.store; + + const sk = generateSecretKey(); + const signer = new NSecSigner(sk); + + const app = new Hono().use( + async (c, next) => { + c.set('signer', signer); + await next(); + }, + async (c, next) => { + c.set('store', store); + await next(); + }, + ).route('/', cashuApp); await db.store.event(genEvent({ kind: 17375 }, sk)); - const response2 = await app.request('/wallet', { + const response = await app.request('/wallet', { method: 'PUT', headers: [['content-type', 'application/json']], body: JSON.stringify({ @@ -141,8 +163,8 @@ Deno.test('PUT /wallet must NOT be successful', { }), }); - const body2 = await response2.json(); + const body2 = await response.json(); - assertEquals(response2.status, 400); + assertEquals(response.status, 400); assertEquals(body2, { error: 'You already have a wallet 😏' }); }); From 5e86844c12a97d58224308c72bccc6614107d828 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 11 Feb 2025 22:10:33 -0300 Subject: [PATCH 21/29] feat: craete GET '/api/v1/ditto/cashu/wallet' endpoint refactor: remove old swap controller and create swapNutzapsMiddleware --- src/controllers/api/cashu.ts | 164 +++++----------------- src/middleware/swapNutzapsMiddleware.ts | 172 ++++++++++++++++++++++++ src/schema.ts | 1 + 3 files changed, 210 insertions(+), 127 deletions(-) create mode 100644 src/middleware/swapNutzapsMiddleware.ts diff --git a/src/controllers/api/cashu.ts b/src/controllers/api/cashu.ts index 67dcda1d..19fad9b6 100644 --- a/src/controllers/api/cashu.ts +++ b/src/controllers/api/cashu.ts @@ -1,19 +1,19 @@ +import { Proof } from '@cashu/cashu-ts'; import { Hono } from '@hono/hono'; -import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; -import { NostrFilter } from '@nostrify/nostrify'; -import { logi } from '@soapbox/logi'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; import { z } from 'zod'; import { Conf } from '@/config.ts'; -import { isNostrId } from '@/utils.ts'; import { createEvent, parseBody } from '@/utils/api.ts'; -import { errorJson } from '@/utils/log.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; import { requireNip44Signer } from '@/middleware/requireSigner.ts'; import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; import { walletSchema } from '@/schema.ts'; +import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; +import { isNostrId } from '@/utils.ts'; +import { logi } from '@soapbox/logi'; +import { errorJson } from '@/utils/log.ts'; type Wallet = z.infer; @@ -115,148 +115,58 @@ app.put('/wallet', requireNip44Signer, async (c) => { return c.json(walletEntity, 200); }); -/** - * Swaps all nutzaps (NIP-61) to the user's wallet (NIP-60) - */ -app.post('/swap', async (c) => { - const signer = c.get('signer')!; +/** Gets a wallet, if it exists. */ +app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => { + const signer = c.get('signer'); const store = c.get('store'); const pubkey = await signer.getPublicKey(); const { signal } = c.req.raw; - const nip44 = signer.nip44; - if (!nip44) { - return c.json({ error: 'Signer does not have nip 44.' }, 400); + const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); + if (!event) { + return c.json({ error: 'Wallet not found' }, 404); } - const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); - if (!wallet) { - return c.json({ error: 'You need to have a wallet to swap the nutzaps into it.' }, 400); - } + const decryptedContent: string[][] = JSON.parse(await signer.nip44.decrypt(pubkey, event.content)); - let decryptedContent: string; - try { - decryptedContent = await nip44.decrypt(pubkey, wallet.content); - } 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 = JSON.parse(decryptedContent); - } catch { - return c.json({ error: 'Could not JSON parse the decrypted wallet content.' }, 400); - } - - const privkey = contentTags.find(([value]) => value === 'privkey')?.[1]; + 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.' }, 400); + 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 [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); - } + let balance = 0; + const mints: string[] = []; - 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 [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); - if (nutzapHistory) { - nutzapsFilter.since = nutzapHistory.created_at; - } - - const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; - - const nutzaps = await store.query([nutzapsFilter], { signal }); - - for (const event of nutzaps) { + const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal }); + for (const token of tokens) { try { - const mint = event.tags.find(([name]) => name === 'u')?.[1]; - if (!mint) { - continue; + const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse( + await signer.nip44.decrypt(pubkey, token.content), + ); + + if (!mints.includes(decryptedContent.mint)) { + mints.push(decryptedContent.mint); } - const proof = event.tags.find(([name]) => name === 'proof')?.[1]; - if (!proof) { - continue; - } - - if (!mintsToProofs[mint]) { - mintsToProofs[mint] = { proofs: [], redeemed: [] }; - } - - mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...JSON.parse(proof)]; - 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: any) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); - } - } - - // 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 nip44.encrypt( - pubkey, - JSON.stringify({ - mint, - proofs: receiveProofs, - }), - ), - }, c); - - const amount = receiveProofs.reduce((accumulator, current) => { + balance += decryptedContent.proofs.reduce((accumulator, current) => { return accumulator + current.amount; }, 0); - - await createEvent({ - kind: 7376, - content: await nip44.encrypt( - pubkey, - JSON.stringify([ - ['direction', 'in'], - ['amount', amount], - ['e', unspentProofs.id, Conf.relay, 'created'], - ]), - ), - tags: mintsToProofs[mint].redeemed, - }, c); - } catch (e: any) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); } } - return c.json(201); + // 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); }); export default app; diff --git a/src/middleware/swapNutzapsMiddleware.ts b/src/middleware/swapNutzapsMiddleware.ts new file mode 100644 index 00000000..286965c6 --- /dev/null +++ b/src/middleware/swapNutzapsMiddleware.ts @@ -0,0 +1,172 @@ +import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; +import { MiddlewareHandler } from '@hono/hono'; +import { HTTPException } from '@hono/hono/http-exception'; +import { getPublicKey } from 'nostr-tools'; +import { NostrFilter, NostrSigner, NStore } from '@nostrify/nostrify'; +import { SetRequired } from 'type-fest'; +import { stringToBytes } from '@scure/base'; +import { logi } from '@soapbox/logi'; + +import { isNostrId } from '@/utils.ts'; +import { errorJson } from '@/utils/log.ts'; +import { Conf } from '@/config.ts'; +import { createEvent } from '@/utils/api.ts'; + +/** + * Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough. + * Errors are only thrown if 'signer' and 'store' middlewares are not set. + */ +export const swapNutzapsMiddleware: MiddlewareHandler< + { Variables: { signer: SetRequired; store: NStore } } +> = async (c, next) => { + const signer = c.get('signer'); + const store = c.get('store'); + + if (!signer) { + throw new HTTPException(401, { message: 'No pubkey provided' }); + } + + if (!signer.nip44) { + throw new HTTPException(401, { message: 'No NIP-44 signer provided' }); + } + + if (!store) { + throw new HTTPException(401, { message: 'No store provided' }); + } + + 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; + try { + decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content); + } 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 = JSON.parse(decryptedContent); + } catch { + return c.json({ error: 'Could not JSON 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 [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); + if (nutzapHistory) { + nutzapsFilter.since = nutzapHistory.created_at; + } + + const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; + + 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: [] }; + } + + mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...JSON.parse(proof)]; + 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: any) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); + } + } + + // 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: any) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); + } + } + } + + await next(); +}; diff --git a/src/schema.ts b/src/schema.ts index 6658bdbe..30b4520a 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -90,6 +90,7 @@ const walletSchema = z.object({ relays: z.array(z.string()).nonempty().transform((val) => { return [...new Set(val)]; }), + /** Unit in sats */ balance: z.number(), }); From 03946fabc80307a0f6992ec386c620095bea01fc Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 11 Feb 2025 22:11:06 -0300 Subject: [PATCH 22/29] test: GET /wallet must be successful --- src/controllers/api/cashu.test.ts | 115 ++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/src/controllers/api/cashu.test.ts b/src/controllers/api/cashu.test.ts index cc30709d..be6e7e34 100644 --- a/src/controllers/api/cashu.test.ts +++ b/src/controllers/api/cashu.test.ts @@ -168,3 +168,118 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', { assertEquals(response.status, 400); assertEquals(body2, { error: 'You already have a wallet 😏' }); }); + +Deno.test('GET /wallet must be successful', { + sanitizeOps: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' + sanitizeResources: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' +}, async () => { + await using db = await createTestDB(); + 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)); + + const app = new Hono().use( + async (c, next) => { + c.set('signer', signer); + await next(); + }, + async (c, next) => { + c.set('store', store); + await next(); + }, + ).route('/', cashuApp); + + // Wallet + await db.store.event(genEvent({ + kind: 17375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['privkey', privkey], + ['mint', 'https://mint.soul.com'], + ]), + ), + }, sk)); + + // Nutzap information + await db.store.event(genEvent({ + kind: 10019, + tags: [ + ['pubkey', p2pk], + ['mint', 'https://mint.soul.com'], + ], + }, sk)); + + // Unspent proofs + await db.store.event(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)); + + // TODO: find a way to have a Mock mint so operations like 'swap', 'mint' and 'melt' can be tested (this will be a bit hard). + // Nutzap + const senderSk = generateSecretKey(); + + await db.store.event(genEvent({ + kind: 9321, + content: 'Nice post!', + tags: [ + ['p', pubkey], + ['u', 'https://mint.soul.com'], + [ + 'proof', + '{"amount":1,"C":"02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f","id":"000a93d6f8a1d2c4","secret":"[\\"P2PK\\",{\\"nonce\\":\\"b00bdd0467b0090a25bdf2d2f0d45ac4e355c482c1418350f273a04fedaaee83\\",\\"data\\":\\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\\"}]"}', + ], + ], + }, senderSk)); + + const response = await app.request('/wallet', { + method: 'GET', + }); + + const body = await response.json(); + + assertEquals(response.status, 200); + assertEquals(body, { + pubkey_p2pk: p2pk, + mints: ['https://mint.soul.com'], + relays: ['ws://localhost:4036/relay'], + balance: 100, + }); +}); From 70955191989c4c741bdb934e0ef4bf03045af76d Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 12 Feb 2025 13:46:37 -0300 Subject: [PATCH 23/29] chore: remove done comments --- src/controllers/api/cashu.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/controllers/api/cashu.ts b/src/controllers/api/cashu.ts index 19fad9b6..2db03318 100644 --- a/src/controllers/api/cashu.ts +++ b/src/controllers/api/cashu.ts @@ -25,12 +25,8 @@ const app = new Hono().use('*', storeMiddleware, signerMiddleware); // app.get('/mints') -> Mint[] -// app.get(swapMiddleware, '/wallet') -> Wallet, 404 -// app.put('/wallet') -> Wallet // app.delete('/wallet') -> 204 -// app.post('/swap') Maybe make this a middleware? Also pipeline interaction. - // app.post(swapMiddleware, '/nutzap'); /* GET /api/v1/ditto/cashu/wallet -> Wallet, 404 */ From 96a16a9fd09fdc66ca4305bf148e76ad3e440646 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 12 Feb 2025 16:33:56 -0300 Subject: [PATCH 24/29] feat: create GET '/api/v1/ditto/cashu/mints' endpoint --- src/config.ts | 4 ++++ src/controllers/api/cashu.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/src/config.ts b/src/config.ts index cdd88705..4a79a1a0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -295,6 +295,10 @@ class Conf { static get preferredLanguages(): LanguageCode[] | undefined { return Deno.env.get('DITTO_LANGUAGES')?.split(',')?.filter(ISO6391.validate); } + /** Mints to be displayed in the UI when the user decides to create a wallet.*/ + static get cashuMints(): string[] { + return Deno.env.get('CASHU_MINTS')?.split(',') ?? []; + } /** Translation provider used to translate posts. */ static get translationProvider(): string | undefined { return Deno.env.get('TRANSLATION_PROVIDER'); diff --git a/src/controllers/api/cashu.ts b/src/controllers/api/cashu.ts index 2db03318..0a8d45b5 100644 --- a/src/controllers/api/cashu.ts +++ b/src/controllers/api/cashu.ts @@ -165,4 +165,11 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => { return c.json(walletEntity, 200); }); +/** Get mints set by the CASHU_MINTS environment variable. */ +app.get('/mints', (c) => { + const mints = Conf.cashuMints; + + return c.json({ mints }, 200); +}); + export default app; From 7d2258ff509e5979628ba38d84dacd205658d2d8 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 12 Feb 2025 20:11:02 -0300 Subject: [PATCH 25/29] refactor: delete old controllers code: swapNutzapsToWalletController, createNutzapInformationController and createCashuWalletController --- src/app.ts | 5 - src/controllers/api/ditto.ts | 276 ----------------------------------- 2 files changed, 281 deletions(-) diff --git a/src/app.ts b/src/app.ts index a6a4981a..8482d491 100644 --- a/src/app.ts +++ b/src/app.ts @@ -44,13 +44,11 @@ import { captchaController, captchaVerifyController } from '@/controllers/api/ca import { adminRelaysController, adminSetRelaysController, - createNutzapInformationController, deleteZapSplitsController, getZapSplitsController, nameRequestController, nameRequestsController, statusZapSplitsController, - swapNutzapsToWalletController, updateInstanceController, updateZapSplitsController, } from '@/controllers/api/ditto.ts'; @@ -410,9 +408,6 @@ app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController app.route('/api/v1/ditto/cashu', cashuApp); -app.post('/api/v1/ditto/nutzap_information/create', requireSigner, createNutzapInformationController); -app.post('/api/v1/ditto/nutzap/swap_to_wallet', requireSigner, swapNutzapsToWalletController); - app.post('/api/v1/reports', requireSigner, reportController); app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController); app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', requireSigner, requireRole('admin'), adminReportController); diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index da905127..75aa7c26 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -348,279 +348,3 @@ export const updateInstanceController: AppController = async (c) => { return c.json(204); }; - -const createCashuWalletSchema = z.object({ - mints: z.array(z.string().url()).nonempty(), // must contain at least one item -}); - -/** - * Creates a replaceable Cashu wallet. - * https://github.com/nostr-protocol/nips/blob/master/60.md - */ -export const createCashuWalletController: AppController = async (c) => { - const signer = c.get('signer')!; - const store = c.get('store'); - const pubkey = await signer.getPublicKey(); - const body = await parseBody(c.req.raw); - const { signal } = c.req.raw; - const result = createCashuWalletSchema.safeParse(body); - - if (!result.success) { - return c.json({ error: 'Bad schema', schema: result.error }, 400); - } - - const nip44 = signer.nip44; - if (!nip44) { - return c.json({ error: 'Signer does not have nip 44' }, 400); - } - - const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); - if (event) { - return c.json({ error: 'You already have a wallet 😏' }, 400); - } - - const contentTags: string[][] = []; - - const sk = generateSecretKey(); - const privkey = bytesToString('hex', sk); - - contentTags.push(['privkey', privkey]); - - const { mints } = result.data; - - for (const mint of new Set(mints)) { - contentTags.push(['mint', mint]); - } - - const encryptedContentTags = await nip44.encrypt(pubkey, JSON.stringify(contentTags)); - - // Wallet - await createEvent({ - kind: 17375, - content: encryptedContentTags, - }, c); - - return c.json(201); -}; - -const createNutzapInformationSchema = z.object({ - relays: z.array(z.string().url()), - mints: z.array(z.string().url()).nonempty(), // must contain at least one item -}); - -/** - * Creates a replaceable Nutzap information for a specific wallet. - * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event - */ -export const createNutzapInformationController: AppController = async (c) => { - const signer = c.get('signer')!; - const store = c.get('store'); - const pubkey = await signer.getPublicKey(); - const body = await parseBody(c.req.raw); - const { signal } = c.req.raw; - const result = createNutzapInformationSchema.safeParse(body); - - if (!result.success) { - return c.json({ error: 'Bad schema', schema: result.error }, 400); - } - - const nip44 = signer.nip44; - if (!nip44) { - return c.json({ error: 'Signer does not have nip 44' }, 400); - } - - const { relays, mints } = result.data; // TODO: MAYBE get those mints and replace the mints specified in wallet, so 'nutzap information event' and the wallet always have the same mints - - const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); - if (!event) { - return c.json({ error: 'You need to have a wallet to create a nutzap information event.' }, 400); - } - - relays.push(Conf.relay); - - const tags: string[][] = []; - - for (const mint of new Set(mints)) { - tags.push(['mint', mint, 'sat']); - } - - for (const relay of new Set(relays)) { - tags.push(['relay', relay]); - } - - let decryptedContent: string; - try { - decryptedContent = await nip44.decrypt(pubkey, event.content); - } catch (e) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', id: event.id, kind: event.kind, error: errorJson(e) }); - return c.json({ error: 'Could not decrypt wallet content.' }, 400); - } - - let contentTags: string[][]; - try { - contentTags = JSON.parse(decryptedContent); - } catch { - return c.json({ error: 'Could not JSON 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)); - - tags.push(['pubkey', p2pk]); - - // Nutzap information - await createEvent({ - kind: 10019, - tags, - }, c); - - return c.json(201); -}; - -/** - * Swaps all nutzaps (NIP-61) to the user's wallet (NIP-60) - */ -export const swapNutzapsToWalletController: AppController = async (c) => { - const signer = c.get('signer')!; - const store = c.get('store'); - const pubkey = await signer.getPublicKey(); - const { signal } = c.req.raw; - - const nip44 = signer.nip44; - if (!nip44) { - return c.json({ error: 'Signer does not have nip 44.' }, 400); - } - - const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); - if (!wallet) { - return c.json({ error: 'You need to have a wallet to swap the nutzaps into it.' }, 400); - } - - let decryptedContent: string; - try { - decryptedContent = await nip44.decrypt(pubkey, wallet.content); - } 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 = JSON.parse(decryptedContent); - } catch { - return c.json({ error: 'Could not JSON 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 [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); - if (nutzapHistory) { - nutzapsFilter.since = nutzapHistory.created_at; - } - - const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; - - 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: [] }; - } - - mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...JSON.parse(proof)]; - 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: any) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); - } - } - - // 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 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 nip44.encrypt( - pubkey, - JSON.stringify([ - ['direction', 'in'], - ['amount', amount], - ['e', unspentProofs.id, Conf.relay, 'created'], - ]), - ), - tags: mintsToProofs[mint].redeemed, - }, c); - } catch (e: any) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); - } - } - - return c.json(201); -}; From 795c83ee88e19f42283efec36007e0f9741ba04f Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 12 Feb 2025 20:19:00 -0300 Subject: [PATCH 26/29] refactor: remove unused imports and get rid of useless await --- src/controllers/api/ditto.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 75aa7c26..5022c141 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,21 +1,15 @@ -import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; -import { logi } from '@soapbox/logi'; -import { generateSecretKey, getPublicKey } from 'nostr-tools'; -import { bytesToString, stringToBytes } from '@scure/base'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { getAuthor } from '@/queries.ts'; -import { isNostrId } from '@/utils.ts'; import { addTag } from '@/utils/tags.ts'; import { createEvent, paginated, parseBody, updateAdminEvent } from '@/utils/api.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; import { deleteTag } from '@/utils/tags.ts'; import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts'; -import { errorJson } from '@/utils/log.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { screenshotsSchema } from '@/schemas/nostr.ts'; import { booleanParamSchema, percentageSchema, wsUrlSchema } from '@/schema.ts'; @@ -249,7 +243,7 @@ export const getZapSplitsController: AppController = async (c) => { const zapSplits = await Promise.all(pubkeys.map(async (pubkey) => { const author = await getAuthor(pubkey); - const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey); + const account = author ? renderAccount(author) : accountFromPubkey(pubkey); return { account, @@ -278,9 +272,9 @@ export const statusZapSplitsController: AppController = async (c) => { const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal }); await hydrateEvents({ events: users, store, signal }); - const zapSplits = (await Promise.all(pubkeys.map(async (pubkey) => { + const zapSplits = (await Promise.all(pubkeys.map((pubkey) => { const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author; - const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey); + const account = author ? renderAccount(author) : accountFromPubkey(pubkey); const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0; From 3418871a708c8ce2669a5a0dda5b21dffb15298c Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 13 Feb 2025 13:23:47 -0300 Subject: [PATCH 27/29] feat: create GET '/api/v1/ditto/cashu/mints' endpoint --- src/controllers/api/cashu.test.ts | 13 +++++++++++++ src/controllers/api/cashu.ts | 7 +------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/controllers/api/cashu.test.ts b/src/controllers/api/cashu.test.ts index be6e7e34..bba10765 100644 --- a/src/controllers/api/cashu.test.ts +++ b/src/controllers/api/cashu.test.ts @@ -283,3 +283,16 @@ Deno.test('GET /wallet must be successful', { balance: 100, }); }); + +Deno.test('GET /mints must be successful', {}, async () => { + const app = new Hono().route('/', cashuApp); + + const response = await app.request('/mints', { + method: 'GET', + }); + + const body = await response.json(); + + assertEquals(response.status, 200); + assertEquals(body, { mints: [] }); +}); diff --git a/src/controllers/api/cashu.ts b/src/controllers/api/cashu.ts index 0a8d45b5..58a150de 100644 --- a/src/controllers/api/cashu.ts +++ b/src/controllers/api/cashu.ts @@ -19,12 +19,6 @@ type Wallet = z.infer; const app = new Hono().use('*', storeMiddleware, signerMiddleware); -// CASHU_MINTS = ['https://mint.cashu.io/1', 'https://mint.cashu.io/2', 'https://mint.cashu.io/3'] - -// Mint: https://github.com/cashubtc/nuts/blob/main/06.md - -// app.get('/mints') -> Mint[] - // app.delete('/wallet') -> 204 // app.post(swapMiddleware, '/nutzap'); @@ -167,6 +161,7 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => { /** Get mints set by the CASHU_MINTS environment variable. */ app.get('/mints', (c) => { + // TODO: Return full Mint information: https://github.com/cashubtc/nuts/blob/main/06.md const mints = Conf.cashuMints; return c.json({ mints }, 200); From 26346b83acbdb8b88829427493406eeccd60d065 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 13 Feb 2025 15:47:53 -0600 Subject: [PATCH 28/29] Fix leaky tests, but nutzapMiddleware is still broken --- src/controllers/api/cashu.test.ts | 22 +++++-------------- src/controllers/api/cashu.ts | 7 +++---- src/middleware/storeMiddleware.ts | 7 +++++++ src/middleware/swapNutzapsMiddleware.ts | 28 +++++++++++++++++++------ 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/controllers/api/cashu.test.ts b/src/controllers/api/cashu.test.ts index bba10765..f367cc10 100644 --- a/src/controllers/api/cashu.test.ts +++ b/src/controllers/api/cashu.test.ts @@ -18,10 +18,7 @@ interface AppEnv extends HonoEnv { }; } -Deno.test('PUT /wallet must be successful', { - sanitizeOps: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' - sanitizeResources: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' -}, async () => { +Deno.test('PUT /wallet must be successful', async () => { await using db = await createTestDB(); const store = db.store; @@ -97,10 +94,7 @@ Deno.test('PUT /wallet must be successful', { ]); }); -Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', { - sanitizeOps: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' - sanitizeResources: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' -}, async () => { +Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async () => { await using db = await createTestDB(); const store = db.store; @@ -132,10 +126,7 @@ Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', { assertObjectMatch(body, { error: 'Bad schema' }); }); -Deno.test('PUT /wallet must NOT be successful: wallet already exists', { - sanitizeOps: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' - sanitizeResources: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' -}, async () => { +Deno.test('PUT /wallet must NOT be successful: wallet already exists', async () => { await using db = await createTestDB(); const store = db.store; @@ -169,10 +160,7 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', { assertEquals(body2, { error: 'You already have a wallet 😏' }); }); -Deno.test('GET /wallet must be successful', { - sanitizeOps: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' - sanitizeResources: false, // postgres.js calls 'setTimeout' without calling 'clearTimeout' -}, async () => { +Deno.test('GET /wallet must be successful', async () => { await using db = await createTestDB(); const store = db.store; @@ -284,7 +272,7 @@ Deno.test('GET /wallet must be successful', { }); }); -Deno.test('GET /mints must be successful', {}, async () => { +Deno.test('GET /mints must be successful', async () => { const app = new Hono().route('/', cashuApp); const response = await app.request('/mints', { diff --git a/src/controllers/api/cashu.ts b/src/controllers/api/cashu.ts index 58a150de..19a29658 100644 --- a/src/controllers/api/cashu.ts +++ b/src/controllers/api/cashu.ts @@ -6,9 +6,8 @@ import { z } from 'zod'; import { Conf } from '@/config.ts'; import { createEvent, parseBody } from '@/utils/api.ts'; -import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; import { requireNip44Signer } from '@/middleware/requireSigner.ts'; -import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; +import { requireStore } from '@/middleware/storeMiddleware.ts'; import { walletSchema } from '@/schema.ts'; import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; import { isNostrId } from '@/utils.ts'; @@ -17,7 +16,7 @@ import { errorJson } from '@/utils/log.ts'; type Wallet = z.infer; -const app = new Hono().use('*', storeMiddleware, signerMiddleware); +const app = new Hono().use('*', requireStore); // app.delete('/wallet') -> 204 @@ -46,7 +45,7 @@ const createCashuWalletAndNutzapInfoSchema = z.object({ * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event */ app.put('/wallet', requireNip44Signer, async (c) => { - const signer = c.get('signer'); + const signer = c.var.signer; const store = c.get('store'); const pubkey = await signer.getPublicKey(); const body = await parseBody(c.req.raw); diff --git a/src/middleware/storeMiddleware.ts b/src/middleware/storeMiddleware.ts index 37d04856..f69712a3 100644 --- a/src/middleware/storeMiddleware.ts +++ b/src/middleware/storeMiddleware.ts @@ -4,6 +4,13 @@ import { NostrSigner, NStore } from '@nostrify/nostrify'; import { UserStore } from '@/storages/UserStore.ts'; import { Storages } from '@/storages.ts'; +export const requireStore: MiddlewareHandler<{ Variables: { store: NStore } }> = async (c, next) => { + if (!c.get('store')) { + throw new Error('Store is required'); + } + await next(); +}; + /** Store middleware. */ export const storeMiddleware: MiddlewareHandler<{ Variables: { signer?: NostrSigner; store: NStore } }> = async ( c, diff --git a/src/middleware/swapNutzapsMiddleware.ts b/src/middleware/swapNutzapsMiddleware.ts index 286965c6..b24dee80 100644 --- a/src/middleware/swapNutzapsMiddleware.ts +++ b/src/middleware/swapNutzapsMiddleware.ts @@ -2,7 +2,7 @@ import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cash import { MiddlewareHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; import { getPublicKey } from 'nostr-tools'; -import { NostrFilter, NostrSigner, NStore } from '@nostrify/nostrify'; +import { NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify'; import { SetRequired } from 'type-fest'; import { stringToBytes } from '@scure/base'; import { logi } from '@soapbox/logi'; @@ -11,6 +11,7 @@ import { isNostrId } from '@/utils.ts'; import { errorJson } from '@/utils/log.ts'; import { Conf } from '@/config.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. @@ -111,7 +112,22 @@ export const swapNutzapsMiddleware: MiddlewareHandler< mintsToProofs[mint] = { proofs: [], redeemed: [] }; } - mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...JSON.parse(proof)]; + 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, [ @@ -122,8 +138,8 @@ export const swapNutzapsMiddleware: MiddlewareHandler< ], ['p', event.pubkey], // pubkey of the author of the 9321 event (nutzap sender) ]; - } catch (e: any) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); } } @@ -162,8 +178,8 @@ export const swapNutzapsMiddleware: MiddlewareHandler< ), tags: mintsToProofs[mint].redeemed, }, c); - } catch (e: any) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e }); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); } } } From a5d4906257c288741fe0639a906fe4f9b13ee64e Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 13 Feb 2025 19:51:13 -0300 Subject: [PATCH 29/29] refactor: just ignore leaky tests --- src/controllers/api/cashu.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/controllers/api/cashu.test.ts b/src/controllers/api/cashu.test.ts index f367cc10..ac8eb699 100644 --- a/src/controllers/api/cashu.test.ts +++ b/src/controllers/api/cashu.test.ts @@ -18,7 +18,10 @@ interface AppEnv extends HonoEnv { }; } -Deno.test('PUT /wallet must be successful', async () => { +Deno.test('PUT /wallet must be successful', { + sanitizeOps: false, + sanitizeResources: false, +}, async () => { await using db = await createTestDB(); const store = db.store; @@ -273,7 +276,15 @@ Deno.test('GET /wallet must be successful', async () => { }); Deno.test('GET /mints must be successful', async () => { - const app = new Hono().route('/', cashuApp); + await using db = await createTestDB(); + const store = db.store; + + const app = new Hono().use( + async (c, next) => { + c.set('store', store); + await next(); + }, + ).route('/', cashuApp); const response = await app.request('/mints', { method: 'GET',