From 484c98cc5b348ea6558eff2d7b23d173e61dd9d2 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:31:04 -0400 Subject: [PATCH 1/6] fix(tour): the practice sandbox is at the bridge, not in dsmClient's names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Practice mode swapped dsmClient's properties and refused the ones whose names matched a list of verbs. A screen that imported a write directly never saw the swap: TokenCreationDialog's createToken (a real token, the real ERA fee burned), AccountsScreen's burnToken / forgetToken / addTokenByAnchor, every SoFi action (trade, route, createVault, close, resolve, relay), the lock's configure_lock and the NFC and recovery services all reached the real wallet while the tour ran. The verb list also missed trade, route, resolve, relay, enable, activate and complete. Owner ruling 2026-10-01: "make the bridge-level practice gate the authoritative safety boundary, and treat the current dsmClient interception as convenience behavior only… While practice mode is active, no state-changing Rust call reaches the real wallet unless it is explicitly designated as a practice-safe simulated action." - bridge/practiceGate.ts: while the sandbox is on, every request is read before it leaves. Only named reads cross: bridge methods, router routes (queries and the two SoFi reads the router takes as invokes), the camera, and the display preferences ui_theme / sfx_enabled. Everything else, including a request that cannot be read and a raw sendMessageBin frame carrying an ingress or host request, is answered "blocked in practice mode" in its channel's wire shape and never reaches Rust or the host. - bridge/BridgeRegistry.ts: getBridgeInstance() is the practice view while the sandbox is on. Every path to native code (callBin, the ingress, host requests) takes its bridge from here. - practiceMode: enter/leave switch the sandbox. The dsmClient overrides keep only the simulated answers; the verb regex and its refusals are deleted. - The practice addContact answered { ok: true }, which contactsStore read as a refusal, and stored a fixed practice id for every added device. It now answers AddContactResult for the card Rust read, with the card's ids, named as Rust names a contact added with no alias. - tests/helpers/rustIngressRecord.ts: the record-answering bridge, now shared, logs what reached it. Tests (bridge/__tests__/practiceGate.test.ts): - practice on: direct imports burnToken, sofi.trade and sofi.resolve are blocked, and a raw ingress frame too; the bridge receives nothing; - a read (wallet.amount) crosses and Rust answers it; - ui_theme crosses, lock and diagnostics preferences do not; - the QR camera crosses, an NFC write does not; - practice off: the same writes reach the bridge; - practiceMode: dsmClient.burnToken / forgetToken are blocked, a practice send crosses only wallet.amount reads, and leave() ends the sandbox. --- .../frontend/src/bridge/BridgeRegistry.ts | 9 +- .../src/bridge/__tests__/practiceGate.test.ts | 117 +++++++++++++ .../frontend/src/bridge/practiceGate.ts | 162 ++++++++++++++++++ .../tour/__tests__/practiceMode.test.ts | 48 +----- .../src/components/tour/practiceMode.ts | 59 +++---- .../src/tests/helpers/rustIngressRecord.ts | 72 ++++++++ 6 files changed, 386 insertions(+), 81 deletions(-) create mode 100644 dsm_client/frontend/src/bridge/__tests__/practiceGate.test.ts create mode 100644 dsm_client/frontend/src/bridge/practiceGate.ts create mode 100644 dsm_client/frontend/src/tests/helpers/rustIngressRecord.ts diff --git a/dsm_client/frontend/src/bridge/BridgeRegistry.ts b/dsm_client/frontend/src/bridge/BridgeRegistry.ts index f754e4582..245c0f024 100644 --- a/dsm_client/frontend/src/bridge/BridgeRegistry.ts +++ b/dsm_client/frontend/src/bridge/BridgeRegistry.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 import type { AndroidBridgeV3 } from '../dsm/bridgeTypes'; +import { inPracticeSandbox, practiceView } from './practiceGate'; let currentBridge: AndroidBridgeV3 | undefined; @@ -8,6 +9,12 @@ export function setBridgeInstance(bridge: AndroidBridgeV3 | undefined) { currentBridge = bridge; } +/** + * The bridge every call to native code goes through. While the guided tour's + * practice wallet stands in, it is the practice view, which lets only reads + * cross (bridge/practiceGate.ts). + */ export function getBridgeInstance(): AndroidBridgeV3 | undefined { - return currentBridge; + if (currentBridge === undefined) return undefined; + return inPracticeSandbox() ? practiceView(currentBridge) : currentBridge; } diff --git a/dsm_client/frontend/src/bridge/__tests__/practiceGate.test.ts b/dsm_client/frontend/src/bridge/__tests__/practiceGate.test.ts new file mode 100644 index 000000000..a54fa97c7 --- /dev/null +++ b/dsm_client/frontend/src/bridge/__tests__/practiceGate.test.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +//! While the tour's practice wallet stands in, the bridge lets only reads reach +//! native code, whatever module makes the call. Practice mode used to patch the +//! dsmClient object and nothing else, so a screen that imported a write +//! directly (token creation, burn, forget, every SoFi action, the lock, NFC) +//! reached the real wallet during the tour. These tests drive such direct +//! imports. Reads are answered from Rust's own record of the ingress, and every +//! request that reaches the bridge is logged, so "blocked" means the bridge +//! never saw it. + +import { join } from 'path'; +import * as pb from '../../proto/dsm_app_pb'; +import { answerFromRustRecord } from '../../tests/helpers/rustIngressRecord'; +import type { Arrival } from '../../tests/helpers/rustIngressRecord'; +import { enterPracticeSandbox, inPracticeSandbox, leavePracticeSandbox, PRACTICE_BLOCKED_MESSAGE } from '../practiceGate'; +import { burnToken } from '../../dsm/policies'; +import * as sofi from '../../dsm/sofi'; +import { walletAmount } from '../../dsm/amount'; +import { callBin, setPreference } from '../../dsm/WebViewBridge'; +import { startNativeQrScan, writeNfcTagPayloadHost } from '../../dsm/NativeHostBridge'; +import { dsmClient } from '../../services/dsmClient'; +import { practiceMode } from '../../components/tour/practiceMode'; + +const RECORD = join(__dirname, '../../components/tour/__tests__/fixtures/wallet_amount.ingress.bin'); +const carried = (arrivals: Arrival[]): string[] => arrivals.map((a) => a.carried); +const client = dsmClient as unknown as Record Promise>; + +const VAULT = new Uint8Array(32).fill(0x51); +const TOKEN_IN = new Uint8Array(32).fill(0x52); +const TOKEN_OUT = new Uint8Array(32).fill(0x53); + +describe('the practice sandbox at the bridge', () => { + let arrivals: Arrival[]; + beforeEach(() => { + arrivals = answerFromRustRecord(RECORD); + enterPracticeSandbox(); + }); + afterEach(() => leavePracticeSandbox()); + + it('blocks a write a screen imports directly, past dsmClient, before it reaches native code', async () => { + const burned = await burnToken({ tokenId: 'ERA', amount: '1' }); + expect(burned).toEqual(expect.objectContaining({ message: expect.stringContaining(PRACTICE_BLOCKED_MESSAGE) })); + await expect( + sofi.trade({ vaultId: VAULT, tokenIn: TOKEN_IN, tokenOut: TOKEN_OUT, amountIn: '1', minAmountOut: '1' }), + ).rejects.toThrow(PRACTICE_BLOCKED_MESSAGE); + await expect(sofi.resolve()).rejects.toThrow(PRACTICE_BLOCKED_MESSAGE); + expect(arrivals).toEqual([]); + }); + + it('blocks a raw frame that carries a write to the ingress', async () => { + const frame = new pb.IngressRequest({ + operation: { case: 'routerInvoke', value: new pb.RouterInvokeOp({ method: 'token.create' }) }, + }); + await expect(callBin('nativeBoundaryIngress', frame.toBinary())).rejects.toThrow(PRACTICE_BLOCKED_MESSAGE); + expect(arrivals).toEqual([]); + }); + + it('lets a read through, and Rust answers it', async () => { + await expect(walletAmount({ tokenId: 'ERA' }, { entered: '1000' })).resolves.toEqual({ + baseUnits: 100000n, + displayAmount: '1000.00', + decimals: 2, + }); + expect(carried(arrivals)).toEqual(['wallet.amount']); + }); + + it('keeps the preferences that change how the app looks and sounds, and no others', async () => { + await setPreference('ui_theme', 'dark'); + await setPreference('lock_enabled', '1'); + await setPreference('diagnostics_consent', '1'); + expect(carried(arrivals)).toEqual(['ui_theme']); + }); + + it('lets the camera open for a contact code, and blocks a write to an NFC ring', async () => { + await expect(startNativeQrScan()).rejects.toThrow(/no recorded answer/); + await expect(writeNfcTagPayloadHost(new Uint8Array([7]))).rejects.toThrow(PRACTICE_BLOCKED_MESSAGE); + expect(carried(arrivals)).toEqual(['HOST_CONTROL_QR_START_SCAN']); + }); +}); + +describe('outside practice', () => { + it('the same writes reach native code', async () => { + const arrivals = answerFromRustRecord(RECORD); + await burnToken({ tokenId: 'ERA', amount: '1' }); + await expect(sofi.resolve()).rejects.toThrow(/no recorded answer/); + await setPreference('lock_enabled', '1'); + expect(carried(arrivals)).toEqual(['token.burn', 'sofi.resolve', 'lock_enabled']); + }); +}); + +describe('practice mode puts the bridge in its sandbox', () => { + let arrivals: Arrival[]; + beforeEach(() => { + arrivals = answerFromRustRecord(RECORD); + practiceMode.enter(); + }); + afterEach(() => practiceMode.leave()); + + it('blocks a dsmClient write that practice does not answer', async () => { + const burned = await client.burnToken({ tokenId: 'ERA', amount: '1' }); + expect(burned).toEqual(expect.objectContaining({ message: expect.stringContaining(PRACTICE_BLOCKED_MESSAGE) })); + await expect(client.forgetToken('PLAY')).rejects.toThrow(PRACTICE_BLOCKED_MESSAGE); + expect(arrivals).toEqual([]); + }); + + it('answers a write it simulates with only Rust reads crossing the bridge', async () => { + const sent = await client.sendOnlineTransferSmart('alice', '25', undefined, 'ERA'); + expect(sent).toEqual(expect.objectContaining({ newBalance: 97500n })); + expect(new Set(carried(arrivals))).toEqual(new Set(['wallet.amount'])); + }); + + it('takes the bridge out of its sandbox when it leaves', () => { + expect(inPracticeSandbox()).toBeTruthy(); + practiceMode.leave(); + expect(inPracticeSandbox()).toBeFalsy(); + }); +}); diff --git a/dsm_client/frontend/src/bridge/practiceGate.ts b/dsm_client/frontend/src/bridge/practiceGate.ts new file mode 100644 index 000000000..1d0783152 --- /dev/null +++ b/dsm_client/frontend/src/bridge/practiceGate.ts @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The guided tour's sandbox, at the bridge. While the tour's practice wallet +// stands in for the device's, every call the WebView makes to native code is +// read here before it leaves, whichever module made it and however it was +// imported, and only reads cross: each one named below. Everything else is +// answered "blocked in practice mode" in its channel's own wire shape and never +// reaches Rust or the host. The practice wallet's own answers +// (components/tour/practiceMode.ts) are made before any call is sent; this is +// what keeps the real wallet untouched while they are. + +import { + BridgeRpcRequest, + BridgeRpcResponse, + Error as ProtoError, + ErrorResponse, + IngressRequest, + IngressResponse, + NativeHostRequest, + NativeHostRequestKind, + NativeHostResponse, + PreferencePayload, +} from '../proto/dsm_app_pb'; +import type { AndroidBridgeV3 } from '../dsm/bridgeTypes'; + +export const PRACTICE_BLOCKED_MESSAGE = + 'Practice mode: this is switched off until the tour ends. Your real wallet is untouched.'; + +/** Bridge methods that only read. */ +const READ_METHODS = new Set([ + 'getAllBalancesStrict', + 'getTransportHeadersV3Bin', + 'getPreference', + 'getArchitectureInfo', + 'getDiagnosticsLog', +]); + +/** The preferences the tour's shell lessons change: how the app looks and sounds. */ +const DISPLAY_PREFERENCES = new Set(['ui_theme', 'sfx_enabled']); + +/** + * Router routes that only read, whether the router takes them as a query or an + * invoke: the screens the tour visits read these. A route that writes anything, + * a query path included (`tokens.addByAnchor`, `storage.sync`, `prefs.set`), is + * not here. + */ +const READ_ROUTES = new Set([ + 'balance.list', + 'wallet.history', + 'wallet.amount', + 'contacts.list', + 'contacts.readContactCode', + 'identity.contact_code', + 'inbox.pull', + 'storage.status', + 'tokens.getFeeSchedule', + 'token.adoptionQr', + 'bilateral.pending_list', + 'recovery.status', + 'recovery.capsulePreview', + 'recovery.phase', + 'recovery.syncStatus', + 'sofi.findRoute', + 'sofi.vaults', + 'bitcoin.balance', + 'bitcoin.vault.list', +]); + +/** Host requests that change nothing: what the host can do, and the camera a contact code is scanned with. */ +const HOST_READS = new Set([ + NativeHostRequestKind.HOST_CONTROL_CAPABILITIES_GET, + NativeHostRequestKind.HOST_CONTROL_QR_START_SCAN, + NativeHostRequestKind.HOST_CONTROL_QR_STOP_SCAN, +]); + +let sandbox: 'real' | 'practice' = 'real'; + +/** The tour's practice wallet is standing in: only reads reach native code. */ +export function enterPracticeSandbox(): void { + sandbox = 'practice'; +} + +export function leavePracticeSandbox(): void { + sandbox = 'real'; +} + +export function inPracticeSandbox(): boolean { + return sandbox === 'practice'; +} + +/** What a request is, when it may not cross: undefined when it is a read. */ +type Refusal = string | undefined; + +function ingressRefusal(bytes: Uint8Array): Refusal { + const operation = IngressRequest.fromBinary(bytes).operation; + if (operation.case === 'routerQuery' || operation.case === 'routerInvoke') { + return READ_ROUTES.has(operation.value.method) ? undefined : operation.value.method; + } + return `ingress ${String(operation.case)}`; +} + +function hostRefusal(bytes: Uint8Array): Refusal { + const kind = NativeHostRequest.fromBinary(bytes).kind; + return HOST_READS.has(kind) ? undefined : `host request ${NativeHostRequestKind[kind]}`; +} + +function rpcRefusal(bytes: Uint8Array): Refusal { + const call = BridgeRpcRequest.fromBinary(bytes); + const payload = call.payload.case === 'bytes' ? call.payload.value.data : new Uint8Array(0); + if (call.method === 'nativeBoundaryIngress') return ingressRefusal(payload); + if (call.method === 'nativeHostRequest') return hostRefusal(payload); + if (call.method === 'setPreference') { + const key = PreferencePayload.fromBinary(payload).key; + return DISPLAY_PREFERENCES.has(key) ? undefined : `setPreference ${key}`; + } + return READ_METHODS.has(call.method) ? undefined : call.method; +} + +/** A request that cannot be read is refused, never let through. */ +function refusalOf(read: (bytes: Uint8Array) => Refusal, bytes: Uint8Array): Refusal { + try { + return read(bytes); + } catch (e) { + return `an unreadable request (${e instanceof Error ? e.message : String(e)})`; + } +} + +const blocked = (what: string): string => `${PRACTICE_BLOCKED_MESSAGE} (${what})`; + +/** + * The bridge as the WebView sees it while the tour runs: the same object, with + * every request read first. Reads go to the real bridge; anything else is + * answered here, refused, in the shape that channel answers a refusal in. + */ +export function practiceView(bridge: AndroidBridgeV3): AndroidBridgeV3 { + return { + __binary: bridge.__binary, + isAvailable: () => bridge.isAvailable(), + getBridgeStatus: () => bridge.getBridgeStatus(), + sendMessageBin: async (bytes: Uint8Array) => { + const what = refusalOf(rpcRefusal, bytes); + if (what === undefined) return bridge.sendMessageBin(bytes); + return new BridgeRpcResponse({ + result: { case: 'error', value: new ErrorResponse({ message: blocked(what) }) }, + }).toBinary(); + }, + ingress: async (bytes: Uint8Array) => { + const what = refusalOf(ingressRefusal, bytes); + if (what === undefined) return bridge.ingress(bytes); + return new IngressResponse({ + result: { case: 'error', value: new ProtoError({ message: blocked(what) }) }, + }).toBinary(); + }, + hostRequest: async (bytes: Uint8Array) => { + const what = refusalOf(hostRefusal, bytes); + if (what === undefined) return bridge.hostRequest(bytes); + return new NativeHostResponse({ + result: { case: 'error', value: new ProtoError({ message: blocked(what) }) }, + }).toBinary(); + }, + }; +} diff --git a/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts b/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts index 373456754..a7fe8904c 100644 --- a/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts +++ b/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts @@ -12,59 +12,19 @@ //! writes and holds equal to the live ingress. A request Rust has no recorded //! answer for is the bridge's error, so these tests pass only on Rust's answers. -import { readFileSync } from 'fs'; import { join } from 'path'; -import * as pb from '../../../proto/dsm_app_pb'; +import { answerFromRustRecord } from '../../../tests/helpers/rustIngressRecord'; import { dsmClient } from '../../../services/dsmClient'; import { practiceMode, PRACTICE_CONTACT_ALIAS, PRACTICE_CONTACT_DEVICE_ID } from '../practiceMode'; const client = dsmClient as unknown as Record Promise>; -type RecordedAnswer = { request: Uint8Array; response: Uint8Array }; - -/** Rust's record: length-prefixed pairs of an IngressRequest and the IngressResponse the JNI handed back. */ -function rustRecord(): RecordedAnswer[] { - const bytes = new Uint8Array(readFileSync(join(__dirname, 'fixtures/wallet_amount.ingress.bin'))); - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - let at = 0; - const part = (): Uint8Array => { - const length = view.getUint32(at); - const out = bytes.slice(at + 4, at + 4 + length); - at += 4 + length; - return out; - }; - const answers: RecordedAnswer[] = []; - while (at < bytes.length) answers.push({ request: part(), response: part() }); - return answers; -} - -const sameBytes = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((v, i) => v === b[i]); - -/** The app's bridge, answering the native ingress from Rust's record and nothing else. */ -function answerFromRustRecord(): void { - const record = rustRecord(); - window.DsmBridge = { - sendMessageBin: async (bytes: Uint8Array): Promise => { - const call = pb.BridgeRpcRequest.fromBinary(bytes); - const payload = call.payload.case === 'bytes' ? call.payload.value.data : new Uint8Array(0); - const answer = call.method === 'nativeBoundaryIngress' - ? record.find((recorded) => sameBytes(recorded.request, payload)) - : undefined; - if (!answer) { - const message = `Rust has no recorded answer to this ${call.method} request`; - return new pb.BridgeRpcResponse({ result: { case: 'error', value: { errorCode: 1, message } } }).toBinary(); - } - return new pb.BridgeRpcResponse({ - result: { case: 'success', value: { data: new Uint8Array(answer.response) } }, - }).toBinary(); - }, - }; -} +const RECORD = join(__dirname, 'fixtures/wallet_amount.ingress.bin'); const eraRow = async () => (await client.getAllBalances()).find((r: any) => r.tokenId === 'ERA'); describe('practice mode answers as the real calls do', () => { - beforeAll(() => answerFromRustRecord()); + beforeAll(() => answerFromRustRecord(RECORD)); beforeEach(() => practiceMode.enter()); afterEach(() => practiceMode.leave()); @@ -99,7 +59,7 @@ describe('practice mode answers as the real calls do', () => { }); describe('practice ERA counts as Rust counts ERA', () => { - beforeAll(() => answerFromRustRecord()); + beforeAll(() => answerFromRustRecord(RECORD)); beforeEach(() => practiceMode.enter()); afterEach(() => practiceMode.leave()); diff --git a/dsm_client/frontend/src/components/tour/practiceMode.ts b/dsm_client/frontend/src/components/tour/practiceMode.ts index 22787ac55..3bcc3b9d1 100644 --- a/dsm_client/frontend/src/components/tour/practiceMode.ts +++ b/dsm_client/frontend/src/components/tour/practiceMode.ts @@ -5,16 +5,19 @@ // While the tour runs, the real screens stay on screen but the calls they make // for the things a beginner tries (balances, contacts, history, sending, the // faucet, adding a contact) are answered from a small in-memory practice -// wallet. Every other call whose name says it changes state is refused. When -// the tour ends, the real client is put back exactly as it was, so nothing the -// user does in the tour ever reaches the device's real state. +// wallet. That is a convenience. What keeps the real wallet untouched is the +// bridge: entering practice puts it in its sandbox (bridge/practiceGate.ts), +// where only reads reach native code, whatever module makes the call. When the +// tour ends, the real client and the bridge are put back exactly as they were. // // Every figure the practice wallet shows is Rust's. It asks `wallet.amount` to // parse what the user typed and to render each balance it keeps, as the real // wallet's figures are parsed and rendered, so practice ERA counts as ERA does. import { dsmClient } from '../../services/dsmClient'; +import { enterPracticeSandbox, leavePracticeSandbox } from '../../bridge/practiceGate'; import { walletAmount } from '../../dsm/amount'; +import { encodeBase32Crockford } from '../../utils/textId'; import type { AmountForms } from '../../dsm/amount'; import type { DomainContact, @@ -25,16 +28,6 @@ import type { TokenBalanceView } from '../../dsm/types'; export type PracticeEvent = 'sent' | 'claimed' | 'contactAdded'; -export const PRACTICE_BLOCKED_MESSAGE = - 'Practice mode: this is switched off until the tour ends. Your real wallet is untouched.'; - -/** Calls whose names start like this change state, so practice mode refuses them. */ -const STATE_CHANGING = - /^(send|load|unload|create|claim|add|remove|delete|publish|withdraw|deposit|swap|import|export|mint|burn|update|accept|reject|register|approve|revoke|write|reset|close|open|unlock|lock|execute|submit|broadcast|sign|pair|unpair|sync|reconcile|recover|restore|enroll|admit|fund|redeem|transfer|post|put|store|bind|advance|commit|finalize|apply|generate|start|stop|cancel|retry|refresh|clear|forget|rotate|set)/; - -/** Real even in practice: display preferences only. */ -const ALWAYS_REAL = new Set(['getPreference', 'setPreference']); - // Practice ids use only Base32 Crockford characters, so any code that decodes // an id keeps working. const PAD = '0'.repeat(52); @@ -249,30 +242,29 @@ function simulations(state: PracticeState, emit: (event: PracticeEvent) => void) message: `Practice: claimed ${paid.displayAmount} ERA`, }; }, - addContact: async (input: { alias: string; genesisHash: string | Uint8Array; deviceId: string | Uint8Array }) => { + // Answers in the shape the real addContact does (AddContactResult), for the + // card Rust read from the contact code the user entered. + addContact: async (input: { alias: string; deviceId: Uint8Array; genesisHash: Uint8Array; signingPublicKey: Uint8Array }) => { await pause(400); + const contactId = encodeBase32Crockford(input.deviceId); + // As Rust names a contact added with no alias: by its device id's first eight characters. + const alias = input.alias.trim() || contactId.slice(0, 8); state.contacts.push({ - alias: input.alias, - genesisHash: typeof input.genesisHash === 'string' ? input.genesisHash : practiceId('PRACT1CEGENES1S'), - deviceId: typeof input.deviceId === 'string' ? input.deviceId : practiceId('PRACT1CEDEV1CE'), - signingPublicKey: practiceId('PRACT1CEKEY'), + alias, + deviceId: contactId, + genesisHash: encodeBase32Crockford(input.genesisHash), + signingPublicKey: encodeBase32Crockford(input.signingPublicKey), pairing: 'idle', genesisVerifiedOnline: true, sendReady: true, sendCheckState: 'ready', }); emit('contactAdded'); - return { ok: true }; + return { accepted: true, contactId, alias }; }, }; } -function refused(name: string): AnyFn { - return async () => { - throw new Error(`${PRACTICE_BLOCKED_MESSAGE} (${name})`); - }; -} - class PracticeMode { private state: PracticeState | null = null; private originals = new Map(); @@ -286,18 +278,12 @@ class PracticeMode { if (this.state) return; const state = freshState(); this.state = state; + enterPracticeSandbox(); const client = dsmClient as unknown as Record; - const simulated = simulations(state, (event) => this.listeners.forEach((listener) => listener(event))); - for (const key of Object.keys(client)) { - const value = client[key]; - if (typeof value !== 'function' || ALWAYS_REAL.has(key)) continue; - if (Object.prototype.hasOwnProperty.call(simulated, key)) { - this.originals.set(key, value); - client[key] = simulated[key]; - } else if (STATE_CHANGING.test(key)) { - this.originals.set(key, value); - client[key] = refused(key); - } + const answers = simulations(state, (event) => this.listeners.forEach((listener) => listener(event))); + for (const key of Object.keys(answers)) { + this.originals.set(key, client[key]); + client[key] = answers[key]; } } @@ -309,6 +295,7 @@ class PracticeMode { }); this.originals.clear(); this.state = null; + leavePracticeSandbox(); } onEvent(listener: (event: PracticeEvent) => void): () => void { diff --git a/dsm_client/frontend/src/tests/helpers/rustIngressRecord.ts b/dsm_client/frontend/src/tests/helpers/rustIngressRecord.ts new file mode 100644 index 000000000..d4e4f0d7d --- /dev/null +++ b/dsm_client/frontend/src/tests/helpers/rustIngressRecord.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The app's bridge for tests that cannot run Rust. It answers the native +// ingress from a record of Rust's own answers (each request as the WebView +// frames it, and the bytes the JNI handed back), and answers anything else +// with an error naming it. It logs every request that reached it, so a test can +// tell a request the practice sandbox stopped from one that crossed. + +import { readFileSync } from 'fs'; +import * as pb from '../../proto/dsm_app_pb'; + +export type RecordedAnswer = { request: Uint8Array; response: Uint8Array }; + +/** What reached the bridge: the bridge method, and the route, host request or preference it carried. */ +export type Arrival = { method: string; carried: string }; + +/** A record: length-prefixed pairs of an IngressRequest and the IngressResponse the JNI handed back. */ +export function readRustRecord(path: string): RecordedAnswer[] { + const bytes = new Uint8Array(readFileSync(path)); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let at = 0; + const part = (): Uint8Array => { + const length = view.getUint32(at); + const out = bytes.slice(at + 4, at + 4 + length); + at += 4 + length; + return out; + }; + const answers: RecordedAnswer[] = []; + while (at < bytes.length) answers.push({ request: part(), response: part() }); + return answers; +} + +const sameBytes = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((v, i) => v === b[i]); + +function carriedBy(method: string, payload: Uint8Array): string { + if (method === 'nativeBoundaryIngress') { + const operation = pb.IngressRequest.fromBinary(payload).operation; + return operation.case === 'routerQuery' || operation.case === 'routerInvoke' + ? operation.value.method + : String(operation.case); + } + if (method === 'nativeHostRequest') return pb.NativeHostRequestKind[pb.NativeHostRequest.fromBinary(payload).kind]; + if (method === 'setPreference' || method === 'getPreference') return pb.PreferencePayload.fromBinary(payload).key; + return method; +} + +/** + * Installs the bridge, answering the native ingress from the record at `path` + * and nothing else from Rust, and returns the log of what reached it. + */ +export function answerFromRustRecord(path: string): Arrival[] { + const record = readRustRecord(path); + const arrivals: Arrival[] = []; + window.DsmBridge = { + sendMessageBin: async (bytes: Uint8Array): Promise => { + const call = pb.BridgeRpcRequest.fromBinary(bytes); + const payload = call.payload.case === 'bytes' ? call.payload.value.data : new Uint8Array(0); + arrivals.push({ method: call.method, carried: carriedBy(call.method, payload) }); + const answer = call.method === 'nativeBoundaryIngress' + ? record.find((recorded) => sameBytes(recorded.request, payload)) + : undefined; + if (!answer) { + const message = `Rust has no recorded answer to this ${call.method} request`; + return new pb.BridgeRpcResponse({ result: { case: 'error', value: { errorCode: 1, message } } }).toBinary(); + } + return new pb.BridgeRpcResponse({ + result: { case: 'success', value: { data: new Uint8Array(answer.response) } }, + }).toBinary(); + }, + }; + return arrivals; +} From 12773e71ea79cd04f25830918dae3ed6e3b751c5 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:41:59 -0400 Subject: [PATCH 2/6] feat(tour): the tour covers the online beta, without offline sending or Bitcoin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner ruling 2026-10-01: the tour "needs to be expanded… cover everything… Except for Bitcoin for now, and the offline for now", with walkthroughs that highlight the real UI and keep the action blocked where Rust offers no practice route, and hands-on add-contact-by-code. Removed: the five offline-send steps (mode, go-offline, offline-funding, appliance, go-online) and the Bitcoin tab in the wallet-tabs text. Fixed: the lock step named a fingerprint unlock the app does not have; the storage step said two tabs where there are three; token-detail described Burn and Forget on a card that has neither. Added, 79 steps from 57: - Wallet: recent activity, wallet identity, the History tab, the inbox (the real one, only read) and closing it. - Tokens: the creation wizard opened, walked through, and closed; Add Token by anchor; Burn and Forget; sharing a token by its anchor and QR. - Trade: what you pay, what you get, the quote and trade; a liquidity vault's reserves and fee, closing a vault, relaying a fulfillment. - Contacts, hands-on: copy bob's code, paste it, Add; open a contact and its facts and stitched receipts. - Storage: the DLVs tab's liquidity vaults. - Settings: the lock setup screen, the ring backup and the recover screen. Bob's code is Rust's. The dsm_sdk test the_tour_practice_contact_code_is_one_rust_reads builds his card on the beta network, requires read_contact_code to read it back, and holds components/tour/practiceContact.ts equal to its encoding (DSM_WRITE_FRONTEND_FIXTURES=1 rewrites it). In the tour, pasting it runs the real contacts.readContactCode, a read the sandbox lets through, and Add is practice mode's. - GuidedTour: a step can carry text the user needs, shown selectable with a Copy button. - Anchors where no stable selector existed: recent-activity, add-token, swap-pay, swap-get, liquidity-create, liquidity-close, contact-code, contact-list, liquidity-vaults; and the classes wallet-identity, sofi-relay. The DLVs anchor wraps the vault section in every state, so the step does not wait forever on a wallet with no vaults. Tests (tour.test.tsx): - the steps' data-tour anchors are exactly the fourteen, each in the app; - every target, wait and back-out selector names only attribute values, classes and ids the app's screens or the shell render; - no step teaches offline sending or Bitcoin, or targets their anchors. practiceMode.test.ts: - Add Contact answers AddContactResult under the card's ids; - with no alias, the contact is named as Rust names it. --- .../dsm_sdk/src/handlers/identity_routes.rs | 54 ++++ .../src/components/qr/QRCodeScannerPanel.tsx | 2 +- .../src/components/screens/AccountsScreen.tsx | 1 + .../components/screens/ContactsTabScreen.tsx | 2 +- .../src/components/screens/SofiScreen.tsx | 10 +- .../src/components/screens/StorageScreen.tsx | 10 +- .../components/screens/wallet/OverviewTab.tsx | 4 +- .../src/components/tour/GuidedTour.css | 22 ++ .../src/components/tour/GuidedTour.tsx | 26 ++ .../tour/__tests__/practiceMode.test.ts | 33 ++ .../components/tour/__tests__/tour.test.tsx | 42 ++- .../src/components/tour/practiceContact.ts | 9 + .../frontend/src/components/tour/tourSteps.ts | 285 ++++++++++++++---- 13 files changed, 432 insertions(+), 68 deletions(-) create mode 100644 dsm_client/frontend/src/components/tour/practiceContact.ts diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/identity_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/identity_routes.rs index 980f78c15..d9786af48 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/identity_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/identity_routes.rs @@ -225,4 +225,58 @@ mod tests { short.signing_public_key.pop(); assert!(refusal(&code_of(&short)).contains("signing key is 63 bytes")); } + + /// Where the guided tour's practice contact code is kept in the frontend. + const PRACTICE_CONTACT_FILE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../frontend/src/components/tour/practiceContact.ts" + ); + + /// The guided tour's practice contact, bob: a card on the beta network, + /// written as this module writes a contact code. The tour has the user + /// paste the code into Add Contact, where Rust reads it for real (a read + /// the practice sandbox lets through) and practice mode adds bob. The + /// frontend's file must hold exactly this code, one Rust reads back as the + /// card; DSM_WRITE_FRONTEND_FIXTURES=1 rewrites it. + #[test] + #[serial_test::serial] + fn the_tour_practice_contact_code_is_one_rust_reads() { + identity(); + let card = generated::ContactQrV3 { + device_id: vec![0xB0; 32], + network: String::from_utf8(dsm::economic::register::BETA_NETWORK_ID.to_vec()) + .expect("the beta network id is UTF-8"), + genesis_hash: vec![0xB1; 32], + signing_public_key: vec![0xB2; 64], + preferred_alias: "bob".into(), + ..Default::default() + }; + let code = code_of(&card); + assert_eq!( + read_contact_code(&code).expect("Rust reads the practice code"), + card + ); + + let file = format!( + "// SPDX-License-Identifier: Apache-2.0\n\ + //\n\ + // The guided tour's practice contact, bob: his contact code, as Rust writes\n\ + // one. Written by the dsm_sdk test\n\ + // `the_tour_practice_contact_code_is_one_rust_reads` (handlers/identity_routes.rs),\n\ + // which fails when this file and Rust's encoding differ. Rust reads it when\n\ + // it is pasted into Add Contact; adding bob is practice mode's.\n\ + export const PRACTICE_CONTACT_CODE =\n '{code}';\n" + ); + match std::env::var_os("DSM_WRITE_FRONTEND_FIXTURES") { + Some(_) => std::fs::write(PRACTICE_CONTACT_FILE, &file) + .expect("write the frontend's practice contact"), + None => assert_eq!( + std::fs::read_to_string(PRACTICE_CONTACT_FILE) + .expect("the frontend's committed practice contact"), + file, + "the frontend's practice contact code differs from Rust's encoding; \ + rewrite it with DSM_WRITE_FRONTEND_FIXTURES=1" + ), + } + } } diff --git a/dsm_client/frontend/src/components/qr/QRCodeScannerPanel.tsx b/dsm_client/frontend/src/components/qr/QRCodeScannerPanel.tsx index 8c2f3b98b..c8e8be2fb 100644 --- a/dsm_client/frontend/src/components/qr/QRCodeScannerPanel.tsx +++ b/dsm_client/frontend/src/components/qr/QRCodeScannerPanel.tsx @@ -211,7 +211,7 @@ export default function QRCodeScannerPanel(props: QRCodeScannerProps = {}): Reac : 'Enter the contact code shown with the QR, or use the camera.'}

-
+
Enter Contact Code