Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ dist/
.claude/
*.log
.DS_Store
.env
.env.local
.env.*.local
44 changes: 44 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
87 changes: 44 additions & 43 deletions src/api/dapi.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -49,17 +55,16 @@ 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,
timeoutMs: number = 60000,
onRetry?: (attempt: number, maxAttempts: number, error: unknown) => void
): Promise<Uint8Array> {
const startTime = Date.now();
const pollInterval = 2000; // Poll every 2 seconds
const pollInterval = 2000;

while (Date.now() - startTime < timeoutMs) {
try {
Expand All @@ -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));
}

Expand All @@ -81,51 +85,48 @@ 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<Uint8Array | null> {
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++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}

40 changes: 37 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,13 +30,40 @@ 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,
dustThreshold: 546,
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 <addr> 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;
}
}
10 changes: 5 additions & 5 deletions src/crypto/hd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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`;
}
Expand All @@ -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 {
Expand Down Expand Up @@ -110,7 +110,7 @@ export function deriveKeyAtPath(
*/
export function deriveAssetLockKeyPair(
mnemonic: string,
network: 'testnet' | 'mainnet'
network: 'testnet' | 'mainnet' | 'local'
): {
privateKey: Uint8Array;
publicKey: Uint8Array;
Expand All @@ -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;
Expand Down
Loading