= {
+ 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,
};
}