Merge branch 'staging' into 'main'

testing

See merge request soapbox-pub/ditto!718
This commit is contained in:
P. Reis 2025-03-12 17:08:43 -03:00
commit 722522206b
10 changed files with 1032 additions and 174 deletions

View file

@ -13,7 +13,8 @@
"./packages/ratelimiter", "./packages/ratelimiter",
"./packages/transcode", "./packages/transcode",
"./packages/translators", "./packages/translators",
"./packages/uploaders" "./packages/uploaders",
"./packages/cashu"
], ],
"tasks": { "tasks": {
"start": "deno run -A --env-file --deny-read=.env packages/ditto/server.ts", "start": "deno run -A --env-file --deny-read=.env packages/ditto/server.ts",

View file

@ -0,0 +1,345 @@
import { type NostrFilter, 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 { DittoPolyPg, TestDB } from '@ditto/db';
import { DittoConf } from '@ditto/conf';
import { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts';
Deno.test('validateAndParseWallet function returns valid data', 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 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 store.event(wallet);
// Nutzap information
const nutzapInfo = genEvent({
kind: 10019,
tags: [
['pubkey', p2pk],
['mint', 'https://mint.soul.com'],
],
}, sk);
await 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'],
});
});
Deno.test('organizeProofs 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: 7375,
content: await signer.nip44.encrypt(
pubkey,
JSON.stringify({
mint: 'https://mint.soul.com',
proofs: [
{
id: '005c2502034d4f12',
amount: 25,
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
},
{
id: '005c2502034d4f12',
amount: 25,
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
},
{
id: '005c2502034d4f12',
amount: 25,
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
},
{
id: '005c2502034d4f12',
amount: 25,
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
},
],
del: [],
}),
),
}, sk);
await store.event(event1);
const proof1 = {
'id': '004f7adf2a04356c',
'amount': 1,
'secret': '6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0',
'C': '03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3',
'dleq': {
'e': 'bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351',
's': 'a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67',
'r': 'b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df',
},
};
const token1 = JSON.stringify({
mint: 'https://mint-fashion.com',
proofs: [proof1],
del: [],
});
const event2 = genEvent({
kind: 7375,
content: await signer.nip44.encrypt(
pubkey,
token1,
),
}, sk);
await store.event(event2);
const proof2 = {
'id': '004f7adf2a04356c',
'amount': 123,
'secret': '6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0',
'C': '03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3',
'dleq': {
'e': 'bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351',
's': 'a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67',
'r': 'b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df',
},
};
const token2 = JSON.stringify({
mint: 'https://mint-fashion.com',
proofs: [proof2],
del: [],
});
const event3 = genEvent({
kind: 7375,
content: await signer.nip44.encrypt(
pubkey,
token2,
),
}, sk);
await store.event(event3);
const unspentProofs = await store.query([{ kinds: [7375], authors: [pubkey] }]);
const organizedProofs = await organizeProofs(unspentProofs, signer);
assertEquals(organizedProofs, {
'https://mint.soul.com': {
totalBalance: 100,
[event1.id]: { event: event1, balance: 100 },
},
'https://mint-fashion.com': {
totalBalance: 124,
[event2.id]: { event: event2, balance: 1 },
[event3.id]: { event: event3, balance: 123 },
},
});
});
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: '<nip-44-encrypted>',
created_at: Math.floor(Date.now() / 1000), // now
tags: [
['e', '<event-id-of-created-token>', '', 'redeemed'],
],
}, sk);
await store.event(event1);
const event2 = genEvent({
kind: 7376,
content: '<nip-44-encrypted>',
created_at: Math.floor((Date.now() - 86400000) / 1000), // yesterday
tags: [
['e', '<event-id-of-created-token>', '', 'redeemed'],
],
}, sk);
await store.event(event2);
const event3 = genEvent({
kind: 7376,
content: '<nip-44-encrypted>',
created_at: Math.floor((Date.now() - 86400000) / 1000), // yesterday
tags: [
['e', '<event-id-of-created-token>', '', 'redeemed'],
],
}, sk);
await store.event(event3);
const event4 = genEvent({
kind: 7376,
content: '<nip-44-encrypted>',
created_at: Math.floor((Date.now() + 86400000) / 1000), // tomorrow
tags: [
['e', '<event-id-of-created-token>', '', 'redeemed'],
],
}, sk);
await store.event(event4);
const event = await getLastRedeemedNutzap(store, pubkey);
assertEquals(event, event4);
});
Deno.test('getMintsToProofs 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 redeemedNutzap = genEvent({
created_at: Math.floor(Date.now() / 1000), // now
kind: 9321,
content: 'Thanks buddy! Nice idea.',
tags: [
[
'proof',
JSON.stringify({
id: '005c2502034d4f12',
amount: 25,
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
}),
],
['u', 'https://mint.soul.com'],
['e', 'nutzapped-post'],
['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'],
],
}, sk);
await store.event(redeemedNutzap);
await new Promise((r) => setTimeout(r, 1000));
const history = genEvent({
created_at: Math.floor(Date.now() / 1000), // now
kind: 7376,
content: 'nip-44-encrypted',
tags: [
['e', redeemedNutzap.id, conf.relay, 'redeemed'],
['p', redeemedNutzap.pubkey],
],
}, sk);
await store.event(history);
const nutzap = genEvent({
created_at: Math.floor(Date.now() / 1000), // now
kind: 9321,
content: 'Thanks buddy! Nice idea.',
tags: [
[
'proof',
JSON.stringify({
id: '005c2502034d4f12',
amount: 50,
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
}),
],
['u', 'https://mint.soul.com'],
['e', 'nutzapped-post'],
['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'],
],
}, sk);
await store.event(nutzap);
const nutzapsFilter: NostrFilter = {
kinds: [9321],
'#p': ['47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'],
'#u': ['https://mint.soul.com'],
};
const lastRedeemedNutzap = await getLastRedeemedNutzap(store, pubkey);
if (lastRedeemedNutzap) {
nutzapsFilter.since = lastRedeemedNutzap.created_at;
}
const mintsToProofs = await getMintsToProofs(store, nutzapsFilter, conf.relay);
assertEquals(mintsToProofs, {
'https://mint.soul.com': {
proofs: [{
id: '005c2502034d4f12',
amount: 50,
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
}],
toBeRedeemed: [
['e', nutzap.id, conf.relay, 'redeemed'],
['p', nutzap.pubkey],
],
},
});
});

251
packages/cashu/cashu.ts Normal file
View file

@ -0,0 +1,251 @@
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 { stringToBytes } from '@scure/base';
import { logi } from '@soapbox/logi';
import type { SetRequired } from 'type-fest';
import { z } from 'zod';
import { proofSchema, tokenEventSchema } from './schemas.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 };
}
type OrganizedProofs = {
[mintUrl: string]: {
/** Total balance in this mint */
totalBalance: number;
/** Event id */
[eventId: string]: {
event: NostrEvent;
/** Total balance in this event */
balance: number;
} | number;
};
};
async function organizeProofs(
events: NostrEvent[],
signer: SetRequired<NostrSigner, 'nip44'>,
): Promise<OrganizedProofs> {
const organizedProofs: OrganizedProofs = {};
const pubkey = await signer.getPublicKey();
for (const event of events) {
const decryptedContent = await signer.nip44.decrypt(pubkey, event.content);
const { data: token, success } = n.json().pipe(tokenEventSchema).safeParse(decryptedContent);
if (!success) {
continue;
}
const { mint, proofs } = token;
const balance = proofs.reduce((prev, current) => prev + current.amount, 0);
if (!organizedProofs[mint]) {
organizedProofs[mint] = { totalBalance: 0 };
}
organizedProofs[mint] = { ...organizedProofs[mint], [event.id]: { event, balance } };
organizedProofs[mint].totalBalance += balance;
}
return organizedProofs;
}
/** Returns a spending history event that contains the last redeemed nutzap. */
async function getLastRedeemedNutzap(
store: NStore,
pubkey: string,
opts?: { signal?: AbortSignal },
): Promise<NostrEvent | undefined> {
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>", "<relay-hint>", "redeemed" ], // nutzap event that has been redeemed
* [ "p", "<sender-pubkey>" ] // 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>", "<relay-hint>", "redeemed" ], // nutzap event that has been redeemed
* [ "p", "<sender-pubkey>" ] // pubkey of the author of the 9321 event (nutzap sender)
* ]
* ```
*/
async function getMintsToProofs(
store: NStore,
nutzapsFilter: NostrFilter,
relay: string,
opts?: { signal?: AbortSignal },
): Promise<MintsToProofs> {
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 };

7
packages/cashu/deno.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "@ditto/cashu",
"version": "0.1.0",
"exports": {
".": "./mod.ts"
}
}

2
packages/cashu/mod.ts Normal file
View file

@ -0,0 +1,2 @@
export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts';
export { proofSchema, tokenEventSchema } from './schemas.ts';

View file

@ -0,0 +1,39 @@
import { NSchema as n } from '@nostrify/nostrify';
import { assertEquals } from '@std/assert';
import { proofSchema } from './schemas.ts';
import { tokenEventSchema } from './schemas.ts';
Deno.test('Parse proof', () => {
const proof =
'{"id":"004f7adf2a04356c","amount":1,"secret":"6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0","C":"03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3","dleq":{"e":"bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351","s":"a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67","r":"b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df"}}';
assertEquals(n.json().pipe(proofSchema).safeParse(proof).success, true);
assertEquals(n.json().pipe(proofSchema).safeParse(JSON.parse(proof)).success, false);
assertEquals(proofSchema.safeParse(JSON.parse(proof)).success, true);
assertEquals(proofSchema.safeParse(proof).success, false);
});
Deno.test('Parse token', () => {
const proof = {
'id': '004f7adf2a04356c',
'amount': 1,
'secret': '6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0',
'C': '03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3',
'dleq': {
'e': 'bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351',
's': 'a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67',
'r': 'b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df',
},
};
const token = JSON.stringify({
mint: 'https://mint-fashion.com',
proofs: [proof],
del: [],
});
assertEquals(n.json().pipe(tokenEventSchema).safeParse(token).success, true);
assertEquals(n.json().pipe(tokenEventSchema).safeParse(JSON.parse(token)).success, false);
assertEquals(tokenEventSchema.safeParse(JSON.parse(token)).success, true);
assertEquals(tokenEventSchema.safeParse(tokenEventSchema).success, false);
});

28
packages/cashu/schemas.ts Normal file
View file

@ -0,0 +1,28 @@
import { z } from 'zod';
export const proofSchema: z.ZodType<{
id: string;
amount: number;
secret: string;
C: string;
dleq?: { s: string; e: string; r?: string };
dleqValid?: boolean;
}> = z.object({
id: z.string(),
amount: z.number(),
secret: z.string(),
C: z.string(),
dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }).optional(),
dleqValid: z.boolean().optional(),
});
/** Decrypted content of a kind 7375 */
export const tokenEventSchema: z.ZodType<{
mint: string;
proofs: Array<z.infer<typeof proofSchema>>;
del?: string[];
}> = z.object({
mint: z.string().url(),
proofs: proofSchema.array(),
del: z.string().array().optional(),
});

View file

@ -8,9 +8,8 @@ import { stub } from '@std/testing/mock';
import { assertEquals, assertExists, assertObjectMatch } from '@std/assert'; import { assertEquals, assertExists, assertObjectMatch } from '@std/assert';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import cashuRoute from '@/controllers/api/cashu.ts';
import { createTestDB } from '@/test.ts'; import { createTestDB } from '@/test.ts';
import cashuRoute from './cashu.ts';
import { walletSchema } from '@/schema.ts'; import { walletSchema } from '@/schema.ts';
Deno.test('PUT /wallet must be successful', async () => { Deno.test('PUT /wallet must be successful', async () => {

View file

@ -1,16 +1,21 @@
import { Proof } from '@cashu/cashu-ts'; import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts';
import { organizeProofs, tokenEventSchema, validateAndParseWallet } from '@ditto/cashu';
import { userMiddleware } from '@ditto/mastoapi/middleware'; import { userMiddleware } from '@ditto/mastoapi/middleware';
import { DittoRoute } from '@ditto/mastoapi/router'; import { DittoRoute } from '@ditto/mastoapi/router';
import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { bytesToString, stringToBytes } from '@scure/base'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { bytesToString } from '@scure/base';
import { logi } from '@soapbox/logi';
import { z } from 'zod'; import { z } from 'zod';
import { createEvent, parseBody } from '@/utils/api.ts'; import { createEvent, parseBody } from '@/utils/api.ts';
import { walletSchema } from '@/schema.ts';
import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts';
import { isNostrId } from '@/utils.ts'; import { walletSchema } from '@/schema.ts';
import { logi } from '@soapbox/logi'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { nostrNow } from '@/utils.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { getAmount } from '@/utils/bolt11.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
type Wallet = z.infer<typeof walletSchema>; type Wallet = z.infer<typeof walletSchema>;
@ -31,7 +36,133 @@ interface Nutzap {
recipient_pubkey: string; recipient_pubkey: string;
} }
const createCashuWalletAndNutzapInfoSchema = z.object({ const createMintQuoteSchema = z.object({
mint: z.string().url(),
amount: z.number().int(),
});
/**
* Creates a new mint quote in a specific mint.
* https://github.com/cashubtc/nuts/blob/main/04.md#mint-quote
*/
route.post('/quote', userMiddleware({ enc: 'nip44' }), async (c) => {
const { user } = c.var;
const pubkey = await user.signer.getPublicKey();
const body = await parseBody(c.req.raw);
const result = createMintQuoteSchema.safeParse(body);
if (!result.success) {
return c.json({ error: 'Bad schema', schema: result.error }, 400);
}
const { mint: mintUrl, amount } = result.data;
try {
const mint = new CashuMint(mintUrl);
const wallet = new CashuWallet(mint);
await wallet.loadMint();
const mintQuote = await wallet.createMintQuote(amount);
await createEvent({
kind: 7374,
content: await user.signer.nip44.encrypt(pubkey, mintQuote.quote),
tags: [
['expiration', String(mintQuote.expiry)],
['mint', mintUrl],
],
}, c);
return c.json(mintQuote, 200);
} catch (e) {
logi({ level: 'error', ns: 'ditto.api.cashu.quote', error: errorJson(e) });
return c.json({ error: 'Could not create mint quote' }, 500);
}
});
/**
* Checks if the quote has been paid, if it has then mint new tokens.
* https://github.com/cashubtc/nuts/blob/main/04.md#minting-tokens
*/
route.post('/mint/:quote_id', userMiddleware({ enc: 'nip44' }), async (c) => {
const { conf, user, relay, signal } = c.var;
const pubkey = await user.signer.getPublicKey();
const quote_id = c.req.param('quote_id');
const expiredQuoteIds: string[] = [];
const deleteExpiredQuotes = async (ids: string[]) => {
await createEvent({
kind: 5,
tags: ids.map((id) => ['e', id, conf.relay]),
}, c);
};
const events = await relay.query([{ kinds: [7374], authors: [pubkey] }], { signal });
for (const event of events) {
const decryptedQuoteId = await user.signer.nip44.decrypt(pubkey, event.content);
const mintUrl = event.tags.find(([name]) => name === 'mint')?.[1];
const expiration = Number(event.tags.find(([name]) => name === 'expiration')?.[1]);
const now = nostrNow();
try {
if (mintUrl && (expiration > now) && (quote_id === decryptedQuoteId)) { // TODO: organize order of operations of deleting expired quote ids
const mint = new CashuMint(mintUrl);
const wallet = new CashuWallet(mint);
await wallet.loadMint();
const mintQuote = await wallet.checkMintQuote(quote_id);
const amount = Number(getAmount(mintQuote.request)) / 1000;
if ((mintQuote.state === MintQuoteState.PAID) && amount) {
const proofs = await wallet.mintProofs(amount, mintQuote.quote);
const unspentProofs = await createEvent({
kind: 7375,
content: await user.signer.nip44.encrypt(
pubkey,
JSON.stringify({
mint: mintUrl,
proofs,
}),
),
}, c);
await createEvent({
kind: 7376,
content: await user.signer.nip44.encrypt(
pubkey,
JSON.stringify([
['direction', 'in'],
['amount', amount],
['e', unspentProofs.id, conf.relay, 'created'],
]),
),
}, c);
expiredQuoteIds.push(event.id);
await deleteExpiredQuotes(expiredQuoteIds);
return c.json({ success: 'Minting successful!', state: MintQuoteState.ISSUED }, 200);
} else {
await deleteExpiredQuotes(expiredQuoteIds);
return c.json(mintQuote, 200);
}
}
} catch (e) {
logi({ level: 'error', ns: 'ditto.api.cashu.mint', error: errorJson(e) });
return c.json({ error: 'Server error' }, 500);
}
expiredQuoteIds.push(event.id);
}
await deleteExpiredQuotes(expiredQuoteIds);
return c.json({ error: 'Quote not found' }, 404);
});
const createWalletSchema = z.object({
mints: z.array(z.string().url()).nonempty().transform((val) => { mints: z.array(z.string().url()).nonempty().transform((val) => {
return [...new Set(val)]; return [...new Set(val)];
}), }),
@ -47,7 +178,7 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => {
const pubkey = await user.signer.getPublicKey(); const pubkey = await user.signer.getPublicKey();
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = createCashuWalletAndNutzapInfoSchema.safeParse(body); const result = createWalletSchema.safeParse(body);
if (!result.success) { if (!result.success) {
return c.json({ error: 'Bad schema', schema: result.error }, 400); return c.json({ error: 'Bad schema', schema: result.error }, 400);
@ -64,7 +195,7 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => {
const sk = generateSecretKey(); const sk = generateSecretKey();
const privkey = bytesToString('hex', sk); const privkey = bytesToString('hex', sk);
const p2pk = getPublicKey(stringToBytes('hex', privkey)); const p2pk = getPublicKey(sk);
walletContentTags.push(['privkey', privkey]); walletContentTags.push(['privkey', privkey]);
@ -107,22 +238,14 @@ route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, as
const pubkey = await user.signer.getPublicKey(); const pubkey = await user.signer.getPublicKey();
const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal }); const { data, error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal });
if (!event) { if (error) {
return c.json({ error: 'Wallet not found' }, 404); return c.json({ error: error.message }, 404);
} }
const decryptedContent: string[][] = JSON.parse(await user.signer.nip44.decrypt(pubkey, event.content)); const { p2pk, mints } = data;
const privkey = decryptedContent.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.' }, 422);
}
const p2pk = getPublicKey(stringToBytes('hex', privkey));
let balance = 0; let balance = 0;
const mints: string[] = [];
const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal }); const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal });
for (const token of tokens) { for (const token of tokens) {
@ -139,7 +262,7 @@ route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, as
return accumulator + current.amount; return accumulator + current.amount;
}, 0); }, 0);
} catch (e) { } catch (e) {
logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', requestId, error: errorJson(e) }); logi({ level: 'error', ns: 'ditto.api.cashu.wallet', requestId, error: errorJson(e) });
} }
} }
@ -164,4 +287,156 @@ route.get('/mints', (c) => {
return c.json({ mints }, 200); return c.json({ mints }, 200);
}); });
const nutzapSchema = z.object({
account_id: n.id(),
status_id: n.id().optional(),
amount: z.number().int().positive(),
comment: z.string().optional(),
});
/** Nutzaps a post or a user. */
route.post('/nutzap', userMiddleware({ enc: 'nip44' }), async (c) => {
const { conf, relay, user, signal } = c.var;
const pubkey = await user.signer.getPublicKey();
const body = await parseBody(c.req.raw);
const result = nutzapSchema.safeParse(body);
if (!result.success) {
return c.json({ error: 'Bad schema', schema: result.error }, 400);
}
const { account_id, status_id, amount, comment } = result.data;
const filter = status_id ? [{ kinds: [1], ids: [status_id] }] : [{ kinds: [0], authors: [account_id] }];
const [event] = await relay.query(filter, { signal });
if (!event) {
return c.json({ error: status_id ? 'Status not found' : 'Account not found' }, 404);
}
if (status_id) {
await hydrateEvents({ ...c.var, events: [event] });
}
if (event.kind === 1 && ((event as DittoEvent)?.author?.pubkey !== account_id)) {
return c.json({ error: 'Post author does not match author' }, 422);
}
const [nutzapInfo] = await relay.query([{ kinds: [10019], authors: [account_id] }], { signal });
if (!nutzapInfo) {
return c.json({ error: 'Target user does not have a nutzap information event' }, 404);
}
const recipientMints = nutzapInfo.tags.filter(([name]) => name === 'mint').map((tag) => tag[1]).filter(Boolean);
if (recipientMints.length < 1) {
return c.json({ error: 'Target user does not have any mints setup' }, 422);
}
const p2pk = nutzapInfo.tags.find(([name]) => name === 'pubkey')?.[1];
if (!p2pk) {
return c.json({ error: 'Target user does not have a cashu pubkey' }, 422);
}
const unspentProofs = await relay.query([{ kinds: [7375], authors: [pubkey] }], { signal });
const organizedProofs = await organizeProofs(unspentProofs, user.signer);
const proofsToBeUsed: Proof[] = [];
const eventsToBeDeleted: NostrEvent[] = [];
let selectedMint: string | undefined;
for (const mint of recipientMints) {
if (organizedProofs[mint]?.totalBalance >= amount) {
selectedMint = mint;
let minimumRequiredBalance = 0;
for (const key of Object.keys(organizedProofs[mint])) {
if (key === 'totalBalance' || typeof organizedProofs[mint][key] === 'number') {
continue;
}
if (minimumRequiredBalance >= amount) {
break;
}
const event = organizedProofs[mint][key].event;
const decryptedContent = await user.signer.nip44.decrypt(pubkey, event.content);
const { data: token, success } = n.json().pipe(tokenEventSchema).safeParse(decryptedContent);
if (!success) {
continue; // TODO: maybe abort everything
}
const { proofs } = token;
proofsToBeUsed.push(...proofs);
eventsToBeDeleted.push(event);
minimumRequiredBalance += organizedProofs[mint][key].balance;
}
break;
}
}
if (!selectedMint) {
return c.json({ error: 'You do not have mints in common with enough balance' }, 422);
}
const mint = new CashuMint(selectedMint);
const wallet = new CashuWallet(mint);
await wallet.loadMint();
const { keep: proofsToKeep, send: proofsToSend } = await wallet.send(amount, proofsToBeUsed, {
includeFees: true,
pubkey: p2pk.length === 64 ? '02' + p2pk : p2pk,
});
const newUnspentProof = await createEvent({
kind: 7375,
content: await user.signer.nip44.encrypt(
pubkey,
JSON.stringify({
mint: selectedMint,
proofs: proofsToKeep,
del: eventsToBeDeleted.map((e) => e.id),
}),
),
}, c);
await createEvent({
kind: 7376,
content: await user.signer.nip44.encrypt(
pubkey,
JSON.stringify([
['direction', 'out'],
['amount', String(proofsToSend.reduce((accumulator, current) => accumulator + current.amount, 0))],
...eventsToBeDeleted.map((e) => ['e', e.id, conf.relay, 'destroyed']),
['e', newUnspentProof.id, conf.relay, 'created'],
]),
),
}, c);
await createEvent({
kind: 5,
tags: eventsToBeDeleted.map((e) => ['e', e.id, conf.relay]),
}, c);
const nutzapTags: string[][] = [
...proofsToSend.map((proof) => ['proof', JSON.stringify(proof)]),
['u', selectedMint],
['p', account_id], // recipient of nutzap
];
if (status_id) {
nutzapTags.push(['e', status_id, conf.relay]);
}
// nutzap
await createEvent({
kind: 9321,
content: comment ?? '',
tags: nutzapTags,
}, c);
return c.json({ message: 'Nutzap with success!!!' }, 200); // TODO: return wallet entity
});
export default route; export default route;

View file

@ -1,22 +1,18 @@
import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; import { CashuMint, CashuWallet, getEncodedToken } from '@cashu/cashu-ts';
import { MiddlewareHandler } from '@hono/hono'; import { getLastRedeemedNutzap, getMintsToProofs, validateAndParseWallet } from '@ditto/cashu';
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { getPublicKey } from 'nostr-tools'; import { NostrFilter } from '@nostrify/nostrify';
import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { stringToBytes } from '@scure/base';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { AppEnv } from '@/app.ts';
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 { z } from 'zod'; import { MiddlewareHandler } from '@hono/hono/types';
/** /**
* 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.
* Errors are only thrown if 'signer' and 'store' middlewares are not set. * Errors are only thrown if 'signer' and 'store' middlewares are not set.
*/ */
export const swapNutzapsMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => { export const swapNutzapsMiddleware: MiddlewareHandler = async (c, next) => {
const { conf, relay, user, signal } = c.var; const { conf, relay, user, signal } = c.var;
if (!user) { if (!user) {
@ -32,150 +28,65 @@ export const swapNutzapsMiddleware: MiddlewareHandler<AppEnv> = async (c, next)
} }
const pubkey = await user.signer.getPublicKey(); const pubkey = await user.signer.getPublicKey();
const [wallet] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal });
if (wallet) { const { data, error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal });
let decryptedContent: string;
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(relay, pubkey, { signal });
if (lastRedeemedNutzap) {
nutzapsFilter.since = lastRedeemedNutzap.created_at;
}
const mintsToProofs = await getMintsToProofs(relay, nutzapsFilter, conf.relay, { signal });
for (const mint of Object.keys(mintsToProofs)) {
try { try {
decryptedContent = await user.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 user.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 user.signer.nip44.encrypt(
pubkey,
JSON.stringify([
['direction', 'in'],
['amount', amount],
['e', unspentProofs.id, conf.relay, 'created'],
]),
),
tags: mintsToProofs[mint].toBeRedeemed,
}, c);
} catch (e) { } catch (e) {
logi({ logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) });
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 = JSON.parse(decryptedContent);
} catch {
return c.json({ error: 'Could not JSON 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 relay.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 [nutzapHistory] = await relay.query([{ kinds: [7376], authors: [pubkey] }], { signal });
if (nutzapHistory) {
nutzapsFilter.since = nutzapHistory.created_at;
}
const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {};
const nutzaps = await relay.query([nutzapsFilter], { signal });
for (const event of nutzaps) {
try {
const mint = event.tags.find(([name]) => name === 'u')?.[1];
if (!mint) {
continue;
}
const proof = event.tags.find(([name]) => name === 'proof')?.[1];
if (!proof) {
continue;
}
if (!mintsToProofs[mint]) {
mintsToProofs[mint] = { proofs: [], redeemed: [] };
}
const parsed = n.json().pipe(
z.object({
id: z.string(),
amount: z.number(),
secret: z.string(),
C: z.string(),
dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }).optional(),
dleqValid: z.boolean().optional(),
}).array(),
).safeParse(proof);
if (!parsed.success) {
continue;
}
mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data];
mintsToProofs[mint].redeemed = [
...mintsToProofs[mint].redeemed,
[
'e', // nutzap event that has been redeemed
event.id,
conf.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) });
}
}
// 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 user.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 user.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) });
}
} }
} }