From 355c53fd64aa156188aae8503fde9be39bda4576 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 17 Mar 2025 22:28:36 -0300 Subject: [PATCH] refactor: create getWallet function, with tests --- packages/cashu/cashu.test.ts | 114 ++++++++++++++++++- packages/cashu/cashu.ts | 57 +++++++++- packages/cashu/mod.ts | 4 +- packages/cashu/schemas.ts | 16 +++ packages/ditto/controllers/api/cashu.test.ts | 4 +- packages/ditto/controllers/api/cashu.ts | 43 ++----- packages/ditto/schema.ts | 14 --- 7 files changed, 196 insertions(+), 56 deletions(-) diff --git a/packages/cashu/cashu.test.ts b/packages/cashu/cashu.test.ts index a997f5ee..2e5aca5b 100644 --- a/packages/cashu/cashu.test.ts +++ b/packages/cashu/cashu.test.ts @@ -9,7 +9,7 @@ import { assertEquals } from '@std/assert'; import { DittoPolyPg, TestDB } from '@ditto/db'; import { DittoConf } from '@ditto/conf'; -import { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts'; +import { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; Deno.test('validateAndParseWallet function returns valid data', async () => { const conf = new DittoConf(Deno.env); @@ -46,6 +46,7 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { tags: [ ['pubkey', p2pk], ['mint', 'https://mint.soul.com'], + ['relay', conf.relay], ], }, sk); await store.event(nutzapInfo); @@ -59,6 +60,7 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { privkey, p2pk, mints: ['https://mint.soul.com'], + relays: [conf.relay], }); }); @@ -343,3 +345,113 @@ Deno.test('getMintsToProofs function is working', async () => { }, }); }); + +Deno.test('getWallet 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 privkey = bytesToString('hex', sk); + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + const relay = new NPostgres(orig.kysely); + + const proofs = genEvent({ + kind: 7375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify({ + mint: 'https://cuiaba.mint.com', + proofs: [ + { + 'id': '004f7adf2a04356c', + 'amount': 2, + 'secret': '700312ccba84cb15d6a008c1d01b0dbf00025d3f2cb01f030a756553aca52de3', + 'C': '02f0ff21fdd19a547d66d9ca09df5573ad88d28e4951825130708ba53cbed19561', + 'dleq': { + 'e': '9c44a58cb429be619c474b97216009bd96ff1b7dd145b35828a14f180c03a86f', + 's': 'a11b8f616dfee5157a2c7c36da0ee181fe71b28729bee56b789e472c027ceb3b', + 'r': 'c51b9ade8cfd3939b78d509c9723f86b43b432680f55a6791e3e252b53d4b465', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 4, + 'secret': '5936f22d486734c03bd50b89aaa34be8e99f20d199bcebc09da8716890e95fb3', + 'C': '039b55f92c02243e31b04e964f2ad0bcd2ed3229e334f4c7a81037392b8411d6e7', + 'dleq': { + 'e': '7b7be700f2515f1978ca27bc1045d50b9d146bb30d1fe0c0f48827c086412b9e', + 's': 'cf44b08c7e64fd2bd9199667327b10a29b7c699b10cb7437be518203b25fe3fa', + 'r': 'ec0cf54ce2d17fae5db1c6e5e5fd5f34d7c7df18798b8d92bcb7cb005ec2f93b', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 16, + 'secret': '89e2315c058f3a010972dc6d546b1a2e81142614d715c28d169c6afdba5326bd', + 'C': '02bc1c3756e77563fe6c7769fc9d9bc578ea0b84bf4bf045cf31c7e2d3f3ad0818', + 'dleq': { + 'e': '8dfa000c9e2a43d35d2a0b1c7f36a96904aed35457ca308c6e7d10f334f84e72', + 's': '9270a914b1a53e32682b1277f34c5cfa931a6fab701a5dbee5855b68ddf621ab', + 'r': 'ae71e572839a3273b0141ea2f626915592b4b3f5f91b37bbeacce0d3396332c9', + }, + }, + { + 'id': '004f7adf2a04356c', + 'amount': 16, + 'secret': '06f2209f313d92505ae5c72087263f711b7a97b1b29a71886870e672a1b180ac', + 'C': '02fa2ad933b62449e2765255d39593c48293f10b287cf7036b23570c8f01c27fae', + 'dleq': { + 'e': 'e696d61f6259ae97f8fe13a5af55d47f526eea62a7998bf888626fd1ae35e720', + 's': 'b9f1ef2a8aec0e73c1a4aaff67e28b3ca3bc4628a532113e0733643c697ed7ce', + 'r': 'b66ed62852811d14e9bf822baebfda92ba47c5c4babc4f2499d9ce81fbbbd3f2', + }, + }, + ], + del: [], + }), + ), + created_at: Math.floor(Date.now() / 1000), // now + }, sk); + + await relay.event(proofs); + + await relay.event(genEvent({ + kind: 10019, + tags: [ + ['pubkey', p2pk], + ['mint', 'https://mint.soul.com'], + ['mint', 'https://cuiaba.mint.com'], + ['relay', conf.relay], + ], + }, sk)); + + const wallet = genEvent({ + kind: 17375, + content: await signer.nip44.encrypt( + pubkey, + JSON.stringify([ + ['privkey', privkey], + ['mint', 'https://mint.soul.com'], + ]), + ), + }, sk); + + await relay.event(wallet); + + const walletEntity = await getWallet(relay, pubkey, signer); + + assertEquals(walletEntity, { + balance: 38, + mints: ['https://mint.soul.com', 'https://cuiaba.mint.com'], + relays: [conf.relay], + pubkey_p2pk: p2pk, + }); +}); diff --git a/packages/cashu/cashu.ts b/packages/cashu/cashu.ts index 77c61c0c..aa1b3583 100644 --- a/packages/cashu/cashu.ts +++ b/packages/cashu/cashu.ts @@ -6,7 +6,7 @@ import { logi } from '@soapbox/logi'; import type { SetRequired } from 'type-fest'; import { z } from 'zod'; -import { proofSchema, tokenEventSchema } from './schemas.ts'; +import { proofSchema, tokenEventSchema, type Wallet } from './schemas.ts'; type Data = { wallet: NostrEvent; @@ -14,6 +14,7 @@ type Data = { privkey: string; p2pk: string; mints: string[]; + relays: string[]; }; type CustomError = @@ -96,7 +97,9 @@ async function validateAndParseWallet( }; } - return { data: { wallet, nutzapInfo, privkey, p2pk, mints }, error: null }; + const relays = [...new Set(nutzapInfo.tags.filter(([name]) => name === 'relay').map(([_, value]) => value))]; + + return { data: { wallet, nutzapInfo, privkey, p2pk, mints, relays }, error: null }; } type OrganizedProofs = { @@ -235,6 +238,54 @@ async function getMintsToProofs( return mintsToProofs; } +/** Returns a wallet entity with the latest balance. */ +async function getWallet( + store: NStore, + pubkey: string, + signer: SetRequired, + opts?: { signal?: AbortSignal }, +): Promise { + const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal: opts?.signal }); + + if (error) { + logi({ level: 'error', ns: 'ditto.cashu.get_wallet', error: errorJson(error) }); + return; + } + + const { p2pk, mints, relays } = data; + + let balance = 0; + + const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal: opts?.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: 'dtto.cashu.get_wallet', 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, + balance, + }; + + return walletEntity; +} + /** Serialize an error into JSON for JSON logging. */ export function errorJson(error: unknown): Error | null { if (error instanceof Error) { @@ -248,4 +299,4 @@ function isNostrId(value: unknown): boolean { return n.id().safeParse(value).success; } -export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet }; +export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet }; diff --git a/packages/cashu/mod.ts b/packages/cashu/mod.ts index 47b2f9a0..5292dc15 100644 --- a/packages/cashu/mod.ts +++ b/packages/cashu/mod.ts @@ -1,2 +1,2 @@ -export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts'; -export { proofSchema, tokenEventSchema } from './schemas.ts'; +export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; +export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts'; diff --git a/packages/cashu/schemas.ts b/packages/cashu/schemas.ts index 5d8a187b..83ee0b87 100644 --- a/packages/cashu/schemas.ts +++ b/packages/cashu/schemas.ts @@ -1,3 +1,4 @@ +import { NSchema as n } from '@nostrify/nostrify'; import { z } from 'zod'; export const proofSchema: z.ZodType<{ @@ -26,3 +27,18 @@ export const tokenEventSchema: z.ZodType<{ proofs: proofSchema.array(), del: z.string().array().optional(), }); + +/** Ditto Cashu wallet */ +export 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 type Wallet = z.infer; diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 0f93345b..b7ebdd16 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -1,4 +1,4 @@ -import { proofSchema } from '@ditto/cashu'; +import { proofSchema, walletSchema } from '@ditto/cashu'; import { DittoConf } from '@ditto/conf'; import { type User } from '@ditto/mastoapi/middleware'; import { DittoApp, DittoMiddleware } from '@ditto/mastoapi/router'; @@ -11,7 +11,6 @@ import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; import cashuRoute from '@/controllers/api/cashu.ts'; import { createTestDB } from '@/test.ts'; -import { walletSchema } from '@/schema.ts'; import { nostrNow } from '@/utils.ts'; import { Proof } from '@cashu/cashu-ts'; @@ -171,6 +170,7 @@ Deno.test('GET /wallet must be successful', async () => { tags: [ ['pubkey', p2pk], ['mint', 'https://mint.soul.com'], + ['relay', 'ws://localhost:4036/relay'], ], }, sk)); diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 62772c8f..e7cbc598 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,5 +1,5 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; -import { organizeProofs, tokenEventSchema, validateAndParseWallet } from '@ditto/cashu'; +import { getWallet, organizeProofs, tokenEventSchema, validateAndParseWallet, type Wallet } from '@ditto/cashu'; import { userMiddleware } from '@ditto/mastoapi/middleware'; import { DittoRoute } from '@ditto/mastoapi/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; @@ -10,15 +10,12 @@ import { z } from 'zod'; import { createEvent, parseBody } from '@/utils/api.ts'; import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; -import { walletSchema } from '@/schema.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { nostrNow } from '@/utils.ts'; import { errorJson } from '@/utils/log.ts'; import { getAmount } from '@/utils/bolt11.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -type Wallet = z.infer; - const route = new DittoRoute(); interface Nutzap { @@ -230,49 +227,27 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => { /** Gets a wallet, if it exists. */ route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, async (c) => { - const { conf, relay, user, signal, requestId } = c.var; + const { relay, user, signal } = c.var; const pubkey = await user.signer.getPublicKey(); - const { data, error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal }); + const { error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal }); if (error) { return c.json({ error: error.message }, 404); } - const { p2pk, mints } = data; + const walletEntity = await getWallet(relay, pubkey, user.signer); - let balance = 0; - - const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal }); - for (const token of tokens) { - try { - const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse( - await user.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', requestId, error: errorJson(e) }); - } + if (!walletEntity) { + return c.json({ 'error': 'Wallet not found' }, 404); } - // 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); }); +// PUT wallet +// what errors to return? + /** Get mints set by the CASHU_MINTS environment variable. */ route.get('/mints', (c) => { const { conf } = c.var; diff --git a/packages/ditto/schema.ts b/packages/ditto/schema.ts index c67aa5f6..2fe19e70 100644 --- a/packages/ditto/schema.ts +++ b/packages/ditto/schema.ts @@ -59,19 +59,6 @@ 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, fileSchema, @@ -82,5 +69,4 @@ export { percentageSchema, safeUrlSchema, sizesSchema, - walletSchema, };