diff --git a/deno.json b/deno.json index 18a6621c..2a8d18f9 100644 --- a/deno.json +++ b/deno.json @@ -40,6 +40,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", "@core/asyncutil": "jsr:@core/asyncutil@^1.2.0", "@electric-sql/pglite": "npm:@electric-sql/pglite@^0.2.8", "@esroyo/scoped-performance": "jsr:@esroyo/scoped-performance@^3.1.0", diff --git a/deno.lock b/deno.lock index 029740a6..7737f3d0 100644 --- a/deno.lock +++ b/deno.lock @@ -85,6 +85,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", @@ -740,6 +741,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==" }, @@ -857,6 +877,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==" }, @@ -866,6 +892,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==" }, @@ -878,6 +907,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": [ @@ -894,6 +926,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": [ @@ -908,6 +948,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": [ @@ -944,6 +991,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==" }, @@ -956,6 +1006,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==" }, @@ -1194,6 +1251,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": [ @@ -2387,6 +2447,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/app.ts b/src/app.ts index 6929757f..8482d491 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, deleteZapSplitsController, getZapSplitsController, nameRequestController, @@ -406,7 +406,7 @@ 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/reports', requireSigner, reportController); app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController); diff --git a/src/config.ts b/src/config.ts index 5bc5d865..be333334 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.test.ts b/src/controllers/api/cashu.test.ts new file mode 100644 index 00000000..ac8eb699 --- /dev/null +++ b/src/controllers/api/cashu.test.ts @@ -0,0 +1,297 @@ +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, assertObjectMatch } from '@std/assert'; + +import { createTestDB, genEvent } from '@/test.ts'; + +import cashuApp from '@/controllers/api/cashu.ts'; +import { walletSchema } from '@/schema.ts'; + +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, + sanitizeResources: false, +}, 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 = 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 } = walletSchema.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', + ]); +}); + +Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', 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' }); +}); + +Deno.test('PUT /wallet must NOT be successful: wallet already exists', 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 response = await app.request('/wallet', { + method: 'PUT', + headers: [['content-type', 'application/json']], + body: JSON.stringify({ + mints: ['https://mint.heart.com'], + }), + }); + + const body2 = await response.json(); + + assertEquals(response.status, 400); + assertEquals(body2, { error: 'You already have a wallet 😏' }); +}); + +Deno.test('GET /wallet must be successful', 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, + }); +}); + +Deno.test('GET /mints must be successful', async () => { + 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', + }); + + 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 new file mode 100644 index 00000000..19a29658 --- /dev/null +++ b/src/controllers/api/cashu.ts @@ -0,0 +1,169 @@ +import { Proof } from '@cashu/cashu-ts'; +import { Hono } from '@hono/hono'; +import { generateSecretKey, getPublicKey } from 'nostr-tools'; +import { bytesToString, stringToBytes } from '@scure/base'; +import { z } from 'zod'; + +import { Conf } from '@/config.ts'; +import { createEvent, parseBody } from '@/utils/api.ts'; +import { requireNip44Signer } from '@/middleware/requireSigner.ts'; +import { requireStore } from '@/middleware/storeMiddleware.ts'; +import { walletSchema } from '@/schema.ts'; +import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; +import { isNostrId } from '@/utils.ts'; +import { logi } from '@soapbox/logi'; +import { errorJson } from '@/utils/log.ts'; + +type Wallet = z.infer; + +const app = new Hono().use('*', requireStore); + +// app.delete('/wallet') -> 204 + +// app.post(swapMiddleware, '/nutzap'); + +/* GET /api/v1/ditto/cashu/wallet -> Wallet, 404 */ +/* PUT /api/v1/ditto/cashu/wallet -> Wallet */ +/* DELETE /api/v1/ditto/cashu/wallet -> 204 */ + +interface Nutzap { + amount: number; + event_id?: string; + mint: string; // mint the nutzap was created + recipient_pubkey: string; +} + +const createCashuWalletAndNutzapInfoSchema = z.object({ + mints: z.array(z.string().url()).nonempty().transform((val) => { + return [...new Set(val)]; + }), +}); + +/** + * 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.put('/wallet', requireNip44Signer, async (c) => { + const signer = c.var.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 = 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 walletContentTags: string[][] = []; + + const sk = generateSecretKey(); + const privkey = bytesToString('hex', sk); + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + walletContentTags.push(['privkey', privkey]); + + for (const mint of mints) { + walletContentTags.push(['mint', mint]); + } + + const encryptedWalletContentTags = await signer.nip44.encrypt(pubkey, JSON.stringify(walletContentTags)); + + // Wallet + await createEvent({ + kind: 17375, + content: encryptedWalletContentTags, + }, c); + + // Nutzap information + await createEvent({ + kind: 10019, + tags: [ + ...mints.map((mint) => ['mint', mint, 'sat']), + ['relay', Conf.relay], // TODO: add more relays once things get more stable + ['pubkey', p2pk], + ], + }, c); + + // 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); +}); + +/** 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 [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); + if (!event) { + return c.json({ error: 'Wallet not found' }, 404); + } + + const decryptedContent: string[][] = JSON.parse(await signer.nip44.decrypt(pubkey, event.content)); + + const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1]; + if (!privkey || !isNostrId(privkey)) { + return c.json({ error: 'Wallet does not contain privkey or privkey is not a valid nostr id.' }, 422); + } + + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + let balance = 0; + const mints: string[] = []; + + const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal }); + for (const token of tokens) { + try { + const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse( + await signer.nip44.decrypt(pubkey, token.content), + ); + + if (!mints.includes(decryptedContent.mint)) { + mints.push(decryptedContent.mint); + } + + balance += decryptedContent.proofs.reduce((accumulator, current) => { + return accumulator + current.amount; + }, 0); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); + } + } + + // TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint + const walletEntity: Wallet = { + pubkey_p2pk: p2pk, + mints, + relays: [Conf.relay], + balance, + }; + + return c.json(walletEntity, 200); +}); + +/** 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); +}); + +export default app; diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index b8476608..5022c141 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,16 +1,14 @@ import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; -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 { 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 { AdminSigner } from '@/signers/AdminSigner.ts'; import { screenshotsSchema } from '@/schemas/nostr.ts'; @@ -245,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, @@ -274,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; @@ -344,135 +342,3 @@ export const updateInstanceController: AppController = async (c) => { return c.json(204); }; - -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. - * 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 request', schema: result.error }, 400); - } - - const [event] = await store.query([{ authors: [pubkey], kinds: [37375] }], { 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 sk = generateSecretKey(); - const privkey = bytesToString('hex', sk); - - const contentTags = [ - ['privkey', privkey], - ]; - const encryptedContentTags = await signer.nip44?.encrypt(pubkey, JSON.stringify(contentTags)); - - // Wallet - await createEvent({ - kind: 37375, - content: encryptedContentTags, - tags, - }, c); - - return c.json({ wallet_id }, 200); -}; - -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(), -}); - -/** - * 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 request', schema: result.error }, 400); - } - - const nip44 = signer.nip44; - if (!nip44) { - return c.json({ error: 'Signer does not have nip 44' }, 400); - } - - const { relays, mints, wallet_id } = result.data; - - const [event] = await store.query([{ authors: [pubkey], kinds: [37375], '#d': [wallet_id] }], { signal }); - if (!event) { - return c.json({ error: 'Could not find a wallet with the id: ' + wallet_id }, 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]); - } - - const contentTags: string[][] = JSON.parse(await nip44.decrypt(pubkey, event.content)); - - const privkey = contentTags.find(([value]) => value === 'privkey')?.[1]; - if (!privkey) { - return c.json({ error: 'Wallet does not contain privkey' }, 400); - } - - const p2pk = getPublicKey(stringToBytes('hex', privkey)); - - tags.push(['pubkey', p2pk]); - - // Nutzap information - await createEvent({ - kind: 10019, - tags, - }, c); - - return c.json(201); -}; 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..f69712a3 100644 --- a/src/middleware/storeMiddleware.ts +++ b/src/middleware/storeMiddleware.ts @@ -1,9 +1,21 @@ -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'; +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: 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/middleware/swapNutzapsMiddleware.ts b/src/middleware/swapNutzapsMiddleware.ts new file mode 100644 index 00000000..b24dee80 --- /dev/null +++ b/src/middleware/swapNutzapsMiddleware.ts @@ -0,0 +1,188 @@ +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, NSchema as n, 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'; +import { z } from 'zod'; + +/** + * 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: [] }; + } + + const parsed = n.json().pipe( + z.object({ + id: z.string(), + amount: z.number(), + secret: z.string(), + C: z.string(), + dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }).optional(), + dleqValid: z.boolean().optional(), + }).array(), + ).safeParse(proof); + + if (!parsed.success) { + continue; + } + + mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data]; + mintsToProofs[mint].redeemed = [ + ...mintsToProofs[mint].redeemed, + [ + 'e', // nutzap event that has been redeemed + event.id, + Conf.relay, + 'redeemed', + ], + ['p', event.pubkey], // pubkey of the author of the 9321 event (nutzap sender) + ]; + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); + } + } + + // TODO: throw error if mintsToProofs is an empty object? + for (const mint of Object.keys(mintsToProofs)) { + try { + const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs }); + + const cashuWallet = new CashuWallet(new CashuMint(mint)); + const receiveProofs = await cashuWallet.receive(token, { privkey }); + + const unspentProofs = await createEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify({ + mint, + proofs: receiveProofs, + }), + ), + }, c); + + const amount = receiveProofs.reduce((accumulator, current) => { + return accumulator + current.amount; + }, 0); + + await createEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'in'], + ['amount', amount], + ['e', unspentProofs.id, Conf.relay, 'created'], + ]), + ), + tags: mintsToProofs[mint].redeemed, + }, c); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); + } + } + } + + await next(); +}; diff --git a/src/schema.ts b/src/schema.ts index 0fce60d4..30b4520a 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,19 @@ const sizesSchema = z.string().refine((value) => value.split(' ').every((v) => /^[1-9]\d{0,3}[xX][1-9]\d{0,3}$/.test(v)) ); +/** Ditto Cashu wallet */ +const walletSchema = z.object({ + pubkey_p2pk: n.id(), + mints: z.array(z.string().url()).nonempty().transform((val) => { + return [...new Set(val)]; + }), + relays: z.array(z.string()).nonempty().transform((val) => { + return [...new Set(val)]; + }), + /** Unit in sats */ + balance: z.number(), +}); + export { booleanParamSchema, decode64Schema, @@ -91,5 +105,6 @@ export { percentageSchema, safeUrlSchema, sizesSchema, + walletSchema, wsUrlSchema, }; diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index 64876718..c0a9bec4 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -67,7 +67,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; }, }; diff --git a/src/utils/api.ts b/src/utils/api.ts index 91eda723..79512190 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) {