mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
262 lines
8.3 KiB
TypeScript
262 lines
8.3 KiB
TypeScript
import { Hono } from '@hono/hono';
|
|
import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts';
|
|
import { NostrFilter } from '@nostrify/nostrify';
|
|
import { logi } from '@soapbox/logi';
|
|
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
|
import { bytesToString, stringToBytes } from '@scure/base';
|
|
import { z } from 'zod';
|
|
|
|
import { Conf } from '@/config.ts';
|
|
import { isNostrId } from '@/utils.ts';
|
|
import { createEvent, parseBody } from '@/utils/api.ts';
|
|
import { errorJson } from '@/utils/log.ts';
|
|
import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
|
|
import { requireNip44Signer } from '@/middleware/requireSigner.ts';
|
|
import { storeMiddleware } from '@/middleware/storeMiddleware.ts';
|
|
import { walletSchema } from '@/schema.ts';
|
|
|
|
type Wallet = z.infer<typeof walletSchema>;
|
|
|
|
const app = new Hono().use('*', storeMiddleware, signerMiddleware);
|
|
|
|
// CASHU_MINTS = ['https://mint.cashu.io/1', 'https://mint.cashu.io/2', 'https://mint.cashu.io/3']
|
|
|
|
// Mint: https://github.com/cashubtc/nuts/blob/main/06.md
|
|
|
|
// app.get('/mints') -> Mint[]
|
|
|
|
// app.get(swapMiddleware, '/wallet') -> Wallet, 404
|
|
// app.put('/wallet') -> Wallet
|
|
// app.delete('/wallet') -> 204
|
|
|
|
// app.post('/swap') Maybe make this a middleware? Also pipeline interaction.
|
|
|
|
// app.post(swapMiddleware, '/nutzap');
|
|
|
|
/* GET /api/v1/ditto/cashu/wallet -> Wallet, 404 */
|
|
/* PUT /api/v1/ditto/cashu/wallet -> Wallet */
|
|
/* DELETE /api/v1/ditto/cashu/wallet -> 204 */
|
|
|
|
interface Nutzap {
|
|
amount: number;
|
|
event_id?: string;
|
|
mint: string; // mint the nutzap was created
|
|
recipient_pubkey: string;
|
|
}
|
|
|
|
const createCashuWalletAndNutzapInfoSchema = z.object({
|
|
mints: z.array(z.string().url()).nonempty().transform((val) => {
|
|
return [...new Set(val)];
|
|
}),
|
|
});
|
|
|
|
/**
|
|
* Creates a replaceable Cashu wallet and a replaceable nutzap information event.
|
|
* https://github.com/nostr-protocol/nips/blob/master/60.md
|
|
* https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event
|
|
*/
|
|
app.put('/wallet', requireNip44Signer, async (c) => {
|
|
const signer = c.get('signer');
|
|
const store = c.get('store');
|
|
const pubkey = await signer.getPublicKey();
|
|
const body = await parseBody(c.req.raw);
|
|
const { signal } = c.req.raw;
|
|
const result = createCashuWalletAndNutzapInfoSchema.safeParse(body);
|
|
|
|
if (!result.success) {
|
|
return c.json({ error: 'Bad schema', schema: result.error }, 400);
|
|
}
|
|
|
|
const { mints } = result.data;
|
|
|
|
const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
|
if (event) {
|
|
return c.json({ error: 'You already have a wallet 😏' }, 400);
|
|
}
|
|
|
|
const walletContentTags: string[][] = [];
|
|
|
|
const sk = generateSecretKey();
|
|
const privkey = bytesToString('hex', sk);
|
|
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
|
|
|
walletContentTags.push(['privkey', privkey]);
|
|
|
|
for (const mint of mints) {
|
|
walletContentTags.push(['mint', mint]);
|
|
}
|
|
|
|
const encryptedWalletContentTags = await signer.nip44.encrypt(pubkey, JSON.stringify(walletContentTags));
|
|
|
|
// Wallet
|
|
await createEvent({
|
|
kind: 17375,
|
|
content: encryptedWalletContentTags,
|
|
}, c);
|
|
|
|
// Nutzap information
|
|
await createEvent({
|
|
kind: 10019,
|
|
tags: [
|
|
...mints.map((mint) => ['mint', mint, 'sat']),
|
|
['relay', Conf.relay], // TODO: add more relays once things get more stable
|
|
['pubkey', p2pk],
|
|
],
|
|
}, c);
|
|
|
|
// TODO: hydrate wallet and add a 'balance' field when a 'renderWallet' view function is created
|
|
const walletEntity: Wallet = {
|
|
pubkey_p2pk: p2pk,
|
|
mints,
|
|
relays: [Conf.relay],
|
|
balance: 0, // Newly created wallet, balance is zero.
|
|
};
|
|
|
|
return c.json(walletEntity, 200);
|
|
});
|
|
|
|
/**
|
|
* Swaps all nutzaps (NIP-61) to the user's wallet (NIP-60)
|
|
*/
|
|
app.post('/swap', async (c) => {
|
|
const signer = c.get('signer')!;
|
|
const store = c.get('store');
|
|
const pubkey = await signer.getPublicKey();
|
|
const { signal } = c.req.raw;
|
|
|
|
const nip44 = signer.nip44;
|
|
if (!nip44) {
|
|
return c.json({ error: 'Signer does not have nip 44.' }, 400);
|
|
}
|
|
|
|
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
|
if (!wallet) {
|
|
return c.json({ error: 'You need to have a wallet to swap the nutzaps into it.' }, 400);
|
|
}
|
|
|
|
let decryptedContent: string;
|
|
try {
|
|
decryptedContent = await 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);
|
|
}
|
|
|
|
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 store.query([{ authors: [pubkey], kinds: [10019] }], { signal });
|
|
if (!nutzapInformation) {
|
|
return c.json({ error: 'You need to have a nutzap information event so we can get the mints.' }, 400);
|
|
}
|
|
|
|
const nutzapInformationPubkey = nutzapInformation.tags.find(([name]) => name === 'pubkey')?.[1];
|
|
if (!nutzapInformationPubkey || (nutzapInformationPubkey !== p2pk)) {
|
|
return c.json({
|
|
error:
|
|
"You do not have a 'pubkey' tag in your nutzap information event or the one you have does not match the one derivated from the wallet.",
|
|
}, 400);
|
|
}
|
|
|
|
const mints = [...new Set(nutzapInformation.tags.filter(([name]) => name === 'mint').map(([_, value]) => value))];
|
|
if (mints.length < 1) {
|
|
return c.json({ error: 'You do not have any mints in your nutzap information event.' }, 400);
|
|
}
|
|
|
|
const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints };
|
|
|
|
const [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal });
|
|
if (nutzapHistory) {
|
|
nutzapsFilter.since = nutzapHistory.created_at;
|
|
}
|
|
|
|
const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {};
|
|
|
|
const nutzaps = await store.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: [] };
|
|
}
|
|
|
|
mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...JSON.parse(proof)];
|
|
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: any) {
|
|
logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: 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 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 nip44.encrypt(
|
|
pubkey,
|
|
JSON.stringify([
|
|
['direction', 'in'],
|
|
['amount', amount],
|
|
['e', unspentProofs.id, Conf.relay, 'created'],
|
|
]),
|
|
),
|
|
tags: mintsToProofs[mint].redeemed,
|
|
}, c);
|
|
} catch (e: any) {
|
|
logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: e });
|
|
}
|
|
}
|
|
|
|
return c.json(201);
|
|
});
|
|
|
|
export default app;
|