diff --git a/e2e/deterministic.spec.ts b/e2e/deterministic.spec.ts
index 0b145fd..d8733e6 100644
--- a/e2e/deterministic.spec.ts
+++ b/e2e/deterministic.spec.ts
@@ -3,6 +3,8 @@ import {
E2E_MOCK_DPNS_WIF,
E2E_MOCK_IDENTITY_ID,
E2E_MOCK_MANAGE_WIF,
+ E2E_MOCK_WITHDRAW_WIF,
+ E2E_MOCK_WITHDRAW_ADDRESS,
} from '../src/e2e-mock-constants';
const MOCK_QUERY = '/?network=testnet&e2e=mock';
@@ -92,6 +94,65 @@ test.describe('Deterministic UI E2E (mock mode)', () => {
await expect(page.getByText('Update Complete!')).toBeVisible();
});
+ test('withdraw flow validates inputs and completes with status tracking', async ({ page }) => {
+ await page.goto(MOCK_QUERY);
+
+ await page.click('#mode-withdraw-btn');
+ await expect(page.locator('#withdraw-identity-id-input')).toBeVisible();
+
+ // Invalid identity ID is rejected
+ await page.fill('#withdraw-identity-id-input', 'nope');
+ await page.locator('#withdraw-identity-id-input').press('Tab');
+ await expect(page.getByText('Invalid identity ID format')).toBeVisible();
+
+ // Valid identity advances to configure with the balance shown
+ await page.fill('#withdraw-identity-id-input', E2E_MOCK_IDENTITY_ID);
+ await page.locator('#withdraw-identity-id-input').press('Tab');
+ await expect(page.getByText('Configure Withdrawal')).toBeVisible();
+ await expect(page.locator('.withdraw-balance')).toContainText('0.25 DASH');
+
+ // Wrong WIF is rejected, the mock WIF validates as a TRANSFER key
+ await page.fill('#withdraw-private-key-input', 'bad-key');
+ await page.locator('#withdraw-private-key-input').press('Tab');
+ await expect(page.getByText('Mock mode: use the configured test private key')).toBeVisible();
+
+ await page.fill('#withdraw-private-key-input', E2E_MOCK_WITHDRAW_WIF);
+ await page.locator('#withdraw-private-key-input').press('Tab');
+ await expect(page.getByText('Key matches key #3 (TRANSFER / CRITICAL)')).toBeVisible();
+
+ // Bad address is rejected (real validation runs even in mock mode)
+ await page.fill('#withdraw-address-input', 'not-an-address');
+ await page.locator('#withdraw-address-input').press('Tab');
+ await expect(page.locator('#withdraw-address-error')).toBeVisible();
+
+ await page.fill('#withdraw-address-input', E2E_MOCK_WITHDRAW_ADDRESS);
+ await page.locator('#withdraw-address-input').press('Tab');
+ await expect(page.locator('#withdraw-address-error')).toHaveCount(0);
+
+ // Amount below the minimum, above the balance, then valid
+ await page.fill('#withdraw-amount-input', '0.000001');
+ await page.locator('#withdraw-amount-input').press('Tab');
+ await expect(page.locator('#withdraw-amount-error')).toContainText('Minimum withdrawal');
+
+ await page.fill('#withdraw-amount-input', '1');
+ await page.locator('#withdraw-amount-input').press('Tab');
+ await expect(page.locator('#withdraw-amount-error')).toContainText('exceeds your balance');
+ await expect(page.locator('#withdraw-submit-btn')).toBeDisabled();
+
+ await page.fill('#withdraw-amount-input', '0.1');
+ await page.locator('#withdraw-amount-input').press('Tab');
+ await expect(page.locator('#withdraw-amount-credits')).toContainText('10,000,000,000 credits');
+ await expect(page.locator('#withdraw-submit-btn')).toBeEnabled();
+
+ // Submit walks the mock status sequence to completion
+ await page.click('#withdraw-submit-btn');
+ await expect(page.getByText('Withdrawal Complete!')).toBeVisible();
+ const details = page.locator('.withdraw-success-details');
+ await expect(details).toContainText('Amount: 0.1 DASH');
+ await expect(details).toContainText(E2E_MOCK_WITHDRAW_ADDRESS);
+ await expect(details).toContainText('Remaining balance: 0.15 DASH');
+ });
+
test('standalone DPNS flow validates identity + key and completes registration', async ({ page }) => {
await page.goto(MOCK_QUERY);
diff --git a/index.html b/index.html
index 79f019d..1665949 100644
--- a/index.html
+++ b/index.html
@@ -2772,6 +2772,135 @@
padding: 8px 10px;
}
}
+
+ /* ============================================================================
+ Withdraw (Asset Unlock) Styles
+ ============================================================================ */
+
+ .withdraw-identity-summary {
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 10px;
+ padding: 14px 16px;
+ margin-bottom: 20px;
+ text-align: left;
+ }
+
+ .withdraw-identity-summary .withdraw-identity-id code {
+ font-size: 0.8rem;
+ color: #b0b0b0;
+ word-break: break-all;
+ }
+
+ .withdraw-identity-summary .withdraw-balance {
+ color: #fff;
+ margin-top: 6px;
+ }
+
+ .withdraw-amount-row {
+ display: flex;
+ gap: 8px;
+ align-items: stretch;
+ }
+
+ .withdraw-amount-row .manage-input {
+ flex: 1;
+ }
+
+ .withdraw-fee-note {
+ background: rgba(0, 141, 228, 0.08);
+ border: 1px solid rgba(0, 141, 228, 0.2);
+ border-radius: 10px;
+ padding: 10px 14px;
+ margin-bottom: 16px;
+ text-align: left;
+ }
+
+ .withdraw-status-timeline {
+ display: flex;
+ justify-content: center;
+ gap: 18px;
+ margin: 24px 0;
+ flex-wrap: wrap;
+ }
+
+ .withdraw-status-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ color: #9a9aa5; /* keeps small pending labels above WCAG AA 4.5:1 on the dark background */
+ font-size: 0.8rem;
+ }
+
+ .withdraw-status-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.15);
+ }
+
+ .withdraw-status-item.done .withdraw-status-dot {
+ background: #4caf50;
+ }
+
+ .withdraw-status-item.done {
+ color: #4caf50;
+ }
+
+ .withdraw-status-item.active .withdraw-status-dot {
+ background: #008de4;
+ box-shadow: 0 0 8px rgba(0, 141, 228, 0.7);
+ }
+
+ .withdraw-status-item.active {
+ color: #fff;
+ }
+
+ .withdraw-poll-warning {
+ color: #ffb74d;
+ font-size: 0.85rem;
+ margin: 12px 0;
+ }
+
+ .withdraw-success-details {
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 10px;
+ padding: 14px 16px;
+ margin-bottom: 16px;
+ text-align: left;
+ color: #ddd;
+ }
+
+ .withdraw-success-details code {
+ word-break: break-all;
+ font-size: 0.85rem;
+ }
+
+ .withdraw-success-msg {
+ color: #4caf50;
+ margin-bottom: 16px;
+ }
+
+ .withdraw-expired-msg,
+ .withdraw-pending-msg {
+ color: #ffb74d;
+ margin-bottom: 16px;
+ }
+
+ .withdraw-error-msg {
+ background: rgba(244, 67, 54, 0.1);
+ border: 1px solid rgba(244, 67, 54, 0.3);
+ border-radius: 10px;
+ padding: 14px 16px;
+ margin-bottom: 16px;
+ text-align: left;
+ }
+
+ .withdraw-error-msg .error-detail {
+ color: #ef9a9a;
+ font-size: 0.85rem;
+ word-break: break-word;
+ }
diff --git a/src/config.ts b/src/config.ts
index a813fe1..dd4cac7 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -3,6 +3,8 @@ export interface NetworkConfig {
name: string;
insightApiUrl: string;
addressPrefix: number;
+ /** Base58 version byte for P2SH addresses (dashcore SCRIPT_ADDRESS: 16 mainnet, 19 testnet/devnet). */
+ p2shPrefix: number;
wifPrefix: number;
minFee: number;
dustThreshold: number;
@@ -36,6 +38,7 @@ export const TESTNET: NetworkConfig = {
name: 'testnet',
insightApiUrl: 'https://insight.testnet.networks.dash.org/insight-api',
addressPrefix: 140,
+ p2shPrefix: 19,
wifPrefix: 239,
minFee: 1000,
dustThreshold: 546,
@@ -49,6 +52,7 @@ export const MAINNET: NetworkConfig = {
name: 'mainnet',
insightApiUrl: 'https://insight.dash.org/insight-api',
addressPrefix: 76,
+ p2shPrefix: 16,
wifPrefix: 204,
minFee: 1000,
dustThreshold: 546,
@@ -61,6 +65,7 @@ export const DEVNET_PALOMA: NetworkConfig = {
name: 'devnet-paloma',
insightApiUrl: 'https://insight.paloma.networks.dash.org/insight-api',
addressPrefix: 140,
+ p2shPrefix: 19,
wifPrefix: 239,
minFee: 1000,
dustThreshold: 546,
@@ -108,7 +113,7 @@ function loadCustomDevnets(): NetworkConfig[] {
if (!stored) return [];
const parsed = JSON.parse(stored);
if (!Array.isArray(parsed)) return [];
- return parsed.filter(
+ const valid = parsed.filter(
(c): c is NetworkConfig =>
c &&
typeof c.name === 'string' &&
@@ -126,6 +131,8 @@ function loadCustomDevnets(): NetworkConfig[] {
(c.useTrustedContext === undefined || typeof c.useTrustedContext === 'boolean') &&
(c.trustedQuorumUrl === undefined || typeof c.trustedQuorumUrl === 'string')
);
+ // Devnets saved before p2shPrefix existed default to the testnet/devnet value.
+ return valid.map((c) => (typeof c.p2shPrefix === 'number' ? c : { ...c, p2shPrefix: 19 }));
} catch {
return [];
}
@@ -164,6 +171,7 @@ export function createCustomDevnetConfig(params: {
name: params.name,
insightApiUrl: params.insightApiUrl,
addressPrefix: 140,
+ p2shPrefix: 19,
wifPrefix: 239,
minFee: 1000,
dustThreshold: 546,
diff --git a/src/crypto/address.test.ts b/src/crypto/address.test.ts
new file mode 100644
index 0000000..64bf0c0
--- /dev/null
+++ b/src/crypto/address.test.ts
@@ -0,0 +1,62 @@
+import { describe, it, expect } from 'vitest';
+
+import { validateCoreAddress } from './address.js';
+import { base58CheckEncode } from '../utils/base58.js';
+import { TESTNET, MAINNET } from '../config.js';
+
+function makeAddress(prefix: number, fill = 0x42): string {
+ const payload = new Uint8Array(21);
+ payload[0] = prefix;
+ payload.fill(fill, 1);
+ return base58CheckEncode(payload);
+}
+
+describe('validateCoreAddress', () => {
+ it('accepts a testnet P2PKH address', () => {
+ const result = validateCoreAddress(makeAddress(TESTNET.addressPrefix), TESTNET);
+ expect(result).toEqual({ valid: true, type: 'p2pkh' });
+ });
+
+ it('accepts a testnet P2SH address', () => {
+ const result = validateCoreAddress(makeAddress(TESTNET.p2shPrefix), TESTNET);
+ expect(result).toEqual({ valid: true, type: 'p2sh' });
+ });
+
+ it('accepts mainnet P2PKH and P2SH addresses on mainnet', () => {
+ expect(validateCoreAddress(makeAddress(MAINNET.addressPrefix), MAINNET).valid).toBe(true);
+ expect(validateCoreAddress(makeAddress(MAINNET.p2shPrefix), MAINNET).valid).toBe(true);
+ });
+
+ it('rejects a mainnet address on testnet with a wrong-network error', () => {
+ const result = validateCoreAddress(makeAddress(MAINNET.addressPrefix), TESTNET);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('wrong network');
+ });
+
+ it('rejects a bech32m platform address with a targeted error', () => {
+ const result = validateCoreAddress(
+ 'tdash1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq',
+ TESTNET
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('Platform address');
+ });
+
+ it('rejects garbage and bad checksums', () => {
+ expect(validateCoreAddress('not-an-address', TESTNET).valid).toBe(false);
+ expect(validateCoreAddress('yYyYyYyYyYyYyYyYyYyYyYyYyYyYyYyYyY', TESTNET).valid).toBe(false);
+ expect(validateCoreAddress('', TESTNET).valid).toBe(false);
+ });
+
+ it('rejects payloads that are not 21 bytes', () => {
+ const short = base58CheckEncode(new Uint8Array([TESTNET.addressPrefix, 1, 2, 3]));
+ const result = validateCoreAddress(short, TESTNET);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('length');
+ });
+
+ it('trims surrounding whitespace', () => {
+ const addr = makeAddress(TESTNET.addressPrefix);
+ expect(validateCoreAddress(` ${addr} `, TESTNET).valid).toBe(true);
+ });
+});
diff --git a/src/crypto/address.ts b/src/crypto/address.ts
index 9aaa43c..8dacf26 100644
--- a/src/crypto/address.ts
+++ b/src/crypto/address.ts
@@ -1,8 +1,55 @@
import { hash160 } from './hash.js';
-import { base58CheckEncode } from '../utils/base58.js';
+import { base58CheckEncode, base58CheckDecode } from '../utils/base58.js';
import { concatBytes } from '../utils/hex.js';
import type { NetworkConfig } from '../config.js';
+export interface CoreAddressValidation {
+ valid: boolean;
+ type?: 'p2pkh' | 'p2sh';
+ error?: string;
+}
+
+/**
+ * Validate a Dash Core (L1) base58check address for the given network.
+ * Accepts P2PKH and P2SH addresses only — the two script types Platform
+ * allows as credit-withdrawal destinations.
+ */
+export function validateCoreAddress(
+ address: string,
+ network: NetworkConfig
+): CoreAddressValidation {
+ const trimmed = address.trim();
+ if (trimmed.length === 0) {
+ return { valid: false, error: 'Enter a Dash address' };
+ }
+ if (trimmed.toLowerCase().startsWith('dash1') || trimmed.toLowerCase().startsWith('tdash1')) {
+ return {
+ valid: false,
+ error: 'This looks like a Platform address. Enter a Dash Core address (starts with X on mainnet, y on testnet).',
+ };
+ }
+ let payload: Uint8Array;
+ try {
+ payload = base58CheckDecode(trimmed);
+ } catch {
+ return { valid: false, error: 'Invalid address (bad characters or checksum)' };
+ }
+ if (payload.length !== 21) {
+ return { valid: false, error: 'Invalid address (unexpected length)' };
+ }
+ const version = payload[0];
+ if (version === network.addressPrefix) {
+ return { valid: true, type: 'p2pkh' };
+ }
+ if (version === network.p2shPrefix) {
+ return { valid: true, type: 'p2sh' };
+ }
+ return {
+ valid: false,
+ error: `This address is not valid for ${network.name} (wrong network prefix)`,
+ };
+}
+
/**
* Generate P2PKH address from public key
*/
diff --git a/src/crypto/index.ts b/src/crypto/index.ts
index aef7d69..5906757 100644
--- a/src/crypto/index.ts
+++ b/src/crypto/index.ts
@@ -8,7 +8,7 @@ export {
updateKeyType,
generateDefaultIdentityKeys,
} from './keys.js';
-export { publicKeyToAddress, publicKeyToHash } from './address.js';
+export { publicKeyToAddress, publicKeyToHash, validateCoreAddress } from './address.js';
export {
signHash,
createP2PKHScriptSig,
diff --git a/src/e2e-mock-constants.ts b/src/e2e-mock-constants.ts
index b2abdee..c82363c 100644
--- a/src/e2e-mock-constants.ts
+++ b/src/e2e-mock-constants.ts
@@ -1,3 +1,8 @@
export const E2E_MOCK_IDENTITY_ID = '11111111111111111111111111111111111111111111';
export const E2E_MOCK_DPNS_WIF = 'cMockDpnsPrivateKeyWif';
export const E2E_MOCK_MANAGE_WIF = 'cMockManagePrivateKeyWif';
+export const E2E_MOCK_WITHDRAW_WIF = 'cMockWithdrawPrivateKeyWif';
+/** A structurally valid testnet P2PKH address (prefix 140) so address validation runs for real in mock mode. */
+export const E2E_MOCK_WITHDRAW_ADDRESS = 'ySMnpcCKx4wD57T5dhjz3t3im3hgaQ5JYG';
+/** Mock identity balance in credits (0.25 DASH). */
+export const E2E_MOCK_WITHDRAW_BALANCE = 25_000_000_000;
diff --git a/src/main.ts b/src/main.ts
index 39452d2..601a400 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,5 +1,12 @@
import { getNetwork, initNetworkRegistry, createCustomDevnetConfig, saveCustomDevnet, isReservedNetworkName, MAINNET, TESTNET } from './config.js';
-import { publicKeyToAddress, signTransaction, generateKeyPair } from './crypto/index.js';
+import { publicKeyToAddress, signTransaction, generateKeyPair, validateCoreAddress } from './crypto/index.js';
+import {
+ formatCreditsAsDash,
+ MIN_WITHDRAWAL_CREDITS,
+ maxWithdrawableCredits,
+ validateWithdrawalAmount,
+} from './utils/credits.js';
+import { extractErrorMessage } from './utils/errors.js';
import { deriveAssetLockKeyPair } from './crypto/hd.js';
import { createAssetLockTransaction, serializeTransaction, calculateTxId } from './transaction/index.js';
import { InsightClient } from './api/insight.js';
@@ -89,6 +96,24 @@ import {
setContractComplete,
setModeContractFromIdentity,
setContractStartBridge,
+ // Withdraw state functions
+ setWithdrawIdentityFetching,
+ setWithdrawIdentityFetched,
+ setWithdrawIdentityFetchError,
+ setWithdrawKeyValidated,
+ setWithdrawKeyValidationError,
+ clearWithdrawKeyValidation,
+ setWithdrawAddress,
+ setWithdrawAmount,
+ setWithdrawAmountError,
+ setWithdrawSubmitting,
+ setWithdrawSubmitted,
+ setWithdrawSubmitError,
+ setWithdrawStatusUpdate,
+ setWithdrawStatusNote,
+ setWithdrawTrackingTimeout,
+ setWithdrawBackToEntry,
+ setWithdrawRetry,
// Faucet state functions
setFaucetSolvingPow,
setFaucetRequesting,
@@ -137,6 +162,8 @@ import {
E2E_MOCK_DPNS_WIF,
E2E_MOCK_IDENTITY_ID,
E2E_MOCK_MANAGE_WIF,
+ E2E_MOCK_WITHDRAW_WIF,
+ E2E_MOCK_WITHDRAW_BALANCE,
} from './e2e-mock-constants.js';
// Global state
@@ -469,6 +496,9 @@ function init() {
if (contractParam) {
void hydrateContractDeepLink(contractParam);
}
+ } else if (modeParam === 'withdraw') {
+ // Deep-link: ?mode=withdraw opens credit withdrawal mode
+ state = setMode(state, 'withdraw');
}
// Render UI
@@ -1433,6 +1463,7 @@ function setupEventListeners(container: HTMLElement) {
function wireKeyUpload(
inputId: string,
onParsed: (result: { identityId: string; privateKeyWif: string }) => void,
+ preferredPurpose?: string,
) {
const fileInput = container.querySelector(`#${inputId}`);
const dropzone = container.querySelector(`#${inputId}-dropzone`);
@@ -1444,7 +1475,7 @@ function setupEventListeners(container: HTMLElement) {
reader.onload = () => {
try {
const json = JSON.parse(reader.result as string);
- const parsed = parseKeyBackup(json);
+ const parsed = parseKeyBackup(json, preferredPurpose);
if (!parsed) {
if (statusEl) { statusEl.textContent = 'No identity or keys found in file'; statusEl.className = 'key-upload-status error'; }
return;
@@ -1542,6 +1573,245 @@ function setupEventListeners(container: HTMLElement) {
}
});
+ // ============================================================================
+ // Withdraw Event Listeners
+ // ============================================================================
+
+ // Withdraw key upload — prefer the TRANSFER key from the backup, fetch
+ // identity + balance, validate, and land on the configure step in one shot
+ wireKeyUpload('withdraw-key-upload', async (result) => {
+ updateState(setWithdrawIdentityFetching(state, result.identityId));
+ try {
+ const [keys, identityState] = await Promise.all([
+ getIdentityPublicKeys(result.identityId, state.network),
+ getIdentityBalanceAndRevision(result.identityId, state.network),
+ ]);
+ let finalState = setWithdrawIdentityFetched(state, keys, BigInt(identityState.balance));
+ const match = findMatchingKeyIndex(result.privateKeyWif, keys, state.network);
+ if (match && match.purpose === 3 /* TRANSFER */) {
+ finalState = setWithdrawKeyValidated(finalState, match.keyId, match.purpose, match.securityLevel, result.privateKeyWif);
+ } else if (match) {
+ finalState = {
+ ...finalState,
+ withdrawKeyValidationError: `The backup's ${getPurposeName(match.purpose)} key cannot sign withdrawals — a TRANSFER key is required.`,
+ };
+ } else {
+ finalState = {
+ ...finalState,
+ withdrawKeyValidationError: 'No key in the backup matches this identity',
+ };
+ }
+ updateState(finalState);
+ } catch (error) {
+ updateState(setWithdrawIdentityFetchError(state, error instanceof Error ? error.message : String(error)));
+ }
+ }, 'TRANSFER');
+
+ // Withdraw mode button (init page)
+ const modeWithdrawBtn = container.querySelector('#mode-withdraw-btn');
+ attachDashWarmup(modeWithdrawBtn);
+ if (modeWithdrawBtn) {
+ modeWithdrawBtn.addEventListener('click', () => {
+ updateState(setMode(state, 'withdraw'));
+ });
+ }
+
+ // Withdraw back button (enter identity / configure steps)
+ const withdrawBackBtn = container.querySelector('#withdraw-back-btn');
+ if (withdrawBackBtn) {
+ withdrawBackBtn.addEventListener('click', () => {
+ switch (state.step) {
+ case 'withdraw_configure':
+ updateState(setWithdrawBackToEntry(state));
+ break;
+ default:
+ updateState(setStep(state, 'init'));
+ }
+ });
+ }
+
+ // Wire the standard "validate on blur, and shortly after paste" input pattern.
+ const onBlurOrPaste = (
+ selector: string,
+ handler: (input: HTMLInputElement) => void
+ ): HTMLInputElement | null => {
+ const input = container.querySelector(selector);
+ if (!input) return null;
+ const run = () => handler(input);
+ input.addEventListener('blur', run);
+ input.addEventListener('paste', () => setTimeout(run, 50));
+ return input;
+ };
+
+ // Withdraw identity ID input - fetch identity + balance
+ onBlurOrPaste('#withdraw-identity-id-input', async (input) => {
+ const identityId = input.value.trim();
+
+ if (!identityId || state.withdrawIdentityFetching) {
+ return;
+ }
+
+ // Skip if this identity is already fetched — refetching would clear a
+ // validated TRANSFER key and repeat two network calls for no change.
+ if (state.targetIdentityId === identityId && state.withdrawIdentityKeys) {
+ return;
+ }
+
+ if (!validateIdentityId(identityId)) {
+ updateState(setWithdrawIdentityFetchError(state, 'Invalid identity ID format (expected 44 character Base58 string)'));
+ return;
+ }
+
+ updateState(setWithdrawIdentityFetching(state, identityId));
+
+ try {
+ if (isE2EMockMode()) {
+ await delay(30);
+ updateState(setWithdrawIdentityFetched(state, createE2EMockIdentityKeys(), BigInt(E2E_MOCK_WITHDRAW_BALANCE)));
+ return;
+ }
+
+ const [keys, identityState] = await Promise.all([
+ getIdentityPublicKeys(identityId, state.network),
+ getIdentityBalanceAndRevision(identityId, state.network),
+ ]);
+ updateState(setWithdrawIdentityFetched(state, keys, BigInt(identityState.balance)));
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to fetch identity';
+ updateState(setWithdrawIdentityFetchError(state, message));
+ }
+ });
+
+ // Withdraw private key input - must match a TRANSFER key on the identity
+ onBlurOrPaste('#withdraw-private-key-input', (input) => {
+ const privateKeyWif = input.value.trim();
+
+ // The WIF is deliberately never rendered back into the DOM, so the field
+ // is blank after every re-render. An empty blur is a no-op (a validated
+ // key stays validated); entering a new key replaces the old one.
+ if (!privateKeyWif) {
+ if (!state.withdrawSigningKeyInfo && state.withdrawKeyValidationError) {
+ updateState(clearWithdrawKeyValidation(state));
+ }
+ return;
+ }
+
+ if (!state.withdrawIdentityKeys || state.withdrawIdentityKeys.length === 0) {
+ updateState(setWithdrawKeyValidationError(state, 'Please enter an identity ID first'));
+ return;
+ }
+
+ if (isE2EMockMode()) {
+ if (privateKeyWif === E2E_MOCK_WITHDRAW_WIF) {
+ updateState(setWithdrawKeyValidated(state, 3, 3, 1, privateKeyWif));
+ } else {
+ updateState(setWithdrawKeyValidationError(state, 'Mock mode: use the configured test private key'));
+ }
+ return;
+ }
+
+ const match = findMatchingKeyIndex(privateKeyWif, state.withdrawIdentityKeys, state.network);
+
+ if (!match) {
+ updateState(setWithdrawKeyValidationError(state, 'This key does not match any key registered with this identity'));
+ return;
+ }
+
+ // Withdrawals to an address must be signed with a TRANSFER key (purpose 3).
+ // OWNER keys are rejected by consensus when a destination address is set.
+ if (match.purpose !== 3) {
+ const purposeName = getPurposeName(match.purpose);
+ updateState(setWithdrawKeyValidationError(
+ state,
+ `This key has ${purposeName} purpose. Withdrawals must be signed with a TRANSFER key (identities created by this bridge have one at HD index 3).`
+ ));
+ return;
+ }
+
+ updateState(setWithdrawKeyValidated(state, match.keyId, match.purpose, match.securityLevel, privateKeyWif));
+ });
+
+ // Withdraw destination address input
+ onBlurOrPaste('#withdraw-address-input', (input) => {
+ const address = input.value.trim();
+
+ if (!address) {
+ updateState(setWithdrawAddress(state, ''));
+ return;
+ }
+
+ const result = validateCoreAddress(address, getNetwork(state.network));
+ updateState(setWithdrawAddress(state, address, result.valid ? undefined : result.error || 'Invalid address'));
+ });
+
+ // Withdraw amount input
+ const withdrawAmountInput = onBlurOrPaste('#withdraw-amount-input', (input) => {
+ const raw = input.value.trim();
+
+ if (!raw) {
+ updateState(setWithdrawAmountError(state, '', ''));
+ return;
+ }
+
+ const result = validateWithdrawalAmount(raw, state.withdrawBalance ?? 0n);
+ if ('error' in result) {
+ updateState(setWithdrawAmountError(state, raw, result.error));
+ } else {
+ updateState(setWithdrawAmount(state, result.credits, raw));
+ }
+ });
+
+ // Max button: fill with balance minus a reserve for the Platform transition fee
+ if (withdrawAmountInput) {
+ const withdrawMaxBtn = container.querySelector('#withdraw-max-btn');
+ if (withdrawMaxBtn) {
+ withdrawMaxBtn.addEventListener('click', () => {
+ const max = maxWithdrawableCredits(state.withdrawBalance ?? 0n);
+ if (max < MIN_WITHDRAWAL_CREDITS) {
+ updateState(setWithdrawAmountError(state, '', `Balance is too small to withdraw (minimum ${formatCreditsAsDash(MIN_WITHDRAWAL_CREDITS)} DASH plus fees)`));
+ return;
+ }
+ const raw = formatCreditsAsDash(max);
+ withdrawAmountInput.value = raw;
+ updateState(setWithdrawAmount(state, max, raw));
+ });
+ }
+ }
+
+ // Withdraw submit button
+ const withdrawSubmitBtn = container.querySelector('#withdraw-submit-btn');
+ if (withdrawSubmitBtn) {
+ withdrawSubmitBtn.addEventListener('click', () => {
+ startWithdrawal();
+ });
+ }
+
+ // Withdraw tracking: leave polling but keep the success result
+ const withdrawTrackingDoneBtn = container.querySelector('#withdraw-tracking-done-btn');
+ if (withdrawTrackingDoneBtn) {
+ withdrawTrackingDoneBtn.addEventListener('click', () => {
+ updateState(setWithdrawTrackingTimeout(
+ state,
+ 'Your withdrawal was accepted and continues processing on the network. The payout will arrive at the destination address; check your Core wallet.'
+ ));
+ });
+ }
+
+ // Withdraw complete: retry / start over
+ const withdrawRetryBtn = container.querySelector('#withdraw-retry-btn');
+ if (withdrawRetryBtn) {
+ withdrawRetryBtn.addEventListener('click', () => {
+ updateState(setWithdrawRetry(state));
+ });
+ }
+
+ const withdrawStartOverBtn = container.querySelector('#withdraw-start-over-btn');
+ if (withdrawStartOverBtn) {
+ withdrawStartOverBtn.addEventListener('click', () => {
+ updateState(setStep(state, 'init'));
+ });
+ }
+
// ============================================================================
// Contract Registration Event Listeners
// ============================================================================
@@ -1763,7 +2033,7 @@ function validateIdentityId(id?: string): boolean {
* are required for DPNS and contract operations. MASTER keys are ranked lower
* because they are rejected by isPurposeAllowedForDpns/isSecurityLevelAllowedForDpns.
*/
-function parseKeyBackup(json: unknown): { identityId: string; privateKeyWif: string; purpose: string; securityLevel: string } | null {
+function parseKeyBackup(json: unknown, preferredPurpose?: string): { identityId: string; privateKeyWif: string; purpose: string; securityLevel: string } | null {
if (!json || typeof json !== 'object') return null;
const obj = json as Record;
const identityId = (obj.identityId || obj.targetIdentityId) as string | undefined;
@@ -1775,6 +2045,12 @@ function parseKeyBackup(json: unknown): { identityId: string; privateKeyWif: str
const ranked = keys
.filter((k) => typeof k.privateKeyWif === 'string')
.sort((a, b) => {
+ // Caller-preferred purpose wins outright (e.g. TRANSFER for withdrawals)
+ if (preferredPurpose) {
+ const aPref = a.purpose === preferredPurpose ? 1 : 0;
+ const bPref = b.purpose === preferredPurpose ? 1 : 0;
+ if (aPref !== bPref) return bPref - aPref;
+ }
// Prefer AUTHENTICATION purpose
const aAuth = a.purpose === 'AUTHENTICATION' ? 1 : 0;
const bAuth = b.purpose === 'AUTHENTICATION' ? 1 : 0;
@@ -2915,6 +3191,160 @@ async function startManageUpdate() {
}
}
+// ============================================================================
+// Withdraw Functions
+// ============================================================================
+
+const WITHDRAW_POLL_INTERVAL_MS = 10_000;
+const WITHDRAW_POLL_TIMEOUT_MS = 5 * 60_000;
+const WITHDRAW_POLL_FAILURE_WARNING_THRESHOLD = 5;
+
+async function startWithdrawal() {
+ const identityId = state.targetIdentityId;
+ const privateKeyWif = state.withdrawPrivateKeyWif;
+ const amountCredits = state.withdrawAmountCredits;
+ const toAddress = state.withdrawToAddress;
+
+ if (!identityId || !privateKeyWif || amountCredits === undefined || !toAddress) {
+ updateState(setWithdrawSubmitError(state, 'Missing withdrawal details'));
+ return;
+ }
+
+ updateState(setWithdrawSubmitting(state));
+
+ try {
+ if (isE2EMockMode()) {
+ await delay(80);
+ const mockRemaining = BigInt(E2E_MOCK_WITHDRAW_BALANCE) - amountCredits;
+ updateState(setWithdrawSubmitted(state, mockRemaining));
+ // Deterministically walk the payout statuses to completion
+ await delay(30);
+ updateState(setWithdrawStatusUpdate(state, 1)); // POOLED
+ await delay(30);
+ updateState(setWithdrawStatusUpdate(state, 3)); // COMPLETE
+ return;
+ }
+
+ // Capture before submitting so the withdrawal document (created when the
+ // transition is processed) is matched even with modest clock skew.
+ const sinceMs = Date.now() - 60_000;
+
+ const { withdrawCredits } = await loadPlatformModule();
+ const result = await withdrawCredits(
+ identityId,
+ privateKeyWif,
+ amountCredits,
+ toAddress,
+ state.network
+ );
+
+ if (result.timedOut) {
+ // Ambiguous outcome: the transition may already be broadcast, so a
+ // "failed" screen here would invite a double spend via retry. Track
+ // instead — the withdrawal document will show up if it went through.
+ updateState(setWithdrawStatusNote(
+ setWithdrawSubmitted(state),
+ 'Confirmation timed out, but the withdrawal may still have been submitted — checking the network for it. Do not retry until the outcome is clear.'
+ ));
+ void pollWithdrawalStatus(identityId, sinceMs);
+ return;
+ }
+
+ if (!result.success || result.remainingBalance === undefined) {
+ // A non-timeout error can still have occurred AFTER broadcast (e.g.
+ // while waiting for the state transition result). Before offering a
+ // retryable failure screen, check whether the withdrawal actually
+ // landed — a second submission would withdraw twice.
+ if (await didWithdrawalLand(identityId, sinceMs)) {
+ updateState(setWithdrawStatusNote(
+ setWithdrawSubmitted(state),
+ 'The submission reported an error, but the withdrawal was found on the network — tracking its payout instead.'
+ ));
+ void pollWithdrawalStatus(identityId, sinceMs);
+ return;
+ }
+ updateState(setWithdrawSubmitError(state, result.error || 'Withdrawal failed'));
+ return;
+ }
+
+ updateState(setWithdrawSubmitted(state, result.remainingBalance));
+ void pollWithdrawalStatus(identityId, sinceMs);
+ } catch (error) {
+ console.error('Withdrawal error:', error);
+ updateState(setWithdrawSubmitError(state, extractErrorMessage(error)));
+ }
+}
+
+/**
+ * Check whether a withdrawal document for this identity appeared on the
+ * network after `sinceMs`. Used to disambiguate submission errors: an error
+ * thrown after broadcast leaves a document behind even though the SDK call
+ * failed. A few short attempts cover processing lag; lookup failures count
+ * as "not found" (the failure screen already warns about the ambiguity).
+ */
+async function didWithdrawalLand(identityId: string, sinceMs: number): Promise {
+ const { fetchLatestWithdrawalStatus } = await loadPlatformModule();
+ for (let attempt = 0; attempt < 3; attempt++) {
+ if (attempt > 0) await delay(5_000);
+ try {
+ const record = await fetchLatestWithdrawalStatus(identityId, state.network, sinceMs);
+ if (record !== null) return true;
+ } catch (error) {
+ console.warn('Withdrawal landed-check failed:', error);
+ }
+ }
+ return false;
+}
+
+/**
+ * Poll the withdrawals contract for the payout status until it completes,
+ * the user leaves the tracking step, or the timeout elapses. Poll failures are
+ * informational only — the credits already left the identity, so a failure to
+ * *observe* the payout must never be presented as a failed withdrawal.
+ */
+async function pollWithdrawalStatus(identityId: string, sinceMs: number) {
+ const deadline = Date.now() + WITHDRAW_POLL_TIMEOUT_MS;
+ let consecutiveFailures = 0;
+ let warningShown = false;
+
+ while (state.step === 'withdraw_tracking') {
+ if (Date.now() > deadline) {
+ updateState(setWithdrawTrackingTimeout(
+ state,
+ 'The withdrawal was accepted and is still being processed by the network. During heavy volume (daily withdrawal limit) payouts can stay queued for a while — check your Core wallet later.'
+ ));
+ return;
+ }
+
+ await delay(WITHDRAW_POLL_INTERVAL_MS);
+ if (state.step !== 'withdraw_tracking') return;
+
+ try {
+ const { fetchLatestWithdrawalStatus } = await loadPlatformModule();
+ const record = await fetchLatestWithdrawalStatus(identityId, state.network, sinceMs);
+ consecutiveFailures = 0;
+ if (record !== null && record.status !== state.withdrawStatus) {
+ // Also clears any transient polling warning
+ updateState(setWithdrawStatusUpdate(state, record.status));
+ warningShown = false;
+ } else if (warningShown && state.step === 'withdraw_tracking') {
+ updateState(setWithdrawStatusNote(state, undefined));
+ warningShown = false;
+ }
+ } catch (error) {
+ consecutiveFailures++;
+ console.warn('Withdrawal status poll failed:', error);
+ if (consecutiveFailures === WITHDRAW_POLL_FAILURE_WARNING_THRESHOLD && state.step === 'withdraw_tracking') {
+ updateState(setWithdrawStatusNote(
+ state,
+ 'Having trouble checking the payout status — the withdrawal itself was accepted and continues processing.'
+ ));
+ warningShown = true;
+ }
+ }
+ }
+}
+
// ============================================================================
// Faucet Functions
// ============================================================================
diff --git a/src/platform/client.ts b/src/platform/client.ts
index e061155..075e890 100644
--- a/src/platform/client.ts
+++ b/src/platform/client.ts
@@ -132,6 +132,19 @@ export async function withConnectedPlatformSdk(
}
}
+/**
+ * Thrown when a platform operation exceeds its wall-clock guard. Callers that
+ * must distinguish "timed out (outcome unknown)" from "failed" — e.g. credit
+ * withdrawals, where a false failure invites a double spend — check for this
+ * type instead of matching the message text.
+ */
+export class PlatformOperationTimeoutError extends Error {
+ constructor(action: string) {
+ super(`Timed out while ${action}`);
+ this.name = 'PlatformOperationTimeoutError';
+ }
+}
+
export async function withPlatformOperationTimeout(
promise: Promise,
action: string,
@@ -144,7 +157,7 @@ export async function withPlatformOperationTimeout(
promise,
new Promise((_, reject) => {
timeoutId = window.setTimeout(() => {
- reject(new Error(`Timed out while ${action}`));
+ reject(new PlatformOperationTimeoutError(action));
}, timeoutMs);
}),
]);
diff --git a/src/platform/index.ts b/src/platform/index.ts
index e2ee3d1..ec994a4 100644
--- a/src/platform/index.ts
+++ b/src/platform/index.ts
@@ -14,3 +14,12 @@ export {
} from './identity.js';
export type { AddKeyConfig } from './identity.js';
+
+export {
+ withdrawCredits,
+ fetchLatestWithdrawalStatus,
+ WithdrawalStatus,
+ WITHDRAWALS_CONTRACT_ID,
+} from './withdrawal.js';
+
+export type { WithdrawResult, WithdrawalStatusRecord } from './withdrawal.js';
diff --git a/src/platform/withdrawal-status.ts b/src/platform/withdrawal-status.ts
new file mode 100644
index 0000000..8275f90
--- /dev/null
+++ b/src/platform/withdrawal-status.ts
@@ -0,0 +1,15 @@
+/**
+ * Withdrawal document lifecycle statuses. The validator quorum moves a
+ * withdrawal through these states; the client only observes them.
+ *
+ * Kept in a leaf module (no SDK imports) so the eagerly-loaded UI layer can
+ * use the constants without pulling the lazily-loaded platform chunk into
+ * the main bundle.
+ */
+export const WithdrawalStatus = {
+ QUEUED: 0,
+ POOLED: 1,
+ BROADCASTED: 2,
+ COMPLETE: 3,
+ EXPIRED: 4,
+} as const;
diff --git a/src/platform/withdrawal.ts b/src/platform/withdrawal.ts
new file mode 100644
index 0000000..71eadd9
--- /dev/null
+++ b/src/platform/withdrawal.ts
@@ -0,0 +1,155 @@
+import { withRetry, type RetryOptions } from '../utils/retry.js';
+import { extractErrorMessage } from '../utils/errors.js';
+import { loadSdkModule } from './sdkModule.js';
+import {
+ PLATFORM_PUT_SETTINGS,
+ PlatformOperationTimeoutError,
+ fetchIdentityWithSdk,
+ withConnectedPlatformSdk,
+ withPlatformOperationTimeout,
+} from './client.js';
+
+/**
+ * The withdrawals system data contract. Not bundled into the wasm SDK's
+ * default known contracts, so the first documents query fetches it (with
+ * proof) from the network.
+ */
+export const WITHDRAWALS_CONTRACT_ID = '4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6';
+
+export { WithdrawalStatus } from './withdrawal-status.js';
+
+/**
+ * Timeout for the full submit-and-wait-for-inclusion round trip. Longer than
+ * the default 45s guard because a timeout here is ambiguous — the transition
+ * may already be broadcast — and a false failure invites a double spend.
+ */
+const WITHDRAWAL_OPERATION_TIMEOUT_MS = 120_000;
+
+export interface WithdrawResult {
+ success: boolean;
+ /** Identity balance (credits) after the withdrawal, when successful. */
+ remainingBalance?: bigint;
+ error?: string;
+ /**
+ * True when the operation timed out waiting for confirmation. The
+ * transition may still have been broadcast and included — callers must
+ * treat this as "unknown", not as a failure.
+ */
+ timedOut?: boolean;
+}
+
+/**
+ * Withdraw credits from an identity to a Dash Core address.
+ *
+ * The signing key must be a TRANSFER-purpose key: with a destination address
+ * present, OWNER-key-signed withdrawals are rejected by consensus. The SDK
+ * builds the transition (pooling Never, nonce fetch), signs, broadcasts, and
+ * waits for inclusion, returning the remaining identity balance.
+ */
+export async function withdrawCredits(
+ identityId: string,
+ privateKeyWif: string,
+ amountCredits: bigint,
+ toAddress: string,
+ network: string,
+ retryOptions?: RetryOptions
+): Promise {
+ return withConnectedPlatformSdk(network, async (sdk) => {
+ try {
+ console.log('Withdrawing', amountCredits.toString(), 'credits from', identityId, 'to', toAddress);
+
+ const identity = await fetchIdentityWithSdk(sdk, identityId, retryOptions);
+ if (!identity) {
+ throw new Error(`Identity not found: ${identityId}`);
+ }
+
+ const { IdentitySigner } = await loadSdkModule();
+ const signer = new IdentitySigner();
+ signer.addKeyFromWif(privateKeyWif);
+
+ // Deliberately single-submit: no retry wrapper and no SDK-level retries.
+ // A retry after an ambiguous network error would rebuild the transition
+ // with a fresh nonce and could withdraw a second time if the first
+ // attempt actually landed. Ambiguous outcomes surface as timedOut and
+ // are resolved by the caller via status tracking instead.
+ const remainingBalance = await withPlatformOperationTimeout(
+ sdk.identities.creditWithdrawal({
+ identity,
+ amount: amountCredits,
+ toAddress,
+ coreFeePerByte: 1,
+ signer,
+ settings: { ...PLATFORM_PUT_SETTINGS, retries: 0 },
+ }),
+ 'waiting for credit withdrawal confirmation',
+ WITHDRAWAL_OPERATION_TIMEOUT_MS
+ );
+
+ console.log('Withdrawal accepted, remaining balance:', remainingBalance.toString());
+
+ return { success: true, remainingBalance };
+ } catch (error) {
+ console.error('Credit withdrawal error:', error);
+ return {
+ success: false,
+ error: extractErrorMessage(error),
+ timedOut: error instanceof PlatformOperationTimeoutError,
+ };
+ }
+ }, retryOptions);
+}
+
+export interface WithdrawalStatusRecord {
+ status: number;
+ amountCredits: bigint;
+ createdAt: number;
+ updatedAt: number;
+}
+
+/**
+ * Fetch the most recent withdrawal document for an identity created at or
+ * after `sinceMs`. Returns null while no matching document exists yet
+ * (the quorum creates it when the withdrawal transition is processed).
+ */
+export async function fetchLatestWithdrawalStatus(
+ identityId: string,
+ network: string,
+ sinceMs: number,
+ retryOptions?: RetryOptions
+): Promise {
+ return withConnectedPlatformSdk(network, async (sdk) => {
+ const documents = await withPlatformOperationTimeout(
+ withRetry(
+ () => sdk.documents.query({
+ dataContractId: WITHDRAWALS_CONTRACT_ID,
+ documentTypeName: 'withdrawal',
+ where: [['$ownerId', '==', identityId]],
+ orderBy: [['$updatedAt', 'desc']],
+ limit: 10,
+ }),
+ retryOptions
+ ),
+ 'fetching withdrawal status'
+ );
+
+ let latest: WithdrawalStatusRecord | null = null;
+ for (const doc of documents.values()) {
+ if (!doc) continue;
+ const createdAt = doc.createdAt !== undefined ? Number(doc.createdAt) : 0;
+ const updatedAt = doc.updatedAt !== undefined ? Number(doc.updatedAt) : createdAt;
+ if (createdAt < sinceMs) continue;
+ const props = doc.properties as { status?: unknown; amount?: unknown };
+ const status = typeof props.status === 'number' ? props.status : Number(props.status ?? NaN);
+ if (!Number.isFinite(status)) continue;
+ if (!latest || updatedAt > latest.updatedAt) {
+ latest = {
+ status,
+ amountCredits: BigInt(String(props.amount ?? 0)),
+ createdAt,
+ updatedAt,
+ };
+ }
+ }
+ return latest;
+ }, retryOptions);
+}
diff --git a/src/types.ts b/src/types.ts
index 7c173b5..c4b52da 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -68,7 +68,7 @@ export interface IdentityKeyConfig {
/**
* Bridge operation mode
*/
-export type BridgeMode = 'create' | 'topup' | 'send_to_address' | 'dpns' | 'manage' | 'contract';
+export type BridgeMode = 'create' | 'topup' | 'send_to_address' | 'dpns' | 'manage' | 'contract' | 'withdraw';
/**
* DPNS identity source for standalone mode
@@ -184,7 +184,13 @@ export type BridgeStep =
| 'contract_enter_contract' // Paste contract JSON, see fee estimate
| 'contract_review' // Review contract + fees before publishing
| 'contract_registering' // Publishing contract on platform
- | 'contract_complete'; // Contract registered
+ | 'contract_complete' // Contract registered
+ // Withdraw (asset unlock) steps
+ | 'withdraw_enter_identity' // Enter identity ID, fetch keys + balance
+ | 'withdraw_configure' // Enter TRANSFER key WIF, destination address, amount
+ | 'withdraw_submitting' // Credit withdrawal transition in flight
+ | 'withdraw_tracking' // Polling withdrawal document status
+ | 'withdraw_complete'; // Withdrawal done (or failed)
/**
* Status of network retry attempts
@@ -386,6 +392,38 @@ export interface BridgeState {
/** Minimum deposit amount in satoshis (overrides default 300,000 for contract mode) */
minimumDeposit?: number;
+ // Withdraw (asset unlock) fields
+ /** Withdraw: whether identity is being fetched */
+ withdrawIdentityFetching?: boolean;
+ /** Withdraw: identity fetch error */
+ withdrawIdentityFetchError?: string;
+ /** Withdraw: fetched identity keys */
+ withdrawIdentityKeys?: IdentityPublicKeyInfo[];
+ /** Withdraw: identity credit balance in credits */
+ withdrawBalance?: bigint;
+ /** Withdraw: private key WIF for signing (must match a TRANSFER key) */
+ withdrawPrivateKeyWif?: string;
+ /** Withdraw: validated signing key info */
+ withdrawSigningKeyInfo?: { keyId: number; purpose: number; securityLevel: number };
+ /** Withdraw: key validation error message */
+ withdrawKeyValidationError?: string;
+ /** Withdraw: destination Dash Core address as typed (valid when withdrawAddressError is unset) */
+ withdrawToAddress?: string;
+ /** Withdraw: destination address validation error */
+ withdrawAddressError?: string;
+ /** Withdraw: validated amount to withdraw, in credits */
+ withdrawAmountCredits?: bigint;
+ /** Withdraw: raw DASH amount input (preserved across re-renders) */
+ withdrawAmountInput?: string;
+ /** Withdraw: amount validation error */
+ withdrawAmountError?: string;
+ /** Withdraw: submission result */
+ withdrawResult?: { success: boolean; remainingBalance?: bigint; error?: string };
+ /** Withdraw: latest withdrawal document status (0 QUEUED, 1 POOLED, 2 BROADCASTED, 3 COMPLETE, 4 EXPIRED) */
+ withdrawStatus?: number;
+ /** Withdraw: status polling problem / timeout explanation (informational, not a failure) */
+ withdrawStatusError?: string;
+
// Faucet request state
/** Current status of faucet request */
faucetRequestStatus?: 'idle' | 'solving_pow' | 'requesting' | 'success' | 'error';
diff --git a/src/ui/components.ts b/src/ui/components.ts
index a613089..d13fa36 100644
--- a/src/ui/components.ts
+++ b/src/ui/components.ts
@@ -3,6 +3,8 @@ import { getStepProgress, getStepDescription, ErrorCodes, ErrorCodeLabels } from
import { shouldShowContestedWarning, countUsernameStatuses } from '../platform/dpns-utils.js';
import { generateQRCodeDataUrl } from './qrcode.js';
import { privateKeyToWif } from '../utils/wif.js';
+import { formatCreditsAsDash, formatCredits, MIN_WITHDRAWAL_CREDITS } from '../utils/credits.js';
+import { WithdrawalStatus } from '../platform/withdrawal-status.js';
import { bytesToHex } from '../utils/hex.js';
import { getNetwork, getAvailableNetworks } from '../config.js';
import { getAssetLockDerivationPath } from '../crypto/hd.js';
@@ -320,6 +322,23 @@ export function render(state: BridgeState, container: HTMLElement): void {
case 'contract_complete':
content.appendChild(renderContractCompleteStep(state));
break;
+
+ // Withdraw steps
+ case 'withdraw_enter_identity':
+ content.appendChild(renderWithdrawEnterIdentityStep(state));
+ break;
+ case 'withdraw_configure':
+ content.appendChild(renderWithdrawConfigureStep(state));
+ break;
+ case 'withdraw_submitting':
+ content.appendChild(renderWithdrawSubmittingStep(state));
+ break;
+ case 'withdraw_tracking':
+ content.appendChild(renderWithdrawTrackingStep(state));
+ break;
+ case 'withdraw_complete':
+ content.appendChild(renderWithdrawCompleteStep(state));
+ break;
}
wrapper.appendChild(content);
@@ -404,6 +423,10 @@ function renderInitStep(state: BridgeState): HTMLElement {
Register Data Contract
Publish a data contract on Dash Platform
+
`;
div.appendChild(modeButtons);
@@ -1119,6 +1142,18 @@ function buildErrorDiagnostics(state: BridgeState): Record {
if (state.manageKeysToAdd?.length) diag.manageKeysToAddCount = state.manageKeysToAdd.length;
if (state.manageKeyIdsToDisable?.length) diag.manageKeyIdsToDisable = state.manageKeyIdsToDisable;
+ // Withdraw context — counts and public data only, never key material
+ if (state.withdrawSigningKeyInfo) diag.withdrawSigningKeyInfo = state.withdrawSigningKeyInfo;
+ if (state.withdrawToAddress) diag.withdrawToAddress = state.withdrawToAddress;
+ if (state.withdrawAmountCredits !== undefined) diag.withdrawAmountCredits = String(state.withdrawAmountCredits);
+ if (state.withdrawStatus !== undefined) diag.withdrawStatus = state.withdrawStatus;
+ if (state.withdrawResult) {
+ diag.withdrawResult = {
+ success: state.withdrawResult.success,
+ error: state.withdrawResult.error ?? null,
+ };
+ }
+
// Retry state
if (state.retryStatus) diag.retryStatus = state.retryStatus;
@@ -2254,6 +2289,377 @@ function renderManageCompleteStep(state: BridgeState): HTMLElement {
return div;
}
+// ============================================================================
+// Withdraw (Asset Unlock) Steps
+// ============================================================================
+
+/** Withdrawal document statuses, in payout order. */
+const WITHDRAW_STATUS_LABELS: { status: number; label: string }[] = [
+ { status: WithdrawalStatus.QUEUED, label: 'Queued' },
+ { status: WithdrawalStatus.POOLED, label: 'Pooled' },
+ { status: WithdrawalStatus.BROADCASTED, label: 'Broadcast to Core' },
+ { status: WithdrawalStatus.COMPLETE, label: 'Complete' },
+];
+
+function renderWithdrawEnterIdentityStep(state: BridgeState): HTMLElement {
+ const div = document.createElement('div');
+ div.className = 'withdraw-enter-identity-step';
+ div.id = 'withdraw-key-upload-dropzone';
+
+ const headline = document.createElement('h2');
+ headline.className = 'manage-headline';
+ headline.textContent = 'Withdraw Credits';
+ div.appendChild(headline);
+
+ const subtitle = document.createElement('p');
+ subtitle.className = 'manage-subtitle';
+ subtitle.textContent = 'Send Platform credits from your identity back to a Dash Core address.';
+ div.appendChild(subtitle);
+
+ const form = document.createElement('div');
+ form.className = 'manage-identity-form';
+
+ const isFetching = state.withdrawIdentityFetching === true;
+ const hasFetchError = state.withdrawIdentityFetchError !== undefined;
+
+ let identityStatusHtml = '';
+ if (isFetching) {
+ identityStatusHtml = 'Fetching identity...
';
+ } else if (hasFetchError) {
+ identityStatusHtml = `${escapeHtml(state.withdrawIdentityFetchError!)}
`;
+ }
+
+ form.innerHTML = `
+ ${renderKeyUploadSection('withdraw-key-upload')}
+
+
+ `;
+ 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 = `
+
+
+
+
+
+
+
+
The Core network mining fee (~190 duffs) is deducted from the withdrawn amount. Payouts are batched by the network and typically take a few minutes.
+
+ `;
+ div.appendChild(form);
+
+ const navButtons = document.createElement('div');
+ navButtons.className = 'nav-buttons';
+
+ const backBtn = document.createElement('button');
+ backBtn.id = 'withdraw-back-btn';
+ backBtn.className = 'secondary-btn';
+ backBtn.textContent = 'Back';
+ navButtons.appendChild(backBtn);
+
+ const submitBtn = document.createElement('button');
+ submitBtn.id = 'withdraw-submit-btn';
+ submitBtn.className = 'primary-btn';
+ submitBtn.textContent = 'Withdraw';
+ const canSubmit =
+ hasValidatedKey &&
+ state.withdrawToAddress !== undefined &&
+ state.withdrawAddressError === undefined &&
+ state.withdrawAmountCredits !== undefined;
+ if (!canSubmit) {
+ submitBtn.setAttribute('disabled', 'true');
+ }
+ navButtons.appendChild(submitBtn);
+
+ div.appendChild(navButtons);
+
+ return div;
+}
+
+function renderWithdrawSubmittingStep(_state: BridgeState): HTMLElement {
+ const div = document.createElement('div');
+ div.className = 'withdraw-submitting-step';
+
+ const headline = document.createElement('h2');
+ headline.className = 'manage-headline';
+ headline.textContent = 'Submitting Withdrawal';
+ div.appendChild(headline);
+
+ const subtitle = document.createElement('p');
+ subtitle.className = 'manage-subtitle';
+ subtitle.textContent = 'Signing and broadcasting the credit withdrawal to Dash Platform...';
+ div.appendChild(subtitle);
+
+ const spinner = document.createElement('div');
+ spinner.className = 'spinner large';
+ div.appendChild(spinner);
+
+ return div;
+}
+
+function renderWithdrawTrackingStep(state: BridgeState): HTMLElement {
+ const div = document.createElement('div');
+ div.className = 'withdraw-tracking-step';
+
+ const headline = document.createElement('h2');
+ headline.className = 'manage-headline';
+ headline.textContent = 'Processing Withdrawal';
+ div.appendChild(headline);
+
+ const subtitle = document.createElement('p');
+ subtitle.className = 'manage-subtitle';
+ subtitle.textContent = 'Your withdrawal was accepted. The network is preparing the Core payout transaction.';
+ div.appendChild(subtitle);
+
+ const currentStatus = state.withdrawStatus ?? 0;
+ const timeline = document.createElement('div');
+ timeline.className = 'withdraw-status-timeline';
+ timeline.innerHTML = WITHDRAW_STATUS_LABELS.map(({ status, label }) => {
+ let cls = 'pending';
+ if (status < currentStatus) {
+ cls = 'done';
+ } else if (status === currentStatus) {
+ cls = 'active';
+ }
+ return `
+
+ ${escapeHtml(label)}
+
`;
+ }).join('');
+ div.appendChild(timeline);
+
+ const spinner = document.createElement('div');
+ spinner.className = 'spinner';
+ div.appendChild(spinner);
+
+ if (state.withdrawStatusError) {
+ const pollNote = document.createElement('p');
+ pollNote.className = 'withdraw-poll-warning';
+ pollNote.textContent = state.withdrawStatusError;
+ div.appendChild(pollNote);
+ }
+
+ const note = document.createElement('p');
+ note.className = 'input-hint';
+ note.textContent = 'Payouts are batched by the validator network and can take several minutes. During heavy network volume (daily withdrawal limit), a withdrawal can stay queued longer — that is normal, not a failure.';
+ div.appendChild(note);
+
+ const navButtons = document.createElement('div');
+ navButtons.className = 'nav-buttons';
+
+ const doneBtn = document.createElement('button');
+ doneBtn.id = 'withdraw-tracking-done-btn';
+ doneBtn.className = 'secondary-btn';
+ doneBtn.textContent = 'Continue in Background';
+ navButtons.appendChild(doneBtn);
+
+ div.appendChild(navButtons);
+
+ return div;
+}
+
+function renderWithdrawCompleteStep(state: BridgeState): HTMLElement {
+ const div = document.createElement('div');
+ div.className = 'withdraw-complete-step';
+
+ const result = state.withdrawResult;
+ const isSuccess = result?.success === true;
+ const status = state.withdrawStatus;
+
+ const headline = document.createElement('h2');
+ headline.className = 'manage-headline';
+ if (!isSuccess) {
+ headline.textContent = 'Withdrawal Failed';
+ } else if (status === WithdrawalStatus.EXPIRED) {
+ headline.textContent = 'Withdrawal Expired';
+ } else if (status === WithdrawalStatus.COMPLETE) {
+ headline.textContent = 'Withdrawal Complete!';
+ } else {
+ headline.textContent = 'Withdrawal Submitted';
+ }
+ div.appendChild(headline);
+
+ if (isSuccess) {
+ const details = document.createElement('div');
+ details.className = 'withdraw-success-details';
+
+ const amount = state.withdrawAmountCredits;
+ const amountHtml = amount !== undefined
+ ? `Amount: ${escapeHtml(formatCreditsAsDash(amount))} DASH (${escapeHtml(formatCredits(amount))} credits)
`
+ : '';
+ const remaining = result?.remainingBalance;
+ const remainingHtml = remaining !== undefined
+ ? `Remaining balance: ${escapeHtml(formatCreditsAsDash(remaining))} DASH
`
+ : '';
+ details.innerHTML = `
+ ${amountHtml}
+ Destination: ${escapeHtml(state.withdrawToAddress || '')}
+ ${remainingHtml}
+ `;
+ div.appendChild(details);
+
+ if (status === WithdrawalStatus.COMPLETE) {
+ const msg = document.createElement('p');
+ msg.className = 'withdraw-success-msg';
+ msg.textContent = 'The payout transaction has been chain-locked on Dash Core. Funds should appear in your wallet.';
+ div.appendChild(msg);
+ } else if (status === WithdrawalStatus.EXPIRED) {
+ const msg = document.createElement('p');
+ msg.className = 'withdraw-expired-msg';
+ msg.textContent = 'The payout transaction expired before it was mined. The network automatically re-broadcasts expired withdrawals; if the funds do not arrive, the credits are returned to your identity.';
+ div.appendChild(msg);
+ } else if (state.withdrawStatusError) {
+ const msg = document.createElement('p');
+ msg.className = 'withdraw-pending-msg';
+ msg.textContent = state.withdrawStatusError;
+ div.appendChild(msg);
+ }
+ } else {
+ const errorMsg = document.createElement('div');
+ errorMsg.className = 'withdraw-error-msg';
+ errorMsg.innerHTML = `
+ The withdrawal could not be completed. Your credits have not left the identity unless the error occurred after broadcast.
+ ${escapeHtml(result?.error || 'Unknown error')} (${ErrorCodes.WITHDRAW})
+ `;
+ div.appendChild(errorMsg);
+ }
+
+ // Identity info with copy + explorer link
+ const withdrawIdentityId = state.targetIdentityId || 'Unknown';
+ div.appendChild(renderIdSection('Identity ID', withdrawIdentityId, {
+ explorerHref: withdrawIdentityId !== 'Unknown' ? explorerUrl(state.network, 'identity', withdrawIdentityId) : undefined,
+ copyBtnId: 'copy-withdraw-identity-btn',
+ }));
+
+ const actionButtons = document.createElement('div');
+ actionButtons.className = 'withdraw-action-buttons nav-buttons';
+
+ if (!isSuccess) {
+ const retryBtn = document.createElement('button');
+ retryBtn.id = 'withdraw-retry-btn';
+ retryBtn.className = 'primary-btn';
+ retryBtn.textContent = 'Try Again';
+ actionButtons.appendChild(retryBtn);
+ }
+
+ const startOverBtn = document.createElement('button');
+ startOverBtn.id = 'withdraw-start-over-btn';
+ startOverBtn.className = 'secondary-btn';
+ startOverBtn.textContent = 'Start Over';
+ actionButtons.appendChild(startOverBtn);
+
+ div.appendChild(actionButtons);
+
+ return div;
+}
+
// ============================================================================
// Contract Registration Steps
// ============================================================================
diff --git a/src/ui/index.ts b/src/ui/index.ts
index 2669af7..bc12475 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -81,6 +81,24 @@ export {
setContractComplete,
setModeContractFromIdentity,
setContractStartBridge,
+ // Withdraw state functions
+ setWithdrawIdentityFetching,
+ setWithdrawIdentityFetched,
+ setWithdrawIdentityFetchError,
+ setWithdrawKeyValidated,
+ setWithdrawKeyValidationError,
+ clearWithdrawKeyValidation,
+ setWithdrawAddress,
+ setWithdrawAmount,
+ setWithdrawAmountError,
+ setWithdrawSubmitting,
+ setWithdrawSubmitted,
+ setWithdrawSubmitError,
+ setWithdrawStatusUpdate,
+ setWithdrawStatusNote,
+ setWithdrawTrackingTimeout,
+ setWithdrawBackToEntry,
+ setWithdrawRetry,
// Faucet state functions
setFaucetSolvingPow,
setFaucetRequesting,
diff --git a/src/ui/state.test.ts b/src/ui/state.test.ts
index 2ff1fbb..a87c852 100644
--- a/src/ui/state.test.ts
+++ b/src/ui/state.test.ts
@@ -1,6 +1,23 @@
import { describe, it, expect } from 'vitest';
-import { ErrorCodes, createInitialState, getStepDescription, setError } from './state.js';
+import {
+ ErrorCodes,
+ createInitialState,
+ getStepDescription,
+ setError,
+ setMode,
+ setWithdrawIdentityFetching,
+ setWithdrawIdentityFetched,
+ setWithdrawKeyValidated,
+ setWithdrawKeyValidationError,
+ setWithdrawAmountError,
+ setWithdrawAddress,
+ setWithdrawSubmitting,
+ setWithdrawSubmitted,
+ setWithdrawSubmitError,
+ setWithdrawStatusUpdate,
+ setWithdrawTrackingTimeout,
+} from './state.js';
import type { BridgeState } from '../types.js';
function baseState(): BridgeState {
@@ -60,3 +77,116 @@ describe('step descriptions', () => {
expect(getStepDescription('generating_keys')).toBe('Preparing Dash Platform...');
});
});
+
+describe('withdraw mode state transitions', () => {
+ it('setMode withdraw enters withdraw_enter_identity with cleared fields', () => {
+ const dirty: BridgeState = {
+ ...baseState(),
+ withdrawPrivateKeyWif: 'someWif',
+ withdrawToAddress: 'yAddr',
+ withdrawAmountCredits: 5n,
+ withdrawStatus: 2,
+ };
+ const result = setMode(dirty, 'withdraw');
+ expect(result.step).toBe('withdraw_enter_identity');
+ expect(result.mode).toBe('withdraw');
+ expect(result.withdrawPrivateKeyWif).toBeUndefined();
+ expect(result.withdrawToAddress).toBeUndefined();
+ expect(result.withdrawAmountCredits).toBeUndefined();
+ expect(result.withdrawStatus).toBeUndefined();
+ });
+
+ it('switching to another mode clears withdraw-sensitive fields', () => {
+ const state: BridgeState = {
+ ...baseState(),
+ mode: 'withdraw',
+ withdrawPrivateKeyWif: 'someWif',
+ withdrawToAddress: 'yAddr',
+ withdrawAmountCredits: 5n,
+ };
+ const result = setMode(state, 'topup');
+ expect(result.withdrawPrivateKeyWif).toBeUndefined();
+ expect(result.withdrawToAddress).toBeUndefined();
+ expect(result.withdrawAmountCredits).toBeUndefined();
+ });
+
+ it('identity fetch success advances to configure with keys and balance', () => {
+ const fetching = setWithdrawIdentityFetching(baseState(), 'someIdentityId');
+ expect(fetching.withdrawIdentityFetching).toBe(true);
+ expect(fetching.targetIdentityId).toBe('someIdentityId');
+ const fetched = setWithdrawIdentityFetched(fetching, [], 123456n);
+ expect(fetched.step).toBe('withdraw_configure');
+ expect(fetched.withdrawIdentityFetching).toBe(false);
+ expect(fetched.withdrawBalance).toBe(123456n);
+ });
+
+ it('submit success moves to tracking with QUEUED status', () => {
+ const state = setWithdrawSubmitting(baseState());
+ expect(state.step).toBe('withdraw_submitting');
+ const submitted = setWithdrawSubmitted(state, 42n);
+ expect(submitted.step).toBe('withdraw_tracking');
+ expect(submitted.withdrawStatus).toBe(0);
+ expect(submitted.withdrawResult).toEqual({ success: true, remainingBalance: 42n });
+ });
+
+ it('ambiguous timeout enters tracking without a remaining balance, still a success', () => {
+ const submitted = setWithdrawSubmitted(setWithdrawSubmitting(baseState()));
+ expect(submitted.step).toBe('withdraw_tracking');
+ expect(submitted.withdrawStatus).toBe(0);
+ expect(submitted.withdrawResult).toEqual({ success: true, remainingBalance: undefined });
+ });
+
+ it('submit error terminates with a failed result', () => {
+ const result = setWithdrawSubmitError(setWithdrawSubmitting(baseState()), 'boom');
+ expect(result.step).toBe('withdraw_complete');
+ expect(result.withdrawResult).toEqual({ success: false, error: 'boom' });
+ });
+
+ it('status updates stay in tracking until terminal', () => {
+ let state = setWithdrawSubmitted(setWithdrawSubmitting(baseState()), 42n);
+ state = setWithdrawStatusUpdate(state, 1);
+ expect(state.step).toBe('withdraw_tracking');
+ state = setWithdrawStatusUpdate(state, 2);
+ expect(state.step).toBe('withdraw_tracking');
+ state = setWithdrawStatusUpdate(state, 3);
+ expect(state.step).toBe('withdraw_complete');
+ expect(state.withdrawStatus).toBe(3);
+ });
+
+ it('EXPIRED status is terminal', () => {
+ const state = setWithdrawStatusUpdate(
+ setWithdrawSubmitted(setWithdrawSubmitting(baseState()), 42n),
+ 4
+ );
+ expect(state.step).toBe('withdraw_complete');
+ });
+
+ it('tracking timeout completes without clearing the success result', () => {
+ const submitted = setWithdrawSubmitted(setWithdrawSubmitting(baseState()), 42n);
+ const timedOut = setWithdrawTrackingTimeout(submitted, 'still queued');
+ expect(timedOut.step).toBe('withdraw_complete');
+ expect(timedOut.withdrawResult?.success).toBe(true);
+ expect(timedOut.withdrawStatusError).toBe('still queued');
+ });
+
+ it('key validation error clears validated key material', () => {
+ const validated = setWithdrawKeyValidated(baseState(), 3, 3, 1, 'wif');
+ expect(validated.withdrawSigningKeyInfo).toEqual({ keyId: 3, purpose: 3, securityLevel: 1 });
+ const errored = setWithdrawKeyValidationError(validated, 'not a transfer key');
+ expect(errored.withdrawSigningKeyInfo).toBeUndefined();
+ expect(errored.withdrawPrivateKeyWif).toBeUndefined();
+ expect(errored.withdrawKeyValidationError).toBe('not a transfer key');
+ });
+
+ it('amount/address errors preserve raw input', () => {
+ const badAmount = setWithdrawAmountError(baseState(), '0.0000001', 'too small');
+ expect(badAmount.withdrawAmountInput).toBe('0.0000001');
+ expect(badAmount.withdrawAmountCredits).toBeUndefined();
+ const badAddress = setWithdrawAddress(baseState(), 'Xoops', 'wrong network');
+ expect(badAddress.withdrawToAddress).toBe('Xoops');
+ expect(badAddress.withdrawAddressError).toBe('wrong network');
+ const goodAddress = setWithdrawAddress(badAddress, 'yGoodAddr');
+ expect(goodAddress.withdrawToAddress).toBe('yGoodAddr');
+ expect(goodAddress.withdrawAddressError).toBeUndefined();
+ });
+});
diff --git a/src/ui/state.ts b/src/ui/state.ts
index 46e2195..7c17aa2 100644
--- a/src/ui/state.ts
+++ b/src/ui/state.ts
@@ -19,6 +19,7 @@ import {
} from '../crypto/keys.js';
import { generateNewMnemonic } from '../crypto/hd.js';
import { createEmptyUsernameEntry, createUsernameEntry } from '../platform/dpns-utils.js';
+import { WithdrawalStatus } from '../platform/withdrawal-status.js';
/**
* Error codes for user-facing display.
@@ -41,6 +42,7 @@ export const ErrorCodes = {
CONFIG: 'ERR-1012',
CONTRACT_REGISTER: 'ERR-1013',
CHAINLOCK: 'ERR-1014',
+ WITHDRAW: 'ERR-1015',
} as const;
/** Human-readable labels for error codes */
@@ -60,6 +62,7 @@ export const ErrorCodeLabels: Record = {
[ErrorCodes.CONFIG]: 'Configuration error',
[ErrorCodes.CONTRACT_REGISTER]: 'Contract registration failed',
[ErrorCodes.CHAINLOCK]: 'Chain lock fallback failed',
+ [ErrorCodes.WITHDRAW]: 'Credit withdrawal failed',
};
/** Map a processing step to its error code */
@@ -77,6 +80,8 @@ const StepErrorCodes: Partial> = {
dpns_registering: ErrorCodes.DPNS_REGISTER,
manage_updating: ErrorCodes.IDENTITY_UPDATE,
contract_registering: ErrorCodes.CONTRACT_REGISTER,
+ withdraw_submitting: ErrorCodes.WITHDRAW,
+ withdraw_tracking: ErrorCodes.WITHDRAW,
};
/** Coerce an unknown caught value into an Error */
@@ -195,6 +200,31 @@ export function setMode(state: BridgeState, mode: BridgeMode): BridgeState {
contractIdentityBalance: undefined,
minimumDeposit: undefined,
};
+ } else if (mode === 'withdraw') {
+ // Withdraw mode: go to identity entry, clear any previous withdraw state
+ return {
+ ...clearedState,
+ step: 'withdraw_enter_identity',
+ mode,
+ mnemonic: undefined,
+ identityKeys: [],
+ targetIdentityId: undefined,
+ withdrawIdentityFetching: undefined,
+ withdrawIdentityFetchError: undefined,
+ withdrawIdentityKeys: undefined,
+ withdrawBalance: undefined,
+ withdrawPrivateKeyWif: undefined,
+ withdrawSigningKeyInfo: undefined,
+ withdrawKeyValidationError: undefined,
+ withdrawToAddress: undefined,
+ withdrawAddressError: undefined,
+ withdrawAmountCredits: undefined,
+ withdrawAmountInput: undefined,
+ withdrawAmountError: undefined,
+ withdrawResult: undefined,
+ withdrawStatus: undefined,
+ withdrawStatusError: undefined,
+ };
} else {
// Manage mode: go to identity entry
return {
@@ -216,9 +246,15 @@ export function setMode(state: BridgeState, mode: BridgeMode): BridgeState {
}
function clearModeSensitiveFields(state: BridgeState, mode: BridgeMode): BridgeState {
+ // setMode('withdraw') re-clears the withdraw block itself, so these can be
+ // dropped unconditionally here.
return {
...state,
recipientPlatformAddress: mode === 'send_to_address' ? state.recipientPlatformAddress : undefined,
+ withdrawPrivateKeyWif: undefined,
+ withdrawSigningKeyInfo: undefined,
+ withdrawToAddress: undefined,
+ withdrawAmountCredits: undefined,
};
}
@@ -643,6 +679,12 @@ export function getStepDescription(step: BridgeStep): string {
contract_review: 'Review contract',
contract_registering: 'Publishing contract...',
contract_complete: 'Contract registered',
+ // Withdraw steps
+ withdraw_enter_identity: 'Withdraw credits',
+ withdraw_configure: 'Configure withdrawal',
+ withdraw_submitting: 'Submitting withdrawal...',
+ withdraw_tracking: 'Processing withdrawal...',
+ withdraw_complete: 'Withdrawal complete',
};
return descriptions[step];
}
@@ -689,6 +731,12 @@ export function getStepProgress(step: BridgeStep): number {
contract_review: 60,
contract_registering: 80,
contract_complete: 100,
+ // Withdraw steps
+ withdraw_enter_identity: 20,
+ withdraw_configure: 40,
+ withdraw_submitting: 70,
+ withdraw_tracking: 85,
+ withdraw_complete: 100,
};
return progress[step];
}
@@ -715,6 +763,9 @@ export function isProcessingStep(step: BridgeStep): boolean {
'manage_updating',
// Contract registration processing steps
'contract_registering',
+ // Withdraw processing steps
+ 'withdraw_submitting',
+ 'withdraw_tracking',
];
return processingSteps.includes(step);
}
@@ -1477,3 +1528,237 @@ export function resetFaucetState(state: BridgeState): BridgeState {
faucetError: undefined,
};
}
+
+// ============================================================================
+// Withdraw (Asset Unlock) State Functions
+// ============================================================================
+
+/**
+ * Start fetching identity keys + balance for withdrawal
+ */
+export function setWithdrawIdentityFetching(state: BridgeState, identityId: string): BridgeState {
+ return {
+ ...state,
+ targetIdentityId: identityId,
+ withdrawIdentityFetching: true,
+ withdrawIdentityFetchError: undefined,
+ withdrawIdentityKeys: undefined,
+ withdrawBalance: undefined,
+ withdrawSigningKeyInfo: undefined,
+ withdrawKeyValidationError: undefined,
+ };
+}
+
+/**
+ * Identity fetch succeeded — advance to configure step with keys + balance
+ */
+export function setWithdrawIdentityFetched(
+ state: BridgeState,
+ keys: IdentityPublicKeyInfo[],
+ balanceCredits: bigint
+): BridgeState {
+ return {
+ ...state,
+ step: 'withdraw_configure',
+ withdrawIdentityFetching: false,
+ withdrawIdentityFetchError: undefined,
+ withdrawIdentityKeys: keys,
+ withdrawBalance: balanceCredits,
+ };
+}
+
+/**
+ * Identity fetch failed
+ */
+export function setWithdrawIdentityFetchError(state: BridgeState, error: string): BridgeState {
+ return {
+ ...state,
+ withdrawIdentityFetching: false,
+ withdrawIdentityFetchError: error,
+ withdrawIdentityKeys: undefined,
+ withdrawBalance: undefined,
+ };
+}
+
+/**
+ * Signing key validated (matched a TRANSFER key on the identity)
+ */
+export function setWithdrawKeyValidated(
+ state: BridgeState,
+ keyId: number,
+ purpose: number,
+ securityLevel: number,
+ privateKeyWif: string
+): BridgeState {
+ return {
+ ...state,
+ withdrawPrivateKeyWif: privateKeyWif,
+ withdrawSigningKeyInfo: { keyId, purpose, securityLevel },
+ withdrawKeyValidationError: undefined,
+ };
+}
+
+/**
+ * Key validation failed
+ */
+export function setWithdrawKeyValidationError(state: BridgeState, error: string): BridgeState {
+ return {
+ ...state,
+ withdrawSigningKeyInfo: undefined,
+ withdrawPrivateKeyWif: undefined,
+ withdrawKeyValidationError: error,
+ };
+}
+
+/**
+ * Clear key validation (when private key input changes)
+ */
+export function clearWithdrawKeyValidation(state: BridgeState): BridgeState {
+ return {
+ ...state,
+ withdrawSigningKeyInfo: undefined,
+ withdrawPrivateKeyWif: undefined,
+ withdrawKeyValidationError: undefined,
+ };
+}
+
+/**
+ * Set the destination address input; pass `error` when it failed validation.
+ * The raw input is kept either way so the user can correct typos in place.
+ */
+export function setWithdrawAddress(state: BridgeState, input: string, error?: string): BridgeState {
+ return {
+ ...state,
+ withdrawToAddress: input || undefined,
+ withdrawAddressError: error,
+ };
+}
+
+/**
+ * Set validated withdrawal amount (in credits)
+ */
+export function setWithdrawAmount(state: BridgeState, credits: bigint, input: string): BridgeState {
+ return {
+ ...state,
+ withdrawAmountCredits: credits,
+ withdrawAmountInput: input,
+ withdrawAmountError: undefined,
+ };
+}
+
+/**
+ * Amount validation failed (keeps the raw input for correction)
+ */
+export function setWithdrawAmountError(state: BridgeState, input: string, error: string): BridgeState {
+ return {
+ ...state,
+ withdrawAmountCredits: undefined,
+ withdrawAmountInput: input,
+ withdrawAmountError: error,
+ };
+}
+
+/**
+ * Start submitting the withdrawal transition
+ */
+export function setWithdrawSubmitting(state: BridgeState): BridgeState {
+ return {
+ ...state,
+ step: 'withdraw_submitting',
+ withdrawResult: undefined,
+ withdrawStatus: undefined,
+ withdrawStatusError: undefined,
+ };
+}
+
+/**
+ * Withdrawal transition accepted (or its outcome is unknown after a submission
+ * timeout) — start tracking payout status. `remainingBalance` is undefined in
+ * the timed-out case, where the post-withdrawal balance was never observed.
+ */
+export function setWithdrawSubmitted(state: BridgeState, remainingBalance?: bigint): BridgeState {
+ return {
+ ...state,
+ step: 'withdraw_tracking',
+ withdrawResult: { success: true, remainingBalance },
+ withdrawStatus: WithdrawalStatus.QUEUED,
+ };
+}
+
+/**
+ * Withdrawal transition failed
+ */
+export function setWithdrawSubmitError(state: BridgeState, error: string): BridgeState {
+ return {
+ ...state,
+ step: 'withdraw_complete',
+ withdrawResult: { success: false, error },
+ };
+}
+
+/**
+ * Update tracked withdrawal status; terminal statuses finish the flow.
+ * A successful status read also clears any transient polling warning.
+ */
+export function setWithdrawStatusUpdate(state: BridgeState, status: number): BridgeState {
+ const isTerminal = status === WithdrawalStatus.COMPLETE || status === WithdrawalStatus.EXPIRED;
+ return {
+ ...state,
+ step: isTerminal ? 'withdraw_complete' : state.step,
+ withdrawStatus: status,
+ withdrawStatusError: undefined,
+ };
+}
+
+/**
+ * Set or clear the transient status-polling note shown on the tracking step
+ */
+export function setWithdrawStatusNote(state: BridgeState, note?: string): BridgeState {
+ return {
+ ...state,
+ withdrawStatusError: note,
+ };
+}
+
+/**
+ * Stop tracking without a terminal status (timeout or user chose to leave).
+ * The withdrawal itself succeeded — this only annotates why tracking stopped.
+ */
+export function setWithdrawTrackingTimeout(state: BridgeState, explanation: string): BridgeState {
+ return {
+ ...state,
+ step: 'withdraw_complete',
+ withdrawStatusError: explanation,
+ };
+}
+
+/**
+ * Go back to withdraw identity entry, clearing key material
+ */
+export function setWithdrawBackToEntry(state: BridgeState): BridgeState {
+ return {
+ ...state,
+ step: 'withdraw_enter_identity',
+ withdrawSigningKeyInfo: undefined,
+ withdrawPrivateKeyWif: undefined,
+ withdrawKeyValidationError: undefined,
+ withdrawAmountCredits: undefined,
+ withdrawAmountInput: undefined,
+ withdrawAmountError: undefined,
+ withdrawToAddress: undefined,
+ withdrawAddressError: undefined,
+ };
+}
+
+/**
+ * Return to configure step to retry a failed withdrawal
+ */
+export function setWithdrawRetry(state: BridgeState): BridgeState {
+ return {
+ ...state,
+ step: 'withdraw_configure',
+ withdrawResult: undefined,
+ withdrawStatus: undefined,
+ withdrawStatusError: undefined,
+ };
+}
diff --git a/src/utils/credits.test.ts b/src/utils/credits.test.ts
new file mode 100644
index 0000000..2ce5e0b
--- /dev/null
+++ b/src/utils/credits.test.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect } from 'vitest';
+
+import {
+ CREDITS_PER_DASH,
+ MIN_WITHDRAWAL_CREDITS,
+ MAX_WITHDRAWAL_CREDITS,
+ WITHDRAWAL_FEE_RESERVE_CREDITS,
+ parseDashToCredits,
+ formatCreditsAsDash,
+ formatCredits,
+ maxWithdrawableCredits,
+ validateWithdrawalAmount,
+} from './credits.js';
+
+describe('parseDashToCredits', () => {
+ it('parses whole DASH amounts', () => {
+ expect(parseDashToCredits('1')).toBe(CREDITS_PER_DASH);
+ expect(parseDashToCredits('500')).toBe(MAX_WITHDRAWAL_CREDITS);
+ });
+
+ it('parses fractional amounts at credit precision', () => {
+ expect(parseDashToCredits('0.00001')).toBe(1_000_000n); // 1000 duffs
+ expect(parseDashToCredits('0.00000001')).toBe(1000n); // 1 duff
+ expect(parseDashToCredits('0.00000000001')).toBe(1n); // 1 credit
+ expect(parseDashToCredits('1.5')).toBe(150_000_000_000n);
+ });
+
+ it('accepts the consensus minimum as a DASH string', () => {
+ expect(parseDashToCredits('0.00001')).toBe(MIN_WITHDRAWAL_CREDITS);
+ });
+
+ it('rejects more than 11 fractional digits', () => {
+ expect(parseDashToCredits('0.000000000001')).toBeNull();
+ });
+
+ it('rejects malformed input', () => {
+ expect(parseDashToCredits('')).toBeNull();
+ expect(parseDashToCredits('abc')).toBeNull();
+ expect(parseDashToCredits('-1')).toBeNull();
+ expect(parseDashToCredits('1.')).toBeNull();
+ expect(parseDashToCredits('.5')).toBeNull();
+ expect(parseDashToCredits('1e5')).toBeNull();
+ expect(parseDashToCredits('1,5')).toBeNull();
+ });
+
+ it('trims surrounding whitespace', () => {
+ expect(parseDashToCredits(' 2 ')).toBe(2n * CREDITS_PER_DASH);
+ });
+});
+
+describe('formatCreditsAsDash', () => {
+ it('formats whole DASH without a fraction', () => {
+ expect(formatCreditsAsDash(CREDITS_PER_DASH)).toBe('1');
+ expect(formatCreditsAsDash(0n)).toBe('0');
+ });
+
+ it('trims trailing zeros in the fraction', () => {
+ expect(formatCreditsAsDash(150_000_000_000n)).toBe('1.5');
+ expect(formatCreditsAsDash(1_000_000n)).toBe('0.00001');
+ expect(formatCreditsAsDash(1n)).toBe('0.00000000001');
+ });
+
+ it('formats mid-range balances', () => {
+ expect(formatCreditsAsDash(250_000_000_000n)).toBe('2.5');
+ });
+
+ it('round-trips with parseDashToCredits', () => {
+ for (const credits of [1n, 1000n, MIN_WITHDRAWAL_CREDITS, 123_456_789_012n, MAX_WITHDRAWAL_CREDITS]) {
+ expect(parseDashToCredits(formatCreditsAsDash(credits))).toBe(credits);
+ }
+ });
+});
+
+describe('formatCredits', () => {
+ it('adds thousands separators', () => {
+ expect(formatCredits(1_000_000n)).toBe('1,000,000');
+ expect(formatCredits(1234n)).toBe('1,234');
+ });
+});
+
+describe('maxWithdrawableCredits', () => {
+ it('reserves fee headroom from the balance', () => {
+ expect(maxWithdrawableCredits(1_000_000_000n)).toBe(1_000_000_000n - WITHDRAWAL_FEE_RESERVE_CREDITS);
+ });
+
+ it('caps at the consensus maximum for huge balances', () => {
+ expect(maxWithdrawableCredits(MAX_WITHDRAWAL_CREDITS * 3n)).toBe(MAX_WITHDRAWAL_CREDITS);
+ });
+
+ it('goes negative for dust balances', () => {
+ expect(maxWithdrawableCredits(1000n) < 0n).toBe(true);
+ });
+});
+
+describe('validateWithdrawalAmount', () => {
+ const balance = 25_000_000_000n; // 0.25 DASH
+
+ it('accepts a normal amount', () => {
+ expect(validateWithdrawalAmount('0.1', balance)).toEqual({ credits: 10_000_000_000n });
+ });
+
+ it('rejects malformed input', () => {
+ const result = validateWithdrawalAmount('abc', balance);
+ expect('error' in result && result.error).toContain('valid DASH amount');
+ });
+
+ it('rejects amounts below the consensus minimum', () => {
+ const result = validateWithdrawalAmount('0.000001', balance);
+ expect('error' in result && result.error).toContain('Minimum withdrawal');
+ });
+
+ it('rejects amounts above the consensus maximum', () => {
+ const result = validateWithdrawalAmount('501', MAX_WITHDRAWAL_CREDITS * 2n);
+ expect('error' in result && result.error).toContain('Maximum withdrawal');
+ });
+
+ it('rejects amounts above the balance', () => {
+ const result = validateWithdrawalAmount('1', balance);
+ expect('error' in result && result.error).toContain('exceeds your balance');
+ });
+
+ it('reports a too-small balance instead of a negative maximum', () => {
+ // Balance above the withdrawal minimum but below the fee reserve:
+ // the amount passes the balance check but no maximum is withdrawable.
+ const dustBalance = 2_000_000n; // 0.00002 DASH, reserve is 50M
+ const result = validateWithdrawalAmount('0.00001', dustBalance);
+ expect('error' in result && result.error).toContain('too small');
+ expect('error' in result && result.error).not.toContain('-');
+ });
+
+ it('rejects amounts inside the fee-reserve headroom', () => {
+ // Between balance - reserve and balance: passes the plain balance check
+ // but not the fee-reserve check.
+ const result = validateWithdrawalAmount('0.24999', balance);
+ expect('error' in result && result.error).toContain('transition fee');
+ });
+
+ it('accepts exactly the max withdrawable amount', () => {
+ const max = maxWithdrawableCredits(balance);
+ expect(validateWithdrawalAmount(formatCreditsAsDash(max), balance)).toEqual({ credits: max });
+ });
+});
diff --git a/src/utils/credits.ts b/src/utils/credits.ts
new file mode 100644
index 0000000..7b4909d
--- /dev/null
+++ b/src/utils/credits.ts
@@ -0,0 +1,110 @@
+/**
+ * Credit/duff/DASH unit conversions for Platform credit withdrawals.
+ *
+ * 1 duff = 1000 credits; 1 DASH = 100,000,000 duffs = 100,000,000,000 credits.
+ */
+
+export const CREDITS_PER_DUFF = 1000n;
+export const DUFFS_PER_DASH = 100_000_000n;
+export const CREDITS_PER_DASH = CREDITS_PER_DUFF * DUFFS_PER_DASH; // 100_000_000_000n
+
+/**
+ * Minimum withdrawal amount enforced by Platform consensus: 1000 duffs.
+ * (platform system_limits min_withdrawal_amount, raised from 190 duffs in protocol v12.)
+ */
+export const MIN_WITHDRAWAL_CREDITS = 1_000_000n;
+
+/**
+ * Maximum withdrawal amount enforced by Platform consensus: 500 DASH.
+ */
+export const MAX_WITHDRAWAL_CREDITS = 50_000_000_000_000n;
+
+/** Max fractional digits a DASH amount can carry at credit precision. */
+const DASH_DECIMALS = 11;
+
+/**
+ * Parse a decimal DASH amount string into credits.
+ * Returns null for anything that isn't a plain non-negative decimal number
+ * with at most 11 fractional digits (credit precision).
+ */
+export function parseDashToCredits(input: string): bigint | null {
+ const trimmed = input.trim();
+ if (!/^\d+(\.\d+)?$/.test(trimmed)) return null;
+ const [wholePart, fracPart = ''] = trimmed.split('.');
+ if (fracPart.length > DASH_DECIMALS) return null;
+ const fracPadded = fracPart.padEnd(DASH_DECIMALS, '0');
+ return BigInt(wholePart) * CREDITS_PER_DASH + BigInt(fracPadded);
+}
+
+/**
+ * Format a credit amount as a decimal DASH string, trimming trailing zeros.
+ */
+export function formatCreditsAsDash(credits: bigint): string {
+ const negative = credits < 0n;
+ const abs = negative ? -credits : credits;
+ const whole = abs / CREDITS_PER_DASH;
+ const frac = (abs % CREDITS_PER_DASH).toString().padStart(DASH_DECIMALS, '0').replace(/0+$/, '');
+ const sign = negative ? '-' : '';
+ return frac.length > 0 ? `${sign}${whole}.${frac}` : `${sign}${whole}`;
+}
+
+/**
+ * Format a credit amount with thousands separators (for "= N credits" captions).
+ */
+export function formatCredits(credits: bigint): string {
+ return credits.toLocaleString('en-US');
+}
+
+/**
+ * Platform's minimum-fee gate for credit withdrawals: the network rejects a
+ * withdrawal unless balance >= amount + this value (protocol constant
+ * `state_transition_min_fees.credit_withdrawal`, 400M credits — "credit
+ * withdrawals are more expensive than the rest"). There is no "withdraw all"
+ * sentinel in the protocol, so the exact maximum is balance minus this gate.
+ */
+export const WITHDRAWAL_FEE_RESERVE_CREDITS = 400_000_000n; // 0.004 DASH
+
+/**
+ * The most that can be withdrawn from a balance: the per-transition consensus
+ * maximum, capped by the balance minus the fee reserve. Can be negative for
+ * dust balances — callers compare against MIN_WITHDRAWAL_CREDITS.
+ */
+export function maxWithdrawableCredits(balanceCredits: bigint): bigint {
+ const spendable = balanceCredits - WITHDRAWAL_FEE_RESERVE_CREDITS;
+ return spendable > MAX_WITHDRAWAL_CREDITS ? MAX_WITHDRAWAL_CREDITS : spendable;
+}
+
+export type WithdrawalAmountValidation = { credits: bigint } | { error: string };
+
+/**
+ * Parse and validate a user-entered DASH amount against the consensus limits
+ * and the identity's balance (including fee-reserve headroom).
+ */
+export function validateWithdrawalAmount(
+ input: string,
+ balanceCredits: bigint
+): WithdrawalAmountValidation {
+ const credits = parseDashToCredits(input);
+ if (credits === null) {
+ return { error: 'Enter a valid DASH amount (up to 11 decimal places)' };
+ }
+ if (credits < MIN_WITHDRAWAL_CREDITS) {
+ return { error: `Minimum withdrawal is ${formatCreditsAsDash(MIN_WITHDRAWAL_CREDITS)} DASH (1000 duffs)` };
+ }
+ if (credits > MAX_WITHDRAWAL_CREDITS) {
+ return { error: `Maximum withdrawal is ${formatCreditsAsDash(MAX_WITHDRAWAL_CREDITS)} DASH per transaction` };
+ }
+ if (credits > balanceCredits) {
+ return { error: `Amount exceeds your balance of ${formatCreditsAsDash(balanceCredits)} DASH` };
+ }
+ const max = maxWithdrawableCredits(balanceCredits);
+ if (credits > max) {
+ if (max < MIN_WITHDRAWAL_CREDITS) {
+ return { error: 'Balance is too small to cover the withdrawal minimum plus the Platform transition fee' };
+ }
+ return {
+ error: `Amount is too close to your full balance to cover the Platform transition fee. Maximum: ${formatCreditsAsDash(max)} DASH`,
+ };
+ }
+ return { credits };
+}
diff --git a/src/utils/errors.ts b/src/utils/errors.ts
new file mode 100644
index 0000000..d5a6bab
--- /dev/null
+++ b/src/utils/errors.ts
@@ -0,0 +1,11 @@
+/**
+ * Extract a human-readable message from an unknown caught value.
+ * WasmSdkError is not a standard Error, so a plain `instanceof Error`
+ * check misses it — fall back to any object with a `message` property.
+ */
+export function extractErrorMessage(error: unknown): string {
+ if (error && typeof error === 'object' && 'message' in error) {
+ return String((error as { message: unknown }).message);
+ }
+ return error instanceof Error ? error.message : String(error);
+}