diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 44b74f10..c7216e34 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -1,3 +1,4 @@ +import { Proof } from '@cashu/cashu-ts'; import { proofSchema, walletSchema } from '@ditto/cashu'; import { DittoConf } from '@ditto/conf'; import { type User } from '@ditto/mastoapi/middleware'; @@ -10,9 +11,9 @@ import { assertArrayIncludes, assertEquals, assertExists, assertObjectMatch } fr import { generateSecretKey, getPublicKey } from 'nostr-tools'; import cashuRoute from '@/controllers/api/cashu.ts'; +import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { createTestDB } from '@/test.ts'; import { nostrNow } from '@/utils.ts'; -import { Proof } from '@cashu/cashu-ts'; Deno.test('PUT /wallet must be successful', async () => { const mock = stub(globalThis, 'fetch', () => { @@ -1158,6 +1159,136 @@ Deno.test('POST /nutzap must be successful WITHOUT proofs to keep', async () => mock.restore(); }); +Deno.test('GET /statuses/:id{[0-9a-f]{64}}/nutzapped_by must be successful', async () => { + const mock = stub(globalThis, 'fetch', () => { + return Promise.resolve(new Response()); + }); + + await using test = await createTestRoute(); + const { route, sk, relay, signer } = test; + + const pubkey = await signer.getPublicKey(); + + const post = genEvent({ + kind: 1, + content: 'Hello', + }, sk); + await relay.event(post); + + const senderSk = generateSecretKey(); + const sender = getPublicKey(senderSk); + + await relay.event(genEvent({ + created_at: nostrNow() - 1, + kind: 9321, + content: 'Who do I have?', + tags: [ + ['e', post.id], + ['p', pubkey], + ['u', 'https://mint.soul.com'], + [ + 'proof', + '{"amount":1,"C":"02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f","id":"000a93d6f8a1d2c4","secret":"[\\"P2PK\\",{\\"nonce\\":\\"b00bdd0467b0090a25bdf2d2f0d45ac4e355c482c1418350f273a04fedaaee83\\",\\"data\\":\\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\\"}]"}', + ], + [ + 'proof', + '{"amount":1,"C":"02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f","id":"000a93d6f8a1d2c4","secret":"[\\"P2PK\\",{\\"nonce\\":\\"b00bdd0467b0090a25bdf2d2f0d45ac4e355c482c1418350f273a04fedaaee83\\",\\"data\\":\\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\\"}]"}', + ], + ], + }, senderSk)); + + await relay.event(genEvent({ + created_at: nostrNow() - 3, + kind: 9321, + content: 'Want it all to end', + tags: [ + ['e', post.id], + ['p', pubkey], + ['u', 'https://mint.soul.com'], + [ + 'proof', + JSON.stringify({ + id: '005c2502034d4f12', + amount: 25, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }), + ], + ], + }, senderSk)); + + await relay.event(genEvent({ + created_at: nostrNow() - 5, + kind: 9321, + content: 'Evidence', + tags: [ + ['e', post.id], + ['p', pubkey], + ['u', 'https://mint.soul.com'], + [ + 'proof', + '{"amount":1,"C":"02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f","id":"000a93d6f8a1d2c4","secret":"[\\"P2PK\\",{\\"nonce\\":\\"b00bdd0467b0090a25bdf2d2f0d45ac4e355c482c1418350f273a04fedaaee83\\",\\"data\\":\\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\\"}]"}', + ], + ], + }, senderSk)); + + const sender2Sk = generateSecretKey(); + const sender2 = getPublicKey(sender2Sk); + + await relay.event(genEvent({ + created_at: nostrNow() + 10, + kind: 9321, + content: 'Reach out', + tags: [ + ['e', post.id], + ['p', pubkey], + ['u', 'https://mint.soul.com'], + [ + 'proof', + JSON.stringify({ + id: '005c2502034d4f12', + amount: 25, + secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=', + C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46', + }), + ], + ], + }, sender2Sk)); + + const response = await route.request(`/statuses/${post.id}/nutzapped_by`, { + method: 'GET', + }); + + const body = await response.json(); + + assertEquals(response.status, 200); + + assertEquals(body, [ + { + comment: 'Reach out', + amount: 25, + account: JSON.parse(JSON.stringify(accountFromPubkey(sender2))), + }, + { + comment: 'Who do I have?', + amount: 2, + account: JSON.parse(JSON.stringify(accountFromPubkey(sender))), + }, + { + comment: 'Want it all to end', + amount: 25, + account: JSON.parse(JSON.stringify(accountFromPubkey(sender))), + }, + { + comment: 'Evidence', + amount: 1, + account: JSON.parse(JSON.stringify(accountFromPubkey(sender))), + }, + ]); + + mock.restore(); +}); + async function createTestRoute() { const conf = new DittoConf( new Map([['DITTO_NSEC', 'nsec14fg8xd04hvlznnvhaz77ms0k9kxy9yegdsgs2ar27czhh46xemuquqlv0m']]), diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index f355d392..96b50916 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -2,6 +2,7 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; import { getWallet, organizeProofs, + proofSchema, renderTransaction, tokenEventSchema, validateAndParseWallet, @@ -14,6 +15,7 @@ import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { bytesToString, stringToBytes } from '@scure/base'; import { logi } from '@soapbox/logi'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { z } from 'zod'; import { createEvent, parseBody } from '@/utils/api.ts'; @@ -294,6 +296,48 @@ route.get('/transactions', userMiddleware({ enc: 'nip44' }), async (c) => { return paginated(c, events, transactions); }); +/** Gets the nutzaps that a post received. */ +route.get('statuses/:id{[0-9a-f]{64}}/nutzapped_by', async (c) => { + const id = c.req.param('id'); + const { relay, signal } = c.var; + const { limit, since, until } = paginationSchema().parse(c.req.query()); + + const events = await relay.query([{ kinds: [9321], '#e': [id], since, until, limit }], { + signal, + }); + + if (!events.length) { + return c.json([], 200); + } + + await hydrateEvents({ ...c.var, events }); + + const results = (await Promise.all( + events.map((event: DittoEvent) => { + const proofs = (event.tags.filter(([name]) => name === 'proof').map(([_, proof]) => { + const { success, data } = n.json().pipe(proofSchema).safeParse(proof); + if (!success) return; + + return data; + }) + .filter(Boolean)) as Proof[]; + + const amount = proofs.reduce((prev, current) => prev + current.amount, 0); + const comment = event.content; + + const account = event?.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey); + + return { + comment, + amount, + account, + }; + }), + )).filter(Boolean); + + return paginated(c, events, results); +}); + /** Get mints set by the CASHU_MINTS environment variable. */ route.get('/mints', (c) => { const { conf } = c.var;