From f9c4ec5835ee50749fe744495921bb355b883b53 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 20 Mar 2025 14:00:14 -0300 Subject: [PATCH] feat: create GET transactions endpoint (with tests) --- packages/cashu/cashu.test.ts | 78 +++++++++++++++++++- packages/cashu/cashu.ts | 51 ++++++++++++- packages/cashu/mod.ts | 9 ++- packages/ditto/controllers/api/cashu.test.ts | 2 +- packages/ditto/controllers/api/cashu.ts | 22 +++++- 5 files changed, 157 insertions(+), 5 deletions(-) diff --git a/packages/cashu/cashu.test.ts b/packages/cashu/cashu.test.ts index 2e5aca5b..5bf88951 100644 --- a/packages/cashu/cashu.test.ts +++ b/packages/cashu/cashu.test.ts @@ -9,7 +9,14 @@ import { assertEquals } from '@std/assert'; import { DittoPolyPg, TestDB } from '@ditto/db'; import { DittoConf } from '@ditto/conf'; -import { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; +import { + getLastRedeemedNutzap, + getMintsToProofs, + getTransactions, + getWallet, + organizeProofs, + validateAndParseWallet, +} from './cashu.ts'; Deno.test('validateAndParseWallet function returns valid data', async () => { const conf = new DittoConf(Deno.env); @@ -455,3 +462,72 @@ Deno.test('getWallet function is working', async () => { pubkey_p2pk: p2pk, }); }); + +Deno.test('getTransactions function is working', async () => { + const conf = new DittoConf(Deno.env); + const orig = new DittoPolyPg(conf.databaseUrl); + + await using db = new TestDB(orig); + await db.migrate(); + await db.clear(); + + const sk = generateSecretKey(); + const signer = new NSecSigner(sk); + const pubkey = await signer.getPublicKey(); + + const relay = new NPostgres(orig.kysely); + + const history1 = genEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'in'], + ['amount', '33'], + ]), + ), + created_at: Math.floor(Date.now() / 1000), // now + }, sk); + await relay.event(history1); + + const history2 = genEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'out'], + ['amount', '29'], + ]), + ), + created_at: Math.floor(Date.now() / 1000) - 1, // now - 1 second + }, sk); + await relay.event(history2); + + const history3 = genEvent({ + kind: 7376, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['direction', 'ouch'], + ['amount', 'yolo'], + ]), + ), + created_at: Math.floor(Date.now() / 1000) - 2, // now - 2 second + }, sk); + await relay.event(history3); + + const transactions = await getTransactions(relay, pubkey, signer, {}); + + assertEquals(transactions, [ + { + direction: 'in', + amount: 33, + created_at: history1.created_at, + }, + { + direction: 'out', + amount: 29, + created_at: history2.created_at, + }, + ]); +}); diff --git a/packages/cashu/cashu.ts b/packages/cashu/cashu.ts index aa1b3583..b676d731 100644 --- a/packages/cashu/cashu.ts +++ b/packages/cashu/cashu.ts @@ -286,6 +286,55 @@ async function getWallet( return walletEntity; } +type Transactions = { + amount: number; + created_at: number; + direction: 'in' | 'out'; +}[]; + +/** Returns a history of transactions. */ +async function getTransactions( + store: NStore, + pubkey: string, + signer: SetRequired, + pagination: { limit?: number; until?: number; since?: number }, + opts?: { signal?: AbortSignal }, +): Promise { + const { since, until, limit } = pagination; + const transactions: Transactions = []; + + const events = await store.query([{ kinds: [7376], authors: [pubkey], since, until, limit }], { + signal: opts?.signal, + }); + + for (const event of events) { + const { data: contentTags, success } = n.json().pipe(z.string().array().min(2).array()).safeParse( + await signer.nip44.decrypt(pubkey, event.content), + ); + + if (!success) { + continue; + } + + const direction = contentTags.find(([name]) => name === 'direction')?.[1]; + if (direction !== 'out' && direction !== 'in') { + continue; + } + const amount = parseInt(contentTags.find(([name]) => name === 'amount')?.[1] ?? '', 10); + if (isNaN(amount)) { + continue; + } + + transactions.push({ + created_at: event.created_at, + direction, + amount, + }); + } + + return transactions; +} + /** Serialize an error into JSON for JSON logging. */ export function errorJson(error: unknown): Error | null { if (error instanceof Error) { @@ -299,4 +348,4 @@ function isNostrId(value: unknown): boolean { return n.id().safeParse(value).success; } -export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet }; +export { getLastRedeemedNutzap, getMintsToProofs, getTransactions, getWallet, organizeProofs, validateAndParseWallet }; diff --git a/packages/cashu/mod.ts b/packages/cashu/mod.ts index 5292dc15..392eeccb 100644 --- a/packages/cashu/mod.ts +++ b/packages/cashu/mod.ts @@ -1,2 +1,9 @@ -export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; +export { + getLastRedeemedNutzap, + getMintsToProofs, + getTransactions, + getWallet, + organizeProofs, + validateAndParseWallet, +} from './cashu.ts'; export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts'; diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 83944d81..44b74f10 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -7,7 +7,7 @@ import { genEvent } from '@nostrify/nostrify/test'; import { bytesToString, stringToBytes } from '@scure/base'; import { stub } from '@std/testing/mock'; import { assertArrayIncludes, assertEquals, assertExists, assertObjectMatch } from '@std/assert'; -import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; +import { generateSecretKey, getPublicKey } from 'nostr-tools'; import cashuRoute from '@/controllers/api/cashu.ts'; import { createTestDB } from '@/test.ts'; diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index bd2b2777..18cbb7a5 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,6 +1,14 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; -import { getWallet, organizeProofs, tokenEventSchema, validateAndParseWallet, type Wallet } from '@ditto/cashu'; +import { + getTransactions, + getWallet, + organizeProofs, + tokenEventSchema, + validateAndParseWallet, + type Wallet, +} from '@ditto/cashu'; import { userMiddleware } from '@ditto/mastoapi/middleware'; +import { paginationSchema } from '@ditto/mastoapi/pagination'; import { DittoRoute } from '@ditto/mastoapi/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; @@ -262,6 +270,18 @@ route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, as return c.json(walletEntity, 200); }); +/** Gets a history of transactions. */ +route.get('/transactions', userMiddleware({ enc: 'nip44' }), async (c) => { + const { relay, user, signal } = c.var; + const { limit, since, until } = paginationSchema.parse(c.req.query()); + + const pubkey = await user.signer.getPublicKey(); + + const transactions = await getTransactions(relay, pubkey, user.signer, { since, until, limit }, { signal }); + + return c.json(transactions, 200); +}); + /** Get mints set by the CASHU_MINTS environment variable. */ route.get('/mints', (c) => { const { conf } = c.var;