/** * DChain REST API client. * All requests go to the configured node URL (e.g. http://192.168.1.10:8081). */ import type { Envelope, TxRecord, NetStats, Contact } from './types'; // ─── Base ───────────────────────────────────────────────────────────────────── let _nodeUrl = 'http://localhost:8081'; /** * Listeners invoked AFTER _nodeUrl changes. The WS client registers here so * that switching nodes in Settings tears down the old socket and re-dials * the new one (without this, a user who pointed their app at node A would * keep receiving A's events forever after flipping to B). */ const nodeUrlListeners = new Set<(url: string) => void>(); export function setNodeUrl(url: string) { const normalised = url.replace(/\/$/, ''); if (_nodeUrl === normalised) return; _nodeUrl = normalised; for (const fn of nodeUrlListeners) { try { fn(_nodeUrl); } catch { /* ignore — listeners are best-effort */ } } } export function getNodeUrl(): string { return _nodeUrl; } /** Register a callback for node-URL changes. Returns an unsubscribe fn. */ export function onNodeUrlChange(fn: (url: string) => void): () => void { nodeUrlListeners.add(fn); return () => { nodeUrlListeners.delete(fn); }; } async function get(path: string): Promise { const res = await fetch(`${_nodeUrl}${path}`); if (!res.ok) throw new Error(`GET ${path} → ${res.status}`); return res.json() as Promise; } /** * Enhanced error reporter for POST failures. The node's `jsonErr` writes * `{"error": "..."}` as the response body; we parse that out so the UI layer * can show a meaningful message instead of a raw status code. * * Rate-limit and timestamp-skew rejections produce specific strings the UI * can translate to user-friendly Russian via matcher functions below. */ async function post(path: string, body: unknown): Promise { const res = await fetch(`${_nodeUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text(); // Try to extract {"error":"..."} payload for a cleaner message. let detail = text; try { const parsed = JSON.parse(text); if (parsed?.error) detail = parsed.error; } catch { /* keep raw text */ } // Include HTTP status so `humanizeTxError` can branch on 429/400/etc. throw new Error(`${res.status}: ${detail}`); } return res.json() as Promise; } /** * Turn a submission error from `post()` / `submitTx()` into a user-facing * Russian message with actionable hints. Preserves the raw detail at the end * so advanced users can still copy the original for support. */ export function humanizeTxError(e: unknown): string { const raw = e instanceof Error ? e.message : String(e); if (raw.startsWith('429')) { return 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.'; } if (raw.startsWith('400') && raw.includes('timestamp')) { return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).'; } if (raw.startsWith('400') && raw.includes('signature')) { return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.'; } if (raw.startsWith('400')) { return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`; } if (raw.startsWith('5')) { return `Ошибка ноды (${raw}). Попробуйте позже.`; } // Network-level if (raw.toLowerCase().includes('network request failed')) { return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.'; } return raw; } // ─── Chain API ──────────────────────────────────────────────────────────────── export async function getNetStats(): Promise { return get('/api/netstats'); } interface AddrResponse { balance_ut: number; balance: string; transactions: Array<{ id: string; type: string; from: string; to?: string; amount_ut: number; fee_ut: number; time: string; // ISO-8601 e.g. "2025-01-01T12:00:00Z" block_index: number; }>; tx_count: number; has_more: boolean; } export async function getBalance(pubkey: string): Promise { const data = await get(`/api/address/${pubkey}`); return data.balance_ut ?? 0; } /** * Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction JSON. * Key facts: * - `payload` is base64-encoded JSON bytes (Go []byte → base64 in JSON) * - `signature` is base64-encoded Ed25519 sig (Go []byte → base64 in JSON) * - `timestamp` is RFC3339 string (Go time.Time → string in JSON) * - There is NO nonce field; dedup is by `id` */ export interface RawTx { id: string; // "tx-" or sha256-based type: string; // "TRANSFER", "CONTACT_REQUEST", etc. from: string; // hex Ed25519 pub key to: string; // hex Ed25519 pub key (empty string if N/A) amount: number; // µT (uint64) fee: number; // µT (uint64) memo?: string; // optional payload: string; // base64(json.Marshal(TypeSpecificPayload)) signature: string; // base64(ed25519.Sign(canonical_bytes, priv)) timestamp: string; // RFC3339 e.g. "2025-01-01T12:00:00Z" } export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> { console.log('[submitTx] →', { id: tx.id, type: tx.type, from: tx.from.slice(0, 12) + '…', to: tx.to ? tx.to.slice(0, 12) + '…' : '', amount: tx.amount, fee: tx.fee, timestamp: tx.timestamp, transport: 'auto', }); // Try the WebSocket path first: no HTTP round-trip, and we get a proper // submit_ack correlated back to our tx id. Falls through to HTTP if WS is // unavailable (old node, disconnected, timeout, etc.) so legacy setups // keep working. try { // Lazy import avoids a circular dep with lib/ws.ts (which itself // imports getNodeUrl from this module). const { getWSClient } = await import('./ws'); const ws = getWSClient(); if (ws.isConnected()) { try { const res = await ws.submitTx(tx); console.log('[submitTx] ← accepted via WS', res); return { id: res.id || tx.id, status: 'accepted' }; } catch (e) { console.warn('[submitTx] WS path failed, falling back to HTTP:', e); } } } catch { /* circular import edge case — ignore and use HTTP */ } try { const res = await post<{ id: string; status: string }>('/api/tx', tx); console.log('[submitTx] ← accepted via HTTP', res); return res; } catch (e) { console.warn('[submitTx] ← rejected', e); throw e; } } export async function getTxHistory(pubkey: string, limit = 50): Promise { const data = await get(`/api/address/${pubkey}?limit=${limit}`); return (data.transactions ?? []).map(tx => ({ hash: tx.id, type: tx.type, from: tx.from, to: tx.to, amount: tx.amount_ut, fee: tx.fee_ut, // Convert ISO-8601 string → unix seconds timestamp: tx.time ? Math.floor(new Date(tx.time).getTime() / 1000) : 0, status: 'confirmed' as const, })); } // ─── Relay API ──────────────────────────────────────────────────────────────── export interface SendEnvelopeReq { sender_pub: string; recipient_pub: string; nonce: string; ciphertext: string; } export async function sendEnvelope(env: SendEnvelopeReq): Promise<{ ok: boolean }> { return post<{ ok: boolean }>('/api/relay/send', env); } export async function fetchInbox(x25519PubHex: string): Promise { return get(`/api/relay/inbox?pub=${x25519PubHex}`); } // ─── Contact requests (on-chain) ───────────────────────────────────────────── /** * Maps blockchain.ContactInfo returned by GET /api/relay/contacts?pub=... * The response shape is { pub, count, contacts: ContactInfo[] }. */ export interface ContactRequestRaw { requester_pub: string; // Ed25519 pubkey of requester requester_addr: string; // DChain address (DC…) status: string; // "pending" | "accepted" | "blocked" intro: string; // plaintext intro message (may be empty) fee_ut: number; // anti-spam fee paid in µT tx_id: string; // transaction ID created_at: number; // unix seconds } export async function fetchContactRequests(edPubHex: string): Promise { const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`); return data.contacts ?? []; } // ─── Identity API ───────────────────────────────────────────────────────────── export interface IdentityInfo { pub_key: string; address: string; x25519_pub: string; // hex Curve25519 key; empty string if not published nickname: string; registered: boolean; } /** Fetch identity info for any pubkey or DC address. Returns null on 404. */ export async function getIdentity(pubkeyOrAddr: string): Promise { try { return await get(`/api/identity/${pubkeyOrAddr}`); } catch { return null; } } // ─── Contract API ───────────────────────────────────────────────────────────── /** * Response shape from GET /api/contracts/{id}/state/{key}. * The node handler (node/api_contract.go:handleContractState) returns either: * { value_b64: null, value_hex: null, ... } when the key is missing * or * { value_b64: "...", value_hex: "...", value_u64?: 0 } when the key exists. */ interface ContractStateResponse { contract_id: string; key: string; value_b64: string | null; value_hex: string | null; value_u64?: number; } /** * Decode a hex string (lowercase/uppercase) back to the original string value * it represents. The username registry contract stores values as plain ASCII * bytes (pubkey hex strings / username strings), so `value_hex` on the wire * is the hex-encoding of UTF-8 bytes. We hex-decode to bytes, then interpret * those bytes as UTF-8. */ function hexToUtf8(hex: string): string { if (hex.length % 2 !== 0) return ''; const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substr(i, 2), 16); } // TextDecoder is available in Hermes / RN's JS runtime. try { return new TextDecoder('utf-8').decode(bytes); } catch { // Fallback for environments without TextDecoder. let s = ''; for (const b of bytes) s += String.fromCharCode(b); return s; } } /** username → address (hex pubkey). Returns null if unregistered. */ export async function resolveUsername(contractId: string, username: string): Promise { try { const data = await get(`/api/contracts/${contractId}/state/name:${username}`); if (!data.value_hex) return null; const decoded = hexToUtf8(data.value_hex).trim(); return decoded || null; } catch { return null; } } /** address (hex pubkey) → username. Returns null if this address hasn't registered a name. */ export async function reverseResolve(contractId: string, address: string): Promise { try { const data = await get(`/api/contracts/${contractId}/state/addr:${address}`); if (!data.value_hex) return null; const decoded = hexToUtf8(data.value_hex).trim(); return decoded || null; } catch { return null; } } // ─── Well-known contracts ───────────────────────────────────────────────────── /** * Per-entry shape returned by GET /api/well-known-contracts. * Matches node/api_well_known.go:WellKnownContract. */ export interface WellKnownContract { contract_id: string; name: string; version?: string; deployed_at: number; } /** * Response from GET /api/well-known-contracts. * `contracts` is keyed by ABI name (e.g. "username_registry"). */ export interface WellKnownResponse { count: number; contracts: Record; } /** * Fetch the node's view of canonical system contracts so the client doesn't * have to force the user to paste contract IDs into settings. * * The node returns the earliest-deployed contract per ABI name; this means * every peer in the same chain reports the same mapping. * * Returns `null` on failure (old node, network hiccup, endpoint missing). */ export async function fetchWellKnownContracts(): Promise { try { return await get('/api/well-known-contracts'); } catch { return null; } } // ─── Node version / update-check ───────────────────────────────────────────── // // The three calls below let the client: // 1. fetchNodeVersion() — see what tag/commit/features the connected node // exposes. Used on first boot + on every chain-switch so we can warn if // a required feature is missing. // 2. checkNodeVersion(required) — thin wrapper that returns {supported, // missing} by diffing a client-expected feature list against the node's. // 3. fetchUpdateCheck() — ask the node whether its operator has a newer // release available from their configured release source (Gitea). For // messenger UX this is purely informational ("the node you're on is N // versions behind"), never used to update the node automatically. /** The shape returned by GET /api/well-known-version. */ export interface NodeVersionInfo { node_version: string; protocol_version: number; features: string[]; chain_id?: string; build?: { tag: string; commit: string; date: string; dirty: string; }; } /** Client-expected protocol version. Bumped only when wire-protocol breaks. */ export const CLIENT_PROTOCOL_VERSION = 1; /** * Minimum feature set this client build relies on. A node missing any of * these is considered "unsupported" — caller should surface an upgrade * prompt to the user instead of silently failing on the first feature call. */ export const CLIENT_REQUIRED_FEATURES = [ 'chain_id', 'identity_registry', 'onboarding_api', 'relay_mailbox', 'ws_submit_tx', ]; /** GET /api/well-known-version. Returns null on failure (old node, network hiccup). */ export async function fetchNodeVersion(): Promise { try { return await get('/api/well-known-version'); } catch { return null; } } /** * Check whether the connected node supports this client's required features * and protocol version. Returns a decision blob the UI can render directly. * * { supported: true } → everything fine * { supported: false, reason: "...", ... } → show update prompt * { supported: null, reason: "unreachable" } → couldn't reach the endpoint, * likely old node — assume OK * but warn quietly. */ export async function checkNodeVersion( required: string[] = CLIENT_REQUIRED_FEATURES, ): Promise<{ supported: boolean | null; reason?: string; missing?: string[]; info?: NodeVersionInfo; }> { const info = await fetchNodeVersion(); if (!info) { return { supported: null, reason: 'unreachable' }; } if (info.protocol_version !== CLIENT_PROTOCOL_VERSION) { return { supported: false, reason: `protocol v${info.protocol_version} but client expects v${CLIENT_PROTOCOL_VERSION}`, info, }; } const have = new Set(info.features || []); const missing = required.filter((f) => !have.has(f)); if (missing.length > 0) { return { supported: false, reason: `node missing features: ${missing.join(', ')}`, missing, info, }; } return { supported: true, info }; } /** The shape returned by GET /api/update-check. */ export interface UpdateCheckResponse { current: { tag: string; commit: string; date: string; dirty: string }; latest?: { tag: string; commit?: string; url?: string; published_at?: string }; update_available: boolean; checked_at: string; source?: string; } /** * GET /api/update-check. Returns null when: * - the node operator hasn't configured DCHAIN_UPDATE_SOURCE_URL (503), * - upstream Gitea call failed (502), * - request errored out. * All three are non-fatal for the client; the UI just doesn't render the * "update available" banner. */ export async function fetchUpdateCheck(): Promise { try { return await get('/api/update-check'); } catch { return null; } } // ─── Transaction builder helpers ───────────────────────────────────────────── import { signBase64, bytesToBase64 } from './crypto'; /** Minimum blockchain tx fee paid to the block validator (matches blockchain.MinFee = 1000 µT). */ const MIN_TX_FEE = 1000; const _encoder = new TextEncoder(); /** RFC3339 timestamp with second precision — matches Go time.Time JSON output. */ function rfc3339Now(): string { const d = new Date(); d.setMilliseconds(0); // toISOString() gives "2025-01-01T12:00:00.000Z" → replace ".000Z" with "Z" return d.toISOString().replace('.000Z', 'Z'); } /** Unique transaction ID (nanoseconds-like using Date.now + random). */ function newTxID(): string { return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`; } /** * Canonical bytes for signing — must match identity.txSignBytes in Go exactly. * * Go struct field order: id, type, from, to, amount, fee, payload, timestamp. * JS JSON.stringify preserves insertion order, so we rely on that here. */ function txCanonicalBytes(tx: { id: string; type: string; from: string; to: string; amount: number; fee: number; payload: string; timestamp: string; }): Uint8Array { const s = JSON.stringify({ id: tx.id, type: tx.type, from: tx.from, to: tx.to, amount: tx.amount, fee: tx.fee, payload: tx.payload, timestamp: tx.timestamp, }); return _encoder.encode(s); } /** Encode a JS string (UTF-8) to base64. */ function strToBase64(s: string): string { return bytesToBase64(_encoder.encode(s)); } export function buildTransferTx(params: { from: string; to: string; amount: number; fee: number; privKey: string; memo?: string; }): RawTx { const id = newTxID(); const timestamp = rfc3339Now(); const payloadObj = params.memo ? { memo: params.memo } : {}; const payload = strToBase64(JSON.stringify(payloadObj)); const canonical = txCanonicalBytes({ id, type: 'TRANSFER', from: params.from, to: params.to, amount: params.amount, fee: params.fee, payload, timestamp, }); return { id, type: 'TRANSFER', from: params.from, to: params.to, amount: params.amount, fee: params.fee, memo: params.memo, payload, timestamp, signature: signBase64(canonical, params.privKey), }; } /** * CONTACT_REQUEST transaction. * * blockchain.Transaction fields: * Amount = contactFee — anti-spam fee, paid directly to recipient (>= 5000 µT) * Fee = MIN_TX_FEE — blockchain tx fee to the block validator (1000 µT) * Payload = ContactRequestPayload { intro? } as base64 JSON bytes */ export function buildContactRequestTx(params: { from: string; // sender Ed25519 pubkey to: string; // recipient Ed25519 pubkey contactFee: number; // anti-spam amount paid to recipient (>= 5000 µT) intro?: string; // optional plaintext intro message (≤ 280 chars) privKey: string; }): RawTx { const id = newTxID(); const timestamp = rfc3339Now(); // Payload matches ContactRequestPayload{Intro: "..."} in Go const payloadObj = params.intro ? { intro: params.intro } : {}; const payload = strToBase64(JSON.stringify(payloadObj)); const canonical = txCanonicalBytes({ id, type: 'CONTACT_REQUEST', from: params.from, to: params.to, amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp, }); return { id, type: 'CONTACT_REQUEST', from: params.from, to: params.to, amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp, signature: signBase64(canonical, params.privKey), }; } /** * ACCEPT_CONTACT transaction. * AcceptContactPayload is an empty struct in Go — no fields needed. */ export function buildAcceptContactTx(params: { from: string; // acceptor Ed25519 pubkey (us — the recipient of the request) to: string; // requester Ed25519 pubkey privKey: string; }): RawTx { const id = newTxID(); const timestamp = rfc3339Now(); const payload = strToBase64(JSON.stringify({})); // AcceptContactPayload{} const canonical = txCanonicalBytes({ id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to, amount: 0, fee: MIN_TX_FEE, payload, timestamp, }); return { id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to, amount: 0, fee: MIN_TX_FEE, payload, timestamp, signature: signBase64(canonical, params.privKey), }; } // ─── Contract call ──────────────────────────────────────────────────────────── /** Minimum base fee for CALL_CONTRACT (matches blockchain.MinCallFee). */ const MIN_CALL_FEE = 1000; /** * CALL_CONTRACT transaction. * * Payload shape (CallContractPayload): * { contract_id, method, args_json?, gas_limit } * * `amount` is the payment attached to the call and made available to the * contract as `tx.Amount`. Whether it's collected depends on the contract * — e.g. username_registry.register requires exactly 10_000 µT. Contracts * that don't need payment should be called with `amount: 0` (default). * * The on-chain tx envelope carries `amount` openly, so the explorer shows * the exact cost of a call rather than hiding it in a contract-internal * debit — this was the UX motivation for this field. * * `fee` is the NETWORK fee paid to the block validator (not the contract). * `gas` costs are additional and billed at the live gas price. */ export function buildCallContractTx(params: { from: string; contractId: string; method: string; args?: unknown[]; // JSON-serializable arguments amount?: number; // µT attached to the call (default 0) gasLimit?: number; // default 1_000_000 privKey: string; }): RawTx { const id = newTxID(); const timestamp = rfc3339Now(); const amount = params.amount ?? 0; const argsJson = params.args && params.args.length > 0 ? JSON.stringify(params.args) : ''; const payloadObj = { contract_id: params.contractId, method: params.method, args_json: argsJson, gas_limit: params.gasLimit ?? 1_000_000, }; const payload = strToBase64(JSON.stringify(payloadObj)); const canonical = txCanonicalBytes({ id, type: 'CALL_CONTRACT', from: params.from, to: '', amount, fee: MIN_CALL_FEE, payload, timestamp, }); return { id, type: 'CALL_CONTRACT', from: params.from, to: '', amount, fee: MIN_CALL_FEE, payload, timestamp, signature: signBase64(canonical, params.privKey), }; } /** * Flat registration fee for a username, in µT. * * The native username_registry charges a single flat fee (10 000 µT = 0.01 T) * per register() call regardless of name length, replacing the earlier * length-based formula. Flat pricing is easier to communicate and the * 4-char minimum (enforced both in the client UI and the on-chain contract) * already removes the squatting pressure that tiered pricing mitigated. */ export const USERNAME_REGISTRATION_FEE = 10_000; /** Minimum/maximum allowed username length. Match blockchain/native_username.go. */ export const MIN_USERNAME_LENGTH = 4; export const MAX_USERNAME_LENGTH = 32; /** @deprecated Kept for backward compatibility; always returns the flat fee. */ export function usernameRegistrationFee(_name: string): number { return USERNAME_REGISTRATION_FEE; }