diff --git a/.gitignore b/.gitignore index ad90a56..0c12622 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ .claude/ *.log .DS_Store +.env +.env.local +.env.*.local diff --git a/index.html b/index.html index a73a4fe..4eb8fbd 100644 --- a/index.html +++ b/index.html @@ -727,6 +727,50 @@ color: #008de4; } + .network-btn.local.active { + background: rgba(156, 39, 176, 0.15); + border-color: #9c27b0; + color: #d178e0; + } + + .local-cert-notice { + background: rgba(255, 152, 0, 0.08); + border: 1px solid rgba(255, 152, 0, 0.25); + border-radius: 10px; + padding: 12px 16px; + margin: -8px 0 24px; + font-size: 0.85rem; + color: #ccc; + text-align: center; + line-height: 1.5; + } + + .local-cert-notice code { + background: rgba(255, 255, 255, 0.06); + padding: 1px 6px; + border-radius: 4px; + font-size: 0.8rem; + } + + .local-cert-links { + display: inline-flex; + gap: 8px; + margin-left: 6px; + } + + .local-cert-links a { + color: #ff9800; + text-decoration: none; + padding: 2px 8px; + border: 1px solid rgba(255, 152, 0, 0.4); + border-radius: 4px; + font-size: 0.8rem; + } + + .local-cert-links a:hover { + background: rgba(255, 152, 0, 0.1); + } + /* Configure keys step */ .configure-keys-step { text-align: left; diff --git a/src/api/dapi.ts b/src/api/dapi.ts index 669c7b9..83179f8 100644 --- a/src/api/dapi.ts +++ b/src/api/dapi.ts @@ -1,44 +1,50 @@ /** - * Client for InstantSend lock retrieval via RPC API + * Client for InstantSend lock retrieval via JSON-RPC. + * + * - testnet/mainnet: hits the public tRPC mirrors (trpc/rpc.digitalcash.dev). + * - local: hits Dash Core's `getislocks` RPC directly via the Vite dev proxy + * (which injects basic-auth from .env.local). */ import { withRetry, type RetryOptions } from '../utils/retry.js'; - -const API_URLS = { - testnet: 'https://trpc.digitalcash.dev', - mainnet: 'https://rpc.digitalcash.dev', -} as const; +import { getNetwork, type NetworkName } from '../config.js'; export interface DAPIConfig { - network: 'testnet' | 'mainnet'; + network: NetworkName; } /** - * Response from the getislocks JSON-RPC endpoint + * Response shape for `getislocks`. + * + * Both endpoints return the same per-item shape ({ txid, hex, signature?, cycleHash? }), + * but Dash Core's RPC returns the literal string `"None"` for txids that have no lock + * yet — those entries must be filtered out before parsing. */ interface IslockResponse { - result?: Array<{ - txid: string; - hex: string; // hex-encoded islock bytes - signature?: string; - cycleHash?: string; - }>; + result?: Array< + | string + | { + txid: string; + hex: string; + signature?: string; + cycleHash?: string; + } + >; error?: unknown; id?: unknown; } -/** - * Client for InstantSend lock retrieval - */ export class DAPIClient { - readonly network: 'testnet' | 'mainnet'; + readonly network: NetworkName; + private readonly rpcUrl: string; constructor(config: DAPIConfig) { this.network = config.network; + this.rpcUrl = getNetwork(config.network).instantLockRpcUrl; } /** - * Broadcast a transaction via DAPI + * Broadcast a transaction via DAPI. * * Note: This is a placeholder. Use InsightClient.broadcastTransaction instead. */ @@ -49,9 +55,8 @@ export class DAPIClient { } /** - * Get InstantSend lock from tRPC API - * Polls the API until the islock is available or timeout is reached - * @param onRetry - Optional callback when a network error causes a retry + * Get InstantSend lock from JSON-RPC. + * Polls until the islock is available or timeout is reached. */ async waitForInstantSendLock( txid: string, @@ -59,7 +64,7 @@ export class DAPIClient { onRetry?: (attempt: number, maxAttempts: number, error: unknown) => void ): Promise { const startTime = Date.now(); - const pollInterval = 2000; // Poll every 2 seconds + const pollInterval = 2000; while (Date.now() - startTime < timeoutMs) { try { @@ -71,7 +76,6 @@ export class DAPIClient { console.warn('Error polling for islock:', error); } - // Wait before next poll await new Promise((resolve) => setTimeout(resolve, pollInterval)); } @@ -81,46 +85,44 @@ export class DAPIClient { } /** - * Fetch islock from JSON-RPC API + * Fetch islock via JSON-RPC. + * + * Wire format is identical between Dash Core RPC and the public tRPC mirrors. + * Core RPC returns the string "None" inside the result array when no lock exists + * for a txid; we treat that as "not available yet" and return null. */ private async getIslock(txid: string, retryOptions?: RetryOptions): Promise { return withRetry(async () => { - const baseUrl = API_URLS[this.network]; - - const response = await fetch(baseUrl, { + const response = await fetch(this.rpcUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + jsonrpc: '1.0', + id: 'bridge', method: 'getislocks', params: [[txid]], }), }); if (!response.ok) { - throw new Error(`RPC API error: ${response.status} ${response.statusText}`); + throw new Error(`RPC error: ${response.status} ${response.statusText}`); } const data: IslockResponse = await response.json(); - // Check if we got a result - if (data.result && data.result.length > 0) { - const islockData = data.result.find((item) => item.txid === txid); - if (islockData?.hex) { - // Convert hex string to Uint8Array - return hexToBytes(islockData.hex); + if (!Array.isArray(data.result)) return null; + + for (const item of data.result) { + if (typeof item !== 'object' || item === null) continue; // skip "None" + if (item.txid === txid && item.hex) { + return hexToBytes(item.hex); } } - return null; }, retryOptions); } } -/** - * Convert hex string to Uint8Array - */ function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) { @@ -128,4 +130,3 @@ function hexToBytes(hex: string): Uint8Array { } return bytes; } - diff --git a/src/config.ts b/src/config.ts index 0aea455..0222b34 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,17 +1,24 @@ +export type NetworkName = 'testnet' | 'mainnet' | 'local'; + export interface NetworkConfig { - name: 'testnet' | 'mainnet'; + name: NetworkName; insightApiUrl: string; + /** JSON-RPC endpoint used to fetch InstantSend locks. May require basic-auth (handled by Vite proxy in dev). */ + instantLockRpcUrl: string; addressPrefix: number; wifPrefix: number; minFee: number; dustThreshold: number; platformHrp: string; faucetBaseUrl?: string; + /** Hint shown when running against a local regtest cluster (no faucet). */ + localFundingHint?: string; } export const TESTNET: NetworkConfig = { name: 'testnet', insightApiUrl: 'https://insight.testnet.networks.dash.org/insight-api', + instantLockRpcUrl: 'https://trpc.digitalcash.dev', addressPrefix: 140, // 'y' prefix wifPrefix: 239, // 0xef minFee: 1000, // 0.00001 DASH @@ -23,6 +30,7 @@ export const TESTNET: NetworkConfig = { export const MAINNET: NetworkConfig = { name: 'mainnet', insightApiUrl: 'https://insight.dash.org/insight-api', + instantLockRpcUrl: 'https://rpc.digitalcash.dev', addressPrefix: 76, // 'X' prefix wifPrefix: 204, // 0xcc minFee: 1000, @@ -30,6 +38,32 @@ export const MAINNET: NetworkConfig = { platformHrp: 'dash', }; -export function getNetwork(name: 'testnet' | 'mainnet'): NetworkConfig { - return name === 'mainnet' ? MAINNET : TESTNET; +/** + * Local regtest network served by `dashmate setup local`. + * + * URLs are same-origin proxy paths (see vite.config.ts) that forward to: + * /local-insight -> http://127.0.0.1:23001/insight-api + * /local-rpc -> http://127.0.0.1:20302 (with dashmate basic-auth injected) + * + * Regtest reuses testnet address/WIF prefixes. + */ +export const LOCAL: NetworkConfig = { + name: 'local', + insightApiUrl: '/local-insight', + instantLockRpcUrl: '/local-rpc', + addressPrefix: 140, + wifPrefix: 239, + minFee: 1000, + dustThreshold: 546, + platformHrp: 'tdash', + localFundingHint: 'Fund deposits with: dashmate core cli sendtoaddress 1 && dashmate core cli generate 1', +}; + +export function getNetwork(name: NetworkName): NetworkConfig { + switch (name) { + case 'mainnet': return MAINNET; + case 'local': return LOCAL; + case 'testnet': + default: return TESTNET; + } } diff --git a/src/crypto/hd.ts b/src/crypto/hd.ts index 745c3a0..d8ca09e 100644 --- a/src/crypto/hd.ts +++ b/src/crypto/hd.ts @@ -32,7 +32,7 @@ const IDENTITY_INDEX = 0; * Mainnet: 5 (Dash) * Testnet: 1 (Testnet) */ -export function getCoinType(network: 'testnet' | 'mainnet'): number { +export function getCoinType(network: 'testnet' | 'mainnet' | 'local'): number { return network === 'mainnet' ? 5 : 1; } @@ -58,7 +58,7 @@ export function mnemonicToHDKey(mnemonic: string, passphrase: string = ''): HDKe * Get asset lock key derivation path (BIP44) * Path: m/44'/[coin_type]'/0'/0/0 */ -export function getAssetLockDerivationPath(network: 'testnet' | 'mainnet'): string { +export function getAssetLockDerivationPath(network: 'testnet' | 'mainnet' | 'local'): string { const coinType = getCoinType(network); return `m/${BIP44_PURPOSE}'/${coinType}'/0'/0/0`; } @@ -74,7 +74,7 @@ export function getAssetLockDerivationPath(network: 'testnet' | 'mainnet'): stri */ export function getIdentityKeyDerivationPath( keyIndex: number, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', identityIndex: number = IDENTITY_INDEX, keyType: number = ECDSA_KEY_TYPE ): string { @@ -110,7 +110,7 @@ export function deriveKeyAtPath( */ export function deriveAssetLockKeyPair( mnemonic: string, - network: 'testnet' | 'mainnet' + network: 'testnet' | 'mainnet' | 'local' ): { privateKey: Uint8Array; publicKey: Uint8Array; @@ -130,7 +130,7 @@ export function deriveAssetLockKeyPair( export function deriveIdentityKey( mnemonic: string, keyIndex: number, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', identityIndex: number = IDENTITY_INDEX ): { privateKey: Uint8Array; diff --git a/src/crypto/keys.ts b/src/crypto/keys.ts index 16018f6..eb351f0 100644 --- a/src/crypto/keys.ts +++ b/src/crypto/keys.ts @@ -67,7 +67,7 @@ export function generateIdentityKey( keyType: KeyType, purpose: KeyPurpose, securityLevel: SecurityLevel, - network: 'testnet' | 'mainnet' + network: 'testnet' | 'mainnet' | 'local' ): IdentityKeyConfig { const keyPair = generateKeyPair(); const networkConfig = getNetwork(network); @@ -93,7 +93,7 @@ export function generateIdentityKey( */ export function regenerateIdentityKey( existing: IdentityKeyConfig, - network: 'testnet' | 'mainnet' + network: 'testnet' | 'mainnet' | 'local' ): IdentityKeyConfig { return generateIdentityKey( existing.id, @@ -111,7 +111,7 @@ export function regenerateIdentityKey( export function updateKeyType( existing: IdentityKeyConfig, newKeyType: KeyType, - network: 'testnet' | 'mainnet' + network: 'testnet' | 'mainnet' | 'local' ): IdentityKeyConfig { const networkConfig = getNetwork(network); const dataBytes = getKeyDataBytes(existing.publicKey, newKeyType); @@ -129,7 +129,7 @@ export function updateKeyType( * @deprecated Use generateDefaultIdentityKeysHD for HD derivation */ export function generateDefaultIdentityKeys( - network: 'testnet' | 'mainnet' + network: 'testnet' | 'mainnet' | 'local' ): IdentityKeyConfig[] { return [ generateIdentityKey(0, 'Master', 'ECDSA_SECP256K1', 'AUTHENTICATION', 'MASTER', network), @@ -151,7 +151,7 @@ export function createIdentityKeyFromKeyPair( securityLevel: SecurityLevel, privateKey: Uint8Array, publicKey: Uint8Array, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', derivationPath?: string ): IdentityKeyConfig { const networkConfig = getNetwork(network); @@ -182,7 +182,7 @@ export function generateIdentityKeyFromMnemonic( keyType: KeyType, purpose: KeyPurpose, securityLevel: SecurityLevel, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', mnemonic: string, keyIndex: number ): IdentityKeyConfig { @@ -205,7 +205,7 @@ export function generateIdentityKeyFromMnemonic( * Generate default identity keys using HD derivation */ export function generateDefaultIdentityKeysHD( - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', mnemonic: string ): IdentityKeyConfig[] { return [ @@ -235,7 +235,7 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { export function findMatchingKeyIndex( privateKeyWif: string, identityPublicKeys: IdentityPublicKeyInfo[], - network: 'testnet' | 'mainnet' + network: 'testnet' | 'mainnet' | 'local' ): { keyId: number; securityLevel: number; purpose: number; publicKey: Uint8Array } | null { // Decode the WIF to get the private key let privateKey: Uint8Array; diff --git a/src/main.ts b/src/main.ts index 580c5a5..d9b83b0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -125,7 +125,11 @@ function init() { const urlParams = new URLSearchParams(window.location.search); // Infer network from ?address= param prefix, falling back to ?network= param - let network: 'testnet' | 'mainnet' = urlParams.get('network') === 'mainnet' ? 'mainnet' : 'testnet'; + const networkParam = urlParams.get('network'); + let network: 'testnet' | 'mainnet' | 'local' = + networkParam === 'mainnet' ? 'mainnet' + : networkParam === 'local' ? 'local' + : 'testnet'; const addressParam = urlParams.get('address')?.trim(); if (addressParam) { try { @@ -232,7 +236,7 @@ function setupEventListeners(container: HTMLElement) { // Network selector buttons container.querySelectorAll('.network-btn').forEach((btn) => { btn.addEventListener('click', () => { - const network = (btn as HTMLElement).dataset.network as 'testnet' | 'mainnet'; + const network = (btn as HTMLElement).dataset.network as 'testnet' | 'mainnet' | 'local'; if (network && network !== state.network) { // Update state and reinitialize clients for new network updateState(setNetwork(state, network)); @@ -344,6 +348,12 @@ function setupEventListeners(container: HTMLElement) { faucetBtn.addEventListener('click', requestFaucetFunds); } + // Local-network funds button (regtest only): RPC sendtoaddress through the dev proxy + const localFundsBtn = container.querySelector('#use-local-funds-btn'); + if (localFundsBtn) { + localFundsBtn.addEventListener('click', requestLocalFunds); + } + // Deposit method toggle (testnet collapsible section) const depositToggle = container.querySelector('.deposit-method-toggle'); if (depositToggle) { @@ -1104,8 +1114,8 @@ function setupEventListeners(container: HTMLElement) { const keys = await getIdentityPublicKeys(result.identityId, state.network); let balance: number | undefined; try { - const { EvoSDK } = await import('@dashevo/evo-sdk'); - const sdk = state.network === 'mainnet' ? EvoSDK.mainnetTrusted() : EvoSDK.testnetTrusted(); + const { createPlatformSdk } = await import('./platform/sdk.js'); + const sdk = await createPlatformSdk(state.network); await sdk.connect(); const br = await sdk.identities.balanceAndRevision(result.identityId); balance = Number(br?.balance ?? 0n); @@ -1383,7 +1393,7 @@ function parseKeyBackup(json: unknown): { identityId: string; privateKeyWif: str /** * Validate platform address format (bech32m, correct network prefix) */ -function validatePlatformAddress(address: string, network: 'testnet' | 'mainnet'): boolean { +function validatePlatformAddress(address: string, network: 'testnet' | 'mainnet' | 'local'): boolean { const trimmed = address.trim(); if (!trimmed) return false; @@ -2151,6 +2161,73 @@ async function requestFaucetFunds() { } } +/** + * Send funds to the deposit address via dashmate's Core RPC (regtest only). + * + * Goes through the Vite `/local-rpc` proxy, which injects the basic-auth header + * from .env.local so credentials never reach the browser bundle. After the call, + * the existing UTXO polling loop picks up the deposit automatically. + */ +async function requestLocalFunds() { + if (state.network !== 'local' || !state.depositAddress) return; + + // Reuse the faucet state slot — same UX (button → loading → success/error). + if (state.faucetRequestStatus === 'requesting') return; + + // Mirror the testnet faucet's 1 DASH default — plenty of headroom. + const amountDash = 1.0; + + updateState(setFaucetRequesting(state)); + + try { + const response = await fetch('/local-rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '1.0', + id: 'bridge-local-funds', + method: 'sendtoaddress', + params: [state.depositAddress, amountDash, '', '', false, true], + }), + }); + + if (!response.ok) { + throw new Error(`RPC HTTP ${response.status}`); + } + + const data = await response.json(); + if (data.error) { + const msg = typeof data.error === 'string' + ? data.error + : (data.error?.message ?? JSON.stringify(data.error)); + throw new Error(msg); + } + + const txid: string = data.result; + updateState(setFaucetSuccess(state, txid)); + + // Quick UTXO check shortly after — same pattern as the faucet path. + const addressToCheck = state.depositAddress; + setTimeout(async () => { + if (!addressToCheck || state.step !== 'detecting_deposit') return; + try { + const utxos = await insightClient.getUTXOs(addressToCheck); + const min = state.minimumDeposit || 300_000; + const sufficient = utxos.find((u) => u.satoshis >= min); + if (sufficient && state.step === 'detecting_deposit') { + updateState(setUtxoDetected(state, sufficient)); + } + } catch { + // Ignored — regular polling will catch it. + } + }, 250); + } catch (error) { + console.error('Local funds RPC error:', error); + const message = error instanceof Error ? error.message : 'Local RPC call failed'; + updateState(setFaucetError(state, message)); + } +} + // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); diff --git a/src/platform/contract.ts b/src/platform/contract.ts index d69bff4..994fac9 100644 --- a/src/platform/contract.ts +++ b/src/platform/contract.ts @@ -1,5 +1,6 @@ -import { EvoSDK, IdentitySigner, DataContract } from '@dashevo/evo-sdk'; +import { IdentitySigner, DataContract } from '@dashevo/evo-sdk'; import { withRetry, type RetryOptions } from '../utils/retry.js'; +import { createPlatformSdk } from './sdk.js'; /** * Publish a data contract on Dash Platform. @@ -17,12 +18,10 @@ export async function publishContract( tokens: Record | undefined, publicKeyId: number, privateKeyWif: string, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', retryOptions?: RetryOptions, ): Promise<{ contractId: string }> { - const sdk = network === 'mainnet' - ? EvoSDK.mainnetTrusted() - : EvoSDK.testnetTrusted(); + const sdk = await createPlatformSdk(network); console.log(`Connecting to ${network} for contract publishing...`); await withRetry(() => sdk.connect(), retryOptions); diff --git a/src/platform/dpns.ts b/src/platform/dpns.ts index ff10676..2f00fd2 100644 --- a/src/platform/dpns.ts +++ b/src/platform/dpns.ts @@ -1,4 +1,5 @@ -import { EvoSDK, IdentitySigner } from '@dashevo/evo-sdk'; +import { IdentitySigner } from "@dashevo/evo-sdk"; +import { createPlatformSdk } from "./sdk.js"; import type { DpnsUsernameEntry, DpnsRegistrationResult, IdentityPublicKeyInfo } from '../types.js'; import { withRetry, type RetryOptions } from '../utils/retry.js'; @@ -7,10 +8,10 @@ import { withRetry, type RetryOptions } from '../utils/retry.js'; */ export async function getIdentityPublicKeys( identityId: string, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', retryOptions?: RetryOptions ): Promise { - const sdk = network === 'mainnet' ? EvoSDK.mainnetTrusted() : EvoSDK.testnetTrusted(); + const sdk = await createPlatformSdk(network); console.log(`Connecting to ${network} to fetch identity ${identityId}...`); await withRetry(() => sdk.connect(), retryOptions); @@ -211,11 +212,11 @@ export function createEmptyUsernameEntry(): DpnsUsernameEntry { */ export async function checkUsernameAvailability( label: string, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', retryOptions?: RetryOptions ): Promise { // Must use trusted mode for WASM SDK - const sdk = network === 'mainnet' ? EvoSDK.mainnetTrusted() : EvoSDK.testnetTrusted(); + const sdk = await createPlatformSdk(network); console.log(`Connecting to ${network} to check username availability...`); await withRetry(() => sdk.connect(), retryOptions); @@ -236,11 +237,11 @@ export async function checkUsernameAvailability( */ export async function checkMultipleAvailability( entries: DpnsUsernameEntry[], - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', retryOptions?: RetryOptions ): Promise { // Must use trusted mode for WASM SDK - const sdk = network === 'mainnet' ? EvoSDK.mainnetTrusted() : EvoSDK.testnetTrusted(); + const sdk = await createPlatformSdk(network); console.log(`Connecting to ${network} to check ${entries.length} username(s)...`); await withRetry(() => sdk.connect(), retryOptions); @@ -289,14 +290,12 @@ export async function registerDpnsName( identityId: string, publicKeyId: number, privateKeyWif: string, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', onPreorder?: () => void, retryOptions?: RetryOptions ): Promise<{ success: boolean; isContested: boolean; error?: string }> { // Use trusted mode for registration (requires identity fetch) - const sdk = network === 'mainnet' - ? EvoSDK.mainnetTrusted() - : EvoSDK.testnetTrusted(); + const sdk = await createPlatformSdk(network); console.log(`Connecting to ${network} for DPNS registration...`); await withRetry(() => sdk.connect(), retryOptions); @@ -354,7 +353,7 @@ export async function registerMultipleNames( identityId: string, publicKeyId: number, privateKeyWif: string, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', onProgress?: (current: number, total: number, label: string) => void ): Promise { const results: DpnsRegistrationResult[] = []; diff --git a/src/platform/identity.ts b/src/platform/identity.ts index 3e77697..142df05 100644 --- a/src/platform/identity.ts +++ b/src/platform/identity.ts @@ -1,5 +1,4 @@ import { - EvoSDK, AssetLockProof, Identity, IdentityPublicKey, @@ -12,6 +11,11 @@ import { sha256 } from '@noble/hashes/sha256'; import { ripemd160 } from '@noble/hashes/ripemd160'; import type { PublicKeyInfo, IdentityKeyConfig, AssetLockProofData } from '../types.js'; import { withRetry, type RetryOptions } from '../utils/retry.js'; +import { + createPlatformSdk, + PLATFORM_PUT_SETTINGS, + PLATFORM_OPERATION_TIMEOUT_MS, +} from './sdk.js'; /** * Compute hash160 (RIPEMD160(SHA256(data))) of a buffer @@ -41,21 +45,6 @@ function base64ToBytes(base64: string): Uint8Array { return bytes; } -const PLATFORM_REQUEST_SETTINGS = { - connectTimeoutMs: 10000, - // wait_for_state_transition_result uses a 30s server-side wait window, - // so client-side request timeout must exceed that on runtimes that honor it. - timeoutMs: 40000, - retries: 2, - banFailedAddress: true, -} as const; - -const PLATFORM_PUT_SETTINGS = { - ...PLATFORM_REQUEST_SETTINGS, -} as const; - -const PLATFORM_OPERATION_TIMEOUT_MS = 45000; - async function withOperationTimeout( promise: Promise, action: string, @@ -79,16 +68,6 @@ async function withOperationTimeout( } } -function createPlatformSdk(network: 'testnet' | 'mainnet'): EvoSDK { - const options = { settings: PLATFORM_REQUEST_SETTINGS }; - - if (network === 'mainnet') { - return EvoSDK.mainnetTrusted(options); - } - - return EvoSDK.testnetTrusted(options); -} - /** * Identity key types as defined by Dash Platform */ @@ -194,11 +173,11 @@ export async function registerIdentity( assetLockProofData: AssetLockProofData, assetLockPrivateKeyWif: string, identityKeys: IdentityKeyConfig[], - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', retryOptions?: RetryOptions ): Promise<{ identityId: string; balance: number; revision: number }> { // Initialize SDK for the target network - const sdk = createPlatformSdk(network); + const sdk = await createPlatformSdk(network); // Connect to the network with retry console.log(`Connecting to ${network}...`); @@ -222,9 +201,11 @@ export async function registerIdentity( const publicKey = new IdentityPublicKey({ keyId: key.id, - purpose: key.purpose, - securityLevel: key.securityLevel, - keyType: key.keyType, + // SDK accepts uppercase strings, lowercase strings, or numeric enum values. + // Type defs in newer wasm-sdk are stricter than runtime — cast to bridge gap. + purpose: key.purpose as any, + securityLevel: key.securityLevel as any, + keyType: key.keyType as any, isReadOnly: false, data: keyBytes, }); @@ -277,11 +258,11 @@ export async function topUpIdentity( identityId: string, assetLockProofData: AssetLockProofData, assetLockPrivateKeyWif: string, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', retryOptions?: RetryOptions ): Promise<{ success: boolean; balance?: number }> { // Initialize SDK for the target network (trusted mode required for identity fetch) - const sdk = createPlatformSdk(network); + const sdk = await createPlatformSdk(network); // Connect to the network with retry console.log(`Connecting to ${network}...`); @@ -357,11 +338,11 @@ export async function updateIdentity( privateKeyWif: string, addPublicKeys: AddKeyConfig[], disablePublicKeyIds: number[], - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', retryOptions?: RetryOptions ): Promise<{ success: boolean; error?: string }> { // Initialize SDK for the target network (trusted mode required for identity fetch) - const sdk = createPlatformSdk(network); + const sdk = await createPlatformSdk(network); console.log(`Connecting to ${network}...`); await withRetry(() => sdk.connect(), retryOptions); @@ -408,9 +389,9 @@ export async function updateIdentity( return new IdentityPublicKeyInCreation({ keyId: maxKeyId + index + 1, - purpose: key.purpose, - securityLevel: key.securityLevel, - keyType: key.keyType, + purpose: key.purpose as any, + securityLevel: key.securityLevel as any, + keyType: key.keyType as any, isReadOnly: false, data: keyDataBytes, }); @@ -463,10 +444,10 @@ export async function sendToPlatformAddress( recipientAddress: string, assetLockProofData: AssetLockProofData, assetLockPrivateKeyWif: string, - network: 'testnet' | 'mainnet', + network: 'testnet' | 'mainnet' | 'local', retryOptions?: RetryOptions ): Promise<{ success: boolean; recipientAddress: string }> { - const sdk = createPlatformSdk(network); + const sdk = await createPlatformSdk(network); console.log(`Connecting to ${network}...`); await withRetry(() => sdk.connect(), retryOptions); diff --git a/src/platform/sdk.ts b/src/platform/sdk.ts new file mode 100644 index 0000000..88605e6 --- /dev/null +++ b/src/platform/sdk.ts @@ -0,0 +1,70 @@ +import { + EvoSDK, + WasmSdkBuilder, + WasmTrustedContext, + ensureInitialized, +} from '@dashevo/evo-sdk'; +import type { NetworkName } from '../config.js'; + +export const PLATFORM_REQUEST_SETTINGS = { + connectTimeoutMs: 10000, + // wait_for_state_transition_result uses a 30s server-side wait window, so the + // client-side request timeout must exceed that on runtimes that honor it. + timeoutMs: 40000, + retries: 2, + banFailedAddress: true, +} as const; + +export const PLATFORM_PUT_SETTINGS = { + ...PLATFORM_REQUEST_SETTINGS, +} as const; + +export const PLATFORM_OPERATION_TIMEOUT_MS = 45000; + +/** + * Quorum-list sidecar URL for `dashmate setup local`. Provides quorum public + * keys and discovered masternode addresses; reachable from the browser thanks + * to the sidecar's permissive CORS headers. + */ +const LOCAL_QUORUM_SIDECAR_URL = 'http://127.0.0.1:22444'; + +/** + * Build an EvoSDK for the given network. + * + * For local regtest we bypass the EvoSDK testnet/mainnet factories and assemble + * a WasmSdk directly: the quorum-list sidecar provides the trusted quorum keys + * and masternode addresses, then `EvoSDK.fromWasm()` wraps the result. + * + * Note: the local DAPI gateways serve self-signed certificates. The browser + * must trust them once (visit https://127.0.0.1:2443, 2543, 2643 and accept). + */ +export async function createPlatformSdk(network: NetworkName): Promise { + const options = { settings: PLATFORM_REQUEST_SETTINGS }; + + if (network === 'mainnet') { + return EvoSDK.mainnetTrusted(options); + } + + if (network === 'local') { + await ensureInitialized(); + const ctx = await WasmTrustedContext.prefetchLocalWithUrl(LOCAL_QUORUM_SIDECAR_URL); + // For local: discovered masternode addresses are + // https://127.0.0.1:{2443,2543,2643} + // and each gateway has its own self-signed cert. We can't ban a "failed" + // address (it's just an untrusted cert, not a real outage), and we want the + // SDK to keep failing over so the user only needs to trust ONE of the three + // certs for things to work. So: banFailedAddress=false, retries bumped. + const wasmSdk = WasmSdkBuilder.local() + .withTrustedContext(ctx) + .withSettings( + PLATFORM_REQUEST_SETTINGS.connectTimeoutMs, + PLATFORM_REQUEST_SETTINGS.timeoutMs, + 5, + false + ) + .build(); + return EvoSDK.fromWasm(wasmSdk); + } + + return EvoSDK.testnetTrusted(options); +} diff --git a/src/types.ts b/src/types.ts index b85adc7..80e8c07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -203,7 +203,7 @@ export interface AssetLockProofData { export interface BridgeState { step: BridgeStep; - network: 'testnet' | 'mainnet'; + network: 'testnet' | 'mainnet' | 'local'; /** Bridge operation mode */ mode: BridgeMode; /** Current network retry status (for displaying retry indicator) */ diff --git a/src/ui/components.ts b/src/ui/components.ts index fa58eda..0f99d57 100644 --- a/src/ui/components.ts +++ b/src/ui/components.ts @@ -10,7 +10,7 @@ import { getAssetLockDerivationPath } from '../crypto/hd.js'; /** * Build a Platform Explorer URL for a given entity. */ -function explorerUrl(network: 'testnet' | 'mainnet', type: 'identity' | 'dataContract', id: string): string { +function explorerUrl(network: 'testnet' | 'mainnet' | 'local', type: 'identity' | 'dataContract', id: string): string { const base = network === 'testnet' ? 'https://testnet.platform-explorer.com' : 'https://platform-explorer.com'; return `${base}/${type}/${id}`; } @@ -273,9 +273,28 @@ function renderInitStep(state: BridgeState): HTMLElement { networkSelector.innerHTML = ` + `; div.appendChild(networkSelector); + // Local-network heads-up: each gateway has its own self-signed cert and the + // browser will reject DAPI gRPC-Web calls until at least one is trusted. + if (state.network === 'local') { + const certNotice = document.createElement('div'); + certNotice.className = 'local-cert-notice'; + certNotice.innerHTML = ` + Heads up: the local DAPI gateways use self-signed certs. + If Platform calls fail with TypeError: Failed to fetch, open + each link, click Advanced → Proceed, then come back: + + 2443 + 2543 + 2643 + + `; + div.appendChild(certNotice); + } + // Mode selection buttons const modeButtons = document.createElement('div'); modeButtons.className = 'mode-buttons'; @@ -550,6 +569,44 @@ function renderDepositStep(state: BridgeState): HTMLElement { reassurance.textContent = "We'll continue automatically once detected."; div.appendChild(reassurance); + // For local regtest: show "Use local funds" hero action. + // Same UX as the testnet faucet — reuses the faucet* state fields. + if (state.network === 'local' && state.depositAddress) { + const localSection = document.createElement('div'); + localSection.className = 'faucet-section faucet-hero'; + const status = state.faucetRequestStatus || 'idle'; + + if (status === 'success' && state.faucetTxid) { + const t = state.faucetTxid; + const truncated = t.length > 16 ? `${t.slice(0, 8)}...${t.slice(-8)}` : t; + localSection.innerHTML = ` +
+ + Sent from local node! + ${truncated} +
+ `; + } else if (status === 'requesting') { + localSection.innerHTML = ` +
+
+ Sending from local node... +
+ `; + } else { + const errorHtml = (status === 'error' && state.faucetError) + ? `

${escapeHtml(state.faucetError)}

` + : ''; + localSection.innerHTML = ` +

Don't have local funds?

+ + ${errorHtml} + `; + } + + div.appendChild(localSection); + } + // For testnet: Show faucet as hero action first if (state.network === 'testnet' && state.depositAddress) { const faucetSection = document.createElement('div'); diff --git a/src/ui/state.ts b/src/ui/state.ts index 78ab5ce..d9a28fb 100644 --- a/src/ui/state.ts +++ b/src/ui/state.ts @@ -88,7 +88,7 @@ export function toError(value: unknown): Error { * Create initial bridge state (mode selection) * Keys are generated when mode is selected, not at init */ -export function createInitialState(network: 'testnet' | 'mainnet'): BridgeState { +export function createInitialState(network: 'testnet' | 'mainnet' | 'local'): BridgeState { return { step: 'init', network, @@ -463,7 +463,7 @@ export function setError(state: BridgeState, error: Error, errorCode?: string): */ export function setNetwork( state: BridgeState, - network: 'testnet' | 'mainnet' + network: 'testnet' | 'mainnet' | 'local' ): BridgeState { if (!state.mnemonic) { // Fallback: generate new mnemonic if none exists diff --git a/vite.config.ts b/vite.config.ts index e8e9862..4824b76 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,56 @@ -import { defineConfig } from 'vite'; - -export default defineConfig({ - base: process.env.BASE_PATH || '/', - build: { - target: 'es2020', - }, - optimizeDeps: { - esbuildOptions: { +import { defineConfig, loadEnv } from 'vite'; + +/** + * Local regtest defaults (match dashmate's `local_seed` config). + * Override with env vars in .env.local if your dashmate setup differs. + */ +const DEFAULT_LOCAL_INSIGHT = 'http://127.0.0.1:23001'; +const DEFAULT_LOCAL_RPC = 'http://127.0.0.1:20302'; +const DEFAULT_LOCAL_RPC_USER = 'dashmate'; +const DEFAULT_LOCAL_RPC_PASSWORD = ''; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + + const insightTarget = env.LOCAL_INSIGHT_URL || DEFAULT_LOCAL_INSIGHT; + const rpcTarget = env.LOCAL_RPC_URL || DEFAULT_LOCAL_RPC; + const rpcUser = env.LOCAL_RPC_USER || DEFAULT_LOCAL_RPC_USER; + const rpcPassword = env.LOCAL_RPC_PASSWORD || DEFAULT_LOCAL_RPC_PASSWORD; + const rpcAuth = rpcPassword + ? 'Basic ' + Buffer.from(`${rpcUser}:${rpcPassword}`).toString('base64') + : ''; + + return { + base: process.env.BASE_PATH || '/', + build: { target: 'es2020', }, - }, + optimizeDeps: { + esbuildOptions: { + target: 'es2020', + }, + }, + server: { + proxy: { + // Insight API: /local-insight/* -> http://127.0.0.1:23001/insight-api/* + '/local-insight': { + target: insightTarget, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/local-insight/, '/insight-api'), + }, + // Dash Core JSON-RPC: POST /local-rpc -> http://127.0.0.1:20302/ + // Authorization header injected from .env.local so creds stay out of the bundle. + '/local-rpc': { + target: rpcTarget, + changeOrigin: true, + rewrite: () => '/', + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + if (rpcAuth) proxyReq.setHeader('Authorization', rpcAuth); + }); + }, + }, + }, + }, + }; });