diff --git a/e2e/deterministic.spec.ts b/e2e/deterministic.spec.ts index 0b145fd..d8733e6 100644 --- a/e2e/deterministic.spec.ts +++ b/e2e/deterministic.spec.ts @@ -3,6 +3,8 @@ import { E2E_MOCK_DPNS_WIF, E2E_MOCK_IDENTITY_ID, E2E_MOCK_MANAGE_WIF, + E2E_MOCK_WITHDRAW_WIF, + E2E_MOCK_WITHDRAW_ADDRESS, } from '../src/e2e-mock-constants'; const MOCK_QUERY = '/?network=testnet&e2e=mock'; @@ -92,6 +94,65 @@ test.describe('Deterministic UI E2E (mock mode)', () => { await expect(page.getByText('Update Complete!')).toBeVisible(); }); + test('withdraw flow validates inputs and completes with status tracking', async ({ page }) => { + await page.goto(MOCK_QUERY); + + await page.click('#mode-withdraw-btn'); + await expect(page.locator('#withdraw-identity-id-input')).toBeVisible(); + + // 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(); + + // Valid identity advances to configure with the balance shown + await page.fill('#withdraw-identity-id-input', E2E_MOCK_IDENTITY_ID); + await page.locator('#withdraw-identity-id-input').press('Tab'); + await expect(page.getByText('Configure Withdrawal')).toBeVisible(); + await expect(page.locator('.withdraw-balance')).toContainText('0.25 DASH'); + + // Wrong WIF is rejected, the mock WIF validates as a TRANSFER key + await page.fill('#withdraw-private-key-input', 'bad-key'); + await page.locator('#withdraw-private-key-input').press('Tab'); + await expect(page.getByText('Mock mode: use the configured test private key')).toBeVisible(); + + await page.fill('#withdraw-private-key-input', E2E_MOCK_WITHDRAW_WIF); + await page.locator('#withdraw-private-key-input').press('Tab'); + await expect(page.getByText('Key matches key #3 (TRANSFER / CRITICAL)')).toBeVisible(); + + // Bad address is rejected (real validation runs even in mock mode) + await page.fill('#withdraw-address-input', 'not-an-address'); + await page.locator('#withdraw-address-input').press('Tab'); + await expect(page.locator('#withdraw-address-error')).toBeVisible(); + + await page.fill('#withdraw-address-input', E2E_MOCK_WITHDRAW_ADDRESS); + await page.locator('#withdraw-address-input').press('Tab'); + await expect(page.locator('#withdraw-address-error')).toHaveCount(0); + + // Amount below the minimum, above the balance, then valid + await page.fill('#withdraw-amount-input', '0.000001'); + await page.locator('#withdraw-amount-input').press('Tab'); + await expect(page.locator('#withdraw-amount-error')).toContainText('Minimum withdrawal'); + + await page.fill('#withdraw-amount-input', '1'); + await page.locator('#withdraw-amount-input').press('Tab'); + await expect(page.locator('#withdraw-amount-error')).toContainText('exceeds your balance'); + await expect(page.locator('#withdraw-submit-btn')).toBeDisabled(); + + await page.fill('#withdraw-amount-input', '0.1'); + await page.locator('#withdraw-amount-input').press('Tab'); + await expect(page.locator('#withdraw-amount-credits')).toContainText('10,000,000,000 credits'); + await expect(page.locator('#withdraw-submit-btn')).toBeEnabled(); + + // Submit walks the mock status sequence to completion + await page.click('#withdraw-submit-btn'); + await expect(page.getByText('Withdrawal Complete!')).toBeVisible(); + const details = page.locator('.withdraw-success-details'); + await expect(details).toContainText('Amount: 0.1 DASH'); + await expect(details).toContainText(E2E_MOCK_WITHDRAW_ADDRESS); + await expect(details).toContainText('Remaining balance: 0.15 DASH'); + }); + test('standalone DPNS flow validates identity + key and completes registration', async ({ page }) => { await page.goto(MOCK_QUERY); diff --git a/index.html b/index.html index 79f019d..1665949 100644 --- a/index.html +++ b/index.html @@ -2772,6 +2772,135 @@ padding: 8px 10px; } } + + /* ============================================================================ + Withdraw (Asset Unlock) Styles + ============================================================================ */ + + .withdraw-identity-summary { + background: rgba(0, 0, 0, 0.2); + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 20px; + text-align: left; + } + + .withdraw-identity-summary .withdraw-identity-id code { + font-size: 0.8rem; + color: #b0b0b0; + word-break: break-all; + } + + .withdraw-identity-summary .withdraw-balance { + color: #fff; + margin-top: 6px; + } + + .withdraw-amount-row { + display: flex; + gap: 8px; + align-items: stretch; + } + + .withdraw-amount-row .manage-input { + flex: 1; + } + + .withdraw-fee-note { + background: rgba(0, 141, 228, 0.08); + border: 1px solid rgba(0, 141, 228, 0.2); + border-radius: 10px; + padding: 10px 14px; + margin-bottom: 16px; + text-align: left; + } + + .withdraw-status-timeline { + display: flex; + justify-content: center; + gap: 18px; + margin: 24px 0; + flex-wrap: wrap; + } + + .withdraw-status-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + color: #9a9aa5; /* keeps small pending labels above WCAG AA 4.5:1 on the dark background */ + font-size: 0.8rem; + } + + .withdraw-status-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + } + + .withdraw-status-item.done .withdraw-status-dot { + background: #4caf50; + } + + .withdraw-status-item.done { + color: #4caf50; + } + + .withdraw-status-item.active .withdraw-status-dot { + background: #008de4; + box-shadow: 0 0 8px rgba(0, 141, 228, 0.7); + } + + .withdraw-status-item.active { + color: #fff; + } + + .withdraw-poll-warning { + color: #ffb74d; + font-size: 0.85rem; + margin: 12px 0; + } + + .withdraw-success-details { + background: rgba(0, 0, 0, 0.2); + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 16px; + text-align: left; + color: #ddd; + } + + .withdraw-success-details code { + word-break: break-all; + font-size: 0.85rem; + } + + .withdraw-success-msg { + color: #4caf50; + margin-bottom: 16px; + } + + .withdraw-expired-msg, + .withdraw-pending-msg { + color: #ffb74d; + margin-bottom: 16px; + } + + .withdraw-error-msg { + background: rgba(244, 67, 54, 0.1); + border: 1px solid rgba(244, 67, 54, 0.3); + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 16px; + text-align: left; + } + + .withdraw-error-msg .error-detail { + color: #ef9a9a; + font-size: 0.85rem; + word-break: break-word; + } diff --git a/src/config.ts b/src/config.ts index a813fe1..dd4cac7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,8 @@ export interface NetworkConfig { name: string; insightApiUrl: string; addressPrefix: number; + /** Base58 version byte for P2SH addresses (dashcore SCRIPT_ADDRESS: 16 mainnet, 19 testnet/devnet). */ + p2shPrefix: number; wifPrefix: number; minFee: number; dustThreshold: number; @@ -36,6 +38,7 @@ export const TESTNET: NetworkConfig = { name: 'testnet', insightApiUrl: 'https://insight.testnet.networks.dash.org/insight-api', addressPrefix: 140, + p2shPrefix: 19, wifPrefix: 239, minFee: 1000, dustThreshold: 546, @@ -49,6 +52,7 @@ export const MAINNET: NetworkConfig = { name: 'mainnet', insightApiUrl: 'https://insight.dash.org/insight-api', addressPrefix: 76, + p2shPrefix: 16, wifPrefix: 204, minFee: 1000, dustThreshold: 546, @@ -61,6 +65,7 @@ export const DEVNET_PALOMA: NetworkConfig = { name: 'devnet-paloma', insightApiUrl: 'https://insight.paloma.networks.dash.org/insight-api', addressPrefix: 140, + p2shPrefix: 19, wifPrefix: 239, minFee: 1000, dustThreshold: 546, @@ -108,7 +113,7 @@ function loadCustomDevnets(): NetworkConfig[] { if (!stored) return []; const parsed = JSON.parse(stored); if (!Array.isArray(parsed)) return []; - return parsed.filter( + const valid = parsed.filter( (c): c is NetworkConfig => c && typeof c.name === 'string' && @@ -126,6 +131,8 @@ function loadCustomDevnets(): NetworkConfig[] { (c.useTrustedContext === undefined || typeof c.useTrustedContext === 'boolean') && (c.trustedQuorumUrl === undefined || typeof c.trustedQuorumUrl === 'string') ); + // Devnets saved before p2shPrefix existed default to the testnet/devnet value. + return valid.map((c) => (typeof c.p2shPrefix === 'number' ? c : { ...c, p2shPrefix: 19 })); } catch { return []; } @@ -164,6 +171,7 @@ export function createCustomDevnetConfig(params: { name: params.name, insightApiUrl: params.insightApiUrl, addressPrefix: 140, + p2shPrefix: 19, wifPrefix: 239, minFee: 1000, dustThreshold: 546, diff --git a/src/crypto/address.test.ts b/src/crypto/address.test.ts new file mode 100644 index 0000000..64bf0c0 --- /dev/null +++ b/src/crypto/address.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; + +import { validateCoreAddress } from './address.js'; +import { base58CheckEncode } from '../utils/base58.js'; +import { TESTNET, MAINNET } from '../config.js'; + +function makeAddress(prefix: number, fill = 0x42): string { + const payload = new Uint8Array(21); + payload[0] = prefix; + payload.fill(fill, 1); + return base58CheckEncode(payload); +} + +describe('validateCoreAddress', () => { + it('accepts a testnet P2PKH address', () => { + const result = validateCoreAddress(makeAddress(TESTNET.addressPrefix), TESTNET); + expect(result).toEqual({ valid: true, type: 'p2pkh' }); + }); + + it('accepts a testnet P2SH address', () => { + const result = validateCoreAddress(makeAddress(TESTNET.p2shPrefix), TESTNET); + expect(result).toEqual({ valid: true, type: 'p2sh' }); + }); + + it('accepts mainnet P2PKH and P2SH addresses on mainnet', () => { + expect(validateCoreAddress(makeAddress(MAINNET.addressPrefix), MAINNET).valid).toBe(true); + expect(validateCoreAddress(makeAddress(MAINNET.p2shPrefix), MAINNET).valid).toBe(true); + }); + + it('rejects a mainnet address on testnet with a wrong-network error', () => { + const result = validateCoreAddress(makeAddress(MAINNET.addressPrefix), TESTNET); + expect(result.valid).toBe(false); + expect(result.error).toContain('wrong network'); + }); + + it('rejects a bech32m platform address with a targeted error', () => { + const result = validateCoreAddress( + 'tdash1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', + TESTNET + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('Platform address'); + }); + + it('rejects garbage and bad checksums', () => { + expect(validateCoreAddress('not-an-address', TESTNET).valid).toBe(false); + expect(validateCoreAddress('yYyYyYyYyYyYyYyYyYyYyYyYyYyYyYyYyY', TESTNET).valid).toBe(false); + expect(validateCoreAddress('', TESTNET).valid).toBe(false); + }); + + it('rejects payloads that are not 21 bytes', () => { + const short = base58CheckEncode(new Uint8Array([TESTNET.addressPrefix, 1, 2, 3])); + const result = validateCoreAddress(short, TESTNET); + expect(result.valid).toBe(false); + expect(result.error).toContain('length'); + }); + + it('trims surrounding whitespace', () => { + const addr = makeAddress(TESTNET.addressPrefix); + expect(validateCoreAddress(` ${addr} `, TESTNET).valid).toBe(true); + }); +}); diff --git a/src/crypto/address.ts b/src/crypto/address.ts index 9aaa43c..8dacf26 100644 --- a/src/crypto/address.ts +++ b/src/crypto/address.ts @@ -1,8 +1,55 @@ import { hash160 } from './hash.js'; -import { base58CheckEncode } from '../utils/base58.js'; +import { base58CheckEncode, base58CheckDecode } from '../utils/base58.js'; import { concatBytes } from '../utils/hex.js'; import type { NetworkConfig } from '../config.js'; +export interface CoreAddressValidation { + valid: boolean; + type?: 'p2pkh' | 'p2sh'; + error?: string; +} + +/** + * Validate a Dash Core (L1) base58check address for the given network. + * Accepts P2PKH and P2SH addresses only — the two script types Platform + * allows as credit-withdrawal destinations. + */ +export function validateCoreAddress( + address: string, + network: NetworkConfig +): CoreAddressValidation { + const trimmed = address.trim(); + if (trimmed.length === 0) { + return { valid: false, error: 'Enter a Dash address' }; + } + if (trimmed.toLowerCase().startsWith('dash1') || trimmed.toLowerCase().startsWith('tdash1')) { + return { + valid: false, + error: 'This looks like a Platform address. Enter a Dash Core address (starts with X on mainnet, y on testnet).', + }; + } + let payload: Uint8Array; + try { + payload = base58CheckDecode(trimmed); + } catch { + return { valid: false, error: 'Invalid address (bad characters or checksum)' }; + } + if (payload.length !== 21) { + return { valid: false, error: 'Invalid address (unexpected length)' }; + } + const version = payload[0]; + if (version === network.addressPrefix) { + return { valid: true, type: 'p2pkh' }; + } + if (version === network.p2shPrefix) { + return { valid: true, type: 'p2sh' }; + } + return { + valid: false, + error: `This address is not valid for ${network.name} (wrong network prefix)`, + }; +} + /** * Generate P2PKH address from public key */ diff --git a/src/crypto/index.ts b/src/crypto/index.ts index aef7d69..5906757 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -8,7 +8,7 @@ export { updateKeyType, generateDefaultIdentityKeys, } from './keys.js'; -export { publicKeyToAddress, publicKeyToHash } from './address.js'; +export { publicKeyToAddress, publicKeyToHash, validateCoreAddress } from './address.js'; export { signHash, createP2PKHScriptSig, diff --git a/src/e2e-mock-constants.ts b/src/e2e-mock-constants.ts index b2abdee..c82363c 100644 --- a/src/e2e-mock-constants.ts +++ b/src/e2e-mock-constants.ts @@ -1,3 +1,8 @@ export const E2E_MOCK_IDENTITY_ID = '11111111111111111111111111111111111111111111'; export const E2E_MOCK_DPNS_WIF = 'cMockDpnsPrivateKeyWif'; export const E2E_MOCK_MANAGE_WIF = 'cMockManagePrivateKeyWif'; +export const E2E_MOCK_WITHDRAW_WIF = 'cMockWithdrawPrivateKeyWif'; +/** A structurally valid testnet P2PKH address (prefix 140) so address validation runs for real in mock mode. */ +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; diff --git a/src/main.ts b/src/main.ts index 39452d2..601a400 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,12 @@ import { getNetwork, initNetworkRegistry, createCustomDevnetConfig, saveCustomDevnet, isReservedNetworkName, MAINNET, TESTNET } from './config.js'; -import { publicKeyToAddress, signTransaction, generateKeyPair } from './crypto/index.js'; +import { publicKeyToAddress, signTransaction, generateKeyPair, validateCoreAddress } from './crypto/index.js'; +import { + formatCreditsAsDash, + MIN_WITHDRAWAL_CREDITS, + maxWithdrawableCredits, + validateWithdrawalAmount, +} from './utils/credits.js'; +import { extractErrorMessage } from './utils/errors.js'; import { deriveAssetLockKeyPair } from './crypto/hd.js'; import { createAssetLockTransaction, serializeTransaction, calculateTxId } from './transaction/index.js'; import { InsightClient } from './api/insight.js'; @@ -89,6 +96,24 @@ import { setContractComplete, setModeContractFromIdentity, setContractStartBridge, + // Withdraw state functions + setWithdrawIdentityFetching, + setWithdrawIdentityFetched, + setWithdrawIdentityFetchError, + setWithdrawKeyValidated, + setWithdrawKeyValidationError, + clearWithdrawKeyValidation, + setWithdrawAddress, + setWithdrawAmount, + setWithdrawAmountError, + setWithdrawSubmitting, + setWithdrawSubmitted, + setWithdrawSubmitError, + setWithdrawStatusUpdate, + setWithdrawStatusNote, + setWithdrawTrackingTimeout, + setWithdrawBackToEntry, + setWithdrawRetry, // Faucet state functions setFaucetSolvingPow, setFaucetRequesting, @@ -137,6 +162,8 @@ import { E2E_MOCK_DPNS_WIF, E2E_MOCK_IDENTITY_ID, E2E_MOCK_MANAGE_WIF, + E2E_MOCK_WITHDRAW_WIF, + E2E_MOCK_WITHDRAW_BALANCE, } from './e2e-mock-constants.js'; // Global state @@ -469,6 +496,9 @@ function init() { if (contractParam) { void hydrateContractDeepLink(contractParam); } + } else if (modeParam === 'withdraw') { + // Deep-link: ?mode=withdraw opens credit withdrawal mode + state = setMode(state, 'withdraw'); } // Render UI @@ -1433,6 +1463,7 @@ function setupEventListeners(container: HTMLElement) { function wireKeyUpload( inputId: string, onParsed: (result: { identityId: string; privateKeyWif: string }) => void, + preferredPurpose?: string, ) { const fileInput = container.querySelector(`#${inputId}`); const dropzone = container.querySelector(`#${inputId}-dropzone`); @@ -1444,7 +1475,7 @@ function setupEventListeners(container: HTMLElement) { reader.onload = () => { try { const json = JSON.parse(reader.result as string); - const parsed = parseKeyBackup(json); + const parsed = parseKeyBackup(json, preferredPurpose); if (!parsed) { if (statusEl) { statusEl.textContent = 'No identity or keys found in file'; statusEl.className = 'key-upload-status error'; } return; @@ -1542,6 +1573,245 @@ function setupEventListeners(container: HTMLElement) { } }); + // ============================================================================ + // Withdraw Event Listeners + // ============================================================================ + + // Withdraw key upload — prefer the TRANSFER key from the backup, fetch + // identity + balance, validate, and land on the configure step in one shot + wireKeyUpload('withdraw-key-upload', async (result) => { + updateState(setWithdrawIdentityFetching(state, result.identityId)); + try { + const [keys, identityState] = await Promise.all([ + getIdentityPublicKeys(result.identityId, state.network), + getIdentityBalanceAndRevision(result.identityId, state.network), + ]); + let finalState = setWithdrawIdentityFetched(state, keys, BigInt(identityState.balance)); + const match = findMatchingKeyIndex(result.privateKeyWif, keys, state.network); + if (match && match.purpose === 3 /* TRANSFER */) { + finalState = setWithdrawKeyValidated(finalState, match.keyId, match.purpose, match.securityLevel, result.privateKeyWif); + } else if (match) { + finalState = { + ...finalState, + withdrawKeyValidationError: `The backup's ${getPurposeName(match.purpose)} key cannot sign withdrawals — a TRANSFER key is required.`, + }; + } else { + finalState = { + ...finalState, + withdrawKeyValidationError: 'No key in the backup matches this identity', + }; + } + updateState(finalState); + } catch (error) { + updateState(setWithdrawIdentityFetchError(state, error instanceof Error ? error.message : String(error))); + } + }, 'TRANSFER'); + + // Withdraw mode button (init page) + const modeWithdrawBtn = container.querySelector('#mode-withdraw-btn'); + attachDashWarmup(modeWithdrawBtn); + if (modeWithdrawBtn) { + modeWithdrawBtn.addEventListener('click', () => { + updateState(setMode(state, 'withdraw')); + }); + } + + // Withdraw back button (enter identity / configure steps) + const withdrawBackBtn = container.querySelector('#withdraw-back-btn'); + if (withdrawBackBtn) { + withdrawBackBtn.addEventListener('click', () => { + switch (state.step) { + case 'withdraw_configure': + updateState(setWithdrawBackToEntry(state)); + break; + default: + updateState(setStep(state, 'init')); + } + }); + } + + // Wire the standard "validate on blur, and shortly after paste" input pattern. + const onBlurOrPaste = ( + selector: string, + handler: (input: HTMLInputElement) => void + ): HTMLInputElement | null => { + const input = container.querySelector(selector); + if (!input) return null; + const run = () => handler(input); + input.addEventListener('blur', run); + input.addEventListener('paste', () => setTimeout(run, 50)); + return input; + }; + + // Withdraw identity ID input - fetch identity + balance + onBlurOrPaste('#withdraw-identity-id-input', async (input) => { + const identityId = input.value.trim(); + + if (!identityId || state.withdrawIdentityFetching) { + return; + } + + // Skip if this identity is already fetched — refetching would clear a + // validated TRANSFER key and repeat two network calls for no change. + if (state.targetIdentityId === identityId && state.withdrawIdentityKeys) { + return; + } + + if (!validateIdentityId(identityId)) { + updateState(setWithdrawIdentityFetchError(state, 'Invalid identity ID format (expected 44 character Base58 string)')); + return; + } + + updateState(setWithdrawIdentityFetching(state, identityId)); + + try { + if (isE2EMockMode()) { + await delay(30); + updateState(setWithdrawIdentityFetched(state, createE2EMockIdentityKeys(), BigInt(E2E_MOCK_WITHDRAW_BALANCE))); + return; + } + + const [keys, identityState] = await Promise.all([ + getIdentityPublicKeys(identityId, state.network), + getIdentityBalanceAndRevision(identityId, state.network), + ]); + updateState(setWithdrawIdentityFetched(state, keys, BigInt(identityState.balance))); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch identity'; + updateState(setWithdrawIdentityFetchError(state, message)); + } + }); + + // Withdraw private key input - must match a TRANSFER key on the identity + onBlurOrPaste('#withdraw-private-key-input', (input) => { + const privateKeyWif = input.value.trim(); + + // The WIF is deliberately never rendered back into the DOM, so the field + // is blank after every re-render. An empty blur is a no-op (a validated + // key stays validated); entering a new key replaces the old one. + if (!privateKeyWif) { + if (!state.withdrawSigningKeyInfo && state.withdrawKeyValidationError) { + updateState(clearWithdrawKeyValidation(state)); + } + return; + } + + if (!state.withdrawIdentityKeys || state.withdrawIdentityKeys.length === 0) { + updateState(setWithdrawKeyValidationError(state, 'Please enter an identity ID first')); + return; + } + + if (isE2EMockMode()) { + if (privateKeyWif === E2E_MOCK_WITHDRAW_WIF) { + updateState(setWithdrawKeyValidated(state, 3, 3, 1, privateKeyWif)); + } else { + updateState(setWithdrawKeyValidationError(state, 'Mock mode: use the configured test private key')); + } + return; + } + + const match = findMatchingKeyIndex(privateKeyWif, state.withdrawIdentityKeys, state.network); + + if (!match) { + updateState(setWithdrawKeyValidationError(state, 'This key does not match any key registered with this identity')); + return; + } + + // Withdrawals to an address must be signed with a TRANSFER key (purpose 3). + // OWNER keys are rejected by consensus when a destination address is set. + if (match.purpose !== 3) { + const purposeName = getPurposeName(match.purpose); + updateState(setWithdrawKeyValidationError( + state, + `This key has ${purposeName} purpose. Withdrawals must be signed with a TRANSFER key (identities created by this bridge have one at HD index 3).` + )); + return; + } + + updateState(setWithdrawKeyValidated(state, match.keyId, match.purpose, match.securityLevel, privateKeyWif)); + }); + + // Withdraw destination address input + onBlurOrPaste('#withdraw-address-input', (input) => { + const address = input.value.trim(); + + if (!address) { + updateState(setWithdrawAddress(state, '')); + return; + } + + const result = validateCoreAddress(address, getNetwork(state.network)); + updateState(setWithdrawAddress(state, address, result.valid ? undefined : result.error || 'Invalid address')); + }); + + // Withdraw amount input + const withdrawAmountInput = onBlurOrPaste('#withdraw-amount-input', (input) => { + const raw = input.value.trim(); + + if (!raw) { + updateState(setWithdrawAmountError(state, '', '')); + return; + } + + const result = validateWithdrawalAmount(raw, state.withdrawBalance ?? 0n); + if ('error' in result) { + updateState(setWithdrawAmountError(state, raw, result.error)); + } else { + updateState(setWithdrawAmount(state, result.credits, raw)); + } + }); + + // Max button: fill with balance minus a reserve for the Platform transition fee + if (withdrawAmountInput) { + const withdrawMaxBtn = container.querySelector('#withdraw-max-btn'); + if (withdrawMaxBtn) { + withdrawMaxBtn.addEventListener('click', () => { + const max = maxWithdrawableCredits(state.withdrawBalance ?? 0n); + if (max < MIN_WITHDRAWAL_CREDITS) { + updateState(setWithdrawAmountError(state, '', `Balance is too small to withdraw (minimum ${formatCreditsAsDash(MIN_WITHDRAWAL_CREDITS)} DASH plus fees)`)); + return; + } + const raw = formatCreditsAsDash(max); + withdrawAmountInput.value = raw; + updateState(setWithdrawAmount(state, max, raw)); + }); + } + } + + // Withdraw submit button + const withdrawSubmitBtn = container.querySelector('#withdraw-submit-btn'); + if (withdrawSubmitBtn) { + withdrawSubmitBtn.addEventListener('click', () => { + startWithdrawal(); + }); + } + + // Withdraw tracking: leave polling but keep the success result + const withdrawTrackingDoneBtn = container.querySelector('#withdraw-tracking-done-btn'); + if (withdrawTrackingDoneBtn) { + withdrawTrackingDoneBtn.addEventListener('click', () => { + updateState(setWithdrawTrackingTimeout( + state, + 'Your withdrawal was accepted and continues processing on the network. The payout will arrive at the destination address; check your Core wallet.' + )); + }); + } + + // Withdraw complete: retry / start over + const withdrawRetryBtn = container.querySelector('#withdraw-retry-btn'); + if (withdrawRetryBtn) { + withdrawRetryBtn.addEventListener('click', () => { + updateState(setWithdrawRetry(state)); + }); + } + + const withdrawStartOverBtn = container.querySelector('#withdraw-start-over-btn'); + if (withdrawStartOverBtn) { + withdrawStartOverBtn.addEventListener('click', () => { + updateState(setStep(state, 'init')); + }); + } + // ============================================================================ // Contract Registration Event Listeners // ============================================================================ @@ -1763,7 +2033,7 @@ function validateIdentityId(id?: string): boolean { * are required for DPNS and contract operations. MASTER keys are ranked lower * because they are rejected by isPurposeAllowedForDpns/isSecurityLevelAllowedForDpns. */ -function parseKeyBackup(json: unknown): { identityId: string; privateKeyWif: string; purpose: string; securityLevel: string } | null { +function parseKeyBackup(json: unknown, preferredPurpose?: string): { identityId: string; privateKeyWif: string; purpose: string; securityLevel: string } | null { if (!json || typeof json !== 'object') return null; const obj = json as Record; const identityId = (obj.identityId || obj.targetIdentityId) as string | undefined; @@ -1775,6 +2045,12 @@ function parseKeyBackup(json: unknown): { identityId: string; privateKeyWif: str const ranked = keys .filter((k) => typeof k.privateKeyWif === 'string') .sort((a, b) => { + // Caller-preferred purpose wins outright (e.g. TRANSFER for withdrawals) + if (preferredPurpose) { + const aPref = a.purpose === preferredPurpose ? 1 : 0; + const bPref = b.purpose === preferredPurpose ? 1 : 0; + if (aPref !== bPref) return bPref - aPref; + } // Prefer AUTHENTICATION purpose const aAuth = a.purpose === 'AUTHENTICATION' ? 1 : 0; const bAuth = b.purpose === 'AUTHENTICATION' ? 1 : 0; @@ -2915,6 +3191,160 @@ async function startManageUpdate() { } } +// ============================================================================ +// Withdraw Functions +// ============================================================================ + +const WITHDRAW_POLL_INTERVAL_MS = 10_000; +const WITHDRAW_POLL_TIMEOUT_MS = 5 * 60_000; +const WITHDRAW_POLL_FAILURE_WARNING_THRESHOLD = 5; + +async function startWithdrawal() { + const identityId = state.targetIdentityId; + const privateKeyWif = state.withdrawPrivateKeyWif; + const amountCredits = state.withdrawAmountCredits; + const toAddress = state.withdrawToAddress; + + if (!identityId || !privateKeyWif || amountCredits === undefined || !toAddress) { + updateState(setWithdrawSubmitError(state, 'Missing withdrawal details')); + return; + } + + updateState(setWithdrawSubmitting(state)); + + try { + if (isE2EMockMode()) { + await delay(80); + const mockRemaining = BigInt(E2E_MOCK_WITHDRAW_BALANCE) - amountCredits; + updateState(setWithdrawSubmitted(state, mockRemaining)); + // Deterministically walk the payout statuses to completion + await delay(30); + updateState(setWithdrawStatusUpdate(state, 1)); // POOLED + await delay(30); + updateState(setWithdrawStatusUpdate(state, 3)); // COMPLETE + return; + } + + // Capture before submitting so the withdrawal document (created when the + // transition is processed) is matched even with modest clock skew. + const sinceMs = Date.now() - 60_000; + + const { withdrawCredits } = await loadPlatformModule(); + const result = await withdrawCredits( + identityId, + privateKeyWif, + amountCredits, + toAddress, + state.network + ); + + if (result.timedOut) { + // Ambiguous outcome: the transition may already be broadcast, so a + // "failed" screen here would invite a double spend via retry. Track + // instead — the withdrawal document will show up if it went through. + updateState(setWithdrawStatusNote( + setWithdrawSubmitted(state), + 'Confirmation timed out, but the withdrawal may still have been submitted — checking the network for it. Do not retry until the outcome is clear.' + )); + void pollWithdrawalStatus(identityId, sinceMs); + return; + } + + if (!result.success || result.remainingBalance === undefined) { + // A non-timeout error can still have occurred AFTER broadcast (e.g. + // while waiting for the state transition result). Before offering a + // retryable failure screen, check whether the withdrawal actually + // landed — a second submission would withdraw twice. + if (await didWithdrawalLand(identityId, sinceMs)) { + updateState(setWithdrawStatusNote( + setWithdrawSubmitted(state), + 'The submission reported an error, but the withdrawal was found on the network — tracking its payout instead.' + )); + void pollWithdrawalStatus(identityId, sinceMs); + return; + } + updateState(setWithdrawSubmitError(state, result.error || 'Withdrawal failed')); + return; + } + + updateState(setWithdrawSubmitted(state, result.remainingBalance)); + void pollWithdrawalStatus(identityId, sinceMs); + } catch (error) { + console.error('Withdrawal error:', error); + updateState(setWithdrawSubmitError(state, extractErrorMessage(error))); + } +} + +/** + * Check whether a withdrawal document for this identity appeared on the + * network after `sinceMs`. Used to disambiguate submission errors: an error + * thrown after broadcast leaves a document behind even though the SDK call + * failed. A few short attempts cover processing lag; lookup failures count + * as "not found" (the failure screen already warns about the ambiguity). + */ +async function didWithdrawalLand(identityId: string, sinceMs: number): Promise { + const { fetchLatestWithdrawalStatus } = await loadPlatformModule(); + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt > 0) await delay(5_000); + try { + const record = await fetchLatestWithdrawalStatus(identityId, state.network, sinceMs); + if (record !== null) return true; + } catch (error) { + console.warn('Withdrawal landed-check failed:', error); + } + } + return false; +} + +/** + * Poll the withdrawals contract for the payout status until it completes, + * the user leaves the tracking step, or the timeout elapses. Poll failures are + * informational only — the credits already left the identity, so a failure to + * *observe* the payout must never be presented as a failed withdrawal. + */ +async function pollWithdrawalStatus(identityId: string, sinceMs: number) { + const deadline = Date.now() + WITHDRAW_POLL_TIMEOUT_MS; + let consecutiveFailures = 0; + let warningShown = false; + + while (state.step === 'withdraw_tracking') { + if (Date.now() > deadline) { + updateState(setWithdrawTrackingTimeout( + state, + 'The withdrawal was accepted and is still being processed by the network. During heavy volume (daily withdrawal limit) payouts can stay queued for a while — check your Core wallet later.' + )); + return; + } + + await delay(WITHDRAW_POLL_INTERVAL_MS); + if (state.step !== 'withdraw_tracking') return; + + try { + const { fetchLatestWithdrawalStatus } = await loadPlatformModule(); + const record = await fetchLatestWithdrawalStatus(identityId, state.network, sinceMs); + consecutiveFailures = 0; + if (record !== null && record.status !== state.withdrawStatus) { + // Also clears any transient polling warning + updateState(setWithdrawStatusUpdate(state, record.status)); + warningShown = false; + } else if (warningShown && state.step === 'withdraw_tracking') { + updateState(setWithdrawStatusNote(state, undefined)); + warningShown = false; + } + } catch (error) { + consecutiveFailures++; + console.warn('Withdrawal status poll failed:', error); + if (consecutiveFailures === WITHDRAW_POLL_FAILURE_WARNING_THRESHOLD && state.step === 'withdraw_tracking') { + updateState(setWithdrawStatusNote( + state, + 'Having trouble checking the payout status — the withdrawal itself was accepted and continues processing.' + )); + warningShown = true; + } + } + } +} + // ============================================================================ // Faucet Functions // ============================================================================ diff --git a/src/platform/client.ts b/src/platform/client.ts index e061155..075e890 100644 --- a/src/platform/client.ts +++ b/src/platform/client.ts @@ -132,6 +132,19 @@ export async function withConnectedPlatformSdk( } } +/** + * Thrown when a platform operation exceeds its wall-clock guard. Callers that + * must distinguish "timed out (outcome unknown)" from "failed" — e.g. credit + * withdrawals, where a false failure invites a double spend — check for this + * type instead of matching the message text. + */ +export class PlatformOperationTimeoutError extends Error { + constructor(action: string) { + super(`Timed out while ${action}`); + this.name = 'PlatformOperationTimeoutError'; + } +} + export async function withPlatformOperationTimeout( promise: Promise, action: string, @@ -144,7 +157,7 @@ export async function withPlatformOperationTimeout( promise, new Promise((_, reject) => { timeoutId = window.setTimeout(() => { - reject(new Error(`Timed out while ${action}`)); + reject(new PlatformOperationTimeoutError(action)); }, timeoutMs); }), ]); diff --git a/src/platform/index.ts b/src/platform/index.ts index e2ee3d1..ec994a4 100644 --- a/src/platform/index.ts +++ b/src/platform/index.ts @@ -14,3 +14,12 @@ export { } from './identity.js'; export type { AddKeyConfig } from './identity.js'; + +export { + withdrawCredits, + fetchLatestWithdrawalStatus, + WithdrawalStatus, + WITHDRAWALS_CONTRACT_ID, +} from './withdrawal.js'; + +export type { WithdrawResult, WithdrawalStatusRecord } from './withdrawal.js'; diff --git a/src/platform/withdrawal-status.ts b/src/platform/withdrawal-status.ts new file mode 100644 index 0000000..8275f90 --- /dev/null +++ b/src/platform/withdrawal-status.ts @@ -0,0 +1,15 @@ +/** + * Withdrawal document lifecycle statuses. The validator quorum moves a + * withdrawal through these states; the client only observes them. + * + * Kept in a leaf module (no SDK imports) so the eagerly-loaded UI layer can + * use the constants without pulling the lazily-loaded platform chunk into + * the main bundle. + */ +export const WithdrawalStatus = { + QUEUED: 0, + POOLED: 1, + BROADCASTED: 2, + COMPLETE: 3, + EXPIRED: 4, +} as const; diff --git a/src/platform/withdrawal.ts b/src/platform/withdrawal.ts new file mode 100644 index 0000000..71eadd9 --- /dev/null +++ b/src/platform/withdrawal.ts @@ -0,0 +1,155 @@ +import { withRetry, type RetryOptions } from '../utils/retry.js'; +import { extractErrorMessage } from '../utils/errors.js'; +import { loadSdkModule } from './sdkModule.js'; +import { + PLATFORM_PUT_SETTINGS, + PlatformOperationTimeoutError, + fetchIdentityWithSdk, + withConnectedPlatformSdk, + withPlatformOperationTimeout, +} from './client.js'; + +/** + * The withdrawals system data contract. Not bundled into the wasm SDK's + * default known contracts, so the first documents query fetches it (with + * proof) from the network. + */ +export const WITHDRAWALS_CONTRACT_ID = '4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6'; + +export { WithdrawalStatus } from './withdrawal-status.js'; + +/** + * Timeout for the full submit-and-wait-for-inclusion round trip. Longer than + * the default 45s guard because a timeout here is ambiguous — the transition + * may already be broadcast — and a false failure invites a double spend. + */ +const WITHDRAWAL_OPERATION_TIMEOUT_MS = 120_000; + +export interface WithdrawResult { + success: boolean; + /** Identity balance (credits) after the withdrawal, when successful. */ + remainingBalance?: bigint; + error?: string; + /** + * True when the operation timed out waiting for confirmation. The + * transition may still have been broadcast and included — callers must + * treat this as "unknown", not as a failure. + */ + timedOut?: boolean; +} + +/** + * Withdraw credits from an identity to a Dash Core address. + * + * The signing key must be a TRANSFER-purpose key: with a destination address + * present, OWNER-key-signed withdrawals are rejected by consensus. The SDK + * builds the transition (pooling Never, nonce fetch), signs, broadcasts, and + * waits for inclusion, returning the remaining identity balance. + */ +export async function withdrawCredits( + identityId: string, + privateKeyWif: string, + amountCredits: bigint, + toAddress: string, + network: string, + retryOptions?: RetryOptions +): Promise { + return withConnectedPlatformSdk(network, async (sdk) => { + try { + console.log('Withdrawing', amountCredits.toString(), 'credits from', identityId, 'to', toAddress); + + const identity = await fetchIdentityWithSdk(sdk, identityId, retryOptions); + if (!identity) { + throw new Error(`Identity not found: ${identityId}`); + } + + const { IdentitySigner } = await loadSdkModule(); + const signer = new IdentitySigner(); + signer.addKeyFromWif(privateKeyWif); + + // Deliberately single-submit: no retry wrapper and no SDK-level retries. + // A retry after an ambiguous network error would rebuild the transition + // with a fresh nonce and could withdraw a second time if the first + // attempt actually landed. Ambiguous outcomes surface as timedOut and + // are resolved by the caller via status tracking instead. + const remainingBalance = await withPlatformOperationTimeout( + sdk.identities.creditWithdrawal({ + identity, + amount: amountCredits, + toAddress, + coreFeePerByte: 1, + signer, + settings: { ...PLATFORM_PUT_SETTINGS, retries: 0 }, + }), + 'waiting for credit withdrawal confirmation', + WITHDRAWAL_OPERATION_TIMEOUT_MS + ); + + console.log('Withdrawal accepted, remaining balance:', remainingBalance.toString()); + + return { success: true, remainingBalance }; + } catch (error) { + console.error('Credit withdrawal error:', error); + return { + success: false, + error: extractErrorMessage(error), + timedOut: error instanceof PlatformOperationTimeoutError, + }; + } + }, retryOptions); +} + +export interface WithdrawalStatusRecord { + status: number; + amountCredits: bigint; + createdAt: number; + updatedAt: number; +} + +/** + * Fetch the most recent withdrawal document for an identity created at or + * after `sinceMs`. Returns null while no matching document exists yet + * (the quorum creates it when the withdrawal transition is processed). + */ +export async function fetchLatestWithdrawalStatus( + identityId: string, + network: string, + sinceMs: number, + retryOptions?: RetryOptions +): Promise { + return withConnectedPlatformSdk(network, async (sdk) => { + const documents = await withPlatformOperationTimeout( + withRetry( + () => sdk.documents.query({ + dataContractId: WITHDRAWALS_CONTRACT_ID, + documentTypeName: 'withdrawal', + where: [['$ownerId', '==', identityId]], + orderBy: [['$updatedAt', 'desc']], + limit: 10, + }), + retryOptions + ), + 'fetching withdrawal status' + ); + + let latest: WithdrawalStatusRecord | null = null; + for (const doc of documents.values()) { + if (!doc) continue; + const createdAt = doc.createdAt !== undefined ? Number(doc.createdAt) : 0; + const updatedAt = doc.updatedAt !== undefined ? Number(doc.updatedAt) : createdAt; + if (createdAt < sinceMs) continue; + const props = doc.properties as { status?: unknown; amount?: unknown }; + const status = typeof props.status === 'number' ? props.status : Number(props.status ?? NaN); + if (!Number.isFinite(status)) continue; + if (!latest || updatedAt > latest.updatedAt) { + latest = { + status, + amountCredits: BigInt(String(props.amount ?? 0)), + createdAt, + updatedAt, + }; + } + } + return latest; + }, retryOptions); +} diff --git a/src/types.ts b/src/types.ts index 7c173b5..c4b52da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,7 +68,7 @@ export interface IdentityKeyConfig { /** * Bridge operation mode */ -export type BridgeMode = 'create' | 'topup' | 'send_to_address' | 'dpns' | 'manage' | 'contract'; +export type BridgeMode = 'create' | 'topup' | 'send_to_address' | 'dpns' | 'manage' | 'contract' | 'withdraw'; /** * DPNS identity source for standalone mode @@ -184,7 +184,13 @@ export type BridgeStep = | 'contract_enter_contract' // Paste contract JSON, see fee estimate | 'contract_review' // Review contract + fees before publishing | 'contract_registering' // Publishing contract on platform - | 'contract_complete'; // Contract registered + | 'contract_complete' // Contract registered + // Withdraw (asset unlock) steps + | 'withdraw_enter_identity' // Enter identity ID, fetch keys + balance + | 'withdraw_configure' // Enter TRANSFER key WIF, destination address, amount + | 'withdraw_submitting' // Credit withdrawal transition in flight + | 'withdraw_tracking' // Polling withdrawal document status + | 'withdraw_complete'; // Withdrawal done (or failed) /** * Status of network retry attempts @@ -386,6 +392,38 @@ export interface BridgeState { /** Minimum deposit amount in satoshis (overrides default 300,000 for contract mode) */ minimumDeposit?: number; + // Withdraw (asset unlock) fields + /** Withdraw: whether identity is being fetched */ + withdrawIdentityFetching?: boolean; + /** Withdraw: identity fetch error */ + withdrawIdentityFetchError?: string; + /** Withdraw: fetched identity keys */ + withdrawIdentityKeys?: IdentityPublicKeyInfo[]; + /** Withdraw: identity credit balance in credits */ + withdrawBalance?: bigint; + /** Withdraw: private key WIF for signing (must match a TRANSFER key) */ + withdrawPrivateKeyWif?: string; + /** Withdraw: validated signing key info */ + withdrawSigningKeyInfo?: { keyId: number; purpose: number; securityLevel: number }; + /** Withdraw: key validation error message */ + withdrawKeyValidationError?: string; + /** Withdraw: destination Dash Core address as typed (valid when withdrawAddressError is unset) */ + withdrawToAddress?: string; + /** Withdraw: destination address validation error */ + withdrawAddressError?: string; + /** Withdraw: validated amount to withdraw, in credits */ + withdrawAmountCredits?: bigint; + /** Withdraw: raw DASH amount input (preserved across re-renders) */ + withdrawAmountInput?: string; + /** Withdraw: amount validation error */ + withdrawAmountError?: string; + /** Withdraw: submission result */ + withdrawResult?: { success: boolean; remainingBalance?: bigint; error?: string }; + /** Withdraw: latest withdrawal document status (0 QUEUED, 1 POOLED, 2 BROADCASTED, 3 COMPLETE, 4 EXPIRED) */ + withdrawStatus?: number; + /** Withdraw: status polling problem / timeout explanation (informational, not a failure) */ + withdrawStatusError?: string; + // Faucet request state /** Current status of faucet request */ faucetRequestStatus?: 'idle' | 'solving_pow' | 'requesting' | 'success' | 'error'; diff --git a/src/ui/components.ts b/src/ui/components.ts index a613089..d13fa36 100644 --- a/src/ui/components.ts +++ b/src/ui/components.ts @@ -3,6 +3,8 @@ import { getStepProgress, getStepDescription, ErrorCodes, ErrorCodeLabels } from import { shouldShowContestedWarning, countUsernameStatuses } from '../platform/dpns-utils.js'; import { generateQRCodeDataUrl } from './qrcode.js'; import { privateKeyToWif } from '../utils/wif.js'; +import { formatCreditsAsDash, formatCredits, MIN_WITHDRAWAL_CREDITS } from '../utils/credits.js'; +import { WithdrawalStatus } from '../platform/withdrawal-status.js'; import { bytesToHex } from '../utils/hex.js'; import { getNetwork, getAvailableNetworks } from '../config.js'; import { getAssetLockDerivationPath } from '../crypto/hd.js'; @@ -320,6 +322,23 @@ export function render(state: BridgeState, container: HTMLElement): void { case 'contract_complete': content.appendChild(renderContractCompleteStep(state)); break; + + // Withdraw steps + case 'withdraw_enter_identity': + content.appendChild(renderWithdrawEnterIdentityStep(state)); + break; + case 'withdraw_configure': + content.appendChild(renderWithdrawConfigureStep(state)); + break; + case 'withdraw_submitting': + content.appendChild(renderWithdrawSubmittingStep(state)); + break; + case 'withdraw_tracking': + content.appendChild(renderWithdrawTrackingStep(state)); + break; + case 'withdraw_complete': + content.appendChild(renderWithdrawCompleteStep(state)); + break; } wrapper.appendChild(content); @@ -404,6 +423,10 @@ function renderInitStep(state: BridgeState): HTMLElement { Register Data Contract Publish a data contract on Dash Platform + `; div.appendChild(modeButtons); @@ -1119,6 +1142,18 @@ function buildErrorDiagnostics(state: BridgeState): Record { if (state.manageKeysToAdd?.length) diag.manageKeysToAddCount = state.manageKeysToAdd.length; if (state.manageKeyIdsToDisable?.length) diag.manageKeyIdsToDisable = state.manageKeyIdsToDisable; + // Withdraw context — counts and public data only, never key material + if (state.withdrawSigningKeyInfo) diag.withdrawSigningKeyInfo = state.withdrawSigningKeyInfo; + if (state.withdrawToAddress) diag.withdrawToAddress = state.withdrawToAddress; + if (state.withdrawAmountCredits !== undefined) diag.withdrawAmountCredits = String(state.withdrawAmountCredits); + if (state.withdrawStatus !== undefined) diag.withdrawStatus = state.withdrawStatus; + if (state.withdrawResult) { + diag.withdrawResult = { + success: state.withdrawResult.success, + error: state.withdrawResult.error ?? null, + }; + } + // Retry state if (state.retryStatus) diag.retryStatus = state.retryStatus; @@ -2254,6 +2289,377 @@ function renderManageCompleteStep(state: BridgeState): HTMLElement { return div; } +// ============================================================================ +// Withdraw (Asset Unlock) Steps +// ============================================================================ + +/** Withdrawal document statuses, in payout order. */ +const WITHDRAW_STATUS_LABELS: { status: number; label: string }[] = [ + { status: WithdrawalStatus.QUEUED, label: 'Queued' }, + { status: WithdrawalStatus.POOLED, label: 'Pooled' }, + { status: WithdrawalStatus.BROADCASTED, label: 'Broadcast to Core' }, + { status: WithdrawalStatus.COMPLETE, label: 'Complete' }, +]; + +function renderWithdrawEnterIdentityStep(state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'withdraw-enter-identity-step'; + div.id = 'withdraw-key-upload-dropzone'; + + const headline = document.createElement('h2'); + headline.className = 'manage-headline'; + headline.textContent = 'Withdraw Credits'; + div.appendChild(headline); + + const subtitle = document.createElement('p'); + subtitle.className = 'manage-subtitle'; + subtitle.textContent = 'Send Platform credits from your identity back to a Dash Core address.'; + div.appendChild(subtitle); + + const form = document.createElement('div'); + form.className = 'manage-identity-form'; + + const isFetching = state.withdrawIdentityFetching === true; + const hasFetchError = state.withdrawIdentityFetchError !== undefined; + + let identityStatusHtml = ''; + if (isFetching) { + identityStatusHtml = '

Fetching identity...

'; + } else if (hasFetchError) { + identityStatusHtml = `

${escapeHtml(state.withdrawIdentityFetchError!)}

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

The Base58 identifier for the identity to withdraw from

+ ${identityStatusHtml} +
+ `; + div.appendChild(form); + + const navButtons = document.createElement('div'); + navButtons.className = 'nav-buttons'; + + const backBtn = document.createElement('button'); + backBtn.id = 'withdraw-back-btn'; + backBtn.className = 'secondary-btn'; + backBtn.textContent = 'Back'; + navButtons.appendChild(backBtn); + + div.appendChild(navButtons); + + return div; +} + +function renderWithdrawConfigureStep(state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'withdraw-configure-step'; + + const headline = document.createElement('h2'); + headline.className = 'manage-headline'; + headline.textContent = 'Configure Withdrawal'; + div.appendChild(headline); + + // Identity + balance summary + const balance = state.withdrawBalance ?? 0n; + const summary = document.createElement('div'); + summary.className = 'withdraw-identity-summary'; + summary.innerHTML = ` +

${escapeHtml(state.targetIdentityId || '')}

+

Balance: ${escapeHtml(formatCreditsAsDash(balance))} DASH (${escapeHtml(formatCredits(balance))} credits)

+ `; + div.appendChild(summary); + + const hasValidatedKey = state.withdrawSigningKeyInfo !== undefined; + const hasKeyError = state.withdrawKeyValidationError !== undefined; + + let keyValidationHtml = ''; + if (hasValidatedKey) { + const info = state.withdrawSigningKeyInfo!; + keyValidationHtml = `

Key matches key #${info.keyId} (${getKeyPurposeName(info.purpose)} / ${getSecurityLevelName(info.securityLevel)})

`; + } else if (hasKeyError) { + keyValidationHtml = `

${escapeHtml(state.withdrawKeyValidationError!)}

`; + } + + const addressErrorHtml = state.withdrawAddressError + ? `

${escapeHtml(state.withdrawAddressError)}

` + : ''; + + const amountErrorHtml = state.withdrawAmountError + ? `

${escapeHtml(state.withdrawAmountError)}

` + : ''; + + const amountCaption = state.withdrawAmountCredits !== undefined + ? `

= ${escapeHtml(formatCredits(state.withdrawAmountCredits))} credits

` + : `

Amount in DASH (min ${formatCreditsAsDash(MIN_WITHDRAWAL_CREDITS)})

`; + + const form = document.createElement('div'); + form.className = 'manage-identity-form'; + form.innerHTML = ` +
+ + +

Withdrawals must be signed with a TRANSFER key. Identities created by this bridge have it at HD index 3.

+ ${keyValidationHtml} +
+ +
+ + +

A Dash Core (L1) address on ${escapeHtml(state.network)}

+ ${addressErrorHtml} +
+ +
+ +
+ + +
+ ${amountCaption} + ${amountErrorHtml} +
+ +
+

The Core network mining fee (~190 duffs) is deducted from the withdrawn amount. Payouts are batched by the network and typically take a few minutes.

+
+ `; + div.appendChild(form); + + const navButtons = document.createElement('div'); + navButtons.className = 'nav-buttons'; + + const backBtn = document.createElement('button'); + backBtn.id = 'withdraw-back-btn'; + backBtn.className = 'secondary-btn'; + backBtn.textContent = 'Back'; + navButtons.appendChild(backBtn); + + const submitBtn = document.createElement('button'); + submitBtn.id = 'withdraw-submit-btn'; + submitBtn.className = 'primary-btn'; + submitBtn.textContent = 'Withdraw'; + const canSubmit = + hasValidatedKey && + state.withdrawToAddress !== undefined && + state.withdrawAddressError === undefined && + state.withdrawAmountCredits !== undefined; + if (!canSubmit) { + submitBtn.setAttribute('disabled', 'true'); + } + navButtons.appendChild(submitBtn); + + div.appendChild(navButtons); + + return div; +} + +function renderWithdrawSubmittingStep(_state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'withdraw-submitting-step'; + + const headline = document.createElement('h2'); + headline.className = 'manage-headline'; + headline.textContent = 'Submitting Withdrawal'; + div.appendChild(headline); + + const subtitle = document.createElement('p'); + subtitle.className = 'manage-subtitle'; + subtitle.textContent = 'Signing and broadcasting the credit withdrawal to Dash Platform...'; + div.appendChild(subtitle); + + const spinner = document.createElement('div'); + spinner.className = 'spinner large'; + div.appendChild(spinner); + + return div; +} + +function renderWithdrawTrackingStep(state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'withdraw-tracking-step'; + + const headline = document.createElement('h2'); + headline.className = 'manage-headline'; + headline.textContent = 'Processing Withdrawal'; + div.appendChild(headline); + + const subtitle = document.createElement('p'); + subtitle.className = 'manage-subtitle'; + subtitle.textContent = 'Your withdrawal was accepted. The network is preparing the Core payout transaction.'; + div.appendChild(subtitle); + + const currentStatus = state.withdrawStatus ?? 0; + const timeline = document.createElement('div'); + timeline.className = 'withdraw-status-timeline'; + timeline.innerHTML = WITHDRAW_STATUS_LABELS.map(({ status, label }) => { + let cls = 'pending'; + if (status < currentStatus) { + cls = 'done'; + } else if (status === currentStatus) { + cls = 'active'; + } + return `
+ + ${escapeHtml(label)} +
`; + }).join(''); + div.appendChild(timeline); + + const spinner = document.createElement('div'); + spinner.className = 'spinner'; + div.appendChild(spinner); + + if (state.withdrawStatusError) { + const pollNote = document.createElement('p'); + pollNote.className = 'withdraw-poll-warning'; + pollNote.textContent = state.withdrawStatusError; + div.appendChild(pollNote); + } + + const note = document.createElement('p'); + note.className = 'input-hint'; + note.textContent = 'Payouts are batched by the validator network and can take several minutes. During heavy network volume (daily withdrawal limit), a withdrawal can stay queued longer — that is normal, not a failure.'; + div.appendChild(note); + + const navButtons = document.createElement('div'); + navButtons.className = 'nav-buttons'; + + const doneBtn = document.createElement('button'); + doneBtn.id = 'withdraw-tracking-done-btn'; + doneBtn.className = 'secondary-btn'; + doneBtn.textContent = 'Continue in Background'; + navButtons.appendChild(doneBtn); + + div.appendChild(navButtons); + + return div; +} + +function renderWithdrawCompleteStep(state: BridgeState): HTMLElement { + const div = document.createElement('div'); + div.className = 'withdraw-complete-step'; + + const result = state.withdrawResult; + const isSuccess = result?.success === true; + const status = state.withdrawStatus; + + const headline = document.createElement('h2'); + headline.className = 'manage-headline'; + if (!isSuccess) { + headline.textContent = 'Withdrawal Failed'; + } else if (status === WithdrawalStatus.EXPIRED) { + headline.textContent = 'Withdrawal Expired'; + } else if (status === WithdrawalStatus.COMPLETE) { + headline.textContent = 'Withdrawal Complete!'; + } else { + headline.textContent = 'Withdrawal Submitted'; + } + div.appendChild(headline); + + if (isSuccess) { + const details = document.createElement('div'); + details.className = 'withdraw-success-details'; + + const amount = state.withdrawAmountCredits; + const amountHtml = amount !== undefined + ? `

Amount: ${escapeHtml(formatCreditsAsDash(amount))} DASH (${escapeHtml(formatCredits(amount))} credits)

` + : ''; + const remaining = result?.remainingBalance; + const remainingHtml = remaining !== undefined + ? `

Remaining balance: ${escapeHtml(formatCreditsAsDash(remaining))} DASH

` + : ''; + details.innerHTML = ` + ${amountHtml} +

Destination: ${escapeHtml(state.withdrawToAddress || '')}

+ ${remainingHtml} + `; + div.appendChild(details); + + if (status === WithdrawalStatus.COMPLETE) { + const msg = document.createElement('p'); + msg.className = 'withdraw-success-msg'; + msg.textContent = 'The payout transaction has been chain-locked on Dash Core. Funds should appear in your wallet.'; + div.appendChild(msg); + } else if (status === WithdrawalStatus.EXPIRED) { + const msg = document.createElement('p'); + msg.className = 'withdraw-expired-msg'; + msg.textContent = 'The payout transaction expired before it was mined. The network automatically re-broadcasts expired withdrawals; if the funds do not arrive, the credits are returned to your identity.'; + div.appendChild(msg); + } else if (state.withdrawStatusError) { + const msg = document.createElement('p'); + msg.className = 'withdraw-pending-msg'; + msg.textContent = state.withdrawStatusError; + div.appendChild(msg); + } + } else { + const errorMsg = document.createElement('div'); + errorMsg.className = 'withdraw-error-msg'; + errorMsg.innerHTML = ` +

The withdrawal could not be completed. Your credits have not left the identity unless the error occurred after broadcast.

+

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

+ `; + div.appendChild(errorMsg); + } + + // Identity info with copy + explorer link + const withdrawIdentityId = state.targetIdentityId || 'Unknown'; + div.appendChild(renderIdSection('Identity ID', withdrawIdentityId, { + explorerHref: withdrawIdentityId !== 'Unknown' ? explorerUrl(state.network, 'identity', withdrawIdentityId) : undefined, + copyBtnId: 'copy-withdraw-identity-btn', + })); + + const actionButtons = document.createElement('div'); + actionButtons.className = 'withdraw-action-buttons nav-buttons'; + + if (!isSuccess) { + const retryBtn = document.createElement('button'); + retryBtn.id = 'withdraw-retry-btn'; + retryBtn.className = 'primary-btn'; + retryBtn.textContent = 'Try Again'; + actionButtons.appendChild(retryBtn); + } + + const startOverBtn = document.createElement('button'); + startOverBtn.id = 'withdraw-start-over-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 2669af7..bc12475 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -81,6 +81,24 @@ export { setContractComplete, setModeContractFromIdentity, setContractStartBridge, + // Withdraw state functions + setWithdrawIdentityFetching, + setWithdrawIdentityFetched, + setWithdrawIdentityFetchError, + setWithdrawKeyValidated, + setWithdrawKeyValidationError, + clearWithdrawKeyValidation, + setWithdrawAddress, + setWithdrawAmount, + setWithdrawAmountError, + setWithdrawSubmitting, + setWithdrawSubmitted, + setWithdrawSubmitError, + setWithdrawStatusUpdate, + setWithdrawStatusNote, + setWithdrawTrackingTimeout, + setWithdrawBackToEntry, + setWithdrawRetry, // Faucet state functions setFaucetSolvingPow, setFaucetRequesting, diff --git a/src/ui/state.test.ts b/src/ui/state.test.ts index 2ff1fbb..a87c852 100644 --- a/src/ui/state.test.ts +++ b/src/ui/state.test.ts @@ -1,6 +1,23 @@ import { describe, it, expect } from 'vitest'; -import { ErrorCodes, createInitialState, getStepDescription, setError } from './state.js'; +import { + ErrorCodes, + createInitialState, + getStepDescription, + setError, + setMode, + setWithdrawIdentityFetching, + setWithdrawIdentityFetched, + setWithdrawKeyValidated, + setWithdrawKeyValidationError, + setWithdrawAmountError, + setWithdrawAddress, + setWithdrawSubmitting, + setWithdrawSubmitted, + setWithdrawSubmitError, + setWithdrawStatusUpdate, + setWithdrawTrackingTimeout, +} from './state.js'; import type { BridgeState } from '../types.js'; function baseState(): BridgeState { @@ -60,3 +77,116 @@ describe('step descriptions', () => { expect(getStepDescription('generating_keys')).toBe('Preparing Dash Platform...'); }); }); + +describe('withdraw mode state transitions', () => { + it('setMode withdraw enters withdraw_enter_identity with cleared fields', () => { + const dirty: BridgeState = { + ...baseState(), + withdrawPrivateKeyWif: 'someWif', + withdrawToAddress: 'yAddr', + withdrawAmountCredits: 5n, + withdrawStatus: 2, + }; + const result = setMode(dirty, 'withdraw'); + expect(result.step).toBe('withdraw_enter_identity'); + expect(result.mode).toBe('withdraw'); + expect(result.withdrawPrivateKeyWif).toBeUndefined(); + expect(result.withdrawToAddress).toBeUndefined(); + expect(result.withdrawAmountCredits).toBeUndefined(); + expect(result.withdrawStatus).toBeUndefined(); + }); + + it('switching to another mode clears withdraw-sensitive fields', () => { + const state: BridgeState = { + ...baseState(), + mode: 'withdraw', + withdrawPrivateKeyWif: 'someWif', + withdrawToAddress: 'yAddr', + withdrawAmountCredits: 5n, + }; + const result = setMode(state, 'topup'); + expect(result.withdrawPrivateKeyWif).toBeUndefined(); + expect(result.withdrawToAddress).toBeUndefined(); + expect(result.withdrawAmountCredits).toBeUndefined(); + }); + + it('identity fetch success advances to configure with keys and balance', () => { + const fetching = setWithdrawIdentityFetching(baseState(), 'someIdentityId'); + expect(fetching.withdrawIdentityFetching).toBe(true); + expect(fetching.targetIdentityId).toBe('someIdentityId'); + const fetched = setWithdrawIdentityFetched(fetching, [], 123456n); + expect(fetched.step).toBe('withdraw_configure'); + expect(fetched.withdrawIdentityFetching).toBe(false); + expect(fetched.withdrawBalance).toBe(123456n); + }); + + it('submit success moves to tracking with QUEUED status', () => { + const state = setWithdrawSubmitting(baseState()); + expect(state.step).toBe('withdraw_submitting'); + const submitted = setWithdrawSubmitted(state, 42n); + expect(submitted.step).toBe('withdraw_tracking'); + expect(submitted.withdrawStatus).toBe(0); + expect(submitted.withdrawResult).toEqual({ success: true, remainingBalance: 42n }); + }); + + it('ambiguous timeout enters tracking without a remaining balance, still a success', () => { + const submitted = setWithdrawSubmitted(setWithdrawSubmitting(baseState())); + expect(submitted.step).toBe('withdraw_tracking'); + expect(submitted.withdrawStatus).toBe(0); + expect(submitted.withdrawResult).toEqual({ success: true, remainingBalance: undefined }); + }); + + it('submit error terminates with a failed result', () => { + const result = setWithdrawSubmitError(setWithdrawSubmitting(baseState()), 'boom'); + expect(result.step).toBe('withdraw_complete'); + expect(result.withdrawResult).toEqual({ success: false, error: 'boom' }); + }); + + it('status updates stay in tracking until terminal', () => { + let state = setWithdrawSubmitted(setWithdrawSubmitting(baseState()), 42n); + state = setWithdrawStatusUpdate(state, 1); + expect(state.step).toBe('withdraw_tracking'); + state = setWithdrawStatusUpdate(state, 2); + expect(state.step).toBe('withdraw_tracking'); + state = setWithdrawStatusUpdate(state, 3); + expect(state.step).toBe('withdraw_complete'); + expect(state.withdrawStatus).toBe(3); + }); + + it('EXPIRED status is terminal', () => { + const state = setWithdrawStatusUpdate( + setWithdrawSubmitted(setWithdrawSubmitting(baseState()), 42n), + 4 + ); + expect(state.step).toBe('withdraw_complete'); + }); + + it('tracking timeout completes without clearing the success result', () => { + const submitted = setWithdrawSubmitted(setWithdrawSubmitting(baseState()), 42n); + const timedOut = setWithdrawTrackingTimeout(submitted, 'still queued'); + expect(timedOut.step).toBe('withdraw_complete'); + expect(timedOut.withdrawResult?.success).toBe(true); + expect(timedOut.withdrawStatusError).toBe('still queued'); + }); + + it('key validation error clears validated key material', () => { + const validated = setWithdrawKeyValidated(baseState(), 3, 3, 1, 'wif'); + expect(validated.withdrawSigningKeyInfo).toEqual({ keyId: 3, purpose: 3, securityLevel: 1 }); + const errored = setWithdrawKeyValidationError(validated, 'not a transfer key'); + expect(errored.withdrawSigningKeyInfo).toBeUndefined(); + expect(errored.withdrawPrivateKeyWif).toBeUndefined(); + expect(errored.withdrawKeyValidationError).toBe('not a transfer key'); + }); + + it('amount/address errors preserve raw input', () => { + const badAmount = setWithdrawAmountError(baseState(), '0.0000001', 'too small'); + expect(badAmount.withdrawAmountInput).toBe('0.0000001'); + expect(badAmount.withdrawAmountCredits).toBeUndefined(); + const badAddress = setWithdrawAddress(baseState(), 'Xoops', 'wrong network'); + expect(badAddress.withdrawToAddress).toBe('Xoops'); + expect(badAddress.withdrawAddressError).toBe('wrong network'); + const goodAddress = setWithdrawAddress(badAddress, 'yGoodAddr'); + expect(goodAddress.withdrawToAddress).toBe('yGoodAddr'); + expect(goodAddress.withdrawAddressError).toBeUndefined(); + }); +}); diff --git a/src/ui/state.ts b/src/ui/state.ts index 46e2195..7c17aa2 100644 --- a/src/ui/state.ts +++ b/src/ui/state.ts @@ -19,6 +19,7 @@ import { } from '../crypto/keys.js'; import { generateNewMnemonic } from '../crypto/hd.js'; import { createEmptyUsernameEntry, createUsernameEntry } from '../platform/dpns-utils.js'; +import { WithdrawalStatus } from '../platform/withdrawal-status.js'; /** * Error codes for user-facing display. @@ -41,6 +42,7 @@ export const ErrorCodes = { CONFIG: 'ERR-1012', CONTRACT_REGISTER: 'ERR-1013', CHAINLOCK: 'ERR-1014', + WITHDRAW: 'ERR-1015', } as const; /** Human-readable labels for error codes */ @@ -60,6 +62,7 @@ export const ErrorCodeLabels: Record = { [ErrorCodes.CONFIG]: 'Configuration error', [ErrorCodes.CONTRACT_REGISTER]: 'Contract registration failed', [ErrorCodes.CHAINLOCK]: 'Chain lock fallback failed', + [ErrorCodes.WITHDRAW]: 'Credit withdrawal failed', }; /** Map a processing step to its error code */ @@ -77,6 +80,8 @@ const StepErrorCodes: Partial> = { dpns_registering: ErrorCodes.DPNS_REGISTER, manage_updating: ErrorCodes.IDENTITY_UPDATE, contract_registering: ErrorCodes.CONTRACT_REGISTER, + withdraw_submitting: ErrorCodes.WITHDRAW, + withdraw_tracking: ErrorCodes.WITHDRAW, }; /** Coerce an unknown caught value into an Error */ @@ -195,6 +200,31 @@ export function setMode(state: BridgeState, mode: BridgeMode): BridgeState { contractIdentityBalance: undefined, minimumDeposit: undefined, }; + } else if (mode === 'withdraw') { + // Withdraw mode: go to identity entry, clear any previous withdraw state + return { + ...clearedState, + step: 'withdraw_enter_identity', + mode, + mnemonic: undefined, + identityKeys: [], + targetIdentityId: undefined, + withdrawIdentityFetching: undefined, + withdrawIdentityFetchError: undefined, + withdrawIdentityKeys: undefined, + withdrawBalance: undefined, + withdrawPrivateKeyWif: undefined, + withdrawSigningKeyInfo: undefined, + withdrawKeyValidationError: undefined, + withdrawToAddress: undefined, + withdrawAddressError: undefined, + withdrawAmountCredits: undefined, + withdrawAmountInput: undefined, + withdrawAmountError: undefined, + withdrawResult: undefined, + withdrawStatus: undefined, + withdrawStatusError: undefined, + }; } else { // Manage mode: go to identity entry return { @@ -216,9 +246,15 @@ export function setMode(state: BridgeState, mode: BridgeMode): BridgeState { } function clearModeSensitiveFields(state: BridgeState, mode: BridgeMode): BridgeState { + // setMode('withdraw') re-clears the withdraw block itself, so these can be + // dropped unconditionally here. return { ...state, recipientPlatformAddress: mode === 'send_to_address' ? state.recipientPlatformAddress : undefined, + withdrawPrivateKeyWif: undefined, + withdrawSigningKeyInfo: undefined, + withdrawToAddress: undefined, + withdrawAmountCredits: undefined, }; } @@ -643,6 +679,12 @@ export function getStepDescription(step: BridgeStep): string { contract_review: 'Review contract', contract_registering: 'Publishing contract...', contract_complete: 'Contract registered', + // Withdraw steps + withdraw_enter_identity: 'Withdraw credits', + withdraw_configure: 'Configure withdrawal', + withdraw_submitting: 'Submitting withdrawal...', + withdraw_tracking: 'Processing withdrawal...', + withdraw_complete: 'Withdrawal complete', }; return descriptions[step]; } @@ -689,6 +731,12 @@ export function getStepProgress(step: BridgeStep): number { contract_review: 60, contract_registering: 80, contract_complete: 100, + // Withdraw steps + withdraw_enter_identity: 20, + withdraw_configure: 40, + withdraw_submitting: 70, + withdraw_tracking: 85, + withdraw_complete: 100, }; return progress[step]; } @@ -715,6 +763,9 @@ export function isProcessingStep(step: BridgeStep): boolean { 'manage_updating', // Contract registration processing steps 'contract_registering', + // Withdraw processing steps + 'withdraw_submitting', + 'withdraw_tracking', ]; return processingSteps.includes(step); } @@ -1477,3 +1528,237 @@ export function resetFaucetState(state: BridgeState): BridgeState { faucetError: undefined, }; } + +// ============================================================================ +// Withdraw (Asset Unlock) State Functions +// ============================================================================ + +/** + * Start fetching identity keys + balance for withdrawal + */ +export function setWithdrawIdentityFetching(state: BridgeState, identityId: string): BridgeState { + return { + ...state, + targetIdentityId: identityId, + withdrawIdentityFetching: true, + withdrawIdentityFetchError: undefined, + withdrawIdentityKeys: undefined, + withdrawBalance: undefined, + withdrawSigningKeyInfo: undefined, + withdrawKeyValidationError: undefined, + }; +} + +/** + * Identity fetch succeeded — advance to configure step with keys + balance + */ +export function setWithdrawIdentityFetched( + state: BridgeState, + keys: IdentityPublicKeyInfo[], + balanceCredits: bigint +): BridgeState { + return { + ...state, + step: 'withdraw_configure', + withdrawIdentityFetching: false, + withdrawIdentityFetchError: undefined, + withdrawIdentityKeys: keys, + withdrawBalance: balanceCredits, + }; +} + +/** + * Identity fetch failed + */ +export function setWithdrawIdentityFetchError(state: BridgeState, error: string): BridgeState { + return { + ...state, + withdrawIdentityFetching: false, + withdrawIdentityFetchError: error, + withdrawIdentityKeys: undefined, + withdrawBalance: undefined, + }; +} + +/** + * Signing key validated (matched a TRANSFER key on the identity) + */ +export function setWithdrawKeyValidated( + state: BridgeState, + keyId: number, + purpose: number, + securityLevel: number, + privateKeyWif: string +): BridgeState { + return { + ...state, + withdrawPrivateKeyWif: privateKeyWif, + withdrawSigningKeyInfo: { keyId, purpose, securityLevel }, + withdrawKeyValidationError: undefined, + }; +} + +/** + * Key validation failed + */ +export function setWithdrawKeyValidationError(state: BridgeState, error: string): BridgeState { + return { + ...state, + withdrawSigningKeyInfo: undefined, + withdrawPrivateKeyWif: undefined, + withdrawKeyValidationError: error, + }; +} + +/** + * Clear key validation (when private key input changes) + */ +export function clearWithdrawKeyValidation(state: BridgeState): BridgeState { + return { + ...state, + withdrawSigningKeyInfo: undefined, + withdrawPrivateKeyWif: undefined, + withdrawKeyValidationError: undefined, + }; +} + +/** + * Set the destination address input; pass `error` when it failed validation. + * The raw input is kept either way so the user can correct typos in place. + */ +export function setWithdrawAddress(state: BridgeState, input: string, error?: string): BridgeState { + return { + ...state, + withdrawToAddress: input || undefined, + withdrawAddressError: error, + }; +} + +/** + * Set validated withdrawal amount (in credits) + */ +export function setWithdrawAmount(state: BridgeState, credits: bigint, input: string): BridgeState { + return { + ...state, + withdrawAmountCredits: credits, + withdrawAmountInput: input, + withdrawAmountError: undefined, + }; +} + +/** + * Amount validation failed (keeps the raw input for correction) + */ +export function setWithdrawAmountError(state: BridgeState, input: string, error: string): BridgeState { + return { + ...state, + withdrawAmountCredits: undefined, + withdrawAmountInput: input, + withdrawAmountError: error, + }; +} + +/** + * Start submitting the withdrawal transition + */ +export function setWithdrawSubmitting(state: BridgeState): BridgeState { + return { + ...state, + step: 'withdraw_submitting', + withdrawResult: undefined, + withdrawStatus: undefined, + withdrawStatusError: undefined, + }; +} + +/** + * Withdrawal transition accepted (or its outcome is unknown after a submission + * timeout) — start tracking payout status. `remainingBalance` is undefined in + * the timed-out case, where the post-withdrawal balance was never observed. + */ +export function setWithdrawSubmitted(state: BridgeState, remainingBalance?: bigint): BridgeState { + return { + ...state, + step: 'withdraw_tracking', + withdrawResult: { success: true, remainingBalance }, + withdrawStatus: WithdrawalStatus.QUEUED, + }; +} + +/** + * Withdrawal transition failed + */ +export function setWithdrawSubmitError(state: BridgeState, error: string): BridgeState { + return { + ...state, + step: 'withdraw_complete', + withdrawResult: { success: false, error }, + }; +} + +/** + * Update tracked withdrawal status; terminal statuses finish the flow. + * A successful status read also clears any transient polling warning. + */ +export function setWithdrawStatusUpdate(state: BridgeState, status: number): BridgeState { + const isTerminal = status === WithdrawalStatus.COMPLETE || status === WithdrawalStatus.EXPIRED; + return { + ...state, + step: isTerminal ? 'withdraw_complete' : state.step, + withdrawStatus: status, + withdrawStatusError: undefined, + }; +} + +/** + * Set or clear the transient status-polling note shown on the tracking step + */ +export function setWithdrawStatusNote(state: BridgeState, note?: string): BridgeState { + return { + ...state, + withdrawStatusError: note, + }; +} + +/** + * Stop tracking without a terminal status (timeout or user chose to leave). + * The withdrawal itself succeeded — this only annotates why tracking stopped. + */ +export function setWithdrawTrackingTimeout(state: BridgeState, explanation: string): BridgeState { + return { + ...state, + step: 'withdraw_complete', + withdrawStatusError: explanation, + }; +} + +/** + * Go back to withdraw identity entry, clearing key material + */ +export function setWithdrawBackToEntry(state: BridgeState): BridgeState { + return { + ...state, + step: 'withdraw_enter_identity', + withdrawSigningKeyInfo: undefined, + withdrawPrivateKeyWif: undefined, + withdrawKeyValidationError: undefined, + withdrawAmountCredits: undefined, + withdrawAmountInput: undefined, + withdrawAmountError: undefined, + withdrawToAddress: undefined, + withdrawAddressError: undefined, + }; +} + +/** + * Return to configure step to retry a failed withdrawal + */ +export function setWithdrawRetry(state: BridgeState): BridgeState { + return { + ...state, + step: 'withdraw_configure', + withdrawResult: undefined, + withdrawStatus: undefined, + withdrawStatusError: undefined, + }; +} diff --git a/src/utils/credits.test.ts b/src/utils/credits.test.ts new file mode 100644 index 0000000..2ce5e0b --- /dev/null +++ b/src/utils/credits.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; + +import { + CREDITS_PER_DASH, + MIN_WITHDRAWAL_CREDITS, + MAX_WITHDRAWAL_CREDITS, + WITHDRAWAL_FEE_RESERVE_CREDITS, + parseDashToCredits, + formatCreditsAsDash, + formatCredits, + maxWithdrawableCredits, + validateWithdrawalAmount, +} from './credits.js'; + +describe('parseDashToCredits', () => { + it('parses whole DASH amounts', () => { + expect(parseDashToCredits('1')).toBe(CREDITS_PER_DASH); + expect(parseDashToCredits('500')).toBe(MAX_WITHDRAWAL_CREDITS); + }); + + it('parses fractional amounts at credit precision', () => { + expect(parseDashToCredits('0.00001')).toBe(1_000_000n); // 1000 duffs + expect(parseDashToCredits('0.00000001')).toBe(1000n); // 1 duff + expect(parseDashToCredits('0.00000000001')).toBe(1n); // 1 credit + expect(parseDashToCredits('1.5')).toBe(150_000_000_000n); + }); + + it('accepts the consensus minimum as a DASH string', () => { + expect(parseDashToCredits('0.00001')).toBe(MIN_WITHDRAWAL_CREDITS); + }); + + it('rejects more than 11 fractional digits', () => { + expect(parseDashToCredits('0.000000000001')).toBeNull(); + }); + + it('rejects malformed input', () => { + expect(parseDashToCredits('')).toBeNull(); + expect(parseDashToCredits('abc')).toBeNull(); + expect(parseDashToCredits('-1')).toBeNull(); + expect(parseDashToCredits('1.')).toBeNull(); + expect(parseDashToCredits('.5')).toBeNull(); + expect(parseDashToCredits('1e5')).toBeNull(); + expect(parseDashToCredits('1,5')).toBeNull(); + }); + + it('trims surrounding whitespace', () => { + expect(parseDashToCredits(' 2 ')).toBe(2n * CREDITS_PER_DASH); + }); +}); + +describe('formatCreditsAsDash', () => { + it('formats whole DASH without a fraction', () => { + expect(formatCreditsAsDash(CREDITS_PER_DASH)).toBe('1'); + expect(formatCreditsAsDash(0n)).toBe('0'); + }); + + it('trims trailing zeros in the fraction', () => { + expect(formatCreditsAsDash(150_000_000_000n)).toBe('1.5'); + expect(formatCreditsAsDash(1_000_000n)).toBe('0.00001'); + expect(formatCreditsAsDash(1n)).toBe('0.00000000001'); + }); + + it('formats mid-range balances', () => { + expect(formatCreditsAsDash(250_000_000_000n)).toBe('2.5'); + }); + + it('round-trips with parseDashToCredits', () => { + for (const credits of [1n, 1000n, MIN_WITHDRAWAL_CREDITS, 123_456_789_012n, MAX_WITHDRAWAL_CREDITS]) { + expect(parseDashToCredits(formatCreditsAsDash(credits))).toBe(credits); + } + }); +}); + +describe('formatCredits', () => { + it('adds thousands separators', () => { + expect(formatCredits(1_000_000n)).toBe('1,000,000'); + expect(formatCredits(1234n)).toBe('1,234'); + }); +}); + +describe('maxWithdrawableCredits', () => { + it('reserves fee headroom from the balance', () => { + expect(maxWithdrawableCredits(1_000_000_000n)).toBe(1_000_000_000n - WITHDRAWAL_FEE_RESERVE_CREDITS); + }); + + it('caps at the consensus maximum for huge balances', () => { + expect(maxWithdrawableCredits(MAX_WITHDRAWAL_CREDITS * 3n)).toBe(MAX_WITHDRAWAL_CREDITS); + }); + + it('goes negative for dust balances', () => { + expect(maxWithdrawableCredits(1000n) < 0n).toBe(true); + }); +}); + +describe('validateWithdrawalAmount', () => { + const balance = 25_000_000_000n; // 0.25 DASH + + it('accepts a normal amount', () => { + expect(validateWithdrawalAmount('0.1', balance)).toEqual({ credits: 10_000_000_000n }); + }); + + it('rejects malformed input', () => { + const result = validateWithdrawalAmount('abc', balance); + expect('error' in result && result.error).toContain('valid DASH amount'); + }); + + it('rejects amounts below the consensus minimum', () => { + const result = validateWithdrawalAmount('0.000001', balance); + expect('error' in result && result.error).toContain('Minimum withdrawal'); + }); + + it('rejects amounts above the consensus maximum', () => { + const result = validateWithdrawalAmount('501', MAX_WITHDRAWAL_CREDITS * 2n); + expect('error' in result && result.error).toContain('Maximum withdrawal'); + }); + + it('rejects amounts above the balance', () => { + const result = validateWithdrawalAmount('1', balance); + expect('error' in result && result.error).toContain('exceeds your balance'); + }); + + it('reports a too-small balance instead of a negative maximum', () => { + // Balance above the withdrawal minimum but below the fee reserve: + // the amount passes the balance check but no maximum is withdrawable. + const dustBalance = 2_000_000n; // 0.00002 DASH, reserve is 50M + const result = validateWithdrawalAmount('0.00001', dustBalance); + expect('error' in result && result.error).toContain('too small'); + expect('error' in result && result.error).not.toContain('-'); + }); + + it('rejects amounts inside the fee-reserve headroom', () => { + // Between balance - reserve and balance: passes the plain balance check + // but not the fee-reserve check. + const result = validateWithdrawalAmount('0.24999', balance); + expect('error' in result && result.error).toContain('transition fee'); + }); + + it('accepts exactly the max withdrawable amount', () => { + const max = maxWithdrawableCredits(balance); + expect(validateWithdrawalAmount(formatCreditsAsDash(max), balance)).toEqual({ credits: max }); + }); +}); diff --git a/src/utils/credits.ts b/src/utils/credits.ts new file mode 100644 index 0000000..7b4909d --- /dev/null +++ b/src/utils/credits.ts @@ -0,0 +1,110 @@ +/** + * Credit/duff/DASH unit conversions for Platform credit withdrawals. + * + * 1 duff = 1000 credits; 1 DASH = 100,000,000 duffs = 100,000,000,000 credits. + */ + +export const CREDITS_PER_DUFF = 1000n; +export const DUFFS_PER_DASH = 100_000_000n; +export const CREDITS_PER_DASH = CREDITS_PER_DUFF * DUFFS_PER_DASH; // 100_000_000_000n + +/** + * Minimum withdrawal amount enforced by Platform consensus: 1000 duffs. + * (platform system_limits min_withdrawal_amount, raised from 190 duffs in protocol v12.) + */ +export const MIN_WITHDRAWAL_CREDITS = 1_000_000n; + +/** + * Maximum withdrawal amount enforced by Platform consensus: 500 DASH. + */ +export const MAX_WITHDRAWAL_CREDITS = 50_000_000_000_000n; + +/** Max fractional digits a DASH amount can carry at credit precision. */ +const DASH_DECIMALS = 11; + +/** + * Parse a decimal DASH amount string into credits. + * Returns null for anything that isn't a plain non-negative decimal number + * with at most 11 fractional digits (credit precision). + */ +export function parseDashToCredits(input: string): bigint | null { + const trimmed = input.trim(); + if (!/^\d+(\.\d+)?$/.test(trimmed)) return null; + const [wholePart, fracPart = ''] = trimmed.split('.'); + if (fracPart.length > DASH_DECIMALS) return null; + const fracPadded = fracPart.padEnd(DASH_DECIMALS, '0'); + return BigInt(wholePart) * CREDITS_PER_DASH + BigInt(fracPadded); +} + +/** + * Format a credit amount as a decimal DASH string, trimming trailing zeros. + */ +export function formatCreditsAsDash(credits: bigint): string { + const negative = credits < 0n; + const abs = negative ? -credits : credits; + const whole = abs / CREDITS_PER_DASH; + const frac = (abs % CREDITS_PER_DASH).toString().padStart(DASH_DECIMALS, '0').replace(/0+$/, ''); + const sign = negative ? '-' : ''; + return frac.length > 0 ? `${sign}${whole}.${frac}` : `${sign}${whole}`; +} + +/** + * Format a credit amount with thousands separators (for "= N credits" captions). + */ +export function formatCredits(credits: bigint): string { + return credits.toLocaleString('en-US'); +} + +/** + * Platform's minimum-fee gate for credit withdrawals: the network rejects a + * withdrawal unless balance >= amount + this value (protocol constant + * `state_transition_min_fees.credit_withdrawal`, 400M credits — "credit + * withdrawals are more expensive than the rest"). There is no "withdraw all" + * sentinel in the protocol, so the exact maximum is balance minus this gate. + */ +export const WITHDRAWAL_FEE_RESERVE_CREDITS = 400_000_000n; // 0.004 DASH + +/** + * The most that can be withdrawn from a balance: the per-transition consensus + * maximum, capped by the balance minus the fee reserve. Can be negative for + * dust balances — callers compare against MIN_WITHDRAWAL_CREDITS. + */ +export function maxWithdrawableCredits(balanceCredits: bigint): bigint { + const spendable = balanceCredits - WITHDRAWAL_FEE_RESERVE_CREDITS; + return spendable > MAX_WITHDRAWAL_CREDITS ? MAX_WITHDRAWAL_CREDITS : spendable; +} + +export type WithdrawalAmountValidation = { credits: bigint } | { error: string }; + +/** + * Parse and validate a user-entered DASH amount against the consensus limits + * and the identity's balance (including fee-reserve headroom). + */ +export function validateWithdrawalAmount( + input: string, + balanceCredits: bigint +): WithdrawalAmountValidation { + const credits = parseDashToCredits(input); + if (credits === null) { + return { error: 'Enter a valid DASH amount (up to 11 decimal places)' }; + } + if (credits < MIN_WITHDRAWAL_CREDITS) { + return { error: `Minimum withdrawal is ${formatCreditsAsDash(MIN_WITHDRAWAL_CREDITS)} DASH (1000 duffs)` }; + } + if (credits > MAX_WITHDRAWAL_CREDITS) { + return { error: `Maximum withdrawal is ${formatCreditsAsDash(MAX_WITHDRAWAL_CREDITS)} DASH per transaction` }; + } + if (credits > balanceCredits) { + return { error: `Amount exceeds your balance of ${formatCreditsAsDash(balanceCredits)} DASH` }; + } + const max = maxWithdrawableCredits(balanceCredits); + if (credits > max) { + if (max < MIN_WITHDRAWAL_CREDITS) { + return { error: 'Balance is too small to cover the withdrawal minimum plus the Platform transition fee' }; + } + return { + error: `Amount is too close to your full balance to cover the Platform transition fee. Maximum: ${formatCreditsAsDash(max)} DASH`, + }; + } + return { credits }; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..d5a6bab --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,11 @@ +/** + * Extract a human-readable message from an unknown caught value. + * WasmSdkError is not a standard Error, so a plain `instanceof Error` + * check misses it — fall back to any object with a `message` property. + */ +export function extractErrorMessage(error: unknown): string { + if (error && typeof error === 'object' && 'message' in error) { + return String((error as { message: unknown }).message); + } + return error instanceof Error ? error.message : String(error); +}