From cb3be328069219127a4a9fbb75d7e202814b3652 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 14 Aug 2026 23:52:04 -0500 Subject: [PATCH 1/4] feat: add withdraw mode for platform credit asset unlocks Adds a new Withdraw Credits mode that converts Platform credits back to Dash Core funds via identity credit withdrawal transitions (asset unlocks). The flow fetches an identity's keys and balance, validates a TRANSFER-purpose signing key client-side (OWNER-signed withdrawals with an output script are consensus-rejected), validates the destination P2PKH/P2SH address per network, enforces consensus amount limits (min 1000 duffs, max 500 DASH) plus fee-reserve headroom, submits via sdk.identities.creditWithdrawal, and tracks the payout by polling the withdrawals system contract (QUEUED, POOLED, BROADCASTED, COMPLETE, EXPIRED). Polling failures and submission timeouts are never presented as withdrawal failures since credits leave the identity once the transition is accepted; a timed-out submission enters tracking instead of a retryable failure screen to prevent double spends. --- e2e/deterministic.spec.ts | 60 +++++ index.html | 129 ++++++++++ src/config.ts | 10 +- src/crypto/address.test.ts | 62 +++++ src/crypto/address.ts | 49 +++- src/crypto/index.ts | 2 +- src/e2e-mock-constants.ts | 5 + src/main.ts | 351 +++++++++++++++++++++++++- src/platform/index.ts | 9 + src/platform/withdrawal-status.ts | 15 ++ src/platform/withdrawal.ts | 153 +++++++++++ src/types.ts | 42 +++- src/ui/components.ts | 404 ++++++++++++++++++++++++++++++ src/ui/index.ts | 18 ++ src/ui/state.test.ts | 125 ++++++++- src/ui/state.ts | 285 +++++++++++++++++++++ src/utils/credits.test.ts | 133 ++++++++++ src/utils/credits.ts | 104 ++++++++ src/utils/errors.ts | 11 + 19 files changed, 1960 insertions(+), 7 deletions(-) create mode 100644 src/crypto/address.test.ts create mode 100644 src/platform/withdrawal-status.ts create mode 100644 src/platform/withdrawal.ts create mode 100644 src/utils/credits.test.ts create mode 100644 src/utils/credits.ts create mode 100644 src/utils/errors.ts diff --git a/e2e/deterministic.spec.ts b/e2e/deterministic.spec.ts index 0b145fd..fc41018 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,64 @@ 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(); + await expect(page.getByText('0.1 DASH')).toBeVisible(); + await expect(page.getByText(E2E_MOCK_WITHDRAW_ADDRESS)).toBeVisible(); + await expect(page.getByText('0.15 DASH')).toBeVisible(); // remaining balance + }); + 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..9d7b3a1 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: #666; + 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..9e5dd80 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 @@ -1542,6 +1572,204 @@ function setupEventListeners(container: HTMLElement) { } }); + // ============================================================================ + // Withdraw Event Listeners + // ============================================================================ + + // 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; + } + + 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(); + + if (!privateKeyWif) { + 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 // ============================================================================ @@ -2915,6 +3143,127 @@ 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) { + 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))); + } +} + +/** + * 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/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..5b6c15b --- /dev/null +++ b/src/platform/withdrawal.ts @@ -0,0 +1,153 @@ +import { withRetry, type RetryOptions } from '../utils/retry.js'; +import { extractErrorMessage } from '../utils/errors.js'; +import { loadSdkModule } from './sdkModule.js'; +import { + PLATFORM_PUT_SETTINGS, + 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); + + const remainingBalance = await withPlatformOperationTimeout( + withRetry( + () => sdk.identities.creditWithdrawal({ + identity, + amount: amountCredits, + toAddress, + coreFeePerByte: 1, + signer, + settings: PLATFORM_PUT_SETTINGS, + }), + retryOptions + ), + '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); + const errorMessage = extractErrorMessage(error); + return { + success: false, + error: errorMessage, + timedOut: errorMessage.startsWith('Timed out while'), + }; + } + }, 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..026217d 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,375 @@ 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'; + + 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 = ` +
+ + +

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..2a771bd 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,109 @@ 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('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..bfd4c50 --- /dev/null +++ b/src/utils/credits.test.ts @@ -0,0 +1,133 @@ +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('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..a37d028 --- /dev/null +++ b/src/utils/credits.ts @@ -0,0 +1,104 @@ +/** + * 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'); +} + +/** + * Credits held back from the spendable balance so the withdrawal transition's + * own Platform processing fee (paid from the remaining identity balance) can + * still be covered. Heuristic headroom, not a consensus value. + */ +export const WITHDRAWAL_FEE_RESERVE_CREDITS = 50_000_000n; // 0.0005 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` }; + } + if (credits > maxWithdrawableCredits(balanceCredits)) { + return { + error: `Amount is too close to your full balance to cover the Platform transition fee. Maximum: ${formatCreditsAsDash(maxWithdrawableCredits(balanceCredits))} 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); +} From 4b5a72d590183795e5f3fc65180cfe64fef188d9 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 15 Aug 2026 00:12:42 -0500 Subject: [PATCH 2/4] fix: address CodeRabbit review findings on withdraw mode Make credit withdrawal strictly single-submit (no retry wrapper, SDK retries 0) so an ambiguous network error can never rebuild the transition with a fresh nonce and withdraw twice; detect submission timeouts with a typed PlatformOperationTimeoutError instead of message matching; skip refetching an already-fetched identity so a validated TRANSFER key is not discarded on blur; never render the private key WIF back into the DOM; report a too-small balance instead of a negative maximum in the fee-reserve error; associate withdraw form labels with their inputs; improve pending status label contrast; scope e2e completion assertions. --- e2e/deterministic.spec.ts | 7 ++++--- index.html | 2 +- src/main.ts | 13 ++++++++++++- src/platform/client.ts | 15 ++++++++++++++- src/platform/withdrawal.ts | 30 ++++++++++++++++-------------- src/ui/components.ts | 9 ++++----- src/ui/state.test.ts | 7 +++++++ src/utils/credits.test.ts | 9 +++++++++ src/utils/credits.ts | 8 ++++++-- 9 files changed, 73 insertions(+), 27 deletions(-) diff --git a/e2e/deterministic.spec.ts b/e2e/deterministic.spec.ts index fc41018..d8733e6 100644 --- a/e2e/deterministic.spec.ts +++ b/e2e/deterministic.spec.ts @@ -147,9 +147,10 @@ test.describe('Deterministic UI E2E (mock mode)', () => { // Submit walks the mock status sequence to completion await page.click('#withdraw-submit-btn'); await expect(page.getByText('Withdrawal Complete!')).toBeVisible(); - await expect(page.getByText('0.1 DASH')).toBeVisible(); - await expect(page.getByText(E2E_MOCK_WITHDRAW_ADDRESS)).toBeVisible(); - await expect(page.getByText('0.15 DASH')).toBeVisible(); // remaining balance + 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 }) => { diff --git a/index.html b/index.html index 9d7b3a1..1665949 100644 --- a/index.html +++ b/index.html @@ -2828,7 +2828,7 @@ flex-direction: column; align-items: center; gap: 6px; - color: #666; + color: #9a9aa5; /* keeps small pending labels above WCAG AA 4.5:1 on the dark background */ font-size: 0.8rem; } diff --git a/src/main.ts b/src/main.ts index 9e5dd80..e339243 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1620,6 +1620,12 @@ function setupEventListeners(container: HTMLElement) { 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; @@ -1649,8 +1655,13 @@ function setupEventListeners(container: HTMLElement) { 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) { - updateState(clearWithdrawKeyValidation(state)); + if (!state.withdrawSigningKeyInfo && state.withdrawKeyValidationError) { + updateState(clearWithdrawKeyValidation(state)); + } return; } 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/withdrawal.ts b/src/platform/withdrawal.ts index 5b6c15b..71eadd9 100644 --- a/src/platform/withdrawal.ts +++ b/src/platform/withdrawal.ts @@ -3,6 +3,7 @@ import { extractErrorMessage } from '../utils/errors.js'; import { loadSdkModule } from './sdkModule.js'; import { PLATFORM_PUT_SETTINGS, + PlatformOperationTimeoutError, fetchIdentityWithSdk, withConnectedPlatformSdk, withPlatformOperationTimeout, @@ -66,18 +67,20 @@ export async function withdrawCredits( 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( - withRetry( - () => sdk.identities.creditWithdrawal({ - identity, - amount: amountCredits, - toAddress, - coreFeePerByte: 1, - signer, - settings: PLATFORM_PUT_SETTINGS, - }), - retryOptions - ), + 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 ); @@ -87,11 +90,10 @@ export async function withdrawCredits( return { success: true, remainingBalance }; } catch (error) { console.error('Credit withdrawal error:', error); - const errorMessage = extractErrorMessage(error); return { success: false, - error: errorMessage, - timedOut: errorMessage.startsWith('Timed out while'), + error: extractErrorMessage(error), + timedOut: error instanceof PlatformOperationTimeoutError, }; } }, retryOptions); diff --git a/src/ui/components.ts b/src/ui/components.ts index 026217d..8766b2d 100644 --- a/src/ui/components.ts +++ b/src/ui/components.ts @@ -2330,7 +2330,7 @@ function renderWithdrawEnterIdentityStep(state: BridgeState): HTMLElement { form.innerHTML = `
- + - +

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

${keyValidationHtml}
- +
- +
{ 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'); diff --git a/src/utils/credits.test.ts b/src/utils/credits.test.ts index bfd4c50..2ce5e0b 100644 --- a/src/utils/credits.test.ts +++ b/src/utils/credits.test.ts @@ -119,6 +119,15 @@ describe('validateWithdrawalAmount', () => { 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. diff --git a/src/utils/credits.ts b/src/utils/credits.ts index a37d028..1754ee5 100644 --- a/src/utils/credits.ts +++ b/src/utils/credits.ts @@ -95,9 +95,13 @@ export function validateWithdrawalAmount( if (credits > balanceCredits) { return { error: `Amount exceeds your balance of ${formatCreditsAsDash(balanceCredits)} DASH` }; } - if (credits > maxWithdrawableCredits(balanceCredits)) { + 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(maxWithdrawableCredits(balanceCredits))} DASH`, + error: `Amount is too close to your full balance to cover the Platform transition fee. Maximum: ${formatCreditsAsDash(max)} DASH`, }; } return { credits }; From e9abb86528385c3c4091d926e1f1a8116d006b7a Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 15 Aug 2026 00:35:42 -0500 Subject: [PATCH 3/4] fix: verify withdrawal landed before offering retry on submission errors A creditWithdrawal error thrown after broadcast (e.g. while waiting for the state transition result) previously surfaced as a retryable failure, allowing a second submission to withdraw twice. Non-timeout submission errors now check the withdrawals contract for a document created since submission before showing the failure screen; if found, the flow enters status tracking instead. --- src/main.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/main.ts b/src/main.ts index e339243..3ab3f37 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3214,6 +3214,18 @@ async function startWithdrawal() { } 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; } @@ -3226,6 +3238,27 @@ async function startWithdrawal() { } } +/** + * 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 From 71ce1b26b027f9278e4965b34e67661b6c79c76f Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 15 Aug 2026 11:38:36 -0500 Subject: [PATCH 4/4] fix: use exact protocol fee gate for Max, add key backup upload to withdraw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Max button and amount validation previously reserved a heuristic 50M credits, but Platform rejects withdrawals unless balance >= amount + 400M credits (protocol constant state_transition_min_fees.credit_withdrawal, verified against a live testnet rejection). Reserve exactly that gate so Max computes the true maximum. Also adds the key-backup upload section to the withdraw identity step, preferring the TRANSFER key from the backup and landing on the configure step with the key pre-validated — same pattern as the manage/dpns/contract flows. --- src/main.ts | 41 +++++++++++++++++++++++++++++++++++++++-- src/ui/components.ts | 3 +++ src/utils/credits.ts | 10 ++++++---- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/main.ts b/src/main.ts index 3ab3f37..601a400 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1463,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`); @@ -1474,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; @@ -1576,6 +1577,36 @@ 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); @@ -2002,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; @@ -2014,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; diff --git a/src/ui/components.ts b/src/ui/components.ts index 8766b2d..d13fa36 100644 --- a/src/ui/components.ts +++ b/src/ui/components.ts @@ -2304,6 +2304,7 @@ const WITHDRAW_STATUS_LABELS: { status: number; label: string }[] = [ 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'; @@ -2329,6 +2330,8 @@ function renderWithdrawEnterIdentityStep(state: BridgeState): HTMLElement { } form.innerHTML = ` + ${renderKeyUploadSection('withdraw-key-upload')} +
= 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 = 50_000_000n; // 0.0005 DASH +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