diff --git a/e2e/deterministic.spec.ts b/e2e/deterministic.spec.ts index d8733e6..dc42051 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); @@ -103,7 +108,7 @@ test.describe('Deterministic UI E2E (mock mode)', () => { // Invalid identity ID is rejected await page.fill('#withdraw-identity-id-input', 'nope'); await page.locator('#withdraw-identity-id-input').press('Tab'); - await expect(page.getByText('Invalid identity ID format')).toBeVisible(); + await expect(page.getByText(/Invalid identity ID/)).toBeVisible(); // Valid identity advances to configure with the balance shown await page.fill('#withdraw-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/)).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/e2e/live.transfer.spec.ts b/e2e/live.transfer.spec.ts new file mode 100644 index 0000000..ba30274 --- /dev/null +++ b/e2e/live.transfer.spec.ts @@ -0,0 +1,128 @@ +import { expect, test } from '@playwright/test'; + +/** + * Live testnet exercise of the username transfer flow. + * + * Opt in with PW_LIVE_XFER=1. These tests broadcast real testnet state + * transitions and spend testnet credits, so they are skipped by default and are + * not part of `npm run test:e2e`. + * + * Each stage opts in separately so that enabling the suite never provisions or + * spends anything on its own: + * PW_XFER_PROVISION=1 create a destination identity + * PW_XFER_TOPUP= add credits to an identity + * PW_XFER_SEED / _RECIPIENT / _USERNAME the transfer itself + */ +const LIVE = process.env.PW_LIVE_XFER === '1'; +const LIVE_URL = '/?network=testnet'; + +test.describe('Live testnet username transfer', () => { + test.skip(!LIVE, 'set PW_LIVE_XFER=1 to run'); + + test('create a destination identity via the faucet', async ({ page }) => { + test.setTimeout(15 * 60_000); + // Opt in separately: this spends faucet funds, so enabling the suite alone + // must not provision anything. + test.skip(process.env.PW_XFER_PROVISION !== '1', 'set PW_XFER_PROVISION=1 to run'); + + await page.goto(LIVE_URL); + await page.click('#mode-create-btn'); + await page.click('#continue-btn'); + + await expect(page.locator('.deposit-headline')).toBeVisible({ timeout: 60_000 }); + + const depositAddress = await page.locator('.deposit-address, .address-value, code').first().innerText(); + console.log('DEPOSIT ADDRESS:', depositAddress.trim()); + + await page.click('#request-faucet-btn'); + + // Faucet solves a proof-of-work challenge, then the deposit must confirm + // and the identity register — all of which is slow on a live network. + await expect(page.getByText('Save your keys')).toBeVisible({ timeout: 13 * 60_000 }); + + const identityId = await page + .locator('.contract-id-section', { hasText: 'Your Identity ID' }) + .locator('.identity-id') + .innerText(); + // The recovery phrase is deliberately NOT logged — runner logs are not a + // place for seed phrases, even testnet ones. Read it off the completion + // screen by hand if you want a reusable destination identity. + console.log('DESTINATION IDENTITY:', identityId.trim()); + expect(identityId.trim().length).toBeGreaterThan(40); + }); + + test('top up the source identity via the faucet', async ({ page }) => { + test.setTimeout(15 * 60_000); + const identityId = process.env.PW_XFER_TOPUP; + test.skip(!identityId, 'set PW_XFER_TOPUP= to run'); + + await page.goto(LIVE_URL); + await page.click('#mode-topup-btn'); + await page.fill('#identity-id-input', identityId!); + await page.click('#continue-topup-btn'); + + await expect(page.locator('.deposit-headline')).toBeVisible({ timeout: 60_000 }); + await page.click('#request-faucet-btn'); + + await expect(page.getByText('Top-up complete!')).toBeVisible({ timeout: 13 * 60_000 }); + console.log('TOPPED UP:', identityId); + }); + + test('transfer a username to the destination identity', async ({ page }) => { + test.setTimeout(10 * 60_000); + + const seed = process.env.PW_XFER_SEED; + const recipient = process.env.PW_XFER_RECIPIENT; + const username = process.env.PW_XFER_USERNAME; + test.skip(!seed || !recipient || !username, 'needs PW_XFER_SEED / _RECIPIENT / _USERNAME'); + + page.on('console', (m) => console.log(`[browser:${m.type()}]`, m.text())); + + await page.goto(LIVE_URL); + await page.click('#mode-manage-btn'); + await page.click('#manage-action-transfer-btn'); + + // Seed phrase alone must be enough to find the identity and its signing key. + await page.fill('#xfer-mnemonic-input', seed!); + await page.click('#xfer-unlock-btn'); + + await expect(page.locator('.xfer-username-list')).toBeVisible({ timeout: 3 * 60_000 }); + const signingKey = await page.locator('.key-status.success').innerText(); + console.log('SIGNING KEY:', signingKey); + // Must skip the MASTER key at index 0 and pick an eligible one. + expect(signingKey).toMatch(/HIGH|CRITICAL/); + + const owned = await page.locator('.xfer-username-label').allInnerTexts(); + console.log('OWNED USERNAMES:', owned.join(', ')); + expect(owned).toContain(username!); + + await page + .locator('.xfer-username-option', { hasText: username! }) + .locator('.xfer-username-radio') + .click({ force: true }); + + // A well-formed but nonexistent recipient must be refused before anything + // is signed. (Not a string of "1"s — that decodes to 44 zero bytes and is + // rejected as malformed by the local check, which is a different path.) + const bogus = 'cEtJ6iEvm51o1zW56pKpytoE8bx8M1Z5bHNR3wBfwae'; + await page.fill('#xfer-recipient-input', bogus); + await page.locator('#xfer-recipient-input').blur(); + await expect(page.getByText(/No identity with this ID exists/)).toBeVisible({ timeout: 2 * 60_000 }); + await expect(page.locator('#xfer-select-continue-btn')).toBeDisabled(); + console.log('OK: nonexistent recipient refused pre-broadcast'); + + await page.fill('#xfer-recipient-input', recipient!); + await page.locator('#xfer-recipient-input').blur(); + await expect(page.getByText('Destination identity found')).toBeVisible({ timeout: 2 * 60_000 }); + + await page.click('#xfer-select-continue-btn'); + await expect(page.getByText('Confirm Transfer')).toBeVisible(); + await page.locator('#xfer-confirm-checkbox').click({ force: true }); + await page.click('#xfer-transfer-btn'); + + await expect(page.getByText(/Username Transferred!|Transfer Failed/)).toBeVisible({ timeout: 5 * 60_000 }); + const headline = await page.locator('.xfer-headline').innerText(); + console.log('RESULT:', headline); + await expect(page.getByText('Username Transferred!')).toBeVisible(); + }); +}); diff --git a/index.html b/index.html index 1665949..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,13 +1999,201 @@ word-break: break-word; } - .manage-action-buttons { + .manage-action-buttons, + .xfer-action-buttons { display: flex; flex-direction: column; gap: 12px; margin-top: 24px; } + /* ============================================================================ + Username Transfer Styles + ============================================================================ */ + + /* 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-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; + } + /* ============================================================================ Faucet Styles ============================================================================ */ @@ -2594,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/package.json b/package.json index a9f5ade..83c8fde 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:e2e": "playwright test e2e/deterministic.spec.ts", "test:e2e:headed": "playwright test e2e/deterministic.spec.ts --headed", "test:e2e:live": "PW_E2E_LIVE=1 playwright test e2e/live.testnet.spec.ts", + "test:e2e:live:transfer": "PW_LIVE_XFER=1 playwright test e2e/live.transfer.spec.ts", "test:e2e:install": "playwright install --with-deps chromium" }, "dependencies": { diff --git a/src/e2e-mock-constants.ts b/src/e2e-mock-constants.ts index c82363c..aca5d62 100644 --- a/src/e2e-mock-constants.ts +++ b/src/e2e-mock-constants.ts @@ -1,4 +1,8 @@ -export const E2E_MOCK_IDENTITY_ID = '11111111111111111111111111111111111111111111'; +/** + * Structurally valid identity IDs: Base58 decoding to exactly 32 bytes, so + * mock-mode runs exercise the same identifier validation as a real network. + */ +export const E2E_MOCK_IDENTITY_ID = '4ufjwRfdhMM87uBaGmTvesgLm6k2Q2r7SVyZdTUzFebA'; export const E2E_MOCK_DPNS_WIF = 'cMockDpnsPrivateKeyWif'; export const E2E_MOCK_MANAGE_WIF = 'cMockManagePrivateKeyWif'; export const E2E_MOCK_WITHDRAW_WIF = 'cMockWithdrawPrivateKeyWif'; @@ -6,3 +10,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 = '4uvqP8FNZCyqYgPe3GUxP18RWdiLqxne1h4d4byFhdqK'; diff --git a/src/main.ts b/src/main.ts index 601a400..fed933a 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,21 @@ import { loadIslockModule, loadPlatformClientModule, loadPlatformModule, + loadUsernameTransferModule, warmDashModules, } from './platform/loaders.js'; +import { + deriveCandidateKeys, + explainKeyIneligibility, + isProtocolVersionBlocked, + isValidIdentityId, + isValidMnemonic, + isWellFormedIdentityId, + normalizeMnemonic, + selectTransferSigningKey, + MIN_TRANSFER_PROTOCOL_VERSION, + type SigningKeySelection, +} from './platform/username-transfer-utils.js'; import { loadSdkModule } from './platform/sdkModule.js'; import type { BridgeState, @@ -164,6 +197,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 +428,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 +1191,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 +1509,153 @@ 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.xferDiscoveryStatus) { + 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 + // Deliberately no 'input' listener: this app re-renders the whole tree on any + // state change, so updating state per keystroke tears the field out from + // under the user (and under any automation) mid-edit. Verify on blur/paste + // instead, matching the identity inputs in the DPNS and manage flows. + const xferRecipientInput = container.querySelector('#xfer-recipient-input'); + if (xferRecipientInput) { + const verifyRecipient = () => { + const recipientId = (xferRecipientInput as HTMLInputElement).value.trim(); + if (!recipientId || state.xferRecipientChecking) return; + if (recipientId === state.xferRecipientId && state.xferRecipientVerified !== undefined) 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', () => { + const typed = ( + container.querySelector('#xfer-recipient-input')?.value ?? '' + ).trim(); + + // The field may have been edited after the check without ever blurring — + // never carry a stale verification onto a different recipient. An emptied + // field counts as a change too, so clearing it cannot silently advance + // with the previously verified ID. + if (typed !== state.xferRecipientId) { + startXferRecipientCheck(typed); + return; + } + + 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 +1746,15 @@ 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 guardXferUnlock(() => startXferUnlockFromKey(result.identityId, result.privateKeyWif)); + }); + // Contract key upload — show loading, fetch identity + balance, validate key, update once wireKeyUpload('contract-key-upload', async (result) => { updateState({ @@ -2022,9 +2232,7 @@ function setupEventListeners(container: HTMLElement) { * Validate identity ID format (Base58, ~44 characters) */ function validateIdentityId(id?: string): boolean { - if (!id) return false; - // Dash identity IDs are Base58 encoded, typically 43-44 characters - return /^[1-9A-HJ-NP-Za-km-z]{43,44}$/.test(id); + return !!id && isValidIdentityId(id); } /** @@ -3191,6 +3399,317 @@ 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 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; + } + + 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); + await applyXferKeySelection( + discovered.identityId, + selectTransferSigningKey(candidates, keys, state.network), + 'That seed phrase does not control any active key on the identity it points to.' + ); +} + +/** + * 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 unlockXferMock( + identityId, + privateKeyWif, + privateKeyWif === E2E_MOCK_DPNS_WIF + ? undefined + : 'Mock mode: use the configured test private key' + ); + return; + } + + const keys = await getIdentityPublicKeys(identityId, state.network); + await applyXferKeySelection( + identityId, + selectTransferSigningKey([{ privateKeyWif }], keys, state.network), + '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 (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; + } + + updateState(setXferDiscoveryStatus(state, 'Loading your usernames...')); + + const { usernames, protocolVersion } = await loadTransferContext(identityId); + + updateState(setXferIdentityUnlocked(state, { + identityId, + privateKeyWif: selection.candidate.privateKeyWif, + keyId: selection.keyId, + securityLevel: selection.securityLevel, + usernames, + protocolVersion, + })); +} + +/** + * Deterministic stand-in for identity discovery in Playwright mock mode. + */ +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 { + await run(); + } catch (error) { + console.error('Username transfer unlock error:', error); + updateState(setXferCredentialError(state, toError(error).message)); + } +} + +/** + * 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; + } + + const identityId = ( + document.querySelector('#xfer-identity-id-input')?.value ?? '' + ).trim(); + const privateKeyWif = ( + document.querySelector('#xfer-private-key-input')?.value ?? '' + ).trim(); + + await startXferUnlockFromKey(identityId, privateKeyWif); + }); +} + +/** + * 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) { + // Record what is being checked before validating: it invalidates any earlier + // verification, and keeps the typed value through the re-render (the field is + // uncontrolled between blurs). + updateState(setXferRecipientId(state, recipientId)); + + if (!recipientId) { + updateState(setXferRecipientError(state, 'Enter the identity ID to transfer the username to')); + return; + } + + if (!isWellFormedIdentityId(recipientId)) { + updateState(setXferRecipientError(state, 'Invalid identity ID (expected a Base58 string decoding to 32 bytes)')); + 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 (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.`, + })); + 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..16f0629 --- /dev/null +++ b/src/platform/username-transfer-utils.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; + +import { + MIN_TRANSFER_PROTOCOL_VERSION, + deriveCandidateKeys, + explainKeyIneligibility, + isEligibleTransferKey, + isProtocolVersionBlocked, + isProtocolVersionSupported, + isValidIdentityId, + isValidMnemonic, + isWellFormedIdentityId, + 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('well-formed identity ID', () => { + // A real testnet identity: Base58 that decodes to exactly 32 bytes. + const REAL = '79KWAGD8C336Snx8u2C3f2U4XcyE1rdfhPE2qbocjkvg'; + + it('accepts an id that decodes to 32 bytes', () => { + expect(isWellFormedIdentityId(REAL)).toBe(true); + expect(isWellFormedIdentityId(` ${REAL} `)).toBe(true); + }); + + it('rejects Base58 of the right length that decodes to the wrong size', () => { + // "1" is Base58's zero digit, so 44 of them decode to 44 zero bytes, not 32. + // The loose charset check passes it; the SDK would reject it at the network. + const zeros = '1'.repeat(44); + expect(isValidIdentityId(zeros)).toBe(true); + expect(isWellFormedIdentityId(zeros)).toBe(false); + }); + + it('rejects malformed input', () => { + expect(isWellFormedIdentityId('')).toBe(false); + expect(isWellFormedIdentityId('not-base58-0OIl')).toBe(false); + expect(isWellFormedIdentityId(REAL.slice(0, 20))).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); + }); + + 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', () => { + 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' }); + }); + + // 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 new file mode 100644 index 0000000..a7d72bf --- /dev/null +++ b/src/platform/username-transfer-utils.ts @@ -0,0 +1,230 @@ +import { validateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english.js'; +import { base58 } from '@scure/base'; +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; +} + +/** 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: T; + 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()); +} + +/** + * Stricter check: the string is Base58 *and* decodes to exactly 32 bytes. + * + * The length/charset test alone is not sufficient — "1" is Base58's zero digit, + * so a 44-character string of them decodes to 44 zero bytes and the SDK rejects + * it as `Invalid identity ID: byte length not 32 bytes`. Used for the transfer + * recipient, where sending to a bad ID would orphan the username, so it is + * worth catching locally instead of via a network round trip. + */ +export function isWellFormedIdentityId(identityId: string): boolean { + const trimmed = identityId.trim(); + if (!isValidIdentityId(trimmed)) return false; + try { + return base58.decode(trimmed).length === 32; + } catch { + return false; + } +} + +/** + * 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; +} + +/** + * 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. + * + * 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: T[], + 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..fad0498 --- /dev/null +++ b/src/platform/username-transfer.ts @@ -0,0 +1,348 @@ +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, + 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'; + +/** + * DPNS is a system data contract with the same ID on every network. + * Source: packages/dpns-contract/lib/systemIds.js in dashpay/platform. + */ +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; + +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 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; 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() }; + } catch (error) { + lastError = error; + } + + try { + const identities = await sdk.identities.byNonUniquePublicKeyHash(hashHex); + answered = true; + if (identities.length > 0) return { identityId: identities[0].id.toString() }; + } catch (error) { + lastError = error; + } + + // 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 }; +} + +/** + * 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 }; + } + if (result.error === undefined) { + anyAnswered = true; + } else { + lastError = result.error; + } + } + + 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 { + const identity = await fetchIdentity(identityId, network, retryOptions); + return identity !== undefined && identity !== null; +} + +/** + * 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); + + 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 read-back below settles what really happened instead. + await withPlatformOperationTimeout( + sdk.documents.transfer({ + document, + recipientId: Identifier.fromBase58(recipientId), + identityKey, + signer, + settings: PLATFORM_PUT_SETTINGS, + }), + 'transferring username' + ); + } catch (error) { + broadcastError = error; + } + + // 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, + }; + } + + // 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 c4b52da..2f0b307 100644 --- a/src/types.ts +++ b/src/types.ts @@ -124,6 +124,31 @@ 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; + /** + * 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; +} + /** * Configuration for a new key to add during identity update */ @@ -174,10 +199,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 +389,42 @@ 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: 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; + /** 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..1d88bf2 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, 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'; @@ -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,471 @@ 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 source = state.xferCredentialSource ?? 'seed'; + + const div = document.createElement('div'); + div.className = 'xfer-credentials-step'; + // 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 isDiscovering = state.xferDiscoveryStatus !== undefined; + + 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!; + 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 = isProtocolVersionBlocked(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) => ` + + `) + .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 = ` +

${result?.unconfirmed + ? 'The transfer failed, but we could not read the username back to confirm that.' + : 'The username could not be transferred.'}

+

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

+ `; + div.appendChild(errorMsg); + + if (result?.unconfirmed) { + const note = document.createElement('div'); + note.className = 'warning-box'; + note.innerHTML = ` +

Check before retrying.

+

The transfer may still have gone through. Look the username up on the explorer first — if it already belongs to the destination, do not transfer again.

+ `; + div.appendChild(note); + } + } + + 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..7195961 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -68,6 +68,26 @@ export { 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, diff --git a/src/ui/state.ts b/src/ui/state.ts index 7c17aa2..7b7ebdf 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', + 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, }; } } @@ -249,7 +255,10 @@ function clearModeSensitiveFields(state: BridgeState, mode: BridgeMode): BridgeS // setMode('withdraw') re-clears the withdraw block itself, so these can be // dropped unconditionally here. return { - ...state, + // Drop the transfer flow's credentials on any mode switch. `xferMnemonic` + // is the user's real wallet seed, so it must not outlive the flow that + // asked for it. + ...clearUsernameTransferFields(state), recipientPlatformAddress: mode === 'send_to_address' ? state.recipientPlatformAddress : undefined, withdrawPrivateKeyWif: undefined, withdrawSigningKeyInfo: undefined, @@ -668,10 +677,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 +736,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 +784,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 +1307,288 @@ 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, + 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', + targetIdentityId: undefined, + }; +} + +/** + * 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, + 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, + 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, + 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 // ============================================================================