feat: implement GET statuses/:id{[0-9a-f]{64}}/nutzapped_by (with tests)

This commit is contained in:
P. Reis 2025-03-26 15:55:59 -03:00
parent 71a558a9de
commit 8a75f9e944
2 changed files with 176 additions and 1 deletions

View file

@ -1,3 +1,4 @@
import { Proof } from '@cashu/cashu-ts';
import { proofSchema, walletSchema } from '@ditto/cashu'; import { proofSchema, walletSchema } from '@ditto/cashu';
import { DittoConf } from '@ditto/conf'; import { DittoConf } from '@ditto/conf';
import { type User } from '@ditto/mastoapi/middleware'; import { type User } from '@ditto/mastoapi/middleware';
@ -10,9 +11,9 @@ import { assertArrayIncludes, assertEquals, assertExists, assertObjectMatch } fr
import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { generateSecretKey, getPublicKey } from 'nostr-tools';
import cashuRoute from '@/controllers/api/cashu.ts'; import cashuRoute from '@/controllers/api/cashu.ts';
import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
import { createTestDB } from '@/test.ts'; import { createTestDB } from '@/test.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { Proof } from '@cashu/cashu-ts';
Deno.test('PUT /wallet must be successful', async () => { Deno.test('PUT /wallet must be successful', async () => {
const mock = stub(globalThis, 'fetch', () => { const mock = stub(globalThis, 'fetch', () => {
@ -1158,6 +1159,136 @@ Deno.test('POST /nutzap must be successful WITHOUT proofs to keep', async () =>
mock.restore(); 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() { async function createTestRoute() {
const conf = new DittoConf( const conf = new DittoConf(
new Map([['DITTO_NSEC', 'nsec14fg8xd04hvlznnvhaz77ms0k9kxy9yegdsgs2ar27czhh46xemuquqlv0m']]), new Map([['DITTO_NSEC', 'nsec14fg8xd04hvlznnvhaz77ms0k9kxy9yegdsgs2ar27czhh46xemuquqlv0m']]),

View file

@ -2,6 +2,7 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts';
import { import {
getWallet, getWallet,
organizeProofs, organizeProofs,
proofSchema,
renderTransaction, renderTransaction,
tokenEventSchema, tokenEventSchema,
validateAndParseWallet, validateAndParseWallet,
@ -14,6 +15,7 @@ import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { bytesToString, stringToBytes } from '@scure/base'; import { bytesToString, stringToBytes } from '@scure/base';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { z } from 'zod'; import { z } from 'zod';
import { createEvent, parseBody } from '@/utils/api.ts'; import { createEvent, parseBody } from '@/utils/api.ts';
@ -294,6 +296,48 @@ route.get('/transactions', userMiddleware({ enc: 'nip44' }), async (c) => {
return paginated(c, events, transactions); 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. */ /** Get mints set by the CASHU_MINTS environment variable. */
route.get('/mints', (c) => { route.get('/mints', (c) => {
const { conf } = c.var; const { conf } = c.var;