diff --git a/mobile_app/app/dev/arcium-beacon.tsx b/mobile_app/app/dev/arcium-beacon.tsx
new file mode 100644
index 00000000..2a072863
--- /dev/null
+++ b/mobile_app/app/dev/arcium-beacon.tsx
@@ -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 ;
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
+ const ArciumBeaconScreen = require('@/components/dev/ArciumBeaconScreen').default;
+ return ;
+}
diff --git a/mobile_app/components/dev/ArciumBeaconScreen.tsx b/mobile_app/components/dev/ArciumBeaconScreen.tsx
new file mode 100644
index 00000000..6eb70b31
--- /dev/null
+++ b/mobile_app/components/dev/ArciumBeaconScreen.tsx
@@ -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(null);
+ const [count, setCount] = useState(null);
+ const [busy, setBusy] = useState(null);
+ const [error, setError] = useState(null);
+ const [lastTx, setLastTx] = useState(null);
+ const [selfTest, setSelfTest] = useState(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) => 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 }) => (
+
+ {label}
+
+ );
+
+ const StatusRow = ({ label, value }: { label: string; value: string }) => (
+ {label}{value}
+ );
+
+ return (
+
+
+
+ Beacon privacy
+
+ 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.
+
+
+
+ {selfTest && (
+
+
+ {selfTest.pass ? 'SELF-TEST PASS ✓' : 'SELF-TEST FAIL ✗'}
+
+ {selfTest.lines.map((l) => (
+
+ {l.name}
+ {l.ok ? '✓' : '✗'}
+
+ ))}
+
+ )}
+
+ {!isConnected || !ctx ? (
+ Connect a wallet first.
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+ {busy && (
+
+
+ {busy}
+
+ )}
+ {error && {error}}
+ {lastTx && last tx: {lastTx.slice(0, 16)}…}
+
+
+
+
+ void refresh()} ghost />
+ >
+ )}
+
+
+ );
+}
diff --git a/mobile_app/src/services/arcium/README.md b/mobile_app/src/services/arcium/README.md
new file mode 100644
index 00000000..dba7c946
--- /dev/null
+++ b/mobile_app/src/services/arcium/README.md
@@ -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`.
diff --git a/mobile_app/src/services/arcium/__tests__/crypto.test.mjs b/mobile_app/src/services/arcium/__tests__/crypto.test.mjs
new file mode 100644
index 00000000..f05df1ee
--- /dev/null
+++ b/mobile_app/src/services/arcium/__tests__/crypto.test.mjs
@@ -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);
diff --git a/mobile_app/src/services/arcium/__tests__/integration.devnet.mjs b/mobile_app/src/services/arcium/__tests__/integration.devnet.mjs
new file mode 100644
index 00000000..c45ea6af
--- /dev/null
+++ b/mobile_app/src/services/arcium/__tests__/integration.devnet.mjs
@@ -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); });
diff --git a/mobile_app/src/services/arcium/beaconClient.ts b/mobile_app/src/services/arcium/beaconClient.ts
new file mode 100644
index 00000000..b8971d46
--- /dev/null
+++ b/mobile_app/src/services/arcium/beaconClient.ts
@@ -0,0 +1,189 @@
+/**
+ * Arcium beacon-operator service. Drives the privacy flow end-to-end using the
+ * app's wallet + RPC adapters:
+ * registerBeacon -> waitForBindingVerified -> initRelayStats
+ * -> recordRelay -> waitForRelayRecorded -> getDecryptedRelayCount
+ *
+ * All instruction-building + crypto is the devnet-proven code in
+ * beaconInstructions.ts + vendor/arciumCrypto.ts. Signing reuses
+ * signAndSubmitTransaction (local Keystore or MWA).
+ */
+import { PublicKey, Transaction } from '@solana/web3.js';
+import type { IWalletAdapter } from '@/src/infrastructure/wallet';
+import type { IRpcAdapter } from '@/src/infrastructure/network';
+import { signAndSubmitTransaction } from '@/src/services/sendTransaction';
+// eslint-disable-next-line import/extensions
+import { RescueCipher, deserializeLE, serializeLE, randomBytes, x25519 } from './vendor/arciumCrypto';
+import {
+ buildRegisterBeaconInstruction, buildInitRelayStatsInstruction, buildRecordRelayInstruction,
+ getMxeX25519Pubkey, beaconPda, relayStatsPda, decodeBindingVerified, decodeRelayStats,
+} from './beaconInstructions';
+import { BEACON_REGISTRY_OFFSETS } from './constants';
+import { getOrCreateBeaconX25519Key } from './beaconKeys';
+
+const randU64 = (): bigint => deserializeLE(randomBytes(8));
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+interface Ctx {
+ walletAdapter: IWalletAdapter;
+ rpcAdapter: IRpcAdapter;
+}
+
+function requireOperator(walletAdapter: IWalletAdapter): PublicKey {
+ const pk = walletAdapter.getPublicKey();
+ if (!pk) throw new Error('Wallet not connected');
+ return pk;
+}
+
+/**
+ * Build a RescueCipher bound to (operator x25519 secret, live MXE pubkey).
+ * Zeroes the operator secret + derived shared secret once the cipher has
+ * absorbed the key material — mirrors the secret-key hygiene in
+ * sendTransaction.ts (the cipher keeps its own derived key, not these buffers).
+ */
+async function buildCipher(rpcAdapter: IRpcAdapter, secret: Uint8Array): Promise {
+ const mxePub = await getMxeX25519Pubkey((pk) => rpcAdapter.getAccountInfo(pk));
+ const shared = x25519.getSharedSecret(secret, mxePub);
+ const cipher = new RescueCipher(shared);
+ secret.fill(0);
+ shared.fill(0);
+ return cipher;
+}
+
+async function submit(ctx: Ctx, ix: ReturnType, operator: PublicKey) {
+ const tx = new Transaction().add(ix);
+ return signAndSubmitTransaction({
+ walletAdapter: ctx.walletAdapter,
+ rpcAdapter: ctx.rpcAdapter,
+ tx,
+ expectedPubkey: operator,
+ });
+}
+
+export interface RegisterOptions {
+ /** 16-byte mesh/RNS destination hash to bind privately. Random if omitted. */
+ rnsDestHash?: Uint8Array;
+ /** 4-byte printable-ASCII region code. Defaults to "US ". */
+ regionCode?: Uint8Array;
+ capabilitiesBitmap?: number;
+}
+
+/** register_beacon_private — encrypts the destination + queues the beacon_bind MPC. */
+export async function registerBeacon(ctx: Ctx, opts: RegisterOptions = {}) {
+ const operator = requireOperator(ctx.walletAdapter);
+ const { secret, publicKey } = await getOrCreateBeaconX25519Key();
+ const cipher = await buildCipher(ctx.rpcAdapter, secret);
+
+ // TODO(product): in a real deployment, pass the operator's actual Reticulum/
+ // LXMF destination hash so the Arcium binding commits to the real mesh
+ // identity. The random fallback below only exercises the flow (dev/devnet);
+ // it does NOT bind a meaningful destination.
+ const rns = opts.rnsDestHash ? deserializeLE(opts.rnsDestHash) % (2n ** 128n) : deserializeLE(randomBytes(16)) % (2n ** 128n);
+ const region = opts.regionCode ?? Uint8Array.from([0x55, 0x53, 0x20, 0x20]); // "US "
+ const regionU32 = BigInt(region[0] | (region[1] << 8) | (region[2] << 16) | (region[3] << 24)) & 0xffffffffn;
+ const nonce = randomBytes(16);
+ const ct = cipher.encrypt([rns, regionU32], nonce);
+
+ const ix = buildRegisterBeaconInstruction({
+ operator,
+ computationOffset: randU64(),
+ encryptedRnsDestHash: Uint8Array.from(ct[0]),
+ encryptedRegionCode: Uint8Array.from(ct[1]),
+ nonce: deserializeLE(nonce),
+ x25519Pubkey: publicKey,
+ regionCode: region,
+ capabilitiesBitmap: opts.capabilitiesBitmap ?? 0,
+ });
+ return submit(ctx, ix, operator);
+}
+
+/** Poll until the beacon_bind MPC callback flips binding_verified. */
+export async function waitForBindingVerified(ctx: Ctx, timeoutMs = 180_000): Promise {
+ const operator = requireOperator(ctx.walletAdapter);
+ const addr = beaconPda(operator);
+ const t0 = Date.now();
+ while (Date.now() - t0 < timeoutMs) {
+ const info = await ctx.rpcAdapter.getAccountInfo(addr);
+ if (info && decodeBindingVerified(info.data)) return;
+ await sleep(3000);
+ }
+ throw new Error('Timed out waiting for Arcium beacon binding to verify');
+}
+
+/** init_relay_stats — creates the encrypted relay counter at 0. */
+export async function initRelayStats(ctx: Ctx) {
+ const operator = requireOperator(ctx.walletAdapter);
+ const { secret, publicKey } = await getOrCreateBeaconX25519Key();
+ const cipher = await buildCipher(ctx.rpcAdapter, secret);
+ const nonce = randomBytes(16);
+ const ct = cipher.encrypt([0n], nonce);
+ const ix = buildInitRelayStatsInstruction({
+ operator,
+ initialCiphertext: Uint8Array.from(ct[0]),
+ initialNonce: deserializeLE(nonce),
+ x25519Pubkey: publicKey,
+ });
+ return submit(ctx, ix, operator);
+}
+
+/** record_relay — queues the relay_increment MPC for one relay event. */
+export async function recordRelay(ctx: Ctx, relayEventHash?: Uint8Array) {
+ const operator = requireOperator(ctx.walletAdapter);
+ const { publicKey } = await getOrCreateBeaconX25519Key();
+ const hash = relayEventHash ?? randomBytes(32);
+ const ix = buildRecordRelayInstruction({
+ operator,
+ computationOffset: randU64(),
+ relayEventHash: hash,
+ x25519Pubkey: publicKey,
+ });
+ return submit(ctx, ix, operator);
+}
+
+/** Poll until the relay_increment MPC callback clears pending_relay_hash. */
+export async function waitForRelayRecorded(ctx: Ctx, timeoutMs = 180_000): Promise {
+ const operator = requireOperator(ctx.walletAdapter);
+ const addr = relayStatsPda(operator);
+ const t0 = Date.now();
+ while (Date.now() - t0 < timeoutMs) {
+ const info = await ctx.rpcAdapter.getAccountInfo(addr);
+ if (info && !decodeRelayStats(info.data).hasPending) return;
+ await sleep(3000);
+ }
+ throw new Error('Timed out waiting for Arcium relay increment');
+}
+
+export interface BeaconStatus {
+ registered: boolean;
+ bindingVerified: boolean;
+ settlementCount: number;
+ relayStatsInitialized: boolean;
+}
+
+/** Read public beacon state (no decryption needed). */
+export async function getBeaconStatus(ctx: Ctx): Promise {
+ const operator = requireOperator(ctx.walletAdapter);
+ const beaconInfo = await ctx.rpcAdapter.getAccountInfo(beaconPda(operator));
+ const statsInfo = await ctx.rpcAdapter.getAccountInfo(relayStatsPda(operator));
+ if (!beaconInfo) {
+ return { registered: false, bindingVerified: false, settlementCount: 0, relayStatsInitialized: false };
+ }
+ const data = Buffer.from(beaconInfo.data);
+ return {
+ registered: true,
+ bindingVerified: decodeBindingVerified(data),
+ settlementCount: Number(data.readBigUInt64LE(BEACON_REGISTRY_OFFSETS.settlementCount)),
+ relayStatsInitialized: !!statsInfo,
+ };
+}
+
+/** Decrypt the operator's private relay count (only the operator can). */
+export async function getDecryptedRelayCount(ctx: Ctx): Promise {
+ const operator = requireOperator(ctx.walletAdapter);
+ const info = await ctx.rpcAdapter.getAccountInfo(relayStatsPda(operator));
+ if (!info) return null;
+ const stats = decodeRelayStats(info.data);
+ const { secret } = await getOrCreateBeaconX25519Key();
+ const cipher = await buildCipher(ctx.rpcAdapter, secret);
+ return cipher.decrypt([stats.encryptedCount], serializeLE(stats.nonce, 16))[0];
+}
diff --git a/mobile_app/src/services/arcium/beaconInstructions.ts b/mobile_app/src/services/arcium/beaconInstructions.ts
new file mode 100644
index 00000000..8d378029
--- /dev/null
+++ b/mobile_app/src/services/arcium/beaconInstructions.ts
@@ -0,0 +1,218 @@
+/**
+ * Raw @solana/web3.js instruction builders + account decoders for the anonbeta1
+ * beacon-operator flow. Framework-agnostic (no expo/RN imports) so it is unit-
+ * testable in Node. The exact logic here was proven end-to-end on devnet via
+ * contract/scripts/beacon-roundtrip-raw.mjs.
+ */
+import {
+ PublicKey,
+ SystemProgram,
+ TransactionInstruction,
+ type AccountInfo,
+} from '@solana/web3.js';
+// eslint-disable-next-line import/extensions
+import {
+ getArciumProgramId,
+ getMXEAccAddress,
+ getCompDefAccAddress,
+ getCompDefAccOffset,
+ getComputationAccAddress,
+ getMempoolAccAddress,
+ getExecutingPoolAccAddress,
+ getClusterAccAddress,
+} from './vendor/arciumCrypto';
+import {
+ ANONBETA1_PROGRAM_ID,
+ MXE_CLOCK,
+ MXE_FEE_POOL,
+ BEACON_REGISTRY_OFFSETS,
+ CIRCUITS,
+ CLUSTER_OFFSET,
+ DISCRIMINATORS,
+ MXE_X25519_PUBKEY_OFFSET,
+ RELAY_STATS_OFFSETS,
+} from './constants';
+
+const PID = ANONBETA1_PROGRAM_ID;
+
+// ── little-endian encoders (BigInt, no bn.js) ──────────────────────────────────
+export function leBytes(value: bigint, len: number): Uint8Array {
+ const out = new Uint8Array(len);
+ let v = value;
+ for (let i = 0; i < len; i++) {
+ out[i] = Number(v & 0xffn);
+ v >>= 8n;
+ }
+ return out;
+}
+const u32le = (n: number): Uint8Array => leBytes(BigInt(n >>> 0), 4);
+
+// ── PDAs ───────────────────────────────────────────────────────────────────────
+const findPda = (seeds: (Buffer | Uint8Array)[]) =>
+ PublicKey.findProgramAddressSync(seeds.map((s) => Buffer.from(s)), PID)[0];
+
+export const beaconPda = (operator: PublicKey) => findPda([Buffer.from('beacon'), operator.toBuffer()]);
+export const privateBindingPda = (operator: PublicKey) => findPda([Buffer.from('private_beacon'), operator.toBuffer()]);
+export const relayStatsPda = (operator: PublicKey) => findPda([Buffer.from('relay_stats'), operator.toBuffer()]);
+export const arciumSignerPda = () => findPda([Buffer.from('ArciumSignerAccount')]);
+
+export const compDefOffset = (circuit: string): number =>
+ Buffer.from(getCompDefAccOffset(circuit)).readUInt32LE(0);
+
+/**
+ * Read the MXE x25519 encryption pubkey straight from the MXE account data.
+ * Replaces the SDK's getMXEPublicKey (which pulls anchor) with a raw read.
+ */
+export async function getMxeX25519Pubkey(
+ getAccountInfo: (pubkey: PublicKey) => Promise | null>,
+): Promise {
+ const info = await getAccountInfo(getMXEAccAddress(PID));
+ if (!info) throw new Error('anonbeta1 MXE account not found on this cluster');
+ if (info.data.length < MXE_X25519_PUBKEY_OFFSET + 32) {
+ throw new Error('MXE account smaller than expected — Arcium layout may have changed');
+ }
+ const key = new Uint8Array(
+ info.data.subarray(MXE_X25519_PUBKEY_OFFSET, MXE_X25519_PUBKEY_OFFSET + 32),
+ );
+ // Offset is verified against the deployed MXE; guard against a silent wrong
+ // read (all-zero) that would otherwise surface as an opaque MPC decrypt
+ // failure far downstream.
+ if (key.every((b) => b === 0)) {
+ throw new Error(
+ `MXE x25519 pubkey read as all-zero at offset ${MXE_X25519_PUBKEY_OFFSET} — Arcium MXE account layout likely changed`,
+ );
+ }
+ return key;
+}
+
+type Meta = { pubkey: PublicKey; isSigner: boolean; isWritable: boolean };
+const m = (pubkey: PublicKey, isSigner: boolean, isWritable: boolean): Meta => ({ pubkey, isSigner, isWritable });
+
+/** The shared Arcium account set used by queue_computation instructions. */
+function arciumAccounts(compDefOff: number, computationOffsetLe8: Uint8Array) {
+ return {
+ mxe: getMXEAccAddress(PID),
+ mempool: getMempoolAccAddress(CLUSTER_OFFSET),
+ execPool: getExecutingPoolAccAddress(CLUSTER_OFFSET),
+ computation: getComputationAccAddress(CLUSTER_OFFSET, computationOffsetLe8),
+ compDef: getCompDefAccAddress(PID, compDefOff),
+ cluster: getClusterAccAddress(CLUSTER_OFFSET),
+ };
+}
+
+export interface RegisterBeaconArgs {
+ operator: PublicKey;
+ computationOffset: bigint;
+ encryptedRnsDestHash: Uint8Array; // [u8;32]
+ encryptedRegionCode: Uint8Array; // [u8;32]
+ nonce: bigint; // u128
+ x25519Pubkey: Uint8Array; // [u8;32]
+ regionCode: Uint8Array; // [u8;4], all printable ASCII
+ capabilitiesBitmap: number; // u32
+}
+
+export function buildRegisterBeaconInstruction(args: RegisterBeaconArgs): TransactionInstruction {
+ const compOffLe8 = leBytes(args.computationOffset, 8);
+ const a = arciumAccounts(compDefOffset(CIRCUITS.beaconBind), compOffLe8);
+ const data = Buffer.concat([
+ DISCRIMINATORS.registerBeaconPrivate,
+ compOffLe8,
+ args.encryptedRnsDestHash,
+ args.encryptedRegionCode,
+ leBytes(args.nonce, 16),
+ args.x25519Pubkey,
+ args.regionCode,
+ u32le(args.capabilitiesBitmap),
+ ].map((x) => Buffer.from(x)));
+ const keys: Meta[] = [
+ m(args.operator, true, true),
+ m(beaconPda(args.operator), false, true),
+ m(privateBindingPda(args.operator), false, true),
+ m(arciumSignerPda(), false, true),
+ m(a.mxe, false, false), m(a.mempool, false, true), m(a.execPool, false, true),
+ m(a.computation, false, true), m(a.compDef, false, false), m(a.cluster, false, true),
+ m(MXE_FEE_POOL, false, true), m(MXE_CLOCK, false, true),
+ m(SystemProgram.programId, false, false), m(getArciumProgramId(), false, false),
+ ];
+ return new TransactionInstruction({ programId: PID, keys, data });
+}
+
+export interface InitRelayStatsArgs {
+ operator: PublicKey;
+ initialCiphertext: Uint8Array; // [u8;32]
+ initialNonce: bigint; // u128
+ x25519Pubkey: Uint8Array; // [u8;32]
+}
+
+export function buildInitRelayStatsInstruction(args: InitRelayStatsArgs): TransactionInstruction {
+ const data = Buffer.concat([
+ DISCRIMINATORS.initRelayStats,
+ Buffer.from(args.initialCiphertext),
+ Buffer.from(leBytes(args.initialNonce, 16)),
+ Buffer.from(args.x25519Pubkey),
+ ]);
+ const keys: Meta[] = [
+ m(args.operator, true, true),
+ m(beaconPda(args.operator), false, false),
+ m(privateBindingPda(args.operator), false, false),
+ m(relayStatsPda(args.operator), false, true),
+ m(SystemProgram.programId, false, false),
+ ];
+ return new TransactionInstruction({ programId: PID, keys, data });
+}
+
+export interface RecordRelayArgs {
+ operator: PublicKey;
+ computationOffset: bigint;
+ relayEventHash: Uint8Array; // [u8;32], non-zero
+ x25519Pubkey: Uint8Array; // [u8;32]
+}
+
+export function buildRecordRelayInstruction(args: RecordRelayArgs): TransactionInstruction {
+ const compOffLe8 = leBytes(args.computationOffset, 8);
+ const a = arciumAccounts(compDefOffset(CIRCUITS.relayIncrement), compOffLe8);
+ const data = Buffer.concat([
+ DISCRIMINATORS.recordRelay,
+ compOffLe8,
+ Buffer.from(args.relayEventHash),
+ Buffer.from(args.x25519Pubkey),
+ ]);
+ const keys: Meta[] = [
+ m(args.operator, true, true),
+ m(beaconPda(args.operator), false, true),
+ m(args.operator, false, false), // operator (address == payer)
+ m(relayStatsPda(args.operator), false, true),
+ m(arciumSignerPda(), false, true),
+ m(a.mxe, false, false), m(a.mempool, false, true), m(a.execPool, false, true),
+ m(a.computation, false, true), m(a.compDef, false, false), m(a.cluster, false, true),
+ m(MXE_FEE_POOL, false, true), m(MXE_CLOCK, false, true),
+ m(SystemProgram.programId, false, false), m(getArciumProgramId(), false, false),
+ ];
+ return new TransactionInstruction({ programId: PID, keys, data });
+}
+
+// ── account decoders ────────────────────────────────────────────────────────────
+export const decodeBindingVerified = (data: Buffer | Uint8Array): boolean =>
+ data[BEACON_REGISTRY_OFFSETS.bindingVerified] === 1;
+
+export interface DecodedRelayStats {
+ encryptedCount: number[]; // [u8;32]
+ nonce: bigint; // u128
+ pendingRelayHash: Uint8Array; // [u8;32]
+ hasPending: boolean;
+}
+
+export function decodeRelayStats(data: Buffer | Uint8Array): DecodedRelayStats {
+ const b = Buffer.from(data);
+ const o = RELAY_STATS_OFFSETS;
+ const nonceBytes = b.subarray(o.nonce, o.nonce + 16);
+ let nonce = 0n;
+ for (let i = 15; i >= 0; i--) nonce = (nonce << 8n) | BigInt(nonceBytes[i]);
+ const pending = b.subarray(o.pendingRelayHash, o.pendingRelayHash + 32);
+ return {
+ encryptedCount: Array.from(b.subarray(o.encryptedCount, o.encryptedCount + 32)),
+ nonce,
+ pendingRelayHash: new Uint8Array(pending),
+ hasPending: pending.some((x) => x !== 0),
+ };
+}
diff --git a/mobile_app/src/services/arcium/beaconKeys.ts b/mobile_app/src/services/arcium/beaconKeys.ts
new file mode 100644
index 00000000..99169283
--- /dev/null
+++ b/mobile_app/src/services/arcium/beaconKeys.ts
@@ -0,0 +1,43 @@
+/**
+ * Operator x25519 keypair for Arcium beacon privacy. The secret derives the
+ * shared secret used to encrypt the RNS destination and decrypt relay stats —
+ * it never leaves the device (stored in the OS keystore via SecureStore).
+ */
+// eslint-disable-next-line import/extensions
+import { x25519 } from './vendor/arciumCrypto';
+import { SecureKeys, secureGet, secureSet, secureDelete } from '@/src/storage';
+
+const toHex = (b: Uint8Array): string => Buffer.from(b).toString('hex');
+const fromHex = (h: string): Uint8Array => Uint8Array.from(Buffer.from(h, 'hex'));
+
+export interface BeaconX25519Key {
+ secret: Uint8Array; // 32 bytes — keep in memory only as long as needed
+ publicKey: Uint8Array; // 32 bytes — safe to expose
+}
+
+/**
+ * Returns the operator's persisted x25519 keypair, generating + storing one on
+ * first use. The same key must be reused across register / init / record so the
+ * MPC re-encrypts results the operator can later decrypt.
+ */
+export async function getOrCreateBeaconX25519Key(): Promise {
+ const existing = await secureGet(SecureKeys.BEACON_X25519_SECRET);
+ if (existing) {
+ const secret = fromHex(existing);
+ return { secret, publicKey: x25519.getPublicKey(secret) };
+ }
+ const secret = x25519.utils.randomSecretKey();
+ await secureSet(SecureKeys.BEACON_X25519_SECRET, toHex(secret));
+ return { secret, publicKey: x25519.getPublicKey(secret) };
+}
+
+/** Public key only — for status displays that don't need the secret. */
+export async function getBeaconX25519PublicKey(): Promise {
+ const existing = await secureGet(SecureKeys.BEACON_X25519_SECRET);
+ return existing ? x25519.getPublicKey(fromHex(existing)) : null;
+}
+
+/** Wipe the operator key (e.g. on wallet reset). */
+export async function deleteBeaconX25519Key(): Promise {
+ await secureDelete(SecureKeys.BEACON_X25519_SECRET);
+}
diff --git a/mobile_app/src/services/arcium/constants.ts b/mobile_app/src/services/arcium/constants.ts
new file mode 100644
index 00000000..a21a720f
--- /dev/null
+++ b/mobile_app/src/services/arcium/constants.ts
@@ -0,0 +1,47 @@
+import { PublicKey } from '@solana/web3.js';
+
+/**
+ * anonbeta1 Arcium program (devnet). Values verified on-chain — see
+ * the arcium integration notes (LOCAL_NOTES) and the proven harness in contract/scripts.
+ */
+export const ANONBETA1_PROGRAM_ID = new PublicKey(
+ 'anon7uu8UtVoFgS8GCSfw2RqyphJhkN3xEjgPwznYDe',
+);
+
+/** Arcium cluster offset for this MXE (read from MXE.cluster on devnet). */
+export const CLUSTER_OFFSET = 456;
+
+/** Fixed Arcium accounts (constant addresses from the program IDL). */
+export const MXE_FEE_POOL = new PublicKey('G2sRWJvi3xoyh5k2gY49eG9L8YhAEWQPtNb1zb1GXTtC');
+export const MXE_CLOCK = new PublicKey('7EbMUTLo5DjdzbN7s8BXeZwXzEwNQb1hScfRvWg8a6ot');
+
+/**
+ * Byte offset of the MXE x25519 encryption pubkey inside the MXE account.
+ * Verified against getMXEPublicKey() output on devnet.
+ */
+export const MXE_X25519_PUBKEY_OFFSET = 95;
+
+/** Anchor instruction discriminators (from target/idl/anonbeta1.json). */
+export const DISCRIMINATORS = {
+ registerBeaconPrivate: Uint8Array.from([124, 235, 154, 5, 22, 208, 60, 219]),
+ initRelayStats: Uint8Array.from([217, 238, 139, 15, 155, 33, 60, 120]),
+ recordRelay: Uint8Array.from([215, 191, 71, 143, 57, 225, 37, 128]),
+} as const;
+
+/** Circuit names for comp-def offset derivation. */
+export const CIRCUITS = {
+ beaconBind: 'beacon_bind',
+ relayIncrement: 'relay_increment',
+} as const;
+
+/** Account-data byte offsets for manual decode (post 8-byte discriminator). */
+export const BEACON_REGISTRY_OFFSETS = {
+ bindingVerified: 121, // bool (1)
+ settlementCount: 138, // u64 (8)
+} as const;
+
+export const RELAY_STATS_OFFSETS = {
+ encryptedCount: 73, // [u8; 32]
+ nonce: 105, // u128 (16, LE)
+ pendingRelayHash: 193, // [u8; 32]
+} as const;
diff --git a/mobile_app/src/services/arcium/index.ts b/mobile_app/src/services/arcium/index.ts
new file mode 100644
index 00000000..c590a4ab
--- /dev/null
+++ b/mobile_app/src/services/arcium/index.ts
@@ -0,0 +1,4 @@
+export * from './beaconClient';
+export * from './beaconKeys';
+export * as beaconInstructions from './beaconInstructions';
+export { ANONBETA1_PROGRAM_ID, CLUSTER_OFFSET } from './constants';
diff --git a/mobile_app/src/services/arcium/selfTest.ts b/mobile_app/src/services/arcium/selfTest.ts
new file mode 100644
index 00000000..7b42fd6e
--- /dev/null
+++ b/mobile_app/src/services/arcium/selfTest.ts
@@ -0,0 +1,63 @@
+/**
+ * In-app crypto + encoding self-test. Runs the same assertions as
+ * __tests__/crypto.test.mjs but inside the React Native (Hermes) runtime, so we
+ * can confirm on-device that the vendored RescueCipher + instruction builders
+ * behave identically to Node/the SDK — the one thing off-device tests can't cover.
+ * Pure: no wallet, no network. Safe to run without a connected wallet.
+ */
+import { PublicKey } from '@solana/web3.js';
+// eslint-disable-next-line import/extensions
+import { RescueCipher, getMXEAccAddress } from './vendor/arciumCrypto';
+import {
+ buildRegisterBeaconInstruction, buildRecordRelayInstruction, compDefOffset, leBytes,
+} from './beaconInstructions';
+import { ANONBETA1_PROGRAM_ID, DISCRIMINATORS } from './constants';
+
+export interface SelfTestResult {
+ pass: boolean;
+ lines: { name: string; ok: boolean }[];
+}
+
+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],
+];
+
+export function runCryptoSelfTest(): SelfTestResult {
+ const lines: { name: string; ok: boolean }[] = [];
+ const add = (name: string, ok: boolean) => lines.push({ name, ok });
+
+ try {
+ 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);
+ add('RescueCipher == golden vector', JSON.stringify(ct) === JSON.stringify(GOLDEN));
+ const dec = new RescueCipher(shared).decrypt(ct, nonce);
+ add('RescueCipher decrypt round-trip', dec[0] === 123456789n && dec[1] === 987654321n);
+
+ add('getMXEAccAddress', getMXEAccAddress(ANONBETA1_PROGRAM_ID).toBase58() === '6EiE6YSJ99qhq3bTEM8CtBqmdmBZHsMm56NZtRJ5shJL');
+ add('beacon_bind offset', compDefOffset('beacon_bind') === 287562432);
+ add('relay_increment offset', compDefOffset('relay_increment') === 1084638176);
+
+ 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,
+ });
+ add('register discriminator', Buffer.from(reg.data.subarray(0, 8)).equals(Buffer.from(DISCRIMINATORS.registerBeaconPrivate)));
+ add('register data length 136', reg.data.length === 136);
+ add('register account count 14', reg.keys.length === 14);
+
+ const rec = buildRecordRelayInstruction({ operator: op, computationOffset: 2n, relayEventHash: new Uint8Array(32).fill(1), x25519Pubkey: new Uint8Array(32) });
+ add('record discriminator', Buffer.from(rec.data.subarray(0, 8)).equals(Buffer.from(DISCRIMINATORS.recordRelay)));
+ add('record data length 80', rec.data.length === 80);
+ add('record account count 15', rec.keys.length === 15);
+
+ add('leBytes u64', Buffer.from(leBytes(0x0102030405060708n, 8)).toString('hex') === '0807060504030201');
+ } catch (e) {
+ add(`threw: ${e instanceof Error ? e.message : String(e)}`, false);
+ }
+
+ return { pass: lines.every((l) => l.ok), lines };
+}
diff --git a/mobile_app/src/services/arcium/vendor/arciumCrypto.ts b/mobile_app/src/services/arcium/vendor/arciumCrypto.ts
new file mode 100644
index 00000000..81a7dd6e
--- /dev/null
+++ b/mobile_app/src/services/arcium/vendor/arciumCrypto.ts
@@ -0,0 +1,1146 @@
+// @ts-nocheck
+/* eslint-disable */
+// VENDORED from @arcium-hq/client@0.9.3 — RescueCipher + Arcium PDA derivations only.
+// node:crypto/fs/anchor stripped; @noble paths adapted for v2; validated byte-for-byte
+// vs the real SDK (see contract/scripts/vendor/validate-vendor.mjs + validate-v2.test.mjs).
+// DO NOT hand-edit. Re-vendor from the proven module if @arcium-hq/client changes.
+/* VENDORED from @arcium-hq/client@0.9.3 (readable build/index.mjs).
+ * Cipher + PDA derivations only. node:crypto/fs/anchor removed; sha256 -> @noble.
+ * Validated byte-for-byte against the real SDK (scripts/validate-vendor.mjs).
+ * x25519 is used directly from @noble/curves by callers. */
+import { ed25519 } from '@noble/curves/ed25519.js';
+export { x25519 } from '@noble/curves/ed25519.js';
+export { randomBytes } from '@noble/hashes/utils.js';
+import { invert, mod, isNegativeLE, pow2 } from '@noble/curves/abstract/modular.js';
+import { shake256, sha3_512 } from '@noble/hashes/sha3.js';
+import { sha256 as _nobleSha256 } from '@noble/hashes/sha2.js';
+import { randomBytes } from '@noble/hashes/utils.js';
+import { PublicKey } from '@solana/web3.js';
+
+function sha256(byteArrays) {
+ const h = _nobleSha256.create();
+ for (const b of byteArrays) h.update(Uint8Array.from(b));
+ return Buffer.from(h.digest());
+}
+const CURVE25519_SCALAR_FIELD_MODULUS = ed25519.Point.Fn.ORDER;
+/**
+ * Generate a random value within the field bound by q.
+ * @param q - Upper bound (exclusive) for the random value.
+ * @returns Random bigint value between 0 and q-1.
+ */
+function generateRandomFieldElem(q) {
+ const byteLength = (q.toString(2).length + 7) >> 3;
+ let r;
+ do {
+ const randomBuffer = randomBytes(byteLength);
+ r = BigInt(`0x${randomBuffer.toString('hex')}`);
+ } while (r >= q);
+ return r;
+}
+/**
+ * Compute the positive modulo of a over m.
+ * @param a - Dividend.
+ * @param m - Modulus.
+ * @returns Positive remainder of a mod m.
+ */
+function positiveModulo(a, m) {
+ return ((a % m) + m) % m;
+}
+/**
+ * Serialize a bigint to a little-endian Uint8Array of the specified length.
+ * @param val - Bigint value to serialize.
+ * @param lengthInBytes - Desired length of the output array.
+ * @returns Serialized value as a Uint8Array.
+ * @throws Error if the value is too large for the specified length.
+ */
+function serializeLE(val, lengthInBytes) {
+ const result = new Uint8Array(lengthInBytes);
+ let tempVal = val;
+ for (let i = 0; i < lengthInBytes; i++) {
+ result[i] = Number(tempVal & BigInt(255));
+ tempVal >>= BigInt(8);
+ }
+ if (tempVal > BigInt(0)) {
+ throw new Error(`Value ${val} is too large for the byte length ${lengthInBytes}`);
+ }
+ return result;
+}
+/**
+ * Deserialize a little-endian Uint8Array to a bigint.
+ * @param bytes - Uint8Array to deserialize.
+ * @returns Deserialized bigint value.
+ */
+function deserializeLE(bytes) {
+ let result = BigInt(0);
+ for (let i = 0; i < bytes.length; i++) {
+ result |= BigInt(bytes[i]) << (BigInt(i) * BigInt(8));
+ }
+ return result;
+}
+// GENERAL
+/**
+ * Compute the SHA-256 hash of an array of Uint8Arrays.
+ * @param byteArrays - Arrays to hash.
+ * @returns SHA-256 hash as a Buffer.
+ */
+function toBinLE(x, binSize) {
+ const res = [];
+ for (let i = 0; i < binSize; ++i) {
+ res.push(ctSignBit(x, BigInt(i)));
+ }
+ return res;
+}
+/**
+ * Convert an array of bits (least significant to most significant, in 2's complement representation) to a bigint.
+ * @param xBin - Array of bits to convert.
+ * @returns Bigint represented by the bit array.
+ */
+function fromBinLE(xBin) {
+ let res = 0n;
+ for (let i = 0; i < xBin.length - 1; ++i) {
+ res |= BigInt(xBin[i]) << BigInt(i);
+ }
+ return res - (BigInt(xBin[xBin.length - 1]) << BigInt(xBin.length - 1));
+}
+/**
+ * Binary adder between x and y (assumes xBin and yBin are of the same length and large enough to represent the sum).
+ * @param xBin - First operand as a bit array.
+ * @param yBin - Second operand as a bit array.
+ * @param carryIn - Initial carry-in value.
+ * @param binSize - Number of bits to use in the operation.
+ * @returns Sum as a bit array.
+ */
+function adder(xBin, yBin, carryIn, binSize) {
+ const res = [];
+ let carry = carryIn;
+ for (let i = 0; i < binSize; ++i) {
+ // res[i] = xBin[i] XOR yBin[i] XOR carry
+ const yXorCarry = yBin[i] !== carry;
+ res.push(xBin[i] !== yXorCarry);
+ // newCarry = (xBin[i] AND yBin[i]) XOR (xBin[i] AND carry) XOR (yBin[i] AND carry)
+ // = (yBin[i] XOR carry) ? xBin[i] : yBin[i]
+ const newCarry = yBin[i] !== (yXorCarry && (xBin[i] !== yBin[i]));
+ carry = newCarry;
+ }
+ return res;
+}
+/**
+ * Constant-time addition of two bigints, using 2's complement representation.
+ * @param x - First operand.
+ * @param y - Second operand.
+ * @param binSize - Number of bits to use in the operation.
+ * @returns Sum as a bigint.
+ */
+function ctAdd(x, y, binSize) {
+ const resBin = adder(toBinLE(x, binSize), toBinLE(y, binSize), false, binSize);
+ return fromBinLE(resBin);
+}
+/**
+ * Constant-time subtraction of two bigints, using 2's complement representation.
+ * @param x - First operand.
+ * @param y - Second operand.
+ * @param binSize - Number of bits to use in the operation.
+ * @returns Difference as a bigint.
+ */
+function ctSub(x, y, binSize) {
+ const yBin = toBinLE(y, binSize);
+ const yBinNot = [];
+ for (let i = 0; i < binSize; ++i) {
+ yBinNot.push(yBin[i] === false);
+ }
+ const resBin = adder(toBinLE(x, binSize), yBinNot, true, binSize);
+ return fromBinLE(resBin);
+}
+/**
+ * Return the sign bit of a bigint in constant time.
+ * @param x - Bigint to check.
+ * @param binSize - Bit position to check (typically the highest bit).
+ * @returns True if the sign bit is set, false otherwise.
+ */
+function ctSignBit(x, binSize) {
+ return ((x >> binSize) & 1n) === 1n;
+}
+/**
+ * Constant-time less-than comparison for two bigints.
+ * @param x - First operand.
+ * @param y - Second operand.
+ * @param binSize - Number of bits to use in the operation.
+ * @returns True if x < y, false otherwise.
+ */
+function ctLt(x, y, binSize) {
+ return ctSignBit(ctSub(x, y, binSize), binSize);
+}
+/**
+ * Constant-time select between two bigints based on a boolean condition.
+ * @param b - Condition; if true, select x, otherwise select y.
+ * @param x - Value to select if b is true.
+ * @param y - Value to select if b is false.
+ * @param binSize - Number of bits to use in the operation.
+ * @returns Selected bigint.
+ */
+function ctSelect(b, x, y, binSize) {
+ return ctAdd(y, BigInt(b) * (ctSub(x, y, binSize)), binSize);
+}
+/**
+ * Check if a bigint fits in the range -2^binSize <= x < 2^binSize.
+ * Not constant-time for arbitrary x, but is constant-time for all inputs for which the function returns true.
+ * If you assert your inputs satisfy verifyBinSize(x, binSize), you need not care about the non constant-timeness of this function.
+ * @param x - Bigint to check.
+ * @param binSize - Number of bits to use in the check.
+ * @returns True if x fits in the range, false otherwise.
+ */
+function verifyBinSize(x, binSize) {
+ const bin = (x >> binSize).toString(2);
+ return bin === '0' || bin === '-1';
+}
+
+/**
+ * Check if code is running in a browser environment.
+ * @returns true if window object exists, false otherwise.
+ */
+function isBrowser() {
+ return (
+ // eslint-disable-next-line no-prototype-builtins
+ typeof window !== 'undefined' && !window.process?.hasOwnProperty('type'));
+}
+/**
+ * Conditionally logs a message if logging is enabled.
+ * @param log - Whether to output the log.
+ * @param args - Arguments to pass to console.log.
+ */
+function optionalLog(log, ...args) {
+ if (log) {
+ // eslint-disable-next-line no-console
+ console.log(...args);
+ }
+}
+/**
+ * Calculate the minimum number of bits needed to represent a value.
+ * Formula: floor(log2(max)) + 1 for unsigned, +1 for signed, +1 for diff of two negatives.
+ * @param max - Bigint value to measure.
+ * @returns Number of bits required.
+ */
+function getBinSize(max) {
+ // floor(log2(max)) + 1 to represent unsigned elements, a +1 for signed elements
+ // and another +1 to account for the diff of two negative elements
+ return BigInt(Math.floor(Math.log2(Number(max)))) + 3n;
+}
+/**
+ * Number of mantissa bits for double-precision floating point values.
+ */
+const DOUBLE_PRECISION_MANTISSA = 52;
+/**
+ * Encode a value as a bigint suitable for Rescue encryption, handling booleans, bigints, and numbers.
+ * The encoding is performed in constant-time to avoid leaking information through timing side-channels.
+ * Throws if the value is out of the supported range for the field.
+ * @param v - Value to encode (bigint, number, or boolean).
+ * @returns Encoded value as a bigint.
+ */
+function encodeAsRescueEncryptable(v) {
+ if (typeof v === 'boolean') {
+ return v ? 1n : 0n;
+ }
+ if (typeof v === 'bigint') {
+ const binSize = getBinSize(CURVE25519_BASE_FIELD.ORDER - 1n);
+ if (!verifyBinSize(v, binSize - 1n) || ctLt(v, -(CURVE25519_BASE_FIELD.ORDER - 1n), binSize) || !ctLt(v, CURVE25519_BASE_FIELD.ORDER, binSize)) {
+ throw Error(`v must be in the range [${CURVE25519_BASE_FIELD.ORDER - 1n}, ${CURVE25519_BASE_FIELD.ORDER - 1n}]`);
+ }
+ return ctSelect(ctSignBit(v, binSize), ctAdd(v, CURVE25519_BASE_FIELD.ORDER, binSize), v, binSize);
+ }
+ if (typeof v === 'number') {
+ if (v < -3777893186295716e7 || v >= 2 ** 75) {
+ throw new Error('Inputs only supported in the range [-2**75, 2**75)');
+ }
+ const vBigInt = BigInt(Math.round(v * 2 ** DOUBLE_PRECISION_MANTISSA));
+ const binSize = getBinSize(CURVE25519_BASE_FIELD.ORDER - 1n);
+ return ctSelect(ctSignBit(vBigInt, binSize), ctAdd(vBigInt, CURVE25519_BASE_FIELD.ORDER, binSize), vBigInt, binSize);
+ }
+ throw new Error('Invalid type to convert from number to bigint');
+}
+/**
+ * Decode a Rescue-decrypted value back to a signed bigint.
+ * Handle the conversion from field element representation to signed integer.
+ * @param v - Decrypted field element value.
+ * @returns Decoded signed bigint value.
+ */
+function decodeRescueDecryptedToBigInt(v) {
+ const twoInv = (CURVE25519_BASE_FIELD.ORDER + 1n) / 2n;
+ const binSize = getBinSize(CURVE25519_BASE_FIELD.ORDER - 1n);
+ const isLtTwoInv = ctLt(v, twoInv, binSize);
+ return ctSelect(isLtTwoInv, v, ctSub(v, CURVE25519_BASE_FIELD.ORDER, binSize), binSize);
+}
+/**
+ * Decode a Rescue-decrypted value back to a JavaScript number.
+ * Convert from field element representation to a floating-point number.
+ * @param v - Decrypted field element value.
+ * @returns Decoded number value.
+ */
+function decodeRescueDecryptedToNumber(v) {
+ const vSigned = decodeRescueDecryptedToBigInt(v);
+ return Number(vSigned) * 2 ** -DOUBLE_PRECISION_MANTISSA;
+}
+/**
+ * Check if a computation reference is null (all zeros).
+ * @param ref - Computation reference to check.
+ * @returns true if the reference is null, false otherwise.
+ */
+function isNullRef(ref) {
+ const bigZero = new anchor.BN(0);
+ return (ref.computationOffset === bigZero
+ && ref.priorityFee === bigZero);
+}
+
+/**
+ * Matrix operations for MPC field arithmetic.
+ * Used internally by Rescue cipher. Not part of public API.
+ * @internal
+ */
+class Matrix {
+ field;
+ data;
+ constructor(field, data) {
+ this.field = field;
+ const nrows = data.length;
+ const ncols = data[0].length;
+ for (let i = 1; i < nrows; ++i) {
+ if (data[i].length !== ncols) {
+ throw Error('All rows must have same number of columns.');
+ }
+ }
+ this.data = data.map((row) => row.map((c) => field.create(c)));
+ }
+ /**
+ * Matrix multiplication between `this` and `rhs`.
+ */
+ matMul(rhs) {
+ const thisNrows = this.data.length;
+ const thisNcols = this.data[0].length;
+ const rhsNrows = rhs.data.length;
+ const rhsNcols = rhs.data[0].length;
+ if (thisNcols !== rhsNrows) {
+ throw Error(`this.ncols must be equal to rhs.nrows (found ${thisNcols} and ${rhsNrows})`);
+ }
+ const data = [];
+ for (let i = 0; i < thisNrows; ++i) {
+ const row = [];
+ for (let j = 0; j < rhsNcols; ++j) {
+ let c = this.field.ZERO;
+ for (let k = 0; k < thisNcols; ++k) {
+ c = this.field.add(c, this.field.mul(this.data[i][k], rhs.data[k][j]));
+ }
+ row.push(c);
+ }
+ data.push(row);
+ }
+ return new Matrix(this.field, data);
+ }
+ /**
+ * Element-wise addition between `this` and `rhs`.
+ */
+ add(rhs, ct = false) {
+ const thisNrows = this.data.length;
+ const thisNcols = this.data[0].length;
+ const rhsNrows = rhs.data.length;
+ const rhsNcols = rhs.data[0].length;
+ if (thisNrows !== rhsNrows) {
+ throw Error(`this.nrows must be equal to rhs.nrows (found ${thisNrows} and ${rhsNrows})`);
+ }
+ if (thisNcols !== rhsNcols) {
+ throw Error(`this.ncols must be equal to rhs.ncols (found ${thisNcols} and ${rhsNcols})`);
+ }
+ const binSize = getBinSize(this.field.ORDER - 1n);
+ const data = [];
+ for (let i = 0; i < thisNrows; ++i) {
+ const row = [];
+ for (let j = 0; j < thisNcols; ++j) {
+ if (ct) {
+ const sum = ctAdd(this.data[i][j], rhs.data[i][j], binSize);
+ row.push(ctSelect(ctLt(sum, this.field.ORDER, binSize), sum, ctSub(sum, this.field.ORDER, binSize), binSize));
+ }
+ else {
+ row.push(this.field.add(this.data[i][j], rhs.data[i][j]));
+ }
+ }
+ data.push(row);
+ }
+ return new Matrix(this.field, data);
+ }
+ /**
+ * Element-wise subtraction between `this` and `rhs`.
+ */
+ sub(rhs, ct = false) {
+ const thisNrows = this.data.length;
+ const thisNcols = this.data[0].length;
+ const rhsNrows = rhs.data.length;
+ const rhsNcols = rhs.data[0].length;
+ if (thisNrows !== rhsNrows) {
+ throw Error(`this.nrows must be equal to rhs.nrows (found ${thisNrows} and ${rhsNrows})`);
+ }
+ if (thisNcols !== rhsNcols) {
+ throw Error(`this.ncols must be equal to rhs.ncols (found ${thisNcols} and ${rhsNcols})`);
+ }
+ const binSize = getBinSize(this.field.ORDER - 1n);
+ const data = [];
+ for (let i = 0; i < thisNrows; ++i) {
+ const row = [];
+ for (let j = 0; j < thisNcols; ++j) {
+ if (ct) {
+ const diff = ctSub(this.data[i][j], rhs.data[i][j], binSize);
+ row.push(ctSelect(ctSignBit(diff, binSize), ctAdd(diff, this.field.ORDER, binSize), diff, binSize));
+ }
+ else {
+ row.push(this.field.sub(this.data[i][j], rhs.data[i][j]));
+ }
+ }
+ data.push(row);
+ }
+ return new Matrix(this.field, data);
+ }
+ /**
+ * Raises each element of `this` to the power `e`.
+ */
+ pow(e) {
+ const data = [];
+ for (let i = 0; i < this.data.length; ++i) {
+ const row = [];
+ for (let j = 0; j < this.data[0].length; ++j) {
+ row.push(this.field.pow(this.data[i][j], e));
+ }
+ data.push(row);
+ }
+ return new Matrix(this.field, data);
+ }
+ /**
+ * Compute the determinant using Gauss elimination.
+ * Match the determinant implementation in Arcis.
+ */
+ det() {
+ // Ensure the matrix is square
+ const n = this.data.length;
+ if (n === 0 || !this.is_square()) {
+ throw Error('Matrix must be square and non-empty to compute the determinant.');
+ }
+ let det = this.field.ONE;
+ // Clone the data to avoid mutating the original matrix
+ let rows = this.data.map((row) => [...row]);
+ for (let i = 0; i < n; ++i) {
+ // we partition into rows that have a leading zero and rows that don't
+ const lzRows = rows.filter((r) => this.field.is0(r[0]));
+ const nlzRows = rows.filter((r) => !this.field.is0(r[0]));
+ // take pivot element
+ const pivotRow = nlzRows.shift();
+ if (pivotRow === undefined) {
+ // no pivot row implies the rank is less than n i.e. the determinant is zero
+ return this.field.ZERO;
+ }
+ const pivot = pivotRow[0];
+ // multiply pivot onto the determinant
+ det = this.field.mul(det, pivot);
+ // subtract all leading non zero values with the pivot element (forward elimination).
+ const pivotInverse = this.field.inv(pivot);
+ // precomputing pivot row such that the leading value is one. This reduces the number of
+ // multiplications in the forward elimination multiplications by 50%
+ const normalizedPivotRow = pivotRow.map((v) => this.field.mul(pivotInverse, v));
+ // forward elimination with normalized pivot row
+ const nlzRowsProcessed = nlzRows.map((row) => {
+ const lead = row[0];
+ return row.map((value, index) => this.field.sub(value, this.field.mul(lead, normalizedPivotRow[index])));
+ });
+ // concat the reamining rows (without pivot row) and remove the pivot column (all first
+ // elements (i.e. zeros) from the remaining rows).
+ rows = nlzRowsProcessed.concat(lzRows).map((row) => row.slice(1));
+ }
+ return det;
+ }
+ is_square() {
+ const n = this.data.length;
+ for (let i = 1; i < n; ++i) {
+ if (this.data[i].length !== n) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
+/**
+ * Generate random matrix for testing.
+ * @internal
+ */
+function randMatrix(field, nrows, ncols) {
+ const data = [];
+ for (let i = 0; i < nrows; ++i) {
+ const row = [];
+ for (let j = 0; j < ncols; ++j) {
+ row.push(generateRandomFieldElem(field.ORDER));
+ }
+ data.push(row);
+ }
+ return new Matrix(field, data);
+}
+
+/**
+ * Curve25519 base field as an IField instance.
+ */
+const CURVE25519_BASE_FIELD = ed25519.Point.Fp;
+/**
+ * Curve25519 scalar field as an IField instance.
+ */
+const CURVE25519_SCALAR_FIELD = ed25519.Point.Fn;
+// Security level for the block cipher.
+const SECURITY_LEVEL_BLOCK_CIPHER = 128;
+// Security level for the hash function.
+const SECURITY_LEVEL_HASH_FUNCTION = 256;
+// We refer to https://tosc.iacr.org/index.php/ToSC/article/view/8695/8287 for more details.
+/**
+ * Description and parameters for the Rescue cipher or hash function, including round constants, MDS matrix, and key schedule.
+ * See: https://tosc.iacr.org/index.php/ToSC/article/view/8695/8287
+ */
+class RescueDesc {
+ mode;
+ field;
+ // The smallest prime that does not divide p-1.
+ alpha;
+ // The inverse of alpha modulo p-1.
+ alphaInverse;
+ nRounds;
+ m;
+ // A Maximum Distance Separable matrix.
+ mdsMat;
+ // Its inverse.
+ mdsMatInverse;
+ // The round keys, needed for encryption and decryption.
+ roundKeys;
+ /**
+ * Construct a RescueDesc for a given field and mode (cipher or hash).
+ * Initialize round constants, MDS matrix, and key schedule.
+ * @param field - Field to use (e.g., CURVE25519_BASE_FIELD).
+ * @param mode - Mode: block cipher or hash function.
+ */
+ constructor(field, mode) {
+ this.field = field;
+ this.mode = mode;
+ switch (this.mode.kind) {
+ case 'cipher': {
+ this.m = this.mode.key.length;
+ if (this.m < 2) {
+ throw Error(`parameter m must be at least 2 (found ${this.m})`);
+ }
+ break;
+ }
+ case 'hash': {
+ this.m = this.mode.m;
+ break;
+ }
+ default: {
+ this.m = 0;
+ break;
+ }
+ }
+ const alphaAndInverse = getAlphaAndInverse(this.field.ORDER);
+ this.alpha = alphaAndInverse[0];
+ this.alphaInverse = alphaAndInverse[1];
+ this.nRounds = getNRounds(this.field.ORDER, this.mode, this.alpha, this.m);
+ const mdsMatrixAndInverse = getMdsMatrixAndInverse(this.field, this.m);
+ this.mdsMat = mdsMatrixAndInverse[0];
+ this.mdsMatInverse = mdsMatrixAndInverse[1];
+ // generate the round constants using SHAKE256
+ const roundConstants = this.sampleConstants(this.nRounds);
+ switch (this.mode.kind) {
+ case 'cipher': {
+ // do the key schedule
+ this.roundKeys = rescuePermutation(this.mode, this.alpha, this.alphaInverse, this.mdsMat, roundConstants, new Matrix(this.field, toVec(this.mode.key)));
+ break;
+ }
+ case 'hash': {
+ this.roundKeys = roundConstants;
+ break;
+ }
+ default: {
+ this.roundKeys = [];
+ break;
+ }
+ }
+ }
+ /**
+ * Sample round constants for the Rescue permutation, using SHAKE256.
+ * @param nRounds - Number of rounds.
+ * @returns Array of round constant matrices.
+ */
+ sampleConstants(nRounds) {
+ const field = this.field;
+ const m = this.m;
+ // setup randomness
+ // dkLen is the output length from the Keccak instance behind shake.
+ // this is irrelevant for our extendable output function (xof), but still we use
+ // the default value from one-time shake256 hashing, as defined in shake256's definition
+ // in noble-hashes-sha3.
+ const hasher = shake256.create({ dkLen: 256 / 8 });
+ // buffer to create field elements from bytes
+ // we add 16 bytes to get a distribution statistically close to uniform
+ const bufferLen = Math.ceil(field.BITS / 8) + 16;
+ switch (this.mode.kind) {
+ case 'cipher': {
+ hasher.update(new TextEncoder().encode('encrypt everything, compute anything'));
+ const rFieldArray = Array.from({ length: m * m + 2 * m }, () => {
+ // create field element from the shake hash
+ const randomness = hasher.xof(bufferLen);
+ // we need not check whether the obtained field element f is in any subgroup,
+ // because we use only prime fields (i.e. there are no subgroups)
+ return field.create(deserializeLE(randomness));
+ });
+ // create matrix and vectors
+ const matData = Array.from({ length: m }, () => rFieldArray.splice(0, m));
+ let roundConstantMat = new Matrix(field, matData);
+ const initData = Array.from({ length: m }, () => rFieldArray.splice(0, 1));
+ const initialRoundConstant = new Matrix(field, initData);
+ const roundData = Array.from({ length: m }, () => rFieldArray.splice(0, 1));
+ const roundConstantAffineTerm = new Matrix(field, roundData);
+ // check for inversability
+ while (field.is0(roundConstantMat.det())) {
+ // resample matrix
+ const resampleArray = Array.from({ length: m * m }, () => {
+ const randomness = hasher.xof(bufferLen);
+ return field.create(deserializeLE(randomness));
+ });
+ const resampleData = Array.from({ length: m }, () => resampleArray.splice(0, m));
+ roundConstantMat = new Matrix(field, resampleData);
+ }
+ const roundConstants = [initialRoundConstant];
+ for (let r = 0; r < 2 * this.nRounds; ++r) {
+ roundConstants.push(roundConstantMat.matMul(roundConstants[r]).add(roundConstantAffineTerm));
+ }
+ return roundConstants;
+ }
+ case 'hash': {
+ hasher.update(new TextEncoder().encode(`Rescue-XLIX(${this.field.ORDER},${m},${this.mode.capacity},${SECURITY_LEVEL_HASH_FUNCTION})`));
+ // this.permute requires an odd number of round keys
+ // prepending a 0 matrix makes it equivalent to Algorithm 3 from https://eprint.iacr.org/2020/1143.pdf
+ const zeros = [];
+ for (let i = 0; i < m; ++i) {
+ zeros.push([0n]);
+ }
+ const roundConstants = [new Matrix(field, zeros)];
+ const rFieldArray = Array.from({ length: 2 * m * nRounds }, () => {
+ // create field element from the shake hash
+ const randomness = hasher.xof(bufferLen);
+ // we need not check whether the obtained field element f is in any subgroup,
+ // because we use only prime fields (i.e. there are no subgroups)
+ return field.create(deserializeLE(randomness));
+ });
+ for (let r = 0; r < 2 * nRounds; ++r) {
+ const data = [];
+ for (let i = 0; i < m; ++i) {
+ data.push([rFieldArray[r * m + i]]);
+ }
+ roundConstants.push(new Matrix(field, data));
+ }
+ return roundConstants;
+ }
+ default: return [];
+ }
+ }
+ /**
+ * Apply the Rescue permutation to a state matrix.
+ * @param state - Input state matrix.
+ * @returns Permuted state matrix.
+ */
+ permute(state) {
+ return rescuePermutation(this.mode, this.alpha, this.alphaInverse, this.mdsMat, this.roundKeys, state)[2 * this.nRounds];
+ }
+ /**
+ * Apply the inverse Rescue permutation to a state matrix.
+ * @param state - Input state matrix.
+ * @returns Inverse-permuted state matrix.
+ */
+ permuteInverse(state) {
+ return rescuePermutationInverse(this.mode, this.alpha, this.alphaInverse, this.mdsMatInverse, this.roundKeys, state)[2 * this.nRounds];
+ }
+}
+/**
+ * Find the smallest prime alpha that does not divide p-1, and compute its inverse modulo p-1.
+ * The alpha parameter is used in the Rescue permutation for exponentiation operations.
+ * @param p - Field modulus (prime number).
+ * @returns Tuple [alpha, alphaInverse] where alpha is the prime and alphaInverse is its modular inverse.
+ * @throws Error if no suitable prime alpha is found.
+ */
+function getAlphaAndInverse(p) {
+ const pMinusOne = p - 1n;
+ let alpha = 0n;
+ for (const a of [2n, 3n, 5n, 7n, 11n, 13n, 17n, 19n, 23n, 29n, 31n, 37n, 41n, 43n, 47n]) {
+ if (pMinusOne % a !== 0n) {
+ alpha = a;
+ break;
+ }
+ }
+ if (alpha === 0n) {
+ throw Error('Could not find prime alpha that does not divide p-1.');
+ }
+ const alphaInverse = invert(alpha, pMinusOne);
+ return [alpha, alphaInverse];
+}
+/**
+ * Calculate the number of rounds required for the Rescue permutation based on security analysis.
+ * The number of rounds is determined by analyzing resistance to differential and algebraic attacks.
+ * See: https://tosc.iacr.org/index.php/ToSC/article/view/8695/8287 for the security analysis.
+ * @param p - Field modulus.
+ * @param mode - Rescue mode (cipher or hash).
+ * @param alpha - Prime alpha parameter.
+ * @param m - State size (block size for cipher, total size for hash).
+ * @returns Number of rounds (will be doubled for the full permutation).
+ */
+function getNRounds(p, mode, alpha, m) {
+ function dcon(n) {
+ return Math.floor(0.5 * (Number(alpha) - 1) * m * (n - 1) + 2.0);
+ }
+ function v(n, rate) {
+ return m * (n - 1) + rate;
+ }
+ function binomial(n, k) {
+ function factorial(x) {
+ if (x === 0n || x === 1n) {
+ return 1n;
+ }
+ return x * factorial(x - 1n);
+ }
+ return factorial(BigInt(n)) / (factorial(BigInt(n - k)) * factorial(BigInt(k)));
+ }
+ switch (mode.kind) {
+ case 'cipher': {
+ const l0 = Math.ceil((2 * SECURITY_LEVEL_BLOCK_CIPHER) / ((m + 1) * (Math.log2(Number(p)) - Math.log2(Number(alpha) - 1))));
+ let l1 = 0;
+ if (alpha === 3n) {
+ l1 = Math.ceil((SECURITY_LEVEL_BLOCK_CIPHER + 2) / (4 * m));
+ }
+ else {
+ l1 = Math.ceil((SECURITY_LEVEL_BLOCK_CIPHER + 3) / (5.5 * m));
+ }
+ return 2 * Math.max(l0, l1, 5);
+ }
+ case 'hash': {
+ // get number of rounds for Groebner basis attack
+ const rate = m - mode.capacity;
+ const target = 1n << BigInt(SECURITY_LEVEL_HASH_FUNCTION);
+ let l1 = 1;
+ let tmp = binomial(v(l1, rate) + dcon(l1), v(l1, rate));
+ while (tmp * tmp <= target && l1 <= 23) {
+ l1 += 1;
+ tmp = binomial(v(l1, rate) + dcon(l1), v(l1, rate));
+ }
+ // set a minimum value for sanity and add 50%
+ return Math.ceil(1.5 * Math.max(5, l1));
+ }
+ default: return 0;
+ }
+}
+/**
+ * Build a Cauchy matrix for use as an MDS (Maximum Distance Separable) matrix.
+ * A Cauchy matrix is guaranteed to be invertible and provides optimal diffusion properties.
+ * The matrix is constructed using the formula: M[i][j] = 1/(i + j) for i, j in [1, size].
+ * @param field - Finite field over which to construct the matrix.
+ * @param size - Size of the square matrix.
+ * @returns Cauchy matrix of the specified size.
+ */
+function buildCauchy(field, size) {
+ const data = [];
+ for (let i = 1n; i <= size; ++i) {
+ const row = [];
+ for (let j = 1n; j <= size; ++j) {
+ row.push(field.inv(i + j));
+ }
+ data.push(row);
+ }
+ return new Matrix(field, data);
+}
+/**
+ * Build the inverse of a Cauchy matrix for use as the inverse MDS matrix.
+ * The inverse is computed using a closed-form formula for Cauchy matrix inversion.
+ * @param field - Finite field over which to construct the matrix.
+ * @param size - Size of the square matrix.
+ * @returns Inverse of the Cauchy matrix.
+ */
+function buildInverseCauchy(field, size) {
+ function product(arr) {
+ return arr.reduce((acc, curr) => field.mul(acc, field.create(curr)), field.ONE);
+ }
+ function prime(arr, val) {
+ return product(arr.map((u) => {
+ if (u !== val) {
+ return val - u;
+ }
+ return 1n;
+ }));
+ }
+ const data = [];
+ for (let i = 1n; i <= size; ++i) {
+ const row = [];
+ for (let j = 1n; j <= size; ++j) {
+ const a = product(Array.from({ length: size }, (_, key) => -i - BigInt(1 + key)));
+ const aPrime = prime(Array.from({ length: size }, (_, key) => BigInt(1 + key)), j);
+ const b = product(Array.from({ length: size }, (_, key) => j + BigInt(1 + key)));
+ const bPrime = prime(Array.from({ length: size }, (_, key) => -BigInt(1 + key)), -i);
+ row.push(field.mul(a, field.mul(b, field.mul(field.inv(aPrime), field.mul(field.inv(bPrime), field.inv(-i - j))))));
+ }
+ data.push(row);
+ }
+ return new Matrix(field, data);
+}
+function getMdsMatrixAndInverse(field, m) {
+ const mdsMat = buildCauchy(field, m);
+ const mdsMatInverse = buildInverseCauchy(field, m);
+ return [mdsMat, mdsMatInverse];
+}
+function exponentForEven(mode, alpha, alphaInverse) {
+ switch (mode.kind) {
+ case 'cipher': {
+ return alphaInverse;
+ }
+ case 'hash': {
+ return alpha;
+ }
+ default: return 0n;
+ }
+}
+function exponentForOdd(mode, alpha, alphaInverse) {
+ switch (mode.kind) {
+ case 'cipher': {
+ return alpha;
+ }
+ case 'hash': {
+ return alphaInverse;
+ }
+ default: return 0n;
+ }
+}
+/**
+ * Core Rescue permutation function implementing the cryptographic primitive.
+ * Apply alternating rounds of exponentiation and MDS matrix multiplication with round keys.
+ * The permutation alternates between using alpha and alphaInverse as exponents based on round parity.
+ * This is the fundamental building block for both Rescue cipher and Rescue-Prime hash.
+ * @param mode - Rescue mode (cipher or hash) determining exponent selection.
+ * @param alpha - Prime exponent for even rounds.
+ * @param alphaInverse - Inverse exponent for odd rounds.
+ * @param mdsMat - Maximum Distance Separable matrix for diffusion.
+ * @param subkeys - Array of round key matrices.
+ * @param state - Initial state matrix to permute.
+ * @returns Array of all intermediate states during the permutation.
+ */
+function rescuePermutation(mode, alpha, alphaInverse, mdsMat, subkeys, state) {
+ const exponentEven = exponentForEven(mode, alpha, alphaInverse);
+ const exponentOdd = exponentForOdd(mode, alpha, alphaInverse);
+ const states = [state.add(subkeys[0])];
+ for (let r = 0; r < subkeys.length - 1; ++r) {
+ let s = states[r];
+ if (r % 2 === 0) {
+ s = s.pow(exponentEven);
+ }
+ else {
+ s = s.pow(exponentOdd);
+ }
+ states.push(mdsMat.matMul(s).add(subkeys[r + 1]));
+ }
+ return states;
+}
+function rescuePermutationInverse(mode, alpha, alphaInverse, mdsMatInverse, subkeys, state) {
+ const exponentEven = exponentForEven(mode, alpha, alphaInverse);
+ const exponentOdd = exponentForOdd(mode, alpha, alphaInverse);
+ // the initial state will need to be removed afterwards
+ const states = [state];
+ for (let r = 0; r < subkeys.length - 1; ++r) {
+ let s = states[r];
+ s = mdsMatInverse.matMul(s.sub(subkeys[subkeys.length - 1 - r]));
+ if (r % 2 === 0) {
+ s = s.pow(exponentEven);
+ }
+ else {
+ s = s.pow(exponentOdd);
+ }
+ states.push(s);
+ }
+ states.push(states[states.length - 1].sub(subkeys[0]));
+ states.shift();
+ return states;
+}
+function toVec(data) {
+ const dataVec = [];
+ for (let i = 0; i < data.length; ++i) {
+ dataVec.push([data[i]]);
+ }
+ return dataVec;
+}
+
+/**
+ * The Rescue-Prime hash function, as described in https://eprint.iacr.org/2020/1143.pdf, offering 256 bits
+ * of security against collision, preimage and second-preimage attacks for any field of size at least 102 bits.
+ * We use the sponge construction with fixed rate = 7 and capacity = 5 (i.e., m = 12), and truncate the
+ * output to 5 field elements.
+ */
+class RescuePrimeHash {
+ desc;
+ rate;
+ digestLength;
+ /**
+ * Construct a RescuePrimeHash instance with rate = 7 and capacity = 5.
+ */
+ constructor(field) {
+ this.desc = new RescueDesc(field, { kind: 'hash', m: 12, capacity: 5 });
+ this.rate = 7;
+ this.digestLength = 5;
+ }
+ // This is Algorithm 1 from https://eprint.iacr.org/2020/1143.pdf, though with the padding (see Algorithm 2).
+ // The hash is truncated to digestLength elements.
+ // According to Section 2.2, this offers min(log2(CURVE25519_BASE_FIELD.ORDER) / 2 * min(digestLength, capacity), s)
+ // bits of security against collision, preimage and second-preimage attacks.
+ // The security level is thus of the order of 256 bits for any field of size at least 102 bits.
+ // The rate and capacity are chosen to achieve minimal number of rounds 8.
+ /**
+ * Compute the Rescue-Prime hash of a message, with padding as described in Algorithm 2 of the paper.
+ * @param message - Input message as an array of bigints.
+ * @returns Hash output as an array of bigints (length = digestLength).
+ */
+ digest(message) {
+ // Create a copy and pad message to avoid mutating input parameter
+ const paddedMessage = [...message, 1n];
+ while (paddedMessage.length % this.rate !== 0) {
+ paddedMessage.push(0n);
+ }
+ const zeros = [];
+ for (let i = 0; i < this.desc.m; ++i) {
+ zeros.push([0n]);
+ }
+ let state = new Matrix(this.desc.field, zeros);
+ for (let r = 0; r < paddedMessage.length / this.rate; ++r) {
+ const data = [];
+ for (let i = 0; i < this.rate; ++i) {
+ data[i] = [paddedMessage[r * this.rate + i]];
+ }
+ for (let i = this.rate; i < this.desc.m; ++i) {
+ data[i] = [0n];
+ }
+ const s = new Matrix(this.desc.field, data);
+ state = this.desc.permute(state.add(s, true));
+ }
+ const res = [];
+ for (let i = 0; i < this.digestLength; ++i) {
+ res.push(state.data[i][0]);
+ }
+ return res;
+ }
+}
+
+/**
+ * Block size m for Rescue cipher operations.
+ * Rescue operates on 5-element blocks of field elements.
+ */
+const RESCUE_CIPHER_BLOCK_SIZE = 5;
+/**
+ * The Rescue cipher in Counter (CTR) mode, with a fixed block size m = 5.
+ * See: https://tosc.iacr.org/index.php/ToSC/article/view/8695/8287
+ */
+class RescueCipherCommon {
+ desc;
+ /**
+ * Construct a RescueCipherCommon instance using a shared secret.
+ * The key is derived using RescuePrimeHash and used to initialize the RescueDesc.
+ * @param sharedSecret - Shared secret to derive the cipher key from.
+ */
+ constructor(sharedSecret, field) {
+ if (sharedSecret.length != 32) {
+ throw Error(`sharedSecret must be of length 32 (found ${sharedSecret.length})`);
+ }
+ const hasher = new RescuePrimeHash(field);
+ // In case `field` is different from CURVE25519_BASE_FIELD we need to injectively map sharedSecret
+ // to a vector of elements over `field`.
+ const converted = [];
+ if (field === CURVE25519_BASE_FIELD) {
+ converted.push(deserializeLE(sharedSecret));
+ }
+ else {
+ // We chunk sharedSecret by field.BYTES - 1 and convert.
+ const chunkSize = field.BYTES - 1;
+ const nChunks = Math.ceil(sharedSecret.length / chunkSize);
+ for (let i = 0; i < nChunks; ++i) {
+ converted.push(deserializeLE(sharedSecret.slice(i * chunkSize, (i + 1) * chunkSize)));
+ }
+ }
+ // We follow [Section 4, Option 1.](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-56Cr2.pdf).
+ // For our choice of hash function, we have:
+ // - H_outputBits = hasher.digestLength = RESCUE_CIPHER_BLOCK_SIZE
+ // - max_H_inputBits = arbitrarily long, as the Rescue-Prime hash function is built upon the
+ // sponge construction
+ // - L = RESCUE_CIPHER_BLOCK_SIZE.
+ // Build the vector `counter || Z || FixedInfo` (we only have i = 1, since reps = 1).
+ // For the FixedInfo we simply take L.
+ const counter = [1n, ...converted, BigInt(RESCUE_CIPHER_BLOCK_SIZE)];
+ const rescueKey = hasher.digest(counter);
+ this.desc = new RescueDesc(field, { kind: 'cipher', key: rescueKey });
+ }
+ /**
+ * Encrypt the plaintext vector in Counter (CTR) mode (raw, returns bigints).
+ * @param plaintext - Array of plaintext bigints to encrypt.
+ * @param nonce - 16-byte nonce for CTR mode.
+ * @returns Ciphertext as an array of bigints.
+ * @throws Error if the nonce is not 16 bytes long.
+ */
+ encrypt_raw(plaintext, nonce) {
+ if (nonce.length !== 16) {
+ throw Error(`nonce must be of length 16 (found ${nonce.length})`);
+ }
+ const binSize = getBinSize(this.desc.field.ORDER - 1n);
+ function encryptBatch(desc, ptxt, cntr) {
+ if (cntr.length !== RESCUE_CIPHER_BLOCK_SIZE) {
+ throw Error(`counter must be of length ${RESCUE_CIPHER_BLOCK_SIZE} (found ${cntr.length})`);
+ }
+ const encryptedCounter = desc.permute(new Matrix(desc.field, toVec(cntr)));
+ const ciphertext = [];
+ for (let i = 0; i < ptxt.length; ++i) {
+ if (!verifyBinSize(ptxt[i], binSize - 1n) || ctSignBit(ptxt[i], binSize) || !ctLt(ptxt[i], desc.field.ORDER, binSize)) {
+ throw Error(`plaintext must be non-negative and less than ${desc.field.ORDER}`);
+ }
+ const sum = ctAdd(ptxt[i], encryptedCounter.data[i][0], binSize);
+ ciphertext.push(ctSelect(ctLt(sum, desc.field.ORDER, binSize), sum, ctSub(sum, desc.field.ORDER, binSize), binSize));
+ }
+ return ciphertext;
+ }
+ const nBlocks = Math.ceil(plaintext.length / RESCUE_CIPHER_BLOCK_SIZE);
+ const counter = getCounter(deserializeLE(nonce), nBlocks);
+ const ciphertext = [];
+ for (let i = 0; i < nBlocks; ++i) {
+ const cnt = RESCUE_CIPHER_BLOCK_SIZE * i;
+ const newCiphertext = encryptBatch(this.desc, plaintext.slice(cnt, Math.min(cnt + RESCUE_CIPHER_BLOCK_SIZE, plaintext.length)), counter.slice(cnt, cnt + RESCUE_CIPHER_BLOCK_SIZE));
+ for (let j = 0; j < newCiphertext.length; ++j) {
+ ciphertext.push(newCiphertext[j]);
+ }
+ }
+ return ciphertext;
+ }
+ /**
+ * Encrypt the plaintext vector in Counter (CTR) mode and serialize each block.
+ * @param plaintext - Array of plaintext bigints to encrypt.
+ * @param nonce - 16-byte nonce for CTR mode.
+ * @returns Ciphertext as an array of arrays of numbers (each 32 bytes).
+ */
+ encrypt(plaintext, nonce) {
+ return this.encrypt_raw(plaintext, nonce).map((c) => Array.from(serializeLE(c, 32)));
+ }
+ /**
+ * Decrypt the ciphertext vector in Counter (CTR) mode (raw, expects bigints).
+ * @param ciphertext - Array of ciphertext bigints to decrypt.
+ * @param nonce - 16-byte nonce for CTR mode.
+ * @returns Decrypted plaintext as an array of bigints.
+ * @throws Error if the nonce is not 16 bytes long.
+ */
+ decrypt_raw(ciphertext, nonce) {
+ if (nonce.length !== 16) {
+ throw Error(`nonce must be of length 16 (found ${nonce.length})`);
+ }
+ const binSize = getBinSize(this.desc.field.ORDER - 1n);
+ function decryptBatch(desc, ctxt, cntr) {
+ if (cntr.length !== RESCUE_CIPHER_BLOCK_SIZE) {
+ throw Error(`counter must be of length ${RESCUE_CIPHER_BLOCK_SIZE} (found ${cntr.length})`);
+ }
+ const encryptedCounter = desc.permute(new Matrix(desc.field, toVec(cntr)));
+ const decrypted = [];
+ for (let i = 0; i < ctxt.length; ++i) {
+ const diff = ctSub(ctxt[i], encryptedCounter.data[i][0], binSize);
+ decrypted.push(ctSelect(ctSignBit(diff, binSize), ctAdd(diff, desc.field.ORDER, binSize), diff, binSize));
+ }
+ return decrypted;
+ }
+ const nBlocks = Math.ceil(ciphertext.length / RESCUE_CIPHER_BLOCK_SIZE);
+ const counter = getCounter(deserializeLE(nonce), nBlocks);
+ const decrypted = [];
+ for (let i = 0; i < nBlocks; ++i) {
+ const cnt = RESCUE_CIPHER_BLOCK_SIZE * i;
+ const newDecrypted = decryptBatch(this.desc, ciphertext.slice(cnt, Math.min(cnt + RESCUE_CIPHER_BLOCK_SIZE, ciphertext.length)), counter.slice(cnt, cnt + RESCUE_CIPHER_BLOCK_SIZE));
+ for (let j = 0; j < newDecrypted.length; ++j) {
+ decrypted.push(newDecrypted[j]);
+ }
+ }
+ return decrypted;
+ }
+ /**
+ * Deserialize and decrypt the ciphertext vector in Counter (CTR) mode.
+ * @param ciphertext - Array of arrays of numbers (each 32 bytes) to decrypt.
+ * @param nonce - 16-byte nonce for CTR mode.
+ * @returns Decrypted plaintext as an array of bigints.
+ */
+ decrypt(ciphertext, nonce) {
+ return this.decrypt_raw(ciphertext.map((c) => {
+ if (c.length !== 32) {
+ throw Error(`ciphertext must be of length 32 (found ${c.length})`);
+ }
+ return deserializeLE(Uint8Array.from(c));
+ }), nonce);
+ }
+}
+/**
+ * Generate the counter values for Rescue cipher CTR mode.
+ * @param nonce - Initial nonce as a bigint.
+ * @param nBlocks - Number of blocks to generate counters for.
+ * @returns Array of counter values as bigints.
+ */
+function getCounter(nonce, nBlocks) {
+ const counter = [];
+ for (let i = 0n; i < nBlocks; ++i) {
+ counter.push(nonce);
+ counter.push(i);
+ // Pad to RESCUE_CIPHER_BLOCK_SIZE elements per counter block
+ for (let j = 2; j < RESCUE_CIPHER_BLOCK_SIZE; ++j) {
+ counter.push(0n);
+ }
+ }
+ return counter;
+}
+
+/**
+ * The Rescue cipher over Curve25519's base field in Counter (CTR) mode, with a fixed block size m = 5.
+ * See: https://tosc.iacr.org/index.php/ToSC/article/view/8695/8287
+ */
+class RescueCipher {
+ cipher;
+ /**
+ * Construct a RescueCipher instance using a shared secret.
+ * The key is derived using RescuePrimeHash and used to initialize the RescueDesc.
+ * @param sharedSecret - Shared secret to derive the cipher key from.
+ */
+ constructor(sharedSecret) {
+ this.cipher = new RescueCipherCommon(sharedSecret, CURVE25519_BASE_FIELD);
+ }
+ /**
+ * Encrypt the plaintext vector in Counter (CTR) mode and serialize each block.
+ * @param plaintext - Array of plaintext bigints to encrypt.
+ * @param nonce - 16-byte nonce for CTR mode.
+ * @returns Ciphertext as an array of arrays of numbers (each 32 bytes).
+ */
+ encrypt(plaintext, nonce) {
+ return this.cipher.encrypt(plaintext, nonce);
+ }
+ /**
+ * Deserialize and decrypt the ciphertext vector in Counter (CTR) mode.
+ * @param ciphertext - Array of arrays of numbers (each 32 bytes) to decrypt.
+ * @param nonce - 16-byte nonce for CTR mode.
+ * @returns Decrypted plaintext as an array of bigints.
+ */
+ decrypt(ciphertext, nonce) {
+ return this.cipher.decrypt(ciphertext, nonce);
+ }
+}
+
+
+// ── Arcium account derivations (vendored) ──────────────────────────────────
+const ARX_PROGRAM_ADDR = 'Arcj82pX7HxYKLR92qvgZUAd7vGS1k4hQvAFcPATFdEQ';
+const OFFSET_BUFFER_SIZE = 4, COMP_DEF_OFFSET_SIZE = 4;
+const COMPUTATION_ACC_SEED='ComputationAccount', MEMPOOL_ACC_SEED='Mempool', EXEC_POOL_ACC_SEED='Execpool';
+const CLUSTER_ACC_SEED='Cluster', MXE_ACCOUNT_SEED='MXEAccount', COMP_DEF_ACC_SEED='ComputationDefinitionAccount';
+export function getArciumProgramId(){ return new PublicKey(ARX_PROGRAM_ADDR); }
+function pda(seeds){ return PublicKey.findProgramAddressSync(seeds, getArciumProgramId())[0]; }
+export function getArciumAccountBaseSeed(name){ return Buffer.from(name,'utf-8'); }
+export function getCompDefAccOffset(circuitName){ return sha256([Buffer.from(circuitName,'utf-8')]).slice(0, COMP_DEF_OFFSET_SIZE); }
+function off4(n){ const b=Buffer.alloc(OFFSET_BUFFER_SIZE); b.writeUInt32LE(n,0); return b; }
+export function getMXEAccAddress(p){ return pda([Buffer.from(MXE_ACCOUNT_SEED), p.toBuffer()]); }
+export function getCompDefAccAddress(p,o){ return pda([Buffer.from(COMP_DEF_ACC_SEED), p.toBuffer(), off4(o)]); }
+export function getComputationAccAddress(cl,coLe8){ return pda([Buffer.from(COMPUTATION_ACC_SEED), off4(cl), Buffer.from(coLe8)]); }
+export function getMempoolAccAddress(cl){ return pda([Buffer.from(MEMPOOL_ACC_SEED), off4(cl)]); }
+export function getExecutingPoolAccAddress(cl){ return pda([Buffer.from(EXEC_POOL_ACC_SEED), off4(cl)]); }
+export function getClusterAccAddress(cl){ return pda([Buffer.from(CLUSTER_ACC_SEED), off4(cl)]); }
+export { RescueCipher, deserializeLE, serializeLE };
diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts
index 85132316..cdfc3456 100644
--- a/mobile_app/src/services/sendTransaction.ts
+++ b/mobile_app/src/services/sendTransaction.ts
@@ -350,7 +350,7 @@ async function buildSplTransferTransaction({
return tx;
}
-async function signAndSubmitTransaction({
+export async function signAndSubmitTransaction({
walletAdapter,
rpcAdapter,
tx,
diff --git a/mobile_app/src/storage/index.ts b/mobile_app/src/storage/index.ts
index b4b0e883..e4cb25ba 100644
--- a/mobile_app/src/storage/index.ts
+++ b/mobile_app/src/storage/index.ts
@@ -36,6 +36,9 @@ export const SecureKeys = {
BEACON_KEYPAIR_HEX: 'beacon_keypair_hex',
// Beacon ed25519 public key (hex) — safe to expose in state/UI.
BEACON_PUBKEY_HEX: 'beacon_pubkey_hex',
+ // Arcium beacon-operator x25519 secret key (hex, 32 bytes) — derives the
+ // shared secret for encrypting/decrypting private beacon binding + relay stats.
+ BEACON_X25519_SECRET: 'arcium_x25519_secret_v1',
} as const;
export const PrefKeys = {