From 630f29a60ca9d1e6e0e16b6a48b595f5d3ef73d4 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 1 Sep 2026 20:39:21 +0200 Subject: [PATCH 1/6] feat: transfer a DPNS username to another identity Adds a username transfer sub-flow under Manage mode. The user supplies a seed phrase (which auto-discovers their identity and signing key) or an identity ID plus a WIF, picks from the usernames they own, and names a destination identity. There is no dpns.transfer in the SDK, so this is composed as a generic document transfer of the DPNS domain document: resolve the name to its document, bump the revision, and sign with an AUTHENTICATION key at CRITICAL or HIGH security level. MASTER keys are rejected by the protocol for document transitions, so the flow needs its own credential screen rather than reusing the MASTER-gated key management entry. Three protocol constraints drive the design: the SDK does not bump the document revision but drive-abci requires stored+1; DPNS transfers are rejected below protocol version 13, so the network version is checked and the UI gated; and Platform does not validate that a transfer recipient exists, so a mistyped destination would orphan the username permanently. The recipient is therefore verified before anything is signed. The transfer itself is deliberately not retried, since a retry after a broadcast that landed fails the revision check and would report a false failure. On error the domain document is re-read to determine the true outcome. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/deterministic.spec.ts | 56 +++ index.html | 246 +++++++++ src/e2e-mock-constants.ts | 3 + src/main.ts | 494 ++++++++++++++++++- src/platform/loaders.ts | 2 + src/platform/username-transfer-utils.test.ts | 189 +++++++ src/platform/username-transfer-utils.ts | 197 ++++++++ src/platform/username-transfer.ts | 337 +++++++++++++ src/types.ts | 59 +++ src/ui/components.ts | 475 ++++++++++++++++++ src/ui/index.ts | 21 + src/ui/state.ts | 313 +++++++++++- 12 files changed, 2388 insertions(+), 4 deletions(-) create mode 100644 src/platform/username-transfer-utils.test.ts create mode 100644 src/platform/username-transfer-utils.ts create mode 100644 src/platform/username-transfer.ts diff --git a/e2e/deterministic.spec.ts b/e2e/deterministic.spec.ts index d8733e6..ffc8403 100644 --- a/e2e/deterministic.spec.ts +++ b/e2e/deterministic.spec.ts @@ -5,6 +5,8 @@ import { E2E_MOCK_MANAGE_WIF, E2E_MOCK_WITHDRAW_WIF, E2E_MOCK_WITHDRAW_ADDRESS, + E2E_MOCK_XFER_MNEMONIC, + E2E_MOCK_XFER_RECIPIENT_ID, } from '../src/e2e-mock-constants'; const MOCK_QUERY = '/?network=testnet&e2e=mock'; @@ -72,6 +74,9 @@ test.describe('Deterministic UI E2E (mock mode)', () => { await page.goto(MOCK_QUERY); await page.click('#mode-manage-btn'); + await expect(page.locator('#manage-action-keys-btn')).toBeVisible(); + + await page.click('#manage-action-keys-btn'); await expect(page.locator('#manage-identity-id-input')).toBeVisible(); await page.fill('#manage-identity-id-input', E2E_MOCK_IDENTITY_ID); @@ -182,4 +187,55 @@ test.describe('Deterministic UI E2E (mock mode)', () => { await page.click('#register-dpns-btn'); await expect(page.getByText('Registration Complete!')).toBeVisible(); }); + + test('username transfer flow discovers the identity from a seed phrase and completes', async ({ page }) => { + await page.goto(MOCK_QUERY); + + await page.click('#mode-manage-btn'); + await page.click('#manage-action-transfer-btn'); + await expect(page.locator('#xfer-mnemonic-input')).toBeVisible(); + + // A phrase that fails the BIP39 checksum is rejected before any lookup. + await page.fill('#xfer-mnemonic-input', 'abandon abandon abandon'); + await page.click('#xfer-unlock-btn'); + await expect(page.getByText('That is not a valid BIP39 seed phrase.')).toBeVisible(); + + await page.fill('#xfer-mnemonic-input', E2E_MOCK_XFER_MNEMONIC); + await page.click('#xfer-unlock-btn'); + + await expect(page.getByText('Signing with key #1 (HIGH level)')).toBeVisible(); + await expect(page.locator('.xfer-username-option')).toHaveCount(2); + await expect(page.locator('#xfer-select-continue-btn')).toBeDisabled(); + + await page.locator('.xfer-username-radio').first().click({ force: true }); + + // A malformed destination is rejected without a network round trip. + await page.fill('#xfer-recipient-input', 'not-an-identity'); + await page.locator('#xfer-recipient-input').blur(); + await expect(page.getByText('Invalid identity ID format')).toBeVisible(); + + // Transferring to yourself is a no-op the SDK would reject anyway. + await page.fill('#xfer-recipient-input', E2E_MOCK_IDENTITY_ID); + await page.locator('#xfer-recipient-input').blur(); + await expect(page.getByText('This is the identity that already owns the username')).toBeVisible(); + await expect(page.locator('#xfer-select-continue-btn')).toBeDisabled(); + + await page.fill('#xfer-recipient-input', E2E_MOCK_XFER_RECIPIENT_ID); + await page.locator('#xfer-recipient-input').blur(); + await expect(page.getByText('Destination identity found')).toBeVisible(); + await expect(page.locator('#xfer-select-continue-btn')).toBeEnabled(); + + await page.click('#xfer-select-continue-btn'); + await expect(page.getByText('Confirm Transfer')).toBeVisible(); + await expect(page.locator('#xfer-transfer-btn')).toBeDisabled(); + + await page.locator('#xfer-confirm-checkbox').click({ force: true }); + await expect(page.locator('#xfer-transfer-btn')).toBeEnabled(); + + await page.click('#xfer-transfer-btn'); + await expect(page.getByText('Username Transferred!')).toBeVisible(); + await expect( + page.locator('.contract-id-section', { hasText: 'New Owner' }).locator('.identity-id') + ).toHaveText(E2E_MOCK_XFER_RECIPIENT_ID); + }); }); diff --git a/index.html b/index.html index 1665949..4d3dea2 100644 --- a/index.html +++ b/index.html @@ -1997,6 +1997,252 @@ margin-top: 24px; } + /* ============================================================================ + Username Transfer Styles + ============================================================================ */ + + /* Manage action chooser (same as mode-buttons) */ + .manage-choice-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 24px; + } + + .xfer-headline { + font-size: 1.4rem; + margin-bottom: 8px; + text-align: center; + } + + .xfer-subtitle { + color: #888; + font-size: 0.9rem; + margin-bottom: 24px; + text-align: center; + } + + /* Seed phrase / private key tabs */ + .xfer-source-tabs { + display: flex; + gap: 8px; + margin-bottom: 20px; + } + + .xfer-tab { + flex: 1; + background: rgba(0, 0, 0, 0.3); + border: 2px solid rgba(255, 255, 255, 0.1); + color: #888; + padding: 12px 16px; + border-radius: 10px; + font-size: 0.9rem; + cursor: pointer; + transition: border-color 0.2s, color 0.2s; + } + + .xfer-tab:hover { + color: #ccc; + } + + .xfer-tab.active { + border-color: #008de4; + color: #fff; + } + + .xfer-credentials-form { + text-align: left; + margin-bottom: 16px; + } + + .xfer-credentials-form .input-group, + .xfer-select-username-step .input-group { + margin-bottom: 16px; + } + + .xfer-input { + width: 100%; + background: rgba(0, 0, 0, 0.3); + border: 2px solid rgba(255, 255, 255, 0.1); + color: #fff; + padding: 16px 18px; + border-radius: 10px; + font-family: 'SF Mono', Monaco, monospace; + font-size: 1rem; + transition: border-color 0.2s; + } + + .xfer-input:focus { + outline: none; + border-color: #008de4; + } + + .xfer-input::placeholder { + color: #555; + } + + .xfer-mnemonic { + resize: vertical; + line-height: 1.6; + } + + /* Shared warning callout */ + .warning-box { + background: rgba(255, 152, 0, 0.1); + border: 1px solid rgba(255, 152, 0, 0.3); + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + text-align: left; + } + + .warning-box p { + color: #b0b0b0; + font-size: 0.9rem; + margin-bottom: 10px; + } + + .warning-box p:last-child { + margin-bottom: 0; + } + + .warning-box strong { + color: #ff9800; + } + + /* Owned username picker */ + .xfer-username-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 20px 0; + text-align: left; + } + + .xfer-username-option { + display: flex; + align-items: center; + gap: 12px; + background: rgba(0, 0, 0, 0.2); + border: 2px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 14px 16px; + cursor: pointer; + transition: border-color 0.2s; + } + + .xfer-username-option:hover { + border-color: rgba(255, 255, 255, 0.25); + } + + .xfer-username-radio { + width: 18px; + height: 18px; + cursor: pointer; + flex-shrink: 0; + } + + .xfer-username-label { + color: #fff; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.95rem; + word-break: break-all; + } + + /* Confirmation summary */ + .xfer-summary { + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + padding: 16px; + margin-bottom: 20px; + text-align: left; + } + + .xfer-summary-row { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 14px; + } + + .xfer-summary-row:last-child { + margin-bottom: 0; + } + + .xfer-summary-label { + color: #888; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .xfer-summary-value { + color: #fff; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.9rem; + word-break: break-all; + } + + .xfer-confirmation { + margin-bottom: 20px; + } + + .checkbox-label { + display: flex; + align-items: flex-start; + gap: 10px; + color: #b0b0b0; + font-size: 0.85rem; + text-align: left; + cursor: pointer; + } + + .checkbox-label input[type="checkbox"] { + margin-top: 3px; + width: 18px; + height: 18px; + cursor: pointer; + flex-shrink: 0; + } + + .xfer-transferring-step, + .xfer-complete-step { + text-align: center; + } + + .xfer-success-msg { + color: #4CAF50; + font-size: 1rem; + margin-bottom: 20px; + word-break: break-all; + } + + .xfer-error-msg { + background: rgba(244, 67, 54, 0.1); + border: 1px solid rgba(244, 67, 54, 0.3); + padding: 16px; + border-radius: 8px; + margin-bottom: 20px; + } + + .xfer-error-msg p { + color: #b0b0b0; + margin: 4px 0; + } + + .xfer-error-msg .error-detail { + color: #f44336; + font-size: 0.85rem; + word-break: break-word; + } + + .xfer-action-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 24px; + } + /* ============================================================================ Faucet Styles ============================================================================ */ diff --git a/src/e2e-mock-constants.ts b/src/e2e-mock-constants.ts index c82363c..65fbd35 100644 --- a/src/e2e-mock-constants.ts +++ b/src/e2e-mock-constants.ts @@ -6,3 +6,6 @@ export const E2E_MOCK_WITHDRAW_WIF = 'cMockWithdrawPrivateKeyWif'; export const E2E_MOCK_WITHDRAW_ADDRESS = 'ySMnpcCKx4wD57T5dhjz3t3im3hgaQ5JYG'; /** Mock identity balance in credits (0.25 DASH). */ export const E2E_MOCK_WITHDRAW_BALANCE = 25_000_000_000; +export const E2E_MOCK_XFER_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +export const E2E_MOCK_XFER_RECIPIENT_ID = '22222222222222222222222222222222222222222222'; diff --git a/src/main.ts b/src/main.ts index 601a400..f02f6a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -83,6 +83,26 @@ import { resetManageState, resetManageStateAndRefresh, setManageBackToEntry, + setManageActionKeys, + setManageActionTransfer, + // Username transfer state functions + setXferCredentialSource, + setXferMnemonic, + setXferDiscovering, + setXferDiscoveryStatus, + setXferCredentialError, + setXferIdentityUnlocked, + setXferSelectedUsername, + setXferRecipientId, + setXferRecipientChecking, + setXferRecipientVerified, + setXferRecipientError, + setXferReview, + setXferConfirmationAcknowledged, + setXferTransferring, + setXferResult, + setXferBackToCredentials, + setXferBackToSelect, // Contract registration state functions setContractIdentitySource, setContractIdentityFetching, @@ -141,8 +161,20 @@ import { loadIslockModule, loadPlatformClientModule, loadPlatformModule, + loadUsernameTransferModule, warmDashModules, } from './platform/loaders.js'; +import { + deriveCandidateKeys, + explainKeyIneligibility, + isEligibleTransferKey, + isProtocolVersionSupported, + isValidIdentityId, + isValidMnemonic, + normalizeMnemonic, + selectTransferSigningKey, + MIN_TRANSFER_PROTOCOL_VERSION, +} from './platform/username-transfer-utils.js'; import { loadSdkModule } from './platform/sdkModule.js'; import type { BridgeState, @@ -164,6 +196,8 @@ import { E2E_MOCK_MANAGE_WIF, E2E_MOCK_WITHDRAW_WIF, E2E_MOCK_WITHDRAW_BALANCE, + E2E_MOCK_XFER_MNEMONIC, + E2E_MOCK_XFER_RECIPIENT_ID, } from './e2e-mock-constants.js'; // Global state @@ -393,6 +427,10 @@ function createE2EMockIdentityKeys(): IdentityPublicKeyInfo[] { ]; } +function createE2EMockOwnedUsernames(): string[] { + return ['mockname.dash', 'second-mockname.dash']; +} + function createE2EMockDpnsAvailability(entries: DpnsUsernameEntry[]): DpnsUsernameEntry[] { return entries.map((entry) => { if (!entry.isValid) { @@ -1152,13 +1190,28 @@ function setupEventListeners(container: HTMLElement) { }); } + // Manage action chooser (keys vs username transfer) + const manageActionKeysBtn = container.querySelector('#manage-action-keys-btn'); + if (manageActionKeysBtn) { + manageActionKeysBtn.addEventListener('click', () => { + updateState(setManageActionKeys(state)); + }); + } + + const manageActionTransferBtn = container.querySelector('#manage-action-transfer-btn'); + if (manageActionTransferBtn) { + manageActionTransferBtn.addEventListener('click', () => { + updateState(setManageActionTransfer(state)); + }); + } + // Manage back button (various steps) const manageBackBtn = container.querySelector('#manage-back-btn'); if (manageBackBtn) { manageBackBtn.addEventListener('click', () => { switch (state.step) { case 'manage_enter_identity': - updateState(setStep(state, 'init')); + updateState(setStep(state, 'manage_choose_action')); break; case 'manage_view_keys': updateState(setManageBackToEntry(state)); @@ -1455,6 +1508,140 @@ function setupEventListeners(container: HTMLElement) { }); } + // ============================================================================ + // Username Transfer Event Listeners + // ============================================================================ + + // Credential source tabs + const xferSourceSeedBtn = container.querySelector('#xfer-source-seed-btn'); + if (xferSourceSeedBtn) { + xferSourceSeedBtn.addEventListener('click', () => { + updateState(setXferCredentialSource(state, 'seed')); + }); + } + + const xferSourceKeyBtn = container.querySelector('#xfer-source-key-btn'); + if (xferSourceKeyBtn) { + xferSourceKeyBtn.addEventListener('click', () => { + updateState(setXferCredentialSource(state, 'key')); + }); + } + + // Seed phrase input — kept in state so it survives re-renders + const xferMnemonicInput = container.querySelector('#xfer-mnemonic-input'); + if (xferMnemonicInput) { + xferMnemonicInput.addEventListener('input', (e) => { + const target = e.target as HTMLTextAreaElement; + updateState(setXferMnemonic(state, target.value)); + }); + } + + // Unlock button + const xferUnlockBtn = container.querySelector('#xfer-unlock-btn'); + if (xferUnlockBtn) { + xferUnlockBtn.addEventListener('click', () => { + if (!state.xferDiscovering) { + startXferUnlock(); + } + }); + } + + // Transfer back button (various steps) + const xferBackBtn = container.querySelector('#xfer-back-btn'); + if (xferBackBtn) { + xferBackBtn.addEventListener('click', () => { + switch (state.step) { + case 'xfer_credentials': + updateState(setStep(state, 'manage_choose_action')); + break; + case 'xfer_select_username': + updateState(setXferBackToCredentials(state)); + break; + case 'xfer_review': + updateState(setXferBackToSelect(state)); + break; + default: + updateState(setStep(state, 'init')); + } + }); + } + + // Username selection + container.querySelectorAll('.xfer-username-radio').forEach((radio) => { + radio.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + const username = target.dataset.username; + if (username) { + updateState(setXferSelectedUsername(state, username)); + } + }); + }); + + // Destination identity input — verify on blur or paste + const xferRecipientInput = container.querySelector('#xfer-recipient-input'); + if (xferRecipientInput) { + xferRecipientInput.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + updateState(setXferRecipientId(state, target.value)); + }); + + const verifyRecipient = () => { + const recipientId = (xferRecipientInput as HTMLInputElement).value.trim(); + if (!recipientId || state.xferRecipientChecking) return; + startXferRecipientCheck(recipientId); + }; + + xferRecipientInput.addEventListener('blur', verifyRecipient); + xferRecipientInput.addEventListener('paste', () => { + setTimeout(verifyRecipient, 50); + }); + } + + // Continue to the confirmation screen + const xferSelectContinueBtn = container.querySelector('#xfer-select-continue-btn'); + if (xferSelectContinueBtn) { + xferSelectContinueBtn.addEventListener('click', () => { + if (state.xferSelectedUsername && state.xferRecipientVerified) { + updateState(setXferReview(state)); + } + }); + } + + // Irreversibility acknowledgement + const xferConfirmCheckbox = container.querySelector('#xfer-confirm-checkbox'); + if (xferConfirmCheckbox) { + xferConfirmCheckbox.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + updateState(setXferConfirmationAcknowledged(state, target.checked)); + }); + } + + // Execute the transfer + const xferTransferBtn = container.querySelector('#xfer-transfer-btn'); + if (xferTransferBtn) { + xferTransferBtn.addEventListener('click', () => { + if (state.xferConfirmationAcknowledged) { + startUsernameTransfer(); + } + }); + } + + // Transfer another username (restarts the sub-flow) + const xferAgainBtn = container.querySelector('#xfer-again-btn'); + if (xferAgainBtn) { + xferAgainBtn.addEventListener('click', () => { + updateState(setManageActionTransfer(state)); + }); + } + + // Retry a failed transfer from the selection screen + const xferRetryBtn = container.querySelector('#xfer-retry-btn'); + if (xferRetryBtn) { + xferRetryBtn.addEventListener('click', () => { + updateState(setXferBackToSelect(state)); + }); + } + // ============================================================================ // ============================================================================ // Key Backup Upload Handlers (shared across DPNS, manage, contract) @@ -1545,6 +1732,17 @@ function setupEventListeners(container: HTMLElement) { } }); + // Username transfer key upload — fills in the identity ID + WIF, then unlocks + wireKeyUpload('xfer-key-upload', async (result) => { + updateState({ + ...setTargetIdentityId(state, result.identityId), + xferPrivateKeyWif: result.privateKeyWif, + }); + await startXferUnlockFromKey(result.identityId, result.privateKeyWif).catch((error) => { + updateState(setXferCredentialError(state, toError(error).message)); + }); + }); + // Contract key upload — show loading, fetch identity + balance, validate key, update once wireKeyUpload('contract-key-upload', async (result) => { updateState({ @@ -3191,6 +3389,300 @@ async function startManageUpdate() { } } +// ============================================================================ +// Username Transfer Functions +// ============================================================================ + +/** + * Load what the transfer flow needs once the identity and signing key are + * known: the names it owns, and the network's protocol version. + */ +async function loadTransferContext(identityId: string): Promise<{ + usernames: string[]; + protocolVersion?: number; +}> { + const { listOwnedUsernames, getProtocolVersion } = await loadUsernameTransferModule(); + + const [usernames, protocolVersion] = await Promise.all([ + listOwnedUsernames(identityId, state.network), + // Best-effort: a failed version read must not block the flow, it only + // suppresses the "this network is too old" warning. + getProtocolVersion(state.network).catch(() => undefined), + ]); + + return { usernames, protocolVersion }; +} + +/** + * Resolve the identity and signing key from a seed phrase, then load the + * usernames it owns. + */ +async function startXferUnlockFromSeed(mnemonic: string) { + if (!isValidMnemonic(mnemonic)) { + updateState(setXferCredentialError(state, 'That is not a valid BIP39 seed phrase. Check for typos or missing words.')); + return; + } + + updateState(setXferDiscovering(state, 'Deriving keys from your seed phrase...')); + + if (isE2EMockMode()) { + await delay(60); + if (mnemonic !== normalizeMnemonic(E2E_MOCK_XFER_MNEMONIC)) { + updateState(setXferCredentialError(state, 'Mock mode: use the configured test seed phrase')); + return; + } + updateState(setXferIdentityUnlocked(state, { + identityId: E2E_MOCK_IDENTITY_ID, + privateKeyWif: E2E_MOCK_DPNS_WIF, + keyId: 1, + securityLevel: 2, + usernames: createE2EMockOwnedUsernames(), + protocolVersion: MIN_TRANSFER_PROTOCOL_VERSION, + })); + return; + } + + const candidates = deriveCandidateKeys(mnemonic, state.network); + + const { discoverIdentityFromCandidates } = await loadUsernameTransferModule(); + const discovered = await discoverIdentityFromCandidates( + candidates, + state.network, + (checked, total) => { + updateState(setXferDiscoveryStatus(state, `Searching for your identity (${checked}/${total})...`)); + } + ); + + if (!discovered) { + updateState(setXferCredentialError( + state, + 'No identity on this network uses a key from that seed phrase. Check you picked the right network, or use the Private Key tab to enter an identity ID directly.' + )); + return; + } + + updateState(setXferDiscoveryStatus(state, 'Identity found. Checking your keys...')); + + const keys = await getIdentityPublicKeys(discovered.identityId, state.network); + const selection = selectTransferSigningKey(candidates, keys, state.network); + + if (selection.status === 'ineligible') { + const reason = explainKeyIneligibility(selection.purpose, selection.securityLevel); + updateState(setXferCredentialError( + state, + `${reason} Use "Manage Keys" to add an AUTHENTICATION key with HIGH security level to this identity, then try again.` + )); + return; + } + + if (selection.status === 'no_match') { + updateState(setXferCredentialError( + state, + 'That seed phrase does not control any active key on the identity it points to.' + )); + return; + } + + updateState(setXferDiscoveryStatus(state, 'Loading your usernames...')); + + const { usernames, protocolVersion } = await loadTransferContext(discovered.identityId); + + updateState(setXferIdentityUnlocked(state, { + identityId: discovered.identityId, + privateKeyWif: selection.candidate.privateKeyWif, + keyId: selection.keyId, + securityLevel: selection.securityLevel, + usernames, + protocolVersion, + })); +} + +/** + * Validate an identity ID + WIF pair, then load the usernames it owns. + */ +async function startXferUnlockFromKey(identityId: string, privateKeyWif: string) { + if (!isValidIdentityId(identityId)) { + updateState(setXferCredentialError(state, 'Invalid identity ID format (expected 44 character Base58 string)')); + return; + } + + if (!privateKeyWif) { + updateState(setXferCredentialError(state, 'Enter the private key for this identity')); + return; + } + + updateState(setXferDiscovering(state, 'Fetching identity...')); + + if (isE2EMockMode()) { + await delay(60); + if (privateKeyWif !== E2E_MOCK_DPNS_WIF) { + updateState(setXferCredentialError(state, 'Mock mode: use the configured test private key')); + return; + } + updateState(setXferIdentityUnlocked(state, { + identityId, + privateKeyWif, + keyId: 1, + securityLevel: 2, + usernames: createE2EMockOwnedUsernames(), + protocolVersion: MIN_TRANSFER_PROTOCOL_VERSION, + })); + return; + } + + const keys = await getIdentityPublicKeys(identityId, state.network); + const match = findMatchingKeyIndex(privateKeyWif, keys.filter((k) => !k.isDisabled), state.network); + + if (!match) { + updateState(setXferCredentialError(state, 'This key does not match any active key registered with this identity')); + return; + } + + if (!isEligibleTransferKey(match.purpose, match.securityLevel)) { + const reason = explainKeyIneligibility(match.purpose, match.securityLevel); + updateState(setXferCredentialError(state, reason ?? 'This key cannot sign a username transfer.')); + return; + } + + updateState(setXferDiscoveryStatus(state, 'Loading your usernames...')); + + const { usernames, protocolVersion } = await loadTransferContext(identityId); + + updateState(setXferIdentityUnlocked(state, { + identityId, + privateKeyWif, + keyId: match.keyId, + securityLevel: match.securityLevel, + usernames, + protocolVersion, + })); +} + +/** + * Entry point for the "Continue" button on the credential screen. + */ +async function startXferUnlock() { + try { + const source = state.xferCredentialSource ?? 'seed'; + + if (source === 'seed') { + await startXferUnlockFromSeed(normalizeMnemonic(state.xferMnemonic || '')); + return; + } + + const identityId = ( + document.querySelector('#xfer-identity-id-input')?.value ?? '' + ).trim(); + const privateKeyWif = ( + document.querySelector('#xfer-private-key-input')?.value ?? '' + ).trim(); + + await startXferUnlockFromKey(identityId, privateKeyWif); + } catch (error) { + console.error('Username transfer unlock error:', error); + updateState(setXferCredentialError(state, toError(error).message)); + } +} + +/** + * Verify the destination identity exists before we let the user sign anything. + * + * Platform does not validate the recipient of a document transfer, so a + * mistyped ID would move the username to an identity nobody controls. + */ +async function startXferRecipientCheck(recipientId: string) { + if (!isValidIdentityId(recipientId)) { + updateState(setXferRecipientError(state, 'Invalid identity ID format (expected 44 character Base58 string)')); + return; + } + + if (recipientId === state.targetIdentityId) { + updateState(setXferRecipientError(state, 'This is the identity that already owns the username')); + return; + } + + updateState(setXferRecipientChecking(state)); + + try { + if (isE2EMockMode()) { + await delay(40); + if (recipientId === E2E_MOCK_XFER_RECIPIENT_ID) { + updateState(setXferRecipientVerified(state)); + } else { + updateState(setXferRecipientError(state, 'Mock mode: use the configured test recipient identity')); + } + return; + } + + const { identityExists } = await loadUsernameTransferModule(); + const exists = await identityExists(recipientId, state.network); + + if (exists) { + updateState(setXferRecipientVerified(state)); + } else { + updateState(setXferRecipientError( + state, + `No identity with this ID exists on ${state.network}. Transferring to it would lose the username permanently.` + )); + } + } catch (error) { + console.error('Recipient verification error:', error); + updateState(setXferRecipientError(state, `Could not verify this identity: ${toError(error).message}`)); + } +} + +/** + * Sign and broadcast the username transfer. + */ +async function startUsernameTransfer() { + const username = state.xferSelectedUsername; + const identityId = state.targetIdentityId; + const privateKeyWif = state.xferPrivateKeyWif; + const recipientId = state.xferRecipientId; + const keyId = state.xferSigningKeyInfo?.keyId; + + if (!username || !identityId || !privateKeyWif || !recipientId || keyId === undefined) { + updateState(setXferResult(state, { success: false, error: 'Missing transfer details' })); + return; + } + + // The UI disables the button, but never sign a transition the network is + // going to reject outright. + if (state.xferProtocolVersion !== undefined && !isProtocolVersionSupported(state.xferProtocolVersion)) { + updateState(setXferResult(state, { + success: false, + error: `${state.network} runs protocol version ${state.xferProtocolVersion}; username transfers require version ${MIN_TRANSFER_PROTOCOL_VERSION} or later.`, + })); + return; + } + + updateState(setXferTransferring(state)); + + try { + if (isE2EMockMode()) { + await delay(80); + updateState(setXferResult(state, { success: true, verifiedOwner: true, recordsUpdated: true })); + return; + } + + const { transferUsername } = await loadUsernameTransferModule(); + const result = await transferUsername({ + username, + identityId, + publicKeyId: keyId, + privateKeyWif, + recipientId, + network: state.network, + }); + + updateState(setXferResult(state, result)); + } catch (error) { + console.error('Username transfer error:', error); + // WasmSdkError is not a standard Error, so check for message property + updateState(setXferResult(state, { success: false, error: toError(error).message })); + } +} + // ============================================================================ // Withdraw Functions // ============================================================================ diff --git a/src/platform/loaders.ts b/src/platform/loaders.ts index 0ad4749..31871f8 100644 --- a/src/platform/loaders.ts +++ b/src/platform/loaders.ts @@ -14,6 +14,7 @@ export function createCachedLoader(load: () => Promise): () => Promise export const loadPlatformModule = createCachedLoader(() => import('./index.js')); export const loadDpnsModule = createCachedLoader(() => import('./dpns.js')); export const loadContractModule = createCachedLoader(() => import('./contract.js')); +export const loadUsernameTransferModule = createCachedLoader(() => import('./username-transfer.js')); export const loadPlatformClientModule = createCachedLoader(() => import('./client.js')); export const loadFeeEstimatorModule = createCachedLoader(() => import('dash-contract-fee-estimator')); export const loadIslockModule = createCachedLoader(() => import('../api/islock.js')); @@ -23,6 +24,7 @@ export function warmDashModules(): Promise[]> { loadPlatformModule(), loadDpnsModule(), loadContractModule(), + loadUsernameTransferModule(), loadPlatformClientModule(), loadFeeEstimatorModule(), loadIslockModule(), diff --git a/src/platform/username-transfer-utils.test.ts b/src/platform/username-transfer-utils.test.ts new file mode 100644 index 0000000..5ead246 --- /dev/null +++ b/src/platform/username-transfer-utils.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; + +import { + MIN_TRANSFER_PROTOCOL_VERSION, + deriveCandidateKeys, + explainKeyIneligibility, + isEligibleTransferKey, + isProtocolVersionSupported, + isValidIdentityId, + isValidMnemonic, + normalizeMnemonic, + selectTransferSigningKey, +} from './username-transfer-utils.js'; +import { getPublicKey } from '../crypto/keys.js'; +import { hash160 } from '../crypto/hash.js'; +import { wifToPrivateKey } from '../utils/wif.js'; +import type { IdentityPublicKeyInfo } from '../types.js'; + +// Standard BIP39 test vector. +const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +/** Build an on-chain key record that the given candidate WIF should match. */ +function keyRecordForWif( + privateKeyWif: string, + overrides: Partial = {} +): IdentityPublicKeyInfo { + const { privateKey } = wifToPrivateKey(privateKeyWif); + return { + id: 0, + type: 0, + purpose: 0, // AUTHENTICATION + securityLevel: 2, // HIGH + data: getPublicKey(privateKey), + ...overrides, + }; +} + +describe('seed phrase input', () => { + it('normalizes whitespace and case', () => { + expect(normalizeMnemonic(' Abandon ABANDON\nabout ')).toBe('abandon abandon about'); + expect(normalizeMnemonic(' ')).toBe(''); + }); + + it('validates the BIP39 checksum', () => { + expect(isValidMnemonic(MNEMONIC)).toBe(true); + expect(isValidMnemonic(MNEMONIC.toUpperCase())).toBe(true); + // Same words, bad checksum. + expect(isValidMnemonic(MNEMONIC.replace(/about$/, 'abandon'))).toBe(false); + expect(isValidMnemonic('not actually bip39 words at all')).toBe(false); + expect(isValidMnemonic('')).toBe(false); + }); +}); + +describe('identity ID validation', () => { + it('accepts 43-44 character Base58 and rejects everything else', () => { + expect(isValidIdentityId('1'.repeat(44))).toBe(true); + expect(isValidIdentityId(` ${'1'.repeat(43)} `)).toBe(true); + expect(isValidIdentityId('1'.repeat(42))).toBe(false); + expect(isValidIdentityId('1'.repeat(45))).toBe(false); + // 0, O, I and l are not in the Base58 alphabet. + expect(isValidIdentityId(`0${'1'.repeat(43)}`)).toBe(false); + }); +}); + +describe('transfer key eligibility', () => { + it('accepts AUTHENTICATION keys at CRITICAL or HIGH', () => { + expect(isEligibleTransferKey(0, 1)).toBe(true); + expect(isEligibleTransferKey(0, 2)).toBe(true); + }); + + it('rejects MASTER, which Platform does not accept for document transitions', () => { + expect(isEligibleTransferKey(0, 0)).toBe(false); + expect(explainKeyIneligibility(0, 0)).toContain('MASTER'); + }); + + it('rejects MEDIUM and non-AUTHENTICATION purposes', () => { + expect(isEligibleTransferKey(0, 3)).toBe(false); + expect(isEligibleTransferKey(3, 1)).toBe(false); // TRANSFER purpose + expect(explainKeyIneligibility(3, 1)).toContain('TRANSFER'); + expect(explainKeyIneligibility(0, 2)).toBeNull(); + }); +}); + +describe('protocol version gate', () => { + it('requires protocol version 13, when DPNS transfers were enabled', () => { + expect(MIN_TRANSFER_PROTOCOL_VERSION).toBe(13); + expect(isProtocolVersionSupported(12)).toBe(false); + expect(isProtocolVersionSupported(13)).toBe(true); + expect(isProtocolVersionSupported(14)).toBe(true); + }); +}); + +describe('candidate key derivation', () => { + it('scans identity index 0 first, then sweeps later identity indices', () => { + const candidates = deriveCandidateKeys(MNEMONIC, 'testnet'); + + expect(candidates.slice(0, 5).map((c) => [c.identityIndex, c.keyIndex])).toEqual([ + [0, 0], [0, 1], [0, 2], [0, 3], [0, 4], + ]); + expect(candidates.slice(5).map((c) => [c.identityIndex, c.keyIndex])).toEqual([ + [1, 0], [2, 0], [3, 0], [4, 0], + ]); + }); + + it('uses the DIP-13 identity path with the network coin type', () => { + expect(deriveCandidateKeys(MNEMONIC, 'testnet')[0].derivationPath).toBe("m/9'/1'/5'/0'/0'/0'/0'"); + expect(deriveCandidateKeys(MNEMONIC, 'mainnet')[0].derivationPath).toBe("m/9'/5'/5'/0'/0'/0'/0'"); + // Devnets derive like testnet. + expect(deriveCandidateKeys(MNEMONIC, 'devnet-paloma')[0].derivationPath).toBe("m/9'/1'/5'/0'/0'/0'/0'"); + }); + + it('derives distinct keys and a matching public key hash', () => { + const candidates = deriveCandidateKeys(MNEMONIC, 'testnet'); + const wifs = new Set(candidates.map((c) => c.privateKeyWif)); + expect(wifs.size).toBe(candidates.length); + + for (const candidate of candidates) { + const { privateKey } = wifToPrivateKey(candidate.privateKeyWif); + expect(getPublicKey(privateKey)).toEqual(candidate.publicKey); + expect(hash160(candidate.publicKey)).toEqual(candidate.publicKeyHash); + } + }); + + it('derives the same keys as the mainnet coin type only for mainnet', () => { + const testnet = deriveCandidateKeys(MNEMONIC, 'testnet')[0]; + const mainnet = deriveCandidateKeys(MNEMONIC, 'mainnet')[0]; + expect(testnet.publicKey).not.toEqual(mainnet.publicKey); + }); +}); + +describe('signing key selection', () => { + const candidates = deriveCandidateKeys(MNEMONIC, 'testnet'); + + it('returns no_match when the seed controls none of the identity keys', () => { + const unrelated: IdentityPublicKeyInfo = { + id: 0, type: 0, purpose: 0, securityLevel: 2, data: new Uint8Array(33), + }; + expect(selectTransferSigningKey(candidates, [unrelated], 'testnet')).toEqual({ status: 'no_match' }); + }); + + it('picks the eligible key even when an ineligible one matches first', () => { + const keys = [ + keyRecordForWif(candidates[0].privateKeyWif, { id: 0, securityLevel: 0 }), // MASTER + keyRecordForWif(candidates[2].privateKeyWif, { id: 2, securityLevel: 2 }), // HIGH + ]; + + const selection = selectTransferSigningKey(candidates, keys, 'testnet'); + expect(selection.status).toBe('ok'); + if (selection.status !== 'ok') return; + expect(selection.keyId).toBe(2); + expect(selection.candidate.keyIndex).toBe(2); + }); + + it('reports ineligible (not no_match) when only a MASTER key matches', () => { + const keys = [keyRecordForWif(candidates[0].privateKeyWif, { id: 0, securityLevel: 0 })]; + expect(selectTransferSigningKey(candidates, keys, 'testnet')).toEqual({ + status: 'ineligible', + keyId: 0, + purpose: 0, + securityLevel: 0, + }); + }); + + it('matches ECDSA_HASH160 keys by public key hash', () => { + const keys = [ + keyRecordForWif(candidates[1].privateKeyWif, { + id: 7, + type: 2, + data: candidates[1].publicKeyHash, + }), + ]; + + const selection = selectTransferSigningKey(candidates, keys, 'testnet'); + expect(selection.status).toBe('ok'); + if (selection.status !== 'ok') return; + expect(selection.keyId).toBe(7); + }); + + it('ignores disabled keys', () => { + const keys = [keyRecordForWif(candidates[0].privateKeyWif, { id: 0, isDisabled: true })]; + expect(selectTransferSigningKey(candidates, keys, 'testnet')).toEqual({ status: 'no_match' }); + }); + + it('does not match a testnet-derived key against a mainnet identity', () => { + const keys = [keyRecordForWif(candidates[0].privateKeyWif, { id: 0 })]; + // findMatchingKeyIndex rejects on WIF network prefix mismatch. + expect(selectTransferSigningKey(candidates, keys, 'mainnet')).toEqual({ status: 'no_match' }); + }); +}); diff --git a/src/platform/username-transfer-utils.ts b/src/platform/username-transfer-utils.ts new file mode 100644 index 0000000..b5018c0 --- /dev/null +++ b/src/platform/username-transfer-utils.ts @@ -0,0 +1,197 @@ +import { validateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english.js'; +import { mnemonicToHDKey, deriveKeyAtPath, getIdentityKeyDerivationPath } from '../crypto/hd.js'; +import { + findMatchingKeyIndex, + getPublicKey, + getSecurityLevelName, + getPurposeName, + isPurposeAllowedForDpns, + isSecurityLevelAllowedForDpns, +} from '../crypto/keys.js'; +import { hash160 } from '../crypto/hash.js'; +import { privateKeyToWif } from '../utils/wif.js'; +import { getNetwork } from '../config.js'; +import type { IdentityPublicKeyInfo } from '../types.js'; + +/** + * DPNS `domain` documents only became transferable at protocol version 13. + * Before that a data trigger rejected Transfer outright, so there is no point + * letting the user sign a transition an older network will refuse. + */ +export const MIN_TRANSFER_PROTOCOL_VERSION = 13; + +/** How many key indices to scan under the first identity index. */ +const KEY_INDEX_SCAN_DEPTH = 5; + +/** How many additional identity indices to probe (first key only). */ +const IDENTITY_INDEX_GAP_LIMIT = 5; + +/** + * A key derived from the user's seed phrase, and everything needed to match it + * against an identity's on-chain keys. + */ +export interface DerivedCandidateKey { + identityIndex: number; + keyIndex: number; + derivationPath: string; + publicKey: Uint8Array; + /** hash160 of the compressed public key — the identity lookup key */ + publicKeyHash: Uint8Array; + privateKeyWif: string; +} + +/** Result of matching derived candidates against an identity's keys. */ +export type SigningKeySelection = + | { + status: 'ok'; + candidate: DerivedCandidateKey; + keyId: number; + purpose: number; + securityLevel: number; + } + | { status: 'ineligible'; keyId: number; purpose: number; securityLevel: number } + | { status: 'no_match' }; + +/** + * Normalize user-entered seed phrase input: collapse whitespace/newlines and + * lowercase. BIP39 English words are lowercase, and pasted phrases routinely + * arrive with line breaks or double spaces. + */ +export function normalizeMnemonic(input: string): string { + return input.trim().toLowerCase().split(/\s+/).filter(Boolean).join(' '); +} + +/** + * Validate a BIP39 seed phrase (checksum included). + */ +export function isValidMnemonic(input: string): boolean { + const normalized = normalizeMnemonic(input); + if (!normalized) return false; + try { + return validateMnemonic(normalized, wordlist); + } catch { + return false; + } +} + +/** + * Validate a Base58 identity ID, matching the format check used elsewhere in + * the app for identity input. + */ +export function isValidIdentityId(identityId: string): boolean { + return /^[1-9A-HJ-NP-Za-km-z]{43,44}$/.test(identityId.trim()); +} + +/** + * Whether a key may sign a DPNS domain transfer. + * + * Platform requires AUTHENTICATION purpose (a document transfer is not a token + * transfer, so `purpose_requirement()` is `[AUTHENTICATION]`), and the DPNS + * `domain` type declares no explicit signature security level, so it defaults + * to HIGH — which the protocol expands to {CRITICAL, HIGH}. MASTER is *not* + * accepted. These are the same constraints as DPNS registration. + */ +export function isEligibleTransferKey(purpose: number, securityLevel: number): boolean { + return isPurposeAllowedForDpns(purpose) && isSecurityLevelAllowedForDpns(securityLevel); +} + +/** + * Explain why a matched key cannot sign a transfer, or null if it can. + */ +export function explainKeyIneligibility(purpose: number, securityLevel: number): string | null { + if (!isPurposeAllowedForDpns(purpose)) { + return `This key has ${getPurposeName(purpose)} purpose. Username transfers must be signed with an AUTHENTICATION key.`; + } + if (!isSecurityLevelAllowedForDpns(securityLevel)) { + return `This key has ${getSecurityLevelName(securityLevel)} security level. Username transfers must be signed with a CRITICAL or HIGH level key.`; + } + return null; +} + +/** + * Whether the network's protocol version supports DPNS username transfers. + */ +export function isProtocolVersionSupported(protocolVersion: number): boolean { + return protocolVersion >= MIN_TRANSFER_PROTOCOL_VERSION; +} + +/** + * Derive the identity key candidates to probe for a seed phrase. + * + * Ordered cheapest-first: every key index under identity index 0 (which covers + * every identity this app creates), then the first key of each subsequent + * identity index as a gap-limit sweep. Callers walk this list in order and stop + * at the first hit, so ordering is what keeps discovery to a couple of round + * trips in the common case. + */ +export function deriveCandidateKeys(mnemonic: string, network: string): DerivedCandidateKey[] { + const normalized = normalizeMnemonic(mnemonic); + const hdKey = mnemonicToHDKey(normalized); + const networkConfig = getNetwork(network); + + const slots: { identityIndex: number; keyIndex: number }[] = []; + for (let keyIndex = 0; keyIndex < KEY_INDEX_SCAN_DEPTH; keyIndex++) { + slots.push({ identityIndex: 0, keyIndex }); + } + for (let identityIndex = 1; identityIndex < IDENTITY_INDEX_GAP_LIMIT; identityIndex++) { + slots.push({ identityIndex, keyIndex: 0 }); + } + + return slots.map(({ identityIndex, keyIndex }) => { + const derivationPath = getIdentityKeyDerivationPath(keyIndex, network, identityIndex); + const { privateKey } = deriveKeyAtPath(hdKey, derivationPath); + const publicKey = getPublicKey(privateKey); + + return { + identityIndex, + keyIndex, + derivationPath, + publicKey, + publicKeyHash: hash160(publicKey), + privateKeyWif: privateKeyToWif(privateKey, networkConfig), + }; + }); +} + +/** + * Pick the derived key that can sign a transfer for this identity. + * + * Returns `ineligible` (rather than `no_match`) when the seed does control a + * registered key but none of them may sign a transfer — most often a seed that + * only yields a MASTER key. That distinction drives a much more useful error + * message, since the fix is to add a HIGH AUTHENTICATION key rather than to + * find a different seed. + */ +export function selectTransferSigningKey( + candidates: DerivedCandidateKey[], + identityKeys: IdentityPublicKeyInfo[], + network: string +): SigningKeySelection { + const enabledKeys = identityKeys.filter((key) => !key.isDisabled); + let ineligible: SigningKeySelection | undefined; + + for (const candidate of candidates) { + const match = findMatchingKeyIndex(candidate.privateKeyWif, enabledKeys, network); + if (!match) continue; + + if (isEligibleTransferKey(match.purpose, match.securityLevel)) { + return { + status: 'ok', + candidate, + keyId: match.keyId, + purpose: match.purpose, + securityLevel: match.securityLevel, + }; + } + + ineligible ??= { + status: 'ineligible', + keyId: match.keyId, + purpose: match.purpose, + securityLevel: match.securityLevel, + }; + } + + return ineligible ?? { status: 'no_match' }; +} diff --git a/src/platform/username-transfer.ts b/src/platform/username-transfer.ts new file mode 100644 index 0000000..6feab9a --- /dev/null +++ b/src/platform/username-transfer.ts @@ -0,0 +1,337 @@ +import type { EvoSDK } from '@dashevo/evo-sdk'; +import { withRetry, type RetryOptions } from '../utils/retry.js'; +import { bytesToHex } from '../utils/hex.js'; +import { + PLATFORM_PUT_SETTINGS, + fetchIdentityWithSdk, + withConnectedPlatformSdk, + withPlatformOperationTimeout, +} from './client.js'; +import { loadSdkModule } from './sdkModule.js'; +import type { DerivedCandidateKey } from './username-transfer-utils.js'; +import type { UsernameTransferOutcome } from '../types.js'; + +/** + * DPNS is a system data contract with the same ID on every network. + * Source: packages/dpns-contract/lib/systemIds.js in dashpay/platform. + */ +export const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +export const DPNS_DOCUMENT_TYPE = 'domain'; + +/** Usernames query defaults to a limit of 10; ask for more than anyone owns. */ +const USERNAME_LIST_LIMIT = 100; + +export interface DiscoveredIdentity { + identityId: string; + candidate: DerivedCandidateKey; +} + +/** + * Look up the identity that a derived key belongs to. + * + * Tries the unique public-key-hash index first, then the non-unique one, since + * identity keys may be registered either way. Both lookups throw on a miss as + * well as on a transport failure, so `answered` records whether at least one + * query actually completed — the caller needs that to tell "this seed owns + * nothing" apart from "the network is unreachable". + */ +async function findIdentityIdByPublicKeyHash( + sdk: EvoSDK, + publicKeyHash: Uint8Array +): Promise<{ identityId?: string; answered: boolean; error?: unknown }> { + const hashHex = bytesToHex(publicKeyHash); + let lastError: unknown; + let answered = false; + + try { + const identity = await sdk.identities.byPublicKeyHash(hashHex); + answered = true; + if (identity) return { identityId: identity.id.toString(), answered }; + } catch (error) { + lastError = error; + } + + try { + const identities = await sdk.identities.byNonUniquePublicKeyHash(hashHex); + answered = true; + if (identities.length > 0) return { identityId: identities[0].id.toString(), answered }; + } catch (error) { + lastError = error; + } + + return { answered, error: answered ? undefined : lastError }; +} + +/** + * Walk derived candidates in order and return the first one that resolves to a + * registered identity. Candidates are pre-ordered cheapest-first, so this + * usually resolves on the first or second lookup. + * + * Throws if no lookup ever completed, so an unreachable network is not reported + * to the user as an unrecognised seed phrase. + */ +export async function discoverIdentityFromCandidates( + candidates: DerivedCandidateKey[], + network: string, + onProgress?: (checked: number, total: number) => void, + retryOptions?: RetryOptions +): Promise { + return withConnectedPlatformSdk( + network, + async (sdk) => { + let anyAnswered = false; + let lastError: unknown; + + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i]; + onProgress?.(i + 1, candidates.length); + + const result = await findIdentityIdByPublicKeyHash(sdk, candidate.publicKeyHash); + if (result.identityId) { + return { identityId: result.identityId, candidate }; + } + anyAnswered ||= result.answered; + lastError = result.error ?? lastError; + } + + if (!anyAnswered && lastError !== undefined) { + throw lastError; + } + return undefined; + }, + retryOptions + ); +} + +/** + * List the usernames an identity owns. + * + * Note this queries by `records.identity`, not `$ownerId` — the two are kept in + * sync by Platform on transfer, but the caller still re-checks `ownerId` on the + * document before signing rather than trusting this listing. + */ +export async function listOwnedUsernames( + identityId: string, + network: string, + retryOptions?: RetryOptions +): Promise { + return withConnectedPlatformSdk( + network, + (sdk) => + withRetry( + () => sdk.dpns.usernames({ identityId, limit: USERNAME_LIST_LIMIT }), + retryOptions + ), + retryOptions + ); +} + +/** + * Read the protocol version the network is currently running. + */ +export async function getProtocolVersion( + network: string, + retryOptions?: RetryOptions +): Promise { + return withConnectedPlatformSdk( + network, + async (sdk) => { + const epoch = await withRetry(() => sdk.epoch.current(), retryOptions); + return epoch.protocolVersion; + }, + retryOptions + ); +} + +/** + * Check that a recipient identity actually exists. + * + * This matters more than it looks: Platform does *not* validate that a document + * transfer's recipient exists, so transferring to a mistyped identity ID would + * permanently orphan the username with no way to recover it. + */ +export async function identityExists( + identityId: string, + network: string, + retryOptions?: RetryOptions +): Promise { + return withConnectedPlatformSdk( + network, + async (sdk) => { + // fetchIdentityWithSdk already retries internally. + const identity = await fetchIdentityWithSdk(sdk, identityId, retryOptions); + return identity !== undefined && identity !== null; + }, + retryOptions + ); +} + +/** + * Normalize an identifier-shaped document property to Base58. + * + * `records.identity` comes back as either an SDK `Identifier` or the raw 32 + * bytes depending on how the document was decoded, and `String(bytes)` would + * silently produce a comma-separated list that never compares equal. + */ +async function toBase58Id(value: unknown): Promise { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') return value; + + if (typeof (value as { toBase58?: unknown }).toBase58 === 'function') { + return (value as { toBase58(): string }).toBase58(); + } + + if (value instanceof Uint8Array) { + const { Identifier } = await loadSdkModule(); + try { + return Identifier.fromBytes(value).toBase58(); + } catch { + return undefined; + } + } + + return undefined; +} + +/** + * Read a domain document's current owner and identity record. + */ +async function readDomainOwnership( + sdk: EvoSDK, + documentId: string +): Promise<{ ownerId: string; recordIdentity?: string } | undefined> { + const document = await sdk.documents.get(DPNS_CONTRACT_ID, DPNS_DOCUMENT_TYPE, documentId); + if (!document) return undefined; + + const records = document.properties?.records as { identity?: unknown } | undefined; + + return { + ownerId: document.ownerId.toString(), + recordIdentity: await toBase58Id(records?.identity), + }; +} + +export interface TransferUsernameParams { + username: string; + identityId: string; + publicKeyId: number; + privateKeyWif: string; + recipientId: string; + network: string; +} + +/** + * Transfer a DPNS username to another identity. + * + * There is no `dpns.transfer` in the SDK — a username transfer is a generic + * document transfer of the DPNS `domain` document that backs the name. + */ +export async function transferUsername( + params: TransferUsernameParams, + retryOptions?: RetryOptions +): Promise { + const { username, identityId, publicKeyId, privateKeyWif, recipientId, network } = params; + + if (identityId === recipientId) { + return { success: false, error: 'Cannot transfer a username to the identity that already owns it' }; + } + + return withConnectedPlatformSdk( + network, + async (sdk) => { + const info = await withRetry(() => sdk.dpns.getUsernameByName(username), retryOptions); + if (!info) { + return { success: false, error: `Username "${username}" was not found on ${network}` }; + } + + const documentId = info.documentId.toString(); + if (info.identityId.toString() !== identityId) { + return { + success: false, + error: `"${username}" is owned by ${info.identityId.toString()}, not ${identityId}`, + }; + } + + const document = await withRetry( + () => sdk.documents.get(DPNS_CONTRACT_ID, DPNS_DOCUMENT_TYPE, documentId), + retryOptions + ); + if (!document) { + return { success: false, error: `Domain document ${documentId} could not be fetched` }; + } + + // Re-check ownership on the document itself. `getUsernameByName` reports + // the owner, but this is the object we are about to sign over. + if (document.ownerId.toString() !== identityId) { + return { + success: false, + error: `Domain document is owned by ${document.ownerId.toString()}, not ${identityId}`, + }; + } + + // Platform requires the transition's revision to be exactly + // stored_revision + 1, and the SDK passes the document's revision through + // untouched, so the bump is ours to do. DPNS `domain` is transferable and + // therefore always carries a revision; guessing one would guarantee a + // rejected transition, so bail out instead. + if (document.revision === undefined || document.revision === null) { + return { success: false, error: `Domain document ${documentId} has no revision` }; + } + document.revision = document.revision + 1n; + + const identity = await fetchIdentityWithSdk(sdk, identityId, retryOptions); + if (!identity) { + return { success: false, error: `Identity ${identityId} not found` }; + } + + const identityKey = identity.getPublicKeyById(publicKeyId); + if (!identityKey) { + return { success: false, error: `Identity key ${publicKeyId} not found` }; + } + + const { IdentitySigner, Identifier } = await loadSdkModule(); + const signer = new IdentitySigner(); + signer.addKeyFromWif(privateKeyWif); + + try { + // Deliberately not wrapped in withRetry: a retry after a broadcast that + // actually landed fails the revision check and would report a false + // failure. The catch below re-reads the document instead. + await withPlatformOperationTimeout( + sdk.documents.transfer({ + document, + recipientId: Identifier.fromBase58(recipientId), + identityKey, + signer, + settings: PLATFORM_PUT_SETTINGS, + }), + 'transferring username' + ); + } catch (error) { + const ownership = await readDomainOwnership(sdk, documentId).catch(() => undefined); + if (ownership?.ownerId === recipientId) { + // The transition landed; only the wait failed. + return { + success: true, + verifiedOwner: true, + recordsUpdated: ownership.recordIdentity === recipientId, + }; + } + + const message = + error && typeof error === 'object' && 'message' in error + ? String((error as { message: unknown }).message) + : String(error); + return { success: false, error: message }; + } + + const ownership = await readDomainOwnership(sdk, documentId).catch(() => undefined); + return { + success: true, + verifiedOwner: ownership?.ownerId === recipientId, + recordsUpdated: ownership?.recordIdentity === recipientId, + }; + }, + retryOptions + ); +} diff --git a/src/types.ts b/src/types.ts index c4b52da..887e254 100644 --- a/src/types.ts +++ b/src/types.ts @@ -124,6 +124,24 @@ export interface IdentityPublicKeyInfo { isDisabled?: boolean; } +/** + * How the user supplied credentials for a username transfer. + * 'seed' auto-discovers the identity; 'key' takes an identity ID + WIF. + */ +export type UsernameTransferCredentialSource = 'seed' | 'key'; + +/** + * Outcome of a username transfer attempt. + */ +export interface UsernameTransferOutcome { + success: boolean; + error?: string; + /** Whether the domain document's owner was confirmed to be the recipient */ + verifiedOwner?: boolean; + /** Whether `records.identity` was rewritten, so the name resolves to the recipient */ + recordsUpdated?: boolean; +} + /** * Configuration for a new key to add during identity update */ @@ -174,10 +192,17 @@ export type BridgeStep = | 'dpns_registering' // Registration in progress | 'dpns_complete' // Done // Identity Management steps + | 'manage_choose_action' // Choose: manage keys or transfer a username | 'manage_enter_identity' // Enter identity ID + private key WIF | 'manage_view_keys' // Display current keys, configure changes | 'manage_updating' // Update transition in progress | 'manage_complete' // Update complete + // Username transfer steps + | 'xfer_credentials' // Enter seed phrase (auto-discovers identity) or identity ID + WIF + | 'xfer_select_username' // Pick an owned username + destination identity ID + | 'xfer_review' // Confirm the irreversible transfer + | 'xfer_transferring' // Document transfer transition in progress + | 'xfer_complete' // Transfer complete // Contract registration steps | 'contract_choose_identity' // Choose: create new or use existing | 'contract_enter_identity' // Enter existing identity ID + private key @@ -357,6 +382,40 @@ export interface BridgeState { /** Manage: key validation error message */ manageKeyValidationError?: string; + // Username transfer fields + /** Transfer: how the user supplied credentials */ + xferCredentialSource?: UsernameTransferCredentialSource; + /** Transfer: raw seed phrase input (kept so the field survives re-render) */ + xferMnemonic?: string; + /** Transfer: identity discovery / lookup in progress */ + xferDiscovering?: boolean; + /** Transfer: progress message shown while discovering or loading usernames */ + xferDiscoveryStatus?: string; + /** Transfer: credential entry error message */ + xferCredentialError?: string; + /** Transfer: WIF for the key that will sign the transfer */ + xferPrivateKeyWif?: string; + /** Transfer: validated signing key */ + xferSigningKeyInfo?: { keyId: number; securityLevel: number }; + /** Transfer: usernames owned by the source identity */ + xferOwnedUsernames?: string[]; + /** Transfer: the username selected for transfer */ + xferSelectedUsername?: string; + /** Transfer: destination identity ID */ + xferRecipientId?: string; + /** Transfer: recipient validation error message */ + xferRecipientError?: string; + /** Transfer: whether the recipient identity was confirmed to exist */ + xferRecipientVerified?: boolean; + /** Transfer: recipient existence check in progress */ + xferRecipientChecking?: boolean; + /** Transfer: network protocol version (transfers need >= 13) */ + xferProtocolVersion?: number; + /** Transfer: whether the user acknowledged that transfers are irreversible */ + xferConfirmationAcknowledged?: boolean; + /** Transfer: result of the transfer attempt */ + xferResult?: UsernameTransferOutcome; + // Contract registration fields /** Contract: identity source (new or existing) */ contractIdentitySource?: 'new' | 'existing'; diff --git a/src/ui/components.ts b/src/ui/components.ts index d13fa36..fe8005f 100644 --- a/src/ui/components.ts +++ b/src/ui/components.ts @@ -1,6 +1,7 @@ import type { BridgeState, KeyType, KeyPurpose, SecurityLevel, NetworkHealth } from '../types.js'; import { getStepProgress, getStepDescription, ErrorCodes, ErrorCodeLabels } from './state.js'; import { shouldShowContestedWarning, countUsernameStatuses } from '../platform/dpns-utils.js'; +import { MIN_TRANSFER_PROTOCOL_VERSION, isProtocolVersionSupported } from '../platform/username-transfer-utils.js'; import { generateQRCodeDataUrl } from './qrcode.js'; import { privateKeyToWif } from '../utils/wif.js'; import { formatCreditsAsDash, formatCredits, MIN_WITHDRAWAL_CREDITS } from '../utils/credits.js'; @@ -287,6 +288,10 @@ export function render(state: BridgeState, container: HTMLElement): void { break; // Identity Management steps + case 'manage_choose_action': + content.appendChild(renderManageChooseActionStep(state)); + break; + case 'manage_enter_identity': content.appendChild(renderManageEnterIdentityStep(state)); break; @@ -303,6 +308,27 @@ export function render(state: BridgeState, container: HTMLElement): void { content.appendChild(renderManageCompleteStep(state)); break; + // Username transfer steps + case 'xfer_credentials': + content.appendChild(renderXferCredentialsStep(state)); + break; + + case 'xfer_select_username': + content.appendChild(renderXferSelectUsernameStep(state)); + break; + + case 'xfer_review': + content.appendChild(renderXferReviewStep(state)); + break; + + case 'xfer_transferring': + content.appendChild(renderXferTransferringStep(state)); + break; + + case 'xfer_complete': + content.appendChild(renderXferCompleteStep(state)); + break; + // Contract registration steps case 'contract_choose_identity': content.appendChild(renderContractChooseIdentityStep(state)); @@ -2660,6 +2686,455 @@ function renderWithdrawCompleteStep(state: BridgeState): HTMLElement { return div; } +// ============================================================================ +// Username Transfer Steps +// ============================================================================ + +/** + * Render the Manage mode landing screen: key management or username transfer. + */ +function renderManageChooseActionStep(_state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'manage-choose-action-step'; + + const headline = document.createElement('h2'); + headline.className = 'manage-headline'; + headline.textContent = 'Manage Identity'; + div.appendChild(headline); + + const subtitle = document.createElement('p'); + subtitle.className = 'manage-subtitle'; + subtitle.textContent = 'Change the keys on an identity, or move a username to another identity.'; + div.appendChild(subtitle); + + const choiceButtons = document.createElement('div'); + choiceButtons.className = 'manage-choice-buttons'; + choiceButtons.innerHTML = ` + + + `; + div.appendChild(choiceButtons); + + const navButtons = document.createElement('div'); + navButtons.className = 'nav-buttons'; + const backBtn = document.createElement('button'); + backBtn.id = 'back-btn'; + backBtn.className = 'secondary-btn'; + backBtn.textContent = 'Back'; + navButtons.appendChild(backBtn); + div.appendChild(navButtons); + + return div; +} + +/** + * Render the transfer credential entry step: a seed phrase (which also + * discovers the identity) or an identity ID plus a private key. + */ +function renderXferCredentialsStep(state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'xfer-credentials-step'; + div.id = 'xfer-key-upload-dropzone'; + + const headline = document.createElement('h2'); + headline.className = 'xfer-headline'; + headline.textContent = 'Unlock Your Identity'; + div.appendChild(headline); + + const source = state.xferCredentialSource ?? 'seed'; + const isDiscovering = state.xferDiscovering === true; + + const tabs = document.createElement('div'); + tabs.className = 'xfer-source-tabs'; + tabs.innerHTML = ` + + + `; + div.appendChild(tabs); + + const form = document.createElement('div'); + form.className = 'xfer-credentials-form'; + + if (source === 'seed') { + form.innerHTML = ` +
+ + +

Used in your browser only, to find your identity and sign the transfer. It is never sent anywhere.

+
+ `; + } else { + form.innerHTML = ` + ${renderKeyUploadSection('xfer-key-upload')} + +
+ + +

The Base58 identifier for the identity that owns the username

+
+ +
+ + +

An AUTHENTICATION key with CRITICAL or HIGH security level

+
+ `; + } + + div.appendChild(form); + + if (isDiscovering) { + const status = document.createElement('p'); + status.className = 'identity-status loading'; + status.textContent = state.xferDiscoveryStatus || 'Looking up your identity...'; + div.appendChild(status); + } else if (state.xferCredentialError) { + const error = document.createElement('p'); + error.className = 'identity-status error'; + error.textContent = state.xferCredentialError; + div.appendChild(error); + } + + const navButtons = document.createElement('div'); + navButtons.className = 'nav-buttons'; + + const backBtn = document.createElement('button'); + backBtn.id = 'xfer-back-btn'; + backBtn.className = 'secondary-btn'; + backBtn.textContent = 'Back'; + navButtons.appendChild(backBtn); + + const unlockBtn = document.createElement('button'); + unlockBtn.id = 'xfer-unlock-btn'; + unlockBtn.className = 'primary-btn'; + unlockBtn.textContent = isDiscovering ? 'Unlocking...' : 'Continue'; + if (isDiscovering) { + unlockBtn.setAttribute('disabled', 'true'); + } + navButtons.appendChild(unlockBtn); + + div.appendChild(navButtons); + + return div; +} + +/** + * Render the username selection + destination step. + */ +function renderXferSelectUsernameStep(state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'xfer-select-username-step'; + + const headline = document.createElement('h2'); + headline.className = 'xfer-headline'; + headline.textContent = 'Choose a Username'; + div.appendChild(headline); + + const identityId = state.targetIdentityId || 'Unknown'; + div.appendChild(renderIdSection('From Identity', identityId, { + explorerHref: explorerUrl(state.network, 'identity', identityId), + copyBtnId: 'copy-xfer-source-btn', + })); + + if (state.xferSigningKeyInfo) { + const keyInfo = document.createElement('p'); + keyInfo.className = 'key-status success'; + keyInfo.textContent = `Signing with key #${state.xferSigningKeyInfo.keyId} (${getSecurityLevelName(state.xferSigningKeyInfo.securityLevel)} level)`; + div.appendChild(keyInfo); + } + + const protocolVersion = state.xferProtocolVersion; + const protocolUnsupported = protocolVersion !== undefined && !isProtocolVersionSupported(protocolVersion); + if (protocolUnsupported) { + const warning = document.createElement('div'); + warning.className = 'warning-box'; + warning.innerHTML = ` +

This network cannot transfer usernames yet.

+

${escapeHtml(state.network)} is running protocol version ${protocolVersion}. Username transfers require version ${MIN_TRANSFER_PROTOCOL_VERSION} or later.

+ `; + div.appendChild(warning); + } + + const usernames = state.xferOwnedUsernames || []; + const list = document.createElement('div'); + list.className = 'xfer-username-list'; + + if (usernames.length === 0) { + list.innerHTML = `

This identity does not own any usernames.

`; + } else { + list.innerHTML = usernames + .map((username, index) => ` + + `) + .join(''); + } + div.appendChild(list); + + // Destination + let recipientStatusHtml = ''; + if (state.xferRecipientChecking) { + recipientStatusHtml = '

Checking destination identity...

'; + } else if (state.xferRecipientError) { + recipientStatusHtml = `

${escapeHtml(state.xferRecipientError)}

`; + } else if (state.xferRecipientVerified) { + recipientStatusHtml = '

Destination identity found

'; + } + + const recipientGroup = document.createElement('div'); + recipientGroup.className = 'input-group'; + recipientGroup.innerHTML = ` + + +

Dash Platform does not check that this identity exists, so we verify it before transferring. A wrong ID would lose the username permanently.

+ ${recipientStatusHtml} + `; + div.appendChild(recipientGroup); + + const navButtons = document.createElement('div'); + navButtons.className = 'nav-buttons'; + + const backBtn = document.createElement('button'); + backBtn.id = 'xfer-back-btn'; + backBtn.className = 'secondary-btn'; + backBtn.textContent = 'Back'; + navButtons.appendChild(backBtn); + + const continueBtn = document.createElement('button'); + continueBtn.id = 'xfer-select-continue-btn'; + continueBtn.className = 'primary-btn'; + continueBtn.textContent = 'Continue'; + const canContinue = + !protocolUnsupported && + state.xferSelectedUsername !== undefined && + state.xferRecipientVerified === true; + if (!canContinue) { + continueBtn.setAttribute('disabled', 'true'); + } + navButtons.appendChild(continueBtn); + + div.appendChild(navButtons); + + return div; +} + +/** + * Render the irreversible-transfer confirmation step. + */ +function renderXferReviewStep(state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'xfer-review-step'; + + const headline = document.createElement('h2'); + headline.className = 'xfer-headline'; + headline.textContent = 'Confirm Transfer'; + div.appendChild(headline); + + const warning = document.createElement('div'); + warning.className = 'warning-box'; + warning.innerHTML = ` +

This cannot be undone.

+

Once transferred, only the destination identity can move the username again. If the destination is wrong, the username is gone for good.

+ `; + div.appendChild(warning); + + const summary = document.createElement('div'); + summary.className = 'xfer-summary'; + summary.innerHTML = ` +
+ Username + ${escapeHtml(state.xferSelectedUsername || '')} +
+
+ From + ${escapeHtml(state.targetIdentityId || '')} +
+
+ To + ${escapeHtml(state.xferRecipientId || '')} +
+ `; + div.appendChild(summary); + + const confirmation = document.createElement('div'); + confirmation.className = 'xfer-confirmation'; + confirmation.innerHTML = ` + + `; + div.appendChild(confirmation); + + const navButtons = document.createElement('div'); + navButtons.className = 'nav-buttons'; + + const backBtn = document.createElement('button'); + backBtn.id = 'xfer-back-btn'; + backBtn.className = 'secondary-btn'; + backBtn.textContent = 'Back'; + navButtons.appendChild(backBtn); + + const transferBtn = document.createElement('button'); + transferBtn.id = 'xfer-transfer-btn'; + transferBtn.className = 'primary-btn'; + transferBtn.textContent = 'Transfer Username'; + if (!state.xferConfirmationAcknowledged) { + transferBtn.setAttribute('disabled', 'true'); + } + navButtons.appendChild(transferBtn); + + div.appendChild(navButtons); + + return div; +} + +/** + * Render the in-progress transfer step. + */ +function renderXferTransferringStep(state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'xfer-transferring-step'; + + const headline = document.createElement('h2'); + headline.className = 'xfer-headline'; + headline.textContent = 'Transferring Username'; + div.appendChild(headline); + + const subtitle = document.createElement('p'); + subtitle.className = 'xfer-subtitle'; + subtitle.textContent = `Submitting the transfer of ${state.xferSelectedUsername || 'your username'} to Dash Platform...`; + div.appendChild(subtitle); + + const spinner = document.createElement('div'); + spinner.className = 'spinner large'; + div.appendChild(spinner); + + return div; +} + +/** + * Render the transfer result step. + */ +function renderXferCompleteStep(state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'xfer-complete-step'; + + const result = state.xferResult; + const isSuccess = result?.success === true; + + const headline = document.createElement('h2'); + headline.className = 'xfer-headline'; + headline.textContent = isSuccess ? 'Username Transferred!' : 'Transfer Failed'; + div.appendChild(headline); + + if (isSuccess) { + const successMsg = document.createElement('p'); + successMsg.className = 'xfer-success-msg'; + successMsg.textContent = `${state.xferSelectedUsername} now belongs to ${state.xferRecipientId}.`; + div.appendChild(successMsg); + + // Surface a partial result honestly rather than implying the name resolves. + if (result?.verifiedOwner && result.recordsUpdated === false) { + const note = document.createElement('div'); + note.className = 'warning-box'; + note.innerHTML = ` +

Ownership moved, but the name's identity record still points at the previous owner.

+

Dash Platform rewrites that record on transfer from protocol version ${MIN_TRANSFER_PROTOCOL_VERSION}. On an older network the username may not resolve to the new owner.

+ `; + div.appendChild(note); + } else if (result?.verifiedOwner === false) { + const note = document.createElement('p'); + note.className = 'input-hint'; + note.textContent = 'The transfer was submitted, but we could not read the document back to confirm it. Check the explorer in a moment.'; + div.appendChild(note); + } + } else { + const errorMsg = document.createElement('div'); + errorMsg.className = 'xfer-error-msg'; + errorMsg.innerHTML = ` +

The username could not be transferred.

+

${escapeHtml(result?.error || 'Unknown error')}

+ `; + div.appendChild(errorMsg); + } + + if (state.xferRecipientId) { + div.appendChild(renderIdSection(isSuccess ? 'New Owner' : 'Intended Recipient', state.xferRecipientId, { + explorerHref: explorerUrl(state.network, 'identity', state.xferRecipientId), + copyBtnId: 'copy-xfer-recipient-btn', + })); + } + + const actionButtons = document.createElement('div'); + actionButtons.className = 'xfer-action-buttons'; + + if (isSuccess) { + const againBtn = document.createElement('button'); + againBtn.id = 'xfer-again-btn'; + againBtn.className = 'primary-btn'; + againBtn.textContent = 'Transfer Another'; + actionButtons.appendChild(againBtn); + } else { + const retryBtn = document.createElement('button'); + retryBtn.id = 'xfer-retry-btn'; + retryBtn.className = 'primary-btn'; + retryBtn.textContent = 'Try Again'; + actionButtons.appendChild(retryBtn); + } + + const startOverBtn = document.createElement('button'); + startOverBtn.id = 'retry-btn'; + startOverBtn.className = 'secondary-btn'; + startOverBtn.textContent = 'Start Over'; + actionButtons.appendChild(startOverBtn); + + div.appendChild(actionButtons); + + return div; +} + // ============================================================================ // Contract Registration Steps // ============================================================================ diff --git a/src/ui/index.ts b/src/ui/index.ts index bc12475..2694af9 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -68,6 +68,27 @@ export { resetManageState, resetManageStateAndRefresh, setManageBackToEntry, + setManageActionKeys, + setManageActionTransfer, + // Username transfer state functions + clearUsernameTransferFields, + setXferCredentialSource, + setXferMnemonic, + setXferDiscovering, + setXferDiscoveryStatus, + setXferCredentialError, + setXferIdentityUnlocked, + setXferSelectedUsername, + setXferRecipientId, + setXferRecipientChecking, + setXferRecipientVerified, + setXferRecipientError, + setXferReview, + setXferConfirmationAcknowledged, + setXferTransferring, + setXferResult, + setXferBackToCredentials, + setXferBackToSelect, // Contract registration state functions setContractIdentitySource, setContractIdentityFetching, diff --git a/src/ui/state.ts b/src/ui/state.ts index 7c17aa2..9abd0a7 100644 --- a/src/ui/state.ts +++ b/src/ui/state.ts @@ -12,6 +12,8 @@ import type { ManageNewKeyConfig, AssetLockProofData, NetworkStatus, + UsernameTransferCredentialSource, + UsernameTransferOutcome, } from '../types.js'; import { generateDefaultIdentityKeysHD, @@ -43,6 +45,7 @@ export const ErrorCodes = { CONTRACT_REGISTER: 'ERR-1013', CHAINLOCK: 'ERR-1014', WITHDRAW: 'ERR-1015', + USERNAME_TRANSFER: 'ERR-1016', } as const; /** Human-readable labels for error codes */ @@ -63,6 +66,7 @@ export const ErrorCodeLabels: Record = { [ErrorCodes.CONTRACT_REGISTER]: 'Contract registration failed', [ErrorCodes.CHAINLOCK]: 'Chain lock fallback failed', [ErrorCodes.WITHDRAW]: 'Credit withdrawal failed', + [ErrorCodes.USERNAME_TRANSFER]: 'Username transfer failed', }; /** Map a processing step to its error code */ @@ -82,6 +86,7 @@ const StepErrorCodes: Partial> = { contract_registering: ErrorCodes.CONTRACT_REGISTER, withdraw_submitting: ErrorCodes.WITHDRAW, withdraw_tracking: ErrorCodes.WITHDRAW, + xfer_transferring: ErrorCodes.USERNAME_TRANSFER, }; /** Coerce an unknown caught value into an Error */ @@ -226,10 +231,10 @@ export function setMode(state: BridgeState, mode: BridgeMode): BridgeState { withdrawStatusError: undefined, }; } else { - // Manage mode: go to identity entry + // Manage mode: choose between key management and username transfer return { - ...clearedState, - step: 'manage_enter_identity', + ...clearUsernameTransferFields(clearedState), + step: 'manage_choose_action', mode, // Clear any previous manage state manageKeysToAdd: [], @@ -241,6 +246,7 @@ export function setMode(state: BridgeState, mode: BridgeMode): BridgeState { manageIdentityKeys: undefined, manageUpdateResult: undefined, manageKeyValidationError: undefined, + targetIdentityId: undefined, }; } } @@ -668,10 +674,17 @@ export function getStepDescription(step: BridgeStep): string { dpns_registering: 'Registering...', dpns_complete: 'Registration complete', // Identity Management steps + manage_choose_action: 'Manage identity', manage_enter_identity: 'Manage identity', manage_view_keys: 'Manage keys', manage_updating: 'Updating identity...', manage_complete: 'Update complete', + // Username transfer steps + xfer_credentials: 'Unlock your identity', + xfer_select_username: 'Choose a username', + xfer_review: 'Confirm transfer', + xfer_transferring: 'Transferring username...', + xfer_complete: 'Transfer complete', // Contract registration steps contract_choose_identity: 'Register contract', contract_enter_identity: 'Enter identity', @@ -720,10 +733,17 @@ export function getStepProgress(step: BridgeStep): number { dpns_registering: 80, dpns_complete: 100, // Identity Management steps + manage_choose_action: 10, manage_enter_identity: 20, manage_view_keys: 40, manage_updating: 70, manage_complete: 100, + // Username transfer steps + xfer_credentials: 20, + xfer_select_username: 40, + xfer_review: 60, + xfer_transferring: 85, + xfer_complete: 100, // Contract registration steps contract_choose_identity: 10, contract_enter_identity: 20, @@ -761,6 +781,8 @@ export function isProcessingStep(step: BridgeStep): boolean { 'dpns_registering', // Identity Management processing steps 'manage_updating', + // Username transfer processing steps + 'xfer_transferring', // Contract registration processing steps 'contract_registering', // Withdraw processing steps @@ -1282,6 +1304,291 @@ export function setManageBackToEntry(state: BridgeState): BridgeState { }; } +// ============================================================================ +// Username Transfer State Functions +// ============================================================================ + +/** + * Clear every field belonging to the username transfer sub-flow. + */ +export function clearUsernameTransferFields(state: BridgeState): BridgeState { + return { + ...state, + xferCredentialSource: undefined, + xferMnemonic: undefined, + xferDiscovering: undefined, + xferDiscoveryStatus: undefined, + xferCredentialError: undefined, + xferPrivateKeyWif: undefined, + xferSigningKeyInfo: undefined, + xferOwnedUsernames: undefined, + xferSelectedUsername: undefined, + xferRecipientId: undefined, + xferRecipientError: undefined, + xferRecipientVerified: undefined, + xferRecipientChecking: undefined, + xferProtocolVersion: undefined, + xferConfirmationAcknowledged: undefined, + xferResult: undefined, + }; +} + +/** + * Manage mode: choose the key management path + */ +export function setManageActionKeys(state: BridgeState): BridgeState { + return { + ...clearUsernameTransferFields(state), + step: 'manage_enter_identity', + }; +} + +/** + * Manage mode: choose the username transfer path + */ +export function setManageActionTransfer(state: BridgeState): BridgeState { + return { + ...clearUsernameTransferFields(state), + step: 'xfer_credentials', + targetIdentityId: undefined, + }; +} + +/** + * Switch between seed phrase and identity ID + WIF credential entry + */ +export function setXferCredentialSource( + state: BridgeState, + source: UsernameTransferCredentialSource +): BridgeState { + return { + ...state, + xferCredentialSource: source, + xferCredentialError: undefined, + xferDiscoveryStatus: undefined, + xferSigningKeyInfo: undefined, + xferPrivateKeyWif: undefined, + }; +} + +/** + * Record seed phrase input without validating it yet + */ +export function setXferMnemonic(state: BridgeState, mnemonic: string): BridgeState { + return { + ...state, + xferMnemonic: mnemonic, + xferCredentialError: undefined, + }; +} + +/** + * Start identity discovery / unlock + */ +export function setXferDiscovering(state: BridgeState, status: string): BridgeState { + return { + ...state, + xferDiscovering: true, + xferDiscoveryStatus: status, + xferCredentialError: undefined, + }; +} + +/** + * Update the discovery progress message without leaving the discovering state + */ +export function setXferDiscoveryStatus(state: BridgeState, status: string): BridgeState { + return { + ...state, + xferDiscoveryStatus: status, + }; +} + +/** + * Discovery or credential validation failed + */ +export function setXferCredentialError(state: BridgeState, error: string): BridgeState { + return { + ...state, + xferDiscovering: false, + xferDiscoveryStatus: undefined, + xferCredentialError: error, + }; +} + +/** + * Credentials accepted: we know the identity, its signing key, and what it owns + */ +export function setXferIdentityUnlocked( + state: BridgeState, + params: { + identityId: string; + privateKeyWif: string; + keyId: number; + securityLevel: number; + usernames: string[]; + protocolVersion?: number; + } +): BridgeState { + return { + ...state, + step: 'xfer_select_username', + targetIdentityId: params.identityId, + xferDiscovering: false, + xferDiscoveryStatus: undefined, + xferCredentialError: undefined, + xferPrivateKeyWif: params.privateKeyWif, + xferSigningKeyInfo: { keyId: params.keyId, securityLevel: params.securityLevel }, + xferOwnedUsernames: params.usernames, + xferProtocolVersion: params.protocolVersion, + // Preselect when there is only one name to choose from. + xferSelectedUsername: params.usernames.length === 1 ? params.usernames[0] : undefined, + }; +} + +/** + * Select which username to transfer + */ +export function setXferSelectedUsername(state: BridgeState, username: string): BridgeState { + return { + ...state, + xferSelectedUsername: username, + }; +} + +/** + * Record the destination identity ID. Any edit invalidates a previous check. + */ +export function setXferRecipientId(state: BridgeState, recipientId: string): BridgeState { + return { + ...state, + xferRecipientId: recipientId, + xferRecipientVerified: undefined, + xferRecipientError: undefined, + }; +} + +/** + * Start verifying that the recipient identity exists + */ +export function setXferRecipientChecking(state: BridgeState): BridgeState { + return { + ...state, + xferRecipientChecking: true, + xferRecipientError: undefined, + xferRecipientVerified: undefined, + }; +} + +/** + * Recipient identity confirmed to exist on Platform + */ +export function setXferRecipientVerified(state: BridgeState): BridgeState { + return { + ...state, + xferRecipientChecking: false, + xferRecipientVerified: true, + xferRecipientError: undefined, + }; +} + +/** + * Recipient identity could not be confirmed. + * + * This is a hard gate rather than a warning: Platform does not check that a + * transfer recipient exists, so a mistyped ID would orphan the username. + */ +export function setXferRecipientError(state: BridgeState, error: string): BridgeState { + return { + ...state, + xferRecipientChecking: false, + xferRecipientVerified: false, + xferRecipientError: error, + }; +} + +/** + * Move to the confirmation screen + */ +export function setXferReview(state: BridgeState): BridgeState { + return { + ...state, + step: 'xfer_review', + xferConfirmationAcknowledged: false, + }; +} + +/** + * Toggle the "I understand this is irreversible" checkbox + */ +export function setXferConfirmationAcknowledged( + state: BridgeState, + acknowledged: boolean +): BridgeState { + return { + ...state, + xferConfirmationAcknowledged: acknowledged, + }; +} + +/** + * Start the transfer + */ +export function setXferTransferring(state: BridgeState): BridgeState { + return { + ...state, + step: 'xfer_transferring', + }; +} + +/** + * Transfer finished (successfully or not). + * + * On success the seed phrase and signing key are dropped — nothing downstream + * needs them. On failure they are kept, since "Try Again" signs again. + */ +export function setXferResult( + state: BridgeState, + result: UsernameTransferOutcome +): BridgeState { + return { + ...state, + step: 'xfer_complete', + xferResult: result, + xferMnemonic: result.success ? undefined : state.xferMnemonic, + xferPrivateKeyWif: result.success ? undefined : state.xferPrivateKeyWif, + }; +} + +/** + * Go back to credential entry, keeping the seed phrase the user typed + */ +export function setXferBackToCredentials(state: BridgeState): BridgeState { + return { + ...state, + step: 'xfer_credentials', + xferSigningKeyInfo: undefined, + xferPrivateKeyWif: undefined, + xferOwnedUsernames: undefined, + xferSelectedUsername: undefined, + xferRecipientId: undefined, + xferRecipientVerified: undefined, + xferRecipientError: undefined, + xferConfirmationAcknowledged: undefined, + }; +} + +/** + * Go back to username selection from the review screen + */ +export function setXferBackToSelect(state: BridgeState): BridgeState { + return { + ...state, + step: 'xfer_select_username', + xferConfirmationAcknowledged: undefined, + }; +} + // ============================================================================ // Contract Registration State Functions // ============================================================================ From ca179bc8c49078d90450ded1499e975a5f2cba92 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 1 Sep 2026 20:54:44 +0200 Subject: [PATCH 2/6] refactor: address review of username transfer flow Review fixes: clear the seed phrase and signing key on any mode switch, not just on re-entering Manage, so a wallet seed cannot outlive the flow that asked for it; report an unconfirmed failure distinctly when the domain document cannot be read back after a throw, since a transfer that landed must not be blindly retried; and stop making the whole credential screen a key-file dropzone while the seed-phrase tab is showing. Simplifications: identityExists now uses client.ts fetchIdentity instead of reimplementing it, validateIdentityId delegates to the shared predicate, the two unlock paths share their tail and mock branch, and the identity-ID + WIF path reuses selectTransferSigningKey (now generic over anything carrying a WIF) so both paths get the same ineligible-vs-unmatched messages. Drops the xferDiscovering flag, which was derivable from xferDiscoveryStatus and allowed a discovering-with-no-message state. New CSS folds into the existing dpns/manage rules rather than restating them. Co-Authored-By: Claude Opus 5 (1M context) --- index.html | 91 +++-------- src/main.ts | 163 ++++++++++--------- src/platform/username-transfer-utils.test.ts | 34 ++++ src/platform/username-transfer-utils.ts | 27 ++- src/platform/username-transfer.ts | 91 ++++++----- src/types.ts | 15 +- src/ui/components.ts | 36 ++-- src/ui/index.ts | 1 - src/ui/state.ts | 12 +- 9 files changed, 255 insertions(+), 215 deletions(-) diff --git a/index.html b/index.html index 4d3dea2..116c482 100644 --- a/index.html +++ b/index.html @@ -1232,14 +1232,16 @@ ============================================================================ */ /* DPNS Headlines and subtitles */ - .dpns-headline { + .dpns-headline, + .xfer-headline { color: #fff; font-size: 1.4rem; margin-bottom: 8px; text-align: center; } - .dpns-subtitle { + .dpns-subtitle, + .xfer-subtitle { color: #888; font-size: 0.9rem; margin-bottom: 24px; @@ -1247,7 +1249,8 @@ } /* DPNS choice buttons (same as mode-buttons) */ - .dpns-choice-buttons { + .dpns-choice-buttons, + .manage-choice-buttons { display: flex; flex-direction: column; gap: 12px; @@ -1255,16 +1258,20 @@ } /* DPNS identity form */ - .dpns-identity-form { + .dpns-identity-form, + .xfer-credentials-form { text-align: left; margin-bottom: 16px; } - .dpns-identity-form .input-group { + .dpns-identity-form .input-group, + .xfer-credentials-form .input-group, + .xfer-select-username-step .input-group { margin-bottom: 16px; } - .dpns-input { + .dpns-input, + .xfer-input { width: 100%; background: rgba(0, 0, 0, 0.3); border: 2px solid rgba(255, 255, 255, 0.1); @@ -1276,12 +1283,14 @@ transition: border-color 0.2s; } - .dpns-input:focus { + .dpns-input:focus, + .xfer-input:focus { outline: none; border-color: #008de4; } - .dpns-input::placeholder { + .dpns-input::placeholder, + .xfer-input::placeholder { color: #555; } @@ -1990,7 +1999,8 @@ word-break: break-word; } - .manage-action-buttons { + .manage-action-buttons, + .xfer-action-buttons { display: flex; flex-direction: column; gap: 12px; @@ -2001,27 +2011,6 @@ Username Transfer Styles ============================================================================ */ - /* Manage action chooser (same as mode-buttons) */ - .manage-choice-buttons { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 24px; - } - - .xfer-headline { - font-size: 1.4rem; - margin-bottom: 8px; - text-align: center; - } - - .xfer-subtitle { - color: #888; - font-size: 0.9rem; - margin-bottom: 24px; - text-align: center; - } - /* Seed phrase / private key tabs */ .xfer-source-tabs { display: flex; @@ -2050,37 +2039,6 @@ color: #fff; } - .xfer-credentials-form { - text-align: left; - margin-bottom: 16px; - } - - .xfer-credentials-form .input-group, - .xfer-select-username-step .input-group { - margin-bottom: 16px; - } - - .xfer-input { - width: 100%; - background: rgba(0, 0, 0, 0.3); - border: 2px solid rgba(255, 255, 255, 0.1); - color: #fff; - padding: 16px 18px; - border-radius: 10px; - font-family: 'SF Mono', Monaco, monospace; - font-size: 1rem; - transition: border-color 0.2s; - } - - .xfer-input:focus { - outline: none; - border-color: #008de4; - } - - .xfer-input::placeholder { - color: #555; - } - .xfer-mnemonic { resize: vertical; line-height: 1.6; @@ -2236,13 +2194,6 @@ word-break: break-word; } - .xfer-action-buttons { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 24px; - } - /* ============================================================================ Faucet Styles ============================================================================ */ @@ -2840,7 +2791,9 @@ .dpns-action-buttons .primary-btn, .dpns-action-buttons .secondary-btn, .manage-action-buttons .primary-btn, - .manage-action-buttons .secondary-btn { + .manage-action-buttons .secondary-btn, + .xfer-action-buttons .primary-btn, + .xfer-action-buttons .secondary-btn { width: 100%; } diff --git a/src/main.ts b/src/main.ts index f02f6a8..4a4c44d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -167,13 +167,13 @@ import { import { deriveCandidateKeys, explainKeyIneligibility, - isEligibleTransferKey, - isProtocolVersionSupported, + isProtocolVersionBlocked, isValidIdentityId, isValidMnemonic, normalizeMnemonic, selectTransferSigningKey, MIN_TRANSFER_PROTOCOL_VERSION, + type SigningKeySelection, } from './platform/username-transfer-utils.js'; import { loadSdkModule } from './platform/sdkModule.js'; import type { @@ -1540,7 +1540,7 @@ function setupEventListeners(container: HTMLElement) { const xferUnlockBtn = container.querySelector('#xfer-unlock-btn'); if (xferUnlockBtn) { xferUnlockBtn.addEventListener('click', () => { - if (!state.xferDiscovering) { + if (!state.xferDiscoveryStatus) { startXferUnlock(); } }); @@ -1738,9 +1738,7 @@ function setupEventListeners(container: HTMLElement) { ...setTargetIdentityId(state, result.identityId), xferPrivateKeyWif: result.privateKeyWif, }); - await startXferUnlockFromKey(result.identityId, result.privateKeyWif).catch((error) => { - updateState(setXferCredentialError(state, toError(error).message)); - }); + await guardXferUnlock(() => startXferUnlockFromKey(result.identityId, result.privateKeyWif)); }); // Contract key upload — show loading, fetch identity + balance, validate key, update once @@ -3426,19 +3424,13 @@ async function startXferUnlockFromSeed(mnemonic: string) { updateState(setXferDiscovering(state, 'Deriving keys from your seed phrase...')); if (isE2EMockMode()) { - await delay(60); - if (mnemonic !== normalizeMnemonic(E2E_MOCK_XFER_MNEMONIC)) { - updateState(setXferCredentialError(state, 'Mock mode: use the configured test seed phrase')); - return; - } - updateState(setXferIdentityUnlocked(state, { - identityId: E2E_MOCK_IDENTITY_ID, - privateKeyWif: E2E_MOCK_DPNS_WIF, - keyId: 1, - securityLevel: 2, - usernames: createE2EMockOwnedUsernames(), - protocolVersion: MIN_TRANSFER_PROTOCOL_VERSION, - })); + await unlockXferMock( + E2E_MOCK_IDENTITY_ID, + E2E_MOCK_DPNS_WIF, + mnemonic === normalizeMnemonic(E2E_MOCK_XFER_MNEMONIC) + ? undefined + : 'Mock mode: use the configured test seed phrase' + ); return; } @@ -3464,37 +3456,11 @@ async function startXferUnlockFromSeed(mnemonic: string) { updateState(setXferDiscoveryStatus(state, 'Identity found. Checking your keys...')); const keys = await getIdentityPublicKeys(discovered.identityId, state.network); - const selection = selectTransferSigningKey(candidates, keys, state.network); - - if (selection.status === 'ineligible') { - const reason = explainKeyIneligibility(selection.purpose, selection.securityLevel); - updateState(setXferCredentialError( - state, - `${reason} Use "Manage Keys" to add an AUTHENTICATION key with HIGH security level to this identity, then try again.` - )); - return; - } - - if (selection.status === 'no_match') { - updateState(setXferCredentialError( - state, - 'That seed phrase does not control any active key on the identity it points to.' - )); - return; - } - - updateState(setXferDiscoveryStatus(state, 'Loading your usernames...')); - - const { usernames, protocolVersion } = await loadTransferContext(discovered.identityId); - - updateState(setXferIdentityUnlocked(state, { - identityId: discovered.identityId, - privateKeyWif: selection.candidate.privateKeyWif, - keyId: selection.keyId, - securityLevel: selection.securityLevel, - usernames, - protocolVersion, - })); + await applyXferKeySelection( + discovered.identityId, + selectTransferSigningKey(candidates, keys, state.network), + 'That seed phrase does not control any active key on the identity it points to.' + ); } /** @@ -3514,33 +3480,43 @@ async function startXferUnlockFromKey(identityId: string, privateKeyWif: string) updateState(setXferDiscovering(state, 'Fetching identity...')); if (isE2EMockMode()) { - await delay(60); - if (privateKeyWif !== E2E_MOCK_DPNS_WIF) { - updateState(setXferCredentialError(state, 'Mock mode: use the configured test private key')); - return; - } - updateState(setXferIdentityUnlocked(state, { + await unlockXferMock( identityId, privateKeyWif, - keyId: 1, - securityLevel: 2, - usernames: createE2EMockOwnedUsernames(), - protocolVersion: MIN_TRANSFER_PROTOCOL_VERSION, - })); + privateKeyWif === E2E_MOCK_DPNS_WIF + ? undefined + : 'Mock mode: use the configured test private key' + ); return; } const keys = await getIdentityPublicKeys(identityId, state.network); - const match = findMatchingKeyIndex(privateKeyWif, keys.filter((k) => !k.isDisabled), state.network); + await applyXferKeySelection( + identityId, + selectTransferSigningKey([{ privateKeyWif }], keys, state.network), + 'This key does not match any active key registered with this identity' + ); +} - if (!match) { - updateState(setXferCredentialError(state, 'This key does not match any active key registered with this identity')); +/** + * Shared tail of both unlock paths: report why the key cannot sign, or load the + * identity's usernames and move on to selection. + */ +async function applyXferKeySelection( + identityId: string, + selection: SigningKeySelection<{ privateKeyWif: string }>, + noMatchMessage: string +) { + if (selection.status === 'no_match') { + updateState(setXferCredentialError(state, noMatchMessage)); return; } - if (!isEligibleTransferKey(match.purpose, match.securityLevel)) { - const reason = explainKeyIneligibility(match.purpose, match.securityLevel); - updateState(setXferCredentialError(state, reason ?? 'This key cannot sign a username transfer.')); + if (selection.status === 'ineligible') { + updateState(setXferCredentialError( + state, + `${explainKeyIneligibility(selection.purpose, selection.securityLevel)} Use "Manage Keys" to add an AUTHENTICATION key with HIGH security level to this identity, then try again.` + )); return; } @@ -3550,22 +3526,54 @@ async function startXferUnlockFromKey(identityId: string, privateKeyWif: string) updateState(setXferIdentityUnlocked(state, { identityId, - privateKeyWif, - keyId: match.keyId, - securityLevel: match.securityLevel, + privateKeyWif: selection.candidate.privateKeyWif, + keyId: selection.keyId, + securityLevel: selection.securityLevel, usernames, protocolVersion, })); } /** - * Entry point for the "Continue" button on the credential screen. + * Deterministic stand-in for identity discovery in Playwright mock mode. */ -async function startXferUnlock() { +async function unlockXferMock(identityId: string, privateKeyWif: string, rejection?: string) { + await delay(60); + + if (rejection) { + updateState(setXferCredentialError(state, rejection)); + return; + } + + updateState(setXferIdentityUnlocked(state, { + identityId, + privateKeyWif, + keyId: 1, + securityLevel: 2, + usernames: createE2EMockOwnedUsernames(), + protocolVersion: MIN_TRANSFER_PROTOCOL_VERSION, + })); +} + +/** + * Run an unlock attempt, surfacing any failure on the credential screen rather + * than leaving the user on a stuck spinner. + */ +async function guardXferUnlock(run: () => Promise) { try { - const source = state.xferCredentialSource ?? 'seed'; + await run(); + } catch (error) { + console.error('Username transfer unlock error:', error); + updateState(setXferCredentialError(state, toError(error).message)); + } +} - if (source === 'seed') { +/** + * Entry point for the "Continue" button on the credential screen. + */ +function startXferUnlock() { + return guardXferUnlock(async () => { + if ((state.xferCredentialSource ?? 'seed') === 'seed') { await startXferUnlockFromSeed(normalizeMnemonic(state.xferMnemonic || '')); return; } @@ -3578,10 +3586,7 @@ async function startXferUnlock() { ).trim(); await startXferUnlockFromKey(identityId, privateKeyWif); - } catch (error) { - console.error('Username transfer unlock error:', error); - updateState(setXferCredentialError(state, toError(error).message)); - } + }); } /** @@ -3648,7 +3653,7 @@ async function startUsernameTransfer() { // The UI disables the button, but never sign a transition the network is // going to reject outright. - if (state.xferProtocolVersion !== undefined && !isProtocolVersionSupported(state.xferProtocolVersion)) { + if (isProtocolVersionBlocked(state.xferProtocolVersion)) { updateState(setXferResult(state, { success: false, error: `${state.network} runs protocol version ${state.xferProtocolVersion}; username transfers require version ${MIN_TRANSFER_PROTOCOL_VERSION} or later.`, diff --git a/src/platform/username-transfer-utils.test.ts b/src/platform/username-transfer-utils.test.ts index 5ead246..835e47a 100644 --- a/src/platform/username-transfer-utils.test.ts +++ b/src/platform/username-transfer-utils.test.ts @@ -5,6 +5,7 @@ import { deriveCandidateKeys, explainKeyIneligibility, isEligibleTransferKey, + isProtocolVersionBlocked, isProtocolVersionSupported, isValidIdentityId, isValidMnemonic, @@ -88,6 +89,14 @@ describe('protocol version gate', () => { expect(isProtocolVersionSupported(13)).toBe(true); expect(isProtocolVersionSupported(14)).toBe(true); }); + + it('does not block when the version could not be read', () => { + // The version read is best-effort; an unknown version must not lock the + // user out of a transfer the network would actually accept. + expect(isProtocolVersionBlocked(undefined)).toBe(false); + expect(isProtocolVersionBlocked(12)).toBe(true); + expect(isProtocolVersionBlocked(13)).toBe(false); + }); }); describe('candidate key derivation', () => { @@ -186,4 +195,29 @@ describe('signing key selection', () => { // findMatchingKeyIndex rejects on WIF network prefix mismatch. expect(selectTransferSigningKey(candidates, keys, 'mainnet')).toEqual({ status: 'no_match' }); }); + + // The identity-ID + WIF path feeds a bare {privateKeyWif} through the same + // selector, so it gets the same eligibility rules as the seed path. + it('accepts a bare WIF candidate, not just derived keys', () => { + const wif = candidates[3].privateKeyWif; + const keys = [keyRecordForWif(wif, { id: 4, securityLevel: 1 })]; + + const selection = selectTransferSigningKey([{ privateKeyWif: wif }], keys, 'testnet'); + expect(selection.status).toBe('ok'); + if (selection.status !== 'ok') return; + expect(selection.keyId).toBe(4); + expect(selection.candidate.privateKeyWif).toBe(wif); + }); + + it('rejects a bare MASTER WIF as ineligible rather than unmatched', () => { + const wif = candidates[3].privateKeyWif; + const keys = [keyRecordForWif(wif, { id: 0, securityLevel: 0 })]; + + expect(selectTransferSigningKey([{ privateKeyWif: wif }], keys, 'testnet')).toEqual({ + status: 'ineligible', + keyId: 0, + purpose: 0, + securityLevel: 0, + }); + }); }); diff --git a/src/platform/username-transfer-utils.ts b/src/platform/username-transfer-utils.ts index b5018c0..5315c37 100644 --- a/src/platform/username-transfer-utils.ts +++ b/src/platform/username-transfer-utils.ts @@ -41,11 +41,16 @@ export interface DerivedCandidateKey { privateKeyWif: string; } -/** Result of matching derived candidates against an identity's keys. */ -export type SigningKeySelection = +/** Anything that carries a WIF can be matched against an identity's keys. */ +export interface WifBearing { + privateKeyWif: string; +} + +/** Result of matching candidate keys against an identity's keys. */ +export type SigningKeySelection = | { status: 'ok'; - candidate: DerivedCandidateKey; + candidate: T; keyId: number; purpose: number; securityLevel: number; @@ -116,6 +121,14 @@ export function isProtocolVersionSupported(protocolVersion: number): boolean { return protocolVersion >= MIN_TRANSFER_PROTOCOL_VERSION; } +/** + * Whether a known protocol version rules transfers out. An unknown version + * (the read is best-effort) does not block — the network gets the final say. + */ +export function isProtocolVersionBlocked(protocolVersion: number | undefined): boolean { + return protocolVersion !== undefined && !isProtocolVersionSupported(protocolVersion); +} + /** * Derive the identity key candidates to probe for a seed phrase. * @@ -163,13 +176,13 @@ export function deriveCandidateKeys(mnemonic: string, network: string): DerivedC * message, since the fix is to add a HIGH AUTHENTICATION key rather than to * find a different seed. */ -export function selectTransferSigningKey( - candidates: DerivedCandidateKey[], +export function selectTransferSigningKey( + candidates: T[], identityKeys: IdentityPublicKeyInfo[], network: string -): SigningKeySelection { +): SigningKeySelection { const enabledKeys = identityKeys.filter((key) => !key.isDisabled); - let ineligible: SigningKeySelection | undefined; + let ineligible: SigningKeySelection | undefined; for (const candidate of candidates) { const match = findMatchingKeyIndex(candidate.privateKeyWif, enabledKeys, network); diff --git a/src/platform/username-transfer.ts b/src/platform/username-transfer.ts index 6feab9a..fad0498 100644 --- a/src/platform/username-transfer.ts +++ b/src/platform/username-transfer.ts @@ -3,10 +3,12 @@ import { withRetry, type RetryOptions } from '../utils/retry.js'; import { bytesToHex } from '../utils/hex.js'; import { PLATFORM_PUT_SETTINGS, + fetchIdentity, fetchIdentityWithSdk, withConnectedPlatformSdk, withPlatformOperationTimeout, } from './client.js'; +import { extractErrorMessage } from '../utils/errors.js'; import { loadSdkModule } from './sdkModule.js'; import type { DerivedCandidateKey } from './username-transfer-utils.js'; import type { UsernameTransferOutcome } from '../types.js'; @@ -15,8 +17,8 @@ import type { UsernameTransferOutcome } from '../types.js'; * DPNS is a system data contract with the same ID on every network. * Source: packages/dpns-contract/lib/systemIds.js in dashpay/platform. */ -export const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; -export const DPNS_DOCUMENT_TYPE = 'domain'; +const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const DPNS_DOCUMENT_TYPE = 'domain'; /** Usernames query defaults to a limit of 10; ask for more than anyone owns. */ const USERNAME_LIST_LIMIT = 100; @@ -31,14 +33,14 @@ export interface DiscoveredIdentity { * * Tries the unique public-key-hash index first, then the non-unique one, since * identity keys may be registered either way. Both lookups throw on a miss as - * well as on a transport failure, so `answered` records whether at least one - * query actually completed — the caller needs that to tell "this seed owns + * well as on a transport failure, so an absent `error` records that at least + * one query actually completed — the caller needs that to tell "this seed owns * nothing" apart from "the network is unreachable". */ async function findIdentityIdByPublicKeyHash( sdk: EvoSDK, publicKeyHash: Uint8Array -): Promise<{ identityId?: string; answered: boolean; error?: unknown }> { +): Promise<{ identityId?: string; error?: unknown }> { const hashHex = bytesToHex(publicKeyHash); let lastError: unknown; let answered = false; @@ -46,7 +48,7 @@ async function findIdentityIdByPublicKeyHash( try { const identity = await sdk.identities.byPublicKeyHash(hashHex); answered = true; - if (identity) return { identityId: identity.id.toString(), answered }; + if (identity) return { identityId: identity.id.toString() }; } catch (error) { lastError = error; } @@ -54,12 +56,14 @@ async function findIdentityIdByPublicKeyHash( try { const identities = await sdk.identities.byNonUniquePublicKeyHash(hashHex); answered = true; - if (identities.length > 0) return { identityId: identities[0].id.toString(), answered }; + if (identities.length > 0) return { identityId: identities[0].id.toString() }; } catch (error) { lastError = error; } - return { answered, error: answered ? undefined : lastError }; + // An absent `error` means at least one query came back — i.e. a real + // "no such identity", not a network problem. + return answered ? {} : { error: lastError }; } /** @@ -90,8 +94,11 @@ export async function discoverIdentityFromCandidates( if (result.identityId) { return { identityId: result.identityId, candidate }; } - anyAnswered ||= result.answered; - lastError = result.error ?? lastError; + if (result.error === undefined) { + anyAnswered = true; + } else { + lastError = result.error; + } } if (!anyAnswered && lastError !== undefined) { @@ -155,15 +162,8 @@ export async function identityExists( network: string, retryOptions?: RetryOptions ): Promise { - return withConnectedPlatformSdk( - network, - async (sdk) => { - // fetchIdentityWithSdk already retries internally. - const identity = await fetchIdentityWithSdk(sdk, identityId, retryOptions); - return identity !== undefined && identity !== null; - }, - retryOptions - ); + const identity = await fetchIdentity(identityId, network, retryOptions); + return identity !== undefined && identity !== null; } /** @@ -293,10 +293,11 @@ export async function transferUsername( const signer = new IdentitySigner(); signer.addKeyFromWif(privateKeyWif); + let broadcastError: unknown; try { // Deliberately not wrapped in withRetry: a retry after a broadcast that // actually landed fails the revision check and would report a false - // failure. The catch below re-reads the document instead. + // failure. The read-back below settles what really happened instead. await withPlatformOperationTimeout( sdk.documents.transfer({ document, @@ -308,29 +309,39 @@ export async function transferUsername( 'transferring username' ); } catch (error) { - const ownership = await readDomainOwnership(sdk, documentId).catch(() => undefined); - if (ownership?.ownerId === recipientId) { - // The transition landed; only the wait failed. - return { - success: true, - verifiedOwner: true, - recordsUpdated: ownership.recordIdentity === recipientId, - }; - } + broadcastError = error; + } - const message = - error && typeof error === 'object' && 'message' in error - ? String((error as { message: unknown }).message) - : String(error); - return { success: false, error: message }; + // This read is what turns a thrown timeout into a truthful answer, so it + // gets the same retry treatment as the other reads here. + const ownership = await withRetry( + () => readDomainOwnership(sdk, documentId), + retryOptions + ).catch(() => undefined); + + if (ownership?.ownerId === recipientId) { + // Landed — whether or not the wait phase threw. + return { + success: true, + verifiedOwner: true, + recordsUpdated: ownership.recordIdentity === recipientId, + }; + } + + if (broadcastError !== undefined) { + return { + success: false, + error: extractErrorMessage(broadcastError), + // If we could not read the document back we genuinely do not know + // whether the transfer landed, and the UI must not present a blind + // retry as safe. + unconfirmed: ownership === undefined, + }; } - const ownership = await readDomainOwnership(sdk, documentId).catch(() => undefined); - return { - success: true, - verifiedOwner: ownership?.ownerId === recipientId, - recordsUpdated: ownership?.recordIdentity === recipientId, - }; + // The SDK reported success but the document does not show the new owner + // yet — most likely read-your-writes lag rather than a failure. + return { success: true, verifiedOwner: false }; }, retryOptions ); diff --git a/src/types.ts b/src/types.ts index 887e254..2f0b307 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,6 +140,13 @@ export interface UsernameTransferOutcome { verifiedOwner?: boolean; /** Whether `records.identity` was rewritten, so the name resolves to the recipient */ recordsUpdated?: boolean; + /** + * Set on failure when the domain document could not be read back, so we + * genuinely do not know whether the transfer landed. Distinct from a + * confirmed failure, because a transfer that landed must not be retried + * blindly. + */ + unconfirmed?: boolean; } /** @@ -387,9 +394,11 @@ export interface BridgeState { xferCredentialSource?: UsernameTransferCredentialSource; /** Transfer: raw seed phrase input (kept so the field survives re-render) */ xferMnemonic?: string; - /** Transfer: identity discovery / lookup in progress */ - xferDiscovering?: boolean; - /** Transfer: progress message shown while discovering or loading usernames */ + /** + * Transfer: progress message shown while discovering or loading usernames. + * Its presence *is* the "discovery in progress" flag — a separate boolean + * would allow a discovering-without-a-message state that means nothing. + */ xferDiscoveryStatus?: string; /** Transfer: credential entry error message */ xferCredentialError?: string; diff --git a/src/ui/components.ts b/src/ui/components.ts index fe8005f..1d88bf2 100644 --- a/src/ui/components.ts +++ b/src/ui/components.ts @@ -1,7 +1,7 @@ import type { BridgeState, KeyType, KeyPurpose, SecurityLevel, NetworkHealth } from '../types.js'; import { getStepProgress, getStepDescription, ErrorCodes, ErrorCodeLabels } from './state.js'; import { shouldShowContestedWarning, countUsernameStatuses } from '../platform/dpns-utils.js'; -import { MIN_TRANSFER_PROTOCOL_VERSION, isProtocolVersionSupported } from '../platform/username-transfer-utils.js'; +import { MIN_TRANSFER_PROTOCOL_VERSION, isProtocolVersionBlocked } from '../platform/username-transfer-utils.js'; import { generateQRCodeDataUrl } from './qrcode.js'; import { privateKeyToWif } from '../utils/wif.js'; import { formatCreditsAsDash, formatCredits, MIN_WITHDRAWAL_CREDITS } from '../utils/credits.js'; @@ -2738,17 +2738,22 @@ function renderManageChooseActionStep(_state: BridgeState): HTMLElement { * discovers the identity) or an identity ID plus a private key. */ function renderXferCredentialsStep(state: BridgeState): HTMLElement { + const source = state.xferCredentialSource ?? 'seed'; + const div = document.createElement('div'); div.className = 'xfer-credentials-step'; - div.id = 'xfer-key-upload-dropzone'; + // Only a dropzone on the tab that actually shows the upload control — + // otherwise dropping a key file on the seed-phrase tab silently unlocks by a + // route the user cannot see. + if (source === 'key') { + div.id = 'xfer-key-upload-dropzone'; + } const headline = document.createElement('h2'); headline.className = 'xfer-headline'; headline.textContent = 'Unlock Your Identity'; div.appendChild(headline); - - const source = state.xferCredentialSource ?? 'seed'; - const isDiscovering = state.xferDiscovering === true; + const isDiscovering = state.xferDiscoveryStatus !== undefined; const tabs = document.createElement('div'); tabs.className = 'xfer-source-tabs'; @@ -2812,7 +2817,7 @@ function renderXferCredentialsStep(state: BridgeState): HTMLElement { if (isDiscovering) { const status = document.createElement('p'); status.className = 'identity-status loading'; - status.textContent = state.xferDiscoveryStatus || 'Looking up your identity...'; + status.textContent = state.xferDiscoveryStatus!; div.appendChild(status); } else if (state.xferCredentialError) { const error = document.createElement('p'); @@ -2870,7 +2875,7 @@ function renderXferSelectUsernameStep(state: BridgeState): HTMLElement { } const protocolVersion = state.xferProtocolVersion; - const protocolUnsupported = protocolVersion !== undefined && !isProtocolVersionSupported(protocolVersion); + const protocolUnsupported = isProtocolVersionBlocked(protocolVersion); if (protocolUnsupported) { const warning = document.createElement('div'); warning.className = 'warning-box'; @@ -2889,13 +2894,12 @@ function renderXferSelectUsernameStep(state: BridgeState): HTMLElement { list.innerHTML = `

This identity does not own any usernames.

`; } else { list.innerHTML = usernames - .map((username, index) => ` + .map((username) => `