From 7510727d22a99ea3a45543309d2bbed04c69ad37 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:53:26 -0400 Subject: [PATCH 1/2] feat(wallet): offline funding and the appliance are back on the send screen, and a balance row states its offline allocation The offline regime had no way in from the app: nothing loaded a token into the device's offline allocation, nothing connected the anchor appliance, and a balance row said nothing about what it held offline. The send screen's Offline mode now carries, below the mode toggle and above the recipient, an Offline Funding button with an i that explains the two balances, and an Appliance button whose dot shows the last read. Offline Funding opens a pop-up that moves a token between the online account and the offline allocation (Load / Unload) through `wallet.loadOffline` / `wallet.unloadOffline` and shows Rust's answer in Rust's words. Appliance opens a pop-up with the plug-in steps and what Rust read from the appliance (state, anchor, counter, frontier); Connect reads it again. Choosing Offline reads the appliance by itself, so after Android's one permission dialog it connects on its own. In Offline mode the balance card shows the offline allocation (what an offline send spends), and the overview lists a token's offline allocation under its row once there is some. Rust: `BalanceGetResponse.offline_allocation` (a message, present with its Rust-rendered display form) is filled at the enrichment boundary from `CoreSDK::offline_allocation_of`, which reads the allocation leaf under the bundle of the appliance attached in this process. The leaf is keyed by that bundle and only the appliance states it, so before one has been attached the field is absent and the wallet reads it as unknown; a zero there would claim a fact nobody holds. The native BTC row has no policy commit and so no leaf. `OfflineCashRequest.amount` is the user's decimal text, scaled by the same parser the sends use, and `OfflineCashResponse` carries both balances rendered by Rust; the route's message is in the token's units. Tour: after the mode step the guide has the user tap Offline, explains Offline Funding and the appliance, and has them tap Online again (a new `gone` wait kind, for an element leaving the screen). Practice mode refuses the two moves. Anchor library: `crates/dsm-android-anchor` declares itself a workspace root. The repository workspace excludes it, and cargo keeps climbing past an excluding root, so from a worktree nested under another checkout it reached the outer manifest and refused to build. --- crates/dsm-android-anchor/Cargo.toml | 7 + .../src/bluetooth/offline_step_tests.rs | 2 +- .../src/handlers/bitcoin_query_routes.rs | 9 +- .../src/handlers/offline_cash_routes.rs | 54 ++++-- .../dsm_sdk/src/handlers/wallet_routes.rs | 74 ++++++-- .../dsm_sdk/src/sdk/core_sdk.rs | 22 +++ .../dsm_sdk/tests/balance_wire_fixture.rs | 14 ++ .../screens/wallet/AppliancePopover.tsx | 104 +++++++++++ .../screens/wallet/OfflineFundingPopover.tsx | 141 ++++++++++++++ .../components/screens/wallet/OverviewTab.tsx | 29 ++- .../src/components/screens/wallet/SendTab.tsx | 92 ++++++++- .../__tests__/SendTab.offlineFunding.test.tsx | 175 ++++++++++++++++++ .../src/components/tour/GuidedTour.tsx | 5 + .../tour/__tests__/practiceMode.test.ts | 5 + .../components/tour/__tests__/tour.test.tsx | 12 +- .../src/components/tour/practiceMode.ts | 2 +- .../frontend/src/components/tour/tourSteps.ts | 36 +++- .../dsm/__tests__/balanceWireContract.test.ts | 5 + .../__tests__/fixtures/balances_list_rigb.bin | 7 +- .../src/dsm/__tests__/offlineCash.test.ts | 71 +++++++ .../frontend/src/dsm/__tests__/wallet.test.ts | 25 +++ dsm_client/frontend/src/dsm/index.ts | 4 + dsm_client/frontend/src/dsm/offlineCash.ts | 52 ++++++ dsm_client/frontend/src/dsm/types.ts | 15 ++ dsm_client/frontend/src/dsm/wallet.ts | 17 +- dsm_client/frontend/src/proto/dsm_app_pb.ts | 84 ++++++++- dsm_client/frontend/src/styles/screen.css | 2 +- proto/dsm_app.proto | 18 +- 28 files changed, 1032 insertions(+), 51 deletions(-) create mode 100644 dsm_client/frontend/src/components/screens/wallet/AppliancePopover.tsx create mode 100644 dsm_client/frontend/src/components/screens/wallet/OfflineFundingPopover.tsx create mode 100644 dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offlineFunding.test.tsx create mode 100644 dsm_client/frontend/src/dsm/__tests__/offlineCash.test.ts create mode 100644 dsm_client/frontend/src/dsm/offlineCash.ts diff --git a/crates/dsm-android-anchor/Cargo.toml b/crates/dsm-android-anchor/Cargo.toml index 844adb02c..908a70aa0 100644 --- a/crates/dsm-android-anchor/Cargo.toml +++ b/crates/dsm-android-anchor/Cargo.toml @@ -6,6 +6,13 @@ edition = "2021" license = "MIT OR Apache-2.0" description = "On-device glue for the DSM Software-Authority / Hardware-Identity anchor: installs the sender USB anchor appliance (UsbAnchorAppliance over the opaque Kotlin picoUsbTransceive) into the dsm_sdk bridge seam, plus gated device-setup ops (counter birth, slot-0 birth cage) and read-only chip diagnostics. Depends on dsm_sdk + dsm-anchor-hw-verifier in ONE direction (no dependency cycle); transitively pulls tropic01, so it is EXCLUDED from the workspace and built only in the Android cargo-ndk pipeline — the default CI workspace stays tropic01-free." +# Its own workspace root. The repository workspace excludes this crate, and cargo keeps +# climbing past an excluding root: from a checkout nested under another checkout (a git +# worktree under `.claude/worktrees/`) it reaches the outer repository's manifest, which +# neither lists nor excludes this path, and refuses to build. Declaring the root here stops +# the climb wherever the checkout sits. +[workspace] + [lib] # rlib for host tests; staticlib/cdylib so the Android build can link the on-device JNI shell (H3). crate-type = ["rlib", "staticlib", "cdylib"] diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs index 97a478dde..a8a746b6b 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs @@ -748,7 +748,7 @@ async fn bearer_pair() -> ( "wallet.loadOffline", &crate::generated::OfflineCashRequest { token_id: "ERA".to_string(), - amount: 20, + amount: "20".to_string(), }, ) .await; 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 e9d4e5b18..dd0c35ab3 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 @@ -449,7 +449,10 @@ impl AppRouterImpl { locked, ..Default::default() }; - if let Err(e) = crate::handlers::wallet_routes::enrich_balance_metadata(&mut reply) + if let Err(e) = + crate::handlers::wallet_routes::enrich_balance_metadata(&mut reply, &|asset| { + self.core_sdk.offline_allocation_of(asset) + }) { return err(format!("bitcoin balance: {e}")); } @@ -636,6 +639,10 @@ impl AppRouterImpl { protocol_defined: false, genesis_supply_display: String::new(), permissions: None, + // An offline allocation leaf is keyed by the asset's + // policy commit, which native BTC does not have: no + // such leaf can exist, so there is nothing to state. + offline_allocation: None, }, )) } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/offline_cash_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/offline_cash_routes.rs index f50cb1926..82506def0 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/offline_cash_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/offline_cash_routes.rs @@ -43,7 +43,18 @@ impl AppRouterImpl { )) } }; - if req.amount == 0 { + // The amount as the user typed it, scaled by the token's decimals here: + // the parser the sends use, so one rule owns the unit in both directions. + let decimals = match super::wallet_routes::token_decimals(&req.token_id) { + Ok(d) => d, + Err(e) => return err(format!("wallet.{verb}: {e}")), + }; + let amount = + match super::wallet_routes::parse_display_amount_to_base_units(&req.amount, decimals) { + Ok(a) => a, + Err(e) => return err(format!("wallet.{verb}: invalid amount: {e}")), + }; + if amount == 0 { return err(format!("wallet.{verb}: amount must be > 0")); } @@ -68,9 +79,9 @@ impl AppRouterImpl { // Apply the conserved regime shift (fail-closed persist-before-install in CoreSDK). let outcome = if is_load { - self.core_sdk.load_offline_cash(bundle, asset, req.amount) + self.core_sdk.load_offline_cash(bundle, asset, amount) } else { - self.core_sdk.unload_offline_cash(bundle, asset, req.amount) + self.core_sdk.unload_offline_cash(bundle, asset, amount) }; let outcome = match outcome { Ok(o) => o, @@ -78,19 +89,28 @@ impl AppRouterImpl { }; let online_balance = self.core_sdk.get_device_balance(&asset); + // Rendered here, in the token's units: the wallet prints these and + // computes nothing. + let render = |base_units: u64| { + super::wallet_routes::format_base_units_for_display(base_units, decimals) + }; + let online_display = render(online_balance); + let allocation_display = render(outcome.amount); let resp = generated::OfflineCashResponse { success: true, online_balance, allocation_balance: outcome.amount, device_root: outcome.new_root.to_vec(), message: format!( - "{} {} of {} — offline allocation now {}, online {}", + "{} {} {} — offline allocation now {}, online {}", if is_load { "loaded" } else { "unloaded" }, - req.amount, + render(amount), req.token_id, - outcome.amount, - online_balance, + allocation_display, + online_display, ), + online_display, + allocation_display, }; pack_envelope_ok(generated::envelope::Payload::OfflineCashResponse(resp)) } @@ -115,7 +135,7 @@ mod tests { .expect("router init") } - async fn invoke(router: &AppRouterImpl, method: &str) -> AppResult { + async fn invoke(router: &AppRouterImpl, method: &str, amount: &str) -> AppResult { router .handle_offline_cash_invoke(AppInvoke { method: method.to_string(), @@ -123,7 +143,7 @@ mod tests { codec: generated::Codec::Proto as i32, body: generated::OfflineCashRequest { token_id: "ERA".to_string(), - amount: 10, + amount: amount.to_string(), } .encode_to_vec(), ..Default::default() @@ -141,7 +161,7 @@ mod tests { #[serial_test::serial] async fn load_offline_refuses_when_the_anchor_appliance_cannot_be_reached() { let r = router_on_a_device(); - let res = invoke(&r, "wallet.loadOffline").await; + let res = invoke(&r, "wallet.loadOffline", "10").await; assert!(!res.success, "load must refuse when no appliance answers"); let msg = res.error_message.unwrap_or_default(); assert!( @@ -156,11 +176,23 @@ mod tests { #[serial_test::serial] async fn unload_offline_refuses_when_the_anchor_appliance_cannot_be_reached() { let r = router_on_a_device(); - let res = invoke(&r, "wallet.unloadOffline").await; + let res = invoke(&r, "wallet.unloadOffline", "10").await; assert!(!res.success); assert!(res .error_message .unwrap_or_default() .contains("connect your anchor device")); } + + /// The amount is the user's decimal text, scaled by Rust: text that is not + /// an amount is refused as one, before any gate that needs the appliance. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn an_amount_that_is_not_a_number_is_refused_as_an_amount() { + let r = router_on_a_device(); + let res = invoke(&r, "wallet.loadOffline", "ten").await; + assert!(!res.success); + let msg = res.error_message.unwrap_or_default(); + assert!(msg.contains("invalid amount"), "{msg}"); + } } 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 04ca2688e..57c17e7fc 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 @@ -70,8 +70,12 @@ pub fn enrich_transaction_display(tx: &mut generated::TransactionInfo) -> Result Ok(()) } +/// Fill in everything a balance row says beyond its amounts: the token's +/// name, unit and policy facts, its anchor, and its offline allocation as +/// `offline_of` reads it for the row's asset. pub(crate) fn enrich_balance_metadata( reply: &mut generated::BalanceGetResponse, + offline_of: &dyn Fn(&[u8; 32]) -> Option, ) -> Result<(), String> { let token_id = reply.token_id.trim().to_uppercase(); match token_id.as_str() { @@ -83,9 +87,10 @@ pub(crate) fn enrich_balance_metadata( reply.decimals = era.decimals; reply.token_name = era.alias.clone(); reply.display_amount = format_base_units_for_display(reply.available, era.decimals); - set_anchor( + set_anchor_and_offline( reply, &dsm::core::token::token_state_manager::era_policy_commit(), + offline_of, ); reply.protocol_defined = true; reply.genesis_supply_display = @@ -102,7 +107,7 @@ pub(crate) fn enrich_balance_metadata( reply.token_name = "dBTC".to_string(); reply.display_amount = format_base_units_for_display(reply.available, 8); if let Some(c) = crate::policy::builtin_policy_commit("dBTC") { - set_anchor(reply, &c); + set_anchor_and_offline(reply, &c, offline_of); } reply.protocol_defined = true; } @@ -131,7 +136,7 @@ pub(crate) fn enrich_balance_metadata( }; reply.decimals = row.decimals; reply.canonical_token_id = row.token_id.clone(); - set_anchor(reply, &row.policy_commit); + set_anchor_and_offline(reply, &row.policy_commit, offline_of); 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 @@ -186,17 +191,32 @@ fn registered_policy( /// identifier: nothing resolves a token by fingerprint. const ANCHOR_FINGERPRINT_LEN: usize = 8; -/// Render a token's CPTA policy anchor onto the wire record. +/// Render a token's CPTA policy anchor and its offline allocation onto the wire record. /// /// A creator could not see the anchor of a token it had created — the adoption /// card showed one, the creator's screen showed nothing — so handing it to a /// peer meant deriving it by hand, off-device. Base32 Crockford, encoded by the /// canonical encoder, because a second encoder gets the trailing-group padding /// wrong and produces a plausible string that resolves to nothing. -fn set_anchor(reply: &mut generated::BalanceGetResponse, policy_commit: &[u8; 32]) { +/// +/// The offline allocation rides with the anchor because it is the same asset's +/// cash in hand, keyed by the commit the anchor renders, and rendered here with +/// the row's decimals. `None` from the reader means no appliance has stated a +/// bundle yet: the field stays absent, which the wallet reads as unknown. A +/// zero there would claim a fact nobody holds. +fn set_anchor_and_offline( + reply: &mut generated::BalanceGetResponse, + policy_commit: &[u8; 32], + offline_of: &dyn Fn(&[u8; 32]) -> Option, +) { let b32 = crate::util::text_id::encode_base32_crockford(policy_commit); reply.anchor_fingerprint = b32.chars().take(ANCHOR_FINGERPRINT_LEN).collect(); reply.policy_anchor_b32 = b32; + reply.offline_allocation = + offline_of(policy_commit).map(|base_units| generated::OfflineAllocationView { + base_units, + display_amount: format_base_units_for_display(base_units, reply.decimals), + }); } fn ensure_default_visible_balances( @@ -471,7 +491,9 @@ impl AppRouterImpl { locked: bal.locked(), ..Default::default() }; - if let Err(e) = enrich_balance_metadata(&mut reply) { + if let Err(e) = enrich_balance_metadata(&mut reply, &|asset| { + self.core_sdk.offline_allocation_of(asset) + }) { return err(format!("balance.get: {e}")); } pack_envelope_ok(generated::envelope::Payload::BalanceGetResponse(reply)) @@ -739,7 +761,9 @@ impl AppRouterImpl { // enrichment belongs at the encoding boundary, where it cannot // be skipped by whichever path produced a row. for item in items.iter_mut() { - if let Err(e) = enrich_balance_metadata(item) { + if let Err(e) = enrich_balance_metadata(item, &|asset| { + self.core_sdk.offline_allocation_of(asset) + }) { return err(format!("balance.list: {e}")); } } @@ -1515,7 +1539,7 @@ mod tests { fn era_reports_its_committed_policy_supply_permissions_and_anchor() { fresh_db(); let mut era = seed("ERA", 264, 0); - super::enrich_balance_metadata(&mut era).expect("ERA is named"); + super::enrich_balance_metadata(&mut era, &|_| None).expect("ERA is named"); assert!(era.protocol_defined); assert_eq!((era.symbol.as_str(), era.decimals), ("ERA", 0)); assert_eq!(era.genesis_supply_display, "80000000000"); @@ -1531,7 +1555,7 @@ mod tests { "JXPMPGJH45HDTE0ARWE2CTB9E9BWTQZ3T78CE5RFF1RXMR9VKK80" ); let mut dbtc = seed("dBTC", 0, 0); - super::enrich_balance_metadata(&mut dbtc).expect("dBTC is named"); + super::enrich_balance_metadata(&mut dbtc, &|_| None).expect("dBTC is named"); assert!(dbtc.protocol_defined); } @@ -1545,7 +1569,7 @@ mod tests { 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"); + super::enrich_balance_metadata(&mut row, &|_| None).expect("a registered token is named"); assert!(!row.protocol_defined); assert_eq!(row.genesis_supply_display, "1000.00"); assert_eq!( @@ -1560,6 +1584,34 @@ mod tests { assert_eq!(row.display_amount, "0.05"); } + /// A row states its offline allocation only from a reader that knows the + /// attached appliance's bundle, for the row's own asset. With none attached + /// the field is absent: unknown is not zero, and the wallet must not print + /// a pot it cannot see. + #[test] + #[serial_test::serial] + fn a_row_states_its_offline_allocation_only_when_the_bundle_is_known() { + fresh_db(); + let (policy, commit) = store_created_policy("RIGB", 2, 100_000, true, false); + register(&policy, commit, 100_000); + let mut unknown = seed("RIGB", 5, 0); + super::enrich_balance_metadata(&mut unknown, &|_| None).expect("named"); + assert_eq!(unknown.offline_allocation, None); + let mut known = seed("RIGB", 5, 0); + super::enrich_balance_metadata(&mut known, &|asset| { + assert_eq!(asset, &commit, "read for the row's own asset"); + Some(2_500) + }) + .expect("named"); + assert_eq!( + known.offline_allocation, + Some(generated::OfflineAllocationView { + base_units: 2_500, + display_amount: "25.00".to_string(), + }) + ); + } + /// A row that disagrees with the bytes its own commit names is corrupt: /// refused, with nothing from either side reported. #[test] @@ -1569,7 +1621,7 @@ mod tests { 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) + let refused = super::enrich_balance_metadata(&mut row, &|_| None) .expect_err("a row disagreeing with its policy is refused"); assert!(refused.contains("disagrees with its policy"), "{refused}"); assert!( diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs index 23b459e38..fa181c560 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs @@ -1749,6 +1749,28 @@ impl CoreSDK { } } + /// The offline allocation of `asset` under the bundle of the appliance + /// attached in this process, or `None` while none has been attached or + /// before genesis. The allocation leaf is keyed by the appliance's bundle, + /// which only the appliance states, so a device that has not attached one + /// cannot say what it holds offline: that is unknown, not zero. + pub fn offline_allocation_of(&self, asset: &[u8; 32]) -> Option { + let bundle = self + .anchor_appliance + .lock() + .as_ref() + .map(|appliance| appliance.pin().bundle)?; + let sm = self.state_machine.lock(); + let ds = sm.device_head()?; + let key = dsm::types::offline_allocation_leaf::offline_allocation_key( + &ds.genesis_digest(), + &ds.devid(), + &bundle, + asset, + ); + Some(ds.offline_allocation(&key)) + } + /// Get the canonical DeviceState head (§2.2 SMT root). pub fn device_head(&self) -> Option { self.state_machine.lock().device_head().cloned() 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 f2714b2aa..b6d8da526 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 @@ -54,6 +54,13 @@ fn emit_balances_list_fixture() { burn_enabled: true, transferable: false, }), + // Cash in hand under the attached appliance's bundle, rendered by Rust: + // 2_500 base units at 2 decimals is 25.00. Present, because absent means + // unknown, and the wallet then prints no offline figure at all. + offline_allocation: Some(dsm_sdk::generated::OfflineAllocationView { + base_units: 2_500, + display_amount: crate_format(2_500, 2), + }), }; let list = dsm_sdk::generated::BalancesListResponse { balances: vec![row], @@ -90,6 +97,13 @@ fn emit_balances_list_fixture() { }), "the policy's permissions must survive encoding, present" ); + assert_eq!( + b.offline_allocation + .as_ref() + .map(|o| (o.base_units, o.display_amount.as_str())), + Some((2_500, "25.00")), + "the offline allocation must survive encoding, present" + ); } other => panic!("unexpected payload {other:?}"), } diff --git a/dsm_client/frontend/src/components/screens/wallet/AppliancePopover.tsx b/dsm_client/frontend/src/components/screens/wallet/AppliancePopover.tsx new file mode 100644 index 000000000..8d5e7d1f8 --- /dev/null +++ b/dsm_client/frontend/src/components/screens/wallet/AppliancePopover.tsx @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +// Appliance — the pop-up that connects the DSM Anchor appliance and shows what +// Rust read from it. The first connection needs the user: plug it in, tap +// Connect, and allow it when Android asks. After that Android keeps the +// permission while the appliance stays plugged in, and the send screen +// connects on its own whenever Offline is chosen. +import React, { useEffect, useRef } from 'react'; +import { Notice } from '../../common/ScreenFrame'; +import { useBackButton } from '../../../hooks/useBackButton'; +import type { AnchorStatus } from '../../../dsm/anchor'; + +/** What the last read of the appliance said, or that none has happened. */ +export type ApplianceRead = + | { kind: 'unread' } + | { kind: 'read'; status: AnchorStatus } + | { kind: 'error'; message: string }; + +type Props = { + read: ApplianceRead; + busy: boolean; + /** Reads the appliance through Rust, which attaches it. */ + onConnect: () => Promise; + onClose: () => void; +}; + +export function applianceConnected(read: ApplianceRead): boolean { + return read.kind === 'read' && read.status.connected; +} + +function stateText(read: ApplianceRead): string { + switch (read.kind) { + case 'unread': + return 'Not checked yet'; + case 'read': + return read.status.statusText || (read.status.connected ? 'Connected' : 'Not connected'); + case 'error': + return 'Not connected'; + } +} + +export function AppliancePopover({ read, busy, onConnect, onClose }: Props): React.JSX.Element { + const dialogRef = useRef(null); + useBackButton(true, onClose); + useEffect(() => { + dialogRef.current?.focus(); + }, []); + const connected = applianceConnected(read); + + return ( +
{ e.stopPropagation(); onClose(); }}> +
e.stopPropagation()} + > +
+
+
+

Offline sends need the DSM Anchor appliance plugged into this phone.

+
    +
  1. Plug the appliance into the USB port.
  2. +
  3. Tap Connect.
  4. +
  5. When Android asks, allow DSM to use it.
  6. +
+

Android asks once. While the appliance stays plugged in, it connects on its own whenever you choose Offline.

+
+ State + {stateText(read)} +
+ {read.kind === 'read' && read.status.connected && ( + <> +
+ Anchor + {read.status.anchorIdB32} +
+
+ Counter + {String(read.status.anchorCounter)} +
+
+ Frontier + {read.status.frontierRootB32} +
+ + )} + {read.kind === 'error' && {read.message}} +
+
+ + +
+
+
+ ); +} diff --git a/dsm_client/frontend/src/components/screens/wallet/OfflineFundingPopover.tsx b/dsm_client/frontend/src/components/screens/wallet/OfflineFundingPopover.tsx new file mode 100644 index 000000000..933550cdd --- /dev/null +++ b/dsm_client/frontend/src/components/screens/wallet/OfflineFundingPopover.tsx @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 +// Offline Funding — the pop-up that moves a token between this device's two +// balances: the online account, spent through the storage nodes, and the +// offline allocation, the cash in hand the anchor appliance spends over +// Bluetooth. Rust makes the move and scales the amount; this shows both +// balances as Rust rendered them and carries the user's text in. +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { dsmClient } from '../../../services/dsmClient'; +import { TokenSelect } from '../../common/TokenSelect'; +import { Notice } from '../../common/ScreenFrame'; +import { useBackButton } from '../../../hooks/useBackButton'; +import BluetoothIcon from '../../icons/BluetoothIcon'; +import type { TokenBalanceView } from '../../../dsm/types'; + +type Direction = 'load' | 'unload'; + +type Props = { + balances: TokenBalanceView[]; + /** The token the send form has selected; the pop-up starts on it. */ + initialTokenId: string; + /** Re-reads the wallet after a move, so both balances shown are the store's. */ + onMoved: () => Promise; + onClose: () => void; +}; + +export function OfflineFundingPopover({ balances, initialTokenId, onMoved, onClose }: Props): React.JSX.Element { + const [tokenId, setTokenId] = useState(initialTokenId); + const [direction, setDirection] = useState('load'); + const [amount, setAmount] = useState(''); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null); + const dialogRef = useRef(null); + + useBackButton(true, onClose); + useEffect(() => { + dialogRef.current?.focus(); + }, []); + + const selected = useMemo(() => balances.find((b) => b.tokenId === tokenId) ?? null, [balances, tokenId]); + + const move = useCallback(async () => { + if (!selected || !amount.trim()) return; + setBusy(true); + setNotice(null); + try { + const res = direction === 'load' + ? await dsmClient.loadOfflineCash(selected.tokenId, amount) + : await dsmClient.unloadOfflineCash(selected.tokenId, amount); + setNotice({ kind: 'success', text: res.message }); + setAmount(''); + await onMoved(); + } catch (e) { + setNotice({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); + } finally { + setBusy(false); + } + }, [selected, amount, direction, onMoved]); + + const decimals = selected?.decimals ?? 0; + + return ( +
{ e.stopPropagation(); onClose(); }}> +
e.stopPropagation()} + > +
+ +

Offline Funding

+ +
+
+ {selected ? ( + <> +
+ Online account + {`${selected.displayAmount} ${selected.symbol}`} +
+
+ Offline allocation + + {selected.offline + ? `${selected.offline.displayAmount} ${selected.symbol}` + : 'unknown until the appliance connects'} + +
+ + ) : ( +
No balances to move yet.
+ )} +
+ Direction +
+ + +
+
+
+ +
+ 0 ? `0.${'0'.repeat(decimals - 1)}1` : '1'} + min="0" + value={amount} + onChange={(e) => setAmount(e.target.value)} + placeholder={decimals > 0 ? `0.${'0'.repeat(decimals)}` : '0'} + className="sb-input sb-input--mono" + /> + ({ value: b.tokenId, ticker: b.symbol, iconUrl: b.iconUrl }))} + onChange={setTokenId} + /> +
+
+ {notice && {notice.text}} +
+
+ + +
+
+
+ ); +} diff --git a/dsm_client/frontend/src/components/screens/wallet/OverviewTab.tsx b/dsm_client/frontend/src/components/screens/wallet/OverviewTab.tsx index b14fb8b55..419e9fa40 100644 --- a/dsm_client/frontend/src/components/screens/wallet/OverviewTab.tsx +++ b/dsm_client/frontend/src/components/screens/wallet/OverviewTab.tsx @@ -5,6 +5,7 @@ import TransactionItem from './TransactionItem'; import { Disclosure } from '../../common/ScreenFrame'; import type { TokenBalanceView } from '../../../dsm/types'; import { TokenMark } from '../../TokenMark'; +import BluetoothIcon from '../../icons/BluetoothIcon'; import type { DomainTransaction } from '../../../domain/types'; const MAX_OVERVIEW_BALANCES = 5; @@ -50,13 +51,27 @@ function OverviewTabInner({ balances, balancesLoading, transactions, genesisB32, ) : ( <> {visibleBalances.map((b) => ( -
- - - {b.symbol} - - {b.displayAmount} -
+ +
+ + + {b.symbol} + + {b.displayAmount} +
+ {/* Cash in hand, under the token it belongs to: the offline + allocation an offline send spends, which the amount above + does not include. Shown once there is some. */} + {b.offline && b.offline.baseUnits > BigInt(0) && ( +
+ + + offline + + {b.offline.displayAmount} +
+ )} +
))} {balances.length > MAX_OVERVIEW_BALANCES && ( {txMode === 'offline' && ( - - Offline needs Bluetooth. Both appliances next to each other, Bluetooth on. - + <> + + Offline needs Bluetooth. Both appliances next to each other, Bluetooth on. + + {/* The two things an offline send needs besides Bluetooth: tokens + in the offline allocation, and the appliance that spends them. */} +
+ + +

Your wallet keeps two balances of each token. The online account spends through the storage nodes. The offline allocation is cash in hand: it spends appliance to appliance over Bluetooth, guarded by the anchor appliance, and an offline send draws from it.

+

Load moves tokens from the online account into the offline allocation. Unload moves them back. Both need the appliance connected, and both change only which of your two balances holds the tokens.

+
+ +
+ )} @@ -296,6 +360,22 @@ function SendTabInner({ onConfirm={() => { setShowSendConfirm(false); void handleSendTransaction(); }} onCancel={() => setShowSendConfirm(false)} /> + {fundingOpen && ( + setFundingOpen(false)} + /> + )} + {applianceOpen && ( + setApplianceOpen(false)} + /> + )} ); } diff --git a/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offlineFunding.test.tsx b/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offlineFunding.test.tsx new file mode 100644 index 000000000..27de304db --- /dev/null +++ b/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offlineFunding.test.tsx @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Offline mode on the send tab shows the two things an offline send needs +//! besides Bluetooth: Offline Funding, which moves a token between the online +//! account and the offline allocation, and Appliance, which connects the +//! anchor appliance. Choosing Offline connects the appliance by itself. + +import React from 'react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +jest.mock('../../../fx/FxProvider', () => ({ + useFx: () => ({ play: jest.fn(), dismiss: jest.fn() }), +})); + +jest.mock('../../../../services/dsmClient', () => ({ + dsmClient: { + sendOfflineTransfer: jest.fn(), + sendOnlineTransferSmart: jest.fn(), + loadOfflineCash: jest.fn(), + unloadOfflineCash: jest.fn(), + }, +})); + +jest.mock('../../../../dsm/anchor', () => ({ getAnchorStatus: jest.fn() })); + +import { dsmClient } from '../../../../services/dsmClient'; +import { getAnchorStatus } from '../../../../dsm/anchor'; +import SendTab from '../SendTab'; + +const disconnected = { + connected: false, + anchorIdB32: '', + pkChipB32: '', + anchorCounter: 0n, + frontierRootB32: '', + enrolledCounter: 0n, + statusText: 'no anchor appliance connected', +}; +const connected = { + ...disconnected, + connected: true, + anchorIdB32: 'ANCH0R1DXX', + anchorCounter: 7n, + frontierRootB32: 'FR0NT1ERXX', + statusText: 'anchor connected (counter u=7)', +}; + +const era = (offline?: { baseUnits: bigint; displayAmount: string }) => ({ + tokenId: 'ERA', symbol: 'ERA', tokenName: 'ERA', baseUnits: 90n, displayAmount: '90', decimals: 0, protocolDefined: true, offline, +}); + +function renderSend(props: { balances?: any[]; loadWalletData?: jest.Mock } = {}) { + const loadWalletData = props.loadWalletData ?? jest.fn().mockResolvedValue(undefined); + render( + , + ); + return { loadWalletData }; +} + +function renderOffline(props: { balances?: any[]; loadWalletData?: jest.Mock } = {}) { + const out = renderSend(props); + fireEvent.click(screen.getByRole('button', { name: 'Offline' })); + return out; +} + +describe('SendTab offline funding and appliance', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getAnchorStatus as jest.Mock).mockResolvedValue(disconnected); + }); + + it('online mode shows the online amount and never touches the appliance', () => { + renderSend(); + expect(screen.getByText('90')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Offline Funding' })).not.toBeInTheDocument(); + expect(getAnchorStatus).not.toHaveBeenCalled(); + }); + + it('offline mode shows the offline allocation, the two controls, and reads the appliance by itself', async () => { + renderOffline(); + expect(screen.getByRole('button', { name: 'Offline Funding' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'About offline funding' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Appliance/ })).toBeInTheDocument(); + // The card shows the pot an offline send spends, not the online amount. + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.queryByText('90')).not.toBeInTheDocument(); + await waitFor(() => expect(getAnchorStatus).toHaveBeenCalledTimes(1)); + }); + + it('says the offline allocation is unknown until the appliance connects, never 0', () => { + renderOffline({ balances: [era(undefined)] }); + expect(screen.getByText('—')).toBeInTheDocument(); + expect(screen.getByText(/unknown until the appliance connects/)).toBeInTheDocument(); + expect(screen.queryByText('0')).not.toBeInTheDocument(); + }); + + it('re-reads the wallet once the appliance is connected, so the balances can name their allocations', async () => { + (getAnchorStatus as jest.Mock).mockResolvedValue(connected); + const { loadWalletData } = renderOffline({ balances: [era(undefined)] }); + await waitFor(() => expect(loadWalletData).toHaveBeenCalledTimes(1)); + }); + + it("Offline Funding loads through Rust with the amount as typed and shows Rust's answer", async () => { + (dsmClient.loadOfflineCash as jest.Mock).mockResolvedValue({ + onlineDisplay: '85', + allocationDisplay: '15', + message: 'loaded 5 ERA — offline allocation now 15, online 85', + }); + const { loadWalletData } = renderOffline(); + fireEvent.click(screen.getByRole('button', { name: 'Offline Funding' })); + const dialog = screen.getByRole('dialog', { name: 'Offline Funding' }); + expect(within(dialog).getByText('90 ERA')).toBeInTheDocument(); + expect(within(dialog).getByText('10 ERA')).toBeInTheDocument(); + fireEvent.change(within(dialog).getByLabelText('Amount'), { target: { value: '5' } }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Load' })); + await waitFor(() => expect(dsmClient.loadOfflineCash).toHaveBeenCalledWith('ERA', '5')); + expect(await within(dialog).findByText(/offline allocation now 15/)).toBeInTheDocument(); + expect(loadWalletData).toHaveBeenCalledTimes(1); + expect(dsmClient.unloadOfflineCash).not.toHaveBeenCalled(); + }); + + it('Unload to online goes the other way through Rust', async () => { + (dsmClient.unloadOfflineCash as jest.Mock).mockResolvedValue({ + onlineDisplay: '95', + allocationDisplay: '5', + message: 'unloaded 5 ERA — offline allocation now 5, online 95', + }); + renderOffline(); + fireEvent.click(screen.getByRole('button', { name: 'Offline Funding' })); + const dialog = screen.getByRole('dialog', { name: 'Offline Funding' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Unload to online' })); + fireEvent.change(within(dialog).getByLabelText('Amount'), { target: { value: '5' } }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Unload' })); + await waitFor(() => expect(dsmClient.unloadOfflineCash).toHaveBeenCalledWith('ERA', '5')); + expect(await within(dialog).findByText(/offline allocation now 5/)).toBeInTheDocument(); + expect(dsmClient.loadOfflineCash).not.toHaveBeenCalled(); + }); + + it("shows Rust's refusal in Rust's words and re-reads nothing", async () => { + (dsmClient.loadOfflineCash as jest.Mock).mockRejectedValue( + new Error('wallet.loadOffline: connect your anchor device to manage offline cash'), + ); + const { loadWalletData } = renderOffline(); + fireEvent.click(screen.getByRole('button', { name: 'Offline Funding' })); + const dialog = screen.getByRole('dialog', { name: 'Offline Funding' }); + fireEvent.change(within(dialog).getByLabelText('Amount'), { target: { value: '5' } }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Load' })); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('connect your anchor device'); + expect(loadWalletData).not.toHaveBeenCalled(); + }); + + it('Appliance opens the setup pop-up, and Connect reads the appliance again', async () => { + renderOffline(); + await waitFor(() => expect(getAnchorStatus).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole('button', { name: /^Appliance/ })); + const dialog = screen.getByRole('dialog', { name: 'Appliance' }); + expect(within(dialog).getByTestId('appliance-state')).toHaveTextContent('no anchor appliance connected'); + (getAnchorStatus as jest.Mock).mockResolvedValue(connected); + fireEvent.click(within(dialog).getByRole('button', { name: 'Connect' })); + await waitFor(() => + expect(within(dialog).getByTestId('appliance-state')).toHaveTextContent('anchor connected (counter u=7)'), + ); + expect(getAnchorStatus).toHaveBeenCalledTimes(2); + expect(within(dialog).getByText('ANCH0R1DXX')).toBeInTheDocument(); + expect(within(dialog).getByText('7')).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Check again' })).toBeInTheDocument(); + }); +}); diff --git a/dsm_client/frontend/src/components/tour/GuidedTour.tsx b/dsm_client/frontend/src/components/tour/GuidedTour.tsx index 14a85b0e8..b061655ba 100644 --- a/dsm_client/frontend/src/components/tour/GuidedTour.tsx +++ b/dsm_client/frontend/src/components/tour/GuidedTour.tsx @@ -196,6 +196,11 @@ export default function GuidedTour({ appState }: Props): React.JSX.Element | nul tourStore.next(); return; } + if (wait.kind === 'gone' && here && !find(wait.selector)) { + play('tick'); + tourStore.next(); + return; + } if (wait.kind === 'value' && here) { const field = find(wait.selector) as HTMLInputElement | HTMLSelectElement | null; const value = field ? field.value : ''; diff --git a/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts b/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts index 76f14c669..4080c3966 100644 --- a/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts +++ b/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts @@ -36,4 +36,9 @@ describe('practice mode answers as the real calls do', () => { const online = await client.sendOnlineTransferSmart(PRACTICE_CONTACT_ALIAS, '5', undefined, ''); expect(online).toEqual({ success: false, message: expect.stringContaining('names no token') }); }); + + it('refuses moving offline cash: practice never touches the real allocation', async () => { + await expect(client.loadOfflineCash('PLAY', '5')).rejects.toThrow(/Practice mode/); + await expect(client.unloadOfflineCash('PLAY', '5')).rejects.toThrow(/Practice mode/); + }); }); diff --git a/dsm_client/frontend/src/components/tour/__tests__/tour.test.tsx b/dsm_client/frontend/src/components/tour/__tests__/tour.test.tsx index bdbbf8cf2..13c915200 100644 --- a/dsm_client/frontend/src/components/tour/__tests__/tour.test.tsx +++ b/dsm_client/frontend/src/components/tour/__tests__/tour.test.tsx @@ -289,8 +289,16 @@ describe('tour anchors', () => { ), ); - it('the steps name at least the five tour anchors', () => { - expect(anchors.sort()).toEqual(['contacts-tabs', 'create-token', 'faucet-claim', 'tokens-tabs', 'tutorial-button']); + it('the steps name at least the seven tour anchors', () => { + expect(anchors.sort()).toEqual([ + 'appliance-setup', + 'contacts-tabs', + 'create-token', + 'faucet-claim', + 'offline-funding', + 'tokens-tabs', + 'tutorial-button', + ]); }); it.each(anchors)('data-tour="%s" exists in the app', (anchor) => { diff --git a/dsm_client/frontend/src/components/tour/practiceMode.ts b/dsm_client/frontend/src/components/tour/practiceMode.ts index 47b6ff606..030afa4b3 100644 --- a/dsm_client/frontend/src/components/tour/practiceMode.ts +++ b/dsm_client/frontend/src/components/tour/practiceMode.ts @@ -24,7 +24,7 @@ export const PRACTICE_BLOCKED_MESSAGE = /** Calls whose names start like this change state, so practice mode refuses them. */ const STATE_CHANGING = - /^(send|create|claim|add|remove|delete|publish|withdraw|deposit|swap|import|export|mint|burn|update|accept|reject|register|approve|revoke|write|reset|close|open|unlock|lock|execute|submit|broadcast|sign|pair|unpair|sync|reconcile|recover|restore|enroll|admit|fund|redeem|transfer|post|put|store|bind|advance|commit|finalize|apply|generate|start|stop|cancel|retry|refresh|clear|forget|rotate|set)/; + /^(send|load|unload|create|claim|add|remove|delete|publish|withdraw|deposit|swap|import|export|mint|burn|update|accept|reject|register|approve|revoke|write|reset|close|open|unlock|lock|execute|submit|broadcast|sign|pair|unpair|sync|reconcile|recover|restore|enroll|admit|fund|redeem|transfer|post|put|store|bind|advance|commit|finalize|apply|generate|start|stop|cancel|retry|refresh|clear|forget|rotate|set)/; /** Real even in practice: display preferences only. */ const ALWAYS_REAL = new Set(['getPreference', 'setPreference']); diff --git a/dsm_client/frontend/src/components/tour/tourSteps.ts b/dsm_client/frontend/src/components/tour/tourSteps.ts index c80f58b89..1bca87ea4 100644 --- a/dsm_client/frontend/src/components/tour/tourSteps.ts +++ b/dsm_client/frontend/src/components/tour/tourSteps.ts @@ -12,6 +12,8 @@ export type TourWait = | { kind: 'screen'; screen: ScreenType } /** Move on once this element is on screen. */ | { kind: 'selector'; selector: string } + /** Move on once nothing on screen matches this selector any more. */ + | { kind: 'gone'; selector: string } /** Move on once this field holds a value (a positive number when `positive`). */ | { kind: 'value'; selector: string; positive?: boolean } /** Move on once practice mode reports this action. */ @@ -122,7 +124,39 @@ export const TOUR_STEPS: ReadonlyArray = [ screen: 'wallet', target: '.send-tab [aria-label="Transaction mode"]', title: 'Online or offline', - body: "Online goes through the storage nodes and waits in their inbox, even if they're asleep. Offline goes phone to phone over Bluetooth when you're side by side. Keep Online for now.", + body: "Online goes through the storage nodes and waits in their inbox, even if they're asleep. Offline goes phone to phone over Bluetooth when you're side by side. Let's look at Offline for a moment.", + }, + { + id: 'go-offline', + screen: 'wallet', + target: '.send-tab [aria-label="Transaction mode"] button:nth-of-type(2)', + title: 'Try Offline', + body: 'Tap Offline. An offline send needs two more things besides Bluetooth, and they appear here.', + wait: { kind: 'selector', selector: '[data-tour="offline-funding"]' }, + prompt: 'Tap Offline', + }, + { + id: 'offline-funding', + screen: 'wallet', + target: '[data-tour="offline-funding"]', + title: 'Offline funding', + body: 'Offline sends spend from a separate pot: your offline allocation, which the anchor appliance guards. Offline Funding moves tokens from your online account into that pot, and back again. The i beside it explains the two balances.', + }, + { + id: 'appliance', + screen: 'wallet', + target: '[data-tour="appliance-setup"]', + title: 'The appliance', + body: "The DSM Anchor appliance plugs into this phone's USB port. Appliance shows whether it is connected and walks you through the first plug-in. Android asks for permission once; after that it connects on its own whenever you choose Offline.", + }, + { + id: 'go-online', + screen: 'wallet', + target: '.send-tab [aria-label="Transaction mode"] button:nth-of-type(1)', + title: 'Back to Online', + body: "We'll send online in this tour. Tap Online.", + wait: { kind: 'gone', selector: '[data-tour="offline-funding"]' }, + prompt: 'Tap Online', }, { id: 'info', diff --git a/dsm_client/frontend/src/dsm/__tests__/balanceWireContract.test.ts b/dsm_client/frontend/src/dsm/__tests__/balanceWireContract.test.ts index d38558c8f..65a91e820 100644 --- a/dsm_client/frontend/src/dsm/__tests__/balanceWireContract.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/balanceWireContract.test.ts @@ -54,6 +54,11 @@ describe('balance wire contract (Rust -> TypeScript)', () => { expect(row.permissions).toBeDefined(); expect(row.permissions.burnEnabled).toBe(true); expect(row.permissions.transferable).toBe(false); + // The offline allocation, PRESENT with its rendered form. Absent would + // mean no appliance has stated a bundle, and the wallet prints nothing. + expect(row.offlineAllocation).toBeDefined(); + expect(row.offlineAllocation.baseUnits).toBe(2500n); + expect(row.offlineAllocation.displayAmount).toBe('25.00'); }); /// decimals must arrive as a real number, since the mapper's guard is 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 d9ef898d6..c3fb173c5 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,4 +1,5 @@ -’Ã -À +’Ï +Ì RIGB �"RIGB(2RigBravo:1000.00B4B9D5MPJTB9D5MPJTB9D5MPJTB9D5MPJTB9D5MPJTB9D5MPJTB9D0JB9D5MPJTR4QMK5SY91DSJDY8KHAP6CCTWW80X7GHTVKFZ0KXTHAGQSTMFGV3GGZdsm:coin:v1:FIXTUREj -1000000.00r \ No newline at end of file +1000000.00rz +Ä25.00 \ No newline at end of file diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineCash.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineCash.test.ts new file mode 100644 index 000000000..bafb42ce5 --- /dev/null +++ b/dsm_client/frontend/src/dsm/__tests__/offlineCash.test.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +//! Loading and unloading offline cash carries the user's text to Rust and +//! Rust's rendered balances back; anything but Rust's answer is an error. + +jest.mock('../WebViewBridge', () => ({ routerInvokeBin: jest.fn() })); + +import * as pb from '../../proto/dsm_app_pb'; +import { loadOfflineCash, unloadOfflineCash } from '../offlineCash'; +import { routerInvokeBin } from '../WebViewBridge'; + +function framed(payload: any): Uint8Array { + const bytes = new pb.Envelope({ version: 3, payload }).toBinary(); + const out = new Uint8Array(1 + bytes.length); + out[0] = 0x03; + out.set(bytes, 1); + return out; +} + +const moved = (message: string, online: string, allocation: string) => + framed({ + case: 'offlineCashResponse', + value: new pb.OfflineCashResponse({ + success: true, + onlineBalance: 85n, + allocationBalance: 15n, + onlineDisplay: online, + allocationDisplay: allocation, + message, + }), + }); + +describe('offline cash', () => { + beforeEach(() => jest.clearAllMocks()); + + it('sends the token and the amount as typed, and returns what Rust rendered', async () => { + (routerInvokeBin as jest.Mock).mockResolvedValue(moved('loaded 5 ERA — offline allocation now 15, online 85', '85', '15')); + const res = await loadOfflineCash('ERA', ' 5 '); + expect(res).toEqual({ onlineDisplay: '85', allocationDisplay: '15', message: expect.stringContaining('loaded 5 ERA') }); + const [method, args] = (routerInvokeBin as jest.Mock).mock.calls[0]; + expect(method).toBe('wallet.loadOffline'); + const pack = pb.ArgPack.fromBinary(args); + expect(pack.codec).toBe(pb.Codec.PROTO); + const req = pb.OfflineCashRequest.fromBinary(pack.body); + expect([req.tokenId, req.amount]).toEqual(['ERA', '5']); + }); + + it('unload goes to wallet.unloadOffline', async () => { + (routerInvokeBin as jest.Mock).mockResolvedValue(moved('unloaded 5 ERA — offline allocation now 5, online 95', '95', '5')); + await unloadOfflineCash('ERA', '5'); + expect((routerInvokeBin as jest.Mock).mock.calls[0][0]).toBe('wallet.unloadOffline'); + }); + + it("throws Rust's refusal in Rust's words", async () => { + (routerInvokeBin as jest.Mock).mockResolvedValue( + framed({ case: 'error', value: new pb.Error({ code: 7, message: 'wallet.loadOffline: connect your anchor device to manage offline cash' }) }), + ); + await expect(loadOfflineCash('ERA', '5')).rejects.toThrow(/connect your anchor device/); + }); + + it('refuses an answer that is not the move', async () => { + (routerInvokeBin as jest.Mock).mockResolvedValue( + framed({ case: 'bilateralPrepareResponse', value: new pb.BilateralPrepareResponse({}) }), + ); + await expect(loadOfflineCash('ERA', '5')).rejects.toThrow(/not offlineCashResponse/); + }); + + it('refuses an answer without its rendered balances', async () => { + (routerInvokeBin as jest.Mock).mockResolvedValue(moved('moved', '', '')); + await expect(loadOfflineCash('ERA', '5')).rejects.toThrow(/STRICT/); + }); +}); diff --git a/dsm_client/frontend/src/dsm/__tests__/wallet.test.ts b/dsm_client/frontend/src/dsm/__tests__/wallet.test.ts index e7813c4d3..db1a7b3b3 100644 --- a/dsm_client/frontend/src/dsm/__tests__/wallet.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/wallet.test.ts @@ -140,6 +140,31 @@ describe('wallet.ts', () => { await expect(getAllBalances()).rejects.toThrow(/STRICT.*created token RIGB without its policy facts/); }); + test('carries the offline allocation as Rust stated it, and absent as unknown', async () => { + const answer = (row: pb.BalanceGetResponse) => + frameEnvelope(new pb.Envelope({ + version: 3, + payload: { case: 'balancesListResponse', value: new pb.BalancesListResponse({ balances: [row] }) }, + })); + const era = { tokenId: 'ERA', available: 90n, symbol: 'ERA', decimals: 0, tokenName: 'ERA', displayAmount: '90', protocolDefined: true }; + + // No appliance has stated a bundle: the row carries no allocation, and + // the view says unknown rather than zero. + (getAllBalancesStrictBridge as jest.Mock).mockResolvedValue(answer(new pb.BalanceGetResponse(era))); + expect((await getAllBalances())[0].offline).toBeUndefined(); + + (getAllBalancesStrictBridge as jest.Mock).mockResolvedValue( + answer(new pb.BalanceGetResponse({ ...era, offlineAllocation: { baseUnits: 10n, displayAmount: '10' } })), + ); + expect((await getAllBalances())[0].offline).toEqual({ baseUnits: 10n, displayAmount: '10' }); + + // Present without its rendered form is a row Rust did not finish. + (getAllBalancesStrictBridge as jest.Mock).mockResolvedValue( + answer(new pb.BalanceGetResponse({ ...era, offlineAllocation: { baseUnits: 10n } })), + ); + await expect(getAllBalances()).rejects.toThrow(/STRICT.*ERA's offline allocation without its display form/); + }); + test('throws on error envelope', async () => { const env = new pb.Envelope({ version: 3, diff --git a/dsm_client/frontend/src/dsm/index.ts b/dsm_client/frontend/src/dsm/index.ts index dcc73caea..bb3906f7d 100644 --- a/dsm_client/frontend/src/dsm/index.ts +++ b/dsm_client/frontend/src/dsm/index.ts @@ -16,6 +16,7 @@ // dlv — Deterministic Limbo Vaults (sovereign finance primitives) // storage — Storage node communication // transactions — Bilateral/unilateral transfer logic +// offlineCash — moving a token between the online account and the offline allocation // diagnostics — telemetry, debug // // BRIDGE HELPER RE-EXPORTS (not part of the curated `dsmClient` namespace): @@ -43,6 +44,7 @@ export * from './wallet'; export * from './policies'; export * from './storage'; export * from './transactions'; +export * from './offlineCash'; export * from './diagnostics'; // Re-export bridge helpers used by external consumers. @@ -62,6 +64,7 @@ import * as Wallet from './wallet'; import * as Policies from './policies'; import * as Storage from './storage'; import * as Transactions from './transactions'; +import * as OfflineCash from './offlineCash'; import * as Diagnostics from './diagnostics'; // Flat namespace export for consumers that prefer object-style access. @@ -72,5 +75,6 @@ export const dsmClient = { ...Policies, ...Storage, ...Transactions, + ...OfflineCash, ...Diagnostics, }; diff --git a/dsm_client/frontend/src/dsm/offlineCash.ts b/dsm_client/frontend/src/dsm/offlineCash.ts new file mode 100644 index 000000000..db5383fcc --- /dev/null +++ b/dsm_client/frontend/src/dsm/offlineCash.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// +// Moving a token between this device's two balances: the online account, +// spent through the storage nodes, and the offline allocation the anchor +// appliance spends over Bluetooth. Rust makes the move and scales the amount; +// this layer carries the user's text in and Rust's rendered figures out. + +import * as pb from '../proto/dsm_app_pb'; +import { routerInvokeBin } from './WebViewBridge'; +import { decodeFramedEnvelopeV3 } from './decoding'; + +/** What Rust answered a load or unload: both balances after the move, rendered by Rust. */ +export interface OfflineCashMove { + onlineDisplay: string; + allocationDisplay: string; + /** Rust's own sentence about the move. */ + message: string; +} + +async function moveOfflineCash( + method: 'wallet.loadOffline' | 'wallet.unloadOffline', + tokenId: string, + amount: string, +): Promise { + const body = new Uint8Array(new pb.OfflineCashRequest({ tokenId, amount: amount.trim() }).toBinary()); + const arg = new pb.ArgPack({ codec: pb.Codec.PROTO, body }); + const env = decodeFramedEnvelopeV3(await routerInvokeBin(method, new Uint8Array(arg.toBinary()))); + if (env.payload.case === 'error') { + throw new Error(env.payload.value.message || `${method}: error code ${env.payload.value.code}`); + } + if (env.payload.case !== 'offlineCashResponse') { + throw new Error(`${method}: the SDK answered ${String(env.payload.case)}, not offlineCashResponse`); + } + const res = env.payload.value; + if (!res.success) { + throw new Error(res.message || `${method}: the SDK refused the move`); + } + if (res.onlineDisplay.length === 0 || res.allocationDisplay.length === 0) { + throw new Error(`STRICT: ${method} answered without its rendered balances`); + } + return { onlineDisplay: res.onlineDisplay, allocationDisplay: res.allocationDisplay, message: res.message }; +} + +/** Load `amount` (decimal text, as typed) of `tokenId` from the online account into the offline allocation. */ +export function loadOfflineCash(tokenId: string, amount: string): Promise { + return moveOfflineCash('wallet.loadOffline', tokenId, amount); +} + +/** Unload `amount` (decimal text, as typed) of `tokenId` from the offline allocation back to the online account. */ +export function unloadOfflineCash(tokenId: string, amount: string): Promise { + return moveOfflineCash('wallet.unloadOffline', tokenId, amount); +} diff --git a/dsm_client/frontend/src/dsm/types.ts b/dsm_client/frontend/src/dsm/types.ts index ca61a91d6..214f9ad33 100644 --- a/dsm_client/frontend/src/dsm/types.ts +++ b/dsm_client/frontend/src/dsm/types.ts @@ -182,6 +182,21 @@ export interface TokenBalanceView { genesisSupplyDisplay?: string; /** What the committed policy permits, as Rust read it; absent when Rust holds no policy for the token. */ permissions?: TokenPolicyPermissionsView; + /** + * Cash in hand: what this device has loaded into its offline allocation of + * the token, as Rust read it under the attached appliance's bundle. An + * offline send spends from it; it is not part of `baseUnits`. Absent until + * an anchor appliance has been attached since the app started: unknown, + * never zero. + */ + offline?: OfflineAllocationView; +} + +/** A token's offline allocation on this device, as Rust reported it. */ +export interface OfflineAllocationView { + baseUnits: bigint; + /** Display form of `baseUnits`, rendered by Rust. Never computed here. */ + displayAmount: string; } /** The permission flags of a committed token policy, as Rust read them. */ diff --git a/dsm_client/frontend/src/dsm/wallet.ts b/dsm_client/frontend/src/dsm/wallet.ts index ecf4620ae..e33025289 100644 --- a/dsm_client/frontend/src/dsm/wallet.ts +++ b/dsm_client/frontend/src/dsm/wallet.ts @@ -7,7 +7,7 @@ import { getWalletHistoryStrictBridge, getInboxStrictBridge, } from './WebViewBridge'; -import { TokenBalanceView, WalletHistory, InboxItemView } from './types'; +import { TokenBalanceView, WalletHistory, InboxItemView, OfflineAllocationView } from './types'; import { decodeFramedEnvelopeV3 } from './decoding'; import { mapTransactions } from '../domain/mappers'; import logger from '../utils/logger'; @@ -17,6 +17,20 @@ function named(value: string): string | undefined { return value.length > 0 ? value : undefined; } +/** + * The row's offline allocation as Rust stated it. Absent is unknown, not + * zero; present without its rendered form is a row Rust did not finish, + * refused like any other. + */ +function offlineView(b: pb.BalanceGetResponse): OfflineAllocationView | undefined { + const offline = b.offlineAllocation; + if (!offline) return undefined; + if (offline.displayAmount.length === 0) { + throw new Error(`STRICT: balance.list answered ${b.tokenId}'s offline allocation without its display form`); + } + return { baseUnits: offline.baseUnits, displayAmount: offline.displayAmount }; +} + export async function getAllBalances(): Promise { const responseBytes = await getAllBalancesStrictBridge(); const env = decodeFramedEnvelopeV3(responseBytes); @@ -65,6 +79,7 @@ export async function getAllBalances(): Promise { permissions: b.permissions ? { burnEnabled: b.permissions.burnEnabled, transferable: b.permissions.transferable } : undefined, + offline: offlineView(b), }; }); } diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts index 4d14546ba..63e29f1cd 100644 --- a/dsm_client/frontend/src/proto/dsm_app_pb.ts +++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts @@ -12574,11 +12574,11 @@ export class OfflineCashRequest extends Message { tokenId = ""; /** - * base units to load (online -> allocation) or unload (allocation -> online) + * decimal, as the user typed it; the SDK scales it by the token's decimals * - * @generated from field: uint64 amount = 2; + * @generated from field: string amount = 2; */ - amount = protoInt64.zero; + amount = ""; constructor(data?: PartialMessage) { super(); @@ -12589,7 +12589,7 @@ export class OfflineCashRequest extends Message { static readonly typeName = "dsm.OfflineCashRequest"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "token_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "amount", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, + { no: 2, name: "amount", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): OfflineCashRequest { @@ -12644,6 +12644,20 @@ export class OfflineCashResponse extends Message { */ message = ""; + /** + * display form of `online_balance`, rendered by Rust + * + * @generated from field: string online_display = 6; + */ + onlineDisplay = ""; + + /** + * display form of `allocation_balance`, rendered by Rust + * + * @generated from field: string allocation_display = 7; + */ + allocationDisplay = ""; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -12657,6 +12671,8 @@ export class OfflineCashResponse extends Message { { no: 3, name: "allocation_balance", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, { no: 4, name: "device_root", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, { no: 5, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "online_display", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "allocation_display", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): OfflineCashResponse { @@ -21421,6 +21437,16 @@ export class BalanceGetResponse extends Message { */ permissions?: TokenPolicyPermissions; + /** + * The token's offline allocation on this device, stated only once an anchor + * appliance has been attached since the app started: the allocation leaf is + * keyed by the appliance's bundle, and only the appliance states its bundle. + * Absent means unknown, never zero. + * + * @generated from field: dsm.OfflineAllocationView offline_allocation = 15; + */ + offlineAllocation?: OfflineAllocationView; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -21443,6 +21469,7 @@ export class BalanceGetResponse extends Message { { 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 }, + { no: 15, name: "offline_allocation", kind: "message", T: OfflineAllocationView }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): BalanceGetResponse { @@ -21462,6 +21489,55 @@ export class BalanceGetResponse extends Message { } } +/** + * Cash in hand: what this device has loaded into its offline allocation of a + * token, as Rust read it under the attached appliance's bundle. An offline send + * spends from it; it is not part of `available`. + * + * @generated from message dsm.OfflineAllocationView + */ +export class OfflineAllocationView extends Message { + /** + * @generated from field: uint64 base_units = 1; + */ + baseUnits = protoInt64.zero; + + /** + * Display form of `base_units`, rendered by Rust. + * + * @generated from field: string display_amount = 2; + */ + displayAmount = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "dsm.OfflineAllocationView"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "base_units", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, + { no: 2, name: "display_amount", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): OfflineAllocationView { + return new OfflineAllocationView().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): OfflineAllocationView { + return new OfflineAllocationView().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): OfflineAllocationView { + return new OfflineAllocationView().fromJsonString(jsonString, options); + } + + static equals(a: OfflineAllocationView | PlainMessage | undefined, b: OfflineAllocationView | PlainMessage | undefined): boolean { + return proto3.util.equals(OfflineAllocationView, a, b); + } +} + /** * The permission flags of a committed token policy, as Core's one parser read * them (SoFi §47). diff --git a/dsm_client/frontend/src/styles/screen.css b/dsm_client/frontend/src/styles/screen.css index 7385eb47b..efa4ed09d 100644 --- a/dsm_client/frontend/src/styles/screen.css +++ b/dsm_client/frontend/src/styles/screen.css @@ -650,7 +650,7 @@ button.sb-menu__item { width: 100%; font: inherit; color: inherit; text-align: l .sb-popover__body p { margin: 0 0 8px; } .sb-popover__body p:last-child { margin-bottom: 0; } .sb-popover__body b { font-weight: 700; } -.sb-popover__body ul { margin: 0 0 8px; padding-left: 16px; } +.sb-popover__body ul, .sb-popover__body ol { margin: 0 0 8px; padding-left: 16px; } .sb-popover__body li { margin-bottom: 4px; } .sb-popover .sb-popover__ok { background: var(--bg); diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto index d9ae3e7c7..cb02016fb 100644 --- a/proto/dsm_app.proto +++ b/proto/dsm_app.proto @@ -2032,7 +2032,7 @@ message AnchorStatusResponse { // inside ArgPack.body for wallet.loadOffline / wallet.unloadOffline. message OfflineCashRequest { string token_id = 1; // asset to move between regimes (resolved to a CPTA policy_commit) - uint64 amount = 2; // base units to load (online -> allocation) or unload (allocation -> online) + string amount = 2; // decimal, as the user typed it; the SDK scales it by the token's decimals } message OfflineCashResponse { @@ -2041,6 +2041,8 @@ message OfflineCashResponse { uint64 allocation_balance = 3; // device-bound offline allocation AFTER the move bytes device_root = 4 [(dsm_fixed_len)=32]; // new device SMT root r_A string message = 5; + string online_display = 6; // display form of `online_balance`, rendered by Rust + string allocation_display = 7; // display form of `allocation_balance`, rendered by Rust } // =========================== Bluetooth Transport ==================== @@ -3433,6 +3435,20 @@ message BalanceGetResponse { // token: "not stated" and "not permitted" are different facts, and a // defaulted bool cannot tell them apart. TokenPolicyPermissions permissions = 14; + // The token's offline allocation on this device, stated only once an anchor + // appliance has been attached since the app started: the allocation leaf is + // keyed by the appliance's bundle, and only the appliance states its bundle. + // Absent means unknown, never zero. + OfflineAllocationView offline_allocation = 15; +} + +// Cash in hand: what this device has loaded into its offline allocation of a +// token, as Rust read it under the attached appliance's bundle. An offline send +// spends from it; it is not part of `available`. +message OfflineAllocationView { + uint64 base_units = 1; + // Display form of `base_units`, rendered by Rust. + string display_amount = 2; } // The permission flags of a committed token policy, as Core's one parser read // them (SoFi §47). From 0402d190f202281195a2be644671e5d7d5164989 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:54:22 -0400 Subject: [PATCH 2/2] chore(anchor): the anchor crate's lockfile follows the SDK's dependency removals Building the on-device library from the current SDK re-resolved its lock: the dependencies the Core and SDK dropped in the placeholder sweep leave this lockfile too. --- crates/dsm-android-anchor/Cargo.lock | 61 ---------------------------- 1 file changed, 61 deletions(-) diff --git a/crates/dsm-android-anchor/Cargo.lock b/crates/dsm-android-anchor/Cargo.lock index 5e7219deb..8c356cd2c 100644 --- a/crates/dsm-android-anchor/Cargo.lock +++ b/crates/dsm-android-anchor/Cargo.lock @@ -883,27 +883,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -1039,11 +1018,9 @@ dependencies = [ "chacha20poly1305", "crc32fast", "ctor", - "dirs", "dsm", "dsm-anchor-core", "dsm-anchor-verifier", - "ed25519-dalek", "env_logger", "erased-serde", "fastrand", @@ -1053,7 +1030,6 @@ dependencies = [ "getrandom 0.4.3", "glob", "hmac 0.13.0", - "hostname", "jni", "lazy_static", "libc", @@ -1609,17 +1585,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "hostname" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" -dependencies = [ - "cfg-if", - "libc", - "windows-link", -] - [[package]] name = "http" version = "1.4.2" @@ -2006,15 +1971,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libredox" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" -dependencies = [ - "libc", -] - [[package]] name = "libsqlite3-sys" version = "0.38.1" @@ -2253,12 +2209,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "p256" version = "0.13.2" @@ -2853,17 +2803,6 @@ dependencies = [ "bitflags 2.13.0", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - [[package]] name = "regex" version = "1.12.4"