Files
dchain/client-app/lib/api.ts
vsecoder 423d307125 feat(client): multi-device fan-out + auto-link (v2.2.0-alpha2)
PR #2 of the multi-device roadmap — wires the messenger pipeline
against the on-chain registry landed in v2.2.0-alpha1.

lib/api.ts:
  - DeviceInfo type mirroring blockchain.DeviceInfo.
  - IdentityInfo.device_count (optional; populated from /api/identity).
  - fetchDevices(masterPub) → /api/devices/{master_pub}, returns [].
    Errors swallowed so a downed endpoint doesn't block messaging.
  - resolveRecipientKeys(masterPub) — the routing primitive. Returns
    devices[] if registered, else falls back to IdentityInfo.x25519_pub
    (pre-v2.2.0 path). Empty only when recipient has published nothing.
  - buildLinkDeviceTx / buildUnlinkDeviceTx — signed by master Ed25519,
    min-fee cost, canonical JSON payload matching the chain-side
    LinkDevicePayload / UnlinkDevicePayload.

app/(app)/chats/[id].tsx:
  - sendCore now fans out: encrypts once per recipient device pub
    (Promise.all, any failure rejects the batch), falls back to the
    cached contact.x25519Pub if the registry lookup returns nothing.
  - Saved Messages short-circuit preserved; no devices lookup for self.

app/(app)/_layout.tsx:
  - On every sign-in, auto-submit LINK_DEVICE for this device if its
    X25519 pub isn't already in the master's registry. Device name
    picks "iPhone" / "Android phone" / "Device" by Platform. Errors
    (insufficient balance / legacy chain without LINK_DEVICE support)
    are silent — next launch retries.

Backward compatibility: senders fall back to identity.x25519_pub when
the recipient has no registry entries, so pre-v2.2.0 clients still
receive messages. Chain-side already gates new validation on the event
types existing; old clients simply never emit LINK_DEVICE and keep
working with a single X25519.

Next — PR #3 (Settings → Devices screen + QR pairing flow + receive-side
self-wipe on revoke detection).
2026-04-22 16:24:36 +03:00

983 lines
35 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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';
import { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes } from './crypto';
// ─── 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<T>(path: string): Promise<T> {
const res = await fetch(`${_nodeUrl}${path}`);
if (!res.ok) throw new Error(`GET ${path}${res.status}`);
return res.json() as Promise<T>;
}
/**
* 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<T>(path: string, body: unknown): Promise<T> {
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<T>;
}
/**
* 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 'Too many requests to the node. Wait a couple of seconds and try again.';
}
if (raw.startsWith('400') && raw.includes('timestamp')) {
return 'Device clock is out of sync with the node. Check the time on your phone (±1 hour).';
}
if (raw.startsWith('400') && raw.includes('signature')) {
return 'Transaction signature is invalid. Try again; if this persists the client and node versions may be incompatible.';
}
if (raw.startsWith('400')) {
return `Node rejected transaction: ${raw.replace(/^400:\s*/, '')}`;
}
if (raw.startsWith('5')) {
return `Node error (${raw}). Please try again later.`;
}
// Network-level
if (raw.toLowerCase().includes('network request failed')) {
return 'Cannot reach the node. Check the URL in settings and that the server is online.';
}
return raw;
}
// ─── Chain API ────────────────────────────────────────────────────────────────
export async function getNetStats(): Promise<NetStats> {
return get<NetStats>('/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<number> {
const data = await get<AddrResponse>(`/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-<nanoseconds>" 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;
}
}
/**
* Full transaction detail as returned by GET /api/tx/{id}. Matches the
* explorer's txDetail wire format. Payload is JSON-decoded when the
* node recognises the tx type, otherwise payload_hex is set.
*/
export interface TxDetail {
id: string;
type: string;
memo?: string;
from: string;
from_addr?: string;
to?: string;
to_addr?: string;
amount_ut: number;
amount: string;
fee_ut: number;
fee: string;
time: string; // ISO-8601 UTC
block_index: number;
block_hash: string;
block_time: string; // ISO-8601 UTC
gas_used?: number;
payload?: unknown;
payload_hex?: string;
signature_hex?: string;
}
/** Fetch full tx detail by hash/id. Returns null on 404. */
export async function getTxDetail(txID: string): Promise<TxDetail | null> {
try {
return await get<TxDetail>(`/api/tx/${txID}`);
} catch (e: any) {
if (/→\s*404\b/.test(String(e?.message))) return null;
throw e;
}
}
export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord[]> {
const data = await get<AddrResponse>(`/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 ────────────────────────────────────────────────────────────────
//
// Endpoints are mounted at the ROOT of the node HTTP server (not under /api):
// POST /relay/broadcast — publish pre-sealed envelope (proper E2E)
// GET /relay/inbox — fetch envelopes addressed to <pub>
//
// Why /relay/broadcast, not /relay/send?
// /relay/send takes plaintext (msg_b64) и SEAL'ит его ключом релей-ноды —
// это ломает end-to-end шифрование (получатель не сможет расшифровать
// своим ключом). Для E2E всегда используем /relay/broadcast с уже
// запечатанным на клиенте envelope'ом.
/**
* Shape of envelope item returned by GET /relay/inbox (server item type).
* Go `[]byte` поля сериализуются как base64 в JSON — поэтому `nonce` и
* `ciphertext` приходят base64, а не hex. Мы декодируем их в hex для
* совместимости с crypto.ts (decryptMessage принимает hex).
*/
interface InboxItemWire {
id: string;
sender_pub: string;
recipient_pub: string;
fee_ut?: number;
sent_at: number;
sent_at_human?: string;
nonce: string; // base64
ciphertext: string; // base64
}
interface InboxResponseWire {
pub: string;
count: number;
has_more: boolean;
items: InboxItemWire[];
}
/**
* Клиент собирает envelope через encryptMessage и шлёт на /relay/broadcast.
* Серверный формат: `{envelope: <relay.Envelope JSON>}`. Nonce/ciphertext
* там — base64 (Go []byte), а у нас в crypto.ts — hex, так что на wire
* конвертим hex→bytes→base64.
*/
export async function sendEnvelope(params: {
senderPub: string; // X25519 hex
recipientPub: string; // X25519 hex
nonce: string; // hex
ciphertext: string; // hex
senderEd25519Pub?: string; // optional — для будущих fee-релеев
}): Promise<{ id: string; status: string }> {
const sentAt = Math.floor(Date.now() / 1000);
const nonceB64 = bytesToBase64(hexToBytes(params.nonce));
const ctB64 = bytesToBase64(hexToBytes(params.ciphertext));
// envelope.id — 16 байт, hex. Сервер только проверяет что поле не
// пустое и использует его как ключ mailbox'а. Первые 16 байт nonce
// уже криптографически-случайны (nacl.randomBytes), так что берём их.
const id = bytesToHex(hexToBytes(params.nonce).slice(0, 16));
return post<{ id: string; status: string }>('/relay/broadcast', {
envelope: {
id,
sender_pub: params.senderPub,
recipient_pub: params.recipientPub,
sender_ed25519_pub: params.senderEd25519Pub ?? '',
fee_ut: 0,
fee_sig: null,
nonce: nonceB64,
ciphertext: ctB64,
sent_at: sentAt,
},
});
}
/**
* Fetch envelopes адресованные нам из relay-почтовика.
* Server: `GET /relay/inbox?pub=<x25519hex>` → `{pub, count, has_more, items}`.
* Нормализуем item'ы к clientскому Envelope type: sent_at → timestamp,
* base64 nonce/ciphertext → hex.
*/
export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519PubHex}`);
const items = Array.isArray(resp?.items) ? resp.items : [];
return items.map((it): Envelope => ({
id: it.id,
sender_pub: it.sender_pub,
recipient_pub: it.recipient_pub,
nonce: bytesToHex(base64ToBytes(it.nonce)),
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
timestamp: it.sent_at ?? 0,
}));
}
// ─── Contact requests (on-chain) ─────────────────────────────────────────────
/**
* Maps blockchain.ContactInfo returned by GET /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<ContactRequestRaw[]> {
const data = await get<{ contacts: ContactRequestRaw[] }>(`/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;
/**
* Number of active (non-revoked) devices linked to this master identity
* via LINK_DEVICE (v2.2.0). 0 for legacy identities that only published
* a single X25519 via REGISTER_KEY — senders should fall back to
* `x25519_pub` above and skip the device fan-out path.
*/
device_count?: number;
}
/**
* One active device in an identity's multi-device registry. Returned by
* GET /api/devices/{master_pub} as part of `devices[]`. Senders use the
* list to fan out one sealed envelope per X25519 pub so all of the
* recipient's devices receive the message.
*/
export interface DeviceInfo {
x25519_pub_key: string;
device_name: string;
added_at: number; // unix seconds
}
/**
* Relay registration info for a node pub key, as returned by
* /api/relays (which comes back as an array of RegisteredRelayInfo).
* We don't wrap the individual lookup on the server — just filter the
* full list client-side. It's bounded (N nodes in the network) and
* cached heavily enough that this is cheaper than a new endpoint.
*/
export interface RegisteredRelayInfo {
pub_key: string;
address: string;
relay: {
x25519_pub_key: string;
fee_per_msg_ut: number;
multiaddr?: string;
};
last_heartbeat?: number; // unix seconds
}
/** GET /api/relays — all relay nodes registered on-chain. */
export async function getRelays(): Promise<RegisteredRelayInfo[]> {
try {
return await get<RegisteredRelayInfo[]>('/api/relays');
} catch {
return [];
}
}
/** Find relay entry for a specific pub key. null if the address isn't a relay. */
export async function getRelayFor(pubKey: string): Promise<RegisteredRelayInfo | null> {
const all = await getRelays();
return all.find(r => r.pub_key === pubKey) ?? null;
}
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
try {
return await get<IdentityInfo>(`/api/identity/${pubkeyOrAddr}`);
} catch {
return null;
}
}
// ─── Multi-device registry (v2.2.0) ───────────────────────────────────────
interface DevicesResponse {
master_pub: string;
count: number;
devices: DeviceInfo[];
}
/**
* GET /api/devices/{master_pub} — all active (non-revoked) device records
* for the given master identity. Returns an empty array for a legacy
* identity (device_count == 0) or a network error — callers should treat
* both the same way and fall back to IdentityInfo.x25519_pub so the
* pre-v2.2.0 single-device path keeps working.
*/
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
try {
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
return resp.devices ?? [];
} catch {
return [];
}
}
/**
* Pick the right set of recipient X25519 pubs for a sender's fan-out.
* Two paths, in priority order:
*
* 1. New path — /api/devices returns ≥1 entry. Send to each device.
* 2. Legacy path — identity published an X25519 via REGISTER_KEY
* (pre-v2.2.0 clients). Send to just that one.
*
* Returns an empty array only when the recipient has published nothing
* at all — caller must surface "no encryption key" to the user rather
* than drop the message on the floor.
*/
export async function resolveRecipientKeys(
recipientMasterPub: string,
): Promise<string[]> {
const devs = await fetchDevices(recipientMasterPub);
if (devs.length > 0) {
return devs.map(d => d.x25519_pub_key);
}
const identity = await getIdentity(recipientMasterPub);
if (identity?.x25519_pub) {
return [identity.x25519_pub];
}
return [];
}
// ─── 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<string | null> {
try {
const data = await get<ContractStateResponse>(`/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<string | null> {
try {
const data = await get<ContractStateResponse>(`/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<string, WellKnownContract>;
}
/**
* 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<WellKnownResponse | null> {
try {
return await get<WellKnownResponse>('/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',
'feed_v2', // social feed (v2.0.0) — PostCard, timeline, forYou
'identity_registry',
'media_scrub', // server-side EXIF strip — we rely on this for privacy
'onboarding_api',
'relay_broadcast', // /relay/broadcast for E2E envelopes (not /relay/send)
'relay_mailbox',
'ws_submit_tx',
];
/** GET /api/well-known-version. Returns null on failure (old node, network hiccup). */
export async function fetchNodeVersion(): Promise<NodeVersionInfo | null> {
try {
return await get<NodeVersionInfo>('/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<UpdateCheckResponse | null> {
try {
return await get<UpdateCheckResponse>('/api/update-check');
} catch {
return null;
}
}
// ─── Transaction builder helpers ─────────────────────────────────────────────
import { signBase64 } 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),
};
}
/**
* LINK_DEVICE transaction — publish a per-device X25519 pub in the
* identity's device registry so senders can fan out envelopes across
* every active device. Signed by the master Ed25519 (= `from`).
*
* `deviceName` is a short human label shown in Settings → Devices
* (≤ 64 bytes, printable ASCII/UTF-8, no control chars).
*/
export function buildLinkDeviceTx(params: {
from: string; // master Ed25519 pubkey
x25519Pub: string; // per-device X25519 pubkey (64 hex chars, lowercase)
deviceName: string;
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = {
x25519_pub_key: params.x25519Pub,
device_name: params.deviceName,
};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'LINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'LINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* UNLINK_DEVICE transaction — revoke a previously-linked device so senders
* stop shipping envelopes to its X25519 pub. The revoked device itself,
* when it next comes online and sees its own pub in the revoked list,
* is expected to wipe local state (master priv + cached chats).
*/
export function buildUnlinkDeviceTx(params: {
from: string; // master Ed25519 pubkey
x25519Pub: string; // pub to revoke
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = { x25519_pub_key: params.x25519Pub };
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, 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;
}