diff --git a/index.html b/index.html index ac1fa0c..0338c7b 100644 --- a/index.html +++ b/index.html @@ -495,6 +495,32 @@ .error-step h2 { color: #f44336; + margin-bottom: 8px; + } + + .error-code-badge { + display: inline-block; + background: rgba(244, 67, 54, 0.15); + color: #f44336; + font-family: monospace; + font-size: 0.85rem; + font-weight: 600; + padding: 4px 12px; + border-radius: 4px; + margin-bottom: 12px; + letter-spacing: 0.5px; + } + + .error-label { + font-size: 1rem; + font-weight: 500; + color: #e0e0e0; + margin-bottom: 4px; + } + + .error-failed-step { + font-size: 0.85rem; + color: #9e9e9e; margin-bottom: 16px; } @@ -504,8 +530,48 @@ padding: 16px; border-radius: 8px; margin-bottom: 24px; - font-size: 0.9rem; + font-size: 0.85rem; word-break: break-word; + text-align: left; + } + + .error-actions { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; + } + + .error-technical { + margin-bottom: 20px; + text-align: left; + } + + .error-technical summary { + cursor: pointer; + font-size: 0.8rem; + color: #9e9e9e; + margin-bottom: 8px; + user-select: none; + } + + .error-technical summary:hover { + color: #e0e0e0; + } + + .error-technical-content { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 12px; + border-radius: 6px; + font-size: 0.75rem; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + max-height: 300px; + overflow-y: auto; + color: #bdbdbd; } /* Key configuration styles */ diff --git a/src/main.ts b/src/main.ts index f06e887..f3a4858 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,6 +24,8 @@ import { setInstantLockReceived, setIdentityRegistered, setError, + toError, + ErrorCodes, setDepositTimedOut, setNetwork, updateIdentityKey, @@ -420,7 +422,8 @@ function setupEventListeners(container: HTMLElement) { } else { updateState(setError( state, - new Error('No valid key for DPNS registration. You need an AUTHENTICATION key with CRITICAL or HIGH security level.') + new Error('No valid key for DPNS registration. You need an AUTHENTICATION key with CRITICAL or HIGH security level.'), + ErrorCodes.CONFIG )); } }); @@ -1104,7 +1107,7 @@ async function startTopUp() { } catch (error) { console.error('Top-up error:', error); - updateState(setError(state, error instanceof Error ? error : new Error(String(error)))); + updateState(setError(state, toError(error))); } } @@ -1206,7 +1209,7 @@ async function startSendToAddress() { } catch (error) { console.error('Send to platform address error:', error); - updateState(setError(state, error instanceof Error ? error : new Error(String(error)))); + updateState(setError(state, toError(error))); } } @@ -1326,7 +1329,7 @@ async function startBridge() { } catch (error) { console.error('Bridge error:', error); - updateState(setError(state, error instanceof Error ? error : new Error(String(error)))); + updateState(setError(state, toError(error))); } } @@ -1451,7 +1454,7 @@ async function recheckDeposit() { } catch (error) { console.error('Bridge error:', error); - updateState(setError(state, error instanceof Error ? error : new Error(String(error)))); + updateState(setError(state, toError(error))); } } @@ -1482,7 +1485,7 @@ async function startDpnsCheck() { } catch (error) { console.error('DPNS check error:', error); - updateState(setError(state, error instanceof Error ? error : new Error(String(error)))); + updateState(setError(state, toError(error))); } } @@ -1532,7 +1535,7 @@ async function startDpnsRegistration() { } catch (error) { console.error('DPNS registration error:', error); - updateState(setError(state, error instanceof Error ? error : new Error(String(error)))); + updateState(setError(state, toError(error))); } } diff --git a/src/types.ts b/src/types.ts index 989c30f..8a56f1e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -215,6 +215,10 @@ export interface BridgeState { assetLockProof?: AssetLockProofData; identityId?: string; error?: Error; + /** Error code for user-facing display (e.g., "ERR-1006") */ + errorCode?: string; + /** The step that was active when the error occurred */ + errorStep?: BridgeStep; /** True when deposit detection timed out and needs manual recheck */ depositTimedOut?: boolean; /** Current detected deposit amount (may be below minimum) */ diff --git a/src/ui/components.ts b/src/ui/components.ts index eb99212..92c0f45 100644 --- a/src/ui/components.ts +++ b/src/ui/components.ts @@ -1,5 +1,5 @@ import type { BridgeState, KeyType, KeyPurpose, SecurityLevel } from '../types.js'; -import { getStepProgress } from './state.js'; +import { getStepProgress, getStepDescription, ErrorCodes, ErrorCodeLabels } from './state.js'; import { shouldShowContestedWarning, countUsernameStatuses } from '../platform/dpns.js'; import { generateQRCodeDataUrl } from './qrcode.js'; import { privateKeyToWif } from '../utils/wif.js'; @@ -810,17 +810,134 @@ function renderCompleteStep(state: BridgeState): HTMLElement { return div; } +/** Build a full diagnostic object from the error state for dev troubleshooting */ +function buildErrorDiagnostics(state: BridgeState): Record { + const errorCode = state.errorCode ?? ErrorCodes.UNKNOWN; + const diag: Record = { + errorCode, + errorLabel: ErrorCodeLabels[errorCode] ?? 'Unknown error', + errorStep: state.errorStep ?? null, + message: state.error?.message ?? 'An unknown error occurred', + stack: state.error?.stack ?? null, + network: state.network, + mode: state.mode, + timestamp: new Date().toISOString(), + }; + + // Transaction context — critical for tracking on-chain state + // NOTE: signedTxHex is intentionally excluded — if the error occurred before + // broadcast, a signed tx in a bug report lets anyone steal the user's funds. + // txid + UTXO data is sufficient for on-chain lookup. + if (state.depositAddress) diag.depositAddress = state.depositAddress; + if (state.txid) diag.txid = state.txid; + if (state.depositAmount !== undefined) diag.depositAmount = String(state.depositAmount); + if (state.detectedUtxo) { + diag.utxo = { + txid: state.detectedUtxo.txid, + vout: state.detectedUtxo.vout, + satoshis: state.detectedUtxo.satoshis, + }; + } + + // Identity context + if (state.identityId) diag.identityId = state.identityId; + if (state.targetIdentityId) diag.targetIdentityId = state.targetIdentityId; + if (state.recipientPlatformAddress) diag.recipientPlatformAddress = state.recipientPlatformAddress; + + // DPNS context + if (state.dpnsUsernames?.length) { + diag.dpnsUsernames = state.dpnsUsernames.map(u => ({ + label: u.label, + status: u.status, + isAvailable: u.isAvailable ?? null, + isContested: u.isContested ?? null, + })); + } + if (state.dpnsPublicKeyId !== undefined) diag.dpnsPublicKeyId = state.dpnsPublicKeyId; + if (state.dpnsRegistrationProgress !== undefined) diag.dpnsRegistrationProgress = state.dpnsRegistrationProgress; + if (state.dpnsResults?.length) { + diag.dpnsResults = state.dpnsResults.map(r => ({ + label: r.label, + success: r.success, + error: r.error ?? null, + isContested: r.isContested, + })); + } + + // Identity management context + if (state.manageSigningKeyInfo) diag.manageSigningKeyInfo = state.manageSigningKeyInfo; + // Count only — ManageNewKeyConfig objects contain private key material + if (state.manageKeysToAdd?.length) diag.manageKeysToAddCount = state.manageKeysToAdd.length; + if (state.manageKeyIdsToDisable?.length) diag.manageKeyIdsToDisable = state.manageKeyIdsToDisable; + + // Retry state + if (state.retryStatus) diag.retryStatus = state.retryStatus; + + // Identity keys count (not the keys themselves) + if (state.identityKeys.length) diag.identityKeysCount = state.identityKeys.length; + + return diag; +} + function renderErrorStep(state: BridgeState): HTMLElement { const div = document.createElement('div'); div.className = 'error-step'; + const diag = buildErrorDiagnostics(state); + const errorCode = String(diag.errorCode); + const errorLabel = String(diag.errorLabel); + const errorMessage = String(diag.message); + const failedStep = state.errorStep ? getStepDescription(state.errorStep) : undefined; + + const failedStepHtml = failedStep + ? `

Failed during: ${escapeHtml(failedStep)}

` + : ''; + + // Build a concise technical summary from the diagnostic object + const techLines: string[] = []; + if (diag.errorStep) techLines.push(`Step: ${diag.errorStep}`); + if (diag.depositAddress) techLines.push(`Deposit: ${diag.depositAddress}`); + if (diag.txid) techLines.push(`TxID: ${diag.txid}`); + if (diag.identityId) techLines.push(`Identity: ${diag.identityId}`); + if (diag.targetIdentityId) techLines.push(`Target Identity: ${diag.targetIdentityId}`); + if (diag.recipientPlatformAddress) techLines.push(`Recipient: ${diag.recipientPlatformAddress}`); + if (diag.stack) techLines.push(`\nStack Trace:\n${diag.stack}`); + + const techDetailsHtml = techLines.length > 0 ? ` +
+ Technical Details +
${escapeHtml(techLines.join('\n'))}
+
+ ` : ''; + div.innerHTML = `

Error

-

${state.error?.message || 'An unknown error occurred'}

- +
${escapeHtml(errorCode)}
+

${escapeHtml(errorLabel)}

+ ${failedStepHtml} +

${escapeHtml(errorMessage)}

+ ${techDetailsHtml} +
+ + +
`; + const copyBtn = div.querySelector('#copy-error-btn'); + if (copyBtn) { + copyBtn.addEventListener('click', () => { + const copyText = JSON.stringify(diag, null, 2); + navigator.clipboard.writeText(copyText).then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { copyBtn.textContent = 'Copy Error Details'; }, 2000); + }).catch(() => { + copyBtn.textContent = 'Copy failed'; + setTimeout(() => { copyBtn.textContent = 'Copy Error Details'; }, 2000); + }); + }); + } + return div; } diff --git a/src/ui/index.ts b/src/ui/index.ts index ce9645c..0bbf4a6 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -14,6 +14,9 @@ export { setInstantLockReceived, setIdentityRegistered, setError, + toError, + ErrorCodes, + ErrorCodeLabels, setDepositTimedOut, setNetwork, getStepDescription, diff --git a/src/ui/state.ts b/src/ui/state.ts index 43c7983..491feec 100644 --- a/src/ui/state.ts +++ b/src/ui/state.ts @@ -19,6 +19,64 @@ import { import { generateNewMnemonic } from '../crypto/hd.js'; import { createEmptyUsernameEntry, createUsernameEntry } from '../platform/dpns.js'; +/** + * Error codes for user-facing display. + * Each code maps to a specific failure category so users can report issues + * with a reference that helps identify what went wrong. + */ +export const ErrorCodes = { + UNKNOWN: 'ERR-1000', + KEY_GEN: 'ERR-1001', + TX_BUILD: 'ERR-1002', + TX_SIGN: 'ERR-1003', + BROADCAST: 'ERR-1004', + ISLOCK: 'ERR-1005', + REGISTER: 'ERR-1006', + TOPUP: 'ERR-1007', + SEND_ADDRESS: 'ERR-1008', + DPNS_CHECK: 'ERR-1009', + DPNS_REGISTER: 'ERR-1010', + IDENTITY_UPDATE: 'ERR-1011', + CONFIG: 'ERR-1012', +} as const; + +/** Human-readable labels for error codes */ +export const ErrorCodeLabels: Record = { + [ErrorCodes.UNKNOWN]: 'Unknown error', + [ErrorCodes.KEY_GEN]: 'Key generation failed', + [ErrorCodes.TX_BUILD]: 'Transaction build failed', + [ErrorCodes.TX_SIGN]: 'Transaction signing failed', + [ErrorCodes.BROADCAST]: 'Transaction broadcast failed', + [ErrorCodes.ISLOCK]: 'InstantSend lock failed', + [ErrorCodes.REGISTER]: 'Identity registration failed', + [ErrorCodes.TOPUP]: 'Identity top-up failed', + [ErrorCodes.SEND_ADDRESS]: 'Send to address failed', + [ErrorCodes.DPNS_CHECK]: 'Username availability check failed', + [ErrorCodes.DPNS_REGISTER]: 'Username registration failed', + [ErrorCodes.IDENTITY_UPDATE]: 'Identity update failed', + [ErrorCodes.CONFIG]: 'Configuration error', +}; + +/** Map a processing step to its error code */ +const StepErrorCodes: Partial> = { + generating_keys: ErrorCodes.KEY_GEN, + building_transaction: ErrorCodes.TX_BUILD, + signing_transaction: ErrorCodes.TX_SIGN, + broadcasting: ErrorCodes.BROADCAST, + waiting_islock: ErrorCodes.ISLOCK, + registering_identity: ErrorCodes.REGISTER, + topping_up: ErrorCodes.TOPUP, + sending_to_address: ErrorCodes.SEND_ADDRESS, + dpns_checking: ErrorCodes.DPNS_CHECK, + dpns_registering: ErrorCodes.DPNS_REGISTER, + manage_updating: ErrorCodes.IDENTITY_UPDATE, +}; + +/** Coerce an unknown caught value into an Error */ +export function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + /** * Create initial bridge state (mode selection) * Keys are generated when mode is selected, not at init @@ -360,11 +418,13 @@ export function setIdentityRegistered( }; } -export function setError(state: BridgeState, error: Error): BridgeState { +export function setError(state: BridgeState, error: Error, errorCode?: string): BridgeState { return { ...state, step: 'error', error, + errorCode: errorCode ?? StepErrorCodes[state.step] ?? ErrorCodes.UNKNOWN, + errorStep: state.step, }; }