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