├── .eslintrc.json ├── public ├── favicon.ico ├── twelve.png ├── twelve-cash-hero.webp ├── twelve-cash-poster.png ├── twelvecash-favicon.png ├── vercel.svg └── next.svg ├── src ├── app │ ├── favicon.ico │ ├── features │ │ ├── Tagline.tsx │ │ ├── CopyBip353Button.tsx │ │ ├── CopyUserLinkButton.tsx │ │ ├── NostrAuthSpinner.tsx │ │ ├── SearchForm.tsx │ │ ├── UserDetails.tsx │ │ └── NewPayCodeForm.tsx │ ├── search │ │ └── page.tsx │ ├── components │ │ ├── getUserServer.tsx │ │ ├── TwelveCashLogo.tsx │ │ ├── LogoutButton.tsx │ │ ├── UserProvider.tsx │ │ ├── ClientUserProvider.tsx │ │ ├── InteractionModal.tsx │ │ ├── InputZ.tsx │ │ ├── Header.tsx │ │ ├── Input.tsx │ │ ├── Button.tsx │ │ ├── PaymentDetail.tsx │ │ ├── Bip353Box.tsx │ │ └── Invoice.tsx │ ├── auth │ │ ├── page.tsx │ │ └── nostr │ │ │ └── page.tsx │ ├── globals.css │ ├── new │ │ ├── page.tsx │ │ └── [user] │ │ │ └── page.tsx │ ├── [user] │ │ └── page.tsx │ ├── api │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── page.tsx │ ├── account │ │ └── page.tsx │ ├── layout.tsx │ └── record │ │ └── route.ts ├── lib │ ├── dnssec-prover │ │ ├── dnssec_prover_wasm_bg.wasm │ │ ├── package.json │ │ ├── dnssec_prover_wasm_bg.wasm.d.ts │ │ ├── doh_lookup.js │ │ ├── dnssec_prover_wasm.d.ts │ │ └── dnssec_prover_wasm.js │ └── util │ │ ├── useZodForm.ts │ │ ├── constant.ts │ │ ├── index.ts │ │ └── index.test.ts ├── server │ ├── db.ts │ ├── api │ │ ├── root.ts │ │ ├── routers │ │ │ ├── user.ts │ │ │ ├── auth.ts │ │ │ └── payCode.ts │ │ └── trpc.ts │ └── lnd.ts ├── trpc │ ├── query-client.ts │ ├── server.ts │ └── react.tsx └── env.js ├── postcss.config.js ├── .env.test ├── jest.config.ts ├── .gitignore ├── tsconfig.json ├── .env.sample ├── LICENSE ├── next.config.js ├── package.json ├── tailwind.config.ts ├── start-database.sh ├── prisma └── schema.prisma └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATLBitLab/twelvecash/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/twelve.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATLBitLab/twelvecash/HEAD/public/twelve.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATLBitLab/twelvecash/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/twelve-cash-hero.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATLBitLab/twelvecash/HEAD/public/twelve-cash-hero.webp -------------------------------------------------------------------------------- /public/twelve-cash-poster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATLBitLab/twelvecash/HEAD/public/twelve-cash-poster.png -------------------------------------------------------------------------------- /public/twelvecash-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATLBitLab/twelvecash/HEAD/public/twelvecash-favicon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | NETWORK="regtest" 2 | DOMAINS={"12cash.dev": "id123"} 3 | PROVIDER="cloudflare" 4 | CF_TOKEN="" 5 | JWT_SECRET="abc123" -------------------------------------------------------------------------------- /src/lib/dnssec-prover/dnssec_prover_wasm_bg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATLBitLab/twelvecash/HEAD/src/lib/dnssec-prover/dnssec_prover_wasm_bg.wasm -------------------------------------------------------------------------------- /src/app/features/Tagline.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export default function Tagline(){ 4 | const deepAlpha = () => { 5 | alert("this is deep alpha, y'all"); 6 | } 7 | 8 | return( 9 | <> 10 |

A simple way to receive bitcoin*

11 | 12 | ) 13 | } -------------------------------------------------------------------------------- /src/app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import SearchForm from "../features/SearchForm"; 2 | 3 | export default function Check() { 4 | const defaultDomain = process.env.DOMAIN ? process.env.DOMAIN : "twelve.cash"; 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest"; 2 | import nextJest from "next/jest.js"; 3 | 4 | const createJestConfig = nextJest({ 5 | dir: "./", 6 | }); 7 | 8 | const config: Config = { 9 | coverageProvider: "v8", 10 | testEnvironment: "jsdom", 11 | }; 12 | 13 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 14 | export default createJestConfig(config); 15 | -------------------------------------------------------------------------------- /src/lib/util/useZodForm.ts: -------------------------------------------------------------------------------- 1 | import { useForm, UseFormProps } from "react-hook-form"; 2 | import { zodResolver } from "@hookform/resolvers/zod"; 3 | import { z } from "zod"; 4 | 5 | export const useZodForm = ( 6 | props: Omit, "resolver"> & { 7 | schema: TSchema; 8 | } 9 | ) => { 10 | const form = useForm({ 11 | mode: "all", 12 | ...props, 13 | resolver: zodResolver(props.schema, undefined), 14 | }); 15 | 16 | return form; 17 | }; 18 | -------------------------------------------------------------------------------- /src/server/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | import { env } from "@/env"; 4 | 5 | const createPrismaClient = () => 6 | new PrismaClient({ 7 | log: 8 | env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], 9 | }); 10 | 11 | const globalForPrisma = globalThis as unknown as { 12 | prisma: ReturnType | undefined; 13 | }; 14 | 15 | export const db = globalForPrisma.prisma ?? createPrismaClient(); 16 | 17 | if (env.NODE_ENV !== "production") globalForPrisma.prisma = db; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | .env-e 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /src/app/components/getUserServer.tsx: -------------------------------------------------------------------------------- 1 | import { parse } from "cookie"; 2 | import { headers } from "next/headers"; 3 | import jwt from "jsonwebtoken"; 4 | import { TokenUser } from "@/server/api/trpc"; 5 | 6 | // Get the user on server components 7 | export default function getUser() { 8 | const cookieHeader = headers().get("cookie") || ""; 9 | const cookies = cookieHeader ? parse(cookieHeader) : {}; 10 | const accessToken = cookies["access-token"]; 11 | const user = accessToken 12 | ? (jwt.verify(accessToken, process.env.JWT_SECRET ?? "") as TokenUser) 13 | : undefined; 14 | return user; 15 | } 16 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/features/CopyBip353Button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Button from "../components/Button"; 3 | import { CopyIcon } from '@bitcoin-design/bitcoin-icons-react/filled'; 4 | import type { Bip353 } from "@/lib/util"; 5 | import { useState } from "react"; 6 | 7 | export default function CopyBip353Button(props:Bip353){ 8 | const [copied, setCopied] = useState(false); 9 | return( 10 | <> 11 | 14 | 15 | ) 16 | } -------------------------------------------------------------------------------- /src/app/auth/page.tsx: -------------------------------------------------------------------------------- 1 | import Button from "../components/Button"; 2 | import getUserServer from "../components/getUserServer"; 3 | 4 | export default function Auth() { 5 | const user = getUserServer(); 6 | if (user) { 7 | return ( 8 |
9 |

You shouldn't be here

10 |
11 | ); 12 | } 13 | return ( 14 |
15 |

Login with your Nostr key so that you can keep track of your user names.

16 | 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: light) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body, html { 20 | @apply h-full bg-12teal p-2; 21 | } 22 | 23 | a { 24 | @apply underline; 25 | } 26 | 27 | h1, .h1 { 28 | @apply text-2xl md:text-4xl; 29 | } 30 | 31 | h2, .h2 { 32 | @apply text-3xl; 33 | } 34 | 35 | h3, .h3 { 36 | @apply text-2xl font-normal; 37 | } -------------------------------------------------------------------------------- /src/app/components/TwelveCashLogo.tsx: -------------------------------------------------------------------------------- 1 | type TwelveCashLogoProps = { 2 | size?: "small" | "large"; 3 | } 4 | 5 | export default function TwelveCashLogo(props:TwelveCashLogoProps) { 6 | return( 7 | <> 8 | 9 | Twelve Cash 10 | 11 | 12 | ) 13 | } -------------------------------------------------------------------------------- /src/lib/dnssec-prover/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dnssec-prover-wasm", 3 | "collaborators": [ 4 | "Matt Corallo" 5 | ], 6 | "description": "A simple crate which allows for the creation and validation of transferrable proofs of entries in the DNS.", 7 | "version": "0.1.0", 8 | "license": "MIT OR Apache-2.0", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://git.bitcoin.ninja/index.cgi?p=dnssec-prover" 12 | }, 13 | "files": [ 14 | "dnssec_prover_wasm_bg.wasm", 15 | "dnssec_prover_wasm.js", 16 | "dnssec_prover_wasm.d.ts" 17 | ], 18 | "module": "dnssec_prover_wasm.js", 19 | "types": "dnssec_prover_wasm.d.ts", 20 | "sideEffects": [ 21 | "./snippets/*" 22 | ] 23 | } -------------------------------------------------------------------------------- /src/app/features/CopyUserLinkButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Button from "../components/Button"; 3 | import { LinkIcon } from '@bitcoin-design/bitcoin-icons-react/filled'; 4 | import { useState } from "react"; 5 | 6 | type CopyUserLinkButtonProps = { 7 | link: string; 8 | } 9 | 10 | export default function CopyUserLinkButton(props:CopyUserLinkButtonProps){ 11 | const [copied, setCopied] = useState(false); 12 | return( 13 | <> 14 | 17 | 18 | ) 19 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/app/components/LogoutButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { useUser } from "./ClientUserProvider"; // Adjust the import path as necessary 5 | import Button from "./Button"; 6 | import { api } from "@/trpc/react"; 7 | 8 | export default function LogoutButton() { 9 | const router = useRouter(); 10 | const { setUser } = useUser(); 11 | const logout = api.user.logout.useMutation({ 12 | onSuccess: () => { 13 | console.debug("logged out"); 14 | setUser(undefined); 15 | router.push(`/`); 16 | }, 17 | onError: () => { 18 | console.error("Failed to log in"); 19 | }, 20 | }); 21 | 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /src/app/components/UserProvider.tsx: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import { TokenUser } from "@/server/api/trpc"; 3 | import { headers } from "next/headers"; 4 | import { parse } from "cookie"; 5 | import ClientUserProvider from "./ClientUserProvider"; 6 | 7 | export default function UserProvider({ 8 | children, 9 | }: { 10 | children: React.ReactNode; 11 | }) { 12 | const cookieHeader = headers().get("cookie") || ""; 13 | const cookies = cookieHeader ? parse(cookieHeader) : {}; 14 | const accessToken = cookies["access-token"]; 15 | const user = accessToken 16 | ? (jwt.verify(accessToken, process.env.JWT_SECRET ?? "") as TokenUser) 17 | : undefined; 18 | 19 | return {children}; 20 | } 21 | -------------------------------------------------------------------------------- /src/app/new/page.tsx: -------------------------------------------------------------------------------- 1 | import NewPayCodeForm from "@/app/features/NewPayCodeForm"; 2 | import { DOMAINS } from "@/lib/util/constant"; 3 | import type { TwelveCashDomains } from "@/lib/util/constant"; 4 | import { env } from "@/env"; 5 | 6 | const getDefaultDomain = (): TwelveCashDomains => { 7 | const domain = Object.keys(env.DOMAINS)[0] as TwelveCashDomains; 8 | return DOMAINS.includes(domain) ? domain : "12cash.dev"; 9 | }; 10 | 11 | const defaultDomain = getDefaultDomain(); 12 | 13 | export default function New() { 14 | 15 | return ( 16 |
17 |

Create a Pay Code

18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/trpc/query-client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultShouldDehydrateQuery, 3 | QueryClient, 4 | } from "@tanstack/react-query"; 5 | import SuperJSON from "superjson"; 6 | 7 | export const createQueryClient = () => 8 | new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | // With SSR, we usually want to set some default staleTime 12 | // above 0 to avoid refetching immediately on the client 13 | staleTime: 30 * 1000, 14 | }, 15 | dehydrate: { 16 | serializeData: SuperJSON.serialize, 17 | shouldDehydrateQuery: (query) => 18 | defaultShouldDehydrateQuery(query) || 19 | query.state.status === "pending", 20 | }, 21 | hydrate: { 22 | deserializeData: SuperJSON.deserialize, 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/features/NostrAuthSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { RefreshIcon } from "@bitcoin-design/bitcoin-icons-react/filled"; 2 | import Button from "../components/Button"; 3 | 4 | type NostrAuthSpinnerProps = { 5 | text: string; 6 | button?: boolean; 7 | buttonText?: string; 8 | buttonFunction?: () => void; 9 | buttonDisabled?: boolean; 10 | } 11 | 12 | export default function NostrAuthSpinner(props:NostrAuthSpinnerProps){ 13 | return( 14 | <> 15 | 16 |

{props.text}

17 | {props.button ? 18 | 19 | : ``} 20 | 21 | 22 | ) 23 | } -------------------------------------------------------------------------------- /src/lib/dnssec-prover/dnssec_prover_wasm_bg.wasm.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | export const memory: WebAssembly.Memory; 4 | export function __wbg_wasmproofbuilder_free(a: number): void; 5 | export function init_proof_builder(a: number, b: number, c: number): number; 6 | export function process_query_response(a: number, b: number, c: number): void; 7 | export function get_next_query(a: number, b: number): void; 8 | export function get_unverified_proof(a: number, b: number): void; 9 | export function verify_byte_stream(a: number, b: number, c: number, d: number, e: number): void; 10 | export function __wbindgen_malloc(a: number, b: number): number; 11 | export function __wbindgen_realloc(a: number, b: number, c: number, d: number): number; 12 | export function __wbindgen_add_to_stack_pointer(a: number): number; 13 | export function __wbindgen_free(a: number, b: number, c: number): void; 14 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # CloudFlare API Token 2 | CF_TOKEN="abcd1234" 3 | 4 | # Dictionary of "Domain": "CloudFlare Domain ID" 5 | # Domain must be the root domain, not a subdomain, e.g. "example.com" and not "subdomain.example.com" 6 | DOMAINS={"12cash.dev": "domainId123", "twelve.cash": "domainId123"} 7 | 8 | # DNS Provider (digitalocean, cloudflare), use cloudflare for DNSSEC 9 | PROVIDER="cloudflare" 10 | 11 | # Network: "" for mainnet, "testnet" for testnet, "regtest" for regtest 12 | NETWORK="regtest" 13 | 14 | # Prisma 15 | # https://www.prisma.io/docs/reference/database-reference/connection-urls#env 16 | DATABASE_URL="postgresql://postgres:password@localhost:5432/twelvecash" 17 | 18 | # Secret value for signing JWTs 19 | JWT_SECRET="abc123" 20 | 21 | # LND connection info 22 | LND_HOST="" # Rest host ex. https://127.0.0.1 23 | LND_PORT="" # ex. 8082 24 | LND_MACAROON="" # base64 invoices macaroon 25 | LND_TLS_CERT="" # base64 26 | -------------------------------------------------------------------------------- /src/server/api/root.ts: -------------------------------------------------------------------------------- 1 | import { userRouter } from "@/server/api/routers/user"; 2 | import { authRouter } from "@/server/api/routers/auth"; 3 | import { payCodeRouter } from "@/server/api/routers/payCode"; 4 | import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc"; 5 | 6 | /** 7 | * This is the primary router for your server. 8 | * 9 | * All routers added in /api/routers should be manually added here. 10 | */ 11 | export const appRouter = createTRPCRouter({ 12 | auth: authRouter, 13 | user: userRouter, 14 | payCode: payCodeRouter, 15 | }); 16 | 17 | // export type definition of API 18 | export type AppRouter = typeof appRouter; 19 | 20 | /** 21 | * Create a server-side caller for the tRPC API. 22 | * @example 23 | * const trpc = createCaller(createContext); 24 | * const res = await trpc.post.all(); 25 | * ^? Post[] 26 | */ 27 | export const createCaller = createCallerFactory(appRouter); 28 | -------------------------------------------------------------------------------- /src/server/api/routers/user.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; 2 | import { TRPCError } from "@trpc/server"; 3 | 4 | export const userRouter = createTRPCRouter({ 5 | getMe: protectedProcedure.query(async ({ ctx }) => { 6 | console.debug("GET ME"); 7 | console.debug("user", ctx.user); 8 | const tokenUser = ctx.user; 9 | try { 10 | return await ctx.db.user.findUnique({ 11 | where: { 12 | id: tokenUser?.id, 13 | }, 14 | }); 15 | } catch (error) { 16 | throw new TRPCError({ 17 | code: "UNAUTHORIZED", 18 | message: "User not found.", 19 | }); 20 | } 21 | }), 22 | logout: protectedProcedure.mutation(async ({ ctx }) => { 23 | ctx.resHeaders?.resHeaders.set( 24 | "Set-Cookie", 25 | `access-token=; Path=/; HttpOnly; SameSite=Strict; Expires ${new Date(0)}` 26 | ); 27 | return { result: "Success" }; 28 | }), 29 | }); 30 | -------------------------------------------------------------------------------- /src/trpc/server.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | 3 | import { createHydrationHelpers } from "@trpc/react-query/rsc"; 4 | import { headers } from "next/headers"; 5 | import { cache } from "react"; 6 | 7 | import { createCaller, type AppRouter } from "@/server/api/root"; 8 | import { createTRPCContext } from "@/server/api/trpc"; 9 | import { createQueryClient } from "./query-client"; 10 | 11 | /** 12 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when 13 | * handling a tRPC call from a React Server Component. 14 | */ 15 | const createContext = cache(() => { 16 | const heads = new Headers(headers()); 17 | heads.set("x-trpc-source", "rsc"); 18 | 19 | return createTRPCContext({ 20 | headers: heads, 21 | }); 22 | }); 23 | 24 | const getQueryClient = cache(createQueryClient); 25 | const caller = createCaller(createContext); 26 | 27 | export const { trpc: api, HydrateClient } = createHydrationHelpers( 28 | caller, 29 | getQueryClient 30 | ); 31 | -------------------------------------------------------------------------------- /src/app/[user]/page.tsx: -------------------------------------------------------------------------------- 1 | import Bip353Box from "../components/Bip353Box"; 2 | import CopyUserLinkButton from "../features/CopyUserLinkButton"; 3 | import CopyBip353Button from "../features/CopyBip353Button"; 4 | import PaymentDetail from "../components/PaymentDetail"; 5 | import Button from "../components/Button"; 6 | import { ArrowLeftIcon } from "@bitcoin-design/bitcoin-icons-react/filled"; 7 | import UserDetails from "../features/UserDetails"; 8 | 9 | export default function User({ params }: { params: { user: string } }) { 10 | const decoded = decodeURIComponent(params.user); 11 | const [user, domain] = decoded.split("@"); 12 | return ( 13 |
14 | 15 |
16 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Stephen DeLorme & Chad Welch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/components/ClientUserProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TokenUser } from "@/server/api/trpc"; 4 | import React, { createContext, useContext, useState, useEffect } from "react"; 5 | 6 | interface UserContextType { 7 | user?: TokenUser; 8 | setUser: React.Dispatch>; 9 | } 10 | 11 | const UserContext = createContext(undefined); 12 | 13 | export default function ClientUserProvider({ 14 | children, 15 | initialUser, 16 | }: { 17 | children: React.ReactNode; 18 | initialUser: TokenUser | undefined; 19 | }) { 20 | const [user, setUser] = useState(initialUser); 21 | 22 | useEffect(() => { 23 | if (initialUser) { 24 | setUser(initialUser); 25 | } 26 | }, [initialUser]); 27 | 28 | return ( 29 | 30 | {children} 31 | 32 | ); 33 | } 34 | 35 | export function useUser(): UserContextType { 36 | const context = useContext(UserContext); 37 | if (context === undefined) { 38 | throw new Error("useUser must be used within a UserProvider"); 39 | } 40 | return context; 41 | } 42 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | const { env } = require("./src/env"); 4 | 5 | /** 6 | * Don't be scared of the generics here. 7 | * All they do is to give us autocompletion when using this. 8 | * 9 | * @template {import('next').NextConfig} T 10 | * @param {T} config - A generic parameter that flows through to the return type 11 | * @constraint {{import('next').NextConfig}} 12 | */ 13 | function getConfig(config) { 14 | return config; 15 | } 16 | 17 | /** 18 | * @link https://nextjs.org/docs/api-reference/next.config.js/introduction 19 | */ 20 | module.exports = getConfig({ 21 | /** 22 | * Dynamic configuration available for the browser and server. 23 | * Note: requires `ssr: true` or a `getInitialProps` in `_app.tsx` 24 | * @link https://nextjs.org/docs/api-reference/next.config.js/runtime-configuration 25 | */ 26 | publicRuntimeConfig: { 27 | NODE_ENV: env.NODE_ENV, 28 | }, 29 | /** We run eslint as a separate task in CI */ 30 | eslint: { ignoreDuringBuilds: !!process.env.CI }, 31 | /** We run typechecking as a separate task in CI */ 32 | typescript: { 33 | ignoreBuildErrors: true, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/app/features/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Button from "../components/Button"; 4 | import Input from "../components/Input"; 5 | import { SearchIcon } from "@bitcoin-design/bitcoin-icons-react/filled"; 6 | import { Inter } from 'next/font/google' 7 | import { useState } from "react"; 8 | 9 | const inter = Inter({ subsets: ['latin'] }) 10 | 11 | type SearchFormProps = { 12 | defaultDomain: string; 13 | } 14 | 15 | export default function SearchForm(props:SearchFormProps) { 16 | const [userNameToCheck, setUserNameToCheck] = useState("stephen@" + props.defaultDomain); 17 | 18 | const updateUserNameToCheck = (value:string) => { 19 | setUserNameToCheck(value); 20 | } 21 | 22 | return ( 23 | <> 24 |

Check Payment Code

25 |
26 | 27 |
28 |
29 | 30 |
31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/env.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is included in `/next.config.js` which ensures the app isn't built with invalid env vars. 3 | * It has to be a `.js`-file to be imported there. 4 | */ 5 | const { z } = require("zod"); 6 | 7 | let domainMap = {}; 8 | if (process.env.DOMAINS) { 9 | try { 10 | domainMap = JSON.parse(process.env.DOMAINS); 11 | } catch (e) { 12 | console.error("Failed to parse DOMAIN_MAP:", e); 13 | process.exit(1); 14 | } 15 | } 16 | 17 | const envSchema = z 18 | .object({ 19 | NETWORK: z.enum(["", "testnet", "regtest"]), 20 | DOMAINS: z.record(z.string(), z.string()), 21 | CF_TOKEN: z.string(), 22 | DATABASE_URL: z.string(), 23 | JWT_SECRET: z.string(), 24 | LND_HOST: z.string(), 25 | LND_PORT: z.string(), 26 | LND_MACAROON: z.string(), 27 | LND_TLS_CERT: z.string(), 28 | NODE_ENV: z.enum(["development", "test", "production"]), 29 | }) 30 | .refine( 31 | (data) => Object.keys(data.DOMAINS).length > 0, 32 | "Requires at least one domain: domainId pair" 33 | ); 34 | const env = envSchema.safeParse({ ...process.env, DOMAINS: domainMap }); 35 | 36 | if (!env.success) { 37 | console.error( 38 | "❌ Invalid environment variables:", 39 | JSON.stringify(env.error.format(), null, 4) 40 | ); 41 | process.exit(1); 42 | } 43 | 44 | module.exports.env = env.data; 45 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FetchCreateContextFnOptions, 3 | fetchRequestHandler, 4 | } from "@trpc/server/adapters/fetch"; 5 | import { type NextRequest, type NextResponse } from "next/server"; 6 | 7 | import { env } from "@/env"; 8 | import { appRouter } from "@/server/api/root"; 9 | import { createTRPCContext } from "@/server/api/trpc"; 10 | 11 | /** 12 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when 13 | * handling a HTTP request (e.g. when you make requests from Client Components). 14 | */ 15 | const createContext = async ( 16 | req: NextRequest, 17 | resHeaders: FetchCreateContextFnOptions 18 | ) => { 19 | return createTRPCContext({ 20 | headers: req.headers, 21 | // resHeaders added in so we can set the JWT in the cookie after authentication 22 | resHeaders: resHeaders, 23 | }); 24 | }; 25 | 26 | const handler = (req: NextRequest, res: NextResponse) => 27 | fetchRequestHandler({ 28 | endpoint: "/api/trpc", 29 | req, 30 | router: appRouter, 31 | createContext: (resHeaders) => createContext(req, resHeaders), 32 | onError: 33 | env.NODE_ENV === "development" 34 | ? ({ path, error }) => { 35 | console.error( 36 | `❌ tRPC failed on ${path ?? ""}: ${error.message}` 37 | ); 38 | } 39 | : undefined, 40 | }); 41 | 42 | export { handler as GET, handler as POST }; 43 | -------------------------------------------------------------------------------- /src/app/components/InteractionModal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import Button from "./Button"; 3 | import { CrossIcon } from "@bitcoin-design/bitcoin-icons-react/filled"; 4 | 5 | interface InteractionModalProps { 6 | title: string; 7 | children: ReactNode; 8 | close: () => void; 9 | } 10 | export default function InteractionModal({ 11 | children, 12 | close, 13 | title, 14 | }: InteractionModalProps) { 15 | return ( 16 | <> 17 |
18 |
19 |
20 |
21 |

{title}

22 |
23 |
{children}
24 |
25 | 32 |
33 |
34 |
35 |
36 |
37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Bip353Box from "./components/Bip353Box"; 2 | import TwelveCashLogo from "./components/TwelveCashLogo"; 3 | import Button from "./components/Button"; 4 | import { 5 | ArrowRightIcon, 6 | SearchIcon, 7 | } from "@bitcoin-design/bitcoin-icons-react/filled"; 8 | import { HydrateClient } from "@/trpc/server"; 9 | 10 | export default function Home() { 11 | const defaultDomain = process.env.DOMAIN ? process.env.DOMAIN : "twelve.cash"; 12 | const users = [ 13 | { user: "sensible.pangolin", domain: defaultDomain }, 14 | { user: "magnificent.deinonychus", domain: defaultDomain }, 15 | { user: "whimsical.salamander", domain: defaultDomain }, 16 | { user: "rowdy.archaeopteryx", domain: defaultDomain }, 17 | { user: "insidious.mongoose", domain: defaultDomain }, 18 | { user: "formidable.mastodon", domain: defaultDomain }, 19 | ]; 20 | return ( 21 | 22 |
23 |
24 | 25 |
26 | 27 |

28 | A simple way to share your bitcoin payment info with the world. 29 |

30 |
31 | 34 | 37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/app/components/InputZ.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | 4 | interface InputProps { 5 | name: string; 6 | register: Function; 7 | placeholder?: string; 8 | append?: string; 9 | prepend?: string; 10 | label?: string; 11 | description?: string; 12 | hidden?: boolean; 13 | } 14 | 15 | export default function Input(props: InputProps) { 16 | const [focus, setFocus] = useState(false); 17 | 18 | return ( 19 | <> 20 |
21 | {props.label ? ( 22 | 23 | ) : ( 24 | `` 25 | )} 26 |
31 |
32 | {props.prepend} 33 |
34 | { 41 | setFocus(true); 42 | }} 43 | {...props.register(props.name, { 44 | onBlur: () => setFocus(false), 45 | })} 46 | /> 47 |
48 | {props.append} 49 |
50 |
51 | {props.description ?

{props.description}

: ``} 52 |
53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/components/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | PlusIcon, 4 | SearchIcon, 5 | WalletIcon, 6 | ContactsIcon, 7 | MenuIcon 8 | } from "@bitcoin-design/bitcoin-icons-react/filled"; 9 | import { useState } from "react"; 10 | import Button from "./Button"; 11 | import TwelveCashLogo from "./TwelveCashLogo"; 12 | import { useUser } from "./ClientUserProvider"; 13 | 14 | export default function Header() { 15 | const user = useUser(); 16 | const [menuOpen, setMenuOpen] = useState(false); 17 | return ( 18 |
19 |
20 |
21 | 22 |
23 |
24 | 27 | 45 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twelvecash", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest", 11 | "db:generate": "prisma migrate dev", 12 | "db:migrate": "prisma migrate deploy", 13 | "db:push": "prisma db push", 14 | "db:studio": "prisma studio", 15 | "db:reset": "prisma migrate reset", 16 | "postinstall": "prisma generate" 17 | }, 18 | "dependencies": { 19 | "@bitcoin-design/bitcoin-icons-react": "^0.1.10", 20 | "@hookform/resolvers": "^3.9.0", 21 | "@prisma/client": "^5.17.0", 22 | "@tanstack/react-query": "^5.50.0", 23 | "@trpc/client": "^11.0.0-rc.446", 24 | "@trpc/react-query": "^11.0.0-rc.446", 25 | "@trpc/server": "^11.0.0-rc.446", 26 | "axios": "^1.6.2", 27 | "bech32": "^2.0.0", 28 | "cookie": "^0.6.0", 29 | "jsonwebtoken": "^9.0.2", 30 | "next": "14.1.1", 31 | "nostr-tools": "^2.7.1", 32 | "qrcode.react": "^3.1.0", 33 | "react": "^18", 34 | "react-confetti": "^6.1.0", 35 | "react-dom": "^18", 36 | "react-hook-form": "^7.52.1", 37 | "react-use": "^17.5.0", 38 | "superjson": "^2.2.1", 39 | "unique-names-generator": "^4.7.1", 40 | "zod": "^3.23.8" 41 | }, 42 | "devDependencies": { 43 | "@testing-library/jest-dom": "^6.4.6", 44 | "@testing-library/react": "^16.0.0", 45 | "@types/cookie": "^0.6.0", 46 | "@types/jest": "^29.5.12", 47 | "@types/jsonwebtoken": "^9.0.6", 48 | "@types/node": "^20", 49 | "@types/react": "^18", 50 | "@types/react-dom": "^18", 51 | "autoprefixer": "^10.0.1", 52 | "eslint": "^8", 53 | "eslint-config-next": "14.0.3", 54 | "jest": "^29.7.0", 55 | "jest-environment-jsdom": "^29.7.0", 56 | "postcss": "^8", 57 | "prisma": "^5.17.0", 58 | "tailwindcss": "^3.3.0", 59 | "ts-node": "^10.9.2", 60 | "typescript": "^5" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/util/constant.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const DOMAINS = ["twelve.cash", "12cash.dev"] as const; 4 | 5 | export type TwelveCashDomains = typeof DOMAINS[number]; 6 | 7 | const Custom = z.object({ 8 | prefix: z.string(), 9 | value: z.string(), 10 | }); 11 | export const randomPayCodeInput = z 12 | .object({ 13 | domain: z.enum(DOMAINS), 14 | onChain: z.string().optional(), 15 | label: z.string().optional(), 16 | lno: z 17 | .union([z.string().startsWith("lno"), z.string().length(0)]) 18 | .optional(), 19 | sp: z.union([z.string().startsWith("sp"), z.string().length(0)]).optional(), 20 | lnurl: z.string().optional(), 21 | custom: z.array(Custom).optional(), 22 | }) 23 | .refine( 24 | (data) => 25 | data.onChain || 26 | data.lno || 27 | data.sp || 28 | data.lnurl || 29 | (Array.isArray(data.custom) && data.custom.length > 0), 30 | { 31 | message: "At least one payment option is required", 32 | path: [], 33 | } 34 | ); 35 | 36 | export const payCodeInput = z 37 | .object({ 38 | userName: z 39 | .string() 40 | .min(4) 41 | .regex(/^[a-zA-Z0-9._-]+$/, { 42 | message: "Accepted characters are: a-z, A-Z, 0-9, '.', '-', and '_'", 43 | }), 44 | domain: z.enum(DOMAINS), 45 | onChain: z.string().optional(), 46 | label: z.string().optional(), 47 | lno: z 48 | .union([z.string().startsWith("lno"), z.string().length(0)]) 49 | .optional(), 50 | sp: z.union([z.string().startsWith("sp"), z.string().length(0)]).optional(), 51 | lnurl: z.string().optional(), 52 | custom: z.array(Custom).optional(), 53 | }) 54 | .refine( 55 | (data) => 56 | data.onChain || 57 | data.lno || 58 | data.sp || 59 | data.lnurl || 60 | (Array.isArray(data.custom) && data.custom.length > 0), 61 | { 62 | message: "At least one payment option is required", 63 | path: [], 64 | } 65 | ); 66 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | colors: { 17 | '12teal': '#0FFFC5' 18 | }, 19 | animation: { 20 | 'loading-pulse': 'loading-pulse 1s linear infinite', 21 | 'slide-up-1': 'slide-up 18s ease-in-out infinite both', 22 | 'slide-up-2': 'slide-up 18s ease-in-out infinite both 3s', 23 | 'slide-up-3': 'slide-up 18s ease-in-out infinite both 6s', 24 | 'slide-up-4': 'slide-up 18s ease-in-out infinite both 9s', 25 | 'slide-up-5': 'slide-up 18s ease-in-out infinite both 12s', 26 | 'slide-up-6': 'slide-up 18s ease-in-out infinite both 15s', 27 | }, 28 | keyframes: { 29 | 'loading-pulse': { 30 | '0%': {transform: 'scaleX(0.5) translateX(-100%)'}, 31 | '100%': {transform: 'scaleX(0.5) translateX(200%)'}, 32 | }, 33 | 'slide-up': { 34 | '0%': { 35 | opacity: '0.0', 36 | transform: 'translateY(100%)', 37 | }, 38 | '2%': { 39 | opacity: '1.0', 40 | transform: 'translateY(0%)', 41 | }, 42 | '14%': { 43 | opacity: '1.0', 44 | transform: 'translateY(0%)', 45 | }, 46 | '16%': { 47 | opacity: '0.0', 48 | transform: 'translateY(-100%)', 49 | }, 50 | '100%': { 51 | opacity: '0.0', 52 | transform: 'translateY(-100%)', 53 | }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | plugins: [], 59 | } 60 | export default config 61 | -------------------------------------------------------------------------------- /src/app/components/Input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ChangeEvent, useState } from "react"; 3 | 4 | interface InputProps { 5 | placeholder?: string; 6 | value?: string; 7 | append?: string; 8 | prepend?: string; 9 | focus?: boolean; 10 | onChange?: (value:string) => void; 11 | label?: string; 12 | description?: string; 13 | } 14 | 15 | export default function Input(props:InputProps) { 16 | const [focus, setFocus] = useState(false); 17 | 18 | const handleInputChange = (event: React.ChangeEvent) => { 19 | if(props.onChange) props.onChange(event.target.value); 20 | } 21 | 22 | return ( 23 | <> 24 |
25 | {props.label ? 26 | 27 | : ``} 28 |
29 |
30 | {props.prepend} 31 |
32 | { setFocus(true); } } 38 | onBlur={ ()=>{ setFocus(false); } } 39 | onChange={handleInputChange} 40 | /> 41 |
42 | {props.append} 43 |
44 |
45 | {props.description ? 46 |

{props.description}

47 | : ``} 48 |
49 | 50 | ); 51 | } -------------------------------------------------------------------------------- /src/app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | interface ButtonProps { 4 | format?: "primary" | "secondary" | "outline" | "free"; 5 | disabled?: boolean; 6 | onClick?: () => void; 7 | href?: string; 8 | children?: React.ReactNode; 9 | size?: "small" | "medium" | "large"; 10 | wide?: boolean; 11 | active?: boolean; 12 | id?: string; 13 | className?: string; 14 | } 15 | 16 | export default function Button(props:ButtonProps){ 17 | const className = `no-underline rounded-lg flex-none flex flex-row ${props.wide ? 'grow' : ''} items-center text-center justify-center font-semibold transition-all bg-gradient-to-br border ${props.format === 'secondary' ? 'bg-yellow-300 from-orange-500/0 to-orange-500/30 hover:to-orange-500/50 text-purple-800 border-yellow-200' : props.format === 'outline' ? 'from-white/0 to-white/0 border-2 border-purple-800 bg-white/0 hover:bg-white/10' : props.format === 'free' ? 'from-white/0 to-white/0 border-0 bg-white/0 hover:bg-white/10' : 'bg-purple-800 from-orange-500/0 to-orange-500/20 hover:to-orange-500/40 text-white border-purple-400'} ${props.size && props.size === 'large' ? 'p-6 gap-4 text-xl' : props.size && props.size === 'small' ? 'p-2 gap-1 text-base' : 'p-4 gap-2 text-lg'} ${props.disabled ? 'opacity-75 cursor-not-allowed pointer-events-none' : ''} ${props.active ? 'outline' : ''} ${props.className || ''}`; 18 | 19 | if(props.href){ 20 | return( 21 | <> 22 | 26 | {props.children || "Click Here"} 27 | 28 | 29 | ) 30 | } 31 | else { 32 | return( 33 | <> 34 | 41 | 42 | ) 43 | } 44 | } -------------------------------------------------------------------------------- /src/app/account/page.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "@/trpc/server"; 2 | import LogoutButton from "@/app/components/LogoutButton"; 3 | import Bip353Box from "../components/Bip353Box"; 4 | import { TRPCError } from "@trpc/server"; 5 | import getUser from "../components/getUserServer"; 6 | import Button from "../components/Button"; 7 | import Link from "next/link"; 8 | 9 | export default async function Account() { 10 | const user = getUser(); 11 | if (!user) { 12 | return ( 13 |
14 |
15 |

You are Logged Out

16 | 17 |
18 |
19 | ) 20 | } 21 | const paycodes = await api.payCode.getUserPaycodes(); 22 | return ( 23 |
24 |
25 |

Your Acount

26 | 27 |
28 |
29 |
30 |

Your paycodes

31 |
32 | {paycodes.length === 0 && 33 |
34 |

You don't have any paycodes yet

35 |
36 | 37 |
38 |
39 | } 40 |
41 | {paycodes.map((pc) => ( 42 | 43 | 44 | 45 | ))} 46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /start-database.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to start a docker container for a local development database 3 | 4 | # TO RUN ON WINDOWS: 5 | # 1. Install WSL (Windows Subsystem for Linux) - https://learn.microsoft.com/en-us/windows/wsl/install 6 | # 2. Install Docker Desktop for Windows - https://docs.docker.com/docker-for-windows/install/ 7 | # 3. Open WSL - `wsl` 8 | # 4. Run this script - `./start-database.sh` 9 | 10 | # On Linux and macOS you can run this script directly - `./start-database.sh` 11 | 12 | DB_CONTAINER_NAME="twelvecash-postgres" 13 | 14 | if ! [ -x "$(command -v docker)" ]; then 15 | echo -e "Docker is not installed. Please install docker and try again.\nDocker install guide: https://docs.docker.com/engine/install/" 16 | exit 1 17 | fi 18 | 19 | if [ "$(docker ps -q -f name=$DB_CONTAINER_NAME)" ]; then 20 | echo "Database container '$DB_CONTAINER_NAME' already running" 21 | exit 0 22 | fi 23 | 24 | if [ "$(docker ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then 25 | docker start "$DB_CONTAINER_NAME" 26 | echo "Existing database container '$DB_CONTAINER_NAME' started" 27 | exit 0 28 | fi 29 | 30 | # import env variables from .env 31 | set -a 32 | source .env 33 | 34 | DB_PASSWORD=$(echo "$DATABASE_URL" | awk -F':' '{print $3}' | awk -F'@' '{print $1}') 35 | DB_PORT=$(echo "$DATABASE_URL" | awk -F':' '{print $4}' | awk -F'\/' '{print $1}') 36 | 37 | if [ "$DB_PASSWORD" = "password" ]; then 38 | echo "You are using the default database password" 39 | read -p "Should we generate a random password for you? [y/N]: " -r REPLY 40 | if ! [[ $REPLY =~ ^[Yy]$ ]]; then 41 | echo "Please set a password in the .env file and try again" 42 | exit 1 43 | fi 44 | # Generate a random URL-safe password 45 | DB_PASSWORD=$(openssl rand -base64 12 | tr '+/' '-_') 46 | sed -i -e "s#:password@#:$DB_PASSWORD@#" .env 47 | fi 48 | 49 | docker run -d \ 50 | --name $DB_CONTAINER_NAME \ 51 | -e POSTGRES_USER="postgres" \ 52 | -e POSTGRES_PASSWORD="$DB_PASSWORD" \ 53 | -e POSTGRES_DB=twelvecash \ 54 | -p "$DB_PORT":5432 \ 55 | docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created" 56 | -------------------------------------------------------------------------------- /src/server/lnd.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import https from "https"; 3 | 4 | const LND_HOST = process.env.LND_HOST!; 5 | const LND_PORT = process.env.LND_PORT!; 6 | const MACAROON = process.env.LND_MACAROON!; 7 | const TLS_CERT = process.env.LND_TLS_CERT!; 8 | 9 | const getAxiosInstance = () => { 10 | return axios.create({ 11 | baseURL: `${LND_HOST}:${LND_PORT}`, 12 | headers: { 13 | "Grpc-Metadata-macaroon": Buffer.from(MACAROON, "base64").toString("hex"), 14 | }, 15 | httpsAgent: new https.Agent({ 16 | ca: Buffer.from(TLS_CERT, "base64").toString("ascii"), 17 | }), 18 | }); 19 | }; 20 | // const axiosInstance = axios.create({ 21 | // baseURL: `${LND_HOST}:${LND_PORT}`, 22 | // headers: { 23 | // "Grpc-Metadata-macaroon": Buffer.from(MACAROON, "base64").toString("hex"), 24 | // }, 25 | // httpsAgent: new https.Agent({ 26 | // ca: Buffer.from(TLS_CERT, "base64").toString("ascii"), 27 | // }), 28 | // }); 29 | 30 | // Console output: 31 | // { 32 | // "r_hash": , // 33 | // "payment_request": , // 34 | // "add_index": , // 35 | // "payment_addr": , // 36 | // } 37 | export async function createInvoice(amount: number, memo: string) { 38 | const axiosInstance = getAxiosInstance(); 39 | try { 40 | const response = await axiosInstance.post("/v1/invoices", { 41 | value_msat: amount, 42 | memo: memo, 43 | }); 44 | const rHashHex = Buffer.from(response.data.r_hash, "base64").toString( 45 | "hex" 46 | ); 47 | 48 | console.debug("response", response); 49 | return { 50 | ...response.data, 51 | r_hash: rHashHex, 52 | }; 53 | } catch (error) { 54 | console.error("Error creating invoice:", error); 55 | throw error; 56 | } 57 | } 58 | 59 | export async function lookupInvoice(paymentHash: string) { 60 | const axiosInstance = getAxiosInstance(); 61 | try { 62 | const response = await axiosInstance.get(`/v1/invoice/${paymentHash}`); 63 | return response.data; 64 | } catch (error) { 65 | console.error("Error looking up invoice:", error); 66 | throw error; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/dnssec-prover/doh_lookup.js: -------------------------------------------------------------------------------- 1 | import init from './dnssec_prover_wasm.js'; 2 | import * as wasm from './dnssec_prover_wasm.js'; 3 | 4 | /** 5 | * Asynchronously resolves a given domain and type using the provided DoH endpoint, then verifies 6 | * the returned DNSSEC data and ultimately returns a JSON-encoded list of validated records. 7 | */ 8 | export async function lookup_doh(domain, ty, doh_endpoint) { 9 | await init(); 10 | 11 | if (!domain.endsWith(".")) domain += "."; 12 | if (ty.toLowerCase() == "txt") { 13 | ty = 16; 14 | } else if (ty.toLowerCase() == "tlsa") { 15 | ty = 52; 16 | } else if (ty.toLowerCase() == "a") { 17 | ty = 1; 18 | } else if (ty.toLowerCase() == "aaaa") { 19 | ty = 28; 20 | } 21 | if (typeof(ty) == "number") { 22 | var builder = wasm.init_proof_builder(domain, ty); 23 | if (builder == null) { 24 | return "{\"error\":\"Bad domain\"}"; 25 | } else { 26 | var queries_pending = 0; 27 | var send_next_query; 28 | send_next_query = async function() { 29 | var query = wasm.get_next_query(builder); 30 | if (query != null) { 31 | queries_pending += 1; 32 | var b64 = btoa(String.fromCodePoint(...query)); 33 | var b64url = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); 34 | try { 35 | var resp = await fetch(doh_endpoint + "?dns=" + b64url, 36 | {headers: {"accept": "application/dns-message"}}); 37 | if (!resp.ok) { throw "Query returned HTTP " + resp.status; } 38 | var array = await resp.arrayBuffer(); 39 | var buf = new Uint8Array(array); 40 | wasm.process_query_response(builder, buf); 41 | queries_pending -= 1; 42 | } catch (e) { 43 | return "{\"error\":\"DoH Query failed: " + e + "\"}"; 44 | } 45 | return await send_next_query(); 46 | } else if (queries_pending == 0) { 47 | var proof = wasm.get_unverified_proof(builder); 48 | if (proof != null) { 49 | var result = wasm.verify_byte_stream(proof, domain); 50 | return JSON.stringify(JSON.parse(result), null, 1); 51 | } else { 52 | return "{\"error\":\"Failed to build proof\"}"; 53 | } 54 | } 55 | } 56 | return await send_next_query(); 57 | } 58 | } else { 59 | return "{\"error\":\"Unsupported Type\"}"; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Urbanist } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | import { TRPCReactProvider } from "@/trpc/react"; 6 | import UserProvider from "./components/UserProvider"; 7 | import Header from "./components/Header"; 8 | 9 | const urbanist = Urbanist({ subsets: ["latin"] }); 10 | 11 | const title = "TwelveCash | Simple Bitcoin Usernames" 12 | const description = "Get your own TwelveCash address. Supports BOLT 12 offers, Silent payments, and more!" 13 | const poster = "https://twelve.cash/twelve-cash-poster.png" 14 | 15 | export const metadata: Metadata = { 16 | title: title, 17 | description: description, 18 | icons: [{ url: '/favicon.ico', rel: 'icon' }], 19 | openGraph: { 20 | title: title, 21 | description: description, 22 | images: [{ url: poster }], 23 | }, 24 | twitter: { 25 | site: "TwelveCash", 26 | description: description, 27 | title: title, 28 | images: poster 29 | } 30 | } 31 | 32 | export default function RootLayout({ 33 | children, 34 | }: { 35 | children: React.ReactNode; 36 | }) { 37 | return ( 38 | 39 | 40 | 41 | 42 |
43 |
44 |
{children}
45 | 58 |
59 |
60 |
61 | 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/components/PaymentDetail.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { CopyIcon } from "@bitcoin-design/bitcoin-icons-react/filled" 3 | import Button from "./Button"; 4 | 5 | type PaymentDetailProps = { 6 | label: string; 7 | value: string; 8 | uri: string; 9 | loading?: boolean; 10 | } 11 | 12 | export default function PaymentDetail(props:PaymentDetailProps){ 13 | 14 | return( 15 | <> 16 |
17 | {props.loading ? 18 | <> 19 |
20 |                  21 |
22 |
23 |                                  24 |
25 | 26 | : 27 | <> 28 |
29 | {props.label} 30 |
31 |
32 | {props.value} 33 |
34 | 35 | } 36 | 37 | 38 | 39 | {props.loading ? 40 |
41 | :``} 42 |
43 | 44 | ) 45 | } -------------------------------------------------------------------------------- /src/app/components/Bip353Box.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import type { Bip353 } from "@/lib/util"; 3 | 4 | const inter = Inter({ subsets: ["latin"] }); 5 | 6 | type Bip353BoxProps = { 7 | users: Bip353[]; 8 | } 9 | 10 | export default function Bip353Box(props:Bip353BoxProps){ 11 | let className = `bg-blue-900 text-white text-2xl md:text-4xl px-2 py-6 md:p-9 rounded-xl font-light overflow-x-hidden`; 12 | 13 | if(props.users.length === 1){ 14 | return( 15 | <> 16 |
17 |

18 | 19 | {props.users[0].user} 20 | @{props.users[0].domain} 21 |

22 |
23 | 24 | ) 25 | } 26 | else { 27 | return( 28 | <> 29 |
30 |

31 | 32 | 33 | 34 | {props.users.map((user, index) => ( 35 | 36 | {user.user} 37 | 38 | ))} 39 | 40 | 41 | {/* 42 | {props.users[0].user} 43 | */} 44 | @{props.users[0].domain} 45 |

46 |
47 | 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/components/Invoice.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { QRCodeSVG } from "qrcode.react"; 3 | import { RouterOutputs, api } from "@/trpc/react"; 4 | import { useEffect, useState } from "react"; 5 | import Button from "./Button"; 6 | import { CopyIcon } from "@bitcoin-design/bitcoin-icons-react/filled"; 7 | 8 | export default function Invoice({ 9 | paymentInfo, 10 | onSuccess, 11 | }: { 12 | paymentInfo: RouterOutputs["payCode"]["createPayCode"]; 13 | onSuccess: () => void; 14 | }) { 15 | const { data } = api.payCode.checkPayment.useQuery( 16 | { invoiceId: paymentInfo.invoice.id }, 17 | { 18 | refetchOnWindowFocus: false, 19 | refetchInterval: 1500, 20 | } 21 | ); 22 | useEffect(() => { 23 | if (data?.status === "SETTLED") { 24 | console.debug("should close and mutate"); 25 | // add a state here to stop any potential queries? 26 | onSuccess(); 27 | } 28 | }, [data?.status]); 29 | 30 | const copyInvoice = () => { 31 | navigator.clipboard.writeText(paymentInfo.invoice.bolt11 || ""); 32 | copyResponse(); 33 | }; 34 | 35 | const [copied, setCopied] = useState(false); 36 | 37 | const copyResponse = () => { 38 | if(copied) return; 39 | setCopied(true); 40 | let resetCopied = setTimeout(() => {setCopied(false)}, 3000); 41 | } 42 | return ( 43 |
44 |
45 | 46 |
47 |
48 |
49 | 50 | {paymentInfo.invoice.bolt11} 51 | 52 | 53 | … 54 | 55 | 56 | {paymentInfo.invoice.bolt11?.slice(paymentInfo.invoice.bolt11.length-4, paymentInfo.invoice.bolt11.length)} 57 | 58 | {copied && Copied!} 59 |
60 | 61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/new/[user]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Bip353Box from "@/app/components/Bip353Box" 3 | import React from 'react' 4 | import useWindowSize from "react-use/lib/useWindowSize" 5 | import Confetti from 'react-confetti' 6 | import { useState, useEffect } from "react"; 7 | import CopyUserLinkButton from "@/app/features/CopyUserLinkButton"; 8 | import CopyBip353Button from "@/app/features/CopyBip353Button"; 9 | 10 | export default function NewUser({ params }: { params: { user: string } }){ 11 | const decoded = decodeURIComponent(params.user); 12 | const [user, domain] = decoded.split("@"); 13 | // const { width, height } = useWindowSize() 14 | // console.log(width, height) 15 | 16 | const [windowSize, setWindowSize] = useState({ 17 | width: 0, 18 | height: 0, 19 | }); 20 | 21 | useEffect(() => { 22 | // Handler to call on window resize 23 | const handleResize = () => { 24 | setWindowSize({ 25 | width: document.documentElement.clientWidth, 26 | height: window.innerHeight, 27 | }); 28 | }; 29 | 30 | // Add event listener 31 | window.addEventListener('resize', handleResize); 32 | 33 | // Call handler right away so state gets updated with initial window size 34 | handleResize(); 35 | 36 | // Remove event listener on cleanup 37 | return () => window.removeEventListener('resize', handleResize); 38 | }, []); // Empty array ensures that effect is only run on mount and unmount 39 | 40 | return( 41 | <> 42 |
43 | 49 |

Pay code created!

50 |

Your pay code is created, but it may take 5-10 minutes for the pay code to work. Enjoy!

51 | 52 |
53 |
54 | 55 |
56 |
57 | 58 |
59 |
60 |
61 | 62 | ) 63 | } -------------------------------------------------------------------------------- /src/trpc/react.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; 4 | import { loggerLink, httpBatchLink } from "@trpc/client"; 5 | import { createTRPCReact } from "@trpc/react-query"; 6 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; 7 | import { useState } from "react"; 8 | import SuperJSON from "superjson"; 9 | 10 | import { type AppRouter } from "@/server/api/root"; 11 | import { createQueryClient } from "./query-client"; 12 | 13 | let clientQueryClientSingleton: QueryClient | undefined = undefined; 14 | const getQueryClient = () => { 15 | if (typeof window === "undefined") { 16 | // Server: always make a new query client 17 | return createQueryClient(); 18 | } 19 | // Browser: use singleton pattern to keep the same query client 20 | return (clientQueryClientSingleton ??= createQueryClient()); 21 | }; 22 | 23 | export const api = createTRPCReact(); 24 | 25 | /** 26 | * Inference helper for inputs. 27 | * 28 | * @example type HelloInput = RouterInputs['example']['hello'] 29 | */ 30 | export type RouterInputs = inferRouterInputs; 31 | 32 | /** 33 | * Inference helper for outputs. 34 | * 35 | * @example type HelloOutput = RouterOutputs['example']['hello'] 36 | */ 37 | export type RouterOutputs = inferRouterOutputs; 38 | 39 | export function TRPCReactProvider(props: { children: React.ReactNode }) { 40 | const queryClient = getQueryClient(); 41 | 42 | const [trpcClient] = useState(() => 43 | api.createClient({ 44 | links: [ 45 | loggerLink({ 46 | enabled: (op) => 47 | process.env.NODE_ENV === "development" || 48 | (op.direction === "down" && op.result instanceof Error), 49 | }), 50 | // stream doesn't work with cookies or something 51 | // unstable_httpBatchStreamLink({ 52 | httpBatchLink({ 53 | transformer: SuperJSON, 54 | url: getBaseUrl() + "/api/trpc", 55 | headers: () => { 56 | const headers = new Headers(); 57 | headers.set("x-trpc-source", "nextjs-react"); 58 | return headers; 59 | }, 60 | }), 61 | ], 62 | }) 63 | ); 64 | 65 | return ( 66 | 67 | 68 | {props.children} 69 | 70 | 71 | ); 72 | } 73 | 74 | function getBaseUrl() { 75 | if (typeof window !== "undefined") return window.location.origin; 76 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; 77 | return `http://localhost:${process.env.PORT ?? 3000}`; 78 | } 79 | -------------------------------------------------------------------------------- /src/app/record/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | const axios = require("axios").default; 3 | 4 | export async function POST(req: NextRequest) { 5 | let localPart: string, bolt12: string; 6 | 7 | // Check for errors 8 | try { 9 | const json = await req.json(); 10 | console.debug("json", json); 11 | // validate localPart, validate bolt12... 12 | if (!json.localPart || !json.bolt12) 13 | return NextResponse.json( 14 | { error: "Missing parameters" }, 15 | { status: 400 } 16 | ); 17 | localPart = json.localPart; 18 | bolt12 = json.bolt12; 19 | console.debug("localPart", localPart, "bolt12", bolt12); 20 | } catch (e) { 21 | console.error(e); 22 | return NextResponse.json({ error: "Bad request" }, { status: 400 }); 23 | } 24 | 25 | // Begin assembling DNS name 26 | const fullName = process.env.NETWORK 27 | ? `${localPart}.user._bitcoin-payment.${process.env.NETWORK}.${process.env.DOMAIN}` 28 | : `${localPart}.user._bitcoin-payment.${process.env.DOMAIN}`; 29 | 30 | const CF_URL = `https://api.cloudflare.com/client/v4/zones/${process.env.CF_DOMAIN_ID}/dns_records?name=${fullName}&type=TXT`; 31 | // First check to see if this record name already exists 32 | try { 33 | const res = await axios.get(CF_URL, { 34 | headers: { 35 | Content_Type: "application/json", 36 | Authorization: `Bearer ${process.env.CF_TOKEN}`, 37 | }, 38 | }); 39 | if (res.data.result.length > 0) { 40 | return NextResponse.json( 41 | { message: "Name is already taken." }, 42 | { status: 409 } 43 | ); 44 | } 45 | } catch (e: any) { 46 | return NextResponse.json( 47 | { message: "Failed to lookup paycode." }, 48 | { status: 400 } 49 | ); 50 | } 51 | 52 | const config = { 53 | method: "POST", 54 | headers: { 55 | Content_Type: "application/json", 56 | Authorization: `Bearer ${process.env.CF_TOKEN}`, 57 | }, 58 | }; 59 | 60 | const data = { 61 | content: "bitcoin:?lno=" + bolt12, 62 | name: fullName, 63 | proxied: false, 64 | type: "TXT", 65 | comment: "Twelve Cash User DNS Update", 66 | ttl: 3600, 67 | }; 68 | 69 | try { 70 | const res = await axios.post(CF_URL, data, config); 71 | console.debug(res.data); 72 | } catch (error: any) { 73 | if (error.response.data.errors[0].code === 81058) { 74 | return NextResponse.json( 75 | { message: "Name is already taken." }, 76 | { status: 409 } 77 | ); 78 | } 79 | return NextResponse.json( 80 | { message: "Failed to create Paycode." }, 81 | { status: 400 } 82 | ); 83 | } 84 | 85 | return NextResponse.json( 86 | { message: "Bolt12 Address Created" }, 87 | { status: 201 } 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/app/auth/nostr/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useUser } from "@/app/components/ClientUserProvider"; 3 | import { api } from "@/trpc/react"; 4 | import { useRouter } from "next/navigation"; 5 | import { useEffect, useState } from "react"; 6 | import NostrAuthSpinner from "@/app/features/NostrAuthSpinner"; 7 | 8 | declare global { 9 | interface Window { 10 | nostr: any; 11 | } 12 | } 13 | 14 | export default function NostrAuth() { 15 | const router = useRouter(); 16 | const user = useUser(); 17 | const [nostrReady, setNostrReady] = useState(false); 18 | const { data } = api.auth.getChallenge.useQuery(undefined, { 19 | refetchOnWindowFocus: false, 20 | refetchInterval: 5 * 60 * 1000, // 5 min... display timeout? 21 | }); 22 | const login = api.auth.nostrLogin.useMutation({ 23 | onSuccess: (data) => { 24 | user.setUser(data.user); 25 | router.push(`/account`); 26 | }, 27 | onError: () => { 28 | console.error("Failed to log in"); 29 | // handle timeout 30 | }, 31 | }); 32 | 33 | useEffect(() => { 34 | if (!window.nostr) return; 35 | setNostrReady(true); 36 | if (data?.challenge) authenticate(); 37 | }, [data?.challenge]); 38 | 39 | const authenticate = async () => { 40 | if (!data?.challenge) throw new Error("Missing challenge!"); 41 | let pubkey = ""; 42 | try { 43 | pubkey = await window.nostr.getPublicKey(); 44 | console.debug("pubkey", pubkey); 45 | } catch (e: any) { 46 | console.error("Failed to get public key"); 47 | return; 48 | } 49 | const event = { 50 | kind: 27235, 51 | pubkey: pubkey, 52 | created_at: Math.floor(Date.now() / 1000), 53 | tags: [ 54 | ["u", "https://twelve.cash/api/trpc/auth.login"], 55 | ["method", "POST"], 56 | ["payload", data.challenge], 57 | ], 58 | content: "", 59 | }; 60 | 61 | let signedEvent = ""; 62 | try { 63 | signedEvent = await window.nostr.signEvent(event); 64 | } catch (e: any) { 65 | console.error("Failed to sign the authentication event"); 66 | return; 67 | } 68 | login.mutate({ event: JSON.stringify(signedEvent) }); 69 | }; 70 | 71 | if (user.user) { 72 | router.push('/account'); 73 | return ( 74 |
75 | 79 |
80 | ); 81 | } 82 | 83 | return( 84 |
85 | 92 |
93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id String @id @default(uuid()) 15 | createdAt DateTime @default(now()) 16 | updatedAt DateTime @updatedAt 17 | lastLogin DateTime? 18 | nostrPublicKey String? @unique 19 | lnNodePublicKey String? @unique 20 | apiKey String @default(uuid()) 21 | payCode PayCode[] 22 | Invoice Invoice[] 23 | } 24 | 25 | model UserAuth { 26 | id String @id @default(uuid()) 27 | createdAt DateTime @default(now()) 28 | challengeHash String @unique 29 | } 30 | 31 | model PayCode { 32 | id String @id @default(uuid()) 33 | createdAt DateTime @default(now()) 34 | updatedAt DateTime @updatedAt 35 | expires DateTime? 36 | status PayCodeStatus 37 | label String? 38 | domain String 39 | userName String // should not be unique so people can get an expired username 40 | params PayCodeParam[] 41 | user User? @relation(fields: [userId], references: [id]) 42 | userId String? 43 | // single or array? one can time out... but could make a new PayCode per request 44 | // would check valid paycodes by looking at invoice redemption status 45 | invoices Invoice[] 46 | } 47 | 48 | enum PayCodeStatus { 49 | PENDING // Pending payment 50 | ACTIVE 51 | EXPIRED // would need to ensure the actual records are removed, cron job could set this 52 | REVOKED 53 | } 54 | 55 | model PayCodeParam { 56 | id String @id @default(uuid()) 57 | prefix String? // used for custom type 58 | value String 59 | type PayCodeParamType 60 | payCode PayCode @relation(fields: [payCodeId], references: [id]) 61 | payCodeId String 62 | } 63 | 64 | enum PayCodeParamType { 65 | ONCHAIN 66 | LABEL 67 | LNO 68 | SP 69 | LNURL 70 | CUSTOM 71 | } 72 | 73 | model Invoice { 74 | id String @id @default(uuid()) 75 | createdAt DateTime @default(now()) 76 | updatedAt DateTime @updatedAt 77 | confirmedAt DateTime? 78 | maxAgeSeconds Int 79 | description String 80 | status InvoiceStatus 81 | kind InvoiceKind 82 | hash String @unique 83 | bolt11 String? 84 | // always bolt 11? Don't need a bolt12 lni if we are generating an invoice 85 | bolt12 String? 86 | mSatsTarget Int 87 | mSatsSettled Int? 88 | redeemed Boolean @default(false) 89 | user User? @relation(fields: [userId], references: [id]) 90 | userId String? 91 | payCode PayCode @relation(fields: [payCodeId], references: [id]) 92 | payCodeId String 93 | } 94 | 95 | enum InvoiceKind { 96 | PURCHASE 97 | RENEWAL 98 | } 99 | 100 | enum InvoiceStatus { 101 | OPEN 102 | // The actual LN invoice can be settled, but not reflected here 103 | SETTLED 104 | CANCELED 105 | ACCEPTED 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Twelve Cash](https://twelve.cash/twelve-cash-hero.webp) 2 | 3 | # Twelve Cash 4 | 5 | This is an attempt to encode bitcoin payment instructions, specifically BOLT 12 offers, into DNS records. For users, this means something that looks like `my-name@twelve.cash`, but resolves to a BOLT12 offer when a BOLT12 supporting wallet attempts to pay to it. 6 | 7 | This is an implementation of BIP-353. Inititally inspired by Bastien Teinturier's post [Lightning Address in a Bolt 12 world](https://lists.linuxfoundation.org/pipermail/lightning-dev/2023-November/004204.html). 8 | 9 | ## How to Use 10 | 11 | ### Create a User Name 12 | 13 | Hit the API endpoint `https://twelve.cash/v2/record` with a POST request containing the payload: 14 | 15 | ``` 16 | { 17 | "domain": "twelve.cash", 18 | "lno": "lno123...xyz", 19 | "sp": "sp123...xyz", 20 | "onChain": "bc1p...xyz", 21 | "custom": [ 22 | { 23 | "prefix": "lnurl", 24 | "value": "lnur123...xyz" 25 | }, 26 | { 27 | "prefix": "mystory", 28 | "value": "Bitcoin ipsum dolor sit amet." 29 | } 30 | ] 31 | } 32 | ``` 33 | 34 | ### Lookup a User Name 35 | 36 | You can verify that this worked by opening a shell and running: 37 | 38 | `dig txt stephen.user._bitcoin-payment.twelve.cash` 39 | 40 | The expected output should be: 41 | 42 | `stephen.user._bitcoin-payment.twelve.cash. 3600 IN TXT "bitcoin:?lno=lno1pgg8getnw3q8gam9d3mx2tnrv9eks93pqw7dv89vg89dtreg63cwn4mtg7js438yk3alw3a43zshdgsm0p08q"` 43 | 44 | ## Validate a User Name 45 | 46 | For this, we rely on the [dnssec-prover tool](https://github.com/TheBlueMatt/dnssec-prover) from TheBlueMatt. This (or something like it) should be built into any tool that facilitates payments to Twelve Cash addresses. However, we have exposed this on our website frontend so you can experiment and validate these addresses. 47 | 48 | ## Roadmap 49 | 50 | - [x] Create [API](https://github.com/ATLBitLab/twelvecash/blob/main/src/app/record/route.ts) for adding bitcoin payment instructions to DNS records 51 | - [x] Create [Web UI](https://twelve.cash) for creating user names 52 | - [x] Integrate API into popular bitcoin wallet - [Zeus](https://github.com/atlbitlab/zeus) 53 | - [x] Add support for DNSSEC 54 | - [x] Follow [BIP Draft](https://github.com/bitcoin/bips/pull/1551/files) progress and update TwelveCash as the spec matures 55 | - [ ] Create easy way for users to edit/update their Twelve Cash user name 56 | 57 | ## Development 58 | 59 | You can use this tool with Cloudflare DNS API. 60 | 61 | ### Cloudflare 62 | 63 | - Setup your domain with DNSSEC - [Docs](https://developers.cloudflare.com/dns/dnssec/) 64 | - Create an API token in Cloudflare, giving it access to your domain name 65 | - In the .env file, add your Cloudflare API token and domain ID (which you can find by clicking on your domain name in Cloudflare and scrolling down the page) 66 | 67 | ### Running the Dev Server 68 | 69 | Start up the database: 70 | 71 | ```bash 72 | ./start-database.sh 73 | ``` 74 | 75 | Create the Prisma client and push it to the database: 76 | 77 | ```bash 78 | yarn postinstall 79 | yarn db:push 80 | ``` 81 | 82 | Run the development server: 83 | 84 | ```bash 85 | yarn dev 86 | ``` 87 | 88 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 89 | -------------------------------------------------------------------------------- /src/lib/dnssec-prover/dnssec_prover_wasm.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Builds a proof builder which can generate a proof for records of the given `ty`pe at the given 5 | * `name`. 6 | * 7 | * After calling this [`get_next_query`] should be called to fetch the initial query. 8 | * @param {string} name 9 | * @param {number} ty 10 | * @returns {WASMProofBuilder | undefined} 11 | */ 12 | export function init_proof_builder(name: string, ty: number): WASMProofBuilder | undefined; 13 | /** 14 | * Processes a response to a query previously fetched from [`get_next_query`]. 15 | * 16 | * After calling this, [`get_next_query`] should be called until pending queries are exhausted and 17 | * no more pending queries exist, at which point [`get_unverified_proof`] should be called. 18 | * @param {WASMProofBuilder} proof_builder 19 | * @param {Uint8Array} response 20 | */ 21 | export function process_query_response(proof_builder: WASMProofBuilder, response: Uint8Array): void; 22 | /** 23 | * Gets the next query (if any) that should be sent to the resolver for the given proof builder. 24 | * 25 | * Once the resolver responds [`process_query_response`] should be called with the response. 26 | * @param {WASMProofBuilder} proof_builder 27 | * @returns {Uint8Array | undefined} 28 | */ 29 | export function get_next_query(proof_builder: WASMProofBuilder): Uint8Array | undefined; 30 | /** 31 | * Gets the final, unverified, proof once all queries fetched via [`get_next_query`] have 32 | * completed and their responses passed to [`process_query_response`]. 33 | * @param {WASMProofBuilder} proof_builder 34 | * @returns {Uint8Array | undefined} 35 | */ 36 | export function get_unverified_proof(proof_builder: WASMProofBuilder): Uint8Array | undefined; 37 | /** 38 | * Verifies an RFC 9102-formatted proof and returns verified records matching the given name 39 | * (resolving any C/DNAMEs as required). 40 | * @param {Uint8Array} stream 41 | * @param {string} name_to_resolve 42 | * @returns {string} 43 | */ 44 | export function verify_byte_stream(stream: Uint8Array, name_to_resolve: string): string; 45 | /** 46 | */ 47 | export class WASMProofBuilder { 48 | free(): void; 49 | } 50 | 51 | export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; 52 | 53 | export interface InitOutput { 54 | readonly memory: WebAssembly.Memory; 55 | readonly __wbg_wasmproofbuilder_free: (a: number) => void; 56 | readonly init_proof_builder: (a: number, b: number, c: number) => number; 57 | readonly process_query_response: (a: number, b: number, c: number) => void; 58 | readonly get_next_query: (a: number, b: number) => void; 59 | readonly get_unverified_proof: (a: number, b: number) => void; 60 | readonly verify_byte_stream: (a: number, b: number, c: number, d: number, e: number) => void; 61 | readonly __wbindgen_malloc: (a: number, b: number) => number; 62 | readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; 63 | readonly __wbindgen_add_to_stack_pointer: (a: number) => number; 64 | readonly __wbindgen_free: (a: number, b: number, c: number) => void; 65 | } 66 | 67 | export type SyncInitInput = BufferSource | WebAssembly.Module; 68 | /** 69 | * Instantiates the given `module`, which can either be bytes or 70 | * a precompiled `WebAssembly.Module`. 71 | * 72 | * @param {SyncInitInput} module 73 | * 74 | * @returns {InitOutput} 75 | */ 76 | export function initSync(module: SyncInitInput): InitOutput; 77 | 78 | /** 79 | * If `module_or_path` is {RequestInfo} or {URL}, makes a request and 80 | * for everything else, calls `WebAssembly.instantiate` directly. 81 | * 82 | * @param {InitInput | Promise} module_or_path 83 | * 84 | * @returns {Promise} 85 | */ 86 | export default function __wbg_init (module_or_path?: InitInput | Promise): Promise; 87 | -------------------------------------------------------------------------------- /src/server/api/routers/auth.ts: -------------------------------------------------------------------------------- 1 | import { createHash, randomBytes } from "crypto"; 2 | import { verifyEvent } from "nostr-tools/pure"; 3 | import { z } from "zod"; 4 | import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; 5 | import { TRPCError } from "@trpc/server"; 6 | import jwt from "jsonwebtoken"; 7 | 8 | const CHALLENGE_TIMEOUT_MS = 5 * 60 * 1000; // 5 min 9 | 10 | export const authRouter = createTRPCRouter({ 11 | getChallenge: publicProcedure.query(async ({ ctx }) => { 12 | const secret = randomBytes(32).toString("hex"); 13 | await ctx.db.userAuth.create({ 14 | data: { 15 | challengeHash: createHash("sha256").update(secret).digest("hex"), 16 | }, 17 | }); 18 | return { 19 | challenge: secret, 20 | }; 21 | }), 22 | 23 | nostrLogin: publicProcedure 24 | .input( 25 | z.object({ 26 | event: z.string(), // TODO: Don't stringify json? 27 | }) 28 | ) 29 | .mutation(async ({ input, ctx }) => { 30 | console.debug("nostrLogin input", input); 31 | const signedEvent = JSON.parse(input.event); 32 | if (!verifyEvent(signedEvent)) 33 | throw new TRPCError({ 34 | code: "BAD_REQUEST", 35 | message: "Invalid signed event", 36 | }); 37 | 38 | // signature and event okay... now check challenge 39 | let challenge = null; 40 | const challengeTag = signedEvent.tags.find( 41 | ([t, v]) => t === "payload" && v 42 | ); 43 | if (challengeTag && challengeTag[1]) challenge = challengeTag[1]; 44 | else 45 | throw new TRPCError({ 46 | code: "BAD_REQUEST", 47 | message: "Missing challenge secret", 48 | }); 49 | 50 | // whole event is signed... just check if this hash is in the db 51 | const challengeHash = createHash("sha256") 52 | .update(challenge) 53 | .digest("hex"); 54 | 55 | const userAuth = await ctx.db.userAuth.findFirst({ 56 | where: { challengeHash }, 57 | }); 58 | if (!userAuth) { 59 | throw new TRPCError({ 60 | code: "NOT_FOUND", 61 | message: "No such challenge", 62 | }); 63 | } 64 | 65 | // Check if the challenge has expired 66 | const expiresAt = new Date( 67 | userAuth.createdAt.getTime() + CHALLENGE_TIMEOUT_MS 68 | ); 69 | const currentTime = new Date(); 70 | if (currentTime > expiresAt) { 71 | throw new TRPCError({ 72 | code: "BAD_REQUEST", 73 | message: "Challenge has expired", 74 | }); 75 | } 76 | 77 | // Now create a new user or return the existing user 78 | const user = await ctx.db.$transaction(async (transactionPrisma) => { 79 | let innerUser; 80 | innerUser = await transactionPrisma.user.findUnique({ 81 | where: { 82 | nostrPublicKey: signedEvent.pubkey, 83 | }, 84 | }); 85 | 86 | if (!innerUser) { 87 | innerUser = await transactionPrisma.user.create({ 88 | data: { 89 | nostrPublicKey: signedEvent.pubkey, 90 | }, 91 | }); 92 | } 93 | 94 | return innerUser; 95 | }); 96 | 97 | // Delete the challengeHash that was used and any challenges older than 5 minutes 98 | const fiveMinutesAgo = new Date( 99 | currentTime.getTime() - CHALLENGE_TIMEOUT_MS 100 | ); 101 | await ctx.db.userAuth.deleteMany({ 102 | where: { 103 | OR: [{ challengeHash }, { createdAt: { lt: fiveMinutesAgo } }], 104 | }, 105 | }); 106 | const authToken = jwt.sign({ ...user }, process.env.JWT_SECRET!); 107 | // use better lib 108 | ctx.resHeaders?.resHeaders.set( 109 | "Set-Cookie", 110 | `access-token=${authToken}; Path=/; HttpOnly; SameSite=Strict` 111 | ); 112 | return { user: user }; 113 | }), 114 | }); 115 | -------------------------------------------------------------------------------- /src/server/api/trpc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: 3 | * 1. You want to modify request context (see Part 1). 4 | * 2. You want to create a new middleware or type of procedure (see Part 3). 5 | * 6 | * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will 7 | * need to use are documented accordingly near the end. 8 | */ 9 | import { TRPCError, initTRPC } from "@trpc/server"; 10 | import superjson from "superjson"; 11 | import { ZodError } from "zod"; 12 | import jwt from "jsonwebtoken"; 13 | 14 | import { db } from "@/server/db"; 15 | 16 | /** 17 | * 1. CONTEXT 18 | * 19 | * This section defines the "contexts" that are available in the backend API. 20 | * 21 | * These allow you to access things when processing a request, like the database, the session, etc. 22 | * 23 | * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each 24 | * wrap this and provides the required context. 25 | * 26 | * @see https://trpc.io/docs/server/context 27 | */ 28 | import { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"; 29 | import { parse } from "cookie"; 30 | 31 | export interface TokenUser { 32 | id: string; 33 | nostrPublicKey: string | null; 34 | lnNodePublicKey: string | null; 35 | apiKey: string; 36 | createdAt: Date; 37 | updatedAt: Date; 38 | lastLogin: Date | null; 39 | } 40 | 41 | export const createTRPCContext = async (opts: { 42 | headers: Headers; 43 | resHeaders?: FetchCreateContextFnOptions; 44 | }) => { 45 | const cookieHeader = opts.headers.get("cookie"); 46 | const cookies = cookieHeader ? parse(cookieHeader) : {}; 47 | const accessToken = cookies["access-token"]; 48 | 49 | const user = accessToken 50 | ? (jwt.verify(accessToken, process.env.JWT_SECRET ?? "") as TokenUser) 51 | : undefined; 52 | 53 | return { 54 | db, 55 | user, 56 | ...opts, 57 | }; 58 | }; 59 | 60 | /** 61 | * 2. INITIALIZATION 62 | * 63 | * This is where the tRPC API is initialized, connecting the context and transformer. We also parse 64 | * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation 65 | * errors on the backend. 66 | */ 67 | const t = initTRPC.context().create({ 68 | transformer: superjson, 69 | errorFormatter({ shape, error }) { 70 | return { 71 | ...shape, 72 | data: { 73 | ...shape.data, 74 | zodError: 75 | error.cause instanceof ZodError ? error.cause.flatten() : null, 76 | }, 77 | }; 78 | }, 79 | }); 80 | 81 | /** 82 | * Create a server-side caller. 83 | * 84 | * @see https://trpc.io/docs/server/server-side-calls 85 | */ 86 | export const createCallerFactory = t.createCallerFactory; 87 | 88 | /** 89 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) 90 | * 91 | * These are the pieces you use to build your tRPC API. You should import these a lot in the 92 | * "/src/server/api/routers" directory. 93 | */ 94 | 95 | /** 96 | * This is how you create new routers and sub-routers in your tRPC API. 97 | * 98 | * @see https://trpc.io/docs/router 99 | */ 100 | export const createTRPCRouter = t.router; 101 | 102 | /** 103 | * Public (unauthenticated) procedure 104 | * 105 | * This is the base piece you use to build new queries and mutations on your tRPC API. It does not 106 | * guarantee that a user querying is authorized, but you can still access user session data if they 107 | * are logged in. 108 | */ 109 | export const publicProcedure = t.procedure; 110 | 111 | /** 112 | * Protected (authenticated) procedure 113 | * 114 | * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies 115 | * the session is valid and guarantees `ctx.session.user` is not null. 116 | * 117 | * @see https://trpc.io/docs/procedures 118 | */ 119 | export const protectedProcedure = t.procedure.use(({ ctx, next }) => { 120 | if (!ctx || !ctx.user) { 121 | throw new TRPCError({ code: "UNAUTHORIZED" }); 122 | } 123 | return next({ 124 | ctx: { 125 | user: ctx.user, 126 | }, 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /src/app/features/UserDetails.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState, useEffect } from "react"; 3 | import * as doh from "../../lib/dnssec-prover/doh_lookup.js"; 4 | import Bip353Box from "../components/Bip353Box"; 5 | import type { Bip353, Bip21URI } from "@/lib/util/index.js"; 6 | import CopyUserLinkButton from "./CopyUserLinkButton"; 7 | import CopyBip353Button from "./CopyBip353Button"; 8 | import Button from "../components/Button"; 9 | import { parseBip21URI } from "@/lib/util/index"; 10 | import PaymentDetail from "../components/PaymentDetail"; 11 | 12 | export default function UserDetails(props:Bip353){ 13 | const [userNameCheck, setUserNameCheck] = useState(null); 14 | const [uri, setURI] = useState(null); 15 | const [validPayCode, setValidPayCode] = useState(null); 16 | const [multipleRecords, setMultipleRecords] = useState(false); 17 | 18 | const checkUserName = () => { 19 | doh 20 | .lookup_doh( 21 | `${props.user}.user._bitcoin-payment.${props.domain}`, 22 | "TXT", 23 | "https://1.1.1.1/dns-query" 24 | ) 25 | .then((response) => { 26 | console.log(response); 27 | let validation = JSON.parse(response); 28 | setUserNameCheck(validation); 29 | 30 | if (validation.valid_from && validation.verified_rrs.length === 1) { 31 | setValidPayCode(true); 32 | let parsedURI = parseBip21URI(validation.verified_rrs[0].contents); 33 | console.log(parsedURI); 34 | setURI(parsedURI); 35 | } else if ( 36 | validation.valid_from && 37 | validation.verified_rrs.length > 1 38 | ) { 39 | setValidPayCode(false); 40 | setMultipleRecords(true); 41 | } else setValidPayCode(false); 42 | }); 43 | }; 44 | 45 | useEffect(() => { 46 | const timer = setTimeout(() => { 47 | checkUserName(); 48 | }, 0); 49 | return () => clearTimeout(timer); // Cleanup in case the component unmounts 50 | }, []); 51 | 52 | return( 53 | <> 54 |

55 | {validPayCode ? "Valid Paycode" : validPayCode === null ? "Checking..." : "Invalid Paycode"} 56 |

57 | 58 |
59 |
60 | 61 |
62 |
63 | 64 |
65 |
66 |
67 |

Payment Details

68 | {uri ? 69 |
70 | 71 | {uri.path ? 72 | 73 | : ``} 74 | {Object.entries(uri?.params).map(([key, value]) => { 75 | let prettyKey = key; 76 | let prettyUri = uri.uri; 77 | 78 | switch(key) { 79 | case 'lno': 80 | prettyKey = 'Offer'; 81 | prettyUri = `bitcoin:?lno=${value}`; 82 | break; 83 | case 'sp': 84 | prettyKey = 'Silent Payment'; 85 | prettyUri = `bitcoin:?sp=${value}`; 86 | break; 87 | case 'label': 88 | prettyKey = 'Label'; 89 | break; 90 | case 'lnurl': 91 | prettyKey = 'LNURL'; 92 | prettyUri = `lightning:${value}`; 93 | break; 94 | } 95 | return( 96 | 97 | ) 98 | })} 99 |
100 | : 101 | validPayCode !== false ? 102 |
103 | 104 | 105 |
106 | : 107 |

108 | Sorry, no valid payment details were found. If you just created this Pay Code, try waiting a few minutes so the DNS records can propagate. 109 |

110 | } 111 |
112 | 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /src/lib/util/index.ts: -------------------------------------------------------------------------------- 1 | import { PayCodeParamType } from "@prisma/client"; 2 | import { bech32 } from "bech32"; 3 | import { z } from "zod"; 4 | import { Prisma } from "@prisma/client"; 5 | 6 | export const lnAddrToLNURL = (lnaddr: string) => { 7 | const [username, domain] = lnaddr.split("@"); 8 | if (!username || !domain) 9 | throw new Error("Failed to parse lightning address"); 10 | 11 | const decodedLnurl = `https://${domain}/.well-known/lnurlp/${username}`; 12 | 13 | let words = bech32.toWords(Buffer.from(decodedLnurl, "utf8")); 14 | const lnurl = bech32.encode("lnurl", words); 15 | return lnurl; 16 | }; 17 | 18 | export type Custom = { 19 | prefix: string; 20 | value: string; 21 | }; 22 | 23 | export type Bip21Dict = { 24 | onChain?: string; 25 | label?: string; 26 | lno?: string; 27 | sp?: string; 28 | custom?: Custom[]; 29 | }; 30 | 31 | export type Bip353 = { 32 | user: string; 33 | domain: string; 34 | }; 35 | 36 | // TODO: Fix unit tests 37 | export const createBip21 = ( 38 | onChain?: string, 39 | label?: string, 40 | lno?: string, 41 | sp?: string, 42 | lnurl?: string, 43 | custom?: Custom[] 44 | ): string => { 45 | const base = onChain ? `bitcoin:${onChain}` : "bitcoin:"; 46 | const url = new URL(base); 47 | 48 | if (label && onChain) url.searchParams.append("label", label); 49 | if (lno) url.searchParams.append("lno", lno); 50 | if (sp) url.searchParams.append("sp", sp); 51 | if (lnurl) url.searchParams.append("lnurl", lnurl); 52 | 53 | if (Array.isArray(custom)) { 54 | custom.forEach((item: Custom) => { 55 | url.searchParams.append(item.prefix, item.value); 56 | }); 57 | } 58 | 59 | const bip21 = url.toString(); 60 | if (bip21 === "bitcoin:") throw new Error("No payment option provided"); 61 | if (bip21.length > 2048) 62 | throw new Error("Bip21 URI is greater than 2048 characters"); 63 | 64 | return bip21; 65 | }; 66 | 67 | export const createPayCodeParams = ( 68 | onChain?: string, 69 | label?: string, 70 | lno?: string, 71 | sp?: string, 72 | lnurl?: string, 73 | custom?: Custom[] 74 | ): Prisma.PayCodeParamCreateWithoutPayCodeInput[] => { 75 | let create: Prisma.PayCodeParamCreateWithoutPayCodeInput[] = []; 76 | if (onChain) create.push({ value: onChain, type: PayCodeParamType.ONCHAIN }); 77 | if (label) create.push({ value: label, type: PayCodeParamType.LABEL }); 78 | if (lno) create.push({ value: lno, type: PayCodeParamType.LNO }); 79 | if (sp) create.push({ value: sp, type: PayCodeParamType.SP }); 80 | if (lnurl) create.push({ value: lnurl, type: PayCodeParamType.LNURL }); 81 | 82 | if (Array.isArray(custom)) { 83 | custom.forEach((item: Custom) => { 84 | create.push({ 85 | prefix: item.prefix, 86 | value: item.value, 87 | type: PayCodeParamType.CUSTOM, 88 | }); 89 | }); 90 | } 91 | 92 | if (create.length == 0) { 93 | throw new Error("No parameters provided"); 94 | } 95 | 96 | return create; 97 | }; 98 | 99 | export type Param = { 100 | prefix: string | null; 101 | value: string; 102 | type: PayCodeParamType; 103 | }; 104 | 105 | export const createBip21FromParams = (params: Param[]) => { 106 | if (params.length === 0) throw new Error("No parameters"); 107 | const base = "bitcoin:"; 108 | const url = new URL(base); 109 | for (let param of params) { 110 | if (param.type === PayCodeParamType.LNO) 111 | url.searchParams.append("lno", param.value); 112 | if (param.type === PayCodeParamType.SP) 113 | url.searchParams.append("sp", param.value); 114 | if (param.type === PayCodeParamType.LNURL) 115 | url.searchParams.append("lnurl", param.value); 116 | if (param.type === PayCodeParamType.CUSTOM) 117 | url.searchParams.append(param.prefix!, param.value); 118 | 119 | // lol 120 | if (param.type === PayCodeParamType.ONCHAIN) { 121 | const onChainUrl = new URL(`${base}${param.value}`); 122 | for (let innerParam of params) { 123 | if (innerParam.type === PayCodeParamType.LABEL) 124 | onChainUrl.searchParams.append("label", innerParam.value); 125 | if (innerParam.type === PayCodeParamType.LNO) 126 | onChainUrl.searchParams.append("lno", innerParam.value); 127 | if (innerParam.type === PayCodeParamType.SP) 128 | onChainUrl.searchParams.append("sp", innerParam.value); 129 | if (innerParam.type === PayCodeParamType.LNURL) 130 | onChainUrl.searchParams.append("lnurl", innerParam.value); 131 | if (innerParam.type === PayCodeParamType.CUSTOM) 132 | onChainUrl.searchParams.append(innerParam.prefix!, innerParam.value); 133 | } 134 | return onChainUrl.toString(); 135 | } 136 | } 137 | return url.toString(); 138 | }; 139 | 140 | export function getZodEnumFromObjectKeys< 141 | TI extends Record, 142 | R extends string = TI extends Record ? R : never 143 | >(input: TI): z.ZodEnum<[R, ...R[]]> { 144 | const [firstKey, ...otherKeys] = Object.keys(input) as [R, ...R[]]; 145 | return z.enum([firstKey, ...otherKeys]); 146 | } 147 | 148 | export type Bip21URI = { 149 | uri: string; 150 | scheme: string; 151 | path: string; 152 | query: string; 153 | params: { [key: string]: string }; 154 | }; 155 | 156 | export function parseBip21URI(uriString: string): Bip21URI { 157 | const regex = 158 | /^(?:([^:/?#]+):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?/; 159 | const match = uriString.match(regex); 160 | 161 | if (!match) { 162 | throw new Error("Invalid URI"); 163 | } 164 | 165 | let URI: Bip21URI = { 166 | uri: uriString, 167 | scheme: match[1] || "", 168 | path: match[3] || "", 169 | query: match[4] || "", 170 | params: {}, 171 | }; 172 | 173 | if (URI.query) { 174 | URI.params = Object.fromEntries( 175 | match[4].split("&").map((pair) => pair.split("=").map(decodeURIComponent)) 176 | ); 177 | } 178 | 179 | return URI; 180 | } 181 | -------------------------------------------------------------------------------- /src/lib/util/index.test.ts: -------------------------------------------------------------------------------- 1 | import { PayCodeParamType } from "@prisma/client"; 2 | import { createBip21FromParams, Param } from "."; 3 | 4 | const { lnAddrToLNURL, createBip21 } = require("./index"); 5 | 6 | test("try using different lightning address inputs", () => { 7 | expect(lnAddrToLNURL("chad@strike.me")).toBe( 8 | "lnurl1dp68gurn8ghj7um5wf5kkefwd4jj7tnhv4kxctttdehhwm30d3h82unvwqhkx6rpvsclqksp" 9 | ); 10 | expect(() => lnAddrToLNURL("")).toThrow(); 11 | expect(() => lnAddrToLNURL("test")).toThrow(); 12 | expect(() => lnAddrToLNURL("@domain.com")).toThrow(); 13 | }); 14 | 15 | const onChain = "175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W"; 16 | const label = "Luke-Jr"; // make sure no spaces 17 | const lno = 18 | "lno1qsgqmqvgm96frzdg8m0gc6nzeqffvzsqzrxqy32afmr3jn9ggkwg3egfwch2hy0l6jut6vfd8vpsc3h89l6u3dm4q2d6nuamav3w27xvdmv3lpgklhg7l5teypqz9l53hj7zvuaenh34xqsz2sa967yzqkylfu9xtcd5ymcmfp32h083e805y7jfd236w9afhavqqvl8uyma7x77yun4ehe9pnhu2gekjguexmxpqjcr2j822xr7q34p078gzslf9wpwz5y57alxu99s0z2ql0kfqvwhzycqq45ehh58xnfpuek80hw6spvwrvttjrrq9pphh0dpydh06qqspp5uq4gpyt6n9mwexde44qv7lstzzq60nr40ff38u27un6y53aypmx0p4qruk2tf9mjwqlhxak4znvna5y"; 19 | const sp = 20 | "sp1qqweplq6ylpfrzuq6hfznzmv28djsraupudz0s0dclyt8erh70pgwxqkz2ydatksrdzf770umsntsmcjp4kcz7jqu03jeszh0gdmpjzmrf5u4zh0c"; 21 | const lnurl = 22 | "lnurl1dp68gurn8ghj7um5wf5kkefwd4jj7tnhv4kxctttdehhwm30d3h82unvwqhkx6rpvsclqksp"; 23 | const custom1 = { 24 | prefix: "someCustom", 25 | value: "someCustomValue", 26 | }; 27 | const custom2 = { 28 | prefix: "chad", 29 | value: "test", 30 | }; 31 | const testCases = [ 32 | { input: {}, expected: "No payment option provided", shouldThrow: true }, 33 | { input: { onChain }, expected: `bitcoin:${onChain}`, shouldThrow: false }, 34 | { 35 | input: { onChain, label }, 36 | expected: `bitcoin:${onChain}?label=${label}`, 37 | shouldThrow: false, 38 | }, 39 | { 40 | input: { label }, 41 | expected: "No payment option provided", 42 | shouldThrow: true, 43 | }, 44 | { 45 | input: { onChain, label, lno }, 46 | expected: `bitcoin:${onChain}?label=${label}&lno=${lno}`, 47 | shouldThrow: false, 48 | }, 49 | { 50 | input: { onChain, lno }, 51 | expected: `bitcoin:${onChain}?lno=${lno}`, 52 | shouldThrow: false, 53 | }, 54 | { input: { lno }, expected: `bitcoin:?lno=${lno}`, shouldThrow: false }, 55 | { 56 | input: { lno, label }, 57 | expected: `bitcoin:?lno=${lno}`, 58 | shouldThrow: false, 59 | }, 60 | { 61 | input: { sp, custom: [custom1] }, 62 | expected: `bitcoin:?sp=${sp}&${custom1.prefix}=${custom1.value}`, 63 | shouldThrow: false, 64 | }, 65 | { 66 | input: { custom: [custom2, custom1] }, 67 | expected: `bitcoin:?${custom2.prefix}=${custom2.value}&${custom1.prefix}=${custom1.value}`, 68 | shouldThrow: false, 69 | }, 70 | { 71 | input: { custom: [] }, 72 | expected: "No payment option provided", 73 | shouldThrow: true, 74 | }, 75 | { 76 | input: { sp, custom: [] }, 77 | expected: `bitcoin:?sp=${sp}`, 78 | shouldThrow: false, 79 | }, 80 | { 81 | input: { sp: "z".repeat(2036) }, // "bitcoin:?sp=" is 12 chars, total 2048 82 | expected: `bitcoin:?sp=${"z".repeat(2036)}`, 83 | shouldThrow: false, 84 | }, 85 | { 86 | input: { sp: "z".repeat(2037) }, // total 2049 87 | expected: "Bip21 URI is greater than 2048 characters", 88 | shouldThrow: true, 89 | }, 90 | { 91 | input: { 92 | lno: "lno123...xyz", 93 | sp: "sp123...xyz", 94 | onChain: "bc1p...xyz", 95 | lnurl: "lnurl...xyz", 96 | custom: [ 97 | { prefix: "food", value: "yum" }, 98 | { prefix: "veggie", value: "carrot" }, 99 | ], 100 | }, 101 | expected: `bitcoin:bc1p...xyz?lno=lno123...xyz&sp=sp123...xyz&lnurl=lnurl...xyz&food=yum&veggie=carrot`, 102 | shouldThrow: false, 103 | }, 104 | ]; 105 | test.each(testCases)( 106 | "createBip21($input) should return $expected", 107 | ({ input, expected, shouldThrow }) => { 108 | if (shouldThrow) { 109 | expect(() => 110 | createBip21( 111 | input.onChain, 112 | input.label, 113 | input.lno, 114 | input.sp, 115 | input.lnurl, 116 | input.custom 117 | ) 118 | ).toThrow(expected); 119 | } else { 120 | expect( 121 | createBip21( 122 | input.onChain, 123 | input.label, 124 | input.lno, 125 | input.sp, 126 | input.lnurl, 127 | input.custom 128 | ) 129 | ).toBe(expected); 130 | } 131 | } 132 | ); 133 | 134 | const onChainParam: Param = { 135 | prefix: null, 136 | value: onChain, 137 | type: PayCodeParamType.ONCHAIN, 138 | }; 139 | const labelParam: Param = { 140 | prefix: null, 141 | value: label, 142 | type: PayCodeParamType.LABEL, 143 | }; 144 | const spParam: Param = { 145 | prefix: null, 146 | value: sp, 147 | type: PayCodeParamType.SP, 148 | }; 149 | const lnoParam: Param = { 150 | prefix: null, 151 | value: lno, 152 | type: PayCodeParamType.LNO, 153 | }; 154 | const lnurlParam: Param = { 155 | prefix: null, 156 | value: lnurl, 157 | type: PayCodeParamType.LNURL, 158 | }; 159 | const customParam1: Param = { 160 | prefix: "apple", 161 | value: "banana", 162 | type: PayCodeParamType.CUSTOM, 163 | }; 164 | const customParam2: Param = { 165 | prefix: "coconut", 166 | value: "tree", 167 | type: PayCodeParamType.CUSTOM, 168 | }; 169 | const createBip21FromParamsTestCases = [ 170 | { input: [], expected: "No parameters", shouldThrow: true }, 171 | { 172 | input: [spParam], 173 | expected: `bitcoin:?sp=${spParam.value}`, 174 | shouldThrow: false, 175 | }, 176 | { 177 | // no label if no onChain address 178 | input: [spParam, labelParam], 179 | expected: `bitcoin:?sp=${spParam.value}`, 180 | shouldThrow: false, 181 | }, 182 | { 183 | input: [lnurlParam, lnoParam], 184 | expected: `bitcoin:?lnurl=${lnurlParam.value}&lno=${lnoParam.value}`, 185 | shouldThrow: false, 186 | }, 187 | { 188 | input: [customParam1], 189 | expected: `bitcoin:?${customParam1.prefix}=${customParam1.value}`, 190 | shouldThrow: false, 191 | }, 192 | { 193 | input: [onChainParam], 194 | expected: `bitcoin:${onChainParam.value}`, 195 | shouldThrow: false, 196 | }, 197 | { 198 | input: [onChainParam, labelParam], 199 | expected: `bitcoin:${onChainParam.value}?label=${labelParam.value}`, 200 | shouldThrow: false, 201 | }, 202 | { 203 | input: [onChainParam, spParam], 204 | expected: `bitcoin:${onChainParam.value}?sp=${spParam.value}`, 205 | shouldThrow: false, 206 | }, 207 | { 208 | input: [onChainParam, customParam2, customParam1], 209 | expected: `bitcoin:${onChainParam.value}?${customParam2.prefix}=${customParam2.value}&${customParam1.prefix}=${customParam1.value}`, 210 | shouldThrow: false, 211 | }, 212 | ]; 213 | 214 | test.each(createBip21FromParamsTestCases)( 215 | "createBip21FromParams should return $expected", 216 | ({ input, expected, shouldThrow }) => { 217 | if (shouldThrow) { 218 | expect(() => createBip21FromParams(input)).toThrow(expected); 219 | } else { 220 | expect(createBip21FromParams(input)).toBe(expected); 221 | } 222 | } 223 | ); 224 | -------------------------------------------------------------------------------- /src/app/features/NewPayCodeForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Button from "@/app/components/Button"; 3 | import Input from "@/app/components/InputZ"; 4 | import { 5 | ArrowRightIcon, 6 | CaretUpIcon, 7 | CaretDownIcon, 8 | RefreshIcon, 9 | } from "@bitcoin-design/bitcoin-icons-react/filled"; 10 | import InteractionModal from "@/app/components/InteractionModal"; 11 | import Invoice from "@/app/components/Invoice"; 12 | import type { TwelveCashDomains } from "@/lib/util/constant"; 13 | import { useEffect, useState } from "react"; 14 | import { RouterOutputs, api } from "@/trpc/react"; 15 | import { useZodForm } from "@/lib/util/useZodForm"; 16 | import { useRouter } from "next/navigation"; 17 | import { RouterInputs } from "@/trpc/react"; 18 | import { payCodeInput } from "@/lib/util/constant"; 19 | 20 | interface NewPayCodeFormProps { 21 | defaultDomain: TwelveCashDomains; 22 | } 23 | 24 | export default function NewPayCodeForm(props: NewPayCodeFormProps) { 25 | const [optionsExpanded, setOptionsExpanded] = useState(false); 26 | const [paymentInfo, setPaymentInfo] = useState< 27 | RouterOutputs["payCode"]["createPayCode"] | null 28 | >(null); 29 | const [freeName, setFreeName] = useState(false); 30 | 31 | const router = useRouter(); 32 | const createPayCode = api.payCode.createPayCode.useMutation({ 33 | onSuccess: (data) => { 34 | console.debug("success data", data); 35 | setPaymentInfo(data); 36 | }, 37 | onError: (err) => { 38 | console.error("Failed to create paycode", err); 39 | }, 40 | }); 41 | const createRandomPaycode = api.payCode.createRandomPayCode.useMutation({ 42 | onSuccess: (data) => { 43 | console.debug("success data", data); 44 | router.push(`/new/${data.userName}@${data.domain}`); 45 | }, 46 | onError: () => { 47 | console.error("Failed to create paycode"); 48 | }, 49 | }); 50 | const redeemPayCode = api.payCode.redeemPayCode.useMutation({ 51 | onSuccess: (payCode) => { 52 | // setPaymentInfo null? 53 | setPaymentInfo(null); 54 | console.debug("redeem success!", payCode); 55 | router.push(`/new/${payCode.userName}@${payCode.domain}`); 56 | }, 57 | onError: (err) => { 58 | setPaymentInfo(null); 59 | console.error("Failed to redeem paycode... sorry", err); 60 | }, 61 | }); 62 | 63 | const { 64 | register, 65 | handleSubmit, 66 | setValue, 67 | setError, 68 | trigger, 69 | formState: { errors, isDirty, isValid }, 70 | } = useZodForm({ 71 | mode: "onChange", 72 | schema: payCodeInput, 73 | defaultValues: { 74 | domain: props.defaultDomain, 75 | }, 76 | }); 77 | 78 | useEffect(() => { 79 | if (freeName) { 80 | console.log("freeName", freeName); 81 | // hack to allow us to use the same validator for both free and paid 82 | // this username won't be used in the free api. 83 | setValue("userName", "hack"); 84 | trigger(); 85 | return; 86 | } 87 | setValue("userName", ""); 88 | trigger(); 89 | }, [freeName]); 90 | 91 | const createPaycode = async ( 92 | data: RouterInputs["payCode"]["createPayCode"] 93 | ) => { 94 | if (!data.lno && !data.sp && !data.onChain && !data.lnurl) { 95 | console.error("At least one payment option must be provided."); 96 | setError("lno", { 97 | message: "At least one payment option must be provided.", 98 | }); 99 | return; 100 | } 101 | if (freeName) { 102 | createRandomPaycode.mutate(data); 103 | return; 104 | } 105 | createPayCode.mutate(data); 106 | }; 107 | 108 | return ( 109 |
110 |
111 |
112 | 120 | 128 |
129 | {!freeName && ( 130 | 140 | )} 141 |
142 | 151 | 160 | 172 | 184 | 196 |
197 | 211 | 217 |
218 | {paymentInfo && ( 219 | setPaymentInfo(null)} 222 | > 223 |
224 |

225 | 5,000 sats 226 |

227 |
228 | Awaiting Payment 229 |
230 |
231 | 234 | redeemPayCode.mutate({ invoiceId: paymentInfo.invoice.id }) 235 | } 236 | /> 237 |
238 | )} 239 |
240 | ) 241 | } -------------------------------------------------------------------------------- /src/lib/dnssec-prover/dnssec_prover_wasm.js: -------------------------------------------------------------------------------- 1 | let wasm; 2 | 3 | const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } ); 4 | 5 | if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); }; 6 | 7 | let cachedUint8Memory0 = null; 8 | 9 | function getUint8Memory0() { 10 | if (cachedUint8Memory0 === null || cachedUint8Memory0.byteLength === 0) { 11 | cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer); 12 | } 13 | return cachedUint8Memory0; 14 | } 15 | 16 | function getStringFromWasm0(ptr, len) { 17 | ptr = ptr >>> 0; 18 | return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len)); 19 | } 20 | 21 | let WASM_VECTOR_LEN = 0; 22 | 23 | const cachedTextEncoder = (typeof TextEncoder !== 'undefined' ? new TextEncoder('utf-8') : { encode: () => { throw Error('TextEncoder not available') } } ); 24 | 25 | const encodeString = (typeof cachedTextEncoder.encodeInto === 'function' 26 | ? function (arg, view) { 27 | return cachedTextEncoder.encodeInto(arg, view); 28 | } 29 | : function (arg, view) { 30 | const buf = cachedTextEncoder.encode(arg); 31 | view.set(buf); 32 | return { 33 | read: arg.length, 34 | written: buf.length 35 | }; 36 | }); 37 | 38 | function passStringToWasm0(arg, malloc, realloc) { 39 | 40 | if (realloc === undefined) { 41 | const buf = cachedTextEncoder.encode(arg); 42 | const ptr = malloc(buf.length, 1) >>> 0; 43 | getUint8Memory0().subarray(ptr, ptr + buf.length).set(buf); 44 | WASM_VECTOR_LEN = buf.length; 45 | return ptr; 46 | } 47 | 48 | let len = arg.length; 49 | let ptr = malloc(len, 1) >>> 0; 50 | 51 | const mem = getUint8Memory0(); 52 | 53 | let offset = 0; 54 | 55 | for (; offset < len; offset++) { 56 | const code = arg.charCodeAt(offset); 57 | if (code > 0x7F) break; 58 | mem[ptr + offset] = code; 59 | } 60 | 61 | if (offset !== len) { 62 | if (offset !== 0) { 63 | arg = arg.slice(offset); 64 | } 65 | ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; 66 | const view = getUint8Memory0().subarray(ptr + offset, ptr + len); 67 | const ret = encodeString(arg, view); 68 | 69 | offset += ret.written; 70 | ptr = realloc(ptr, len, offset, 1) >>> 0; 71 | } 72 | 73 | WASM_VECTOR_LEN = offset; 74 | return ptr; 75 | } 76 | /** 77 | * Builds a proof builder which can generate a proof for records of the given `ty`pe at the given 78 | * `name`. 79 | * 80 | * After calling this [`get_next_query`] should be called to fetch the initial query. 81 | * @param {string} name 82 | * @param {number} ty 83 | * @returns {WASMProofBuilder | undefined} 84 | */ 85 | export function init_proof_builder(name, ty) { 86 | const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 87 | const len0 = WASM_VECTOR_LEN; 88 | const ret = wasm.init_proof_builder(ptr0, len0, ty); 89 | return ret === 0 ? undefined : WASMProofBuilder.__wrap(ret); 90 | } 91 | 92 | function _assertClass(instance, klass) { 93 | if (!(instance instanceof klass)) { 94 | throw new Error(`expected instance of ${klass.name}`); 95 | } 96 | return instance.ptr; 97 | } 98 | 99 | function passArray8ToWasm0(arg, malloc) { 100 | const ptr = malloc(arg.length * 1, 1) >>> 0; 101 | getUint8Memory0().set(arg, ptr / 1); 102 | WASM_VECTOR_LEN = arg.length; 103 | return ptr; 104 | } 105 | /** 106 | * Processes a response to a query previously fetched from [`get_next_query`]. 107 | * 108 | * After calling this, [`get_next_query`] should be called until pending queries are exhausted and 109 | * no more pending queries exist, at which point [`get_unverified_proof`] should be called. 110 | * @param {WASMProofBuilder} proof_builder 111 | * @param {Uint8Array} response 112 | */ 113 | export function process_query_response(proof_builder, response) { 114 | _assertClass(proof_builder, WASMProofBuilder); 115 | const ptr0 = passArray8ToWasm0(response, wasm.__wbindgen_malloc); 116 | const len0 = WASM_VECTOR_LEN; 117 | wasm.process_query_response(proof_builder.__wbg_ptr, ptr0, len0); 118 | } 119 | 120 | let cachedInt32Memory0 = null; 121 | 122 | function getInt32Memory0() { 123 | if (cachedInt32Memory0 === null || cachedInt32Memory0.byteLength === 0) { 124 | cachedInt32Memory0 = new Int32Array(wasm.memory.buffer); 125 | } 126 | return cachedInt32Memory0; 127 | } 128 | 129 | function getArrayU8FromWasm0(ptr, len) { 130 | ptr = ptr >>> 0; 131 | return getUint8Memory0().subarray(ptr / 1, ptr / 1 + len); 132 | } 133 | /** 134 | * Gets the next query (if any) that should be sent to the resolver for the given proof builder. 135 | * 136 | * Once the resolver responds [`process_query_response`] should be called with the response. 137 | * @param {WASMProofBuilder} proof_builder 138 | * @returns {Uint8Array | undefined} 139 | */ 140 | export function get_next_query(proof_builder) { 141 | try { 142 | const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); 143 | _assertClass(proof_builder, WASMProofBuilder); 144 | wasm.get_next_query(retptr, proof_builder.__wbg_ptr); 145 | var r0 = getInt32Memory0()[retptr / 4 + 0]; 146 | var r1 = getInt32Memory0()[retptr / 4 + 1]; 147 | let v1; 148 | if (r0 !== 0) { 149 | v1 = getArrayU8FromWasm0(r0, r1).slice(); 150 | wasm.__wbindgen_free(r0, r1 * 1, 1); 151 | } 152 | return v1; 153 | } finally { 154 | wasm.__wbindgen_add_to_stack_pointer(16); 155 | } 156 | } 157 | 158 | /** 159 | * Gets the final, unverified, proof once all queries fetched via [`get_next_query`] have 160 | * completed and their responses passed to [`process_query_response`]. 161 | * @param {WASMProofBuilder} proof_builder 162 | * @returns {Uint8Array | undefined} 163 | */ 164 | export function get_unverified_proof(proof_builder) { 165 | try { 166 | const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); 167 | _assertClass(proof_builder, WASMProofBuilder); 168 | var ptr0 = proof_builder.__destroy_into_raw(); 169 | wasm.get_unverified_proof(retptr, ptr0); 170 | var r0 = getInt32Memory0()[retptr / 4 + 0]; 171 | var r1 = getInt32Memory0()[retptr / 4 + 1]; 172 | let v2; 173 | if (r0 !== 0) { 174 | v2 = getArrayU8FromWasm0(r0, r1).slice(); 175 | wasm.__wbindgen_free(r0, r1 * 1, 1); 176 | } 177 | return v2; 178 | } finally { 179 | wasm.__wbindgen_add_to_stack_pointer(16); 180 | } 181 | } 182 | 183 | /** 184 | * Verifies an RFC 9102-formatted proof and returns verified records matching the given name 185 | * (resolving any C/DNAMEs as required). 186 | * @param {Uint8Array} stream 187 | * @param {string} name_to_resolve 188 | * @returns {string} 189 | */ 190 | export function verify_byte_stream(stream, name_to_resolve) { 191 | let deferred3_0; 192 | let deferred3_1; 193 | try { 194 | const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); 195 | const ptr0 = passArray8ToWasm0(stream, wasm.__wbindgen_malloc); 196 | const len0 = WASM_VECTOR_LEN; 197 | const ptr1 = passStringToWasm0(name_to_resolve, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 198 | const len1 = WASM_VECTOR_LEN; 199 | wasm.verify_byte_stream(retptr, ptr0, len0, ptr1, len1); 200 | var r0 = getInt32Memory0()[retptr / 4 + 0]; 201 | var r1 = getInt32Memory0()[retptr / 4 + 1]; 202 | deferred3_0 = r0; 203 | deferred3_1 = r1; 204 | return getStringFromWasm0(r0, r1); 205 | } finally { 206 | wasm.__wbindgen_add_to_stack_pointer(16); 207 | wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); 208 | } 209 | } 210 | 211 | const WASMProofBuilderFinalization = (typeof FinalizationRegistry === 'undefined') 212 | ? { register: () => {}, unregister: () => {} } 213 | : new FinalizationRegistry(ptr => wasm.__wbg_wasmproofbuilder_free(ptr >>> 0)); 214 | /** 215 | */ 216 | export class WASMProofBuilder { 217 | 218 | static __wrap(ptr) { 219 | ptr = ptr >>> 0; 220 | const obj = Object.create(WASMProofBuilder.prototype); 221 | obj.__wbg_ptr = ptr; 222 | WASMProofBuilderFinalization.register(obj, obj.__wbg_ptr, obj); 223 | return obj; 224 | } 225 | 226 | __destroy_into_raw() { 227 | const ptr = this.__wbg_ptr; 228 | this.__wbg_ptr = 0; 229 | WASMProofBuilderFinalization.unregister(this); 230 | return ptr; 231 | } 232 | 233 | free() { 234 | const ptr = this.__destroy_into_raw(); 235 | wasm.__wbg_wasmproofbuilder_free(ptr); 236 | } 237 | } 238 | 239 | async function __wbg_load(module, imports) { 240 | if (typeof Response === 'function' && module instanceof Response) { 241 | if (typeof WebAssembly.instantiateStreaming === 'function') { 242 | try { 243 | return await WebAssembly.instantiateStreaming(module, imports); 244 | 245 | } catch (e) { 246 | if (module.headers.get('Content-Type') != 'application/wasm') { 247 | console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); 248 | 249 | } else { 250 | throw e; 251 | } 252 | } 253 | } 254 | 255 | const bytes = await module.arrayBuffer(); 256 | return await WebAssembly.instantiate(bytes, imports); 257 | 258 | } else { 259 | const instance = await WebAssembly.instantiate(module, imports); 260 | 261 | if (instance instanceof WebAssembly.Instance) { 262 | return { instance, module }; 263 | 264 | } else { 265 | return instance; 266 | } 267 | } 268 | } 269 | 270 | function __wbg_get_imports() { 271 | const imports = {}; 272 | imports.wbg = {}; 273 | imports.wbg.__wbindgen_throw = function(arg0, arg1) { 274 | throw new Error(getStringFromWasm0(arg0, arg1)); 275 | }; 276 | 277 | return imports; 278 | } 279 | 280 | function __wbg_init_memory(imports, maybe_memory) { 281 | 282 | } 283 | 284 | function __wbg_finalize_init(instance, module) { 285 | wasm = instance.exports; 286 | __wbg_init.__wbindgen_wasm_module = module; 287 | cachedInt32Memory0 = null; 288 | cachedUint8Memory0 = null; 289 | 290 | 291 | return wasm; 292 | } 293 | 294 | function initSync(module) { 295 | if (wasm !== undefined) return wasm; 296 | 297 | const imports = __wbg_get_imports(); 298 | 299 | __wbg_init_memory(imports); 300 | 301 | if (!(module instanceof WebAssembly.Module)) { 302 | module = new WebAssembly.Module(module); 303 | } 304 | 305 | const instance = new WebAssembly.Instance(module, imports); 306 | 307 | return __wbg_finalize_init(instance, module); 308 | } 309 | 310 | async function __wbg_init(input) { 311 | if (wasm !== undefined) return wasm; 312 | 313 | if (typeof input === 'undefined') { 314 | input = new URL('dnssec_prover_wasm_bg.wasm', import.meta.url); 315 | } 316 | const imports = __wbg_get_imports(); 317 | 318 | if (typeof input === 'string' || (typeof Request === 'function' && input instanceof Request) || (typeof URL === 'function' && input instanceof URL)) { 319 | input = fetch(input); 320 | } 321 | 322 | __wbg_init_memory(imports); 323 | 324 | const { instance, module } = await __wbg_load(await input, imports); 325 | 326 | return __wbg_finalize_init(instance, module); 327 | } 328 | 329 | export { initSync } 330 | export default __wbg_init; 331 | -------------------------------------------------------------------------------- /src/server/api/routers/payCode.ts: -------------------------------------------------------------------------------- 1 | import { payCodeInput, randomPayCodeInput } from "@/lib/util/constant"; 2 | import { 3 | createTRPCRouter, 4 | protectedProcedure, 5 | publicProcedure, 6 | } from "@/server/api/trpc"; 7 | import { TRPCError } from "@trpc/server"; 8 | import { 9 | InvoiceKind, 10 | InvoiceStatus, 11 | PayCodeStatus, 12 | Prisma, 13 | } from "@prisma/client"; 14 | import { 15 | createBip21, 16 | createBip21FromParams, 17 | createPayCodeParams, 18 | } from "@/lib/util"; 19 | import axios from "axios"; 20 | import { 21 | adjectives, 22 | animals, 23 | uniqueNamesGenerator, 24 | } from "unique-names-generator"; 25 | import { z } from "zod"; 26 | import { createInvoice, lookupInvoice } from "@/server/lnd"; 27 | 28 | const domainMap = JSON.parse(process.env.DOMAINS!); 29 | 30 | export const payCodeRouter = createTRPCRouter({ 31 | createRandomPayCode: publicProcedure 32 | .input(randomPayCodeInput) 33 | .mutation(async ({ ctx, input }) => { 34 | const user = ctx.user; 35 | console.debug("user", user); 36 | console.debug("input", input); 37 | 38 | let bip21: string; 39 | try { 40 | bip21 = createBip21( 41 | input.onChain, 42 | input.label, 43 | input.lno, 44 | input.sp, 45 | input.lnurl, 46 | input.custom 47 | ); 48 | } catch (e: any) { 49 | throw new TRPCError({ 50 | code: "BAD_REQUEST", 51 | message: e.message, 52 | }); 53 | } 54 | console.debug("bip21", bip21); 55 | 56 | let errMsg = ""; 57 | let userName = ""; 58 | let fullName = ""; 59 | const CF_BASE_URL = `https://api.cloudflare.com/client/v4/zones/${ 60 | domainMap[input.domain] 61 | }/dns_records`; 62 | for (let i = 0; i < 5; i++) { 63 | userName = uniqueNamesGenerator({ 64 | dictionaries: [adjectives, animals], 65 | length: 2, 66 | separator: ".", 67 | }); 68 | fullName = process.env.NETWORK 69 | ? `${userName}.user._bitcoin-payment.${process.env.NETWORK}.${input.domain}` 70 | : `${userName}.user._bitcoin-payment.${input.domain}`; 71 | 72 | // First check to see if this user name already exists in DNS 73 | // Eventually, all paycodes will be in the DB. 74 | const res = await axios 75 | .get(`${CF_BASE_URL}?name=${fullName}&type=TXT`, { 76 | headers: { 77 | Content_Type: "application/json", 78 | Authorization: `Bearer ${process.env.CF_TOKEN}`, 79 | }, 80 | }) 81 | .catch((e: any) => { 82 | throw new TRPCError({ 83 | code: "INTERNAL_SERVER_ERROR", 84 | message: "Server failed to talk to DNS", 85 | }); 86 | }); 87 | if (res.data.result.length > 0) { 88 | errMsg = "Name is already taken."; 89 | continue; 90 | } 91 | 92 | // Check if the paycode exists in the our database 93 | const existingPayCode = await ctx.db.payCode 94 | .findFirst({ 95 | where: { 96 | AND: [ 97 | { userName: userName }, 98 | { domain: input.domain }, 99 | { status: PayCodeStatus.ACTIVE }, 100 | ], 101 | }, 102 | }) 103 | .catch((e: any) => { 104 | console.error("e", e); 105 | throw new TRPCError({ 106 | code: "INTERNAL_SERVER_ERROR", 107 | message: "Failed to lookup paycode", 108 | }); 109 | }); 110 | if (!existingPayCode) { 111 | // username doesn't exist in DNS or database, break out of the loop and create 112 | errMsg = ""; 113 | break; 114 | } 115 | } 116 | 117 | if (errMsg) { 118 | throw new TRPCError({ 119 | code: "INTERNAL_SERVER_ERROR", 120 | message: "Failed to generate random paycode", 121 | }); 122 | } 123 | 124 | let create = []; 125 | try { 126 | create = createPayCodeParams( 127 | input.onChain, 128 | input.label, 129 | input.lno, 130 | input.sp, 131 | input.lnurl, 132 | input.custom 133 | ); 134 | } catch (e: any) { 135 | throw new TRPCError({ 136 | code: "INTERNAL_SERVER_ERROR", 137 | message: "Failed to create pay code params", 138 | }); 139 | } 140 | 141 | const payCode = await ctx.db.$transaction(async (transactionPrisma) => { 142 | const data: Prisma.PayCodeCreateInput = { 143 | userName: userName, 144 | domain: input.domain, 145 | status: PayCodeStatus.ACTIVE, 146 | params: { 147 | create: create, 148 | }, 149 | }; 150 | 151 | if (ctx.user) { 152 | data.user = { 153 | connect: { id: ctx.user.id }, 154 | }; 155 | } 156 | 157 | let innerPaycode = await transactionPrisma.payCode 158 | .create({ 159 | data: data, 160 | include: { 161 | params: true, // Include params in the response 162 | }, 163 | }) 164 | .catch((e: any) => { 165 | throw new TRPCError({ 166 | code: "INTERNAL_SERVER_ERROR", 167 | message: "Failed to add paycode to database", 168 | }); 169 | }); 170 | 171 | await axios 172 | .post( 173 | CF_BASE_URL, 174 | { 175 | content: bip21, 176 | name: fullName, 177 | proxied: false, 178 | type: "TXT", 179 | comment: "Twelve Cash User DNS Update", 180 | ttl: 3600, 181 | }, 182 | { 183 | method: "POST", 184 | headers: { 185 | Content_Type: "application/json", 186 | Authorization: `Bearer ${process.env.CF_TOKEN}`, 187 | }, 188 | } 189 | ) 190 | .catch((e: any) => { 191 | console.error("Failed to post record to CloudFlare", e); 192 | throw new TRPCError({ 193 | code: "INTERNAL_SERVER_ERROR", 194 | message: "Failed to post record to CloudFlare", 195 | }); 196 | }); 197 | 198 | return innerPaycode; 199 | }); 200 | // TODO: catch and throw again? 201 | 202 | return payCode; 203 | }), 204 | 205 | createPayCode: publicProcedure 206 | .input(payCodeInput) 207 | .mutation(async ({ ctx, input }) => { 208 | const fullName = process.env.NETWORK 209 | ? `${input.userName}.user._bitcoin-payment.${process.env.NETWORK}.${input.domain}` 210 | : `${input.userName}.user._bitcoin-payment.${input.domain}`; 211 | 212 | // First check to see if this user name already exists in DNS 213 | // Eventually, all paycodes will be in the DB. 214 | const CF_BASE_URL = `https://api.cloudflare.com/client/v4/zones/${ 215 | domainMap[input.domain] 216 | }/dns_records`; 217 | const res = await axios 218 | .get(`${CF_BASE_URL}?name=${fullName}&type=TXT`, { 219 | headers: { 220 | Content_Type: "application/json", 221 | Authorization: `Bearer ${process.env.CF_TOKEN}`, 222 | }, 223 | }) 224 | .catch((e: any) => { 225 | throw new TRPCError({ 226 | code: "INTERNAL_SERVER_ERROR", 227 | message: "Server failed to talk to DNS", 228 | }); 229 | }); 230 | if (res.data.result.length > 0) { 231 | throw new TRPCError({ 232 | code: "CONFLICT", 233 | message: "User name is taken", 234 | }); 235 | } 236 | // Check if the paycode exists in the our database 237 | // TODO: Should probably make sure there isn't any open invoices 238 | // for the paycode.. someone could be in the middle of purchasing the same name... 239 | const existingPayCode = await ctx.db.payCode 240 | .findFirst({ 241 | where: { 242 | AND: [ 243 | { userName: input.userName }, 244 | { domain: input.domain }, 245 | { status: PayCodeStatus.ACTIVE }, 246 | ], 247 | }, 248 | }) 249 | .catch((e: any) => { 250 | console.error("e", e); 251 | throw new TRPCError({ 252 | code: "INTERNAL_SERVER_ERROR", 253 | message: "Failed to lookup paycode", 254 | }); 255 | }); 256 | if (existingPayCode) { 257 | throw new TRPCError({ 258 | code: "CONFLICT", 259 | message: "User name is taken", 260 | }); 261 | } 262 | 263 | let create = []; 264 | try { 265 | create = createPayCodeParams( 266 | input.onChain, 267 | input.label, 268 | input.lno, 269 | input.sp, 270 | input.lnurl, 271 | input.custom 272 | ); 273 | } catch (e: any) { 274 | throw new TRPCError({ 275 | code: "INTERNAL_SERVER_ERROR", 276 | message: "Failed to create pay code params", 277 | }); 278 | } 279 | 280 | // TODO: calculate price 281 | const priceMsats = 5000000; 282 | let invoice; 283 | 284 | try { 285 | invoice = await createInvoice(priceMsats, "Purchase pay code."); 286 | } catch (e: any) { 287 | throw new TRPCError({ 288 | code: "INTERNAL_SERVER_ERROR", 289 | message: "Failed to create invoice", 290 | }); 291 | } 292 | 293 | // finally, create the paycode, but set to PENDING 294 | const data: Prisma.PayCodeCreateInput = { 295 | userName: input.userName, 296 | domain: input.domain, 297 | status: PayCodeStatus.PENDING, 298 | params: { 299 | create: create, 300 | }, 301 | invoices: { 302 | create: [ 303 | { 304 | maxAgeSeconds: 600, 305 | description: "purchase", 306 | status: InvoiceStatus.OPEN, 307 | bolt11: invoice.payment_request, 308 | kind: InvoiceKind.PURCHASE, 309 | hash: invoice.r_hash, 310 | mSatsTarget: priceMsats, 311 | }, 312 | ], 313 | }, 314 | }; 315 | 316 | if (ctx.user) { 317 | data.user = { 318 | connect: { id: ctx.user.id }, 319 | }; 320 | } 321 | const payCode = await ctx.db.payCode 322 | .create({ 323 | data: data, 324 | include: { 325 | params: true, 326 | invoices: true, 327 | }, 328 | }) 329 | .catch((e: any) => { 330 | console.error(e); 331 | throw new TRPCError({ 332 | code: "INTERNAL_SERVER_ERROR", 333 | message: "Failed to add paycode to database", 334 | }); 335 | }); 336 | 337 | console.debug("paycode.invoices", payCode.invoices[0].id); 338 | 339 | return { 340 | invoice: payCode.invoices[0], 341 | payCodeId: payCode.id, 342 | }; 343 | }), 344 | 345 | checkPayment: publicProcedure 346 | .input( 347 | z.object({ 348 | invoiceId: z.string(), 349 | }) 350 | ) 351 | .query(async ({ ctx, input }) => { 352 | const invoice = await ctx.db.invoice 353 | .findUnique({ 354 | where: { 355 | id: input.invoiceId, 356 | }, 357 | }) 358 | .catch((e: any) => { 359 | console.error("e", e); 360 | throw new TRPCError({ 361 | code: "INTERNAL_SERVER_ERROR", 362 | message: "Failed to lookup invoice", 363 | }); 364 | }); 365 | 366 | if (!invoice) { 367 | throw new TRPCError({ 368 | code: "BAD_REQUEST", 369 | message: "Invoice does not exist", 370 | }); 371 | } 372 | // Final states, no need for invoice lookup 373 | if ( 374 | invoice.status === InvoiceStatus.SETTLED || 375 | invoice.status === InvoiceStatus.CANCELED 376 | ) { 377 | return { status: invoice.status }; 378 | } 379 | 380 | let lndInvoice; 381 | try { 382 | lndInvoice = await lookupInvoice(invoice.hash); 383 | } catch (e: any) { 384 | throw new TRPCError({ 385 | code: "BAD_REQUEST", 386 | message: "Failed to fetch invoice from lnd node", 387 | }); 388 | } 389 | console.debug("lndInvoice state", lndInvoice.state); 390 | if (lndInvoice.status !== invoice.status) { 391 | const updateInvoice = await ctx.db.invoice.update({ 392 | where: { 393 | id: input.invoiceId, 394 | }, 395 | data: { 396 | status: lndInvoice.state, // using same strings 397 | }, 398 | }); 399 | return { status: updateInvoice.status }; 400 | } 401 | return { status: invoice.status }; 402 | }), 403 | 404 | redeemPayCode: publicProcedure 405 | .input( 406 | z.object({ 407 | invoiceId: z.string(), 408 | }) 409 | ) 410 | .mutation(async ({ ctx, input }) => { 411 | // first make sure the invoice hasn't already been redeemed 412 | const invoice = await ctx.db.invoice 413 | .findFirst({ 414 | where: { 415 | AND: [{ id: input.invoiceId }, { redeemed: false }], 416 | }, 417 | }) 418 | .catch((e: any) => { 419 | console.error("e", e); 420 | throw new TRPCError({ 421 | code: "INTERNAL_SERVER_ERROR", 422 | message: "Failed to lookup invoice", 423 | }); 424 | }); 425 | 426 | if (!invoice) { 427 | throw new TRPCError({ 428 | code: "BAD_REQUEST", 429 | message: "Invoice does not exist or is already redeemed", 430 | }); 431 | } 432 | // TODO: If the CF post fails, paycode should still be taken by user since they purchased it. 433 | // TODO: If the transaction takes long (like 5s or something) it will time out and fail... 434 | // can happen if ex. CF api is slow. Better way? 435 | const payCode = await ctx.db 436 | .$transaction(async (transactionPrisma) => { 437 | const updateInvoice = await transactionPrisma.invoice.update({ 438 | where: { 439 | id: input.invoiceId, 440 | }, 441 | data: { 442 | redeemed: true, 443 | }, 444 | }); 445 | const updatePayCode = await transactionPrisma.payCode.update({ 446 | where: { 447 | id: updateInvoice.payCodeId, 448 | }, 449 | data: { 450 | status: PayCodeStatus.ACTIVE, 451 | }, 452 | include: { 453 | params: true, // don't need 454 | }, 455 | }); 456 | // Double check that there isn't already a record there... 457 | const CF_BASE_URL = `https://api.cloudflare.com/client/v4/zones/${ 458 | domainMap[updatePayCode.domain] 459 | }/dns_records`; 460 | const fullName = process.env.NETWORK 461 | ? `${updatePayCode.userName}.user._bitcoin-payment.${process.env.NETWORK}.${updatePayCode.domain}` 462 | : `${updatePayCode.userName}.user._bitcoin-payment.${updatePayCode.domain}`; 463 | 464 | const res = await axios 465 | .get(`${CF_BASE_URL}?name=${fullName}&type=TXT`, { 466 | headers: { 467 | Content_Type: "application/json", 468 | Authorization: `Bearer ${process.env.CF_TOKEN}`, 469 | }, 470 | }) 471 | .catch((e: any) => { 472 | throw new TRPCError({ 473 | code: "INTERNAL_SERVER_ERROR", 474 | message: "Server failed to talk to DNS", 475 | }); 476 | }); 477 | if (res.data.result.length > 0) { 478 | throw new TRPCError({ 479 | code: "CONFLICT", 480 | message: "User name is taken", 481 | }); 482 | } 483 | 484 | let bip21: string; 485 | try { 486 | // use native type returned instead of mapping 487 | bip21 = createBip21FromParams( 488 | updatePayCode.params.map((p) => ({ 489 | prefix: p.prefix, 490 | value: p.value, 491 | type: p.type, 492 | })) 493 | ); 494 | } catch (e: any) { 495 | throw new TRPCError({ 496 | code: "BAD_REQUEST", 497 | message: e.message, 498 | }); 499 | } 500 | 501 | await axios 502 | .post( 503 | CF_BASE_URL, 504 | { 505 | content: bip21, 506 | name: fullName, 507 | proxied: false, 508 | type: "TXT", 509 | comment: "Twelve Cash User DNS Update", 510 | ttl: 3600, 511 | }, 512 | { 513 | method: "POST", 514 | headers: { 515 | Content_Type: "application/json", 516 | Authorization: `Bearer ${process.env.CF_TOKEN}`, 517 | }, 518 | } 519 | ) 520 | .catch((e: any) => { 521 | console.error("Failed to post record to CloudFlare", e); 522 | throw new TRPCError({ 523 | code: "INTERNAL_SERVER_ERROR", 524 | message: "Failed to post record to CloudFlare", 525 | }); 526 | }); 527 | 528 | return updatePayCode; 529 | }) 530 | .catch((e: any) => { 531 | console.error("Failed to complete transaction", e); 532 | throw new TRPCError({ 533 | code: "INTERNAL_SERVER_ERROR", 534 | message: "Failed to redeem pay code.", 535 | }); 536 | }); 537 | 538 | return payCode; 539 | }), 540 | 541 | getUserPaycodes: protectedProcedure.query(async ({ ctx }) => { 542 | console.debug("Getting user's paycodes!"); 543 | // TODO: Select only values we need 544 | return await ctx.db.payCode 545 | .findMany({ 546 | where: { 547 | userId: ctx.user.id, 548 | status: PayCodeStatus.ACTIVE, 549 | }, 550 | orderBy: { 551 | updatedAt: "desc", 552 | }, 553 | }) 554 | .catch((e: any) => { 555 | console.error(e); 556 | throw new TRPCError({ 557 | code: "INTERNAL_SERVER_ERROR", 558 | message: "Failed to get user's paycodes", 559 | }); 560 | }); 561 | }), 562 | }); 563 | --------------------------------------------------------------------------------