mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge branch 'cashu' into 'main'
Swap the tokens into the wallet See merge request soapbox-pub/ditto!636
This commit is contained in:
commit
efb91d9f3f
14 changed files with 778 additions and 148 deletions
|
|
@ -40,6 +40,7 @@
|
||||||
"@/": "./src/",
|
"@/": "./src/",
|
||||||
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
|
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
|
||||||
"@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
"@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
||||||
|
"@cashu/cashu-ts": "npm:@cashu/cashu-ts@^2.2.0",
|
||||||
"@core/asyncutil": "jsr:@core/asyncutil@^1.2.0",
|
"@core/asyncutil": "jsr:@core/asyncutil@^1.2.0",
|
||||||
"@electric-sql/pglite": "npm:@electric-sql/pglite@^0.2.8",
|
"@electric-sql/pglite": "npm:@electric-sql/pglite@^0.2.8",
|
||||||
"@esroyo/scoped-performance": "jsr:@esroyo/scoped-performance@^3.1.0",
|
"@esroyo/scoped-performance": "jsr:@esroyo/scoped-performance@^3.1.0",
|
||||||
|
|
|
||||||
61
deno.lock
generated
61
deno.lock
generated
|
|
@ -85,6 +85,7 @@
|
||||||
"jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1",
|
"jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1",
|
||||||
"jsr:@std/path@~0.213.1": "0.213.1",
|
"jsr:@std/path@~0.213.1": "0.213.1",
|
||||||
"jsr:@std/streams@0.223": "0.223.0",
|
"jsr:@std/streams@0.223": "0.223.0",
|
||||||
|
"npm:@cashu/cashu-ts@^2.2.0": "2.2.0",
|
||||||
"npm:@electric-sql/pglite@~0.2.8": "0.2.8",
|
"npm:@electric-sql/pglite@~0.2.8": "0.2.8",
|
||||||
"npm:@isaacs/ttlcache@^1.4.1": "1.4.1",
|
"npm:@isaacs/ttlcache@^1.4.1": "1.4.1",
|
||||||
"npm:@noble/hashes@^1.4.0": "1.4.0",
|
"npm:@noble/hashes@^1.4.0": "1.4.0",
|
||||||
|
|
@ -740,6 +741,25 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npm": {
|
"npm": {
|
||||||
|
"@cashu/cashu-ts@2.2.0": {
|
||||||
|
"integrity": "sha512-7b6pGyjjpm3uAJvmOL+ztpRxqp1qnmzGpydp+Pu30pOjxj93EhejPTJVrZMDJ0P35y6u5+5jIjHF4k0fpovvmg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@cashu/crypto",
|
||||||
|
"@noble/curves@1.4.0",
|
||||||
|
"@noble/hashes@1.4.0",
|
||||||
|
"buffer"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@cashu/crypto@0.3.4": {
|
||||||
|
"integrity": "sha512-mfv1Pj4iL1PXzUj9NKIJbmncCLMqYfnEDqh/OPxAX0nNBt6BOnVJJLjLWFlQeYxlnEfWABSNkrqPje1t5zcyhA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/curves@1.8.1",
|
||||||
|
"@noble/hashes@1.7.1",
|
||||||
|
"@scure/bip32@1.6.2",
|
||||||
|
"@scure/bip39@1.5.4",
|
||||||
|
"buffer"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@electric-sql/pglite@0.2.8": {
|
"@electric-sql/pglite@0.2.8": {
|
||||||
"integrity": "sha512-0wSmQu22euBRzR5ghqyIHnBH4MfwlkL5WstOrrA3KOsjEWEglvoL/gH92JajEUA6Ufei/+qbkB2hVloC/K/RxQ=="
|
"integrity": "sha512-0wSmQu22euBRzR5ghqyIHnBH4MfwlkL5WstOrrA3KOsjEWEglvoL/gH92JajEUA6Ufei/+qbkB2hVloC/K/RxQ=="
|
||||||
},
|
},
|
||||||
|
|
@ -857,6 +877,12 @@
|
||||||
"@noble/hashes@1.4.0"
|
"@noble/hashes@1.4.0"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@noble/curves@1.8.1": {
|
||||||
|
"integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/hashes@1.7.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@noble/hashes@1.3.1": {
|
"@noble/hashes@1.3.1": {
|
||||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="
|
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="
|
||||||
},
|
},
|
||||||
|
|
@ -866,6 +892,9 @@
|
||||||
"@noble/hashes@1.4.0": {
|
"@noble/hashes@1.4.0": {
|
||||||
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="
|
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="
|
||||||
},
|
},
|
||||||
|
"@noble/hashes@1.7.1": {
|
||||||
|
"integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ=="
|
||||||
|
},
|
||||||
"@noble/secp256k1@2.1.0": {
|
"@noble/secp256k1@2.1.0": {
|
||||||
"integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw=="
|
"integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw=="
|
||||||
},
|
},
|
||||||
|
|
@ -878,6 +907,9 @@
|
||||||
"@scure/base@1.1.6": {
|
"@scure/base@1.1.6": {
|
||||||
"integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g=="
|
"integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g=="
|
||||||
},
|
},
|
||||||
|
"@scure/base@1.2.4": {
|
||||||
|
"integrity": "sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ=="
|
||||||
|
},
|
||||||
"@scure/bip32@1.3.1": {
|
"@scure/bip32@1.3.1": {
|
||||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -894,6 +926,14 @@
|
||||||
"@scure/base@1.1.6"
|
"@scure/base@1.1.6"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@scure/bip32@1.6.2": {
|
||||||
|
"integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/curves@1.8.1",
|
||||||
|
"@noble/hashes@1.7.1",
|
||||||
|
"@scure/base@1.2.4"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@scure/bip39@1.2.1": {
|
"@scure/bip39@1.2.1": {
|
||||||
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -908,6 +948,13 @@
|
||||||
"@scure/base@1.1.6"
|
"@scure/base@1.1.6"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@scure/bip39@1.5.4": {
|
||||||
|
"integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/hashes@1.7.1",
|
||||||
|
"@scure/base@1.2.4"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@types/dompurify@3.0.5": {
|
"@types/dompurify@3.0.5": {
|
||||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -944,6 +991,9 @@
|
||||||
"asynckit@0.4.0": {
|
"asynckit@0.4.0": {
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||||
},
|
},
|
||||||
|
"base64-js@1.5.1": {
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
|
||||||
|
},
|
||||||
"bintrees@1.0.2": {
|
"bintrees@1.0.2": {
|
||||||
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw=="
|
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw=="
|
||||||
},
|
},
|
||||||
|
|
@ -956,6 +1006,13 @@
|
||||||
"fill-range"
|
"fill-range"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"buffer@6.0.3": {
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"dependencies": [
|
||||||
|
"base64-js",
|
||||||
|
"ieee754"
|
||||||
|
]
|
||||||
|
},
|
||||||
"chalk@5.3.0": {
|
"chalk@5.3.0": {
|
||||||
"integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w=="
|
"integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w=="
|
||||||
},
|
},
|
||||||
|
|
@ -1194,6 +1251,9 @@
|
||||||
"safer-buffer"
|
"safer-buffer"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ieee754@1.2.1": {
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
|
||||||
|
},
|
||||||
"image-size@1.1.1": {
|
"image-size@1.1.1": {
|
||||||
"integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==",
|
"integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -2387,6 +2447,7 @@
|
||||||
"jsr:@std/json@0.223",
|
"jsr:@std/json@0.223",
|
||||||
"jsr:@std/media-types@~0.224.1",
|
"jsr:@std/media-types@~0.224.1",
|
||||||
"jsr:@std/streams@0.223",
|
"jsr:@std/streams@0.223",
|
||||||
|
"npm:@cashu/cashu-ts@^2.2.0",
|
||||||
"npm:@electric-sql/pglite@~0.2.8",
|
"npm:@electric-sql/pglite@~0.2.8",
|
||||||
"npm:@isaacs/ttlcache@^1.4.1",
|
"npm:@isaacs/ttlcache@^1.4.1",
|
||||||
"npm:@noble/secp256k1@2",
|
"npm:@noble/secp256k1@2",
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,11 @@ import {
|
||||||
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
|
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
|
||||||
import { blocksController } from '@/controllers/api/blocks.ts';
|
import { blocksController } from '@/controllers/api/blocks.ts';
|
||||||
import { bookmarksController } from '@/controllers/api/bookmarks.ts';
|
import { bookmarksController } from '@/controllers/api/bookmarks.ts';
|
||||||
|
import cashuApp from '@/controllers/api/cashu.ts';
|
||||||
import { captchaController, captchaVerifyController } from '@/controllers/api/captcha.ts';
|
import { captchaController, captchaVerifyController } from '@/controllers/api/captcha.ts';
|
||||||
import {
|
import {
|
||||||
adminRelaysController,
|
adminRelaysController,
|
||||||
adminSetRelaysController,
|
adminSetRelaysController,
|
||||||
createCashuWalletController,
|
|
||||||
deleteZapSplitsController,
|
deleteZapSplitsController,
|
||||||
getZapSplitsController,
|
getZapSplitsController,
|
||||||
nameRequestController,
|
nameRequestController,
|
||||||
|
|
@ -406,7 +406,7 @@ app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSpli
|
||||||
app.post('/api/v1/ditto/zap', requireSigner, zapController);
|
app.post('/api/v1/ditto/zap', requireSigner, zapController);
|
||||||
app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController);
|
app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController);
|
||||||
|
|
||||||
app.post('/api/v1/ditto/wallet/create', requireSigner, createCashuWalletController);
|
app.route('/api/v1/ditto/cashu', cashuApp);
|
||||||
|
|
||||||
app.post('/api/v1/reports', requireSigner, reportController);
|
app.post('/api/v1/reports', requireSigner, reportController);
|
||||||
app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController);
|
app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController);
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,10 @@ class Conf {
|
||||||
static get preferredLanguages(): LanguageCode[] | undefined {
|
static get preferredLanguages(): LanguageCode[] | undefined {
|
||||||
return Deno.env.get('DITTO_LANGUAGES')?.split(',')?.filter(ISO6391.validate);
|
return Deno.env.get('DITTO_LANGUAGES')?.split(',')?.filter(ISO6391.validate);
|
||||||
}
|
}
|
||||||
|
/** Mints to be displayed in the UI when the user decides to create a wallet.*/
|
||||||
|
static get cashuMints(): string[] {
|
||||||
|
return Deno.env.get('CASHU_MINTS')?.split(',') ?? [];
|
||||||
|
}
|
||||||
/** Translation provider used to translate posts. */
|
/** Translation provider used to translate posts. */
|
||||||
static get translationProvider(): string | undefined {
|
static get translationProvider(): string | undefined {
|
||||||
return Deno.env.get('TRANSLATION_PROVIDER');
|
return Deno.env.get('TRANSLATION_PROVIDER');
|
||||||
|
|
|
||||||
297
src/controllers/api/cashu.test.ts
Normal file
297
src/controllers/api/cashu.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { Env as HonoEnv, Hono } from '@hono/hono';
|
||||||
|
import { NostrSigner, NSecSigner, NStore } from '@nostrify/nostrify';
|
||||||
|
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||||
|
import { bytesToString, stringToBytes } from '@scure/base';
|
||||||
|
import { assertEquals, assertExists, assertObjectMatch } from '@std/assert';
|
||||||
|
|
||||||
|
import { createTestDB, genEvent } from '@/test.ts';
|
||||||
|
|
||||||
|
import cashuApp from '@/controllers/api/cashu.ts';
|
||||||
|
import { walletSchema } from '@/schema.ts';
|
||||||
|
|
||||||
|
interface AppEnv extends HonoEnv {
|
||||||
|
Variables: {
|
||||||
|
/** Signer to get the logged-in user's pubkey, relays, and to sign events. */
|
||||||
|
signer: NostrSigner;
|
||||||
|
/** Storage for the user, might filter out unwanted content. */
|
||||||
|
store: NStore;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test('PUT /wallet must be successful', {
|
||||||
|
sanitizeOps: false,
|
||||||
|
sanitizeResources: false,
|
||||||
|
}, async () => {
|
||||||
|
await using db = await createTestDB();
|
||||||
|
const store = db.store;
|
||||||
|
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const signer = new NSecSigner(sk);
|
||||||
|
const nostrPrivateKey = bytesToString('hex', sk);
|
||||||
|
|
||||||
|
const app = new Hono<AppEnv>().use(
|
||||||
|
async (c, next) => {
|
||||||
|
c.set('signer', signer);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async (c, next) => {
|
||||||
|
c.set('store', store);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
).route('/', cashuApp);
|
||||||
|
|
||||||
|
const response = await app.request('/wallet', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: [['content-type', 'application/json']],
|
||||||
|
body: JSON.stringify({
|
||||||
|
mints: [
|
||||||
|
'https://houston.mint.com',
|
||||||
|
'https://houston.mint.com', // duplicate on purpose
|
||||||
|
'https://cuiaba.mint.com',
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(response.status, 200);
|
||||||
|
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
|
||||||
|
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }]);
|
||||||
|
|
||||||
|
assertExists(wallet);
|
||||||
|
assertEquals(wallet.kind, 17375);
|
||||||
|
|
||||||
|
const { data, success } = walletSchema.safeParse(await response.json());
|
||||||
|
|
||||||
|
assertEquals(success, true);
|
||||||
|
if (!data) return; // get rid of typescript error possibly undefined
|
||||||
|
|
||||||
|
const decryptedContent: string[][] = JSON.parse(await signer.nip44.decrypt(pubkey, wallet.content));
|
||||||
|
|
||||||
|
const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1]!;
|
||||||
|
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||||
|
|
||||||
|
assertEquals(nostrPrivateKey !== privkey, true);
|
||||||
|
|
||||||
|
assertEquals(data.pubkey_p2pk, p2pk);
|
||||||
|
assertEquals(data.mints, [
|
||||||
|
'https://houston.mint.com',
|
||||||
|
'https://cuiaba.mint.com',
|
||||||
|
]);
|
||||||
|
assertEquals(data.relays, [
|
||||||
|
'ws://localhost:4036/relay',
|
||||||
|
]);
|
||||||
|
assertEquals(data.balance, 0);
|
||||||
|
|
||||||
|
const [nutzap_info] = await store.query([{ authors: [pubkey], kinds: [10019] }]);
|
||||||
|
|
||||||
|
assertExists(nutzap_info);
|
||||||
|
assertEquals(nutzap_info.kind, 10019);
|
||||||
|
assertEquals(nutzap_info.tags.length, 4);
|
||||||
|
|
||||||
|
const nutzap_p2pk = nutzap_info.tags.find(([value]) => value === 'pubkey')?.[1]!;
|
||||||
|
|
||||||
|
assertEquals(nutzap_p2pk, p2pk);
|
||||||
|
assertEquals([nutzap_info.tags.find(([name]) => name === 'relay')?.[1]!], [
|
||||||
|
'ws://localhost:4036/relay',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async () => {
|
||||||
|
await using db = await createTestDB();
|
||||||
|
const store = db.store;
|
||||||
|
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const signer = new NSecSigner(sk);
|
||||||
|
|
||||||
|
const app = new Hono<AppEnv>().use(
|
||||||
|
async (c, next) => {
|
||||||
|
c.set('signer', signer);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async (c, next) => {
|
||||||
|
c.set('store', store);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
).route('/', cashuApp);
|
||||||
|
|
||||||
|
const response = await app.request('/wallet', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: [['content-type', 'application/json']],
|
||||||
|
body: JSON.stringify({
|
||||||
|
mints: [], // no mints should throw an error
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
assertEquals(response.status, 400);
|
||||||
|
assertObjectMatch(body, { error: 'Bad schema' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('PUT /wallet must NOT be successful: wallet already exists', async () => {
|
||||||
|
await using db = await createTestDB();
|
||||||
|
const store = db.store;
|
||||||
|
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const signer = new NSecSigner(sk);
|
||||||
|
|
||||||
|
const app = new Hono<AppEnv>().use(
|
||||||
|
async (c, next) => {
|
||||||
|
c.set('signer', signer);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async (c, next) => {
|
||||||
|
c.set('store', store);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
).route('/', cashuApp);
|
||||||
|
|
||||||
|
await db.store.event(genEvent({ kind: 17375 }, sk));
|
||||||
|
|
||||||
|
const response = await app.request('/wallet', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: [['content-type', 'application/json']],
|
||||||
|
body: JSON.stringify({
|
||||||
|
mints: ['https://mint.heart.com'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body2 = await response.json();
|
||||||
|
|
||||||
|
assertEquals(response.status, 400);
|
||||||
|
assertEquals(body2, { error: 'You already have a wallet 😏' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('GET /wallet must be successful', async () => {
|
||||||
|
await using db = await createTestDB();
|
||||||
|
const store = db.store;
|
||||||
|
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const signer = new NSecSigner(sk);
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
const privkey = bytesToString('hex', sk);
|
||||||
|
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||||
|
|
||||||
|
const app = new Hono<AppEnv>().use(
|
||||||
|
async (c, next) => {
|
||||||
|
c.set('signer', signer);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async (c, next) => {
|
||||||
|
c.set('store', store);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
).route('/', cashuApp);
|
||||||
|
|
||||||
|
// Wallet
|
||||||
|
await db.store.event(genEvent({
|
||||||
|
kind: 17375,
|
||||||
|
content: await signer.nip44.encrypt(
|
||||||
|
pubkey,
|
||||||
|
JSON.stringify([
|
||||||
|
['privkey', privkey],
|
||||||
|
['mint', 'https://mint.soul.com'],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}, sk));
|
||||||
|
|
||||||
|
// Nutzap information
|
||||||
|
await db.store.event(genEvent({
|
||||||
|
kind: 10019,
|
||||||
|
tags: [
|
||||||
|
['pubkey', p2pk],
|
||||||
|
['mint', 'https://mint.soul.com'],
|
||||||
|
],
|
||||||
|
}, sk));
|
||||||
|
|
||||||
|
// Unspent proofs
|
||||||
|
await db.store.event(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));
|
||||||
|
|
||||||
|
// TODO: find a way to have a Mock mint so operations like 'swap', 'mint' and 'melt' can be tested (this will be a bit hard).
|
||||||
|
// Nutzap
|
||||||
|
const senderSk = generateSecretKey();
|
||||||
|
|
||||||
|
await db.store.event(genEvent({
|
||||||
|
kind: 9321,
|
||||||
|
content: 'Nice post!',
|
||||||
|
tags: [
|
||||||
|
['p', pubkey],
|
||||||
|
['u', 'https://mint.soul.com'],
|
||||||
|
[
|
||||||
|
'proof',
|
||||||
|
'{"amount":1,"C":"02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f","id":"000a93d6f8a1d2c4","secret":"[\\"P2PK\\",{\\"nonce\\":\\"b00bdd0467b0090a25bdf2d2f0d45ac4e355c482c1418350f273a04fedaaee83\\",\\"data\\":\\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\\"}]"}',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}, senderSk));
|
||||||
|
|
||||||
|
const response = await app.request('/wallet', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
assertEquals(response.status, 200);
|
||||||
|
assertEquals(body, {
|
||||||
|
pubkey_p2pk: p2pk,
|
||||||
|
mints: ['https://mint.soul.com'],
|
||||||
|
relays: ['ws://localhost:4036/relay'],
|
||||||
|
balance: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('GET /mints must be successful', async () => {
|
||||||
|
await using db = await createTestDB();
|
||||||
|
const store = db.store;
|
||||||
|
|
||||||
|
const app = new Hono<AppEnv>().use(
|
||||||
|
async (c, next) => {
|
||||||
|
c.set('store', store);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
).route('/', cashuApp);
|
||||||
|
|
||||||
|
const response = await app.request('/mints', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
assertEquals(response.status, 200);
|
||||||
|
assertEquals(body, { mints: [] });
|
||||||
|
});
|
||||||
169
src/controllers/api/cashu.ts
Normal file
169
src/controllers/api/cashu.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { Proof } from '@cashu/cashu-ts';
|
||||||
|
import { Hono } from '@hono/hono';
|
||||||
|
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||||
|
import { bytesToString, stringToBytes } from '@scure/base';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { createEvent, parseBody } from '@/utils/api.ts';
|
||||||
|
import { requireNip44Signer } from '@/middleware/requireSigner.ts';
|
||||||
|
import { requireStore } from '@/middleware/storeMiddleware.ts';
|
||||||
|
import { walletSchema } from '@/schema.ts';
|
||||||
|
import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts';
|
||||||
|
import { isNostrId } from '@/utils.ts';
|
||||||
|
import { logi } from '@soapbox/logi';
|
||||||
|
import { errorJson } from '@/utils/log.ts';
|
||||||
|
|
||||||
|
type Wallet = z.infer<typeof walletSchema>;
|
||||||
|
|
||||||
|
const app = new Hono().use('*', requireStore);
|
||||||
|
|
||||||
|
// app.delete('/wallet') -> 204
|
||||||
|
|
||||||
|
// 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.var.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Gets a wallet, if it exists. */
|
||||||
|
app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
|
||||||
|
const signer = c.get('signer');
|
||||||
|
const store = c.get('store');
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
const { signal } = c.req.raw;
|
||||||
|
|
||||||
|
const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Wallet not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedContent: string[][] = JSON.parse(await signer.nip44.decrypt(pubkey, event.content));
|
||||||
|
|
||||||
|
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;
|
||||||
|
const mints: string[] = [];
|
||||||
|
|
||||||
|
const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { 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: 'ditto.api.cashu.wallet.swap', 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: [Conf.relay],
|
||||||
|
balance,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json(walletEntity, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Get mints set by the CASHU_MINTS environment variable. */
|
||||||
|
app.get('/mints', (c) => {
|
||||||
|
// TODO: Return full Mint information: https://github.com/cashubtc/nuts/blob/main/06.md
|
||||||
|
const mints = Conf.cashuMints;
|
||||||
|
|
||||||
|
return c.json({ mints }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
|
||||||
import { bytesToString, stringToBytes } from '@scure/base';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppController } from '@/app.ts';
|
import { AppController } from '@/app.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { addTag } from '@/utils/tags.ts';
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
import { getAuthor } from '@/queries.ts';
|
import { getAuthor } from '@/queries.ts';
|
||||||
|
import { addTag } from '@/utils/tags.ts';
|
||||||
import { createEvent, paginated, parseBody, updateAdminEvent } from '@/utils/api.ts';
|
import { createEvent, paginated, parseBody, updateAdminEvent } from '@/utils/api.ts';
|
||||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||||
import { deleteTag } from '@/utils/tags.ts';
|
import { deleteTag } from '@/utils/tags.ts';
|
||||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
|
||||||
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
|
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
|
||||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||||
import { screenshotsSchema } from '@/schemas/nostr.ts';
|
import { screenshotsSchema } from '@/schemas/nostr.ts';
|
||||||
|
|
@ -245,7 +243,7 @@ export const getZapSplitsController: AppController = async (c) => {
|
||||||
const zapSplits = await Promise.all(pubkeys.map(async (pubkey) => {
|
const zapSplits = await Promise.all(pubkeys.map(async (pubkey) => {
|
||||||
const author = await getAuthor(pubkey);
|
const author = await getAuthor(pubkey);
|
||||||
|
|
||||||
const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey);
|
const account = author ? renderAccount(author) : accountFromPubkey(pubkey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account,
|
account,
|
||||||
|
|
@ -274,9 +272,9 @@ export const statusZapSplitsController: AppController = async (c) => {
|
||||||
const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal });
|
const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal });
|
||||||
await hydrateEvents({ events: users, store, signal });
|
await hydrateEvents({ events: users, store, signal });
|
||||||
|
|
||||||
const zapSplits = (await Promise.all(pubkeys.map(async (pubkey) => {
|
const zapSplits = (await Promise.all(pubkeys.map((pubkey) => {
|
||||||
const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author;
|
const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author;
|
||||||
const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey);
|
const account = author ? renderAccount(author) : accountFromPubkey(pubkey);
|
||||||
|
|
||||||
const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0;
|
const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0;
|
||||||
|
|
||||||
|
|
@ -344,135 +342,3 @@ export const updateInstanceController: AppController = async (c) => {
|
||||||
|
|
||||||
return c.json(204);
|
return c.json(204);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCashuWalletSchema = z.object({
|
|
||||||
description: z.string(),
|
|
||||||
relays: z.array(z.string().url()),
|
|
||||||
mints: z.array(z.string().url()).nonempty(), // must contain at least one item
|
|
||||||
name: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an addressable Cashu wallet.
|
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/60.md
|
|
||||||
*/
|
|
||||||
export const createCashuWalletController: AppController = 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 = createCashuWalletSchema.safeParse(body);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return c.json({ error: 'Bad request', schema: result.error }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [event] = await store.query([{ authors: [pubkey], kinds: [37375] }], { signal });
|
|
||||||
if (event) {
|
|
||||||
return c.json({ error: 'You already have a wallet 😏' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { description, relays, mints, name } = result.data;
|
|
||||||
relays.push(Conf.relay);
|
|
||||||
|
|
||||||
const tags: string[][] = [];
|
|
||||||
|
|
||||||
const wallet_id = Math.random().toString(36).substring(3);
|
|
||||||
|
|
||||||
tags.push(['d', wallet_id]);
|
|
||||||
tags.push(['name', name]);
|
|
||||||
tags.push(['description', description]);
|
|
||||||
tags.push(['unit', 'sat']);
|
|
||||||
|
|
||||||
for (const mint of new Set(mints)) {
|
|
||||||
tags.push(['mint', mint]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const relay of new Set(relays)) {
|
|
||||||
tags.push(['relay', relay]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sk = generateSecretKey();
|
|
||||||
const privkey = bytesToString('hex', sk);
|
|
||||||
|
|
||||||
const contentTags = [
|
|
||||||
['privkey', privkey],
|
|
||||||
];
|
|
||||||
const encryptedContentTags = await signer.nip44?.encrypt(pubkey, JSON.stringify(contentTags));
|
|
||||||
|
|
||||||
// Wallet
|
|
||||||
await createEvent({
|
|
||||||
kind: 37375,
|
|
||||||
content: encryptedContentTags,
|
|
||||||
tags,
|
|
||||||
}, c);
|
|
||||||
|
|
||||||
return c.json({ wallet_id }, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createNutzapInformationSchema = z.object({
|
|
||||||
relays: z.array(z.string().url()),
|
|
||||||
mints: z.array(z.string().url()).nonempty(), // must contain at least one item
|
|
||||||
wallet_id: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a replaceable Nutzap information for a specific wallet.
|
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event
|
|
||||||
*/
|
|
||||||
export const createNutzapInformationController: AppController = 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 = createNutzapInformationSchema.safeParse(body);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return c.json({ error: 'Bad request', schema: result.error }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nip44 = signer.nip44;
|
|
||||||
if (!nip44) {
|
|
||||||
return c.json({ error: 'Signer does not have nip 44' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { relays, mints, wallet_id } = result.data;
|
|
||||||
|
|
||||||
const [event] = await store.query([{ authors: [pubkey], kinds: [37375], '#d': [wallet_id] }], { signal });
|
|
||||||
if (!event) {
|
|
||||||
return c.json({ error: 'Could not find a wallet with the id: ' + wallet_id }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
relays.push(Conf.relay);
|
|
||||||
|
|
||||||
const tags: string[][] = [];
|
|
||||||
|
|
||||||
for (const mint of new Set(mints)) {
|
|
||||||
tags.push(['mint', mint, 'sat']);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const relay of new Set(relays)) {
|
|
||||||
tags.push(['relay', relay]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentTags: string[][] = JSON.parse(await nip44.decrypt(pubkey, event.content));
|
|
||||||
|
|
||||||
const privkey = contentTags.find(([value]) => value === 'privkey')?.[1];
|
|
||||||
if (!privkey) {
|
|
||||||
return c.json({ error: 'Wallet does not contain privkey' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
|
||||||
|
|
||||||
tags.push(['pubkey', p2pk]);
|
|
||||||
|
|
||||||
// Nutzap information
|
|
||||||
await createEvent({
|
|
||||||
kind: 10019,
|
|
||||||
tags,
|
|
||||||
}, c);
|
|
||||||
|
|
||||||
return c.json(201);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { MiddlewareHandler } from '@hono/hono';
|
import { MiddlewareHandler } from '@hono/hono';
|
||||||
import { HTTPException } from '@hono/hono/http-exception';
|
import { HTTPException } from '@hono/hono/http-exception';
|
||||||
import { NostrSigner } from '@nostrify/nostrify';
|
import { NostrSigner } from '@nostrify/nostrify';
|
||||||
|
import { SetRequired } from 'type-fest';
|
||||||
|
|
||||||
/** Throw a 401 if a signer isn't set. */
|
/** Throw a 401 if a signer isn't set. */
|
||||||
export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => {
|
export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => {
|
||||||
|
|
@ -10,3 +11,19 @@ export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Throw a 401 if a NIP-44 signer isn't set. */
|
||||||
|
export const requireNip44Signer: MiddlewareHandler<{ Variables: { signer: SetRequired<NostrSigner, 'nip44'> } }> =
|
||||||
|
async (c, next) => {
|
||||||
|
const signer = c.get('signer');
|
||||||
|
|
||||||
|
if (!signer) {
|
||||||
|
throw new HTTPException(401, { message: 'No pubkey provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signer.nip44) {
|
||||||
|
throw new HTTPException(401, { message: 'No NIP-44 signer provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
import { MiddlewareHandler } from '@hono/hono';
|
||||||
import { HTTPException } from '@hono/hono/http-exception';
|
import { HTTPException } from '@hono/hono/http-exception';
|
||||||
import { NSecSigner } from '@nostrify/nostrify';
|
import { NostrSigner, NSecSigner } from '@nostrify/nostrify';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import { AppMiddleware } from '@/app.ts';
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
||||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||||
|
|
@ -14,7 +14,7 @@ import { getTokenHash } from '@/utils/auth.ts';
|
||||||
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
|
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
|
||||||
|
|
||||||
/** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */
|
/** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */
|
||||||
export const signerMiddleware: AppMiddleware = async (c, next) => {
|
export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => {
|
||||||
const header = c.req.header('authorization');
|
const header = c.req.header('authorization');
|
||||||
const match = header?.match(BEARER_REGEX);
|
const match = header?.match(BEARER_REGEX);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
import { AppMiddleware } from '@/app.ts';
|
import { MiddlewareHandler } from '@hono/hono';
|
||||||
|
import { NostrSigner, NStore } from '@nostrify/nostrify';
|
||||||
|
|
||||||
import { UserStore } from '@/storages/UserStore.ts';
|
import { UserStore } from '@/storages/UserStore.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
|
|
||||||
|
export const requireStore: MiddlewareHandler<{ Variables: { store: NStore } }> = async (c, next) => {
|
||||||
|
if (!c.get('store')) {
|
||||||
|
throw new Error('Store is required');
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
/** Store middleware. */
|
/** Store middleware. */
|
||||||
export const storeMiddleware: AppMiddleware = async (c, next) => {
|
export const storeMiddleware: MiddlewareHandler<{ Variables: { signer?: NostrSigner; store: NStore } }> = async (
|
||||||
|
c,
|
||||||
|
next,
|
||||||
|
) => {
|
||||||
const pubkey = await c.get('signer')?.getPublicKey();
|
const pubkey = await c.get('signer')?.getPublicKey();
|
||||||
|
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
|
|
|
||||||
188
src/middleware/swapNutzapsMiddleware.ts
Normal file
188
src/middleware/swapNutzapsMiddleware.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts';
|
||||||
|
import { MiddlewareHandler } from '@hono/hono';
|
||||||
|
import { HTTPException } from '@hono/hono/http-exception';
|
||||||
|
import { getPublicKey } from 'nostr-tools';
|
||||||
|
import { NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify';
|
||||||
|
import { SetRequired } from 'type-fest';
|
||||||
|
import { stringToBytes } from '@scure/base';
|
||||||
|
import { logi } from '@soapbox/logi';
|
||||||
|
|
||||||
|
import { isNostrId } from '@/utils.ts';
|
||||||
|
import { errorJson } from '@/utils/log.ts';
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { createEvent } from '@/utils/api.ts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<
|
||||||
|
{ Variables: { signer: SetRequired<NostrSigner, 'nip44'>; store: NStore } }
|
||||||
|
> = async (c, next) => {
|
||||||
|
const signer = c.get('signer');
|
||||||
|
const store = c.get('store');
|
||||||
|
|
||||||
|
if (!signer) {
|
||||||
|
throw new HTTPException(401, { message: 'No pubkey provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signer.nip44) {
|
||||||
|
throw new HTTPException(401, { message: 'No NIP-44 signer provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
throw new HTTPException(401, { message: 'No store provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { signal } = c.req.raw;
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||||
|
|
||||||
|
if (wallet) {
|
||||||
|
let decryptedContent: string;
|
||||||
|
try {
|
||||||
|
decryptedContent = await 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 signer.nip44.encrypt(
|
||||||
|
pubkey,
|
||||||
|
JSON.stringify({
|
||||||
|
mint,
|
||||||
|
proofs: receiveProofs,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}, c);
|
||||||
|
|
||||||
|
const amount = receiveProofs.reduce((accumulator, current) => {
|
||||||
|
return accumulator + current.amount;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
await createEvent({
|
||||||
|
kind: 7376,
|
||||||
|
content: await signer.nip44.encrypt(
|
||||||
|
pubkey,
|
||||||
|
JSON.stringify([
|
||||||
|
['direction', 'in'],
|
||||||
|
['amount', amount],
|
||||||
|
['e', unspentProofs.id, Conf.relay, 'created'],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
tags: mintsToProofs[mint].redeemed,
|
||||||
|
}, c);
|
||||||
|
} catch (e) {
|
||||||
|
logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import ISO6391, { LanguageCode } from 'iso-639-1';
|
import ISO6391, { LanguageCode } from 'iso-639-1';
|
||||||
|
import { NSchema as n } from '@nostrify/nostrify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
/** Validates individual items in an array, dropping any that aren't valid. */
|
/** Validates individual items in an array, dropping any that aren't valid. */
|
||||||
|
|
@ -80,6 +81,19 @@ const sizesSchema = z.string().refine((value) =>
|
||||||
value.split(' ').every((v) => /^[1-9]\d{0,3}[xX][1-9]\d{0,3}$/.test(v))
|
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 {
|
export {
|
||||||
booleanParamSchema,
|
booleanParamSchema,
|
||||||
decode64Schema,
|
decode64Schema,
|
||||||
|
|
@ -91,5 +105,6 @@ export {
|
||||||
percentageSchema,
|
percentageSchema,
|
||||||
safeUrlSchema,
|
safeUrlSchema,
|
||||||
sizesSchema,
|
sizesSchema,
|
||||||
|
walletSchema,
|
||||||
wsUrlSchema,
|
wsUrlSchema,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ class EventsDB extends NPostgres {
|
||||||
't': ({ event, count, value }) =>
|
't': ({ event, count, value }) =>
|
||||||
(value === value.toLowerCase()) && (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50,
|
(value === value.toLowerCase()) && (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50,
|
||||||
'u': ({ count, value }) => {
|
'u': ({ count, value }) => {
|
||||||
const { success } = z.string().url().safeParse(value); // maybe find a better library specific for validating web urls
|
const { success } = z.string().url().safeParse(value); // TODO: maybe find a better library specific for validating web urls
|
||||||
return count < 15 && success;
|
return count < 15 && success;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { purifyEvent } from '@/utils/purify.ts';
|
||||||
type EventStub = TypeFest.SetOptional<EventTemplate, 'content' | 'created_at' | 'tags'>;
|
type EventStub = TypeFest.SetOptional<EventTemplate, 'content' | 'created_at' | 'tags'>;
|
||||||
|
|
||||||
/** Publish an event through the pipeline. */
|
/** Publish an event through the pipeline. */
|
||||||
async function createEvent(t: EventStub, c: AppContext): Promise<NostrEvent> {
|
async function createEvent(t: EventStub, c: Context): Promise<NostrEvent> {
|
||||||
const signer = c.get('signer');
|
const signer = c.get('signer');
|
||||||
|
|
||||||
if (!signer) {
|
if (!signer) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue