Skip to content
18 changes: 18 additions & 0 deletions mobile_app/app/dev/arcium-beacon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Dev-only route for the Arcium beacon-privacy operator screen.
*
* The screen + its arcium-service imports are loaded via a require() gated on
* __DEV__ (a build-time constant). In production builds __DEV__ is statically
* false, so the branch — and therefore the require — is dead-code-eliminated:
* the dev screen never imports, initializes, renders, or ships behind a live
* code path. Reachable in dev via `anonmesh://dev/arcium-beacon`.
*/
import React from 'react';
import { Redirect } from 'expo-router';

export default function ArciumBeaconRoute() {
if (!__DEV__) return <Redirect href="/" />;
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
const ArciumBeaconScreen = require('@/components/dev/ArciumBeaconScreen').default;
return <ArciumBeaconScreen />;
}
185 changes: 185 additions & 0 deletions mobile_app/components/dev/ArciumBeaconScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Arcium beacon-privacy operator screen (dev only).
* Drives register -> bind -> init stats -> record relay -> decrypt count against
* the live anonbeta1 program on devnet, showing each phase.
*
* This component lives outside app/ and is loaded only by the __DEV__-gated
* require() in app/dev/arcium-beacon.tsx, so it (and its arcium-service imports)
* never execute in production builds.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Stack } from 'expo-router';

import { fontFamily, fontSize, useTheme } from '@/theme';
import { useWallet } from '@/context/WalletContext';
import { useNetworkMode } from '@/src/hooks/useNetworkMode';
import {
registerBeacon, waitForBindingVerified, initRelayStats,
recordRelay, waitForRelayRecorded, getBeaconStatus, getDecryptedRelayCount,
type BeaconStatus,
} from '@/src/services/arcium';
import { runCryptoSelfTest, type SelfTestResult } from '@/src/services/arcium/selfTest';

export default function ArciumBeaconScreen() {
const { colors } = useTheme();
const { wallet, publicKey, isConnected } = useWallet();
const { adapter: rpcAdapter } = useNetworkMode();

const [status, setStatus] = useState<BeaconStatus | null>(null);
const [count, setCount] = useState<bigint | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lastTx, setLastTx] = useState<string | null>(null);
const [selfTest, setSelfTest] = useState<SelfTestResult | null>(null);

const onSelfTest = useCallback(() => {
const result = runCryptoSelfTest();
setSelfTest(result);
// logged so it can be captured via logcat during automated on-device runs
console.log('[arcium self-test]', result.pass ? 'PASS' : 'FAIL', JSON.stringify(result.lines));
}, []);

const ctx = useMemo(
() => (wallet && rpcAdapter ? { walletAdapter: wallet, rpcAdapter } : null),
[wallet, rpcAdapter],
);

const refresh = useCallback(async () => {
if (!ctx) return;
try {
setStatus(await getBeaconStatus(ctx));
setCount(await getDecryptedRelayCount(ctx));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, [ctx]);

useEffect(() => {
if (ctx && isConnected) void refresh();
}, [ctx, isConnected, refresh]);

const run = useCallback(
(phase: string, fn: () => Promise<void>) => async () => {
if (!ctx) return;
setError(null);
setBusy(phase);
try {
await fn();
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(null);
}
},
[ctx, refresh],
);

const onRegister = run('Registering beacon + Arcium binding (MPC)…', async () => {
const res = await registerBeacon(ctx!);
setLastTx(res.signature);
await waitForBindingVerified(ctx!);
});
const onInitStats = run('Initializing encrypted relay stats…', async () => {
const res = await initRelayStats(ctx!);
setLastTx(res.signature);
});
const onRecordRelay = run('Recording relay + Arcium increment (MPC)…', async () => {
const res = await recordRelay(ctx!);
setLastTx(res.signature);
await waitForRelayRecorded(ctx!);
});

const s = StyleSheet.create({
body: { padding: 16, gap: 14 },
title: { fontFamily: fontFamily.sansSb, fontSize: fontSize.lg, color: colors.textPrimary },
blurb: { fontFamily: fontFamily.sans, fontSize: fontSize.sm, color: colors.textSecondary, lineHeight: 19 },
card: { backgroundColor: colors.surface1, borderColor: colors.borderSubtle, borderWidth: 1, borderRadius: 12, padding: 14, gap: 8 },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
label: { fontFamily: fontFamily.sans, fontSize: fontSize.sm, color: colors.textTertiary },
value: { fontFamily: fontFamily.sansMd, fontSize: fontSize.sm, color: colors.textPrimary },
btn: { backgroundColor: colors.primary, borderRadius: 10, paddingVertical: 13, alignItems: 'center' },
btnGhost: { backgroundColor: 'transparent', borderColor: colors.borderSubtle, borderWidth: 1 },
btnText: { fontFamily: fontFamily.sansSb, fontSize: fontSize.md, color: colors.background },
btnTextGhost: { color: colors.textPrimary },
disabled: { opacity: 0.4 },
busy: { flexDirection: 'row', gap: 10, alignItems: 'center' },
busyText: { fontFamily: fontFamily.sans, fontSize: fontSize.sm, color: colors.textSecondary, flex: 1 },
err: { fontFamily: fontFamily.sansMd, fontSize: fontSize.sm, color: '#dc2626' },
mono: { fontFamily: fontFamily.sans, fontSize: fontSize.xs, color: colors.textTertiary },
});

const Btn = ({ label, onPress, disabled, ghost }: { label: string; onPress: () => void; disabled?: boolean; ghost?: boolean }) => (
<Pressable
onPress={onPress}
disabled={disabled || !!busy}
style={[s.btn, ghost && s.btnGhost, (disabled || !!busy) && s.disabled]}
>
<Text style={[s.btnText, ghost && s.btnTextGhost]}>{label}</Text>
</Pressable>
);

const StatusRow = ({ label, value }: { label: string; value: string }) => (
<View style={s.row}><Text style={s.label}>{label}</Text><Text style={s.value}>{value}</Text></View>
);

return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['bottom']}>
<Stack.Screen options={{ title: 'Arcium Beacon (dev)' }} />
<ScrollView contentContainerStyle={s.body}>
<Text style={s.title}>Beacon privacy</Text>
<Text style={s.blurb}>
Register this wallet as a relay beacon. The RNS destination is encrypted and bound via
Arcium MPC (never public), and relay activity is counted in an encrypted on-chain
counter only you can decrypt. Runs against anonbeta1 on devnet.
</Text>

<Btn label="Run crypto self-test (Hermes)" onPress={onSelfTest} ghost />
{selfTest && (
<View style={s.card}>
<Text style={[s.value, { color: selfTest.pass ? '#16a34a' : '#dc2626' }]}>
{selfTest.pass ? 'SELF-TEST PASS ✓' : 'SELF-TEST FAIL ✗'}
</Text>
{selfTest.lines.map((l) => (
<View key={l.name} style={s.row}>
<Text style={s.label}>{l.name}</Text>
<Text style={[s.value, { color: l.ok ? '#16a34a' : '#dc2626' }]}>{l.ok ? '✓' : '✗'}</Text>
</View>
))}
</View>
)}

{!isConnected || !ctx ? (
<View style={s.card}><Text style={s.value}>Connect a wallet first.</Text></View>
) : (
<>
<View style={s.card}>
<StatusRow label="Operator" value={publicKey ? `${publicKey.toBase58().slice(0, 4)}…${publicKey.toBase58().slice(-4)}` : '—'} />
<StatusRow label="Registered" value={status?.registered ? 'yes' : 'no'} />
<StatusRow label="Binding verified" value={status?.bindingVerified ? 'yes ✓' : 'pending'} />
<StatusRow label="Relay stats" value={status?.relayStatsInitialized ? 'initialized' : 'not set'} />
<StatusRow label="Settlements" value={String(status?.settlementCount ?? 0)} />
<StatusRow label="Decrypted relay count" value={count === null ? '—' : count.toString()} />
</View>

{busy && (
<View style={[s.card, s.busy]}>
<ActivityIndicator color={colors.primary} />
<Text style={s.busyText}>{busy}</Text>
</View>
)}
{error && <Text style={s.err}>{error}</Text>}
{lastTx && <Text style={s.mono}>last tx: {lastTx.slice(0, 16)}…</Text>}

<Btn label="1 · Register beacon" onPress={onRegister} disabled={status?.registered} />
<Btn label="2 · Init relay stats" onPress={onInitStats} disabled={!status?.bindingVerified || status?.relayStatsInitialized} />
<Btn label="3 · Record relay" onPress={onRecordRelay} disabled={!status?.relayStatsInitialized} />
<Btn label="Refresh" onPress={() => void refresh()} ghost />
</>
)}
</ScrollView>
</SafeAreaView>
);
}
56 changes: 56 additions & 0 deletions mobile_app/src/services/arcium/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Arcium beacon-privacy

Integrates the **anonbeta1** Arcium program (Solana devnet) into the app for the
**beacon-operator privacy flow**. An operator registers a relay beacon whose RNS
destination is encrypted + bound under Arcium MPC (never public), and whose relay
throughput is tracked in an encrypted on-chain counter only the operator can decrypt.

> Scope: operator privacy flow only. The public payment instruction
> (`execute_cosigned_transfer`) is intentionally out of scope here.

## Layout

| File | Role |
|------|------|
| `constants.ts` | program id, cluster offset (456), comp-def offsets, decode offsets |
| `vendor/arciumCrypto.ts` | **vendored** RescueCipher + Arcium PDA derivations (from `@arcium-hq/client@0.9.3`, node/anchor stripped, `@noble` v2). `@ts-nocheck`. Validated byte-for-byte vs the SDK. Do not hand-edit. |
| `beaconInstructions.ts` | raw `@solana/web3.js` instruction builders + account decoders (framework-agnostic) |
| `beaconKeys.ts` | operator x25519 keypair, persisted in the OS keystore |
| `beaconClient.ts` | service: register / waitForBindingVerified / initRelayStats / recordRelay / waitForRelayRecorded / getBeaconStatus / getDecryptedRelayCount |
| `__tests__/` | `crypto.test.mjs` (deterministic) + `integration.devnet.mjs` (live round-trip) |

## Usage

```ts
import { registerBeacon, waitForBindingVerified, initRelayStats,
recordRelay, waitForRelayRecorded, getDecryptedRelayCount } from '@/src/services/arcium';

const ctx = { walletAdapter, rpcAdapter }; // from useWallet().wallet + useNetworkMode().adapter
await registerBeacon(ctx); // signs register_beacon_private
await waitForBindingVerified(ctx); // polls beacon_bind MPC callback
await initRelayStats(ctx);
await recordRelay(ctx); // signs record_relay
await waitForRelayRecorded(ctx); // polls relay_increment MPC callback
const count = await getDecryptedRelayCount(ctx); // bigint, decrypted locally
```

## Tests

```bash
# deterministic (no network): crypto golden vectors + instruction encoding
npx tsx src/services/arcium/__tests__/crypto.test.mjs
# live devnet round-trip of the real modules (funds a fresh operator from the CLI keypair)
npx tsx src/services/arcium/__tests__/integration.devnet.mjs
```

Both pass against devnet. Metro bundles the modules for Android cleanly.

## On-device smoke (Seeker)

1. Serve this branch over Metro and load the dev client.
2. Deep link: `anonmesh://dev/arcium-beacon`.
3. Connect a wallet, then tap **1 Register → 2 Init relay stats → 3 Record relay**.
Each MPC step shows a busy indicator (seconds–minutes); the status card shows
`binding verified ✓` and a non-zero **decrypted relay count** when done.

Verified on devnet: program `anon7uu8UtVoFgS8GCSfw2RqyphJhkN3xEjgPwznYDe`.
53 changes: 53 additions & 0 deletions mobile_app/src/services/arcium/__tests__/crypto.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Deterministic unit checks for the vendored crypto + instruction encoding.
* No network. Run: npx tsx src/services/arcium/__tests__/crypto.test.mjs
* Proves the RN-bundled TS modules match the SDK-validated golden vectors.
*/
import { PublicKey } from '@solana/web3.js';
import { RescueCipher } from '../vendor/arciumCrypto.ts';
import {
compDefOffset, getMxeX25519Pubkey, beaconPda,
buildRegisterBeaconInstruction, buildRecordRelayInstruction, leBytes,
} from '../beaconInstructions.ts';
import { getMXEAccAddress } from '../vendor/arciumCrypto.ts';
import { ANONBETA1_PROGRAM_ID, DISCRIMINATORS } from '../constants.ts';

let fail = 0;
const check = (name, cond) => { if (!cond) fail++; console.log(`${cond ? 'OK ' : 'FAIL'} ${name}`); };

// 1) RescueCipher matches the v1 SDK golden vector (fixed inputs)
const shared = new Uint8Array(32); for (let i = 0; i < 32; i++) shared[i] = i + 1;
const nonce = new Uint8Array(16); for (let i = 0; i < 16; i++) nonce[i] = (i * 7 + 3) & 0xff;
const ct = new RescueCipher(shared).encrypt([123456789n, 987654321n], nonce);
const golden = [[30,126,244,5,116,130,238,81,247,144,198,42,200,9,6,102,169,63,166,133,163,175,3,2,62,166,145,124,107,53,237,54],[128,83,228,133,167,71,3,11,26,45,215,234,234,24,189,137,132,225,9,95,8,92,233,33,195,102,78,177,39,73,239,103]];
check('RescueCipher == SDK golden vector', JSON.stringify(ct) === JSON.stringify(golden));
const dec = new RescueCipher(shared).decrypt(ct, nonce);
check('RescueCipher decrypt round-trip', dec[0] === 123456789n && dec[1] === 987654321n);

// 2) derivations match known on-chain values
check('getMXEAccAddress', getMXEAccAddress(ANONBETA1_PROGRAM_ID).toBase58() === '6EiE6YSJ99qhq3bTEM8CtBqmdmBZHsMm56NZtRJ5shJL');
check('beacon_bind comp-def offset', compDefOffset('beacon_bind') === 287562432);
check('relay_increment comp-def offset', compDefOffset('relay_increment') === 1084638176);

// 3) instruction encoding sanity (discriminator + arg sizes)
const op = new PublicKey('96pAGQK9Fa4dD17oDH9qDw6n38aNteLEEKKakNgsYUWw');
const reg = buildRegisterBeaconInstruction({
operator: op, computationOffset: 1n,
encryptedRnsDestHash: new Uint8Array(32), encryptedRegionCode: new Uint8Array(32),
nonce: 1n, x25519Pubkey: new Uint8Array(32), regionCode: Uint8Array.from([0x55,0x53,0x20,0x20]), capabilitiesBitmap: 0,
});
check('register discriminator', Buffer.from(reg.data.subarray(0, 8)).equals(Buffer.from(DISCRIMINATORS.registerBeaconPrivate)));
check('register data length (8+8+32+32+16+32+4+4=136)', reg.data.length === 136);
check('register account count = 14', reg.keys.length === 14);
check('register operator is signer+writable', reg.keys[0].pubkey.equals(op) && reg.keys[0].isSigner && reg.keys[0].isWritable);

const rec = buildRecordRelayInstruction({ operator: op, computationOffset: 2n, relayEventHash: new Uint8Array(32).fill(1), x25519Pubkey: new Uint8Array(32) });
check('record discriminator', Buffer.from(rec.data.subarray(0, 8)).equals(Buffer.from(DISCRIMINATORS.recordRelay)));
check('record data length (8+8+32+32=80)', rec.data.length === 80);
check('record account count = 15', rec.keys.length === 15);

// 4) leBytes correctness
check('leBytes u64', Buffer.from(leBytes(0x0102030405060708n, 8)).toString('hex') === '0807060504030201');

console.log(fail === 0 ? '\n✅ crypto/encoding unit checks PASS' : `\n❌ ${fail} failure(s)`);
process.exit(fail === 0 ? 0 : 1);
75 changes: 75 additions & 0 deletions mobile_app/src/services/arcium/__tests__/integration.devnet.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Devnet integration test for the ACTUAL mobile TS modules (beaconInstructions +
* vendored crypto). Signing/RPC use Node Keypair/Connection here; in the app they
* are IWalletAdapter/IRpcAdapter. Proves the shipped code path on-chain.
*
* Run: npx tsx src/services/arcium/__tests__/integration.devnet.mjs
* Needs devnet SOL — funds a fresh operator from ~/.config/solana/id.json.
*/
import * as fs from 'fs';
import { Connection, Keypair, Transaction, SystemProgram, sendAndConfirmTransaction } from '@solana/web3.js';
import { x25519 } from '@noble/curves/ed25519.js';
import { randomBytes } from '@noble/hashes/utils.js';
import { RescueCipher, deserializeLE, serializeLE } from '../vendor/arciumCrypto.ts';
import {
buildRegisterBeaconInstruction, buildInitRelayStatsInstruction, buildRecordRelayInstruction,
getMxeX25519Pubkey, beaconPda, relayStatsPda, decodeBindingVerified, decodeRelayStats,
} from '../beaconInstructions.ts';

const RPC = process.env.RPC_URL || 'https://api.devnet.solana.com';
const log = (...a) => console.log(...a);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const randU64 = () => deserializeLE(randomBytes(8));

async function poll(conn, addr, check, label, timeoutMs = 180000) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
const info = await conn.getAccountInfo(addr);
if (info && check(info.data)) return;
await sleep(3000);
}
throw new Error(`timeout: ${label}`);
}

async function main() {
const conn = new Connection(RPC, 'confirmed');
const cli = Keypair.fromSecretKey(new Uint8Array(JSON.parse(fs.readFileSync(process.env.HOME + '/.config/solana/id.json'))));
const operator = Keypair.generate();
log('fresh operator:', operator.publicKey.toBase58());
await sendAndConfirmTransaction(conn, new Transaction().add(SystemProgram.transfer({ fromPubkey: cli.publicKey, toPubkey: operator.publicKey, lamports: 1.5e9 })), [cli]);

const xpriv = x25519.utils.randomSecretKey();
const xpub = x25519.getPublicKey(xpriv);
const mxePub = await getMxeX25519Pubkey((pk) => conn.getAccountInfo(pk));
const cipher = new RescueCipher(x25519.getSharedSecret(xpriv, mxePub));

// register_beacon_private
const rnsNonce = randomBytes(16);
const ct = cipher.encrypt([deserializeLE(randomBytes(16)) % (2n ** 128n), BigInt(0x53550000)], rnsNonce);
const regIx = buildRegisterBeaconInstruction({
operator: operator.publicKey, computationOffset: randU64(),
encryptedRnsDestHash: Uint8Array.from(ct[0]), encryptedRegionCode: Uint8Array.from(ct[1]),
nonce: deserializeLE(rnsNonce), x25519Pubkey: xpub, regionCode: Uint8Array.from([0x55, 0x53, 0x20, 0x20]), capabilitiesBitmap: 0,
});
log('register tx:', await sendAndConfirmTransaction(conn, new Transaction().add(regIx), [operator]));
await poll(conn, beaconPda(operator.publicKey), decodeBindingVerified, 'binding_verified');
log(' binding_verified ✓');

// init_relay_stats
const initNonce = randomBytes(16);
const initCt = cipher.encrypt([0n], initNonce);
const initIx = buildInitRelayStatsInstruction({ operator: operator.publicKey, initialCiphertext: Uint8Array.from(initCt[0]), initialNonce: deserializeLE(initNonce), x25519Pubkey: xpub });
log('init_relay_stats tx:', await sendAndConfirmTransaction(conn, new Transaction().add(initIx), [operator]));

// record_relay
const recIx = buildRecordRelayInstruction({ operator: operator.publicKey, computationOffset: randU64(), relayEventHash: randomBytes(32), x25519Pubkey: xpub });
log('record_relay tx:', await sendAndConfirmTransaction(conn, new Transaction().add(recIx), [operator]));
await poll(conn, relayStatsPda(operator.publicKey), (d) => !decodeRelayStats(d).hasPending, 'relay_increment');

const stats = decodeRelayStats((await conn.getAccountInfo(relayStatsPda(operator.publicKey))).data);
const count = cipher.decrypt([stats.encryptedCount], serializeLE(stats.nonce, 16));
log('decrypted relay count =', count[0].toString());
if (count[0] !== 1n) throw new Error(`expected 1, got ${count[0]}`);
log('\n✅ MOBILE TS MODULES — devnet round-trip OK');
}
main().catch((e) => { console.error('FATAL:', e.message); if (e.logs) e.logs.forEach((l) => console.error(' ', l)); process.exit(1); });
Loading
Loading