mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
refactor: extract repetitive validation and put it into a new function called 'validateAndParseWallet', tests included
This commit is contained in:
parent
c530aa310d
commit
71fd6ef965
3 changed files with 223 additions and 100 deletions
|
|
@ -2,16 +2,14 @@ import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cash
|
|||
import { type DittoConf } from '@ditto/conf';
|
||||
import { MiddlewareHandler } from '@hono/hono';
|
||||
import { HTTPException } from '@hono/hono/http-exception';
|
||||
import { getPublicKey } from 'nostr-tools';
|
||||
import { NostrEvent, NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify';
|
||||
import { SetRequired } from 'type-fest';
|
||||
import { stringToBytes } from '@scure/base';
|
||||
import { logi } from '@soapbox/logi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isNostrId } from '@/utils.ts';
|
||||
import { errorJson } from '@/utils/log.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.
|
||||
|
|
@ -38,101 +36,65 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
|
|||
|
||||
const { signal } = c.req.raw;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||
|
||||
if (wallet) {
|
||||
let decryptedContent: string;
|
||||
const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal });
|
||||
|
||||
if (error && error.code === 'wallet-not-found') {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
|
||||
const { mints, privkey } = data;
|
||||
|
||||
const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints };
|
||||
|
||||
const lastRedeemedNutzap = await getLastRedeemedNutzap(store, pubkey, { signal });
|
||||
if (lastRedeemedNutzap) {
|
||||
nutzapsFilter.since = lastRedeemedNutzap.created_at;
|
||||
}
|
||||
|
||||
const mintsToProofs = await getMintsToProofs(store, nutzapsFilter, conf.relay, { signal });
|
||||
|
||||
for (const mint of Object.keys(mintsToProofs)) {
|
||||
try {
|
||||
decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content);
|
||||
const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs });
|
||||
|
||||
const cashuWallet = new CashuWallet(new CashuMint(mint));
|
||||
const receiveProofs = await cashuWallet.receive(token, { privkey });
|
||||
|
||||
const unspentProofs = await createEvent({
|
||||
kind: 7375,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify({
|
||||
mint,
|
||||
proofs: receiveProofs,
|
||||
}),
|
||||
),
|
||||
}, c);
|
||||
|
||||
const amount = receiveProofs.reduce((accumulator, current) => {
|
||||
return accumulator + current.amount;
|
||||
}, 0);
|
||||
|
||||
await createEvent({
|
||||
kind: 7376,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['direction', 'in'],
|
||||
['amount', amount],
|
||||
['e', unspentProofs.id, conf.relay, 'created'],
|
||||
]),
|
||||
),
|
||||
tags: mintsToProofs[mint].toBeRedeemed,
|
||||
}, c);
|
||||
} catch (e) {
|
||||
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[][];
|
||||
try {
|
||||
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];
|
||||
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 lastRedeemedNutzap = await getLastRedeemedNutzap(store, pubkey, { signal });
|
||||
if (lastRedeemedNutzap) {
|
||||
nutzapsFilter.since = lastRedeemedNutzap.created_at;
|
||||
}
|
||||
|
||||
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)) {
|
||||
try {
|
||||
const token = getEncodedToken({ mint, proofs: mintsToProofs[mint].proofs });
|
||||
|
||||
const cashuWallet = new CashuWallet(new CashuMint(mint));
|
||||
const receiveProofs = await cashuWallet.receive(token, { privkey });
|
||||
|
||||
const unspentProofs = await createEvent({
|
||||
kind: 7375,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify({
|
||||
mint,
|
||||
proofs: receiveProofs,
|
||||
}),
|
||||
),
|
||||
}, c);
|
||||
|
||||
const amount = receiveProofs.reduce((accumulator, current) => {
|
||||
return accumulator + current.amount;
|
||||
}, 0);
|
||||
|
||||
await createEvent({
|
||||
kind: 7376,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['direction', 'in'],
|
||||
['amount', amount],
|
||||
['e', unspentProofs.id, conf.relay, 'created'],
|
||||
]),
|
||||
),
|
||||
tags: mintsToProofs[mint].redeemed,
|
||||
}, c);
|
||||
} 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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
* Each proof is associated with a specific mint.
|
||||
|
|
@ -178,8 +146,8 @@ async function getMintsToProofs(
|
|||
nutzapsFilter: NostrFilter,
|
||||
relay: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<{ [key: string]: { proofs: Proof[]; redeemed: string[][] } }> {
|
||||
const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {};
|
||||
): Promise<MintsToProofs> {
|
||||
const mintsToProofs: MintsToProofs = {};
|
||||
|
||||
const nutzaps = await store.query([nutzapsFilter], { signal: opts?.signal });
|
||||
|
||||
|
|
@ -196,7 +164,7 @@ async function getMintsToProofs(
|
|||
}
|
||||
|
||||
if (!mintsToProofs[mint]) {
|
||||
mintsToProofs[mint] = { proofs: [], redeemed: [] };
|
||||
mintsToProofs[mint] = { proofs: [], toBeRedeemed: [] };
|
||||
}
|
||||
|
||||
const parsed = n.json().pipe(
|
||||
|
|
@ -215,8 +183,8 @@ async function getMintsToProofs(
|
|||
}
|
||||
|
||||
mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data];
|
||||
mintsToProofs[mint].redeemed = [
|
||||
...mintsToProofs[mint].redeemed,
|
||||
mintsToProofs[mint].toBeRedeemed = [
|
||||
...mintsToProofs[mint].toBeRedeemed,
|
||||
[
|
||||
'e', // nutzap event that has been redeemed
|
||||
event.id,
|
||||
|
|
|
|||
53
packages/ditto/utils/cashu.test.ts
Normal file
53
packages/ditto/utils/cashu.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
102
packages/ditto/utils/cashu.ts
Normal file
102
packages/ditto/utils/cashu.ts
Normal 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 };
|
||||
Loading…
Add table
Reference in a new issue