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:
@@ -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(() => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import * as Clipboard from 'expo-clipboard';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useMessages } from '@/hooks/useMessages';
|
||||
import { encryptMessage } from '@/lib/crypto';
|
||||
import { sendEnvelope } from '@/lib/api';
|
||||
import { sendEnvelope, resolveRecipientKeys } from '@/lib/api';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||
import { randomId, safeBack } from '@/lib/utils';
|
||||
@@ -212,15 +212,34 @@ export default function ChatScreen() {
|
||||
// leaves the device, so no encryption/fee/network round-trip is needed.
|
||||
// Regular chats still go through the NaCl + relay pipeline below.
|
||||
if (hasText && !isSavedMessages) {
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: contact.x25519Pub,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
// Multi-device fan-out (v2.2.0): resolve the recipient's active
|
||||
// device X25519 pubs via /api/devices. Legacy identities (no
|
||||
// devices registered) fall back to their published identity
|
||||
// x25519 pub, preserving the pre-v2.2.0 single-device path.
|
||||
// `contact.x25519Pub` stays the floor — if both network calls
|
||||
// fail we still attempt delivery to the cached pub so a flaky
|
||||
// connection doesn't block outgoing messages.
|
||||
let recipientPubs = await resolveRecipientKeys(contact.address);
|
||||
if (recipientPubs.length === 0 && contact.x25519Pub) {
|
||||
recipientPubs = [contact.x25519Pub];
|
||||
}
|
||||
if (recipientPubs.length === 0) {
|
||||
throw new Error('recipient has no encryption key published');
|
||||
}
|
||||
// One sealed envelope per recipient device. Parallel — slow
|
||||
// relays don't block each other; any individual failure
|
||||
// rejects the whole send (user retries).
|
||||
await Promise.all(recipientPubs.map(async (rpub) => {
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
actualText.trim(), keyFile.x25519_priv, rpub,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: rpub,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
const msg: Message = {
|
||||
|
||||
Reference in New Issue
Block a user