feat(web-wallet): add QR code generation and scanning support

- Add QRCode component using qrcode.react (SVG-based, H error correction)
- Add QRScanner component using html5-qrcode for camera-based scanning
- Update Receive page with real QR code and payment request amount
- Update Send page with QR scan button to auto-fill recipient address
- Support Synor payment URI format: synor:address?amount=X
- Add Dockerfile for web wallet with nginx serving
- Add web-wallet service to docker-compose.testnet.yml (port 17300)
- Fix TypeScript WebCrypto ArrayBuffer type issues

QR features:
- SVG rendering for crisp display at any size
- Error correction level H (30% recovery) for printed codes
- Camera permission handling with user-friendly error states
- Auto-fill amount when scanning payment requests
This commit is contained in:
Gulshan Yadav 2026-01-10 05:53:34 +05:30
parent e0475176c5
commit a439389943
11 changed files with 430 additions and 32 deletions

39
apps/web/Dockerfile Normal file
View file

@ -0,0 +1,39 @@
# Dockerfile for Synor Web Wallet
# Multi-stage build for optimized production image
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package.json ./
RUN npm install
# Copy source and build
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine AS production
# Copy build output
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx config for SPA routing
RUN echo 'server { \
listen 80; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -17,7 +17,9 @@
"@noble/hashes": "^1.3.3",
"@noble/ed25519": "^2.1.0",
"bip39": "^3.1.0",
"zustand": "^4.5.0"
"zustand": "^4.5.0",
"qrcode.react": "^3.1.0",
"html5-qrcode": "^2.3.8"
},
"devDependencies": {
"@types/react": "^18.3.0",

View file

@ -0,0 +1,100 @@
import { QRCodeSVG } from 'qrcode.react';
interface QRCodeProps {
value: string;
size?: number;
includeMargin?: boolean;
level?: 'L' | 'M' | 'Q' | 'H';
className?: string;
}
/**
* QR Code display component using SVG rendering.
*
* For Synor wallet addresses, the QR code encodes the full address
* in the format: synor:<bech32-address>
*
* Error correction levels:
* - L: ~7% recovery
* - M: ~15% recovery (default)
* - Q: ~25% recovery
* - H: ~30% recovery (recommended for addresses)
*/
export function QRCode({
value,
size = 200,
includeMargin = true,
level = 'H',
className = '',
}: QRCodeProps) {
return (
<div className={`bg-white p-4 rounded-lg inline-block ${className}`}>
<QRCodeSVG
value={value}
size={size}
level={level}
includeMargin={includeMargin}
bgColor="#ffffff"
fgColor="#000000"
/>
</div>
);
}
/**
* Generate a Synor payment URI for QR codes.
* Format: synor:<address>?amount=<amount>&label=<label>&message=<message>
*/
export function createPaymentUri(
address: string,
options?: {
amount?: string;
label?: string;
message?: string;
}
): string {
const params = new URLSearchParams();
if (options?.amount) {
params.set('amount', options.amount);
}
if (options?.label) {
params.set('label', options.label);
}
if (options?.message) {
params.set('message', options.message);
}
const queryString = params.toString();
return queryString ? `${address}?${queryString}` : address;
}
/**
* Parse a Synor payment URI from a QR code scan.
*/
export function parsePaymentUri(uri: string): {
address: string;
amount?: string;
label?: string;
message?: string;
} | null {
// Handle both plain addresses and URIs with parameters
if (!uri.startsWith('synor:')) {
return null;
}
const [addressPart, queryPart] = uri.split('?');
const address = addressPart;
if (!queryPart) {
return { address };
}
const params = new URLSearchParams(queryPart);
return {
address,
amount: params.get('amount') || undefined,
label: params.get('label') || undefined,
message: params.get('message') || undefined,
};
}

View file

@ -0,0 +1,164 @@
import { useEffect, useRef, useState } from 'react';
import { Html5Qrcode } from 'html5-qrcode';
interface QRScannerProps {
onScan: (result: string) => void;
onError?: (error: string) => void;
onClose: () => void;
}
/**
* QR Scanner component using device camera.
*
* Supports both rear and front cameras. On desktop, will use webcam.
* On mobile, defaults to rear camera for easier scanning.
*/
export function QRScanner({ onScan, onError, onClose }: QRScannerProps) {
const scannerRef = useRef<Html5Qrcode | null>(null);
const [isStarting, setIsStarting] = useState(true);
const [permissionDenied, setPermissionDenied] = useState(false);
useEffect(() => {
const scannerId = 'qr-reader';
let mounted = true;
const startScanner = async () => {
try {
const scanner = new Html5Qrcode(scannerId);
scannerRef.current = scanner;
await scanner.start(
{ facingMode: 'environment' },
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1,
},
(decodedText) => {
// Successfully scanned
scanner.stop().catch(() => {});
onScan(decodedText);
},
() => {
// Scan error (ignore - just means no QR found in frame)
}
);
if (mounted) {
setIsStarting(false);
}
} catch (err) {
if (mounted) {
setIsStarting(false);
if (err instanceof Error && err.message.includes('Permission')) {
setPermissionDenied(true);
}
onError?.(err instanceof Error ? err.message : 'Camera error');
}
}
};
startScanner();
return () => {
mounted = false;
if (scannerRef.current) {
scannerRef.current.stop().catch(() => {});
}
};
}, [onScan, onError]);
return (
<div className="fixed inset-0 z-50 bg-black/90 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 bg-black/50">
<h2 className="text-lg font-semibold">Scan QR Code</h2>
<button
onClick={onClose}
className="p-2 hover:bg-slate-800 rounded-lg transition-colors"
aria-label="Close scanner"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Scanner area */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="relative w-full max-w-sm">
{isStarting && !permissionDenied && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-900 rounded-lg">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-synor-400 border-t-transparent rounded-full mx-auto mb-2" />
<p className="text-slate-400">Starting camera...</p>
</div>
</div>
)}
{permissionDenied && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-900 rounded-lg p-6">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-900/50 flex items-center justify-center">
<svg
className="w-8 h-8 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Camera Access Denied</h3>
<p className="text-slate-400 text-sm mb-4">
Please allow camera access in your browser settings to scan QR codes.
</p>
<button onClick={onClose} className="btn btn-secondary">
Close
</button>
</div>
</div>
)}
<div
id="qr-reader"
className="w-full aspect-square rounded-lg overflow-hidden"
/>
{/* Scan guide overlay */}
{!isStarting && !permissionDenied && (
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-0 border-2 border-synor-400/30 rounded-lg" />
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-synor-400 rounded-tl-lg" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-synor-400 rounded-tr-lg" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-synor-400 rounded-bl-lg" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-synor-400 rounded-br-lg" />
</div>
)}
</div>
</div>
{/* Instructions */}
<div className="p-4 text-center bg-black/50">
<p className="text-slate-400 text-sm">
Point your camera at a Synor address QR code
</p>
</div>
</div>
);
}

View file

@ -185,9 +185,9 @@ export async function encrypt(
const key = await deriveEncryptionKey(password, salt);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
key,
data
data.buffer as ArrayBuffer
);
return {
@ -208,9 +208,9 @@ export async function decrypt(
): Promise<Uint8Array> {
const key = await deriveEncryptionKey(password, salt);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
key,
ciphertext
ciphertext.buffer as ArrayBuffer
);
return new Uint8Array(plaintext);
@ -235,7 +235,7 @@ async function deriveEncryptionKey(
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
salt: salt.buffer as ArrayBuffer,
iterations: 100000,
hash: 'SHA-256',
},

View file

@ -15,10 +15,8 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import {
hash,
type Keypair,
type HybridSignature,
createHybridSignatureLocal,
createHybridSignatureSmart,
serializeHybridSignature,
type SigningConfig,
} from './crypto';
import { getClient, type Utxo } from './rpc';

View file

@ -96,8 +96,10 @@ export async function loadWasmCrypto(): Promise<SynorCryptoWasm> {
// For wasm-bindgen bundler target, the default export is the init function
// Some bundlers auto-init, others require explicit init call
if (typeof wasm.default === 'function') {
await wasm.default();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const initFn = (wasm as any).default;
if (typeof initFn === 'function') {
await initFn();
}
// Cast to our interface type

View file

@ -1,9 +1,11 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useWalletStore } from '../store/wallet';
import { QRCode, createPaymentUri } from '../components/QRCode';
export default function ReceivePage() {
const { address } = useWalletStore();
const [copied, setCopied] = useState(false);
const [requestAmount, setRequestAmount] = useState('');
const handleCopy = async () => {
if (address) {
@ -13,20 +15,29 @@ export default function ReceivePage() {
}
};
// Generate payment URI with optional amount
const qrValue = useMemo(() => {
if (!address) return '';
return createPaymentUri(address, requestAmount ? { amount: requestAmount } : undefined);
}, [address, requestAmount]);
return (
<div className="max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-6">Receive SYNOR</h1>
<div className="card">
{/* QR Code placeholder */}
<div className="bg-white p-6 rounded-lg mb-6 flex items-center justify-center">
{/* QR Code */}
<div className="flex items-center justify-center mb-6">
{address ? (
<QRCode value={qrValue} size={200} />
) : (
<div className="w-48 h-48 bg-slate-200 rounded flex items-center justify-center text-slate-500">
{/* In production, render actual QR code */}
<div className="text-center">
<div className="text-6xl mb-2"></div>
<div className="text-sm">QR Code</div>
<div className="text-4xl mb-2">?</div>
<div className="text-sm">No wallet loaded</div>
</div>
</div>
)}
</div>
{/* Address */}
@ -58,7 +69,7 @@ export default function ReceivePage() {
</div>
</div>
{/* Request Payment (optional feature) */}
{/* Request Payment */}
<div className="card mt-6">
<h2 className="text-lg font-semibold mb-4">Request Payment</h2>
<div className="space-y-4">
@ -69,6 +80,8 @@ export default function ReceivePage() {
<div className="relative">
<input
type="number"
value={requestAmount}
onChange={(e) => setRequestAmount(e.target.value)}
className="input pr-20"
placeholder="0.00"
step="0.00000001"
@ -79,8 +92,19 @@ export default function ReceivePage() {
</span>
</div>
</div>
<button className="w-full btn btn-secondary">
Generate Payment Link
{requestAmount && parseFloat(requestAmount) > 0 && (
<div className="p-3 bg-synor-900/30 border border-synor-700/50 rounded-lg">
<p className="text-sm text-synor-300">
QR code updated to request {requestAmount} SYNOR
</p>
</div>
)}
<button
onClick={handleCopy}
className="w-full btn btn-secondary"
disabled={!address}
>
Copy Payment Link
</button>
</div>
</div>

View file

@ -1,8 +1,10 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWalletStore } from '../store/wallet';
import { createSendTransaction, serializeTransaction, fromSomas, toSomas } from '../lib/transaction';
import { createSendTransaction, serializeTransaction } from '../lib/transaction';
import { getClient } from '../lib/rpc';
import { QRScanner } from '../components/QRScanner';
import { parsePaymentUri } from '../components/QRCode';
export default function SendPage() {
const { address, keypair, balance, refreshBalance } = useWalletStore();
@ -13,6 +15,23 @@ export default function SendPage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState<{ txId: string } | null>(null);
const [showScanner, setShowScanner] = useState(false);
const handleQRScan = (scannedText: string) => {
setShowScanner(false);
const parsed = parsePaymentUri(scannedText);
if (parsed) {
setRecipient(parsed.address);
if (parsed.amount) {
setAmount(parsed.amount);
}
} else if (scannedText.startsWith('synor:')) {
// Plain address without parameters
setRecipient(scannedText);
} else {
setError('Invalid QR code. Expected a Synor address.');
}
};
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
@ -123,14 +142,36 @@ export default function SendPage() {
<label className="block text-sm text-slate-400 mb-2">
Recipient Address
</label>
<div className="flex gap-2">
<input
type="text"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
className="input font-mono text-sm"
className="input font-mono text-sm flex-1"
placeholder="synor:qz..."
autoFocus
/>
<button
type="button"
onClick={() => setShowScanner(true)}
className="btn btn-secondary px-3"
title="Scan QR Code"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"
/>
</svg>
</button>
</div>
</div>
<div>
@ -182,6 +223,17 @@ export default function SendPage() {
{isLoading ? 'Sending...' : 'Send Transaction'}
</button>
</form>
{/* QR Scanner Modal */}
{showScanner && (
<QRScanner
onScan={handleQRScan}
onClose={() => setShowScanner(false)}
onError={(err) => {
console.error('QR scan error:', err);
}}
/>
)}
</div>
);
}

View file

@ -151,7 +151,6 @@ export const useWalletStore = create<WalletState>()(
password
);
const wallet = await createWallet('', '', network);
// Re-derive keypair from decrypted seed
const { deriveKeypair, publicKeyToAddress } = await import('../lib/crypto');
const keypair = await deriveKeypair(seed);

View file

@ -115,6 +115,24 @@ services:
seed1:
condition: service_healthy
# ==========================================================================
# Web Wallet (React + Vite)
# ==========================================================================
web-wallet:
build:
context: ./apps/web
dockerfile: Dockerfile
container_name: synor-web-wallet
hostname: web-wallet
restart: unless-stopped
ports:
- "17300:80"
networks:
- synor-testnet
depends_on:
seed1:
condition: service_healthy
# ==========================================================================
# Testnet Faucet Service
# ==========================================================================