diff --git a/ci/android_exported_components.sh b/ci/android_exported_components.sh new file mode 100755 index 000000000..b0f64c97e --- /dev/null +++ b/ci/android_exported_components.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Pre-audit item 16: no component a release build declares may be started by +# another app except the launcher activity. An exported component is an entry +# point any installed app can call with intents and extras of its choosing; a +# component that can do something irreversible or security-sensitive must not +# be one. Debug-only tools are declared in src/debug/AndroidManifest.xml, which +# a release build does not merge. +set -euo pipefail +echo "=== android: the release manifest exports the launcher only ===" + +manifest=dsm_client/android/app/src/main/AndroidManifest.xml +[[ -f "$manifest" ]] || { echo "[FAIL] $manifest is missing"; exit 1; } + +python3 - "$manifest" <<'PY' +import sys +import xml.etree.ElementTree as ET + +ANDROID = "{http://schemas.android.com/apk/res/android}" +LAUNCHER = "com.dsm.wallet.ui.MainActivity" +manifest = ET.parse(sys.argv[1]).getroot() +application = manifest.find("application") +problems = [] +for kind in ("activity", "activity-alias", "service", "receiver", "provider"): + for component in application.findall(kind): + name = component.get(ANDROID + "name", "") + exported = component.get(ANDROID + "exported") + filters = component.findall("intent-filter") + if exported is None: + problems.append(f"{kind} {name} does not state android:exported") + continue + if exported == "true" and name != LAUNCHER: + problems.append(f"{kind} {name} is exported") + if name == LAUNCHER: + actions = {a.get(ANDROID + "name") for f in filters for a in f.findall("action")} + if actions != {"android.intent.action.MAIN"}: + problems.append(f"the launcher answers {sorted(actions)}, not MAIN alone") + for f in filters: + if f.findall("data"): + problems.append("the launcher declares a data filter (a deep link)") +for problem in problems: + print(f"[FAIL] {problem}") +if problems: + sys.exit(1) +print(" ✓ only the launcher is exported, and it answers MAIN alone") +PY diff --git a/ci/production_safety_checks.sh b/ci/production_safety_checks.sh index 8f1e09433..2ece618eb 100755 --- a/ci/production_safety_checks.sh +++ b/ci/production_safety_checks.sh @@ -55,6 +55,7 @@ echo "" # still owes the binding from that operation to the accepted owner transition. bash ci/sofi_genesis_acceptance_binding.sh bash ci/sofi_relay_is_party_neutral.sh +bash ci/android_exported_components.sh # Only an ordinary single-root lineage can become an eligible peer debit # (P15-9). The discriminant is worthless if a caller can attach it, and diff --git a/dsm_client/android/app/src/debug/AndroidManifest.xml b/dsm_client/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..66e44b0ee --- /dev/null +++ b/dsm_client/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + diff --git a/dsm_client/android/app/src/main/res/xml/pico_device_filter.xml b/dsm_client/android/app/src/debug/res/xml/pico_device_filter.xml similarity index 100% rename from dsm_client/android/app/src/main/res/xml/pico_device_filter.xml rename to dsm_client/android/app/src/debug/res/xml/pico_device_filter.xml diff --git a/dsm_client/android/app/src/main/AndroidManifest.xml b/dsm_client/android/app/src/main/AndroidManifest.xml index 4117e86aa..8cf1b9fac 100644 --- a/dsm_client/android/app/src/main/AndroidManifest.xml +++ b/dsm_client/android/app/src/main/AndroidManifest.xml @@ -114,21 +114,9 @@ android:exported="false" android:foregroundServiceType="connectedDevice|dataSync" /> - - - - - - - + + try { + startActivity(Intent(Intent.ACTION_VIEW, uri)) + } catch (e: ActivityNotFoundException) { + Log.w(tag, "No browser to open the issue form in", e) + } + WebNavigation.App, WebNavigation.NativeQr, WebNavigation.Refused -> + Log.w(tag, "Refused to open ${uri.scheme}://${uri.host} outside the app") + } + } + @SuppressLint("SetJavaScriptEnabled") private fun setupWebView(wv: WebView) { assetLoader = WebViewAssetLoader.Builder() @@ -1776,21 +1794,14 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { val hitResult = view?.hitTestResult val url = hitResult?.extra if (!url.isNullOrEmpty()) { - Log.i(tag, "window.open intercepted — opening in system browser: $url") - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - startActivity(intent) + openOutside(Uri.parse(url)) return false } // Secondary path: create a temporary WebView to capture the URL val tempWebView = WebView(view?.context ?: this@MainActivity) tempWebView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(v: WebView?, request: WebResourceRequest?): Boolean { - val uri = request?.url - if (uri != null) { - Log.i(tag, "window.open secondary path: opening in system browser: ${uri.host}") - val intent = Intent(Intent.ACTION_VIEW, uri) - startActivity(intent) - } + request?.url?.let { openOutside(it) } tempWebView.destroy() return true } @@ -1874,7 +1885,7 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { try { if (WebViewFeature.isFeatureSupported(WebViewFeature.POST_WEB_MESSAGE)) { val msg = WebMessageCompat("", arrayOf(port)) - WebViewCompat.postWebMessage(target, msg, "https://appassets.androidplatform.net".toUri()) + WebViewCompat.postWebMessage(target, msg, WebNavigationPolicy.APP_ORIGIN.toUri()) pendingJsPort = null Log.i(tag, "Delivered DSM MessagePort to page") } @@ -1908,31 +1919,25 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { return assetLoader.shouldInterceptRequest(uri) } + // Every navigation goes through WebNavigationPolicy (pre-audit item 13): + // only the app's own page loads here; the QR link and the issue form + // are handled natively; everything else is refused. override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - try { - val uri = request?.url ?: return false - // Handle native DSM deep links - if (uri.scheme == "dsm" && uri.host == "native") { - val path = uri.path ?: "" - if (path == "/qr/start") { - Log.i(tag, "WebView requested native QR scan") - launchNativeQrScanner { qrText: String? -> - dispatchQrScanResult(qrText) - } - return true + val uri = request?.url ?: return super.shouldOverrideUrlLoading(view, request) + val navigation = WebNavigationPolicy.decide(uri.scheme, uri.host, uri.path) + when (navigation) { + WebNavigation.App -> Unit + WebNavigation.NativeQr -> { + Log.i(tag, "WebView requested native QR scan") + launchNativeQrScanner { qrText: String? -> + dispatchQrScanResult(qrText) } } - // External URLs (http/https): open in system browser, keep WebView intact - if (uri.scheme == "http" || uri.scheme == "https") { - Log.i(tag, "Opening external URL in system browser: ${uri.host}") - val intent = Intent(Intent.ACTION_VIEW, uri) - startActivity(intent) - return true - } - } catch (t: Throwable) { - Log.w(tag, "shouldOverrideUrlLoading: error", t) + WebNavigation.IssueForm -> openOutside(uri) + WebNavigation.Refused -> + Log.w(tag, "Refused navigation to ${uri.scheme}://${uri.host}") } - return false + return navigation != WebNavigation.App } } diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/WebNavigationPolicy.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/WebNavigationPolicy.kt new file mode 100644 index 000000000..6240c89df --- /dev/null +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/WebNavigationPolicy.kt @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.dsm.wallet.ui + +/** Where a navigation the page asks for may go. */ +enum class WebNavigation { + /** The app's own packaged page: it loads in the WebView. */ + App, + + /** The page's one link to the native QR scanner. */ + NativeQr, + + /** The release repository's new-issue form, opened in the system browser. */ + IssueForm, + + /** Anything else: never loaded in the WebView, never handed to another app. */ + Refused, +} + +/** + * The WebView's navigation policy (pre-audit item 13). The page is the app's + * packaged origin and nothing else: it loads only its own assets, asks for the + * native QR scanner by its one link, and sends the user to one outside page, + * the beta bug and feedback form. Every other address is refused: another + * site, the app's origin outside its assets, and every other scheme (`file:`, + * `content:`, `intent:`, `javascript:`, `data:`, `http:`), so an injected link + * can neither replace the page nor launch another app with the page's data. + */ +object WebNavigationPolicy { + const val APP_ORIGIN = "https://appassets.androidplatform.net" + private const val APP_HOST = "appassets.androidplatform.net" + private const val APP_PATH_PREFIX = "/assets/" + private const val ISSUE_FORM_HOST = "github.com" + private const val ISSUE_FORM_PATH = "/deterministicstatemachine/dsm/issues/new" + + /** The navigation to `scheme://host/path`, as `android.net.Uri` splits it. */ + fun decide(scheme: String?, host: String?, path: String?): WebNavigation { + val s = scheme?.lowercase() + val h = host?.lowercase() + return when { + s == "https" && h == APP_HOST && path.orEmpty().startsWith(APP_PATH_PREFIX) -> + WebNavigation.App + s == "dsm" && h == "native" && path == "/qr/start" -> WebNavigation.NativeQr + s == "https" && h == ISSUE_FORM_HOST && path == ISSUE_FORM_PATH -> WebNavigation.IssueForm + else -> WebNavigation.Refused + } + } +} diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/ui/WebNavigationPolicyTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/ui/WebNavigationPolicyTest.kt new file mode 100644 index 000000000..6823e3ea6 --- /dev/null +++ b/dsm_client/android/app/src/test/java/com/dsm/wallet/ui/WebNavigationPolicyTest.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.dsm.wallet.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pre-audit item 13: the WebView loads only the app's own packaged page, + * reaches the native QR scanner by its one link, and opens only the beta + * issue form outside the app. Everything else is refused. + */ +class WebNavigationPolicyTest { + private fun decide(scheme: String?, host: String?, path: String?) = + WebNavigationPolicy.decide(scheme, host, path) + + @Test + fun the_apps_own_page_loads_in_the_webview() { + assertEquals(WebNavigation.App, decide("https", "appassets.androidplatform.net", "/assets/index.html")) + assertEquals(WebNavigation.App, decide("HTTPS", "AppAssets.androidplatform.net", "/assets/js/main.js")) + } + + @Test + fun the_qr_link_and_the_issue_form_are_the_only_ways_out() { + assertEquals(WebNavigation.NativeQr, decide("dsm", "native", "/qr/start")) + assertEquals( + WebNavigation.IssueForm, + decide("https", "github.com", "/deterministicstatemachine/dsm/issues/new"), + ) + } + + @Test + fun every_other_address_is_refused() { + val refused = listOf( + Triple("https", "appassets.androidplatform.net", "/res/raw/secret"), + Triple("http", "appassets.androidplatform.net", "/assets/index.html"), + Triple("https", "evil.example", "/assets/index.html"), + Triple("https", "github.com", "/deterministicstatemachine/dsm/issues"), + Triple("https", "github.com", "/someone-else/dsm/issues/new"), + Triple("https", "github.com.evil.example", "/deterministicstatemachine/dsm/issues/new"), + Triple("http", "github.com", "/deterministicstatemachine/dsm/issues/new"), + Triple("dsm", "native", "/wallet/send"), + Triple("file", null, "/data/data/com.dsm.wallet/databases/dsm_client.db"), + Triple("content", "com.dsm.wallet.provider", "/anything"), + Triple("intent", null, null), + Triple("javascript", null, null), + Triple("data", null, null), + Triple(null, null, null), + ) + for ((scheme, host, path) in refused) { + assertEquals("$scheme://$host$path", WebNavigation.Refused, decide(scheme, host, path)) + } + } +} diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs index e98790b6b..ccd3f0931 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs @@ -147,14 +147,27 @@ pub struct ExerciseReads<'a> { pub conformance: &'a ConformanceEvidence, /// What `RouteValidation(P, G, E)` reads. pub evidence: &'a Evidence, - /// This verifier's own admitted position at `P.p`, when `P` names a - /// conditional parent: what that position selected is what this - /// verifier itself resolved. Nothing else resolves a parent. - pub parent: Option, + /// What this verifier established about the trader's lineage at `P.p` + /// ([`TraderAtParent`]); `None` while it has not. Nothing else resolves + /// a parent. + pub parent: Option, /// One entry per leg of `P`, in P's leg order. pub legs: &'a [LegReads<'a>], } +/// What this verifier established about the trader's lineage at `p`: the +/// claim it holds there, or that it holds none, ever, because lineage +/// validation established it Invalid at or before `p` (SoFi Amendment S13, +/// the verdict `validation::setup_lineage` reads for setups). A lineage not +/// established yet is neither: no fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TraderAtParent { + /// The claim final at the trader's `K_root(p)` and the root it installed. + Held(ResolvedParent), + /// The trader's lineage is Invalid at or before `p`. + LineageInvalid, +} + /// What this verifier itself established at `p`: the claim that holds the /// trader's `K_root(p)`, as a `P` names it, and the root the lineage holds /// there — for a conditional position, the root its resolution selected. @@ -209,7 +222,7 @@ impl ResolvedParent { pub struct GroundReads<'a> { pub exercise: &'a RecognizedExercise, pub registration: &'a RegistrationRead, - pub parent: Option, + pub parent: Option, pub legs: &'a [LegReads<'a>], } @@ -224,8 +237,7 @@ pub struct GroundReads<'a> { #[derive(Debug, Clone)] pub struct GroundFacts { pub(crate) external_commitment: D32, - pub(crate) registered: bool, - pub(crate) position_lost: bool, + pub(crate) pair: PairStanding, pub(crate) parent: ParentPosition, pub(crate) parent_pre_root: D32, /// One per leg of `P`, in P's leg order. @@ -242,7 +254,7 @@ impl GroundFacts { pub(crate) fn route_ground(&self) -> GroundRouteFacts<'_> { GroundRouteFacts { external_commitment: self.external_commitment, - position_lost: self.position_lost, + pair: self.pair, parent: self.parent, parent_pre_root: self.parent_pre_root, legs: &self.legs, @@ -272,9 +284,8 @@ impl GroundFacts { pub struct EstablishedFacts { pub(crate) fulfillment_id: D32, pub(crate) external_commitment: D32, - pub(crate) registered: bool, + pub(crate) pair: PairStanding, pub(crate) conformance: Validation, - pub(crate) position_lost: bool, pub(crate) parent: ParentPosition, pub(crate) parent_pre_root: D32, pub(crate) validation: Validation, @@ -310,9 +321,8 @@ impl EstablishedFacts { pub(crate) fn route_facts(&self) -> RouteFacts<'_> { RouteFacts { external_commitment: self.external_commitment, - registered: self.registered, + pair: self.pair, conformance: self.conformance, - position_lost: self.position_lost, parent: self.parent, parent_pre_root: self.parent_pre_root, validation: self.validation, @@ -346,13 +356,13 @@ pub enum Established { } /// A position whose exercise is refuted in hand, with the one fact the -/// ladder asks of it: whether that exercise registered. +/// ladder asks of it: where that exercise stands at its pair. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RefutedPosition { pub(crate) fulfillment_id: D32, pub(crate) external_commitment: D32, pub(crate) refuted: RefutedInHand, - pub(crate) registered: bool, + pub(crate) pair: PairStanding, } impl Established { @@ -377,10 +387,8 @@ impl Established { fulfillment_id: refutation.fulfillment_id, external_commitment: refutation.external_commitment, refuted: refutation.refuted, - registered: matches!( - registration.standing_of(&exercise.precommit().body, &exercise.fulfillment().body), - PairStanding::Registered - ), + pair: registration + .standing_of(&exercise.precommit().body, &exercise.fulfillment().body), })) } @@ -481,14 +489,13 @@ pub fn establish(reads: &ExerciseReads<'_>) -> Result) -> Result, + held: Option, ) -> Option { - let held = held.filter(|held| held.economic_position == position)?; + let held = match held? { + TraderAtParent::LineageInvalid => return Some(ParentPosition::LineageInvalid), + TraderAtParent::Held(held) => held, + }; + let held = (held.economic_position == position).then_some(held)?; Some(match named { ParentClaimRef::SingleRoot { .. } => ParentPosition::SingleRoot { named, @@ -548,10 +560,9 @@ pub fn establish_ground(reads: &GroundReads<'_>) -> Result) -> Result, /// What holds the trader's `K_root(p)`, as this verifier established /// it: by default the claim `P` names, at the root `P` was built on. - parent: Option, + parent: Option, } impl Reads { @@ -814,7 +824,7 @@ mod tests { .collect(); let conformance = conformance_evidence(&fx, &exercise, BTreeMap::new()); let evidence = fx.evidence.clone(); - let parent = Some(held_parent(&built.precommit)); + let parent = Some(TraderAtParent::Held(held_parent(&built.precommit))); ( fx, Reads { @@ -870,8 +880,7 @@ mod tests { let f = &r.exercise.fulfillment().body; assert_eq!(facts.fulfillment_id(), &derive::fulfillment_id(f)); assert_eq!(facts.external_commitment(), p.external_commitment()); - assert!(facts.registered); - assert!(!facts.position_lost); + assert_eq!(facts.pair, PairStanding::Registered); assert_eq!( facts.conformance, Validation::Valid, @@ -1014,12 +1023,12 @@ mod tests { fn a_parent_claim_the_trader_does_not_hold_at_p_never_realizes() { let (_, mut r) = reads(1, &[0]); let held = held_parent(&r.exercise.precommit().body); - r.parent = Some(ResolvedParent { + r.parent = Some(TraderAtParent::Held(ResolvedParent { named: ParentClaimRef::SingleRoot { claim_ref: OTHER_CLAIM, }, ..held - }); + })); let facts = r.establish(&r.legs()).expect("every read decides"); assert_eq!( resolve_position(&facts.route_facts()), @@ -1040,10 +1049,10 @@ mod tests { fn a_parent_at_another_root_than_the_trader_holds_never_realizes() { let (_, mut r) = reads(1, &[0]); let held = held_parent(&r.exercise.precommit().body); - r.parent = Some(ResolvedParent { + r.parent = Some(TraderAtParent::Held(ResolvedParent { selected_root: OTHER_ROOT, ..held - }); + })); let facts = r.establish(&r.legs()).expect("every read decides"); assert_eq!( resolve_position(&facts.route_facts()), @@ -1088,11 +1097,11 @@ mod tests { fulfillment_id: [0xF7; 32], }; let held = |claim| { - Some(ResolvedParent { + Some(TraderAtParent::Held(ResolvedParent { economic_position: position, selected_root: root, named: claim, - }) + })) }; assert_eq!( parent_position(named, position, held(named)), @@ -1127,6 +1136,89 @@ mod tests { assert_eq!(parent_position(named, position, None), None); } + /// Pre-audit 12f, SoFi §23.3 and Amendment S13: a trader's lineage that + /// lineage validation established Invalid at or before `p` holds no claim + /// there, ever. Whatever `P` names, single-root or conditional, its + /// parent is terminal: the position is Invalid and the key its exercise + /// holds is skipped, never left waiting for a lineage that cannot yield + /// a claim. On the old code the walk's verdict reached the facts as no + /// parent at all, and the key waited forever. + #[test] + fn a_parent_on_a_lineage_known_invalid_is_terminal() { + for named in [ + ParentClaimRef::SingleRoot { + claim_ref: [0xC7; 32], + }, + ParentClaimRef::Conditional { + fulfillment_id: [0xF7; 32], + }, + ] { + assert_eq!( + parent_position(named, 7, Some(TraderAtParent::LineageInvalid)), + Some(ParentPosition::LineageInvalid), + "{named:?}" + ); + assert!(trader_parent_impossible( + &ParentPosition::LineageInvalid, + &[0x77; 32] + )); + } + + let (_, mut r) = reads(1, &[0]); + r.parent = Some(TraderAtParent::LineageInvalid); + let facts = r.establish(&r.legs()).expect("every read decides"); + assert_eq!( + resolve_position(&facts.route_facts()), + Ok(Resolution::Invalid), + "built on a lineage that holds nothing at p" + ); + let (class, ..) = classify_attempt(&facts.route_facts(), &facts.legs[0]); + assert_eq!( + class, + AttemptClass::Skipped, + "the vault's key is passed over" + ); + } + + /// Pre-audit 12k, SoFi Amendment S20 (owner ruling, 2026-10-01: "same + /// bytes => same terminal verdict for local and peer resolution"): a pair + /// final on `F` whose root cell names `F` under another body than + /// `derive(P, F)` is misbodied. The trader's own ladder resolves the + /// position Invalid, as a peer's walk does, and the vault's key is + /// skipped, never consumed. On the old code the ladder read the pair as + /// not registered and waited for good. + #[test] + fn a_pair_final_on_its_fulfillment_under_another_body_resolves_invalid() { + let (_, mut r) = reads(1, &[0]); + let p = r.exercise.precommit().body.clone(); + let f = r.exercise.fulfillment().body.clone(); + let forged = SofiResolutionClaim { + realize_root: [0xBD; 32], + ..derive::resolution_claim(&p, &f) + }; + r.registration = registration_read( + &p, + &f, + &r.exercise.fulfillment().signature, + p.void_root(), + Some(forged), + ); + let facts = r.establish(&r.legs()).expect("every read decides"); + assert_eq!(facts.pair, PairStanding::Misbodied); + assert_eq!( + resolve_position(&facts.route_facts()), + Ok(Resolution::Invalid), + "the lineage's verdict is the peers'" + ); + assert!(!consumed_route(&facts.route_facts())); + let (class, ..) = classify_attempt(&facts.route_facts(), &facts.legs[0]); + assert_eq!( + class, + AttemptClass::Skipped, + "the vault's key is passed over" + ); + } + /// Registration is read from the pair, never assumed: with `C_q` not /// final at `K_root(q)` the position is unregistered, the ladder stops /// at rung 0, and nothing is lost either. @@ -1143,8 +1235,7 @@ mod tests { None, ); let facts = r.establish(&r.legs()).expect("every read decides"); - assert!(!facts.registered); - assert!(!facts.position_lost); + assert_eq!(facts.pair, PairStanding::Pending); assert!(!facts.storage_resolved); assert_eq!( resolve_position(&facts.route_facts()), @@ -1230,7 +1321,7 @@ mod tests { evidence: fx.evidence.clone(), cells: vec![cell_read(&v, &root, 1, Some(&bytes))], chains: BTreeMap::from([(v, chain_naming(&fx, &v, root))]), - parent: Some(held_parent(&built.precommit)), + parent: Some(TraderAtParent::Held(held_parent(&built.precommit))), exercise, }; @@ -1272,9 +1363,8 @@ mod tests { 0, RouteFacts { external_commitment: OTHER_E, - registered: true, + pair: PairStanding::Registered, conformance: Validation::Valid, - position_lost: false, parent: ParentPosition::SingleRoot { named: *built.precommit.parent_claim_ref(), held: *built.precommit.parent_claim_ref(), @@ -1319,7 +1409,7 @@ mod tests { else { panic!("a refutation") }; - assert!(position.registered); + assert_eq!(position.pair, PairStanding::Registered); assert_eq!(position.refuted, RefutedInHand::Conformance); let other = InHandRefutation { @@ -1353,6 +1443,6 @@ mod tests { else { panic!("a refutation") }; - assert!(!position.registered); + assert_eq!(position.pair, PairStanding::Pending); } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs index 9dfeba158..bc65b6f84 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs @@ -388,9 +388,7 @@ fn derive_resolved<'e>( Established::RefutedInHand(refuted) => { // Registration is the one fact the ladder asks of a refuted // exercise; registered, it is Invalid whatever the cells say. - match resolve_refuted_in_hand(refuted.registered) - .map_err(AdvanceError::FactsIncomplete)? - { + match resolve_refuted_in_hand(refuted.pair).map_err(AdvanceError::FactsIncomplete)? { Resolution::Invalid => return Err(AdvanceError::LineageIsTerminal), Resolution::Realized | Resolution::Void => { return Err(AdvanceError::RefutedYetNotTerminal) @@ -824,6 +822,7 @@ mod tests { use crate::route_chain::{CellFact, ChainState}; use crate::sofi::conformance::Validation; use crate::sofi::facts::{EstablishedFacts, RefutedPosition}; + use crate::sofi::registration::PairStanding; use crate::sofi::resolution::{LegFacts, ParentPosition, ParentStatus, RefutedInHand}; use crate::sofi::validation::fixtures::{swap_fixture_n, Fixture}; use crate::sofi::wire::{PrecommitLeg, TraderRelationshipLeaf}; @@ -943,9 +942,8 @@ mod tests { Established::Facts(Box::new(EstablishedFacts { fulfillment_id: derive::fulfillment_id(f), external_commitment: e, - registered: true, + pair: PairStanding::Registered, conformance: Validation::Valid, - position_lost: false, parent: ParentPosition::SingleRoot { named: *p.parent_claim_ref(), held: *p.parent_claim_ref(), @@ -1092,7 +1090,7 @@ mod tests { unreachable!("stated facts") }; let unregistered = EstablishedFacts { - registered: false, + pair: PairStanding::Pending, ..(*complete).clone() }; assert_eq!( @@ -1135,12 +1133,12 @@ mod tests { let pre = d(0xA0); let p = precommit(pre, d(0xA1)); let f = fulfillment(&p); - let refuted = |registered| { + let refuted = |pair| { Established::RefutedInHand(RefutedPosition { fulfillment_id: derive::fulfillment_id(&f), external_commitment: *p.external_commitment(), refuted: RefutedInHand::Conformance, - registered, + pair, }) }; assert_eq!( @@ -1149,7 +1147,7 @@ mod tests { &p, &f, p.parent_claim_ref(), - &refuted(true), + &refuted(PairStanding::Registered), &bare_receiver(), ), Err(AdvanceError::LineageIsTerminal) @@ -1160,7 +1158,7 @@ mod tests { &p, &f, p.parent_claim_ref(), - &refuted(false), + &refuted(PairStanding::Pending), &bare_receiver(), ), Err(AdvanceError::FactsIncomplete(Incomplete::NotRegistered)) diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/registration.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/registration.rs index 921b1abf3..e0048a4a9 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/registration.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/registration.rs @@ -193,8 +193,12 @@ impl RegistrationRead { /// the leader keeps a value, no other is ever final there), or when /// `K_root(q)`'s leader link is held by a claim other than /// `derive(P, F)`: another fulfillment's, an ordinary transition's, or - /// one naming `F` whose body is not `F`'s. Registered when the pair is - /// final on `F` and on exactly `derive(P, F)`. Both are permanent. + /// one naming `F` whose body is not `F`'s. When that last one is final + /// with `F` final at `K_ful(q)`, the pair is matched by `F`'s id and its + /// body is not `F`'s claim: misbodied, which loses `F` and makes the + /// position Invalid for the trader's lineage (S20). Registered when the + /// pair is final on `F` and on exactly `derive(P, F)`. All but Pending + /// are permanent. pub fn standing_of( &self, precommit: &TraderPrecommitBody, @@ -204,6 +208,11 @@ impl RegistrationRead { &derive::resolution_claim(precommit, fulfillment).encode(), ); let root_is_another = self.root_claim.is_some_and(|claim| claim != own); + if root_is_another + && matches!(&self.registration, Registration::Registered(held) if held.body == *fulfillment) + { + return PairStanding::Misbodied; + } let holder = match &self.registration { Registration::Registered(signed) | Registration::Held(signed) @@ -237,6 +246,19 @@ pub enum PairStanding { Pending, /// `F` can never register at `q` (SoFi Amendments S14, S20). Permanent. Lost, + /// The pair is final on `F`, matched by its id, but the claim at + /// `K_root(q)` is not `derive(P, F)`: it names `F` under another body. + /// `F` is lost there (S14's skip, no Void) and the position is Invalid + /// for the trader's lineage (S20). Permanent. + Misbodied, +} + +impl PairStanding { + /// `F` can never register at `q`: lost to another claim, or to its own + /// pair under another body. What the S14 skip reads. + pub fn is_lost(self) -> bool { + matches!(self, Self::Lost | Self::Misbodied) + } } /// Which of the two cells settled the answer. @@ -769,7 +791,8 @@ mod tests { assert_eq!(standing(&root_only, &rival), PairStanding::Lost); // A claim naming F's id, with a body that is not derive(P, F): the - // pair matches by id, and F is lost where P is in hand. + // pair matches by id, and where P is in hand F is misbodied (lost, + // and the position Invalid for the lineage, SoFi Amendment S20; 12k). let forged = SofiResolutionClaim { realize_root: [0xBD; 32], ..own_claim @@ -779,7 +802,12 @@ mod tests { mismatched.registration(), Registration::Registered(signed) if signed.body == f )); - assert_eq!(standing(&mismatched, &f), PairStanding::Lost); + assert_eq!(standing(&mismatched, &f), PairStanding::Misbodied); + assert!(standing(&mismatched, &f).is_lost()); + // Not final yet, the same claim already loses F, and the lineage + // waits for the pair to settle before it reads the verdict. + let settling = at(Some((&f, last)), Some((forged, 0))); + assert_eq!(standing(&settling, &f), PairStanding::Lost); let nothing = at(None, None); assert_eq!(standing(¬hing, &f), PairStanding::Pending); diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs index 8a7aa6557..127a8db65 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs @@ -38,6 +38,7 @@ use crate::route_chain::{CellFact, ChainState}; use super::conformance::Validation; +use super::registration::PairStanding; use super::wire::{next_attempt, ParentClaimRef}; /// What a verifier has established about the predecessor position `p` that `P` @@ -70,6 +71,13 @@ pub enum ParentPosition { /// The `C_p` that `P` names selects no root, ever: another claim holds /// `p`, so it never registered there, or it resolved Invalid. Terminal. ConditionalNoRoot, + /// Lineage validation established the trader's lineage Invalid at or + /// before `p` (an Invalid step, or a divergent write-once register cell + /// it quarantines): that lineage holds no claim at `p`, ever, whatever + /// `P` names. Terminal (SoFi §23.3; Amendment S13: "No trade whose + /// trader's lineage is known invalid can occupy a vault key + /// indefinitely"). + LineageInvalid, } /// A trader-position result. Every one is permanent (Amendment S7). Core @@ -421,19 +429,19 @@ pub struct RouteFacts<'legs> { /// `E` — the ONE external commitment this operation is bound to. Every /// required leg must be final on exactly this value. pub(crate) external_commitment: [u8; 32], - /// `FulfillmentRegistered(q, F)` — the exercise boundary. - pub(crate) registered: bool, + /// Where `F` stands at its position pair (R10, SoFi Amendments S14 and + /// S20): `Registered` is `FulfillmentRegistered(q, F)`, the exercise + /// boundary. `Lost` is the fact behind the skip + /// `RejectedFinalInadmissible`: position `q` already holds a different + /// claim, so this `F` can never register (Section 21.1), a fact about `F` + /// and never an arm of `RouteImpossible(P, E)`. `Misbodied` is the pair + /// final on `F` under a claim that is not `derive(P, F)`: lost the same + /// way, and the position Invalid for the trader's lineage. + pub(crate) pair: PairStanding, /// `FulfillmentConformance(F)` (Section 20.2), as the ladder reads it. /// Registration supplies no truth value for it: a registered `F` may be /// `Invalid`, and a producer's pre-sign check is not this verifier's. pub(crate) conformance: Validation, - /// The fact behind the skip `RejectedFinalInadmissible` (SoFi Amendment - /// S14): position `q` already holds a different claim — an ordinary - /// transition, or another fulfillment of the same trader naming other - /// attempt keys — so this `F` can never register (Section 21.1). A fact - /// about `F`, never an arm of `RouteImpossible(P, E)`. Read from the - /// position pair (R10); it never holds together with `registered`. - pub(crate) position_lost: bool, /// What is known about the claim at `p`. pub(crate) parent: ParentPosition, /// `P.void_root == T°.pre_root`. @@ -454,7 +462,7 @@ pub struct RouteFacts<'legs> { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct GroundRouteFacts<'legs> { pub(crate) external_commitment: [u8; 32], - pub(crate) position_lost: bool, + pub(crate) pair: PairStanding, pub(crate) parent: ParentPosition, pub(crate) parent_pre_root: [u8; 32], pub(crate) legs: &'legs [LegFacts], @@ -480,38 +488,44 @@ impl RouteFacts<'_> { } } -/// `TraderParentCompatible(P)`: the parent is an ordinary claim the trader -/// holds at `p`, at exactly the root this operation was built on, or a -/// conditional claim that selected exactly that root. The parent is always resolved here: Core resolves only over complete -/// facts, the predecessor's resolution among them (Amendment S7). -pub fn trader_parent_compatible(parent: &ParentPosition, parent_pre_root: &[u8; 32]) -> bool { +/// The root a terminal parent leaves `P` standing on: the root the ordinary +/// claim `P` names installed, when the trader's lineage holds that very claim +/// at `p`; the root a conditional `C_p` selected. `None` when the parent +/// leaves `P` on no root, ever: an ordinary claim the lineage does not hold +/// at `p`, a `C_p` that selects no root, or a lineage known Invalid at or +/// before `p`. +fn root_left_by(parent: &ParentPosition) -> Option<&[u8; 32]> { match parent { ParentPosition::SingleRoot { named, held, held_root, - } => named == held && held_root == parent_pre_root, - ParentPosition::ConditionalSelected { selected_root } => selected_root == parent_pre_root, - ParentPosition::ConditionalNoRoot => false, + } => (named == held).then_some(held_root), + ParentPosition::ConditionalSelected { selected_root } => Some(selected_root), + ParentPosition::ConditionalNoRoot | ParentPosition::LineageInvalid => None, } } +/// `TraderParentCompatible(P)`: the parent is an ordinary claim the trader +/// holds at `p`, at exactly the root this operation was built on, or a +/// conditional claim that selected exactly that root. The parent is always +/// resolved here: Core resolves only over complete facts, the predecessor's +/// resolution among them (Amendment S7). +pub fn trader_parent_compatible(parent: &ParentPosition, parent_pre_root: &[u8; 32]) -> bool { + root_left_by(parent) == Some(parent_pre_root) +} + /// `TraderParentImpossible(P)`: the parent is terminal and did not select the /// root this operation was built on — either it selected nothing (Invalid), or /// it selected the other branch — or it is an ordinary claim the trader does -/// not hold at `p`, or holds at another root. +/// not hold at `p`, or holds at another root, or the trader's lineage is +/// known Invalid at or before `p`, so it holds nothing there. /// -/// Objective and monotone. +/// A [`ParentPosition`] is always terminal: a parent not established yet is +/// no fact at all (`NotEstablished::ParentUnresolved`). So the parent is +/// impossible exactly when it is not compatible. Objective and monotone. pub fn trader_parent_impossible(parent: &ParentPosition, parent_pre_root: &[u8; 32]) -> bool { - match parent { - ParentPosition::ConditionalNoRoot => true, - ParentPosition::ConditionalSelected { selected_root } => selected_root != parent_pre_root, - ParentPosition::SingleRoot { - named, - held, - held_root, - } => named != held || held_root != parent_pre_root, - } + !trader_parent_compatible(parent, parent_pre_root) } /// `ConsumedRoute(F, E)` (Section 23.2): registered, conforming, statically @@ -524,7 +538,7 @@ pub fn trader_parent_impossible(parent: &ParentPosition, parent_pre_root: &[u8; /// `FulfillmentConformance` is a conjunct of its own: registration is a race /// at a leader, not a verdict on the bytes that won it. pub fn consumed_route(facts: &RouteFacts<'_>) -> bool { - facts.registered + facts.pair == PairStanding::Registered && facts.conformance == Validation::Valid && facts.validation == Validation::Valid && trader_parent_compatible(&facts.parent, &facts.parent_pre_root) @@ -617,9 +631,13 @@ pub enum Incomplete { /// Crate-private: the one production caller is `advance_resolved`, which /// installs a root on this answer and on nothing a caller says. pub(crate) fn resolve_position(facts: &RouteFacts<'_>) -> Result { - // 0 — nothing is exercised before registration. - if !facts.registered { - return Err(Incomplete::NotRegistered); + // 0 — nothing is exercised before registration; a pair final on `F` + // under another body than `derive(P, F)` is Invalid for the lineage + // (SoFi Amendment S20), the same verdict a peer reads. + match facts.pair { + PairStanding::Registered => {} + PairStanding::Misbodied => return Ok(Resolution::Invalid), + PairStanding::Pending | PairStanding::Lost => return Err(Incomplete::NotRegistered), } // 1 — the parent took another branch, or none. if trader_parent_impossible(&facts.parent, &facts.parent_pre_root) { @@ -729,7 +747,7 @@ pub fn classify_attempt( // another claim holds its position, so no registered fulfillment will // ever name this cell. Without the skip the parent's attempt chain // stops here forever (TLA `DSM_SofiFulfillment`, `LostPosition`). - if facts.position_lost { + if facts.pair.is_lost() { return ( AttemptClass::Skipped, Some(SkipReason::RejectedFinalInadmissible), @@ -823,7 +841,7 @@ pub fn skip_without_evidence( SkipReason::RejectedFinalRoute(ImpossibleArm::ParentConsumedElsewhere) } else if trader_parent_impossible(&ground.parent, &ground.parent_pre_root) { SkipReason::RejectedFinalRoute(ImpossibleArm::TraderParentImpossible) - } else if ground.position_lost { + } else if ground.pair.is_lost() { SkipReason::RejectedFinalInadmissible } else { return (AttemptClass::Unresolved, None); @@ -831,16 +849,16 @@ pub fn skip_without_evidence( (AttemptClass::Skipped, Some(reason)) } -/// The ladder over a position whose exercise is refuted in hand: registration -/// is the one fact it reads. Unregistered, rung 0 holds. Registered, the +/// The ladder over a position whose exercise is refuted in hand: the pair is +/// the one fact it reads. Unregistered, rung 0 holds. Registered, the /// position is Invalid whatever the other facts are — rung 1 when the parent /// took another branch, else rung 2 for a non-conforming `F`, else rung 4 -/// for an invalid route, which nothing before it can consume. -pub(crate) fn resolve_refuted_in_hand(registered: bool) -> Result { - if registered { - Ok(Resolution::Invalid) - } else { - Err(Incomplete::NotRegistered) +/// for an invalid route, which nothing before it can consume. Misbodied, it +/// is Invalid at rung 0. +pub(crate) fn resolve_refuted_in_hand(pair: PairStanding) -> Result { + match pair { + PairStanding::Registered | PairStanding::Misbodied => Ok(Resolution::Invalid), + PairStanding::Pending | PairStanding::Lost => Err(Incomplete::NotRegistered), } } @@ -1339,9 +1357,8 @@ mod tests { fn realized<'l>(legs: &'l [LegFacts]) -> RouteFacts<'l> { RouteFacts { external_commitment: E, - registered: true, + pair: PairStanding::Registered, conformance: Valid, - position_lost: false, parent: HELD, parent_pre_root: PRE, validation: Valid, @@ -1359,7 +1376,7 @@ mod tests { fn an_unregistered_fulfillment_is_not_resolved_even_with_every_leg_final() { let legs = [good_leg()]; let facts = RouteFacts { - registered: false, + pair: PairStanding::Pending, ..realized(&legs) }; assert_eq!(resolve_position(&facts), Err(Incomplete::NotRegistered)); @@ -1767,7 +1784,7 @@ mod tests { conformance: Invalid, ..realized(&legs) }; - assert!(facts.registered); + assert_eq!(facts.pair, PairStanding::Registered); assert_ne!(resolve_position(&facts), Ok(Resolution::Realized)); } @@ -1814,8 +1831,7 @@ mod tests { fn a_final_cell_whose_fulfillment_lost_its_position_is_skipped() { let legs = [good_leg()]; let facts = RouteFacts { - registered: false, - position_lost: true, + pair: PairStanding::Lost, ..realized(&legs) }; assert_eq!(route_impossible(&facts), None); @@ -1831,7 +1847,7 @@ mod tests { assert!(!consumed_route(&facts)); // Without the skip the same cell is that F's open question forever. let held = RouteFacts { - position_lost: false, + pair: PairStanding::Pending, ..facts }; assert_eq!(route_impossible(&held), None); @@ -1874,7 +1890,7 @@ mod tests { fn ground_of<'l>(facts: &RouteFacts<'l>) -> GroundRouteFacts<'l> { GroundRouteFacts { external_commitment: facts.external_commitment, - position_lost: facts.position_lost, + pair: facts.pair, parent: facts.parent, parent_pre_root: facts.parent_pre_root, legs: facts.legs, @@ -1953,7 +1969,7 @@ mod tests { let lost = around(&legs, Valid, Valid) .into_iter() - .find(|f| f.position_lost && f.parent == HELD) + .find(|f| f.pair == PairStanding::Lost && f.parent == HELD) .expect("a lost position among the facts"); assert_eq!( skip_without_evidence(&ground_of(&lost), &legs[0]), @@ -2107,9 +2123,8 @@ mod tests { let legs = [LegFacts { cell, ..good_leg() }]; let facts = RouteFacts { external_commitment: E, - registered: true, + pair: PairStanding::Registered, conformance, - position_lost: false, parent, parent_pre_root: PRE, validation, @@ -2273,9 +2288,8 @@ mod tests { attempt, RouteFacts { external_commitment: e, - registered: true, + pair: PairStanding::Registered, conformance: Valid, - position_lost: false, parent: HELD, parent_pre_root: PRE, validation, @@ -2326,9 +2340,8 @@ mod tests { attempt, RouteFacts { external_commitment: OTHER_E, - registered: true, + pair: PairStanding::Registered, conformance: Valid, - position_lost: false, parent: HELD, parent_pre_root: PRE, validation: Invalid, @@ -2381,9 +2394,8 @@ mod tests { attempt, RouteFacts { external_commitment: OTHER_E, - registered: true, + pair: PairStanding::Registered, conformance: Valid, - position_lost: false, parent: HELD, parent_pre_root: PRE, validation: Invalid, @@ -2433,8 +2445,13 @@ mod tests { validation: Validation, ) -> Vec> { let mut out = Vec::new(); - for registered in [true, false] { - for position_lost in [true, false] { + for pair in [ + PairStanding::Registered, + PairStanding::Pending, + PairStanding::Lost, + PairStanding::Misbodied, + ] { + { for storage_resolved in [true, false] { for parent in [ HELD, @@ -2448,9 +2465,8 @@ mod tests { ] { out.push(RouteFacts { external_commitment: E, - registered, + pair, conformance, - position_lost: position_lost && !registered, parent, parent_pre_root: PRE, validation, @@ -2508,7 +2524,7 @@ mod tests { ); assert_eq!( resolve_position(&facts), - resolve_refuted_in_hand(facts.registered), + resolve_refuted_in_hand(facts.pair), "{refuted:?} over {facts:?}" ); } @@ -2634,9 +2650,8 @@ mod tests { attempt, RouteFacts { external_commitment: OTHER_E, - registered: true, + pair: PairStanding::Registered, conformance: Valid, - position_lost: false, parent: HELD, parent_pre_root: PRE, validation: Invalid, diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs index e537779a7..62c1cf32b 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs @@ -44,6 +44,7 @@ use super::exercise::{ use super::facts::{ establish, establish_ground, refuted_in_hand, Established, EstablishedFacts, ExerciseReads, GroundFacts, GroundReads, InHandRefutation, LegReads, NotEstablished, ResolvedParent, + TraderAtParent, }; use super::lineage::{ advance_peer_resolved, AdvanceError, PeerResolvedAdvance, genesis_accepted, genesis_root, @@ -1825,30 +1826,52 @@ impl Verifier<'_, R> { /// admitted position when that is the claim `P` names, and otherwise the /// frontier-relative walk of the trader's lineage (DSM Amendment A8; SoFi /// Amendment S15). Whether what is held is what `P` names is the facts' - /// to decide (§6.62). `None` while the reads do not establish the lineage - /// at that position: the facts then report the parent unresolved, and - /// nothing is refuted. - fn parent_for(&self, exercise: &RecognizedExercise) -> Option { + /// to decide (§6.62). A lineage the walk establishes Invalid at or before + /// that position, or quarantines for a divergent write-once register + /// cell, holds nothing there, ever: the verdict reaches the facts as such + /// (SoFi Amendment S13, the same classes `validation::setup_lineage` + /// reads). `None` while the reads do not establish the lineage at that + /// position: the facts then report the parent unresolved, and nothing is + /// refuted. + fn parent_for(&self, exercise: &RecognizedExercise) -> Option { let precommit = &exercise.precommit().body; let position = precommit.position(); if let Some(own) = self.parent { if own.named == *precommit.parent_claim_ref() && own.economic_position == position { - return Some(own); + return Some(TraderAtParent::Held(own)); } } - match self - .reads - .trader_root_at(precommit.genesis(), precommit.device_id(), position) - { - Ok((root, named)) => held_at( - position, - root.economic_position(), - root.economic_root(), - named, - ), - // The reads do not establish the lineage at that position yet. - Err(..) => None, + trader_at_parent( + position, + self.reads + .trader_root_at(precommit.genesis(), precommit.device_id(), position), + ) + } +} + +/// What the walk of a trader's lineage to `position` establishes there +/// (DSM Amendment A8; SoFi Amendment S15): the claim it holds and its root; +/// or, when the walk establishes the lineage Invalid at or before `position` +/// — an Invalid step, or a divergent write-once register cell it +/// quarantines — that it holds nothing there, ever (SoFi Amendment S13, the +/// classes `validation::setup_lineage` reads). Evidence not in hand and a +/// position the walk cannot pass yet establish nothing. +fn trader_at_parent( + position: u64, + walked: Result<(ValidatedEconomicRoot, ParentClaimRef), PeerLineageFailure>, +) -> Option { + match walked { + Ok((root, named)) => held_at( + position, + root.economic_position(), + root.economic_root(), + named, + ) + .map(TraderAtParent::Held), + Err(PeerLineageFailure::Invalid(..) | PeerLineageFailure::Quarantined(..)) => { + Some(TraderAtParent::LineageInvalid) } + Err(PeerLineageFailure::Incomplete(..) | PeerLineageFailure::Unresolved(..)) => None, } } @@ -2107,6 +2130,55 @@ mod tests { assert_eq!(remembered(&memo, &other, [accepted.as_slice()]), None); } + /// Pre-audit 12f, SoFi Amendment S13: the walk's verdict reaches the + /// facts as what it is. A lineage the walk establishes Invalid at or + /// before `p`, or quarantines for a divergent register cell, holds + /// nothing at `p`, ever; evidence not in hand and a position the walk + /// cannot pass yet establish nothing; a walk that holds a claim at `p` + /// holds it there and nowhere else. On the old code every failure read as + /// no parent, so a key held on an invalid lineage waited forever. + #[test] + fn the_walks_verdict_on_a_traders_lineage_reaches_the_facts() { + use crate::economic::provenance::PeerLineageFailure as F; + for verdict in [ + F::Invalid("a step's witness does not fold".to_string()), + F::Quarantined("two claims hold the register cell".to_string()), + ] { + assert_eq!( + trader_at_parent(7, Err(verdict)), + Some(TraderAtParent::LineageInvalid) + ); + } + for pending in [ + F::Incomplete("the register cell is not decided yet".to_string()), + F::Unresolved("a conditional position has not resolved".to_string()), + ] { + assert_eq!(trader_at_parent(7, Err(pending)), None); + } + let named = ParentClaimRef::SingleRoot { + claim_ref: [0xC7; 32], + }; + let at = |position| { + ValidatedEconomicRoot::rehydrate_from_admitted_store( + crate::economic::lineage::AdmittedEconomicPosition::SingleRoot { + economic_position: position, + economic_root: [0x77; 32], + claim_ref: [0xC7; 32], + }, + ) + .expect("an ordinary admitted position") + }; + assert_eq!( + trader_at_parent(7, Ok((at(7), named))), + Some(TraderAtParent::Held(ResolvedParent { + economic_position: 7, + selected_root: [0x77; 32], + named, + })) + ); + assert_eq!(trader_at_parent(7, Ok((at(6), named))), None); + } + /// What a walk holds counts only at the position it reached: there it is /// the claim final at `K_root(p)` and its root, whichever kind of claim /// that is, for the facts to compare with what `P` names (§6.62); a walk diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_helpers.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_helpers.rs index 8ba0a2fa1..e7a212352 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_helpers.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_helpers.rs @@ -300,6 +300,7 @@ mod tests { processed: pulled, pushed: 0, errors: errors.iter().map(|s| s.to_string()).collect(), + more_pending: Vec::new(), } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_invoke_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_invoke_routes.rs index af88b3968..b410005e1 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_invoke_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_invoke_routes.rs @@ -5178,6 +5178,7 @@ mod tests { processed: pulled, pushed: 0, errors: Vec::new(), + more_pending: Vec::new(), } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_query_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_query_routes.rs index dd0c35ab3..839b64c9b 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_query_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bitcoin_query_routes.rs @@ -1208,6 +1208,7 @@ mod tests { processed: 0, pushed: 0, errors: Vec::new(), + more_pending: Vec::new(), })]); crate::sdk::bitcoin_tap_sdk::BitcoinTapSdk::set_dbtc_storage_list_results(vec![Err( "catalog unavailable".to_string(), @@ -1250,6 +1251,7 @@ mod tests { processed: 0, pushed: 0, errors: Vec::new(), + more_pending: Vec::new(), })]); let res = router diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs index 2576c968f..007d444a5 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs @@ -289,6 +289,350 @@ async fn an_inbox_read_that_did_not_cover_every_delivery_is_not_a_complete_sync( assert_eq!(count(), before + 1); } +/// `storage.sync` as the poller makes it, with `limit` as each route's budget. +async fn sync_with_budget(d: &TestDevice, limit: u32) -> generated::StorageSyncResponse { + d.enter(); + let params = generated::ArgPack { + codec: generated::Codec::Proto as i32, + body: generated::StorageSyncRequest { + limit, + ..crate::sdk::inbox_poller::poll_sync_request() + } + .encode_to_vec(), + schema_hash: None, + } + .encode_to_vec(); + let answered = d + .router() + .query(AppQuery { + path: "storage.sync".to_string(), + params, + }) + .await; + assert!( + answered.success, + "storage.sync: {:?}", + answered.error_message + ); + let env = crate::handlers::response_helpers::decode_local_envelope(&answered.data) + .expect("storage.sync answers an envelope"); + match env.payload { + Some(Payload::StorageSyncResponse(resp)) => resp, + other => panic!("storage.sync answered {other:?}"), + } +} + +/// A copy `from` composes under `message_id` with one invoke, `method` with +/// `body` (no request at all when `body` is `None`). +fn invoke_copy( + from: &TestDevice, + message_id: [u8; 16], + method: &str, + body: Option>, +) -> dsm::types::proto::Envelope { + use dsm::types::proto::{universal_op, Envelope, Headers, Invoke, UniversalOp, UniversalTx}; + Envelope { + version: 3, + headers: Some(Headers { + device_id: from.device_id.to_vec(), + genesis_hash: from.genesis.to_vec(), + }), + message_id: message_id.to_vec(), + payload: Some(Payload::UniversalTx(UniversalTx { + ops: vec![UniversalOp { + kind: Some(universal_op::Kind::Invoke(Invoke { + method: method.to_string(), + args: body.map(|body| generated::ArgPack { + codec: generated::Codec::Proto as i32, + body, + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }], + ..Default::default() + })), + } +} + +/// `inner`, sealed by `from` to `to` and spooled on `route` to its quorum. +/// Its copy key (`envelope_merge_key`), the key a sync passes it over by. +async fn spool_sealed( + from: &TestDevice, + to: &TestDevice, + route: &str, + inner: &dsm::types::proto::Envelope, +) -> String { + from.enter(); + let id = crate::util::text_id::encode_base32_crockford(&inner.message_id); + crate::sdk::b0x_sdk::seal_for(&to.device_id, &id, &inner.encode_to_vec()) + .expect("sealed to the recipient"); + let mut sdk = crate::sdk::b0x_sdk::B0xSDK::new( + crate::util::text_id::encode_base32_crockford(&from.device_id), + from.router().core_sdk.clone(), + crate::sdk::storage_set::pinned_endpoints().expect("the pinned set"), + ) + .expect("a spool client"); + sdk.submit_stored_envelope(route, &id) + .await + .expect("delivered to its quorum"); + crate::sdk::b0x_sdk::envelope_merge_key(inner) +} + +/// Junk `from` seals to `to` on `route`: a `wallet.send` that carries no +/// request, which `to` opens and can never take. Its copy key. +async fn deliver_junk(from: &TestDevice, to: &TestDevice, route: &str) -> String { + let inner = invoke_copy(from, rand::random(), "wallet.send", None); + spool_sealed(from, to, route, &inner).await +} + +/// How many of `copy_keys` `d` has passed over on `route`. +fn passed_over(d: &TestDevice, route: &str, copy_keys: &[String]) -> usize { + d.enter(); + copy_keys + .iter() + .filter(|key| { + crate::storage::client_db::b0x_consumed::is_passed_over(route, key) + .expect("the pass-over record") + }) + .count() +} + +/// B's route with `contact`, as B's sync reads it now. +fn route_with(b: &TestDevice, contact: &TestDevice) -> String { + b.enter(); + let contacts = crate::storage::client_db::get_all_contacts().expect("B's contacts"); + let record = contacts + .iter() + .find(|k| k.device_id == contact.device_id.to_vec()) + .expect("a contact of B's"); + let tip = crate::handlers::app_router_impl::contact_relationship_tip(record) + .expect("a relationship tip"); + crate::sdk::b0x_sdk::B0xSDK::compute_b0x_address(&b.genesis, &b.device_id, &tip) + .expect("the route's address") +} + +/// Pre-audit item 11, the owner's ruling (2026-10-01): each inbox route has +/// its own budget, a route whose budget runs out with entries left is +/// `more_pending` (a status, never a failure), junk classified terminally is +/// passed over so it is charged once, and repeated syncs work through a route +/// however much junk it holds. B's first route holds more junk than one +/// sync's budget; B's other contact pays B on the second route. +/// +/// - The payment lands in the first sync: junk on one route keeps no other +/// route unread (on the old code the global limit was spent on the junk and +/// the second route was never read, with the sync reported complete). +/// - That sync succeeds and names the first route `more_pending`. +/// - The next sync works through the rest; every junk entry is passed over, +/// so none is charged again. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_route_full_of_junk_keeps_no_other_route_unread_and_is_worked_through() { + let p = Pair::boot(100, 0).await; + let mut c = TestDevice::create("C", 0x0C); + c.boot(&p.fleet).await; + c.add_contact(&p.b).await; + p.b.add_contact(&c).await; + c.fund_admitted(100).await; + + // B's routes, in the order a sync reads them (sorted by address): one per + // contact. The junk goes on the route read first, and the contact on the + // other route pays, so the old global budget is spent before the payment + // is reached. + p.b.enter(); + let contacts = crate::storage::client_db::get_all_contacts().expect("B's contacts"); + let routes = crate::handlers::app_router_impl::collect_tagged_inbox_addresses( + p.b.genesis, + p.b.device_id, + &contacts, + ) + .expect("B's routes"); + assert_eq!(routes.len(), 2, "one route per contact"); + let junked = routes[0].address.clone(); + let first_read = contacts + .iter() + .find(|k| { + let tip = crate::handlers::app_router_impl::contact_relationship_tip(k) + .expect("a relationship tip"); + crate::sdk::b0x_sdk::B0xSDK::compute_b0x_address(&p.b.genesis, &p.b.device_id, &tip) + .expect("the route's address") + == junked + }) + .expect("the route read first is a contact's"); + let (junker, payer) = if first_read.device_id == p.a.device_id.to_vec() { + (&p.a, &c) + } else { + (&c, &p.a) + }; + + let budget = 3; + let mut junk = Vec::new(); + for _ in 0..budget + 2 { + junk.push(deliver_junk(junker, &p.b, &junked).await); + } + let paid = payer.send(&p.b, 10).await; + assert!(paid.success, "{:?}", paid.error_message); + + let first = sync_with_budget(&p.b, budget).await; + assert!(first.success, "{:?}", first.errors); + assert_eq!( + p.b.era_balance(), + 10, + "the payment on the other route landed" + ); + assert_eq!( + first.more_pending.len(), + 1, + "the junked route is more_pending: {:?}", + first.more_pending + ); + assert_eq!( + passed_over(&p.b, &junked, &junk), + budget as usize, + "one budget's worth, passed over" + ); + + let second = sync_with_budget(&p.b, budget).await; + assert!(second.success, "{:?}", second.errors); + assert!(second.more_pending.is_empty(), "{:?}", second.more_pending); + assert_eq!( + passed_over(&p.b, &junked, &junk), + junk.len(), + "the rest of the junk, passed over" + ); +} + +/// Pre-audit item 11: a copy is passed over by its content, never by the id it +/// is spooled under. Anyone can spool a copy under another message's id, and +/// the node keeps both (storage spec §8). Passing junk over by id would hide +/// every copy under that id for good, an honest one included, which is the +/// shadowing `envelope_merge_key` closed for the merge. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_copy_passed_over_hides_no_other_copy_under_its_id() { + let p = Pair::boot(100, 0).await; + // C, a third party: C holds B's keys to seal to; B does not know C. + let mut c = TestDevice::create("C", 0x0C); + c.boot(&p.fleet).await; + c.add_contact(&p.b).await; + let route = route_with(&p.b, &p.a); + let id: [u8; 16] = rand::random(); + + let junk = spool_sealed( + &p.a, + &p.b, + &route, + &invoke_copy(&p.a, id, "wallet.send", None), + ) + .await; + let first = sync_with_budget(&p.b, 10).await; + assert!(first.success, "{:?}", first.errors); + assert_eq!(first.pulled, 1, "the junk was read: {first:?}"); + + // Another copy under the same id, other content, from another device. + let other = spool_sealed( + &c, + &p.b, + &route, + &invoke_copy(&c, id, "wallet.send", Some(vec![0xFF])), + ) + .await; + assert_ne!(other, junk, "two copies, one id"); + let second = sync_with_budget(&p.b, 10).await; + assert!(second.success, "{:?}", second.errors); + assert_eq!( + second.pulled, 1, + "the other copy under the id was read, not hidden by the first: {second:?}" + ); + assert_eq!( + passed_over(&p.b, &route, &[junk, other]), + 2, + "each copy passed over by its own content" + ); +} + +/// Pre-audit item 11: a sync's read resumes where the last one stopped. A copy +/// that is never consumed (here a countersign whose body is not one, which +/// the sender's path refuses and leaves) holds the read position where it is. +/// On the old code every read started there and read at most its page cap, so +/// what lay more than a cap behind it was never read, junk passed over or not. +/// A third party puts one such copy on B's route with A, a cap's worth of +/// copies that open to none of the spooled payloads behind it, and A then +/// pays B. +/// +/// - The first sync stops at the cap before the payment, names the route +/// `more_pending`, and passes over the junk it read. +/// - The second resumes where the first stopped, and the payment lands. +/// - A route that is pending while nothing was taken from it does not hurry +/// the poller (`inbox_poller::enters_eager_mode`). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_copy_that_waits_hides_nothing_more_than_a_page_cap_behind_it() { + let p = Pair::boot(100, 0).await; + // C, a third party: C holds B's keys to seal to; B does not know C. + let mut c = TestDevice::create("C", 0x0C); + c.boot(&p.fleet).await; + c.add_contact(&p.b).await; + let route = route_with(&p.b, &p.a); + + let pin = invoke_copy( + &c, + rand::random(), + crate::sdk::b0x_sdk::RECEIPT_COUNTERSIGN_B_METHOD, + Some(vec![0xFF]), + ); + spool_sealed(&c, &p.b, &route, &pin).await; + // One page past the cap (64 entries a page, 16 pages a read). + let cap = 16 * 64; + let mut junk = Vec::with_capacity(cap + 64); + for _ in 0..cap + 64 { + let unknown = invoke_copy(&c, rand::random(), "no.such.payload", None); + junk.push(spool_sealed(&c, &p.b, &route, &unknown).await); + } + let paid = p.a.send(&p.b, 10).await; + assert!(paid.success, "{:?}", paid.error_message); + + let first = sync_with_budget(&p.b, 10).await; + assert!(first.success, "{:?}", first.errors); + assert_eq!(p.b.era_balance(), 0, "the first read stops at its cap"); + assert_eq!(first.more_pending.len(), 1, "{:?}", first.more_pending); + let read = passed_over(&p.b, &route, &junk); + assert!( + read > 0 && read < junk.len(), + "the junk the first read reached is passed over: {read} of {}", + junk.len() + ); + + let second = sync_with_budget(&p.b, 10).await; + assert!(second.success, "{:?}", second.errors); + assert_eq!( + p.b.era_balance(), + 10, + "the next read resumed and the payment landed" + ); + assert_eq!( + passed_over(&p.b, &route, &junk), + junk.len(), + "all the junk, passed over" + ); + + // Back at the waiting copy, the read goes over what it passed over; the + // route is pending, nothing is taken, and the poller is not hurried. + let lap = sync_with_budget(&p.b, 10).await; + assert!(lap.success, "{:?}", lap.errors); + assert_eq!(lap.pulled, 0, "nothing taken: {lap:?}"); + assert_eq!(lap.more_pending.len(), 1, "{:?}", lap.more_pending); + assert!( + !crate::sdk::inbox_poller::enters_eager_mode( + lap.processed, + lap.pulled, + lap.more_pending.len() + ), + "a pending route from which nothing was taken leaves the poller at its cadence" + ); +} + /// SoFi §51 (`ReleaseRule::AllAtCreation`): creating a token puts its whole /// genesis supply in the creator's balance, in the creating transition. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -1589,6 +1933,168 @@ async fn a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair( assert_eq!(held_at(pair.root().routed()).await, root_before); } +/// SoFi Amendment S20 (12j), Amendment S15: a position whose root cell +/// holds a claim naming its fulfillment but with another body than the one +/// its `P` and `F` derive resolves Invalid for the trader's lineage, read by +/// any peer walking that lineage, and the vault agrees: the key the +/// exercise holds is skipped, never consumed. +/// +/// B trades with its pair's leader refusing the pair, then writes its pair +/// itself: `F` at `K_ful(q)` and, at `K_root(q)`, a claim B signs naming `F` +/// with another `R_realize`. The pair registers, being matched by `F`'s id; +/// only the body tells it apart. A, walking B's lineage to `q` (DSM +/// Amendment A8), gets Invalid, with the body check's own reason. B's own +/// device resolves `q` Invalid too (pre-audit 12k, the owner's 2026-10-01 +/// ruling: "same bytes => same terminal verdict for local and peer +/// resolution"); before 12k it answered `RetriesExhausted` for good. B's +/// exercise then holds the vault's first key, and A's walk of the vault +/// passes over it. Mutations: the body check removed from `peer_position`; +/// the misbodied pair read as merely lost by the trader's own ladder. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_root_cell_naming_the_fulfillment_with_another_body_is_invalid_for_the_lineage() { + let p = Pair::boot(500, 200).await; + let m = open_market(&p).await; + let set = canonical_set(NETWORK).expect("the pinned set"); + let exhausted = generated::SofiPositionState::RetriesExhausted as i32; + let invalid = generated::SofiPositionState::Invalid as i32; + + let q = admitted_position(&p.b) + 1; + let (.., root) = { + p.b.enter(); + economic_lineage::get_admitted_coordinate() + .expect("read admitted") + .expect("an admitted position") + }; + let pair = position_cells(&set, &p.b.genesis, &p.b.device_id, q, &root) + .expect("B's next position pair"); + let pair_leader = member_name(pair.fulfillment().route().leader()); + p.nodes + .refuse_cell_writes( + &pair_leader, + &[*pair.fulfillment().key(), *pair.root().routed().key()], + ) + .await; + let r = invoke(&p.b, "sofi.trade", args(&trade_request(&p, &m, 10))).await; + assert_eq!(position_of(&r, "sofi.trade"), (q, exhausted)); + p.nodes.accept_cell_writes(&pair_leader).await; + + // B writes its pair with a claim naming its F under another body. + p.b.enter(); + let head = p.b.router().core_sdk.device_head().expect("B's head"); + let fulfillment_id = match head + .pending_economic_admission() + .map(|pending| pending.kind) + { + Some(dsm::economic::admission::PendingAdmissionKind::SofiFulfillment { + fulfillment_id, + }) => fulfillment_id, + other => panic!("B's pending admission is its fulfillment: {other:?}"), + }; + let fulfillment = match crate::sdk::sofi_publish::fetch_fulfillment(&set, &fulfillment_id) + .await + .expect("read") + { + Resolved::Kept(signed) => signed, + other => panic!("F is stored under its id: {other:?}"), + }; + let precommit = match fetch_precommit(&set, fulfillment.body.precommit_id()) + .await + .expect("read") + { + Resolved::Kept(signed) => signed, + other => panic!("P is stored under its id: {other:?}"), + }; + let derived = derive::resolution_claim(&precommit.body, &fulfillment.body); + let other_body = dsm::sofi::wire::SofiResolutionClaim { + realize_root: [0x5E; 32], + ..derived + }; + assert_eq!(other_body.fulfillment_id, fulfillment_id); + assert_ne!(other_body, derived); + let claim = { + let (pk, sk) = crate::sdk::signing_authority::current_keypair().expect("B's AK"); + let att_a = crate::sdk::signing_authority::current_att_a().expect("B's AttA"); + dsm::sofi::signature::sign_resolution_claim( + other_body, + fulfillment.body.signature_alg(), + &pk, + att_a, + &sk, + ) + .expect("B signs its own claim") + .encode() + }; + let f_bytes = Publication::Fulfillment { + body: &fulfillment.body, + signature: &fulfillment.signature, + } + .object_bytes() + .expect("F's envelope"); + let written = crate::sdk::route_seats::write_recorded_position(&set, &pair, &f_bytes, &claim) + .await + .expect("the pair is written along its route"); + assert!(written.iter().all(|report| report.reached_leader())); + + // The pair registers: it is matched by F's id. + p.a.enter(); + let (own_a, parents_a) = standing_of(&p.a); + let ctx = VerifierContext::new(&set, Some(own_a), parents_a.as_ref()).expect("a verifier"); + let registration = ctx + .verifier() + .read_registration(&p.b.genesis, &p.b.device_id, q, &root) + .expect("read") + .expect("decided"); + assert!( + matches!(registration.registration(), Registration::Registered(signed) if signed.body == fulfillment.body), + "{:?}", + registration.registration() + ); + + // A peer walking B's lineage to q: Invalid, by the body check. + let live = crate::sdk::sofi_reads::LiveSofiReads::new(&set, None).expect("live reads"); + let walked = + dsm::sofi::resolve::SofiReads::trader_root_at(&live, &p.b.genesis, &p.b.device_id, q); + assert!( + matches!( + &walked, + Err(dsm::economic::provenance::PeerLineageFailure::Invalid(why)) + if why.contains("not the claim its P and F derive") + ), + "{walked:?}" + ); + + // B's own resolution agrees with every peer's: Invalid. + assert!(matches!(complete(&p).await, Completion::Written(..))); + assert_eq!( + resolve(&p).await, + (q, invalid), + "B's own device reads the same verdict its peers do" + ); + + // B's exercise holds the vault's first key, and the vault passes over it: + // its F is lost at q (Amendment S14), so it consumes nothing. + p.a.enter(); + let ctx = VerifierContext::new(&set, Some(own_a), parents_a.as_ref()).expect("a verifier"); + let verifier = ctx.verifier(); + let chain = verifier.chain(&m.vault_id).expect("the vault's chain"); + let r0 = chain.roots()[0]; + assert!( + verifier + .read_attempt_cell(&m.vault_id, &r0, 0) + .expect("read") + .expect("decided") + .exercise() + .is_some(), + "B's exercise holds the vault's first key" + ); + let chains = BTreeMap::from([(m.vault_id, chain)]); + let walked = verifier + .walk_parent(&chains, &m.vault_id, &r0, 0, WALK_BUDGET) + .expect("the walk"); + assert_eq!(walked.outcome, WalkOutcome::Unresolved { attempt: 1 }); +} + /// A fulfillment B signs for position `q`, naming `precommit_id`, as the /// signed envelope a seat would hold at `K_ful(q)`. B's own key, so it passes /// any check of who may sign at B's cells; what it names is up to whoever 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 f12a0d72e..ba9d9116f 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 @@ -1871,6 +1871,7 @@ impl AppRouterImpl { processed: report.processed, pushed: report.pushed, errors: report.errors, + more_pending: report.more_pending, }, Err(failure) => generated::StorageSyncResponse { success: false, @@ -1878,6 +1879,7 @@ impl AppRouterImpl { processed: failure.report.processed, pushed: failure.report.pushed, errors: failure.report.errors, + more_pending: failure.report.more_pending, }, }; pack_envelope_ok(generated::envelope::Payload::StorageSyncResponse(response)) @@ -1889,9 +1891,9 @@ impl AppRouterImpl { } /// `StorageSyncRequest.limit` when the request leaves it 0 (the wire -/// contract's default). +/// contract's default): the most entries one sync reads from each route. const STORAGE_SYNC_DEFAULT_LIMIT: usize = 100; -/// The most inbox items one sync pulls. +/// The largest per-route budget a request may ask for. const STORAGE_SYNC_MAX_LIMIT: u32 = 200; /// A decoded `storage.sync` request. @@ -1937,6 +1939,10 @@ struct StorageSyncReport { /// route no member answered for, or one read from too few members. What /// was read is processed; the run is not complete (storage spec §4). inbox_incomplete: Option, + /// The routes holding more than this sync took: a read that stopped at + /// its page cap, or more entries than the route's budget. A status, never + /// an error; the next sync resumes each where it stopped. + more_pending: Vec, } /// A sync that could not run to its end, with what it did before it stopped. @@ -1962,15 +1968,41 @@ enum StaleOrIngested { Stale(crate::handlers::recipient_dispatch::StaleCopy), } +/// What a sync records about the copies it read, by route: copies of decided +/// objects consumed by the id the spool holds them by, and copies that never +/// will be anything this device can take passed over by their content. +#[derive(Default)] +struct CopiesTaken { + consume: std::collections::BTreeMap>, + pass_over: std::collections::BTreeMap>, +} + +impl CopiesTaken { + fn consume(&mut self, address: &str, message_id: &str) { + self.consume + .entry(address.to_string()) + .or_default() + .push(message_id.to_string()); + } + + fn pass_over(&mut self, address: &str, copy_key: &str) { + self.pass_over + .entry(address.to_string()) + .or_default() + .push(copy_key.to_string()); + } +} + /// Act on what the boundary made of one copy read at `address` under -/// `message_id`. A copy of an object the canonical apply has decided is -/// consumed, under the id the spool holds it by; a copy that is not recognized -/// is left unconsumed and recorded nowhere. +/// `message_id`, whose content key is `copy_key`. A copy of an object the +/// canonical apply has decided is consumed, under the id the spool holds it +/// by; a copy that is not recognized is passed over by its content. fn take_in_copy( copy: Result, address: &str, message_id: &str, - consume_now: &mut std::collections::BTreeMap>, + copy_key: &str, + taken: &mut CopiesTaken, report: &mut StorageSyncReport, ) { use crate::handlers::recipient_dispatch::{Ingested, StaleCopy}; @@ -2012,16 +2044,23 @@ fn take_in_copy( }; match ingested { Ingested::Staged => {} - Ingested::Decided => consume_now - .entry(address.to_string()) - .or_default() - .push(message_id.to_string()), - // Recorded nowhere: a copy that is not a transfer or receipt this - // device can take stays on the spool as raw material (Amendment A1). - Ingested::NotRecognized(why) => log::info!( - "[storage.sync] copy {message_id} on {}.. is not recognized: {why}", - short_route(address) - ), + Ingested::Decided => taken.consume(address, message_id), + // Not being recognized is a fact about the copy's own bytes against + // the keys this device pins for the contact; what this device cannot + // decide yet is an `Err` or a staged copy, never this. So the copy + // never will be taken here, and it is passed over (owner ruling, + // 2026-10-01, pre-audit item 11: junk classified terminally is not + // charged forever). By its content, never its id: anyone can spool a + // copy under an honest message's id, and the honest copy is still + // read. The spool keeps it as raw material (Amendment A1); nothing is + // recorded about it beyond this device's read. + Ingested::NotRecognized(why) => { + log::info!( + "[storage.sync] copy {message_id} on {}.. is not recognized: {why}", + short_route(address) + ); + taken.pass_over(address, copy_key); + } } } @@ -2048,6 +2087,7 @@ impl AppRouterImpl { pushed: 0, errors: Vec::new(), inbox_incomplete: None, + more_pending: Vec::new(), }; let storage_endpoints = match crate::sdk::storage_set::pinned_endpoints() { Ok(endpoints) => endpoints, @@ -2074,6 +2114,18 @@ impl AppRouterImpl { /// Pull every rotated inbox route, process what arrived, and drive the /// durable completion passes that do not depend on this pull. + /// + /// `limit` is each route's budget (owner ruling, 2026-10-01, pre-audit + /// item 11): a route reads at most `limit` entries per sync, and one + /// whose budget runs out with entries left is reported `more_pending`, a + /// status and not an error; the next sync resumes it. No route can spend + /// another's budget, so junk on one route never keeps another unread. A + /// read resumes where the last one stopped (`retrieve_resuming`), so + /// repeated syncs work through a route however much waits on it. A copy + /// classified terminally (one that opens to none of the spooled payloads, + /// a request that is not what its method names, or a copy this device can + /// never take) is passed over by its content, so it is charged once and + /// never again, and an honest copy spooled under the same id is still read. async fn pull_and_process_inbox( &self, mut report: StorageSyncReport, @@ -2109,17 +2161,15 @@ impl AppRouterImpl { // Copies whose object the canonical apply has already decided, by the // address and message id they were read under: consumed directly. - let mut consume_now: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); + // Copies that never will be anything this device can take, by their + // content: passed over. + let mut taken = CopiesTaken::default(); let mut pulled = 0usize; // Routes read to full coverage, routes read partially, and routes no // member answered for. Only the first kind says "nothing more there". let (mut routes_read, mut routes_partial, mut routes_unread) = (0usize, 0usize, 0usize); for tagged in tagged_addresses { - if pulled >= limit { - break; - } - let retrieved = b0x_sdk.retrieve_from_b0x_v2(&tagged.address).await; + let retrieved = b0x_sdk.retrieve_resuming(&tagged.address).await; // Replies ride the same spool as forward transfers, as distinct // payloads; they are drained whether or not this route yielded // transfers, since a reply alone can release a pending gate. @@ -2152,7 +2202,8 @@ impl AppRouterImpl { copy, &tagged.address, &artifact.message_id, - &mut consume_now, + &artifact.copy_key, + &mut taken, &mut report, ); } @@ -2170,7 +2221,7 @@ impl AppRouterImpl { self.initiate_required_cert_resyncs(storage_endpoints, &mut report) .await; - let polled = match retrieved { + let (polled, read_on) = match retrieved { Ok(outcome) => { if let crate::sdk::b0x_sdk::SpoolCoverage::Partial { responded, needed } = outcome.coverage @@ -2185,7 +2236,12 @@ impl AppRouterImpl { } else { routes_read += 1; } - outcome.entries + // Opened, and none of the payloads a device spools: never + // will be. + for copy_key in &outcome.unknown { + taken.pass_over(&tagged.address, copy_key); + } + (outcome.entries, outcome.more) } Err(e) => { routes_unread += 1; @@ -2196,13 +2252,23 @@ impl AppRouterImpl { continue; } }; - let remaining = limit - pulled; - for entry in polled.into_iter().take(remaining) { + // More on this route than this sync takes: a read that stopped at + // its page cap, or more entries than the route's budget. The next + // sync resumes it. + if read_on || polled.len() > limit { + report + .more_pending + .push(format!("{}..", short_route(&tagged.address))); + } + for entry in polled.into_iter().take(limit) { pulled += 1; // Only transfers are the transfer pipeline's business; the // boundary reads every value-bearing field from the signed - // operation it verifies itself. + // operation it verifies itself. A request whose body is not + // what its method names never will be: passed over by its + // content, so it is not charged to this route's budget again. if entry.kind != crate::sdk::b0x_sdk::B0xEntryKind::Transfer { + taken.pass_over(&tagged.address, &entry.copy_key); continue; } let copy = if stale { @@ -2224,14 +2290,17 @@ impl AppRouterImpl { copy, &entry.inbox_key, &entry.transaction_id, - &mut consume_now, + &entry.copy_key, + &mut taken, &mut report, ); } } - // At most `limit`, which the request bounds by STORAGE_SYNC_MAX_LIMIT. + // At most `limit` per route, which the request bounds by + // STORAGE_SYNC_MAX_LIMIT, over the few routes this device's contacts + // give it: far below u32::MAX. report.pulled = pulled as u32; - for (route, ids) in consume_now { + for (route, ids) in taken.consume { if let Err(e) = b0x_sdk.record_consumed_b0x(&route, ids).await { report.errors.push(format!( "consume decided copies on {}..: {e}", @@ -2239,6 +2308,16 @@ impl AppRouterImpl { )); } } + for (route, copy_keys) in taken.pass_over { + if let Err(e) = + crate::storage::client_db::b0x_consumed::record_passed_over(&route, ©_keys) + { + report.errors.push(format!( + "pass over unrecognized copies on {}..: {e}", + short_route(&route) + )); + } + } self.complete_split_transfers(&mut b0x_sdk, &mut report) .await; 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 0866d5f06..66e77f2da 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 @@ -264,6 +264,35 @@ pub struct RetrievalOutcome { pub responded: usize, pub members: usize, pub coverage: SpoolCoverage, + /// The copies that opened for this device and are none of the payloads a + /// device spools (a transfer, its evidence, a countersign, a finality + /// certificate, a cert resync), by `envelope_merge_key`. They never will + /// be; the consumer may pass them over. + pub unknown: Vec, + /// Whether some member's read stopped at its page cap rather than at the + /// end of its spool: there is more on this route than this read reached. + pub more: bool, +} + +/// Where a read of one member's spool starts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReadFrom { + /// The read position, below which everything is consumed. A read from it + /// leaves no cursor: a preview reads here and moves nothing a sync reads. + Position, + /// Where `storage.sync`'s previous read of this member stopped at its page + /// cap, or the read position when it did not. The read leaves its own + /// cursor for the next (pre-audit item 11). + Resume, +} + +/// Where one member's read stopped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReadStop { + /// At the end of the member's spool: nothing more there. + End, + /// At the page cap: the spool goes on past where this read stopped. + PageCap, } /// What kind of request a polled entry carries. Nothing about its content is @@ -295,13 +324,18 @@ pub struct B0xEntry { /// boundary verifies SIG A over the canonical operation inside them. Empty /// for a message. pub transfer_wire_bytes: Vec, + /// This copy's `envelope_merge_key`: its message id and content digest. + /// A copy passed over is recorded under it, never under the id alone. + pub copy_key: String, } /// One A-side evidence artifact, with the message id the spool holds it by — -/// the id its consumed marker is written with. +/// the id its consumed marker is written with — and its `envelope_merge_key`, +/// the key it is passed over by. #[derive(Debug, Clone)] pub struct EvidenceArtifact { pub message_id: String, + pub copy_key: String, pub evidence: dsm::types::proto::ReceiptEvidenceA, } @@ -2118,9 +2152,32 @@ impl B0xSDK { /// ([`SpoolCoverage`]): a read that did not reach enough members to meet /// every delivery is partial, and no caller may take it for "nothing /// more". + /// + /// Each member is read from the read position and no cursor is left: a + /// preview (`inbox.pull`) moves nothing the next sync reads. pub async fn retrieve_from_b0x_v2( &mut self, b0x_address: &str, + ) -> Result { + self.retrieve_from(b0x_address, ReadFrom::Position).await + } + + /// [`Self::retrieve_from_b0x_v2`] for `storage.sync`: each member's read + /// resumes where the previous one stopped at its page cap, and leaves its + /// own cursor for the next; a read that reaches the end of a member's + /// spool sends the next back to the read position. Repeated syncs work + /// through a route however much waits on it (pre-audit item 11). + pub async fn retrieve_resuming( + &mut self, + b0x_address: &str, + ) -> Result { + self.retrieve_from(b0x_address, ReadFrom::Resume).await + } + + async fn retrieve_from( + &mut self, + b0x_address: &str, + from: ReadFrom, ) -> Result { use crate::storage::client_db::b0x_consumed; let local = |e: anyhow::Error| { @@ -2151,22 +2208,42 @@ impl B0xSDK { // This device's key, once for the whole read: without it nothing can // be opened, which is this device's state and not the envelopes'. let kyber_secret = local_kyber_secret()?; - let mut map: HashMap = HashMap::new(); + // Each distinct copy once, by `envelope_merge_key`, in the order the + // spools hold them: a consumer that takes only some takes the oldest, + // and nothing spooled later gets ahead of them. + let mut copies: Vec<(String, dsm::types::proto::Envelope)> = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); // Members whose spool was read to an answer. None is not an empty // inbox: nothing was read (storage spec §4). let mut answered = 0usize; + // Members whose read answered and stopped at its page cap. + let mut capped_members = 0usize; for epc in endpoints { // `position`: below it, everything on this node is consumed. // `cursor`: where the next page is read from. A message still // waiting (one half of a pair) stops `position`, never `cursor`, // so nothing behind it is held up. let mut position = b0x_consumed::read_position(b0x_address, &epc).map_err(local)?; - let mut cursor = position; - let mut consumed_run = true; + let resumed_at = match from { + ReadFrom::Position => None, + ReadFrom::Resume => b0x_consumed::scan_cursor(b0x_address, &epc) + .map_err(local)? + .filter(|resume| *resume > position), + }; + let mut cursor = match resumed_at { + Some(resume) => resume, + None => position, + }; + // Only a read that starts at the position can move it: one that + // resumed past it has not read what lies between. + let mut consumed_run = resumed_at.is_none(); let mut failed = false; // Why this node's answer is not a spool's, when it is not: // nothing past it is read or advanced by it. let mut malformed: Option = None; + // Where the read stopped: at the page cap unless a page ends the + // spool first. + let mut stop = ReadStop::PageCap; let mut pages = 0; 'pages: while pages < Self::MAX_RETRIEVE_PAGES_PER_NODE { pages += 1; @@ -2179,7 +2256,10 @@ impl B0xSDK { .send() .await; let batch = match resp { - Ok(r) if r.status() == reqwest::StatusCode::NO_CONTENT => break, + Ok(r) if r.status() == reqwest::StatusCode::NO_CONTENT => { + stop = ReadStop::End; + break; + } Ok(r) if r.status().is_success() => { let bytes = match r.bytes().await { Ok(b) => b, @@ -2210,6 +2290,7 @@ impl B0xSDK { } }; if batch.envelopes.is_empty() || batch.next_seq <= cursor { + stop = ReadStop::End; break; } if batch.next_seq > Self::MAX_SPOOL_POSITION { @@ -2257,8 +2338,22 @@ impl B0xSDK { // envelope: it holds up nothing behind it. match open_sealed(&kyber_secret, &env) { Ok(inner) => { + // A copy this device passed over is not read + // again: by its content, so another copy under + // the same id still is. + let copy_key = envelope_merge_key(&inner); + if b0x_consumed::is_passed_over(b0x_address, ©_key) + .map_err(local)? + { + if consumed_run { + position = position.max(after); + } + continue; + } consumed_run = false; - map.entry(envelope_merge_key(&inner)).or_insert(inner); + if seen.insert(copy_key.clone()) { + copies.push((copy_key, inner)); + } } Err(e) => { warn!("b0x envelope {} does not open: {}", id, e); @@ -2271,6 +2366,17 @@ impl B0xSDK { cursor = batch.next_seq; } b0x_consumed::advance_read_position(b0x_address, &epc, position).map_err(local)?; + // A read that failed leaves the cursor where it was: what it did + // not reach is read from there again. + let read_answered = !failed && malformed.is_none(); + let capped = read_answered && stop == ReadStop::PageCap; + if from == ReadFrom::Resume && read_answered { + b0x_consumed::set_scan_cursor(b0x_address, &epc, capped.then_some(cursor)) + .map_err(local)?; + } + if capped { + capped_members += 1; + } if let Some(why) = &malformed { warn!("b0x retrieve from {}: {}", epc, why); } @@ -2291,7 +2397,8 @@ impl B0xSDK { let coverage = SpoolCoverage::of(answered, members, self.quorum_k); let mut entries = Vec::new(); - for env in map.into_values() { + let mut unknown = Vec::new(); + for (copy_key, env) in copies { // §16.6 reply window: an acceptance artifact is NOT a forward transfer and // has no B0xEntry shape. It is discriminated by the EXPLICIT invoke method // (never a trial-decode) and buffered for the sender-finalization path; @@ -2340,13 +2447,20 @@ impl B0xSDK { ); self.pending_evidence_artifacts.push(EvidenceArtifact { message_id, + copy_key, evidence, }); continue; } - if let Some(mut e) = self.envelope_to_b0x_entry(env) { - e.inbox_key = b0x_address.to_string(); - entries.push(e); + match self.envelope_to_b0x_entry(env, ©_key) { + Some(mut e) => { + e.inbox_key = b0x_address.to_string(); + entries.push(e); + } + None => { + info!("📬 copy {copy_key} on {b0x_address} is none of the spooled payloads"); + unknown.push(copy_key); + } } } info!( @@ -2358,6 +2472,8 @@ impl B0xSDK { responded: answered, members, coverage, + unknown, + more: capped_members > 0, }) } @@ -2389,7 +2505,11 @@ impl B0xSDK { // Helpers // ------------------------------------------------------------------------ - fn envelope_to_b0x_entry(&self, env: dsm::types::proto::Envelope) -> Option { + fn envelope_to_b0x_entry( + &self, + env: dsm::types::proto::Envelope, + copy_key: &str, + ) -> Option { let tid = text_id::encode_base32_crockford(&env.message_id); let sender_dev = match &env.headers { Some(h) => crate::util::text_id::encode_base32_crockford(&h.device_id), @@ -2418,6 +2538,7 @@ impl B0xSDK { reason: "the wallet.send invoke carries no request".to_string(), }, transfer_wire_bytes: Vec::new(), + copy_key: copy_key.to_string(), }); }; // The request bytes, exactly as they arrived. Nothing in them is @@ -2430,6 +2551,7 @@ impl B0xSDK { sender_device_id: sender_dev, kind: B0xEntryKind::Transfer, transfer_wire_bytes: arg_pack.body.clone(), + copy_key: copy_key.to_string(), }); } None @@ -2812,9 +2934,11 @@ mod tests { p.fleet.endpoints(), ) .expect("B's spool client"); + let copy_key = envelope_merge_key(&inner); let entry = sdk - .envelope_to_b0x_entry(inner.clone()) + .envelope_to_b0x_entry(inner.clone(), ©_key) .expect("a transfer entry"); + assert_eq!(entry.copy_key, copy_key, "the entry carries its copy's key"); assert_eq!(entry.kind, B0xEntryKind::Transfer); assert_eq!(entry.transaction_id, one.message_id); assert_eq!( @@ -2854,7 +2978,10 @@ mod tests { }; invoke.args = None; } - let entry = sdk.envelope_to_b0x_entry(stripped).expect("still listed"); + let copy_key = envelope_merge_key(&stripped); + let entry = sdk + .envelope_to_b0x_entry(stripped, ©_key) + .expect("still listed"); assert!( matches!(&entry.kind, B0xEntryKind::Unrecognized { reason } if reason.contains("carries no request")), "{:?}", @@ -3723,7 +3850,9 @@ mod tests { /// nothing else: no trial decode, no size heuristic. An evidence half and a /// legacy full-receipt reply on the retired `wallet.acceptanceReceipt` /// method are both `None` here, and the legacy one is not a transfer either - /// — it matches no discriminator and is dropped, never consumed. + /// — it matches no discriminator. The read lists it among the copies of no + /// spooled payload, which a sync passes over by its content; no id is ever + /// consumed for it. #[test] #[serial_test::serial] fn retrieve_discriminates_a_countersign_delta_by_its_method_only() { @@ -3740,9 +3869,10 @@ mod tests { assert!(B0xSDK::decode_receipt_evidence_a(&legacy).is_none()); let (device_b32, core, fleet) = test_device(); let sdk = B0xSDK::new(device_b32, core, fleet.endpoints()).expect("B0xSDK"); + let copy_key = envelope_merge_key(&legacy); assert!( - sdk.envelope_to_b0x_entry(legacy).is_none(), - "a legacy full-receipt reply is not a transfer and must be dropped, not consumed" + sdk.envelope_to_b0x_entry(legacy, ©_key).is_none(), + "a legacy full-receipt reply is not a transfer" ); } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/inbox_poller.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/inbox_poller.rs index a0543b580..5de43ce8f 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/inbox_poller.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/inbox_poller.rs @@ -150,7 +150,7 @@ pub fn start_poller() { break; } - let (processed, _pulled) = run_inbox_sync_cycle_counted("poll").await; + let (processed, pulled, more_pending) = run_inbox_sync_cycle_counted("poll").await; #[cfg(test)] POLLER_CYCLE_DONE.notify_waiters(); // Settlement-urgent covers BOTH directions: the sender awaiting an @@ -168,9 +168,7 @@ pub fn start_poller() { } }; - // Enter eager mode when items are processed, so follow-up - // messages (ACKs, rapid exchanges) are discovered faster. - if processed > 0 { + if enters_eager_mode(processed, pulled, more_pending) { eager_remaining = EAGER_POLL_CYCLES; log::info!( "[inbox_poller] Entering eager mode ({} cycles at {}ms)", @@ -273,8 +271,19 @@ pub fn resume_poller() { } } +/// Whether a cycle sends the poller into eager mode. It processed something, +/// so follow-up messages (ACKs, rapid exchanges) are found faster; or a route +/// holds more than the sync took and the sync took entries, so a backlog +/// drains soon (pre-audit item 11). A route still pending when nothing was +/// taken (a read that went over copies already passed over, behind one that +/// waits) is left to the normal cadence: junk cannot hold the poller at the +/// eager rate. +pub(crate) fn enters_eager_mode(processed: u32, pulled: u32, routes_pending: usize) -> bool { + processed > 0 || (routes_pending > 0 && pulled > 0) +} + /// The `storage.sync` request every poll makes: pull the inbox, push what is -/// owed, 50 items. +/// owed, at most 50 entries from each route. pub(crate) fn poll_sync_request() -> generated::StorageSyncRequest { generated::StorageSyncRequest { pull_inbox: true, @@ -285,13 +294,14 @@ pub(crate) fn poll_sync_request() -> generated::StorageSyncRequest { /// Run one sync cycle: call `storage.sync` through the app router, /// then push `inbox.updated` to the WebView if items were processed. -/// Returns (processed, pulled) counts for adaptive polling. -async fn run_inbox_sync_cycle_counted(source: &str) -> (u32, u32) { +/// Returns the processed and pulled counts and how many routes are +/// `more_pending`, for adaptive polling. +async fn run_inbox_sync_cycle_counted(source: &str) -> (u32, u32, usize) { let router = match crate::bridge::app_router() { Some(r) => r, None => { log::debug!("[inbox_poller] AppRouter not installed yet, skipping cycle"); - return (0, 0); + return (0, 0, 0); } }; @@ -311,37 +321,41 @@ async fn run_inbox_sync_cycle_counted(source: &str) -> (u32, u32) { if !result.success { let msg = result.error_message.as_deref().unwrap_or("unknown"); log::warn!("[inbox_poller] storage.sync failed: {msg}"); - return (0, 0); + return (0, 0, 0); } // Decode the Envelope response to get StorageSyncResponse. - let (processed, pulled) = match decode_sync_response(&result.data) { + let (processed, pulled, more_pending) = match decode_sync_response(&result.data) { Ok(counts) => counts, Err(e) => { log::error!("[inbox_poller] storage.sync answer is not readable: {e}"); - return (0, 0); + return (0, 0, 0); } }; log::info!( - "[inbox_poller] sync cycle complete: pulled={pulled}, processed={processed}, source={source}" + "[inbox_poller] sync cycle complete: pulled={pulled}, processed={processed}, \ + more_pending={}, source={source}", + more_pending.len() ); + let routes_pending = more_pending.len(); // Push `inbox.updated` event to WebView via the canonical reverse-spine. - push_inbox_event_to_webview(pulled, processed); + push_inbox_event_to_webview(pulled, processed, more_pending); - (processed, pulled) + (processed, pulled, routes_pending) } /// Push inbox.updated + optional wallet refresh to WebView. #[cfg(all(target_os = "android", feature = "jni"))] -fn push_inbox_event_to_webview(pulled: u32, processed: u32) { +fn push_inbox_event_to_webview(pulled: u32, processed: u32, more_pending: Vec) { let event_payload = generated::StorageSyncResponse { success: true, pulled, processed, pushed: 0, errors: vec![], + more_pending, }; let payload_bytes = event_payload.encode_to_vec(); @@ -357,14 +371,14 @@ fn push_inbox_event_to_webview(pulled: u32, processed: u32) { } #[cfg(not(all(target_os = "android", feature = "jni")))] -fn push_inbox_event_to_webview(_pulled: u32, _processed: u32) { +fn push_inbox_event_to_webview(_pulled: u32, _processed: u32, _more_pending: Vec) { // No-op on non-Android / non-JNI builds. } /// The `(processed, pulled)` counts of `storage.sync`'s answer. The router /// answers its own caller with a local answer (`pack_envelope_ok`: `[0x03]` /// framing, no sender headers, no message id), so it is read as one. -fn decode_sync_response(data: &[u8]) -> Result<(u32, u32), String> { +fn decode_sync_response(data: &[u8]) -> Result<(u32, u32, Vec), String> { let envelope = crate::handlers::response_helpers::decode_local_envelope(data)?; match envelope.payload { Some(generated::envelope::Payload::StorageSyncResponse(resp)) => { @@ -375,7 +389,7 @@ fn decode_sync_response(data: &[u8]) -> Result<(u32, u32), String> { resp.errors ); } - Ok((resp.processed, resp.pulled)) + Ok((resp.processed, resp.pulled, resp.more_pending)) } _ => Err("storage.sync answered with a payload that is not a StorageSyncResponse".into()), } @@ -481,6 +495,7 @@ mod tests { processed, pushed: 0, errors, + more_pending: Vec::new(), }, )) } @@ -491,10 +506,13 @@ mod tests { /// and never announced a sync. #[test] fn a_storage_sync_answer_as_the_router_frames_it_is_read() { - assert_eq!(decode_sync_response(&sync_answer(7, 3, vec![])), Ok((3, 7))); + assert_eq!( + decode_sync_response(&sync_answer(7, 3, vec![])), + Ok((3, 7, Vec::new())) + ); assert_eq!( decode_sync_response(&sync_answer(u32::MAX, u32::MAX - 1, vec!["e".into()])), - Ok((u32::MAX - 1, u32::MAX)) + Ok((u32::MAX - 1, u32::MAX, Vec::new())) ); } @@ -633,9 +651,20 @@ mod tests { // ── push_inbox_event_to_webview is no-op on non-android ── + #[test] + fn a_pending_route_hurries_the_poller_only_while_entries_are_taken() { + assert!(enters_eager_mode(1, 0, 0), "something processed"); + assert!(enters_eager_mode(0, 3, 1), "a backlog being taken"); + assert!( + !enters_eager_mode(0, 0, 1), + "a pending route from which nothing was taken" + ); + assert!(!enters_eager_mode(0, 3, 0), "junk taken, nothing left"); + } + #[test] fn push_inbox_event_noop_on_test_platform() { // Should not panic on non-Android - push_inbox_event_to_webview(5, 3); + push_inbox_event_to_webview(5, 3, Vec::new()); } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/b0x_consumed.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/b0x_consumed.rs index 930152001..b4ed5d943 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/b0x_consumed.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/b0x_consumed.rs @@ -14,6 +14,18 @@ //! - `b0x_read_position`: per inbox and per node, the spool position below //! which every message is consumed. Each node numbers its own spool, so a //! position belongs to one node. It only moves forward. +//! - `b0x_passed_over`: copies this device classified as never anything it +//! can take, keyed by the copy's content (`envelope_merge_key`: message id +//! and content digest), never by message id alone. Anyone can spool a copy +//! under an honest message's id, and the node keeps both (storage spec §8); +//! a pass-over by id would hide the honest copy for good. A copy passed over +//! is never charged to a sync's budget again (owner ruling, 2026-10-01, +//! pre-audit item 11). +//! - `b0x_scan_cursor`: per inbox and per node, where `storage.sync`'s next +//! read resumes. A read stops at its page cap; the next one goes on from +//! there, and one that reaches the end of the spool sends the next back to +//! the read position, where anything still waiting is read again. Nothing +//! that waits can hide what lies more than one read behind it. use anyhow::{anyhow, Result}; use rusqlite::{params, OptionalExtension}; @@ -90,6 +102,95 @@ pub fn advance_read_position(address: &str, endpoint: &str, next_seq: u64) -> Re Ok(()) } +/// Record the copies `copy_keys` (each an `envelope_merge_key`) at `address` +/// as passed over. Idempotent. +pub fn record_passed_over(address: &str, copy_keys: &[String]) -> Result<()> { + if copy_keys.is_empty() { + return Ok(()); + } + let binding = get_connection()?; + let mut conn = binding + .lock() + .map_err(|e| anyhow!("b0x_consumed: db lock poisoned: {e}"))?; + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare_cached( + "INSERT OR IGNORE INTO b0x_passed_over(address, copy_key) VALUES (?1, ?2)", + )?; + for key in copy_keys { + stmt.execute(params![address, key])?; + } + } + tx.commit()?; + Ok(()) +} + +/// Whether the copy `copy_key` at `address` has been passed over. +pub fn is_passed_over(address: &str, copy_key: &str) -> Result { + let binding = get_connection()?; + let conn = binding + .lock() + .map_err(|e| anyhow!("b0x_consumed: db lock poisoned: {e}"))?; + let found: Option = conn + .query_row( + "SELECT 1 FROM b0x_passed_over WHERE address = ?1 AND copy_key = ?2", + params![address, copy_key], + |r| r.get(0), + ) + .optional()?; + Ok(found.is_some()) +} + +/// Where `storage.sync`'s next read of `address` on `endpoint` resumes, if a +/// read stopped short of the spool's end there. +pub fn scan_cursor(address: &str, endpoint: &str) -> Result> { + let binding = get_connection()?; + let conn = binding + .lock() + .map_err(|e| anyhow!("b0x_consumed: db lock poisoned: {e}"))?; + let cursor: Option = conn + .query_row( + "SELECT next_seq FROM b0x_scan_cursor WHERE address = ?1 AND endpoint = ?2", + params![address, endpoint], + |r| r.get(0), + ) + .optional()?; + cursor + .map(|c| { + u64::try_from(c) + .map_err(|e| anyhow!("b0x_scan_cursor holds a negative cursor {c}: {e}")) + }) + .transpose() +} + +/// Set where the next read resumes: `Some(seq)` after a read that stopped at +/// its page cap, `None` after one that reached the spool's end (the next read +/// starts from the read position). +pub fn set_scan_cursor(address: &str, endpoint: &str, next_seq: Option) -> Result<()> { + let binding = get_connection()?; + let conn = binding + .lock() + .map_err(|e| anyhow!("b0x_consumed: db lock poisoned: {e}"))?; + match next_seq { + Some(seq) => { + let seq = i64::try_from(seq) + .map_err(|e| anyhow!("b0x scan cursor {seq} exceeds i64: {e}"))?; + conn.execute( + "INSERT INTO b0x_scan_cursor(address, endpoint, next_seq) VALUES (?1, ?2, ?3) + ON CONFLICT(address, endpoint) DO UPDATE SET next_seq = excluded.next_seq", + params![address, endpoint, seq], + )?; + } + None => { + conn.execute( + "DELETE FROM b0x_scan_cursor WHERE address = ?1 AND endpoint = ?2", + params![address, endpoint], + )?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -130,4 +231,33 @@ mod tests { advance_read_position("INBOX-A", "https://n1", 9).unwrap(); assert_eq!(read_position("INBOX-A", "https://n1").unwrap(), 9); } + + #[test] + #[serial_test::serial] + fn a_copy_passed_over_is_its_content_and_consumes_no_id() { + fresh(); + record_passed_over("INBOX-A", &["M1:JUNK".into()]).unwrap(); + assert!(is_passed_over("INBOX-A", "M1:JUNK").unwrap()); + assert!( + !is_passed_over("INBOX-A", "M1:HONEST").unwrap(), + "another copy under the same id is its own" + ); + assert!( + !is_consumed("INBOX-A", "M1").unwrap(), + "passing a copy over consumes nothing by id" + ); + assert!(!is_passed_over("INBOX-B", "M1:JUNK").unwrap()); + } + + #[test] + #[serial_test::serial] + fn a_scan_cursor_is_per_node_and_cleared_at_the_spools_end() { + fresh(); + assert_eq!(scan_cursor("INBOX-A", "https://n1").unwrap(), None); + set_scan_cursor("INBOX-A", "https://n1", Some(40)).unwrap(); + assert_eq!(scan_cursor("INBOX-A", "https://n1").unwrap(), Some(40)); + assert_eq!(scan_cursor("INBOX-A", "https://n2").unwrap(), None); + set_scan_cursor("INBOX-A", "https://n1", None).unwrap(); + assert_eq!(scan_cursor("INBOX-A", "https://n1").unwrap(), None); + } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs index 87281b015..a7ca35634 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs @@ -829,6 +829,21 @@ fn create_schema(conn: &Connection) -> Result<()> { next_seq INTEGER NOT NULL, PRIMARY KEY (address, endpoint) ); + -- Copies this device classified as never anything it can take, by + -- content, never by message id alone (b0x_consumed.rs). + CREATE TABLE IF NOT EXISTS b0x_passed_over( + address TEXT NOT NULL, + copy_key TEXT NOT NULL, + PRIMARY KEY (address, copy_key) + ); + -- Where storage.sync's read of each inbox on each node resumes + -- (b0x_consumed.rs). + CREATE TABLE IF NOT EXISTS b0x_scan_cursor( + address TEXT NOT NULL, + endpoint TEXT NOT NULL, + next_seq INTEGER NOT NULL, + PRIMARY KEY (address, endpoint) + ); -- The one sealed form of each outgoing spool payload, by message id -- (DSM Amendment A7; b0x_sealed.rs). CREATE TABLE IF NOT EXISTS b0x_sealed( diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs index d9d83582f..431a3d3a2 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs @@ -57,6 +57,10 @@ pub struct TestDevice { seq: Arc, } +/// How many times one funding claim's held admission is resumed before the +/// harness gives up on it. +const FUNDING_RESUMES: usize = 4; + impl TestDevice { /// Create the device's identity the way wallet creation does, in its own /// (empty) database slot. Leaves the device entered. @@ -209,9 +213,33 @@ impl TestDevice { self.enter(); let core = self.router().core_sdk.clone(); for claim in 0..(whole_era / payout) { - crate::sdk::faucet_claim_flow::claim_era_faucet(&core, economic_fixtures::NETWORK) + // A claim whose admission is held (its evidence not yet final at + // every member a verifier reads) is finished by resuming that + // admission, exactly as the device resumes it before anything + // else. A failure with nothing held is a refusal. + let mut outcome = + crate::sdk::faucet_claim_flow::claim_era_faucet(&core, economic_fixtures::NETWORK) + .await + .map(drop); + let mut resumed = 0; + while let Err(e) = outcome { + let held = core + .device_head() + .and_then(|head| head.pending_economic_admission().cloned()); + resumed += 1; + let Some(pending) = held.filter(|_| resumed <= FUNDING_RESUMES) else { + panic!("funding claim {claim}: {e}"); + }; + // The members a verifier reads catch up between attempts. + tokio::time::sleep(std::time::Duration::from_millis(250 * resumed as u64)).await; + outcome = crate::sdk::economic_admission_flow::resume_pending_admission( + &core, + economic_fixtures::NETWORK, + pending, + ) .await - .unwrap_or_else(|e| panic!("funding claim {claim}: {e}")); + .map(drop); + } } } diff --git a/dsm_client/frontend/public/index.html b/dsm_client/frontend/public/index.html index cf843c0c0..bb21a8080 100644 --- a/dsm_client/frontend/public/index.html +++ b/dsm_client/frontend/public/index.html @@ -13,13 +13,16 @@ + object-src 'none'; + base-uri 'none'; + form-action 'none'; + frame-src 'none';" /> diff --git a/dsm_client/frontend/scripts/validate-android-assets.js b/dsm_client/frontend/scripts/validate-android-assets.js index c3eda3f04..b688a3616 100644 --- a/dsm_client/frontend/scripts/validate-android-assets.js +++ b/dsm_client/frontend/scripts/validate-android-assets.js @@ -71,7 +71,18 @@ for (const name of ['dsm_env_config.toml', 'ca.crt']) { } } -if (!ok) { +// The policy the shipped page runs under (pre-audit item 13). +const policyProblems = existsExact('index.html') + ? require('./webview-policy').policyProblems(fs.readFileSync(path.join(ASSETS_DIR, 'index.html'), 'utf8')) + : []; +for (const problem of policyProblems) { + console.error(`Error: index.html's Content-Security-Policy: ${problem}`); +} +if (policyProblems.length === 0) { + console.log("OK: index.html's Content-Security-Policy admits no eval and no unhashed inline script"); +} + +if (!ok || policyProblems.length > 0) { console.error(`\nAsset validation failed in ${ASSETS_DIR}`); process.exit(1); } diff --git a/dsm_client/frontend/scripts/webview-policy.js b/dsm_client/frontend/scripts/webview-policy.js new file mode 100644 index 000000000..28a5cdb25 --- /dev/null +++ b/dsm_client/frontend/scripts/webview-policy.js @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + The WebView's Content-Security-Policy, read from the page the APK ships + (pre-audit item 13). Every problem with it, by name; none is a pass. + + - No 'unsafe-eval' anywhere, and no 'unsafe-inline' for scripts: script + runs only from the app's own files and from the inline scripts the page + carries, each admitted by the SHA-256 of its exact text. + - Every inline script in the page is one of those hashes, so the policy + blocks none of the page's own code; no hash names a script the page lacks. + - The page cannot be re-based, cannot post a form, and cannot frame + anything (base-uri, form-action, frame-src 'none'). +*/ +const crypto = require('crypto'); + +function directives(html) { + const meta = html.match(/]*)>([\s\S]*?)<\/script\b[^>]*>/gi)] + .filter(([, attributes]) => !/\bsrc\s*=/i.test(attributes)) + .map(([, , text]) => `'sha256-${crypto.createHash('sha256').update(text, 'utf8').digest('base64')}'`); +} + +function policyProblems(html) { + const policy = directives(html); + if (!policy) return ['the page carries no Content-Security-Policy']; + const problems = []; + for (const [name, sources] of policy) { + if (sources.includes("'unsafe-eval'")) problems.push(`${name} admits 'unsafe-eval'`); + } + // Without script-src the policy falls back to default-src for scripts. + const scriptSrc = policy.get('script-src') || policy.get('default-src'); + if (!scriptSrc) return [...problems, 'the policy names neither script-src nor default-src']; + if (scriptSrc.includes("'unsafe-inline'")) problems.push("script-src admits 'unsafe-inline'"); + if (scriptSrc.some((source) => source.startsWith('__'))) { + problems.push(`script-src still names the build's token: ${scriptSrc.join(' ')}`); + } + const pageHashes = inlineScriptHashes(html); + for (const hash of pageHashes) { + if (!scriptSrc.includes(hash)) problems.push(`an inline script is not admitted by its hash ${hash}`); + } + for (const source of scriptSrc.filter((s) => s.startsWith("'sha256-"))) { + if (!pageHashes.includes(source)) problems.push(`script-src admits ${source}, which no inline script has`); + } + for (const name of ['base-uri', 'form-action', 'frame-src', 'object-src']) { + const sources = policy.get(name); + if (!sources || sources.join(' ') !== "'none'") problems.push(`${name} is not 'none'`); + } + return problems; +} + +module.exports = { policyProblems }; diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts index ba849f57d..dde564f0a 100644 --- a/dsm_client/frontend/src/proto/dsm_app_pb.ts +++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts @@ -22337,7 +22337,7 @@ export class StorageSyncRequest extends Message { pushPending = false; /** - * max inbox items to pull (0 = default 100) + * max inbox items to read from each route (0 = default 100) * * @generated from field: uint32 limit = 3; */ @@ -22410,6 +22410,15 @@ export class StorageSyncResponse extends Message { */ errors: string[] = []; + /** + * Inbox routes holding more than this sync took (a read stopped at its + * page cap, or more entries than the route's budget): a status, not an + * error. The next sync resumes each where it stopped. + * + * @generated from field: repeated string more_pending = 6; + */ + morePending: string[] = []; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -22423,6 +22432,7 @@ export class StorageSyncResponse extends Message { { no: 3, name: "processed", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 4, name: "pushed", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 5, name: "errors", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + { no: 6, name: "more_pending", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): StorageSyncResponse { diff --git a/dsm_client/frontend/webpack.config.js b/dsm_client/frontend/webpack.config.js index 3cef84a54..4b4987320 100644 --- a/dsm_client/frontend/webpack.config.js +++ b/dsm_client/frontend/webpack.config.js @@ -4,6 +4,45 @@ const webpack = require('webpack'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtract = require('mini-css-extract-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); +const crypto = require('crypto'); + +// The WebView's Content-Security-Policy admits no inline script by kind +// ('unsafe-inline') and no eval ('unsafe-eval'): only the app's own files and +// the inline scripts index.html itself carries, each by the SHA-256 of its +// exact text in the page as emitted (pre-audit item 13). The page names where +// the hashes go with this token; a page without it, or an inline script with +// attributes the hash cannot vouch for, fails the build rather than ship a +// policy that admits more, or a page whose own scripts it blocks. +const INLINE_SCRIPT_HASHES_TOKEN = '__DSM_INLINE_SCRIPT_HASHES__'; + +class InlineScriptHashes { + apply(compiler) { + compiler.hooks.compilation.tap('InlineScriptHashes', (compilation) => { + HtmlWebpackPlugin.getCompilationHooks(compilation).beforeEmit.tapAsync( + 'InlineScriptHashes', + (data, done) => { + // Tag names are case-insensitive, and a closing tag may carry whitespace: + // every spelling a browser reads as a script is a script here. + const scripts = [...data.html.matchAll(/]*)>([\s\S]*?)<\/script\b[^>]*>/gi)]; + const inline = scripts.filter(([, attributes]) => !/\bsrc\s*=/i.test(attributes)); + const unhashable = inline.filter(([, attributes]) => attributes.trim().length > 0); + if (unhashable.length > 0) { + done(new Error(`an inline script carries attributes: ${unhashable[0][1].trim()}`)); + return; + } + if (data.html.split(INLINE_SCRIPT_HASHES_TOKEN).length !== 2) { + done(new Error(`the page must name ${INLINE_SCRIPT_HASHES_TOKEN} exactly once in its policy`)); + return; + } + const hashes = inline.map(([, , text]) => + `'sha256-${crypto.createHash('sha256').update(text, 'utf8').digest('base64')}'`); + data.html = data.html.replace(INLINE_SCRIPT_HASHES_TOKEN, hashes.join(' ')); + done(null, data); + }, + ); + }); + } +} module.exports = (env, argv) => { const isProd = argv.mode === 'production'; @@ -90,6 +129,7 @@ module.exports = (env, argv) => { minifyCSS: true } }), + new InlineScriptHashes(), isProd && new MiniCssExtract({ filename: 'css/[name].[contenthash:8].css' }), @@ -209,7 +249,7 @@ module.exports = (env, argv) => { ? false : inMobile ? 'source-map' // WebView-safe, no eval() - : 'eval-cheap-module-source-map', + : 'cheap-module-source-map', // the policy admits no eval devServer: inMobile ? undefined : { static: { directory: path.join(__dirname, 'public') }, diff --git a/lean4/DSMSofiAtomicity.lean b/lean4/DSMSofiAtomicity.lean index 2517bbdcc..8ea206d76 100644 --- a/lean4/DSMSofiAtomicity.lean +++ b/lean4/DSMSofiAtomicity.lean @@ -1053,6 +1053,11 @@ inductive ParentState where so it never registered there (CONFORMANCE §6.66 12d), or it resolved Invalid. -/ | noRoot + /-- The trader's lineage is known Invalid at or before `p` (an Invalid step, + or a divergent write-once register cell the walk quarantines): it holds no + claim at `p`, ever, whatever P names (CONFORMANCE §6.66 12f; SoFi Amendment + S13). Terminal. -/ + | lineageInvalid deriving DecidableEq, Repr def parentCompatible : ParentState → Bool @@ -1064,6 +1069,7 @@ def parentImpossible : ParentState → Bool | .otherBranch => true | .noRoot => true | .singleNotHeld => true + | .lineageInvalid => true | _ => false def parentPending : ParentState → Bool @@ -1101,6 +1107,11 @@ the one fulfillment that does register (R15-2). Keys are DLV parents `R_j`; per-parent facts are at the fulfillment's own attempt. -/ structure Facts where registered : Bool + /-- The pair is final on `F` under a root claim that names `F` with another + body than `derive(P, F)` (SoFi Amendment S20; CONFORMANCE §6.66 12k): `F` is + lost there, and the position is Invalid for the trader's lineage, the same + verdict a peer's walk reads. Never together with `registered`. -/ + misbodied : Bool := false /-- The leg half of `RouteValidation(P, G, E)`: policy fulfillment, arithmetic, shadows, signatures, canonical witnesses. -/ validation : Validation @@ -1184,7 +1195,7 @@ never took is Invalid whatever its own legs did. Void requires Unavailable" and "permanent once non-Pending" jointly require (see `literal_ladder_is_not_permanent`). -/ def resolve (x : Facts) (legs : List Nat) (e : Nat) : Resolution := - if x.registered = false then .pending + if x.registered = false then (if x.misbodied = true then .invalid else .pending) else if parentPending x.parent = true then .pending else if parentImpossible x.parent = true then .invalid else if x.conformance = .invalid then .invalid @@ -1233,6 +1244,8 @@ structure Evolves (x x' : Facts) : Prop where consumedElsewhere : ∀ R, x.consumedElsewhere R = true → x'.consumedElsewhere R = true /-- An open branch may be selected; a terminal one never changes. -/ parent : x.parent ≠ .openBranch → x'.parent = x.parent := by intro _; rfl + /-- A misbodied pair is final: it stays misbodied and never registers. -/ + misbodied : x.misbodied = true → x'.misbodied = true ∧ x'.registered = false theorem consumedRoute_iff (x : Facts) (legs : List Nat) (e : Nat) : ConsumedRoute x legs e = true ↔ @@ -1463,7 +1476,7 @@ theorem resolve_realized_iff (x : Facts) (legs : List Nat) (e : Nat) : · intro h unfold resolve at h by_cases h1 : x.registered = false - · rw [if_pos h1] at h; cases h + · rw [if_pos h1] at h; split at h <;> cases h · rw [if_neg h1] at h by_cases hp : parentPending x.parent = true · rw [if_pos hp] at h; cases h @@ -1533,11 +1546,11 @@ admissibility. Whatever the cells hold, an unregistered fulfillment consumes nothing and its position is Pending. Mutation: make `ConsumedRoute` the leg predicate alone -- red. -/ theorem early_cell_cannot_cause_consumption {x : Facts} {legs : List Nat} {e : Nat} - (hnr : x.registered = false) : + (hnr : x.registered = false) (hnm : x.misbodied = false) : ConsumedRoute x legs e = false ∧ resolve x legs e = .pending := by constructor · simp [ConsumedRoute, hnr] - · unfold resolve; rw [if_pos hnr] + · unfold resolve; rw [if_pos hnr, if_neg (by simp [hnm])] /-- Witnesses: every cell final on E, every parent canonical -- and no registration, or a registered F that does not conform. -/ @@ -1566,13 +1579,13 @@ theorem stored_is_not_valid_and_registered_is_not_valid : resolved keeps the position Pending. The parent alone consumes nothing and makes nothing impossible — an undecided branch is not a verdict. -/ theorem conditional_parent_pending_keeps_the_position_pending {x : Facts} {legs : List Nat} - {e : Nat} (hopen : x.parent = .openBranch) : + {e : Nat} (hopen : x.parent = .openBranch) (hnm : x.misbodied = false) : resolve x legs e = .pending ∧ ConsumedRoute x legs e = false ∧ parentCompatible x.parent = false ∧ parentImpossible x.parent = false := by refine ⟨?_, ?_, by rw [hopen]; rfl, by rw [hopen]; rfl⟩ · unfold resolve by_cases hreg : x.registered = false - · rw [if_pos hreg] + · rw [if_pos hreg, if_neg (by simp [hnm])] · rw [if_neg hreg, if_pos (by rw [hopen]; rfl : parentPending x.parent = true)] · simp [ConsumedRoute, hopen, parentCompatible] @@ -1612,6 +1625,17 @@ theorem single_root_parent_not_held_is_invalid {x : Facts} {legs : List Nat} {e resolve x legs e = .invalid ∧ ConsumedRoute x legs e = false := conditional_parent_on_another_branch_is_invalid hreg (by rw [hp]; rfl) +/-- CONFORMANCE §6.66 12f: a parent on a lineage known Invalid at or before +`p` makes the position Invalid and its route never consumed, whatever its legs +and evidence say. That lineage can never yield a claim at `p`, so the key its +exercise holds is skipped rather than held for good (SoFi Amendment S13: "No +trade whose trader's lineage is known invalid can occupy a vault key +indefinitely"). -/ +theorem parent_on_an_invalid_lineage_is_invalid {x : Facts} {legs : List Nat} {e : Nat} + (hreg : x.registered = true) (hp : x.parent = .lineageInvalid) : + resolve x legs e = .invalid ∧ ConsumedRoute x legs e = false := + conditional_parent_on_another_branch_is_invalid hreg (by rw [hp]; rfl) + /-- A concrete position on a branch its parent never took: registered, valid, every leg final on this E, Complete — and still Invalid, because `p` selected the other root. Its two siblings differ only in the parent's state. Without @@ -1630,12 +1654,15 @@ def builtOnTheOtherBranch : Facts where def awaitingItsParent : Facts := { builtOnTheOtherBranch with parent := .openBranch } def onTheTakenBranch : Facts := { builtOnTheOtherBranch with parent := .taken } def terminalParentNoRoot : Facts := { builtOnTheOtherBranch with parent := .noRoot } +def onAnInvalidLineage : Facts := { builtOnTheOtherBranch with parent := .lineageInvalid } /-- THE RUNGS ARE NOT VACUOUS, and they separate three outcomes on facts that differ ONLY in what the trader parent did. -/ theorem the_trader_parent_rungs_are_not_vacuous : resolve builtOnTheOtherBranch [10] 50 = .invalid ∧ resolve terminalParentNoRoot [10] 50 = .invalid + ∧ resolve onAnInvalidLineage [10] 50 = .invalid + ∧ ConsumedRoute onAnInvalidLineage [10] 50 = false ∧ resolve awaitingItsParent [10] 50 = .pending ∧ resolve onTheTakenBranch [10] 50 = .realized ∧ ConsumedRoute builtOnTheOtherBranch [10] 50 = false @@ -1806,10 +1833,16 @@ theorem voidEvidence_mono {x x' : Facts} (hev : Evolves x x') {legs : List Nat} theorem resolution_is_permanent {x x' : Facts} {legs : List Nat} {e : Nat} (hev : Evolves x x') (hc' : Coherent x' legs e) (hnp : resolve x legs e ≠ .pending) : resolve x' legs e = resolve x legs e := by - have hreg : x.registered = true := by - cases h : x.registered - · exact absurd (by unfold resolve; rw [if_pos h]) hnp - · rfl + -- Unregistered and non-Pending is a misbodied pair: Invalid, and it stays + -- misbodied and unregistered (CONFORMANCE §6.66 12k). + by_cases hr : x.registered = false + · have hm : x.misbodied = true := by + cases hm : x.misbodied + · exact absurd (by unfold resolve; rw [if_pos hr, if_neg (by simp [hm])]) hnp + · rfl + obtain ⟨hm', hr'⟩ := hev.misbodied hm + unfold resolve; rw [if_pos hr, if_pos hm, if_pos hr', if_pos hm'] + have hreg : x.registered = true := by simpa using hr have hreg' := hev.registered hreg have h1 : ¬ x.registered = false := by simp [hreg] have h1' : ¬ x'.registered = false := by simp [hreg'] @@ -1905,7 +1938,7 @@ theorem literal_ladder_is_not_permanent : ∧ resolveLiteral laterInvalid [10] 50 = .invalid ∧ resolve unavailableThenVoid [10] 50 = .pending := by refine ⟨⟨fun h => h, trivial, rfl, rfl, fun _ => rfl, fun _ h => h, fun _ _ h => h, - fun _ h => h, fun _ h => h, fun _ => rfl⟩, ⟨(fun _ _ h => nomatch h), + fun _ h => h, fun _ h => h, fun _ => rfl, fun h => nomatch h⟩, ⟨(fun _ _ h => nomatch h), (fun h => by simp [ConsumedRoute, laterInvalid, unavailableThenVoid, Facts.routeValidation, Validation.and] at h)⟩, (by decide), (by decide), (by decide)⟩ @@ -1953,12 +1986,30 @@ theorem at_most_one_candidate_registers_per_position {hm : HashModel} · cases h · exact (Option.some.inj h).symm -/-- A candidate that never registers never resolves the position: the ladder -answers Pending for it forever, and the position's terminal answer comes from -the one fulfillment that did register. -/ +/-- A candidate that never registers, and whose pair is not misbodied, never +resolves the position: the ladder answers Pending for it forever, and the +position's terminal answer comes from the one fulfillment that did register. -/ theorem a_candidate_that_never_registers_stays_pending (x : Facts) (legs : List Nat) (e : Nat) - (h : x.registered = false) : resolve x legs e = .pending := by - unfold resolve; rw [if_pos h] + (h : x.registered = false) (hnm : x.misbodied = false) : resolve x legs e = .pending := by + unfold resolve; rw [if_pos h, if_neg (by simp [hnm])] + +/-- CONFORMANCE §6.66 12k (owner ruling, 2026-10-01: "same bytes => same +terminal verdict for local and peer resolution"): a pair final on `F` under a +root claim naming `F` with another body is Invalid for the trader's lineage, +whatever else the facts say, and its route consumes nothing. A peer's walk +reads the same Invalid (SoFi Amendment S20). -/ +theorem a_misbodied_pair_is_invalid {x : Facts} {legs : List Nat} {e : Nat} + (hm : x.misbodied = true) (hnr : x.registered = false) : + resolve x legs e = .invalid ∧ ConsumedRoute x legs e = false := by + refine ⟨?_, by simp [ConsumedRoute, hnr]⟩ + unfold resolve; rw [if_pos hnr, if_pos hm] + +/-- The 12k rung is not vacuous: facts that differ only in `misbodied` resolve +Pending and Invalid. -/ +theorem the_misbodied_rung_is_not_vacuous : + resolve cellsFinalUnregistered [10, 11] 50 = .pending + ∧ resolve { cellsFinalUnregistered with misbodied := true } [10, 11] 50 = .invalid := by + decide /-- NO GUARANTEE WITHOUT A PREPARE LOCK. Witnesses do not lock (§6), so a registered, fully valid fulfillment is not guaranteed to realize; the only way to @@ -2125,6 +2176,7 @@ theorem without_evidence_a_registered_fulfillment_stays_pending : ∀ x', Evolves unavailableThenVoid x' → x'.validation = .unavailable → resolve x' [10] 50 ≠ .realized ∧ resolve x' [10] 50 ≠ .void := by intro x' hevx hu + have hreg' : x'.registered = true := hevx.registered rfl have hpar : x'.parent = .single := by have := hevx.parent (by decide); simpa [unavailableThenVoid] using this have hrv : x'.routeValidation ≠ .valid := by @@ -2140,6 +2192,7 @@ theorem without_evidence_a_registered_fulfillment_stays_pending : · intro h; rw [(resolve_realized_iff _ _ _).mp h] at hcr; cases hcr · intro h unfold resolve at h + rw [if_neg (by simp [hreg'])] at h cases hc : x'.conformance <;> cases hh : x'.routeValidation <;> simp_all [parentPending, parentImpossible] <;> split at h <;> simp_all @@ -2654,6 +2707,7 @@ theorem later_setups_confer_no_authority (p k q k0 : Nat) (rest : List (Nat × N #print axioms conditional_parent_on_another_branch_is_invalid #print axioms terminal_parent_with_no_root_is_invalid_without_evidence #print axioms single_root_parent_not_held_is_invalid +#print axioms parent_on_an_invalid_lineage_is_invalid #print axioms the_trader_parent_rungs_are_not_vacuous #print axioms trader_parent_arm_is_monotone #print axioms fulfillment_atomic_all_or_none @@ -2672,6 +2726,8 @@ theorem later_setups_confer_no_authority (p k q k0 : Nat) (rest : List (Nat × N #print axioms fulfillment_registrable_after_parent_loss_resolves_void #print axioms at_most_one_candidate_registers_per_position #print axioms a_candidate_that_never_registers_stays_pending +#print axioms a_misbodied_pair_is_invalid +#print axioms the_misbodied_rung_is_not_vacuous #print axioms no_guarantee_without_prepare_lock -- ── §10 FulfillmentConformance over fetched evidence (rebuild step R7) ───── diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto index 492273bc9..c95b02f2e 100644 --- a/proto/dsm_app.proto +++ b/proto/dsm_app.proto @@ -3609,7 +3609,7 @@ message InboxResponse { repeated InboxItem items = 1; } message StorageSyncRequest { bool pull_inbox = 1; // retrieve pending items from b0x bool push_pending = 2; // submit local pending transactions - uint32 limit = 3; // max inbox items to pull (0 = default 100) + uint32 limit = 3; // max inbox items to read from each route (0 = default 100) } message StorageSyncResponse { bool success = 1; @@ -3617,6 +3617,10 @@ message StorageSyncResponse { uint32 processed = 3; // items successfully processed uint32 pushed = 4; // transactions pushed to storage repeated string errors = 5; // any errors encountered + // Inbox routes holding more than this sync took (a read stopped at its + // page cap, or more entries than the route's budget): a status, not an + // error. The next sync resumes each where it stopped. + repeated string more_pending = 6; } // ========================= Canonical Proofs ========================= diff --git a/specs/SoFi_Settlement_Specification.md b/specs/SoFi_Settlement_Specification.md index 836084210..779279424 100644 --- a/specs/SoFi_Settlement_Specification.md +++ b/specs/SoFi_Settlement_Specification.md @@ -1118,12 +1118,13 @@ Cq is SofiResolutionClaim, CORE/sofi/wire/objects.rs:580, class 0x003A. > - **`F` carries the trader device's `AttA`**, after its key. `F` names `K_ful(q)` only when `derive_devid(F.key, F.AttA)` is the trader's `DevID`. That is decided from `F`'s bytes alone (below), so a foreign-key `F` neither holds the cell nor makes a reader wait on a `P` it names. With `F.key = P.key` (P conformance 8), it binds `P`'s key too. > - **The exercise carries the trader's signed `C_q`.** Recognition requires that its body is the `C_q` of the exercise's `P` and `F`, under the same bound key, and that `derive_devid(F.key, F.AttA)` is `P`'s `DevID`. Anything that can hold a vault key therefore carries everything needed to register its own `F`. > - **A relayer never authors `C_q`.** "Any caller MAY relay F" (above) stands, but the relayer carries the trader's signed `C_q` from the exercise, or from the cell, exactly as signed. It does not recompute it. +> - **Relaying a withheld pair (owner, 2026-10-01).** Pre-audit 12e. Choosing which device relays a pair whose trader withheld it, the owner ruled "Next trader": "If a vault key is held by an exercise whose pair is not yet registered, the relayer MUST use the signed C_q carried by that exercise to register the trader's missing pair before advancing past that held key. The relayer does not author or modify C_q. It only republishes the exact trader-signed bytes already committed in the exercise. After successful pair registration, the relayer retries progression. If the embedded signed C_q is missing, malformed, does not match the exercise, or fails trader/device binding, fail closed for that exercise." So the next operation at a vault, a trade, a route or a close, registers such a pair from the exercise before its walk goes past the key, and walks again. An exercise whose `C_q` fails recognition is no exercise at the key (§17.5). A pair already registered or lost is not written. > - **`K_ful(q)` is decided from `F`'s bytes alone.** An object names `K_ful(q)` when it is a fulfillment envelope that recognizes under its own key (R8), its position is `q`, and `derive_devid(F.key, F.AttA)` is the trader's `DevID`. `P` is never read to decide it. `F` carries no genesis: the cell's key is derived from `(G, DevID, q)`, and only the holder of `DevID`'s key can write an `F` that passes, so whatever holds the trader's `K_ful(q)` is the trader's own act. Before this, a reader passed over an `F` whose `P` it did not hold yet. A trader could write `F_a`, naming a `P_a` it had not published, ahead of the `F_b` it traded with: a verifier reading before `P_a` appeared saw `F_b` registered and the trade realized, and one reading after saw `F_a` holding the cell and `F_b` neither registered nor lost. > - **Registration is matched by `F`'s id.** `FulfillmentRegistered(F)` holds when `K_ful(q)` is final on `F` and `K_root(q)` is final on a trader-signed `C_q` whose `fulfillment_id` is `FulfillmentId(F)`. The pair is matched from the two cells' bytes, so registration is the same for every verifier at every time. > - **Lost.** `F` is lost at `q` when another `F` holds `K_ful(q)`'s leader link in any state, leader-held, preserved or final (§23.1: once the leader keeps a value, no other value is final at that cell), or when `K_root(q)`'s leader link is held by a claim other than one naming `FulfillmentId(F)`, another fulfillment's or an ordinary transition's. A lost `F` is what S14's skip frees: its exception for `F`'s own `C_q`, "whose fulfillment may still be relayed", holds only while no other `F` holds `K_ful(q)`'s leader link. > - **The body is checked where `P` is in hand.** In an exercise's facts (`P` comes with the exercise) and in a position's resolution (Amendment S15), the `C_q` at `K_root(q)` is compared with `derive(P, F)`. A difference means `K_root(q)` holds a claim that is not `F`'s `C_q`: `F` is lost there, by S14's skip with no Void, and the position resolves Invalid for the trader's lineage. The vault and the lineage therefore agree on the one operation. `P`'s genesis, `DevID` and key are checked against the position and `F` in the same place (conformance). A trader that withholds the `P` its winning `F` names wedges only its own lineage, the same way for every verifier, and the vault keys held by its other fulfillments' exercises are freed by the skip. > -> This amends this section (`K_root(q)` holds the signed `C_q`, class `0x0062`), "What it accomplishes", the class registries of §14.2 and §15 (the occupant at `K_root(q)` is `0x0062`), §19.6, the crash table of §25, stages 7 and 8 of §31, §33, §36, and conditions 4 and 8 of §44: wherever a relayer or another caller "completes the cells", it does so with the trader's signed `C_q`. It also amends §23.6 (the pair is matched by `FulfillmentId(F)` from the two cells' bytes) and Amendment S14's skip (a fulfillment whose `K_ful(q)` another fulfillment holds is lost, as is one whose `C_q` body differs from `derive(P, F)`). Bytes written in the previous format occupy nothing, and nothing written before this amendment carries over (a clean cut). +> This amends this section (`K_root(q)` holds the signed `C_q`, class `0x0062`), "What it accomplishes", the class registries of §14.2 and §15 (the occupant at `K_root(q)` is `0x0062`), §17.5 (the exercise carries the trader's signed `C_q`, after `fulfillment`), §19.6, the crash table of §25, stages 7 and 8 of §31, §33 (the next operation at a vault relays a withheld pair, above), §36, and conditions 4 and 8 of §44: wherever a relayer or another caller "completes the cells", it does so with the trader's signed `C_q`. It also amends §23.6 (the pair is matched by `FulfillmentId(F)` from the two cells' bytes) and Amendment S14's skip (a fulfillment whose `K_ful(q)` another fulfillment holds is lost, as is one whose `C_q` body differs from `derive(P, F)`). Bytes written in the previous format occupy nothing, and nothing written before this amendment carries over (a clean cut). **Rule — registration** diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 3e436f1b9..2d9a37a5a 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1105,7 +1105,7 @@ Tests for the radio's word: `BleCoordinatorRadioTest` (7: a refused advertising - Kotlin · UnifiedContactBridge.kt `removeContact`, `hasContactForDeviceId`, with their `Unified` functions, externals and JNI exports: nothing calls them. A contacts pass. - `dsm_sdk` · bluetooth/bilateral_ble_handler.rs `handle_prepare_request`: a prepare that is not for this relationship is not meaningless bytes to the receiver, though its parent can never match. The receiver never checks that the prepare is for it: it logs the target and carries on. When the sender is also its contact, the tip mismatch stores the sender's claimed tip as a live-peer claim, and that claim blocks the receiving appliance's sends to the sender, online as well as offline (`wallet.send` checks the same readiness); nothing outside recovery clears it. The receiver also answers with a signed rejection, which the sender drops, and emits a rejected event to its own app. A throwaway probe reproduced it: send-ready before, then blocked with "Live peer reported a different relationship tip". The target field is not under the sender's signature; the operation inside the signed commitment names the recipient. The Kotlin fallback below is one way such a prepare arrives. - Kotlin · bridge/ble/BleCoordinator.kt `resolveSession`: when the address a transfer names is not a live session and no identity matches it, the transfer goes to the one ready peer, whichever appliance that is. A prepare for one contact can reach another appliance; the fallback picks a destination the SDK did not choose. A transport pass. -- Kotlin · AndroidManifest.xml `PicoSelfTestActivity`: a bench self-test its own comment calls debug bring-up only, exported and launched on USB attach in the production manifest. The hardware track's. +- Kotlin · AndroidManifest.xml `PicoSelfTestActivity`: a bench self-test its own comment calls debug bring-up only, exported and launched on USB attach in the production manifest. The hardware track's. **Closed by pre-audit item 16 (§6.69):** declared in the debug manifest only. - `proto` · `BilateralReconciliationRequest`, `BilateralReconciliationResponse`, `BleFrameType` 10–11, with their arms in `dsm_sdk` bluetooth/frame_classify.rs and wire/mod.rs: reconciliation was deleted (a fork is a Tripwire violation, not reconcilable) and its frames outlived it; the request's `include_peer_status` and `ble_address` name the peer status read deleted above. A backend wire pass. - frontend · SofiScreen: orders a vault's token pair bytewise before sending it (§28) — a protocol rule applied above Rust. The SoFi track's screen. - frontend · dsm/transactions.ts `schedulePostAcceptRefreshes`: after Accept, four re-reads of the wallet on a frame cadence (0/0.5/1/2 s) beside Rust's TRANSFER_COMPLETE announcement, kept because whether the announcement alone reaches the screen on a device is undecided; a device run decides, and the cadence goes if it does. @@ -2307,7 +2307,22 @@ The timeouts are `Duration`s handed to the HTTP and pool libraries. `no_clock_re - **Capping the mirror's distinct ByteCommits per member and cycle.** Spec §14 rule 6 keeps every one. - **Committing the CA with the storage set.** It is configured today. -**Open, SOFI's sync design:** in `pull_and_process_inbox`, an entry that never becomes a transfer counts against the sync's limit and is never consumed. Routes the limit leaves unread are not reported, and reporting them would make every limit-capped sync fail (`inbox_incomplete` stops the sync). Both depend on what a limit-capped sync means. +**Item 11, what a limit-capped sync means (SOFI, `security/beta-pre-audit-remaining`, 2026-10-02; closed).** The open question this section left: an entry that never became a transfer counted against the sync's limit and was never consumed, and routes the limit left unread were not reported. The owner's ruling (2026-10-01): each route gets its own budget; a route left with more is resumable status (`more_pending`), not an error; junk classified terminally is not charged again; no route can starve another; repeated syncs make bounded progress through a route. + +| What was wrong | Now | Test | Mutation control | +|---|---|---|---| +| `StorageSyncRequest.limit` was one budget for the whole sync, spent route by route. Junk on the first route read spent all of it, and the routes after it were neither read nor reported; the sync still answered complete. | `limit` is each route's budget, and every route is read on every sync (`pull_and_process_inbox`). | `dsm_sdk::handlers::node_e2e_tests::a_route_full_of_junk_keeps_no_other_route_unread_and_is_worked_through` | The global break restored → red (the payment on the other route never lands). | +| A route holding more than the sync took was reported nowhere. | `StorageSyncResponse.more_pending` (field 6) names it: either its read stopped at the page cap (`RetrievalOutcome::more`) or it held more entries than its budget. The sync still succeeds. | the test above; `…::a_copy_that_waits_hides_nothing_more_than_a_page_cap_behind_it` | Never reported → both red. The page cap not reported → the second red. | +| An entry that never became a transfer was left on the spool and charged against the budget on every sync. | A copy classified terminally is passed over. That covers a request whose body is not what its method names, a transfer or receipt the boundary returns `NotRecognized` for, and a copy that opens to none of the spooled payloads (`RetrievalOutcome::unknown`). `NotRecognized` is a fact about the copy's own bytes against the keys this device pins; anything this device cannot decide yet comes back as an `Err` or a staged copy instead. Every payload a device spools is one of the read's five discriminators. | both tests above | Unrecognized requests not passed over → the first red. Unknown copies not passed over → the second red. | +| Found in review of the hand-off, never released: the work in progress consumed junk by message id. The id is cleartext on the spool, and the node keeps a second copy under an id it already holds (`dsm_storage_node::api::transport::b0x::tests::an_envelope_reusing_a_message_id_is_kept_after_the_first`). One junk copy under an honest message's id would therefore have hidden the honest copy for good. That is the shadowing `envelope_merge_key` closed for the merge. | Passed over by content: `b0x_passed_over(address, copy_key)`, where `copy_key` is the copy's `envelope_merge_key` (message id and content digest), checked after the copy opens. `b0x_consumed` keeps consumption by id, for copies of decided objects only. | `…::a_copy_passed_over_hides_no_other_copy_under_its_id`; `dsm_sdk::storage::client_db::b0x_consumed::tests::a_copy_passed_over_is_its_content_and_consumes_no_id` | Consumed by id → red: the second sync takes nothing (`pulled: 0`). | +| Every read of a member started at the read position and read at most 16 pages of 64. A copy that is never consumed holds that position, so anything more than 1,024 entries behind it was never read, whether passed over or not. A third party can make such a copy, for example a countersign whose body is not one. | `storage.sync` reads with `B0xSDK::retrieve_resuming`. Each member's read resumes at a persisted cursor (`b0x_scan_cursor`) and goes back to the read position once it reaches the end of the spool. A resumed read never moves the position. `inbox.pull` reads from the position and moves no cursor. | the page-cap test | The read never resumes → red (the payment never lands). | +| Found in review of the hand-off: the poller entered eager mode on any `more_pending`. A route pinned as above is pending again on every pass from the position, which would have held the poller at 8 s instead of 60 s. | Eager on `more_pending` only when the sync took entries (`inbox_poller::enters_eager_mode`). | `dsm_sdk::sdk::inbox_poller::tests::a_pending_route_hurries_the_poller_only_while_entries_are_taken`; the page-cap test's last pass | Any pending route hurries the poller → both red. | + +Also, without a dedicated test: the read keeps copies in spool order rather than hash order, so a sync that takes only part of a route takes the oldest, and nothing spooled later gets ahead of them. + +Each mutation was applied, its named tests run, and the code restored (2026-10-02). No node change, so nothing for the fleet. + +**Open:** a countersign, finality certificate or cert-resync message whose body is junk is refused by its own path and left on the spool, not passed over. It is never charged against the entry budget, and with the resuming read it hides nothing. But a route holding one re-reads what lies behind it each time the read returns to the position: at most 16 pages per member, at the normal cadence. Passing these over needs each path's terminal outcomes named, for example `CountersignOutcome::WireRejected` and `NoProposal`. ### 6.65 A position cell's occupant proves its own authority (`security/core-root-cell-binding`, pre-audit item 1, 2026-10-01) @@ -2453,7 +2468,7 @@ This is the owner's beta security pre-audit, item 12 (P1): "cheap garbage must n | Another fulfillment's leader link loses nothing (the old S14 exception) | Both tests red | | A registered pair's body is not compared | The unit test red | -The resolution's body check (Invalid in `peer_position`) has no test of its own yet. +The resolution's body check (Invalid in `peer_position`) is tested by `dsm_sdk::handlers::node_e2e_tests::a_root_cell_naming_the_fulfillment_with_another_body_is_invalid_for_the_lineage` (`77dc9cfc1`). B writes its pair with a claim it signs naming its `F` under another `R_realize`. The pair registers, matched by `F`'s id; a peer walking B's lineage to `q` gets Invalid with the check's reason, and the vault's walk passes over the key B's exercise holds. Mutation: the check comparing only the fulfillment id → red (the walk reads `q` as unresolved, not Invalid). The test found 12k below. **12e. A trader that withheld its pair held the vault's key (closed).** @@ -2479,8 +2494,35 @@ The resolution's body check (Invalid in `peer_position`) has no test of its own | `verify_resolution_claim` dropped from claim recognition | The same test red: this claim under another claim's signature is accepted | | Recognition drops `fulfillment_proves_the_device` | `an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing` red | +**12f. A parent on a lineage known invalid left the key waiting (closed).** + +| Field | Record | +|---|---| +| Severity | High | +| Files | `dsm/src/sofi/resolve.rs` · `trader_at_parent` (new), `Verifier::parent_for`; `dsm/src/sofi/facts.rs` · `TraderAtParent` (new), `parent_position`; `dsm/src/sofi/resolution.rs` · `ParentPosition::LineageInvalid` (new), `root_left_by` (new), `trader_parent_compatible`, `trader_parent_impossible`; `lean4/DSMSofiAtomicity.lean` · `ParentState.lineageInvalid` | +| Exploit | The walk of a trader's lineage (DSM Amendment A8) reports a lineage Invalid at or before `p`, or quarantined for a divergent write-once register cell, as a failure. `parent_for` read every failure as "not established", so the facts saw no parent and the key the exercise held waited forever. A trader whose lineage is invalid between its setups and `p` (setups checked at an earlier position pass S13) could write an exercise naming any parent at `p` and hold a vault key for good: §23.5 arm (iv) exists so that an impossible operation cannot strand a DLV key. A conditional parent that resolved Invalid is the same case. | +| Violates | SoFi §23.3 (`TraderParentImpossible(P)` when `C_p` is terminal and selected no root); Amendment S13: "No trade whose trader's lineage is known invalid can occupy a vault key indefinitely because that lineage can never yield an accepted claim." MR-SOFI-0234. | +| Verification | `dsm::sofi::resolve::tests::the_walks_verdict_on_a_traders_lineage_reaches_the_facts` (Invalid and Quarantined reach the facts as a lineage known invalid; Incomplete and Unresolved as nothing; a held claim only at the position the walk reached). `dsm::sofi::facts::tests::a_parent_on_a_lineage_known_invalid_is_terminal` (single-root and conditional named claims alike: the position Invalid, the vault's key Skipped). Lean `parent_on_an_invalid_lineage_is_invalid` with its witness in `the_trader_parent_rungs_are_not_vacuous`. No node test: an invalid lineage on the nodes needs a step that verifies as wrong, which the harness cannot produce from a device; the classification is the one `validation::setup_lineage` already applies to setups (S13), tested the same way. | +| Closure condition | The walk's verdict reaches the facts as what it is: `TraderAtParent::LineageInvalid`, so `ParentPosition::LineageInvalid`, terminal whatever `P` names, never compatible and always impossible. The two predicates now derive from the root the parent leaves `P` standing on (`root_left_by`); impossible is not compatible, since a `ParentPosition` is always terminal. | +| Status | Closed (`819396e0e`). | + +Mutation controls (12f, restored byte for byte): the walk's verdict read as no parent → `the_walks_verdict_on_a_traders_lineage_reaches_the_facts` red; a lineage known invalid read as no fact → `a_parent_on_a_lineage_known_invalid_is_terminal` red; `lineageInvalid` dropped from Lean's `parentImpossible` → three proofs fail to check. + +**12k. The trader's own device did not resolve a misbodied pair Invalid (closed).** Found by the 12j body-check test. + +| Field | Record | +|---|---| +| Severity | Low: only the trader's own key can sign the claim, and nothing advanced past the position either way | +| Files | `dsm/src/sofi/registration.rs` · `PairStanding::Misbodied` (new), `PairStanding::is_lost` (new), `RegistrationRead::standing_of`; `dsm/src/sofi/facts.rs` · `GroundFacts`, `EstablishedFacts`, `RefutedPosition` carry `pair`; `dsm/src/sofi/resolution.rs` · `RouteFacts::pair`, `resolve_position` rung 0, `resolve_refuted_in_hand`, `consumed_route`, `classify_attempt`, `skip_without_evidence`; `lean4/DSMSofiAtomicity.lean` · `Facts.misbodied`, `resolve`, `Evolves.misbodied` | +| Exploit | A pair final on `F` whose root claim names `F` under another body than `derive(P, F)` is Invalid for the trader's lineage (S20), and every peer's walk read it so (`peer_position`). The trader's own ladder read the pair as merely lost: rung 0 answered `NotRegistered`, so `sofi.resolve` answered `RetriesExhausted` on every call. The device and its peers disagreed about the same bytes. | +| Violates | SoFi Amendment S20 ("the position resolves Invalid for the trader's lineage. The vault and the lineage therefore agree"). Owner ruling, 2026-10-01 ("Fix now"): "same bytes => same terminal verdict for local and peer resolution; no retry/incomplete result once all required evidence is present; no advancing past the Invalid position; update the Rust resolution path and corresponding Lean model; add a dedicated regression test for this exact local/peer agreement case; mutation-test removal of the new fact/check and require the test to fail." | +| Verification | `dsm_sdk::handlers::node_e2e_tests::a_root_cell_naming_the_fulfillment_with_another_body_is_invalid_for_the_lineage`: a peer walking B's lineage to `q` reads Invalid, and B's own `sofi.resolve` answers `(q, Invalid)`; on the old code `(q, RetriesExhausted)`. `dsm::sofi::facts::tests::a_pair_final_on_its_fulfillment_under_another_body_resolves_invalid` (the ladder Invalid, nothing consumed, the key skipped). `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own` (Misbodied once final, Lost while settling). Lean `a_misbodied_pair_is_invalid`, `the_misbodied_rung_is_not_vacuous`, and `resolution_is_permanent` over the new rung. | +| Closure condition | The pair standing is the one fact the ladder reads for registration (`pair` replaces `registered` and `position_lost`); `Misbodied` resolves Invalid at rung 0 for the trader's own lineage and is lost for the vault's S14 skip, exactly as a peer reads it. | +| Status | Closed (`55daf37bf`). | + +Mutation controls (12k, restored byte for byte): rung 0 reading `Misbodied` as not registered → the facts test and the node test red (`(4, 4)` where `(4, 3)` is required); the `Misbodied` fact removed from `standing_of` → two unit tests red; the Lean rung dropped → the misbodied theorems and three structural proofs fail. + **Open: found here, not built.** -- **12f. A conditional parent that resolved Invalid.** §23.3 makes this parent impossible too (`C_p` terminal, no root). The A8 walk reports an Invalid position as a failure, so `trader_root_at` fails, the facts see no parent, and the key waits forever. A single-root parent whose lineage is known invalid at or before `p` is the same case, as Amendment S13 already decides for setups. The walk's verdict has to reach the facts as a terminal parent. Mine, next. Verification: read at the code. - **12g. A defeated route strands its other legs (owner ruling).** A route is Void when a reserved key's leader holds another exercise first (§24 rung 8). That is not an arm of `RouteImpossible` (§23.5 arms i–iv), so the route's other final cells are skipped only once the lost leg's parent is consumed elsewhere or orphaned. A trader that owns a vault no one else trades can hold another vault's key indefinitely: route through both, leg 1 final, leg 2's key taken first by its own other exercise. Like S14, this is a fact about `F`, not about `P` and `E`. It needs an amendment: a final cell whose fulfillment's reserved key went to another exercise is skipped. Verification: read at the code (`route_impossible`, `classify_attempt`). - **12h. Withheld evidence holds a key (owner ruling).** A registered exercise whose trader never publishes an object conformance or validation needs, such as the setup a leg names, can never be classified. §5.3 lets that position stay unresolved, but none of the skips applies, so it also holds the vault's key forever. The spec's remedy is the challenge rule (Amendment S1, storage §9.1), and Amendment S7 puts it outside beta. Verification: read at the code. - **12i. Locators anyone can compute in advance can be flooded first (owner ruling, and one fix in reach).** An index scan that exceeds its budget is Unavailable (§11). A locator computable before the honest object exists can be filled first with appends of junk, past the budget, for good. @@ -2542,16 +2584,49 @@ Also: `a_credits_source_is_validated_through_its_own_segment` (B validates C's f **Still open.** `peer_root_at`, the SoFi walk, records nothing, so §6.53's liveness bound on vault owners stands. The ruling's "Cache/update validated frontiers" answers the owner question recorded there, and SOFI holds the change. +### 6.69 The WebView runs only the app's own scripts and goes nowhere else; a release build exports the launcher only (`security/beta-pre-audit-remaining`, pre-audit items 13 and 16, 2026-10-01) + +The owner's beta security pre-audit, items 13 ("WebView hardening: Remove `unsafe-eval`, reduce/remove `unsafe-inline`, lock navigation/origins down and preserve the packaged-origin-only native bridge") and 16 ("Exported Android component hardening: Review and restrict exported activities/components capable of irreversible or security-sensitive actions"). Audited at main `e736dbca7` by a read-only pass over `public/index.html`, `MainActivity.kt` and the manifests; the tree was unchanged after it. + +**Item 13. The WebView admitted any script and any address (closed).** + +| Field | Record | +|---|---| +| Severity | Medium (P2) | +| Files | `dsm_client/frontend/public/index.html` (the policy); `dsm_client/frontend/webpack.config.js` · `InlineScriptHashes` (new), the web-development devtool; `dsm_client/frontend/scripts/webview-policy.js` (new), `validate-android-assets.js`; `dsm_client/android/.../ui/WebNavigationPolicy.kt` (new), `MainActivity.kt` · `shouldOverrideUrlLoading`, `onCreateWindow`, `openOutside` (new); `tools/sanitize-android-index.js` (deleted) | +| Exploit | Any script that reached the page ran: `script-src` admitted `'unsafe-inline'` and `'unsafe-eval'`, so an injection anywhere in what the page renders would run with the native bridge in reach. Any http(s) link the page followed was handed to another app with `ACTION_VIEW` (data in the URL leaves the device), every other scheme (`file:`, `content:`, `intent:`, `data:`) loaded in the WebView itself, and an exception while deciding loaded it too. `window.open` was forwarded to `ACTION_VIEW` unfiltered. | +| Violates | The pre-audit's item 13; the packaged-origin-only bridge (the port is posted to the app's origin alone, and nothing else may become the page). | +| Verification | The shipped page, served locally, runs its three inline scripts and mounts React with no violation; an injected inline script is blocked (`securitypolicyviolation`, `script-src-elem`). `validate-android-assets.js` (CI's `npm run build`) passes on the shipped page. `WebNavigationPolicyTest` (3 tests): the app's assets load; the QR link and the issue form are the only ways out; fourteen other addresses are refused. Android unit tests 262/0. | +| Closure condition | `script-src 'self'` plus the SHA-256 of each inline script the emitted page carries, written by the build; no `'unsafe-eval'` anywhere; `base-uri`, `form-action`, `frame-src` and `object-src` `'none'`; the shipped page is checked by CI. Every navigation and new window goes through `WebNavigationPolicy`: the app's own assets load, `dsm://native/qr/start` opens the scanner, the beta issue form (`https://github.com/deterministicstatemachine/dsm/issues/new`) opens in the browser, everything else is refused. | +| Residual | `style-src` keeps `'unsafe-inline'`: six `