create-fedi-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +11113 -0
- package/dist/templates/base/.env.example +5 -0
- package/dist/templates/base/app/demo/page.tsx +25 -0
- package/dist/templates/base/app/globals.css +95 -0
- package/dist/templates/base/app/layout.tsx +39 -0
- package/dist/templates/base/app/page.tsx +83 -0
- package/dist/templates/base/components/FediDevToolbar/FediDevToolbar.tsx +170 -0
- package/dist/templates/base/components/providers.tsx +63 -0
- package/dist/templates/base/env.ts +10 -0
- package/dist/templates/base/hooks/useFediInternal.ts +41 -0
- package/dist/templates/base/lib/fedi-types.ts +96 -0
- package/dist/templates/base/lib/fedi.ts +18 -0
- package/dist/templates/base/lib/nostr/hooks.ts +52 -0
- package/dist/templates/base/lib/nostr/index.ts +9 -0
- package/dist/templates/base/lib/nostr/mock.ts +60 -0
- package/dist/templates/base/lib/nostr/provider.tsx +64 -0
- package/dist/templates/base/lib/utils.ts +3 -0
- package/dist/templates/base/lib/webln/hooks.ts +67 -0
- package/dist/templates/base/lib/webln/index.ts +12 -0
- package/dist/templates/base/lib/webln/mock.ts +96 -0
- package/dist/templates/base/lib/webln/provider.tsx +52 -0
- package/dist/templates/base/next.config.ts +3 -0
- package/dist/templates/base/package.json +40 -0
- package/dist/templates/base/proxy.ts +8 -0
- package/dist/templates/base/tsconfig.json +20 -0
- package/dist/templates/base/vitest.config.ts +6 -0
- package/dist/templates/base/vitest.setup.ts +40 -0
- package/dist/templates/modules/ai-assistant/app/api/assistant/route.ts +45 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/AssistantDemoClient.tsx +70 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/page.tsx +23 -0
- package/dist/templates/modules/ai-assistant/components/ai/Assistant.tsx +220 -0
- package/dist/templates/modules/ai-assistant/components/ai/AssistantProvider.tsx +71 -0
- package/dist/templates/modules/ai-assistant/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-assistant/module.json +48 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/invoice/route.ts +15 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/route.ts +57 -0
- package/dist/templates/modules/ai-chat-gated/app/demo/ai-chat/page.tsx +58 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/ChatMessage.tsx +50 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/GatedChat.tsx +181 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +168 -0
- package/dist/templates/modules/ai-chat-gated/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-chat-gated/lib/chat-payment.ts +161 -0
- package/dist/templates/modules/ai-chat-gated/module.json +62 -0
- package/dist/templates/modules/ai-rules/.cursorrules +8 -0
- package/dist/templates/modules/ai-rules/.github/copilot-instructions.md +8 -0
- package/dist/templates/modules/ai-rules/CLAUDE.md +8 -0
- package/dist/templates/modules/ai-rules/module.json +20 -0
- package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +56 -0
- package/dist/templates/modules/ai-rules/rules/architecture.md +108 -0
- package/dist/templates/modules/ai-rules/rules/design-system.md +94 -0
- package/dist/templates/modules/ai-rules/rules/fedi-api.md +120 -0
- package/dist/templates/modules/ai-rules/rules/nostr.md +232 -0
- package/dist/templates/modules/ai-rules/rules/patterns.md +408 -0
- package/dist/templates/modules/ai-rules/rules/testing.md +238 -0
- package/dist/templates/modules/ai-rules/rules/webln.md +241 -0
- package/dist/templates/modules/database/drizzle/supabase/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/supabase/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle/turso/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/turso/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle.config.supabase.ts +10 -0
- package/dist/templates/modules/database/drizzle.config.turso.ts +11 -0
- package/dist/templates/modules/database/env.supabase.ts +24 -0
- package/dist/templates/modules/database/env.turso.ts +23 -0
- package/dist/templates/modules/database/lib/db/index.supabase.ts +19 -0
- package/dist/templates/modules/database/lib/db/index.turso.ts +20 -0
- package/dist/templates/modules/database/lib/db/schema.supabase.ts +13 -0
- package/dist/templates/modules/database/lib/db/schema.turso.ts +13 -0
- package/dist/templates/modules/database/module.json +110 -0
- package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +115 -0
- package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +162 -0
- package/dist/templates/modules/ecash-balance/components/fedi/FediVersionBadge.tsx +39 -0
- package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +74 -0
- package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +65 -0
- package/dist/templates/modules/ecash-balance/module.json +14 -0
- package/dist/templates/modules/lnurl/app/api/lnurlauth/route.ts +118 -0
- package/dist/templates/modules/lnurl/app/api/lnurlp/[username]/route.ts +70 -0
- package/dist/templates/modules/lnurl/app/api/lnurlw/route.ts +57 -0
- package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +136 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlAuth.tsx +156 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +36 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +96 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +141 -0
- package/dist/templates/modules/lnurl/lib/lnurl-auth-verify.ts +87 -0
- package/dist/templates/modules/lnurl/lib/lnurl-store.ts +112 -0
- package/dist/templates/modules/lnurl/lib/lnurl-utils.ts +56 -0
- package/dist/templates/modules/lnurl/module.json +27 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/MultispendDemoClient.tsx +109 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/page.tsx +23 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ApprovalVote.tsx +122 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendDemo.tsx +220 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendProposal.tsx +213 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ProposalList.tsx +49 -0
- package/dist/templates/modules/multispend-demo/hooks/useMultispendDemo.ts +127 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-types.ts +33 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-utils.ts +69 -0
- package/dist/templates/modules/multispend-demo/module.json +18 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/NostrFeedDemoClient.tsx +134 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/page.tsx +23 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NostrFeedProvider.tsx +47 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteCard.tsx +68 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteFeed.tsx +109 -0
- package/dist/templates/modules/nostr-feed/components/nostr/PublishNote.tsx +104 -0
- package/dist/templates/modules/nostr-feed/components/nostr/ZapButton.tsx +140 -0
- package/dist/templates/modules/nostr-feed/lib/nostr/relay.ts +107 -0
- package/dist/templates/modules/nostr-feed/lib/nostr-zap.ts +159 -0
- package/dist/templates/modules/nostr-feed/module.json +25 -0
- package/dist/templates/modules/nostr-identity/app/demo/nostr/page.tsx +136 -0
- package/dist/templates/modules/nostr-identity/components/nostr/IdentityBadge.tsx +109 -0
- package/dist/templates/modules/nostr-identity/components/nostr/NostrLogin.tsx +107 -0
- package/dist/templates/modules/nostr-identity/components/nostr/SignedMessage.tsx +103 -0
- package/dist/templates/modules/nostr-identity/hooks/useIdentityFlow.ts +61 -0
- package/dist/templates/modules/nostr-identity/lib/nostr-utils.ts +30 -0
- package/dist/templates/modules/nostr-identity/module.json +15 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +25 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/verify/route.ts +39 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +71 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +134 -0
- package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +267 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +195 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +104 -0
- package/dist/templates/modules/payment-gated-content/module.json +24 -0
- package/dist/templates/modules/payment-gated-content/proxy.ts +27 -0
- package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +176 -0
- package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +170 -0
- package/dist/templates/modules/webln-payments/components/webln/PayButton.tsx +92 -0
- package/dist/templates/modules/webln-payments/components/webln/PaymentHistory.tsx +102 -0
- package/dist/templates/modules/webln-payments/hooks/__tests__/usePaymentFlow.test.tsx +182 -0
- package/dist/templates/modules/webln-payments/hooks/usePaymentFlow.ts +100 -0
- package/dist/templates/modules/webln-payments/lib/payment-history.ts +75 -0
- package/dist/templates/modules/webln-payments/module.json +17 -0
- package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +41 -0
- package/package.json +29 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
|
|
3
|
+
export type TLnurlAuthSession = {
|
|
4
|
+
k1: string;
|
|
5
|
+
createdAt: number;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
pubkey: string | null;
|
|
8
|
+
authenticatedAt: number | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type TLnurlWithdrawSession = {
|
|
12
|
+
k1: string;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
expiresAt: number;
|
|
15
|
+
withdrawnMsats: number | null;
|
|
16
|
+
paymentRequest: string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const authSessions = new Map<string, TLnurlAuthSession>();
|
|
20
|
+
const withdrawSessions = new Map<string, TLnurlWithdrawSession>();
|
|
21
|
+
|
|
22
|
+
const AUTH_TTL_MS = 10 * 60 * 1000;
|
|
23
|
+
const WITHDRAW_TTL_MS = 10 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
function generateK1(): string {
|
|
26
|
+
return randomBytes(32).toString('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildMockInvoice(amountSats: number, id: string): string {
|
|
30
|
+
const amountPart = amountSats.toString(16).padStart(6, '0');
|
|
31
|
+
return `lnbc${amountPart}n1p${id.slice(0, 40)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createAuthSession(): TLnurlAuthSession {
|
|
35
|
+
const k1 = generateK1();
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const session: TLnurlAuthSession = {
|
|
38
|
+
k1,
|
|
39
|
+
createdAt: now,
|
|
40
|
+
expiresAt: now + AUTH_TTL_MS,
|
|
41
|
+
pubkey: null,
|
|
42
|
+
authenticatedAt: null,
|
|
43
|
+
};
|
|
44
|
+
authSessions.set(k1, session);
|
|
45
|
+
return session;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getAuthSession(k1: string): TLnurlAuthSession | null {
|
|
49
|
+
const session = authSessions.get(k1);
|
|
50
|
+
if (!session) return null;
|
|
51
|
+
if (Date.now() > session.expiresAt) {
|
|
52
|
+
authSessions.delete(k1);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return session;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function completeAuthSession(k1: string, pubkey: string): TLnurlAuthSession | null {
|
|
59
|
+
const session = getAuthSession(k1);
|
|
60
|
+
if (!session) return null;
|
|
61
|
+
session.pubkey = pubkey;
|
|
62
|
+
session.authenticatedAt = Date.now();
|
|
63
|
+
authSessions.set(k1, session);
|
|
64
|
+
return session;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createWithdrawSession(): TLnurlWithdrawSession {
|
|
68
|
+
const k1 = generateK1();
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const session: TLnurlWithdrawSession = {
|
|
71
|
+
k1,
|
|
72
|
+
createdAt: now,
|
|
73
|
+
expiresAt: now + WITHDRAW_TTL_MS,
|
|
74
|
+
withdrawnMsats: null,
|
|
75
|
+
paymentRequest: null,
|
|
76
|
+
};
|
|
77
|
+
withdrawSessions.set(k1, session);
|
|
78
|
+
return session;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getWithdrawSession(k1: string): TLnurlWithdrawSession | null {
|
|
82
|
+
const session = withdrawSessions.get(k1);
|
|
83
|
+
if (!session) return null;
|
|
84
|
+
if (Date.now() > session.expiresAt) {
|
|
85
|
+
withdrawSessions.delete(k1);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return session;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function completeWithdrawSession(
|
|
92
|
+
k1: string,
|
|
93
|
+
paymentRequest: string,
|
|
94
|
+
amountMsats: number,
|
|
95
|
+
): TLnurlWithdrawSession | null {
|
|
96
|
+
const session = getWithdrawSession(k1);
|
|
97
|
+
if (!session) return null;
|
|
98
|
+
session.paymentRequest = paymentRequest;
|
|
99
|
+
session.withdrawnMsats = amountMsats;
|
|
100
|
+
withdrawSessions.set(k1, session);
|
|
101
|
+
return session;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Creates a mock BOLT11 invoice for LNURL-pay callbacks.
|
|
106
|
+
* Replace with your Lightning node / LND / CLN integration in production.
|
|
107
|
+
*/
|
|
108
|
+
export function createLnurlPayInvoice(amountMsats: number, username: string): string {
|
|
109
|
+
const amountSats = Math.max(1, Math.floor(amountMsats / 1000));
|
|
110
|
+
const id = randomBytes(8).toString('hex');
|
|
111
|
+
return buildMockInvoice(amountSats, `${username}-${id}`);
|
|
112
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { bech32 } from '@scure/base';
|
|
2
|
+
|
|
3
|
+
/** LNURL metadata item: [mime type, payload]. */
|
|
4
|
+
export type TLnurlMetadataItem = [string, string];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Encodes a URL as an uppercase bech32 LNURL string (BIP-173, HRP `lnurl`).
|
|
8
|
+
*/
|
|
9
|
+
export function encodeLnurl(url: string): string {
|
|
10
|
+
const bytes = new TextEncoder().encode(url);
|
|
11
|
+
return bech32.encodeFromBytes('lnurl', bytes).toUpperCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Decodes a bech32 LNURL string back to the underlying HTTPS URL.
|
|
16
|
+
*/
|
|
17
|
+
export function decodeLnurl(lnurl: string): string {
|
|
18
|
+
const { words } = bech32.decode(lnurl.toLowerCase());
|
|
19
|
+
const bytes = bech32.fromWords(words);
|
|
20
|
+
return new TextDecoder().decode(bytes);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Builds the comma-separated metadata string required by LUD-06.
|
|
25
|
+
* @see https://github.com/lnurl/luds/blob/luds/06.md
|
|
26
|
+
*/
|
|
27
|
+
export function buildLnurlMetadata(items: TLnurlMetadataItem[]): string {
|
|
28
|
+
return JSON.stringify(items);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolves the public base URL for LNURL callbacks.
|
|
33
|
+
* Prefer `LNURL_SERVER_URL` in production behind proxies.
|
|
34
|
+
*/
|
|
35
|
+
export function getLnurlServerBaseUrl(request?: Request): string {
|
|
36
|
+
const fromEnv = process.env.LNURL_SERVER_URL?.trim();
|
|
37
|
+
if (fromEnv) return fromEnv.replace(/\/$/, '');
|
|
38
|
+
|
|
39
|
+
if (request) {
|
|
40
|
+
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
|
|
41
|
+
const proto = request.headers.get('x-forwarded-proto') ?? 'https';
|
|
42
|
+
if (host) return `${proto}://${host}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return 'http://localhost:3000';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Converts millisats to sats for display. */
|
|
49
|
+
export function msatsToSats(msats: number): number {
|
|
50
|
+
return Math.floor(msats / 1000);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Converts sats to millisats for LNURL amounts. */
|
|
54
|
+
export function satsToMsats(sats: number): number {
|
|
55
|
+
return sats * 1000;
|
|
56
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lnurl",
|
|
3
|
+
"description": "LNURL-pay, LNURL-auth, and LNURL-withdraw flows",
|
|
4
|
+
"dependencies": ["qrcode.react"],
|
|
5
|
+
"devDependencies": [],
|
|
6
|
+
"files": [
|
|
7
|
+
{ "src": "lib/lnurl-utils.ts", "dest": "lib/lnurl-utils.ts", "merge": "add" },
|
|
8
|
+
{ "src": "lib/lnurl-store.ts", "dest": "lib/lnurl-store.ts", "merge": "add" },
|
|
9
|
+
{ "src": "lib/lnurl-auth-verify.ts", "dest": "lib/lnurl-auth-verify.ts", "merge": "add" },
|
|
10
|
+
{ "src": "components/lnurl/LnurlQR.tsx", "dest": "components/lnurl/LnurlQR.tsx", "merge": "add" },
|
|
11
|
+
{ "src": "components/lnurl/LnurlPay.tsx", "dest": "components/lnurl/LnurlPay.tsx", "merge": "add" },
|
|
12
|
+
{ "src": "components/lnurl/LnurlAuth.tsx", "dest": "components/lnurl/LnurlAuth.tsx", "merge": "add" },
|
|
13
|
+
{ "src": "components/lnurl/LnurlWithdraw.tsx", "dest": "components/lnurl/LnurlWithdraw.tsx", "merge": "add" },
|
|
14
|
+
{ "src": "app/api/lnurlp/[username]/route.ts", "dest": "app/api/lnurlp/[username]/route.ts", "merge": "add" },
|
|
15
|
+
{ "src": "app/api/lnurlauth/route.ts", "dest": "app/api/lnurlauth/route.ts", "merge": "add" },
|
|
16
|
+
{ "src": "app/api/lnurlw/route.ts", "dest": "app/api/lnurlw/route.ts", "merge": "add" },
|
|
17
|
+
{ "src": "app/demo/lnurl/page.tsx", "dest": "app/demo/lnurl/page.tsx", "merge": "add" }
|
|
18
|
+
],
|
|
19
|
+
"envVars": [
|
|
20
|
+
{
|
|
21
|
+
"key": "LNURL_SERVER_URL",
|
|
22
|
+
"description": "Public base URL for LNURL callbacks (required behind reverse proxies)",
|
|
23
|
+
"example": "https://your-app.example.com",
|
|
24
|
+
"required": false
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { MultispendDemo } from '../../../components/multispend/MultispendDemo';
|
|
6
|
+
|
|
7
|
+
export function MultispendDemoClient() {
|
|
8
|
+
const [howItWorksOpen, setHowItWorksOpen] = useState(false);
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
|
|
12
|
+
<div
|
|
13
|
+
className="mx-auto w-full max-w-[390px] px-4 pt-6"
|
|
14
|
+
style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
|
|
15
|
+
>
|
|
16
|
+
<Link
|
|
17
|
+
href="/demo"
|
|
18
|
+
className="mb-6 inline-block text-xs text-[var(--color-text-muted)] transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
19
|
+
>
|
|
20
|
+
← back
|
|
21
|
+
</Link>
|
|
22
|
+
|
|
23
|
+
<header className="mb-8 space-y-2">
|
|
24
|
+
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
|
|
25
|
+
Multispend
|
|
26
|
+
</h1>
|
|
27
|
+
<p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
28
|
+
Fedi's threshold multi-signature spending for shared Stable Balance funds. No single
|
|
29
|
+
person controls the wallet. A configured number of voters must approve each withdrawal.
|
|
30
|
+
</p>
|
|
31
|
+
</header>
|
|
32
|
+
|
|
33
|
+
<div className="space-y-8">
|
|
34
|
+
<section className="space-y-3 rounded-xl px-4 py-3 text-sm leading-[1.65]"
|
|
35
|
+
style={{
|
|
36
|
+
background: 'var(--color-surface-1)',
|
|
37
|
+
border: '1px solid var(--color-border)',
|
|
38
|
+
borderRadius: 'var(--radius-lg)',
|
|
39
|
+
color: 'var(--color-text-muted)',
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<p>
|
|
43
|
+
<strong className="text-[var(--color-text)]">Why it matters:</strong> Groups (DAOs,
|
|
44
|
+
communities, teams) need shared treasuries without trusting one admin. Multispend
|
|
45
|
+
enforces collective approval before funds move.
|
|
46
|
+
</p>
|
|
47
|
+
<p>
|
|
48
|
+
<strong className="text-[var(--color-text)]">This demo is mocked.</strong> The real
|
|
49
|
+
Multispend API is not yet exposed to mini apps. Votes here use{' '}
|
|
50
|
+
<code className="font-mono text-xs">signEvent()</code> to show how Nostr signatures
|
|
51
|
+
could attest to approvals; execution is simulated locally.
|
|
52
|
+
</p>
|
|
53
|
+
</section>
|
|
54
|
+
|
|
55
|
+
<MultispendDemo />
|
|
56
|
+
|
|
57
|
+
<section className="space-y-3">
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={() => setHowItWorksOpen((open) => !open)}
|
|
61
|
+
className="flex w-full items-center justify-between rounded-lg px-4 py-3 text-left text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
62
|
+
style={{
|
|
63
|
+
background: 'var(--color-surface-1)',
|
|
64
|
+
border: '1px solid var(--color-border)',
|
|
65
|
+
color: 'var(--color-text)',
|
|
66
|
+
borderRadius: 'var(--radius-lg)',
|
|
67
|
+
}}
|
|
68
|
+
aria-expanded={howItWorksOpen}
|
|
69
|
+
aria-controls="multispend-how-it-works"
|
|
70
|
+
>
|
|
71
|
+
How Multispend works in Fedi
|
|
72
|
+
<span aria-hidden>{howItWorksOpen ? '−' : '+'}</span>
|
|
73
|
+
</button>
|
|
74
|
+
|
|
75
|
+
{howItWorksOpen && (
|
|
76
|
+
<div
|
|
77
|
+
id="multispend-how-it-works"
|
|
78
|
+
className="space-y-3 rounded-lg px-4 py-3 text-sm leading-[1.65]"
|
|
79
|
+
style={{
|
|
80
|
+
background: 'var(--color-surface-1)',
|
|
81
|
+
border: '1px solid var(--color-border)',
|
|
82
|
+
color: 'var(--color-text-muted)',
|
|
83
|
+
borderRadius: 'var(--radius-lg)',
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<p>
|
|
87
|
+
A group creates a Multispend wallet with up to 21 assigned{' '}
|
|
88
|
+
<strong className="text-[var(--color-text)]">voters</strong> and an{' '}
|
|
89
|
+
<strong className="text-[var(--color-text)]">approval threshold</strong> (e.g.
|
|
90
|
+
2-of-3). Voters and threshold are fixed at creation.
|
|
91
|
+
</p>
|
|
92
|
+
<p>
|
|
93
|
+
Members deposit into the shared fund. To spend, a member submits a withdrawal
|
|
94
|
+
request. Voters approve or reject. Once the threshold is met, Fedi processes the
|
|
95
|
+
withdrawal automatically. Funds move to the requester's personal wallet.
|
|
96
|
+
</p>
|
|
97
|
+
<p>
|
|
98
|
+
Mini apps cannot yet create Multispend groups or submit real withdrawal requests.
|
|
99
|
+
This module demonstrates the approval UX pattern and Nostr-signed votes so you
|
|
100
|
+
can design flows before the API lands.
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</section>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { MultispendDemoClient } from './MultispendDemoClient';
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: 'Multispend',
|
|
6
|
+
description:
|
|
7
|
+
'Mock Multispend workflow for Fedi mini apps. Create spending proposals, collect Nostr-signed approvals, and simulate threshold execution for shared wallets.',
|
|
8
|
+
openGraph: {
|
|
9
|
+
title: 'Multispend',
|
|
10
|
+
description:
|
|
11
|
+
'Mock Multispend workflow for Fedi mini apps. Create spending proposals, collect Nostr-signed approvals, and simulate threshold execution for shared wallets.',
|
|
12
|
+
},
|
|
13
|
+
twitter: {
|
|
14
|
+
card: 'summary',
|
|
15
|
+
title: 'Multispend',
|
|
16
|
+
description:
|
|
17
|
+
'Mock Multispend workflow for Fedi mini apps. Create spending proposals, collect Nostr-signed approvals, and simulate threshold execution for shared wallets.',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default function MultispendDemoPage() {
|
|
22
|
+
return <MultispendDemoClient />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useIdentity } from '../../lib/nostr';
|
|
5
|
+
import type { NostrEvent } from '../../lib/nostr';
|
|
6
|
+
import { MULTISPEND_VOTE_KIND, type TVoteDecision } from '../../lib/multispend-types';
|
|
7
|
+
|
|
8
|
+
interface IApprovalVoteProps {
|
|
9
|
+
proposalId: string;
|
|
10
|
+
voterPubkey: string;
|
|
11
|
+
currentPubkey: string | null;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
onVote: (vote: TVoteDecision, signedEvent: NostrEvent) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ApprovalVote({
|
|
17
|
+
proposalId,
|
|
18
|
+
voterPubkey,
|
|
19
|
+
currentPubkey,
|
|
20
|
+
disabled = false,
|
|
21
|
+
onVote,
|
|
22
|
+
}: IApprovalVoteProps) {
|
|
23
|
+
const { signEvent, isConnecting } = useIdentity();
|
|
24
|
+
const [isVoting, setIsVoting] = useState(false);
|
|
25
|
+
const [voteError, setVoteError] = useState<Error | null>(null);
|
|
26
|
+
const [lastVote, setLastVote] = useState<TVoteDecision | null>(null);
|
|
27
|
+
|
|
28
|
+
const isCurrentVoter = currentPubkey === voterPubkey;
|
|
29
|
+
|
|
30
|
+
async function castVote(vote: TVoteDecision) {
|
|
31
|
+
if (!currentPubkey || !isCurrentVoter) return;
|
|
32
|
+
|
|
33
|
+
setIsVoting(true);
|
|
34
|
+
setVoteError(null);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const signedEvent = await signEvent({
|
|
38
|
+
kind: MULTISPEND_VOTE_KIND,
|
|
39
|
+
content: JSON.stringify({ proposalId, vote }),
|
|
40
|
+
tags: [
|
|
41
|
+
['d', proposalId],
|
|
42
|
+
['vote', vote],
|
|
43
|
+
],
|
|
44
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!signedEvent) {
|
|
48
|
+
setVoteError(new Error('Could not sign vote. Connect your Nostr identity first.'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setLastVote(vote);
|
|
53
|
+
onVote(vote, signedEvent);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
setVoteError(err instanceof Error ? err : new Error('Vote signing failed'));
|
|
56
|
+
} finally {
|
|
57
|
+
setIsVoting(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!isCurrentVoter) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (lastVote) {
|
|
66
|
+
return (
|
|
67
|
+
<p
|
|
68
|
+
className="text-xs font-medium"
|
|
69
|
+
style={{ color: lastVote === 'approve' ? 'var(--color-accent)' : 'var(--color-error, #ef4444)' }}
|
|
70
|
+
role="status"
|
|
71
|
+
>
|
|
72
|
+
You {lastVote === 'approve' ? 'approved' : 'rejected'} this proposal via Nostr signature.
|
|
73
|
+
</p>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-col gap-2">
|
|
79
|
+
<div className="flex flex-wrap gap-2">
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={() => castVote('approve')}
|
|
83
|
+
disabled={disabled || isVoting || isConnecting}
|
|
84
|
+
className="rounded-lg px-4 py-2 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
|
|
85
|
+
style={{
|
|
86
|
+
background: 'var(--color-accent)',
|
|
87
|
+
color: 'var(--color-primary-foreground)',
|
|
88
|
+
borderRadius: 'var(--radius-md)',
|
|
89
|
+
}}
|
|
90
|
+
aria-label="Approve spending proposal"
|
|
91
|
+
>
|
|
92
|
+
{isVoting ? 'Signing…' : 'Approve'}
|
|
93
|
+
</button>
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => castVote('reject')}
|
|
97
|
+
disabled={disabled || isVoting || isConnecting}
|
|
98
|
+
className="rounded-lg px-4 py-2 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
|
|
99
|
+
style={{
|
|
100
|
+
background: 'var(--color-surface-1)',
|
|
101
|
+
border: '1px solid var(--color-border)',
|
|
102
|
+
color: 'var(--color-text)',
|
|
103
|
+
borderRadius: 'var(--radius-md)',
|
|
104
|
+
}}
|
|
105
|
+
aria-label="Reject spending proposal"
|
|
106
|
+
>
|
|
107
|
+
Reject
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
<p className="text-xs leading-[1.65] text-[var(--color-text-subtle)]">
|
|
111
|
+
Votes are signed with{' '}
|
|
112
|
+
<code className="font-mono text-[11px]">signEvent()</code> (kind {MULTISPEND_VOTE_KIND}).
|
|
113
|
+
Mock co-voters auto-approve after your signature so you can complete the demo flow.
|
|
114
|
+
</p>
|
|
115
|
+
{voteError && (
|
|
116
|
+
<p className="text-xs text-[var(--color-error,#ef4444)]" role="alert">
|
|
117
|
+
{voteError.message}
|
|
118
|
+
</p>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useIdentity } from '../../lib/nostr';
|
|
5
|
+
import { IdentityBadge } from '../nostr/IdentityBadge';
|
|
6
|
+
import { useMultispendDemo } from '../../hooks/useMultispendDemo';
|
|
7
|
+
import { ProposalList } from './ProposalList';
|
|
8
|
+
|
|
9
|
+
export function MultispendDemo() {
|
|
10
|
+
const { pubkey, getPublicKey, isConnecting } = useIdentity();
|
|
11
|
+
const [localPubkey, setLocalPubkey] = useState<string | null>(null);
|
|
12
|
+
const [amountSats, setAmountSats] = useState('25000');
|
|
13
|
+
const [description, setDescription] = useState('');
|
|
14
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
proposals,
|
|
18
|
+
openProposals,
|
|
19
|
+
lastSignedEvent,
|
|
20
|
+
executingId,
|
|
21
|
+
createProposal,
|
|
22
|
+
recordVote,
|
|
23
|
+
simulateExecution,
|
|
24
|
+
} = useMultispendDemo();
|
|
25
|
+
|
|
26
|
+
const activePubkey = pubkey ?? localPubkey;
|
|
27
|
+
|
|
28
|
+
async function ensureConnected(): Promise<string | null> {
|
|
29
|
+
if (activePubkey) return activePubkey;
|
|
30
|
+
const pk = await getPublicKey();
|
|
31
|
+
if (pk) setLocalPubkey(pk);
|
|
32
|
+
return pk;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function handleCreateProposal(event: React.FormEvent) {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
setCreateError(null);
|
|
38
|
+
|
|
39
|
+
const proposerPubkey = await ensureConnected();
|
|
40
|
+
if (!proposerPubkey) {
|
|
41
|
+
setCreateError('Connect your Nostr identity to create a proposal.');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parsedAmount = Number.parseInt(amountSats, 10);
|
|
46
|
+
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
|
47
|
+
setCreateError('Enter a valid amount in sats.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!description.trim()) {
|
|
52
|
+
setCreateError('Add a description for the spending request.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
createProposal({
|
|
57
|
+
amountSats: parsedAmount,
|
|
58
|
+
description: description.trim(),
|
|
59
|
+
proposerPubkey,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
setDescription('');
|
|
63
|
+
setAmountSats('25000');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="space-y-8">
|
|
68
|
+
<section className="space-y-4">
|
|
69
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
70
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
71
|
+
Your identity
|
|
72
|
+
</h2>
|
|
73
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
74
|
+
Connect with Nostr to vote on proposals. Your pubkey becomes one of the required signers
|
|
75
|
+
when you create a request.
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
<IdentityBadge />
|
|
79
|
+
</section>
|
|
80
|
+
|
|
81
|
+
<section className="space-y-4">
|
|
82
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
83
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
84
|
+
Create proposal
|
|
85
|
+
</h2>
|
|
86
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
87
|
+
Mock a withdrawal request from a shared Multispend wallet. Other voters are simulated.
|
|
88
|
+
Only your vote uses a real Nostr signature.
|
|
89
|
+
</p>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<form
|
|
93
|
+
onSubmit={handleCreateProposal}
|
|
94
|
+
className="space-y-4 rounded-xl p-4"
|
|
95
|
+
style={{
|
|
96
|
+
background: 'var(--color-surface-1)',
|
|
97
|
+
border: '1px solid var(--color-border)',
|
|
98
|
+
borderRadius: 'var(--radius-lg)',
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<label className="flex flex-col gap-1.5">
|
|
102
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
|
|
103
|
+
Amount (sats)
|
|
104
|
+
</span>
|
|
105
|
+
<input
|
|
106
|
+
type="number"
|
|
107
|
+
min={1}
|
|
108
|
+
step={1}
|
|
109
|
+
value={amountSats}
|
|
110
|
+
onChange={(e) => setAmountSats(e.target.value)}
|
|
111
|
+
className="w-full rounded-lg px-3 py-2 text-sm"
|
|
112
|
+
style={{
|
|
113
|
+
background: 'var(--color-surface-2, var(--color-bg))',
|
|
114
|
+
border: '1px solid var(--color-border)',
|
|
115
|
+
color: 'var(--color-text)',
|
|
116
|
+
borderRadius: 'var(--radius-md)',
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
</label>
|
|
120
|
+
|
|
121
|
+
<label className="flex flex-col gap-1.5">
|
|
122
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
|
|
123
|
+
Description
|
|
124
|
+
</span>
|
|
125
|
+
<textarea
|
|
126
|
+
value={description}
|
|
127
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
128
|
+
rows={3}
|
|
129
|
+
placeholder="What is this withdrawal for?"
|
|
130
|
+
className="w-full resize-none rounded-lg px-3 py-2 text-sm"
|
|
131
|
+
style={{
|
|
132
|
+
background: 'var(--color-surface-2, var(--color-bg))',
|
|
133
|
+
border: '1px solid var(--color-border)',
|
|
134
|
+
color: 'var(--color-text)',
|
|
135
|
+
borderRadius: 'var(--radius-md)',
|
|
136
|
+
}}
|
|
137
|
+
/>
|
|
138
|
+
</label>
|
|
139
|
+
|
|
140
|
+
<p className="text-xs leading-[1.65] text-[var(--color-text-subtle)]">
|
|
141
|
+
New proposals use a 2-of-3 threshold with you plus two mock voters (Alice and Bob).
|
|
142
|
+
</p>
|
|
143
|
+
|
|
144
|
+
{createError && (
|
|
145
|
+
<p className="text-xs text-[var(--color-error,#ef4444)]" role="alert">
|
|
146
|
+
{createError}
|
|
147
|
+
</p>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
<button
|
|
151
|
+
type="submit"
|
|
152
|
+
disabled={isConnecting}
|
|
153
|
+
className="rounded-lg px-4 py-2 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
|
|
154
|
+
style={{
|
|
155
|
+
background: 'var(--color-accent)',
|
|
156
|
+
color: 'var(--color-primary-foreground)',
|
|
157
|
+
borderRadius: 'var(--radius-md)',
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
Create proposal
|
|
161
|
+
</button>
|
|
162
|
+
</form>
|
|
163
|
+
</section>
|
|
164
|
+
|
|
165
|
+
<section className="space-y-4">
|
|
166
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
167
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
168
|
+
Open proposals
|
|
169
|
+
</h2>
|
|
170
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
171
|
+
Approve or reject pending requests. When enough voters agree, simulate the withdrawal
|
|
172
|
+
execution.
|
|
173
|
+
</p>
|
|
174
|
+
</div>
|
|
175
|
+
<ProposalList
|
|
176
|
+
proposals={openProposals}
|
|
177
|
+
currentPubkey={activePubkey}
|
|
178
|
+
onVote={(proposalId, vote, signedEvent) =>
|
|
179
|
+
recordVote(proposalId, activePubkey!, vote, signedEvent)
|
|
180
|
+
}
|
|
181
|
+
onExecute={simulateExecution}
|
|
182
|
+
executingId={executingId}
|
|
183
|
+
/>
|
|
184
|
+
</section>
|
|
185
|
+
|
|
186
|
+
{proposals.some((p) => p.status === 'executed') && (
|
|
187
|
+
<section className="space-y-4">
|
|
188
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
189
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
190
|
+
Executed
|
|
191
|
+
</h2>
|
|
192
|
+
</div>
|
|
193
|
+
<ProposalList
|
|
194
|
+
proposals={proposals.filter((p) => p.status === 'executed')}
|
|
195
|
+
currentPubkey={activePubkey}
|
|
196
|
+
/>
|
|
197
|
+
</section>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{lastSignedEvent && (
|
|
201
|
+
<section className="space-y-2">
|
|
202
|
+
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
|
|
203
|
+
Last signed vote event
|
|
204
|
+
</p>
|
|
205
|
+
<pre
|
|
206
|
+
className="overflow-auto rounded-lg p-3 font-mono text-xs leading-relaxed"
|
|
207
|
+
style={{
|
|
208
|
+
background: 'var(--color-surface-1)',
|
|
209
|
+
border: '1px solid var(--color-border)',
|
|
210
|
+
color: 'var(--color-text-muted)',
|
|
211
|
+
borderRadius: 'var(--radius-md)',
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{JSON.stringify(lastSignedEvent, null, 2)}
|
|
215
|
+
</pre>
|
|
216
|
+
</section>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|