diff --git a/web-v2/Dockerfile b/web-v2/Dockerfile index 5917d40..7849d14 100644 --- a/web-v2/Dockerfile +++ b/web-v2/Dockerfile @@ -1,9 +1,28 @@ -FROM node:20-alpine AS builder +FROM rust:1.90-bookworm AS lwk-builder + +ARG LWK_REF=wasm_0.16.0 +ARG LWK_COMMIT=4786e456db6efc05413721f6489899a6426eaa47 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git pkg-config libssl-dev ca-certificates clang \ + && rm -rf /var/lib/apt/lists/* +RUN rustup target add wasm32-unknown-unknown +RUN cargo install wasm-pack --locked +RUN git clone --depth 1 --branch "${LWK_REF}" https://github.com/Blockstream/lwk.git /tmp/lwk +RUN test "$(git -C /tmp/lwk rev-parse HEAD)" = "${LWK_COMMIT}" +WORKDIR /tmp/lwk/lwk_wasm +RUN RUSTFLAGS='--cfg web_sys_unstable_apis' wasm-pack build --target web --out-dir pkg_web --features simplicity,serial + +FROM node:22-bookworm AS builder WORKDIR /app -RUN corepack enable +RUN corepack enable && \ + corepack prepare pnpm@10.33.0 --activate COPY package.json pnpm-lock.yaml ./ + +COPY --from=lwk-builder /tmp/lwk/lwk_wasm/pkg_web /lwk_wasm/pkg_web + RUN pnpm install --frozen-lockfile COPY . . diff --git a/web-v2/README.md b/web-v2/README.md index b0bbb81..f6adc2a 100644 --- a/web-v2/README.md +++ b/web-v2/README.md @@ -27,6 +27,8 @@ Copy `.env.example` to `.env` and adjust as needed. ## Install +> To build the LWK WASM module required by this app, see [docs/HOW_TO_BUILD_LWK_WASM.md](docs/HOW_TO_BUILD_LWK_WASM.md). + ```bash pnpm install ``` diff --git a/web-v2/docs/HOW_TO_BUILD_LWK_WASM.md b/web-v2/docs/HOW_TO_BUILD_LWK_WASM.md new file mode 100644 index 0000000..d2399f7 --- /dev/null +++ b/web-v2/docs/HOW_TO_BUILD_LWK_WASM.md @@ -0,0 +1,28 @@ +# How to build lwk_wasm + +**Recommended — Docker (stable, no Rust on host)** + +## 1. **Build lwk-builder stage:** + +```bash +# run from repo root +docker build -f web-v2/Dockerfile --target lwk-builder -t lwk-builder . +``` + +## **2. Extract pkg_web to repo root:** + +```bash +docker create --name tmp lwk-builder +docker cp tmp:/tmp/lwk/lwk_wasm/pkg_web ./pkg_web_from_docker +docker rm tmp +mkdir -p ./lwk_wasm +mv ./pkg_web_from_docker ./lwk_wasm/pkg_web +``` + +## **3. Install & run web-v2**: + +```bash +cd web-v2 +pnpm install +pnpm dev +``` diff --git a/web-v2/package.json b/web-v2/package.json index 9434161..881f8cb 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -16,6 +16,7 @@ "add-icon": "tsx ./scripts/add-icon.ts" }, "dependencies": { + "lwk_web": "file:../lwk_wasm/pkg_web", "@heroui/react": "^3.0.4", "@tanstack/react-query": "^5.100.10", "react": "^19.2.6", diff --git a/web-v2/pnpm-lock.yaml b/web-v2/pnpm-lock.yaml index 027a748..2bbfb1d 100644 --- a/web-v2/pnpm-lock.yaml +++ b/web-v2/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@tanstack/react-query': specifier: ^5.100.10 version: 5.100.10(react@19.2.6) + lwk_web: + specifier: file:../lwk_wasm/pkg_web + version: lwk_wasm@file:../lwk_wasm/pkg_web react: specifier: ^19.2.6 version: 19.2.6 @@ -689,66 +692,79 @@ packages: resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.3': resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.3': resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.3': resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.3': resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.3': resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.3': resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.3': resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.3': resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.3': resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.3': resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.3': resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.3': resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.3': resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} @@ -845,24 +861,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -1039,41 +1059,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1993,24 +2021,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2042,6 +2074,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lwk_wasm@file:../lwk_wasm/pkg_web: + resolution: {directory: ../lwk_wasm/pkg_web, type: directory} + macos-version@6.0.0: resolution: {integrity: sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4713,6 +4748,8 @@ snapshots: dependencies: yallist: 3.1.1 + lwk_wasm@file:../lwk_wasm/pkg_web: {} + macos-version@6.0.0: dependencies: semver: 7.8.0 diff --git a/web-v2/src/constants/env.ts b/web-v2/src/constants/env.ts index 1d4379f..b60860f 100644 --- a/web-v2/src/constants/env.ts +++ b/web-v2/src/constants/env.ts @@ -13,3 +13,5 @@ export const env = envSchema.parse({ }) export type AppEnv = z.infer + +export type NetworkName = AppEnv['VITE_NETWORK'] diff --git a/web-v2/src/lwk/index.ts b/web-v2/src/lwk/index.ts new file mode 100644 index 0000000..7225c34 --- /dev/null +++ b/web-v2/src/lwk/index.ts @@ -0,0 +1,44 @@ +import type { Network, SimplicityArguments, Transaction, XOnlyPublicKey } from 'lwk_web' + +import type { NetworkName } from '@/constants/env' + +export type Lwk = typeof import('lwk_web') + +let lwk: Lwk | null = null + +export async function getLwk(): Promise { + if (!lwk) { + lwk = await import('lwk_web') + if (typeof lwk.default === 'function') await lwk.default() + } + return lwk +} + +export function createLwkNetwork(network: NetworkName, lwk: Lwk): Network { + switch (network) { + case 'liquid': + return lwk.Network.mainnet() + case 'liquidtestnet': + return lwk.Network.testnet() + case 'regtest': + return lwk.Network.regtestDefault() + } +} + +export interface PsetWithExtractTx { + extractTx(): Transaction +} + +export interface CreateP2trAddressParams { + source: string + args: SimplicityArguments + internalKey: XOnlyPublicKey + network: NetworkName +} + +export function createP2trAddress(lwk: Lwk, params: CreateP2trAddressParams): string { + const program = lwk.SimplicityProgram.load(params.source, params.args) + const net = createLwkNetwork(params.network, lwk) + const address = program.createP2trAddress(params.internalKey, net) + return address.toString() +} diff --git a/web-v2/src/pages/Dashboard/index.tsx b/web-v2/src/pages/Dashboard/index.tsx index 8103beb..fab02c8 100644 --- a/web-v2/src/pages/Dashboard/index.tsx +++ b/web-v2/src/pages/Dashboard/index.tsx @@ -1,3 +1,48 @@ +import { useLwk } from '@/providers/lwk/useLwk' + +// EXAMPLE OF LWK USAGE export default function DashboardPage() { - return

Dashboard

+ const { network, isTestnet, isMainnet, isRegtest, lwkNetwork } = useLwk() + + const info = lwkNetwork + ? { + label: lwkNetwork.toString(), + genesisBlockHash: lwkNetwork.genesisBlockHash(), + defaultExplorerUrl: lwkNetwork.defaultExplorerUrl(), + policyAsset: lwkNetwork.policyAsset().toString(), + } + : null + + return ( +
+

Dashboard

+

+ Network: {network} +

+

+ isTestnet: {isTestnet.toString()} / isMainnet: {isMainnet.toString()} / isRegtest:{' '} + {isRegtest.toString()} +

+ {info && ( +
+
LWK label
+
+ {info.label} +
+
Genesis block hash
+
+ {info.genesisBlockHash} +
+
Default explorer
+
+ {info.defaultExplorerUrl} +
+
Policy asset
+
+ {info.policyAsset} +
+
+ )} +
+ ) } diff --git a/web-v2/src/providers/AppProviders.tsx b/web-v2/src/providers/AppProviders.tsx index 995506e..e15dbbf 100644 --- a/web-v2/src/providers/AppProviders.tsx +++ b/web-v2/src/providers/AppProviders.tsx @@ -1,8 +1,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { PropsWithChildren } from 'react' +import { LwkProvider } from './lwk/LwkProvider' + const queryClient = new QueryClient() export function AppProviders({ children }: PropsWithChildren) { - return {children} + return ( + + {children} + + ) } diff --git a/web-v2/src/providers/lwk/LwkContext.ts b/web-v2/src/providers/lwk/LwkContext.ts new file mode 100644 index 0000000..63dfbfb --- /dev/null +++ b/web-v2/src/providers/lwk/LwkContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react' + +import type { LwkContextValue } from './types' + +export const LWK_CONTEXT_UNINITIALIZED = Symbol('LWK_CONTEXT_UNINITIALIZED') + +export const LwkContext = createContext( + LWK_CONTEXT_UNINITIALIZED, +) diff --git a/web-v2/src/providers/lwk/LwkProvider.tsx b/web-v2/src/providers/lwk/LwkProvider.tsx new file mode 100644 index 0000000..24aa382 --- /dev/null +++ b/web-v2/src/providers/lwk/LwkProvider.tsx @@ -0,0 +1,52 @@ +import { useEffect, useMemo, useState } from 'react' + +import { env } from '@/constants/env' +import { createLwkNetwork, getLwk, type Lwk } from '@/lwk' + +import { LwkContext } from './LwkContext' + +const network = env.VITE_NETWORK + +export function LwkProvider({ children }: { children: React.ReactNode }) { + const [lwk, setLwk] = useState(null) + + useEffect(() => { + let cancelled = false + + getLwk().then(instance => { + if (!cancelled) setLwk(instance) + }) + + return () => { + cancelled = true + } + }, []) + + const lwkNetwork = useMemo(() => (lwk ? createLwkNetwork(network, lwk) : null), [lwk]) + + useEffect(() => { + return () => { + lwkNetwork?.free() + } + }, [lwkNetwork]) + + if (!lwk || !lwkNetwork) { + // TODO: Replace with proper loader after UI framework setup + return
Loading...
+ } + + return ( + + {children} + + ) +} diff --git a/web-v2/src/providers/lwk/types.ts b/web-v2/src/providers/lwk/types.ts new file mode 100644 index 0000000..85e5e55 --- /dev/null +++ b/web-v2/src/providers/lwk/types.ts @@ -0,0 +1,13 @@ +import type { Network } from 'lwk_web' + +import type { NetworkName } from '@/constants/env' +import type { Lwk } from '@/lwk' + +export interface LwkContextValue { + lwk: Lwk + lwkNetwork: Network + network: NetworkName + isTestnet: boolean + isMainnet: boolean + isRegtest: boolean +} diff --git a/web-v2/src/providers/lwk/useLwk.ts b/web-v2/src/providers/lwk/useLwk.ts new file mode 100644 index 0000000..5b0a29c --- /dev/null +++ b/web-v2/src/providers/lwk/useLwk.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react' + +import { LWK_CONTEXT_UNINITIALIZED, LwkContext } from './LwkContext' +import type { LwkContextValue } from './types' + +export function useLwk(): LwkContextValue { + const ctx = useContext(LwkContext) + if (ctx === LWK_CONTEXT_UNINITIALIZED) { + throw new Error('useLwk() must be used within ') + } + return ctx +} diff --git a/web-v2/vite.config.ts b/web-v2/vite.config.ts index 292303e..3c2c642 100644 --- a/web-v2/vite.config.ts +++ b/web-v2/vite.config.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import react from '@vitejs/plugin-react' +import fs from 'fs' import { defineConfig } from 'vite' import { checker } from 'vite-plugin-checker' @@ -9,7 +10,25 @@ const root = path.dirname(fileURLToPath(import.meta.url)) // https://vite.dev/config/ export default defineConfig({ + server: { + fs: { + allow: [root, path.resolve(root, '..', 'lwk_wasm', 'pkg_web')], + }, + }, plugins: [ + { + name: 'lwk-wasm-dev', + configureServer(server) { + const wasmFile = path.resolve(root, '..', 'lwk_wasm', 'pkg_web', 'lwk_wasm_bg.wasm') + server.middlewares.use((req, res, next) => { + if (!req.url) return next() + if (!req.url.endsWith('lwk_wasm_bg.wasm')) return next() + if (!fs.existsSync(wasmFile)) return next() + res.setHeader('Content-Type', 'application/wasm') + fs.createReadStream(wasmFile).pipe(res) + }) + }, + }, react(), checker({ overlay: {