From e993e856452996fdab492508da30a18a8a4356bf Mon Sep 17 00:00:00 2001 From: mona-i Date: Sun, 19 Jul 2026 15:16:34 +0100 Subject: [PATCH 1/2] feat: voluntary exit message builder with offline cold storage signing #521 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full voluntary validator exit workflow per issue #521. ## What was changed ### New files - src/utils/exitMessageBuilder.ts: SSZ-encodes VoluntaryExit{epoch, validator_index} (16 bytes, little-endian uint64), computes SHA-256 hash for audit log, validates BLS signature hex format, and fetches current epoch from /eth/v1/beacon/genesis with local fallback. - src/utils/qrEncoder.ts: Wraps qrcode in binary/byte mode (Version 40, Medium ECC) to generate 512px PNG QR codes for the unsigned hex blob; enforces 2,953-byte Version-40 capacity limit. - src/utils/sszSerialization.ts: Single stable import surface re-exporting all SSZ/serialization helpers from exitMessageBuilder. - src/store/exitSlice.ts: Zustand store tracking 3-step wizard state, 60-second cooldown timer, per-epoch rate limiter (max 4 exits), signed blob, and IndexedDB audit entry ID. Adds advanceToSign() action for correct step 1→2 transition. - src/hooks/useVoluntaryExit.ts: Drives the complete workflow: fetch epoch, rate-limit check, SSZ encode, IndexedDB audit log, 60s countdown, BLS hex validation, and beacon node broadcast via postVoluntaryExit. - src/services/exitAuditStore.ts: IndexedDB (exit_audit_db v1) audit trail storing unsigned_msg_hash, timestamp, validator_index, and broadcast_status (pending/broadcast/failed/aborted) with SHA-256 tamper evidence. - src/services/beaconChainService.ts: Extended with postVoluntaryExit(baseUrl, payload) that POSTs to /eth/v1/beacon/pool/voluntary_exits and throws on non-2xx responses. - src/components/validators/ExitWorkflowWizard.tsx: 3-step wizard UI — Step 1: validator index input + QR code display + hex blob + cooldown timer; Step 2: signed blob paste/camera input with BLS format validation; Step 3: irreversibility warning + confirmation checkbox + broadcast button (enabled only after cooldown). ### Modified files - src/store/exitSlice.ts: Added advanceToSign() action (step 1→2 transition without setting a signed blob) and updated setSignedBlob() to advance to broadcast-review step on success. - src/components/validators/ExitWorkflowWizard.tsx: Fixed misplaced import (useExitStore was imported at the bottom of file — now at the top). Fixed I-have-signed-it button to call advanceToSign() instead of setSignedBlob('') which was invalid. Fixed step-3 render condition to show correctly after signature validation. Fixed broadcast button disabled logic to use loading flag instead of step value. - src/components/validators/ValidatorDetail.tsx: Added Voluntary Exit button in the actions toolbar that opens ExitWorkflowWizard in an accessible modal overlay. ### New test - src/__tests__/exitMessageBuilder.test.ts: Unit tests for SSZ encoding, hex utilities, SHA-256 hashing, BLS signature validation, domain constants, and QR payload bounds. --- src/__tests__/exitMessageBuilder.test.ts | 145 +++++ .../validators/ExitWorkflowWizard.tsx | 586 ++++++++++++++++++ src/components/validators/ValidatorDetail.tsx | 36 ++ src/hooks/useVoluntaryExit.ts | 277 +++++++++ src/services/exitAuditStore.ts | 127 ++++ src/store/exitSlice.ts | 175 ++++++ src/utils/exitMessageBuilder.ts | 139 +++++ src/utils/qrEncoder.ts | 61 ++ src/utils/sszSerialization.ts | 29 + 9 files changed, 1575 insertions(+) create mode 100644 src/__tests__/exitMessageBuilder.test.ts create mode 100644 src/components/validators/ExitWorkflowWizard.tsx create mode 100644 src/hooks/useVoluntaryExit.ts create mode 100644 src/services/exitAuditStore.ts create mode 100644 src/store/exitSlice.ts create mode 100644 src/utils/exitMessageBuilder.ts create mode 100644 src/utils/qrEncoder.ts create mode 100644 src/utils/sszSerialization.ts diff --git a/src/__tests__/exitMessageBuilder.test.ts b/src/__tests__/exitMessageBuilder.test.ts new file mode 100644 index 0000000..3453b21 --- /dev/null +++ b/src/__tests__/exitMessageBuilder.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { + encodeVoluntaryExitSSZ, + bytesToHex, + hexToBytes, + buildUnsignedExitMessage, + isValidSignatureHex, + getVoluntaryExitDomain, + DOMAIN_VOLUNTARY_EXIT, + QR_MAX_PAYLOAD_BYTES, +} from '../utils/exitMessageBuilder'; + +describe('exitMessageBuilder', () => { + describe('encodeVoluntaryExitSSZ', () => { + it('produces a 16-byte buffer', () => { + const result = encodeVoluntaryExitSSZ({ epoch: 100, validatorIndex: 42 }); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.byteLength).toBe(16); + }); + + it('encodes epoch in the first 8 bytes as little-endian uint64', () => { + const result = encodeVoluntaryExitSSZ({ epoch: 1, validatorIndex: 0 }); + const view = new DataView(result.buffer); + expect(view.getBigUint64(0, true)).toBe(1n); + }); + + it('encodes validator_index in bytes 8–15 as little-endian uint64', () => { + const result = encodeVoluntaryExitSSZ({ epoch: 0, validatorIndex: 999 }); + const view = new DataView(result.buffer); + expect(view.getBigUint64(8, true)).toBe(999n); + }); + + it('encodes epoch=0 and validatorIndex=0 as all-zero bytes', () => { + const result = encodeVoluntaryExitSSZ({ epoch: 0, validatorIndex: 0 }); + expect(result.every((b) => b === 0)).toBe(true); + }); + + it('handles large validator indices correctly', () => { + const validatorIndex = 500_000; + const result = encodeVoluntaryExitSSZ({ epoch: 12345, validatorIndex }); + const view = new DataView(result.buffer); + expect(view.getBigUint64(8, true)).toBe(BigInt(validatorIndex)); + }); + }); + + describe('bytesToHex / hexToBytes roundtrip', () => { + it('converts bytes to hex string', () => { + const bytes = new Uint8Array([0x00, 0x01, 0xff, 0xab]); + expect(bytesToHex(bytes)).toBe('0001ffab'); + }); + + it('roundtrips hex → bytes → hex', () => { + const original = new Uint8Array([0x04, 0x00, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef]); + const hex = bytesToHex(original); + const recovered = hexToBytes(hex); + expect(Array.from(recovered)).toEqual(Array.from(original)); + }); + + it('hexToBytes handles 0x prefix', () => { + const hex = '0xdeadbeef'; + const bytes = hexToBytes(hex); + expect(bytesToHex(bytes)).toBe('deadbeef'); + }); + + it('hexToBytes throws on odd-length hex string', () => { + expect(() => hexToBytes('abc')).toThrow(); + }); + }); + + describe('buildUnsignedExitMessage', () => { + it('returns hexBlob, messageHash and sszBytes of correct lengths', async () => { + const result = await buildUnsignedExitMessage({ epoch: 42, validatorIndex: 100 }); + // 16-byte SSZ → 32-char hex + expect(result.hexBlob).toHaveLength(32); + // SHA-256 → 64-char hex + expect(result.messageHash).toHaveLength(64); + expect(result.sszBytes.byteLength).toBe(16); + }); + + it('hexBlob matches bytesToHex of sszBytes', async () => { + const result = await buildUnsignedExitMessage({ epoch: 1, validatorIndex: 2 }); + expect(result.hexBlob).toBe(bytesToHex(result.sszBytes)); + }); + + it('produces deterministic output for the same input', async () => { + const a = await buildUnsignedExitMessage({ epoch: 100, validatorIndex: 9999 }); + const b = await buildUnsignedExitMessage({ epoch: 100, validatorIndex: 9999 }); + expect(a.hexBlob).toBe(b.hexBlob); + expect(a.messageHash).toBe(b.messageHash); + }); + + it('produces distinct output for different validator indices', async () => { + const a = await buildUnsignedExitMessage({ epoch: 100, validatorIndex: 1 }); + const b = await buildUnsignedExitMessage({ epoch: 100, validatorIndex: 2 }); + expect(a.hexBlob).not.toBe(b.hexBlob); + expect(a.messageHash).not.toBe(b.messageHash); + }); + }); + + describe('isValidSignatureHex', () => { + const validSig = 'a'.repeat(192); + const validSigWithPrefix = '0x' + 'b'.repeat(192); + + it('accepts a 192-char hex string without prefix', () => { + expect(isValidSignatureHex(validSig)).toBe(true); + }); + + it('accepts a 194-char hex string with 0x prefix', () => { + expect(isValidSignatureHex(validSigWithPrefix)).toBe(true); + }); + + it('rejects a signature that is too short', () => { + expect(isValidSignatureHex('a'.repeat(191))).toBe(false); + }); + + it('rejects a signature that is too long', () => { + expect(isValidSignatureHex('a'.repeat(193))).toBe(false); + }); + + it('rejects a signature with non-hex characters', () => { + expect(isValidSignatureHex('z'.repeat(192))).toBe(false); + }); + + it('rejects an empty string', () => { + expect(isValidSignatureHex('')).toBe(false); + }); + }); + + describe('getVoluntaryExitDomain', () => { + it('returns the correct 4-byte domain', () => { + const domain = getVoluntaryExitDomain(); + expect(domain).toEqual(new Uint8Array([0x04, 0x00, 0x00, 0x00])); + }); + + it('is consistent with the DOMAIN_VOLUNTARY_EXIT constant', () => { + expect(DOMAIN_VOLUNTARY_EXIT).toBe('0x04000000'); + }); + }); + + describe('QR_MAX_PAYLOAD_BYTES', () => { + it('is 2953 (Version-40 Medium ECC byte-mode capacity)', () => { + expect(QR_MAX_PAYLOAD_BYTES).toBe(2_953); + }); + }); +}); diff --git a/src/components/validators/ExitWorkflowWizard.tsx b/src/components/validators/ExitWorkflowWizard.tsx new file mode 100644 index 0000000..325a5bc --- /dev/null +++ b/src/components/validators/ExitWorkflowWizard.tsx @@ -0,0 +1,586 @@ +'use client'; + +/** + * ExitWorkflowWizard – 3-step guided UI for voluntary validator exits. + * + * Step 1 – Initiate: validator selection, unsigned message display (QR + hex), + * cooldown timer starts. + * Step 2 – Sign: accepts signed blob from air-gapped cold-storage device + * (paste hex or QR scan via camera input). + * Step 3 – Broadcast: final confirmation with irreversibility warning, + * rate-limit badge, broadcasts to beacon node. + */ + +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useVoluntaryExit } from '@/src/hooks/useVoluntaryExit'; +import { useExitStore } from '@/src/store/exitSlice'; +import { encodeExitQR } from '@/src/utils/qrEncoder'; + +interface ExitWorkflowWizardProps { + /** Pre-fill a specific validator index (from the validator detail toolbar). */ + defaultValidatorIndex?: number; + /** Beacon node URL for epoch fetching and broadcasting. */ + beaconNodeUrl?: string; + /** Operator identifier written to the audit log. */ + operatorId?: string; + onComplete?: () => void; + onClose?: () => void; +} + +// ── Internal step helpers ──────────────────────────────────────────────────── + +function StepIndicator({ current }: { current: 1 | 2 | 3 }) { + const steps = ['1. Initiate', '2. Sign', '3. Broadcast'] as const; + return ( +
+ {steps.map((label, i) => { + const n = (i + 1) as 1 | 2 | 3; + const active = n === current; + const done = n < current; + return ( +
+
+ {done ? '✓' : n} +
+ + {label} + + {i < 2 && } +
+ ); + })} +
+ ); +} + +function CooldownBadge({ seconds }: { seconds: number }) { + const done = seconds === 0; + return ( +
+ {done ? '✓' : '⏱'} + {done ? 'Cooldown complete — broadcast enabled' : `Cooldown: ${seconds}s remaining`} +
+ ); +} + +// ── Main component ────────────────────────────────────────────────────────── + +export function ExitWorkflowWizard({ + defaultValidatorIndex, + beaconNodeUrl, + operatorId, + onComplete, + onClose, +}: ExitWorkflowWizardProps) { + const { + step, + validatorIndex, + currentEpoch, + unsignedHexBlob, + messageHash, + signedBlob, + cooldownSecondsLeft, + cooldownComplete, + loading, + error, + initiateExit, + acceptSignedBlob, + broadcastExit, + abortExit, + reset, + } = useVoluntaryExit({ beaconNodeUrl, operatorId }); + + // ── Local state ──────────────────────────────────────────────────────────── + const [validatorInput, setValidatorInput] = useState( + defaultValidatorIndex !== undefined ? String(defaultValidatorIndex) : '', + ); + const [signedInput, setSignedInput] = useState(''); + const [signedError, setSignedError] = useState(''); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [qrError, setQrError] = useState(''); + const [confirmChecked, setConfirmChecked] = useState(false); + const [broadcastSuccess, setBroadcastSuccess] = useState(false); + const videoRef = useRef(null); + const [cameraActive, setCameraActive] = useState(false); + const [cameraError, setCameraError] = useState(''); + + // ── Generate QR when blob is ready ──────────────────────────────────────── + useEffect(() => { + if (!unsignedHexBlob) { setQrDataUrl(null); return; } + encodeExitQR(unsignedHexBlob) + .then((r) => setQrDataUrl(r.dataUrl)) + .catch((err) => setQrError(err instanceof Error ? err.message : 'QR generation failed')); + }, [unsignedHexBlob]); + + // ── Watch for broadcast done ─────────────────────────────────────────────── + useEffect(() => { + if (step === 'done') { + setBroadcastSuccess(true); + onComplete?.(); + } + }, [step, onComplete]); + + // ── Camera cleanup on unmount ────────────────────────────────────────────── + useEffect(() => { + return () => { + if (videoRef.current?.srcObject) { + (videoRef.current.srcObject as MediaStream).getTracks().forEach((t) => t.stop()); + } + }; + }, []); + + // ── Handlers ────────────────────────────────────────────────────────────── + + const handleInitiate = useCallback(async () => { + const idx = parseInt(validatorInput, 10); + if (!Number.isFinite(idx) || idx < 0) { + return; + } + await initiateExit(idx); + }, [validatorInput, initiateExit]); + + const handleAcceptSigned = useCallback(() => { + setSignedError(''); + if (!signedInput.trim()) { + setSignedError('Please paste the signed hex blob from your cold-storage device.'); + return; + } + acceptSignedBlob(signedInput.trim()); + }, [signedInput, acceptSignedBlob]); + + const handleBroadcast = useCallback(async () => { + await broadcastExit(); + }, [broadcastExit]); + + const handleAbort = useCallback(async () => { + await abortExit(); + reset(); + setValidatorInput(defaultValidatorIndex !== undefined ? String(defaultValidatorIndex) : ''); + setSignedInput(''); + setConfirmChecked(false); + setBroadcastSuccess(false); + }, [abortExit, reset, defaultValidatorIndex]); + + const handleCameraToggle = useCallback(async () => { + if (cameraActive) { + if (videoRef.current?.srcObject) { + (videoRef.current.srcObject as MediaStream).getTracks().forEach((t) => t.stop()); + } + setCameraActive(false); + return; + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }); + if (videoRef.current) { + videoRef.current.srcObject = stream; + await videoRef.current.play(); + } + setCameraActive(true); + setCameraError(''); + } catch { + setCameraError('Camera not available — please paste the hex signature instead.'); + } + }, [cameraActive]); + + // ── Derived ─────────────────────────────────────────────────────────────── + const wizardStep: 1 | 2 | 3 = + step === 'idle' || step === 'initiate' ? 1 : step === 'sign' ? 2 : 3; + + const isStep1Active = step === 'idle'; + const isStep2Active = step === 'initiate'; + const isStep3Active = step === 'sign' || step === 'broadcast' || step === 'done'; + + // ── Aborted / done states ───────────────────────────────────────────────── + if (step === 'aborted') { + return ( +
+

Exit workflow cancelled.

+ + {onClose && ( + + )} +
+ ); + } + + if (broadcastSuccess) { + return ( +
+
+ +

Exit broadcast successfully

+
+

+ Validator #{validatorIndex} voluntary exit + has been submitted to the beacon chain. +

+

+ ⚠ This action is irreversible. The validator will begin the exit queue process. +

+ {onClose && ( + + )} +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Voluntary Exit Workflow

+

+ Air-gapped cold-storage signing · 3-step guided process +

+
+ {onClose && ( + + )} +
+ + + + {/* Global error banner */} + {error && ( +
+ {error} +
+ )} + + {/* ── Step 1: Validator selection + QR display ──────────────────────── */} + {(isStep1Active || isStep2Active) && ( +
+
+

+ Step 1 — Initiate exit +

+ + {isStep1Active && ( +
+
+ + setValidatorInput(e.target.value)} + className="w-full rounded-xl border border-white/10 bg-slate-950 px-4 py-2.5 font-mono text-white placeholder-slate-600 focus:border-sky-500 focus:outline-none" + placeholder="e.g. 123456" + /> +
+ +
+ )} + + {/* Cooldown timer displayed during step 1 (after initiate) */} + {isStep2Active && ( +
+ + + {/* Unsigned message details */} +
+
+ + +
+
+

+ SHA-256 message hash (audit) +

+

{messageHash}

+
+
+ + {/* QR code */} +
+ {qrError ? ( +

{qrError}

+ ) : qrDataUrl ? ( +
+ Unsigned voluntary exit message QR code for air-gapped signing +
+ ) : ( +

Generating QR…

+ )} +

+ Scan this QR code with your air-gapped signing device +

+
+ + {/* Hex blob (scrollable) */} +
+

+ Unsigned exit hex blob +

+
+ + {unsignedHexBlob} + +
+
+ +
+ + +
+
+ )} +
+
+ )} + + {/* ── Step 2: Accept signed blob ───────────────────────────────────── */} + {isStep3Active && step === 'sign' && ( +
+

+ Step 2 — Submit signed blob +

+ +

+ Paste the 192-character hex BLS signature produced by your cold-storage device, or + scan the signed QR code with your camera. +

+ + {/* Camera toggle */} +
+ + {cameraError &&

{cameraError}

} + {cameraActive && ( +
+ + {/* Paste hex */} +
+ +