feat: desktop messaging + pairing + cross-client master-pub attribution (v2.2.0-alpha5)
Two coordinated changes:
1. Desktop client gets a functional Messages section and working pairing
flow, putting it at feature parity with mobile for the v2.2.0 line.
2. Server + both clients teach each other to use the sender's master
Ed25519 (not just their X25519) to address conversations, so a peer
writing from a different linked device still rolls into the same chat.
This is the "new API logic" the desktop scaffold was waiting on.
Server (node/api_relay.go, cmd/node/main.go):
* /relay/inbox items now carry `sender_ed25519_pub` alongside the
per-device `sender_pub`. Empty string for pre-v2.2.0 senders.
* WS `inbox` push summary also includes `sender_ed25519_pub`, so the
client can skip the refetch when the envelope plainly isn't for
the chat they're watching.
* Both existing tests pass.
Mobile client:
* lib/types.ts Envelope grew `sender_ed25519_pub`; fetchInbox normalises
it (default '') for older nodes.
* hooks/useGlobalInbox matches contacts by (master Ed25519 OR legacy
X25519) so an incoming message from a peer's desktop reuses the
existing chat instead of creating a duplicate placeholder.
* hooks/useMessages now takes an optional `contactMasterEd25519` and
exposes a matchesChat() predicate; WS inbox handler uses it too to
avoid spurious refetches.
* chats/[id].tsx passes `contact.address` (master) along with x25519.
Desktop client — all new:
* src/lib/crypto.ts — tweetnacl hex/base64 helpers, generateKeyFile,
encryptMessage/decryptMessage, signBase64, shortAddr. Same signatures
as the mobile lib; uses Chromium's window.crypto, no expo-crypto dep.
* src/lib/tx.ts — buildTransferTx / buildLinkDeviceTx / buildUnlinkDeviceTx
+ submitTx + humanizeTxError, canonical-bytes identical to mobile.
* src/lib/relay.ts — fetchInbox, sendEnvelope, resolveRecipientKeys
(multi-device fan-out with legacy identity.x25519 fallback).
* src/lib/store.ts — zustand state gets messages{}, unread{},
activeChat.
* src/lib/storage.ts — per-chat cache via localStorage (500-msg cap).
* src/hooks/useInboxPoll — 4s polling loop, addresses conversations
by master Ed25519, bumps unread unless chat is active.
* src/sections/messages/* — ChatList (sorted tiles, unread badges),
Conversation (auto-scroll messages + composer + fan-out send,
Enter-to-send / Shift+Enter for newline), EmptyConversation.
* src/auth/Pair.tsx — 6-digit code + device key screen, polls inbox
for a handshake envelope, assembles the KeyFile on arrival.
* Welcome.tsx: Pair button now actually routes to <Pair>; imports
generateKeyFile from lib/crypto (was inlined).
docs/ROADMAP.md delta: alpha5 row flipped to done inline. Alpha6
(feed + wallet) and rc1 (contacts + devices UI + profile) still
pending.
This commit is contained in:
93
desktop/src/lib/crypto.ts
Normal file
93
desktop/src/lib/crypto.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Crypto primitives. Mirrors client-app/lib/crypto.ts function-for-
|
||||
// function (same signatures, same hex/base64 formats on the wire) so
|
||||
// the two clients decrypt each other's envelopes and sign txs the node
|
||||
// accepts interchangeably.
|
||||
//
|
||||
// The only real difference from mobile: we don't need expo-crypto — the
|
||||
// Electron renderer is a Chromium browser, so window.crypto.getRandomValues
|
||||
// is always available and we just let tweetnacl pick it up on its own
|
||||
// (tweetnacl auto-detects window.crypto when present).
|
||||
|
||||
import nacl from 'tweetnacl';
|
||||
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
|
||||
import type { KeyFile } from './types';
|
||||
|
||||
// ─── Hex / base64 ────────────────────────────────────────────────────────
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
if (hex.length % 2 !== 0) throw new Error('odd hex length');
|
||||
const b = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return b;
|
||||
}
|
||||
export function bytesToHex(b: Uint8Array): string {
|
||||
return Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
export function bytesToBase64(b: Uint8Array): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Key generation ──────────────────────────────────────────────────────
|
||||
|
||||
export function generateKeyFile(): KeyFile {
|
||||
const sign = nacl.sign.keyPair();
|
||||
const box = nacl.box.keyPair();
|
||||
return {
|
||||
pub_key: bytesToHex(sign.publicKey),
|
||||
priv_key: bytesToHex(sign.secretKey),
|
||||
x25519_pub: bytesToHex(box.publicKey),
|
||||
x25519_priv: bytesToHex(box.secretKey),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── NaCl box (E2E messaging) ────────────────────────────────────────────
|
||||
|
||||
export function encryptMessage(
|
||||
plaintext: string,
|
||||
senderSecretHex: string,
|
||||
recipientPubHex: string,
|
||||
): { nonce: string; ciphertext: string } {
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||
const msg = decodeUTF8(plaintext);
|
||||
const box = nacl.box(msg, nonce, hexToBytes(recipientPubHex), hexToBytes(senderSecretHex));
|
||||
return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(box) };
|
||||
}
|
||||
|
||||
export function decryptMessage(
|
||||
ciphertextHex: string,
|
||||
nonceHex: string,
|
||||
senderPubHex: string,
|
||||
recipientSecHex: string,
|
||||
): string | null {
|
||||
try {
|
||||
const plain = nacl.box.open(
|
||||
hexToBytes(ciphertextHex), hexToBytes(nonceHex),
|
||||
hexToBytes(senderPubHex), hexToBytes(recipientSecHex),
|
||||
);
|
||||
return plain ? encodeUTF8(plain) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ed25519 signing ─────────────────────────────────────────────────────
|
||||
|
||||
export function signBase64(data: Uint8Array, privKeyHex: string): string {
|
||||
const sig = nacl.sign.detached(data, hexToBytes(privKeyHex));
|
||||
return bytesToBase64(sig);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function shortAddr(hex: string, chars = 8): string {
|
||||
if (hex.length <= chars * 2 + 3) return hex;
|
||||
return `${hex.slice(0, chars)}…${hex.slice(-chars)}`;
|
||||
}
|
||||
113
desktop/src/lib/relay.ts
Normal file
113
desktop/src/lib/relay.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Relay mailbox client. Same wire format + semantics as
|
||||
// client-app/lib/api.ts, narrowed to the calls the desktop actually
|
||||
// needs right now: broadcast sealed envelopes, fetch inbox, resolve a
|
||||
// recipient's device pubs for fan-out.
|
||||
|
||||
import { get, post, fetchDevices, getIdentity } from './api';
|
||||
import {
|
||||
hexToBytes, bytesToHex, bytesToBase64, base64ToBytes,
|
||||
} from './crypto';
|
||||
|
||||
export interface Envelope {
|
||||
id: string;
|
||||
sender_pub: string; // X25519 hex (per-device key)
|
||||
/**
|
||||
* sender_ed25519_pub (v2.2.0+): master Ed25519 identity of the sender.
|
||||
* Empty for legacy senders; when present, clients should use this as
|
||||
* the conversation address so messages from any of the sender's
|
||||
* linked devices roll into a single chat.
|
||||
*/
|
||||
sender_ed25519_pub: string;
|
||||
recipient_pub: string;
|
||||
nonce: string; // hex
|
||||
ciphertext: string; // hex
|
||||
timestamp: number; // unix seconds
|
||||
}
|
||||
|
||||
// ─── Inbox ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface InboxItemWire {
|
||||
id: string;
|
||||
sender_pub: string;
|
||||
sender_ed25519_pub?: string; // v2.2.0+; omitted by older nodes
|
||||
recipient_pub: string;
|
||||
sent_at: number;
|
||||
nonce: string; // base64 on the wire
|
||||
ciphertext: string; // base64 on the wire
|
||||
}
|
||||
interface InboxResponseWire {
|
||||
pub: string;
|
||||
count: number;
|
||||
has_more: boolean;
|
||||
items: InboxItemWire[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /relay/inbox?pub=<x25519> → envelopes addressed to that pub.
|
||||
* Converts base64 nonce/ciphertext (Go wire format) to hex so they
|
||||
* line up with what crypto.decryptMessage expects.
|
||||
*/
|
||||
export async function fetchInbox(x25519Pub: string): Promise<Envelope[]> {
|
||||
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519Pub}`);
|
||||
const items = Array.isArray(resp?.items) ? resp.items : [];
|
||||
return items.map((it): Envelope => ({
|
||||
id: it.id,
|
||||
sender_pub: it.sender_pub,
|
||||
sender_ed25519_pub: it.sender_ed25519_pub ?? '',
|
||||
recipient_pub: it.recipient_pub,
|
||||
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
||||
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
||||
timestamp: it.sent_at ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Broadcast ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /relay/broadcast — submits a pre-sealed E2E envelope. The node
|
||||
* relays without ever reading the plaintext; only the recipient's
|
||||
* X25519 priv can open it. Sender_ed25519_pub is advisory for future
|
||||
* fee-proof flows; current node ignores it when fee_ut = 0.
|
||||
*/
|
||||
export async function sendEnvelope(params: {
|
||||
senderPub: string; // X25519 hex
|
||||
recipientPub: string; // X25519 hex
|
||||
nonce: string; // hex
|
||||
ciphertext: string; // hex
|
||||
senderEd25519Pub?: string; // optional
|
||||
}): 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 is server-facing dedup key; first 16 bytes of the nonce
|
||||
// are cryptographically random, reuse them to avoid another RNG call.
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Recipient resolution (multi-device v2.2.0) ──────────────────────────
|
||||
|
||||
/**
|
||||
* For a recipient identity, return every X25519 pub we should ship an
|
||||
* envelope to. Device registry first, identity.x25519_pub as fall-back.
|
||||
* Same helper lives in client-app — copied here rather than imported so
|
||||
* the desktop build stays React-Native-free.
|
||||
*/
|
||||
export async function resolveRecipientKeys(masterPub: string): Promise<string[]> {
|
||||
const devs = await fetchDevices(masterPub);
|
||||
if (devs.length > 0) return devs.map(d => d.x25519_pub_key);
|
||||
const id = await getIdentity(masterPub);
|
||||
return id?.x25519_pub ? [id.x25519_pub] : [];
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
// per-install state. A future polish could move chats to IndexedDB
|
||||
// for streaming writes, but localStorage is fine for v2.2.0.
|
||||
|
||||
import type { KeyFile, NodeSettings, Contact } from './types';
|
||||
import type { KeyFile, NodeSettings, Contact, Message } from './types';
|
||||
import type { DChainAPI } from '../../electron/preload';
|
||||
|
||||
declare global {
|
||||
@@ -105,6 +105,34 @@ export function upsertContact(c: Contact): void {
|
||||
saveContacts(cs);
|
||||
}
|
||||
|
||||
// ─── Chat cache (per-conversation, capped) ───────────────────────────────
|
||||
|
||||
const CHATS_PREFIX = 'dchain_chats_';
|
||||
const CHAT_CAP = 500;
|
||||
|
||||
export function loadMessages(chatAddr: string): Message[] {
|
||||
const raw = localStorage.getItem(CHATS_PREFIX + chatAddr);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
return JSON.parse(raw) as Message[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append + persist. Deduplicates by id, trims to CHAT_CAP newest. Callers
|
||||
* in the UI should prefer zustand's store.appendMessage for reactivity
|
||||
* and call this from effects to flush to disk.
|
||||
*/
|
||||
export function appendMessage(chatAddr: string, m: Message): void {
|
||||
const cur = loadMessages(chatAddr);
|
||||
if (cur.some(x => x.id === m.id)) return;
|
||||
cur.push(m);
|
||||
const trimmed = cur.slice(-CHAT_CAP);
|
||||
localStorage.setItem(CHATS_PREFIX + chatAddr, JSON.stringify(trimmed));
|
||||
}
|
||||
|
||||
// ─── Multi-device bookkeeping (shared semantic with mobile client) ───────
|
||||
|
||||
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Zustand store — same pattern as client-app/lib/store.ts, just lighter.
|
||||
// Holds identity, node settings, UI nav state (current section), and the
|
||||
// bootstrapped flag so the Welcome screen can redirect only once boot
|
||||
// has run.
|
||||
// Zustand store — mirrors client-app/lib/store.ts, trimmed to what the
|
||||
// desktop shell needs today. Holds identity, node settings, live chat
|
||||
// state (contacts + per-chat messages + unread counters) and UI nav
|
||||
// (current section + selected contact). Persistence lives in
|
||||
// lib/storage.ts and hooks (auto-save on mutations).
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { KeyFile, NodeSettings, Contact } from './types';
|
||||
import type { KeyFile, NodeSettings, Contact, Message } from './types';
|
||||
|
||||
export type Section = 'messages' | 'feed' | 'wallet' | 'contacts' | 'settings' | 'profile';
|
||||
|
||||
@@ -14,6 +15,12 @@ interface State {
|
||||
settings: NodeSettings;
|
||||
contacts: Contact[];
|
||||
section: Section;
|
||||
/** address of the currently-open conversation (mirrors mobile's route param). */
|
||||
activeChat: string | null;
|
||||
/** Messages keyed by contact.address. Each list is chronological (old → new). */
|
||||
messages: Record<string, Message[]>;
|
||||
/** Unread counters keyed by contact.address; 0 (or absent) = nothing pending. */
|
||||
unread: Record<string, number>;
|
||||
|
||||
setBooted: (v: boolean) => void;
|
||||
setKeyFile: (k: KeyFile | null) => void;
|
||||
@@ -21,14 +28,23 @@ interface State {
|
||||
setContacts: (cs: Contact[]) => void;
|
||||
upsertContact: (c: Contact) => void;
|
||||
setSection: (s: Section) => void;
|
||||
setActiveChat: (addr: string | null) => void;
|
||||
|
||||
setMessages: (addr: string, msgs: Message[]) => void;
|
||||
appendMessage: (addr: string, m: Message) => void;
|
||||
bumpUnread: (addr: string) => void;
|
||||
clearUnread: (addr: string) => void;
|
||||
}
|
||||
|
||||
export const useStore = create<State>((set) => ({
|
||||
booted: false,
|
||||
keyFile: null,
|
||||
settings: { nodeUrl: 'http://localhost:8080', contractId: '' },
|
||||
contacts: [],
|
||||
section: 'messages',
|
||||
booted: false,
|
||||
keyFile: null,
|
||||
settings: { nodeUrl: 'http://localhost:8080', contractId: '' },
|
||||
contacts: [],
|
||||
section: 'messages',
|
||||
activeChat: null,
|
||||
messages: {},
|
||||
unread: {},
|
||||
|
||||
setBooted: (v) => set({ booted: v }),
|
||||
setKeyFile: (k) => set({ keyFile: k }),
|
||||
@@ -44,4 +60,25 @@ export const useStore = create<State>((set) => ({
|
||||
return { contacts: [...st.contacts, c] };
|
||||
}),
|
||||
setSection: (s) => set({ section: s }),
|
||||
setActiveChat: (addr) => set({ activeChat: addr }),
|
||||
|
||||
setMessages: (addr, msgs) => set((st) => ({
|
||||
messages: { ...st.messages, [addr]: msgs },
|
||||
})),
|
||||
appendMessage: (addr, m) => set((st) => {
|
||||
const cur = st.messages[addr] ?? [];
|
||||
// Idempotent — duplicate envelope deliveries (WS + HTTP race) shouldn't
|
||||
// double-insert.
|
||||
if (cur.some(x => x.id === m.id)) return {};
|
||||
return { messages: { ...st.messages, [addr]: [...cur, m] } };
|
||||
}),
|
||||
bumpUnread: (addr) => set((st) => ({
|
||||
unread: { ...st.unread, [addr]: (st.unread[addr] ?? 0) + 1 },
|
||||
})),
|
||||
clearUnread: (addr) => set((st) => {
|
||||
if (!(addr in st.unread)) return {};
|
||||
const next = { ...st.unread };
|
||||
delete next[addr];
|
||||
return { unread: next };
|
||||
}),
|
||||
}));
|
||||
|
||||
145
desktop/src/lib/tx.ts
Normal file
145
desktop/src/lib/tx.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// Transaction builders + submission.
|
||||
//
|
||||
// Mirrors the handful of builders we actually use from client-app/lib/api.ts
|
||||
// (Transfer, Link/UnlinkDevice for now; more will follow as sections land).
|
||||
// Canonical bytes and wire format are identical to the mobile client —
|
||||
// both talk to the same Go node, so any divergence here is a bug.
|
||||
|
||||
import { bytesToBase64, signBase64 } from './crypto';
|
||||
import { post } from './api';
|
||||
|
||||
const MIN_TX_FEE = 1_000;
|
||||
const _encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction
|
||||
* JSON. `payload` and `signature` are base64 because Go's json.Marshal
|
||||
* encodes []byte that way; `timestamp` is RFC3339 because Go's time.Time
|
||||
* does the same.
|
||||
*/
|
||||
export interface RawTx {
|
||||
id: string;
|
||||
type: string;
|
||||
from: string;
|
||||
to: string;
|
||||
amount: number;
|
||||
fee: number;
|
||||
memo?: string;
|
||||
payload: string;
|
||||
signature: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function rfc3339Now(): string {
|
||||
const d = new Date();
|
||||
d.setMilliseconds(0);
|
||||
return d.toISOString().replace('.000Z', 'Z');
|
||||
}
|
||||
|
||||
function newTxID(): string {
|
||||
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical bytes the node re-derives to verify tx.signature. Order of
|
||||
* keys matches Go's field order in identity.txSignBytes — JS object
|
||||
* literals preserve insertion order so JSON.stringify is enough.
|
||||
*/
|
||||
function canonicalBytes(tx: {
|
||||
id: string; type: string; from: string; to: string;
|
||||
amount: number; fee: number; payload: string; timestamp: string;
|
||||
}): Uint8Array {
|
||||
return _encoder.encode(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,
|
||||
}));
|
||||
}
|
||||
|
||||
function strToBase64(s: string): string {
|
||||
return bytesToBase64(_encoder.encode(s));
|
||||
}
|
||||
|
||||
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
|
||||
return post<{ id: string; status: string }>('/api/tx', tx);
|
||||
}
|
||||
|
||||
// ─── Builders ────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildTransferTx(p: {
|
||||
from: string; to: string; amount: number; fee: number;
|
||||
privKey: string; memo?: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify(p.memo ? { memo: p.memo } : {}));
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'TRANSFER', from: p.from, to: p.to,
|
||||
amount: p.amount, fee: p.fee, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'TRANSFER', from: p.from, to: p.to,
|
||||
amount: p.amount, fee: p.fee, memo: p.memo, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLinkDeviceTx(p: {
|
||||
from: string; x25519Pub: string; deviceName: string; privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify({
|
||||
x25519_pub_key: p.x25519Pub,
|
||||
device_name: p.deviceName,
|
||||
}));
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'LINK_DEVICE', from: p.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'LINK_DEVICE', from: p.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUnlinkDeviceTx(p: {
|
||||
from: string; x25519Pub: string; privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify({ x25519_pub_key: p.x25519Pub }));
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* humanizeTxError unwraps the server's `{"error":"…"}` shape and common
|
||||
* message wrappers into a one-line user-facing string. Same helper the
|
||||
* mobile client exposes from lib/api.ts; copied here to keep the two
|
||||
* codebases independent until we factor into a shared package.
|
||||
*/
|
||||
export function humanizeTxError(err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const m = /→\s*({[^}]+})/.exec(raw);
|
||||
if (m) {
|
||||
try {
|
||||
const parsed = JSON.parse(m[1]);
|
||||
if (parsed.error) return parsed.error;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
Reference in New Issue
Block a user