From 62642eb1581c76cae96fb6c369d7b52c2e0251eb Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:52:23 -0400 Subject: [PATCH 01/23] fix(sdk): the online send carries the signing key the route resolved, never a substitute The envelope builder re-derived the sender's public key under the state lock and, when that failed, took the caller's copy or the persisted app-state key with an empty default. The routes already resolve the key from the signing authority fail-closed and the params check requires 64 bytes, so the builder embeds exactly that key or no evidence at all. The receiver roots verification in its stored contact either way. --- .../dsm_sdk/src/sdk/b0x_sdk.rs | 51 ++++--------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/b0x_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/b0x_sdk.rs index 129c2d64..9a82ef18 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/b0x_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/b0x_sdk.rs @@ -1846,55 +1846,26 @@ impl B0xSDK { // signature already lives in OnlineTransferRequest.signature / OnlineMessageRequest.signature. // Do NOT duplicate that signature into EvidenceOracle.signature for b0x transport, // or envelopes can exceed storage-node body limits (HTTP 413). - // Keep only oracle_key in evidence so receivers can still verify without extra lookups. // - // Source of truth: signing_authority derives the pk deterministically from - // (genesis_hash, device_id, C-DBRW binding key) — the SAME derivation that - // produces the secret key used by `wallet.sign_operation_bytes`. Embedding - // this pk guarantees the receiver's sphincs_verify uses the same pk that - // produced the signature; using `state.device_info.public_key` or - // `AppState::get_public_key()` can drift (stale genesis pk, fallback - // 32-byte placeholder, etc.) and silently poison the inbox. - // DEADLOCK: the fallback here used to be `self.core_sdk.get_current_state()`, - // which takes the `state_machine` lock (core_sdk.rs:420). This builder runs - // inside `pre_write`, where that NON-REENTRANT parking_lot mutex is ALREADY - // held (core_sdk.rs:1116). Re-locking it hangs silently — no panic, no error. - // - // The caller has already resolved this key fail-closed, so prefer the value - // it handed us over re-deriving one. That removes the re-entry AND removes a - // live source of public-key drift: the param was previously length-validated - // and then ignored. - let sender_signing_public_key = match crate::sdk::signing_authority::current_public_key() { - Ok(pk) => pk, - Err(e) if !params.sender_signing_public_key.is_empty() => { - log::warn!( - "submit_to_b0x: signing_authority pk unavailable ({e}); using caller-supplied \ - sender_signing_public_key" - ); - params.sender_signing_public_key.clone() - } - Err(e) => { - log::warn!( - "submit_to_b0x: signing_authority pk unavailable ({e}) and caller supplied \ - none; falling back to persisted app-state pk" - ); - crate::sdk::app_state::AppState::get_public_key().unwrap_or_default() - } - }; - - let evidence = if !sender_signing_public_key.is_empty() { + // The evidence carries only the sender's signing public key, exactly as the + // caller resolved it from the signing authority (the routes fail closed when + // it is unavailable; `validate_submission_params` requires 64 bytes). The + // receiver roots verification in its STORED contact and treats a disagreeing + // wire key as a signal, so nothing here substitutes another key for it: no + // re-derivation under the state lock, no persisted app-state key, no + // default. + let evidence = if params.sender_signing_public_key.is_empty() { + None + } else { Some(dsm::types::proto::Evidence { kind: Some(dsm::types::proto::evidence::Kind::Oracle( dsm::types::proto::EvidenceOracle { payload: vec![], signature: vec![], - // Carry sender signing public key so receivers can verify without contact lookups. - oracle_key: sender_signing_public_key.clone(), + oracle_key: params.sender_signing_public_key.clone(), }, )), }) - } else { - None }; let invoke = dsm::types::proto::Invoke { From a12c16db87b57f27578ee0d38199938b1fd1f2bc Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:52:24 -0400 Subject: [PATCH 02/23] fix(sdk): a staged half the finality barrier cannot read is an error, not nothing in flight counterparty_has_unconverged_inbound skipped any frozen half that did not decode, so an unreadable inbound transfer read as absent and a send could cross it. A half that does not decode is now an error, which the barrier reports as a failed staging read. Tests: an_unconverged_inbound_half_names_its_sender, a_staged_half_that_does_not_decode_is_an_error_not_absence (the skip restored -> red). --- .../storage/client_db/recipient_staging.rs | 61 ++++++++++++++++--- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/recipient_staging.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/recipient_staging.rs index fa0b4310..1342185d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/recipient_staging.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/recipient_staging.rs @@ -412,20 +412,25 @@ pub fn counterparty_has_unconverged_inbound(counterparty_device_id: &[u8]) -> Re }, )? .collect::>>()?; + // A frozen half the store holds but cannot read is an error, never "nothing + // in flight": the barrier must not let a send cross a transfer it cannot see. for (transfer, evidence) in rows { if let Some(t) = transfer { - if let Ok(req) = dsm::types::proto::OnlineTransferRequest::decode(t.as_slice()) { - if req.from_device_id.as_slice() == counterparty_device_id { - return Ok(true); - } + let req = + dsm::types::proto::OnlineTransferRequest::decode(t.as_slice()).map_err(|e| { + anyhow!("recipient_staging: a staged transfer half does not decode: {e}") + })?; + if req.from_device_id.as_slice() == counterparty_device_id { + return Ok(true); } } if let Some(e) = evidence { - if let Ok(r) = dsm::types::receipt_types::StitchedReceiptV2::from_canonical_protobuf(&e) - { - if r.devid_a.as_slice() == counterparty_device_id { - return Ok(true); - } + let r = dsm::types::receipt_types::StitchedReceiptV2::from_canonical_protobuf(&e) + .map_err(|e| { + anyhow!("recipient_staging: a staged evidence half does not decode: {e}") + })?; + if r.devid_a.as_slice() == counterparty_device_id { + return Ok(true); } } } @@ -545,6 +550,44 @@ mod tests { ) } + /// The finality barrier reads the counterparty from the frozen half itself. + #[test] + #[serial] + fn an_unconverged_inbound_half_names_its_sender() { + use prost::Message; + fresh_db(); + let peer = [0x5Au8; 32]; + let half = dsm::types::proto::OnlineTransferRequest { + from_device_id: peer.to_vec(), + ..Default::default() + } + .encode_to_vec(); + stage_transfer_half("XFER-PEER", &half, &digest_of(&evidence(0xC1)), "TESTROUTE") + .expect("stage transfer"); + assert!(counterparty_has_unconverged_inbound(&peer).expect("readable")); + assert!(!counterparty_has_unconverged_inbound(&[0x5Bu8; 32]).expect("readable")); + } + + /// A staged half the store holds but cannot decode is an error, never + /// "nothing in flight": the barrier must not let a send cross a transfer it + /// cannot read. + #[test] + #[serial] + fn a_staged_half_that_does_not_decode_is_an_error_not_absence() { + fresh_db(); + // A lone field tag with no value: prost refuses it. + stage_transfer_half( + "XFER-BAD", + b"\x08", + &digest_of(&evidence(0xC3)), + "TESTROUTE", + ) + .expect("stage transfer"); + let err = counterparty_has_unconverged_inbound(&[0x5Au8; 32]) + .expect_err("an unreadable half is an error"); + assert!(err.to_string().contains("does not decode"), "{err}"); + } + /// Transfer first, then a restart, then evidence. The staged half must /// survive the restart and the pair must become ready. #[test] From 2998bdb6043d257309f5686896fa9a1105863ed3 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:52:24 -0400 Subject: [PATCH 03/23] fix(sdk): a reply row without its release is a local defect, not an empty release The reply-window sweep submitted whatever release bytes the row held, an empty slice when it held none, which the sender refuses. A promoted row without its post-admission release is logged as a defect and left unmarked; nothing is submitted for it. --- .../dsm_sdk/src/handlers/storage_routes.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/storage_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/storage_routes.rs index b55ca345..38fc389e 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/storage_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/storage_routes.rs @@ -1124,6 +1124,19 @@ async fn deliver_pending_acceptance_replies( } }; + // The post-admission RELEASE (3.5b PR4): the sweep only sees promoted + // rows, so a row carries its release by construction. A row without one + // is a local defect — the sender refuses a bare delta — and stays + // unmarked and visible rather than going out with an empty release. + let Some(release_bytes) = reply.release_bytes.as_deref() else { + log::error!( + "[storage.sync] §16.6 reply for commitment {}.. has no post-admission release — \ + local defect, left unmarked", + crate::util::text_id::encode_base32_crockford(&reply.commitment[..4]), + ); + continue; + }; + // NOTE: the envelope is built from `dsm::types::proto`, which is a SEPARATE // prost generation from `crate::generated` — same schema, distinct Rust types. let mut b0x = match crate::sdk::b0x_sdk::B0xSDK::new( @@ -1149,11 +1162,7 @@ async fn deliver_pending_acceptance_replies( &reply.commitment, &reply.receipt_bytes, (reply.applied_parent_tip_b, reply.applied_child_tip_b), - // The post-admission RELEASE (3.5b PR4): the sweep only sees - // promoted rows, so a Some here is admission-terminal by - // construction. Rows from before the release existed carry - // none and the sender refuses them — beta clean cut. - reply.release_bytes.as_deref().unwrap_or_default(), + release_bytes, ) .await { From e424b245c212b46c11fef5b0dbdecd6c59245673 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:52:24 -0400 Subject: [PATCH 04/23] fix(sdk,frontend): wallet.sendOffline answers with its own message, not the peer's with default fields The route answered a BilateralPrepareResponse, the peer's message, with every field but the commitment defaulted. It now answers a BilateralTransferResponse: the prepare went out, transaction_hash names the proposal's commitment. The frontend reads that answer, treats any other payload as unexpected, and no longer decodes a reject the route never produced; the tests stub the real answer. --- .../dsm_sdk/src/handlers/wallet_routes.rs | 16 +++++++++---- .../dsm/__tests__/offlineSend.radio.test.ts | 8 +++++-- .../src/dsm/__tests__/offlineSend.test.ts | 24 ++++++++++++------- ...offlineTransfer_consistency_bridge.test.ts | 16 ++++++++----- dsm_client/frontend/src/dsm/transactions.ts | 13 +++++----- .../src/tests/E2E.offlineBleExchange.test.ts | 9 +++---- .../src/tests/E2E.transferProof.test.ts | 15 ++++++------ 7 files changed, 63 insertions(+), 38 deletions(-) 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 7bbc71db..04ca2688 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 @@ -1016,13 +1016,21 @@ impl AppRouterImpl { } } - let resp = generated::BilateralPrepareResponse { - commitment_hash: Some(generated::Hash32 { + // This device's own answer: the prepare went out and the proposal + // is identified by its commitment. The peer's prepare response, + // when it arrives over BLE, is a different message. + let resp = generated::BilateralTransferResponse { + success: true, + transaction_hash: Some(generated::Hash32 { v: commitment_hash.to_vec(), }), - ..Default::default() + message: "prepare sent over BLE; the transfer completes when the peer's \ + response arrives" + .to_string(), }; - pack_envelope_ok(generated::envelope::Payload::BilateralPrepareResponse(resp)) + pack_envelope_ok(generated::envelope::Payload::BilateralTransferResponse( + resp, + )) } #[cfg(not(all(target_os = "android", feature = "bluetooth", feature = "jni")))] diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts index 3f7d3682..10308da2 100644 --- a/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts @@ -42,8 +42,12 @@ function recordingBridge() { ? framed(new pb.Envelope({ version: 3, payload: { - case: 'bilateralPrepareResponse', - value: new pb.BilateralPrepareResponse({ commitmentHash: new pb.Hash32({ v: COMMITMENT }) }), + case: 'bilateralTransferResponse', + value: new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: COMMITMENT }), + message: 'prepare sent over BLE', + }), }, })) : new Uint8Array(0); diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts index a1e44612..bca52ef8 100644 --- a/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts @@ -37,13 +37,16 @@ function decodeRouterInvoke(reqBytes: Uint8Array): { route: string; args: Uint8A }; } -function prepareResponseBytes(commitmentHash: Uint8Array): Uint8Array { +/** The SDK's answer to wallet.sendOffline: the prepare went out under this commitment. */ +function sendAnswerBytes(commitmentHash: Uint8Array): Uint8Array { const env = new pb.Envelope({ version: 3, payload: { - case: 'bilateralPrepareResponse', - value: new pb.BilateralPrepareResponse({ - commitmentHash: new pb.Hash32({ v: new Uint8Array(commitmentHash) }), + case: 'bilateralTransferResponse', + value: new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: new Uint8Array(commitmentHash) }), + message: 'prepare sent over BLE', }), }, }); @@ -80,7 +83,7 @@ describe('offlineSend', () => { amount: '1', memo: '', }).toBinary()); - return prepareResponseBytes(commitmentHash); + return sendAnswerBytes(commitmentHash); }; const promise = dsm.offlineSend({ to, amount: 1n, tokenId: 'ERA' }); @@ -105,7 +108,7 @@ describe('offlineSend', () => { (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { route } = decodeRouterInvoke(reqBytes); expect(route).toBe('wallet.sendOffline'); - return prepareResponseBytes(commitmentHash); + return sendAnswerBytes(commitmentHash); }; let settled = false; @@ -135,7 +138,9 @@ describe('offlineSend', () => { await expect(promise).resolves.toEqual(expect.objectContaining({ accepted: true })); }); - test('surfaces bilateral prepare rejects from wallet.sendOffline', async () => { + // The peer's reject is a BLE event, never wallet.sendOffline's own answer: + // an answer of any other shape is refused as what it is. + test("an answer that is not the SDK's send answer is refused", async () => { const to = new Uint8Array(32).fill(0x44); (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { @@ -152,7 +157,10 @@ describe('offlineSend', () => { }; await expect(dsm.offlineSend({ to, amount: 1n, tokenId: 'ERA' })).resolves.toEqual( - expect.objectContaining({ accepted: false, result: 'offline rejected' }), + expect.objectContaining({ + accepted: false, + result: 'offlineSend: unexpected payload case bilateralPrepareReject', + }), ); }); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts index c20b3854..f0065333 100644 --- a/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts @@ -65,9 +65,11 @@ describe('offline transfer sender/recipient consistency through WebView bridge', const env = new pb.Envelope({ version: 3, payload: { - case: 'bilateralPrepareResponse', - value: new pb.BilateralPrepareResponse({ - commitmentHash: new pb.Hash32({ v: commitmentHash }), + case: 'bilateralTransferResponse', + value: new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: commitmentHash }), + message: 'prepare sent over BLE', }), }, }); @@ -98,9 +100,11 @@ describe('offline transfer sender/recipient consistency through WebView bridge', const env = new pb.Envelope({ version: 3, payload: { - case: 'bilateralPrepareResponse', - value: new pb.BilateralPrepareResponse({ - commitmentHash: new pb.Hash32({ v: commitmentHash }), + case: 'bilateralTransferResponse', + value: new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: commitmentHash }), + message: 'prepare sent over BLE', }), }, }); diff --git a/dsm_client/frontend/src/dsm/transactions.ts b/dsm_client/frontend/src/dsm/transactions.ts index 29b2bc6c..ba3e7d88 100644 --- a/dsm_client/frontend/src/dsm/transactions.ts +++ b/dsm_client/frontend/src/dsm/transactions.ts @@ -259,14 +259,13 @@ export async function offlineSend(transfer: GenericTransaction): Promise wallet refresh', () => { if (ingress.operation.case === 'routerInvoke') { const ingressMethod = ingress.operation.value.method; if (ingressMethod === 'wallet.sendOffline') { - const resp = new pb.BilateralPrepareResponse({ - commitmentHash: new pb.Hash32({ v: new Uint8Array(32) } as any), - localSignature: new Uint8Array(64), + const resp = new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: new Uint8Array(32) } as any), + message: 'prepare sent over BLE', }); const env = new pb.Envelope({ version: 3, - payload: { case: 'bilateralPrepareResponse', value: resp }, + payload: { case: 'bilateralTransferResponse', value: resp }, } as any); return wrapIngressOk(frameEnvelope(env)); } diff --git a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts index 14d1bb94..b57e0608 100644 --- a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts +++ b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts @@ -109,15 +109,16 @@ function makeContactsFramedEnvelope(bleAddress?: string): Uint8Array { return frameEnvelope(env); } -/** Build a BilateralPrepareResponse inside a framed Envelope. */ -function makeBilateralPrepareResponseEnvelope(commitHash: Uint8Array): Uint8Array { - // The commitment is what the frontend reads; the rest is Rust's to fill. - const resp = new pb.BilateralPrepareResponse({ - commitmentHash: new pb.Hash32({ v: commitHash } as any), +/** The SDK's answer to wallet.sendOffline: the prepare went out under this commitment. */ +function makeOfflineSendAnswerEnvelope(commitHash: Uint8Array): Uint8Array { + const resp = new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: commitHash } as any), + message: 'prepare sent over BLE', } as any); const env = new pb.Envelope({ version: 3, - payload: { case: 'bilateralPrepareResponse', value: resp }, + payload: { case: 'bilateralTransferResponse', value: resp }, } as any); return frameEnvelope(env); } @@ -215,7 +216,7 @@ function installBridge(opts?: { contactBleAddress?: string }) { if (bilateralResponseOverride) { return wrapIngressOk(bilateralResponseOverride()); } - return wrapIngressOk(makeBilateralPrepareResponseEnvelope(COMMITMENT_HASH)); + return wrapIngressOk(makeOfflineSendAnswerEnvelope(COMMITMENT_HASH)); } return wrapIngressOk(new Uint8Array(0)); } From 6bd63a4969d2e511a2b7f3ff14258991363bbb9b Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:52:24 -0400 Subject: [PATCH 05/23] =?UTF-8?q?docs(gaps):=20=C2=A76.36=20states=20after?= =?UTF-8?q?=20the=20sixth=20fix=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/requirements/CONFORMANCE_GAPS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index ee29b8a1..d834dc23 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1349,11 +1349,11 @@ The State column says what this branch did; "Open" rows are holes left visible, | B-2 | `SDK/handlers/recovery_routes.rs` (`recovery.enable`) | A failed first capsule is answered `enabled: true`; an anchor publish that failed "will retry", and nothing retries. | Open (recovery boundary) | | B-3 | `SDK/handlers/recovery_routes.rs` (`completeResume`) | `let _ =` on the store, then success (§6.9). | Open (recovery boundary) | | B-4 | `SDK/sdk/recovery_sdk.rs` (status reads) | Read errors become 0 and false (§6.29 partly). | Open (recovery boundary) | -| B-5 | `SDK/sdk/token_sdk.rs` (`execute_simplified_bilateral_transfer` remains) | `new_transaction_hash` is the caller's own head; the receipt is `vec![]`. | Open | -| B-6 | `SDK/sdk/b0x_sdk.rs` (`EvidenceOracle`) | Every online send carries an empty oracle whose key is `get_public_key().unwrap_or_default()`. | Open | -| B-7 | `SDK/handlers/storage_routes.rs` (the finality barrier) | A staged half that does not decode is "nothing in flight". | Open | -| B-8 | `SDK/handlers/wallet_routes.rs` (`wallet.sendOffline`) | Answers the peer's message type with default fields. | Open | -| B-9 | `SDK/handlers/storage_routes.rs` (reply rows) | A reply row without its release submits an empty release. | Open | +| B-5 | `SDK/sdk/token_sdk.rs` (`execute_simplified_bilateral_transfer` remains) | `new_transaction_hash` is the caller's own head; the receipt is `vec![]`. | Corrected: no such function exists on `main` or in this tree; the row was stale. | +| B-6 | `SDK/sdk/b0x_sdk.rs` (`EvidenceOracle`) | Every online send carries an empty oracle whose key is `get_public_key().unwrap_or_default()`. | Resolved: the builder embeds exactly the key the route resolved from the signing authority (fail-closed; 64 bytes by `validate_submission_params`) or no evidence at all; the re-derivation under the state lock, the caller-supplied substitute and the persisted app-state default are gone. The receiver already roots verification in the stored contact and treats a disagreeing wire key as a signal. | +| B-7 | `SDK/storage/client_db/recipient_staging.rs` (`counterparty_has_unconverged_inbound`, read by the finality barrier in `relationship_status`) | A staged half that does not decode is "nothing in flight". | Resolved: a frozen half that does not decode is an error, which the barrier reports as a failed staging read instead of letting the send cross. Tests: `an_unconverged_inbound_half_names_its_sender`, `a_staged_half_that_does_not_decode_is_an_error_not_absence`. | +| B-8 | `SDK/handlers/wallet_routes.rs` (`wallet.sendOffline`) | Answers the peer's message type with default fields. | Resolved: the route answers `BilateralTransferResponse` (its own message: the prepare went out, `transaction_hash` = the proposal commitment) instead of a `BilateralPrepareResponse` with every field but the commitment defaulted; the frontend reads that answer and treats any other payload as unexpected. | +| B-9 | `SDK/handlers/storage_routes.rs` (reply rows) | A reply row without its release submits an empty release. | Resolved: a promoted reply row without its post-admission release is logged as a local defect and left unmarked; nothing is submitted for it. | | B-10 | `SDK/sdk/recovery_impl.rs` | A recovered `AppState` genesis is `succession.new_device_commitment`. | Open (recovery boundary) | | B-K11 | `KT/bridge/…` (`qr.available`) | Hard-coded `true`. | Resolved: `qr_available` is the device's camera feature (`FEATURE_CAMERA_ANY`), not a literal. | | B-F4 | `FE/dsm/transactions.ts` (accept) | Emits `committed: true` before the confirm arrived. | Open | From 8ed071ba6ab7c5ddecfe060e758ee7c0dab19006 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:05:12 -0400 Subject: [PATCH 06/23] fix(android): the WebView's CORS proxy and host allowlist served nothing Nothing in the page fetches an external host: the map the tile hosts served is gone and the only fetch is a local sound. The proxy, the allowlist and the test that locked it are deleted; the WebView serves APK assets and handles everything else itself. --- .../java/com/dsm/wallet/ui/MainActivity.kt | 94 +------------------ .../com/dsm/wallet/ui/WebViewAllowlistTest.kt | 68 -------------- 2 files changed, 2 insertions(+), 160 deletions(-) delete mode 100644 dsm_client/android/app/src/test/java/com/dsm/wallet/ui/WebViewAllowlistTest.kt diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt index 164abe75..1657bb33 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt @@ -68,13 +68,10 @@ import dsm.types.proto.NativeHostEvent import dsm.types.proto.NativeHostEventKind import dsm.types.proto.QrScanResultPayload import dsm.types.proto.SessionHardwareFactsProto -import java.io.ByteArrayInputStream import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.io.InputStream import java.lang.ref.WeakReference -import java.net.HttpURLConnection import java.net.URL import java.util.Locale @@ -878,57 +875,6 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { - // The WebView external-host allowlist lives at file scope below so that - // `WebViewAllowlistTest` (src/test) can read the set literals and the - // `isAllowlistedExternalHost` predicate directly. The lock is the test - // — any change to the allowlist requires a matching test diff that is - // visible in PR review. - - @VisibleForTesting - internal fun proxyWithCorsForTest(request: WebResourceRequest): WebResourceResponse? { - return proxyWithCorsInternal(request) - } - - private fun proxyWithCorsInternal(request: WebResourceRequest): WebResourceResponse? { - val url = request.url?.toString() ?: return null - val host = request.url?.host ?: return null - if (!isAllowlistedExternalHost(host)) return null - return try { - val conn = (URL(url).openConnection() as HttpURLConnection).apply { - connectTimeout = 10_000 - readTimeout = 15_000 - requestMethod = request.method - for ((k, v) in request.requestHeaders) { - if (k.isNullOrBlank()) continue - setRequestProperty(k, v) - } - } - val code = conn.responseCode - val rawContentType = conn.contentType ?: "application/octet-stream" - val parts = rawContentType.split(';').map { it.trim() } - val mime = parts.firstOrNull()?.ifBlank { "application/octet-stream" } ?: "application/octet-stream" - val charset = parts.firstOrNull { it.startsWith("charset=", ignoreCase = true) } - ?.substringAfter('=') - ?.ifBlank { null } - ?: "utf-8" - - val stream: InputStream = try { - conn.inputStream - } catch (_: Throwable) { - conn.errorStream ?: ByteArrayInputStream(ByteArray(0)) - } - - val headers = mutableMapOf() - headers["Access-Control-Allow-Origin"] = "https://appassets.androidplatform.net" - headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" - headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" - - WebResourceResponse(mime, charset, code, conn.responseMessage ?: "OK", headers, stream) - } catch (_: Throwable) { - null - } - } - private fun installDsmBinaryBridge(wv: WebView) { if (!WebViewFeature.isFeatureSupported(WebViewFeature.CREATE_WEB_MESSAGE_CHANNEL)) { Log.e(tag, "WebViewFeature.CREATE_WEB_MESSAGE_CHANNEL not supported") @@ -1964,11 +1910,9 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { ?: return super.shouldInterceptRequest(view, request as WebResourceRequest?) val uri = req.url ?: return super.shouldInterceptRequest(view, request as WebResourceRequest?) - // APK assets are served by WebViewAssetLoader. - // Allowlisted external hosts fall through to the CORS proxy. - // Everything else returns null so WebView handles it normally. + // APK assets are served by WebViewAssetLoader; everything else + // returns null so WebView handles it normally. return assetLoader.shouldInterceptRequest(uri) - ?: proxyWithCorsInternal(req) } override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { @@ -2008,37 +1952,3 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { } } -// ============================================================================= -// SECURITY: WebView external-host allowlist (CI-locked). -// -// These declarations are deliberately at file scope (not inside MainActivity) -// so that `dsm_client/android/app/src/test/.../WebViewAllowlistTest.kt` can -// read the set literals and call `isAllowlistedExternalHost` directly. The -// lock is the test: any change to either set requires a matching test diff -// that is visible in PR review. -// -// `proxyWithCorsInternal` consults `isAllowlistedExternalHost` BEFORE -// performing any external fetch or injecting CORS response headers. Adding -// a new external host therefore requires both: -// 1. updating the set(s) below, AND -// 2. updating WebViewAllowlistTest.kt to match. -// Both edits land in the same PR diff and trigger explicit review. -// ============================================================================= - -internal val WEBVIEW_ALLOWED_EXACT_HOSTS: Set = setOf( - "tile.openstreetmap.org", - "localhost", - "127.0.0.1", -) - -internal val WEBVIEW_ALLOWED_HOST_SUFFIXES: Set = setOf( - ".tile.openstreetmap.org", -) - -internal fun isAllowlistedExternalHost(host: String): Boolean { - if (host in WEBVIEW_ALLOWED_EXACT_HOSTS) return true - for (suffix in WEBVIEW_ALLOWED_HOST_SUFFIXES) { - if (host.endsWith(suffix)) return true - } - return false -} diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/ui/WebViewAllowlistTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/ui/WebViewAllowlistTest.kt deleted file mode 100644 index 3cfc182c..00000000 --- a/dsm_client/android/app/src/test/java/com/dsm/wallet/ui/WebViewAllowlistTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.dsm.wallet.ui - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * SECURITY: WebView external-host allowlist lock. - * - * The first two tests freeze the allowlist contents byte-for-byte. Any - * change to `WEBVIEW_ALLOWED_EXACT_HOSTS` or `WEBVIEW_ALLOWED_HOST_SUFFIXES` - * in MainActivity.kt MUST be reflected in this file. That makes allowlist - * expansion visible in PR review — the reviewer is forced to consciously - * approve a new external host the WebView is allowed to fetch from. - * - * The third test asserts the predicate still correctly maps the sets to - * accept/reject decisions for representative hosts. - * - * If you are editing this file because the allowlist legitimately grew, - * please confirm: - * 1. The new host is necessary for product behaviour (not dev convenience). - * 2. The host operator's TLS/CORS posture has been reviewed. - * 3. The PR description names the new host and the reason. - */ -class WebViewAllowlistTest { - - @Test - fun allowlist_exact_hosts_frozen() { - assertEquals( - setOf( - "tile.openstreetmap.org", - "localhost", - "127.0.0.1", - ), - WEBVIEW_ALLOWED_EXACT_HOSTS, - ) - } - - @Test - fun allowlist_host_suffixes_frozen() { - assertEquals( - setOf( - ".tile.openstreetmap.org", - ), - WEBVIEW_ALLOWED_HOST_SUFFIXES, - ) - } - - @Test - fun isAllowlistedExternalHost_respects_sets() { - // Exact-match accept. - assertTrue(isAllowlistedExternalHost("tile.openstreetmap.org")) - assertTrue(isAllowlistedExternalHost("localhost")) - assertTrue(isAllowlistedExternalHost("127.0.0.1")) - - // Suffix-match accept (subdomains of openstreetmap tile servers). - assertTrue(isAllowlistedExternalHost("a.tile.openstreetmap.org")) - assertTrue(isAllowlistedExternalHost("b.tile.openstreetmap.org")) - - // Reject — not in either set. - assertFalse(isAllowlistedExternalHost("evil.example.com")) - assertFalse(isAllowlistedExternalHost("openstreetmap.org")) - assertFalse(isAllowlistedExternalHost("tile.openstreetmap.org.attacker.com")) - assertFalse(isAllowlistedExternalHost("")) - assertFalse(isAllowlistedExternalHost("127.0.0.2")) - } -} From af794fc011dea54b9c130dc8b6835f44bd5f838b Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:05:13 -0400 Subject: [PATCH 07/23] fix(frontend): the headers cache that never invalidated is gone; the CSP and service worker name no host the page uses getHeaders answered a window global once it had been filled and never asked the bridge again, so a changed identity was never seen. It now reads the bridge on every call; the tests that reset the global are updated and the cache test asserts the opposite property. The CSP's tile, maplibre, dsm-wallet.io and localhost entries and the service worker's tile cache had no consumer. --- dsm_client/frontend/public/index.html | 4 +- dsm_client/frontend/public/service-worker.js | 41 +------------------ .../src/dsm/__tests__/identity.test.ts | 20 +++++---- dsm_client/frontend/src/dsm/identity.ts | 16 -------- .../src/tests/E2E.sendOnlineTransfer.test.ts | 6 --- .../src/tests/E2E.transferProof.test.ts | 2 - .../src/tests/E2E.uiCoordination.test.tsx | 4 -- 7 files changed, 15 insertions(+), 78 deletions(-) diff --git a/dsm_client/frontend/public/index.html b/dsm_client/frontend/public/index.html index c1447764..1336a264 100644 --- a/dsm_client/frontend/public/index.html +++ b/dsm_client/frontend/public/index.html @@ -16,8 +16,8 @@ script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; - img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://demotiles.maplibre.org; - connect-src 'self' https://*.dsm-wallet.io https://demotiles.maplibre.org https://*.tile.openstreetmap.org https://tile.openstreetmap.org <% if (htmlWebpackPlugin && htmlWebpackPlugin.options && htmlWebpackPlugin.options.templateParameters && htmlWebpackPlugin.options.templateParameters.buildTarget === 'android') { %> http://localhost:8080 http://localhost:8081 http://localhost:8082 http://localhost:8083 http://localhost:8084 http://127.0.0.1:8080 http://127.0.0.1:8081 http://127.0.0.1:8082 http://127.0.0.1:8083 http://127.0.0.1:8084 <% } %>; + img-src 'self' data: blob:; + connect-src 'self'; worker-src 'self' blob:; object-src 'none';" /> diff --git a/dsm_client/frontend/public/service-worker.js b/dsm_client/frontend/public/service-worker.js index dff08906..e5e3b199 100644 --- a/dsm_client/frontend/public/service-worker.js +++ b/dsm_client/frontend/public/service-worker.js @@ -1,4 +1,4 @@ -/* DSM Service Worker - offline-first tiles + app shell */ +/* DSM Service Worker - offline-first app shell */ const VERSION = 'v1'; const APP_SHELL = [ '/', @@ -7,9 +7,7 @@ const APP_SHELL = [ // Add other critical assets if needed ]; -// Separate caches to keep tiles bounded const APP_CACHE = `dsm-app-${VERSION}`; -const TILE_CACHE = `dsm-tiles-${VERSION}`; self.addEventListener('install', (event) => { event.waitUntil( @@ -22,26 +20,13 @@ self.addEventListener('activate', (event) => { caches.keys().then((keys) => Promise.all( keys - .filter((k) => ![APP_CACHE, TILE_CACHE].includes(k)) + .filter((k) => k !== APP_CACHE) .map((k) => caches.delete(k)) ) ).then(() => self.clients.claim()) ); }); -// Helper: is tile request -function isTileRequest(url) { - try { - const u = new URL(url); - return ( - u.hostname.endsWith('tile.openstreetmap.org') || - u.hostname.endsWith('demotiles.maplibre.org') - ); - } catch (_) { - return false; - } -} - self.addEventListener('fetch', (event) => { const { request } = event; const url = request.url; @@ -49,28 +34,6 @@ self.addEventListener('fetch', (event) => { // Only handle GET if (request.method !== 'GET') return; - // Cache-first for map tiles with expiration - if (isTileRequest(url)) { - event.respondWith( - caches.open(TILE_CACHE).then(async (cache) => { - const cached = await cache.match(request); - if (cached) return cached; - try { - const resp = await fetch(request, { mode: 'cors' }); - // Only cache successful, opaque or basic responses - if (resp && (resp.status === 200 || resp.type === 'opaque')) { - cache.put(request, resp.clone()); - } - return resp; - } catch (err) { - // If offline and no cache, fall through (will fail) - return cached || Response.error(); - } - }) - ); - return; - } - // Network-first for app/json requests event.respondWith( (async () => { diff --git a/dsm_client/frontend/src/dsm/__tests__/identity.test.ts b/dsm_client/frontend/src/dsm/__tests__/identity.test.ts index dc1d94a9..d974b224 100644 --- a/dsm_client/frontend/src/dsm/__tests__/identity.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/identity.test.ts @@ -51,8 +51,6 @@ function makeHeadersBinary(deviceId: Uint8Array, genesisHash: Uint8Array): Uint8 describe('identity.ts', () => { beforeEach(() => { jest.clearAllMocks(); - const g = globalThis as any; - g.__dsmLastGoodHeaders = { deviceId: undefined, genesisHash: undefined }; }); // ── getHeaders ───────────────────────────────────────────────────── @@ -68,16 +66,20 @@ describe('identity.ts', () => { expect(headers.genesisHash).toEqual(genesisHash); }); - test('caches valid headers for subsequent calls', async () => { - const deviceId = makeValidDeviceId(); + test('reads the bridge on every call, so a changed identity is seen', async () => { + const first = makeValidDeviceId(); const genesisHash = makeValidGenesisHash(); - (queryTransportHeadersV3 as jest.Mock).mockResolvedValue(makeHeadersBinary(deviceId, genesisHash)); + const second = new Uint8Array(32).fill(0x5c); + (queryTransportHeadersV3 as jest.Mock) + .mockResolvedValueOnce(makeHeadersBinary(first, genesisHash)) + .mockResolvedValueOnce(makeHeadersBinary(second, genesisHash)); - await getHeaders(); + const headers1 = await getHeaders(); const headers2 = await getHeaders(); - // Second call should use cache, only 1 bridge call total - expect(queryTransportHeadersV3).toHaveBeenCalledTimes(1); - expect(headers2.deviceId).toEqual(deviceId); + // No cache: the second answer is what the bridge said the second time. + expect(queryTransportHeadersV3).toHaveBeenCalledTimes(2); + expect(headers1.deviceId).toEqual(first); + expect(headers2.deviceId).toEqual(second); }); test('throws when bridge returns empty bytes', async () => { diff --git a/dsm_client/frontend/src/dsm/identity.ts b/dsm_client/frontend/src/dsm/identity.ts index 74d255b4..ab45adaf 100644 --- a/dsm_client/frontend/src/dsm/identity.ts +++ b/dsm_client/frontend/src/dsm/identity.ts @@ -10,12 +10,6 @@ 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; -if (!g.__dsmLastGoodHeaders) { - g.__dsmLastGoodHeaders = { deviceId: undefined as Uint8Array | undefined, genesisHash: undefined as Uint8Array | undefined }; -} - export async function getHeaders(): Promise { const isAllZero = (u: Uint8Array) => u.every((v) => v === 0); @@ -42,14 +36,6 @@ export async function getHeaders(): Promise { } }; - const cached = g.__dsmLastGoodHeaders as { deviceId?: Uint8Array; genesisHash?: Uint8Array }; - const cachedDevOk = cached.deviceId instanceof Uint8Array && cached.deviceId.length === 32 && !isAllZero(cached.deviceId); - const cachedGhOk = cached.genesisHash instanceof Uint8Array && cached.genesisHash.length === 32 && !isAllZero(cached.genesisHash); - - if (cachedDevOk && cachedGhOk) { - return new pb.Headers({ deviceId: cached.deviceId as any, genesisHash: cached.genesisHash as any } as any); - } - let lastSeen: { deviceId?: Uint8Array; genesisHash?: Uint8Array } = {}; try { @@ -62,8 +48,6 @@ export async function getHeaders(): Promise { const ghOk = lastSeen.genesisHash instanceof Uint8Array && lastSeen.genesisHash.length === 32 && !isAllZero(lastSeen.genesisHash); if (devOk && ghOk) { - cached.deviceId = lastSeen.deviceId; - cached.genesisHash = lastSeen.genesisHash; return new pb.Headers({ deviceId: lastSeen.deviceId as any, genesisHash: lastSeen.genesisHash as any, diff --git a/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts b/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts index 4d2330e6..ba308154 100644 --- a/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts +++ b/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts @@ -85,12 +85,6 @@ describe('E2E: sendOnlineTransfer (unit-level, mocked storage)', () => { jest.restoreAllMocks(); // Provide a simple bridge with device identity getters and minimal hooks (global as any).window = (global as any).window || {}; - (global as any).__dsmLastGoodHeaders = { - deviceId: undefined, - genesisHash: undefined, - chainTip: undefined, - seq: undefined, - }; (global as any).window.DsmBridge = (global as any).window.DsmBridge || {}; // Bytes-only MessagePort bridge contract (required by WebViewBridge.callBin) diff --git a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts index b57e0608..330199d0 100644 --- a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts +++ b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts @@ -255,8 +255,6 @@ beforeEach(() => { headersOverride = null; testIndex++; initializeEventBridge(); - // Clear headers cache so each test gets fresh headers from bridge - (global as any).__dsmLastGoodHeaders = { deviceId: undefined, genesisHash: undefined, chainTip: undefined }; }); // ───────────────────────────────────────────────────────────────── diff --git a/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx b/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx index 07deb2ba..66bbd4fa 100644 --- a/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx +++ b/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx @@ -637,9 +637,6 @@ describe('INTEGRATED: Full chain with sendMessageBin-only mock', () => { balancesState = [{ tokenId: 'ERA', available: 10000n }]; historyState = [{ amount: 100n, amountSigned: 100n }]; - // Clear identity cache - (global as any).__dsmLastGoodHeaders = { deviceId: undefined, genesisHash: undefined, chainTip: undefined }; - // Re-install bridge mock (in case previous test modified it) installCallBinMock(); }); @@ -1001,7 +998,6 @@ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { capturedMethods = []; balancesState = [{ tokenId: 'ERA', available: 5000n }]; historyState = []; - (global as any).__dsmLastGoodHeaders = { deviceId: undefined, genesisHash: undefined, chainTip: undefined }; installCallBinMock(); }); From cc2c20c38d5de88eb5a5ef9343096e097fd928ca Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:05:13 -0400 Subject: [PATCH 08/23] fix(sdk): the bootstrap-report op nothing sends is gone with its trust-level gate BootstrapMeasurementReport envelopes (started, progress, finalize, aborted, error) and the finalize path gated on a caller-supplied trust level described as a C-DBRW entropy health test had no sender; C-DBRW is gone and Genesis v2 (system.createGenesisV2) is the one bootstrap path. Such an envelope now reaches the Core bridge and is refused as unsupported. Two startup tests are renamed after the real op they exercise. --- .../dsm_sdk/src/ingress.rs | 310 +----------------- 1 file changed, 5 insertions(+), 305 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs index fe0e33cf..62deb816 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs @@ -69,309 +69,15 @@ fn push_canonical_envelope_event(payload: pb::envelope::Payload) -> Result<(), p } /// Push a genesis lifecycle event to the WebView (EventBridge maps kinds to the -/// `genesis.securing-device*` topics the frontend renders). `pub(crate)` so the canonical -/// Genesis v2 route (`system.createGenesisV2` in `handlers::system_routes`) drives the SAME -/// securing-screen rail as this legacy bootstrap path. +/// `genesis.securing-device*` topics the frontend renders). `pub(crate)` for the +/// canonical Genesis v2 route (`system.createGenesisV2` in `handlers::system_routes`), +/// which drives the securing-screen rail. pub(crate) fn push_genesis_lifecycle_event(kind: i32, progress: u32) -> Result<(), pb::Error> { push_canonical_envelope_event(pb::envelope::Payload::GenesisLifecycle( pb::GenesisLifecycleEvent { kind, progress }, )) } -fn bootstrap_finalize_envelope( - result: i32, - device_id: Vec, - genesis_hash: Vec, - message: impl Into, -) -> Envelope { - crate::envelope::local_answer(pb::envelope::Payload::BootstrapFinalizeResponse( - pb::BootstrapFinalizeResponse { - result, - device_id, - genesis_hash, - message: message.into(), - }, - )) -} - -fn startup_initialize_identity_context( - device_id: Vec, - genesis_hash: Vec, -) -> Result<(), pb::Error> { - match dispatch_startup(StartupRequest { - operation: Some(startup_request::Operation::InitializeIdentityContext( - pb::InitializeIdentityContextOp { - device_id, - genesis_hash, - }, - )), - }) - .result - { - Some(startup_response::Result::OkBytes(_)) => Ok(()), - Some(startup_response::Result::Error(error)) => Err(error), - None => Err(ingress_error( - ERROR_CODE_PROCESSING_FAILED, - "startup: empty initialize identity response", - )), - } -} - -fn finalize_bootstrap_core(report: pb::BootstrapMeasurementReport) -> Result { - log::info!( - "FINALIZE_BOOTSTRAP: ENTRY phase={} trust={}", - report.phase, - report.trust_level - ); - // Scope guard: keep BOOTSTRAP_SECURING=true until this function exits, then clear it - // unconditionally. This preserves phase=securing_device throughout the whole finalize - // (including startup_initialize_identity_context which writes the identity), so any - // concurrent session state read observes securing_device → wallet_ready atomically - // instead of the prior race where the flag was cleared BEFORE has_identity became true, - // exposing a transient phase=needs_genesis flash in the UI. - struct ClearBootstrapSecuringOnDrop; - impl Drop for ClearBootstrapSecuringOnDrop { - fn drop(&mut self) { - log::info!("FINALIZE_BOOTSTRAP: SCOPE_GUARD_DROP clearing BOOTSTRAP_SECURING=false"); - crate::sdk::session_manager::BOOTSTRAP_SECURING - .store(false, std::sync::atomic::Ordering::SeqCst); - log::info!( - "FINALIZE_BOOTSTRAP: POST_DROP BOOTSTRAP_SECURING={} SDK_READY={} has_id={}", - crate::sdk::session_manager::BOOTSTRAP_SECURING - .load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::session_manager::SDK_READY.load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::app_state::AppState::get_has_identity() - ); - } - } - let _clear_on_exit = ClearBootstrapSecuringOnDrop; - - if report.device_id.len() != 32 { - return Err(ingress_error( - ERROR_CODE_INVALID_INPUT, - format!( - "bootstrap_finalize: device_id must be 32 bytes, got {}", - report.device_id.len() - ), - )); - } - if report.genesis_hash.len() != 32 { - return Err(ingress_error( - ERROR_CODE_INVALID_INPUT, - format!( - "bootstrap_finalize: genesis_hash must be 32 bytes, got {}", - report.genesis_hash.len() - ), - )); - } - - let device_id = report.device_id.clone(); - let genesis_hash = report.genesis_hash.clone(); - - // Strict enforcement: no feature gate and no default-allow path. - // A ReadOnly trust level from the bootstrap measurement means the device - // failed the C-DBRW entropy health test and MUST NOT be allowed to proceed - // through genesis creation. The caller surface returns a BootstrapResultReadOnly - // envelope and the genesis lifecycle emits an error event for telemetry. - match report.trust_level { - x if x - == pb::bootstrap_measurement_report::TrustLevel::BootstrapTrustLevelReadOnly as i32 => - { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - )?; - return Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultReadOnly as i32, - device_id, - genesis_hash, - "bootstrap rejected by Rust: read-only trust state", - )); - } - x if x - == pb::bootstrap_measurement_report::TrustLevel::BootstrapTrustLevelBlocked as i32 => - { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - )?; - return Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultBlocked as i32, - device_id, - genesis_hash, - "bootstrap rejected by Rust: blocked trust state", - )); - } - x if x - == pb::bootstrap_measurement_report::TrustLevel::BootstrapTrustLevelUnspecified - as i32 => - { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - )?; - return Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultRejected as i32, - device_id, - genesis_hash, - "bootstrap rejected by Rust: missing trust level", - )); - } - _ => {} - } - - let context = PlatformContext::bootstrap(RawPlatformInputs { - device_id_raw: device_id.clone(), - genesis_hash_raw: genesis_hash.clone(), - }) - .map_err(|e| { - ingress_error( - ERROR_CODE_PROCESSING_FAILED, - format!("bootstrap_finalize: PlatformContext::bootstrap failed: {e}"), - ) - })?; - - if let Err(error) = startup_initialize_identity_context( - context.device_id.to_vec(), - context.genesis_hash.to_vec(), - ) { - log::error!( - "FLASH_DEBUG: FINALIZE_BOOTSTRAP: startup_initialize_identity_context FAILED err={} BOOTSTRAP_SECURING={} SDK_READY={} has_id={}", - error.message, - crate::sdk::session_manager::BOOTSTRAP_SECURING.load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::session_manager::SDK_READY.load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::app_state::AppState::get_has_identity() - ); - let message = match push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - ) { - Ok(()) => error.message, - Err(push_error) => format!( - "{}; the error lifecycle event was not delivered: {}", - error.message, push_error.message - ), - }; - return Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultError as i32, - device_id, - genesis_hash, - message, - )); - } - - // CRITICAL EVIDENCE POINT: at this moment, startup_initialize_identity_context has - // returned successfully, which means prime_identity_app_state has already stored - // has_identity=true AND initialize_sdk_core has stored SDK_READY=true. The scope - // guard is still holding BOOTSTRAP_SECURING=true. If compute_phase runs at this - // exact instant it should return `securing_device` (not `wallet_ready` yet). The - // scope guard drops only after we return from finalize_bootstrap_core below. - log::info!( - "FLASH_DEBUG: FINALIZE_BOOTSTRAP: IDENTITY_INSTALLED BOOTSTRAP_SECURING={} SDK_READY={} has_id={}", - crate::sdk::session_manager::BOOTSTRAP_SECURING.load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::session_manager::SDK_READY.load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::app_state::AppState::get_has_identity() - ); - - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindSecuringComplete as i32, - 0, - )?; - push_genesis_lifecycle_event(pb::genesis_lifecycle_event::Kind::GenesisKindOk as i32, 0)?; - - let ready_message = if report.trust_level - == pb::bootstrap_measurement_report::TrustLevel::BootstrapTrustLevelPinRequired as i32 - { - "bootstrap ready with degraded trust: PIN required" - } else { - "bootstrap ready" - }; - - Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultReady as i32, - context.device_id.to_vec(), - context.genesis_hash.to_vec(), - ready_message, - )) -} - -fn handle_bootstrap_measurement_report_core( - report: pb::BootstrapMeasurementReport, -) -> Result { - match report.phase { - x if x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseStarted as i32 => { - // Mark that C-DBRW securing is in progress — session manager returns - // "securing_device" phase until finalization completes. - crate::sdk::session_manager::BOOTSTRAP_SECURING - .store(true, std::sync::atomic::Ordering::SeqCst); - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindStarted as i32, - 0, - )?; - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindSecuringDevice as i32, - 0, - )?; - Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultUnspecified as i32, - report.device_id, - report.genesis_hash, - "bootstrap measurement started", - )) - } - x if x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseProgress as i32 => { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindSecuringProgress as i32, - report.progress_percent, - )?; - Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultUnspecified as i32, - report.device_id, - report.genesis_hash, - "bootstrap progress", - )) - } - x if x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseFinalize as i32 - || x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseResumeFinalize - as i32 => - { - finalize_bootstrap_core(report) - } - x if x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseAborted as i32 => { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindSecuringAborted as i32, - 0, - )?; - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - )?; - Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultAborted as i32, - report.device_id, - report.genesis_hash, - report.error_message, - )) - } - x if x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseError as i32 => { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - )?; - Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultError as i32, - report.device_id, - report.genesis_hash, - report.error_message, - )) - } - _ => Err(ingress_error( - ERROR_CODE_INVALID_INPUT, - format!("bootstrap measurement: unsupported phase {}", report.phase), - )), - } -} - fn process_envelope_core(envelope_in: Envelope) -> Result { crate::envelope::validate_envelope_v3(&envelope_in).map_err(|e| { ingress_error( @@ -380,12 +86,6 @@ fn process_envelope_core(envelope_in: Envelope) -> Result { ) })?; - if let Some(pb::envelope::Payload::BootstrapMeasurementReport(report)) = - envelope_in.payload.clone() - { - return handle_bootstrap_measurement_report_core(report); - } - let mut raw = Vec::new(); match envelope_in.encode(&mut raw) { Ok(()) => {} @@ -1279,7 +979,7 @@ mod tests { #[test] #[serial] - fn startup_initialize_identity_context_sets_identity_and_router() { + fn initialize_identity_context_sets_identity_and_router() { let identity = restarted_device(0x11); install_identity_context_core(identity.device_id.to_vec(), identity.genesis.to_vec()) .expect("identity context install should succeed"); @@ -1302,7 +1002,7 @@ mod tests { #[test] #[serial] - fn startup_initialize_identity_context_via_dispatch_succeeds() { + fn initialize_identity_context_via_dispatch_succeeds() { let identity = restarted_device(0x13); let fleet = fleet(); let response = dispatch_startup(StartupRequest { From 238deee8b33b8e9c81a16bae95b66179aa71f292 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:05:13 -0400 Subject: [PATCH 09/23] fix(core): the platform-context module says what it does It fixes the identity pair to two 32-byte arrays and nothing else. The unused domain-tag parameter and the doc's claims of hashing, canonization and being the only object the Core trusts are gone. --- .../dsm/src/pbi.rs | 69 +++++++------------ 1 file changed, 24 insertions(+), 45 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/pbi.rs b/dsm_client/deterministic_state_machine/dsm/src/pbi.rs index dc3b4b24..9f64b6ad 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/pbi.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/pbi.rs @@ -1,54 +1,36 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Platform Boundary Interface (PBI) +//! Platform boundary: the identity pair the platform hands the SDK. //! -//! This module implements the hard platform boundary described in the public -//! protocol and security documentation. -//! It acts as the sole entry point for ingesting raw, non-deterministic platform inputs (IO, JNI, Entropy) -//! and transforming them into canonical, immutable, cryptographic types *before* they touch the Core State Machine. -//! -//! # Architecture -//! -//! 1. **Ingestion**: Raw bytes from JNI/Platform are accepted. -//! 2. **Canonization**: Inputs are immediately hashed/validated into domain-separated types. -//! 3. **Context Creation**: A `PlatformContext` is built. This is the ONLY object the Core trusts. -//! -//! # Invariants -//! -//! - No raw `Vec` or `String` inputs allowed deep in the core. -//! - All inputs must be length-checked and domain-separated immediately. +//! The platform (JNI/Kotlin) hands over the persisted device id and genesis hash +//! as raw bytes. This module turns them into fixed-size arrays and nothing else: +//! each must be exactly 32 bytes. It does not hash, derive or verify them; whether +//! they name a real identity is established by the SDK's identity restore and by +//! the signing authority that derives from them. use crate::types::error::DsmError; use zeroize::{Zeroize, ZeroizeOnDrop}; -/// Canonical, immutable platform context. -/// This is the "Safe" object that the Core consumes. +/// The identity pair as two 32-byte arrays. #[derive(Debug, Clone, Zeroize, ZeroizeOnDrop)] pub struct PlatformContext { - /// Canonical Device ID (32 bytes) + /// Device ID (32 bytes) pub device_id: [u8; 32], - /// Canonical Genesis Hash (32 bytes) + /// Genesis hash (32 bytes) pub genesis_hash: [u8; 32], } -/// Raw inputs from the platform (JNI/Kotlin/Swift). -/// These are "unsafe" and must be processed immediately. +/// The pair as the platform hands it over: raw bytes of any length. pub struct RawPlatformInputs { pub device_id_raw: Vec, pub genesis_hash_raw: Vec, } impl PlatformContext { - /// The Single Point of Entry for bootstrapping the Core. - /// - /// This function consumes raw inputs and returns a sanitized Context or an Error. - /// It enforces the "Zero Tolerance" policy at the perimeter. + /// Fixes both identifiers to 32 bytes, or refuses the pair. pub fn bootstrap(inputs: RawPlatformInputs) -> Result { - // 1. Canonize Device ID - let device_id = Self::canonize_identifier(&inputs.device_id_raw, "DSM/devid\0")?; - - // 2. Canonize Genesis Hash - let genesis_hash = Self::canonize_identifier(&inputs.genesis_hash_raw, "DSM/genesis\0")?; + let device_id = Self::exactly_32_bytes(&inputs.device_id_raw)?; + let genesis_hash = Self::exactly_32_bytes(&inputs.genesis_hash_raw)?; Ok(Self { device_id, @@ -56,10 +38,7 @@ impl PlatformContext { }) } - /// Helper to validate and canonize 32-byte identifiers. - /// STRICTNESS: We expect the platform to pass the *pre-calculated* digests for IDs, - /// but we verify lengths strictly. - fn canonize_identifier(input: &[u8], _domain_tag: &str) -> Result<[u8; 32], DsmError> { + fn exactly_32_bytes(input: &[u8]) -> Result<[u8; 32], DsmError> { if input.len() != 32 { return Err(DsmError::Validation { context: format!( @@ -87,17 +66,17 @@ mod tests { } #[test] - fn canonize_identifier_exact_32_bytes() { + fn exactly_32_bytes_exact_32_bytes() { let input = vec![0x42u8; 32]; - let result = PlatformContext::canonize_identifier(&input, "DSM/test\0"); + let result = PlatformContext::exactly_32_bytes(&input); assert!(result.is_ok()); assert_eq!(result.unwrap(), [0x42u8; 32]); } #[test] - fn canonize_identifier_too_short() { + fn exactly_32_bytes_too_short() { let input = vec![0x01u8; 16]; - let result = PlatformContext::canonize_identifier(&input, "DSM/test\0"); + let result = PlatformContext::exactly_32_bytes(&input); assert!(result.is_err()); match result.unwrap_err() { DsmError::Validation { context, .. } => { @@ -109,9 +88,9 @@ mod tests { } #[test] - fn canonize_identifier_too_long() { + fn exactly_32_bytes_too_long() { let input = vec![0x01u8; 64]; - let result = PlatformContext::canonize_identifier(&input, "DSM/test\0"); + let result = PlatformContext::exactly_32_bytes(&input); assert!(result.is_err()); match result.unwrap_err() { DsmError::Validation { context, .. } => { @@ -123,15 +102,15 @@ mod tests { } #[test] - fn canonize_identifier_empty() { - let result = PlatformContext::canonize_identifier(&[], "DSM/test\0"); + fn exactly_32_bytes_empty() { + let result = PlatformContext::exactly_32_bytes(&[]); assert!(result.is_err()); } #[test] - fn canonize_identifier_preserves_bytes() { + fn exactly_32_bytes_preserves_bytes() { let input: Vec = (0..32).collect(); - let arr = PlatformContext::canonize_identifier(&input, "DSM/devid\0").unwrap(); + let arr = PlatformContext::exactly_32_bytes(&input).unwrap(); assert_eq!(&arr[..], &input[..]); } From cb50a14bd52397864f38505feee36dad8ea1e9b3 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:05:13 -0400 Subject: [PATCH 10/23] =?UTF-8?q?docs(gaps):=20=C2=A76.36=20states=20after?= =?UTF-8?q?=20the=20seventh=20fix=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/requirements/CONFORMANCE_GAPS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index d834dc23..2c836d6f 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1328,7 +1328,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | S-CONTACT | `CORE/types/contact_types.rs`, `core/contact_manager.rs` | 3, 5 | `can_perform_bilateral_transaction` is the caller-set bool `genesis_verified_online`; `add_verified_contact` admits any caller-built "verified" contact. It gates every offline step. | Open | | S-SUBSTRATE | `CORE/economic/lineage.rs` (`AcceptedSubstrate::from_verified_*`) | 5 | Public constructors that verify nothing; the SDK's own admission builds one from local coordinates. | Open | | S-IDS | `CORE/types/operations.rs`, `core/bilateral_transaction_manager.rs` | 2 | The offline `policy_id` and `anchor_set_id` are `H(tag, ε)`: identifiers with no bytes behind them (the class of §6.32); bearer status is decided from the `mode` discriminant. | Open (owner) | -| S-PBI | `CORE/pbi.rs` (`PlatformContext::bootstrap`) | 3 | "The ONLY object the Core trusts" checks lengths and nothing else; its domain tag is unused. | Open | +| S-PBI | `CORE/pbi.rs` (`PlatformContext::bootstrap`) | 3 | "The ONLY object the Core trusts" checks lengths and nothing else; its domain tag is unused. | Resolved: the module says what it does — the identity pair as two 32-byte arrays, checked for length and nothing else; the unused domain-tag parameter and the claims of hashing, canonization and sole trust are gone. | | S-NONCE | `CORE/recovery/capsule.rs` | 2 | The capsule's XChaCha20 nonce is `H(counter ‖ rollup_hash)` under a per-mnemonic key and excludes the SMT root and tips: the same counter and rollup with different content reuse a nonce. (§6.8 fixed `bitcoin_accounts` only.) | Open (recovery boundary) | | S-EXTC | `CORE/core/bridge.rs` (`ExternalCommit`); `SDK/sdk/recovery_impl.rs` (`handle_nfc_tag`) | 3, 1 | The "check" recomputes `commit_id` from the request's own fields; `handle_nfc_tag` echoes its payload as `accepted: true`. | Open | | S-APPSTATE | `CORE/core/bridge.rs` (`AppStateRequest` get/set) | 1 | Both discard their result and answer a success string. | Resolved: the arm is gone; a Core-addressed `AppStateRequest` is refused as not a Core operation. Nothing sends one: the SDK's `prefs.*` and `recovery.*` routes carry app state as ArgPack bodies. | @@ -1336,7 +1336,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | S-ACT | `SDK/handlers/recovery_routes.rs` (`recovery.activate`), `sdk/recovery_sdk.rs` | 6 | A hard-coded refusal ("not yet wired"), matched by message substring, answered as the success status `assembled;awaiting-go-live`. | Open (recovery boundary) | | S-BEARER | `SDK/sdk/core_sdk.rs` (`stage_offline_bearer_transition`) | 2 | Rewrites the head's anchor-state leaf outside `advance`: no step, no receipt, not persisted; the next receipt's parent root is one no committed step produced. | Open (owner: offline boundary) | | S-BLE | `SDK/bluetooth/ble_frame_coordinator.rs`, `bilateral_transport_adapter.rs` | constraint, 1, 2 | A 60 s wall-clock dedup drops a complete frame delivered again (which #1000's owed-frame redelivery relies on); an unexpected or `Unspecified` frame is reflected back to the peer; a malformed header gets a zero frame commitment. | Open | -| S-INGRESS | `SDK/ingress.rs` (genesis bootstrap) | 3, 5 | Gated on a caller-supplied `trust_level` described as a "C-DBRW entropy health test"; C-DBRW is deleted and nothing sends the message. | Open | +| S-INGRESS | `SDK/ingress.rs` (genesis bootstrap) | 3, 5 | Gated on a caller-supplied `trust_level` described as a "C-DBRW entropy health test"; C-DBRW is deleted and nothing sends the message. | Resolved: the bootstrap-report op (`BootstrapMeasurementReport` envelopes: started, progress, finalize, aborted, error; the trust-level gate; the finalize path and its helpers) is deleted. Nothing sent it; Genesis v2 (`system.createGenesisV2`) is the one bootstrap path. Such an envelope now reaches the Core bridge and is refused as unsupported. | | S-K9 | `KT/bridge/BridgeIdentityHandler.kt` | 5 | Kotlin keeps `device_id` and the genesis in SharedPreferences and hands them to Rust's `restore_identity_context` on cold start, beside a comment forbidding a preferences side channel. Whether Rust checks them against its own store is not established. | Open | | S-K10 | `KT/bridge/SinglePathWebViewBridge.kt`, `BridgeIdentityHandler.kt` | 1 | Exceptions and bad input are answered as SUCCESS with empty data; the frontend then fails to decode and Rust's reason is lost. | Resolved: every arm lets its failure reach the dispatcher, which answers a `BridgeRpcResponse` error carrying the reason (`getAllBalancesStrict`, `getTransportHeadersV3Bin` — a failed identity restore or status read, with empty kept only for Rust's NO_IDENTITY — `openBluetoothSettings` without an activity, a wrong-sized accept commitment, an undecodable reject/cancel payload, a blank genesis mnemonic, a failed `isErrorEnvelope` read, and any `createGenesisV2` failure after the artifacts are cleared). The test-only `handleBinaryRpcRaw` (empty on error) and `handleBinaryRpcRawStrict` are deleted; `SinglePathWebViewBridgeErrorResponseTest` decodes the dispatcher's error through the real path. | | S-N1 | `NODE/lib.rs` (`/api/v2/health`) | 1 | Always "ok", with no pool checkout; a node whose Postgres is down is healthy to its healthcheck. | Resolved: `/api/v2/health` checks a connection out of the pool and runs `SELECT 1`; 503 with the cause otherwise. Test: `dsm_storage_node::health::the_health_route_answers_ok_only_over_a_live_postgres` (live → ok; the pool closed under the node → 503). | @@ -1369,11 +1369,11 @@ The State column says what this branch did; "Open" rows are holes left visible, | ID | Location | Finding | State | |---|---|---|---| | C-F3 | `FE/components/tour/practiceMode.ts` | Practice mode monkey-patches the shared `dsmClient` with invented state, isolates by a method-name regex, and five screens bypass it. | Open | -| C-K4 | `KT/ui/MainActivity.kt` (WebView) | A CORS proxy and localhost CSP entries with no consumer. | Open | +| C-K4 | `KT/ui/MainActivity.kt` (WebView) | A CORS proxy and localhost CSP entries with no consumer. | Resolved: the CORS proxy, the host allowlist and its lock test are deleted (nothing in the page fetches an external host; the map tiles they served have no consumer), and with them the CSP's tile, maplibre, `dsm-wallet.io` and localhost entries and the service worker's tile cache. | | C-K5 | `KT/ui/MainActivity.kt` (`biometric.auth`, `setSystemBarColors`) | Two no-op ACK arms outside the RPC-name gate; `applySystemBarColors` ignores both arguments "so the route does not error" (A27 residual). No frontend caller. | Resolved: both arms and `applySystemBarColors` deleted (nothing in the frontend or `index.html` sends either; the biometric flow is the `biometric.authorize` host request). Kotlin compiles. | | C-K6 | `KT/bridge/NativeHostBridge.kt` | Five host-request arms nothing sends; `QR_STOP_SCAN` acknowledges and stops nothing. | Resolved: the bridge handles the four kinds the frontend builds (QR start, NFC reader start/stop, NFC tag write) and answers every other kind as unsupported; the seven pass-through parameters no arm read are gone. With the arms went their only reaches: `showBiometricPrompt`, the `androidx.biometric` dependency, `requestNamedPermissionsFromUi` with its camera request code, and the frontend's `BIOMETRIC_RESULT` event decode. The `'biometric'` lock-method value the frontend still declares is S-LOCK's. | | C-K17 | `KT/bridge/*` (`@VisibleForTesting` statics) | Statics that skip the port path; the instrumented proof never runs the port path production takes. | Open | -| C-F10 | `FE/dsm/*` (`__dsmLastGoodHeaders`) | A window global never invalidated. | Open | +| C-F10 | `FE/dsm/*` (`__dsmLastGoodHeaders`) | A window global never invalidated. | Resolved: `getHeaders` reads the bridge on every call; the window cache and the tests' resets of it are gone. | | C-C19 | `dsm/Cargo.toml` | Six features that gate only a name. | Open | | C-C26 | `bitcoin-testnet-bypass` | §6.22; parked. | Open | From e94c441f99e515c619bdb5a531f46885fca59594 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:12:39 -0400 Subject: [PATCH 11/23] fix(frontend): the accept path signals an accepted transfer, never a committed one acceptOfflineTransfer emitted wallet.bilateralCommitted with committed: true as soon as the accept answer arrived, before the peer's confirm. The event is now wallet.bilateralAccepted and names the transfer (commitment and counterparty) and nothing else; the transfer commits when the confirm arrives as a BLE event. The toast it drives already said accepted. --- ...ed.test.tsx => WalletContext.bilateralAccepted.test.tsx} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename dsm_client/frontend/src/contexts/__tests__/{WalletContext.bilateralCommitted.test.tsx => WalletContext.bilateralAccepted.test.tsx} (94%) diff --git a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralCommitted.test.tsx b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralAccepted.test.tsx similarity index 94% rename from dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralCommitted.test.tsx rename to dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralAccepted.test.tsx index a52717a4..1ac2ff77 100644 --- a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralCommitted.test.tsx +++ b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralAccepted.test.tsx @@ -40,7 +40,7 @@ describe('WalletContext bilateral committed event', () => { }); // An accepted transfer reaches the provider as two events from the accept - // path: `wallet.bilateralCommitted` (the signal) and `wallet.refresh` (the + // path: `wallet.bilateralAccepted` (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 () => { @@ -71,7 +71,7 @@ describe('WalletContext bilateral committed event', () => { // The signal alone reloads nothing. await act(async () => { - bridgeEvents.emit('wallet.bilateralCommitted', {} as any); + bridgeEvents.emit('wallet.bilateralAccepted', {} as any); await Promise.resolve(); await Promise.resolve(); }); @@ -106,7 +106,7 @@ describe('WalletContext bilateral committed event', () => { await renderWalletProvider(); await act(async () => { - bridgeEvents.emit('wallet.bilateralCommitted', { accepted: true } as any); + bridgeEvents.emit('wallet.bilateralAccepted', { accepted: true } as any); await Promise.resolve(); }); From 8899f318ee549be65081a862fa4e481575d604c6 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:12:39 -0400 Subject: [PATCH 12/23] fix(frontend): a bridge error shows its own message, not a cause invented for a code nothing emits The unwrapper rewrote codes 460, 404 and 408 into peer-connection, stale- state and peer-timeout stories; the Kotlin dispatcher emits codes 1 to 8 and none of those. The message the bridge sent is shown with its code. --- .../frontend/src/dsm/WebViewBridge/transportCore.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts b/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts index 4c9600e8..69b7678e 100644 --- a/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts +++ b/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts @@ -79,13 +79,7 @@ const unwrapProtobufResponse = async (_method: string, buf: Uint8Array): Promise const hex = `0x${code.toString(16).toUpperCase()}`; let uiMessage = err.message ?? `Bridge error ${hex}`; - if (code === 460) { - uiMessage = `Transfer Rejected (Offline Mode) - Check peer connection [${hex}]`; - } else if (code === 404) { - uiMessage = `Item Not Found - State may be stale [${hex}]`; - } else if (code === 408) { - uiMessage = `Protocol Timeout - Peer did not respond [${hex}]`; - } else if (!uiMessage.includes(hex)) { + if (!uiMessage.includes(hex)) { uiMessage += ` [${hex}]`; } From 1385b18cf372819828bf1e53b74c2bf9a6ad1ace Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:12:39 -0400 Subject: [PATCH 13/23] fix(frontend): a session snapshot missing its statuses is refused, not filled with false Rust fills every nested status on every snapshot. The decoder answered false, true and 'none' for anything absent and the store spread defaults under whatever passed a five-field guard. The decoder now refuses a snapshot without its lock or hardware status, reads the rest as sent, and the store keeps the snapshot as decoded, adding only that it arrived. --- dsm_client/frontend/src/dsm/EventBridge.ts | 38 ++++++++++++------- .../src/runtime/nativeSessionStore.ts | 17 ++------- .../src/runtime/nativeSessionTypes.ts | 14 ------- 3 files changed, 29 insertions(+), 40 deletions(-) diff --git a/dsm_client/frontend/src/dsm/EventBridge.ts b/dsm_client/frontend/src/dsm/EventBridge.ts index bb3ac590..f28886c3 100644 --- a/dsm_client/frontend/src/dsm/EventBridge.ts +++ b/dsm_client/frontend/src/dsm/EventBridge.ts @@ -121,33 +121,45 @@ function decodeSessionState(bytes: Uint8Array): NativeSessionSnapshot { throw new Error(`decodeSessionState: unexpected payload case '${payload?.case}'`); } const session = payload.value as pb.AppSessionStateProto; + // Rust fills every nested status on every snapshot (session_manager's + // compute_snapshot). A snapshot missing one is malformed, not a status of + // false, and is refused rather than filled in here. + const lock = session.lockStatus; + const hardware = session.hardwareStatus; + const ble = hardware?.ble; + const qr = hardware?.qr; + if (!lock || !hardware || !ble || !qr) { + throw new Error('decodeSessionState: the snapshot lacks its lock or hardware status'); + } return { received: true, phase: session.phase as NativeSessionSnapshot['phase'], identity_status: session.identityStatus as NativeSessionSnapshot['identity_status'], env_config_status: session.envConfigStatus as NativeSessionSnapshot['env_config_status'], lock_status: { - enabled: session.lockStatus?.enabled ?? false, - locked: session.lockStatus?.locked ?? false, - method: (session.lockStatus?.method || 'none') as NativeSessionSnapshot['lock_status']['method'], - lock_on_pause: session.lockStatus?.lockOnPause ?? true, + enabled: lock.enabled, + locked: lock.locked, + // Rust spells the method itself ("none" when there is no lock). + method: lock.method as NativeSessionSnapshot['lock_status']['method'], + lock_on_pause: lock.lockOnPause, }, hardware_status: { - app_foreground: session.hardwareStatus?.appForeground ?? true, + app_foreground: hardware.appForeground, ble: { - enabled: session.hardwareStatus?.ble?.enabled ?? false, - permissions_granted: session.hardwareStatus?.ble?.permissionsGranted ?? false, - scanning: session.hardwareStatus?.ble?.scanning ?? false, - advertising: session.hardwareStatus?.ble?.advertising ?? false, + enabled: ble.enabled, + permissions_granted: ble.permissionsGranted, + scanning: ble.scanning, + advertising: ble.advertising, }, qr: { - available: session.hardwareStatus?.qr?.available ?? true, - active: session.hardwareStatus?.qr?.active ?? false, - camera_permission: session.hardwareStatus?.qr?.cameraPermission ?? false, + available: qr.available, + active: qr.active, + camera_permission: qr.cameraPermission, }, }, + // Rust sends an empty string for no error. fatal_error: session.fatalError || null, - wallet_refresh_hint: Number(session.walletRefreshHint ?? 0), + wallet_refresh_hint: Number(session.walletRefreshHint), }; } diff --git a/dsm_client/frontend/src/runtime/nativeSessionStore.ts b/dsm_client/frontend/src/runtime/nativeSessionStore.ts index fe085e98..55697f3c 100644 --- a/dsm_client/frontend/src/runtime/nativeSessionStore.ts +++ b/dsm_client/frontend/src/runtime/nativeSessionStore.ts @@ -2,26 +2,17 @@ import { useSyncExternalStore } from 'react'; import { bridgeEvents } from '../bridge/bridgeEvents'; -import { - DEFAULT_NATIVE_SESSION, - type NativeSessionSnapshot, - isNativeSessionSnapshot, -} from './nativeSessionTypes'; +import { DEFAULT_NATIVE_SESSION, type NativeSessionSnapshot } from './nativeSessionTypes'; class NativeSessionStore { private snapshot: NativeSessionSnapshot = DEFAULT_NATIVE_SESSION; private listeners = new Set<() => void>(); constructor() { + // The bus carries whole snapshots (decodeSessionState refuses a partial + // one); the store adds only its own fact, that one arrived. bridgeEvents.on('session.state', (next) => { - if (!isNativeSessionSnapshot(next)) { - return; - } - this.snapshot = { - ...DEFAULT_NATIVE_SESSION, - ...next, - received: true, - }; + this.snapshot = { ...next, received: true }; this.emit(); }); } diff --git a/dsm_client/frontend/src/runtime/nativeSessionTypes.ts b/dsm_client/frontend/src/runtime/nativeSessionTypes.ts index 9bf2e071..6520ee6b 100644 --- a/dsm_client/frontend/src/runtime/nativeSessionTypes.ts +++ b/dsm_client/frontend/src/runtime/nativeSessionTypes.ts @@ -72,17 +72,3 @@ export const DEFAULT_NATIVE_SESSION: NativeSessionSnapshot = { fatal_error: null, wallet_refresh_hint: 0, }; - -export function isNativeSessionSnapshot(value: unknown): value is NativeSessionSnapshot { - if (!value || typeof value !== 'object') { - return false; - } - const snapshot = value as Partial; - return ( - typeof snapshot.phase === 'string' && - typeof snapshot.identity_status === 'string' && - typeof snapshot.env_config_status === 'string' && - typeof snapshot.lock_status === 'object' && - typeof snapshot.hardware_status === 'object' - ); -} From 46ce4e656604937e5f193737d3c67c8debc9c136 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:12:39 -0400 Subject: [PATCH 14/23] fix(frontend): the page takes the bridge port once, with its handshake; the response catch can release its entry Any window message carrying a port replaced the bridge port, whenever it arrived. The port is now taken once, from the empty-string message Kotlin posts with it; a later port-bearing message, or one without the handshake, is ignored and logged. In the port handler the response id was declared inside the try, so the catch's cleanup never saw it and a response that failed mid-decode held its pending entry until the timeout; the id is declared outside. --- dsm_client/frontend/public/index.html | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/dsm_client/frontend/public/index.html b/dsm_client/frontend/public/index.html index 1336a264..cf843c0c 100644 --- a/dsm_client/frontend/public/index.html +++ b/dsm_client/frontend/public/index.html @@ -1886,6 +1886,8 @@ // Single shared MessagePort onmessage handler (ID-based routing) function installPortHandler(p) { p.onmessage = (e) => { + // Declared outside the try so the catch can release the pending entry. + let respId; try { let bytes; const data = e.data; @@ -1928,7 +1930,7 @@ const idView = new DataView(bytes.buffer, bytes.byteOffset, 8); const hi = idView.getUint32(0, false); // big-endian high 32 bits const lo = idView.getUint32(4, false); // big-endian low 32 bits - const respId = (BigInt(hi) << 32n) | BigInt(lo); + respId = (BigInt(hi) << 32n) | BigInt(lo); // Lookup pending request by ID const state = pending.get(respId); @@ -2014,14 +2016,23 @@ try { console.log('[DSM] Bridge interface installed, waiting for port...'); } catch (_) {} - // Listen for MessagePort from Android + // The bridge port: Kotlin posts it once, as an empty-string message + // carrying the port. It is taken once; any later port-bearing message, + // or one without that handshake, is not the bridge and is ignored. window.addEventListener('message', (ev) => { - if (ev.ports && ev.ports[0]) { - port = ev.ports[0]; - installPortHandler(port); - resolvePortWaiters(port); - window.dispatchEvent(new Event('dsm-bridge-ready')); + if (!(ev.ports && ev.ports[0])) return; + if (port) { + console.warn('[DSM] Ignoring a second MessagePort delivery'); + return; } + if (ev.data !== '') { + console.warn('[DSM] Ignoring a MessagePort without the bridge handshake'); + return; + } + port = ev.ports[0]; + installPortHandler(port); + resolvePortWaiters(port); + window.dispatchEvent(new Event('dsm-bridge-ready')); }); })(); From 93cb1daa43826f89ed1ab0e8ebb6136c66ec3761 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:12:39 -0400 Subject: [PATCH 15/23] =?UTF-8?q?docs(gaps):=20=C2=A76.36=20states=20after?= =?UTF-8?q?=20the=20eighth=20fix=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/requirements/CONFORMANCE_GAPS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 2c836d6f..e58d3353 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1319,7 +1319,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | S-K1 | `KT/bridge/BridgeLogger.kt`, `SinglePathWebViewBridge.kt`, `ui/MainActivity.kt` | 7 | The first 32 bytes of every bridge payload and result are appended to `filesDir/bridge_diagnostics.log` in release builds. `generateMnemonic`'s result is the mnemonic text and `createGenesisV2`'s payload carries it. The diagnostics bundle exports that log, and its first 2,200 characters are put into a GitHub issue URL. Console output is relayed to logcat at INFO in release. | Resolved in part: the persisted bridge line names the method, the sizes and a failure, never a byte of the payload or the answer; the page's console reaches logcat in debug builds only. Open: the `diagnosticsLog` RPC still appends the frontend's own report text, and the bundle's export path is unchanged. | | S-K2 | `KT/ui/MainActivity.kt` (network config override) | 7 | On every cold start the network configuration (storage set, CA, `allow_localhost`) can be overridden from `filesDir` and from the public `Downloads/dsm_env_config.toml`. | Open | | S-F2 | `FE/dsm/rigDebug.ts`, `FE/dsm/BridgeProvider.tsx` | 7 | `window.__dsmRigQuery` ships in the production bundle and runs any router query path (queries only; the invoke surface is not exposed). The owner's rig depends on it (`scripts/rig_compose_verdict.py`), so it is not cut here. | Open (owner: keep as the rig's read hook, or gate it to debug builds) | -| S-F14 | `dsm_client/frontend/public/index.html` (`installPortHandler`) | 3 | The bridge `MessagePort` is taken from any `message` event that carries ports, with no origin check, and is replaced every time one arrives. | Open | +| S-F14 | `dsm_client/frontend/public/index.html` (`installPortHandler`) | 3 | The bridge `MessagePort` is taken from any `message` event that carries ports, with no origin check, and is replaced every time one arrives. | Resolved: the page takes the bridge port once, from the empty-string handshake Kotlin posts with it; a later port-bearing message, or one without the handshake, is ignored and logged. | | S-LOCK | `FE/services/lock/lockService.ts`, `FE/components/lock/LockScreen.tsx`; `SDK/handlers/session_routes.rs` (`session.unlock`); `KT/…/KeystoreVault.kt` | 5, 3 | The wallet lock is decided in JavaScript: PBKDF2 in WebCrypto against a hash JavaScript stored through preferences (an unsalted SHA-256 form is still accepted), the cooldown is `Date.now` in preferences, `session.unlock` takes no credential, and the seed vault's keystore key requires no user authentication. A curtain, not a lock. | Open (owner: where the credential is checked) | | S-TOFU | `SDK/bluetooth/anchor_accept.rs`, `bilateral_ble_handler.rs`; `CORE/crypto/anchor_enrollment.rs` (`FusedAnchorPin.uncompromised`) | 5, 3 | On a first transfer the sender's self-disclosed anchor pin is admitted before the release predicate runs and stays if the release is refused; `uncompromised` is a constant `true` nothing can set false; with no prior frontier the release's own `prev_root` is the frontier it is checked against. | Open (owner: offline boundary) | | S-ACK | `SDK/handlers/recovery_routes.rs`, `sdk/recovery_impl.rs`, `storage/client_db/recovery.rs` | 1, 3 | The tombstone-ACK sync gate: a lost counterparty list gives an empty gate, and an empty gate reads as "everyone acknowledged". | Open (recovery boundary) | @@ -1356,9 +1356,9 @@ The State column says what this branch did; "Open" rows are holes left visible, | B-9 | `SDK/handlers/storage_routes.rs` (reply rows) | A reply row without its release submits an empty release. | Resolved: a promoted reply row without its post-admission release is logged as a local defect and left unmarked; nothing is submitted for it. | | B-10 | `SDK/sdk/recovery_impl.rs` | A recovered `AppState` genesis is `succession.new_device_commitment`. | Open (recovery boundary) | | B-K11 | `KT/bridge/…` (`qr.available`) | Hard-coded `true`. | Resolved: `qr_available` is the device's camera feature (`FEATURE_CAMERA_ANY`), not a literal. | -| B-F4 | `FE/dsm/transactions.ts` (accept) | Emits `committed: true` before the confirm arrived. | Open | -| B-F7 | `FE/dsm/*` error mapping | Invents causes for error codes Kotlin never emits. | Open | -| B-F9 | `FE/runtime/nativeSessionStore.ts` | A session snapshot fills absent fields with defaults. | Open | +| B-F4 | `FE/dsm/transactions.ts` (accept) | Emits `committed: true` before the confirm arrived. | Resolved: the accept path emits `wallet.bilateralAccepted` naming the transfer; no `committed` flag exists to invent, and the transfer commits when the peer's confirm arrives as a BLE event. | +| B-F7 | `FE/dsm/*` error mapping | Invents causes for error codes Kotlin never emits. | Resolved: the bridge error's own message is shown with its code; the causes invented for codes 460, 404 and 408, which nothing emits, are gone. | +| B-F9 | `FE/runtime/nativeSessionStore.ts` | A session snapshot fills absent fields with defaults. | Resolved: a session snapshot missing its lock or hardware status is refused as malformed instead of filled with false; the store keeps the snapshot as decoded and adds only that it arrived. The guard and the default spread are gone. | | B-F11 | `FE/components/screens/StorageScreen.tsx` (DLVs) | A failed vault list shows as "No DLVs"; JavaScript sums locked dBTC. Bitcoin, parked. | Open | | B-F16 | `dsm_client/frontend/public/index.html` (battery LED) | Defaults to full and reads a `window.DSMBridge.getBatteryStatus` nothing installs. | Resolved: without the Battery API the LED is unlit and unclassed, and the phantom native fallback is deleted; the markup no longer starts as `full`. | | B-C13 | `CORE/recovery/tombstone.rs` (`TombstoneReceipt.old_counter`); `init_*` no-ops | Signs a counter DSM does not keep; initialisers that initialise nothing. | Open (recovery boundary) | @@ -1381,7 +1381,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | ID | Location | Finding | State | |---|---|---|---| -| D-CODEC | `KT/bridge/BridgeEnvelopeCodec.kt`; `dsm_client/frontend/public/index.html` | Hand-rolled protobuf codecs with hard-coded field numbers, beside "Kotlin MUST NOT implement custom wire decoders"; the index.html catch cleanup reads a variable out of scope. | Open | +| D-CODEC | `KT/bridge/BridgeEnvelopeCodec.kt`; `dsm_client/frontend/public/index.html` | Hand-rolled protobuf codecs with hard-coded field numbers, beside "Kotlin MUST NOT implement custom wire decoders"; the index.html catch cleanup reads a variable out of scope. | Open: the page's port handler now declares the response id outside its `try`, so the catch releases the pending entry it used to miss; the Kotlin hand-rolled codecs remain. | | D-SAFETY | `KT/bridge/*` (safety scan); `FE/dsm/*` (safety classification) | Kotlin scans a field that cannot match on the ingress path; the frontend classifies safety by regex over message text instead of reading `Error.source_tag`. | Open | | D-F8 | `FE/dsm/WebViewBridge/strictQueries.ts` (`wallet.history`) | Sixteen raw little-endian bytes in a `Codec.PROTO` ArgPack. | Open | | D-F13 | `FE/services/recovery/nfcRecoveryService.ts` | A `key=value` text protocol with `'0'` defaults; JavaScript decodes capsule bytes (§6.29 Open, recovery boundary). | Open | From 8c1ccfbcfbfd34709c8db1271314738b89df3267 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:13:14 -0400 Subject: [PATCH 16/23] fix(frontend): the sources of the accept-event rename The commit before last carried only the renamed provider test: its add command failed on the old test path and staged nothing else. This is the rest of the rename: the event map, the emitter, the accept path, the provider's signal and the tests that name the event. --- .../frontend/src/bridge/bridgeEvents.ts | 2 +- .../frontend/src/contexts/WalletContext.tsx | 2 +- .../WalletContext.bilateralThrottle.test.tsx | 17 +++++--- .../__tests__/bilateralAcceptEvent.test.ts | 8 ++-- .../frontend/src/dsm/__tests__/events.test.ts | 34 ++++++--------- .../src/dsm/__tests__/policies.test.ts | 2 +- dsm_client/frontend/src/dsm/events.ts | 16 ++++---- dsm_client/frontend/src/dsm/transactions.ts | 12 +++--- .../src/tests/E2E.uiCoordination.test.tsx | 41 ++++++++----------- 9 files changed, 58 insertions(+), 76 deletions(-) diff --git a/dsm_client/frontend/src/bridge/bridgeEvents.ts b/dsm_client/frontend/src/bridge/bridgeEvents.ts index 10b39998..e5799071 100644 --- a/dsm_client/frontend/src/bridge/bridgeEvents.ts +++ b/dsm_client/frontend/src/bridge/bridgeEvents.ts @@ -8,7 +8,7 @@ export type BridgeEventMap = { 'session.state': NativeSessionSnapshot; 'identity.ready': void; 'wallet.refresh': { source: string; [k: string]: any }; - 'wallet.bilateralCommitted': { commitmentHash?: Uint8Array; counterpartyDeviceId?: Uint8Array; accepted?: boolean; committed?: boolean; rejected?: boolean }; + 'wallet.bilateralAccepted': { commitmentHash: Uint8Array; counterpartyDeviceId: Uint8Array }; 'wallet.creditReceived': { source: string; tokenId?: string; amount?: bigint | string | number; nextBalance?: bigint | string | number; creditCount?: number }; 'dsm.deterministicSafety': { classification: string; message?: string }; 'contact.bleMapped': { address: string; deviceId?: string; genesisHash?: string }; diff --git a/dsm_client/frontend/src/contexts/WalletContext.tsx b/dsm_client/frontend/src/contexts/WalletContext.tsx index 8df16e64..4557cdf0 100644 --- a/dsm_client/frontend/src/contexts/WalletContext.tsx +++ b/dsm_client/frontend/src/contexts/WalletContext.tsx @@ -63,7 +63,7 @@ export const WalletContext = createContext(defaultValue); export const WalletProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const { notifyToast } = useUX(); const state = useWalletStore(); - const bilateralSignal = useEventSignal('wallet.bilateralCommitted'); + const bilateralSignal = useEventSignal('wallet.bilateralAccepted'); const refreshWalletProjection = React.useCallback(async () => { try { 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 cbff8873..7d469de5 100644 --- a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralThrottle.test.tsx +++ b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralThrottle.test.tsx @@ -6,7 +6,12 @@ import { UXProvider } from '../UXContext'; import { WalletProvider } from '../WalletContext'; import GlobalToast from '../../components/GlobalToast'; import { dsmClient } from '@/dsm/index'; -import { emitBilateralCommitted } from '@/dsm/events'; +import { emitBilateralAccepted } from '@/dsm/events'; + +const accepted = { + commitmentHash: new Uint8Array(32).fill(0x11), + counterpartyDeviceId: new Uint8Array(32).fill(0x22), +}; describe('WalletContext bilateral event throttle & toast', () => { afterEach(() => { @@ -52,9 +57,9 @@ describe('WalletContext bilateral event throttle & toast', () => { // Rapidly dispatch 3 events at t=0 act(() => { - emitBilateralCommitted(); - emitBilateralCommitted(); - emitBilateralCommitted(); + emitBilateralAccepted(accepted); + emitBilateralAccepted(accepted); + emitBilateralAccepted(accepted); }); // Deterministic coalescing uses a microtask gate. Flush microtasks to allow it to run. @@ -68,8 +73,8 @@ describe('WalletContext bilateral event throttle & toast', () => { // A second burst: one more toast, still no reload from the signal. act(() => { - emitBilateralCommitted(); - emitBilateralCommitted(); + emitBilateralAccepted(accepted); + emitBilateralAccepted(accepted); }); await act(async () => { diff --git a/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts b/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts index 259d3639..f0eb6b8f 100644 --- a/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts @@ -26,9 +26,9 @@ describe('bilateral accept event dispatch', () => { jest.restoreAllMocks(); }); - // One accept, one committed signal. It used to be dispatched as a window + // One accept, one accepted 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 () => { + test('acceptOfflineTransfer emits wallet.bilateralAccepted exactly once', async () => { const commitmentHash = new Uint8Array(32).fill(2); const counterpartyDeviceId = new Uint8Array(32).fill(3); const env = new pb.Envelope({ @@ -51,15 +51,13 @@ describe('bilateral accept event dispatch', () => { }; const handler = jest.fn(); - const off = bridgeEvents.on('wallet.bilateralCommitted', handler as any); + const off = bridgeEvents.on('wallet.bilateralAccepted', handler as any); await acceptOfflineTransfer({ commitmentHash, counterpartyDeviceId }); off(); expect(handler).toHaveBeenCalledTimes(1); expect(handler.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ - accepted: true, - committed: true, commitmentHash, counterpartyDeviceId, })); diff --git a/dsm_client/frontend/src/dsm/__tests__/events.test.ts b/dsm_client/frontend/src/dsm/__tests__/events.test.ts index 6b12ef1e..aec39538 100644 --- a/dsm_client/frontend/src/dsm/__tests__/events.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/events.test.ts @@ -22,7 +22,7 @@ jest.mock('../../bridge/bridgeEvents', () => { }; }); -import { emitWalletRefresh, emitBilateralCommitted } from '../events'; +import { emitWalletRefresh, emitBilateralAccepted } from '../events'; import { bridgeEvents } from '../../bridge/bridgeEvents'; describe('events.ts', () => { @@ -42,27 +42,14 @@ describe('events.ts', () => { }); }); - describe('emitBilateralCommitted', () => { - test('emits wallet.bilateralCommitted with detail', () => { + describe('emitBilateralAccepted', () => { + test('emits wallet.bilateralAccepted with the transfer it names', () => { const detail = { commitmentHash: new Uint8Array(32).fill(0xAA), counterpartyDeviceId: new Uint8Array(32).fill(0xBB), - accepted: true, - committed: true, - rejected: false, }; - emitBilateralCommitted(detail); - expect(bridgeEvents.emit).toHaveBeenCalledWith('wallet.bilateralCommitted', detail); - }); - - test('emits empty object when no detail provided', () => { - emitBilateralCommitted(); - expect(bridgeEvents.emit).toHaveBeenCalledWith('wallet.bilateralCommitted', {}); - }); - - test('emits empty object when undefined is passed', () => { - emitBilateralCommitted(undefined); - expect(bridgeEvents.emit).toHaveBeenCalledWith('wallet.bilateralCommitted', {}); + emitBilateralAccepted(detail); + expect(bridgeEvents.emit).toHaveBeenCalledWith('wallet.bilateralAccepted', detail); }); }); @@ -75,12 +62,15 @@ describe('events.ts', () => { expect(listener).toHaveBeenCalledWith({ source: 'test' }); }); - test('wallet.bilateralCommitted event is received by listeners', () => { + test('wallet.bilateralAccepted event is received by listeners', () => { const listener = jest.fn(); - bridgeEvents.on('wallet.bilateralCommitted', listener); + bridgeEvents.on('wallet.bilateralAccepted', listener); - const detail = { accepted: true, committed: true }; - emitBilateralCommitted(detail); + const detail = { + commitmentHash: new Uint8Array(32).fill(1), + counterpartyDeviceId: new Uint8Array(32).fill(2), + }; + emitBilateralAccepted(detail); expect(listener).toHaveBeenCalledWith(detail); }); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/policies.test.ts b/dsm_client/frontend/src/dsm/__tests__/policies.test.ts index 03c6caa5..c419b9ea 100644 --- a/dsm_client/frontend/src/dsm/__tests__/policies.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/policies.test.ts @@ -9,7 +9,7 @@ jest.mock('../WebViewBridge', () => ({ jest.mock('../events', () => ({ emitWalletRefresh: jest.fn(), - emitBilateralCommitted: jest.fn(), + emitBilateralAccepted: jest.fn(), })); import * as pb from '../../proto/dsm_app_pb'; diff --git a/dsm_client/frontend/src/dsm/events.ts b/dsm_client/frontend/src/dsm/events.ts index f86c9e09..83ff98ee 100644 --- a/dsm_client/frontend/src/dsm/events.ts +++ b/dsm_client/frontend/src/dsm/events.ts @@ -3,15 +3,13 @@ // SPDX-License-Identifier: Apache-2.0 // Shared event definitions to avoid circular dependencies between index.ts and EventBridge.ts -// Standard event payload for UI updates: emitted when a bilateral transfer has been committed -export interface BilateralCommittedEventDetail { +// Emitted once the accept of an offline transfer has been sent. The transfer +// is not committed by then: the peer's confirm arrives later as a BLE event. +export interface BilateralAcceptedEventDetail { // Bytes-only: protocol boundary must not depend on hex/json. // If UI needs display, compute Base32 at render-time. - commitmentHash?: Uint8Array; - counterpartyDeviceId?: Uint8Array; - accepted?: boolean; - committed?: boolean; - rejected?: boolean; + commitmentHash: Uint8Array; + counterpartyDeviceId: Uint8Array; } /** @@ -37,6 +35,6 @@ export function emitWalletRefresh(detail: WalletRefreshDetail): void { bridgeEvents.emit('wallet.refresh', detail); } -export function emitBilateralCommitted(detail?: BilateralCommittedEventDetail): void { - bridgeEvents.emit('wallet.bilateralCommitted', detail ?? {}); +export function emitBilateralAccepted(detail: BilateralAcceptedEventDetail): void { + bridgeEvents.emit('wallet.bilateralAccepted', detail); } diff --git a/dsm_client/frontend/src/dsm/transactions.ts b/dsm_client/frontend/src/dsm/transactions.ts index ba3e7d88..0d2e31e3 100644 --- a/dsm_client/frontend/src/dsm/transactions.ts +++ b/dsm_client/frontend/src/dsm/transactions.ts @@ -13,7 +13,7 @@ import { getPendingBilateralListStrictBridge, } from './WebViewBridge'; import { on as eventBridgeOn } from './EventBridge'; -import { emitBilateralCommitted } from './events'; +import { emitBilateralAccepted } from './events'; import { bridgeEvents } from '../bridge/bridgeEvents'; import logger from '../utils/logger'; @@ -364,17 +364,15 @@ export async function acceptOfflineTransfer(args: { commitmentHash: Uint8Array, if (!answer.success) { return answer; } - // 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. + // The accept went out: that, and only that, is signalled here, once. The + // transfer commits when the peer's confirm arrives as a BLE event. try { - emitBilateralCommitted({ + emitBilateralAccepted({ commitmentHash: new Uint8Array(args.commitmentHash), counterpartyDeviceId: new Uint8Array(args.counterpartyDeviceId), - accepted: true, - committed: true, }); } catch (e) { - logger.warn('[DSM] Failed to emit bilateral committed event:', e); + logger.warn('[DSM] Failed to emit bilateral accepted event:', e); } // Don't fire wallet.refresh here — the balance hasn't changed yet (Accept // was sent, but Confirm hasn't arrived). Firing now queries balance=0 and diff --git a/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx b/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx index 66bbd4fa..bf5c54a0 100644 --- a/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx +++ b/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx @@ -355,19 +355,13 @@ describe('BridgeEventBus — core event delivery', () => { u1(); u2(); }); - test('wallet.bilateralCommitted carries typed payload', () => { + test('wallet.bilateralAccepted carries typed payload', () => { const spy = jest.fn(); - const unsub = bridgeEvents.on('wallet.bilateralCommitted', spy); - bridgeEvents.emit('wallet.bilateralCommitted', { - commitmentHash: makeCommitmentHash(), - counterpartyDeviceId: makeDeviceId(), - accepted: true, - committed: true, - }); - expect(spy).toHaveBeenCalledWith(expect.objectContaining({ - accepted: true, - committed: true, - })); + const unsub = bridgeEvents.on('wallet.bilateralAccepted', spy); + const commitmentHash = makeCommitmentHash(); + const counterpartyDeviceId = makeDeviceId(); + bridgeEvents.emit('wallet.bilateralAccepted', { commitmentHash, counterpartyDeviceId }); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ commitmentHash, counterpartyDeviceId })); unsub(); }); @@ -389,11 +383,11 @@ describe('useEventSignal — React external store integration', () => { test('increments on bridge event emission', async () => { const C: React.FC = () => { - const s = useEventSignal('wallet.bilateralCommitted'); + const s = useEventSignal('wallet.bilateralAccepted'); return
{s}
; }; const { unmount } = render(); - act(() => { bridgeEvents.emit('wallet.bilateralCommitted', {} as any); }); + act(() => { bridgeEvents.emit('wallet.bilateralAccepted', {} as any); }); await waitFor(() => { expect(parseInt(screen.getByTestId('sig2').textContent || '0')).toBeGreaterThan(0); }); @@ -931,11 +925,11 @@ describe('INTEGRATED: Full chain with sendMessageBin-only mock', () => { balancesState = [{ tokenId: 'ERA', available: 10500n }]; capturedMethods = []; - // What the accept path emits: the committed signal (the toast's trigger) + // What the accept path emits: the accepted 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.bilateralAccepted', { commitmentHash: makeCommitmentHash(), counterpartyDeviceId: makeDeviceId() }); bridgeEvents.emit('wallet.refresh', { source: 'bilateral.accept_followup' }); }); @@ -1098,14 +1092,13 @@ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { // Verify sendMessageBin was called for the balance refresh expect(capturedMethods).toContain('getAllBalancesStrict'); - // ──── Step 7: wallet.bilateralCommitted → refresh again via sendMessageBin ──── + // ──── Step 7: wallet.bilateralAccepted → refresh again via sendMessageBin ──── capturedMethods = []; act(() => { - bridgeEvents.emit('wallet.bilateralCommitted', { + bridgeEvents.emit('wallet.bilateralAccepted', { commitmentHash: makeCommitmentHash(0x22), - accepted: true, - committed: true, - } as any); + counterpartyDeviceId: makeDeviceId(), + }); }); await waitFor(() => { @@ -1194,9 +1187,9 @@ describe('Error resilience', () => { test('handler error does not break other handlers', () => { const bad = jest.fn(() => { throw new Error('crash'); }); const good = jest.fn(); - const u1 = bridgeEvents.on('wallet.bilateralCommitted', bad as any); - const u2 = bridgeEvents.on('wallet.bilateralCommitted', good); - bridgeEvents.emit('wallet.bilateralCommitted', { accepted: true } as any); + const u1 = bridgeEvents.on('wallet.bilateralAccepted', bad as any); + const u2 = bridgeEvents.on('wallet.bilateralAccepted', good); + bridgeEvents.emit('wallet.bilateralAccepted', { accepted: true } as any); expect(bad).toHaveBeenCalled(); expect(good).toHaveBeenCalled(); u1(); u2(); From dc2c186e042c8522669e95802884017a9bfedb68 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:18:33 -0400 Subject: [PATCH 17/23] fix(bridge): the Kotlin codec decodes and encodes through the generated protobuf classes BridgeEnvelopeCodec carried its own varint and length-delimited parser with hard-coded field numbers for every bridge message, beside the rule that Kotlin implements no wire decoder. It now uses the generated classes of dsm_app.proto for the request, the response, the error, the app-router, preference and bilateral payloads and the envelope's error, keeping its public shape. A request without a method is refused. Tests that asserted the hand-rolled parser's private rules (a second oneof member, a wrong wire type as a failure, an empty-versus-absent debug string) now assert protobuf's semantics. --- .../dsm/wallet/bridge/BridgeEnvelopeCodec.kt | 634 ++++-------------- .../wallet/bridge/BridgeEnvelopeCodecTest.kt | 46 +- 2 files changed, 144 insertions(+), 536 deletions(-) diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt index 5a1a19c1..bb104101 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt @@ -2,8 +2,22 @@ package com.dsm.wallet.bridge -import java.io.ByteArrayOutputStream - +import com.google.protobuf.ByteString +import com.google.protobuf.InvalidProtocolBufferException +import dsm.types.proto.AppRouterPayload +import dsm.types.proto.BilateralPayload +import dsm.types.proto.BridgeRpcRequest +import dsm.types.proto.BridgeRpcResponse +import dsm.types.proto.Envelope +import dsm.types.proto.ErrorResponse +import dsm.types.proto.PreferencePayload +import dsm.types.proto.SuccessResponse + +/** + * The bridge's wire shapes, decoded and encoded by the generated protobuf classes + * of `proto/dsm_app.proto` (`dsm.types.proto`). Kotlin implements no wire decoder + * of its own: every field number and wire type is the generated parser's. + */ internal object BridgeEnvelopeCodec { data class BridgeRequest(val method: String, val payload: ByteArray) @@ -18,551 +32,155 @@ internal object BridgeEnvelopeCodec { data class BilateralRequest(val commitment: ByteArray, val reason: String?) - fun parseBridgeRequest(requestBytes: ByteArray): BridgeRequest { - var offset = 0 - var method = "" - var payload = ByteArray(0) - var payloadFound = false + private const val METHOD_MAX_BYTES = 128 - while (offset < requestBytes.size) { - val (key, keyOff) = readVarint(requestBytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() + /** Rust's source tag on a deterministic-safety refusal. */ + private const val SOURCE_TAG_DETERMINISTIC_SAFETY = 11 - when (fieldNumber) { - 1 -> { - if (wireType != 2) throw IllegalArgumentException("BridgeRpcRequest.method wrong wire type") - val (bytes, off) = readLengthDelimited(requestBytes, offset) - if (bytes.size > 128) { - throw IllegalArgumentException("BridgeRpcRequest.method too long: ${bytes.size} bytes (max 128)") - } - offset = off - method = bytes.toString(Charsets.UTF_8) - // Additional validation: method should be a valid identifier - if (method.isEmpty() || !method.all { it.isLetterOrDigit() || it in "_.-" }) { - throw IllegalArgumentException("BridgeRpcRequest.method invalid characters: '$method'") - } - } - 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 -> { - if (wireType != 2) throw IllegalArgumentException("BridgeRpcRequest.payload wrong wire type") - if (payloadFound) throw IllegalArgumentException("BridgeRpcRequest has multiple payloads") - payloadFound = true - val (bytes, off) = readLengthDelimited(requestBytes, offset) - offset = off - payload = decodePayload(fieldNumber, bytes) - } - else -> { - offset = skipField(wireType, requestBytes, offset) - } - } + /** + * Decodes a `BridgeRpcRequest`. The method must be present, at most 128 bytes + * and an identifier (letters, digits, `_`, `.`, `-`). The payload is the oneof + * member's content: the inner bytes of the bytes, string and BLE payloads, and + * the message's own bytes for the typed payloads the dispatcher decodes itself. + */ + fun parseBridgeRequest(requestBytes: ByteArray): BridgeRequest { + val req = try { + BridgeRpcRequest.parseFrom(requestBytes) + } catch (e: InvalidProtocolBufferException) { + throw IllegalArgumentException("BridgeRpcRequest does not decode: ${e.message}", e) + } + val method = req.method + if (method.isEmpty()) { + throw IllegalArgumentException("BridgeRpcRequest.method missing") + } + val methodBytes = method.toByteArray(Charsets.UTF_8).size + if (methodBytes > METHOD_MAX_BYTES) { + throw IllegalArgumentException("BridgeRpcRequest.method too long: $methodBytes bytes (max $METHOD_MAX_BYTES)") + } + if (!method.all { it.isLetterOrDigit() || it in "_.-" }) { + throw IllegalArgumentException("BridgeRpcRequest.method invalid characters: '$method'") + } + val payload = when (req.payloadCase) { + BridgeRpcRequest.PayloadCase.EMPTY, + BridgeRpcRequest.PayloadCase.PAYLOAD_NOT_SET -> ByteArray(0) + BridgeRpcRequest.PayloadCase.BYTES -> req.bytes.data.toByteArray() + BridgeRpcRequest.PayloadCase.STRING -> req.string.value.toByteArray(Charsets.UTF_8) + BridgeRpcRequest.PayloadCase.PREFERENCE -> req.preference.toByteArray() + BridgeRpcRequest.PayloadCase.APP_ROUTER -> req.appRouter.toByteArray() + BridgeRpcRequest.PayloadCase.BLE_CONTACT -> req.bleContact.pairingData.toByteArray() + BridgeRpcRequest.PayloadCase.BLE_ADDRESS -> req.bleAddress.deviceId.toByteArray() + BridgeRpcRequest.PayloadCase.BILATERAL -> req.bilateral.toByteArray() } - return BridgeRequest(method, payload) } + /** The message of a deterministic-safety refusal carried by an `Envelope`, else null. */ fun extractDeterministicSafetyMessageFromEnvelope(envelopeBytes: ByteArray): String? { val err = extractErrorInfoFromEnvelope(envelopeBytes) ?: return null - return if (err.sourceTag == 11) err.message else null + return if (err.sourceTag == SOURCE_TAG_DETERMINISTIC_SAFETY) err.message else null } + /** (isSuccess, payload): the success data, or the `ErrorResponse` bytes. */ fun parseEnvelopeResponse(responseBytes: ByteArray): Pair { - return parseBridgeRpcResponse(responseBytes) + val resp = try { + BridgeRpcResponse.parseFrom(responseBytes) + } catch (e: InvalidProtocolBufferException) { + throw IllegalArgumentException("BridgeRpcResponse does not decode: ${e.message}", e) + } + return when (resp.resultCase) { + BridgeRpcResponse.ResultCase.SUCCESS -> Pair(true, resp.success.data.toByteArray()) + BridgeRpcResponse.ResultCase.ERROR -> Pair(false, resp.error.toByteArray()) + BridgeRpcResponse.ResultCase.RESULT_NOT_SET -> + throw IllegalArgumentException("BridgeRpcResponse missing result") + } } fun decodeBridgeRpcError(errorBytes: ByteArray): BridgeRpcError? { - var offset = 0 - var errorCode = 0 - var message = "" - var debugB32: String? = null - - while (offset < errorBytes.size) { - val (key, keyOff) = readVarint(errorBytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - - when (fieldNumber) { - 1 -> { - if (wireType != 0) return null - val (value, off) = readVarint(errorBytes, offset) - offset = off - errorCode = value.toInt() - } - 2 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(errorBytes, offset) - offset = off - message = bytes.toString(Charsets.UTF_8) - } - 3 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(errorBytes, offset) - offset = off - debugB32 = bytes.toString(Charsets.UTF_8) - } - else -> { - offset = skipField(wireType, errorBytes, offset) - } - } - } - - return if (errorCode != 0 || message.isNotEmpty() || !debugB32.isNullOrEmpty()) { - BridgeRpcError(errorCode, message, debugB32) - } else { - null + val err = try { + ErrorResponse.parseFrom(errorBytes) + } catch (_: InvalidProtocolBufferException) { + return null } + if (err.errorCode == 0 && err.message.isEmpty() && err.debugB32.isEmpty()) return null + return BridgeRpcError(err.errorCode, err.message, err.debugB32.ifEmpty { null }) } - fun encodeAppRouterPayload(methodName: String, args: ByteArray): ByteArray { - val methodBytes = methodName.toByteArray(Charsets.UTF_8) - - val out = ByteArrayOutputStream() - // field 1 (method_name), wire type 2 - out.write(0x0A) - out.write(encodeVarint32(methodBytes.size)) - out.write(methodBytes) - - // field 2 (args), wire type 2 - out.write(0x12) - out.write(encodeVarint32(args.size)) - out.write(args) - return out.toByteArray() - } + fun encodeAppRouterPayload(methodName: String, args: ByteArray): ByteArray = + AppRouterPayload.newBuilder() + .setMethodName(methodName) + .setArgs(ByteString.copyFrom(args)) + .build() + .toByteArray() fun decodeAppRouterPayload(payloadBytes: ByteArray): AppRouterRequest? { - var offset = 0 - var methodName = "" - var args = ByteArray(0) - while (offset < payloadBytes.size) { - val (key, keyOff) = readVarint(payloadBytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - when (fieldNumber) { - 1 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - methodName = bytes.toString(Charsets.UTF_8) - } - 2 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - args = bytes - } - else -> { - offset = skipField(wireType, payloadBytes, offset) - } - } + val p = try { + AppRouterPayload.parseFrom(payloadBytes) + } catch (_: InvalidProtocolBufferException) { + return null } - if (methodName.isBlank()) return null - return AppRouterRequest(methodName, args) + if (p.methodName.isBlank()) return null + return AppRouterRequest(p.methodName, p.args.toByteArray()) } fun decodePreferencePayload(payloadBytes: ByteArray): PreferenceRequest? { - var offset = 0 - var key = "" - var value: String? = null - while (offset < payloadBytes.size) { - val (tag, keyOff) = readVarint(payloadBytes, offset) - offset = keyOff - val fieldNumber = (tag ushr 3).toInt() - val wireType = (tag and 0x07).toInt() - when (fieldNumber) { - 1 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - key = bytes.toString(Charsets.UTF_8) - } - 2 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - value = bytes.toString(Charsets.UTF_8) - } - else -> { - offset = skipField(wireType, payloadBytes, offset) - } - } + val p = try { + PreferencePayload.parseFrom(payloadBytes) + } catch (_: InvalidProtocolBufferException) { + return null } - if (key.isBlank()) return null - return PreferenceRequest(key, value) + if (p.key.isBlank()) return null + return PreferenceRequest(p.key, if (p.hasValue()) p.value else null) } fun decodeBilateralPayload(payloadBytes: ByteArray): BilateralRequest? { - var offset = 0 - var commitment = ByteArray(0) - var reason: String? = null - while (offset < payloadBytes.size) { - val (tag, keyOff) = readVarint(payloadBytes, offset) - offset = keyOff - val fieldNumber = (tag ushr 3).toInt() - val wireType = (tag and 0x07).toInt() - when (fieldNumber) { - 1 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - commitment = bytes - } - 2 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - reason = bytes.toString(Charsets.UTF_8) - } - else -> { - offset = skipField(wireType, payloadBytes, offset) - } - } + val p = try { + BilateralPayload.parseFrom(payloadBytes) + } catch (_: InvalidProtocolBufferException) { + return null } + val commitment = p.commitment.toByteArray() if (commitment.size != 32) return null - return BilateralRequest(commitment, reason) + return BilateralRequest(commitment, if (p.hasReason()) p.reason else null) } - fun createSuccessResponse(data: ByteArray): ByteArray { - // SuccessResponse { bytes data = 1 } - val successStream = ByteArrayOutputStream() - successStream.write(0x0A) // field 1, wire type 2 - val dataLenVarint = encodeVarint32(data.size) - successStream.write(dataLenVarint) - successStream.write(data) - val successBytes = successStream.toByteArray() - - // BridgeRpcResponse: field 1 = success - val bridgeStream = ByteArrayOutputStream() - bridgeStream.write(0x0A) - bridgeStream.write(encodeVarint32(successBytes.size)) - bridgeStream.write(successBytes) - return bridgeStream.toByteArray() - } + fun createSuccessResponse(data: ByteArray): ByteArray = + BridgeRpcResponse.newBuilder() + .setSuccess(SuccessResponse.newBuilder().setData(ByteString.copyFrom(data))) + .build() + .toByteArray() + /** The debug string encodes the error without itself; an encoder that fails leaves it empty. */ fun createErrorResponse( errorCode: Int, message: String, debugEncoder: (ByteArray) -> String ): ByteArray { - val msgBytes = message.toByteArray(Charsets.UTF_8) - - val preimageStream = ByteArrayOutputStream() - preimageStream.write(0x08) // field 1, wire type 0 - preimageStream.write(encodeVarint32(errorCode)) - preimageStream.write(0x12) // field 2, wire type 2 - preimageStream.write(encodeVarint32(msgBytes.size)) - preimageStream.write(msgBytes) - preimageStream.write(0x1A) // field 3, wire type 2 (empty debug) - preimageStream.write(encodeVarint32(0)) - - val errorPreimageBytes = preimageStream.toByteArray() - val debugStr = try { debugEncoder(errorPreimageBytes) } catch (_: Throwable) { "" } - val debugBytes = debugStr.toByteArray(Charsets.UTF_8) - - val errStream = ByteArrayOutputStream() - errStream.write(0x08) // field 1, wire type 0 - errStream.write(encodeVarint32(errorCode)) - errStream.write(0x12) // field 2, wire type 2 - errStream.write(encodeVarint32(msgBytes.size)) - errStream.write(msgBytes) - errStream.write(0x1A) // field 3, wire type 2 - errStream.write(encodeVarint32(debugBytes.size)) - errStream.write(debugBytes) - - val errorBytes = errStream.toByteArray() - - // BridgeRpcResponse: field 2 = error - val bridgeStream = ByteArrayOutputStream() - bridgeStream.write(0x12) - bridgeStream.write(encodeVarint32(errorBytes.size)) - bridgeStream.write(errorBytes) - return bridgeStream.toByteArray() - } - - private fun parseBridgeRpcResponse(bytes: ByteArray): Pair { - var offset = 0 - var isSuccess: Boolean? = null - var payload = ByteArray(0) - - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - - when (fieldNumber) { - 1 -> { - if (wireType != 2) throw IllegalArgumentException("BridgeRpcResponse.success wrong wire type") - if (isSuccess != null) throw IllegalArgumentException("BridgeRpcResponse has multiple results") - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - payload = parseSuccessPayload(msgBytes) - isSuccess = true - } - 2 -> { - if (wireType != 2) throw IllegalArgumentException("BridgeRpcResponse.error wrong wire type") - if (isSuccess != null) throw IllegalArgumentException("BridgeRpcResponse has multiple results") - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - payload = msgBytes - isSuccess = false - } - else -> { - offset = skipField(wireType, bytes, offset) - } - } - } - - if (isSuccess == null) throw IllegalArgumentException("BridgeRpcResponse missing result") - return Pair(isSuccess == true, payload) + val preimage = ErrorResponse.newBuilder() + .setErrorCode(errorCode) + .setMessage(message) + .build() + .toByteArray() + val debug = try { debugEncoder(preimage) } catch (_: Throwable) { "" } + val error = ErrorResponse.newBuilder() + .setErrorCode(errorCode) + .setMessage(message) + .setDebugB32(debug) + .build() + return BridgeRpcResponse.newBuilder().setError(error).build().toByteArray() } private fun extractErrorInfoFromEnvelope(bytes: ByteArray): DsmErrorInfo? { - var offset = 0 - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - - when (fieldNumber) { - 99 -> { - if (wireType != 2) return null - val (errBytes, _) = readLengthDelimited(bytes, offset) - return parseErrorInfo(errBytes) - } - 11 -> { - if (wireType != 2) { - offset = skipField(wireType, bytes, offset) - } else { - val (rxBytes, off) = readLengthDelimited(bytes, offset) - offset = off - val err = parseUniversalRxForError(rxBytes) - if (err != null) return err - } - } - else -> { - offset = skipField(wireType, bytes, offset) - } - } - } - return null - } - - private fun parseUniversalRxForError(bytes: ByteArray): DsmErrorInfo? { - var offset = 0 - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - - if (fieldNumber == 1 && wireType == 2) { - val (opBytes, off) = readLengthDelimited(bytes, offset) - offset = off - val err = parseOpResultForError(opBytes) - if (err != null) return err - } else { - offset = skipField(wireType, bytes, offset) - } - } - return null - } - - private fun parseOpResultForError(bytes: ByteArray): DsmErrorInfo? { - var offset = 0 - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - if (fieldNumber == 5 && wireType == 2) { - val (errBytes, _) = readLengthDelimited(bytes, offset) - return parseErrorInfo(errBytes) - } - offset = skipField(wireType, bytes, offset) - } - return null - } - - private fun parseErrorInfo(bytes: ByteArray): DsmErrorInfo? { - var offset = 0 - var message = "" - var sourceTag = 0 - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - when (fieldNumber) { - 2 -> { - if (wireType != 2) return null - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - message = msgBytes.toString(Charsets.UTF_8) - } - 4 -> { - if (wireType != 0) return null - val (tag, off) = readVarint(bytes, offset) - offset = off - sourceTag = tag.toInt() - } - else -> { - offset = skipField(wireType, bytes, offset) - } - } - } - return if (sourceTag != 0 || message.isNotEmpty()) DsmErrorInfo(sourceTag, message) else null - } - - private fun parseSuccessPayload(bytes: ByteArray): ByteArray { - var offset = 0 - var data = ByteArray(0) - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - if (fieldNumber == 1) { - if (wireType != 2) throw IllegalArgumentException("SuccessResponse.data wrong wire type") - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - data = msgBytes - } else { - offset = skipField(wireType, bytes, offset) - } - } - return data - } - - private fun decodePayload(fieldNumber: Int, bytes: ByteArray): ByteArray { - return when (fieldNumber) { - 2 -> ByteArray(0) - 3 -> parseBytesPayload(bytes) - 4 -> parseStringPayload(bytes) - 5 -> parsePreferencePayload(bytes) - 6 -> parseAppRouterPayload(bytes) - 8 -> parseSingleBytesPayload(bytes) - 9 -> parseSingleBytesPayload(bytes) - 11 -> parseBilateralPayload(bytes) - else -> ByteArray(0) - } - } - - private fun parseBytesPayload(bytes: ByteArray): ByteArray { - var offset = 0 - var out = ByteArray(0) - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - if (fieldNumber == 1) { - if (wireType != 2) throw IllegalArgumentException("BytesPayload.data wrong wire type") - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - out = msgBytes - } else { - offset = skipField(wireType, bytes, offset) - } - } - return out - } - - private fun parseStringPayload(bytes: ByteArray): ByteArray { - var offset = 0 - var out = ByteArray(0) - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - if (fieldNumber == 1) { - if (wireType != 2) throw IllegalArgumentException("StringPayload.value wrong wire type") - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - out = msgBytes - } else { - offset = skipField(wireType, bytes, offset) - } - } - return out - } - - private fun parsePreferencePayload(bytes: ByteArray): ByteArray { - // Keep canonical protobuf bytes for downstream typed decoders. - return bytes - } - - private fun parseAppRouterPayload(bytes: ByteArray): ByteArray { - // Keep canonical protobuf bytes for downstream typed decoders. - return bytes - } - - private fun parseSingleBytesPayload(bytes: ByteArray): ByteArray { - return parseBytesPayload(bytes) - } - - private fun parseBilateralPayload(bytes: ByteArray): ByteArray { - // Keep canonical protobuf bytes for downstream typed decoders. - return bytes - } - - private fun readVarint(bytes: ByteArray, start: Int): Pair { - var shift = 0 - var result = 0L - var offset = start - while (offset < bytes.size) { - val b = bytes[offset].toInt() and 0xFF - result = result or ((b and 0x7F).toLong() shl shift) - offset += 1 - if (b and 0x80 == 0) break - shift += 7 - if (shift > 63) throw IllegalArgumentException("varint too long") - if (offset >= bytes.size && (b and 0x80) != 0) throw IllegalArgumentException("truncated varint") - } - return Pair(result, offset) - } - - private fun readLengthDelimited(bytes: ByteArray, start: Int): Pair { - val (len, off) = readVarint(bytes, start) - val l = len.toInt() - if (l < 0 || off + l > bytes.size) throw IllegalArgumentException("invalid length-delimited size") - val out = bytes.copyOfRange(off, off + l) - return Pair(out, off + l) - } - - private fun skipField(wireType: Int, bytes: ByteArray, start: Int): Int { - return when (wireType) { - 0 -> readVarint(bytes, start).second - 1 -> { - val off = start + 8 - if (off > bytes.size) throw IllegalArgumentException("truncated fixed64") - off - } - 2 -> readLengthDelimited(bytes, start).second - 5 -> { - val off = start + 4 - if (off > bytes.size) throw IllegalArgumentException("truncated fixed32") - off - } - else -> throw IllegalArgumentException("unsupported wire type: $wireType") - } - } - - private fun encodeVarint32(valueIn: Int): ByteArray { - var v = valueIn - val baos = ByteArrayOutputStream() - while (true) { - if (v and 0x7F.inv() == 0) { - baos.write(v) - break - } else { - baos.write((v and 0x7F) or 0x80) - v = v ushr 7 - } - } - return baos.toByteArray() + val env = try { + Envelope.parseFrom(bytes) + } catch (_: InvalidProtocolBufferException) { + return null + } + val error = when { + env.hasError() -> env.error + env.hasUniversalRx() -> env.universalRx.resultsList.firstOrNull { it.hasError() }?.error + else -> null + } ?: return null + if (error.sourceTag == 0 && error.message.isEmpty()) return null + return DsmErrorInfo(error.sourceTag, error.message) } } diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt index bc497b70..40bc2615 100644 --- a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt +++ b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt @@ -96,11 +96,9 @@ class BridgeEnvelopeCodecTest { assertEquals(method128, req.method) } - @Test - fun parseBridgeRequest_emptyInput_defaultValues() { - val req = BridgeEnvelopeCodec.parseBridgeRequest(ByteArray(0)) - assertEquals("", req.method) - assertEquals(0, req.payload.size) + @Test(expected = IllegalArgumentException::class) + fun parseBridgeRequest_noMethod_refused() { + BridgeEnvelopeCodec.parseBridgeRequest(ByteArray(0)) } @Test @@ -213,18 +211,14 @@ class BridgeEnvelopeCodecTest { BridgeEnvelopeCodec.parseBridgeRequest(encodeVarintField(1, 42)) } - @Test(expected = IllegalArgumentException::class) - fun parseBridgeRequest_payloadWrongWireType() { + @Test + fun parseBridgeRequest_wrongWireTypeIsNotThePayload() { + // A varint where the preference message would be is an unknown field to + // the protobuf parser: it is skipped, and the request carries no payload. val bytes = encodeLenField(1, "test".toByteArray()) + encodeVarintField(5, 42) - BridgeEnvelopeCodec.parseBridgeRequest(bytes) - } - - @Test(expected = IllegalArgumentException::class) - fun parseBridgeRequest_duplicatePayloads() { - val methodField = encodeLenField(1, "test".toByteArray()) - val payload1 = encodeLenField(3, encodeLenField(1, byteArrayOf(1))) - val payload2 = encodeLenField(4, encodeLenField(1, byteArrayOf(2))) - BridgeEnvelopeCodec.parseBridgeRequest(methodField + payload1 + payload2) + val req = BridgeEnvelopeCodec.parseBridgeRequest(bytes) + assertEquals("test", req.method) + assertEquals(0, req.payload.size) } @Test(expected = IllegalArgumentException::class) @@ -303,9 +297,13 @@ class BridgeEnvelopeCodecTest { } @Test - fun decodeBridgeRpcError_wrongWireTypeForMessage_returnsNull() { + fun decodeBridgeRpcError_wrongWireTypeForMessage_leavesTheMessageEmpty() { + // A varint where the message string would be is skipped as unknown; the + // code stands alone. val bytes = encodeVarintField(1, 1) + encodeVarintField(2, 42) - assertNull(BridgeEnvelopeCodec.decodeBridgeRpcError(bytes)) + val err = BridgeEnvelopeCodec.decodeBridgeRpcError(bytes)!! + assertEquals(1, err.errorCode) + assertEquals("", err.message) } @Test @@ -367,14 +365,6 @@ class BridgeEnvelopeCodecTest { BridgeEnvelopeCodec.parseEnvelopeResponse(ByteArray(0)) } - @Test(expected = IllegalArgumentException::class) - fun parseEnvelopeResponse_multipleResults() { - val successInner = encodeLenField(1, byteArrayOf(0x01)) - val success = encodeLenField(1, successInner) - val error = encodeLenField(2, byteArrayOf(0x08, 0x01)) - BridgeEnvelopeCodec.parseEnvelopeResponse(success + error) - } - @Test(expected = IllegalArgumentException::class) fun parseEnvelopeResponse_successWrongWireType() { BridgeEnvelopeCodec.parseEnvelopeResponse(encodeVarintField(1, 42)) @@ -414,14 +404,14 @@ class BridgeEnvelopeCodecTest { } @Test - fun createErrorResponse_debugEncoderThrows_emptyDebug() { + fun createErrorResponse_debugEncoderThrows_noDebug() { val response = BridgeEnvelopeCodec.createErrorResponse(1, "test") { throw RuntimeException("encoder broke") } val (isSuccess, errorPayload) = BridgeEnvelopeCodec.parseEnvelopeResponse(response) assertFalse(isSuccess) val err = BridgeEnvelopeCodec.decodeBridgeRpcError(errorPayload)!! - assertEquals("", err.debugB32) + assertNull(err.debugB32) } // ── encodeAppRouterPayload / decodeAppRouterPayload ────────────────── From 97a61b5c59e1a6e0e94554eb6c96a2bcb6cd0ad3 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:18:33 -0400 Subject: [PATCH 18/23] =?UTF-8?q?docs(gaps):=20=C2=A76.36=20states=20after?= =?UTF-8?q?=20the=20ninth=20fix=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/requirements/CONFORMANCE_GAPS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index e58d3353..36f0393f 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1381,7 +1381,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | ID | Location | Finding | State | |---|---|---|---| -| D-CODEC | `KT/bridge/BridgeEnvelopeCodec.kt`; `dsm_client/frontend/public/index.html` | Hand-rolled protobuf codecs with hard-coded field numbers, beside "Kotlin MUST NOT implement custom wire decoders"; the index.html catch cleanup reads a variable out of scope. | Open: the page's port handler now declares the response id outside its `try`, so the catch releases the pending entry it used to miss; the Kotlin hand-rolled codecs remain. | +| D-CODEC | `KT/bridge/BridgeEnvelopeCodec.kt`; `dsm_client/frontend/public/index.html` | Hand-rolled protobuf codecs with hard-coded field numbers, beside "Kotlin MUST NOT implement custom wire decoders"; the index.html catch cleanup reads a variable out of scope. | Resolved: the page's port handler declares the response id outside its `try`, so the catch releases the pending entry it used to miss; `BridgeEnvelopeCodec` decodes and encodes through the generated protobuf classes (request, response, error, app-router, preference and bilateral payloads, and the envelope's error), and Kotlin holds no varint parser. Tests that asserted the hand-rolled parser's private rules (a second oneof member, a wrong wire type as a failure, an empty-versus-absent debug string) now assert protobuf's semantics. | | D-SAFETY | `KT/bridge/*` (safety scan); `FE/dsm/*` (safety classification) | Kotlin scans a field that cannot match on the ingress path; the frontend classifies safety by regex over message text instead of reading `Error.source_tag`. | Open | | D-F8 | `FE/dsm/WebViewBridge/strictQueries.ts` (`wallet.history`) | Sixteen raw little-endian bytes in a `Codec.PROTO` ArgPack. | Open | | D-F13 | `FE/services/recovery/nfcRecoveryService.ts` | A `key=value` text protocol with `'0'` defaults; JavaScript decodes capsule bytes (§6.29 Open, recovery boundary). | Open | From 34de3cafbd6b91c1a3e5fd8f5874154d2a9160e6 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:31:07 -0400 Subject: [PATCH 19/23] fix(frontend,android): a deterministic-safety refusal is classified by Rust's source tag, never by message text The page recognised a safety refusal by a regex over any error message, and Kotlin scanned every answer for an Envelope error with source tag 11, which never parsed a framed answer and so never matched. Rust tags DsmError::DeterministicSafety with source tag 11 and carries the class in the error's context; the page now reads exactly that, at the two places every transport error surfaces (decodeFramedEnvelopeV3 and the ingress unwrapper). The regex, its callers and Kotlin's scan are gone. --- .../dsm/wallet/bridge/BridgeEnvelopeCodec.kt | 27 ----- .../java/com/dsm/wallet/ui/MainActivity.kt | 12 -- .../wallet/bridge/BridgeEnvelopeCodecTest.kt | 53 --------- .../dsm/src/common/deterministic_id.rs | 49 -------- dsm_client/frontend/src/dsm/EventBridge.ts | 11 -- .../frontend/src/dsm/NativeBoundaryBridge.ts | 3 + .../src/dsm/WebViewBridge/transportCore.ts | 7 +- dsm_client/frontend/src/dsm/decoding.ts | 6 + .../__tests__/deterministicSafety.test.ts | 108 +++++++----------- .../frontend/src/utils/deterministicSafety.ts | 35 ++++-- 10 files changed, 75 insertions(+), 236 deletions(-) delete mode 100644 dsm_client/deterministic_state_machine/dsm/src/common/deterministic_id.rs diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt index bb104101..40e2c0f1 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt @@ -8,7 +8,6 @@ import dsm.types.proto.AppRouterPayload import dsm.types.proto.BilateralPayload import dsm.types.proto.BridgeRpcRequest import dsm.types.proto.BridgeRpcResponse -import dsm.types.proto.Envelope import dsm.types.proto.ErrorResponse import dsm.types.proto.PreferencePayload import dsm.types.proto.SuccessResponse @@ -22,8 +21,6 @@ internal object BridgeEnvelopeCodec { data class BridgeRequest(val method: String, val payload: ByteArray) - data class DsmErrorInfo(val sourceTag: Int, val message: String) - data class BridgeRpcError(val errorCode: Int, val message: String, val debugB32: String?) data class AppRouterRequest(val methodName: String, val args: ByteArray) @@ -34,9 +31,6 @@ internal object BridgeEnvelopeCodec { private const val METHOD_MAX_BYTES = 128 - /** Rust's source tag on a deterministic-safety refusal. */ - private const val SOURCE_TAG_DETERMINISTIC_SAFETY = 11 - /** * Decodes a `BridgeRpcRequest`. The method must be present, at most 128 bytes * and an identifier (letters, digits, `_`, `.`, `-`). The payload is the oneof @@ -74,12 +68,6 @@ internal object BridgeEnvelopeCodec { return BridgeRequest(method, payload) } - /** The message of a deterministic-safety refusal carried by an `Envelope`, else null. */ - fun extractDeterministicSafetyMessageFromEnvelope(envelopeBytes: ByteArray): String? { - val err = extractErrorInfoFromEnvelope(envelopeBytes) ?: return null - return if (err.sourceTag == SOURCE_TAG_DETERMINISTIC_SAFETY) err.message else null - } - /** (isSuccess, payload): the success data, or the `ErrorResponse` bytes. */ fun parseEnvelopeResponse(responseBytes: ByteArray): Pair { val resp = try { @@ -168,19 +156,4 @@ internal object BridgeEnvelopeCodec { .build() return BridgeRpcResponse.newBuilder().setError(error).build().toByteArray() } - - private fun extractErrorInfoFromEnvelope(bytes: ByteArray): DsmErrorInfo? { - val env = try { - Envelope.parseFrom(bytes) - } catch (_: InvalidProtocolBufferException) { - return null - } - val error = when { - env.hasError() -> env.error - env.hasUniversalRx() -> env.universalRx.resultsList.firstOrNull { it.hasError() }?.error - else -> null - } ?: return null - if (error.sourceTag == 0 && error.message.isEmpty()) return null - return DsmErrorInfo(error.sourceTag, error.message) - } } diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt index 1657bb33..f66dbde0 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt @@ -1004,18 +1004,6 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { } Log.i(tag, "DSM bridge: method '$method' response size: ${respBytes.size} bytes") - // Optional: native-side deterministic safety routing (Error.source_tag == 11) - try { - val (ok, data) = com.dsm.wallet.bridge.BridgeEnvelopeCodec.parseEnvelopeResponse(respBytes) - if (ok) { - com.dsm.wallet.bridge.BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope(data)?.let { - dispatchDsmEventOnUi("dsm.deterministicSafety", it.toByteArray(Charsets.UTF_8)) - } - } - } catch (_: Throwable) { - // ignore parse errors (response may not be an Envelope) - } - // Prepend message ID to response (8 bytes u64) val responseWithId = ByteArray(8 + respBytes.size) java.nio.ByteBuffer.wrap(responseWithId, 0, 8).order(java.nio.ByteOrder.BIG_ENDIAN).putLong(messageId) diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt index 40bc2615..55fb37c0 100644 --- a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt +++ b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt @@ -521,57 +521,4 @@ class BridgeEnvelopeCodecTest { fun decodeBilateralPayload_emptyInput_returnsNull() { assertNull(BridgeEnvelopeCodec.decodeBilateralPayload(ByteArray(0))) } - - // ── extractDeterministicSafetyMessageFromEnvelope ───────────────────── - - @Test - fun extractSafetyMessage_emptyEnvelope_returnsNull() { - assertNull( - BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope(ByteArray(0)) - ) - } - - @Test - fun extractSafetyMessage_noErrorInfo_returnsNull() { - assertNull( - BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope( - encodeVarintField(1, 42) - ) - ) - } - - @Test - fun extractSafetyMessage_field99_sourceTag11_returnsMessage() { - val errorInfo = encodeLenField(2, "safety violation".toByteArray()) + - encodeVarintField(4, 11) - val envelope = encodeLenField(99, errorInfo) - assertEquals( - "safety violation", - BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope(envelope) - ) - } - - @Test - fun extractSafetyMessage_field99_differentSourceTag_returnsNull() { - val errorInfo = encodeLenField(2, "other error".toByteArray()) + - encodeVarintField(4, 5) - assertNull( - BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope( - encodeLenField(99, errorInfo) - ) - ) - } - - @Test - fun extractSafetyMessage_field11_nestedPath_returnsMessage() { - val errorInfo = encodeLenField(2, "deep error".toByteArray()) + - encodeVarintField(4, 11) - val opResult = encodeLenField(5, errorInfo) - val universalRx = encodeLenField(1, opResult) - val envelope = encodeLenField(11, universalRx) - assertEquals( - "deep error", - BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope(envelope) - ) - } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/deterministic_id.rs b/dsm_client/deterministic_state_machine/dsm/src/common/deterministic_id.rs deleted file mode 100644 index cb4b86df..00000000 --- a/dsm_client/deterministic_state_machine/dsm/src/common/deterministic_id.rs +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Deterministic ID Generation (No UUID, No Wall-Clock) -//! -//! This module provides deterministic, reproducible ID generation for all DSM components. -//! All IDs are derived from cryptographic hashes, never random UUIDs. -//! -//! Constraints: -//! - No UUID::new_v4() or UUID::now_v7() (non-deterministic) -//! - No wall-clock unix_tss -//! - All IDs are reproducible from explicit inputs - -use crate::crypto::blake3::dsm_domain_hasher; - -/// Generate a deterministic ID from domain-separated hash of inputs -/// -/// # Arguments -/// * `domain` - Domain separator (e.g., "DSM/tx-id", "DSM/msg-id") -/// * `inputs` - Variable number of byte slices to hash -/// -/// # Returns -/// Hex string of first 16 bytes of BLAKE3 hash (UUID-compatible format) -pub fn derive_id_from_hash(domain: &str, inputs: &[&[u8]]) -> String { - let mut hasher = dsm_domain_hasher(crate::common::domain_tags::TAG_DSM_DETERMINISTIC_ID); - hasher.update(domain.as_bytes()); - - for input in inputs { - hasher.update(input); - } - - let hash = hasher.finalize(); - let bytes = hash.as_bytes(); - - // Take first 16 bytes and format as UUID-compatible string - // Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - format!( - "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", - bytes[0], bytes[1], bytes[2], bytes[3], - bytes[4], bytes[5], - bytes[6], bytes[7], - bytes[8], bytes[9], - bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] - ) -} - -/// Generate a deterministic session ID from participants and unix_ts-free context -pub fn generate_session_id(context: &[u8]) -> String { - derive_id_from_hash("DSM/session-id", &[context]) -} diff --git a/dsm_client/frontend/src/dsm/EventBridge.ts b/dsm_client/frontend/src/dsm/EventBridge.ts index f28886c3..a5c50b49 100644 --- a/dsm_client/frontend/src/dsm/EventBridge.ts +++ b/dsm_client/frontend/src/dsm/EventBridge.ts @@ -12,7 +12,6 @@ import { decodeNativeHostEventToLegacyTopic } from './NativeHostBridge'; import { dispatchNativeQrScannerActive } from './qrScannerState'; import { bytesToBase32CrockfordPrefix, encodeBase32Crockford } from '../utils/textId'; import { bridgeEvents } from '../bridge/bridgeEvents'; -import { emitDeterministicSafetyIfPresent } from '../utils/deterministicSafety'; import logger from '../utils/logger'; import type { NativeSessionSnapshot } from '../runtime/nativeSessionTypes'; @@ -215,16 +214,6 @@ export function initializeEventBridge(): void { return; } - if (topic === 'dsm.deterministicSafety') { - try { - const msg = new TextDecoder().decode(bytes); - emitDeterministicSafetyIfPresent(msg); - } catch { - // ignore - } - return; - } - // --- Lifecycle events from Kotlin (previously evaluateJavascript, now binary) --- // Re-dispatch as DOM CustomEvents so existing hooks work. diff --git a/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts b/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts index 5d0bca9b..020d5965 100644 --- a/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts +++ b/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts @@ -5,6 +5,7 @@ import { getBridgeInstance } from '../bridge/BridgeRegistry'; import { bridgeEvents } from '../bridge/bridgeEvents'; import type { AndroidBridgeV3 } from './bridgeTypes'; import { IngressRequest, IngressResponse, RouterInvokeOp, RouterQueryOp } from '../proto/dsm_app_pb'; +import { emitDeterministicSafetyForError } from '../utils/deterministicSafety'; function mustBridge(): AndroidBridgeV3 { const bridge = getBridgeInstance(); @@ -51,6 +52,8 @@ function unwrapIngressResponse(responseBytes: Uint8Array): Uint8Array { return response.result.value; } if (response.result.case === 'error') { + // A refusal Rust tagged as deterministic safety is announced by its tag. + emitDeterministicSafetyForError(response.result.value); throw new Error(response.result.value?.message || 'ingress boundary error'); } throw new Error('ingress boundary returned no result'); diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts b/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts index 69b7678e..2749626b 100644 --- a/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts +++ b/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts @@ -15,7 +15,7 @@ import { import { bridgeEvents } from "../../bridge/bridgeEvents"; import { getBridgeInstance } from "../../bridge/BridgeRegistry"; import type { AndroidBridgeV3 } from "../bridgeTypes"; -import { emitDeterministicSafetyIfPresent } from "../../utils/deterministicSafety"; +; import { buildRouterInvokeIngressRequest, buildRouterQueryIngressRequest, @@ -48,7 +48,6 @@ export const toBytes = (bytes: Uint8Array): Uint8Array => { return out; }; - export class BridgeError extends Error { errorCode?: number; details?: unknown; @@ -83,8 +82,6 @@ const unwrapProtobufResponse = async (_method: string, buf: Uint8Array): Promise uiMessage += ` [${hex}]`; } - emitDeterministicSafetyIfPresent(uiMessage); - const be = new BridgeError(code, uiMessage); be.details = err; @@ -100,7 +97,6 @@ const unwrapProtobufResponse = async (_method: string, buf: Uint8Array): Promise throw be; } const errorMessage = new TextDecoder().decode(buf); - emitDeterministicSafetyIfPresent(errorMessage); try { bridgeEvents.emit("bridge.error", { code: 0, message: errorMessage, debugB32: "" }); } catch (_e) { @@ -111,7 +107,6 @@ const unwrapProtobufResponse = async (_method: string, buf: Uint8Array): Promise if (e instanceof BridgeError) throw e; const errorMessage = new TextDecoder().decode(buf); - emitDeterministicSafetyIfPresent(errorMessage); try { bridgeEvents.emit("bridge.error", { code: 0, message: errorMessage, debugB32: "" }); } catch (_e) { diff --git a/dsm_client/frontend/src/dsm/decoding.ts b/dsm_client/frontend/src/dsm/decoding.ts index 7dd27522..de471696 100644 --- a/dsm_client/frontend/src/dsm/decoding.ts +++ b/dsm_client/frontend/src/dsm/decoding.ts @@ -2,6 +2,7 @@ import * as pb from '../proto/dsm_app_pb'; import { encodeBase32Crockford } from '../utils/textId'; +import { emitDeterministicSafetyForError } from '../utils/deterministicSafety'; /** @@ -42,6 +43,11 @@ export function decodeFramedEnvelopeV3(bytes: Uint8Array): pb.Envelope { throw new Error(`Expected Envelope v3, got v${env.version}`); } + // A refusal Rust tagged as deterministic safety is announced from the one + // place every transport envelope is decoded; the tag, never the text, says so. + if (env.payload.case === 'error') { + emitDeterministicSafetyForError(env.payload.value); + } return env; } diff --git a/dsm_client/frontend/src/utils/__tests__/deterministicSafety.test.ts b/dsm_client/frontend/src/utils/__tests__/deterministicSafety.test.ts index 8033b0ed..68ea6f7a 100644 --- a/dsm_client/frontend/src/utils/__tests__/deterministicSafety.test.ts +++ b/dsm_client/frontend/src/utils/__tests__/deterministicSafety.test.ts @@ -7,66 +7,42 @@ declare const expect: any; declare const beforeEach: any; declare const afterEach: any; -import { parseDeterministicSafety, emitDeterministicSafetyIfPresent } from '../deterministicSafety'; +import { + DETERMINISTIC_SAFETY_SOURCE_TAG, + deterministicSafetyFromError, + emitDeterministicSafetyForError, +} from '../deterministicSafety'; import { bridgeEvents } from '../../bridge/bridgeEvents'; -describe('parseDeterministicSafety', () => { - test('returns null for null/undefined/empty', () => { - expect(parseDeterministicSafety(null)).toBeNull(); - expect(parseDeterministicSafety(undefined)).toBeNull(); - expect(parseDeterministicSafety('')).toBeNull(); - }); - - test('returns null for non-matching messages', () => { - expect(parseDeterministicSafety('some random error')).toBeNull(); - expect(parseDeterministicSafety('Deterministic safety')).toBeNull(); - expect(parseDeterministicSafety('Deterministic safety rejection')).toBeNull(); - }); - - test('parses a valid deterministic safety rejection message', () => { - const msg = 'Deterministic safety rejection [OVERFLOW]: value exceeds maximum'; - const result = parseDeterministicSafety(msg); - expect(result).toEqual({ - classification: 'OVERFLOW', - message: 'value exceeds maximum', - }); - }); - - test('is case-insensitive', () => { - const msg = 'deterministic safety rejection [Replay]: duplicate nonce detected'; - const result = parseDeterministicSafety(msg); - expect(result).toEqual({ - classification: 'Replay', - message: 'duplicate nonce detected', - }); - }); +const enc = (s: string) => new TextEncoder().encode(s); - test('handles empty classification gracefully', () => { - const msg = 'Deterministic safety rejection []: some detail'; - const result = parseDeterministicSafety(msg); - expect(result).toBeNull(); +describe('deterministicSafetyFromError', () => { + test('an error Rust did not tag is not a safety refusal, whatever it says', () => { + expect(deterministicSafetyFromError({ + sourceTag: 10, + message: 'Deterministic safety rejection [ParentConsumed]: parent already consumed', + context: enc('classification=ParentConsumed message=parent already consumed'), + })).toBeNull(); }); - test('handles empty detail', () => { - const msg = 'Deterministic safety rejection [CRITICAL]:'; - const result = parseDeterministicSafety(msg); - expect(result).toEqual({ - classification: 'CRITICAL', - message: '', - }); + test('a tagged error yields the class and message Rust put in its context', () => { + expect(deterministicSafetyFromError({ + sourceTag: DETERMINISTIC_SAFETY_SOURCE_TAG, + message: 'Deterministic safety rejection [StalePrecommit]: tip moved', + context: enc('classification=StalePrecommit message=tip moved'), + })).toEqual({ classification: 'StalePrecommit', message: 'tip moved' }); }); - test('trims classification and detail', () => { - const msg = 'Deterministic safety rejection [ BOUNDS ]: out of range '; - const result = parseDeterministicSafety(msg); - expect(result).toEqual({ - classification: 'BOUNDS', - message: 'out of range', - }); + test('a tagged error without the context format keeps its message and no class', () => { + expect(deterministicSafetyFromError({ + sourceTag: DETERMINISTIC_SAFETY_SOURCE_TAG, + message: 'refused', + context: new Uint8Array(0), + })).toEqual({ classification: '', message: 'refused' }); }); }); -describe('emitDeterministicSafetyIfPresent', () => { +describe('emitDeterministicSafetyForError', () => { let emitSpy: any; beforeEach(() => { @@ -77,29 +53,29 @@ describe('emitDeterministicSafetyIfPresent', () => { emitSpy.mockRestore(); }); - test('returns false and does not emit for non-matching messages', () => { - expect(emitDeterministicSafetyIfPresent('random error')).toBe(false); - expect(emitSpy).not.toHaveBeenCalled(); - }); - - test('returns false for null/undefined', () => { - expect(emitDeterministicSafetyIfPresent(null)).toBe(false); - expect(emitDeterministicSafetyIfPresent(undefined)).toBe(false); + test('emits nothing for an untagged error', () => { + expect(emitDeterministicSafetyForError({ sourceTag: 0, message: 'random error', context: new Uint8Array(0) })).toBe(false); expect(emitSpy).not.toHaveBeenCalled(); }); - test('returns true and emits for valid safety rejection', () => { - const msg = 'Deterministic safety rejection [DOUBLE_SPEND]: already spent'; - expect(emitDeterministicSafetyIfPresent(msg)).toBe(true); + test('emits the detail for a tagged error', () => { + expect(emitDeterministicSafetyForError({ + sourceTag: DETERMINISTIC_SAFETY_SOURCE_TAG, + message: 'x', + context: enc('classification=TipMismatch message=expected tip differs'), + })).toBe(true); expect(emitSpy).toHaveBeenCalledWith('dsm.deterministicSafety', { - classification: 'DOUBLE_SPEND', - message: 'already spent', + classification: 'TipMismatch', + message: 'expected tip differs', }); }); - test('returns true even if emit throws', () => { + test('answers true even if a listener throws', () => { emitSpy.mockImplementation(() => { throw new Error('fail'); }); - const msg = 'Deterministic safety rejection [ERROR]: something broke'; - expect(emitDeterministicSafetyIfPresent(msg)).toBe(true); + expect(emitDeterministicSafetyForError({ + sourceTag: DETERMINISTIC_SAFETY_SOURCE_TAG, + message: 'x', + context: enc('classification=ParentConsumed message=already consumed'), + })).toBe(true); }); }); diff --git a/dsm_client/frontend/src/utils/deterministicSafety.ts b/dsm_client/frontend/src/utils/deterministicSafety.ts index 70303b39..4416329d 100644 --- a/dsm_client/frontend/src/utils/deterministicSafety.ts +++ b/dsm_client/frontend/src/utils/deterministicSafety.ts @@ -7,25 +7,36 @@ export type DeterministicSafetyDetail = { message: string; }; -const SAFETY_REGEX = /Deterministic safety rejection \[([^\]]+)\]:\s*(.*)/i; +/** Rust's source tag on `DsmError::DeterministicSafety` (`dsm_sdk/src/wire/mod.rs`). */ +export const DETERMINISTIC_SAFETY_SOURCE_TAG = 11; -export function parseDeterministicSafety(message?: string | null): DeterministicSafetyDetail | null { - if (!message) return null; - const match = String(message).match(SAFETY_REGEX); - if (!match) return null; - const classification = String(match[1] || '').trim(); - const detail = String(match[2] || '').trim(); - if (!classification) return null; - return { classification, message: detail }; +/** The wire `Error` fields this reads. */ +export type WireErrorLike = { + sourceTag: number; + message: string; + context: Uint8Array; +}; + +/** + * A deterministic-safety refusal is what Rust tagged as one, never what a message + * happens to say. Rust's context for that error is + * `classification= message=`; the class is read from there. + */ +export function deterministicSafetyFromError(err: WireErrorLike): DeterministicSafetyDetail | null { + if (err.sourceTag !== DETERMINISTIC_SAFETY_SOURCE_TAG) return null; + const context = new TextDecoder().decode(err.context); + const match = context.match(/^classification=(\S+) message=([\s\S]*)$/); + if (!match) return { classification: '', message: err.message }; + return { classification: match[1], message: match[2] }; } -export function emitDeterministicSafetyIfPresent(message?: string | null): boolean { - const detail = parseDeterministicSafety(message); +export function emitDeterministicSafetyForError(err: WireErrorLike): boolean { + const detail = deterministicSafetyFromError(err); if (!detail) return false; try { bridgeEvents.emit('dsm.deterministicSafety', detail); } catch { - // ignore + // a listener's failure is its own } return true; } From 0501fdca2b0f37bd3a2866b0bf5d64aaa4a22828 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:31:08 -0400 Subject: [PATCH 20/23] chore(core): six features that gated only a name are gone with the dependencies they carried bluetooth, storage, threadsafe, jni, web-stack and formal gated no code in the Core; three of them only pulled optional dependencies the Core never used (jni, tokio-stream, rocksdb, axum, tower). Deleted with the build-info function that only listed them and the build script's rustc and target stamps it alone read. The lockfile loses rocksdb and its native build chain and nothing else. sphincs-trace and bitcoin-testnet-bypass gate code and stay. --- .../deterministic_state_machine/Cargo.lock | 136 +----------------- .../dsm/Cargo.toml | 17 --- .../deterministic_state_machine/dsm/build.rs | 19 --- .../dsm/src/lib.rs | 56 -------- 4 files changed, 4 insertions(+), 224 deletions(-) diff --git a/dsm_client/deterministic_state_machine/Cargo.lock b/dsm_client/deterministic_state_machine/Cargo.lock index a02f7b1b..10e34a51 100644 --- a/dsm_client/deterministic_state_machine/Cargo.lock +++ b/dsm_client/deterministic_state_machine/Cargo.lock @@ -401,24 +401,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn 2.0.117", -] - [[package]] name = "bip39" version = "2.2.2" @@ -582,16 +564,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "cbindgen" version = "0.29.3" @@ -629,15 +601,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -696,17 +659,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" version = "4.6.1" @@ -1154,7 +1106,6 @@ dependencies = [ "argon2", "arrayref", "async-trait", - "axum", "base32", "bincode", "bitcoin", @@ -1173,7 +1124,6 @@ dependencies = [ "futures", "getrandom 0.4.2", "hmac 0.13.0", - "jni", "lazy_static", "log", "lru", @@ -1194,7 +1144,6 @@ dependencies = [ "quickcheck", "rand 0.9.4", "rand_chacha 0.9.0", - "rocksdb", "rstest", "serde", "serial_test", @@ -1204,8 +1153,6 @@ dependencies = [ "test-log", "thiserror 2.0.18", "tokio", - "tokio-stream", - "tower", "tracing", "tracing-subscriber", "uuid", @@ -2237,15 +2184,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -2374,16 +2312,6 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "libredox" version = "0.1.12" @@ -2394,21 +2322,6 @@ dependencies = [ "libc", ] -[[package]] -name = "librocksdb-sys" -version = "0.17.3+10.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" -dependencies = [ - "bindgen", - "bzip2-sys", - "cc", - "libc", - "libz-sys", - "lz4-sys", - "zstd-sys", -] - [[package]] name = "libsqlite3-sys" version = "0.38.1" @@ -2420,17 +2333,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "link-section" version = "0.16.1" @@ -2486,16 +2388,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "matchers" version = "0.2.0" @@ -3138,7 +3030,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.14.0", + "itertools", "log", "multimap", "once_cell", @@ -3158,7 +3050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.14.0", + "itertools", "log", "multimap", "petgraph 0.8.3", @@ -3177,7 +3069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools", "proc-macro2", "quote", "syn 2.0.117", @@ -3190,7 +3082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools", "proc-macro2", "quote", "syn 2.0.117", @@ -3591,16 +3483,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rocksdb" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f" -dependencies = [ - "libc", - "librocksdb-sys", -] - [[package]] name = "ron" version = "0.12.2" @@ -5517,13 +5399,3 @@ name = "zmij" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/dsm_client/deterministic_state_machine/dsm/Cargo.toml b/dsm_client/deterministic_state_machine/dsm/Cargo.toml index ddef8fe7..202cfc47 100644 --- a/dsm_client/deterministic_state_machine/dsm/Cargo.toml +++ b/dsm_client/deterministic_state_machine/dsm/Cargo.toml @@ -17,13 +17,7 @@ unused_must_use = "deny" [features] default = [] # NO JNI, demos, or HTTP features in core - pure business logic -bluetooth = ["dep:tokio-stream"] -storage = ["rocksdb"] -threadsafe = [] -jni = ["dep:jni"] # JNI feature for Android integration -web-stack = ["dep:axum", "dep:tower"] sphincs-trace = [] # Enable verbose SPHINCS+ tracing (debug only) - formal = [] # Issue #181 Finding 2: NON_PAPER_MODE testnet/signet bypass is OFF by default. # Without this feature, all Bitcoin networks (including Testnet/Signet) require # checkpoint-rooted header chains and entry-anchor verification — i.e. the @@ -100,13 +94,6 @@ ml-kem = { version = "0.2.0", features = ["deterministic"] } # Pure Rust FIPS 2 # pqcrypto-mlkem = "0.1.0" # REMOVED: Hardware optimized, caused SIGILL on Android # pqcrypto-sphincsplus has been replaced with a pure Rust implementation -jni = { version = "0.21", optional = true } - -# Bluetooth support (optional) -tokio-stream = { version = "0.1.14", optional = true, features = ["sync"] } - -# Storage - Optional for server-side components only (mobile apps use HTTP API) -rocksdb = { version = "0.24.0", features = ["multi-threaded-cf"], optional = true } once_cell = "1.19" # Removed dsm-storage-node dependency to avoid circular dependency @@ -142,10 +129,6 @@ env_logger = "0.11.10" # Type safety and validation derive_more = { version = "2.1.1", features = ["from", "display", "error", "debug", "into"] } -# Web framework and middleware (optional; core must be network-agnostic by default; no JSON feature) -axum = { version = "0.8", features = ["tracing"], optional = true } -tower = { version = "0.5.3", features = ["util", "timeout", "load-shed", "limit"], optional = true } - [dev-dependencies] mockall = "0.15.0" tempfile = "3.10.0" diff --git a/dsm_client/deterministic_state_machine/dsm/build.rs b/dsm_client/deterministic_state_machine/dsm/build.rs index 916eb238..3590f391 100644 --- a/dsm_client/deterministic_state_machine/dsm/build.rs +++ b/dsm_client/deterministic_state_machine/dsm/build.rs @@ -6,29 +6,10 @@ use std::env; use std::path::PathBuf; -use std::process::Command; - -fn rustc_version() -> Result> { - let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); - let output = Command::new(rustc).arg("--version").output()?; - if !output.status.success() { - return Err("failed to query rustc version".into()); - } - - Ok(String::from_utf8(output.stdout)?.trim().to_string()) -} fn main() -> Result<(), Box> { let out_dir = PathBuf::from(env::var("OUT_DIR")?); let vendored_include = protoc_bin_vendored::include_path()?; - let target = env::var("TARGET")?; - let rustc_version = rustc_version()?; - - println!("cargo:rustc-env=DSM_BUILD_TARGET={target}"); - println!("cargo:rustc-env=DSM_RUSTC_VERSION={rustc_version}"); - println!("cargo:rerun-if-env-changed=RUSTC"); - println!("cargo:rerun-if-env-changed=TARGET"); - // Canonical schema location is the repository root at `proto/`. // Allow override via DSM_PROTO_ROOT, but default to the repo-root canonical path. let proto_root = env::var("DSM_PROTO_ROOT") diff --git a/dsm_client/deterministic_state_machine/dsm/src/lib.rs b/dsm_client/deterministic_state_machine/dsm/src/lib.rs index 5b76bf81..783aae28 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/lib.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/lib.rs @@ -101,8 +101,6 @@ pub mod verification; use crate::types::error::DsmError; const VERSION: &str = env!("CARGO_PKG_VERSION"); -const RUST_VERSION: &str = env!("DSM_RUSTC_VERSION"); -const TARGET: &str = env!("DSM_BUILD_TARGET"); /// Returns the version of the SDK /// @@ -114,57 +112,3 @@ const TARGET: &str = env!("DSM_BUILD_TARGET"); pub fn version() -> String { VERSION.to_string() } - -/// Build information for debugging and support -pub fn build_info() -> BuildInfo { - BuildInfo { - version: VERSION.to_string(), - rust_version: RUST_VERSION.to_string(), - target: TARGET.to_string(), - features: get_enabled_features(), - } -} - -/// Build information structure -#[derive(Debug, Clone)] -pub struct BuildInfo { - /// SDK version - pub version: String, - /// Rust compiler version - pub rust_version: String, - /// Target architecture - pub target: String, - /// Enabled features - pub features: Vec, -} - -#[allow(unused_mut)] -#[allow(clippy::vec_init_then_push)] -fn get_enabled_features() -> Vec { - let mut features = vec![]; - // JNI moved to dsm_sdk - #[cfg(feature = "bluetooth")] - features.push("bluetooth".to_string()); - #[cfg(feature = "storage")] - features.push("storage".to_string()); - #[cfg(feature = "threadsafe")] - features.push("threadsafe".to_string()); - features -} - -#[cfg(test)] -mod tests { - use super::{build_info, version, VERSION}; - - #[test] - fn build_info_is_compile_time_stamped() { - let info = build_info(); - - assert_eq!(version(), VERSION); - assert_eq!(info.version, VERSION); - assert_ne!(info.rust_version, "unknown"); - assert!(!info.rust_version.is_empty()); - assert_ne!(info.target, "unknown"); - assert!(!info.target.is_empty()); - } -} From 15cbe91f24b393b89fc43b56ff9009dd3cf9f952 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:31:08 -0400 Subject: [PATCH 21/23] fix(core): helpers reached only by their own tests and the hex id module are gone deterministic_id.rs produced hex UUID strings and had no caller. calculate_next_entropy and hash_blake3 were reached only by their own tests; init_crypto had no caller and was the only caller of the init_sphincs self-test. All deleted with those tests; init_kyber stays, key generation calls it. --- .../dsm/src/common/mod.rs | 1 - .../dsm/src/core/state_machine/random_walk.rs | 16 ------- .../dsm/src/core/state_machine/utils.rs | 45 ------------------- .../dsm/src/crypto/mod.rs | 13 ------ .../dsm/src/crypto/sphincs.rs | 22 --------- 5 files changed, 97 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/common/mod.rs index 37da6750..3b76a45e 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/mod.rs @@ -12,7 +12,6 @@ /// Centralized canonical encoding for cryptographic commitments pub mod canonical_encoding; /// Deterministic ID generation (no UUID, no wall-clock) -pub mod deterministic_id; /// Additional-device admission (§16.3 — existing device admits a new device into the tree) pub mod device_admission; pub mod device_tree; diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/random_walk.rs b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/random_walk.rs index 5a26aae6..c3afaaf8 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/random_walk.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/random_walk.rs @@ -256,7 +256,6 @@ pub mod algorithms { mod tests { use super::*; use crate::crypto::blake3::dsm_domain_hasher; - use crate::core::state_machine::utils; struct TestCsprng { current: [u8; 32], @@ -409,20 +408,5 @@ pub mod algorithms { .unwrap(); assert!(!result2); } - - #[test] - fn test_calculate_next_entropy() { - let current_entropy = b"current_entropy"; - let operation = b"operation"; - - // Same inputs → same entropy (deterministic) - let entropy1 = utils::calculate_next_entropy(current_entropy, operation, &[0u8; 32]); - let entropy2 = utils::calculate_next_entropy(current_entropy, operation, &[0u8; 32]); - assert_eq!(entropy1, entropy2); - - // Different parent hash → different entropy - let entropy3 = utils::calculate_next_entropy(current_entropy, operation, &[0x01; 32]); - assert_ne!(entropy1, entropy3); - } } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/utils.rs b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/utils.rs index 2874fbad..68b35a15 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/utils.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/utils.rs @@ -5,9 +5,6 @@ //! This module contains common utility functions used across the state machine //! implementation, ensuring consistent behavior and reducing duplication. -use blake3; -use crate::common::domain_tags::TAG_STATE_HASH; - /// Perform constant-time equality comparison to prevent timing attacks /// /// This function implements constant-time comparison for cryptographic values, @@ -25,38 +22,6 @@ pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { result == 0 } -/// Domain-separated BLAKE3 hash for general state machine operations. -/// -/// Uses the `"DSM/state-hash"` domain tag per the whitepaper mandate that all -/// production hashing must be domain-separated: `BLAKE3("DSM/\0" || data)`. -pub fn hash_blake3(data: &[u8]) -> blake3::Hash { - crate::crypto::blake3::domain_hash(TAG_STATE_HASH, data) -} - -// verify_state_hash(&State) deleted: only caller was relationship.rs::validate_transition -// (also dead). HashChain has its own verify_state_hash impl for chain-internal use. - -/// Calculate the next entropy based on current entropy, operation, and state number -/// -/// Implements the deterministic entropy evolution from whitepaper §11 eq. 14: -/// `e_{n+1} = H("DSM/next-entropy" || e_n || op || H(S_n))`. Per §4.3 no -/// counter participates — adjacency comes from the parent hash. -pub fn calculate_next_entropy( - current_entropy: &[u8], - operation_bytes: &[u8], - parent_hash: &[u8; 32], -) -> [u8; 32] { - let mut hasher = - crate::crypto::blake3::dsm_domain_hasher(crate::common::domain_tags::TAG_DSM_NEXT_ENTROPY); - hasher.update(current_entropy); - hasher.update(operation_bytes); - hasher.update(parent_hash); - - *hasher.finalize().as_bytes() -} - -// create_test_transition() deleted: zero callers (no other tests imported it). - #[cfg(test)] mod tests { use super::*; @@ -71,14 +36,4 @@ mod tests { assert!(!constant_time_eq(&a, &c)); assert!(!constant_time_eq(&a, &[1, 2, 3])); } - - #[test] - fn test_hash_blake3() { - let data = b"test data"; - let hash = hash_blake3(data); - - // hash_blake3 uses the TAG_STATE_HASH domain internally - let expected = crate::crypto::blake3::domain_hash(TAG_STATE_HASH, data); - assert_eq!(hash.as_bytes(), expected.as_bytes()); - } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/crypto/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/crypto/mod.rs index c9a51e0d..b0754704 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/crypto/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/crypto/mod.rs @@ -142,19 +142,6 @@ pub fn hash_multiple(parts: &[&[u8]]) -> Vec { hasher.finalize().as_bytes().to_vec() } -// ===== Initialization ===== - -/// Initialize crypto subsystems used by DSM. -pub fn init_crypto() -> Result<(), DsmError> { - // Kyber KEM/AES - kyber::init_kyber()?; - - // SPHINCS+ (ensures self-tests run at startup) - sphincs::init_sphincs()?; - - Ok(()) -} - // ===== Nonce generation ===== // Notes: // - AES-GCM requires a 96-bit (12-byte) nonce. Use `generate_gcm_nonce`. diff --git a/dsm_client/deterministic_state_machine/dsm/src/crypto/sphincs.rs b/dsm_client/deterministic_state_machine/dsm/src/crypto/sphincs.rs index 0778f5e3..52c9ec60 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/crypto/sphincs.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/crypto/sphincs.rs @@ -59,7 +59,6 @@ use rand::rngs::OsRng; use rand::{RngCore, SeedableRng, TryRngCore}; use rand_chacha::ChaCha20Rng; use subtle::ConstantTimeEq; -use tracing::{debug, error, info}; use zeroize::{Zeroize, ZeroizeOnDrop}; #[cfg(feature = "sphincs-trace")] @@ -1109,27 +1108,6 @@ pub fn signature_bytes(v: SphincsVariant) -> usize { param_set(v).sig_bytes } -// ================================ Init ====================================== - -pub fn init_sphincs() -> Result<(), DsmError> { - // self-test a small variant for sanity and log supported variants - info!("Initializing SPHINCS+ (BLAKE3-only) with 6 parameter sets"); - let v = SphincsVariant::SPX128s; - let kp = generate_keypair(v)?; - let msg = b"SPHINCS+ self-test message"; - let sig = sign(v, &kp.secret_key, msg)?; - let ok = verify(v, &kp.public_key, msg, &sig)?; - if !ok { - error!("SPHINCS+ self-test failed"); - return Err(DsmError::crypto( - "SPHINCS+ self-test failure".to_string(), - None::, - )); - } - debug!("SPHINCS+ self-test passed for {:?}", v); - Ok(()) -} - // ===================== Default Variant Wrappers ========================== /// Generate SPHINCS+ keypair using default variant (SPX256f). From 0d9a27b614056ed81418e7e05c5f5765f7b74c42 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:31:09 -0400 Subject: [PATCH 22/23] =?UTF-8?q?docs(gaps):=20=C2=A76.36=20states=20after?= =?UTF-8?q?=20the=20tenth=20fix=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/requirements/CONFORMANCE_GAPS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 36f0393f..22353d91 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1374,7 +1374,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | C-K6 | `KT/bridge/NativeHostBridge.kt` | Five host-request arms nothing sends; `QR_STOP_SCAN` acknowledges and stops nothing. | Resolved: the bridge handles the four kinds the frontend builds (QR start, NFC reader start/stop, NFC tag write) and answers every other kind as unsupported; the seven pass-through parameters no arm read are gone. With the arms went their only reaches: `showBiometricPrompt`, the `androidx.biometric` dependency, `requestNamedPermissionsFromUi` with its camera request code, and the frontend's `BIOMETRIC_RESULT` event decode. The `'biometric'` lock-method value the frontend still declares is S-LOCK's. | | C-K17 | `KT/bridge/*` (`@VisibleForTesting` statics) | Statics that skip the port path; the instrumented proof never runs the port path production takes. | Open | | C-F10 | `FE/dsm/*` (`__dsmLastGoodHeaders`) | A window global never invalidated. | Resolved: `getHeaders` reads the bridge on every call; the window cache and the tests' resets of it are gone. | -| C-C19 | `dsm/Cargo.toml` | Six features that gate only a name. | Open | +| C-C19 | `dsm/Cargo.toml` | Six features that gate only a name. | Resolved: the six features (`bluetooth`, `storage`, `threadsafe`, `jni`, `web-stack`, `formal`) and the optional dependencies they carried (`jni`, `tokio-stream`, `rocksdb`, `axum`, `tower`) are deleted, with the build-info function that only listed them; `sphincs-trace` and `bitcoin-testnet-bypass` gate code and stay. | | C-C26 | `bitcoin-testnet-bypass` | §6.22; parked. | Open | **D. Repository constraints broken** @@ -1382,10 +1382,10 @@ The State column says what this branch did; "Open" rows are holes left visible, | ID | Location | Finding | State | |---|---|---|---| | D-CODEC | `KT/bridge/BridgeEnvelopeCodec.kt`; `dsm_client/frontend/public/index.html` | Hand-rolled protobuf codecs with hard-coded field numbers, beside "Kotlin MUST NOT implement custom wire decoders"; the index.html catch cleanup reads a variable out of scope. | Resolved: the page's port handler declares the response id outside its `try`, so the catch releases the pending entry it used to miss; `BridgeEnvelopeCodec` decodes and encodes through the generated protobuf classes (request, response, error, app-router, preference and bilateral payloads, and the envelope's error), and Kotlin holds no varint parser. Tests that asserted the hand-rolled parser's private rules (a second oneof member, a wrong wire type as a failure, an empty-versus-absent debug string) now assert protobuf's semantics. | -| D-SAFETY | `KT/bridge/*` (safety scan); `FE/dsm/*` (safety classification) | Kotlin scans a field that cannot match on the ingress path; the frontend classifies safety by regex over message text instead of reading `Error.source_tag`. | Open | +| D-SAFETY | `KT/bridge/*` (safety scan); `FE/dsm/*` (safety classification) | Kotlin scans a field that cannot match on the ingress path; the frontend classifies safety by regex over message text instead of reading `Error.source_tag`. | Resolved: the page classifies a deterministic-safety refusal by Rust's `Error.source_tag` (11) and reads the class from the error's context, at the two places every transport error surfaces (`decodeFramedEnvelopeV3`, the ingress unwrapper); the regex over message text and Kotlin's scan of the answer bytes (which never parsed a framed answer) are gone. | | D-F8 | `FE/dsm/WebViewBridge/strictQueries.ts` (`wallet.history`) | Sixteen raw little-endian bytes in a `Codec.PROTO` ArgPack. | Open | | D-F13 | `FE/services/recovery/nfcRecoveryService.ts` | A `key=value` text protocol with `'0'` defaults; JavaScript decodes capsule bytes (§6.29 Open, recovery boundary). | Open | -| D-C15 | `CORE/deterministic_id.rs` | Hex UUID ids; dead. | Open | +| D-C15 | `CORE/deterministic_id.rs` | Hex UUID ids; dead. | Resolved: `deterministic_id.rs` deleted. | | D-DBTC | `SDK/policy/builtins.rs` | The dBTC commit is checked with raw BLAKE3, not `TAG_DSM_POLICY`. Parked. | Open | | D-F17 | `FE/*` | `alert()` on production paths. | Resolved: the transfer dialog and the diagnostics overlay report through the app's toasts; no `alert()` remains in production code. | @@ -1394,7 +1394,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | ID | Location | Finding | State | |---|---|---|---| | E-C14 | `CORE/emissions/` | The whole module; emissions are out of this round and nothing reaches it. | Open | -| E-C15–18 | `CORE/deterministic_id.rs`; `crypto` (`calculate_next_entropy`, `hash_blake3`, `init_crypto`, `init_sphincs` — a self-test that never runs); `types/state_types.rs` (`State` methods nothing calls) | No production caller. | Open | +| E-C15–18 | `CORE/deterministic_id.rs`; `crypto` (`calculate_next_entropy`, `hash_blake3`, `init_crypto`, `init_sphincs` — a self-test that never runs); `types/state_types.rs` (`State` methods nothing calls) | No production caller. | Resolved for the named items: `deterministic_id.rs`, `calculate_next_entropy` and `hash_blake3` (each reached only by its own test), `init_crypto` and the `init_sphincs` self-test it alone called are deleted; `init_kyber` stays (key generation calls it). The `State` methods are not enumerated by the audit and remain Open. | | E-C21 | `CORE/*` (`verify_offline_allocation_leaf`, `verify_rollup_sequence`, `classical_verify` (P-256), `is_drain_proof`, `position_leader`, `dlv_manager::{try_unlock, claim, create_vault_post}`, `common/device_admission`, the `external_commitment` verifier family) | Verifiers and helpers with no production caller. | Open | | E-C9 | `CORE/…` (dBTC builtin literal) | Parked. | Open | | E-S21 | `SDK/lib.rs` (non-Android or non-Bluetooth builds) | `initialize_bilateral_sdk` answers `Ok(())` and `is_bilateral_ready` is `true` on builds that have no bilateral stack (A27 residual). The shipped Android path reads a real flag. | Resolved: on a build without the stack `initialize_bilateral_sdk` is an error naming the missing stack and `is_bilateral_ready` is false. | From a54890836f669fe67c915094d64a231ed75b0cb9 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:31:41 -0400 Subject: [PATCH 23/23] chore(core): the root lockfile follows the Core's dependency removals --- Cargo.lock | 119 ----------------------------------------------------- 1 file changed, 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 42e47af8..74a539a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,24 +400,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn", -] - [[package]] name = "bip39" version = "2.2.2" @@ -595,16 +577,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "cbindgen" version = "0.29.4" @@ -642,15 +614,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -709,17 +672,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" version = "4.6.1" @@ -1163,7 +1115,6 @@ dependencies = [ "argon2", "arrayref", "async-trait", - "axum", "base32", "bincode", "bitcoin", @@ -1182,7 +1133,6 @@ dependencies = [ "futures", "getrandom 0.4.3", "hmac 0.13.0", - "jni", "lazy_static", "log", "lru", @@ -1203,7 +1153,6 @@ dependencies = [ "quickcheck", "rand 0.9.4", "rand_chacha 0.9.0", - "rocksdb", "rstest", "serde", "serial_test", @@ -1213,8 +1162,6 @@ dependencies = [ "test-log", "thiserror 2.0.18", "tokio", - "tokio-stream", - "tower", "tracing", "tracing-subscriber", "uuid", @@ -2404,16 +2351,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "libredox" version = "0.1.16" @@ -2423,21 +2360,6 @@ dependencies = [ "libc", ] -[[package]] -name = "librocksdb-sys" -version = "0.17.3+10.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" -dependencies = [ - "bindgen", - "bzip2-sys", - "cc", - "libc", - "libz-sys", - "lz4-sys", - "zstd-sys", -] - [[package]] name = "libsqlite3-sys" version = "0.38.1" @@ -2449,17 +2371,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-sys" -version = "1.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "link-section" version = "0.19.0" @@ -2515,16 +2426,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "matchers" version = "0.2.0" @@ -3630,16 +3531,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rocksdb" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f" -dependencies = [ - "libc", - "librocksdb-sys", -] - [[package]] name = "ron" version = "0.12.1" @@ -5410,13 +5301,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -]