refactor: extract repetitive validation and put it into a new function called 'validateAndParseWallet', tests included

This commit is contained in:
P. Reis 2025-02-20 19:12:35 -03:00
parent c530aa310d
commit 71fd6ef965
3 changed files with 223 additions and 100 deletions

View file

@ -2,16 +2,14 @@ import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cash
import { type DittoConf } from '@ditto/conf'; import { type DittoConf } from '@ditto/conf';
import { MiddlewareHandler } from '@hono/hono'; import { MiddlewareHandler } from '@hono/hono';
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { getPublicKey } from 'nostr-tools';
import { NostrEvent, NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify';
import { SetRequired } from 'type-fest'; import { SetRequired } from 'type-fest';
import { stringToBytes } from '@scure/base';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { z } from 'zod'; import { z } from 'zod';
import { isNostrId } from '@/utils.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { createEvent } from '@/utils/api.ts'; import { createEvent } from '@/utils/api.ts';
import { validateAndParseWallet } from '@/utils/cashu.ts';
/** /**
* Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough. * Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough.
@ -38,53 +36,19 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
const { signal } = c.req.raw; const { signal } = c.req.raw;
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
if (wallet) { const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal });
let decryptedContent: string;
try { if (error && error.code === 'wallet-not-found') {
decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content); await next();
} catch (e) { return;
logi({
level: 'error',
ns: 'ditto.api.cashu.wallet.swap',
id: wallet.id,
kind: wallet.kind,
error: errorJson(e),
});
return c.json({ error: 'Could not decrypt wallet content.' }, 400);
} }
let contentTags: string[][]; if (error) {
try { return c.json({ error: error.message }, 400);
contentTags = n.json().pipe(z.string().array().array()).parse(decryptedContent);
} catch {
return c.json({ error: 'Could not parse the decrypted wallet content.' }, 400);
} }
const privkey = contentTags.find(([value]) => value === 'privkey')?.[1]; const { mints, privkey } = data;
if (!privkey || !isNostrId(privkey)) {
return c.json({ error: 'Wallet does not contain privkey or privkey is not a valid nostr id.' }, 400);
}
const p2pk = getPublicKey(stringToBytes('hex', privkey));
const [nutzapInformation] = await store.query([{ authors: [pubkey], kinds: [10019] }], { signal });
if (!nutzapInformation) {
return c.json({ error: 'You need to have a nutzap information event so we can get the mints.' }, 400);
}
const nutzapInformationPubkey = nutzapInformation.tags.find(([name]) => name === 'pubkey')?.[1];
if (!nutzapInformationPubkey || (nutzapInformationPubkey !== p2pk)) {
return c.json({
error:
"You do not have a 'pubkey' tag in your nutzap information event or the one you have does not match the one derivated from the wallet.",
}, 400);
}
const mints = [...new Set(nutzapInformation.tags.filter(([name]) => name === 'mint').map(([_, value]) => value))];
if (mints.length < 1) {
return c.json({ error: 'You do not have any mints in your nutzap information event.' }, 400);
}
const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints }; const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints };
@ -95,7 +59,6 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
const mintsToProofs = await getMintsToProofs(store, nutzapsFilter, conf.relay, { signal }); const mintsToProofs = await getMintsToProofs(store, nutzapsFilter, conf.relay, { signal });
// TODO: throw error if mintsToProofs is an empty object?
for (const mint of Object.keys(mintsToProofs)) { for (const mint of Object.keys(mintsToProofs)) {
try { try {
const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs }); const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs });
@ -128,13 +91,12 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
['e', unspentProofs.id, conf.relay, 'created'], ['e', unspentProofs.id, conf.relay, 'created'],
]), ]),
), ),
tags: mintsToProofs[mint].redeemed, tags: mintsToProofs[mint].toBeRedeemed,
}, c); }, c);
} catch (e) { } catch (e) {
logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) });
} }
} }
}
await next(); await next();
}; };
@ -156,6 +118,12 @@ async function getLastRedeemedNutzap(
} }
} }
/**
* toBeRedeemed are the nutzaps that will be redeemed and saved in the kind 7376 - https://github.com/nostr-protocol/nips/blob/master/60.md#spending-history-event
* The tags format is: [ [ "e", "<event-id-of-created-token>", "", "redeemed" ] ]
*/
type MintsToProofs = { [key: string]: { proofs: Proof[]; toBeRedeemed: string[][] } };
/** /**
* Gets proofs from nutzaps that have not been redeemed yet. * Gets proofs from nutzaps that have not been redeemed yet.
* Each proof is associated with a specific mint. * Each proof is associated with a specific mint.
@ -178,8 +146,8 @@ async function getMintsToProofs(
nutzapsFilter: NostrFilter, nutzapsFilter: NostrFilter,
relay: string, relay: string,
opts?: { signal?: AbortSignal }, opts?: { signal?: AbortSignal },
): Promise<{ [key: string]: { proofs: Proof[]; redeemed: string[][] } }> { ): Promise<MintsToProofs> {
const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; const mintsToProofs: MintsToProofs = {};
const nutzaps = await store.query([nutzapsFilter], { signal: opts?.signal }); const nutzaps = await store.query([nutzapsFilter], { signal: opts?.signal });
@ -196,7 +164,7 @@ async function getMintsToProofs(
} }
if (!mintsToProofs[mint]) { if (!mintsToProofs[mint]) {
mintsToProofs[mint] = { proofs: [], redeemed: [] }; mintsToProofs[mint] = { proofs: [], toBeRedeemed: [] };
} }
const parsed = n.json().pipe( const parsed = n.json().pipe(
@ -215,8 +183,8 @@ async function getMintsToProofs(
} }
mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data]; mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data];
mintsToProofs[mint].redeemed = [ mintsToProofs[mint].toBeRedeemed = [
...mintsToProofs[mint].redeemed, ...mintsToProofs[mint].toBeRedeemed,
[ [
'e', // nutzap event that has been redeemed 'e', // nutzap event that has been redeemed
event.id, event.id,

View file

@ -0,0 +1,53 @@
import { NSecSigner } from '@nostrify/nostrify';
import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { bytesToString, stringToBytes } from '@scure/base';
import { assertEquals } from '@std/assert';
import { createTestDB, genEvent } from '@/test.ts';
import { validateAndParseWallet } from '@/utils/cashu.ts';
Deno.test('validateAndParseWallet function returns valid data', async () => {
await using db = await createTestDB({ pure: true });
const store = db.store;
const sk = generateSecretKey();
const signer = new NSecSigner(sk);
const pubkey = await signer.getPublicKey();
const privkey = bytesToString('hex', sk);
const p2pk = getPublicKey(stringToBytes('hex', privkey));
// Wallet
const wallet = genEvent({
kind: 17375,
content: await signer.nip44.encrypt(
pubkey,
JSON.stringify([
['privkey', privkey],
['mint', 'https://mint.soul.com'],
]),
),
}, sk);
await db.store.event(wallet);
// Nutzap information
const nutzapInfo = genEvent({
kind: 10019,
tags: [
['pubkey', p2pk],
['mint', 'https://mint.soul.com'],
],
}, sk);
await db.store.event(nutzapInfo);
const { data, error } = await validateAndParseWallet(store, signer, pubkey);
assertEquals(error, null);
assertEquals(data, {
wallet,
nutzapInfo,
privkey,
p2pk,
mints: ['https://mint.soul.com'],
});
});

View file

@ -0,0 +1,102 @@
import { SetRequired } from 'type-fest';
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 { z } from 'zod';
import { errorJson } from '@/utils/log.ts';
import { isNostrId } from '@/utils.ts';
type Data = {
wallet: NostrEvent;
nutzapInfo: NostrEvent;
privkey: string;
p2pk: string;
mints: string[];
};
type CustomError =
| { message: 'Wallet not found'; code: 'wallet-not-found' }
| { message: 'Could not decrypt wallet content'; code: 'fail-decrypt-wallet' }
| { message: 'Could not parse wallet content'; code: 'fail-parse-wallet' }
| { message: 'Wallet does not contain privkey or privkey is not a valid nostr id'; code: 'privkey-missing' }
| { message: 'Nutzap information event not found'; code: 'nutzap-info-not-found' }
| {
message:
"You do not have a 'pubkey' tag in your nutzap information event or the one you have does not match the one derivated from the wallet.";
code: 'pubkey-mismatch';
}
| { message: 'You do not have any mints in your nutzap information event.'; code: 'mints-missing' };
/** Ensures that the wallet event and nutzap information event are correct. */
async function validateAndParseWallet(
store: NStore,
signer: SetRequired<NostrSigner, 'nip44'>,
pubkey: string,
opts?: { signal?: AbortSignal },
): Promise<{ data: Data; error: null } | { data: null; error: CustomError }> {
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal: opts?.signal });
if (!wallet) {
return { error: { message: 'Wallet not found', code: 'wallet-not-found' }, data: null };
}
let decryptedContent: string;
try {
decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content);
} catch (e) {
logi({
level: 'error',
ns: 'ditto.api.cashu.wallet',
id: wallet.id,
kind: wallet.kind,
error: errorJson(e),
});
return { data: null, error: { message: 'Could not decrypt wallet content', code: 'fail-decrypt-wallet' } };
}
let contentTags: string[][];
try {
contentTags = n.json().pipe(z.string().array().array()).parse(decryptedContent);
} catch {
return { data: null, error: { message: 'Could not parse wallet content', code: 'fail-parse-wallet' } };
}
const privkey = contentTags.find(([value]) => value === 'privkey')?.[1];
if (!privkey || !isNostrId(privkey)) {
return {
data: null,
error: { message: 'Wallet does not contain privkey or privkey is not a valid nostr id', code: 'privkey-missing' },
};
}
const p2pk = getPublicKey(stringToBytes('hex', privkey));
const [nutzapInfo] = await store.query([{ authors: [pubkey], kinds: [10019] }], { signal: opts?.signal });
if (!nutzapInfo) {
return { data: null, error: { message: 'Nutzap information event not found', code: 'nutzap-info-not-found' } };
}
const nutzapInformationPubkey = nutzapInfo.tags.find(([name]) => name === 'pubkey')?.[1];
if (!nutzapInformationPubkey || (nutzapInformationPubkey !== p2pk)) {
return {
data: null,
error: {
message:
"You do not have a 'pubkey' tag in your nutzap information event or the one you have does not match the one derivated from the wallet.",
code: 'pubkey-mismatch',
},
};
}
const mints = [...new Set(nutzapInfo.tags.filter(([name]) => name === 'mint').map(([_, value]) => value))];
if (mints.length < 1) {
return {
data: null,
error: { message: 'You do not have any mints in your nutzap information event.', code: 'mints-missing' },
};
}
return { data: { wallet, nutzapInfo, privkey, p2pk, mints }, error: null };
}
export { validateAndParseWallet };