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 { 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,
|
||||||
|
|
|
||||||
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