Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions e2e/deterministic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down
129 changes: 129 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

.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;
}
</style>
</head>
<body>
Expand Down
10 changes: 9 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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' &&
Expand All @@ -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 [];
}
Expand Down Expand Up @@ -164,6 +171,7 @@ export function createCustomDevnetConfig(params: {
name: params.name,
insightApiUrl: params.insightApiUrl,
addressPrefix: 140,
p2shPrefix: 19,
wifPrefix: 239,
minFee: 1000,
dustThreshold: 546,
Expand Down
62 changes: 62 additions & 0 deletions src/crypto/address.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
49 changes: 48 additions & 1 deletion src/crypto/address.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down
2 changes: 1 addition & 1 deletion src/crypto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading