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).
This commit is contained in:
@@ -365,6 +365,25 @@ export interface IdentityInfo {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,6 +428,56 @@ export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo |
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -712,6 +781,68 @@ export function buildTransferTx(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user