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:
parent
e0475176c5
commit
a439389943
11 changed files with 430 additions and 32 deletions
39
apps/web/Dockerfile
Normal file
39
apps/web/Dockerfile
Normal 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;"]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
100
apps/web/src/components/QRCode.tsx
Normal file
100
apps/web/src/components/QRCode.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
164
apps/web/src/components/QRScanner.tsx
Normal file
164
apps/web/src/components/QRScanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ==========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue