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:
vsecoder
2026-04-22 16:24:36 +03:00
parent 1d9206494a
commit 423d307125
3 changed files with 202 additions and 11 deletions

View File

@@ -13,7 +13,7 @@
* один раз; переходы между tab'ами их не перезапускают.
*/
import React, { useEffect } from 'react';
import { View } from 'react-native';
import { View, Platform } from 'react-native';
import { router, usePathname } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
@@ -26,6 +26,9 @@ import { getWSClient } from '@/lib/ws';
import { NavBar } from '@/components/NavBar';
import { AnimatedSlot } from '@/components/AnimatedSlot';
import { saveContact } from '@/lib/storage';
import {
fetchDevices, buildLinkDeviceTx, submitTx,
} from '@/lib/api';
export default function AppLayout() {
const keyFile = useStore(s => s.keyFile);
@@ -73,6 +76,44 @@ export default function AppLayout() {
else ws.setAuthCreds(null);
}, [keyFile]);
// Multi-device registry bootstrap (v2.2.0). On every sign-in:
// 1. Fetch our own device list from chain.
// 2. If our local X25519 pub isn't in the active set, submit a
// LINK_DEVICE tx for it — this makes "this device" discoverable
// to senders. No-ops after the first successful submission on
// a given chain + master key pair.
// Failures are swallowed: insufficient balance, offline node, or a
// chain that hasn't upgraded to v2.2.0 all surface the same way
// (our device just isn't registered yet; next sign-in tries again).
useEffect(() => {
if (!keyFile) return;
let cancelled = false;
(async () => {
try {
const devs = await fetchDevices(keyFile.pub_key);
if (cancelled) return;
if (devs.some(d => d.x25519_pub_key === keyFile.x25519_pub)) {
return; // already registered
}
const deviceName = Platform.select({
ios: 'iPhone',
android: 'Android phone',
default: 'Device',
}) ?? 'Device';
const tx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
deviceName,
privKey: keyFile.priv_key,
});
await submitTx(tx);
} catch {
/* best-effort — next launch retries */
}
})();
return () => { cancelled = true; };
}, [keyFile]);
useEffect(() => {
if (keyFile === null) {
const t = setTimeout(() => {