├── .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 |
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 | 
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 |
--------------------------------------------------------------------------------