diff --git a/ci/bridge_rpc_names.py b/ci/bridge_rpc_names.py index c75f160b7..88fe3eaa8 100644 --- a/ci/bridge_rpc_names.py +++ b/ci/bridge_rpc_names.py @@ -23,7 +23,9 @@ # `window.DsmBridge` (every production call on the bridge object is typed # against that interface), and no production source may name `__callBin`, the # transport function only the jest stub installed and every transport path -# once branched on. +# once branched on. A test bridge's stub arms (`method === '…'`) may answer +# only names Kotlin handles: an arm for a method that does not exist is a +# stub of nothing, and two outlived the methods they stubbed. # # Exit 0 only when every set is non-empty and each pair is equal. There is no # allowlist: a name one side must stop using is removed from that side. @@ -175,6 +177,33 @@ def production_names_callbin(): return hits +STUB_ARM_RE = re.compile(r"(? { - NativeBoundaryBridge.startup(payload) - } - "nativeBoundaryIngress" -> { NativeBoundaryBridge.ingress(payload) } diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt index d7cfd5920..8c6d93bbf 100644 --- a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt +++ b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt @@ -31,7 +31,6 @@ class SinglePathWebViewBridgeFuzzTest { "getBluetoothStatus", "getPreference", "setPreference", - "nativeBoundaryStartup", "nativeBoundaryIngress", "nativeHostRequest", "resolveBleAddressForDeviceId", @@ -94,7 +93,7 @@ class SinglePathWebViewBridgeFuzzTest { malformedProtos.forEach { proto -> try { // Try various methods that parse protobuf - val methods = listOf("nativeBoundaryStartup", "nativeBoundaryIngress", "nativeHostRequest") + val methods = listOf("nativeBoundaryIngress", "nativeHostRequest") methods.forEach { method -> val result = SinglePathWebViewBridge.handleBinaryRpc(method, proto) // Bridge is not initialized in unit tests, so all responses @@ -307,7 +306,7 @@ class SinglePathWebViewBridgeFuzzTest { // Test with extremely large payloads val hugePayload = ByteArray(10 * 1024 * 1024) { it.toByte() } // 10MB - val methods = listOf("nativeBoundaryStartup", "nativeBoundaryIngress", "nativeHostRequest") + val methods = listOf("nativeBoundaryIngress", "nativeHostRequest") methods.forEach { method -> try { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_query_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_query_routes.rs index 7f38bed8e..e9d4e5b18 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_query_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_query_routes.rs @@ -630,6 +630,12 @@ impl AppRouterImpl { anchor_fingerprint: String::new(), // Native BTC has no CPTA identity either. canonical_token_id: String::new(), + // Nor a committed DSM policy: this row is neither a + // protocol asset nor a created token, and states no + // supply and no permissions. + protocol_defined: false, + genesis_supply_display: String::new(), + permissions: None, }, )) } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs index 505fdfc57..1b1e728df 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs @@ -81,6 +81,16 @@ pub(crate) fn enrich_balance_metadata( if let Some(c) = crate::policy::builtin_policy_commit("ERA") { set_anchor(reply, &c); } + // The protocol defines ERA, and its supply is the native reserve's, + // from which every unit in circulation was released. ERA's policy + // blob does not exist yet (§6.32), so nothing is stated about what + // it permits: absent, never a defaulted "not permitted". + reply.protocol_defined = true; + reply.genesis_supply_display = format_base_units_for_display( + dsm::economic::native_reserve::ERA_RESERVE_GENESIS_SUPPLY, + 0, + ); + reply.permissions = None; } "DBTC" => { reply.token_id = "dBTC".to_string(); @@ -91,6 +101,7 @@ pub(crate) fn enrich_balance_metadata( if let Some(c) = crate::policy::builtin_policy_commit("dBTC") { set_anchor(reply, &c); } + reply.protocol_defined = true; } // Created and adopted tokens carry their own decimals, and the wire // amount is BASE UNITS. Leaving decimals at the default meant a @@ -108,6 +119,7 @@ pub(crate) fn enrich_balance_metadata( reply.token_id ) })?; + let policy = registered_policy(&row)?; reply.symbol = row.ticker.clone(); reply.token_name = if row.alias.is_empty() { row.ticker @@ -117,26 +129,51 @@ pub(crate) fn enrich_balance_metadata( reply.decimals = row.decimals; reply.canonical_token_id = row.token_id.clone(); set_anchor(reply, &row.policy_commit); - reply.icon_url = policy_icon(&row.policy_commit)?; + reply.icon_url = policy.icon_url.unwrap_or_default(); reply.display_amount = format_base_units_for_display(reply.available, reply.decimals); + // A device created this token; its committed policy fixes the + // supply and says what holders may do with it. + reply.protocol_defined = false; + reply.genesis_supply_display = + format_supply_for_display(policy.genesis_supply, reply.decimals); + reply.permissions = Some(generated::TokenPolicyPermissions { + burn_enabled: policy.burn_enabled, + transferable: policy.transferable, + }); } } Ok(()) } -/// The icon field of a token's anchored policy, from bytes verified against the commit. +/// A registered token's committed policy: the bytes stored under its anchor, +/// verified against it, read by Core's one parser, and in agreement with the +/// row that names them. /// -/// Carried as the policy states it: the wallet draws coin artwork from it; a policy -/// without an icon field has none. A registered token's policy is stored with it, so -/// a policy that is missing or does not parse is an error. -fn policy_icon(policy_commit: &[u8; 32]) -> Result { - let anchor = crate::util::text_id::encode_base32_crockford(policy_commit); - let bytes = crate::storage::client_db::token_registry::load_policy_verified(policy_commit) +/// A registered token's policy is stored with it, so a policy that is missing +/// or does not parse is an error. The row's ticker, alias, decimals and genesis +/// supply were copied from this policy at registration; a row that disagrees +/// with the bytes its own commit names is corrupt, and the wallet reports +/// nothing from either side rather than pick one. +fn registered_policy( + row: &crate::storage::client_db::token_registry::TokenRegistryRow, +) -> Result { + let anchor = crate::util::text_id::encode_base32_crockford(&row.policy_commit); + let bytes = crate::storage::client_db::token_registry::load_policy_verified(&row.policy_commit) .map_err(|e| format!("policy {anchor} unreadable: {e}"))? .ok_or_else(|| format!("policy {anchor} of a registered token is not stored"))?; let policy = super::token_routes::parse_token_policy(&bytes) .ok_or_else(|| format!("stored policy {anchor} does not parse"))?; - Ok(policy.icon_url.unwrap_or_default()) + if policy.ticker != row.ticker + || policy.alias != row.alias + || policy.decimals != row.decimals + || policy.genesis_supply != row.genesis_supply + { + return Err(format!( + "registry row for {} disagrees with its policy {anchor}", + row.ticker + )); + } + Ok(policy) } /// How much of an anchor is enough to compare by eye. @@ -271,10 +308,19 @@ pub(crate) fn canonicalize_token_id(token_id: &str) -> String { /// Integer/string arithmetic — the digits are split, never divided — so a large /// balance stays exact where floating point would round it. pub fn format_base_units_for_display(base_units: u64, decimals: u32) -> String { + format_digits_for_display(base_units.to_string(), decimals) +} + +/// The same rule over a policy's genesis supply, which is `u128` in the +/// policy blob and the registry (SoFi §47). +pub(crate) fn format_supply_for_display(base_units: u128, decimals: u32) -> String { + format_digits_for_display(base_units.to_string(), decimals) +} + +fn format_digits_for_display(digits: String, decimals: u32) -> String { if decimals == 0 { - return base_units.to_string(); + return digits; } - let digits = base_units.to_string(); let d = decimals as usize; if digits.len() <= d { format!("0.{}", "0".repeat(d - digits.len()) + &digits) @@ -1347,6 +1393,139 @@ mod tests { assert!(items.iter().any(|item| item.token_id == "ERA")); assert!(items.iter().any(|item| item.token_id == "dBTC")); } + + fn fresh_db() { + crate::economic_fixtures::use_test_storage_dir(); + crate::storage::client_db::reset_database_for_tests(); + crate::storage::client_db::init_database().expect("init db"); + } + + /// A created token's committed policy, packed by the one packer and stored + /// under its anchor, as creation and adoption store it. + fn store_created_policy( + ticker: &str, + decimals: u32, + genesis_supply: u128, + burn_enabled: bool, + transferable: bool, + ) -> (super::super::token_routes::ParsedTokenPolicy, [u8; 32]) { + use prost::Message; + let (signer_pk, _secret) = + dsm::crypto::sphincs::generate_sphincs_keypair().expect("a signer key"); + let policy = super::super::token_routes::ParsedTokenPolicy { + creator_genesis: [0x11; 32], + creator_device_id: [0x22; 32], + ticker: ticker.to_string(), + alias: format!("{ticker} token"), + decimals, + genesis_supply, + release_rule: dsm::economic::token_policy::ReleaseRule::AllAtCreation, + description: None, + icon_url: Some("dsm:coin:v1:ABC".to_string()), + burn_enabled, + transferable, + threshold: 1, + signers: vec![signer_pk], + allowlist_device_ids: vec![], + }; + let bytes = generated::TokenPolicyV3 { + policy_bytes: super::super::token_routes::build_policy_v3_bytes(&policy) + .expect("the policy packs"), + } + .encode_to_vec(); + let commit = dsm::crypto::blake3::domain_hash_bytes( + dsm::common::domain_tags::TAG_DSM_POLICY, + &bytes, + ); + crate::storage::client_db::token_registry::upsert_policy(&commit, &bytes) + .expect("the policy is stored under its anchor"); + (policy, commit) + } + + /// The registry row adoption writes for a stored policy, with the supply + /// it records. + fn register( + policy: &super::super::token_routes::ParsedTokenPolicy, + commit: [u8; 32], + genesis_supply: u128, + ) { + crate::storage::client_db::token_registry::insert_token( + &crate::storage::client_db::token_registry::TokenRegistryRow { + token_id: format!("token-{}", policy.ticker), + policy_commit: commit, + ticker: policy.ticker.clone(), + alias: policy.alias.clone(), + decimals: policy.decimals, + genesis_supply, + creator_device_id: policy.creator_device_id, + }, + ) + .expect("the token is registered"); + } + + /// A protocol asset is one on Rust's word, not its ticker's. ERA's supply + /// is the reserve's; its policy blob does not exist yet (§6.32), so + /// nothing is stated about what it permits. + #[test] + #[serial_test::serial] + fn era_is_protocol_defined_with_the_reserve_supply_and_no_stated_permissions() { + fresh_db(); + let mut era = seed("ERA", 264, 0); + super::enrich_balance_metadata(&mut era).expect("ERA is named"); + assert!(era.protocol_defined); + assert_eq!(era.genesis_supply_display, "80000000000"); + assert_eq!(era.permissions, None, "no policy blob: nothing stated"); + let mut dbtc = seed("dBTC", 0, 0); + super::enrich_balance_metadata(&mut dbtc).expect("dBTC is named"); + assert!(dbtc.protocol_defined); + } + + /// A created token's facts are its committed policy's, read from bytes + /// verified against the anchor: the supply in display units, what holders + /// may do, the icon. + #[test] + #[serial_test::serial] + fn a_registered_token_reports_its_policy_supply_and_permissions() { + fresh_db(); + let (policy, commit) = store_created_policy("RIGB", 2, 100_000, true, false); + register(&policy, commit, 100_000); + let mut row = seed("RIGB", 5, 0); + super::enrich_balance_metadata(&mut row).expect("a registered token is named"); + assert!(!row.protocol_defined); + assert_eq!(row.genesis_supply_display, "1000.00"); + assert_eq!( + row.permissions, + Some(generated::TokenPolicyPermissions { + burn_enabled: true, + transferable: false, + }) + ); + assert_eq!(row.icon_url, "dsm:coin:v1:ABC"); + assert_eq!(row.symbol, "RIGB"); + assert_eq!(row.display_amount, "0.05"); + } + + /// A row that disagrees with the bytes its own commit names is corrupt: + /// refused, with nothing from either side reported. + #[test] + #[serial_test::serial] + fn a_registry_row_that_disagrees_with_its_policy_is_refused() { + fresh_db(); + let (policy, commit) = store_created_policy("DISA", 0, 10, true, true); + register(&policy, commit, 11); + let mut row = seed("DISA", 0, 0); + let refused = super::enrich_balance_metadata(&mut row) + .expect_err("a row disagreeing with its policy is refused"); + assert!(refused.contains("disagrees with its policy"), "{refused}"); + assert!( + row.permissions.is_none(), + "nothing reported from the policy" + ); + assert!( + row.genesis_supply_display.is_empty(), + "nothing reported from the row" + ); + } } #[cfg(test)] diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/tests/balance_wire_fixture.rs b/dsm_client/deterministic_state_machine/dsm_sdk/tests/balance_wire_fixture.rs index a39ce1789..f2714b2aa 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/tests/balance_wire_fixture.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/tests/balance_wire_fixture.rs @@ -45,6 +45,15 @@ fn emit_balances_list_fixture() { // The policy's icon field, carried as the policy states it; the wallet draws // the token's coin from it. icon_url: "dsm:coin:v1:FIXTURE".to_string(), + // What the token is and what its committed policy fixes and permits, as + // Rust reports them for a created token: 100_000_000 base units at 2 + // decimals is 1000000.00. + protocol_defined: false, + genesis_supply_display: crate_format(100_000_000, 2), + permissions: Some(dsm_sdk::generated::TokenPolicyPermissions { + burn_enabled: true, + transferable: false, + }), }; let list = dsm_sdk::generated::BalancesListResponse { balances: vec![row], @@ -71,6 +80,16 @@ fn emit_balances_list_fixture() { assert_eq!(b.symbol, "RIGB", "symbol must survive encoding"); assert_eq!(b.decimals, 2, "decimals must survive encoding"); assert_eq!(b.token_name, "RigBravo"); + assert!(!b.protocol_defined); + assert_eq!(b.genesis_supply_display, "1000000.00"); + assert_eq!( + b.permissions, + Some(dsm_sdk::generated::TokenPolicyPermissions { + burn_enabled: true, + transferable: false, + }), + "the policy's permissions must survive encoding, present" + ); } other => panic!("unexpected payload {other:?}"), } diff --git a/dsm_client/frontend/public/index.html b/dsm_client/frontend/public/index.html index dfbf7d81b..5a0bd13d9 100644 --- a/dsm_client/frontend/public/index.html +++ b/dsm_client/frontend/public/index.html @@ -1812,7 +1812,6 @@ __binary: true, isAvailable: () => !!port, sendMessageBin, - startup: (payload) => callBridgeMethod('nativeBoundaryStartup', payload), ingress: (payload) => callBridgeMethod('nativeBoundaryIngress', payload), hostRequest: (payload) => callBridgeMethod('nativeHostRequest', payload), getBridgeStatus: () => (port ? 3 : 0), diff --git a/dsm_client/frontend/src/App.tsx b/dsm_client/frontend/src/App.tsx index ebc2da626..d6d68d344 100644 --- a/dsm_client/frontend/src/App.tsx +++ b/dsm_client/frontend/src/App.tsx @@ -9,7 +9,6 @@ import GlobalToast from './components/GlobalToast'; import BilateralTransferDialog from './components/BilateralTransferDialog'; import GuidedTour from './components/tour/GuidedTour'; import TourOffer from './components/tour/TourOffer'; -import { BleProvider } from './contexts/BleContext'; import ScreenContainer from './components/ScreenContainer'; import { useLockState } from './hooks/useLockState'; import { getLockPrefs } from './services/lock/lockService'; @@ -129,7 +128,6 @@ export default function App() { - @@ -168,7 +166,6 @@ export default function App() { - diff --git a/dsm_client/frontend/src/__tests__/App.errorBoundary.test.tsx b/dsm_client/frontend/src/__tests__/App.errorBoundary.test.tsx index 211fe3283..606581c80 100644 --- a/dsm_client/frontend/src/__tests__/App.errorBoundary.test.tsx +++ b/dsm_client/frontend/src/__tests__/App.errorBoundary.test.tsx @@ -29,10 +29,6 @@ jest.mock('../contexts/ContactsContext', () => ({ ContactsProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); -jest.mock('../contexts/BleContext', () => ({ - BleProvider: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - jest.mock('../inputs/providers/StateBoyInputProvider', () => ({ StateBoyInputProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); diff --git a/dsm_client/frontend/src/__tests__/App.lockPrompt.test.tsx b/dsm_client/frontend/src/__tests__/App.lockPrompt.test.tsx index 5b87ddf3b..2a76c16f3 100644 --- a/dsm_client/frontend/src/__tests__/App.lockPrompt.test.tsx +++ b/dsm_client/frontend/src/__tests__/App.lockPrompt.test.tsx @@ -16,10 +16,6 @@ jest.mock('../contexts/ContactsContext', () => ({ ContactsProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); -jest.mock('../contexts/BleContext', () => ({ - BleProvider: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - jest.mock('../bridge/BridgeProvider', () => ({ BridgeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); diff --git a/dsm_client/frontend/src/__tests__/envConfigError.test.tsx b/dsm_client/frontend/src/__tests__/envConfigError.test.tsx index 937734de7..03b9546e4 100644 --- a/dsm_client/frontend/src/__tests__/envConfigError.test.tsx +++ b/dsm_client/frontend/src/__tests__/envConfigError.test.tsx @@ -66,14 +66,18 @@ jest.mock('../dsm/WebViewBridge', () => ({ // Mock dsmClient.getPreference jest.mock('../services/dsmClient', () => ({ dsmClient: { - getIdentity: jest.fn().mockResolvedValue(null), + getIdentity: jest.fn().mockRejectedValue( + Object.assign(new Error('no identity on this device (native session: missing)'), { + name: 'IdentityUnavailableError', + state: 'missing', + }), + ), getPreference: jest.fn().mockImplementation(async (k: string) => { if (k === 'DSM_ENV_CONFIG_PATH') return '/data/user/0/app/files/dsm_env_config.toml'; if (k === 'genesis_hash_bytes') return 'deadbeef'; if (k === 'device_id_bytes') return 'cafebabe'; return null; }), - getBluetoothStatus: jest.fn().mockResolvedValue({ enabled: false, advertising: false, scanning: false }), getContacts: jest.fn().mockResolvedValue({ contacts: [] }), setPreference: jest.fn().mockResolvedValue(undefined), }, diff --git a/dsm_client/frontend/src/bridge/bridgeEvents.ts b/dsm_client/frontend/src/bridge/bridgeEvents.ts index 166f84aea..10b39998e 100644 --- a/dsm_client/frontend/src/bridge/bridgeEvents.ts +++ b/dsm_client/frontend/src/bridge/bridgeEvents.ts @@ -9,40 +9,28 @@ export type BridgeEventMap = { 'identity.ready': void; 'wallet.refresh': { source: string; [k: string]: any }; 'wallet.bilateralCommitted': { commitmentHash?: Uint8Array; counterpartyDeviceId?: Uint8Array; accepted?: boolean; committed?: boolean; rejected?: boolean }; - 'wallet.historyUpdated': void; - 'wallet.balancesUpdated': void; 'wallet.creditReceived': { source: string; tokenId?: string; amount?: bigint | string | number; nextBalance?: bigint | string | number; creditCount?: number }; - 'wallet.sendCommitted': { success?: boolean; tokenId?: string; newBalance?: bigint | string | number; transactionHash?: Uint8Array; toDeviceId?: Uint8Array; amount?: bigint | string | number }; 'dsm.deterministicSafety': { classification: string; message?: string }; - 'contact.added': { contact?: unknown }; 'contact.bleMapped': { address: string; deviceId?: string; genesisHash?: string }; 'contact.bleUpdated': { bleAddress?: string; alias?: string; deviceId?: string; genesisHash?: string }; 'bilateral.transferComplete': void; 'env.config.error': { message: string }; 'bridge.error': { code?: number; message: string; debugB32: string }; - 'port.tx': void; - 'port.rx': void; - 'ui.tick': void; 'inbox.open': { open: boolean }; 'inbox.updated': { newItems: number; source: string }; 'visibility.change': { state: DocumentVisibilityState }; 'ble.permission.error': { message: string }; - 'ble.permission.recovery.needed': void; - 'ble.features.disabled': void; 'ble.deviceFound': { address: string; name: string; rssi: number }; 'ble.scanStarted': void; 'ble.scanStopped': void; 'ble.deviceConnected': { address: string }; 'ble.deviceDisconnected': { address: string }; 'ble.connectionFailed': { reason: string }; - 'ble.advertisingStarted': void; - 'ble.advertisingStopped': void; 'ble.pairingStatus': { deviceId: string; status: string; message: string; bleAddress?: string }; 'deposit.completed': { depositId: string; amount: string }; 'wallet.exitCompleted': { source: string }; // NFC ring backup domain 'nfc.backupWritten': void; - 'nfc.writeStarted': void; }; type Handler = (payload: T) => void; diff --git a/dsm_client/frontend/src/bridge/nativeBridgeAdapter.ts b/dsm_client/frontend/src/bridge/nativeBridgeAdapter.ts index 1773ec054..51b357997 100644 --- a/dsm_client/frontend/src/bridge/nativeBridgeAdapter.ts +++ b/dsm_client/frontend/src/bridge/nativeBridgeAdapter.ts @@ -1,9 +1,11 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ // path: src/bridge/nativeBridgeAdapter.ts // SPDX-License-Identifier: Apache-2.0 +// Installs the event bridge and turns the one DOM signal the app reacts to — +// visibility — into a bus event. Every native lifecycle topic reaches the bus +// from the event bridge directly; the DOM hops that used to sit between them +// (and the ones nothing ever dispatched) are gone. import { initializeEventBridge } from '../dsm/EventBridge'; -import { DSM_WALLET_REFRESH_EVENT } from '../dsm/events'; import { bridgeEvents } from './bridgeEvents'; let installed = false; @@ -15,49 +17,8 @@ export function initializeNativeBridgeAdapter(): void { initializeEventBridge(); if (typeof document !== 'undefined') { - document.addEventListener('dsm-identity-ready', () => { - bridgeEvents.emit('identity.ready', undefined as any); - }); - - document.addEventListener('dsm-env-config-error', (evt: any) => { - const msg = String(evt?.detail?.message || 'Environment configuration error'); - bridgeEvents.emit('env.config.error', { message: msg }); - }); - - document.addEventListener('DSM_PORT_TX', () => bridgeEvents.emit('port.tx', undefined as any)); - document.addEventListener('DSM_PORT_RX', () => bridgeEvents.emit('port.rx', undefined as any)); - document.addEventListener('DSM_UI_TICK', () => bridgeEvents.emit('ui.tick', undefined as any)); - document.addEventListener('visibilitychange', () => { bridgeEvents.emit('visibility.change', { state: document.visibilityState }); }); } - - if (typeof window !== 'undefined') { - window.addEventListener('dsm-bilateral-committed', (evt: any) => { - bridgeEvents.emit('wallet.bilateralCommitted', evt?.detail ?? {}); - }); - - window.addEventListener(DSM_WALLET_REFRESH_EVENT, (evt: any) => { - const detail = evt?.detail ?? { source: 'unknown' }; - bridgeEvents.emit('wallet.refresh', detail); - }); - - window.addEventListener('dsm-history-updated', () => { - bridgeEvents.emit('wallet.historyUpdated', undefined as any); - }); - - window.addEventListener('dsm-balances-updated', () => { - bridgeEvents.emit('wallet.balancesUpdated', undefined as any); - }); - - window.addEventListener('dsm-wallet-send-committed', (evt: any) => { - bridgeEvents.emit('wallet.sendCommitted', evt?.detail ?? {}); - }); - - window.addEventListener('dsm-contact-added', (evt: any) => { - bridgeEvents.emit('contact.added', evt?.detail ?? {}); - }); - - } } diff --git a/dsm_client/frontend/src/components/__tests__/BilateralTransferDialog.transferComplete.test.tsx b/dsm_client/frontend/src/components/__tests__/BilateralTransferDialog.transferComplete.test.tsx index eaca8c4e1..580dd5afd 100644 --- a/dsm_client/frontend/src/components/__tests__/BilateralTransferDialog.transferComplete.test.tsx +++ b/dsm_client/frontend/src/components/__tests__/BilateralTransferDialog.transferComplete.test.tsx @@ -6,7 +6,8 @@ import { render } from '@testing-library/react'; import { emit } from '../../dsm/EventBridge'; import { BilateralTransferDialog } from '../BilateralTransferDialog'; import { bridgeEvents } from '../../bridge/bridgeEvents'; -import { BilateralEventType, encodeBilateralEventNotification } from '../../services/bilateral/bilateralEventService'; +import { BilateralEventType } from '../../services/bilateral/bilateralEventService'; +import { encodeBilateralEventNotification } from '../../tests/helpers/bilateralEventFixture'; jest.mock('../../contexts/UXContext', () => ({ useUX: () => ({ diff --git a/dsm_client/frontend/src/components/common/LoadingSpinner.tsx b/dsm_client/frontend/src/components/common/LoadingSpinner.tsx index 9fe280053..5ea14b113 100644 --- a/dsm_client/frontend/src/components/common/LoadingSpinner.tsx +++ b/dsm_client/frontend/src/components/common/LoadingSpinner.tsx @@ -2,7 +2,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { bridgeEvents } from '../../bridge/bridgeEvents'; interface LoadingSpinnerProps { message?: string; @@ -42,20 +41,6 @@ const LoadingSpinner: React.FC = ({ if (typeof tick === 'number') bump(); }, [tick, bump]); - // Advance on DSM activity events (transport, BLE, or explicit UI tick) - // Advance on DSM activity events (transport or explicit UI tick) - useEffect(() => { - const offTx = bridgeEvents.on('port.tx', () => bump()); - const offRx = bridgeEvents.on('port.rx', () => bump()); - const offUi = bridgeEvents.on('ui.tick', () => bump()); - - return () => { - offTx(); - offRx(); - offUi(); - }; - }, [bump]); - const sizeMap = useMemo( () => ({ diff --git a/dsm_client/frontend/src/components/fx/__tests__/fxEngine.test.ts b/dsm_client/frontend/src/components/fx/__tests__/fxEngine.test.ts index a5031dbbd..9aa9ec9cd 100644 --- a/dsm_client/frontend/src/components/fx/__tests__/fxEngine.test.ts +++ b/dsm_client/frontend/src/components/fx/__tests__/fxEngine.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -import { FX_ANIMS, FX_ENGINE_SRC, fxAmountLabel, isFxEngineReady, loadFxEngine, setFxMuted } from '../fxEngine'; +import { FX_ENGINE_SRC, fxAmountLabel, isFxEngineReady, loadFxEngine, setFxMuted } from '../fxEngine'; describe('fxEngine', () => { afterEach(() => { @@ -35,10 +35,4 @@ describe('fxEngine', () => { expect(fxAmountLabel(undefined)).toBeUndefined(); expect(fxAmountLabel('123456789012345678901 TOKEN', '+')).toHaveLength(17); }); - - it('names every scene the engine ships', () => { - expect([...FX_ANIMS].sort()).toEqual( - ['confirm', 'fail', 'intro', 'lock', 'pair', 'seal', 'tamper', 'trace', 'vault'], - ); - }); }); diff --git a/dsm_client/frontend/src/components/fx/fxEngine.ts b/dsm_client/frontend/src/components/fx/fxEngine.ts index c5f415b43..a35abd666 100644 --- a/dsm_client/frontend/src/components/fx/fxEngine.ts +++ b/dsm_client/frontend/src/components/fx/fxEngine.ts @@ -22,18 +22,6 @@ export type FxAnim = | 'tamper' | 'seal'; -export const FX_ANIMS: readonly FxAnim[] = [ - 'confirm', - 'fail', - 'intro', - 'trace', - 'lock', - 'vault', - 'pair', - 'tamper', - 'seal', -]; - declare global { interface Window { /** Engine-wide mute flag read by every fx-canvas instance. */ diff --git a/dsm_client/frontend/src/components/screens/AccountsScreen.tsx b/dsm_client/frontend/src/components/screens/AccountsScreen.tsx index 6e88b97ce..471c88753 100644 --- a/dsm_client/frontend/src/components/screens/AccountsScreen.tsx +++ b/dsm_client/frontend/src/components/screens/AccountsScreen.tsx @@ -31,34 +31,14 @@ export interface TokenBalance { anchorFingerprint?: string; /** The token policy's icon field, carried from Rust; the row draws the token's coin from it. */ iconUrl?: string; + /** Whether Rust reports the token as one the protocol defines. Never decided here from the ticker. */ + protocolDefined: boolean; + /** The whole supply that will ever exist, rendered by Rust; absent when Rust holds none. */ + genesisSupplyDisplay?: string; + /** What the committed policy permits, as Rust read it; absent when Rust holds no policy for the token. */ + permissions?: { burnEnabled: boolean; transferable: boolean }; } - -interface CptaInfo { - cptaType: string; - anchorId: string; - anchor: string; - maxSupply: string; - supplyLabel: string; -} - -const CPTA_INFO: Record = { - ERA: { - cptaType: 'DJTE EMISSION TOKEN', - anchorId: 'PROTOCOL-DEFINED', - anchor: 'BLAKE3("DSM/cpta\\0" || djte_emission_genesis)\nDeterministic Join-Triggered Emission. ERA has an 80 billion total supply.', - maxSupply: '80,000,000,000', - supplyLabel: 'Total Supply', - }, - DBTC: { - cptaType: 'BITCOIN TAP TOKEN', - anchorId: 'PROTOCOL-DEFINED', - anchor: 'BLAKE3("DSM/cpta\\0" || bitcoin_tap_genesis)\nMint/burn BTC tap asset. dBTC tracks the net BTC tapped into DSM; there is no fixed protocol cap. Fractional exits and possession transfers stay supported.', - maxSupply: 'Variable \u2014 net BTC tapped into DSM', - supplyLabel: 'Supply Model', - }, -}; - const SUPPLY_BTN: React.CSSProperties = { flex: 1, padding: '8px 10px', @@ -85,9 +65,10 @@ const AccountsScreen: React.FC<{ eraTokenSrc?: string; btcLogoSrc?: string }> = const [expandedToken, setExpandedToken] = useState(null); const faucetEnabled = !!isInitialized || !!(window as any).DsmBridge; - // Token creation and supply control. ERA and dBTC are protocol-defined, so - // they are described by CPTA_INFO and offer no supply controls; anything else - // in this list was created or adopted by this device and carries its own policy. + // Token creation and supply control. Rust reports which tokens the protocol + // defines; those offer no supply controls. Anything else in this list was + // created or adopted by this device and carries its own committed policy, + // whose facts Rust reports on the row. const [creating, setCreating] = useState(false); /// Adding a token created elsewhere, by its CPTA anchor. A device cannot /// hold a token whose policy it does not have, so this is the step between @@ -103,13 +84,8 @@ const AccountsScreen: React.FC<{ eraTokenSrc?: string; btcLogoSrc?: string }> = const [amount, setAmount] = useState(''); const [busy, setBusy] = useState(false); - const isProtocolToken = useCallback( - (b: TokenBalance) => - Boolean( - CPTA_INFO[b.tokenId.toUpperCase()] || CPTA_INFO[b.symbol.toUpperCase()], - ), - [], - ); + // Rust's word, never the ticker's: a created token may read "ERA". + const isProtocolToken = useCallback((b: TokenBalance) => b.protocolDefined, []); const hasBalances = useMemo(() => balances.length > 0, [balances]); @@ -137,6 +113,10 @@ const AccountsScreen: React.FC<{ eraTokenSrc?: string; btcLogoSrc?: string }> = policyAnchorB32: b.policyAnchorB32, anchorFingerprint: b.anchorFingerprint, iconUrl: b.iconUrl, + // What the token is and what its policy fixes and permits, as Rust reports them. + protocolDefined: b.protocolDefined, + genesisSupplyDisplay: b.genesisSupplyDisplay, + permissions: b.permissions, })); setBalances(list); return list; @@ -207,9 +187,8 @@ const AccountsScreen: React.FC<{ eraTokenSrc?: string; btcLogoSrc?: string }> = console.warn('AccountsScreen: refreshAll failed after faucet claim:', refreshErr); } // refreshAll() already updated WalletContext (balance + history). - // Do NOT emit wallet.refresh here — that would trigger 3 more RPCs for - // data we just fetched (useWalletSync balance+history, useWalletRefreshListener - // history again). + // Do NOT emit wallet.refresh here — that would reload, through the + // provider's listener, the data we just fetched. // What Rust released, in its words. setSuccessMsg(result.message); @@ -563,13 +542,16 @@ const AccountsScreen: React.FC<{ eraTokenSrc?: string; btcLogoSrc?: string }> = ) : (
{balances.map((balance, bIdx) => { + // The protocol's own artwork is for the protocol's own + // assets: which one is Rust's word plus the ticker, so a + // created token whose ticker contains "btc" draws its coin. const sym = balance.symbol.toLowerCase(); - const isBtc = sym.includes('btc') || sym.includes('dbtc'); + const isBtc = balance.protocolDefined && sym === 'dbtc'; + const isEra = balance.protocolDefined && sym === 'era'; const logoSrc = isBtc ? btcLogoSrc : eraTokenSrc; const logoAlt = isBtc ? 'BTC' : 'ERA'; const isFocused = focusedIndex === 2 + createOffset + bIdx; const isExpanded = expandedToken === balance.tokenId; - const cpta = CPTA_INFO[balance.tokenId.toUpperCase()] || CPTA_INFO[balance.symbol.toUpperCase()]; const isZero = balance.baseUnits === 0n; return (
= textTransform: 'uppercase', letterSpacing: 0.2, }}> - {isBtc || sym === 'era' ? ( + {isBtc || isEra ? ( {logoAlt} =
- {/* Expanded CPTA panel — dark bg */} - {isExpanded && cpta && ( + {/* Expanded policy panel — dark bg. Every line is a fact + Rust reports on the row; a line Rust does not report + is not drawn. */} + {isExpanded && (
=
{([ ['Your Balance', `${balance.balance} ${balance.symbol}`], - ['CPTA Type', cpta.cptaType], - // As Rust reports them for this token. + ['Defined By', balance.protocolDefined ? 'the protocol' : 'its creator’s committed policy'], ['Decimals', String(balance.decimals)], - [cpta.supplyLabel, cpta.maxSupply], - ['Anchor ID', cpta.anchorId], + ...(balance.genesisSupplyDisplay + ? [['Total Supply', `${balance.genesisSupplyDisplay} ${balance.symbol}`]] + : []), + ...(balance.permissions + ? [ + ['Burn', balance.permissions.burnEnabled ? 'permitted' : 'not permitted'], + ['Transfer', balance.permissions.transferable ? 'permitted' : 'not permitted'], + ] + : []), ] as [string, string][]).map(([label, value]) => (
=
))} -
- {cpta.anchor} -
)} @@ -778,13 +758,18 @@ const AccountsScreen: React.FC<{ eraTokenSrc?: string; btcLogoSrc?: string }> = ) : (
- + {/* Offered only where the committed policy + permits burning, as Rust read it; Rust + enforces the policy either way. */} + {balance.permissions?.burnEnabled && ( + + )} {/* Dropping the identity, not the asset. A ticker names one token, so a superseded token blocks its own ticker until it is forgotten. Rust diff --git a/dsm_client/frontend/src/components/screens/ContactsTabScreen.tsx b/dsm_client/frontend/src/components/screens/ContactsTabScreen.tsx index e4f43621e..5ae81fd27 100644 --- a/dsm_client/frontend/src/components/screens/ContactsTabScreen.tsx +++ b/dsm_client/frontend/src/components/screens/ContactsTabScreen.tsx @@ -121,26 +121,11 @@ const ContactsTabScreen: React.FC = ({ eraTokenSrc = 'images/logos/era_to flexShrink: 0, }; - // Listen for contact-added events - refresh and auto-switch to list. - // No loading overlay — the refresh is near-instant and the overlay - // just causes a visible flicker. + // Listen for BLE mapping events - refresh the list. No loading overlay — + // the refresh is near-instant and the overlay just causes a visible + // flicker. An added contact reaches this screen through the contacts + // store, which the add itself refreshes. useEffect(() => { - const handleContactAdded = (_e: Event) => { - if (CONTACTS_DEBUG) console.log('[ContactsTab] dsm-contact-added event received'); - - // Deterministic coalescing: no wall-clock debounce. - if (refreshPendingRef.current) return; - refreshPendingRef.current = true; - queueMicrotask(() => { - refreshPendingRef.current = false; - void (async () => { - await load('contact-added'); - // Auto-switch to list tab to show the new contact - setActiveTab('list'); - })(); - }); - }; - const offBleMapped = bridgeEvents.on('contact.bleMapped', () => { if (CONTACTS_DEBUG) console.log('[ContactsTab] contact.bleMapped event received'); if (refreshPendingRef.current) return; @@ -161,14 +146,9 @@ const ContactsTabScreen: React.FC = ({ eraTokenSrc = 'images/logos/era_to }); }); - const offContactAdded = bridgeEvents.on('contact.added', () => { - handleContactAdded(new Event('contact.added')); - }); - return () => { offBleMapped(); offBleUpdated(); - offContactAdded(); }; }, [load]); @@ -484,7 +464,7 @@ const ContactsTabScreen: React.FC = ({ eraTokenSrc = 'images/logos/era_to ) : (
{contacts.map((c, i) => ( -
+
setSelected(selected === i ? null : i)} @@ -538,7 +518,7 @@ const ContactsTabScreen: React.FC = ({ eraTokenSrc = 'images/logos/era_to boxSizing: 'border-box', }}>
- {c.bleAddress ? 'BLE PAIRED' : c.isVerified ? 'VERIFIED' : 'NOT VERIFIED'} + {c.bleAddress ? 'BLE PAIRED' : c.genesisVerifiedOnline ? 'VERIFIED' : 'NOT VERIFIED'}
@@ -555,11 +535,11 @@ const ContactsTabScreen: React.FC = ({ eraTokenSrc = 'images/logos/era_to
Pub Key - {c.publicKey.length > 24 ? `${c.publicKey.slice(0, 12)}...${c.publicKey.slice(-10)}` : c.publicKey} + {c.signingPublicKey.length > 24 ? `${c.signingPublicKey.slice(0, 12)}...${c.signingPublicKey.slice(-10)}` : c.signingPublicKey}
Verified - {c.isVerified ? 'YES' : 'NO'} + {c.genesisVerifiedOnline ? 'YES' : 'NO'}
diff --git a/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx b/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx index 1d0466ae4..3dc67681a 100644 --- a/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx +++ b/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx @@ -161,6 +161,7 @@ const EnhancedWalletScreen: React.FC = ({ btcLogoSrc, {activeTab === 'overview' && ( { // Rust persisted it; the next registry read is what must reveal it. (dsmClient.getAllBalances as jest.Mock).mockResolvedValue([ ...balances, - row({ tokenId: 'RIGB', baseUnits: 0n, displayAmount: '0' }), + row({ tokenId: 'RIGB', baseUnits: 0n, displayAmount: '0', ...CREATED }), ]); return { success: true, ticker: 'RIGB', tokenId: 'Z68HWMYS' }; }); @@ -384,6 +404,7 @@ describe('AccountsScreen — the screen TOKENS actually opens', () => { canonicalTokenId: 'Z68HWMYSPT9B', policyAnchorB32: REAL_ANCHOR, anchorFingerprint: REAL_ANCHOR.slice(0, 8), + ...CREATED, }), ]); @@ -416,4 +437,68 @@ describe('AccountsScreen — the screen TOKENS actually opens', () => { expect(await screen.findByText(/TICKER_CONFLICT/)).toBeInTheDocument(); }); + + /// Every line of the policy panel is a fact Rust reports on the row. The + /// screen used to carry its own table of what ERA and dBTC are. + it("shows a created token's supply and what its policy permits, as Rust reports them", async () => { + (dsmClient.getAllBalances as jest.Mock).mockResolvedValue(balances); + render(); + fireEvent.click(await screen.findByText('MYTOK')); + expect((await screen.findByText('Total Supply')).nextElementSibling).toHaveTextContent('1000 MYTOK'); + expect(screen.getByText('Burn').nextElementSibling).toHaveTextContent(/^permitted$/); + expect(screen.getByText('Transfer').nextElementSibling).toHaveTextContent(/^permitted$/); + expect(screen.getByText('Defined By').nextElementSibling).toHaveTextContent(/creator/); + }); + + /// A protocol asset's supply is what Rust reports; Rust holds no policy blob + /// for ERA and states no permissions, so the panel draws none. + it("shows a protocol asset's supply from Rust and states nothing Rust does not", async () => { + (dsmClient.getAllBalances as jest.Mock).mockResolvedValue(balances); + render(); + fireEvent.click(await screen.findByText('ERA')); + expect((await screen.findByText('Total Supply')).nextElementSibling).toHaveTextContent('80000000000 ERA'); + expect(screen.getByText('Defined By').nextElementSibling).toHaveTextContent('the protocol'); + expect(screen.queryByText('Burn')).toBeNull(); + expect(screen.queryByText('Transfer')).toBeNull(); + }); + + /// BURN is offered only where the committed policy permits it, as Rust read + /// it. FORGET is not a policy action and stays. + it('offers no BURN where the policy forbids it, and says so', async () => { + (dsmClient.getAllBalances as jest.Mock).mockResolvedValue([ + row({ + tokenId: 'NOBURN', + baseUnits: 5n, + displayAmount: '5', + ...CREATED, + permissions: { burnEnabled: false, transferable: true }, + }), + ]); + render(); + fireEvent.click(await screen.findByText('NOBURN')); + expect((await screen.findByText('Burn')).nextElementSibling).toHaveTextContent(/^not permitted$/); + expect(screen.queryByRole('button', { name: /^BURN$/ })).toBeNull(); + expect(screen.getByRole('button', { name: /^FORGET$/ })).toBeInTheDocument(); + }); + + /// What a token is comes from Rust, never from its ticker: a created token + /// whose ticker reads ERA gets its supply controls, not the protocol asset's + /// treatment. + it('takes a token for a protocol asset only on Rust’s word, never on its ticker', async () => { + (dsmClient.getAllBalances as jest.Mock).mockResolvedValue([ + row({ + tokenId: 'ERA', + baseUnits: 5n, + displayAmount: '5', + ...CREATED, + policyAnchorB32: MYTOK_ANCHOR, + anchorFingerprint: MYTOK_ANCHOR.slice(0, 8), + }), + ]); + render(); + fireEvent.click(await screen.findByText('ERA')); + expect(await screen.findByRole('button', { name: /^BURN$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^FORGET$/ })).toBeInTheDocument(); + expect(screen.getByText('Defined By').nextElementSibling).toHaveTextContent(/creator/); + }); }); diff --git a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.contactRow.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.contactRow.test.tsx index c9fc129fb..c2ba1f92f 100644 --- a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.contactRow.test.tsx +++ b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.contactRow.test.tsx @@ -33,12 +33,11 @@ describe('ContactsTabScreen contact row', () => { it('labels a contact neither paired nor verified as not verified, and shows what Rust listed', async () => { mockContacts.push({ - id: 'DEV1CE', alias: 'bob', deviceId: 'DEV1CE', genesisHash: 'GENES1S', - publicKey: 'PUBKEY', - isVerified: false, + signingPublicKey: 'PUBKEY', + genesisVerifiedOnline: false, }); await act(async () => { diff --git a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx index f9e49721e..561ba3534 100644 --- a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx +++ b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx @@ -33,12 +33,11 @@ jest.mock('../../../contexts/ContactsContext', () => { return { contacts: [ { - id: deviceIdB32, alias: 'peer', deviceId: deviceIdB32, genesisHash: encodeBase32Crockford32(new Uint8Array(32)), - publicKey: encodeBase32Crockford32(new Uint8Array(32).fill(7)), - isVerified: true, + signingPublicKey: encodeBase32Crockford32(new Uint8Array(32).fill(7)), + genesisVerifiedOnline: true, bleAddress: 'AA:BB:CC:DD:EE:FF', }, ], diff --git a/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx index 0ce3ce540..b9aec62f8 100644 --- a/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx +++ b/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx @@ -7,6 +7,44 @@ import EnhancedWalletScreen from '../EnhancedWalletScreen'; import { dsmClient } from '../../../services/dsmClient'; import { bridgeEvents } from '../../../bridge/bridgeEvents'; import { encodeBase32Crockford } from '../../../utils/textId'; +import { UXProvider } from '../../../contexts/UXContext'; +import { WalletProvider } from '../../../contexts/WalletContext'; +import { walletStore } from '../../../stores/walletStore'; +import { contactsStore } from '../../../stores/contactsStore'; + +/** + * The screen as the app mounts it: inside the wallet provider, whose store is + * its balances and history; the contacts store, its contacts, loaded here as + * the contacts provider loads it once the identity is ready. + */ +async function renderWallet() { + const rendered = render( + + + + + , + ); + await act(async () => { + await contactsStore.refreshContacts(); + }); + return rendered; +} + +/** The store is a module singleton; each test starts it empty. */ +function resetWalletStore() { + (walletStore as any).snapshot = { + genesisHash: null, + deviceId: null, + balances: [], + transactions: [], + isInitialized: false, + isLoading: false, + error: null, + }; + (walletStore as any).loadingCount = 0; + (walletStore as any).hasObservedBalances = false; +} jest.mock('../../../services/bitcoinTap', () => ({ formatBtc: (v: bigint | string | number) => String(v), @@ -26,7 +64,6 @@ function contactDto(alias: string, fill: number, bleAddress?: string) { } function installStandardWalletMocks(contactList: any[] = []) { - (dsmClient.isReady as any) = jest.fn().mockResolvedValue(true); (dsmClient.getIdentity as any) = jest.fn().mockResolvedValue({ genesisHash: 'G'.repeat(32), deviceId: 'D'.repeat(32), @@ -37,12 +74,12 @@ function installStandardWalletMocks(contactList: any[] = []) { describe('EnhancedWalletScreen event-driven refresh', () => { beforeEach(() => { jest.restoreAllMocks(); + resetWalletStore(); (dsmClient.getInbox as any) = jest.fn().mockResolvedValue({ items: [] }); }); test('reloads transactions when dsm-wallet-refresh is dispatched', async () => { // Prepare identity to satisfy loadWalletData - (dsmClient.isReady as any) = jest.fn().mockResolvedValue(true); (dsmClient.getIdentity as any) = jest.fn().mockResolvedValue({ genesisHash: 'G'.repeat(32), deviceId: 'D'.repeat(32) }); // getAllBalances: first empty, then updated @@ -58,7 +95,7 @@ describe('EnhancedWalletScreen event-driven refresh', () => { // Minimal contacts and BLE functions used by loadWalletData (dsmClient.getContacts as any) = jest.fn().mockResolvedValue({ contacts: [] }); - render(); + await renderWallet(); // wait for initial load(s) to complete (bridge-ready retry may cause 2 calls) await waitFor(() => expect((dsmClient.getWalletHistory as any).mock.calls.length).toBeGreaterThanOrEqual(1)); @@ -81,7 +118,6 @@ describe('EnhancedWalletScreen event-driven refresh', () => { test('offline send submits through sendOfflineTransfer', async () => { const contact = contactDto('Receiver', 0x0a, 'AA:BB:CC:DD:EE:FF'); - (dsmClient.isReady as any) = jest.fn().mockResolvedValue(true); (dsmClient.getIdentity as any) = jest .fn() .mockResolvedValue({ genesisHash: 'G'.repeat(32), deviceId: 'D'.repeat(32) }); @@ -93,7 +129,7 @@ describe('EnhancedWalletScreen event-driven refresh', () => { (dsmClient.resolveBleAddressForContact as any) = jest.fn().mockResolvedValue(contact.bleAddress); (dsmClient.sendOfflineTransfer as any) = jest.fn().mockResolvedValue({ success: true }); - render(); + await renderWallet(); await waitFor(() => expect(screen.getByText('DSM Wallet')).toBeInTheDocument()); @@ -142,7 +178,7 @@ describe('EnhancedWalletScreen event-driven refresh', () => { return { success: true, message: 'ok', newBalance: 75n }; }); - render(); + await renderWallet(); await waitFor(() => expect(screen.getByText('100')).toBeInTheDocument()); @@ -180,7 +216,7 @@ describe('EnhancedWalletScreen event-driven refresh', () => { return { accepted: true, result: 'Bilateral transfer complete' }; }); - render(); + await renderWallet(); await waitFor(() => expect(screen.getByText('80')).toBeInTheDocument()); @@ -225,7 +261,7 @@ describe('EnhancedWalletScreen event-driven refresh', () => { (dsmClient.getAllBalances as any) = jest.fn().mockImplementation(async () => balancesState); (dsmClient.getWalletHistory as any) = jest.fn().mockImplementation(async () => ({ transactions: historyState })); - render(); + await renderWallet(); await waitFor(() => expect(screen.getByText('40')).toBeInTheDocument()); @@ -244,7 +280,6 @@ describe('EnhancedWalletScreen event-driven refresh', () => { }); test('inbox check loads preview items without full storage sync', async () => { - (dsmClient.isReady as any) = jest.fn().mockResolvedValue(true); (dsmClient.getIdentity as any) = jest .fn() .mockResolvedValue({ genesisHash: 'G'.repeat(32), deviceId: 'D'.repeat(32) }); @@ -258,7 +293,7 @@ describe('EnhancedWalletScreen event-driven refresh', () => { items: [{ id: 'inbox-1', preview: 'Incoming online transfer 25 ERA', isStaleRoute: false }], }); - render(); + await renderWallet(); await waitFor(() => expect(screen.getByText('DSM Wallet')).toBeInTheDocument()); @@ -272,7 +307,6 @@ describe('EnhancedWalletScreen event-driven refresh', () => { }); test('inbox badge updates before the user opens the inbox', async () => { - (dsmClient.isReady as any) = jest.fn().mockResolvedValue(true); (dsmClient.getIdentity as any) = jest .fn() .mockResolvedValue({ genesisHash: 'G'.repeat(32), deviceId: 'D'.repeat(32) }); @@ -283,7 +317,7 @@ describe('EnhancedWalletScreen event-driven refresh', () => { (dsmClient.getWalletHistory as any) = jest.fn().mockResolvedValue({ transactions: [] }); (dsmClient.getInbox as any) = jest.fn().mockResolvedValue({ items: [] }); - render(); + await renderWallet(); await waitFor(() => expect(screen.getByText('DSM Wallet')).toBeInTheDocument()); @@ -295,11 +329,65 @@ describe('EnhancedWalletScreen event-driven refresh', () => { expect(button.className).toContain('has-items'); }); + // The refresh button re-reads both stores: balances and history, and the + // contacts the send tab offers. + test('the refresh button re-reads balances, history and contacts', async () => { + installStandardWalletMocks(); + (dsmClient.getAllBalances as any) = jest + .fn() + .mockResolvedValue([{ tokenId: 'ERA', symbol: 'ERA', baseUnits: 100n, displayAmount: '100', decimals: 0, protocolDefined: true }]); + (dsmClient.getWalletHistory as any) = jest.fn().mockResolvedValue({ transactions: [] }); + + await renderWallet(); + await waitFor(() => expect(screen.getByText('DSM Wallet')).toBeInTheDocument()); + const balancesBefore = (dsmClient.getAllBalances as jest.Mock).mock.calls.length; + const historyBefore = (dsmClient.getWalletHistory as jest.Mock).mock.calls.length; + const contactsBefore = (dsmClient.getContacts as jest.Mock).mock.calls.length; + + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + + await waitFor(() => { + expect((dsmClient.getAllBalances as jest.Mock).mock.calls.length).toBe(balancesBefore + 1); + expect((dsmClient.getWalletHistory as jest.Mock).mock.calls.length).toBe(historyBefore + 1); + expect((dsmClient.getContacts as jest.Mock).mock.calls.length).toBe(contactsBefore + 1); + }); + }); + + // One inbox sync with new items is one reload. The event bridge announces + // it as `inbox.updated` and one `wallet.refresh`; the screen used to reload + // on both, and on `bilateral.transferComplete` beside `wallet.refresh` too. + test('one inbox sync with new items reloads the wallet data once', async () => { + installStandardWalletMocks(); + (dsmClient.getAllBalances as any) = jest + .fn() + .mockResolvedValue([{ tokenId: 'ERA', symbol: 'ERA', baseUnits: 100n, displayAmount: '100', decimals: 0, protocolDefined: true }]); + (dsmClient.getWalletHistory as any) = jest.fn().mockResolvedValue({ transactions: [] }); + + await renderWallet(); + await waitFor(() => expect(dsmClient.getAllBalances).toHaveBeenCalledTimes(1)); + + await act(async () => { + bridgeEvents.emit('inbox.updated', { newItems: 1, source: 'rust_poller' }); + bridgeEvents.emit('wallet.refresh', { source: 'inbox.sync' }); + }); + await waitFor(() => expect(dsmClient.getAllBalances).toHaveBeenCalledTimes(2)); + // A second reload would follow within a frame or two; none does. + await act(async () => { await new Promise((r) => setTimeout(r, 80)); }); + expect(dsmClient.getAllBalances).toHaveBeenCalledTimes(2); + + await act(async () => { + bridgeEvents.emit('wallet.refresh', { source: 'bilateral.transfer_complete' }); + bridgeEvents.emit('bilateral.transferComplete', undefined as any); + }); + await waitFor(() => expect(dsmClient.getAllBalances).toHaveBeenCalledTimes(3)); + await act(async () => { await new Promise((r) => setTimeout(r, 80)); }); + expect(dsmClient.getAllBalances).toHaveBeenCalledTimes(3); + }); + // The overlay lists what Rust found, including an item Rust marked as found // on the previous-tip route; it used to drop those and keep a label nothing // could reach. test('an item Rust marked stale-route is listed with that marking', async () => { - (dsmClient.isReady as any) = jest.fn().mockResolvedValue(true); (dsmClient.getIdentity as any) = jest .fn() .mockResolvedValue({ genesisHash: 'G'.repeat(32), deviceId: 'D'.repeat(32) }); @@ -315,7 +403,7 @@ describe('EnhancedWalletScreen event-driven refresh', () => { ], }); - render(); + await renderWallet(); await waitFor(() => expect(screen.getByText('DSM Wallet')).toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: /Inbox/ })); @@ -327,7 +415,6 @@ describe('EnhancedWalletScreen event-driven refresh', () => { // The bilateral transfer dialog stands aside while the inbox is open; it // learns of it from `inbox.open`, which nothing emitted before. test('opening and closing the inbox announces inbox.open', async () => { - (dsmClient.isReady as any) = jest.fn().mockResolvedValue(true); (dsmClient.getIdentity as any) = jest .fn() .mockResolvedValue({ genesisHash: 'G'.repeat(32), deviceId: 'D'.repeat(32) }); @@ -340,7 +427,7 @@ describe('EnhancedWalletScreen event-driven refresh', () => { const opened = jest.fn(); const off = bridgeEvents.on('inbox.open', opened as any); - render(); + await renderWallet(); await waitFor(() => expect(screen.getByText('DSM Wallet')).toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: /Inbox/ })); expect(opened).toHaveBeenLastCalledWith({ open: true }); diff --git a/dsm_client/frontend/src/components/screens/wallet/OverviewTab.tsx b/dsm_client/frontend/src/components/screens/wallet/OverviewTab.tsx index cbfc372f5..b14fb8b55 100644 --- a/dsm_client/frontend/src/components/screens/wallet/OverviewTab.tsx +++ b/dsm_client/frontend/src/components/screens/wallet/OverviewTab.tsx @@ -3,14 +3,16 @@ import React, { useState, useMemo, useCallback } from 'react'; import TransactionItem from './TransactionItem'; import { Disclosure } from '../../common/ScreenFrame'; -import type { Balance } from './helpers'; +import type { TokenBalanceView } from '../../../dsm/types'; import { TokenMark } from '../../TokenMark'; import type { DomainTransaction } from '../../../domain/types'; const MAX_OVERVIEW_BALANCES = 5; type Props = { - balances: Balance[]; + balances: TokenBalanceView[]; + /** The store has not answered balances yet: neither a holding nor an empty wallet is known. */ + balancesLoading: boolean; transactions: DomainTransaction[]; genesisB32: string; deviceB32: string; @@ -18,7 +20,7 @@ type Props = { onSwitchToHistory: () => void; }; -function OverviewTabInner({ balances, transactions, genesisB32, deviceB32, onSwitchToSend, onSwitchToHistory }: Props): React.JSX.Element { +function OverviewTabInner({ balances, balancesLoading, transactions, genesisB32, deviceB32, onSwitchToSend, onSwitchToHistory }: Props): React.JSX.Element { const [showAllBalances, setShowAllBalances] = useState(false); const [expandedTxId, setExpandedTxId] = useState(null); @@ -39,7 +41,9 @@ function OverviewTabInner({ balances, transactions, genesisB32, deviceB32, onSwi
Your Balances
- {balances.length === 0 ? ( + {balancesLoading ? ( +
Loading balances{'…'}
+ ) : balances.length === 0 ? ( <>
No balances yet. Claim tokens from the faucet to get started.
@@ -51,7 +55,7 @@ function OverviewTabInner({ balances, transactions, genesisB32, deviceB32, onSwi {b.symbol} - {b.balance} + {b.displayAmount}
))} {balances.length > MAX_OVERVIEW_BALANCES && ( diff --git a/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx b/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx index 6f27a4b80..6ee83f7c7 100644 --- a/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx +++ b/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx @@ -10,12 +10,12 @@ import { Notice } from '../../common/ScreenFrame'; import { InfoTip } from '../../common/InfoTip'; import { useFx } from '../../fx/FxProvider'; import { fxAmountLabel } from '../../fx/fxEngine'; -import type { Balance } from './helpers'; +import type { TokenBalanceView } from '../../../dsm/types'; import type { DomainContact } from '../../../domain/types'; type Props = { contacts: DomainContact[]; - balances: Balance[]; + balances: TokenBalanceView[]; onCancel: () => void; onSendComplete: () => void; loadWalletData: () => Promise; @@ -47,7 +47,7 @@ function SendTabInner({ // Only what Rust listed. With no balances there is nothing to send, and the // form says so rather than offering a token the wallet does not hold. - const tokenOptions: Balance[] = balances; + const tokenOptions: TokenBalanceView[] = balances; const selectedSendBalance = useMemo( () => tokenOptions.find((b) => b.tokenId === sendForm.token) ?? null, @@ -188,7 +188,7 @@ function SendTabInner({ {selectedSendBalance.symbol} - {selectedSendBalance.balance} + {selectedSendBalance.displayAmount}
diff --git a/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offline.test.tsx b/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offline.test.tsx index 66df28fb5..c83fecfd2 100644 --- a/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offline.test.tsx +++ b/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offline.test.tsx @@ -31,7 +31,7 @@ function sendOffline(props: { setError: jest.Mock; onSendComplete: jest.Mock }) render( { it('shows the selected token and its balance as Rust rendered them', () => { render( , ); diff --git a/dsm_client/frontend/src/components/screens/wallet/helpers.ts b/dsm_client/frontend/src/components/screens/wallet/helpers.ts index 3a778a58f..73e963d14 100644 --- a/dsm_client/frontend/src/components/screens/wallet/helpers.ts +++ b/dsm_client/frontend/src/components/screens/wallet/helpers.ts @@ -2,19 +2,6 @@ // Shared helpers and types for the wallet screen components. import type { DomainTransaction, DomainTxType } from '../../../domain/types'; -// Local UI types -export type Balance = { - tokenId: string; - symbol: string; - /** Display form, rendered by Rust from the token's decimals. */ - balance: string; - decimals: number; - /** The token policy's icon field, carried from Rust. */ - iconUrl?: string; - /** CPTA policy anchor, Base32 Crockford, rendered by Rust. Carried, never derived. */ - policyAnchorB32?: string; -}; - /** The badge a history row's type shows. */ export function txTypeLabel(txType: DomainTxType): string { switch (txType) { diff --git a/dsm_client/frontend/src/components/screens/wallet/hooks/useWalletScreenData.ts b/dsm_client/frontend/src/components/screens/wallet/hooks/useWalletScreenData.ts index 8a32683b9..831ea3ba7 100644 --- a/dsm_client/frontend/src/components/screens/wallet/hooks/useWalletScreenData.ts +++ b/dsm_client/frontend/src/components/screens/wallet/hooks/useWalletScreenData.ts @@ -1,19 +1,24 @@ // SPDX-License-Identifier: Apache-2.0 -// Data loading hook for the wallet screen — identity, balances, contacts, transactions. -import { useState, useCallback, useEffect, useRef } from 'react'; +// The wallet screen's data. Balances and history are the wallet store's and +// contacts are the contacts store's — the one copy of each, reloaded in the +// providers — and this hook reads them; it used to hold second copies and +// reload them beside the stores', so one wallet change was two reads of +// `balance.list` and `wallet.history`. What the screen owns is the identity, +// read once, or the reason it was not. +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { dsmClient } from '../../../../services/dsmClient'; -import { useWalletRefreshListener } from '../../../../hooks/useWalletRefreshListener'; -import { bridgeEvents } from '../../../../bridge/bridgeEvents'; -import type { Balance } from '../helpers'; +import { useWallet } from '../../../../contexts/WalletContext'; +import { contactsStore, useContactsStore } from '../../../../stores/contactsStore'; +import type { TokenBalanceView } from '../../../../dsm/types'; import type { DomainContact, DomainIdentity, DomainTransaction } from '../../../../domain/types'; -import { mapContactList } from '../../../../domain/mappers'; -import logger from '../../../../utils/logger'; export type WalletScreenData = { identity: DomainIdentity | null; genesisB32: string; deviceB32: string; - balances: Balance[]; + balances: TokenBalanceView[]; + /** The store has not answered balances yet; nothing is known either way. */ + balancesLoading: boolean; contacts: DomainContact[]; transactions: DomainTransaction[]; loading: boolean; @@ -29,158 +34,37 @@ export type WalletScreenData = { }; export function useWalletScreenData(activeTab: string): WalletScreenData { + const wallet = useWallet(); + const contactsState = useContactsStore(); const [identity, setIdentity] = useState(null); - const [genesisB32, setGenesisB32] = useState(''); - const [deviceB32, setDeviceB32] = useState(''); - const [balances, setBalances] = useState([]); - const [contacts, setContacts] = useState([]); - const [transactions, setTransactions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [warning, setWarning] = useState(null); + const [warningDismissed, setWarningDismissed] = useState(null); const [refreshing, setRefreshing] = useState(false); const [touchFeedback, setTouchFeedback] = useState<'refreshed' | 'copied' | 'transaction_sent' | 'b0x_checked' | null>(null); - const inFlightLoadRef = useRef | null>(null); - const reloadQueuedRef = useRef(false); - const hasLoadedOnceRef = useRef(false); - - const performWalletDataLoad = useCallback(async () => { - // Only show the full-screen "Loading wallet…" spinner on the very first - // load. Subsequent refreshes keep the current UI visible so the screen - // never flashes back to the spinner during BLE bilateral transfers — - // which emit wallet.refresh rapidly. - if (!hasLoadedOnceRef.current) setLoading(true); - const keepLoading = false; + // The identity, answered or refused with its reason: missing, runtime not + // ready, or not read. The screen shows the reason. + const loadIdentity = useCallback(async () => { try { setError(null); - setWarning(null); - const warnings: string[] = []; - const id = await dsmClient.getIdentity(); - if (!id || !id.genesisHash || !id.deviceId) { - throw new Error('Identity not initialized'); - } - setIdentity(id); - setGenesisB32(id.genesisHash); - setDeviceB32(id.deviceId); - - // Inbox sync is handled by the background poller (inbox_poller.rs). - // Do NOT call syncWithStorage here — it blocks the Kotlin bridge thread - // for 2-3 minutes while polling 6 storage nodes, which prevents ALL - // other bridge calls (balance.list, wallet.history) from completing. - // When the poller finds new transfers, it emits inbox.updated which - // triggers a wallet.refresh via useWalletRefreshListener. - - try { - const list = await dsmClient.getContacts(); - const snapshot = typeof (dsmClient as unknown as { getBleIdentitySnapshot?: () => { deviceIds: Record; genesis: Record } }).getBleIdentitySnapshot === 'function' - ? (dsmClient as unknown as { getBleIdentitySnapshot: () => { deviceIds: Record; genesis: Record } }).getBleIdentitySnapshot() - : undefined; - const normalized: DomainContact[] = mapContactList(list.contacts, snapshot); - setContacts(normalized); - } catch (e) { - warnings.push(e instanceof Error ? e.message : 'Failed to load contacts'); - } - - try { - const bal = await dsmClient.getAllBalances(); - const eraTokens: Balance[] = bal - .filter((b) => b.tokenId.toUpperCase() !== 'BTC_CHAIN') - .map((b) => ({ - tokenId: b.tokenId, - symbol: b.symbol, - // Rust renders every token from its own decimals, so there is no - // special case here any more. dBTC used to be the ONLY token this - // scaled, which is exactly why a created token showed its base - // units: 100000 where the protocol held 1,000.00. - balance: b.displayAmount, - decimals: b.decimals, - // Both are carried, never derived. The icon is the artwork the - // token was created with, which is how a screen draws its coin; - // the anchor is the token's identity, which is what Swap trades - // against. Dropping them here left Swap with an empty token list - // and every custom token wearing a generic coin. - iconUrl: b.iconUrl, - policyAnchorB32: b.policyAnchorB32, - })); - setBalances(eraTokens); - } catch (e) { - warnings.push(e instanceof Error ? e.message : 'Failed to load balances'); - } - - try { - const history = await dsmClient.getWalletHistory(); - if (history && Array.isArray(history.transactions)) { - setTransactions(history.transactions); - } - } catch (e) { - warnings.push(e instanceof Error ? e.message : 'Failed to load transactions'); - } - - if (warnings.length > 0) { - setWarning(warnings.join(' \u2022 ')); - } + setIdentity(await dsmClient.getIdentity()); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load'); } finally { - if (!keepLoading) { - hasLoadedOnceRef.current = true; - setLoading(false); - } + setLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const loadWalletData = useCallback(async () => { - if (inFlightLoadRef.current) { - reloadQueuedRef.current = true; - await inFlightLoadRef.current; - return; - } - - do { - reloadQueuedRef.current = false; - const run = performWalletDataLoad(); - inFlightLoadRef.current = run; - try { - await run; - } finally { - inFlightLoadRef.current = null; - } - } while (reloadQueuedRef.current); - }, [performWalletDataLoad]); - - useEffect(() => { void loadWalletData(); }, [loadWalletData]); - - // Listen for wallet refresh events with RAF coalescing to avoid bridge spam. - useWalletRefreshListener(() => void loadWalletData(), [loadWalletData]); + useEffect(() => { void loadIdentity(); }, [loadIdentity]); - // Direct bilateral transfer-complete listener — bypasses the wallet.refresh - // throttle chain. When a BLE bilateral transfer completes on the receiver, - // the event chain (Rust → JNI → Kotlin → MessagePort → EventBridge) emits - // bilateral.transferComplete. This direct subscription ensures the wallet - // data reloads even if the wallet.refresh → useWalletRefreshListener path - // drops the event (e.g. during cooldown or RAF scheduling edge cases). - useEffect(() => { - const unsub = bridgeEvents.on('bilateral.transferComplete', () => { - logger.debug('[useWalletScreenData] bilateral.transferComplete -> reloading wallet data'); - void loadWalletData(); - }); - return unsub; - }, [loadWalletData]); - - // Reload when inbox sync applies new transfers (online receive path). - // storage.sync → apply_operation → inbox.updated event → reload balances. - useEffect(() => { - const unsub = bridgeEvents.on('inbox.updated', (detail) => { - const newItems = typeof detail?.newItems === 'number' ? detail.newItems : 0; - if (newItems > 0) { - logger.debug(`[useWalletScreenData] inbox.updated (${newItems} new) -> reloading wallet data`); - void loadWalletData(); - } - }); - return unsub; - }, [loadWalletData]); + // A manual refresh reloads both stores. Balances and history otherwise + // reload on `wallet.refresh`, in the wallet provider, once per change; + // contacts on the contact events, in the contacts provider. + const { refreshAll } = wallet; + const loadWalletData = useCallback(async () => { + await Promise.all([refreshAll(), contactsStore.refreshContacts()]); + }, [refreshAll]); // Reload when leaving bitcoin tab const activeTabRef = useRef(activeTab); @@ -206,13 +90,29 @@ export function useWalletScreenData(activeTab: string): WalletScreenData { return () => clearTimeout(id); }, [touchFeedback]); + // What the stores report failed, as they word it; dismissable until it changes. + const contactsError = contactsState.error; + const warning = useMemo(() => { + const parts = [wallet.error, contactsError].filter((w): w is string => Boolean(w)); + const joined = parts.length > 0 ? parts.join(' • ') : null; + return joined && joined !== warningDismissed ? joined : null; + }, [wallet.error, contactsError, warningDismissed]); + const setWarning = useCallback((next: string | null) => { + if (next === null) { + setWarningDismissed([wallet.error, contactsError].filter(Boolean).join(' • ') || null); + } + }, [wallet.error, contactsError]); + + const contacts: DomainContact[] = contactsState.contacts; + return { identity, - genesisB32, - deviceB32, - balances, + genesisB32: identity?.genesisHash ?? '', + deviceB32: identity?.deviceId ?? '', + balances: wallet.balances, + balancesLoading: wallet.isLoading && wallet.balances.length === 0, contacts, - transactions, + transactions: wallet.transactions, loading, error, warning, diff --git a/dsm_client/frontend/src/components/tour/practiceMode.ts b/dsm_client/frontend/src/components/tour/practiceMode.ts index 709c21c80..672fef30a 100644 --- a/dsm_client/frontend/src/components/tour/practiceMode.ts +++ b/dsm_client/frontend/src/components/tour/practiceMode.ts @@ -52,8 +52,8 @@ function freshState(): PracticeState { deviceId: practiceId('PRACT1CEY0VDEV1CE'), }, balances: [ - { tokenId: 'ERA', tokenName: 'ERA', symbol: 'ERA', decimals: 0, baseUnits: BigInt(1000), displayAmount: '1000' }, - { tokenId: 'PLAY', tokenName: 'Practice Coin', symbol: 'PLAY', decimals: 0, baseUnits: BigInt(50), displayAmount: '50' }, + { tokenId: 'ERA', tokenName: 'ERA', symbol: 'ERA', decimals: 0, baseUnits: BigInt(1000), displayAmount: '1000', protocolDefined: true }, + { tokenId: 'PLAY', tokenName: 'Practice Coin', symbol: 'PLAY', decimals: 0, baseUnits: BigInt(50), displayAmount: '50', protocolDefined: false }, ], contacts: [ { diff --git a/dsm_client/frontend/src/contexts/BleContext.tsx b/dsm_client/frontend/src/contexts/BleContext.tsx deleted file mode 100644 index a4c0d8916..000000000 --- a/dsm_client/frontend/src/contexts/BleContext.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// path: src/contexts/BleContext.tsx -// SPDX-License-Identifier: Apache-2.0 -// BLE React context wired to deterministic DSM BLE bridge (protobuf-only events) - -import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; -import { useUX } from './UXContext'; -import { useBridgeEvent } from '../hooks/useBridgeEvents'; -import { startBleScanViaRouter, stopBleScanViaRouter } from '../dsm/WebViewBridge'; - -type ScanState = 'idle' | 'scanning' | 'connected'; -type Device = { address: string; name?: string }; - -type BleApi = { - state: ScanState; - devices: Device[]; - startScan: () => Promise; - stopScan: () => Promise; - connect: (address: string) => Promise; - disconnect: (address: string) => Promise; - write: (address: string, data: Uint8Array) => Promise; - read: (address: string) => Promise; -}; - -const Ctx = createContext(null); - -export function BleProvider({ children }: { children: React.ReactNode }) { - const { bleFeaturesDisabled } = useUX(); - const [state, setState] = useState('idle'); - const [devices, setDevices] = useState([]); - const seen = useRef>(new Set()); - - const checkBleEnabled = useCallback(() => { - if (bleFeaturesDisabled) { - throw new Error('Bluetooth features are disabled due to permission issues. Please restart the app and grant permissions.'); - } - }, [bleFeaturesDisabled]); - - const startScan = useCallback(async () => { - checkBleEnabled(); - try { - seen.current.clear(); - setDevices([]); - await startBleScanViaRouter(); - // State will be set by ble.scanStarted event from binary bridge - } catch { - // State will be corrected by scan events or stay idle - } - }, [checkBleEnabled]); - - const stopScan = useCallback(async () => { - checkBleEnabled(); - try { - await stopBleScanViaRouter(); - // State will be set by ble.scanStopped event from binary bridge - } catch { - setState('idle'); - } - }, [checkBleEnabled]); - - const connect = useCallback(async (_address: string) => { - checkBleEnabled(); - // Connection is initiated by BleCoordinator automatically after discovery. - // State will be set by ble.deviceConnected event from binary bridge. - }, [checkBleEnabled]); - - const disconnect = useCallback(async (_address: string) => { - checkBleEnabled(); - // Disconnection state will be set by ble.deviceDisconnected event from binary bridge. - }, [checkBleEnabled]); - - const write = useCallback(async (_address: string, _data: Uint8Array) => { - checkBleEnabled(); - // Transaction writes go through BleCoordinator's outbox, not direct GATT writes. - }, [checkBleEnabled]); - - const read = useCallback(async (_address: string): Promise => { - checkBleEnabled(); - // Identity reads are handled automatically by BleCoordinator after MTU negotiation. - return null; - }, [checkBleEnabled]); - - // Subscribe to protobuf-driven BLE state events from the binary bridge - useBridgeEvent('ble.scanStarted', () => { - setState('scanning'); - }); - - useBridgeEvent('ble.scanStopped', () => { - setState(prev => prev === 'connected' ? 'connected' : 'idle'); - }); - - useBridgeEvent('ble.deviceFound', (detail) => { - const addr = detail?.address; - if (!addr || seen.current.has(addr)) return; - seen.current.add(addr); - setDevices(ds => ds.concat({ address: addr, name: detail.name || undefined })); - }); - - useBridgeEvent('ble.deviceConnected', () => { - setState('connected'); - }); - - useBridgeEvent('ble.deviceDisconnected', () => { - setState('idle'); - }); - - const value = useMemo( - () => ({ state, devices, startScan, stopScan, connect, disconnect, write, read }), - [state, devices, startScan, stopScan, connect, disconnect, write, read] - ); - - return {children}; -} - -export function useBle(): BleApi { - const v = useContext(Ctx); - if (!v) throw new Error('BleContext missing provider'); - return v; -} diff --git a/dsm_client/frontend/src/contexts/ContactsContext.tsx b/dsm_client/frontend/src/contexts/ContactsContext.tsx index 3cdea9632..734939b3e 100644 --- a/dsm_client/frontend/src/contexts/ContactsContext.tsx +++ b/dsm_client/frontend/src/contexts/ContactsContext.tsx @@ -11,23 +11,11 @@ import { } from '../dsm/WebViewBridge'; import { getHeaders } from '../dsm/identity'; import type { AddContactResult, ContactCard } from '../dsm/types'; - -/** A contact as Rust lists it, in Base32 Crockford. */ -export interface Contact { - /** The contact's device id: a contact is its device. */ - id: string; - alias: string; - genesisHash: string; - deviceId: string; - publicKey: string; - /** Its genesis was verified against the storage nodes. */ - isVerified: boolean; - bleAddress?: string; - chainTip?: string; -} +import type { DomainContact } from '../domain/types'; export interface ContactsState { - contacts: Contact[]; + /** Rust's list in the one contact shape: a contact is its device, with its send-readiness. */ + contacts: DomainContact[]; isLoading: boolean; error: string | null; } @@ -84,9 +72,6 @@ export function ContactsProvider({ children }: { children: React.ReactNode }) { void ensureBleAdvertisingIfContacts(); }, []); useBridgeEvent('contact.bleUpdated', contactsStore.handleBleUpdated, []); - useBridgeEvent('contact.added', () => { - void contactsStore.refreshContacts(); - }, []); useBridgeEvent('identity.ready', () => { // After identity is ready, refresh contacts then start advertising // so peers can discover us for bilateral transfers. diff --git a/dsm_client/frontend/src/contexts/UXContext.tsx b/dsm_client/frontend/src/contexts/UXContext.tsx index c8925f71d..fbe5e18cb 100644 --- a/dsm_client/frontend/src/contexts/UXContext.tsx +++ b/dsm_client/frontend/src/contexts/UXContext.tsx @@ -16,7 +16,6 @@ export interface UXContextValue { notifyToast: (type: string, message?: string, opts?: { persistent?: boolean }) => void; clearToast: () => void; // BLE features state - bleFeaturesDisabled: boolean; } const defaultValue: UXContextValue = { @@ -28,7 +27,6 @@ const defaultValue: UXContextValue = { globalToast: null, notifyToast: () => void 0, clearToast: () => void 0, - bleFeaturesDisabled: false, }; const Ctx = createContext(defaultValue); @@ -39,7 +37,6 @@ export const UXProvider: React.FC<{ defaultHideComplexity?: boolean; children?: }) => { const [hideComplexity, setHideComplexity] = useState(defaultHideComplexity); const [globalToast, setGlobalToast] = useState<{ type: string; message?: string; persistent?: boolean } | null>(null); - const [bleFeaturesDisabled, setBleFeaturesDisabled] = useState(false); const nfcWriteActiveRef = useRef(false); // Toasts are auto-dismissed by GlobalToast (setTimeout, UI-only — not used in protocol logic). @@ -57,23 +54,8 @@ export const UXProvider: React.FC<{ defaultHideComplexity?: boolean; children?: }, [setGlobalToast]); // BLE permission event listeners (standardized) - useBridgeEvent('ble.permission.error', ({ message }) => { - notifyToast('error', `Bluetooth permission error: ${message}`); - }, [notifyToast]); - - useBridgeEvent('ble.permission.recovery.needed', () => { - notifyToast('warning', 'Bluetooth permissions are required for device-to-device transfers. Please grant permissions in settings.'); - }, [notifyToast]); - - useBridgeEvent('ble.features.disabled', () => { - setBleFeaturesDisabled(true); - notifyToast('error', 'Bluetooth features have been disabled due to repeated permission issues. Please restart the app and grant permissions.'); - }, [notifyToast]); - - // Recovery path: re-enable BLE features if permissions are restored - useBridgeEvent('ble.features.enabled', () => { - setBleFeaturesDisabled(false); - notifyToast('success', 'Bluetooth features re-enabled.'); + useBridgeEvent('ble.permission.error', (detail) => { + notifyToast('error', `Bluetooth permission error: ${detail?.message ?? ''}`); }, [notifyToast]); // Global notification when a dBTC deposit auto-completes (visible on any screen) @@ -153,9 +135,8 @@ export const UXProvider: React.FC<{ defaultHideComplexity?: boolean; children?: globalToast, notifyToast, clearToast, - bleFeaturesDisabled, // eslint-disable-next-line react-hooks/exhaustive-deps - }), [hideComplexity, globalToast, bleFeaturesDisabled]); + }), [hideComplexity, globalToast]); return {children}; }; @@ -163,10 +144,3 @@ export const UXProvider: React.FC<{ defaultHideComplexity?: boolean; children?: export function useUX(): UXContextValue { return useContext(Ctx); } - -export function useUXTerms() { - return { - getScreenTitle: (key: string) => key, - getActionLabel: (key: string) => key, - }; -} diff --git a/dsm_client/frontend/src/contexts/WalletContext.tsx b/dsm_client/frontend/src/contexts/WalletContext.tsx index 54f31894d..8df16e645 100644 --- a/dsm_client/frontend/src/contexts/WalletContext.tsx +++ b/dsm_client/frontend/src/contexts/WalletContext.tsx @@ -7,7 +7,7 @@ import { dsmClient } from '../services/dsmClient'; import { useEventSignal } from '../bridge/useEventSignal'; import { useBridgeEvent } from '@/hooks/useBridgeEvents'; import type { Transaction } from '@/hooks/useTransactions'; -import { useWalletSync } from '@/hooks/useWalletSync'; +import { useWalletRefreshListener } from '@/hooks/useWalletRefreshListener'; import { walletStore, useWalletStore } from '../stores/walletStore'; import type { TokenBalanceView } from '../dsm/types'; @@ -77,40 +77,17 @@ export const WalletProvider: React.FC<{ children: ReactNode }> = ({ children }) void walletStore.initialize(); }, []); - useWalletSync({ - onRefreshAll: async () => { - try { - await walletStore.refreshAll(); - } catch (error) { - console.warn('[WalletProvider] refreshAll failed:', error); - } - }, - onRefreshBalances: async () => { - try { - await walletStore.refreshBalances(); - } catch (error) { - console.warn('[WalletProvider] refreshBalances failed:', error); - } - }, - onRefreshTransactions: async () => { - try { - await walletStore.refreshTransactions(); - } catch (error) { - console.warn('[WalletProvider] refreshTransactions failed:', error); - } - }, - onIdentityReady: async () => { - try { - await walletStore.initialize(); - } catch (error) { - console.warn('[WalletProvider] identity refresh failed:', error); - } - }, - }); + // Rust announcing the identity ready (re)initializes the store: the + // identity is read and the projection loaded. + useBridgeEvent('identity.ready', () => { + void walletStore.initialize(); + }, []); - useBridgeEvent('bilateral.transferComplete', () => { - void refreshWalletProjection(); - }, [refreshWalletProjection]); + // The one path from a wallet change to this projection's reload: + // `wallet.refresh`, coalesced. A completed bilateral transfer and Rust's + // inbox poller both reach it through the event bridge, so neither is + // subscribed to here again — each was a second reload of the same change. + useWalletRefreshListener(refreshWalletProjection, [refreshWalletProjection]); useBridgeEvent('deposit.completed', () => { void refreshWalletProjection(); @@ -120,15 +97,9 @@ export const WalletProvider: React.FC<{ children: ReactNode }> = ({ children }) void refreshWalletProjection(); }, [refreshWalletProjection]); - useBridgeEvent('inbox.updated', (detail?: { newItems?: number }) => { - const newItems = typeof detail?.newItems === 'number' ? detail.newItems : 0; - if (newItems <= 0) return; - void refreshWalletProjection(); - }, [refreshWalletProjection]); - + // The accept path emits `wallet.refresh` itself; this is the toast only. useEffect(() => { if (bilateralSignal > 0) { - void walletStore.refreshAll(); try { notifyToast('transfer_accepted', 'Transfer accepted'); } catch (error) { diff --git a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralCommitted.test.tsx b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralCommitted.test.tsx index f7c57d531..a52717a4c 100644 --- a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralCommitted.test.tsx +++ b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralCommitted.test.tsx @@ -39,7 +39,11 @@ describe('WalletContext bilateral committed event', () => { jest.useRealTimers(); }); - it('refreshes balances and history when wallet.bilateralCommitted fires', async () => { + // An accepted transfer reaches the provider as two events from the accept + // path: `wallet.bilateralCommitted` (the signal) and `wallet.refresh` (the + // reload). The provider reloads once, on the second; it used to reload on + // both. + it('reloads once for an accepted transfer, on the accept path’s wallet.refresh', async () => { const mockBalances = jest.spyOn(dsmClient, 'getAllBalances' as any).mockResolvedValue([]); const mockHistory = jest.spyOn(dsmClient, 'getWalletHistory' as any).mockResolvedValue({ transactions: [] }); const mockIdentity = jest @@ -48,7 +52,12 @@ describe('WalletContext bilateral committed event', () => { genesisHash: 'G'.repeat(32), deviceId: 'D'.repeat(32), }); - jest.spyOn(dsmClient, 'isReady' as any).mockResolvedValue(true); + // The listener reloads on an animation frame; fake timers do not drive + // jsdom's, so run the frame callback at once. + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0); + return 0; + }); // Render provider so initialization happens and initial fetch may occur await renderWalletProvider(
); @@ -60,23 +69,30 @@ describe('WalletContext bilateral committed event', () => { mockBalances.mockClear(); mockHistory.mockClear(); - // Dispatch event that should trigger refreshAll() + // The signal alone reloads nothing. await act(async () => { bridgeEvents.emit('wallet.bilateralCommitted', {} as any); await Promise.resolve(); + await Promise.resolve(); }); + expect(mockBalances).not.toHaveBeenCalled(); + expect(mockHistory).not.toHaveBeenCalled(); - // useSyncExternalStore triggers a synchronous snapshot update; timers not required. - - // Expect the underlying refresh calls to be called - await waitFor(() => expect(mockBalances).toHaveBeenCalled()); - await waitFor(() => expect(mockHistory).toHaveBeenCalled()); + // The accept path's own refresh reloads, once. + await act(async () => { + bridgeEvents.emit('wallet.refresh', { source: 'bilateral.accept_followup' }); + await Promise.resolve(); + await Promise.resolve(); + }); + await waitFor(() => expect(mockBalances).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockHistory).toHaveBeenCalledTimes(1)); await act(async () => { jest.runOnlyPendingTimers(); await Promise.resolve(); await Promise.resolve(); }); + expect(mockBalances).toHaveBeenCalledTimes(1); }); it('does not reopen the transfer accepted toast when the user dismisses it', async () => { @@ -86,7 +102,6 @@ describe('WalletContext bilateral committed event', () => { genesisHash: 'G'.repeat(32), deviceId: 'D'.repeat(32), }); - jest.spyOn(dsmClient, 'isReady' as any).mockResolvedValue(true); await renderWalletProvider(); diff --git a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralThrottle.test.tsx b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralThrottle.test.tsx index 4f81f604d..cbff88737 100644 --- a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralThrottle.test.tsx +++ b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralThrottle.test.tsx @@ -13,7 +13,10 @@ describe('WalletContext bilateral event throttle & toast', () => { jest.clearAllMocks(); }); - test('refreshAll is throttled and toast not spammed on rapid events', async () => { + // The committed signal is the toast's trigger and nothing else: the accept + // path emits its own `wallet.refresh`, and the provider used to reload on + // the signal as well, a second reload of the same accept. + test('a burst of committed signals shows one toast and reloads nothing', async () => { const getBalancesSpy = jest.spyOn(dsmClient, 'getAllBalances').mockResolvedValue([] as any); const getHistorySpy = jest.spyOn(dsmClient, 'getWalletHistory').mockResolvedValue({ transactions: [] } as any); const getContactsSpy = jest.spyOn(dsmClient, 'getContacts').mockResolvedValue({ contacts: [] } as any); @@ -59,21 +62,21 @@ describe('WalletContext bilateral event throttle & toast', () => { await Promise.resolve(); await Promise.resolve(); }); - expect(getBalancesSpy).toHaveBeenCalledTimes(1); + expect(getBalancesSpy).not.toHaveBeenCalled(); + expect(getHistorySpy).not.toHaveBeenCalled(); expect(notifySpy).toHaveBeenCalledTimes(1); - // Dispatch a couple more events inside throttle window (should not cause extra immediate refreshes) + // A second burst: one more toast, still no reload from the signal. act(() => { emitBilateralCommitted(); emitBilateralCommitted(); }); - // Flushing microtasks again should schedule exactly one more refresh. await act(async () => { await Promise.resolve(); await Promise.resolve(); }); - expect(getBalancesSpy).toHaveBeenCalledTimes(2); + expect(getBalancesSpy).not.toHaveBeenCalled(); expect(notifySpy).toHaveBeenCalledTimes(2); }); }); diff --git a/dsm_client/frontend/src/contexts/__tests__/WalletCreditSound.test.tsx b/dsm_client/frontend/src/contexts/__tests__/WalletCreditSound.test.tsx index 7c8a6ea3c..8eb986a0d 100644 --- a/dsm_client/frontend/src/contexts/__tests__/WalletCreditSound.test.tsx +++ b/dsm_client/frontend/src/contexts/__tests__/WalletCreditSound.test.tsx @@ -8,11 +8,18 @@ import { bridgeEvents } from '../../bridge/bridgeEvents'; import { dsmClient } from '../../services/dsmClient'; import { walletStore } from '../../stores/walletStore'; import { playCoinSound } from '../../utils/coinSound'; +import { initializeEventBridge } from '../../dsm/EventBridge'; +import * as pb from '../../proto/dsm_app_pb'; jest.mock('../../utils/coinSound', () => ({ playCoinSound: jest.fn(), })); +/** A native event as Kotlin posts it: the topic and the raw payload bytes. */ +function announce(topic: string, payload: Uint8Array) { + window.dispatchEvent(new CustomEvent('dsm-event-bin', { detail: { topic, payload } })); +} + describe('wallet credit sound routing', () => { beforeEach(() => { jest.restoreAllMocks(); @@ -30,7 +37,13 @@ describe('wallet credit sound routing', () => { (walletStore as any).hasObservedBalances = false; }); - it('plays the coin sound only after a positive balance delta on receiver-side refreshes', async () => { + // What the event bridge announces for one change reloads the projection + // once: a completed bilateral transfer (`bilateral.event`) and an inbox sync + // with new items (`inbox.updated`) each become one `wallet.refresh`, and the + // provider reloads on that alone. The provider used to reload on the raw + // events as well. The coin sound follows a credit, not a reload. + it('reloads once per announced change and plays the coin sound only on a credit', async () => { + initializeEventBridge(); jest.spyOn(dsmClient, 'getIdentity' as any).mockResolvedValue({ genesisHash: 'G'.repeat(32), deviceId: 'D'.repeat(32), @@ -65,21 +78,35 @@ describe('wallet credit sound routing', () => { expect(playCoinSound).not.toHaveBeenCalled(); act(() => { - bridgeEvents.emit('bilateral.transferComplete', undefined as any); + announce( + 'bilateral.event', + new pb.BilateralEventNotification({ + eventType: pb.BilateralEventType.BILATERAL_EVENT_TRANSFER_COMPLETE, + message: 'test', + } as any).toBinary(), + ); }); await waitFor(() => { expect(playCoinSound).toHaveBeenCalledTimes(1); }); + await waitFor(() => { + expect((dsmClient.getAllBalances as any)).toHaveBeenCalledTimes(2); + }); act(() => { - bridgeEvents.emit('inbox.updated', { newItems: 1, source: 'poll' }); + announce('inbox.updated', new pb.StorageSyncResponse({ processed: 1 } as any).toBinary()); }); await waitFor(() => { expect((dsmClient.getAllBalances as any)).toHaveBeenCalledTimes(3); }); - + // A second reload of either change would follow within a frame or two; + // none does. + await act(async () => { + await new Promise((r) => setTimeout(r, 80)); + }); + expect((dsmClient.getAllBalances as any)).toHaveBeenCalledTimes(3); expect(playCoinSound).toHaveBeenCalledTimes(1); }); diff --git a/dsm_client/frontend/src/contexts/contacts/__tests__/utils.test.ts b/dsm_client/frontend/src/contexts/contacts/__tests__/utils.test.ts index 4cb87e457..7c8a0d30f 100644 --- a/dsm_client/frontend/src/contexts/contacts/__tests__/utils.test.ts +++ b/dsm_client/frontend/src/contexts/contacts/__tests__/utils.test.ts @@ -1,57 +1,26 @@ // SPDX-License-Identifier: Apache-2.0 import { toBase32Crockford } from '../../../dsm/decoding'; -import { bytesToDisplay, parseBinary32, parseBinary64 } from '../utils'; +import { bytesToDisplay } from '../utils'; describe('contacts utils', () => { const bytes32 = new Uint8Array(32); for (let i = 0; i < 32; i += 1) bytes32[i] = i; - describe('parseBinary32', () => { - it('accepts Uint8Array of length 32', () => { - expect(parseBinary32(bytes32, 'id')).toBe(bytes32); - }); - - it('rejects Uint8Array with wrong length', () => { - expect(() => parseBinary32(new Uint8Array(31), 'id')).toThrow(/must be 32 bytes/); - }); - - it('parses base32 string of 32 bytes', () => { - const b32 = toBase32Crockford(bytes32); - const out = parseBinary32(b32, 'id'); - expect(out).toEqual(bytes32); - }); - - it('rejects empty string', () => { - expect(() => parseBinary32('', 'label')).toThrow(/empty/); - }); - }); - - describe('parseBinary64', () => { - const b64 = new Uint8Array(64); - b64[0] = 0xff; - b64[63] = 0x01; - - it('accepts Uint8Array of length 64', () => { - expect(parseBinary64(b64, 'h')).toBe(b64); - }); - - it('rejects wrong Uint8Array length', () => { - expect(() => parseBinary64(new Uint8Array(63), 'h')).toThrow(/must be 64 bytes/); - }); - - it('parses base32 string of 64 bytes', () => { - const s = toBase32Crockford(b64); - expect(parseBinary64(s, 'h')).toEqual(b64); - }); - }); - describe('bytesToDisplay', () => { it('returns crockford base32 for Uint8Array', () => { expect(bytesToDisplay(bytes32)).toBe(toBase32Crockford(bytes32)); }); + it('returns UPPERCASE Base32 from the Crockford alphabet', () => { + const disp = bytesToDisplay(new Uint8Array([0, 1, 2, 254, 255])); + // Crockford alphabet: 0-9 and A-Z excluding I, L, O, U + expect(disp).toMatch(/^[0-9A-HJKMNP-TV-Z]+$/); + }); + it('returns empty string for non-Uint8Array', () => { expect(bytesToDisplay(null as unknown as Uint8Array)).toBe(''); + expect(bytesToDisplay(undefined as unknown as Uint8Array)).toBe(''); + expect(bytesToDisplay('not-bytes' as unknown as Uint8Array)).toBe(''); }); }); }); diff --git a/dsm_client/frontend/src/contexts/contacts/utils.test.ts b/dsm_client/frontend/src/contexts/contacts/utils.test.ts deleted file mode 100644 index 9875432fa..000000000 --- a/dsm_client/frontend/src/contexts/contacts/utils.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// SPDX-License-Identifier: Apache-2.0 -// eslint-env jest -// Local declarations to satisfy isolated compilation since test files are excluded from tsconfig type-check. -declare const describe: any; -declare const test: any; -declare const expect: any; -import { parseBinary32, bytesToDisplay } from './utils'; - -describe('contacts utils', () => { - test('parseBinary32 parses 32-byte Base32 (UPPERCASE) and rejects wrong size', () => { - // base32 string representing 32 bytes (just use alphabet cycling) - const raw = new Uint8Array(32).map((_, i) => i); - const base32 = bytesToDisplay(raw); - const parsed = parseBinary32(base32, 'genesis_hash'); - expect(parsed).toBeInstanceOf(Uint8Array); - expect(parsed.length).toBe(32); - // wrong size (remove last char so decode length !=32) - expect(() => parseBinary32(base32.slice(0, -1), 'genesis_hash')).toThrow(/32 bytes/); - }); - - test('bytesToDisplay returns UPPERCASE Base32', () => { - const u8 = new Uint8Array([0, 1, 2, 254, 255]); - const disp = bytesToDisplay(u8); - expect(typeof disp).toBe('string'); - // Crockford alphabet: 0-9 and A-Z excluding I, L, O, U - expect(disp).toMatch(/^[0-9A-TV-Z]+$/); - }); - - test('parseBinary32 throws on empty/whitespace input', () => { - expect(() => parseBinary32('', 'genesis_hash')).toThrow(/empty/); - expect(() => parseBinary32(' ', 'genesis_hash')).toThrow(/empty/); - }); - - test('bytesToDisplay returns empty string for non-Uint8Array inputs', () => { - // @ts-expect-error intentionally sending wrong type - expect(bytesToDisplay(null)).toBe(''); - // @ts-expect-error intentionally sending wrong type - expect(bytesToDisplay(undefined)).toBe(''); - // @ts-expect-error intentionally sending wrong type - expect(bytesToDisplay('not-bytes')).toBe(''); - }); -}); diff --git a/dsm_client/frontend/src/contexts/contacts/utils.ts b/dsm_client/frontend/src/contexts/contacts/utils.ts index fb1221494..91623792e 100644 --- a/dsm_client/frontend/src/contexts/contacts/utils.ts +++ b/dsm_client/frontend/src/contexts/contacts/utils.ts @@ -1,31 +1,7 @@ /* eslint-disable security/detect-object-injection */ // SPDX-License-Identifier: Apache-2.0 // Contacts utilities. Base32 Crockford ONLY. -import { fromBase32Crockford, toBase32Crockford } from '../../dsm/decoding'; - -export function parseBinary32(input: Uint8Array | string, label: string): Uint8Array { - if (input instanceof Uint8Array) { - if (input.length !== 32) throw new Error(`${label} must be 32 bytes, got ${input.length}`); - return input; - } - const trimmed = String(input || '').trim(); - if (!trimmed) throw new Error(`${label} empty`); - const bytes = fromBase32Crockford(trimmed); - if (bytes.length !== 32) throw new Error(`${label} must be 32 bytes`); - return bytes; -} - -export function parseBinary64(input: Uint8Array | string, label: string): Uint8Array { - if (input instanceof Uint8Array) { - if (input.length !== 64) throw new Error(`${label} must be 64 bytes, got ${input.length}`); - return input; - } - const trimmed = String(input || '').trim(); - if (!trimmed) throw new Error(`${label} empty`); - const bytes = fromBase32Crockford(trimmed); - if (bytes.length !== 64) throw new Error(`${label} must be 64 bytes`); - return bytes; -} +import { toBase32Crockford } from '../../dsm/decoding'; export function bytesToDisplay(u8: Uint8Array): string { if (!(u8 instanceof Uint8Array)) return ''; diff --git a/dsm_client/frontend/src/dsm/EventBridge.ts b/dsm_client/frontend/src/dsm/EventBridge.ts index 0062c600d..23e79c792 100644 --- a/dsm_client/frontend/src/dsm/EventBridge.ts +++ b/dsm_client/frontend/src/dsm/EventBridge.ts @@ -162,8 +162,6 @@ export function initializeEventBridge(): void { // Deterministic throttle for wallet.refresh from BLE envelopes. // Without this, every BLE envelope matching bilateral patterns // triggers a full balance+history refresh (~50 calls/sec). - let bleWalletRefreshCounter = 0; - const BLE_WALLET_REFRESH_EVERY = 8; // emit 1 in 8 BLE-triggered refreshes window.addEventListener('dsm-native-host-event-bin', (ev: Event) => { try { @@ -222,7 +220,6 @@ export function initializeEventBridge(): void { try { const snapshot = decodeSessionState(bytes); bridgeEvents.emit('session.state', snapshot); - window.dispatchEvent(new CustomEvent('dsm-session-state', { detail: snapshot })); } catch (e) { logger.warn('[EventBridge] session.state decode failed:', e); } @@ -237,13 +234,15 @@ export function initializeEventBridge(): void { } if (topic === 'dsm-identity-ready') { - try { document.dispatchEvent(new Event('dsm-identity-ready')); } catch (e) { logger.warn('[EventBridge] dsm-identity-ready dispatch failed:', e); } + // Straight to the bus. This used to be a `document` event that the + // adapter re-emitted, and that `getIdentity`'s wake-up listened for on + // `window`, where it never arrived. + bridgeEvents.emit('identity.ready', undefined as never); emit(topic, bytes); return; } if (topic === 'dsm-app-pause') { - try { window.dispatchEvent(new CustomEvent('dsm-app-pause')); } catch (e) { logger.warn('[EventBridge] dsm-app-pause dispatch failed:', e); } emit(topic, bytes); return; } @@ -268,12 +267,7 @@ export function initializeEventBridge(): void { try { const text = new TextDecoder().decode(bytes); const parts = text.split('|'); - const detail = { - type: parts[0] || 'UNKNOWN_ERROR', - message: parts[1] || 'Environment configuration error', - help: parts[2] || undefined, - }; - document.dispatchEvent(new CustomEvent('dsm-env-config-error', { detail })); + bridgeEvents.emit('env.config.error', { message: parts[1] || 'Environment configuration error' }); } catch {} emit(topic, bytes); return; @@ -329,7 +323,7 @@ export function initializeEventBridge(): void { } if (topic === 'dsm-wallet-refresh') { - try { window.dispatchEvent(new CustomEvent('dsm-wallet-refresh', { detail: { source: 'native' } })); } catch {} + bridgeEvents.emit('wallet.refresh', { source: 'native' }); emit(topic, bytes); return; } @@ -384,22 +378,13 @@ export function initializeEventBridge(): void { } catch {} } - // Always fan out a typed DOM event so UI can react even if it doesn't subscribe via EventBridge. - try { - window.dispatchEvent( - new CustomEvent('dsm-bilateral-notification', { - detail: { notification: note, bytes }, - }) - ); - } catch {} - // The event type says a transfer completed; a status string is free text. const isComplete = note.eventType === pb.BilateralEventType.BILATERAL_EVENT_TRANSFER_COMPLETE; if (isComplete) { try { logger.debug('[BilateralTransfer] TRANSFER_COMPLETE - refreshing wallet state'); } catch {} try { bridgeEvents.emit('wallet.refresh', { source: 'bilateral.transfer_complete' }); } catch {} - // Direct signal so listeners can bypass the wallet.refresh throttle chain. + // For the reactions that are not reloads: the toast and the credit sound. try { bridgeEvents.emit('bilateral.transferComplete', undefined as any); } catch {} } } catch (e) { @@ -459,10 +444,6 @@ export function initializeEventBridge(): void { try { bridgeEvents.emit('ble.deviceDisconnected', { address: info?.address ?? '' }); } catch {} } else if (evCase === 'connectionFailed') { try { bridgeEvents.emit('ble.connectionFailed', { reason: String(bleEvent.ev.value ?? '') }); } catch {} - } else if (evCase === 'advertisingStarted') { - try { bridgeEvents.emit('ble.advertisingStarted', undefined as any); } catch {} - } else if (evCase === 'advertisingStopped') { - try { bridgeEvents.emit('ble.advertisingStopped', undefined as any); } catch {} } else if (evCase === 'pairingStatus') { const ps = bleEvent.ev.value as pb.PairingStatusUpdate; const devId = ps?.deviceId instanceof Uint8Array && ps.deviceId.length === 32 @@ -493,17 +474,11 @@ export function initializeEventBridge(): void { return; // handled; do not fall through } - // Check for bilateral response (type 8 = BilateralPrepareResponse) - // Throttled: emit wallet.refresh only every Nth BLE envelope to avoid - // flooding the bridge with balance+history queries (~50/sec without this). - const uTx: any = (p?.case === 'universalTx' ? p.value : p?.universalTx); - const bpResp: any = (p?.case === 'bilateralPrepareResponse' ? p.value : p?.bilateralPrepareResponse); - if (uTx?.type === 8 || bpResp) { - bleWalletRefreshCounter = (bleWalletRefreshCounter + 1) | 0; - if ((bleWalletRefreshCounter % BLE_WALLET_REFRESH_EVERY) === 1) { - bridgeEvents.emit('wallet.refresh', { source: 'bilateral.transfer_complete' }); - } - } + // A bilateral prepare response over BLE is not a wallet change and + // announces none: the wallet changes at TRANSFER_COMPLETE, which + // `bilateral.event` announces. This used to emit a `wallet.refresh` + // claiming `bilateral.transfer_complete` on one prepare response in + // eight. // Check for identity-like payload without depending on a specific generated type name const identity: any = (p?.case === 'bilateralIdentityExchange' ? p.value : p?.bilateralIdentityExchange); diff --git a/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts b/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts index 2073b0fc5..5d0bca9bf 100644 --- a/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts +++ b/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts @@ -4,7 +4,7 @@ import { getBridgeInstance } from '../bridge/BridgeRegistry'; import { bridgeEvents } from '../bridge/bridgeEvents'; import type { AndroidBridgeV3 } from './bridgeTypes'; -import { EnvelopeOp, IngressRequest, IngressResponse, RouterInvokeOp, RouterQueryOp, StartupRequest, StartupResponse } from '../proto/dsm_app_pb'; +import { IngressRequest, IngressResponse, RouterInvokeOp, RouterQueryOp } from '../proto/dsm_app_pb'; function mustBridge(): AndroidBridgeV3 { const bridge = getBridgeInstance(); @@ -21,11 +21,12 @@ function normalizeToBytes(data: unknown): Uint8Array { throw new Error('expected Uint8Array response from native boundary'); } -async function callBoundaryMethod(method: 'nativeBoundaryStartup' | 'nativeBoundaryIngress', payload: Uint8Array): Promise { - // `startup` and `ingress` are the bridge object's own wrappers over the - // MessagePort (`index.html`); they answer the boundary's bytes or throw. +async function callBoundaryMethod(method: 'nativeBoundaryIngress', payload: Uint8Array): Promise { + // `ingress` is the bridge object's own wrapper over the MessagePort + // (`index.html`); it answers the boundary's bytes or throws. The startup + // boundary is Kotlin's to cross, at app start; the WebView never crossed it. const bridge = mustBridge(); - const call = method === 'nativeBoundaryStartup' ? bridge.startup : bridge.ingress; + const call = bridge.ingress; if (typeof call !== 'function') { throw new Error(`DSM bridge does not expose ${method}`); } @@ -40,25 +41,10 @@ async function callBoundaryMethod(method: 'nativeBoundaryStartup' | 'nativeBound } } -function encodeStartupRequest(request: StartupRequest | Uint8Array): Uint8Array { - return request instanceof Uint8Array ? new Uint8Array(request) : request.toBinary(); -} - function encodeIngressRequest(request: IngressRequest | Uint8Array): Uint8Array { return request instanceof Uint8Array ? new Uint8Array(request) : request.toBinary(); } -function unwrapStartupResponse(responseBytes: Uint8Array): Uint8Array { - const response = StartupResponse.fromBinary(responseBytes); - if (response.result.case === 'okBytes') { - return response.result.value; - } - if (response.result.case === 'error') { - throw new Error(response.result.value?.message || 'startup boundary error'); - } - throw new Error('startup boundary returned no result'); -} - function unwrapIngressResponse(responseBytes: Uint8Array): Uint8Array { const response = IngressResponse.fromBinary(responseBytes); if (response.result.case === 'okBytes') { @@ -70,18 +56,10 @@ function unwrapIngressResponse(responseBytes: Uint8Array): Uint8Array { throw new Error('ingress boundary returned no result'); } -export async function startupBoundary(request: StartupRequest | Uint8Array): Promise { - return callBoundaryMethod('nativeBoundaryStartup', encodeStartupRequest(request)); -} - export async function ingressBoundary(request: IngressRequest | Uint8Array): Promise { return callBoundaryMethod('nativeBoundaryIngress', encodeIngressRequest(request)); } -export async function startupBoundaryOk(request: StartupRequest | Uint8Array): Promise { - return unwrapStartupResponse(await startupBoundary(request)); -} - export async function ingressBoundaryOk(request: IngressRequest | Uint8Array): Promise { return unwrapIngressResponse(await ingressBoundary(request)); } @@ -109,12 +87,3 @@ export function buildRouterInvokeIngressRequest(method: string, args?: Uint8Arra }, }); } - -export function buildEnvelopeIngressRequest(envelopeBytes: Uint8Array): IngressRequest { - return new IngressRequest({ - operation: { - case: 'envelope', - value: new EnvelopeOp({ envelopeBytes: new Uint8Array(envelopeBytes) }), - }, - }); -} diff --git a/dsm_client/frontend/src/dsm/NativeHostBridge.ts b/dsm_client/frontend/src/dsm/NativeHostBridge.ts index a34dc8dac..8b73129dd 100644 --- a/dsm_client/frontend/src/dsm/NativeHostBridge.ts +++ b/dsm_client/frontend/src/dsm/NativeHostBridge.ts @@ -6,7 +6,7 @@ import { bridgeEvents } from '../bridge/bridgeEvents'; import logger from '../utils/logger'; import type { AndroidBridgeV3 } from './bridgeTypes'; import { bridgeGate } from './BridgeGate'; -import { BiometricAuthorizePayload, BiometricAuthorizeResult, HostPermissionsRequestPayload, NativeHostAck, NativeHostCapabilities, NativeHostEvent, NativeHostEventKind, NativeHostRequest, NativeHostRequestKind, NativeHostResponse, NfcTagReadPayload, NfcTagReadResult, NfcTagWritePayload, NfcTagWriteResult, QrScanResultPayload } from '../proto/dsm_app_pb'; +import { BiometricAuthorizeResult, NativeHostAck, NativeHostEvent, NativeHostEventKind, NativeHostRequest, NativeHostRequestKind, NativeHostResponse, NfcTagWritePayload, NfcTagWriteResult, QrScanResultPayload } from '../proto/dsm_app_pb'; function mustBridge(): AndroidBridgeV3 { const bridge = getBridgeInstance(); @@ -57,11 +57,6 @@ function unwrapHostResponse(responseBytes: Uint8Array): Uint8Array { throw new Error('native host boundary returned no result'); } -export function isNativeHostUnavailableError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - return error.message.includes('Unknown binary RPC method: nativeHostRequest'); -} - export async function hostRequest(request: NativeHostRequest | Uint8Array): Promise { return bridgeGate.enqueue(() => callHostMethod(encodeRequest(request))); } @@ -77,26 +72,10 @@ export function buildHostRequest(kind: NativeHostRequestKind, payload?: Uint8Arr }); } -export async function getNativeHostCapabilities(): Promise { - const responseBytes = await hostRequest(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_CAPABILITIES_GET)); - const response = NativeHostResponse.fromBinary(responseBytes); - if (response.result.case === 'capabilities') { - return response.result.value; - } - if (response.result.case === 'error') { - throw new Error(response.result.value?.message || 'native host capabilities error'); - } - throw new Error('native host capabilities response missing capabilities'); -} - export async function startNativeQrScan(): Promise { await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_QR_START_SCAN)); } -export async function stopNativeQrScan(): Promise { - await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_QR_STOP_SCAN)); -} - export async function startBleScanHost(): Promise { await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_BLE_SCAN_START)); } @@ -123,34 +102,6 @@ export async function stopNfcReaderHost(): Promise { await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_NFC_READER_STOP)); } -export async function requestHostPermissions(permissions: string[]): Promise { - const payload = new HostPermissionsRequestPayload({ permissions }); - await hostRequestOk( - buildHostRequest(NativeHostRequestKind.HOST_CONTROL_PERMISSIONS_REQUEST, payload.toBinary()), - ); -} - -export async function authorizeBiometricHost(args?: Partial): Promise { - const payload = new BiometricAuthorizePayload(args); - await hostRequestOk( - buildHostRequest( - NativeHostRequestKind.PLATFORM_PRIMITIVE_BIOMETRIC_AUTHORIZE, - payload.toBinary(), - ), - ); -} - -export async function readNfcTagPayloadHost(mimeType = 'application/vnd.dsm.recovery'): Promise { - const payload = new NfcTagReadPayload({ mimeType }); - const bytes = await hostRequestOk( - buildHostRequest( - NativeHostRequestKind.PLATFORM_PRIMITIVE_NFC_TAG_READ_PAYLOAD, - payload.toBinary(), - ), - ); - return NfcTagReadResult.fromBinary(bytes); -} - export async function writeNfcTagPayloadHost(payload?: Uint8Array, mimeType = 'application/vnd.dsm.recovery'): Promise { const requestPayload = new NfcTagWritePayload({ mimeType, diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/index.ts b/dsm_client/frontend/src/dsm/WebViewBridge/index.ts index d87706188..a206de892 100644 --- a/dsm_client/frontend/src/dsm/WebViewBridge/index.ts +++ b/dsm_client/frontend/src/dsm/WebViewBridge/index.ts @@ -27,7 +27,6 @@ export const { callBin, mustBridge, normalizeToBytes, - processEnvelopeV3Bin, queryTransportHeadersV3, routerInvokeBin, routerQueryBin, diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts b/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts index 8d8fb77a8..4c9600e82 100644 --- a/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts +++ b/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts @@ -16,9 +16,7 @@ import { bridgeEvents } from "../../bridge/bridgeEvents"; import { getBridgeInstance } from "../../bridge/BridgeRegistry"; import type { AndroidBridgeV3 } from "../bridgeTypes"; import { emitDeterministicSafetyIfPresent } from "../../utils/deterministicSafety"; -import { decodeFramedEnvelopeV3 } from "../decoding"; import { - buildEnvelopeIngressRequest, buildRouterInvokeIngressRequest, buildRouterQueryIngressRequest, ingressBoundaryOk, @@ -163,10 +161,6 @@ export async function callBin(method: string, payload?: Uint8Array): Promise { - return bridgeGate.enqueue(() => ingressBoundaryOk(buildEnvelopeIngressRequest(envelopeBytes))); -} - export async function routerInvokeBin(method: string, args?: Uint8Array): Promise { if (typeof method !== "string" || method.length === 0) { throw new Error("routerInvokeBin: method required"); @@ -181,16 +175,6 @@ export async function routerQueryBin(path: string, params?: Uint8Array): Promise return bridgeGate.enqueue(() => ingressBoundaryOk(buildRouterQueryIngressRequest(path, params))); } -export async function invokeRouterEnvelope(method: string, args?: Uint8Array) { - const bytes = await routerInvokeBin(method, args); - return { bytes, envelope: decodeFramedEnvelopeV3(bytes) }; -} - -export async function queryRouterEnvelope(path: string, params?: Uint8Array) { - const bytes = await routerQueryBin(path, params); - return { bytes, envelope: decodeFramedEnvelopeV3(bytes) }; -} - export async function queryTransportHeadersV3(): Promise { const responseBytes = await callBin("getTransportHeadersV3Bin", new Uint8Array(0)); return responseBytes; diff --git a/dsm_client/frontend/src/dsm/__tests__/EventBridge.bilateral.test.ts b/dsm_client/frontend/src/dsm/__tests__/EventBridge.bilateral.test.ts index a0e75fb87..eb32317f5 100644 --- a/dsm_client/frontend/src/dsm/__tests__/EventBridge.bilateral.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/EventBridge.bilateral.test.ts @@ -83,6 +83,32 @@ describe('EventBridge announces only what the native payload states', () => { off(); }); + // A prepare response is not a wallet change; the wallet changes at + // TRANSFER_COMPLETE, which `bilateral.event` announces. This used to emit a + // `wallet.refresh` claiming `bilateral.transfer_complete` on one prepare + // response in eight. + test('a BLE prepare response announces no wallet change', async () => { + const refresh = jest.fn(); + const off = bridgeEvents.on('wallet.refresh', refresh as any); + + const env = new pb.Envelope({ + version: 3, + payload: { case: 'bilateralPrepareResponse', value: new pb.BilateralPrepareResponse({}) }, + }); + const bytes = env.toBinary(); + const framed = new Uint8Array(1 + bytes.length); + framed[0] = 0x03; + framed.set(bytes, 1); + for (let i = 0; i < 9; i++) { + window.dispatchEvent(new CustomEvent('dsm-event-bin', { detail: { topic: 'ble.envelope.bin', payload: framed } })); + } + await Promise.resolve(); + await Promise.resolve(); + + expect(refresh).not.toHaveBeenCalled(); + off(); + }); + test('a BLE contact update that names no device is not announced', async () => { const updated = jest.fn(); const off = bridgeEvents.on('contact.bleUpdated', updated as any); diff --git a/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.framing.test.ts b/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.framing.test.ts index 28b28b11a..21d34bf21 100644 --- a/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.framing.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.framing.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -import { processEnvelopeV3Bin } from '../WebViewBridge'; -import { BridgeRpcRequest, BridgeRpcResponse, EnvelopeOp, IngressRequest, IngressResponse } from '../../proto/dsm_app_pb'; +import { routerQueryBin } from '../WebViewBridge'; +import { BridgeRpcRequest, BridgeRpcResponse, IngressRequest, IngressResponse } from '../../proto/dsm_app_pb'; function wrapSuccessEnvelope(data: Uint8Array): Uint8Array { const br = new BridgeRpcResponse({ result: { case: 'success', value: { data: new Uint8Array(data) } } }); @@ -13,7 +13,7 @@ describe('WebViewBridge framing invariants', () => { (global as any).window = (global as any).window ?? {}; }); - test('processEnvelopeV3Bin uses nativeBoundaryIngress with an envelope op', async () => { + test('a router query goes to nativeBoundaryIngress as a routerQuery op', async () => { const seen: { method?: string; payload?: Uint8Array } = {}; const response = new IngressResponse({ result: { case: 'okBytes', value: new Uint8Array([1, 2, 3]) }, @@ -28,13 +28,14 @@ describe('WebViewBridge framing invariants', () => { }, }; - const envelope = new Uint8Array([9, 9, 9, 9]); - await processEnvelopeV3Bin(envelope); + const params = new Uint8Array([9, 9, 9, 9]); + await expect(routerQueryBin('balance.list', params)).resolves.toEqual(new Uint8Array([1, 2, 3])); expect(seen.method).toBe('nativeBoundaryIngress'); const ingressRequest = IngressRequest.fromBinary(seen.payload ?? new Uint8Array(0)); - expect(ingressRequest.operation.case).toBe('envelope'); - expect((ingressRequest.operation.value as EnvelopeOp).envelopeBytes).toEqual(envelope); + expect(ingressRequest.operation.case).toBe('routerQuery'); + expect(ingressRequest.operation.case === 'routerQuery' && ingressRequest.operation.value.method).toBe('balance.list'); + expect(ingressRequest.operation.case === 'routerQuery' && ingressRequest.operation.value.args).toEqual(params); }); // The guard is on the bridge's own `ingress` wrapper's answer: index.html @@ -45,7 +46,7 @@ describe('WebViewBridge framing invariants', () => { ingress: async () => ({ nope: true } as any), }; - await expect(processEnvelopeV3Bin(new Uint8Array([1]))).rejects.toThrow( + await expect(routerQueryBin('balance.list')).rejects.toThrow( /expected Uint8Array response from native boundary/, ); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/balanceWireContract.test.ts b/dsm_client/frontend/src/dsm/__tests__/balanceWireContract.test.ts index dd60d27c1..d38558c8f 100644 --- a/dsm_client/frontend/src/dsm/__tests__/balanceWireContract.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/balanceWireContract.test.ts @@ -46,6 +46,14 @@ describe('balance wire contract (Rust -> TypeScript)', () => { expect(row.policyAnchorB32.startsWith(row.anchorFingerprint)).toBe(true); // The policy's icon field, which the wallet draws the token's coin from. expect(row.iconUrl).toBe('dsm:coin:v1:FIXTURE'); + // What the token is and what its committed policy fixes and permits. The + // permissions arrive PRESENT for a created token; absent means Rust holds + // no policy, which a defaulted pair of booleans could not say. + expect(row.protocolDefined).toBe(false); + expect(row.genesisSupplyDisplay).toBe('1000000.00'); + expect(row.permissions).toBeDefined(); + expect(row.permissions.burnEnabled).toBe(true); + expect(row.permissions.transferable).toBe(false); }); /// decimals must arrive as a real number, since the mapper's guard is diff --git a/dsm_client/frontend/src/dsm/__tests__/balancesDecoding.test.ts b/dsm_client/frontend/src/dsm/__tests__/balancesDecoding.test.ts deleted file mode 100644 index ecfd7704e..000000000 --- a/dsm_client/frontend/src/dsm/__tests__/balancesDecoding.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -import { decodeBalancesListResponseStrict } from '../decoding'; -import { BalancesListResponse, BalanceGetResponse, Envelope, Error as PbError } from '../../proto/dsm_app_pb'; - -describe('decodeBalancesListResponseStrict', () => { - test('decodes Envelope-wrapped BalancesListResponse bytes', () => { - const resp = new BalancesListResponse({ - balances: [new BalanceGetResponse({ tokenId: 'ERA', available: 7n, locked: 0n })], - }); - const env = new Envelope({ - version: 3, - payload: { case: 'balancesListResponse', value: resp } - }); - // Add framing byte (0x03) as done by bridge - const framed = new Uint8Array([0x03, ...env.toBinary()]); - const out = decodeBalancesListResponseStrict(framed); - expect(out.decodedVia).toBe('envelope'); - expect(out.response.balances.length).toBe(1); - expect(out.response.balances[0].tokenId).toBe('ERA'); - expect(out.response.balances[0].available).toBe(7n); - }); - - test('throws on Error Envelope (fail-closed)', () => { - const env = new Envelope({ - version: 3, - payload: { case: 'error', value: new PbError({ code: 77, message: 'nope' }) } - }); - // Add framing byte - const framed = new Uint8Array([0x03, ...env.toBinary()]); - expect(() => decodeBalancesListResponseStrict(framed)).toThrow(/Native error:.*nope/); - }); -}); diff --git a/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts b/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts index ef813f388..259d36395 100644 --- a/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts @@ -3,6 +3,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { acceptOfflineTransfer } from '../index'; import { acceptBilateralByCommitmentBridge } from '../WebViewBridge'; +import { bridgeEvents } from '../../bridge/bridgeEvents'; import * as pb from '../../proto/dsm_app_pb'; // Helper to wrap responses in DSM_BRIDGE format @@ -25,7 +26,9 @@ describe('bilateral accept event dispatch', () => { jest.restoreAllMocks(); }); - test('acceptOfflineTransfer dispatches dsm-bilateral-committed', async () => { + // One accept, one committed signal. It used to be dispatched as a window + // event the adapter re-emitted on the bus, and emitted on the bus again. + test('acceptOfflineTransfer emits wallet.bilateralCommitted exactly once', async () => { const commitmentHash = new Uint8Array(32).fill(2); const counterpartyDeviceId = new Uint8Array(32).fill(3); const env = new pb.Envelope({ @@ -48,13 +51,13 @@ describe('bilateral accept event dispatch', () => { }; const handler = jest.fn(); - window.addEventListener('dsm-bilateral-committed', handler as EventListener, { once: true }); + const off = bridgeEvents.on('wallet.bilateralCommitted', handler as any); await acceptOfflineTransfer({ commitmentHash, counterpartyDeviceId }); + off(); expect(handler).toHaveBeenCalledTimes(1); - const event = handler.mock.calls[0]?.[0] as CustomEvent | undefined; - expect(event?.detail).toEqual(expect.objectContaining({ + expect(handler.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ accepted: true, committed: true, commitmentHash, diff --git a/dsm_client/frontend/src/dsm/__tests__/bleIdentityPrune.test.ts b/dsm_client/frontend/src/dsm/__tests__/bleIdentityPrune.test.ts deleted file mode 100644 index a7ec38fc5..000000000 --- a/dsm_client/frontend/src/dsm/__tests__/bleIdentityPrune.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import { encodeBase32Crockford32 as base32CrockfordEncode32 } from '../../utils/textId'; -// Tests for selective pruning of BLE identity mappings -import { dsmClient } from '../index'; -const enc = new TextEncoder(); -// Jest globals declaration (type-only) for TypeScript without importing @types explicitly in test file -declare const describe: any; // provided by Jest environment -declare const it: any; // provided by Jest environment -declare const expect: any; // provided by Jest environment - -// Helper to wrap response in DSM_BRIDGE format -function wrapSuccessEnvelope(data: Uint8Array): Uint8Array { - return (global as any).createDsmBridgeSuccessResponse(data); -} - -function mkBytes(seed: number): Uint8Array { - const b = new Uint8Array(32); - for (let i = 0; i < 32; i++) b[i] = (seed + i) & 0xff; - return b; -} - -describe('pruneBleIdentityMappings', () => { - it('removes only specified mappings while preserving others', async () => { - (globalThis as any).window = (globalThis as any).window || {}; - // three mappings - const devA = mkBytes(1); const genA = mkBytes(2); const addrA = 'AA:AA:AA:AA:AA:AA'; - const devB = mkBytes(3); const genB = mkBytes(4); const addrB = 'BB:BB:BB:BB:BB:BB'; - const devC = mkBytes(5); const genC = mkBytes(6); const addrC = 'CC:CC:CC:CC:CC:CC'; - const map = new Map([ - [base32CrockfordEncode32(devA), addrA], - [base32CrockfordEncode32(devB), addrB], - [base32CrockfordEncode32(devC), addrC], - ]); - (globalThis as any).window.DsmBridge = { - __binary: true, - sendMessageBin: async (reqBytes: Uint8Array) => { - const pb = require('../../proto/dsm_app_pb'); - const req = pb.BridgeRpcRequest.fromBinary(reqBytes); - const method = req.method || ''; - const payload = req.payload?.case === 'bytes' ? req.payload.value.data : new Uint8Array(0); - if (method === 'resolveBleAddressForDeviceId') { - const key = base32CrockfordEncode32(payload); - const addr = map.get(key) || ''; - return addr ? wrapSuccessEnvelope(new Uint8Array(enc.encode(addr))) : wrapSuccessEnvelope(new Uint8Array(0)); - } - return wrapSuccessEnvelope(new Uint8Array(0)); - }, - }; - - await dsmClient.resolveBleAddressForContact?.({ deviceId: devA, genesisHash: genA } as any); - await dsmClient.resolveBleAddressForContact?.({ deviceId: devB, genesisHash: genB } as any); - await dsmClient.resolveBleAddressForContact?.({ deviceId: devC, genesisHash: genC } as any); - - const devHexA = base32CrockfordEncode32(devA); const devHexB = base32CrockfordEncode32(devB); const devHexC = base32CrockfordEncode32(devC); - const genHexA = base32CrockfordEncode32(genA); const genHexB = base32CrockfordEncode32(genB); const genHexC = base32CrockfordEncode32(genC); - - // snapshot before prune - const snapBefore = dsmClient.getBleIdentitySnapshot?.(); - expect(Object.keys(snapBefore?.deviceIds || {}).length).toBeGreaterThanOrEqual(3); - expect(Object.keys(snapBefore?.genesis || {}).length).toBeGreaterThanOrEqual(3); - - // prune devB and genesis C only (API accepts raw bytes; return value is void) - dsmClient.pruneBleIdentityMappings?.({ deviceIds: [devB], genesisHashes: [genC] } as any); - - const snapAfter = dsmClient.getBleIdentitySnapshot?.(); - // devA & devC remain; devB removed - expect(snapAfter?.deviceIds?.[devHexA]).toBe(addrA); - expect(snapAfter?.deviceIds?.[devHexB]).toBeUndefined(); - expect(snapAfter?.deviceIds?.[devHexC]).toBe(addrC); - // genesis A & B remain; C removed - expect(snapAfter?.genesis?.[genHexA]).toBe(addrA); - expect(snapAfter?.genesis?.[genHexB]).toBe(addrB); - expect(snapAfter?.genesis?.[genHexC]).toBeUndefined(); - - // No localStorage persistence; native mapping is source of truth. - }); -}); diff --git a/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts b/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts index f682ac7b1..b58804cd0 100644 --- a/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts @@ -92,39 +92,4 @@ describe('resolveBleAddressForContact', () => { expect(snap?.deviceIds?.[devIdB32]).toBe(address); expect(snap?.genesis?.[genesisB32]).toBe(address); }); - - it('clears cached identities via clearBleIdentityCache', async () => { - // Precondition: ensure at least one mapping exists (reuse previous test logic) - const devId = mkBytes(20); - const genesis = mkBytes(21); - const address = 'AA:11:22:33:44:55'; - (globalThis as any).window.DsmBridge = { - __binary: true, - sendMessageBin: async (reqBytes: Uint8Array) => { - const pb = require('../../proto/dsm_app_pb'); - const req = pb.BridgeRpcRequest.fromBinary(reqBytes); - const method = req.method || ''; - const payload = req.payload?.case === 'bytes' ? req.payload.value.data : new Uint8Array(0); - if (method === 'resolveBleAddressForDeviceId') { - if (payload.length === devId.length && payload.every((b: number, i: number) => b === devId[i])) { - return createDsmBridgeSuccessResponse(new Uint8Array(enc.encode(address))); - } - } - return createDsmBridgeSuccessResponse(new Uint8Array(0)); - }, - }; - const contact = { alias: 'Peer2', deviceId: devId, genesisHash: genesis }; - const resolved = await dsmClient.resolveBleAddressForContact?.(contact as any); - expect(resolved).toBe(address); - // Clear cache - dsmClient.clearBleIdentityCache?.(); - // Snapshot empty - const snap = dsmClient.getBleIdentitySnapshot?.(); - expect(Object.keys(snap?.deviceIds || {}).length).toBe(0); - expect(Object.keys(snap?.genesis || {}).length).toBe(0); - - // Native mapping still resolves and repopulates cache on demand. - const resolvedAgain = await dsmClient.resolveBleAddressForContact?.(contact as any); - expect(resolvedAgain).toBe(address); - }); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/bridgeDecoding.integration.test.ts b/dsm_client/frontend/src/dsm/__tests__/bridgeDecoding.integration.test.ts index 15fe1ebea..965f2d9b4 100644 --- a/dsm_client/frontend/src/dsm/__tests__/bridgeDecoding.integration.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/bridgeDecoding.integration.test.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 /* eslint-disable @typescript-eslint/no-explicit-any */ -import { decodeBalancesListResponseStrict, decodeFramedEnvelopeV3 } from '../decoding'; -import { processEnvelopeV3Bin } from '../WebViewBridge'; +import { decodeFramedEnvelopeV3 } from '../decoding'; +import { routerQueryBin } from '../WebViewBridge'; function makeInvalidResponse(): Uint8Array { return new Uint8Array([0x01, 0x02, 0x03, 0x04]); @@ -25,12 +25,12 @@ describe('bridge decoding boundary (integration)', () => { // wrapper answers `invalid bridge response for `. it('rejects invalid BridgeRpcResponse bytes', async () => { (global as any).window.DsmBridge.sendMessageBin = async () => makeInvalidResponse(); - await expect(processEnvelopeV3Bin(new Uint8Array([1, 2, 3]))).rejects.toThrow(/invalid bridge response for nativeBoundaryIngress/); + await expect(routerQueryBin('balance.list', new Uint8Array([1, 2, 3]))).rejects.toThrow(/invalid bridge response for nativeBoundaryIngress/); }); it('propagates bridge error payloads', async () => { (global as any).window.DsmBridge.sendMessageBin = async () => makeErrorResponse('native exploded'); - await expect(processEnvelopeV3Bin(new Uint8Array([1]))).rejects.toThrow(/native exploded/i); + await expect(routerQueryBin('balance.list')).rejects.toThrow(/native exploded/i); }); // A boundary failure reaches the diagnostics bus as its message. The port @@ -42,7 +42,7 @@ describe('bridge decoding boundary (integration)', () => { const seen: any[] = []; const off = bridgeEvents.on('bridge.error', (detail: any) => { seen.push(detail); }); try { - await expect(processEnvelopeV3Bin(new Uint8Array([1]))).rejects.toThrow(/native exploded/); + await expect(routerQueryBin('balance.list')).rejects.toThrow(/native exploded/); } finally { off(); } @@ -58,9 +58,4 @@ describe('bridge decoding boundary (integration)', () => { it('decodeFramedEnvelopeV3 rejects empty bytes', () => { expect(() => decodeFramedEnvelopeV3(new Uint8Array(0))).toThrow(); }); - - it('decodeBalancesListResponseStrict rejects garbage bytes', () => { - const raw = new Uint8Array([9, 9, 9, 9, 9, 9]); - expect(() => decodeBalancesListResponseStrict(raw, { label: 'test' })).toThrow(/invalid framing byte/i); - }); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/encoding_decoding_helpers.test.ts b/dsm_client/frontend/src/dsm/__tests__/encoding_decoding_helpers.test.ts index adbaa5638..cb6b9c79b 100644 --- a/dsm_client/frontend/src/dsm/__tests__/encoding_decoding_helpers.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/encoding_decoding_helpers.test.ts @@ -8,17 +8,14 @@ declare const it: any; declare const expect: any; import * as pb from '../../proto/dsm_app_pb'; -import { decodeFramedEnvelopeV3, encodeEnvelope } from '../decoding'; +import { decodeFramedEnvelopeV3 } from '../decoding'; describe('encoding/decoding helpers parity', () => { - it('encodeEnvelope matches native toBinary and decodeFramedEnvelopeV3 round-trips via framing', () => { + it('decodeFramedEnvelopeV3 round-trips a framed envelope', () => { const headers = new pb.Headers({ deviceId: new Uint8Array(32), genesisHash: new Uint8Array(32),} as any); const env = new pb.Envelope({ version: 3, headers } as any); - const direct = env.toBinary(); - const encoded = encodeEnvelope(env); - expect(encoded).toEqual(direct); // Wrap with 0x03 framing for canonical decode path - const framed = new Uint8Array([0x03, ...encoded]); + const framed = new Uint8Array([0x03, ...env.toBinary()]); const decoded = decodeFramedEnvelopeV3(framed); expect(decoded.version).toBe(3); expect(decoded.headers?.deviceId?.length).toBe(32); diff --git a/dsm_client/frontend/src/dsm/__tests__/events.test.ts b/dsm_client/frontend/src/dsm/__tests__/events.test.ts index 83fcdc941..6b12ef1e7 100644 --- a/dsm_client/frontend/src/dsm/__tests__/events.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/events.test.ts @@ -22,18 +22,12 @@ jest.mock('../../bridge/bridgeEvents', () => { }; }); -import { emitWalletRefresh, emitBilateralCommitted, DSM_WALLET_REFRESH_EVENT } from '../events'; +import { emitWalletRefresh, emitBilateralCommitted } from '../events'; import { bridgeEvents } from '../../bridge/bridgeEvents'; describe('events.ts', () => { beforeEach(() => jest.clearAllMocks()); - describe('DSM_WALLET_REFRESH_EVENT', () => { - test('has the canonical event name', () => { - expect(DSM_WALLET_REFRESH_EVENT).toBe('dsm-wallet-refresh'); - }); - }); - describe('emitWalletRefresh', () => { test('emits wallet.refresh event with detail', () => { const detail = { source: 'storage.sync' }; diff --git a/dsm_client/frontend/src/dsm/__tests__/fixtures/balances_list_rigb.bin b/dsm_client/frontend/src/dsm/__tests__/fixtures/balances_list_rigb.bin index c8c8ba1d8..d9ef898d6 100644 --- a/dsm_client/frontend/src/dsm/__tests__/fixtures/balances_list_rigb.bin +++ b/dsm_client/frontend/src/dsm/__tests__/fixtures/balances_list_rigb.bin @@ -1,3 +1,4 @@ -’³ -° -RIGB �"RIGB(2RigBravo:1000.00B4B9D5MPJTB9D5MPJTB9D5MPJTB9D5MPJTB9D5MPJTB9D5MPJTB9D0JB9D5MPJTR4QMK5SY91DSJDY8KHAP6CCTWW80X7GHTVKFZ0KXTHAGQSTMFGV3GGZdsm:coin:v1:FIXTURE \ No newline at end of file +’Ã +À +RIGB �"RIGB(2RigBravo:1000.00B4B9D5MPJTB9D5MPJTB9D5MPJTB9D5MPJTB9D5MPJTB9D5MPJTB9D0JB9D5MPJTR4QMK5SY91DSJDY8KHAP6CCTWW80X7GHTVKFZ0KXTHAGQSTMFGV3GGZdsm:coin:v1:FIXTUREj +1000000.00r \ No newline at end of file diff --git a/dsm_client/frontend/src/dsm/__tests__/identity.test.ts b/dsm_client/frontend/src/dsm/__tests__/identity.test.ts index 9bf0fb1f8..dc1d94a98 100644 --- a/dsm_client/frontend/src/dsm/__tests__/identity.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/identity.test.ts @@ -2,7 +2,6 @@ jest.mock('../WebViewBridge', () => ({ queryTransportHeadersV3: jest.fn(), - getDeviceIdBinBridgeAsync: jest.fn(), getPreference: jest.fn(), setPreference: jest.fn(), })); @@ -19,19 +18,17 @@ import * as pb from '../../proto/dsm_app_pb'; import { getHeaders, getIdentity, - getDeviceIdentity, - getBluetoothStatus, - isReady, getPreference, setPreference, + isIdentityUnavailable, } from '../identity'; import { queryTransportHeadersV3, - getDeviceIdBinBridgeAsync, getPreference as getPreferenceBridge, setPreference as setPreferenceBridge, } from '../WebViewBridge'; import { nativeSessionStore } from '../../runtime/nativeSessionStore'; +import { bridgeEvents } from '../../bridge/bridgeEvents'; import { encodeBase32Crockford } from '../../utils/textId'; function makeValidDeviceId(): Uint8Array { @@ -122,11 +119,74 @@ describe('identity.ts', () => { }); }); - test('returns null after all retries fail', async () => { + /** The native session as Rust reported it, beside the store's hardware fields. */ + function session(state: { received: boolean; identity_status: string }) { + (nativeSessionStore.getSnapshot as jest.Mock).mockReturnValue({ + ...state, + hardware_status: { ble: { enabled: false, advertising: false, scanning: false } }, + }); + } + + // The three answers that used to be one null. + test('with no session state and no readable headers, the answer is runtime-not-ready after the window', async () => { + session({ received: false, identity_status: 'runtime_not_ready' }); (queryTransportHeadersV3 as jest.Mock).mockResolvedValue(new Uint8Array(0)); - const identity = await getIdentity(); - expect(identity).toBeNull(); + const failure = await getIdentity().catch((e) => e); + expect(isIdentityUnavailable(failure)).toBe(true); + expect(failure.state).toBe('runtime_not_ready'); + expect(failure.message).toMatch(/no session state received/); + expect(failure.message).toMatch(/DSM bridge identity not ready/); + }, 30_000); + + test('a missing identity is answered as missing at once, without the retry window', async () => { + session({ received: true, identity_status: 'missing' }); + (queryTransportHeadersV3 as jest.Mock).mockResolvedValue(new Uint8Array(0)); + + const started = Date.now(); + const failure = await getIdentity().catch((e) => e); + expect(isIdentityUnavailable(failure)).toBe(true); + expect(failure.state).toBe('missing'); + expect(Date.now() - started).toBeLessThan(1000); + expect(queryTransportHeadersV3).not.toHaveBeenCalled(); + }); + + // The wait between attempts ends early on Rust's `identity.ready`, on the + // bus. It used to listen on `window` for an event dispatched on + // `document`, and never woke. + test('the wait wakes early on identity.ready from the bus', async () => { + jest.useFakeTimers(); + try { + session({ received: true, identity_status: 'ready' }); + const deviceId = makeValidDeviceId(); + const genesisHash = makeValidGenesisHash(); + (queryTransportHeadersV3 as jest.Mock) + .mockResolvedValueOnce(new Uint8Array(0)) + .mockResolvedValueOnce(makeHeadersBinary(deviceId, genesisHash)); + + const pending = getIdentity(); + // The first attempt fails and the wait begins; the announcement ends it. + // No timer is advanced: only the wake can end the wait. + for (let i = 0; i < 20; i++) { + bridgeEvents.emit('identity.ready', undefined as never); + await Promise.resolve(); + } + const identity = await pending; + expect(identity.deviceId).toBe(encodeBase32Crockford(deviceId)); + expect(queryTransportHeadersV3).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + test('a ready session whose headers never read is answered as not read, with the reason', async () => { + session({ received: true, identity_status: 'ready' }); + (queryTransportHeadersV3 as jest.Mock).mockRejectedValue(new Error('port closed')); + + const failure = await getIdentity().catch((e) => e); + expect(isIdentityUnavailable(failure)).toBe(true); + expect(failure.state).toBe('read_failed'); + expect(failure.message).toMatch(/reports it ready/); }, 30_000); test('succeeds on later retry attempt', async () => { @@ -140,65 +200,10 @@ describe('identity.ts', () => { }); const identity = await getIdentity(); - expect(identity).not.toBeNull(); - expect(identity!.deviceId).toBe(encodeBase32Crockford(deviceId)); + expect(identity.deviceId).toBe(encodeBase32Crockford(deviceId)); }, 30_000); }); - // ── getDeviceIdentity ────────────────────────────────────────────── - - describe('getDeviceIdentity', () => { - test('returns base32-encoded device id', async () => { - const deviceId = makeValidDeviceId(); - (getDeviceIdBinBridgeAsync as jest.Mock).mockResolvedValue(deviceId); - - const result = await getDeviceIdentity(); - expect(result).toBe(encodeBase32Crockford(deviceId)); - }); - - test('returns null for empty bytes', async () => { - (getDeviceIdBinBridgeAsync as jest.Mock).mockResolvedValue(new Uint8Array(0)); - expect(await getDeviceIdentity()).toBeNull(); - }); - - test('returns null on bridge error', async () => { - (getDeviceIdBinBridgeAsync as jest.Mock).mockRejectedValue(new Error('fail')); - expect(await getDeviceIdentity()).toBeNull(); - }); - }); - - // ── getBluetoothStatus ───────────────────────────────────────────── - - describe('getBluetoothStatus', () => { - test('reads from native session store', async () => { - (nativeSessionStore.getSnapshot as jest.Mock).mockReturnValue({ - hardware_status: { ble: { enabled: true, advertising: true, scanning: false } }, - }); - - const status = await getBluetoothStatus(); - expect(status).toEqual({ enabled: true, advertising: true, scanning: false }); - }); - }); - - // ── isReady ──────────────────────────────────────────────────────── - - describe('isReady', () => { - test('returns true when device identity exists', async () => { - (getDeviceIdBinBridgeAsync as jest.Mock).mockResolvedValue(makeValidDeviceId()); - expect(await isReady()).toBe(true); - }); - - test('returns false when device identity is empty', async () => { - (getDeviceIdBinBridgeAsync as jest.Mock).mockResolvedValue(new Uint8Array(0)); - expect(await isReady()).toBe(false); - }); - - test('returns false on error', async () => { - (getDeviceIdBinBridgeAsync as jest.Mock).mockRejectedValue(new Error('nope')); - expect(await isReady()).toBe(false); - }); - }); - // ── Preferences ──────────────────────────────────────────────────── describe('getPreference', () => { diff --git a/dsm_client/frontend/src/dsm/__tests__/policies.test.ts b/dsm_client/frontend/src/dsm/__tests__/policies.test.ts index da6382073..a972ba67f 100644 --- a/dsm_client/frontend/src/dsm/__tests__/policies.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/policies.test.ts @@ -10,7 +10,6 @@ jest.mock('../WebViewBridge', () => ({ jest.mock('../events', () => ({ emitWalletRefresh: jest.fn(), emitBilateralCommitted: jest.fn(), - DSM_WALLET_REFRESH_EVENT: 'dsm-wallet-refresh', })); import * as pb from '../../proto/dsm_app_pb'; diff --git a/dsm_client/frontend/src/dsm/__tests__/repro_balance.test.ts b/dsm_client/frontend/src/dsm/__tests__/repro_balance.test.ts deleted file mode 100644 index e7da62767..000000000 --- a/dsm_client/frontend/src/dsm/__tests__/repro_balance.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - - -import * as pb from '../../proto/dsm_app_pb'; - -describe('Balance Mapping Reproduction', () => { - it('should correctly map available balance from protobuf', () => { - // 1. Create a BalanceGetResponse with balance 5 - const balanceItem = new pb.BalanceGetResponse({ - tokenId: 'ERA', - available: BigInt(5), - locked: BigInt(0), - }); - - // 2. Create a BalancesListResponse containing the item - const listResponse = new pb.BalancesListResponse({ - balances: [balanceItem], - }); - - // 3. Encode to binary - const binary = listResponse.toBinary(); - - // 4. Decode back (mimicking getAllBalances) - const decodedList = pb.BalancesListResponse.fromBinary(binary); - - // 5. Map it (mimicking getAllBalances logic) - const mapped = (decodedList.balances || []).map((b: any) => { - // DEBUG: Inspect the object structure to verify field names - console.log('[DSM] Balance item keys:', Object.keys(b)); - console.log('[DSM] Balance item:', b); - - return { - tokenId: b.tokenId || 'ERA', - symbol: (b.tokenId || 'ERA') === 'ERA' ? 'ERA' : b.tokenId, - balance: String(b.available ?? 0), - }; - }); - - console.log('Mapped result:', mapped); - - // 6. Assertions - expect(mapped).toHaveLength(1); - expect(mapped[0].tokenId).toBe('ERA'); - expect(mapped[0].balance).toBe('5'); - }); -}); diff --git a/dsm_client/frontend/src/dsm/__tests__/resolution.test.ts b/dsm_client/frontend/src/dsm/__tests__/resolution.test.ts index f14a819a8..ece48ed09 100644 --- a/dsm_client/frontend/src/dsm/__tests__/resolution.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/resolution.test.ts @@ -8,13 +8,7 @@ jest.mock('../WebViewBridge', () => ({ resolveBleAddressForDeviceIdBridge: jest.fn(), })); -import { - normalizeBleAddress, - persistBleMapping, - clearBleIdentityCache, - getBleIdentitySnapshot, - pruneBleIdentityMappings, -} from '../resolution'; +import { normalizeBleAddress } from '../resolution'; describe('normalizeBleAddress', () => { it('returns uppercase colon-separated form for valid colon address', () => { @@ -79,89 +73,54 @@ describe('normalizeBleAddress', () => { }); describe('BLE identity cache operations', () => { + // The cache is module state that production never clears; each test gets + // its own copy of the module rather than a setter that existed for tests. + let r: typeof import('../resolution'); + beforeEach(() => { - clearBleIdentityCache(); + jest.isolateModules(() => { + r = require('../resolution'); + }); }); it('starts with an empty snapshot', () => { - const snap = getBleIdentitySnapshot(); + const snap = r.getBleIdentitySnapshot(); expect(snap.deviceIds).toEqual({}); expect(snap.genesis).toEqual({}); }); it('persistBleMapping stores deviceId mapping', () => { const devId = new Uint8Array(32).fill(0x01); - persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:FF', deviceId: devId }); - const snap = getBleIdentitySnapshot(); + r.persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:FF', deviceId: devId }); + const snap = r.getBleIdentitySnapshot(); const key = encodeBase32Crockford(devId); expect(snap.deviceIds[key]).toBe('AA:BB:CC:DD:EE:FF'); }); it('persistBleMapping stores genesisHash mapping', () => { const gen = new Uint8Array(32).fill(0x02); - persistBleMapping({ bleAddress: 'aabbccddeeff', genesisHash: gen }); - const snap = getBleIdentitySnapshot(); + r.persistBleMapping({ bleAddress: 'aabbccddeeff', genesisHash: gen }); + const snap = r.getBleIdentitySnapshot(); const key = encodeBase32Crockford(gen); expect(snap.genesis[key]).toBe('AA:BB:CC:DD:EE:FF'); }); it('persistBleMapping accepts string deviceIdStr', () => { - persistBleMapping({ bleAddress: '11:22:33:44:55:66', deviceIdStr: 'MYDEVICE' }); - const snap = getBleIdentitySnapshot(); + r.persistBleMapping({ bleAddress: '11:22:33:44:55:66', deviceIdStr: 'MYDEVICE' }); + const snap = r.getBleIdentitySnapshot(); expect(snap.deviceIds['MYDEVICE']).toBe('11:22:33:44:55:66'); }); it('persistBleMapping ignores invalid bleAddress', () => { const devId = new Uint8Array(32).fill(0x03); - persistBleMapping({ bleAddress: 'invalid', deviceId: devId }); - const snap = getBleIdentitySnapshot(); + r.persistBleMapping({ bleAddress: 'invalid', deviceId: devId }); + const snap = r.getBleIdentitySnapshot(); expect(Object.keys(snap.deviceIds).length).toBe(0); }); it('persistBleMapping ignores empty Uint8Array deviceId', () => { - persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:FF', deviceId: new Uint8Array(0) }); - const snap = getBleIdentitySnapshot(); + r.persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:FF', deviceId: new Uint8Array(0) }); + const snap = r.getBleIdentitySnapshot(); expect(Object.keys(snap.deviceIds).length).toBe(0); }); - - it('clearBleIdentityCache empties both maps', () => { - const devId = new Uint8Array(32).fill(0x04); - const gen = new Uint8Array(32).fill(0x05); - persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:FF', deviceId: devId, genesisHash: gen }); - expect(Object.keys(getBleIdentitySnapshot().deviceIds).length).toBe(1); - - clearBleIdentityCache(); - const snap = getBleIdentitySnapshot(); - expect(snap.deviceIds).toEqual({}); - expect(snap.genesis).toEqual({}); - }); - - it('pruneBleIdentityMappings removes specified deviceIds', () => { - const devA = new Uint8Array(32).fill(0x0a); - const devB = new Uint8Array(32).fill(0x0b); - persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:01', deviceId: devA }); - persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:02', deviceId: devB }); - expect(Object.keys(getBleIdentitySnapshot().deviceIds).length).toBe(2); - - pruneBleIdentityMappings({ deviceIds: [devA] }); - const snap = getBleIdentitySnapshot(); - expect(Object.keys(snap.deviceIds).length).toBe(1); - expect(snap.deviceIds[encodeBase32Crockford(devB)]).toBe('AA:BB:CC:DD:EE:02'); - }); - - it('pruneBleIdentityMappings removes specified genesisHashes', () => { - const gen = new Uint8Array(32).fill(0x0c); - persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:FF', genesisHash: gen }); - expect(Object.keys(getBleIdentitySnapshot().genesis).length).toBe(1); - - pruneBleIdentityMappings({ genesisHashes: [gen] }); - expect(Object.keys(getBleIdentitySnapshot().genesis).length).toBe(0); - }); - - it('pruneBleIdentityMappings with empty arrays is a no-op', () => { - const devId = new Uint8Array(32).fill(0x0d); - persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:FF', deviceId: devId }); - pruneBleIdentityMappings({ deviceIds: [], genesisHashes: [] }); - expect(Object.keys(getBleIdentitySnapshot().deviceIds).length).toBe(1); - }); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/syncWithStorageError.test.ts b/dsm_client/frontend/src/dsm/__tests__/syncWithStorageError.test.ts index 2b59a0445..642b89eb5 100644 --- a/dsm_client/frontend/src/dsm/__tests__/syncWithStorageError.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/syncWithStorageError.test.ts @@ -25,12 +25,4 @@ describe('syncWithStorage error envelope handling', () => { } }); - test('getAllBalances throws when bridge returns Error envelope', async () => { - const env = new Envelope({ version: 3, payload: { case: 'error', value: new PbError({ code: 77, message: 'nope' }) } as any }); - // Add framing byte as done by bridge - const framed = new Uint8Array([0x03, ...env.toBinary()]); - // Test direct call - const { decodeBalancesListResponseStrict } = await import('../decoding'); - expect(() => decodeBalancesListResponseStrict(framed)).toThrow(/Native error:.*nope/); - }); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/wallet.test.ts b/dsm_client/frontend/src/dsm/__tests__/wallet.test.ts index 080cf1128..e7813c4d3 100644 --- a/dsm_client/frontend/src/dsm/__tests__/wallet.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/wallet.test.ts @@ -14,7 +14,6 @@ import * as pb from '../../proto/dsm_app_pb'; import { getAllBalances, getWalletHistory, - getTransactions, getInbox, } from '../wallet'; import { @@ -44,7 +43,7 @@ describe('wallet.ts', () => { case: 'balancesListResponse', value: new pb.BalancesListResponse({ balances: [ - new pb.BalanceGetResponse({ tokenId: 'ERA', available: 1000n, symbol: 'ERA', decimals: 0, tokenName: 'ERA', displayAmount: '1000' }), + new pb.BalanceGetResponse({ tokenId: 'ERA', available: 1000n, symbol: 'ERA', decimals: 0, tokenName: 'ERA', displayAmount: '1000', protocolDefined: true }), new pb.BalanceGetResponse({ tokenId: 'RIGB', available: 100000n, @@ -56,6 +55,9 @@ describe('wallet.ts', () => { policyAnchorB32: 'ANCH0R', anchorFingerprint: 'ANCH', iconUrl: 'dsm:coin:v1:ABC', + protocolDefined: false, + genesisSupplyDisplay: '1000.00', + permissions: { burnEnabled: true, transferable: false }, }), ], }), @@ -77,6 +79,11 @@ describe('wallet.ts', () => { policyAnchorB32: undefined, anchorFingerprint: undefined, iconUrl: undefined, + // A protocol asset on Rust's word; it states no supply here and no + // permissions, and neither is filled in. + protocolDefined: true, + genesisSupplyDisplay: undefined, + permissions: undefined, }, { tokenId: 'RIGB', @@ -89,6 +96,9 @@ describe('wallet.ts', () => { policyAnchorB32: 'ANCH0R', anchorFingerprint: 'ANCH', iconUrl: 'dsm:coin:v1:ABC', + protocolDefined: false, + genesisSupplyDisplay: '1000.00', + permissions: { burnEnabled: true, transferable: false }, }, ]); }); @@ -121,6 +131,13 @@ describe('wallet.ts', () => { answer(new pb.BalanceGetResponse({ tokenId: 'RIGB', available: 5n, symbol: 'RIGB', tokenName: 'RIGB' })), ); await expect(getAllBalances()).rejects.toThrow(/STRICT.*RIGB without its display_amount/); + + // A created token without its policy's facts: Rust reads them from the + // committed bytes or refuses the row, so their absence is not a row. + (getAllBalancesStrictBridge as jest.Mock).mockResolvedValue( + answer(new pb.BalanceGetResponse({ tokenId: 'RIGB', available: 5n, symbol: 'RIGB', tokenName: 'RIGB', displayAmount: '5' })), + ); + await expect(getAllBalances()).rejects.toThrow(/STRICT.*created token RIGB without its policy facts/); }); test('throws on error envelope', async () => { @@ -215,24 +232,6 @@ describe('wallet.ts', () => { }); }); - // ── getTransactions ──────────────────────────────────────────────── - - describe('getTransactions', () => { - test('returns transactions array from wallet history', async () => { - const env = new pb.Envelope({ - version: 3, - payload: { - case: 'walletHistoryResponse', - value: new pb.WalletHistoryResponse({ transactions: [] }), - }, - }); - (getWalletHistoryStrictBridge as jest.Mock).mockResolvedValue(frameEnvelope(env)); - - const result = await getTransactions(); - expect(Array.isArray(result)).toBe(true); - }); - }); - // ── getInbox ─────────────────────────────────────────────────────── describe('getInbox', () => { diff --git a/dsm_client/frontend/src/dsm/bridgeTypes.ts b/dsm_client/frontend/src/dsm/bridgeTypes.ts index 4a2e50647..5db0d9028 100644 --- a/dsm_client/frontend/src/dsm/bridgeTypes.ts +++ b/dsm_client/frontend/src/dsm/bridgeTypes.ts @@ -9,8 +9,6 @@ export interface AndroidBridgeV3 { isAvailable: () => boolean; /** One BridgeRpcRequest over the port; answers the BridgeRpcResponse bytes Kotlin posted. */ sendMessageBin: (payload: Uint8Array) => Promise; - /** `nativeBoundaryStartup`, unwrapped to the boundary's bytes. */ - startup: (payload: Uint8Array) => Promise; /** `nativeBoundaryIngress`, unwrapped to the boundary's bytes. */ ingress: (payload: Uint8Array) => Promise; /** `nativeHostRequest`, unwrapped to the host's bytes. */ diff --git a/dsm_client/frontend/src/dsm/crypto.ts b/dsm_client/frontend/src/dsm/crypto.ts deleted file mode 100644 index b82a0a421..000000000 --- a/dsm_client/frontend/src/dsm/crypto.ts +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import * as pb from '../proto/dsm_app_pb'; - -export function ab(len: number): Uint8Array { - return new Uint8Array(len); -} - -export function zeroHash32(): pb.Hash32 { - // Cast to any to satisfy Uint8Array vs ArrayBufferLike - return new pb.Hash32({ v: ab(32) as any }); -} - -export function zeroBytes32(): Uint8Array { - return ab(32); -} - -// Oneof helper for protobuf-ts style -export function mkOneof(caseName: string, value: T): any { - return { case: caseName as any, value } as any; -} - -export function codecProto(): number { - const anyPb: any = pb; - // Codec.PROTO is an enum value (0). - // If it's exposed directly on pb namespace or requires pb.Codec.PROTO access - if (typeof anyPb.Codec?.PROTO === 'number') return anyPb.Codec.PROTO; - if (anyPb.CodecProto && typeof anyPb.CodecProto.PROTO === 'number') - return anyPb.CodecProto.PROTO; - return 0; -} diff --git a/dsm_client/frontend/src/dsm/decoding.ts b/dsm_client/frontend/src/dsm/decoding.ts index a46a41a47..9d605d649 100644 --- a/dsm_client/frontend/src/dsm/decoding.ts +++ b/dsm_client/frontend/src/dsm/decoding.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 import * as pb from '../proto/dsm_app_pb'; -import { bytesToBase32CrockfordPrefix, encodeBase32Crockford, decodeBase32Crockford } from '../utils/textId'; +import { encodeBase32Crockford, decodeBase32Crockford } from '../utils/textId'; /** @@ -66,66 +66,5 @@ export function fromBase32Crockford(value: string): Uint8Array { return decodeBase32Crockford(value); } -/** Encode a canonical Envelope into deterministic bytes. */ -export function encodeEnvelope(env: pb.Envelope): Uint8Array { - return env.toBinary(); -} - // decodeEnvelope deleted: all transport ingress MUST go through decodeFramedEnvelopeV3. // Direct pb.Envelope.fromBinary is not allowed outside the canonical decoder. - -// ---------------- Balances decoding (consolidated) ---------------- - -export type DecodeBalancesOpts = { - /** Label used in error messages/logs for easier tracing. */ - label?: string; -}; - -export type DecodeBalancesResult = { - response: pb.BalancesListResponse; - headB32: string; - /** Where we found the response bytes. Useful for debugging bridge mismatches. */ - decodedVia: 'direct' | 'result-pack' | 'arg-pack' | 'arg-pack-result-pack' | 'envelope' | 'unwrapped'; -}; - - - -/** - * Strictly decode a BalancesListResponse from arbitrary bridge bytes. - * - * Rules: - * - Skip 1-byte framing prefix (0x03 transport marker) - * - Decode as Envelope - * - If error payload, throw - * - Extract balancesListResponse from envelope payload - */ -export function decodeBalancesListResponseStrict(bytes: Uint8Array, opts?: DecodeBalancesOpts): DecodeBalancesResult { - const label = opts?.label ?? 'balances'; - if (!(bytes instanceof Uint8Array) || bytes.length === 0) { - throw new Error(`DSM:${label}: empty response bytes`); - } - - const headB32 = bytesToBase32CrockfordPrefix(bytes, 24); - // ALL bridge responses go through the single canonical decoder — no manual byte slicing. - const env = decodeFramedEnvelopeV3(bytes); - - // Check for error response first - if (env.payload.case === 'error') { - const err = env.payload.value; - throw new Error(`Native error: ${err.message || 'Unknown error'} (code ${err.code || 0})`); - } - - // Extract balances from envelope - if (env.payload.case !== 'balancesListResponse') { - throw new Error(`Unexpected payload case for balances: ${env.payload.case}`); - } - - const balancesResponse = env.payload.value; - if (!balancesResponse) { - throw new Error('balancesListResponse payload is null'); - } - - return { response: balancesResponse, headB32, decodedVia: 'envelope' }; -} - -// Helper removed: bytesEqual was unused diff --git a/dsm_client/frontend/src/dsm/events.ts b/dsm_client/frontend/src/dsm/events.ts index 51b3524b4..f86c9e095 100644 --- a/dsm_client/frontend/src/dsm/events.ts +++ b/dsm_client/frontend/src/dsm/events.ts @@ -15,13 +15,11 @@ export interface BilateralCommittedEventDetail { } /** - * Canonical wallet refresh event. + * Canonical wallet refresh event: `wallet.refresh` on the bus. * - * Determinism rule: there should be exactly ONE pathway to trigger a UI refresh. - * All wallet mutation boundaries must emit ONLY this (coalesced) event. + * Determinism rule: there is exactly ONE pathway to trigger a UI refresh. + * All wallet mutation boundaries emit ONLY this (coalesced) event. */ -export const DSM_WALLET_REFRESH_EVENT = 'dsm-wallet-refresh' as const; - export type WalletRefreshDetail = { /** Where the mutation originated (e.g. 'wallet.send', 'storage.sync', 'bilateral.commit'). */ source: string; diff --git a/dsm_client/frontend/src/dsm/identity.ts b/dsm_client/frontend/src/dsm/identity.ts index e9798ad14..74d255b4e 100644 --- a/dsm_client/frontend/src/dsm/identity.ts +++ b/dsm_client/frontend/src/dsm/identity.ts @@ -2,11 +2,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as pb from '../proto/dsm_app_pb'; -import { queryTransportHeadersV3, getDeviceIdBinBridgeAsync, getPreference as getPreferenceBridge, setPreference as setPreferenceBridge } from './WebViewBridge'; +import { queryTransportHeadersV3, getPreference as getPreferenceBridge, setPreference as setPreferenceBridge } from './WebViewBridge'; import { encodeBase32Crockford } from '../utils/textId'; import { IdentityInfo } from './types'; import logger from '../utils/logger'; import { nativeSessionStore } from '../runtime/nativeSessionStore'; +import { bridgeEvents } from '../bridge/bridgeEvents'; +import { IdentityUnavailableError } from './identityUnavailable'; // Cache the last known-good identity to avoid flip-flops. const g: any = globalThis as any; @@ -71,7 +73,10 @@ export async function getHeaders(): Promise { throw new Error('DSM bridge identity not ready'); } -export async function getIdentity(): Promise { +export { IdentityUnavailableError, isIdentityUnavailable } from './identityUnavailable'; +export type { IdentityUnavailableState } from './identityUnavailable'; + +export async function getIdentity(): Promise { // Retry with increasing yields to handle the cold-start race where React // mounts before the Android MessagePort is delivered. The port arrival fires // 'dsm-bridge-ready' but loadWalletData may already be in-flight by then. @@ -85,27 +90,35 @@ export async function getIdentity(): Promise { // from SQLite yet (common on slower devices). Keep retrying through the full window. // Do NOT fast-exit on "identity not ready" — genesis may already exist in the DB but the // SDK initialization is still in-flight. Let the full delay window play out. + // + // The one fast exit is Rust's own word: a native session reporting the + // identity `missing` means there is nothing to wait for, and it is answered + // as missing at once rather than as the same null a failed read produced. const retryDelays = [0, 150, 300, 600, 1000, 1500, 2200]; + let lastFailure: unknown = null; for (let attempt = 0; attempt < retryDelays.length; attempt++) { if (attempt > 0) { - // Yield to event loop to allow bridge port delivery / gate drain. - // Also listen for the identity-ready event so we wake up early if it fires. + // Yield to event loop to allow bridge port delivery / gate drain, and + // wake early on Rust's `identity.ready` — on the bus, where the event + // bridge puts it; it used to be listened for on `window` after being + // dispatched on `document`, and never woke anything. const delay = retryDelays[attempt]; await new Promise(resolve => { let settled = false; - const settle = () => { if (!settled) { settled = true; resolve(); } }; - const onReady = () => { settle(); }; - if (typeof window !== 'undefined') { - window.addEventListener('dsm-identity-ready', onReady, { once: true }); - } - setTimeout(() => { - if (typeof window !== 'undefined') { - window.removeEventListener('dsm-identity-ready', onReady); - } - settle(); - }, delay); + const offReady = bridgeEvents.on('identity.ready', () => settle()); + const settle = () => { + if (settled) return; + settled = true; + offReady(); + resolve(); + }; + setTimeout(settle, delay); }); } + const session = nativeSessionStore.getSnapshot(); + if (session.received && session.identity_status === 'missing') { + throw new IdentityUnavailableError('missing', 'no identity on this device (native session: missing)'); + } try { const h = await getHeaders(); return { @@ -113,34 +126,23 @@ export async function getIdentity(): Promise { genesisHash: encodeBase32Crockford(h.genesisHash), }; } catch (e) { + lastFailure = e; logger.warn(`[getIdentity] attempt ${attempt + 1}/${retryDelays.length} failed:`, e); } } - return null; -} - -export async function getDeviceIdentity(): Promise { - try { - const deviceIdBytes = await getDeviceIdBinBridgeAsync(); - if (deviceIdBytes.length === 0) return null; - return encodeBase32Crockford(deviceIdBytes); - } catch (e) { - logger.warn('getDeviceIdentity failed:', e); - return null; - } -} - -export async function getBluetoothStatus(): Promise<{ enabled: boolean; advertising: boolean; scanning: boolean }> { + const waited = retryDelays.reduce((a, b) => a + b, 0); + const reason = lastFailure instanceof Error ? lastFailure.message : String(lastFailure); const session = nativeSessionStore.getSnapshot(); - return { - enabled: session.hardware_status.ble.enabled, - advertising: session.hardware_status.ble.advertising, - scanning: session.hardware_status.ble.scanning, - }; -} - -export function isReady(): Promise { - return getDeviceIdentity().then(id => !!id).catch(() => false); + if (!session.received || session.identity_status !== 'ready') { + throw new IdentityUnavailableError( + 'runtime_not_ready', + `native session not ready after ${waited} ms (${session.received ? session.identity_status : 'no session state received'}); last read: ${reason}`, + ); + } + throw new IdentityUnavailableError( + 'read_failed', + `identity not read although the native session reports it ready: ${reason}`, + ); } // Preferences (strict bridge) diff --git a/dsm_client/frontend/src/dsm/identityUnavailable.ts b/dsm_client/frontend/src/dsm/identityUnavailable.ts new file mode 100644 index 000000000..ea550f8c0 --- /dev/null +++ b/dsm_client/frontend/src/dsm/identityUnavailable.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Why an identity was not answered. A leaf module: the callers that branch on +// the reason (the wallet store, diagnostics) need the contract, not the +// transport behind `getIdentity`. + +/** As the native session and the bridge report it. */ +export type IdentityUnavailableState = 'missing' | 'runtime_not_ready' | 'read_failed'; + +/** + * The identity was not answered, and why. `missing` is Rust's word (the native + * session) that this device has no identity: a state, not a failure. + * `runtime_not_ready` is the native session not reporting ready within the + * cold-start window; `read_failed` is a session that reports ready whose + * headers still could not be read. The three used to be one `null`. + */ +export class IdentityUnavailableError extends Error { + readonly state: IdentityUnavailableState; + + constructor(state: IdentityUnavailableState, message: string) { + super(message); + this.name = 'IdentityUnavailableError'; + this.state = state; + } +} + +/** Recognised by shape, so a copy of this module in another registry still counts. */ +export function isIdentityUnavailable(e: unknown): e is IdentityUnavailableError { + return e instanceof Error && e.name === 'IdentityUnavailableError' && typeof (e as { state?: unknown }).state === 'string'; +} diff --git a/dsm_client/frontend/src/dsm/index.ts b/dsm_client/frontend/src/dsm/index.ts index cf67a78d6..e5fec1d14 100644 --- a/dsm_client/frontend/src/dsm/index.ts +++ b/dsm_client/frontend/src/dsm/index.ts @@ -9,7 +9,6 @@ // // MODULE MAP (kept in sync with the `export * from './'` lines below): // types — TypeScript types for State, Token, Policy, etc. -// crypto — Client-side crypto utilities (hashing, encoding) // resolution — Name/address resolution // identity — Device identity, genesis, pairing // contacts — Contact management (device IDs, metadata) @@ -19,7 +18,6 @@ // storage — Storage node communication // transactions — Bilateral/unilateral transfer logic // diagnostics — telemetry, debug -// nfc — NFC ring backup (write/read recovery capsules) // // BRIDGE HELPER RE-EXPORTS (not part of the curated `dsmClient` namespace): // eventBridgeOn / eventBridgeEmit — pub/sub on the native bridge @@ -28,9 +26,9 @@ // CURATED FLAT NAMESPACE EXPORT (`dsmClient`): // `dsmClient` exposes a curated, object-style API combining the modules // that need name-collision-free access: identity, contacts, wallet, -// policies, dlv, storage, transactions, diagnostics, resolution, nfc. -// It intentionally OMITS `crypto` and `types` (too generic to flatten -// safely) and the bridge-helper re-exports above (imported by name). +// policies, dlv, storage, transactions, diagnostics, resolution. +// It intentionally OMITS `types` (too generic to flatten safely) and the +// bridge-helper re-exports above (imported by name). // // All exports ultimately call through WebViewBridge.ts (protobuf-only). // See docs/INTEGRATION_GUIDE.md for the full developer onboarding guide. @@ -39,9 +37,6 @@ // Export core types export * from './types'; -// Export crypto utilities -export * from './crypto'; - // Export resolution logic export * from './resolution'; @@ -53,7 +48,6 @@ export * from './policies'; export * from './storage'; export * from './transactions'; export * from './diagnostics'; -export * from './nfc'; // Re-export bridge helpers used by external consumers. import { @@ -73,7 +67,6 @@ import * as Policies from './policies'; import * as Storage from './storage'; import * as Transactions from './transactions'; import * as Diagnostics from './diagnostics'; -import * as Nfc from './nfc'; import * as Resolution from './resolution'; // Flat namespace export for consumers that prefer object-style access. @@ -86,5 +79,4 @@ export const dsmClient = { ...Transactions, ...Diagnostics, ...Resolution, - ...Nfc, }; diff --git a/dsm_client/frontend/src/dsm/nfc.ts b/dsm_client/frontend/src/dsm/nfc.ts deleted file mode 100644 index cd0b4303e..000000000 --- a/dsm_client/frontend/src/dsm/nfc.ts +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// ============================================================================ -// DSM NFC DOMAIN — envelope-based frontend surface for NFC ring backup -// ============================================================================ -// -// Rust is authoritative for all recovery state and protocol decisions. -// TypeScript only issues app-router protobuf requests and listens for -// Rust-authored envelopes relayed through Kotlin's hardware transport. - -import { bridgeEvents } from '../bridge/bridgeEvents'; -import * as EventBridge from './EventBridge'; -import { - getNfcBackupStatus, - writeToNfcRing, -} from '../services/recovery/nfcRecoveryService'; -import { logger } from '../utils/logger'; - -const TAG = '[NFC]'; - -export async function isNfcBackupEnabled(): Promise { - try { - const status = await getNfcBackupStatus(); - return status.enabled; - } catch { - return false; - } -} - -export async function hasPendingCapsule(): Promise { - try { - const status = await getNfcBackupStatus(); - return status.pendingCapsule; - } catch { - return false; - } -} - -export async function startNfcWrite(): Promise { - try { - await writeToNfcRing(); - logger.info(TAG, 'NFC write session authorized via Rust'); - } catch (e) { - logger.error(TAG, 'Failed to start NFC write:', e); - throw e; - } -} - -export type NfcBackupWrittenHandler = () => void; -export type NfcCapsuleReceivedHandler = (payload: Uint8Array) => void; - -export function onBackupWritten(handler: NfcBackupWrittenHandler): () => void { - return bridgeEvents.on('nfc.backupWritten', handler); -} - -export function onCapsuleReceived(handler: NfcCapsuleReceivedHandler): () => void { - return EventBridge.on('nfc-recovery-capsule', handler); -} diff --git a/dsm_client/frontend/src/dsm/resolution.ts b/dsm_client/frontend/src/dsm/resolution.ts index 21675ecc3..df3c97844 100644 --- a/dsm_client/frontend/src/dsm/resolution.ts +++ b/dsm_client/frontend/src/dsm/resolution.ts @@ -72,15 +72,6 @@ export function persistBleMapping(args: { } catch {} } -export function loadPersistedBleMappings(): void { - // No-op: BLE address persistence is native. -} - -export function clearBleIdentityCache(): void { - bleIdentityMap.byDeviceId.clear(); - bleIdentityMap.byGenesis.clear(); -} - export function getBleIdentitySnapshot(): { deviceIds: Record; genesis: Record } { const deviceIds: Record = {}; const genesis: Record = {}; @@ -89,17 +80,6 @@ export function getBleIdentitySnapshot(): { deviceIds: Record; g return { deviceIds, genesis }; } -export function pruneBleIdentityMappings(args: { deviceIds?: Uint8Array[]; genesisHashes?: Uint8Array[] }): void { - const devKeys = (args.deviceIds || []).filter((b) => b instanceof Uint8Array).map((b) => base32Key32(b)); - const genKeys = (args.genesisHashes || []).filter((b) => b instanceof Uint8Array).map((b) => base32Key32(b)); - for (const k of devKeys) { - bleIdentityMap.byDeviceId.delete(k); - } - for (const k of genKeys) { - bleIdentityMap.byGenesis.delete(k); - } -} - // Strict version: single deterministic resolution path export async function resolveBleAddressForContact(contact: any): Promise { if (!contact) return undefined; @@ -153,38 +133,3 @@ export async function resolveBleAddressForContact(contact: any): Promise void): () => void { - const unsubs: Array<() => void> = []; - - unsubs.push(bridgeEvents.on('ble.deviceFound', (d) => { - try { callback({ type: 'deviceFound', state: 'scanning', ...d }); } catch {} - })); - unsubs.push(bridgeEvents.on('ble.scanStarted', () => { - try { callback({ type: 'scanStarted', state: 'scanning' }); } catch {} - })); - unsubs.push(bridgeEvents.on('ble.scanStopped', () => { - try { callback({ type: 'scanStopped', state: 'idle' }); } catch {} - })); - unsubs.push(bridgeEvents.on('ble.advertisingStarted', () => { - try { callback({ type: 'advertisingStarted', state: 'advertising' }); } catch {} - })); - unsubs.push(bridgeEvents.on('ble.advertisingStopped', () => { - try { callback({ type: 'advertisingStopped', state: 'idle' }); } catch {} - })); - unsubs.push(bridgeEvents.on('ble.deviceConnected', (d) => { - try { callback({ type: 'deviceConnected', state: 'connected', ...d }); } catch {} - })); - unsubs.push(bridgeEvents.on('ble.deviceDisconnected', (d) => { - try { callback({ type: 'deviceDisconnected', state: 'idle', ...d }); } catch {} - })); - unsubs.push(bridgeEvents.on('ble.connectionFailed', (d) => { - try { callback({ type: 'connectionFailed', state: 'idle', ...d }); } catch {} - })); - - return () => { - for (const u of unsubs) { - try { u(); } catch {} - } - }; -} diff --git a/dsm_client/frontend/src/dsm/transactions.ts b/dsm_client/frontend/src/dsm/transactions.ts index 2a8fadd77..83897f3ba 100644 --- a/dsm_client/frontend/src/dsm/transactions.ts +++ b/dsm_client/frontend/src/dsm/transactions.ts @@ -17,6 +17,7 @@ import { readPeerRelationshipStatusBridge, } from './WebViewBridge'; import { on as eventBridgeOn } from './EventBridge'; +import { emitBilateralCommitted } from './events'; import { bridgeEvents } from '../bridge/bridgeEvents'; import { getHeaders } from './identity'; @@ -27,13 +28,14 @@ import { GenericTransaction, GenericTxResponse } from './types'; /** - * After the receiver sends Accept, the Confirm arrives within ~1-2 seconds. - * Schedule staggered priority wallet refreshes using RAF batches to ensure - * the UI picks up the balance change from SQLite regardless of whether the - * TRANSFER_COMPLETE event chain delivers successfully. Each refresh uses - * the priority source so useWalletRefreshListener bypasses its cooldown. + * After the receiver sends Accept, the Confirm arrives within ~1-2 seconds + * and Rust announces the completed transfer (`bilateral.event`), which the + * event bridge turns into `wallet.refresh`. These staggered re-reads exist + * beside that announcement, not instead of it: whether the announcement alone + * suffices on a device is undecided (CONFORMANCE_GAPS §6.29, Open), so the + * cadence stays until a device run says. Each re-read names itself. * - * RAF spacing: ~30 frames ≈ 0.5s at 60fps, repeated 4 times ≈ 0/0.5/1.5/3s. + * RAF spacing: ~30 frames ≈ 0.5s at 60fps, repeated 4 times ≈ 0/0.5/1/2s. */ function schedulePostAcceptRefreshes(): void { const INTERVALS = [1, 30, 60, 120]; // RAF frame counts @@ -45,7 +47,7 @@ function schedulePostAcceptRefreshes(): void { if (frame >= INTERVALS[idx]) { idx++; try { - bridgeEvents.emit('wallet.refresh', { source: 'bilateral.transfer_complete' }); + bridgeEvents.emit('wallet.refresh', { source: 'bilateral.accept_followup' }); } catch {} } if (idx < INTERVALS.length) { @@ -412,21 +414,10 @@ export async function acceptOfflineTransfer(args: { commitmentHash: Uint8Array, if (!answer.success) { return answer; } - // Emit through the real DOM/native adapter path so all app listeners see - // the same bilateral acceptance signal as production. - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('dsm-bilateral-committed', { - detail: { - commitmentHash: new Uint8Array(args.commitmentHash), - counterpartyDeviceId: new Uint8Array(args.counterpartyDeviceId), - accepted: true, - committed: true, - }, - })); - } - // Also emit the bridge event for consistency with the event system + // The committed signal, once, on the event bus. It used to be dispatched + // twice: as a window event the adapter re-emitted here, and here again. try { - bridgeEvents.emit('wallet.bilateralCommitted', { + emitBilateralCommitted({ commitmentHash: new Uint8Array(args.commitmentHash), counterpartyDeviceId: new Uint8Array(args.counterpartyDeviceId), accepted: true, diff --git a/dsm_client/frontend/src/dsm/types.ts b/dsm_client/frontend/src/dsm/types.ts index 8f3c1e0a8..ba60f2d3a 100644 --- a/dsm_client/frontend/src/dsm/types.ts +++ b/dsm_client/frontend/src/dsm/types.ts @@ -172,6 +172,21 @@ export interface TokenBalanceView { anchorFingerprint?: string; /** The token policy's icon field, carried from Rust; the wallet draws the token's coin from it. */ iconUrl?: string; + /** + * Whether Rust reports the token as one the protocol defines (ERA, dBTC). + * Never decided here: a ticker is text, and a created token may read "ERA". + */ + protocolDefined: boolean; + /** The whole supply that will ever exist, rendered by Rust; absent when Rust holds none. */ + genesisSupplyDisplay?: string; + /** What the committed policy permits, as Rust read it; absent when Rust holds no policy for the token. */ + permissions?: TokenPolicyPermissionsView; +} + +/** The permission flags of a committed token policy, as Rust read them. */ +export interface TokenPolicyPermissionsView { + burnEnabled: boolean; + transferable: boolean; } /** diff --git a/dsm_client/frontend/src/dsm/wallet.ts b/dsm_client/frontend/src/dsm/wallet.ts index 5425c565c..ecf4620ae 100644 --- a/dsm_client/frontend/src/dsm/wallet.ts +++ b/dsm_client/frontend/src/dsm/wallet.ts @@ -38,6 +38,11 @@ export async function getAllBalances(): Promise { throw new Error(`STRICT: balance.list answered a row for ${b.tokenId || 'no token'} without its ${field}`); } } + // A created token's row always carries its policy's supply and what it + // permits: Rust reads them from the committed bytes or refuses the row. + if (!b.protocolDefined && (b.genesisSupplyDisplay.length === 0 || !b.permissions)) { + throw new Error(`STRICT: balance.list answered a created token ${b.tokenId} without its policy facts`); + } return { tokenId: b.tokenId, symbol: b.symbol, @@ -54,6 +59,12 @@ export async function getAllBalances(): Promise { anchorFingerprint: named(b.anchorFingerprint), // The token policy's icon field, carried from Rust. The wallet draws the coin from it. iconUrl: named(b.iconUrl), + // Rust's word on what the token is and what its policy fixes and permits. + protocolDefined: b.protocolDefined, + genesisSupplyDisplay: named(b.genesisSupplyDisplay), + permissions: b.permissions + ? { burnEnabled: b.permissions.burnEnabled, transferable: b.permissions.transferable } + : undefined, }; }); } @@ -100,11 +111,6 @@ export async function getWalletHistory(): Promise { } } -export async function getTransactions(): Promise { - const history = await getWalletHistory(); - return history.transactions; -} - /** * The items `inbox.pull` found queued for this device. Rust writes an id and a * preview on every item; an item without them is refused, never filled in. diff --git a/dsm_client/frontend/src/hooks/__tests__/useBridgeEvents.test.ts b/dsm_client/frontend/src/hooks/__tests__/useBridgeEvents.test.ts index 45aadc026..c7a3a662a 100644 --- a/dsm_client/frontend/src/hooks/__tests__/useBridgeEvents.test.ts +++ b/dsm_client/frontend/src/hooks/__tests__/useBridgeEvents.test.ts @@ -72,8 +72,8 @@ describe('useBridgeEvent', () => { const handler = jest.fn(); const { rerender } = renderHook( - ({ eventName }) => useBridgeEvent(eventName, handler), - { initialProps: { eventName: 'wallet.refresh' } }, + ({ eventName }: { eventName: 'wallet.refresh' | 'identity.ready' }) => useBridgeEvent(eventName, handler), + { initialProps: { eventName: 'wallet.refresh' as 'wallet.refresh' | 'identity.ready' } }, ); rerender({ eventName: 'identity.ready' }); @@ -99,7 +99,7 @@ describe('useBridgeEvent', () => { it('supports typed payloads', () => { const handler = jest.fn(); - renderHook(() => useBridgeEvent<{ address: string }>('ble.deviceConnected', handler)); + renderHook(() => useBridgeEvent('ble.deviceConnected', handler)); act(() => { emitBridge('ble.deviceConnected', { address: 'AA:BB:CC' }); }); diff --git a/dsm_client/frontend/src/hooks/__tests__/useDiagnostics.test.ts b/dsm_client/frontend/src/hooks/__tests__/useDiagnostics.test.ts index a9986cd1e..32637b1ff 100644 --- a/dsm_client/frontend/src/hooks/__tests__/useDiagnostics.test.ts +++ b/dsm_client/frontend/src/hooks/__tests__/useDiagnostics.test.ts @@ -250,12 +250,17 @@ describe('useDiagnostics', () => { }); it('a missing identity is reported as missing, not as empty fields', async () => { - mockGetIdentity.mockResolvedValueOnce(null); + mockGetIdentity.mockRejectedValueOnce( + Object.assign(new Error('no identity on this device (native session: missing)'), { + name: 'IdentityUnavailableError', + state: 'missing', + }), + ); const { result } = renderHook(() => useDiagnostics(jest.fn())); await act(async () => {}); await act(async () => { await result.current.gatherDiagnostics(); }); - expect(result.current.diagnostics).toContain('identity=none (getIdentity answered null)'); + expect(result.current.diagnostics).toContain('identity=missing: no identity on this device (native session: missing)'); }); // The bundle says why the native log was not read; it used to say "No diff --git a/dsm_client/frontend/src/hooks/__tests__/useTransactions.test.ts b/dsm_client/frontend/src/hooks/__tests__/useTransactions.test.ts index b742558a8..12d6ccd4b 100644 --- a/dsm_client/frontend/src/hooks/__tests__/useTransactions.test.ts +++ b/dsm_client/frontend/src/hooks/__tests__/useTransactions.test.ts @@ -2,14 +2,21 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { renderHook, act, waitFor } from '@testing-library/react'; + +jest.mock('../../utils/identity', () => ({ + checkIdentityState: jest.fn(), +})); + import { useTransactions } from '../useTransactions'; import { dsmClient } from '../../services/dsmClient'; +import { checkIdentityState } from '../../utils/identity'; import type { DomainTransaction } from '../../domain/types'; +const identityState = checkIdentityState as jest.Mock; + describe('useTransactions', () => { const original = { getWalletHistory: dsmClient.getWalletHistory, - isReady: (dsmClient as any).isReady, }; const row: DomainTransaction = { @@ -28,12 +35,11 @@ describe('useTransactions', () => { }; beforeEach(() => { - (dsmClient as any).isReady = async () => true; + identityState.mockResolvedValue('READY'); }); afterEach(() => { (dsmClient as any).getWalletHistory = original.getWalletHistory; - (dsmClient as any).isReady = original.isReady; }); async function renderTransactionsHook() { @@ -66,15 +72,20 @@ describe('useTransactions', () => { }); }); - test('does not ask for history before the device has an identity', async () => { - (dsmClient as any).isReady = async () => false; - const history = jest.fn(); - (dsmClient as any).getWalletHistory = history; + // The native session is Rust's word on whether there is an identity: no + // history is asked for while it reports none, or is not yet ready. + test.each(['NO_IDENTITY', 'RUNTIME_NOT_READY'])( + 'does not ask for history while the native session reports %s', + async (state) => { + identityState.mockResolvedValue(state); + const history = jest.fn(); + (dsmClient as any).getWalletHistory = history; - const { result } = await renderTransactionsHook(); + const { result } = await renderTransactionsHook(); - expect(history).not.toHaveBeenCalled(); - expect(result.current.transactions).toEqual([]); - expect(result.current.error).toBeNull(); - }); + expect(history).not.toHaveBeenCalled(); + expect(result.current.transactions).toEqual([]); + expect(result.current.error).toBeNull(); + }, + ); }); diff --git a/dsm_client/frontend/src/hooks/__tests__/useWalletRefreshListener.test.tsx b/dsm_client/frontend/src/hooks/__tests__/useWalletRefreshListener.test.tsx index d2bf36aa2..c8a625f32 100644 --- a/dsm_client/frontend/src/hooks/__tests__/useWalletRefreshListener.test.tsx +++ b/dsm_client/frontend/src/hooks/__tests__/useWalletRefreshListener.test.tsx @@ -101,48 +101,62 @@ describe('useWalletRefreshListener', () => { expect(refresh).toHaveBeenCalledTimes(1); }); - it('priority source bypasses cooldown', async () => { + // The old "cooldown" counted dropped events, not frames: after a reload the + // next 119 events from a low-priority source were discarded. Every event is + // followed by a reload that started after it, whatever its source. + it('an event after a completed refresh runs another: nothing is dropped', async () => { const refresh = jest.fn().mockResolvedValue(undefined); render(); - // First refresh - act(() => { mockedBridgeEvents.emit('wallet.refresh', { source: 'ble' }); }); + for (const source of ['inbox.sync', 'storage.sync', 'sofi']) { + act(() => { mockedBridgeEvents.emit('wallet.refresh', { source }); }); + await act(async () => { flushRaf(); }); + } + expect(refresh).toHaveBeenCalledTimes(3); + }); + + it('events during a running refresh owe exactly one more after it', async () => { + let resolveRefresh!: () => void; + const refresh = jest.fn().mockReturnValue(new Promise(r => { resolveRefresh = r; })); + render(); + + act(() => { mockedBridgeEvents.emit('wallet.refresh', { source: 'a' }); }); await act(async () => { flushRaf(); }); expect(refresh).toHaveBeenCalledTimes(1); - // Non-priority during cooldown — should be skipped - act(() => { mockedBridgeEvents.emit('wallet.refresh', { source: 'ble.envelope' }); }); + // Three events while the first refresh is still running. + act(() => { + mockedBridgeEvents.emit('wallet.refresh', { source: 'b' }); + mockedBridgeEvents.emit('wallet.refresh', { source: 'c' }); + mockedBridgeEvents.emit('wallet.refresh', { source: 'd' }); + }); await act(async () => { flushRaf(); }); - // Still 1 because cooldown blocks it (the scheduled RAF returns early) + expect(refresh).toHaveBeenCalledTimes(1); - // Priority source — should bypass cooldown - act(() => { mockedBridgeEvents.emit('wallet.refresh', { source: 'wallet.send' }); }); + refresh.mockResolvedValue(undefined); + await act(async () => { resolveRefresh(); }); + await act(async () => { flushRaf(); }); + expect(refresh).toHaveBeenCalledTimes(2); + + // Nothing further is owed. await act(async () => { flushRaf(); }); expect(refresh).toHaveBeenCalledTimes(2); }); - it('queues another frame when refresh is still running', async () => { + it('an owed refresh is not run after unmount', async () => { let resolveRefresh!: () => void; const refresh = jest.fn().mockReturnValue(new Promise(r => { resolveRefresh = r; })); - render(); + const { unmount } = render(); - // Start first refresh act(() => { mockedBridgeEvents.emit('wallet.refresh', { source: 'a' }); }); await act(async () => { flushRaf(); }); - expect(refresh).toHaveBeenCalledTimes(1); - - // Emit while running — should queue - act(() => { mockedBridgeEvents.emit('wallet.refresh', { source: 'wallet.send' }); }); + act(() => { mockedBridgeEvents.emit('wallet.refresh', { source: 'b' }); }); await act(async () => { flushRaf(); }); - // refresh is still running, so this RAF re-scheduled - expect(refresh).toHaveBeenCalledTimes(1); + unmount(); - // Complete the first refresh await act(async () => { resolveRefresh(); }); - - // Now flush the re-scheduled RAF await act(async () => { flushRaf(); }); - expect(refresh).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenCalledTimes(1); }); it('handles refresh errors without crashing', async () => { diff --git a/dsm_client/frontend/src/hooks/__tests__/useWalletSync.test.ts b/dsm_client/frontend/src/hooks/__tests__/useWalletSync.test.ts deleted file mode 100644 index 446e80232..000000000 --- a/dsm_client/frontend/src/hooks/__tests__/useWalletSync.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// SPDX-License-Identifier: Apache-2.0 - -import { renderHook, act } from '@testing-library/react'; - -const bridgeHandlers = new Map>(); -jest.mock('../../bridge/bridgeEvents', () => ({ - bridgeEvents: { - on: jest.fn((event: string, handler: Function) => { - const set = bridgeHandlers.get(event) ?? new Set(); - set.add(handler); - bridgeHandlers.set(event, set); - return () => { set.delete(handler); }; - }), - emit: jest.fn((event: string, payload: any) => { - const set = bridgeHandlers.get(event); - if (set) set.forEach(fn => fn(payload)); - }), - }, -})); - -import { useWalletSync, type WalletSyncHandlers } from '../useWalletSync'; - -function emitBridge(event: string, payload?: any) { - const set = bridgeHandlers.get(event); - if (set) set.forEach(fn => fn(payload)); -} - -beforeEach(() => { - bridgeHandlers.clear(); - jest.spyOn(console, 'error').mockImplementation(() => {}); -}); - -afterEach(() => { - jest.restoreAllMocks(); -}); - -describe('useWalletSync', () => { - it('subscribes to wallet.historyUpdated and calls onRefreshTransactions', () => { - const onRefreshTransactions = jest.fn(); - const handlers: WalletSyncHandlers = { - onRefreshAll: jest.fn(), - onRefreshTransactions, - }; - - renderHook(() => useWalletSync(handlers)); - - act(() => { emitBridge('wallet.historyUpdated'); }); - - expect(onRefreshTransactions).toHaveBeenCalledTimes(1); - }); - - it('subscribes to wallet.balancesUpdated and calls onRefreshBalances', () => { - const onRefreshBalances = jest.fn(); - const handlers: WalletSyncHandlers = { - onRefreshAll: jest.fn(), - onRefreshBalances, - }; - - renderHook(() => useWalletSync(handlers)); - - act(() => { emitBridge('wallet.balancesUpdated'); }); - - expect(onRefreshBalances).toHaveBeenCalledTimes(1); - }); - - it('subscribes to wallet.sendCommitted and calls onRefreshAll', () => { - const onRefreshAll = jest.fn(); - const handlers: WalletSyncHandlers = { onRefreshAll }; - - renderHook(() => useWalletSync(handlers)); - - act(() => { emitBridge('wallet.sendCommitted'); }); - - expect(onRefreshAll).toHaveBeenCalledTimes(1); - }); - - it('subscribes to identity.ready and calls onIdentityReady', () => { - const onIdentityReady = jest.fn(); - const handlers: WalletSyncHandlers = { - onRefreshAll: jest.fn(), - onIdentityReady, - }; - - renderHook(() => useWalletSync(handlers)); - - act(() => { emitBridge('identity.ready'); }); - - expect(onIdentityReady).toHaveBeenCalledTimes(1); - }); - - it('skips onRefreshTransactions when not provided', () => { - const handlers: WalletSyncHandlers = { onRefreshAll: jest.fn() }; - - renderHook(() => useWalletSync(handlers)); - - // Should not throw - act(() => { emitBridge('wallet.historyUpdated'); }); - }); - - it('skips onRefreshBalances when not provided', () => { - const handlers: WalletSyncHandlers = { onRefreshAll: jest.fn() }; - - renderHook(() => useWalletSync(handlers)); - - act(() => { emitBridge('wallet.balancesUpdated'); }); - }); - - it('handles async handler rejection gracefully', async () => { - const onRefreshAll = jest.fn().mockRejectedValue(new Error('refresh boom')); - const handlers: WalletSyncHandlers = { onRefreshAll }; - - renderHook(() => useWalletSync(handlers)); - - act(() => { emitBridge('wallet.sendCommitted'); }); - - // Wait for the rejected promise to be caught - await act(async () => { await new Promise(r => setTimeout(r, 10)); }); - - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining('wallet.sendCommitted'), - expect.any(Error), - ); - }); - - it('unsubscribes on unmount', () => { - const onRefreshAll = jest.fn(); - const handlers: WalletSyncHandlers = { onRefreshAll }; - - const { unmount } = renderHook(() => useWalletSync(handlers)); - unmount(); - - act(() => { emitBridge('wallet.sendCommitted'); }); - - expect(onRefreshAll).not.toHaveBeenCalled(); - }); -}); diff --git a/dsm_client/frontend/src/hooks/useBridgeEvents.ts b/dsm_client/frontend/src/hooks/useBridgeEvents.ts index 32fd42d8f..c4823c38a 100644 --- a/dsm_client/frontend/src/hooks/useBridgeEvents.ts +++ b/dsm_client/frontend/src/hooks/useBridgeEvents.ts @@ -6,18 +6,22 @@ // This centralizes event binding logic and avoids zombie listeners. import { useEffect, useRef } from 'react'; -import { bridgeEvents } from '../bridge/bridgeEvents'; +import { bridgeEvents, type BridgeEventMap } from '../bridge/bridgeEvents'; /** * Hook to subscribe to bridge events securely with automatic cleanup. * Prevents "zombie listeners" and memory leaks. - * - * @param eventName The name of the event to listen for (e.g. 'ble.deviceFound', 'wallet.refresh') - * @param handler The callback to run. + * + * The name is one of the bus's events: a subscription to a name nothing can + * emit does not compile. Five such subscriptions had accumulated behind an + * untyped `string` — toasts and a BLE gate waiting on events with no producer. + * + * @param eventName The event to listen for (e.g. 'ble.deviceFound', 'wallet.refresh') + * @param handler The callback to run. * @param deps Dependencies for the effect. If these change, the subscription is recreated. */ -export function useBridgeEvent( - eventName: string, +export function useBridgeEvent( + eventName: K, handler: (detail?: T) => void, deps: React.DependencyList = [] ) { @@ -34,7 +38,7 @@ export function useBridgeEvent( savedHandler.current(payload); }; - const unsubscribe = bridgeEvents.on(eventName as any, listener as any); + const unsubscribe = bridgeEvents.on(eventName, listener as any); return () => unsubscribe(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventName, ...deps]); diff --git a/dsm_client/frontend/src/hooks/useDiagnostics.ts b/dsm_client/frontend/src/hooks/useDiagnostics.ts index ef88355e5..ef0a27a57 100644 --- a/dsm_client/frontend/src/hooks/useDiagnostics.ts +++ b/dsm_client/frontend/src/hooks/useDiagnostics.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from 'react'; import { dsmClient } from '../services/dsmClient'; +import { isIdentityUnavailable } from '../dsm/identityUnavailable'; import { bridgeEvents } from '../bridge/bridgeEvents'; import { BETA_BUG_TEMPLATE, @@ -106,11 +107,12 @@ export function useDiagnostics(notifyToast: NotifyToast) { let identityLine: string; try { const id = await dsmClient.getIdentity(); - identityLine = id - ? `identity=device ${id.deviceId} genesis ${id.genesisHash}` - : 'identity=none (getIdentity answered null)'; + identityLine = `identity=device ${id.deviceId} genesis ${id.genesisHash}`; } catch (e) { - identityLine = `identity=not read: ${messageOf(e)}`; + // Missing, runtime not ready, or not read: each as Rust and the bridge report it. + identityLine = isIdentityUnavailable(e) + ? `identity=${e.state}: ${e.message}` + : `identity=not read: ${messageOf(e)}`; } let archLine: string; diff --git a/dsm_client/frontend/src/hooks/useTransactions.ts b/dsm_client/frontend/src/hooks/useTransactions.ts index de57380b1..8aaef8af3 100644 --- a/dsm_client/frontend/src/hooks/useTransactions.ts +++ b/dsm_client/frontend/src/hooks/useTransactions.ts @@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react'; import { dsmClient } from '@/services/dsmClient'; +import { checkIdentityState } from '@/utils/identity'; import type { DomainTransaction } from '@/domain/types'; import logger from '@/utils/logger'; @@ -25,10 +26,12 @@ export function useTransactions() { let cancelled = false; (async () => { try { - // Only refresh if we have an identity - const hasIdentity = await dsmClient.isReady(); - if (!hasIdentity) { - logger.debug('[useTransactions] Skipping refresh: no identity yet'); + // History exists only for an identity. The native session is Rust's + // word on whether there is one; "missing" and "runtime not ready" are + // both no read, and each is logged as itself. + const state = await checkIdentityState(); + if (state !== 'READY') { + logger.debug(`[useTransactions] no history read: identity ${state}`); return; } await refresh(); diff --git a/dsm_client/frontend/src/hooks/useWalletRefreshListener.ts b/dsm_client/frontend/src/hooks/useWalletRefreshListener.ts index 154071c32..e95aa6ff6 100644 --- a/dsm_client/frontend/src/hooks/useWalletRefreshListener.ts +++ b/dsm_client/frontend/src/hooks/useWalletRefreshListener.ts @@ -1,97 +1,58 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ // path: dsm_client/frontend/src/hooks/useWalletRefreshListener.ts // SPDX-License-Identifier: Apache-2.0 -// Event-driven wallet refresh listener with natural (RAF) smoothing. -// Coalesces rapid dsm-wallet-refresh events onto animation frames to -// avoid janky re-renders while keeping deterministic, event-driven updates. -// Applies a cooldown between refreshes to prevent BLE-envelope-triggered -// polling storms (~50 calls/sec → ~0.5 calls/sec). +// The one path from a `wallet.refresh` event to a screen's reload. +// +// Events are coalesced onto an animation frame, one reload runs at a time, and +// an event that arrives while a reload is running owes exactly one more reload +// after it — so every event is followed by a reload that started after it, and +// a burst of events costs at most two. There is no cooldown and no priority +// class. The previous "cooldown" counted dropped events rather than frames: +// after any reload the next 119 low-priority events were discarded outright, +// so screens grew direct subscriptions to the raw events to get around it and +// reloaded three to four times per event whenever the gate happened to be open. import { useEffect } from 'react'; import { bridgeEvents } from '../bridge/bridgeEvents'; type RefreshFn = () => Promise | void; -// Sources that should bypass cooldown and always trigger immediate refresh. -// These represent definitive state changes, not continuous BLE envelope streams. -const PRIORITY_SOURCES = new Set([ - 'wallet.send', - 'bilateral.transfer_complete', - 'bilateral_transfer_complete', - 'reconcile.complete', - 'bilateral.reconcile_status', - 'native', - // Token lifecycle changes balances immediately and the user is watching the - // screen when they happen — the ~2s cooldown reads as the action not working. - 'token.create', - 'token.burn', -]); - export function useWalletRefreshListener(refresh: RefreshFn, deps: unknown[] = []): void { useEffect(() => { let rafId: number | null = null; - let pending = false; let running = false; - // Cooldown: skip low-priority refresh calls for COOLDOWN_FRAMES - // animation frames after a refresh completes. At 60fps this is - // ~2 seconds. Prevents BLE-envelope-driven polling storms while - // keeping the UI responsive to definitive state changes. - const COOLDOWN_FRAMES = 120; - let cooldownRemaining = 0; - let priorityQueued = false; - - const schedule = (priority: boolean) => { - if (priority) priorityQueued = true; - if (pending) return; - pending = true; - rafId = requestAnimationFrame(async () => { - pending = false; - const isPriority = priorityQueued; - priorityQueued = false; - - // If a refresh is still running, queue another frame to ensure - // the latest event is honored without overlapping network calls. - if (running) { - schedule(isPriority); - return; - } - - // Cooldown: skip non-priority refreshes to avoid flooding - // the bridge with balance+history queries. Priority events bypass. - // Schedule a tail refresh so the final event during cooldown is - // eventually honored once the cooldown expires. - if (!isPriority && cooldownRemaining > 0) { - cooldownRemaining--; - if (cooldownRemaining === 0) { - // Cooldown just expired — schedule one more refresh - schedule(false); - } - return; - } - - running = true; - try { - await refresh(); - } catch (e) { - try { - console.error('[useWalletRefreshListener] refresh failed:', e); - } catch {} - } finally { - running = false; - cooldownRemaining = COOLDOWN_FRAMES; - } - }); + let owed = false; + let unmounted = false; + + const run = async () => { + rafId = null; + if (running) { + owed = true; + return; + } + running = true; + try { + await refresh(); + } catch (e) { + console.error('[useWalletRefreshListener] refresh failed:', e); + } finally { + running = false; + } + if (owed && !unmounted) { + owed = false; + schedule(); + } }; - const handler = (data: any) => { - const source = typeof data?.source === 'string' ? data.source : ''; - const isPriority = PRIORITY_SOURCES.has(source); - schedule(isPriority); + const schedule = () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + void run(); + }); }; - const unsubscribe = bridgeEvents.on('wallet.refresh', handler as any); - + const unsubscribe = bridgeEvents.on('wallet.refresh', schedule); return () => { + unmounted = true; unsubscribe(); if (rafId !== null) cancelAnimationFrame(rafId); }; diff --git a/dsm_client/frontend/src/hooks/useWalletSync.ts b/dsm_client/frontend/src/hooks/useWalletSync.ts deleted file mode 100644 index e6d55a3d2..000000000 --- a/dsm_client/frontend/src/hooks/useWalletSync.ts +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -/* eslint-disable @typescript-eslint/no-explicit-any */ -// path: src/hooks/useWalletSync.ts -// Centralized wallet event synchronization hook. -// Subscribes to bridge events and invokes provided callbacks so the provider stays dumb. - -import { useCallback } from 'react'; -import { useBridgeEvent } from './useBridgeEvents'; - -export type WalletSyncHandlers = { - onRefreshAll: () => void | Promise; - onRefreshBalances?: () => void | Promise; - onRefreshTransactions?: () => void | Promise; - onIdentityReady?: () => void | Promise; -}; - -export function useWalletSync(handlers: WalletSyncHandlers) { - const { onRefreshAll, onRefreshBalances, onRefreshTransactions, onIdentityReady } = handlers; - - // NOTE: wallet.refresh is handled by useWalletRefreshListener (RAF-coalesced - // + 120-frame cooldown) inside useWalletScreenData. We intentionally do NOT - // subscribe to it here — doing so would create an unthrottled duplicate that - // floods the bridge with balance/history queries during BLE transfers. - - // Specific sub-stream updates - useBridgeEvent('wallet.historyUpdated', useCallback(() => { - if (!onRefreshTransactions) return; - Promise.resolve(onRefreshTransactions()).catch((e) => { - console.error('[useWalletSync] wallet.historyUpdated handler failed:', e); - }); - }, [onRefreshTransactions])); - - useBridgeEvent('wallet.balancesUpdated', useCallback(() => { - if (!onRefreshBalances) return; - Promise.resolve(onRefreshBalances()).catch((e) => { - console.error('[useWalletSync] wallet.balancesUpdated handler failed:', e); - }); - }, [onRefreshBalances])); - - // Sender-side commit is a projection refresh trigger only. - useBridgeEvent('wallet.sendCommitted', useCallback(() => { - Promise.resolve(onRefreshAll()).catch((e) => { - console.error('[useWalletSync] wallet.sendCommitted refresh failed:', e); - }); - }, [onRefreshAll])); - - // Identity lifecycle - useBridgeEvent('identity.ready', useCallback(() => { - Promise.resolve(onIdentityReady?.()).catch((e) => { - console.error('[useWalletSync] identity.ready handler failed:', e); - }); - }, [onIdentityReady])); -} diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts index 1ea4095e2..729ce1beb 100644 --- a/dsm_client/frontend/src/proto/dsm_app_pb.ts +++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts @@ -21128,6 +21128,38 @@ export class BalanceGetResponse extends Message { */ iconUrl = ""; + /** + * Whether this is an asset the protocol defines (ERA, dBTC) rather than a + * token a device created and this device registered. Decided by Rust from + * the builtin policy commit the ticker resolves to. A screen that keyed it + * on the ticker text it displayed would call a created token "ERA" a + * protocol asset, and carried its own copy of what a protocol asset is. + * + * @generated from field: bool protocol_defined = 12; + */ + protocolDefined = false; + + /** + * The whole supply that will ever exist, in display units rendered by Rust + * with the token's decimals. A created token's policy fixes it at creation + * (SoFi §51); ERA's is the native reserve's genesis supply, from which every + * unit in circulation was released. Empty when Rust holds no supply for the + * token. + * + * @generated from field: string genesis_supply_display = 13; + */ + genesisSupplyDisplay = ""; + + /** + * What the token's committed policy permits, read from bytes verified + * against the anchor. Absent when Rust holds no committed policy for the + * token: "not stated" and "not permitted" are different facts, and a + * defaulted bool cannot tell them apart. + * + * @generated from field: dsm.TokenPolicyPermissions permissions = 14; + */ + permissions?: TokenPolicyPermissions; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -21147,6 +21179,9 @@ export class BalanceGetResponse extends Message { { no: 9, name: "anchor_fingerprint", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 10, name: "canonical_token_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 11, name: "icon_url", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 12, name: "protocol_defined", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 13, name: "genesis_supply_display", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 14, name: "permissions", kind: "message", T: TokenPolicyPermissions }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): BalanceGetResponse { @@ -21166,6 +21201,56 @@ export class BalanceGetResponse extends Message { } } +/** + * The permission flags of a committed token policy, as Core's one parser read + * them (SoFi §47). + * + * @generated from message dsm.TokenPolicyPermissions + */ +export class TokenPolicyPermissions extends Message { + /** + * Holders may burn (§54). + * + * @generated from field: bool burn_enabled = 1; + */ + burnEnabled = false; + + /** + * The token may move between holders (§49). + * + * @generated from field: bool transferable = 2; + */ + transferable = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "dsm.TokenPolicyPermissions"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "burn_enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 2, name: "transferable", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): TokenPolicyPermissions { + return new TokenPolicyPermissions().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): TokenPolicyPermissions { + return new TokenPolicyPermissions().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): TokenPolicyPermissions { + return new TokenPolicyPermissions().fromJsonString(jsonString, options); + } + + static equals(a: TokenPolicyPermissions | PlainMessage | undefined, b: TokenPolicyPermissions | PlainMessage | undefined): boolean { + return proto3.util.equals(TokenPolicyPermissions, a, b); + } +} + /** * Multi-token enumeration (ordered lexicographically by token_id on server side) * diff --git a/dsm_client/frontend/src/runtime/bridgeSessionStore.ts b/dsm_client/frontend/src/runtime/bridgeSessionStore.ts index b97cae17d..45f61c07d 100644 --- a/dsm_client/frontend/src/runtime/bridgeSessionStore.ts +++ b/dsm_client/frontend/src/runtime/bridgeSessionStore.ts @@ -1,7 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -import { useSyncExternalStore } from 'react'; - type BridgeSessionSnapshot = { bridgeBound: boolean; bridgeReady: boolean; @@ -65,11 +63,3 @@ class BridgeSessionStore { } export const bridgeSessionStore = new BridgeSessionStore(); - -export function useBridgeSessionStore(): BridgeSessionSnapshot { - return useSyncExternalStore( - bridgeSessionStore.subscribe, - bridgeSessionStore.getSnapshot, - bridgeSessionStore.getServerSnapshot, - ); -} diff --git a/dsm_client/frontend/src/runtime/nativeSessionStore.ts b/dsm_client/frontend/src/runtime/nativeSessionStore.ts index 72f5b9353..fe085e98b 100644 --- a/dsm_client/frontend/src/runtime/nativeSessionStore.ts +++ b/dsm_client/frontend/src/runtime/nativeSessionStore.ts @@ -35,16 +35,6 @@ class NativeSessionStore { getServerSnapshot = (): NativeSessionSnapshot => this.snapshot; - setSnapshotForTest(next: NativeSessionSnapshot): void { - this.snapshot = next; - this.emit(); - } - - resetForTest(): void { - this.snapshot = DEFAULT_NATIVE_SESSION; - this.emit(); - } - private emit(): void { this.listeners.forEach((listener) => listener()); } @@ -59,11 +49,3 @@ export function useNativeSessionStore(): NativeSessionSnapshot { nativeSessionStore.getServerSnapshot, ); } - -export function setNativeSessionSnapshotForTest(next: NativeSessionSnapshot): void { - nativeSessionStore.setSnapshotForTest(next); -} - -export function resetNativeSessionStoreForTest(): void { - nativeSessionStore.resetForTest(); -} diff --git a/dsm_client/frontend/src/services/bilateral/__tests__/bilateralEventService.test.ts b/dsm_client/frontend/src/services/bilateral/__tests__/bilateralEventService.test.ts index 14553578b..d0f68779c 100644 --- a/dsm_client/frontend/src/services/bilateral/__tests__/bilateralEventService.test.ts +++ b/dsm_client/frontend/src/services/bilateral/__tests__/bilateralEventService.test.ts @@ -30,11 +30,11 @@ jest.mock('../../../dsm/index', () => ({ import { BilateralEventType, decodeBilateralEvent, - encodeBilateralEventNotification, acceptIncomingTransfer, rejectIncomingTransfer, BilateralTransferEvent, } from '../bilateralEventService'; +import { encodeBilateralEventNotification } from '../../../tests/helpers/bilateralEventFixture'; import { acceptOfflineTransfer, rejectOfflineTransfer } from '../../../dsm/index'; describe('BilateralEventType', () => { diff --git a/dsm_client/frontend/src/services/bilateral/bilateralEventService.ts b/dsm_client/frontend/src/services/bilateral/bilateralEventService.ts index 1642b163a..68b82b003 100644 --- a/dsm_client/frontend/src/services/bilateral/bilateralEventService.ts +++ b/dsm_client/frontend/src/services/bilateral/bilateralEventService.ts @@ -60,31 +60,6 @@ export function decodeBilateralEvent(payload: Uint8Array): BilateralTransferEven } } -export function encodeBilateralEventNotification(input: { - eventType: BilateralEventTypeValue; - status?: string; - message?: string; - amount?: bigint; - tokenId?: string; - counterpartyDeviceId?: Uint8Array; - commitmentHash?: Uint8Array; - transactionHash?: Uint8Array; - senderBleAddress?: string; -}): Uint8Array { - const note = new pb.BilateralEventNotification({ - eventType: input.eventType as any, - status: input.status || '', - message: input.message || '', - amount: input.amount, - tokenId: input.tokenId, - counterpartyDeviceId: input.counterpartyDeviceId as any, - commitmentHash: input.commitmentHash as any, - transactionHash: input.transactionHash as any, - senderBleAddress: input.senderBleAddress, - } as any); - return note.toBinary(); -} - function decodeB32To32Bytes(value: string, label: string): Uint8Array { const bytes = decodeBase32Crockford(value); if (!(bytes instanceof Uint8Array) || bytes.length !== 32) { diff --git a/dsm_client/frontend/src/services/policy/__tests__/policyDisplayService.test.ts b/dsm_client/frontend/src/services/policy/__tests__/policyDisplayService.test.ts deleted file mode 100644 index e45a5d578..000000000 --- a/dsm_client/frontend/src/services/policy/__tests__/policyDisplayService.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import { mapPoliciesToDisplayEntries, PolicyRecord } from '../policyDisplayService'; -import { shortId, prettyAnchor } from '../../../utils/anchorDisplay'; - -function make32(seed: number): Uint8Array { - const b = new Uint8Array(32); - for (let i = 0; i < 32; i++) b[i] = (seed + i) & 0xff; - return b; -} - -describe('mapPoliciesToDisplayEntries', () => { - it('maps a single policy with all metadata', () => { - const hash = make32(0x10); - const policies: PolicyRecord[] = [ - { - alias: 'My Token', - ticker: 'MTK', - policy_hash: hash, - metadata: { ticker: 'MTK', alias: 'My Token', decimals: 8, maxSupply: '21000000' }, - }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries).toHaveLength(1); - expect(entries[0].label).toBe('MTK'); - expect(entries[0].shortId).toBe(shortId(hash)); - expect(entries[0].prettyAnchor).toBe(prettyAnchor(hash)); - expect(entries[0].ticker).toBe('MTK'); - expect(entries[0].alias).toBe('My Token'); - expect(entries[0].decimals).toBe(8); - expect(entries[0].maxSupply).toBe('21000000'); - }); - - it('falls back to alias/name for label when no metadata ticker', () => { - const hash = make32(0x20); - const policies: PolicyRecord[] = [ - { alias: 'FallbackAlias', policy_hash: hash }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries).toHaveLength(1); - expect(entries[0].label).toBe('FallbackAlias'); - }); - - it('uses shortId as label when no identifier fields at all', () => { - const hash = make32(0x30); - const policies: PolicyRecord[] = [{ policy_hash: hash }]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries).toHaveLength(1); - expect(entries[0].label).toBe(shortId(hash)); - }); - - it('skips policies without any 32-byte anchor', () => { - const policies: PolicyRecord[] = [ - { alias: 'NoBytesPolicy', policy_hash: null }, - { alias: 'ShortBytes', policy_hash: new Uint8Array(16) }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries).toHaveLength(0); - }); - - it('extracts from policy_hash with nested .v field', () => { - const hash = make32(0x40); - const policies: PolicyRecord[] = [ - { alias: 'Nested', policy_hash: { v: hash } }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries).toHaveLength(1); - expect(entries[0].shortId).toBe(shortId(hash)); - }); - - it('extracts from policy_hash with nested .value field', () => { - const hash = make32(0x50); - const policies: PolicyRecord[] = [ - { alias: 'NestedVal', policy_hash: { value: hash } }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries).toHaveLength(1); - expect(entries[0].shortId).toBe(shortId(hash)); - }); - - it('falls back to policyHash when policy_hash is absent', () => { - const hash = make32(0x60); - const policies: PolicyRecord[] = [ - { alias: 'AltHash', policyHash: hash }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries).toHaveLength(1); - expect(entries[0].shortId).toBe(shortId(hash)); - }); - - it('falls back to policy_commit when hashes are absent', () => { - const commit = make32(0x70); - const policies: PolicyRecord[] = [ - { alias: 'Commit', policy_commit: commit }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries).toHaveLength(1); - expect(entries[0].shortId).toBe(shortId(commit)); - }); - - it('falls back to policyCommit when all others are absent', () => { - const commit = make32(0x80); - const policies: PolicyRecord[] = [ - { alias: 'PCom', policyCommit: commit }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries).toHaveLength(1); - expect(entries[0].shortId).toBe(shortId(commit)); - }); - - it('handles an empty array', () => { - expect(mapPoliciesToDisplayEntries([])).toEqual([]); - }); - - it('handles null/undefined input', () => { - expect(mapPoliciesToDisplayEntries(null)).toEqual([]); - expect(mapPoliciesToDisplayEntries(undefined)).toEqual([]); - }); - - it('coerces { policies: [...] } wrapper objects', () => { - const hash = make32(0x90); - const wrapper = { - policies: [{ alias: 'Wrapped', policy_hash: hash }], - }; - const entries = mapPoliciesToDisplayEntries(wrapper); - expect(entries).toHaveLength(1); - expect(entries[0].label).toBe('Wrapped'); - }); - - it('maps multiple policies preserving order', () => { - const h1 = make32(0xa0); - const h2 = make32(0xb0); - const policies: PolicyRecord[] = [ - { alias: 'First', policy_hash: h1 }, - { alias: 'Second', policy_hash: h2 }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries).toHaveLength(2); - expect(entries[0].label).toBe('First'); - expect(entries[1].label).toBe('Second'); - }); - - it('prefers metadata.ticker over alias for label', () => { - const hash = make32(0xc0); - const policies: PolicyRecord[] = [ - { - alias: 'AliasName', - policy_hash: hash, - metadata: { ticker: 'TKR' }, - }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries[0].label).toBe('TKR'); - }); - - it('uses policyId as id fallback in label chain', () => { - const hash = make32(0xd0); - const policies: PolicyRecord[] = [ - { policyId: 'pol-123', policy_hash: hash }, - ]; - const entries = mapPoliciesToDisplayEntries(policies); - expect(entries[0].label).toBe('pol-123'); - }); -}); diff --git a/dsm_client/frontend/src/services/policy/policyDisplayService.ts b/dsm_client/frontend/src/services/policy/policyDisplayService.ts deleted file mode 100644 index 9e81827bd..000000000 --- a/dsm_client/frontend/src/services/policy/policyDisplayService.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// path: src/services/policy/policyDisplayService.ts -// SPDX-License-Identifier: Apache-2.0 -// Policy display helpers to keep raw bytes and proto concerns out of UI. - -import { shortId, prettyAnchor } from '../../utils/anchorDisplay'; - -type Hash32Bytes = Uint8Array & { readonly length: 32 }; -type Hash32Like = - | { v?: Uint8Array | null; value?: Uint8Array | null } - | Uint8Array - | null - | undefined; - -export interface PolicyRecord { - alias?: string; - name?: string; - ticker?: string; - id?: string; - policyId?: string; - tokenGenesis?: string; - policy_bytes?: Uint8Array | null; - metadata?: { - ticker?: string; - alias?: string; - decimals?: number; - maxSupply?: string; - }; - policy_hash?: Hash32Like; - policyHash?: Hash32Like; - policy_commit?: Uint8Array | null; - policyCommit?: Uint8Array | null; -} - -export interface PolicyDisplayEntry { - label: string; - shortId: string; - prettyAnchor: string; - ticker?: string; - alias?: string; - decimals?: number; - maxSupply?: string; -} - -function isUint8Array32(x: unknown): x is Hash32Bytes { - return x instanceof Uint8Array && x.length === 32; -} - -function resolvePolicyId(p: PolicyRecord): string { - const alias = p.alias ?? p.name ?? p.ticker ?? p.id ?? p.policyId ?? p.tokenGenesis ?? ''; - return typeof alias === 'string' ? alias.trim() : ''; -} - -function extractFromHash32(h: Hash32Like): Hash32Bytes | null { - if (!h) return null; - if (isUint8Array32(h)) return h; - const v = (h as { v?: unknown; value?: unknown }).v ?? (h as { value?: unknown }).value; - return isUint8Array32(v) ? (v as Hash32Bytes) : null; -} - -function extractAnchorBytes(policy: PolicyRecord): Hash32Bytes | null { - const h1 = extractFromHash32(policy.policy_hash); - if (h1) return h1; - const h2 = extractFromHash32(policy.policyHash); - if (h2) return h2; - if (isUint8Array32(policy.policy_commit)) return policy.policy_commit as Hash32Bytes; - if (isUint8Array32(policy.policyCommit)) return policy.policyCommit as Hash32Bytes; - return null; -} - -function coercePolicyArray(x: unknown): PolicyRecord[] { - if (Array.isArray(x)) return x as PolicyRecord[]; - if (x && typeof x === 'object' && Array.isArray((x as { policies?: unknown }).policies)) { - return (x as { policies: unknown[] }).policies as PolicyRecord[]; - } - return []; -} - -export function mapPoliciesToDisplayEntries(policies: unknown): PolicyDisplayEntry[] { - const list = coercePolicyArray(policies); - const out: PolicyDisplayEntry[] = []; - for (const p of list) { - const bytes = extractAnchorBytes(p); - if (!bytes) continue; - const meta = p.metadata; - const id = resolvePolicyId(p); - const label = meta?.ticker || id || shortId(bytes); - out.push({ - label, - shortId: shortId(bytes), - prettyAnchor: prettyAnchor(bytes), - ticker: meta?.ticker, - alias: meta?.alias, - decimals: meta?.decimals, - maxSupply: meta?.maxSupply, - }); - } - return out; -} diff --git a/dsm_client/frontend/src/services/policy/policyScanService.ts b/dsm_client/frontend/src/services/policy/policyScanService.ts deleted file mode 100644 index 2366f98e1..000000000 --- a/dsm_client/frontend/src/services/policy/policyScanService.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// path: src/services/policy/policyScanService.ts -// SPDX-License-Identifier: Apache-2.0 -// Policy scan/import helper to keep base32 decoding out of UI. - -import { decodeBase32Crockford, encodeBase32Crockford } from '../../utils/textId'; -import { shortId } from '../../utils/anchorDisplay'; -import { dsmClient } from '../dsmClient'; - -export async function importTokenPolicyFromScanData(scanData: string): Promise<{ - ok: boolean; - message?: string; - shortId?: string; -}> { - try { - let b32 = String(scanData || '').trim(); - if (b32.startsWith('dsm:policy:')) { - b32 = b32.slice('dsm:policy:'.length).trim(); - } - - const bytes = decodeBase32Crockford(b32); - if (!bytes || bytes.length === 0) { - return { ok: false, message: 'Empty payload' }; - } - if (bytes.length !== 32) { - return { ok: false, message: 'This screen only supports importing existing tokens (Anchors). To publish new policies, use the Developer Settings.' }; - } - - const canonicalB32 = encodeBase32Crockford(bytes); - const res = await dsmClient.addTokenByAnchor({ anchorBase32: canonicalB32 }); - if (!res.success) { - return { ok: false, message: res.error || 'Import failed' }; - } - - return { ok: true, shortId: shortId(bytes) }; - } catch (e: any) { - return { ok: false, message: e?.message || 'Scan failed' }; - } -} diff --git a/dsm_client/frontend/src/setupTests.ts b/dsm_client/frontend/src/setupTests.ts index 2636281ee..a4d83d670 100644 --- a/dsm_client/frontend/src/setupTests.ts +++ b/dsm_client/frontend/src/setupTests.ts @@ -52,8 +52,8 @@ if (typeof window !== 'undefined' && typeof window.HTMLMediaElement !== 'undefin if (typeof (global as any).window !== 'undefined') { const g = (global as any); // The test bridge speaks the production interface: the object `index.html` - // installs — `__binary`, `isAvailable`, `sendMessageBin`, and `startup` / - // `ingress` / `hostRequest`, which are index.html's own wrappers over + // installs — `__binary`, `isAvailable`, `sendMessageBin`, and `ingress` / + // `hostRequest`, which are index.html's own wrappers over // `sendMessageBin`. A test supplies `sendMessageBin` (one BridgeRpcRequest in, // BridgeRpcResponse bytes out) and the setter completes the rest, so the // production transport runs unchanged; nothing in production branches on a @@ -85,7 +85,6 @@ if (typeof (global as any).window !== 'undefined') { bridge.__binary = true; if (typeof bridge.isAvailable !== 'function') bridge.isAvailable = () => true; if (typeof bridge.getBridgeStatus !== 'function') bridge.getBridgeStatus = () => 3; - if (typeof bridge.startup !== 'function') bridge.startup = (p: Uint8Array) => callBridgeMethod('nativeBoundaryStartup', p); if (typeof bridge.ingress !== 'function') bridge.ingress = (p: Uint8Array) => callBridgeMethod('nativeBoundaryIngress', p); if (typeof bridge.hostRequest !== 'function') bridge.hostRequest = (p: Uint8Array) => callBridgeMethod('nativeHostRequest', p); return bridge; @@ -137,13 +136,6 @@ if (typeof (global as any).window !== 'undefined') { return createDsmBridgeSuccessResponse(new Uint8Array(0)); } - if (method === 'nativeBoundaryStartup') { - const response = new pb.StartupResponse({ - result: { case: 'okBytes', value: new Uint8Array(0) }, - }); - return createDsmBridgeSuccessResponse(response.toBinary()); - } - if (method === 'nativeBoundaryIngress') { const response = new pb.IngressResponse({ result: { case: 'okBytes', value: new Uint8Array(0) }, diff --git a/dsm_client/frontend/src/stores/__tests__/contactsStore.test.ts b/dsm_client/frontend/src/stores/__tests__/contactsStore.test.ts index 4b8c16106..28cc8d9c0 100644 --- a/dsm_client/frontend/src/stores/__tests__/contactsStore.test.ts +++ b/dsm_client/frontend/src/stores/__tests__/contactsStore.test.ts @@ -2,20 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import { renderHook } from '@testing-library/react'; +import { toBase32Crockford } from '../../dsm/decoding'; jest.mock('../../services/dsmClient', () => ({ dsmClient: { getContacts: jest.fn(), addContact: jest.fn(), + getBleIdentitySnapshot: () => ({ deviceIds: {}, genesis: {} }), }, })); -jest.mock('../../contexts/contacts/utils', () => ({ - parseBinary32: jest.fn((input: any) => (input instanceof Uint8Array ? input : new Uint8Array(32))), - parseBinary64: jest.fn((input: any) => (input instanceof Uint8Array ? input : new Uint8Array(64))), - bytesToDisplay: jest.fn((u8: Uint8Array) => Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('')), -})); - jest.mock('../../utils/logger', () => ({ default: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() }, __esModule: true, @@ -43,13 +39,9 @@ function freshModule() { dsmClient: { getContacts: jest.fn(), addContact: jest.fn(), + getBleIdentitySnapshot: () => ({ deviceIds: {}, genesis: {} }), }, })); - jest.doMock('../../contexts/contacts/utils', () => ({ - parseBinary32: jest.fn((input: any) => (input instanceof Uint8Array ? input : new Uint8Array(32))), - parseBinary64: jest.fn((input: any) => (input instanceof Uint8Array ? input : new Uint8Array(64))), - bytesToDisplay: jest.fn((u8: Uint8Array) => Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('')), - })); jest.doMock('../../utils/logger', () => ({ default: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() }, __esModule: true, @@ -125,16 +117,20 @@ describe('ContactsStore', () => { await contactsStore.refreshContacts(); const s = contactsStore.getSnapshot(); expect(s.contacts).toHaveLength(1); + // The one contact shape every screen reads, from the one mapper: a + // contact is its device, in Base32 Crockford, with its send-readiness. expect(s.contacts[0]).toEqual({ - // A contact is its device: the alias is a label. - id: '01'.repeat(32), alias: 'Alice', - deviceId: '01'.repeat(32), - genesisHash: '02'.repeat(32), - publicKey: '03'.repeat(64), - isVerified: true, + deviceId: toBase32Crockford(new Uint8Array(32).fill(0x01)), + genesisHash: toBase32Crockford(new Uint8Array(32).fill(0x02)), + signingPublicKey: toBase32Crockford(new Uint8Array(64).fill(0x03)), + genesisVerifiedOnline: true, bleAddress: undefined, chainTip: undefined, + sendReady: undefined, + sendCheckState: undefined, + sendBlockReason: undefined, + sendBlockMessage: undefined, }); expect(s.isLoading).toBe(false); }); @@ -304,13 +300,13 @@ describe('ContactsStore', () => { }); describe('contact mapping', () => { - it('sets isVerified from genesisVerifiedOnline', async () => { + it('carries genesisVerifiedOnline as Rust reports it', async () => { const { contactsStore, client } = freshModule(); client.getContacts.mockResolvedValue({ contacts: [makeContact({ genesisVerifiedOnline: false })], }); await contactsStore.refreshContacts(); - expect(contactsStore.getSnapshot().contacts[0].isVerified).toBe(false); + expect(contactsStore.getSnapshot().contacts[0].genesisVerifiedOnline).toBe(false); }); it('carries the tip when the relationship has one', async () => { @@ -319,7 +315,7 @@ describe('ContactsStore', () => { contacts: [makeContact({ chainTip: new Uint8Array(32).fill(0x0c) })], }); await contactsStore.refreshContacts(); - expect(contactsStore.getSnapshot().contacts[0].chainTip).toBe('0c'.repeat(32)); + expect(contactsStore.getSnapshot().contacts[0].chainTip).toBe(toBase32Crockford(new Uint8Array(32).fill(0x0c))); }); }); }); diff --git a/dsm_client/frontend/src/stores/__tests__/walletStore.test.ts b/dsm_client/frontend/src/stores/__tests__/walletStore.test.ts index be655a6f2..c37df4607 100644 --- a/dsm_client/frontend/src/stores/__tests__/walletStore.test.ts +++ b/dsm_client/frontend/src/stores/__tests__/walletStore.test.ts @@ -100,20 +100,40 @@ describe('WalletStore', () => { expect(s.error).toBeNull(); }); - it('sets isInitialized false when genesisHash is missing', async () => { + // No identity on this device is a state Rust reports, not a failure: the + // store stays uninitialized, reads nothing, and shows no error. It used to + // be the same null as a failed read. + it('a missing identity leaves the store uninitialized, with no error and no reads', async () => { const { walletStore, client } = freshModule(); - client.getIdentity.mockResolvedValue({ genesisHash: null, deviceId: 'dev1' }); + client.getIdentity.mockRejectedValue( + Object.assign(new Error('no identity on this device (native session: missing)'), { + name: 'IdentityUnavailableError', + state: 'missing', + }), + ); await walletStore.initialize(); - expect(walletStore.getSnapshot().isInitialized).toBe(false); + const s = walletStore.getSnapshot(); + expect(s.isInitialized).toBe(false); + expect(s.error).toBeNull(); + expect(s.isLoading).toBe(false); + expect(client.getAllBalances).not.toHaveBeenCalled(); + expect(client.getWalletHistory).not.toHaveBeenCalled(); }); - it('sets isInitialized false when deviceId is missing', async () => { + it('a runtime that is not ready in time is an error, as reported', async () => { const { walletStore, client } = freshModule(); - client.getIdentity.mockResolvedValue({ genesisHash: 'abc', deviceId: null }); + client.getIdentity.mockRejectedValue( + Object.assign(new Error('native session not ready after 5750 ms (no session state received)'), { + name: 'IdentityUnavailableError', + state: 'runtime_not_ready', + }), + ); await walletStore.initialize(); - expect(walletStore.getSnapshot().isInitialized).toBe(false); + const s = walletStore.getSnapshot(); + expect(s.isInitialized).toBe(false); + expect(s.error).toMatch(/native session not ready after 5750 ms/); }); it('stores error message on failure', async () => { @@ -134,13 +154,6 @@ describe('WalletStore', () => { expect(walletStore.getSnapshot().error).toBe('Failed to initialize wallet'); }); - it('does not call refreshAll when not initialized', async () => { - const { walletStore, client } = freshModule(); - client.getIdentity.mockResolvedValue({ genesisHash: null, deviceId: null }); - await walletStore.initialize(); - expect(client.getAllBalances).not.toHaveBeenCalled(); - expect(client.getWalletHistory).not.toHaveBeenCalled(); - }); }); describe('refreshBalances()', () => { @@ -304,15 +317,7 @@ describe('WalletStore', () => { }); // Hook tests use static imports (same React instance as @testing-library/react) -import { - useWalletStore, - useWalletBalances, - useWalletTransactions, - useWalletIdentity, - useWalletInitialized, - useWalletLoading, - useWalletError, -} from '../walletStore'; +import { useWalletStore } from '../walletStore'; describe('wallet store hooks', () => { it('useWalletStore returns full snapshot', () => { @@ -322,34 +327,4 @@ describe('wallet store hooks', () => { expect(result.current).toHaveProperty('transactions'); expect(result.current).toHaveProperty('isInitialized'); }); - - it('useWalletBalances returns balances array', () => { - const { result } = renderHook(() => useWalletBalances()); - expect(Array.isArray(result.current)).toBe(true); - }); - - it('useWalletTransactions returns transactions array', () => { - const { result } = renderHook(() => useWalletTransactions()); - expect(Array.isArray(result.current)).toBe(true); - }); - - it('useWalletIdentity returns cached identity object', () => { - const { result } = renderHook(() => useWalletIdentity()); - expect(result.current).toEqual({ genesisHash: null, deviceId: null }); - }); - - it('useWalletInitialized returns boolean', () => { - const { result } = renderHook(() => useWalletInitialized()); - expect(result.current).toBe(false); - }); - - it('useWalletLoading returns boolean', () => { - const { result } = renderHook(() => useWalletLoading()); - expect(result.current).toBe(false); - }); - - it('useWalletError returns null initially', () => { - const { result } = renderHook(() => useWalletError()); - expect(result.current).toBeNull(); - }); }); diff --git a/dsm_client/frontend/src/stores/contactsStore.ts b/dsm_client/frontend/src/stores/contactsStore.ts index 014c4f525..4b6c72734 100644 --- a/dsm_client/frontend/src/stores/contactsStore.ts +++ b/dsm_client/frontend/src/stores/contactsStore.ts @@ -3,9 +3,9 @@ import { useSyncExternalStore } from 'react'; import { dsmClient } from '../services/dsmClient'; -import { bytesToDisplay } from '../contexts/contacts/utils'; -import type { Contact, ContactsState } from '../contexts/ContactsContext'; -import type { AddContactResult, BilateralRelationshipDTO, ContactCard } from '../dsm/types'; +import type { ContactsState } from '../contexts/ContactsContext'; +import type { AddContactResult, ContactCard } from '../dsm/types'; +import { mapContactList } from '../domain/mappers'; import logger from '../utils/logger'; @@ -78,23 +78,6 @@ class ContactsStore { this.emit(); } - private mapContacts(list: BilateralRelationshipDTO[]): Contact[] { - return list.map((c) => { - const deviceId = bytesToDisplay(c.deviceId); - return { - // A contact is its device: the alias is a label and can change. - id: deviceId, - alias: c.alias, - genesisHash: bytesToDisplay(c.genesisHash), - deviceId, - publicKey: bytesToDisplay(c.publicKey), - isVerified: c.genesisVerifiedOnline, - bleAddress: c.bleAddress, - chainTip: c.chainTip ? bytesToDisplay(c.chainTip) : undefined, - }; - }); - } - refreshContacts = async (): Promise => { const seq = ++this.refreshSeq; try { @@ -104,8 +87,11 @@ class ContactsStore { this.setState({ error: null }); const data = await awaitWithFrameBudget(dsmClient.getContacts()); - // Rust's list as it stands: an address Rust no longer holds is not kept. - const contacts = this.mapContacts(data.contacts); + // Rust's list as it stands, in the one contact shape every screen reads + // — each contact with its send-readiness, and the BLE address Rust holds + // or the native side resolved for its device this session. An address + // Rust no longer holds is not kept. + const contacts = mapContactList(data.contacts, dsmClient.getBleIdentitySnapshot()); if (seq === this.refreshSeq) { this.setState({ contacts }); diff --git a/dsm_client/frontend/src/stores/walletStore.ts b/dsm_client/frontend/src/stores/walletStore.ts index 92f36fd32..03ae3e716 100644 --- a/dsm_client/frontend/src/stores/walletStore.ts +++ b/dsm_client/frontend/src/stores/walletStore.ts @@ -3,6 +3,7 @@ import { useSyncExternalStore } from 'react'; import { dsmClient } from '../services/dsmClient'; +import { isIdentityUnavailable } from '../dsm/identityUnavailable'; import { bridgeEvents } from '../bridge/bridgeEvents'; import type { Transaction } from '@/hooks/useTransactions'; import type { WalletBalance, WalletState } from '../contexts/WalletContext'; @@ -78,21 +79,25 @@ class WalletStore { this.setState({ isLoading: true, error: null }); const identity = await dsmClient.getIdentity(); - const genesisHash = identity?.genesisHash ?? null; - const deviceId = identity?.deviceId ?? null; this.setState({ - genesisHash, - deviceId, - isInitialized: Boolean(genesisHash && deviceId), + genesisHash: identity.genesisHash, + deviceId: identity.deviceId, + isInitialized: true, isLoading: false, error: null, }); - if (genesisHash && deviceId) { - await this.refreshAll(); - } + await this.refreshAll(); } catch (error) { + // No identity on this device is a state, not a failure: the store stays + // uninitialized with no error, and the genesis flow is where the app + // goes. Anything else — the runtime not ready within the window, a read + // that failed — is reported as what it is. + if (isIdentityUnavailable(error) && error.state === 'missing') { + this.setState({ genesisHash: null, deviceId: null, isInitialized: false, isLoading: false, error: null }); + return; + } const message = error instanceof Error ? error.message : 'Failed to initialize wallet'; this.setState({ isLoading: false, error: message }); } @@ -184,67 +189,3 @@ export function useWalletStore(): WalletState { walletStore.getServerSnapshot, ); } - -export function useWalletBalances(): WalletBalance[] { - return useSyncExternalStore( - walletStore.subscribe, - () => walletStore.getSnapshot().balances, - () => walletStore.getServerSnapshot().balances, - ); -} - -export function useWalletTransactions(): Transaction[] { - return useSyncExternalStore( - walletStore.subscribe, - () => walletStore.getSnapshot().transactions, - () => walletStore.getServerSnapshot().transactions, - ); -} - -// Memoized identity selector — avoids creating a new object reference on every -// unrelated store change (e.g. transaction updates) which would cause needless -// re-renders in all useWalletIdentity() consumers. -let _cachedIdentity: { genesisHash: string | null; deviceId: string | null } = { - genesisHash: null, - deviceId: null, -}; - -function getIdentitySnapshot(): { genesisHash: string | null; deviceId: string | null } { - const s = walletStore.getSnapshot(); - if (s.genesisHash !== _cachedIdentity.genesisHash || s.deviceId !== _cachedIdentity.deviceId) { - _cachedIdentity = { genesisHash: s.genesisHash, deviceId: s.deviceId }; - } - return _cachedIdentity; -} - -export function useWalletIdentity(): { genesisHash: string | null; deviceId: string | null } { - return useSyncExternalStore( - walletStore.subscribe, - getIdentitySnapshot, - getIdentitySnapshot, - ); -} - -export function useWalletInitialized(): boolean { - return useSyncExternalStore( - walletStore.subscribe, - () => walletStore.getSnapshot().isInitialized, - () => walletStore.getServerSnapshot().isInitialized, - ); -} - -export function useWalletLoading(): boolean { - return useSyncExternalStore( - walletStore.subscribe, - () => walletStore.getSnapshot().isLoading, - () => walletStore.getServerSnapshot().isLoading, - ); -} - -export function useWalletError(): string | null { - return useSyncExternalStore( - walletStore.subscribe, - () => walletStore.getSnapshot().error, - () => walletStore.getServerSnapshot().error, - ); -} diff --git a/dsm_client/frontend/src/tests/E2E.bilateral.acceptFlow.test.tsx b/dsm_client/frontend/src/tests/E2E.bilateral.acceptFlow.test.tsx index dbe90a6de..6abf362c0 100644 --- a/dsm_client/frontend/src/tests/E2E.bilateral.acceptFlow.test.tsx +++ b/dsm_client/frontend/src/tests/E2E.bilateral.acceptFlow.test.tsx @@ -85,8 +85,6 @@ describe('E2E bilateral accept: BLE accept flow triggers refresh and toast', () } throw new Error(`unhandled bridge method:${method}`); }, - getDeviceIdBin: () => new Uint8Array(32).fill(1), - getGenesisHashBin: () => new Uint8Array(32).fill(1), }; // Call accept — RAF mock fires synchronously so all 4 staggered @@ -98,9 +96,12 @@ describe('E2E bilateral accept: BLE accept flow triggers refresh and toast', () off(); // schedulePostAcceptRefreshes emits wallet.refresh at frame intervals [1, 30, 60, 120]. - // With sync RAF all 4 fire. Verify at least one arrived. - expect(refreshEvents.length).toBeGreaterThanOrEqual(1); - // Each event should carry the bilateral source tag - expect(refreshEvents[0]).toEqual(expect.objectContaining({ source: 'bilateral.transfer_complete' })); + // With sync RAF all 4 fire. + expect(refreshEvents.length).toBe(4); + // Each names itself as the accept's follow-up re-read: a completed + // transfer is Rust's to announce, and these used to claim to be one. + for (const event of refreshEvents) { + expect(event).toEqual(expect.objectContaining({ source: 'bilateral.accept_followup' })); + } }); }); diff --git a/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts b/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts index 0025efc59..2c979a083 100644 --- a/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts +++ b/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts @@ -52,7 +52,7 @@ describe('E2E: Offline BLE exchange -> wallet refresh', () => { initializeEventBridge(); }); - test('offline send then receive BilateralPrepareResponse triggers wallet refresh', async () => { + test('offline send: a BLE prepare response announces no wallet change; the completed transfer does', async () => { // Mock bridge methods (global as any).window = (global as any).window || {}; const ALICE_DEVICE_ID = new Uint8Array(32).fill(1); @@ -77,9 +77,6 @@ describe('E2E: Offline BLE exchange -> wallet refresh', () => { }).toBinary(); return wrapSuccessEnvelope(headersBytes); } - if (method === 'getSigningPublicKeyBin') { - return wrapSuccessEnvelope(new Uint8Array(64).fill(0x5a)); - } if (method === 'nativeBoundaryIngress') { const ingress = pb.IngressRequest.fromBinary(payload); if (ingress.operation.case === 'routerQuery') { @@ -133,11 +130,6 @@ describe('E2E: Offline BLE exchange -> wallet refresh', () => { } throw new Error(`unhandled bridge method: ${method} (payloadLen=${payload.length})`); }, - // Some call sites read base32 Crockford strings from these getters. - getDeviceIdBin: () => base32CrockfordEncode(ALICE_DEVICE_ID), - getGenesisHashBin: () => base32CrockfordEncode(ALICE_GENESIS), - getTransportHeadersV3Bin: () => - new pb.Headers({ deviceId: ALICE_DEVICE_ID, genesisHash: ALICE_GENESIS as any,}).toBinary(), }; // Start offline send @@ -169,14 +161,21 @@ describe('E2E: Offline BLE exchange -> wallet refresh', () => { bytes[0] = 0x03; bytes.set(rawBytes, 1); - // Emit through the real DOM ingress path that EventBridge listens to. + // Emit through the real DOM ingress path that EventBridge listens to. A + // prepare response is not a wallet change and announces none; this used + // to emit a `wallet.refresh` claiming a completed transfer on one prepare + // response in eight. window.dispatchEvent(new CustomEvent('dsm-event-bin', { detail: { topic: 'ble.envelope.bin', payload: bytes }, })); + expect(handler).not.toHaveBeenCalled(); - expect(handler).toHaveBeenCalled(); - const calledWith = handler.mock.calls.find((c: any) => c && c[0] && typeof c[0].source === 'string' && c[0].source.indexOf('bilateral') >= 0); - expect(Boolean(calledWith)).toBe(true); + // The completed transfer is Rust's to announce, and that is the refresh. + window.dispatchEvent(new CustomEvent('dsm-event-bin', { + detail: { topic: 'bilateral.event', payload: new Uint8Array(completeNote.toBinary()) }, + })); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toEqual(expect.objectContaining({ source: 'bilateral.transfer_complete' })); unsubscribe(); }); }); diff --git a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts index 6096b9d34..3de8c1ed1 100644 --- a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts +++ b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts @@ -164,8 +164,6 @@ function installBridge(opts?: { contactBleAddress?: string }) { g.window.DsmBridge = { __binary: true, - getDeviceIdBin: () => encodeBase32Crockford(DEVICE_A), - getGenesisHashBin: () => encodeBase32Crockford(GENESIS_A), sendMessageBin: async (reqBytes: Uint8Array): Promise => { const { method, payload } = decodeBridgeReq(reqBytes); @@ -177,10 +175,6 @@ function installBridge(opts?: { contactBleAddress?: string }) { return wrapSuccess(headers.toBinary()); } - if (method === 'getSigningPublicKeyBin') { - return wrapSuccess(SIGNING_KEY); - } - if (method === 'getPreference' || method === 'setPreference') { return wrapSuccess(new Uint8Array(0)); } diff --git a/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx b/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx index 5ff411e2f..07deb2baa 100644 --- a/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx +++ b/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx @@ -16,7 +16,7 @@ * - dsmClient.getAllBalances() → dsm/wallet.ts::getAllBalances() → WebViewBridge::getAllBalancesStrictBridge() → callBin() → sendMessageBin (mock) * - dsmClient.getWalletHistory() → dsm/wallet.ts::getWalletHistory() → WebViewBridge::getWalletHistoryStrictBridge() → routerQueryBin() → sendMessageBin (mock) * - dsmClient.getIdentity() → dsm/identity.ts::getIdentity() → getHeaders() → getTransportHeadersV3Bin() → sendMessageBin (mock) - * - dsmClient.isReady() → hasIdentity() → checkIdentityState() → sendMessageBin (mock) + * - useTransactions → checkIdentityState() (the native session store) * - acceptIncomingTransfer() → acceptOfflineTransfer() → acceptBilateralByCommitmentBridge() → callBin() → sendMessageBin (mock) * - rejectIncomingTransfer() → rejectOfflineTransfer() → rejectBilateralByCommitmentBridge() → sendBridgeRequestBytes() → sendMessageBin (mock) * - EventBridge (REAL — initializeEventBridge) @@ -25,17 +25,15 @@ * - useEventSignal (REAL useSyncExternalStore) * - useWalletSync (REAL event→dispatch routing) * - BridgeGate (REAL — auto-opens for sendMessageBin paths) - * - decodeFramedEnvelopeV3, decodeBalancesListResponseStrict (REAL decoders) + * - decodeFramedEnvelopeV3 (the REAL decoder) * * COVERAGE: * 1. BridgeEventBus — typed delivery, multi-subscriber, error isolation * 2. useEventSignal — useSyncExternalStore, no tearing * 3. Bilateral event encode/decode roundtrip (protobuf) - * 4. DOM event → nativeBridgeAdapter → bridgeEvents (REAL adapter) - * 5. DOM event → EventBridge → bilateral.event (REAL EventBridge) - * 6. INTEGRATED: Dialog + WalletContext — PREPARE → Accept → COMPLETE → refreshAll → REAL getAllBalances → sendMessageBin → proto decode → balance in DOM - * 7. INTEGRATED: wallet.sendCommitted → WalletContext refresh trigger only - * 8. INTEGRATED: Full bilateral sequence through REAL components — EXACT device sequence + * 4. DOM event → EventBridge → bilateral.event, and the native lifecycle topics → the bus (REAL EventBridge) + * 5. INTEGRATED: Dialog + WalletContext — PREPARE → Accept → COMPLETE → refreshAll → REAL getAllBalances → sendMessageBin → proto decode → balance in DOM + * 6. INTEGRATED: Full bilateral sequence through REAL components — EXACT device sequence */ import React from 'react'; @@ -45,11 +43,8 @@ import { useEventSignal } from '../bridge/useEventSignal'; import * as pb from '../proto/dsm_app_pb'; import { emit as eventBridgeEmit, on as eventBridgeOn, initializeEventBridge } from '../dsm/EventBridge'; import { initializeNativeBridgeAdapter } from '../bridge/nativeBridgeAdapter'; -import { - BilateralEventType, - encodeBilateralEventNotification, - decodeBilateralEvent, -} from '../services/bilateral/bilateralEventService'; +import { BilateralEventType, decodeBilateralEvent } from '../services/bilateral/bilateralEventService'; +import { encodeBilateralEventNotification } from './helpers/bilateralEventFixture'; import { setBridgeInstance } from '../bridge/BridgeRegistry'; let consoleWarnSpy: jest.SpyInstance; @@ -124,8 +119,9 @@ function frameEnvelope(env: pb.Envelope): Uint8Array { /** Build FramedEnvelopeV3 containing a BalancesListResponse */ function makeBalancesFramedEnvelope(balances: Array<{ tokenId: string; available: bigint }>): Uint8Array { // Complete rows, as balance.list enriches them: the boundary decoder refuses - // a row missing its symbol, name or display amount. The fixture's tokens are - // whole-unit, so the display form is the base units. + // a row missing its symbol, name or display amount, and a created token's + // row without its policy facts. The fixture's tokens are ERA, a protocol + // asset on Rust's word, and whole-unit, so the display form is the base units. const balList = balances.map(b => new pb.BalanceGetResponse({ tokenId: b.tokenId, available: b.available as any, @@ -134,6 +130,7 @@ function makeBalancesFramedEnvelope(balances: Array<{ tokenId: string; available tokenName: b.tokenId, decimals: 0, displayAmount: b.available.toString(), + protocolDefined: true, } as any)); const resp = new pb.BalancesListResponse({ balances: balList } as any); const env = new pb.Envelope({ @@ -374,25 +371,6 @@ describe('BridgeEventBus — core event delivery', () => { unsub(); }); - test('wallet.sendCommitted carries balance and tx info', () => { - const spy = jest.fn(); - const unsub = bridgeEvents.on('wallet.sendCommitted', spy); - bridgeEvents.emit('wallet.sendCommitted', { - success: true, - tokenId: 'ERA', - newBalance: 9500n, - transactionHash: makeTxHash(), - toDeviceId: makeDeviceId(), - amount: 500n, - }); - expect(spy).toHaveBeenCalledWith(expect.objectContaining({ - success: true, - tokenId: 'ERA', - newBalance: 9500n, - amount: 500n, - })); - unsub(); - }); }); // ═══════════════════════════════════════════════════════════════════════════════ @@ -508,71 +486,40 @@ describe('Bilateral event service — encode/decode roundtrip', () => { }); // ═══════════════════════════════════════════════════════════════════════════════ -// 4. nativeBridgeAdapter — REAL DOM → bridgeEvents Translation +// 4. EventBridge — REAL DOM → EventBridge → subscribers // ═══════════════════════════════════════════════════════════════════════════════ -describe('nativeBridgeAdapter — REAL DOM → bridgeEvents translation', () => { - test('REAL: dsm-bilateral-committed DOM event → wallet.bilateralCommitted', () => { - const spy = jest.fn(); - const unsub = bridgeEvents.on('wallet.bilateralCommitted', spy); - window.dispatchEvent(new CustomEvent('dsm-bilateral-committed', { - detail: { commitmentHash: makeCommitmentHash(0x11), counterpartyDeviceId: makeDeviceId(0x22), accepted: true }, - })); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(expect.objectContaining({ accepted: true })); - unsub(); - }); - - test('REAL: dsm-wallet-refresh DOM event → wallet.refresh', () => { - const spy = jest.fn(); - const unsub = bridgeEvents.on('wallet.refresh', spy); - window.dispatchEvent(new CustomEvent('dsm-wallet-refresh', { detail: { source: 'dom-test' } })); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(expect.objectContaining({ source: 'dom-test' })); - unsub(); - }); - - test('REAL: dsm-wallet-send-committed DOM event → wallet.sendCommitted', () => { - const spy = jest.fn(); - const unsub = bridgeEvents.on('wallet.sendCommitted', spy); - window.dispatchEvent(new CustomEvent('dsm-wallet-send-committed', { - detail: { success: true, tokenId: 'ERA', newBalance: 5000n, amount: 1000n }, - })); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(expect.objectContaining({ success: true, tokenId: 'ERA' })); - unsub(); - }); - - test('REAL: dsm-identity-ready DOM event → identity.ready', () => { +describe('EventBridge — REAL DOM event-bin propagation', () => { + // Native lifecycle topics reach the bus from the event bridge directly. They + // used to go through DOM events the adapter re-emitted, beside four DOM + // events nothing ever dispatched. + test('REAL: the native dsm-identity-ready topic → identity.ready on the bus', () => { const spy = jest.fn(); const unsub = bridgeEvents.on('identity.ready', spy); - document.dispatchEvent(new CustomEvent('dsm-identity-ready')); + window.dispatchEvent(new CustomEvent('dsm-event-bin', { detail: { topic: 'dsm-identity-ready', payload: new Uint8Array(0) } })); expect(spy).toHaveBeenCalledTimes(1); unsub(); }); - test('REAL: dsm-history-updated DOM event → wallet.historyUpdated', () => { + test('REAL: the native dsm-wallet-refresh topic → wallet.refresh from native on the bus', () => { const spy = jest.fn(); - const unsub = bridgeEvents.on('wallet.historyUpdated', spy); - window.dispatchEvent(new CustomEvent('dsm-history-updated')); + const unsub = bridgeEvents.on('wallet.refresh', spy); + window.dispatchEvent(new CustomEvent('dsm-event-bin', { detail: { topic: 'dsm-wallet-refresh', payload: new Uint8Array(0) } })); expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ source: 'native' }); unsub(); }); - test('REAL: dsm-balances-updated DOM event → wallet.balancesUpdated', () => { + test('REAL: the native dsm-env-config-error topic → env.config.error with its message on the bus', () => { const spy = jest.fn(); - const unsub = bridgeEvents.on('wallet.balancesUpdated', spy); - window.dispatchEvent(new CustomEvent('dsm-balances-updated')); - expect(spy).toHaveBeenCalledTimes(1); + const unsub = bridgeEvents.on('env.config.error', spy); + // Built in the window's realm: the event-bin handler takes a Uint8Array of its own. + const payload = new Uint8Array(Array.from(new TextEncoder().encode('MISSING_FILE|no dsm_env_config.toml|put one in files/'))); + window.dispatchEvent(new CustomEvent('dsm-event-bin', { detail: { topic: 'dsm-env-config-error', payload } })); + expect(spy).toHaveBeenCalledWith({ message: 'no dsm_env_config.toml' }); unsub(); }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// 5. EventBridge — REAL DOM → EventBridge → subscribers -// ═══════════════════════════════════════════════════════════════════════════════ -describe('EventBridge — REAL DOM event-bin propagation', () => { test('REAL: dsm-event-bin with topic=bilateral.event → EventBridge subscribers', () => { const spy = jest.fn(); const unsub = eventBridgeOn('bilateral.event', spy); @@ -628,7 +575,7 @@ describe('EventBridge — REAL DOM event-bin propagation', () => { }); // ═══════════════════════════════════════════════════════════════════════════════ -// 6. INTEGRATED: BilateralTransferDialog + WalletProvider — sendMessageBin-only Mock +// 5. INTEGRATED: BilateralTransferDialog + WalletProvider — sendMessageBin-only Mock // The ENTIRE TypeScript chain is REAL. Only sendMessageBin is mocked. // ═══════════════════════════════════════════════════════════════════════════════ @@ -706,14 +653,14 @@ describe('INTEGRATED: Full chain with sendMessageBin-only mock', () => { render(); await settleWalletInit(); - // Wait for the REAL init chain: isReady → getIdentity → getAllBalances → getWalletHistory + // Wait for the REAL init chain: getIdentity → getAllBalances → getWalletHistory await waitFor(() => { expect(screen.getByTestId('i-balance-era').textContent).not.toBe('none'); }); // The balance came from sendMessageBin via: dsmClient.getAllBalances() → dsm.getAllBalances() // → getAllBalancesStrictBridge() → callBin('getAllBalancesStrict') → sendMessageBin → FramedEnvelopeV3 - // → decodeBalancesListResponseStrict() → TokenBalanceView[] + // → decodeFramedEnvelopeV3() → TokenBalanceView[] // PROVES the entire decode chain works. const balText = screen.getByTestId('i-balance-era').textContent; expect(balText).toBe('10000'); @@ -978,7 +925,7 @@ describe('INTEGRATED: Full chain with sendMessageBin-only mock', () => { await waitFor(() => expect(container.querySelector('.bilateral-transfer-overlay')).not.toBeNull()); }); - test('wallet.bilateralCommitted → WalletProvider refreshes (REAL sendMessageBin round trip)', async () => { + test('an accepted transfer → WalletProvider refreshes on the accept path’s wallet.refresh (REAL sendMessageBin round trip)', async () => { render(); await settleWalletInit(); await waitFor(() => expect(screen.getByTestId('i-balance-era').textContent).toBe('10000')); @@ -987,9 +934,12 @@ describe('INTEGRATED: Full chain with sendMessageBin-only mock', () => { balancesState = [{ tokenId: 'ERA', available: 10500n }]; capturedMethods = []; - // Emit wallet.bilateralCommitted — useEventSignal triggers refreshAll + // What the accept path emits: the committed signal (the toast's trigger) + // and its own wallet.refresh (the reload's). The provider reloads on the + // second alone. act(() => { bridgeEvents.emit('wallet.bilateralCommitted', { accepted: true, committed: true } as any); + bridgeEvents.emit('wallet.refresh', { source: 'bilateral.accept_followup' }); }); // Wait for REAL getAllBalances → sendMessageBin round trip @@ -1005,40 +955,10 @@ describe('INTEGRATED: Full chain with sendMessageBin-only mock', () => { await settleWalletEffects(); }); - test('wallet.sendCommitted triggers refresh without frontend-owned balance mutation', async () => { - render(); - await settleWalletInit(); - await waitFor(() => expect(screen.getByTestId('i-balance-era').textContent).toBe('10000')); - - const initialTxCount = parseInt(screen.getByTestId('i-tx-count').textContent || '0'); - capturedMethods = []; - - // Emit sendCommitted — simulates what happens after online send completes - act(() => { - bridgeEvents.emit('wallet.sendCommitted', { - success: true, - tokenId: 'ERA', - newBalance: 9500n, - transactionHash: makeTxHash(0x01), - toDeviceId: makeDeviceId(0x02), - amount: 500n, - }); - }); - - await waitFor(() => { - expect(capturedMethods).toContain('getAllBalancesStrict'); - expect(capturedMethods).toContain('nativeBoundaryIngress'); - }); - - expect(screen.getByTestId('i-balance-era').textContent).toBe('10000'); - expect(parseInt(screen.getByTestId('i-tx-count').textContent || '0')).toBe(initialTxCount); - - await settleWalletEffects(); - }); }); // ═══════════════════════════════════════════════════════════════════════════════ -// 7. INTEGRATED: Full Bilateral Sequence — EXACT Device Event Flow +// 6. INTEGRATED: Full Bilateral Sequence — EXACT Device Event Flow // ═══════════════════════════════════════════════════════════════════════════════ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { @@ -1201,7 +1121,7 @@ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { }); // ═══════════════════════════════════════════════════════════════════════════════ -// 8. Event Ordering & Determinism +// 7. Event Ordering & Determinism // ═══════════════════════════════════════════════════════════════════════════════ describe('Event ordering & determinism', () => { @@ -1257,7 +1177,7 @@ describe('Event ordering & determinism', () => { }); // ═══════════════════════════════════════════════════════════════════════════════ -// 9. Error Resilience +// 8. Error Resilience // ═══════════════════════════════════════════════════════════════════════════════ describe('Error resilience', () => { diff --git a/dsm_client/frontend/src/tests/helpers/bilateralEventFixture.ts b/dsm_client/frontend/src/tests/helpers/bilateralEventFixture.ts new file mode 100644 index 000000000..14c3f94ff --- /dev/null +++ b/dsm_client/frontend/src/tests/helpers/bilateralEventFixture.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// A `BilateralEventNotification` as Rust would post it, for tests that drive +// the event bridge. Production only decodes these (bilateralEventService), so +// the encoder lives with the tests that need it, not in the app. + +import * as pb from '../../proto/dsm_app_pb'; +import type { BilateralEventTypeValue } from '../../services/bilateral/bilateralEventService'; + +export function encodeBilateralEventNotification(input: { + eventType: BilateralEventTypeValue; + status?: string; + message?: string; + amount?: bigint; + tokenId?: string; + counterpartyDeviceId?: Uint8Array; + commitmentHash?: Uint8Array; + transactionHash?: Uint8Array; + senderBleAddress?: string; +}): Uint8Array { + const note = new pb.BilateralEventNotification({ + eventType: input.eventType as pb.BilateralEventType, + status: input.status || '', + message: input.message || '', + amount: input.amount, + tokenId: input.tokenId, + counterpartyDeviceId: input.counterpartyDeviceId && new Uint8Array(input.counterpartyDeviceId), + commitmentHash: input.commitmentHash && new Uint8Array(input.commitmentHash), + transactionHash: input.transactionHash && new Uint8Array(input.transactionHash), + senderBleAddress: input.senderBleAddress, + }); + return note.toBinary(); +} diff --git a/dsm_client/frontend/src/utils/__tests__/base32.property.test.ts b/dsm_client/frontend/src/utils/__tests__/base32.property.test.ts index fffab086e..299eb9ef2 100644 --- a/dsm_client/frontend/src/utils/__tests__/base32.property.test.ts +++ b/dsm_client/frontend/src/utils/__tests__/base32.property.test.ts @@ -4,12 +4,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { decodeBase32Crockford, - decodeBase32Crockford32, encodeBase32Crockford, encodeBase32Crockford32, normalizeBase32Crockford, } from "../textId"; +/** The 32-byte decode the tests below assert: the canonical decoder plus the length. */ +function decodeBase32Crockford32(s: string): Uint8Array { + const bytes = decodeBase32Crockford(s); + expect(bytes.length).toBe(32); + return bytes; +} + function randomBytes(n: number): Uint8Array { const u = new Uint8Array(n); for (let i = 0; i < n; i++) u[i] = Math.floor(Math.random() * 256); diff --git a/dsm_client/frontend/src/utils/__tests__/base32Crockford.centralization.test.ts b/dsm_client/frontend/src/utils/__tests__/base32Crockford.centralization.test.ts index a93a09a95..879dacf70 100644 --- a/dsm_client/frontend/src/utils/__tests__/base32Crockford.centralization.test.ts +++ b/dsm_client/frontend/src/utils/__tests__/base32Crockford.centralization.test.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import { decodeBase32Crockford, - decodeBase32Crockford32, encodeBase32Crockford, encodeBase32Crockford32, normalizeBase32Crockford, @@ -45,12 +44,8 @@ describe('base32Crockford centralized module', () => { .replace(/[IL]/g, '1') ); - const back = decodeBase32Crockford32(messy); + const back = decodeBase32Crockford(messy); + expect(back.length).toBe(32); expect(Array.from(back)).toEqual(Array.from(bytes32)); }); - - test('decode32 throws if not exactly 32 bytes', () => { - expect(() => decodeBase32Crockford32(encodeBase32Crockford(u8(31, 1)))).toThrow(); - expect(() => decodeBase32Crockford32(encodeBase32Crockford(u8(33, 1)))).toThrow(); - }); }); diff --git a/dsm_client/frontend/src/utils/__tests__/binary.test.ts b/dsm_client/frontend/src/utils/__tests__/binary.test.ts deleted file mode 100644 index 7a9583f97..000000000 --- a/dsm_client/frontend/src/utils/__tests__/binary.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { binStringToUint8Array, toUint8Array, uint8ArrayToBinString } from '../binary'; - -describe('binary helpers', () => { - it('round-trips Uint8Array <-> bin string', () => { - const bytes = new Uint8Array([0, 1, 2, 255, 128, 64]); - const s = uint8ArrayToBinString(bytes); - const out = binStringToUint8Array(s); - expect(out).toEqual(bytes); - }); - - it('preserves view offsets when converting ArrayBufferView', () => { - const backing = new Uint8Array([10, 11, 12, 13, 14, 15, 16, 17]); - const view = new Uint8Array(backing.buffer, 2, 3); // [12, 13, 14] - const out = toUint8Array(view); - expect(out.length).toBe(3); - expect(Array.from(out)).toEqual([12, 13, 14]); - - const s = uint8ArrayToBinString(out); - const back = binStringToUint8Array(s); - expect(Array.from(back)).toEqual([12, 13, 14]); - }); - - it('converts ArrayBuffer to Uint8Array safely', () => { - const buf = new Uint8Array([7, 8, 9]).buffer; - const out = toUint8Array(buf); - expect(out).toEqual(new Uint8Array([7, 8, 9])); - }); -}); diff --git a/dsm_client/frontend/src/utils/__tests__/identity.test.ts b/dsm_client/frontend/src/utils/__tests__/identity.test.ts index 0cf79d0e6..9c04e3ac9 100644 --- a/dsm_client/frontend/src/utils/__tests__/identity.test.ts +++ b/dsm_client/frontend/src/utils/__tests__/identity.test.ts @@ -1,48 +1,44 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // SPDX-License-Identifier: Apache-2.0 -// eslint-env jest -declare const describe: any; -declare const test: any; -declare const expect: any; +// The native session reaches the store the way Rust's does: as a +// `session.state` event on the bus. The store used to carry setters that +// existed only for tests. -import { hasIdentity, checkIdentityState } from '../identity'; -import { - DEFAULT_NATIVE_SESSION, - type NativeSessionSnapshot, -} from '../../runtime/nativeSessionTypes'; -import { - resetNativeSessionStoreForTest, - setNativeSessionSnapshotForTest, -} from '../../runtime/nativeSessionStore'; +import { DEFAULT_NATIVE_SESSION, type NativeSessionSnapshot } from '../../runtime/nativeSessionTypes'; -function publishSession(overrides: Partial): void { - setNativeSessionSnapshotForTest({ - ...DEFAULT_NATIVE_SESSION, - received: true, - ...overrides, - }); -} +type IdentityModule = typeof import('../identity'); +type BusModule = typeof import('../../bridge/bridgeEvents'); describe('identity', () => { + let identity: IdentityModule; + let bus: BusModule; let warnSpy: jest.SpyInstance; + // A fresh store per test: the session is module state, and "not received" + // is the state before any event, which nothing can bring back. beforeEach(() => { warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - resetNativeSessionStoreForTest(); + jest.isolateModules(() => { + bus = require('../../bridge/bridgeEvents'); + identity = require('../identity'); + }); }); afterEach(() => { warnSpy.mockRestore(); }); + function publishSession(overrides: Partial): void { + bus.bridgeEvents.emit('session.state', { ...DEFAULT_NATIVE_SESSION, ...overrides } as any); + } + test('hasIdentity returns true when native session reports ready identity', async () => { publishSession({ phase: 'wallet_ready', identity_status: 'ready', env_config_status: 'ready', }); - const ok = await hasIdentity(); - expect(ok).toBe(true); + expect(await identity.hasIdentity()).toBe(true); }); test('hasIdentity returns false when native session reports missing identity', async () => { @@ -51,13 +47,11 @@ describe('identity', () => { identity_status: 'missing', env_config_status: 'ready', }); - const ok = await hasIdentity(); - expect(ok).toBe(false); + expect(await identity.hasIdentity()).toBe(false); }); test('checkIdentityState returns RUNTIME_NOT_READY before native session arrives', async () => { - const state = await checkIdentityState(); - expect(state).toBe('RUNTIME_NOT_READY'); + expect(await identity.checkIdentityState()).toBe('RUNTIME_NOT_READY'); }); test('checkIdentityState returns READY when native session reports ready identity', async () => { @@ -66,8 +60,7 @@ describe('identity', () => { identity_status: 'ready', env_config_status: 'ready', }); - const state = await checkIdentityState(); - expect(state).toBe('READY'); + expect(await identity.checkIdentityState()).toBe('READY'); }); test('checkIdentityState returns NO_IDENTITY when native session reports missing identity', async () => { @@ -76,8 +69,7 @@ describe('identity', () => { identity_status: 'missing', env_config_status: 'ready', }); - const state = await checkIdentityState(); - expect(state).toBe('NO_IDENTITY'); + expect(await identity.checkIdentityState()).toBe('NO_IDENTITY'); }); test('checkIdentityState returns RUNTIME_NOT_READY while env config is still loading', async () => { @@ -86,7 +78,6 @@ describe('identity', () => { identity_status: 'runtime_not_ready', env_config_status: 'loading', }); - const state = await checkIdentityState(); - expect(state).toBe('RUNTIME_NOT_READY'); + expect(await identity.checkIdentityState()).toBe('RUNTIME_NOT_READY'); }); }); diff --git a/dsm_client/frontend/src/utils/__tests__/textId.test.ts b/dsm_client/frontend/src/utils/__tests__/textId.test.ts index 5da884720..bc315e9b7 100644 --- a/dsm_client/frontend/src/utils/__tests__/textId.test.ts +++ b/dsm_client/frontend/src/utils/__tests__/textId.test.ts @@ -11,7 +11,6 @@ import { encodeBase32Crockford, decodeBase32Crockford, encodeBase32Crockford32, - decodeBase32Crockford32, bytesToBase32CrockfordPrefix, } from '../textId'; @@ -139,17 +138,11 @@ describe('encodeBase32Crockford32', () => { }); }); -describe('decodeBase32Crockford32', () => { +describe('decodeBase32Crockford', () => { test('roundtrips with encodeBase32Crockford32', () => { const original = new Uint8Array(32).map((_, i) => (i * 7) & 0xff); const encoded = encodeBase32Crockford32(original); - const decoded = decodeBase32Crockford32(encoded); - expect(decoded).toEqual(original); - }); - - test('throws if decoded result is not 32 bytes', () => { - const short = encodeBase32Crockford(new Uint8Array([1, 2, 3])); - expect(() => decodeBase32Crockford32(short)).toThrow('exactly 32 bytes'); + expect(decodeBase32Crockford(encoded)).toEqual(original); }); }); diff --git a/dsm_client/frontend/src/utils/binary.ts b/dsm_client/frontend/src/utils/binary.ts deleted file mode 100644 index d15e1d9d7..000000000 --- a/dsm_client/frontend/src/utils/binary.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// SPDX-License-Identifier: Apache-2.0 -// Binary helpers for strict bytes-only bridge flows. - -export function uint8ArrayToBinString(bytes: Uint8Array): string { - let s = ''; - for (let i = 0; i < bytes.length; i++) { - s += String.fromCharCode(bytes[i] & 0xff); - } - return s; -} - -export function binStringToUint8Array(s: string): Uint8Array { - const out = new Uint8Array(s.length); - for (let i = 0; i < s.length; i++) { - out[i] = s.charCodeAt(i) & 0xff; - } - return out; -} - -export function toUint8Array(input: ArrayBuffer | ArrayBufferView | Uint8Array): Uint8Array { - if (input instanceof Uint8Array) return input; - if (input instanceof ArrayBuffer) return new Uint8Array(input); - const view = input as ArrayBufferView; - return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); -} diff --git a/dsm_client/frontend/src/utils/textId.ts b/dsm_client/frontend/src/utils/textId.ts index 0b101e52f..d42991716 100644 --- a/dsm_client/frontend/src/utils/textId.ts +++ b/dsm_client/frontend/src/utils/textId.ts @@ -89,13 +89,6 @@ export function encodeBase32Crockford32(bytes32: Uint8Array): string { return encodeBase32Crockford(bytes32); } -export function decodeBase32Crockford32(s: string): Uint8Array { - const bytes = decodeBase32Crockford(s); - if (bytes.length !== 32) { - throw new Error('Base32 Crockford string must decode to exactly 32 bytes'); - } - return bytes; -} /** * Encode the first N bytes as Base32 Crockford for debug/log prefixes. diff --git a/dsm_client/frontend/src/vectors/externalCommitV2.ts b/dsm_client/frontend/src/vectors/externalCommitV2.ts deleted file mode 100644 index 77970944d..000000000 --- a/dsm_client/frontend/src/vectors/externalCommitV2.ts +++ /dev/null @@ -1,270 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -/* eslint-disable @typescript-eslint/no-explicit-any */ -// ExternalCommit v2 runtime vector harness (protobuf/transport only) - -import * as pb from '../proto/dsm_app_pb'; - -type VectorResult = { - name: string; - passed: boolean; - message?: string; -}; - -type VectorRunReport = { - passed: number; - failed: number; - results: VectorResult[]; -}; - -const sidA = new Uint8Array(32).fill(0xa5); -const sidB = new Uint8Array(32).fill(0x11); -const commitA = new Uint8Array(32).fill(0x42); - -const payloadSmall = () => new Uint8Array([1, 2, 3, 4, 5, 250, 251, 252]); -const payloadEmpty = () => new Uint8Array([]); -const payloadLarge = () => { - const v = new Uint8Array(4096); - for (let i = 0; i < v.length; i += 1) { - v[i] = (i * 73 + 41) & 0xff; - } - return v; -}; - -const evidencePreimage = () => - new pb.Evidence({ - kind: { - case: 'preimage', - value: new pb.EvidencePreimage({ preimage: payloadSmall() as any }), - }, - }); - -const flipOneBit = (u8: Uint8Array) => { - const v = new Uint8Array(u8); - if (v.length === 0) { - return new Uint8Array([1]); - } - v[0] ^= 0x01; - return v; -}; - -const wrapExternalCommit = (ec: pb.ExternalCommit) => { - const op = new pb.UniversalOp({ - opId: new pb.Hash32({ v: new Uint8Array(32) as any }), - actor: new Uint8Array(32) as any, - kind: { case: 'externalCommit', value: ec } as any, - }); - const tx = new pb.UniversalTx({ ops: [op], atomic: true }); - return new pb.Envelope({ - version: 3, - headers: new pb.Headers({ - deviceId: new Uint8Array(32) as any, - genesisHash: new Uint8Array(32) as any, - }), - messageId: new Uint8Array(16) as any, - payload: { case: 'universalTx', value: tx } as any, - }); -}; - -const eqBytes = (a?: Uint8Array, b?: Uint8Array) => { - if (!a || !b) return false; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) return false; - } - return true; -}; - -function runCase(name: string, fn: () => void): VectorResult { - try { - fn(); - return { name, passed: true }; - } catch (e: any) { - return { name, passed: false, message: e?.message ?? String(e) }; - } -} - -export async function runExternalCommitV2Vectors(): Promise { - const results: VectorResult[] = []; - - results.push(runCase('no removed source field', () => { - const ec = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: sidA as any }), - payload: payloadSmall() as any, - evidence: evidencePreimage(), - commitId: new pb.Hash32({ v: commitA as any }), - }); - if (Object.prototype.hasOwnProperty.call(ec as any, 'source')) { - throw new Error('removed source field present'); - } - if ((ec as any).source !== undefined) { - throw new Error('removed source field not undefined'); - } - })); - - results.push(runCase('removed source bytes ignored', () => { - const removedFieldBytes = new Uint8Array([0x22, 0x06, 0x6c, 0x65, 0x67, 0x61, 0x63, 0x79]); - const decoded = pb.ExternalCommit.fromBinary(removedFieldBytes); - if (decoded.sourceId !== undefined) { - throw new Error('sourceId populated from removed-field bytes'); - } - if (decoded.payload.length !== 0) { - throw new Error('payload should be empty'); - } - if (decoded.commitId !== undefined) { - throw new Error('commitId populated from removed-field bytes'); - } - })); - - results.push(runCase('binary roundtrip preserves fields', () => { - const ec = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: sidA as any }), - payload: payloadSmall() as any, - evidence: evidencePreimage(), - commitId: new pb.Hash32({ v: commitA as any }), - }); - const bin = ec.toBinary(); - const decoded = pb.ExternalCommit.fromBinary(bin); - const bin2 = decoded.toBinary(); - if (!eqBytes(bin, bin2)) { - throw new Error('binary roundtrip mismatch'); - } - if (!eqBytes(decoded.sourceId?.v as any, sidA)) { - throw new Error('sourceId mismatch'); - } - if (!eqBytes(decoded.payload as any, payloadSmall())) { - throw new Error('payload mismatch'); - } - if (!eqBytes(decoded.commitId?.v as any, commitA)) { - throw new Error('commitId mismatch'); - } - })); - - results.push(runCase('bit flip in source_id changes bytes', () => { - const ec = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: sidA as any }), - payload: payloadEmpty() as any, - evidence: undefined, - commitId: new pb.Hash32({ v: commitA as any }), - }); - const ec2 = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: flipOneBit(sidA) as any }), - payload: payloadEmpty() as any, - evidence: undefined, - commitId: new pb.Hash32({ v: commitA as any }), - }); - if (eqBytes(ec.toBinary(), ec2.toBinary())) { - throw new Error('sourceId flip did not change bytes'); - } - })); - - results.push(runCase('bit flip in payload changes bytes', () => { - const ec = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: sidA as any }), - payload: payloadSmall() as any, - evidence: undefined, - commitId: new pb.Hash32({ v: commitA as any }), - }); - const ec2 = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: sidA as any }), - payload: flipOneBit(payloadSmall()) as any, - evidence: undefined, - commitId: new pb.Hash32({ v: commitA as any }), - }); - if (eqBytes(ec.toBinary(), ec2.toBinary())) { - throw new Error('payload flip did not change bytes'); - } - })); - - results.push(runCase('bit flip in evidence changes bytes', () => { - const ev = evidencePreimage(); - const ev2 = new pb.Evidence({ - kind: { - case: 'preimage', - value: new pb.EvidencePreimage({ preimage: flipOneBit(payloadSmall()) as any }), - }, - }); - - const ec = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: sidA as any }), - payload: payloadSmall() as any, - evidence: ev, - commitId: new pb.Hash32({ v: commitA as any }), - }); - const ec2 = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: sidA as any }), - payload: payloadSmall() as any, - evidence: ev2, - commitId: new pb.Hash32({ v: commitA as any }), - }); - if (eqBytes(ec.toBinary(), ec2.toBinary())) { - throw new Error('evidence flip did not change bytes'); - } - })); - - results.push(runCase('empty payload deterministic', () => { - const ec = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: sidB as any }), - payload: payloadEmpty() as any, - evidence: undefined, - commitId: new pb.Hash32({ v: commitA as any }), - }); - const bin = ec.toBinary(); - const decoded = pb.ExternalCommit.fromBinary(bin); - if (!eqBytes(decoded.payload as any, payloadEmpty())) { - throw new Error('empty payload mismatch'); - } - })); - - results.push(runCase('large payload deterministic', () => { - const ec = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: sidA as any }), - payload: payloadLarge() as any, - evidence: evidencePreimage(), - commitId: new pb.Hash32({ v: commitA as any }), - }); - const bin = ec.toBinary(); - const decoded = pb.ExternalCommit.fromBinary(bin); - if (!eqBytes(decoded.payload as any, payloadLarge())) { - throw new Error('large payload mismatch'); - } - })); - - results.push(runCase('Envelope v3 framing roundtrip', () => { - const ec = new pb.ExternalCommit({ - sourceId: new pb.Hash32({ v: sidA as any }), - payload: payloadSmall() as any, - evidence: evidencePreimage(), - commitId: new pb.Hash32({ v: commitA as any }), - }); - - const env = wrapExternalCommit(ec); - const envBytes = env.toBinary(); - const framed = new Uint8Array(envBytes.length + 1); - framed[0] = 0x03; - framed.set(envBytes, 1); - - if (framed[0] !== 0x03) { - throw new Error('frame prefix mismatch'); - } - - const decoded = pb.Envelope.fromBinary(framed.slice(1)); - const re = decoded.toBinary(); - const reframed = new Uint8Array(re.length + 1); - reframed[0] = 0x03; - reframed.set(re, 1); - - if (!eqBytes(framed, reframed)) { - throw new Error('framed roundtrip mismatch'); - } - })); - - let passed = 0; - let failed = 0; - for (const r of results) { - if (r.passed) passed += 1; - else failed += 1; - } - - return { passed, failed, results }; -} diff --git a/dsm_client/frontend/src/vectors/index.ts b/dsm_client/frontend/src/vectors/index.ts deleted file mode 100644 index 45ac2a3d0..000000000 --- a/dsm_client/frontend/src/vectors/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -import { runExternalCommitV2Vectors } from "./externalCommitV2"; -import { runUiVectors } from "./uiVectors"; - -export async function runExternalCommitV2VectorsAndLog(): Promise { - const report = await runExternalCommitV2Vectors(); - - for (const r of report.results) { - if (r.passed) { - console.log(`PASS external-commit-v2 ${r.name}`); - } else { - console.log(`FAIL external-commit-v2 ${r.name} ${r.message ?? ""}`.trim()); - } - } - - if (report.failed !== 0) { - throw new Error( - `external-commit-v2 vectors failed: ${report.passed} passed, ${report.failed} failed`, - ); - } - - console.log(`OK external-commit-v2 ${report.passed} passed, ${report.failed} failed`); -} - -export async function runUiVectorsAndLog(): Promise { - const report = await runUiVectors(); - - for (const r of report.results) { - if (r.skipped) { - console.log(`SKIP ui-vector ${r.name} ${r.message ?? ""}`.trim()); - } else if (r.passed) { - console.log(`PASS ui-vector ${r.name}`); - } else { - console.log(`FAIL ui-vector ${r.name} ${r.message ?? ""}`.trim()); - } - } - - if (report.failed !== 0) { - throw new Error(`ui vectors failed: ${report.passed} passed, ${report.failed} failed`); - } - - console.log(`OK ui-vectors ${report.passed} passed, ${report.failed} failed`); -} diff --git a/dsm_client/frontend/src/vectors/uiVectors.ts b/dsm_client/frontend/src/vectors/uiVectors.ts deleted file mode 100644 index e574f9da8..000000000 --- a/dsm_client/frontend/src/vectors/uiVectors.ts +++ /dev/null @@ -1,331 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -/* eslint-disable @typescript-eslint/no-explicit-any */ -// UI-level runtime vectors (bridge processing where possible). - -import * as pb from '../proto/dsm_app_pb'; -import { routerInvokeBin, routerQueryBin } from '../dsm/WebViewBridge'; -import { decodeFramedEnvelopeV3 } from '../dsm/decoding'; -import { decodeBase32Crockford } from '../utils/textId'; - -export type UiVectorResult = { - name: string; - passed: boolean; - skipped?: boolean; - message?: string; -}; - -export type UiVectorRunReport = { - passed: number; - failed: number; - results: UiVectorResult[]; -}; - -function runCase(name: string, fn: () => Promise | void): Promise { - return Promise.resolve() - .then(() => fn()) - .then(() => ({ name, passed: true })) - .catch((e: any) => ({ name, passed: false, message: e?.message ?? String(e) })); -} - -function skipCase(name: string, message: string): UiVectorResult { - return { name, passed: true, skipped: true, message }; -} - -async function invokeAndDecode(method: string, body: Uint8Array): Promise { - const argPack = new pb.ArgPack({ - codec: pb.Codec.PROTO as any, - body: body as any, - }); - const resBytes = await routerInvokeBin(method, argPack.toBinary()); - if (!resBytes || resBytes.length === 0) { - throw new Error(`${method}: empty response`); - } - return decodeFramedEnvelopeV3(resBytes); -} - -async function queryAndDecode(path: string): Promise { - const resBytes = await routerQueryBin(path); - if (!resBytes || resBytes.length === 0) { - throw new Error(`${path}: empty response`); - } - return decodeFramedEnvelopeV3(resBytes); -} - -function isErrorPayload(env: pb.Envelope): boolean { - return env.payload.case === 'error'; -} - -/** A decimal string as a big-endian u128 (16 bytes). */ -function u128BeFromDecimal(value: string): Uint8Array { - let n = BigInt(value.trim()); - if (n < 0n) throw new Error('u128 must be non-negative'); - const out = new Uint8Array(new ArrayBuffer(16)); - for (let i = 15; i >= 0; i -= 1) { - out[i] = Number(n & 0xffn); - n >>= 8n; - } - if (n !== 0n) throw new Error('value exceeds u128'); - return out; -} - -export async function runUiVectors(): Promise { - const results: UiVectorResult[] = []; - const cfg: any = (globalThis as any).__DSM_UI_VECTOR_CONFIG__ ?? {}; - - results.push( - await runCase('token.create rejects invalid policy/limits', async () => { - const req = new pb.TokenCreateRequest({ - ticker: 'a', - alias: 'Bad Token', - decimals: 255, - genesisSupplyU128: new Uint8Array(16), - }); - - const env = await invokeAndDecode('token.create', req.toBinary()); - - if (isErrorPayload(env)) { - return; - } - - if (env.payload.case !== 'tokenCreateResponse') { - throw new Error(`token.create: unexpected payload ${env.payload.case}`); - } - - const resp = env.payload.value as pb.TokenCreateResponse; - if (resp.success) { - throw new Error('token.create unexpectedly succeeded for invalid request'); - } - }), - ); - - results.push( - await runCase('wallet.send rejects invalid online transfer', async () => { - const req = new pb.OnlineTransferRequest({ - tokenId: 'DSM', - toDeviceId: new Uint8Array(0), - amount: 1n, - memo: 'vector', - nonce: new Uint8Array(0), - signature: new Uint8Array(0), - fromDeviceId: new Uint8Array(0), - chainTip: new Uint8Array(0), - seq: 1n, - } as any); - - const env = await invokeAndDecode('wallet.send', req.toBinary()); - - if (isErrorPayload(env)) { - return; - } - - if (env.payload.case !== 'onlineTransferResponse') { - throw new Error(`wallet.send: unexpected payload ${env.payload.case}`); - } - - const resp = env.payload.value as pb.OnlineTransferResponse; - if (resp.success) { - throw new Error('wallet.send unexpectedly succeeded for invalid request'); - } - }), - ); - - results.push( - await runCase('faucet.claim rejects missing device id', async () => { - const req = new pb.FaucetClaimRequest({ - deviceId: new Uint8Array(0), - }); - - const env = await invokeAndDecode('faucet.claim', req.toBinary()); - - if (isErrorPayload(env)) { - return; - } - - if (env.payload.case !== 'faucetClaimResponse') { - throw new Error(`faucet.claim: unexpected payload ${env.payload.case}`); - } - - const resp = env.payload.value as pb.FaucetClaimResponse; - if (resp.success) { - throw new Error('faucet.claim unexpectedly succeeded for invalid request'); - } - }), - ); - - if (!cfg?.tokenCreate) { - results.push(skipCase('token.create happy path', 'missing __DSM_UI_VECTOR_CONFIG__.tokenCreate')); - } else { - results.push( - await runCase('token.create happy path', async () => { - const tc = cfg.tokenCreate; - const genesisSupply = u128BeFromDecimal(String(tc.genesisSupply ?? '1')); - - const req = new pb.TokenCreateRequest({ - ticker: String(tc.ticker || 'TEST'), - alias: String(tc.alias || 'Test Token'), - decimals: Number(tc.decimals ?? 0), - genesisSupplyU128: genesisSupply, - threshold: 1, - }); - - const env = await invokeAndDecode('token.create', req.toBinary()); - - if (isErrorPayload(env)) { - throw new Error('token.create returned error envelope'); - } - - if (env.payload.case !== 'tokenCreateResponse') { - throw new Error(`token.create: unexpected payload ${env.payload.case}`); - } - - const resp = env.payload.value as pb.TokenCreateResponse; - if (!resp.success) { - throw new Error('token.create happy path did not succeed'); - } - }), - ); - } - - if (!cfg?.faucet?.deviceIdB32) { - results.push(skipCase('faucet.claim happy path', 'missing __DSM_UI_VECTOR_CONFIG__.faucet.deviceIdB32')); - } else { - results.push( - await runCase('faucet.claim happy path', async () => { - const deviceBytes = new Uint8Array(decodeBase32Crockford(String(cfg.faucet.deviceIdB32))); - if (deviceBytes.length !== 32) { - throw new Error('faucet.claim happy path deviceIdB32 must be 32 bytes'); - } - const req = new pb.FaucetClaimRequest({ - deviceId: deviceBytes, - }); - - const env = await invokeAndDecode('faucet.claim', req.toBinary()); - - if (isErrorPayload(env)) { - throw new Error('faucet.claim returned error envelope'); - } - - if (env.payload.case !== 'faucetClaimResponse') { - throw new Error(`faucet.claim: unexpected payload ${env.payload.case}`); - } - - const resp = env.payload.value as pb.FaucetClaimResponse; - if (!resp.success) { - throw new Error('faucet.claim happy path did not succeed'); - } - }), - ); - } - - // --- token.create + balance verification (config-gated) --- - if (!cfg?.tokenCreateVerify) { - results.push(skipCase('token.create + balance verify', 'missing __DSM_UI_VECTOR_CONFIG__.tokenCreateVerify')); - } else { - results.push( - await runCase('token.create + balance verify', async () => { - const tc = cfg.tokenCreateVerify; - const genesisSupply = u128BeFromDecimal(String(tc.genesisSupply ?? '1')); - - const req = new pb.TokenCreateRequest({ - ticker: String(tc.ticker || 'UIVEC'), - alias: String(tc.alias || 'UI Vector Token'), - decimals: Number(tc.decimals ?? 0), - genesisSupplyU128: genesisSupply, - threshold: 1, - }); - - // Step 1: Create token - const createEnv = await invokeAndDecode('token.create', req.toBinary()); - if (isErrorPayload(createEnv)) { - throw new Error('token.create returned error envelope'); - } - if (createEnv.payload.case !== 'tokenCreateResponse') { - throw new Error(`token.create: unexpected payload ${createEnv.payload.case}`); - } - const createResp = createEnv.payload.value as pb.TokenCreateResponse; - if (!createResp.success) { - throw new Error(`token.create failed: ${createResp.message}`); - } - if (!createResp.tokenId) { - throw new Error('token.create returned empty tokenId'); - } - - // Step 2: Query balance.list and verify new token appears - const balEnv = await queryAndDecode('balance.list'); - if (isErrorPayload(balEnv)) { - throw new Error('balance.list returned error envelope'); - } - if (balEnv.payload.case !== 'balancesListResponse') { - throw new Error(`balance.list: unexpected payload ${balEnv.payload.case}`); - } - const balResp = balEnv.payload.value as pb.BalancesListResponse; - // Token may or may not appear if available=0, so we just log - const found = balResp.balances.find((b: any) => b.tokenId === createResp.tokenId); - if (found) { - console.log(`[uiVector] Token ${createResp.tokenId} found in balance.list`); - } else { - console.log(`[uiVector] Token ${createResp.tokenId} not in balance.list (metadata-only, expected)`); - } - }), - ); - } - - // --- wallet.send rejects insufficient custom token balance (config-gated) --- - if (!cfg?.insufficientTokenTransfer) { - results.push(skipCase('wallet.send rejects insufficient custom token', 'missing __DSM_UI_VECTOR_CONFIG__.insufficientTokenTransfer')); - } else { - results.push( - await runCase('wallet.send rejects insufficient custom token', async () => { - const tc = cfg.insufficientTokenTransfer; - if (!tc?.tokenId || !tc?.toDeviceIdB32 || !tc?.fromDeviceIdB32) { - throw new Error('insufficientTokenTransfer missing required fields'); - } - - const toDeviceId = new Uint8Array(decodeBase32Crockford(String(tc.toDeviceIdB32))); - const fromDeviceId = new Uint8Array(decodeBase32Crockford(String(tc.fromDeviceIdB32))); - - if (toDeviceId.length !== 32 || fromDeviceId.length !== 32) { - throw new Error('insufficientTokenTransfer device IDs must be 32 bytes'); - } - - const req = new pb.OnlineTransferRequest({ - tokenId: String(tc.tokenId), - toDeviceId, - amount: 1n, - memo: 'ui vector: insufficient balance', - nonce: new Uint8Array(12), - signature: new Uint8Array(0), - fromDeviceId, - chainTip: new Uint8Array(32), - seq: 1n, - } as any); - - const env = await invokeAndDecode('wallet.send', req.toBinary()); - - if (isErrorPayload(env)) { - return; // Error envelope = rejection, pass - } - - if (env.payload.case !== 'onlineTransferResponse') { - throw new Error(`wallet.send: unexpected payload ${env.payload.case}`); - } - - const resp = env.payload.value as pb.OnlineTransferResponse; - if (resp.success) { - throw new Error('wallet.send should reject transfer of custom token with 0 balance'); - } - }), - ); - } - - let passed = 0; - let failed = 0; - for (const r of results) { - if (r.skipped) continue; - if (r.passed) passed += 1; - else failed += 1; - } - - return { passed, failed, results }; -} diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto index 675dbbcb3..ae61e3858 100644 --- a/proto/dsm_app.proto +++ b/proto/dsm_app.proto @@ -3342,6 +3342,31 @@ message BalanceGetResponse { // through the coin. Empty for protocol assets and for policies that name no // icon. Rust never interprets it and nothing resolves a token by it. string icon_url = 11; + // Whether this is an asset the protocol defines (ERA, dBTC) rather than a + // token a device created and this device registered. Decided by Rust from + // the builtin policy commit the ticker resolves to. A screen that keyed it + // on the ticker text it displayed would call a created token "ERA" a + // protocol asset, and carried its own copy of what a protocol asset is. + bool protocol_defined = 12; + // The whole supply that will ever exist, in display units rendered by Rust + // with the token's decimals. A created token's policy fixes it at creation + // (SoFi §51); ERA's is the native reserve's genesis supply, from which every + // unit in circulation was released. Empty when Rust holds no supply for the + // token. + string genesis_supply_display = 13; + // What the token's committed policy permits, read from bytes verified + // against the anchor. Absent when Rust holds no committed policy for the + // token: "not stated" and "not permitted" are different facts, and a + // defaulted bool cannot tell them apart. + TokenPolicyPermissions permissions = 14; +} +// The permission flags of a committed token policy, as Core's one parser read +// them (SoFi §47). +message TokenPolicyPermissions { + // Holders may burn (§54). + bool burn_enabled = 1; + // The token may move between holders (§49). + bool transferable = 2; } // Multi-token enumeration (ordered lexicographically by token_id on server side) message BalancesListResponse { repeated BalanceGetResponse balances = 1; } diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 7eb852c5a..fb28bfe92 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1020,6 +1020,16 @@ Owner request: integrate the frontend with the storage nodes properly, working f | `ci` · bridge_rpc_names.py (new); `.github/workflows/ci.yml`; Kotlin · SinglePathWebViewBridge.kt, Unified.kt, UnifiedNativeApi.kt; `dsm_sdk` · jni/unified_protobuf_bridge.rs | Nothing checked that the bridge's two sides agreed on the RPC method names a `BridgeRpcRequest` carries: the frontend sent names Kotlin never handled (`hasIdentityDirect`, for months, answered by the unknown-method arm) and Kotlin kept eight arms nothing sent — `processEnvelopeV3`, `getWalletHistoryStrict`, `getSigningPublicKeyBin`, `getPersistedGenesisEnvelope`, `getGenesisHashBin`, `getDeviceIdBin`, `startNativeQrScanner`, `hasNativeQrScanner` — two of them the only callers of their JNI exports. | `ci/bridge_rpc_names.py`: the names the frontend's production sources and `index.html` send (string literals, and a constant resolved in its file; unresolved fails) must equal the string arms of Kotlin's `handleBinaryRpcInternal`, both ways, no allowlist; it runs in the purity step, the frontend job and the Android unit-test job, so any side's change selects it. The eight dead arms are deleted, with the `getWalletHistoryStrict` and `getSigningPublicKeyBin` Kotlin functions, externals and Rust JNI exports that only they reached, and the prefs reader only one of them used. Negative controls: a phantom frontend name, a dead Kotlin arm and an unresolvable constant each fail the gate naming the offender. | | frontend · dsm/WebViewBridge/transportCore.ts, dsm/NativeBoundaryBridge.ts, dsm/NativeHostBridge.ts, dsm/BridgeGate.ts, services/headerService.ts, dsm/bridgeTypes.ts, globals.d.ts; setupTests.ts and 20 test files; `ci` · bridge_rpc_names.py | Every transport path branched on `window.DsmBridge.__callBin`, a function only the jest stub installed: `sendBridgeRequestBytes`, `callBoundaryMethod` and `callHostMethod` each tried it first and fell back to `sendMessageBin`, the port transport `index.html` installs, so the transport the tests exercised was not the one the app runs. The production branch pre-waited for `dsm-bridge-ready` under a 2.5 s timer and then proceeded — redundant, since `sendMessageBin` waits for the port itself — and ran the answer through `maybeUnframe`, which guessed a 4-byte length prefix in either byte order and stripped it when the guess fit — a decoder with no producer: Kotlin never length-prefixes, and no BridgeRpcResponse (its first byte is a field tag) can begin with its own length, so the guess never fit a real answer and the code was dead. The bridge type declared the phantom, `isNativeHostUnavailableError` matched a test stub's error text, and `globals.d.ts` declared a `dsmBridge` global the bridge gate bans. | The transport is the bridge `index.html` installs and nothing else: `sendMessageBin` for RPCs (its BridgeRpcResponse unwrapped as posted), `startup` / `ingress` / `hostRequest` for the boundaries, `__binary` for readiness; the pre-wait, the unframing, the fallbacks, the stub-text match and the phantom declarations are deleted. `AndroidBridgeV3` is exactly the seven members index.html installs. The jest setup completes any test bridge that supplies `sendMessageBin` with the same wrapper composition index.html uses (reading `sendMessageBin` at call time), so the production transport runs unchanged in tests; the 20 test bridges now supply `sendMessageBin`. `ci/bridge_rpc_names.py` also holds the type's members equal to the installed keys and refuses a production source that names `__callBin`. | | `dsm_sdk` · sdk/faucet_claim_flow.rs, handlers/wallet_routes.rs (`wallet.history`); `proto` · `TransactionType`; frontend · domain/types.ts, domain/mappers.ts, components/screens/wallet/helpers.ts, TransactionItem.tsx | `faucet.claim` wrote no history row: an admitted claim credited 100 ERA and the wallet's history never listed it. `TX_TYPE_FAUCET` (1) had been reserved for that reason. `TransactionInfo.from_device_id` was required-32-bytes on every row, and the frontend refused any row without a sender — a shape with no room for a release whose source is the reserve, not a device. | After the admission is final, the claim writes its row: type `faucet`, 100 ERA to this device, no sender device, the release's operation digest as its hash (so a claim resumed on the same release upserts the same row), and the reserve id, generation, economic position and recipient genesis in its metadata; a row that fails to persist is logged and queued for projection repair, never reported as a failed claim. `TX_TYPE_FAUCET = 1` is deliberately taken back from reserved. `wallet.history` reports a faucet row with an empty sender and "ERA reserve (faucet)" as its counterparty label, and refuses a stored faucet row that names a sender as corrupt; the frontend maps it with no sender and no transport, labels it FAUCET / "ERA faucet claim", shows Rust's source label where a sender would be, and refuses a faucet row naming one. | +| frontend · components/screens/AccountsScreen.tsx (`CPTA_INFO`, `isProtocolToken`, the policy panel, BURN), dsm/types.ts (`TokenBalanceView`), dsm/wallet.ts (`getAllBalances`), tour/practiceMode.ts; `proto` · `BalanceGetResponse` 12–14, `TokenPolicyPermissions`; `dsm_sdk` · handlers/wallet_routes.rs (`enrich_balance_metadata`, `registered_policy`, `format_supply_for_display`) | The screen carried its own table of what ERA and dBTC are — a "DJTE emission token" type, an invented anchor formula, a "PROTOCOL-DEFINED" anchor id, a supply figure — beside the anchor Rust reports, and decided whether a token is protocol-defined (which withholds BURN and the adoption code) from its ticker text, so a created token whose ticker read ERA would have been treated as the protocol's; BURN was offered on every created token whether or not its policy permits burning; the coin artwork keyed on the ticker containing "btc". A created token's row carried the policy's icon but nothing else the policy fixes. | Rust reports the facts on each balance row: `protocol_defined`, decided from the builtin policy commit the ticker resolves to; `genesis_supply_display`, the supply the committed policy fixes at creation, rendered with the token's decimals (ERA's is the native reserve's genesis supply; empty where Rust holds none); `permissions` (`burn_enabled`, `transferable`), read from bytes verified against the anchor and absent — never defaulted — where Rust holds no committed policy (ERA's blob does not exist yet, §6.32). A registered token's row is checked against its own policy (ticker, alias, decimals, supply) and a disagreeing row is refused with nothing reported from either side. The frontend refuses a created token's row that lacks its policy facts, takes a token for a protocol asset on Rust's word only, draws in the panel exactly the lines Rust reports (defined by, decimals, total supply, burn, transfer), offers BURN only where the policy permits it, and keys the protocol artwork on Rust's word plus the ticker. `CPTA_INFO` is deleted. | +| frontend · hooks/useWalletRefreshListener.ts; contexts/WalletContext.tsx; components/screens/wallet/hooks/useWalletScreenData.ts; hooks/useWalletSync.ts; dsm/EventBridge.ts; dsm/transactions.ts | The one reload path, `wallet.refresh` → `useWalletRefreshListener`, had a "cooldown" that counted dropped events rather than frames: after any reload the next 119 events from a low-priority source (`inbox.sync`, `storage.sync`, `sofi`, an adoption, a dialog's completion) were discarded outright, and only nine named sources bypassed it. Around that gate the provider and the wallet screen subscribed to the raw `inbox.updated` and `bilateral.transferComplete` events as well, and the provider reloaded again on `wallet.bilateralCommitted`, so one inbox sync with new items reloaded the projection and the screen three to four times when the gate was open, and the raw subscriptions were the only reload when it was shut. The event bridge also emitted a `wallet.refresh` claiming `bilateral.transfer_complete` on one BLE prepare response in eight — a prepare response is not a wallet change. | The listener coalesces onto an animation frame, runs one reload at a time and owes exactly one more for events that arrive mid-reload; nothing is dropped and there are no priority classes. The provider and the screen hook reload on `wallet.refresh` alone; the raw subscriptions and the provider's reload on `wallet.bilateralCommitted` are deleted (its toast stays). The BLE prepare-response emit is deleted. The accept path's staggered re-reads stay beside Rust's TRANSFER_COMPLETE announcement, named `bilateral.accept_followup` (Open, below). The provider's store and the wallet screen's hook still each held a copy of balances and history after this row; the row on the one wallet store, below, removes the screen's copy. | +| frontend · dsm/NativeHostBridge.ts (`isNativeHostUnavailableError`), dsm/WebViewBridge/transportCore.ts (`processEnvelopeV3Bin`), dsm/NativeBoundaryBridge.ts (`buildEnvelopeIngressRequest`); tests/E2E.transferProof, E2E.bilateral.acceptFlow, E2E.offlineBleExchange; `ci` · bridge_rpc_names.py | `isNativeHostUnavailableError` matched a Kotlin error string nothing called it for. `processEnvelopeV3Bin` sent an `envelope` ingress op no production code builds — the frontend has no envelope to hand Rust; BLE envelopes reach Rust from Kotlin — and lived on through two tests that used it as their way into the boundary. Three test bridges carried `getDeviceIdBin` / `getGenesisHashBin` / `getTransportHeadersV3Bin` members index.html does not install (one with a comment saying call sites read them; none do) and two answered `getSigningPublicKeyBin`, an RPC deleted with #1012 — stubs of methods that do not exist. | All deleted; the boundary tests enter through `routerQueryBin`, the op production sends. The bridge-RPC-name gate now also holds every `method === '…'` arm in a test bridge to a name Kotlin handles, so a stub cannot outlive its method again. | +| frontend · dsm/identity.ts (`getIdentity`, `getDeviceIdentity`, `isReady`); stores/walletStore.ts (`initialize`); hooks/useTransactions.ts; hooks/useDiagnostics.ts; wallet/hooks/useWalletScreenData.ts | `getIdentity` answered `null` after its cold-start window for three different reasons — the native session reporting no identity, the session not ready in time, and headers that could not be read — and waited the whole window (~5.75 s) on a device that has no identity at all. `isReady` reduced a device-id read to a boolean, `false` for an empty answer and for a failed one alike, and `useTransactions` read that as "no identity yet". The wallet store treated the null as "not initialized, no error"; the wallet screen threw "Identity not initialized" for every case. | `getIdentity` answers the identity or throws `IdentityUnavailableError` with its state: `missing` (Rust's word, the native session, answered at once), `runtime_not_ready` (the session not ready within the window, with the last read's failure), or `read_failed` (a session that reports ready whose headers still could not be read). The store treats `missing` as a state — uninitialized, no error, no reads — and reports the other two as errors; the screen shows the reason; diagnostics print the state; `useTransactions` asks the native session (`checkIdentityState`) and logs which of the three it saw. `getDeviceIdentity` and `isReady` are deleted. | +| frontend · dsm/crypto.ts, dsm/nfc.ts, vectors/ (3 files), services/policy/policyScanService.ts, utils/binary.ts (+ its test); dsm/NativeHostBridge.ts (`getNativeHostCapabilities`, `stopNativeQrScan`, `requestHostPermissions`, `authorizeBiometricHost`, `readNfcTagPayloadHost`), dsm/WebViewBridge/transportCore.ts (`invokeRouterEnvelope`, `queryRouterEnvelope`), dsm/resolution.ts (`loadPersistedBleMappings`, `subscribeBleEvents`), contexts/BleContext.tsx (`useBle`), contexts/UXContext.tsx (`useUXTerms`), runtime/bridgeSessionStore.ts (`useBridgeSessionStore`); the startup boundary: dsm/NativeBoundaryBridge.ts (`startupBoundary`, `startupBoundaryOk`), dsm/bridgeTypes.ts (`startup`), `public/index.html` (the `startup` wrapper), setupTests.ts; Kotlin · SinglePathWebViewBridge.kt (`nativeBoundaryStartup` arm), SinglePathWebViewBridgeFuzzTest.kt | A survey of production exports nothing in production references (a grep over whole-word uses) found five whole modules and fourteen functions dead — among them `loadPersistedBleMappings`, a documented no-op, and `useUXTerms`, "terms" that answered the key they were asked for. The WebView's startup-boundary path — a TS function, an index.html wrapper, a bridge-type member, a test-setup stub arm and a Kotlin arm — had no caller: Kotlin crosses the startup boundary itself at app start (`BridgeIdentityHandler.dispatchStartupOrThrow`), and the bridge-RPC-name gate counted the wrapper's own `callBridgeMethod('nativeBoundaryStartup')` as "sent". | All deleted, including the Kotlin arm; `AndroidBridgeV3` and index.html's object are six members; `callBoundaryMethod` crosses the ingress boundary only. The gate refused the test-setup stub arm for the deleted name before it was removed (its stub check, from the chunk above). Exports referenced only by tests (`decodeBalancesListResponseStrict`, `encodeBilateralEventNotification`, `emitBilateralCommitted`, `parseBinary32`/`64`, `clearBleIdentityCache`, `pruneBleIdentityMappings`, `getTransactions`, `getBluetoothStatus`, `mapPoliciesToDisplayEntries`, the walletStore selector hooks, `decodeBase32Crockford32`, `FX_ANIMS`, `encodeEnvelope`, the session store's `…ForTest` setters) are production code kept alive by tests: the row below. Bitcoin (`services/bitcoinTap.ts`) and recovery (`nfcRecoveryService.ts`) exports are not touched. | +| frontend · dsm/decoding.ts (`decodeBalancesListResponseStrict`, `encodeEnvelope`), services/bilateral/bilateralEventService.ts (`encodeBilateralEventNotification`), dsm/transactions.ts + bridge/nativeBridgeAdapter.ts (the committed signal), contexts/contacts/utils.ts (`parseBinary32`/`64`), dsm/resolution.ts (`clearBleIdentityCache`, `pruneBleIdentityMappings`), dsm/wallet.ts (`getTransactions`), dsm/identity.ts (`getBluetoothStatus`), services/policy/policyDisplayService.ts, stores/walletStore.ts (six selector hooks), utils/textId.ts (`decodeBase32Crockford32`), components/fx/fxEngine.ts (`FX_ANIMS`), runtime/nativeSessionStore.ts (`…ForTest` setters); tests | Production exports referenced only by tests: a second balances decoder beside the one `getAllBalances` uses, an event encoder production never encodes with, a second history reader, selector hooks no screen calls, a policy display mapper no screen calls, store setters that existed for tests, and tests that tested them — one "reproduction" test mimicked the balance mapper with its own fallback (`b.tokenId \|\| 'ERA'`) and `console.log`. The accept path dispatched the committed signal twice: as a window event the adapter re-emitted on the bus, and on the bus again. | Dead exports deleted with the tests of them; the event encoder moved to `tests/helpers/bilateralEventFixture.ts` (a harness the tests own); the session store is driven in tests by the production input (`session.state` on the bus) and fresh per test by module isolation; the BLE cache tests likewise. The accept path emits the committed signal once, through `emitBilateralCommitted`; the window hop for it is deleted. | +| frontend · components/screens/wallet/hooks/useWalletScreenData.ts; wallet/OverviewTab.tsx, wallet/SendTab.tsx, wallet/helpers.ts (`Balance`); EnhancedWalletScreen.tsx | The wallet screen held its own copies of balances (a private `Balance` shape mapped from `TokenBalanceView`) and history beside the provider's store, and reloaded them — with the identity and the contacts — on every `wallet.refresh`, so one wallet change was two reads each of `balance.list` and `wallet.history` and one of `contacts.list` and the headers. | The screen reads balances and history from the wallet store, the one copy, reloaded once per change in the provider; the tabs take `TokenBalanceView` and the private `Balance` type is deleted. The screen owns the identity (read once, or the reason it was not) and the send tab's contacts with their send-readiness, re-read when the contacts store reports a change and on a manual refresh. The overview says "Loading balances…" while the store has not answered, rather than "No balances yet". | +| frontend · stores/contactsStore.ts (`mapContacts`), contexts/ContactsContext.tsx (`Contact`), components/screens/ContactsTabScreen.tsx, wallet/hooks/useWalletScreenData.ts | Two mappers read Rust's contact list into two shapes: the contacts store's `Contact` (`id`, `publicKey`, `isVerified`, the raw BLE address) for the contacts screen, and `mapContactList`'s `DomainContact` (the signing key, `genesisVerifiedOnline`, the normalised BLE address or the one the native side resolved this session, and the send-readiness Rust reports) for the send tab — so the wallet screen kept its own copy of the contacts, re-read from Rust beside the store's. | One shape, `DomainContact`, from the one mapper, in the contacts store; `Contact` and `mapContacts` are deleted; the contacts screen reads `deviceId`, `signingPublicKey` and `genesisVerifiedOnline`; the wallet screen reads the store's contacts and re-reads nothing. A manual refresh reloads both stores. | +| frontend · bridge/nativeBridgeAdapter.ts, bridge/bridgeEvents.ts, dsm/EventBridge.ts, dsm/identity.ts (`getIdentity`'s wake-up), dsm/events.ts (`DSM_WALLET_REFRESH_EVENT`), hooks/useWalletSync.ts, contexts/WalletContext.tsx, contexts/ContactsContext.tsx, components/common/LoadingSpinner.tsx | The adapter re-emitted seven DOM events on the bus that nothing ever dispatched (`dsm-history-updated`, `dsm-balances-updated`, `dsm-wallet-send-committed`, `dsm-contact-added`, `DSM_PORT_TX`, `DSM_PORT_RX`, `DSM_UI_TICK`), and `useWalletSync`, the contacts provider and the loading spinner subscribed to the bus events they would have produced — reloads and an activity indicator that could never fire. The native lifecycle topics reached the bus through DOM hops: `dsm-identity-ready` was dispatched on `document`, re-emitted by the adapter, and listened for by `getIdentity`'s early wake-up on `window`, where it never arrived; `dsm-wallet-refresh` and `dsm-env-config-error` likewise went DOM → adapter → bus. `session.state` and `bilateral.event` were also fanned out as DOM events with no listener. | The seven hops, their bus event types, `useWalletSync` (its one live subscription, `identity.ready`, is the wallet provider's own), the contacts provider's `contact.added` subscription and the spinner's activity effect are deleted. The event bridge emits `identity.ready`, `wallet.refresh` (`native`) and `env.config.error` on the bus directly; the adapter keeps only `visibilitychange`; `getIdentity` wakes on the bus event, which now reaches it; `DSM_WALLET_REFRESH_EVENT` and the two listener-less fan-outs are deleted. | +| frontend · hooks/useBridgeEvents.ts (`useBridgeEvent`), bridge/bridgeEvents.ts, contexts/UXContext.tsx, contexts/BleContext.tsx, dsm/EventBridge.ts | `useBridgeEvent` took any string, so subscriptions to events nothing can emit compiled: two toasts (`ble.permission.recovery.needed`, `ble.features.disabled`) and a `ble.features.enabled` handler — the last not even a bus event — which together drove a `bleFeaturesDisabled` flag that gated every BLE call and could never be set. Two BLE advertising events were emitted with no consumer left, and `nfc.writeStarted` had neither. | `useBridgeEvent`'s name is `keyof BridgeEventMap`: a subscription to a name the bus does not carry does not compile. The dead subscriptions, the flag and its gate, the two emits and the three event types are deleted — and with the gate, `BleContext` itself: a provider whose context nothing consumed since `useBle` went (its scan state and four no-op calls), mounted in `App` for nothing. `wallet.exitCompleted`, subscribed to in three places and emitted by nothing, is Bitcoin's exit flow and is not touched. | Tests: `dsm_sdk::handlers::storage_routes::tests::storage_status_reports_the_pinned_set_and_each_members_own_answer` (the router's answer over real nodes on Postgres; then one member stops serving), `dsm_sdk::sdk::storage_node_sdk::tests::a_members_latest_bytecommit_is_its_own_or_there_is_none`, `dsm_sdk::storage::client_db::tests::a_database_that_does_not_exist_has_no_size`; frontend `dsm/__tests__/storage.test.ts` and `components/storage/__tests__/StorageNodePanels.test.tsx`. Mutation controls, each red on its named test: another member's ByteCommit accepted as this member's (`a_members_latest_bytecommit_is_its_own_or_there_is_none`); a missing database file reported as 0 bytes (`a_database_that_does_not_exist_has_no_size`); a member that did not answer reported as "no cycle" (`storage_status_reports_the_pinned_set_and_each_members_own_answer`); the frontend inventing an answer for a member that carries none (`a member that carries no answer is refused, never given one`); every member counted as answering (`shows the set and counts only the members that gave an answer`). @@ -1039,11 +1049,33 @@ Tests for the faucet: frontend `dsm/__tests__/faucetClaim.test.ts`; `components/ Tests for policy publication: `dsm_sdk::handlers::token_routes::tests::bytes_that_are_not_a_token_policy_are_not_published` (refused and not kept; a policy Core accepts is kept); frontend `dsm/__tests__/policies.test.ts` · `publishes the pasted bytes exactly as pasted`, `bytes that are not a policy are Rust's to refuse, in its words`. Mutation controls, each red on its named test: Rust's parser check removed; the frontend's decode and re-encode restored. +Tests for token facts: `dsm_sdk::handlers::wallet_routes::tests::era_is_protocol_defined_with_the_reserve_supply_and_no_stated_permissions`, `a_registered_token_reports_its_policy_supply_and_permissions` (a policy packed by the one packer, stored under its anchor, registered as adoption registers it), `a_registry_row_that_disagrees_with_its_policy_is_refused`; frontend `components/screens/__tests__/AccountsScreen.tokens.test.tsx` · `shows a created token's supply and what its policy permits, as Rust reports them`, `shows a protocol asset's supply from Rust and states nothing Rust does not`, `offers no BURN where the policy forbids it, and says so`, `takes a token for a protocol asset only on Rust's word, never on its ticker`; `dsm/__tests__/wallet.test.ts` · `a row without the fields Rust always writes is refused, never filled in` (a created token's row without its policy facts). Mutation controls, each red on its named test: ERA's permissions defaulted where none are stated; a registered token reported as protocol-defined; a row disagreeing with its policy reported anyway; protocol-ness keyed on the ticker again; BURN offered regardless of the policy; permissions defaulted where Rust states none; a created token's row without its policy facts accepted. + +Tests for the reload path: frontend `hooks/__tests__/useWalletRefreshListener.test.tsx` · `an event after a completed refresh runs another: nothing is dropped`, `events during a running refresh owe exactly one more after it`, `an owed refresh is not run after unmount`; `contexts/__tests__/WalletCreditSound.test.tsx` · `reloads once per announced change and plays the coin sound only on a credit` (the event bridge's own announcements, decoded from bytes); `components/screens/__tests__/EnhancedWalletScreen.events.test.tsx` · `one inbox sync with new items reloads the wallet data once`; `dsm/__tests__/EventBridge.bilateral.test.ts` · `a BLE prepare response announces no wallet change`. Mutation controls, each red on its named test: the drop gate reintroduced; the provider's raw `inbox.updated` reload re-added; the screen hook's raw `inbox.updated` reload re-added; the BLE prepare-response emit restored. + +Tests for the dead transport paths: frontend `dsm/__tests__/WebViewBridge.framing.test.ts` · `a router query goes to nativeBoundaryIngress as a routerQuery op`, `a native answer that is not bytes is refused`; `dsm/__tests__/bridgeDecoding.integration.test.ts` (the boundary's answers, entered through `routerQueryBin`). Gate control, failing naming the offender: a test stub arm answering `getSigningPublicKeyBin`. + +Tests for identity readiness: frontend `dsm/__tests__/identity.test.ts` · `with no session state and no readable headers, the answer is runtime-not-ready after the window`, `a missing identity is answered as missing at once, without the retry window`, `a ready session whose headers never read is answered as not read, with the reason`; `stores/__tests__/walletStore.test.ts` · `a missing identity leaves the store uninitialized, with no error and no reads`, `a runtime that is not ready in time is an error, as reported`; `hooks/__tests__/useDiagnostics.test.ts` · `a missing identity is reported as missing, not as empty fields`; `hooks/__tests__/useTransactions.test.ts` · `does not ask for history while the native session reports NO_IDENTITY / RUNTIME_NOT_READY`. Mutation controls, each red on its named test: the missing fast-exit removed (the window waited, the answer not-ready); a failed window answering null again; the store treating a missing identity as an error. + +Tests for the dead-export cut: none added — deletions; the whole frontend suite, `npm run build`, the Kotlin main / androidTest / unit-test compiles and every gate ran on the result. Gate controls: the `nativeBoundaryStartup` stub arm in setupTests.ts, refused by name while the Kotlin arm was already gone; a `startup` member left on `AndroidBridgeV3` after index.html dropped it is refused as "typed but not installed" (the gate's existing member check). + +Tests for the test-kept exports: frontend `dsm/__tests__/bilateralAcceptEvent.test.ts` · `acceptOfflineTransfer emits wallet.bilateralCommitted exactly once`; `utils/__tests__/identity.test.ts` (the native session published as `session.state` on the bus, a fresh store per test); `dsm/__tests__/resolution.test.ts` (a fresh module per test). Mutation control, red on its named test: the window dispatch of the committed signal restored beside the bus emit (two signals per accept). + +Tests for the one wallet store: frontend `components/screens/__tests__/EnhancedWalletScreen.events.test.tsx` (every test mounts the screen inside the wallet provider, as the app does) · `one inbox sync with new items reloads the wallet data once` (now the store's one reload, the screen adding none), `online receiver refresh updates visible balance and history in the UI`, `reloads transactions when dsm-wallet-refresh is dispatched`. Mutation control, red on its named test: a `wallet.refresh` reload re-added to the screen hook beside the store's. + +Tests for the one contact shape: frontend `stores/__tests__/contactsStore.test.ts` · `fetches contacts and maps them` (the `DomainContact` shape, Base32 Crockford, from the one mapper), `carries genesisVerifiedOnline as Rust reports it`; `components/screens/__tests__/EnhancedWalletScreen.events.test.tsx` · `the refresh button re-reads balances, history and contacts`; the contacts screen tests on the renamed fields. Mutation control, red on its named test: the manual refresh no longer reloading the contacts store. + +Tests for the event plumbing: frontend `tests/E2E.uiCoordination.test.tsx` · `REAL: the native dsm-identity-ready topic → identity.ready on the bus`, `REAL: the native dsm-wallet-refresh topic → wallet.refresh from native on the bus`, `REAL: the native dsm-env-config-error topic → env.config.error with its message on the bus`; `dsm/__tests__/identity.test.ts` · `the wait wakes early on identity.ready from the bus`. Mutation controls, each red on its named test: the identity-ready topic no longer emitted on the bus; the wake-up listener removed. + +Tests for typed subscriptions: none added — a type. Compile control: a `useBridgeEvent('nothing.emits', …)` added to a provider fails `tsc`, naming the file. + **Open** +- frontend · dsm/EventBridge.ts: `dsm-biometric-result`, `bluetooth-permissions` and `ble-dev-automation` are still dispatched as window events with no listener in the frontend; whether device automation or the biometric flow reads them from outside the bundle is a device question, so they stay until a device run says. + - frontend · `offlineSend` primes BLE advertising and scanning itself and sleeps 1.5 s before `wallet.sendOffline`, and ContactsContext's `ensureBleAdvertisingIfContacts` starts advertising on contact events: transport orchestration above Rust, with failures swallowed. The contacts mapper also falls back to BLE addresses the native side resolved this session (`dsm/resolution.ts`). Changing either needs a device run. - frontend · SofiScreen: orders a vault's token pair bytewise before sending it (§28) — a protocol rule applied above Rust. The SoFi track's screen. -- frontend · AccountsScreen `CPTA_INFO`: the ERA entry's type ("DJTE emission token"), anchor formula and "PROTOCOL-DEFINED" anchor id are copy in the screen, not facts Rust reports (the anchor Rust reports is shown beside them), and whether a token is protocol-defined — which withholds burn — is decided by its ticker. The dBTC entry is not touched: Bitcoin is parked. +- frontend · dsm/transactions.ts `schedulePostAcceptRefreshes`: after Accept, four re-reads of the wallet on a frame cadence (0/0.5/1/2 s) beside Rust's TRANSFER_COMPLETE announcement, kept because whether the announcement alone reaches the screen on a device is undecided; a device run decides, and the cadence goes if it does. - frontend · services/recovery/nfcRecoveryService.ts `getNfcBackupStatus`: reads `recovery.status` as `key=value` text inside an `AppStateResponse` and fills a missing `capsule_count`/`last_capsule_index` with 0 — a text protocol on a DSM path. Recovery is a dependency boundary this round; the route and its reader change together when the recovery specification is in scope. - frontend · `public/index.html` `sendMessageBin`: rejects a request after 30 minutes on a wall-clock timer (a bound sized for the M=3 K=21 enrollment); `installPortHandler` tells a response from an async event by whether the first eight bytes match a pending id. Both are transport-side, fail-closed and unchanged by the sweep; they are where the port protocol still guesses. - Kotlin · androidTest `AndroidLayerProofTest.claimFaucet`: hand-encodes the ArgPack's `schema_hash` as 32 zero bytes, where the frontend sends none, and swallows the claim's failure.