mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
Merge branch 'mint-cashu' into 'main'
Allow users to mint for new tokens (cashu) See merge request soapbox-pub/ditto!666
This commit is contained in:
commit
f7c8024c2f
24 changed files with 2638 additions and 250 deletions
|
|
@ -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",
|
||||
|
|
|
|||
457
packages/cashu/cashu.test.ts
Normal file
457
packages/cashu/cashu.test.ts
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
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, getWallet, 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'],
|
||||
['relay', conf.relay],
|
||||
],
|
||||
}, 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'],
|
||||
relays: [conf.relay],
|
||||
});
|
||||
});
|
||||
|
||||
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],
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('getWallet 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 sk = generateSecretKey();
|
||||
const signer = new NSecSigner(sk);
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const privkey = bytesToString('hex', sk);
|
||||
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||
|
||||
const relay = new NPostgres(orig.kysely);
|
||||
|
||||
const proofs = genEvent({
|
||||
kind: 7375,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify({
|
||||
mint: 'https://cuiaba.mint.com',
|
||||
proofs: [
|
||||
{
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 2,
|
||||
'secret': '700312ccba84cb15d6a008c1d01b0dbf00025d3f2cb01f030a756553aca52de3',
|
||||
'C': '02f0ff21fdd19a547d66d9ca09df5573ad88d28e4951825130708ba53cbed19561',
|
||||
'dleq': {
|
||||
'e': '9c44a58cb429be619c474b97216009bd96ff1b7dd145b35828a14f180c03a86f',
|
||||
's': 'a11b8f616dfee5157a2c7c36da0ee181fe71b28729bee56b789e472c027ceb3b',
|
||||
'r': 'c51b9ade8cfd3939b78d509c9723f86b43b432680f55a6791e3e252b53d4b465',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 4,
|
||||
'secret': '5936f22d486734c03bd50b89aaa34be8e99f20d199bcebc09da8716890e95fb3',
|
||||
'C': '039b55f92c02243e31b04e964f2ad0bcd2ed3229e334f4c7a81037392b8411d6e7',
|
||||
'dleq': {
|
||||
'e': '7b7be700f2515f1978ca27bc1045d50b9d146bb30d1fe0c0f48827c086412b9e',
|
||||
's': 'cf44b08c7e64fd2bd9199667327b10a29b7c699b10cb7437be518203b25fe3fa',
|
||||
'r': 'ec0cf54ce2d17fae5db1c6e5e5fd5f34d7c7df18798b8d92bcb7cb005ec2f93b',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 16,
|
||||
'secret': '89e2315c058f3a010972dc6d546b1a2e81142614d715c28d169c6afdba5326bd',
|
||||
'C': '02bc1c3756e77563fe6c7769fc9d9bc578ea0b84bf4bf045cf31c7e2d3f3ad0818',
|
||||
'dleq': {
|
||||
'e': '8dfa000c9e2a43d35d2a0b1c7f36a96904aed35457ca308c6e7d10f334f84e72',
|
||||
's': '9270a914b1a53e32682b1277f34c5cfa931a6fab701a5dbee5855b68ddf621ab',
|
||||
'r': 'ae71e572839a3273b0141ea2f626915592b4b3f5f91b37bbeacce0d3396332c9',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 16,
|
||||
'secret': '06f2209f313d92505ae5c72087263f711b7a97b1b29a71886870e672a1b180ac',
|
||||
'C': '02fa2ad933b62449e2765255d39593c48293f10b287cf7036b23570c8f01c27fae',
|
||||
'dleq': {
|
||||
'e': 'e696d61f6259ae97f8fe13a5af55d47f526eea62a7998bf888626fd1ae35e720',
|
||||
's': 'b9f1ef2a8aec0e73c1a4aaff67e28b3ca3bc4628a532113e0733643c697ed7ce',
|
||||
'r': 'b66ed62852811d14e9bf822baebfda92ba47c5c4babc4f2499d9ce81fbbbd3f2',
|
||||
},
|
||||
},
|
||||
],
|
||||
del: [],
|
||||
}),
|
||||
),
|
||||
created_at: Math.floor(Date.now() / 1000), // now
|
||||
}, sk);
|
||||
|
||||
await relay.event(proofs);
|
||||
|
||||
await relay.event(genEvent({
|
||||
kind: 10019,
|
||||
tags: [
|
||||
['pubkey', p2pk],
|
||||
['mint', 'https://mint.soul.com'],
|
||||
['mint', 'https://cuiaba.mint.com'],
|
||||
['relay', conf.relay],
|
||||
],
|
||||
}, sk));
|
||||
|
||||
const wallet = genEvent({
|
||||
kind: 17375,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['privkey', privkey],
|
||||
['mint', 'https://mint.soul.com'],
|
||||
]),
|
||||
),
|
||||
}, sk);
|
||||
|
||||
await relay.event(wallet);
|
||||
|
||||
const { wallet: walletEntity } = await getWallet(relay, pubkey, signer);
|
||||
|
||||
assertEquals(walletEntity, {
|
||||
balance: 38,
|
||||
mints: ['https://mint.soul.com', 'https://cuiaba.mint.com'],
|
||||
relays: [conf.relay],
|
||||
pubkey_p2pk: p2pk,
|
||||
});
|
||||
});
|
||||
302
packages/cashu/cashu.ts
Normal file
302
packages/cashu/cashu.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
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, type Wallet } from './schemas.ts';
|
||||
|
||||
type Data = {
|
||||
wallet: NostrEvent;
|
||||
nutzapInfo: NostrEvent;
|
||||
privkey: string;
|
||||
p2pk: string;
|
||||
mints: string[];
|
||||
relays: 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' },
|
||||
};
|
||||
}
|
||||
|
||||
const relays = [...new Set(nutzapInfo.tags.filter(([name]) => name === 'relay').map(([_, value]) => value))];
|
||||
|
||||
return { data: { wallet, nutzapInfo, privkey, p2pk, mints, relays }, 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;
|
||||
}
|
||||
|
||||
/** Returns a wallet entity with the latest balance. */
|
||||
async function getWallet(
|
||||
store: NStore,
|
||||
pubkey: string,
|
||||
signer: SetRequired<NostrSigner, 'nip44'>,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<{ wallet: Wallet; error: null } | { wallet: null; error: CustomError }> {
|
||||
const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal: opts?.signal });
|
||||
|
||||
if (error) {
|
||||
logi({ level: 'error', ns: 'ditto.cashu.get_wallet', error: errorJson(error) });
|
||||
return { wallet: null, error };
|
||||
}
|
||||
|
||||
const { p2pk, mints, relays } = data;
|
||||
|
||||
let balance = 0;
|
||||
|
||||
const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal: opts?.signal });
|
||||
for (const token of tokens) {
|
||||
try {
|
||||
const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse(
|
||||
await signer.nip44.decrypt(pubkey, token.content),
|
||||
);
|
||||
|
||||
if (!mints.includes(decryptedContent.mint)) {
|
||||
mints.push(decryptedContent.mint);
|
||||
}
|
||||
|
||||
balance += decryptedContent.proofs.reduce((accumulator, current) => {
|
||||
return accumulator + current.amount;
|
||||
}, 0);
|
||||
} catch (e) {
|
||||
logi({ level: 'error', ns: 'dtto.cashu.get_wallet', error: errorJson(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint
|
||||
const walletEntity: Wallet = {
|
||||
pubkey_p2pk: p2pk,
|
||||
mints,
|
||||
relays,
|
||||
balance,
|
||||
};
|
||||
|
||||
return { wallet: walletEntity, error: null };
|
||||
}
|
||||
|
||||
/** 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, getWallet, organizeProofs, validateAndParseWallet };
|
||||
7
packages/cashu/deno.json
Normal file
7
packages/cashu/deno.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@ditto/cashu",
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
}
|
||||
}
|
||||
3
packages/cashu/mod.ts
Normal file
3
packages/cashu/mod.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts';
|
||||
export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts';
|
||||
export { renderTransaction, type Transaction } from './views.ts';
|
||||
39
packages/cashu/schemas.test.ts
Normal file
39
packages/cashu/schemas.test.ts
Normal 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);
|
||||
});
|
||||
50
packages/cashu/schemas.ts
Normal file
50
packages/cashu/schemas.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { NSchema as n } from '@nostrify/nostrify';
|
||||
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(),
|
||||
});
|
||||
|
||||
/** Ditto Cashu wallet */
|
||||
export const walletSchema: z.ZodType<{
|
||||
pubkey_p2pk: string;
|
||||
mints: string[];
|
||||
relays: string[];
|
||||
balance: number;
|
||||
}> = z.object({
|
||||
pubkey_p2pk: n.id(),
|
||||
mints: z.array(z.string().url()).nonempty().transform((val) => {
|
||||
return [...new Set(val)];
|
||||
}),
|
||||
relays: z.array(z.string()).nonempty().transform((val) => {
|
||||
return [...new Set(val)];
|
||||
}),
|
||||
/** Unit in sats */
|
||||
balance: z.number(),
|
||||
});
|
||||
|
||||
export type Wallet = z.infer<typeof walletSchema>;
|
||||
85
packages/cashu/views.test.ts
Normal file
85
packages/cashu/views.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { NSecSigner } from '@nostrify/nostrify';
|
||||
import { NPostgres } from '@nostrify/db';
|
||||
import { genEvent } from '@nostrify/nostrify/test';
|
||||
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { DittoPolyPg, TestDB } from '@ditto/db';
|
||||
import { DittoConf } from '@ditto/conf';
|
||||
import { renderTransaction } from './views.ts';
|
||||
|
||||
Deno.test('renderTransaction 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 sk = generateSecretKey();
|
||||
const signer = new NSecSigner(sk);
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const relay = new NPostgres(orig.kysely);
|
||||
|
||||
const history1 = genEvent({
|
||||
kind: 7376,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['direction', 'in'],
|
||||
['amount', '33'],
|
||||
]),
|
||||
),
|
||||
created_at: Math.floor(Date.now() / 1000), // now
|
||||
}, sk);
|
||||
await relay.event(history1);
|
||||
|
||||
const history2 = genEvent({
|
||||
kind: 7376,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['direction', 'out'],
|
||||
['amount', '29'],
|
||||
]),
|
||||
),
|
||||
created_at: Math.floor(Date.now() / 1000) - 1, // now - 1 second
|
||||
}, sk);
|
||||
await relay.event(history2);
|
||||
|
||||
const history3 = genEvent({
|
||||
kind: 7376,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['direction', 'ouch'],
|
||||
['amount', 'yolo'],
|
||||
]),
|
||||
),
|
||||
created_at: Math.floor(Date.now() / 1000) - 2, // now - 2 second
|
||||
}, sk);
|
||||
await relay.event(history3);
|
||||
|
||||
const events = await relay.query([{ kinds: [7376], authors: [pubkey], since: history2.created_at }]);
|
||||
|
||||
const transactions = await Promise.all(
|
||||
events.map((event) => {
|
||||
return renderTransaction(event, pubkey, signer);
|
||||
}),
|
||||
);
|
||||
|
||||
assertEquals(transactions, [
|
||||
{
|
||||
direction: 'in',
|
||||
amount: 33,
|
||||
created_at: history1.created_at,
|
||||
},
|
||||
{
|
||||
direction: 'out',
|
||||
amount: 29,
|
||||
created_at: history2.created_at,
|
||||
},
|
||||
]);
|
||||
});
|
||||
44
packages/cashu/views.ts
Normal file
44
packages/cashu/views.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { type NostrEvent, type NostrSigner, NSchema as n } from '@nostrify/nostrify';
|
||||
import type { SetRequired } from 'type-fest';
|
||||
import { z } from 'zod';
|
||||
|
||||
type Transaction = {
|
||||
amount: number;
|
||||
created_at: number;
|
||||
direction: 'in' | 'out';
|
||||
};
|
||||
|
||||
/** Renders one history of transaction. */
|
||||
async function renderTransaction(
|
||||
event: NostrEvent,
|
||||
viewerPubkey: string,
|
||||
signer: SetRequired<NostrSigner, 'nip44'>,
|
||||
): Promise<Transaction | undefined> {
|
||||
if (event.kind !== 7376) return;
|
||||
|
||||
const { data: contentTags, success } = n.json().pipe(z.coerce.string().array().min(2).array()).safeParse(
|
||||
await signer.nip44.decrypt(viewerPubkey, event.content),
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = contentTags.find(([name]) => name === 'direction')?.[1];
|
||||
if (direction !== 'out' && direction !== 'in') {
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = parseInt(contentTags.find(([name]) => name === 'amount')?.[1] ?? '', 10);
|
||||
if (isNaN(amount)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
created_at: event.created_at,
|
||||
direction,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
export { renderTransaction, type Transaction };
|
||||
|
|
@ -36,6 +36,7 @@ interface EventStatsRow {
|
|||
quotes_count: number;
|
||||
reactions: string;
|
||||
zaps_amount: number;
|
||||
zaps_amount_cashu: number;
|
||||
link_preview?: MastodonPreviewCard;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import type { Kysely } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('event_stats')
|
||||
.addColumn('zaps_amount_cashu', 'integer', (col) => col.notNull().defaultTo(0))
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema.alterTable('event_stats').dropColumn('zaps_amount_cashu').execute();
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,40 +1,162 @@
|
|||
import { Proof } from '@cashu/cashu-ts';
|
||||
import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts';
|
||||
import { getWallet, organizeProofs, proofSchema, renderTransaction, tokenEventSchema, type Wallet } from '@ditto/cashu';
|
||||
import { userMiddleware } from '@ditto/mastoapi/middleware';
|
||||
import { paginated, paginationSchema } from '@ditto/mastoapi/pagination';
|
||||
import { DittoRoute } from '@ditto/mastoapi/router';
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||
import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
||||
import { bytesToString, stringToBytes } from '@scure/base';
|
||||
import { logi } from '@soapbox/logi';
|
||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createEvent, parseBody } from '@/utils/api.ts';
|
||||
import { walletSchema } from '@/schema.ts';
|
||||
import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts';
|
||||
import { isNostrId } from '@/utils.ts';
|
||||
import { logi } from '@soapbox/logi';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
|
||||
type Wallet = z.infer<typeof walletSchema>;
|
||||
import { getAmount } from '@/utils/bolt11.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
|
||||
const route = new DittoRoute();
|
||||
|
||||
// app.delete('/wallet') -> 204
|
||||
const createMintQuoteSchema = z.object({
|
||||
mint: z.string().url(),
|
||||
amount: z.number().int(),
|
||||
});
|
||||
|
||||
// app.post(swapMiddleware, '/nutzap');
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/* GET /api/v1/ditto/cashu/wallet -> Wallet, 404 */
|
||||
/* PUT /api/v1/ditto/cashu/wallet -> Wallet */
|
||||
/* DELETE /api/v1/ditto/cashu/wallet -> 204 */
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Bad schema', schema: result.error }, 400);
|
||||
}
|
||||
|
||||
interface Nutzap {
|
||||
amount: number;
|
||||
event_id?: string;
|
||||
mint: string; // mint the nutzap was created
|
||||
recipient_pubkey: string;
|
||||
}
|
||||
const { mint: mintUrl, amount } = result.data;
|
||||
|
||||
const createCashuWalletAndNutzapInfoSchema = z.object({
|
||||
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[]) => {
|
||||
if (ids.length === 0) return;
|
||||
|
||||
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 && (quote_id === decryptedQuoteId)) {
|
||||
if (expiration <= now) {
|
||||
expiredQuoteIds.push(event.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
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', String(amount)],
|
||||
['e', unspentProofs.id, conf.relay, 'created'],
|
||||
]),
|
||||
),
|
||||
}, c);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await deleteExpiredQuotes(expiredQuoteIds);
|
||||
|
||||
return c.json({ error: 'Quote not found' }, 404);
|
||||
});
|
||||
|
||||
const createWalletSchema = z.object({
|
||||
mints: z.array(z.string().url()).nonempty().transform((val) => {
|
||||
return [...new Set(val)];
|
||||
}),
|
||||
relays: z.array(z.string().url()).transform((val) => {
|
||||
return [...new Set(val)];
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -43,27 +165,37 @@ const createCashuWalletAndNutzapInfoSchema = z.object({
|
|||
* https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event
|
||||
*/
|
||||
route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => {
|
||||
const { conf, user, relay, signal } = c.var;
|
||||
const { user, relay, signal, conf } = c.var;
|
||||
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
const body = await parseBody(c.req.raw);
|
||||
const result = createCashuWalletAndNutzapInfoSchema.safeParse(body);
|
||||
const result = createWalletSchema.safeParse(body);
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Bad schema', schema: result.error }, 400);
|
||||
}
|
||||
|
||||
const { mints } = result.data;
|
||||
const { mints, relays } = result.data;
|
||||
let previousPrivkey: string | undefined;
|
||||
|
||||
const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||
if (event) {
|
||||
return c.json({ error: 'You already have a wallet 😏' }, 400);
|
||||
const walletContentSchema = z.string().array().min(2).array();
|
||||
|
||||
const { data: walletContent, success, error } = n.json().pipe(walletContentSchema).safeParse(
|
||||
await user.signer.nip44.decrypt(pubkey, event.content),
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return c.json({ error: 'Your wallet is in an invalid format', schema: error }, 400);
|
||||
}
|
||||
|
||||
previousPrivkey = walletContent.find(([name]) => name === 'privkey')?.[1];
|
||||
}
|
||||
|
||||
const walletContentTags: string[][] = [];
|
||||
|
||||
const sk = generateSecretKey();
|
||||
const privkey = bytesToString('hex', sk);
|
||||
const privkey = previousPrivkey ?? bytesToString('hex', generateSecretKey());
|
||||
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||
|
||||
walletContentTags.push(['privkey', privkey]);
|
||||
|
|
@ -72,6 +204,10 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => {
|
|||
walletContentTags.push(['mint', mint]);
|
||||
}
|
||||
|
||||
if (relays.length < 1) {
|
||||
relays.push(conf.relay);
|
||||
}
|
||||
|
||||
const encryptedWalletContentTags = await user.signer.nip44.encrypt(pubkey, JSON.stringify(walletContentTags));
|
||||
|
||||
// Wallet
|
||||
|
|
@ -85,7 +221,7 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => {
|
|||
kind: 10019,
|
||||
tags: [
|
||||
...mints.map((mint) => ['mint', mint, 'sat']),
|
||||
['relay', conf.relay], // TODO: add more relays once things get more stable
|
||||
...relays.map((relay) => ['relay', relay]),
|
||||
['pubkey', p2pk],
|
||||
],
|
||||
}, c);
|
||||
|
|
@ -94,7 +230,7 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => {
|
|||
const walletEntity: Wallet = {
|
||||
pubkey_p2pk: p2pk,
|
||||
mints,
|
||||
relays: [conf.relay],
|
||||
relays,
|
||||
balance: 0, // Newly created wallet, balance is zero.
|
||||
};
|
||||
|
||||
|
|
@ -103,55 +239,83 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => {
|
|||
|
||||
/** Gets a wallet, if it exists. */
|
||||
route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, async (c) => {
|
||||
const { conf, relay, user, signal, requestId } = c.var;
|
||||
const { relay, user, signal } = c.var;
|
||||
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
|
||||
const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||
if (!event) {
|
||||
return c.json({ error: 'Wallet not found' }, 404);
|
||||
const { wallet, error } = await getWallet(relay, pubkey, user.signer, { signal });
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 404);
|
||||
}
|
||||
|
||||
const decryptedContent: string[][] = JSON.parse(await user.signer.nip44.decrypt(pubkey, event.content));
|
||||
return c.json(wallet, 200);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
/** Gets a history of transactions. */
|
||||
route.get('/transactions', userMiddleware({ enc: 'nip44' }), async (c) => {
|
||||
const { relay, user, signal } = c.var;
|
||||
const { limit, since, until } = paginationSchema().parse(c.req.query());
|
||||
|
||||
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
|
||||
let balance = 0;
|
||||
const mints: string[] = [];
|
||||
const events = await relay.query([{ kinds: [7376], authors: [pubkey], since, until, limit }], {
|
||||
signal,
|
||||
});
|
||||
|
||||
const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal });
|
||||
for (const token of tokens) {
|
||||
try {
|
||||
const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse(
|
||||
await user.signer.nip44.decrypt(pubkey, token.content),
|
||||
const transactions = await Promise.all(
|
||||
events.map((event) => {
|
||||
return renderTransaction(event, pubkey, user.signer);
|
||||
}),
|
||||
);
|
||||
|
||||
if (!mints.includes(decryptedContent.mint)) {
|
||||
mints.push(decryptedContent.mint);
|
||||
if (!transactions.length) {
|
||||
return c.json([], 200);
|
||||
}
|
||||
|
||||
balance += decryptedContent.proofs.reduce((accumulator, current) => {
|
||||
return accumulator + current.amount;
|
||||
}, 0);
|
||||
} catch (e) {
|
||||
logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', requestId, error: errorJson(e) });
|
||||
}
|
||||
return paginated(c, events, transactions);
|
||||
});
|
||||
|
||||
/** Gets the nutzaps that a post received. */
|
||||
route.get('statuses/:id{[0-9a-f]{64}}/nutzapped_by', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { relay, signal } = c.var;
|
||||
const { limit, since, until } = paginationSchema().parse(c.req.query());
|
||||
|
||||
const events = await relay.query([{ kinds: [9321], '#e': [id], since, until, limit }], {
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!events.length) {
|
||||
return c.json([], 200);
|
||||
}
|
||||
|
||||
// TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint
|
||||
const walletEntity: Wallet = {
|
||||
pubkey_p2pk: p2pk,
|
||||
mints,
|
||||
relays: [conf.relay],
|
||||
balance,
|
||||
await hydrateEvents({ ...c.var, events });
|
||||
|
||||
const results = (await Promise.all(
|
||||
events.map((event: DittoEvent) => {
|
||||
const proofs = (event.tags.filter(([name]) => name === 'proof').map(([_, proof]) => {
|
||||
const { success, data } = n.json().pipe(proofSchema).safeParse(proof);
|
||||
if (!success) return;
|
||||
|
||||
return data;
|
||||
})
|
||||
.filter(Boolean)) as Proof[];
|
||||
|
||||
const amount = proofs.reduce((prev, current) => prev + current.amount, 0);
|
||||
const comment = event.content;
|
||||
|
||||
const account = event?.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey);
|
||||
|
||||
return {
|
||||
comment,
|
||||
amount,
|
||||
account,
|
||||
};
|
||||
}),
|
||||
)).filter(Boolean);
|
||||
|
||||
return c.json(walletEntity, 200);
|
||||
return paginated(c, events, results);
|
||||
});
|
||||
|
||||
/** Get mints set by the CASHU_MINTS environment variable. */
|
||||
|
|
@ -164,4 +328,167 @@ route.get('/mints', (c) => {
|
|||
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' }), swapNutzapsMiddleware, 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 });
|
||||
let organizedProofs;
|
||||
try {
|
||||
organizedProofs = await organizeProofs(unspentProofs, user.signer);
|
||||
} catch (e) {
|
||||
logi({ level: 'error', ns: 'ditto.api.cashu.nutzap', error: errorJson(e) });
|
||||
return c.json({ error: 'Failed to organize proofs' }, 500);
|
||||
}
|
||||
|
||||
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 historyTags: string[][] = [
|
||||
['direction', 'out'],
|
||||
['amount', String(proofsToSend.reduce((accumulator, current) => accumulator + current.amount, 0))],
|
||||
...eventsToBeDeleted.map((e) => ['e', e.id, conf.relay, 'destroyed']),
|
||||
];
|
||||
|
||||
if (proofsToKeep.length) {
|
||||
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);
|
||||
|
||||
historyTags.push(['e', newUnspentProof.id, conf.relay, 'created']);
|
||||
}
|
||||
|
||||
await createEvent({
|
||||
kind: 7376,
|
||||
content: await user.signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify(historyTags),
|
||||
),
|
||||
}, 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;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface EventStats {
|
|||
quotes_count: number;
|
||||
reactions: Record<string, number>;
|
||||
zaps_amount: number;
|
||||
zaps_amount_cashu: number;
|
||||
link_preview?: MastodonPreviewCard;
|
||||
}
|
||||
|
||||
|
|
@ -56,5 +57,7 @@ export interface DittoEvent extends NostrEvent {
|
|||
zap_message?: string;
|
||||
/** Language of the event (kind 1s are more accurate). */
|
||||
language?: LanguageCode;
|
||||
/** Whether or not pubkey accepts cashu. */
|
||||
accepts_zaps_cashu?: boolean;
|
||||
client?: DittoEvent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,18 @@
|
|||
import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts';
|
||||
import { MiddlewareHandler } from '@hono/hono';
|
||||
import { CashuMint, CashuWallet, getEncodedToken } from '@cashu/cashu-ts';
|
||||
import { getLastRedeemedNutzap, getMintsToProofs, validateAndParseWallet } from '@ditto/cashu';
|
||||
import { HTTPException } from '@hono/hono/http-exception';
|
||||
import { getPublicKey } from 'nostr-tools';
|
||||
import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||
import { stringToBytes } from '@scure/base';
|
||||
import { NostrFilter } from '@nostrify/nostrify';
|
||||
import { logi } from '@soapbox/logi';
|
||||
|
||||
import { AppEnv } from '@/app.ts';
|
||||
import { isNostrId } from '@/utils.ts';
|
||||
import { errorJson } from '@/utils/log.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.
|
||||
* 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;
|
||||
|
||||
if (!user) {
|
||||
|
|
@ -32,113 +28,29 @@ export const swapNutzapsMiddleware: MiddlewareHandler<AppEnv> = async (c, next)
|
|||
}
|
||||
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
const [wallet] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||
|
||||
if (wallet) {
|
||||
let decryptedContent: string;
|
||||
try {
|
||||
decryptedContent = await user.signer.nip44.decrypt(pubkey, wallet.content);
|
||||
} 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);
|
||||
const { data, error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal });
|
||||
|
||||
if (error && error.code === 'wallet-not-found') {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
let contentTags: string[][];
|
||||
try {
|
||||
contentTags = JSON.parse(decryptedContent);
|
||||
} catch {
|
||||
return c.json({ error: 'Could not JSON parse the decrypted wallet content.' }, 400);
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 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 { mints, privkey } = data;
|
||||
|
||||
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 lastRedeemedNutzap = await getLastRedeemedNutzap(relay, pubkey, { signal });
|
||||
if (lastRedeemedNutzap) {
|
||||
nutzapsFilter.since = lastRedeemedNutzap.created_at;
|
||||
}
|
||||
|
||||
const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {};
|
||||
const mintsToProofs = await getMintsToProofs(relay, nutzapsFilter, conf.relay, { signal });
|
||||
|
||||
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 });
|
||||
|
|
@ -167,17 +79,16 @@ export const swapNutzapsMiddleware: MiddlewareHandler<AppEnv> = async (c, next)
|
|||
pubkey,
|
||||
JSON.stringify([
|
||||
['direction', 'in'],
|
||||
['amount', amount],
|
||||
['amount', String(amount)],
|
||||
['e', unspentProofs.id, conf.relay, 'created'],
|
||||
]),
|
||||
),
|
||||
tags: mintsToProofs[mint].redeemed,
|
||||
tags: mintsToProofs[mint].toBeRedeemed,
|
||||
}, c);
|
||||
} catch (e) {
|
||||
logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import ISO6391, { LanguageCode } from 'iso-639-1';
|
||||
import { NSchema as n } from '@nostrify/nostrify';
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Validates individual items in an array, dropping any that aren't valid. */
|
||||
|
|
@ -59,19 +58,6 @@ const sizesSchema = z.string().refine((value) =>
|
|||
value.split(' ').every((v) => /^[1-9]\d{0,3}[xX][1-9]\d{0,3}$/.test(v))
|
||||
);
|
||||
|
||||
/** Ditto Cashu wallet */
|
||||
const walletSchema = z.object({
|
||||
pubkey_p2pk: n.id(),
|
||||
mints: z.array(z.string().url()).nonempty().transform((val) => {
|
||||
return [...new Set(val)];
|
||||
}),
|
||||
relays: z.array(z.string()).nonempty().transform((val) => {
|
||||
return [...new Set(val)];
|
||||
}),
|
||||
/** Unit in sats */
|
||||
balance: z.number(),
|
||||
});
|
||||
|
||||
export {
|
||||
booleanParamSchema,
|
||||
fileSchema,
|
||||
|
|
@ -82,5 +68,4 @@ export {
|
|||
percentageSchema,
|
||||
safeUrlSchema,
|
||||
sizesSchema,
|
||||
walletSchema,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -448,6 +448,7 @@ export class DittoRelayStore implements NRelay {
|
|||
quotes_count: 0,
|
||||
reactions: '{}',
|
||||
zaps_amount: 0,
|
||||
zaps_amount_cashu: 0,
|
||||
link_preview: linkPreview,
|
||||
})
|
||||
.onConflict((oc) => oc.column('event_id').doUpdateSet({ link_preview: linkPreview }))
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
|
|||
cache.push(event);
|
||||
}
|
||||
|
||||
for (const event of await gatherAcceptCashu({ ...opts, events: cache })) {
|
||||
cache.push(event);
|
||||
}
|
||||
|
||||
for (const event of await gatherClients({ ...opts, events: cache })) {
|
||||
cache.push(event);
|
||||
}
|
||||
|
|
@ -208,6 +212,10 @@ export function assembleEvents(
|
|||
event.zap_message = zapRequest?.content ?? '';
|
||||
}
|
||||
|
||||
event.accepts_zaps_cashu = b.find((e) => matchFilter({ kinds: [10019], authors: [event.pubkey] }, e))
|
||||
? true
|
||||
: false;
|
||||
|
||||
event.author_stats = authorStats[event.pubkey];
|
||||
event.event_stats = eventStats[event.id];
|
||||
}
|
||||
|
|
@ -367,6 +375,24 @@ async function gatherInfo({ conf, events, relay, signal }: HydrateOpts): Promise
|
|||
);
|
||||
}
|
||||
|
||||
/** Collect nutzap informational events. */
|
||||
function gatherAcceptCashu({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||
const pubkeys = new Set<string>();
|
||||
|
||||
for (const event of events) {
|
||||
pubkeys.add(event.pubkey);
|
||||
}
|
||||
|
||||
if (!pubkeys.size) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return relay.query(
|
||||
[{ kinds: [10019], authors: [...pubkeys], limit: pubkeys.size }],
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
function gatherClients({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||
const filters: NostrFilter[] = [];
|
||||
|
||||
|
|
@ -447,6 +473,7 @@ async function gatherEventStats(
|
|||
quotes_count: Math.max(0, row.quotes_count),
|
||||
reactions: row.reactions,
|
||||
zaps_amount: Math.max(0, row.zaps_amount),
|
||||
zaps_amount_cashu: Math.max(0, row.zaps_amount_cashu),
|
||||
link_preview: row.link_preview,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,6 +161,48 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => {
|
|||
assertEquals(stats!.reactions_count, 3);
|
||||
});
|
||||
|
||||
Deno.test('updateStats with kind 9321 increments zaps_amount_cashu count', async () => {
|
||||
await using test = await setupTest();
|
||||
const { kysely, relay } = test;
|
||||
|
||||
const note = genEvent({ kind: 1 });
|
||||
await relay.event(note);
|
||||
|
||||
await updateStats({
|
||||
...test,
|
||||
event: genEvent({
|
||||
kind: 9321,
|
||||
content: 'Do you love me?',
|
||||
tags: [
|
||||
['e', note.id],
|
||||
[
|
||||
'proof',
|
||||
'{"id":"004f7adf2a04356c","amount":29,"secret":"6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0","C":"03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3","dleq":{"e":"bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351","s":"a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67","r":"b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df"}}',
|
||||
],
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await updateStats({
|
||||
...test,
|
||||
event: genEvent({
|
||||
kind: 9321,
|
||||
content: 'Ultimatum',
|
||||
tags: [
|
||||
['e', note.id],
|
||||
[
|
||||
'proof',
|
||||
'{"id":"004f7adf2a04356c","amount":100,"secret":"6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0","C":"03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3","dleq":{"e":"bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351","s":"a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67","r":"b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df"}}',
|
||||
],
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const stats = await getEventStats(kysely, note.id);
|
||||
|
||||
assertEquals(stats!.zaps_amount_cashu, 129);
|
||||
});
|
||||
|
||||
Deno.test('updateStats with kind 5 decrements reactions count', async () => {
|
||||
await using test = await setupTest();
|
||||
const { kysely, relay } = test;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { type Proof } from '@cashu/cashu-ts';
|
||||
import { proofSchema } from '@ditto/cashu';
|
||||
import { DittoTables } from '@ditto/db';
|
||||
import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
|
||||
import { Insertable, Kysely, UpdateObject } from 'kysely';
|
||||
|
|
@ -38,6 +40,8 @@ export async function updateStats(opts: UpdateStatsOpts): Promise<void> {
|
|||
return handleEvent7(opts);
|
||||
case 9735:
|
||||
return handleEvent9735(opts);
|
||||
case 9321:
|
||||
return handleEvent9321(opts);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +236,32 @@ async function handleEvent9735(opts: UpdateStatsOpts): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
/** Update stats for kind 9321 event. */
|
||||
async function handleEvent9321(opts: UpdateStatsOpts): Promise<void> {
|
||||
const { kysely, event } = opts;
|
||||
|
||||
// https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-event
|
||||
// It's possible to nutzap a profile without nutzapping a post, but we don't care about this case
|
||||
const id = event.tags.find(([name]) => name === 'e')?.[1];
|
||||
if (!id) return;
|
||||
|
||||
const proofs = (event.tags.filter(([name]) => name === 'proof').map(([_, proof]) => {
|
||||
const { success, data } = n.json().pipe(proofSchema).safeParse(proof);
|
||||
if (!success) return;
|
||||
|
||||
return data;
|
||||
})
|
||||
.filter(Boolean)) as Proof[];
|
||||
|
||||
const amount = proofs.reduce((prev, current) => prev + current.amount, 0);
|
||||
|
||||
await updateEventStats(
|
||||
kysely,
|
||||
id,
|
||||
({ zaps_amount_cashu }) => ({ zaps_amount_cashu: Math.max(0, zaps_amount_cashu + amount) }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the pubkeys that were added and removed from a follow event. */
|
||||
export function getFollowDiff(
|
||||
tags: string[][],
|
||||
|
|
@ -318,6 +348,7 @@ export async function updateEventStats(
|
|||
reactions_count: 0,
|
||||
quotes_count: 0,
|
||||
zaps_amount: 0,
|
||||
zaps_amount_cashu: 0,
|
||||
reactions: '{}',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ function renderAccount(event: Omit<DittoEvent, 'id' | 'sig'>, opts: ToAccountOpt
|
|||
username: parsed05?.nickname || npub.substring(0, 8),
|
||||
ditto: {
|
||||
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
|
||||
accepts_zaps_cashu: Boolean(event?.accepts_zaps_cashu),
|
||||
external_url: Conf.external(nprofile),
|
||||
streak: {
|
||||
days: streakDays,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ async function renderStatus(
|
|||
? await store.query([
|
||||
{ kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [9321], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
|
|
@ -80,6 +81,7 @@ async function renderStatus(
|
|||
const pinEvent = relatedEvents.find((event) => event.kind === 10001);
|
||||
const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003);
|
||||
const zapEvent = relatedEvents.find((event) => event.kind === 9734);
|
||||
const nutzapEvent = relatedEvents.find((event) => event.kind === 9321);
|
||||
|
||||
const compatMentions = buildInlineRecipients(mentions.filter((m) => {
|
||||
if (m.id === account.id) return false;
|
||||
|
|
@ -160,6 +162,7 @@ async function renderStatus(
|
|||
reblogs_count: event.event_stats?.reposts_count ?? 0,
|
||||
favourites_count: event.event_stats?.reactions['+'] ?? 0,
|
||||
zaps_amount: event.event_stats?.zaps_amount ?? 0,
|
||||
zaps_amount_cashu: event.event_stats?.zaps_amount_cashu ?? 0,
|
||||
favourited: reactionEvent?.content === '+',
|
||||
reblogged: Boolean(repostEvent),
|
||||
muted: false,
|
||||
|
|
@ -178,6 +181,7 @@ async function renderStatus(
|
|||
uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`),
|
||||
url: Conf.local(`/@${account.acct}/${event.id}`),
|
||||
zapped: Boolean(zapEvent),
|
||||
zapped_cashu: Boolean(nutzapEvent),
|
||||
ditto: {
|
||||
external_url: Conf.external(nevent),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export interface MastodonAccount {
|
|||
username: string;
|
||||
ditto: {
|
||||
accepts_zaps: boolean;
|
||||
accepts_zaps_cashu: boolean;
|
||||
external_url: string;
|
||||
streak: {
|
||||
days: number;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface MastodonStatus {
|
|||
reblogs_count: number;
|
||||
favourites_count: number;
|
||||
zaps_amount: number;
|
||||
zaps_amount_cashu: number;
|
||||
favourited: boolean;
|
||||
reblogged: boolean;
|
||||
muted: boolean;
|
||||
|
|
@ -38,6 +39,7 @@ export interface MastodonStatus {
|
|||
uri: string;
|
||||
url: string;
|
||||
zapped: boolean;
|
||||
zapped_cashu: boolean;
|
||||
pleroma: {
|
||||
emoji_reactions: { name: string; count: number; me: boolean }[];
|
||||
expires_at?: string;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue