diff --git a/deno.json b/deno.json index 33cb119c..efd53e63 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,8 @@ "./packages/ratelimiter", "./packages/transcode", "./packages/translators", - "./packages/uploaders" + "./packages/uploaders", + "./packages/cashu" ], "tasks": { "start": "deno run -A --env-file --deny-read=.env packages/ditto/server.ts", diff --git a/packages/ditto/utils/cashu.test.ts b/packages/cashu/cashu.test.ts similarity index 65% rename from packages/ditto/utils/cashu.test.ts rename to packages/cashu/cashu.test.ts index 6e3c80f6..a9d24d1a 100644 --- a/packages/ditto/utils/cashu.test.ts +++ b/packages/cashu/cashu.test.ts @@ -1,17 +1,25 @@ import { NSecSigner } from '@nostrify/nostrify'; +import { NPostgres } from '@nostrify/db'; import { genEvent } from '@nostrify/nostrify/test'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; import { assertEquals } from '@std/assert'; -import { createTestDB } from '@/test.ts'; +import { DittoPolyPg, TestDB } from '@ditto/db'; +import { DittoConf } from '@ditto/conf'; -import { organizeProofs, validateAndParseWallet } from '@/utils/cashu.ts'; +import { getLastRedeemedNutzap, organizeProofs, validateAndParseWallet } from './cashu.ts'; Deno.test('validateAndParseWallet function returns valid data', async () => { - await using db = await createTestDB({ pure: true }); - const store = db.store; + 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 store = new NPostgres(orig.kysely); const sk = generateSecretKey(); const signer = new NSecSigner(sk); @@ -30,7 +38,7 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { ]), ), }, sk); - await db.store.event(wallet); + await store.event(wallet); // Nutzap information const nutzapInfo = genEvent({ @@ -40,7 +48,7 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { ['mint', 'https://mint.soul.com'], ], }, sk); - await db.store.event(nutzapInfo); + await store.event(nutzapInfo); const { data, error } = await validateAndParseWallet(store, signer, pubkey); @@ -55,8 +63,14 @@ Deno.test('validateAndParseWallet function returns valid data', async () => { }); Deno.test('organizeProofs function is working', async () => { - await using db = await createTestDB({ pure: true }); - const store = db.store; + 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 store = new NPostgres(orig.kysely); const sk = generateSecretKey(); const signer = new NSecSigner(sk); @@ -98,7 +112,7 @@ Deno.test('organizeProofs function is working', async () => { }), ), }, sk); - await db.store.event(event1); + await store.event(event1); const proof1 = { 'id': '004f7adf2a04356c', @@ -124,7 +138,7 @@ Deno.test('organizeProofs function is working', async () => { token1, ), }, sk); - await db.store.event(event2); + await store.event(event2); const proof2 = { 'id': '004f7adf2a04356c', @@ -151,7 +165,7 @@ Deno.test('organizeProofs function is working', async () => { token2, ), }, sk); - await db.store.event(event3); + await store.event(event3); const unspentProofs = await store.query([{ kinds: [7375], authors: [pubkey] }]); @@ -169,3 +183,62 @@ Deno.test('organizeProofs function is working', async () => { }, }); }); + +Deno.test('getLastRedeemedNutzap 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 store = new NPostgres(orig.kysely); + + const sk = generateSecretKey(); + const signer = new NSecSigner(sk); + const pubkey = await signer.getPublicKey(); + + const event1 = genEvent({ + kind: 7376, + content: '', + created_at: Math.floor(Date.now() / 1000), // now + tags: [ + ['e', '', '', 'redeemed'], + ], + }, sk); + await store.event(event1); + + const event2 = genEvent({ + kind: 7376, + content: '', + created_at: Math.floor((Date.now() - 86400000) / 1000), // yesterday + tags: [ + ['e', '', '', 'redeemed'], + ], + }, sk); + await store.event(event2); + + const event3 = genEvent({ + kind: 7376, + content: '', + created_at: Math.floor((Date.now() - 86400000) / 1000), // yesterday + tags: [ + ['e', '', '', 'redeemed'], + ], + }, sk); + await store.event(event3); + + const event4 = genEvent({ + kind: 7376, + content: '', + created_at: Math.floor((Date.now() + 86400000) / 1000), // tomorrow + tags: [ + ['e', '', '', 'redeemed'], + ], + }, sk); + await store.event(event4); + + const event = await getLastRedeemedNutzap(store, pubkey); + + assertEquals(event, event4); +}); diff --git a/packages/ditto/utils/cashu.ts b/packages/cashu/cashu.ts similarity index 54% rename from packages/ditto/utils/cashu.ts rename to packages/cashu/cashu.ts index 625c86f3..77c61c0c 100644 --- a/packages/ditto/utils/cashu.ts +++ b/packages/cashu/cashu.ts @@ -1,13 +1,12 @@ -import { SetRequired } from 'type-fest'; +import type { Proof } from '@cashu/cashu-ts'; +import { type NostrEvent, type NostrFilter, type NostrSigner, NSchema as n, type NStore } from '@nostrify/nostrify'; import { getPublicKey } from 'nostr-tools'; -import { NostrEvent, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify'; -import { logi } from '@soapbox/logi'; import { stringToBytes } from '@scure/base'; +import { logi } from '@soapbox/logi'; +import type { SetRequired } from 'type-fest'; import { z } from 'zod'; -import { errorJson } from '@/utils/log.ts'; -import { isNostrId } from '@/utils.ts'; -import { tokenEventSchema } from '@/schemas/cashu.ts'; +import { proofSchema, tokenEventSchema } from './schemas.ts'; type Data = { wallet: NostrEvent; @@ -139,4 +138,114 @@ async function organizeProofs( return organizedProofs; } -export { organizeProofs, validateAndParseWallet }; +/** Returns a spending history event that contains the last redeemed nutzap. */ +async function getLastRedeemedNutzap( + store: NStore, + pubkey: string, + opts?: { signal?: AbortSignal }, +): Promise { + const events = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal: opts?.signal }); + + for (const event of events) { + const nutzap = event.tags.find(([name]) => name === 'e'); + const redeemed = nutzap?.[3]; + if (redeemed === 'redeemed') { + return event; + } + } +} + +/** + * toBeRedeemed are the nutzaps that will be redeemed into a kind 7375 and saved in the kind 7376 tags + * The tags format is: [ + * [ "e", "<9321-event-id>", "", "redeemed" ], // nutzap event that has been redeemed + * [ "p", "" ] // pubkey of the author of the 9321 event (nutzap sender) + * ] + * https://github.com/nostr-protocol/nips/blob/master/61.md#updating-nutzap-redemption-history + */ +type MintsToProofs = { [key: string]: { proofs: Proof[]; toBeRedeemed: string[][] } }; + +/** + * Gets proofs from nutzaps that have not been redeemed yet. + * Each proof is associated with a specific mint. + * @param store Store used to query for the nutzaps + * @param nutzapsFilter Filter used to query for the nutzaps, most useful when + * it contains a 'since' field so it saves time and resources + * @param relay Relay hint where the new kind 7376 will be saved + * @returns MintsToProofs An object where each key is a mint url and the values are an array of proofs + * and an array of redeemed tags in this format: + * ``` + * [ + * ..., + * [ "e", "<9321-event-id>", "", "redeemed" ], // nutzap event that has been redeemed + * [ "p", "" ] // pubkey of the author of the 9321 event (nutzap sender) + * ] + * ``` + */ +async function getMintsToProofs( + store: NStore, + nutzapsFilter: NostrFilter, + relay: string, + opts?: { signal?: AbortSignal }, +): Promise { + const mintsToProofs: MintsToProofs = {}; + + const nutzaps = await store.query([nutzapsFilter], { signal: opts?.signal }); + + for (const event of nutzaps) { + try { + const mint = event.tags.find(([name]) => name === 'u')?.[1]; + if (!mint) { + continue; + } + + const proofs = event.tags.filter(([name]) => name === 'proof').map((tag) => tag[1]).filter(Boolean); + if (proofs.length < 1) { + continue; + } + + if (!mintsToProofs[mint]) { + mintsToProofs[mint] = { proofs: [], toBeRedeemed: [] }; + } + + const parsed = n.json().pipe( + proofSchema, + ).array().safeParse(proofs); + + if (!parsed.success) { + continue; + } + + mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data]; + mintsToProofs[mint].toBeRedeemed = [ + ...mintsToProofs[mint].toBeRedeemed, + [ + 'e', // nutzap event that has been redeemed + event.id, + 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) }); + } + } + + return mintsToProofs; +} + +/** Serialize an error into JSON for JSON logging. */ +export function errorJson(error: unknown): Error | null { + if (error instanceof Error) { + return error; + } else { + return null; + } +} + +function isNostrId(value: unknown): boolean { + return n.id().safeParse(value).success; +} + +export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet }; diff --git a/packages/cashu/deno.json b/packages/cashu/deno.json new file mode 100644 index 00000000..ce73c269 --- /dev/null +++ b/packages/cashu/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@ditto/cashu", + "version": "0.1.0", + "exports": { + ".": "./mod.ts" + } +} diff --git a/packages/cashu/mod.ts b/packages/cashu/mod.ts new file mode 100644 index 00000000..47b2f9a0 --- /dev/null +++ b/packages/cashu/mod.ts @@ -0,0 +1,2 @@ +export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts'; +export { proofSchema, tokenEventSchema } from './schemas.ts'; diff --git a/packages/ditto/schemas/cashu.test.ts b/packages/cashu/schemas.test.ts similarity index 94% rename from packages/ditto/schemas/cashu.test.ts rename to packages/cashu/schemas.test.ts index 57749c7a..5c42534a 100644 --- a/packages/ditto/schemas/cashu.test.ts +++ b/packages/cashu/schemas.test.ts @@ -1,7 +1,8 @@ import { NSchema as n } from '@nostrify/nostrify'; import { assertEquals } from '@std/assert'; -import { proofSchema } from '@/schemas/cashu.ts'; -import { tokenEventSchema } from '@/schemas/cashu.ts'; + +import { proofSchema } from './schemas.ts'; +import { tokenEventSchema } from './schemas.ts'; Deno.test('Parse proof', () => { const proof = diff --git a/packages/ditto/schemas/cashu.ts b/packages/cashu/schemas.ts similarity index 100% rename from packages/ditto/schemas/cashu.ts rename to packages/cashu/schemas.ts diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 57a53722..e121f809 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,4 +1,5 @@ import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; +import { organizeProofs, tokenEventSchema, validateAndParseWallet } from '@ditto/cashu'; import { userMiddleware } from '@ditto/mastoapi/middleware'; import { DittoRoute } from '@ditto/mastoapi/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; @@ -14,8 +15,6 @@ import { hydrateEvents } from '@/storages/hydrate.ts'; import { nostrNow } from '@/utils.ts'; import { errorJson } from '@/utils/log.ts'; import { getAmount } from '@/utils/bolt11.ts'; -import { organizeProofs, validateAndParseWallet } from '@/utils/cashu.ts'; -import { tokenEventSchema } from '@/schemas/cashu.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; type Wallet = z.infer; diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index ea410d5a..b7551d0b 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -1,12 +1,11 @@ -import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; +import { CashuMint, CashuWallet, getEncodedToken } from '@cashu/cashu-ts'; +import { getLastRedeemedNutzap, getMintsToProofs, validateAndParseWallet } from '@ditto/cashu'; import { HTTPException } from '@hono/hono/http-exception'; -import { NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify'; +import { NostrFilter } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; import { errorJson } from '@/utils/log.ts'; import { createEvent } from '@/utils/api.ts'; -import { validateAndParseWallet } from '@/utils/cashu.ts'; -import { proofSchema } from '@/schemas/cashu.ts'; import { MiddlewareHandler } from '@hono/hono/types'; /** @@ -93,100 +92,3 @@ export const swapNutzapsMiddleware: MiddlewareHandler = async (c, next) => { await next(); }; - -/** Returns a spending history event that contains the last redeemed nutzap. */ -async function getLastRedeemedNutzap( - store: NStore, - pubkey: string, - opts?: { signal?: AbortSignal }, -): Promise { - const events = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal: opts?.signal }); - - for (const event of events) { - const nutzap = event.tags.find(([name]) => name === 'e'); - const redeemed = nutzap?.[3]; - if (redeemed === 'redeemed') { - return event; - } - } -} - -/** - * toBeRedeemed are the nutzaps that will be redeemed into a kind 7375 and saved in the kind 7376 tags - * The tags format is: [ - * [ "e", "<9321-event-id>", "", "redeemed" ], // nutzap event that has been redeemed - * [ "p", "" ] // pubkey of the author of the 9321 event (nutzap sender) - * ] - * https://github.com/nostr-protocol/nips/blob/master/61.md#updating-nutzap-redemption-history - */ -type MintsToProofs = { [key: string]: { proofs: Proof[]; toBeRedeemed: string[][] } }; - -/** - * Gets proofs from nutzaps that have not been redeemed yet. - * Each proof is associated with a specific mint. - * @param store Store used to query for the nutzaps - * @param nutzapsFilter Filter used to query for the nutzaps, most useful when - * it contains a 'since' field so it saves time and resources - * @param relay Relay hint where the new kind 7376 will be saved - * @returns MintsToProofs An object where each key is a mint url and the values are an array of proofs - * and an array of redeemed tags in this format: - * ``` - * [ - * ..., - * [ "e", "<9321-event-id>", "", "redeemed" ], // nutzap event that has been redeemed - * [ "p", "" ] // pubkey of the author of the 9321 event (nutzap sender) - * ] - * ``` - */ -async function getMintsToProofs( - store: NStore, - nutzapsFilter: NostrFilter, - relay: string, - opts?: { signal?: AbortSignal }, -): Promise { - const mintsToProofs: MintsToProofs = {}; - - const nutzaps = await store.query([nutzapsFilter], { signal: opts?.signal }); - - for (const event of nutzaps) { - try { - const mint = event.tags.find(([name]) => name === 'u')?.[1]; - if (!mint) { - continue; - } - - const proofs = event.tags.filter(([name]) => name === 'proof').map((tag) => tag[1]).filter(Boolean); - if (proofs.length < 1) { - continue; - } - - if (!mintsToProofs[mint]) { - mintsToProofs[mint] = { proofs: [], toBeRedeemed: [] }; - } - - const parsed = n.json().pipe( - proofSchema, - ).array().safeParse(proofs); - - if (!parsed.success) { - continue; - } - - mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data]; - mintsToProofs[mint].toBeRedeemed = [ - ...mintsToProofs[mint].toBeRedeemed, - [ - 'e', // nutzap event that has been redeemed - event.id, - 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) }); - } - } - - return mintsToProofs; -}