From f01a88d25ebd830cbb0dda7d75675739fad75727 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:58:15 -0400 Subject: [PATCH 01/31] fix(storage): bound what one client can hold on a node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security pre-audit, item 10, from the storage side. Each bound has a test that holds the resource the way a hostile client would, and a mutation control that turns it red. - One count of requests in flight for the whole node. A router applies a layer to each route on its own, so ConcurrencyLimitLayer gave every route its own limit; GlobalConcurrencyLimitLayer shares one. (one_count_of_requests_in_flight_holds_across_every_route; per-route limit → red) - A request time, outside the limit, so waiting for a slot is bounded too. `[http] request_timeout_secs`, 60 by default; answered 408. (a_request_whose_body_never_arrives_is_answered_408; disabled → red) - A header-read bound on every connection, with the timer the HTTP library needs in order to keep any bound. axum-server never gave it one. `[http] header_read_timeout_secs`, 10 by default. (a_connection_that_never_finishes_its_headers_is_closed. Red without the bound, and red without the timer: hyper then drops the connection at once, which the test refuses as too early.) - The pool is bounded: 32 connections, a 10 s wait. Closers queue on the node's own lock before they take a connection, so queued closers hold none. (closers_waiting_their_turn_leave_the_pool_to_everyone_else; a lock taken and dropped → red) - A set-mate's answer is read no further than the largest ByteCommit encoding (221 bytes, derived from the fields). (a_set_mate_whose_answer_never_ends_is_read_no_further_than_a_byte_commit; uncapped → red) - The store-wide SMT builds for a close and for a proof run off the request threads (spawn_blocking). - The mirror write is durable like every other write. - Health answers "postgres unavailable" and logs the error; it no longer sends the driver's message to whoever asks. (health test; old text → red) - Spool keys are kept under their one canonical Base32 Crockford spelling, so a writer's spelling cannot put a message where its recipient never reads. (a_message_sent_under_another_spelling_of_its_key_reaches_its_spool; raw key → red) - The immutable put no longer logs the caller's header text. - Deploy: SSH host keys are accept-new, not ignored. node.key is owned by the uid and gid the image runs as, at 600, not 644. Both services' logs rotate (5 × 50 MB). The timeouts are Durations handed to the HTTP and pool libraries. No node source reads a clock; no_clock_reads stays green. Node suite (release, Postgres): lib 39, bin 4, nine integration suites including resource_limits 4/0 and bytecommit_chain 9/0. fmt, node clippy, the guard and the clock gate are clean. --- Cargo.lock | 2 + .../dsm_sdk/src/test_support/nodes.rs | 3 +- dsm_storage_node/Cargo.toml | 3 +- dsm_storage_node/config/production.toml | 4 +- .../deploy/docker-compose.node.yml | 13 ++ dsm_storage_node/deploy/provision_aws.sh | 2 +- dsm_storage_node/deploy/provision_gcp.sh | 2 +- dsm_storage_node/deploy/push_and_start.sh | 11 +- dsm_storage_node/deploy/teardown_aws.sh | 2 +- dsm_storage_node/deploy/teardown_gcp.sh | 2 +- .../src/api/objects/bytecommit.rs | 33 ++- dsm_storage_node/src/api/objects/immutable.rs | 7 +- dsm_storage_node/src/api/transport/b0x.rs | 63 ++++-- dsm_storage_node/src/db/cell_properties.rs | 7 +- dsm_storage_node/src/db/pg.rs | 71 ++++-- dsm_storage_node/src/lib.rs | 58 ++++- dsm_storage_node/src/main.rs | 24 +- dsm_storage_node/tests/bytecommit_chain.rs | 76 +++++++ dsm_storage_node/tests/common/mod.rs | 1 + dsm_storage_node/tests/health.rs | 10 +- dsm_storage_node/tests/resource_limits.rs | 213 ++++++++++++++++++ 21 files changed, 534 insertions(+), 73 deletions(-) create mode 100644 dsm_storage_node/tests/resource_limits.rs diff --git a/Cargo.lock b/Cargo.lock index 4d3631e8a..07e437e42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1284,6 +1284,7 @@ dependencies = [ "deadpool-postgres", "dsm", "futures", + "hyper-util", "log", "proc-macro2", "prost 0.14.4", @@ -4493,6 +4494,7 @@ dependencies = [ "http-body", "http-body-util", "pin-project-lite", + "tokio", "tower", "tower-layer", "tower-service", diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs index 7e8d08e6d..5194879ff 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs @@ -40,11 +40,12 @@ use dsm_storage_node::{db, set_client, AppLimits, AppState, NodeStorageSet}; /// The network whose pinned register the nodes are. const NETWORK: &[u8] = b"dsm-testnet"; -/// The deployed fleet's limits (`deploy/nodes/*/config/node.toml`). +/// The deployed fleet's limits (`dsm_storage_node/config/production.toml`). fn deployed_limits() -> AppLimits { AppLimits { body_limit_bytes: 1_048_576, concurrency_limit: 256, + request_timeout: std::time::Duration::from_secs(60), } } diff --git a/dsm_storage_node/Cargo.toml b/dsm_storage_node/Cargo.toml index 923bc9532..fb017efa1 100644 --- a/dsm_storage_node/Cargo.toml +++ b/dsm_storage_node/Cargo.toml @@ -14,6 +14,7 @@ futures = "0.3.31" # HTTP server axum = { version = "0.8", features = ["macros"] } axum-server = { version = "0.8", features = ["tls-rustls"] } # TLS listener +hyper-util = { version = "0.1", features = ["tokio", "server-auto"] } # bounds on accepted connections rustls = { version = "0.23", features = ["ring"] } # PEM cert/key parsing via the maintained rustls-pki-types crate (Issue #24). # Replaces the unmaintained rustls-pemfile (RUSTSEC-2025-0134); the @@ -21,7 +22,7 @@ rustls = { version = "0.23", features = ["ring"] } # `from_pem_reader`, etc.) on `rustls::pki_types::CertificateDer` and friends. rustls-pki-types = { version = "1", features = ["alloc"] } tower = { version = "0.5", features = ["limit"] } -tower-http = { version = "0.6", features = ["trace", "set-header", "limit"] } +tower-http = { version = "0.6", features = ["trace", "set-header", "limit", "timeout"] } # Error handling anyhow = "1.0.75" diff --git a/dsm_storage_node/config/production.toml b/dsm_storage_node/config/production.toml index 8d7a8c729..a342a6fc3 100644 --- a/dsm_storage_node/config/production.toml +++ b/dsm_storage_node/config/production.toml @@ -66,7 +66,7 @@ id = "__NODE_ID__" [network] listen_addr = "__LISTEN_ADDR__" port = 8080 -max_connections = 256 +max_connections = 256 # requests in flight at once, across every route together [tls] cert_path = "/certs/node.crt" @@ -78,3 +78,5 @@ url = "__DATABASE_URL__" [http] body_limit_bytes = 1048576 # 1 MiB +request_timeout_secs = 60 # one request, waiting for a slot included; answered 408 after +header_read_timeout_secs = 10 # a connection that has not sent a request's headers by then is closed diff --git a/dsm_storage_node/deploy/docker-compose.node.yml b/dsm_storage_node/deploy/docker-compose.node.yml index 70779360c..dfc20bb1e 100644 --- a/dsm_storage_node/deploy/docker-compose.node.yml +++ b/dsm_storage_node/deploy/docker-compose.node.yml @@ -5,6 +5,9 @@ # # Usage: # docker compose -f docker-compose.node.yml up -d +# +# Each service's log is rotated: at most five files of 50 MB, so a client +# that makes a node log cannot fill its disk. services: postgres: @@ -23,6 +26,11 @@ services: retries: 5 networks: - dsm-internal + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" storage-node: image: dsm-storage-node:latest @@ -46,6 +54,11 @@ services: retries: 3 networks: - dsm-internal + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" volumes: pgdata: diff --git a/dsm_storage_node/deploy/provision_aws.sh b/dsm_storage_node/deploy/provision_aws.sh index 9b105a5cd..7764d0547 100755 --- a/dsm_storage_node/deploy/provision_aws.sh +++ b/dsm_storage_node/deploy/provision_aws.sh @@ -110,7 +110,7 @@ MAX_WAIT=300 for IP in "${IPS_ARRAY[@]}"; do ELAPSED=0 printf " Waiting for %s..." "${IP}" - while ! ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes \ + while ! ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 -o BatchMode=yes \ -i "${SSH_KEY}" "ubuntu@${IP}" "test -f /opt/dsm-storage/.bootstrap-done" 2>/dev/null; do sleep 5 ELAPSED=$((ELAPSED + 5)) diff --git a/dsm_storage_node/deploy/provision_gcp.sh b/dsm_storage_node/deploy/provision_gcp.sh index c8dbed92d..1ad1a8364 100755 --- a/dsm_storage_node/deploy/provision_gcp.sh +++ b/dsm_storage_node/deploy/provision_gcp.sh @@ -60,7 +60,7 @@ echo "==> [3/5] Waiting for SSH readiness..." MAX_WAIT=300 for IP in "${IPS_ARRAY[@]}"; do ELAPSED=0; printf " Waiting for %s..." "${IP}" - while ! ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes \ + while ! ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 -o BatchMode=yes \ -i "${SSH_KEY}" "ubuntu@${IP}" "test -f /opt/dsm-storage/.bootstrap-done" 2>/dev/null; do sleep 5; ELAPSED=$((ELAPSED + 5)) [ "${ELAPSED}" -ge "${MAX_WAIT}" ] && echo " TIMEOUT" && exit 1 diff --git a/dsm_storage_node/deploy/push_and_start.sh b/dsm_storage_node/deploy/push_and_start.sh index d204f9410..6f80eb2cc 100755 --- a/dsm_storage_node/deploy/push_and_start.sh +++ b/dsm_storage_node/deploy/push_and_start.sh @@ -47,8 +47,9 @@ if [ "${N}" -lt 1 ]; then exit 1 fi -# SSH options -SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10" +# SSH options: a host seen for the first time is recorded; a host whose key +# has changed since is refused. +SSH_OPTS="-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10" if [ -n "${SSH_KEY}" ]; then SSH_OPTS="${SSH_OPTS} -i ${SSH_KEY}" fi @@ -115,7 +116,11 @@ set -e cd ${REMOTE_DIR} docker load -i dsm-storage-node.tar rm -f dsm-storage-node.tar -chmod 644 certs/node.key +# The key is readable by the user the image runs as and nobody else. +NODE_UID=\$(docker run --rm --entrypoint id dsm-storage-node:latest -u) +NODE_GID=\$(docker run --rm --entrypoint id dsm-storage-node:latest -g) +sudo chown "\${NODE_UID}:\${NODE_GID}" certs/node.key +sudo chmod 600 certs/node.key docker compose -f docker-compose.node.yml down 2>/dev/null || true docker compose -f docker-compose.node.yml up -d echo "Services started on ${IP}" diff --git a/dsm_storage_node/deploy/teardown_aws.sh b/dsm_storage_node/deploy/teardown_aws.sh index 092572467..29728fe22 100755 --- a/dsm_storage_node/deploy/teardown_aws.sh +++ b/dsm_storage_node/deploy/teardown_aws.sh @@ -43,7 +43,7 @@ if [ "${FORCE}" = false ] && [ -n "${SSH_KEY}" ]; then if [ -n "${IPS}" ]; then for IP in ${IPS}; do printf " Stopping %s... " "${IP}" - ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes \ + ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 -o BatchMode=yes \ -i "${SSH_KEY}" "ubuntu@${IP}" \ "cd /opt/dsm-storage && docker compose -f docker-compose.node.yml down 2>/dev/null" \ 2>/dev/null && echo "done" || echo "skipped (unreachable)" diff --git a/dsm_storage_node/deploy/teardown_gcp.sh b/dsm_storage_node/deploy/teardown_gcp.sh index d8eab36b6..0c8d33556 100755 --- a/dsm_storage_node/deploy/teardown_gcp.sh +++ b/dsm_storage_node/deploy/teardown_gcp.sh @@ -23,7 +23,7 @@ if [ "${FORCE}" = false ] && [ -n "${SSH_KEY}" ]; then cd "${SCRIPT_DIR}" for IP in ${IPS}; do printf " %s... " "${IP}" - ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes -i "${SSH_KEY}" "ubuntu@${IP}" \ + ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 -o BatchMode=yes -i "${SSH_KEY}" "ubuntu@${IP}" \ "cd /opt/dsm-storage && docker compose -f docker-compose.node.yml down 2>/dev/null" 2>/dev/null && echo "done" || echo "skipped" done fi diff --git a/dsm_storage_node/src/api/objects/bytecommit.rs b/dsm_storage_node/src/api/objects/bytecommit.rs index f034d1528..13eabdb52 100644 --- a/dsm_storage_node/src/api/objects/bytecommit.rs +++ b/dsm_storage_node/src/api/objects/bytecommit.rs @@ -49,6 +49,15 @@ const ECHO_HEADER: &str = "x-dsm-node-id"; /// far behind is 128 fetches, well inside what a client waits for one /// request (the SDK's member client waits 30 s). const MAX_SYNC_CYCLES: u64 = 128; +/// The most bytes a set-mate's answer can hold and still be a ByteCommit: +/// the largest `ByteCommitV4` encoding, field by field (tag, length, +/// value). An answer longer than this is not a ByteCommit, and is not read +/// past this point. +const MAX_BYTECOMMIT_ANSWER: usize = (1 + 2 + dsm::storage_cell::MAX_MEMBER_ID_LEN) // member id + + (1 + 10) // cycle index + + (1 + 1 + 32) // root + + (1 + 10) // bytes used + + (1 + 1 + 32); // parent digest pub fn create_router(state: Arc) -> Router<()> { Router::new() @@ -82,9 +91,13 @@ fn commit_or_absent(commit: Option) -> Response { /// Close the next cycle if anything arrived since the last; answer with the /// latest ByteCommit either way. async fn close(Extension(state): Extension>) -> Result { - let commit = db::close_cycle(&state.db_pool, state.configured_member_id.as_bytes()) - .await - .map_err(internal("close"))?; + let commit = db::close_cycle( + &state.db_pool, + &state.closing, + state.configured_member_id.as_bytes(), + ) + .await + .map_err(internal("close"))?; Ok(commit_or_absent(commit)) } @@ -200,7 +213,7 @@ async fn fetch_commit( path: &str, member: &[u8], ) -> anyhow::Result> { - let resp = client + let mut resp = client .get(format!("{}{path}", endpoint.trim_end_matches('/'))) .send() .await?; @@ -217,8 +230,16 @@ async fn fetch_commit( if echoed != member { anyhow::bail!("the node at this endpoint is not the member configured there"); } - let body = resp.bytes().await?; - let commit = dsm::types::proto::ByteCommitV4::decode(body.as_ref()) + let mut body = Vec::new(); + while let Some(chunk) = resp.chunk().await? { + if body.len() + chunk.len() > MAX_BYTECOMMIT_ANSWER { + anyhow::bail!( + "answered more than {MAX_BYTECOMMIT_ANSWER} bytes, which no ByteCommit is" + ); + } + body.extend_from_slice(&chunk); + } + let commit = dsm::types::proto::ByteCommitV4::decode(body.as_slice()) .ok() .and_then(|p| ByteCommit::from_proto(&p)) .ok_or_else(|| anyhow::anyhow!("not a ByteCommit"))?; diff --git a/dsm_storage_node/src/api/objects/immutable.rs b/dsm_storage_node/src/api/objects/immutable.rs index 5dcfb4304..40dd9b3a4 100644 --- a/dsm_storage_node/src/api/objects/immutable.rs +++ b/dsm_storage_node/src/api/objects/immutable.rs @@ -110,11 +110,10 @@ async fn put_immutable( let expected_bytes = dsm::utils::text_id::decode_base32_crockford(expected.trim()) .ok_or(StatusCode::BAD_REQUEST)?; if expected_bytes != addr { + // The caller's text is not logged: what it sent is its own, and + // the node's log records only what the node computed. log::warn!( - "immutable put: expected-addr mismatch (caller encoder disagrees): \ - expected={} computed={}", - expected, - addr_b32 + "immutable put: expected-addr mismatch (caller encoder disagrees): computed={addr_b32}" ); return Err(StatusCode::UNPROCESSABLE_ENTITY); } diff --git a/dsm_storage_node/src/api/transport/b0x.rs b/dsm_storage_node/src/api/transport/b0x.rs index 3f585aa8e..cc12d0270 100644 --- a/dsm_storage_node/src/api/transport/b0x.rs +++ b/dsm_storage_node/src/api/transport/b0x.rs @@ -19,11 +19,16 @@ use dsm::utils::text_id; const MAX_ENVELOPE_BYTES: usize = 128 * 1024; // 128 KiB (normalized) const MAX_BATCH_RETRIEVE: i64 = 64; -fn valid_spool_key(value: &str) -> bool { - matches!( - text_id::decode_base32_crockford(value), - Some(bytes) if bytes.len() == 32 - ) +/// The spool key `value` names, in its one canonical spelling, or `None` +/// when it is not Base32 Crockford of 32 bytes. Base32 Crockford reads +/// several spellings as the same bytes (either case, `I` and `L` for `1`, +/// `O` for `0`), and the spool is kept under exactly one, so the spelling a +/// writer chooses cannot put a message where its recipient never reads. +fn canonical_spool_key(value: &str) -> Option { + match text_id::decode_base32_crockford(value) { + Some(bytes) if bytes.len() == 32 => Some(text_id::encode_base32_crockford(&bytes)), + _ => None, + } } /// The b0x spool. No write authorization and no reader authorization @@ -67,12 +72,10 @@ async fn submit_b0x_envelope( let recipient_spool_key = headers .get("x-dsm-recipient") .and_then(|v| v.to_str().ok()) + .and_then(canonical_spool_key) .ok_or(StatusCode::BAD_REQUEST)?; - if !valid_spool_key(recipient_spool_key) { - return Err(StatusCode::BAD_REQUEST); - } - crate::db::spool_insert(&app.db_pool, recipient_spool_key, &body) + crate::db::spool_insert(&app.db_pool, &recipient_spool_key, &body) .await .map_err(|e| { log::error!("b0x submit: spool_insert failed: {e:?}"); @@ -93,14 +96,11 @@ async fn retrieve_b0x_batch_from_seq( let lookup_key = headers .get("x-dsm-b0x-address") .and_then(|v| v.to_str().ok()) - .filter(|v| !v.is_empty()) + .and_then(canonical_spool_key) .ok_or(StatusCode::BAD_REQUEST)?; - if !valid_spool_key(lookup_key) { - return Err(StatusCode::BAD_REQUEST); - } let rows = - crate::db::spool_list_from_seq(&app.db_pool, lookup_key, from_seq, MAX_BATCH_RETRIEVE) + crate::db::spool_list_from_seq(&app.db_pool, &lookup_key, from_seq, MAX_BATCH_RETRIEVE) .await .map_err(|e| { log::error!("b0x retrieve: spool_list_from_seq failed: {e:?}"); @@ -138,10 +138,20 @@ mod tests { use tower::ServiceExt; // oneshot #[test] - fn valid_spool_key_accepts_canonical_base32_and_rejects_bracketed_paths() { + fn a_spool_key_is_kept_in_its_one_canonical_spelling() { let routed = text_id::encode_base32_crockford(&[0x55u8; 32]); - assert!(valid_spool_key(&routed)); - assert!(!valid_spool_key("b0x[TEST][TEST][TEST]")); + assert_eq!(canonical_spool_key(&routed), Some(routed.clone())); + assert_eq!( + canonical_spool_key(&routed.to_ascii_lowercase()), + Some(routed), + "another spelling of the same bytes names the same spool" + ); + assert_eq!(canonical_spool_key("b0x[TEST][TEST][TEST]"), None); + assert_eq!( + canonical_spool_key(&text_id::encode_base32_crockford(&[0x55u8; 31])), + None, + "a key is 32 bytes" + ); } /// The b0x router on the Postgres test database. @@ -502,6 +512,25 @@ mod tests { } /// A spool nothing was sent to answers with no content. + /// The spool a message lands in does not depend on how its writer spells + /// the key. A submission under another spelling of the same 32 bytes is + /// read back under the canonical spelling its recipient reads, and under + /// the writer's spelling too: one spool, whatever the spelling. + #[tokio::test] + async fn a_message_sent_under_another_spelling_of_its_key_reaches_its_spool() { + let app = spool().await; + let spool_key = crate::db::test_store::unique_name(0x62); + let spelled = spool_key.to_ascii_lowercase(); + assert_ne!(spelled, spool_key, "the key has letters to spell otherwise"); + let body = sealed_envelope().outer.encode_to_vec(); + assert_eq!( + submit(&app, &spelled, "application/octet-stream", body.clone()).await, + HttpStatus::NO_CONTENT + ); + assert_eq!(spooled(&app, &spool_key).await, vec![body.clone()]); + assert_eq!(spooled(&app, &spelled).await, vec![body]); + } + #[tokio::test] async fn an_empty_spool_answers_no_content() { let app = spool().await; diff --git a/dsm_storage_node/src/db/cell_properties.rs b/dsm_storage_node/src/db/cell_properties.rs index 907760362..71f5aa6e4 100644 --- a/dsm_storage_node/src/db/cell_properties.rs +++ b/dsm_storage_node/src/db/cell_properties.rs @@ -305,10 +305,13 @@ async fn bytecommits_close_link_and_prove_on_this_backend() { let member = b"dsm-node-props"; let key = unique_key(0x3C); // Other tests share this store: first flush whatever they left pending. - let base = db::close_cycle(&pool, member).await.expect("flush"); + let closing = tokio::sync::Mutex::new(()); + let base = db::close_cycle(&pool, &closing, member) + .await + .expect("flush"); let r1 = db::put_cell(&pool, NS, &key, b"one").await.expect("put"); - let c1 = db::close_cycle(&pool, member) + let c1 = db::close_cycle(&pool, &closing, member) .await .expect("close") .expect("a cycle"); diff --git a/dsm_storage_node/src/db/pg.rs b/dsm_storage_node/src/db/pg.rs index 000ae05bf..610127736 100644 --- a/dsm_storage_node/src/db/pg.rs +++ b/dsm_storage_node/src/db/pg.rs @@ -512,7 +512,7 @@ async fn verify_schema_layout(client: &deadpool_postgres::Object) -> Result<()> /// `require_durable_commit_posture` additionally refuses to /// start a node whose server-level settings could defeat this. /// -/// Cell, index, ByteCommit and spool writes go through this function; the +/// Cell, index, ByteCommit, mirror and spool writes go through this function; the /// immutable store's serializable transaction sets the same posture itself. async fn begin_durable_write( client: &mut deadpool_postgres::Client, @@ -662,6 +662,15 @@ pub type DBPool = Pool; /// from the host name. `sslmode=prefer` (the Postgres default) would fall /// back to plaintext when the server offers no TLS, so every other mode is /// held to `require`. +/// Connections a node holds to Postgres at most. Every request that touches +/// the store shares them. +pub const POOL_MAX_SIZE: usize = 32; + +/// How long a request waits for one of those connections before it fails, +/// answered as a node that could not serve it rather than left waiting. A +/// transport bound, as an unreachable node is (storage spec §1 rule 4). +pub const POOL_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + pub fn create_pool(database_url: &str) -> anyhow::Result { use tokio_postgres::config::SslMode; @@ -678,7 +687,11 @@ pub fn create_pool(database_url: &str) -> anyhow::Result { pg.ssl_mode(SslMode::Require); Manager::from_config(pg, create_tls_connector(), manager_config) }; - Ok(Pool::builder(manager).runtime(Runtime::Tokio1).build()?) + Ok(Pool::builder(manager) + .max_size(POOL_MAX_SIZE) + .wait_timeout(Some(POOL_WAIT_TIMEOUT)) + .runtime(Runtime::Tokio1) + .build()?) } // ── keyed cells and indexes: bytes in, bytes out ─────────────────────────── @@ -857,22 +870,26 @@ pub async fn get_cell_entries( /// one — all in one durable transaction. Returns the latest ByteCommit /// (the new one, or the existing one if nothing arrived), or `None` if this /// node has never closed a cycle and holds no entries. +/// +/// `closing` is the node's own closer lock: a closer waits on it before it +/// takes a database connection, so closers queued behind one another hold +/// none. The advisory lock on [`CLOSE_LOCK`] still keeps closers of the same +/// database apart across processes. pub async fn close_cycle( pool: &Pool, + closing: &tokio::sync::Mutex<()>, member_id: &[u8], ) -> Result> { use prost::Message; if member_id.is_empty() || member_id.len() > dsm::storage_cell::MAX_MEMBER_ID_LEN { anyhow::bail!("member id cannot name a ByteCommit"); } + let _one_closer = closing.lock().await; let mut client = pool.get().await?; let tx = begin_durable_write(&mut client).await?; // One closer at a time; puts are not blocked. - tx.execute( - "SELECT pg_advisory_xact_lock($1)", - &[&0x4453_4D42_434C_4F53_i64], - ) - .await?; + tx.execute("SELECT pg_advisory_xact_lock($1)", &[&CLOSE_LOCK]) + .await?; let last = tx .query_opt( "SELECT commit_pb FROM own_bytecommits ORDER BY cycle_index DESC LIMIT 1", @@ -900,8 +917,13 @@ pub async fn close_cycle( ) .await?; let leaves = cell_leaves_tx(&tx, cycle_i64).await?; - let tree = - dsm::storage_cell::cell_tree(leaves.iter().map(|(n, k, i, h)| (n.as_slice(), k, *i, h))); + // The tree is built over every cell the node holds: CPU work, kept off + // the threads that serve requests. + let smt_root = tokio::task::spawn_blocking(move || { + *dsm::storage_cell::cell_tree(leaves.iter().map(|(n, k, i, h)| (n.as_slice(), k, *i, h))) + .root() + }) + .await?; let bytes_used: i64 = tx .query_one( "SELECT COALESCE((SELECT SUM(LENGTH(value)) FROM cells), 0)::BIGINT @@ -913,7 +935,7 @@ pub async fn close_cycle( let commit = dsm::storage_cell::ByteCommit { member_id: member_id.to_vec(), cycle_index: cycle, - smt_root: *tree.root(), + smt_root, bytes_used: u64::try_from(bytes_used.max(0))?, parent_digest: last.as_ref().map_or([0u8; 32], |c| c.digest()), }; @@ -931,6 +953,10 @@ pub async fn close_cycle( Ok(Some(commit)) } +/// The advisory lock closers of one database take, so two processes on the +/// same database never close at once. +pub const CLOSE_LOCK: i64 = 0x4453_4D42_434C_4F53; + fn decode_commit(bytes: &[u8]) -> Result { use prost::Message; let p = dsm::types::proto::ByteCommitV4::decode(bytes)?; @@ -1032,15 +1058,16 @@ pub async fn cell_commit_proof( else { return Ok(None); }; - let tree = - dsm::storage_cell::cell_tree(leaves.iter().map(|(n, k, i, h)| (n.as_slice(), k, *i, h))); - Ok(dsm::storage_cell::CellCommitProof::from_tree( - &tree, - namespace, - key, - index, - running_hash, - )) + // The tree is built over every cell the node holds: CPU work, kept off + // the threads that serve requests. + let (namespace, key) = (namespace.to_vec(), *key); + Ok(tokio::task::spawn_blocking(move || { + let tree = dsm::storage_cell::cell_tree( + leaves.iter().map(|(n, k, i, h)| (n.as_slice(), k, *i, h)), + ); + dsm::storage_cell::CellCommitProof::from_tree(&tree, &namespace, &key, index, running_hash) + }) + .await?) } /// Keep a ByteCommit this node fetched from `member_id` itself. Idempotent @@ -1048,8 +1075,9 @@ pub async fn cell_commit_proof( /// beside the first as evidence. Returns whether it was not already held. pub async fn mirror_put(pool: &Pool, commit: &dsm::storage_cell::ByteCommit) -> Result { use prost::Message; - let client = pool.get().await?; - let inserted = client + let mut client = pool.get().await?; + let tx = begin_durable_write(&mut client).await?; + let inserted = tx .execute( "INSERT INTO bytecommit_mirror (member_id, cycle_index, digest, commit_pb) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", @@ -1061,6 +1089,7 @@ pub async fn mirror_put(pool: &Pool, commit: &dsm::storage_cell::ByteCommit) -> ], ) .await?; + tx.commit().await?; Ok(inserted == 1) } diff --git a/dsm_storage_node/src/lib.rs b/dsm_storage_node/src/lib.rs index e101f8124..2d720c698 100644 --- a/dsm_storage_node/src/lib.rs +++ b/dsm_storage_node/src/lib.rs @@ -31,6 +31,10 @@ pub struct AppState { /// Held for the whole of a ByteCommit mirror sync, so one sync runs at a /// time and a caller asking again waits for it instead of repeating it. pub mirror_sync: Arc>, + /// Held while a cycle closes. A closer waits here, before it takes a + /// database connection, so closers queued behind one another hold no + /// connection and cannot starve every other request of the pool. + pub closing: Arc>, } /// This node's view of the canonical storage set it belongs to. @@ -154,6 +158,7 @@ impl AppState { set_client, storage_set: None, mirror_sync: Arc::new(tokio::sync::Mutex::new(())), + closing: Arc::new(tokio::sync::Mutex::new(())), }) } @@ -271,31 +276,62 @@ mod storage_set_tests { #[derive(Debug, Clone, Copy)] pub struct AppLimits { pub body_limit_bytes: usize, + /// Requests in flight at once, across every route together. pub concurrency_limit: usize, + /// How long one request may take, from the moment it waits for a slot + /// under `concurrency_limit` until it is answered, its body included. + /// A transport bound, as an unreachable node is: a request cut off here + /// is answered `408` and changes nothing, so no protocol fact depends on + /// it (storage spec §1 rule 4). + pub request_timeout: std::time::Duration, } /// The node's whole app: every route it serves, with its limits and layers. /// `/api/v2/health`: ok only over a live Postgres. A node whose store is /// down is not healthy, whatever its process is doing. -async fn health(state: std::sync::Arc) -> (axum::http::StatusCode, String) { +/// What went wrong stays in the node's log: the answer names no host, +/// user, database or driver message. +async fn health(state: std::sync::Arc) -> (axum::http::StatusCode, &'static str) { use axum::http::StatusCode; let client = match state.db_pool.get().await { Ok(client) => client, - Err(e) => return (StatusCode::SERVICE_UNAVAILABLE, format!("postgres: {e}")), + Err(e) => { + log::error!("health: no database connection: {e}"); + return (StatusCode::SERVICE_UNAVAILABLE, "postgres unavailable"); + } }; match client.simple_query("SELECT 1").await { - Ok(_) => (StatusCode::OK, "ok".to_string()), - Err(e) => (StatusCode::SERVICE_UNAVAILABLE, format!("postgres: {e}")), + Ok(_) => (StatusCode::OK, "ok"), + Err(e) => { + log::error!("health: the database did not answer: {e}"); + (StatusCode::SERVICE_UNAVAILABLE, "postgres unavailable") + } } } +/// Bound every connection the node accepts before a request on it reaches +/// the app: a client that opens a connection and never finishes sending a +/// request's headers is cut off after `header_read`, instead of holding the +/// connection open for as long as it likes. The server is given a timer for +/// this, as the HTTP library needs one to keep any bound; without it the +/// bound is never kept. A transport bound only (storage spec §1 rule 4). +pub fn bound_connections( + builder: &mut hyper_util::server::conn::auto::Builder, + header_read: std::time::Duration, +) { + builder + .http1() + .timer(hyper_util::rt::TokioTimer::new()) + .header_read_timeout(header_read); +} + /// The binary serves exactly this, and so do tests that stand up real nodes, /// so no test ever runs against an assembly the binary does not serve. pub fn build_app(state: std::sync::Arc, limits: AppLimits) -> axum::Router<()> { use axum::routing::get; use axum::Router; - use tower::limit::ConcurrencyLimitLayer; - use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer}; + use tower::limit::GlobalConcurrencyLimitLayer; + use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer, trace::TraceLayer}; Router::new() .route( @@ -312,7 +348,15 @@ pub fn build_app(state: std::sync::Arc, limits: AppLimits) -> axum::Ro // envelopes never opened. .merge(crate::api::transport::b0x::router(state.clone())) .layer(RequestBodyLimitLayer::new(limits.body_limit_bytes)) - .layer(ConcurrencyLimitLayer::new(limits.concurrency_limit)) + // One limit for the whole node. A router applies a layer to each + // route on its own, so a per-service limit would be one limit per + // route; this one shares a single count across all of them. + .layer(GlobalConcurrencyLimitLayer::new(limits.concurrency_limit)) + // Outside the limit, so waiting for a slot is bounded too. + .layer(TimeoutLayer::with_status_code( + axum::http::StatusCode::REQUEST_TIMEOUT, + limits.request_timeout, + )) .layer(TraceLayer::new_for_http()) .layer(crate::node_identity_echo_layer( state.member_id_header.clone(), diff --git a/dsm_storage_node/src/main.rs b/dsm_storage_node/src/main.rs index 2d2b262a7..4cff724fd 100644 --- a/dsm_storage_node/src/main.rs +++ b/dsm_storage_node/src/main.rs @@ -41,6 +41,12 @@ struct ServerConfig { /// peers to. set_ca_path: String, body_limit_bytes: usize, + /// `[http] request_timeout_secs`: how long one request may take, waiting + /// for a slot included. + request_timeout_secs: usize, + /// `[http] header_read_timeout_secs`: how long a connection has to send + /// a request's headers. + header_read_timeout_secs: usize, database_url: String, /// `[[storage_set.members]]` — each member's id and register incarnation. storage_set_members: Vec<(String, [u8; 32])>, @@ -77,6 +83,9 @@ fn load_server_config(opts: &Opts) -> Result { let concurrency_limit = optional_positive(&settings, "network.max_connections", 256)?; let body_limit_bytes = optional_positive(&settings, "http.body_limit_bytes", 1_048_576)?; + let request_timeout_secs = optional_positive(&settings, "http.request_timeout_secs", 60)?; + let header_read_timeout_secs = + optional_positive(&settings, "http.header_read_timeout_secs", 10)?; let tls_cert_path = required(&settings, "tls.cert_path")?; let tls_key_path = required(&settings, "tls.key_path")?; @@ -161,6 +170,8 @@ fn load_server_config(opts: &Opts) -> Result { tls_key_path, set_ca_path, body_limit_bytes, + request_timeout_secs, + header_read_timeout_secs, database_url, storage_set_members, storage_set_endpoints, @@ -285,6 +296,9 @@ async fn async_main() -> Result<()> { dsm_storage_node::AppLimits { body_limit_bytes: server_config.body_limit_bytes, concurrency_limit: server_config.concurrency_limit, + request_timeout: std::time::Duration::from_secs(u64::try_from( + server_config.request_timeout_secs, + )?), }, ); @@ -325,8 +339,12 @@ async fn async_main() -> Result<()> { RustlsConfig::from_pem_file(&server_config.tls_cert_path, &server_config.tls_key_path) .await .context("failed to load TLS certificates")?; - axum_server::bind_rustls(server_config.bind_addr, tls_config) - .handle(handle) + let mut server = axum_server::bind_rustls(server_config.bind_addr, tls_config).handle(handle); + dsm_storage_node::bound_connections( + server.http_builder(), + std::time::Duration::from_secs(u64::try_from(server_config.header_read_timeout_secs)?), + ); + server .serve(app.into_make_service_with_connect_info::()) .await .context("storage node TLS server error")?; @@ -419,6 +437,8 @@ url = "postgresql://127.0.0.1:5432/node" let whole = load(WHOLE_CONFIG).expect("the whole config loads"); assert_eq!(whole.concurrency_limit, 256); assert_eq!(whole.body_limit_bytes, 1_048_576); + assert_eq!(whole.request_timeout_secs, 60); + assert_eq!(whole.header_read_timeout_secs, 10); let with = |extra: &str| format!("{WHOLE_CONFIG}\n{extra}\n"); let set = load(&with("[http]\nbody_limit_bytes = 4096")).expect("a stated limit loads"); diff --git a/dsm_storage_node/tests/bytecommit_chain.rs b/dsm_storage_node/tests/bytecommit_chain.rs index 361cf80d2..f030b5631 100644 --- a/dsm_storage_node/tests/bytecommit_chain.rs +++ b/dsm_storage_node/tests/bytecommit_chain.rs @@ -378,6 +378,82 @@ async fn a_set_mate_that_never_answers_fails_the_sync_and_frees_it() { } } +/// A set-mate whose answer outgrows any ByteCommit fails the sync, and the +/// node reads no further than a ByteCommit could reach instead of taking in +/// whatever it is sent (storage spec §14, mirror sync: what a node keeps is a +/// ByteCommit). The set-mate names itself correctly and then streams without +/// end, counting what it gets to send before the node hangs up on it. +#[tokio::test] +async fn a_set_mate_whose_answer_never_ends_is_read_no_further_than_a_byte_commit() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + /// Where the set-mate stops on its own, for a node that never hangs up. + const FLOOD: usize = 128 << 20; + let (flood, flood_url) = listener().await; + let sent = Arc::new(AtomicUsize::new(0)); + // How the set-mate's stream ended: the error its next write met once the + // node had hung up, or nothing while it is still sending. + let hung_up = Arc::new(tokio::sync::Mutex::new(None::)); + let (counted, ended) = (sent.clone(), hung_up.clone()); + tokio::spawn(async move { + while let Ok((mut socket, _)) = flood.accept().await { + let (counted, ended) = (counted.clone(), ended.clone()); + tokio::spawn(async move { + let mut request = [0u8; 4096]; + match socket.read(&mut request).await { + Ok(n) if n > 0 => {} + _ => return, + } + let streamed: std::io::Result<()> = async { + socket + .write_all( + b"HTTP/1.1 200 OK\r\nx-dsm-node-id: dsm-node-a\r\n\ + content-type: application/octet-stream\r\n\r\n", + ) + .await?; + let chunk = [0xA5u8; 64 * 1024]; + while counted.load(Ordering::SeqCst) < FLOOD { + socket.write_all(&chunk).await?; + counted.fetch_add(chunk.len(), Ordering::SeqCst); + } + Ok(()) + } + .await; + if let Err(e) = streamed { + *ended.lock().await = Some(e.kind()); + } + }); + } + }); + let b = app(node_state( + "bc_flood_b", + "dsm-node-b", + &["dsm-node-a", "dsm-node-b"], + &[("dsm-node-a", &flood_url)], + ) + .await); + assert_eq!(sync(&b).await, StatusCode::BAD_GATEWAY); + // The set-mate learns the node hung up at its next write that the + // socket's buffers cannot take. + let mut how = None; + for _ in 0..50 { + how = *hung_up.lock().await; + if how.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + let read = sent.load(Ordering::SeqCst); + assert!( + how.is_some(), + "the node never hung up on an answer that kept coming ({read} bytes sent)" + ); + assert!( + read < 16 << 20, + "the node took in {read} bytes of one answer, where a ByteCommit is a few hundred" + ); +} + /// A set-mate that does not answer at its configured endpoint fails the /// sync; the node does not report the sync as done. #[tokio::test] diff --git a/dsm_storage_node/tests/common/mod.rs b/dsm_storage_node/tests/common/mod.rs index 97dae39ab..e2df0106d 100644 --- a/dsm_storage_node/tests/common/mod.rs +++ b/dsm_storage_node/tests/common/mod.rs @@ -143,6 +143,7 @@ pub fn served(state: std::sync::Arc) -> axum::Router dsm_storage_node::AppLimits { body_limit_bytes: 1_048_576, concurrency_limit: 256, + request_timeout: std::time::Duration::from_secs(60), }, ) } diff --git a/dsm_storage_node/tests/health.rs b/dsm_storage_node/tests/health.rs index c8a556195..b57d38e0d 100644 --- a/dsm_storage_node/tests/health.rs +++ b/dsm_storage_node/tests/health.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 //! `/api/v2/health` answers ok only over a live Postgres: it checks a //! connection out of the pool and runs a query on it, so a node whose store is -//! down reports 503, never "ok". +//! down reports 503, never "ok", and says nothing more about its database. // `#[tokio::test]` builds its runtime with `expect`, as every suite here does. #![allow(clippy::disallowed_methods)] @@ -45,12 +45,14 @@ async fn the_health_route_answers_ok_only_over_a_live_postgres() { assert_eq!(body, "ok"); // The store goes away under the running node: no connection can be - // checked out, and health says so. + // checked out, and health says so, and only so. What went wrong stays in + // the node's log; the answer names no host, user, database or driver + // message to whoever asks. state.db_pool.close(); let (status, body) = health_of(&app).await; // Both halves in one comparison, so a failure prints the whole answer. assert_eq!( - (status, body.get(..10)), - (StatusCode::SERVICE_UNAVAILABLE, Some("postgres: ")) + (status, body.as_str()), + (StatusCode::SERVICE_UNAVAILABLE, "postgres unavailable") ); } diff --git a/dsm_storage_node/tests/resource_limits.rs b/dsm_storage_node/tests/resource_limits.rs new file mode 100644 index 000000000..d19d08e40 --- /dev/null +++ b/dsm_storage_node/tests/resource_limits.rs @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 +//! What one client can hold on a node, and for how long. A request in flight +//! counts once against one limit for the whole node, whichever route it is +//! on; a request is answered within the node's request time, its body +//! included; a closer waiting its turn holds no database connection; and a +//! connection that never finishes sending a request's headers is closed. Each +//! test holds the resource the way a hostile client would and checks what +//! everyone else still gets. + +mod common; + +use axum::body::{Body, Bytes}; +use axum::http::{Request, StatusCode}; +use dsm_storage_node::{build_app, db, AppLimits, AppState}; +use std::sync::Arc; +use std::time::Duration; +use tower::ServiceExt; + +/// A node over a fresh store named `store`, its app built with `limits`. +async fn node(store: &str, limits: AppLimits) -> axum::Router { + let pool = common::fresh_store(store).await; + let state = Arc::new(common::ok_or_panic( + AppState::new("member".to_string(), pool, common::set_client()), + "app state", + )); + build_app(state, limits) +} + +fn limits(concurrency_limit: usize, request_timeout: Duration) -> AppLimits { + AppLimits { + body_limit_bytes: 1_048_576, + concurrency_limit, + request_timeout, + } +} + +/// The sending half of a request body that arrives only when it is dropped. +type HeldBody = futures::channel::mpsc::Sender>; + +/// A spool submission whose body has not arrived, and the sender that holds +/// it back for as long as it is kept. +fn held_submission() -> (HeldBody, Request) { + let (sender, body) = futures::channel::mpsc::channel(1); + let recipient = dsm::utils::text_id::encode_base32_crockford(&[0x5Au8; 32]); + let request = common::ok_or_panic( + Request::builder() + .method("POST") + .uri("/api/v2/b0x/submit") + .header("content-type", "application/octet-stream") + .header("x-dsm-recipient", recipient) + .body(Body::from_stream(body)), + "request", + ); + (sender, request) +} + +fn health() -> Request { + common::ok_or_panic( + Request::builder() + .method("GET") + .uri("/api/v2/health") + .body(Body::empty()), + "request", + ) +} + +/// With room for one request in flight, a request held on the spool route +/// keeps a request on the health route waiting: the limit is one count for +/// the node, not one per route. Once the held body arrives, the waiting +/// request is answered. +#[test] +fn one_count_of_requests_in_flight_holds_across_every_route() { + common::runtime().block_on(async { + let app = node("rl_one_count", limits(1, Duration::from_secs(60))).await; + let (sender, held) = held_submission(); + let holder = tokio::spawn(app.clone().oneshot(held)); + // The held request takes the one slot and waits for its body. + tokio::time::sleep(Duration::from_millis(200)).await; + let waiting = tokio::spawn(app.clone().oneshot(health())); + tokio::time::sleep(Duration::from_millis(500)).await; + assert!( + !waiting.is_finished(), + "a request on another route was served while the node's one slot was held" + ); + + drop(sender); + let answered = match tokio::time::timeout(Duration::from_secs(10), waiting).await { + Ok(joined) => common::ok_or_panic(common::ok_or_panic(joined, "task"), "answer"), + Err(elapsed) => panic!("the waiting request was never served: {elapsed}"), + }; + assert_eq!(answered.status(), StatusCode::OK); + let held = common::ok_or_panic(common::ok_or_panic(holder.await, "task"), "answer"); + assert_eq!(held.status(), StatusCode::NO_CONTENT); + }); +} + +/// A request whose body never arrives is answered `408` once the node's +/// request time has passed, instead of holding its slot and its connection +/// for as long as the client keeps it open. +#[test] +fn a_request_whose_body_never_arrives_is_answered_408() { + common::runtime().block_on(async { + let app = node("rl_timeout", limits(256, Duration::from_secs(1))).await; + let (sender, held) = held_submission(); + let answer = match tokio::time::timeout(Duration::from_secs(10), app.oneshot(held)).await { + Ok(answer) => common::ok_or_panic(answer, "the app answers"), + Err(elapsed) => panic!("a request whose body never arrived held the node: {elapsed}"), + }; + assert_eq!(answer.status(), StatusCode::REQUEST_TIMEOUT); + drop(sender); + }); +} + +/// Closers that must wait their turn hold no database connection while they +/// wait. With the close lock held elsewhere and more closers queued than the +/// pool has connections, a read still gets a connection at once. +#[test] +fn closers_waiting_their_turn_leave_the_pool_to_everyone_else() { + common::runtime().block_on(async { + let pool = common::fresh_store("rl_closers").await; + // Another holder of the close lock keeps every closer waiting. + let blocker = common::ok_or_panic(pool.get().await, "a connection"); + common::ok_or_panic( + blocker + .execute("SELECT pg_advisory_lock($1)", &[&db::CLOSE_LOCK]) + .await, + "take the close lock", + ); + let closing = Arc::new(tokio::sync::Mutex::new(())); + let closers: Vec<_> = (0..db::POOL_MAX_SIZE + 4) + .map(|_| { + let (pool, closing) = (pool.clone(), closing.clone()); + tokio::spawn(async move { db::close_cycle(&pool, &closing, b"member").await }) + }) + .collect(); + tokio::time::sleep(Duration::from_millis(500)).await; + + let read = tokio::time::timeout( + Duration::from_secs(5), + db::get_cell_entries(&pool, b"DSM/resource-limits", &[0x11; 32]), + ) + .await; + match read { + Ok(entries) => assert_eq!(common::ok_or_panic(entries, "the read").len(), 0), + Err(elapsed) => panic!("a read waited for a connection the closers held: {elapsed}"), + } + + common::ok_or_panic( + blocker + .execute("SELECT pg_advisory_unlock($1)", &[&db::CLOSE_LOCK]) + .await, + "release the close lock", + ); + for closer in closers { + common::ok_or_panic(common::ok_or_panic(closer.await, "task"), "the close"); + } + }); +} + +/// A connection that opens and never finishes sending a request's headers is +/// closed once the header time has passed, and not before: a connection that +/// ends early ended for some other reason (a server that cannot keep the +/// bound drops it at once). The node's own server is started with the node's +/// own connection bounds, over plain HTTP: the bound is set on the connection +/// builder, the same one the node's TLS listener uses. +#[test] +fn a_connection_that_never_finishes_its_headers_is_closed() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + common::runtime().block_on(async { + let app = node("rl_headers", limits(256, Duration::from_secs(60))).await; + // Tokio's listener, handed over as the server takes it: already in + // the non-blocking mode the server needs. + let listener = + common::ok_or_panic(tokio::net::TcpListener::bind("127.0.0.1:0").await, "bind"); + let address = common::ok_or_panic(listener.local_addr(), "address"); + let listener = common::ok_or_panic(listener.into_std(), "the listener"); + let mut server = common::ok_or_panic(axum_server::from_tcp(listener), "a server"); + let header_time = Duration::from_secs(1); + dsm_storage_node::bound_connections(server.http_builder(), header_time); + tokio::spawn(server.serve(app.into_make_service())); + + let mut socket = + common::ok_or_panic(tokio::net::TcpStream::connect(address).await, "connect"); + common::ok_or_panic( + socket + .write_all(b"GET /api/v2/health HTTP/1.1\r\nhost: node\r\n") + .await, + "half of a request's headers", + ); + let opened = tokio::time::Instant::now(); + let mut rest = Vec::new(); + let read = + tokio::time::timeout(Duration::from_secs(10), socket.read_to_end(&mut rest)).await; + let held = opened.elapsed(); + assert!( + held >= header_time.mul_f32(0.9), + "the connection ended after {held:?}, before the header time of {header_time:?}" + ); + match read { + Ok(Ok(_)) => {} + Ok(Err(e)) => assert!( + matches!( + e.kind(), + std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::BrokenPipe + ), + "the connection ended in an error that is not the node closing it: {e}" + ), + Err(elapsed) => { + panic!("a connection that never finished its headers was held open: {elapsed}") + } + } + }); +} From 92d0ace203aaa64f6c54114c24615c4d5672d296 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:22:02 -0400 Subject: [PATCH 02/31] fix(storage): immutable puts made at once never refuse each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit insert_immutable_object_if_absent ran SERIALIZABLE (SELECT … FOR UPDATE, then INSERT) with no retry. Concurrent puts of different objects failed with serialization failures (40001), which the node answered as 500. SOFI measured 6 of 16 on the rig node set. The put is now read committed and idempotent: - INSERT … ON CONFLICT (addr_b32) DO NOTHING; - when nothing was inserted, the held tuple is read to tell an identical copy from a conflicting one. Different objects never contend, and puts at one address wait for each other at the insert. It runs through begin_durable_write, like every other write. Test: puts_made_at_once_are_each_taken. Three rounds of 64 distinct objects put at once, each taken, and 16 identical puts at once, each acknowledged under one address. Mutation control: the serializable put → red, 29 of 64 refused with 500 in round 0. The corruption test still reports a different tuple at an address. immutable_store_round_trip: 8/0. --- dsm_storage_node/src/db/pg.rs | 65 ++++++++----------- .../tests/immutable_store_round_trip.rs | 40 ++++++++++++ 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/dsm_storage_node/src/db/pg.rs b/dsm_storage_node/src/db/pg.rs index 610127736..6f584b066 100644 --- a/dsm_storage_node/src/db/pg.rs +++ b/dsm_storage_node/src/db/pg.rs @@ -512,8 +512,8 @@ async fn verify_schema_layout(client: &deadpool_postgres::Object) -> Result<()> /// `require_durable_commit_posture` additionally refuses to /// start a node whose server-level settings could defeat this. /// -/// Cell, index, ByteCommit, mirror and spool writes go through this function; the -/// immutable store's serializable transaction sets the same posture itself. +/// Cell, index, ByteCommit, mirror, immutable and spool writes go through +/// this function. async fn begin_durable_write( client: &mut deadpool_postgres::Client, ) -> Result> { @@ -541,47 +541,36 @@ pub async fn insert_immutable_object_if_absent( namespace: &[u8], payload: &[u8], ) -> Result { - use tokio_postgres::IsolationLevel; - + // Read committed and idempotent: the insert takes the address only if no + // tuple holds it, and a put that finds it held reads what is there. Two + // puts of different objects never contend, and two puts at one address + // wait for each other at the insert, so no put fails for having run + // beside another (a serializable put did, under concurrent load). let mut client = pool.get().await?; - let tx = client - .build_transaction() - .isolation_level(IsolationLevel::Serializable) - .start() - .await?; - tx.batch_execute("SET LOCAL synchronous_commit = on") - .await?; - - let row = tx - .query_opt( - "SELECT namespace, payload FROM immutable_objects WHERE addr_b32 = $1 FOR UPDATE", - &[&addr_b32], + let tx = begin_durable_write(&mut client).await?; + let inserted = tx + .execute( + "INSERT INTO immutable_objects (addr_b32, namespace, payload) + VALUES ($1, $2, $3) ON CONFLICT (addr_b32) DO NOTHING", + &[&addr_b32, &namespace, &payload], ) .await?; - - let outcome = match row { - Some(r) => { - let ns: Vec = r.get(0); - let pl: Vec = r.get(1); - if ns == namespace && pl == payload { - ImmutablePutOutcome::AlreadyExistsIdentical - } else { - ImmutablePutOutcome::Conflict - } - } - None => { - let stmt = tx - .prepare_cached( - "INSERT INTO immutable_objects (addr_b32, namespace, payload) - VALUES ($1, $2, $3)", - ) - .await?; - tx.execute(&stmt, &[&addr_b32, &namespace, &payload]) - .await?; - ImmutablePutOutcome::Inserted + let outcome = if inserted == 1 { + ImmutablePutOutcome::Inserted + } else { + let held = tx + .query_one( + "SELECT namespace, payload FROM immutable_objects WHERE addr_b32 = $1", + &[&addr_b32], + ) + .await?; + let (ns, pl): (Vec, Vec) = (held.get(0), held.get(1)); + if ns == namespace && pl == payload { + ImmutablePutOutcome::AlreadyExistsIdentical + } else { + ImmutablePutOutcome::Conflict } }; - tx.commit().await?; Ok(outcome) } diff --git a/dsm_storage_node/tests/immutable_store_round_trip.rs b/dsm_storage_node/tests/immutable_store_round_trip.rs index ee8912b2b..e7a37ffc0 100644 --- a/dsm_storage_node/tests/immutable_store_round_trip.rs +++ b/dsm_storage_node/tests/immutable_store_round_trip.rs @@ -312,3 +312,43 @@ async fn different_bytes_at_an_address_are_reported_as_corruption() { "the held row is left as it was" ); } + +/// Puts made at one member at once never refuse each other. Three rounds of +/// sixty-four different objects, each round put all at once, are each taken; +/// and sixteen puts of one object at once are each acknowledged, under the +/// one address. A serializable put refused some of a concurrent burst with a +/// 500 (a serialization failure) though no two of them touched the same +/// address, so a device's write could fail for having run beside another's. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn puts_made_at_once_are_each_taken() { + let app = member("immutable_concurrent").await; + for round in 0..3u8 { + let objects: Vec<[u8; 3]> = (0..64u8).map(|i| [round, i, 0x5A]).collect(); + let answers = futures::future::join_all( + objects + .iter() + .map(|object| put(&app, "DSM/test/concurrent", object)), + ) + .await; + let refused: Vec = answers + .iter() + .map(|(status, _)| *status) + .filter(|status| !status.is_success()) + .collect(); + assert_eq!( + refused, + Vec::::new(), + "round {round}: distinct puts refused while others ran" + ); + } + + let answers = futures::future::join_all( + (0..16).map(|_| put(&app, "DSM/test/concurrent", b"one object put at once")), + ) + .await; + let first = answers[0].1.clone(); + for (status, addr) in &answers { + assert!(status.is_success(), "an identical put was refused: {status}"); + assert_eq!(addr, &first, "every identical put names the one address"); + } +} From 1ef20cf7a5f0b5d9c50aa71e5486bbe061b0ab79 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:53:11 -0400 Subject: [PATCH 03/31] fix(sofi): a trader's single-root parent is what its lineage holds at K_root(p), never a claim P carries Pre-audit item 2 (Critical), audited at main 076792eac. The flaw: - A trader could name, as P's parent, a single-root claim it signed for itself, with a root it never held and invented pre-balances under it. - Conformance verified that claim under the key it carries. - The facts mapped every single-root parent to compatible. - So the swap realized, and walkers took the vault's key as consumed: a drain, and a mint on Close for a vault-owning attacker. The fix: - A single-root parent now counts only as what the trader's lineage holds at p: the claim final at K_root(p) and its root. The verifier establishes this itself, from its own admitted position or the A8 walk (trader_root_at -> peer_root_at). No separate mechanism is added. - ResolvedParent carries the claim that holds K_root(p). - ParentPosition::SingleRoot { named, held, held_root }: the ladder computes compatibility (held exactly as named, at P's root) and impossibility (its negation), so the old SingleRoot => true/false arms are gone. - The S15 resolver hands over the walk's own authenticated parent for both kinds of claim. Tests: - Adversarial: a claim the trader doesn't hold at p, and the claim at another root, each make the position Invalid and the key Skipped. These were Ok(Realized) before the fix. - A parent that is not established is no fact. - Three mutations turn the tests red. - Lean: ParentState.singleNotHeld, with theorem single_root_parent_not_held_is_invalid. It fails to check if the case is dropped from parentImpossible. Suites: dsm sofi 240/0, SDK node suites 80/0, lint 0, guard clean. Still depends on pre-audit item 1 (CORE: claims at K_root bound to the DevID key). --- .../dsm/src/sofi/facts.rs | 189 +++++++++++++++--- .../dsm/src/sofi/lineage.rs | 6 +- .../dsm/src/sofi/resolution.rs | 87 ++++++-- .../dsm/src/sofi/resolve.rs | 130 ++++++------ lean4/DSMSofiAtomicity.lean | 22 +- 5 files changed, 309 insertions(+), 125 deletions(-) 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 b121b929e..331f2723f 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs @@ -104,8 +104,8 @@ pub enum NotEstablished { ConformanceEvidence(Vec), /// Route evidence not in hand after the retry budget. RouteEvidence(Vec), - /// `P` names a conditional parent this verifier has not resolved. - ParentUnresolved { fulfillment_id: D32 }, + /// `P` names a parent this verifier has not established at `p`. + ParentUnresolved { parent: ParentClaimRef }, /// A leg's attempt cell is not decided by its reads yet. AttemptCell { vault_id: D32, @@ -155,20 +155,23 @@ pub struct ExerciseReads<'a> { pub legs: &'a [LegReads<'a>], } -/// What this verifier itself resolved at `p`, when `p` was a conditional -/// SoFi position: the fulfillment it resolved and the root that resolution -/// selected. The one fact about a conditional parent the facts read; nothing -/// else resolves a parent. +/// 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. +/// Established from this verifier's own admitted position, or by the +/// frontier-relative walk of the trader's lineage (DSM Amendment A8; SoFi +/// Amendment S15). The one fact about a parent the facts read: a claim `P` +/// carries is never its own authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ResolvedParent { pub economic_position: u64, pub selected_root: D32, - pub fulfillment_id: D32, + pub named: ParentClaimRef, } impl ResolvedParent { - /// What an admitted position records of its resolution, when it is a - /// resolved SoFi position. + /// What an admitted position records: the claim it accepted and the root + /// it holds. Nothing for a conditional position not resolved yet. pub fn of(admitted: &AdmittedEconomicPosition) -> Option { match admitted { AdmittedEconomicPosition::ResolvedSofi { @@ -179,10 +182,22 @@ impl ResolvedParent { } => Some(Self { economic_position: *economic_position, selected_root: *selected_root, - fulfillment_id: *fulfillment_id, + named: ParentClaimRef::Conditional { + fulfillment_id: *fulfillment_id, + }, + }), + AdmittedEconomicPosition::SingleRoot { + economic_position, + economic_root, + claim_ref, + } => Some(Self { + economic_position: *economic_position, + selected_root: *economic_root, + named: ParentClaimRef::SingleRoot { + claim_ref: *claim_ref, + }, }), - AdmittedEconomicPosition::SingleRoot { .. } - | AdmittedEconomicPosition::UnresolvedSofi { .. } => None, + AdmittedEconomicPosition::UnresolvedSofi { .. } => None, } } } @@ -517,24 +532,29 @@ pub fn establish_ground(reads: &GroundReads<'_>) -> Result (false, false), }; - // The trader parent: P names it; what it selected is this verifier's - // own resolution of p, and nothing else resolves it. - let parent = match precommit.parent_claim_ref() { - ParentClaimRef::SingleRoot { .. } => ParentPosition::SingleRoot, - ParentClaimRef::Conditional { fulfillment_id } => match reads.parent { - Some(ResolvedParent { - economic_position, - selected_root, - fulfillment_id: resolved, - }) if resolved == *fulfillment_id && economic_position == precommit.position() => { - ParentPosition::ConditionalSelected { selected_root } - } - Some(..) | None => { - return Err(NotEstablished::ParentUnresolved { - fulfillment_id: *fulfillment_id, - }) + // The trader parent: what `P` names must be what the trader's lineage + // holds at `p` — the claim final at its `K_root(p)` and the root that + // claim installed — as this verifier itself established it, from its own + // admitted position or the frontier-relative walk of the trader's lineage + // (DSM Amendment A8; SoFi Amendment S15). A claim `P` carries is never + // its own authority (§6.62). + let named = *precommit.parent_claim_ref(); + let unresolved = || NotEstablished::ParentUnresolved { parent: named }; + let parent = match reads.parent { + Some(held) if held.economic_position == precommit.position() => match named { + ParentClaimRef::SingleRoot { .. } => ParentPosition::SingleRoot { + named, + held: held.named, + held_root: held.selected_root, + }, + ParentClaimRef::Conditional { .. } if held.named == named => { + ParentPosition::ConditionalSelected { + selected_root: held.selected_root, + } } + ParentClaimRef::Conditional { .. } => return Err(unresolved()), }, + Some(..) | None => return Err(unresolved()), }; // Every leg of P at the attempt F fixed for it. The attempts cover the @@ -621,13 +641,16 @@ mod tests { use crate::sofi::lineage::advance_resolved; use crate::sofi::publication::Publication; use crate::sofi::registration::{fulfillment_registered, PositionCells}; - use crate::sofi::resolution::{resolve_position, walk, Incomplete, KeyFacts, Resolution}; + use crate::sofi::resolution::{ + classify_attempt, resolve_position, walk, AttemptClass, Incomplete, KeyFacts, Resolution, + }; use crate::sofi::validation::fixtures::{swap_fixture_n, Fixture}; use crate::sofi::validation::trader_credits; use crate::sofi::wire::{SofiResolutionClaim, TraderFulfillmentBody, TraderPrecommitBody}; use crate::types::device_state::DeviceState; const OTHER_ROOT: D32 = [0x77; 32]; + const OTHER_CLAIM: D32 = [0x7C; 32]; const OTHER_E: D32 = [0x11; 32]; /// One operation's reads, as the SDK fetches them: the position pair @@ -641,6 +664,9 @@ mod tests { evidence: Evidence, cells: Vec, chains: BTreeMap, + /// 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, } impl Reads { @@ -662,7 +688,7 @@ mod tests { registration: &self.registration, conformance: &self.conformance, evidence: &self.evidence, - parent: None, + parent: self.parent, legs, }) } @@ -778,6 +804,7 @@ mod tests { .collect(); let conformance = conformance_evidence(&fx, &exercise, BTreeMap::new()); let evidence = fx.evidence.clone(); + let parent = Some(held_parent(&built.precommit)); ( fx, Reads { @@ -787,6 +814,7 @@ mod tests { evidence, cells, chains, + parent, }, ) } @@ -844,7 +872,15 @@ mod tests { Validation::Valid, "validation recomputed over the evidence" ); - assert_eq!(facts.parent, ParentPosition::SingleRoot); + assert_eq!( + facts.parent, + ParentPosition::SingleRoot { + named: *p.parent_claim_ref(), + held: *p.parent_claim_ref(), + held_root: *p.void_root(), + }, + "the trader holds the very claim P names, at P's root" + ); assert_eq!(facts.parent_pre_root, *p.void_root()); assert!(facts.storage_resolved); assert_eq!(facts.legs.len(), 1); @@ -940,9 +976,93 @@ mod tests { evidence: r.evidence.clone(), cells: r.cells.clone(), chains: r.chains.clone(), + parent: r.parent, } } + /// What an honest trader's lineage holds at `P.p`: the claim `P` names, + /// at the root `P` was built on. + fn held_parent(p: &TraderPrecommitBody) -> ResolvedParent { + ResolvedParent { + economic_position: p.position(), + selected_root: *p.void_root(), + named: *p.parent_claim_ref(), + } + } + + /// The single-root parent `P` names is what the trader's lineage holds at + /// `p` — the claim final at its `K_root(p)` and the root it installed — + /// as this verifier established it, never a claim `P` carries for itself + /// (§6.62; SoFi §17.1 rules 2–3, DSM Amendment A8, SoFi Amendment S15). + /// Here the fixture's parent claim verifies, names the trader, `p` and + /// `P`'s root, and is signed under `P`'s key, but the trader's lineage + /// holds another claim at `p`: `P` was built on a position the trader + /// never held. The position is Invalid, and the vault's key is passed + /// over, never consumed: a vault cannot be drained against a balance the + /// trader's lineage never held. + #[test] + 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 { + 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()), + Ok(Resolution::Invalid), + "built on a claim the trader's lineage never held at p" + ); + let (class, ..) = classify_attempt(&facts.route_facts(), &facts.legs[0]); + assert_eq!( + class, + AttemptClass::Skipped, + "the vault's key is passed over" + ); + } + + /// The same, where the lineage holds the very claim `P` names but at + /// another root than the one `P` was built on. + #[test] + 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 { + selected_root: OTHER_ROOT, + ..held + }); + let facts = r.establish(&r.legs()).expect("every read decides"); + assert_eq!( + resolve_position(&facts.route_facts()), + Ok(Resolution::Invalid), + "built on a root the trader's lineage never held at p" + ); + let (class, ..) = classify_attempt(&facts.route_facts(), &facts.legs[0]); + assert_eq!( + class, + AttemptClass::Skipped, + "the vault's key is passed over" + ); + } + + /// A single-root parent this verifier has not established at `p` — the + /// trader's lineage not walked there, or not established yet — is not a + /// fact the ladder reads: nothing resolves, and nothing is refuted. + #[test] + fn a_single_root_parent_not_established_at_p_is_no_fact() { + let (_, mut r) = reads(1, &[0]); + r.parent = None; + assert_eq!( + r.establish(&r.legs()).err(), + Some(NotEstablished::ParentUnresolved { + parent: *r.exercise.precommit().body.parent_claim_ref() + }) + ); + } + /// 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. @@ -1046,6 +1166,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)), exercise, }; @@ -1090,7 +1211,11 @@ mod tests { registered: true, conformance: Validation::Valid, position_lost: false, - parent: ParentPosition::SingleRoot, + parent: ParentPosition::SingleRoot { + named: *built.precommit.parent_claim_ref(), + held: *built.precommit.parent_claim_ref(), + held_root: root, + }, parent_pre_root: root, validation: Validation::Invalid, storage_resolved: true, 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 ff9b3f4d8..71ff3dfeb 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs @@ -945,7 +945,11 @@ mod tests { registered: true, conformance: Validation::Valid, position_lost: false, - parent: ParentPosition::SingleRoot, + parent: ParentPosition::SingleRoot { + named: *p.parent_claim_ref(), + held: *p.parent_claim_ref(), + held_root: *p.void_root(), + }, parent_pre_root: *p.void_root(), validation: match shape { Shape::Invalid => Validation::Invalid, 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 bc90d4e17..b291ecbad 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs @@ -38,7 +38,7 @@ use crate::route_chain::{CellFact, ChainState}; use super::conformance::Validation; -use super::wire::next_attempt; +use super::wire::{next_attempt, ParentClaimRef}; /// What a verifier has established about the predecessor position `p` that `P` /// names as its trader parent `T0`. @@ -51,9 +51,19 @@ use super::wire::next_attempt; /// (Amendment S7). An unresolved parent is never a fact Core is handed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ParentPosition { - /// `T0` is an ordinary single-root claim. P conformance already bound - /// `T°.pre_root` to that exact registered root at ingress. - SingleRoot, + /// `T0` is an ordinary single-root claim. `named` is the claim `P` names; + /// `held` is the claim final at the trader's `K_root(p)` and `held_root` + /// the root it installed, as this verifier itself established them: its + /// own admitted position, or the frontier-relative walk of the trader's + /// lineage (DSM Amendment A8; SoFi Amendment S15). A claim `P` carries is + /// never its own authority: `P` was built on a position the trader held + /// only when the trader holds exactly what `P` names, at the root `P` was + /// built on (§6.62). + SingleRoot { + named: ParentClaimRef, + held: ParentClaimRef, + held_root: [u8; 32], + }, /// `C_p` resolved and selected this root: `R_realize` when `p` realized, /// `R_void` when it voided. ConditionalSelected { selected_root: [u8; 32] }, @@ -469,13 +479,17 @@ impl RouteFacts<'_> { } } -/// `TraderParentCompatible(P)`: the parent either is an ordinary claim, or is -/// a conditional claim that selected exactly the root this operation was built -/// on. The parent is always resolved here: Core resolves only over complete +/// `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 { match parent { - ParentPosition::SingleRoot => true, + 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, } @@ -483,14 +497,19 @@ pub fn trader_parent_compatible(parent: &ParentPosition, parent_pre_root: &[u8; /// `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. +/// it selected the other branch — or it is an ordinary claim the trader does +/// not hold at `p`, or holds at another root. /// /// 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 => false, + ParentPosition::SingleRoot { + named, + held, + held_root, + } => named != held || held_root != parent_pre_root, } } @@ -1123,6 +1142,30 @@ mod tests { const OTHER_E: [u8; 32] = [0x11; 32]; const PRE: [u8; 32] = [0x99; 32]; const OTHER_ROOT: [u8; 32] = [0x77; 32]; + /// The ordinary parent claim a `P` names. + const NAMED: ParentClaimRef = ParentClaimRef::SingleRoot { + claim_ref: [0x66; 32], + }; + /// An ordinary parent the trader holds at `p`, at `PRE`. + const HELD: ParentPosition = ParentPosition::SingleRoot { + named: NAMED, + held: NAMED, + held_root: PRE, + }; + /// An ordinary parent `P` names while another claim holds `p`. + const NOT_HELD: ParentPosition = ParentPosition::SingleRoot { + named: NAMED, + held: ParentClaimRef::SingleRoot { + claim_ref: [0x67; 32], + }, + held_root: PRE, + }; + /// The claim `P` names, held at another root than `P` was built on. + const HELD_ELSEWHERE: ParentPosition = ParentPosition::SingleRoot { + named: NAMED, + held: NAMED, + held_root: OTHER_ROOT, + }; /// The vault whose attempt keys the walk tests walk, at parent `PRE`. const V: [u8; 32] = [0x5A; 32]; @@ -1298,7 +1341,7 @@ mod tests { registered: true, conformance: Valid, position_lost: false, - parent: ParentPosition::SingleRoot, + parent: HELD, parent_pre_root: PRE, validation: Valid, storage_resolved: true, @@ -1443,7 +1486,9 @@ mod tests { fn the_trader_parent_arm_is_monotone() { for root in [PRE, OTHER_ROOT] { for parent in [ - ParentPosition::SingleRoot, + HELD, + NOT_HELD, + HELD_ELSEWHERE, ParentPosition::ConditionalNoRoot, ParentPosition::ConditionalSelected { selected_root: PRE }, ParentPosition::ConditionalSelected { @@ -1907,7 +1952,7 @@ mod tests { let lost = around(&legs, Valid, Valid) .into_iter() - .find(|f| f.position_lost && f.parent == ParentPosition::SingleRoot) + .find(|f| f.position_lost && f.parent == HELD) .expect("a lost position among the facts"); assert_eq!( skip_without_evidence(&ground_of(&lost), &legs[0]), @@ -2044,7 +2089,9 @@ mod tests { CellFact::Open, ]; let parents = [ - ParentPosition::SingleRoot, + HELD, + NOT_HELD, + HELD_ELSEWHERE, ParentPosition::ConditionalSelected { selected_root: PRE }, ParentPosition::ConditionalSelected { selected_root: OTHER_ROOT, @@ -2228,7 +2275,7 @@ mod tests { registered: true, conformance: Valid, position_lost: false, - parent: ParentPosition::SingleRoot, + parent: HELD, parent_pre_root: PRE, validation, storage_resolved: true, @@ -2281,7 +2328,7 @@ mod tests { registered: true, conformance: Valid, position_lost: false, - parent: ParentPosition::SingleRoot, + parent: HELD, parent_pre_root: PRE, validation: Invalid, storage_resolved: true, @@ -2336,7 +2383,7 @@ mod tests { registered: true, conformance: Valid, position_lost: false, - parent: ParentPosition::SingleRoot, + parent: HELD, parent_pre_root: PRE, validation: Invalid, storage_resolved: true, @@ -2389,7 +2436,9 @@ mod tests { for position_lost in [true, false] { for storage_resolved in [true, false] { for parent in [ - ParentPosition::SingleRoot, + HELD, + NOT_HELD, + HELD_ELSEWHERE, ParentPosition::ConditionalSelected { selected_root: PRE }, ParentPosition::ConditionalSelected { selected_root: OTHER_ROOT, @@ -2587,7 +2636,7 @@ mod tests { registered: true, conformance: Valid, position_lost: false, - parent: ParentPosition::SingleRoot, + parent: HELD, parent_pre_root: PRE, validation: Invalid, storage_resolved: true, 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 b7b059f84..399c21093 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs @@ -1846,21 +1846,20 @@ impl Verifier<'_, R> { Ok(establish(&reads)) } - /// The conditional parent `exercise`'s `P` names, resolved: this - /// verifier's own resolution when it is the one `P` names, and otherwise - /// the root the trader's lineage selected at that position, by - /// frontier-relative verification of the lineage (DSM Amendment A8; SoFi - /// Amendment S15). `None` for a single-root parent, which needs no - /// resolution, and for a conditional one the reads do not establish: - /// the facts then report it unresolved, and nothing is refuted. + /// What the trader's lineage holds at the position `exercise`'s `P` + /// names as its parent — the claim final at its `K_root(p)` and the root + /// that claim installed — as this verifier establishes it: its own + /// 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 { let precommit = &exercise.precommit().body; - let ParentClaimRef::Conditional { fulfillment_id } = precommit.parent_claim_ref() else { - return None; - }; let position = precommit.position(); if let Some(own) = self.parent { - if own.fulfillment_id == *fulfillment_id && own.economic_position == position { + if own.named == *precommit.parent_claim_ref() && own.economic_position == position { return Some(own); } } @@ -1868,9 +1867,8 @@ impl Verifier<'_, R> { .reads .trader_root_at(precommit.genesis(), precommit.device_id(), position) { - Ok((root, named)) => parent_named( + Ok((root, named)) => held_at( position, - fulfillment_id, root.economic_position(), root.economic_root(), named, @@ -1881,27 +1879,21 @@ impl Verifier<'_, R> { } } -/// The parent a `P` at `position` names by `fulfillment_id`, when the trader's -/// lineage, verified to that position, selected `root` at `root_position` and -/// is named there by `named`: only the very position `P` names, held by that -/// very fulfillment, resolves it. Anything else is not the parent `P` names. -fn parent_named( +/// What a trader's lineage holds at `position`, from a walk that reached +/// `root_position`: the claim `named` final there and the `root` it +/// installed. A walk that reached another position holds nothing at this +/// one. +fn held_at( position: u64, - fulfillment_id: &D32, root_position: u64, root: D32, named: ParentClaimRef, ) -> Option { - match named { - ParentClaimRef::Conditional { - fulfillment_id: held, - } if held == *fulfillment_id && root_position == position => Some(ResolvedParent { - economic_position: position, - selected_root: root, - fulfillment_id: *fulfillment_id, - }), - ParentClaimRef::Conditional { .. } | ParentClaimRef::SingleRoot { .. } => None, - } + (root_position == position).then_some(ResolvedParent { + economic_position: position, + selected_root: root, + named, + }) } /// Resolves another trader's conditional position for a frontier-relative @@ -1930,17 +1922,15 @@ impl crate::economic::peer_lineage::ConditionalPositionRe ), PeerLineageFailure, > { - // When `q − 1` was itself a SoFi position, the walk resolved it and - // `previous` is the root that resolution selected: what a `P` naming - // that fulfillment as its parent was built on. - let resolved = match parent { - ParentClaimRef::Conditional { fulfillment_id } => Some(ResolvedParent { - economic_position: previous.economic_position(), - selected_root: previous.economic_root(), - fulfillment_id: *fulfillment_id, - }), - ParentClaimRef::SingleRoot { .. } => None, - }; + // The walk authenticated `q − 1` itself: `parent` is the claim it + // accepted there and `previous` the root it holds — for a SoFi + // position, the root that resolution selected. What a `P` at `q` + // names as its parent is compared against exactly that (§6.62). + let resolved = Some(ResolvedParent { + economic_position: previous.economic_position(), + selected_root: previous.economic_root(), + named: *parent, + }); let verifier = Verifier::new( self.reads, self.members, @@ -2136,38 +2126,34 @@ mod tests { assert_eq!(remembered(&memo, &other, [accepted.as_slice()]), 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 + /// that reached another position holds nothing at `p`. #[test] - fn only_the_position_and_fulfillment_p_names_resolve_its_parent() { - let (position, wanted, root) = (7, [0xF7; 32], [0x77; 32]); - let held = |fulfillment_id| ParentClaimRef::Conditional { fulfillment_id }; - assert_eq!( - parent_named(position, &wanted, position, root, held(wanted)), - Some(ResolvedParent { - economic_position: position, - selected_root: root, - fulfillment_id: wanted, - }) - ); - assert_eq!( - parent_named(position, &wanted, position, root, held([0xF8; 32])), - None, - "another fulfillment holds the position" - ); - assert_eq!( - parent_named(position, &wanted, position + 1, root, held(wanted)), - None, - "the root of another position" - ); - assert_eq!( - parent_named( - position, - &wanted, - position, - root, - ParentClaimRef::SingleRoot { claim_ref: wanted } - ), - None, - "an ordinary position is no conditional parent" - ); + fn what_a_walk_holds_counts_only_at_the_position_it_reached() { + let (position, root) = (7, [0x77; 32]); + for named in [ + ParentClaimRef::Conditional { + fulfillment_id: [0xF7; 32], + }, + ParentClaimRef::SingleRoot { + claim_ref: [0xC7; 32], + }, + ] { + assert_eq!( + held_at(position, position, root, named), + Some(ResolvedParent { + economic_position: position, + selected_root: root, + named, + }) + ); + assert_eq!( + held_at(position, position + 1, root, named), + None, + "the root of another position" + ); + } } } diff --git a/lean4/DSMSofiAtomicity.lean b/lean4/DSMSofiAtomicity.lean index e00d853f7..825bf4fa8 100644 --- a/lean4/DSMSofiAtomicity.lean +++ b/lean4/DSMSofiAtomicity.lean @@ -1034,8 +1034,15 @@ resolved (the fence, Section 22); an object supplied from outside may name one, guessing which branch `p` will take, and rungs 1 and 2 of the ladder classify it (Section 24). -/ inductive ParentState where - /-- An ordinary single-root claim; P conformance pinned its root at ingress. -/ + /-- An ordinary single-root claim the trader holds at `p`: the claim final at + its `K_root(p)` is the one P names, at the root P was built on, as the + verifier itself established it. A claim P carries is never its own + authority (CONFORMANCE §6.62). -/ | single + /-- An ordinary single-root claim the trader does NOT hold at `p`: another + claim, or the claim P names at another root, is final at its `K_root(p)`. + P was built on a position the trader never held. Terminal (§6.62). -/ + | singleNotHeld /-- A conditional claim whose branch selection is not yet known. -/ | openBranch /-- A conditional claim that selected the root P was built on. -/ @@ -1054,6 +1061,7 @@ def parentCompatible : ParentState → Bool def parentImpossible : ParentState → Bool | .otherBranch => true | .noRoot => true + | .singleNotHeld => true | _ => false def parentPending : ParentState → Bool @@ -1591,6 +1599,17 @@ theorem terminal_parent_with_no_root_is_invalid_without_evidence {x : Facts} {le (_hun : x.validation = .unavailable) : resolve x legs e = .invalid := by exact (conditional_parent_on_another_branch_is_invalid hreg (by rw [hno]; rfl)).1 +/-- CONFORMANCE §6.62: an ordinary parent the trader does not hold at `p` — +another claim, or the claim P names at another root, final at its +`K_root(p)` — makes the position Invalid and its route never consumed, +whatever its legs and evidence say. A claim P carries is never its own +authority, so a vault is never drained against a balance the trader's +lineage never held. -/ +theorem single_root_parent_not_held_is_invalid {x : Facts} {legs : List Nat} {e : Nat} + (hreg : x.registered = true) (hp : x.parent = .singleNotHeld) : + resolve x legs e = .invalid ∧ ConsumedRoute x legs e = false := + conditional_parent_on_another_branch_is_invalid hreg (by rw [hp]; rfl) + /-- A concrete position on a branch its parent never took: registered, valid, every leg final on this E, Complete — and still Invalid, because `p` selected the other root. Its two siblings differ only in the parent's state. Without @@ -2632,6 +2651,7 @@ theorem later_setups_confer_no_authority (p k q k0 : Nat) (rest : List (Nat × N #print axioms conditional_parent_pending_keeps_the_position_pending #print axioms conditional_parent_on_another_branch_is_invalid #print axioms terminal_parent_with_no_root_is_invalid_without_evidence +#print axioms single_root_parent_not_held_is_invalid #print axioms the_trader_parent_rungs_are_not_vacuous #print axioms trader_parent_arm_is_monotone #print axioms fulfillment_atomic_all_or_none From d2363caa79ff6649993a8368805d65fc0a926b77 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:53:21 -0400 Subject: [PATCH 04/31] =?UTF-8?q?docs(conformance):=20pre-audit=20item=202?= =?UTF-8?q?,=20a=20single-root=20parent=20is=20what=20the=20lineage=20hold?= =?UTF-8?q?s=20at=20K=5Froot(p)=20(=C2=A76.62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/requirements/CONFORMANCE_GAPS.md | 54 +++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index df8f85026..35dad780d 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -812,6 +812,8 @@ The audit's "P's key is never bound to the trader" (High, confirmed at `dsm/src/ **Open (dependency boundary, not closed here).** A verifier binds P's key to the claim P names and checks that claim against P's own `(G, DevID, p, root)`. It does not establish that the claim is the one registered at `K_root(p)`. A foreign verifier cannot route that cell: it needs the root at `p − 1`, which P does not carry. The trader's own verifier uses its frozen claim. The key → device binding that would close this from the bytes in hand (the `AttA` shape of §6.26) is the audit's K_root recognizer finding, and needs an owner ruling on the claim and precommit formats. MR-SOFI-0144 stays Partial on it. +**Update (2026-10-01, §6.62).** The parent P names is now checked against what the trader's lineage holds at `K_root(p)`, the claim final there and its root, established by this verifier's own admitted position or by the A8 walk. The walk routes the cell from its own authenticated root at `p − 1`. Still open: the key-to-device binding at `K_root` (pre-audit item 1, CORE). + Tests: `dsm::sofi::conformance::tests::a_precommit_under_a_key_its_parent_claim_does_not_carry_does_not_conform`, `dsm::sofi::conformance::tests::a_parent_claim_of_another_position_or_root_does_not_conform`, `dsm::sofi::conformance::tests::a_parent_that_is_not_a_verifying_claim_does_not_conform`, `dsm::sofi::conformance::tests::a_parent_the_closure_does_not_reference_does_not_conform`, `dsm::sofi::conformance::tests::a_conditional_parent_binds_p_to_the_key_of_the_f_that_installed_it`, `dsm::sofi::validation::tests::a_precommit_naming_a_parent_its_closure_does_not_carry_is_invalid`, and the node e2e trades (`dsm_sdk::handlers::node_e2e_tests::a_sofi_trade_executes_end_to_end`, `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone` — a second trade on a resolved conditional parent). Mutations, each observed red on its named test: rule 2 removed from `validate` and from conformance; the single-root key check; each of the genesis, device, position and root checks; the conditional key, position and id checks; the claim's signature check; the producer's closure emptied; the parent F not fetched; `C_p` routed by `P.void_root`. ### 6.28 The placeholder and wiring sweep (#992–#1008, 2026-09-25) @@ -2054,6 +2056,56 @@ The quote took 20 s. - **The chain walk of a vault the trade consumed, about 19 s.** Two passes over the sibling leg re-read the same four or five cells about four times each. - **Establishing the facts, 2.7 s.** +### 6.62 A trader's single-root parent is what its lineage holds at `K_root(p)`, never a claim P carries (`security/beta-pre-audit`, pre-audit item 2, 2026-10-01) + +This is the owner's beta security pre-audit, item 2 (P0), audited at main `076792eac`. CORE reported it and verified it at the code. The finding updates §6.27's Open paragraph and MR-SOFI-0144; it does not duplicate them. + +| Field | Record | +|---|---| +| Severity | Critical | +| Files | `dsm/src/sofi/facts.rs` · `establish_ground`, the parent arm. `dsm/src/sofi/resolution.rs` · `ParentPosition`, `trader_parent_compatible`, `trader_parent_impossible`. `dsm/src/sofi/resolve.rs` · `Verifier::parent_for`, `PeerPositionResolver`. `lean4/DSMSofiAtomicity.lean` · `ParentState`. | +| Exploit | A trader signs a single-root claim for its own position `p`. The claim names a root the trader never held, with invented `TraderPreBalance`s under it. The trader names that claim as P's parent, though it is never final at its `K_root(p)`. Conformance verified the claim under the key the claim itself carries, and checked it against P's own `(G, DevID, p, root)`. The facts then mapped every single-root parent to compatible (`facts.rs:523`; `resolution.rs:478`, `SingleRoot => true`). The swap realized, and walkers classified the exercise Consumed. A vault paid out against a balance the trader's lineage never held, and an attacker who owns a vault can mint the same way on Close. | +| Violates | SoFi §17.1 P conformance rules 2–3 (the parent P extends). DSM Amendment A8 and SoFi Amendment S15: a parent is the authoritative claim at the lineage's position, authenticated from the verifier's frontier. MR-SOFI-0144. | +| Verification | CORE verified it at the code on 2026-10-01. The adversarial tests below reproduce it: they resolve `Ok(Realized)` under the old acceptance. | +| Closure condition | A single-root parent counts only as what the trader's lineage holds at `p`: the claim final at `K_root(p)`, and the root that claim installed. The verifier establishes this itself, from its own admitted position or by the frontier-relative walk (`peer_root_at`). That is the check A8 and S15 already use; nothing separate is added. A parent held exactly as P names it is compatible. Anything else final there makes the position Invalid and the vault's key skipped. A parent not yet established is no fact. | +| Status | Closed for the self-authenticating parent. One dependency stays open, pre-audit item 1 (CORE): until whatever is final at `K_root(p)` must be signed by the key that derives the trader's DevID, a squatter can occupy the trader's `K_root(p)`, and the walk then returns the squatter's claim. Item 3's two-wallet gap, a colluding source admitting its own invented root, is CORE's and is with the owner. | + +**The change.** +- `ResolvedParent` carries the claim that holds `K_root(p)` (`named`) beside the root. `ResolvedParent::of` now covers single-root admitted positions too. +- `ParentPosition::SingleRoot { named, held, held_root }` carries its evidence, and the ladder computes from it: + - compatible is `named == held && held_root == pre_root`; + - impossible is the negation. + + The literal arms `SingleRoot => true` and `SingleRoot => false` are gone. +- `Verifier::parent_for` establishes every parent. It uses this verifier's admitted position when that is the claim P names, and otherwise `trader_root_at`, the A8 walk. The facts decide whether what is held is what P names. +- Inside a walk, the S15 resolver hands over the walk's own authenticated root and accepted claim at `q − 1`, for both kinds of parent. +- §6.27's routing limit, "a foreign verifier cannot route that cell", is answered by the walk: it routes `K_root(p)` from its own authenticated root at `p − 1`. + +**Tests.** +- `dsm::sofi::facts::tests::a_parent_claim_the_trader_does_not_hold_at_p_never_realizes`: another claim is final at `p`. The position is Invalid, and the vault's key is Skipped. +- `dsm::sofi::facts::tests::a_parent_at_another_root_than_the_trader_holds_never_realizes` +- `dsm::sofi::facts::tests::a_single_root_parent_not_established_at_p_is_no_fact` +- `dsm::sofi::resolve::tests::what_a_walk_holds_counts_only_at_the_position_it_reached` +- The ladder's property tests (`the_trader_parent_arm_is_monotone` and the exhaustive ladder sets) now enumerate a held, a not-held and a held-elsewhere single-root parent. +- Lean `DSMSofiAtomicity.single_root_parent_not_held_is_invalid`, with the new case `ParentState.singleNotHeld`. + +**Mutation controls (2026-10-01, restored byte for byte).** + +| Mutation | Result | +|---|---| +| The facts ignore what is held | Both forged-parent tests red | +| The ladder drops the claim comparison | The claim test red | +| An unestablished parent is treated as held | The not-established test red | +| Lean: `.singleNotHeld` removed from `parentImpossible` | The theorem, and the model's partition theorem, fail to check | + +**Suites.** +- `dsm` `sofi::`: 240 passed, 0 failed (237, plus the 3 new tests). +- SDK node and handler suites: 80 passed, 0 failed. Realized trades pass on both parent paths: the trader's own admitted position, and the A8 walk for another trader's parent. +- Lean: `DSMSofiAtomicity.lean` kernel-checks, sorry-free. +- Guard: clean. + +**Fix:** commit `1ef20cf7a` on `security/beta-pre-audit`; `make lint` exit 0. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | @@ -2495,7 +2547,7 @@ The quote took 20 s. | MR-SOFI-0141 | Met | `dsm::sofi::wire::objects::TraderPrecommitBody` | `dsm::sofi_v8_independent::every_object_matches_the_independent_encoder_and_round_trips` | — | | MR-SOFI-0142 | Partial | `dsm::sofi::registration::fulfillment_registered`; `dsm::economic::register::root_claim_naming`; `dsm::types::operations::Operation` | — | No Operation variant carries P and K_root recognizes only registered claims, but no test shows that P at K_root counts as nothing, that several P from one parent coexist, or that storing P is not exercise; the cited write_set test covers only setup, creation and fulfillment. | | MR-SOFI-0143 | Met | `dsm::sofi::validation::validate` (CoreIdentityMismatch, L712-717) | `dsm::sofi::validation::tests::precommit_roots_that_do_not_match_the_cores_are_invalid` | — | -| MR-SOFI-0144 | Partial | `dsm::sofi::validation::validate` (`ParentReferenceNotInClosure`); `dsm::sofi::conformance::fulfillment_conformance` (item 1); `dsm_sdk::sdk::sofi_sdk::pre_e_closure`; `dsm_sdk::sdk::sofi_advance::own_parent_claim` | `dsm::sofi::validation::tests::a_precommit_naming_a_parent_its_closure_does_not_carry_is_invalid`; `dsm::sofi::conformance::tests::a_parent_the_closure_does_not_reference_does_not_conform`; `dsm::sofi::conformance::tests::a_parent_claim_of_another_position_or_root_does_not_conform` | The typed parent reference in `𝒞_E^pre` is enforced, and the producer names its own frozen claim. A foreign verifier checks the claim P names against P's `(G, DevID, p, root)` but cannot establish that it is the one registered at `K_root(p)` (§6.27 Open). | +| MR-SOFI-0144 | Partial | `dsm::sofi::validation::validate` (`ParentReferenceNotInClosure`); `dsm::sofi::conformance::fulfillment_conformance` (item 1); `dsm_sdk::sdk::sofi_sdk::pre_e_closure`; `dsm_sdk::sdk::sofi_advance::own_parent_claim` | `dsm::sofi::validation::tests::a_precommit_naming_a_parent_its_closure_does_not_carry_is_invalid`; `dsm::sofi::conformance::tests::a_parent_the_closure_does_not_reference_does_not_conform`; `dsm::sofi::conformance::tests::a_parent_claim_of_another_position_or_root_does_not_conform`; `dsm::sofi::facts::tests::a_parent_claim_the_trader_does_not_hold_at_p_never_realizes`; `dsm::sofi::facts::tests::a_parent_at_another_root_than_the_trader_holds_never_realizes`; `dsm::sofi::facts::tests::a_single_root_parent_not_established_at_p_is_no_fact` | The typed parent reference in `𝒞_E^pre` is enforced, and the producer names its own frozen claim. Since 2026-10-01 (§6.62), the parent P names is checked against what the trader's lineage holds at `K_root(p)`: the claim final there and its root, established by the verifier's own admitted position or by the A8 walk. A parent the trader does not hold makes the position Invalid and its key skipped. The row stays Partial until pre-audit item 1 binds whatever is final at `K_root(p)` to the trader's DevID key (CORE). | | MR-SOFI-0145 | Met | `dsm::economic::register::read_root_cell`; `dsm::route_chain::evaluate`; `dsm_sdk::sdk::sofi_advance::final_root_cell` | `dsm::economic::register::registered_root_construction_tests::a_final_root_claim_has_a_completion_proof_that_checks`; `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim`; `dsm::route_chain::tests::the_state_is_the_count_of_valid_links` | dsm_sdk sofi_advance.rs has no tests; Core's root-cell reading needs a three-link chain, and the named dsm tests show that the leader's copy alone is not final. | | MR-SOFI-0146 | Met | `dsm::sofi::validation`; `dsm::sofi::lineage::descendant_fence` | `dsm::sofi::lineage::tests::a_resolved_predecessor_admits_only_the_root_it_selected`; `dsm::sofi::lineage::tests::nothing_descends_from_an_unresolved_conditional_predecessor` | — | | MR-SOFI-0147 | Met | `dsm::sofi::validation::validate` (recompute_e check, L700-709) | `dsm::sofi::validation::tests::a_preimage_e_does_not_commit_to_is_invalid` | — | From 5afda74d6e5d43649fc87a0d61594476d56b40f1 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:03:47 -0400 Subject: [PATCH 05/31] fix(sdk): a member is reached over https and known by its certificate; junk cannot wedge an inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security pre-audit, items 7, 8 and 11 from the storage side. Each gate has a test and a mutation control that turns it red. - https:// only, in the env config loader and in every member client (storage_node_sdk::require_https). Tests: network::tests::an_endpoint_that_is_not_https_is_refused and a_member_endpoint_that_is_not_https_gets_no_client. Mutation: http accepted → both red. - A member client trusts only the CAs the env config names, not the system store. It accepts only a certificate naming the member id as a DNS subject alternative name, whatever address it dialled (NamesTheMember). MemberClient::new(member_id, endpoint) builds its own client, so a member's name and its connection's identity cannot disagree. The inbox client holds one such client per member endpoint. The five fleet certs were read in a handshake and each carries DNS:dsm-node-N for its member. Test: a_members_latest_bytecommit_is_its_own_or_there_is_none (a client for dsm-node-2 at dsm-node-1's node is refused on the certificate). Mutation: verify the dialled address → red. - The SDK test harness serves its nodes over TLS from a per-set CA, each certificate naming its member and 127.0.0.1, as the fleet's do. There is no localhost exception. point_sdk_at names the CA as the bundled config does. - Transport errors carry their causes, so a refused certificate says why. - Inbox: - a node-supplied spool position is used only if a spool could hold it. u64::MAX + 1 panicked the poller, and a position past i64::MAX aborted the read (a_position_past_what_a_spool_can_hold_is_none; unchecked → red). - an envelope that will not open with this device's key is passed over, not stopped at for good (what_never_opens_for_this_device_holds_up_nothing_behind_it; no advance → red). The key is derived once per read. - the poller's running flag is cleared however its task ends (a_poller_that_panics_is_no_longer_running; never cleared → red). CONFORMANCE §6.62 records items 7, 8, 10 and 11, the decisions left to the owner (paging, write budgets, the mirror cap, committing the CA) and the sync-limit question left to SOFI. dsm_sdk whole suite (release, Postgres, TLS harness): 1093/0. Clippy for dsm_sdk and dsm_storage_node --all-targets -D warnings is clean. fmt, the guard and conformance_evidence are clean. --- Cargo.lock | 1 + .../dsm_sdk/Cargo.toml | 1 + .../dsm_sdk/src/economic_fixtures.rs | 27 +- .../src/handlers/recipient_dispatch.rs | 6 +- .../dsm_sdk/src/handlers/storage_routes.rs | 7 +- .../dsm_sdk/src/network.rs | 72 ++-- .../dsm_sdk/src/sdk/b0x_sdk.rs | 225 ++++++++++-- .../dsm_sdk/src/sdk/inbox_poller.rs | 36 +- .../dsm_sdk/src/sdk/route_seats.rs | 2 +- .../dsm_sdk/src/sdk/storage_node_sdk.rs | 336 ++++++++++++++---- .../dsm_sdk/src/test_support/nodes.rs | 116 ++++-- .../dsm_sdk/src/test_support/one_device.rs | 4 +- .../dsm_sdk/src/test_support/two_device.rs | 2 +- .../tests/immutable_store_round_trip.rs | 5 +- specs/requirements/CONFORMANCE_GAPS.md | 48 +++ 15 files changed, 728 insertions(+), 160 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 07e437e42..7fb73041d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1200,6 +1200,7 @@ dependencies = [ "arrayref", "async-trait", "axum", + "axum-server", "base32", "bincode", "bip39", diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/Cargo.toml b/dsm_client/deterministic_state_machine/dsm_sdk/Cargo.toml index 8c30f2779..fc9ce46a2 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/Cargo.toml +++ b/dsm_client/deterministic_state_machine/dsm_sdk/Cargo.toml @@ -159,6 +159,7 @@ serial_test = "3.5.0" # on this crate; a dev-dependency back to it is a cycle Cargo allows. dsm_storage_node = { path = "../../../dsm_storage_node" } axum = "0.8" # serves the node's app in tests (the node's own version) +axum-server = { version = "0.8", features = ["tls-rustls"] } # serves it over TLS, as the node binary does # SELF dev-dependency, enabling `test-utils` for THIS crate's own integration # tests (`tests/*.rs`) only. Integration tests are external consumers, so a # `cfg(test)` gate is invisible to them and the feature would otherwise never be diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/economic_fixtures.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/economic_fixtures.rs index af12709c5..a9a87ced2 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/economic_fixtures.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/economic_fixtures.rs @@ -79,6 +79,7 @@ pub fn whole_era(whole: u64) -> u64 { /// own nodes fails to load a config rather than reaching this set's. pub struct FleetGuard { config_path: std::path::PathBuf, + ca_path: std::path::PathBuf, } impl FleetGuard { @@ -95,11 +96,10 @@ impl FleetGuard { impl Drop for FleetGuard { fn drop(&mut self) { - if let Err(e) = std::fs::remove_file(&self.config_path) { - eprintln!( - "fleet config {} was not removed: {e}", - self.config_path.display() - ); + for path in [&self.config_path, &self.ca_path] { + if let Err(e) = std::fs::remove_file(path) { + eprintln!("fleet config {} was not removed: {e}", path.display()); + } } } } @@ -108,12 +108,18 @@ impl Drop for FleetGuard { /// `(member id, endpoint, register incarnation)`, in pin order — what /// `NodeSet::members` reports. The config lives at one path for the process; /// the loader reads it on every load, so each test's nodes replace the last. -pub fn point_sdk_at(members: &[(String, String, [u8; 32])]) -> FleetGuard { +pub fn point_sdk_at(members: &[(String, String, [u8; 32])], ca_pem: &[u8]) -> FleetGuard { let config_path = std::env::temp_dir().join(format!("dsm_sdk_fleet_{}.toml", std::process::id())); - let mut cfg = String::from( + // The fleet's CA beside the config, named relative to it, as the bundled + // config names the CA bundled beside it. + let ca_name = format!("dsm_sdk_fleet_{}.ca.pem", std::process::id()); + let ca_path = std::env::temp_dir().join(&ca_name); + std::fs::write(&ca_path, ca_pem).expect("write the fleet's CA"); + let mut cfg = format!( "allow_localhost = true\n\ - bitcoin_network = \"signet\"\n", + bitcoin_network = \"signet\"\n\ + custom_ca_certs = [\"{ca_name}\"]\n" ); for (member_id, endpoint, incarnation) in members { cfg.push_str(&format!( @@ -130,7 +136,10 @@ pub fn point_sdk_at(members: &[(String, String, [u8; 32])]) -> FleetGuard { Some(path_text.as_str()), "the env config path was set to another file first" ); - FleetGuard { config_path } + FleetGuard { + config_path, + ca_path, + } } /// The network's PINNED root-register member ids, in pin order. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs index 2af3a1437..9277d8c6e 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs @@ -685,7 +685,11 @@ mod tests { let sealed = crate::sdk::b0x_sdk::kept_seal(original_id).expect("A kept the sealed send"); p.b.enter(); let outer = dsm::envelope::from_canonical_bytes(&sealed).expect("the sealed envelope"); - let mut inner = crate::sdk::b0x_sdk::open_sealed(&outer).expect("B opens it"); + let mut inner = crate::sdk::b0x_sdk::open_sealed( + &crate::sdk::b0x_sdk::local_kyber_secret().expect("B's key"), + &outer, + ) + .expect("B opens it"); let id = crate::util::text_id::decode_base32_crockford(message_id).expect("a Base32 id"); inner.message_id = id.clone(); let Some(pb::envelope::Payload::UniversalTx(tx)) = &mut inner.payload else { 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 b3e34d578..41c0e917a 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 @@ -2926,11 +2926,8 @@ mod tests { let mut device = crate::test_support::one_device::Device::start(0x5A).await; let members = device.nodes.members(); let first = members[0].0.clone(); - let closer = crate::sdk::storage_node_sdk::MemberClient::new( - &first, - &members[0].1, - crate::sdk::storage_node_sdk::build_ca_aware_client().expect("a client"), - ); + let closer = crate::sdk::storage_node_sdk::MemberClient::new(&first, &members[0].1) + .expect("a client for the first member"); closer .put_cells(&[( b"DSM/test/storage-status".to_vec(), diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/network.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/network.rs index 0945485d2..ef633dcdd 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/network.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/network.rs @@ -93,7 +93,7 @@ pub struct EnvConfig { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct NodeConfig { pub name: String, - pub endpoint: String, // e.g., "http://10.0.0.5:8080" + pub endpoint: String, // e.g., "https://10.0.0.5:8080"; only https:// is accepted /// The member's durable register incarnation, Base32-Crockford over 32 /// bytes, as that node reports it. /// @@ -178,6 +178,8 @@ pub(crate) fn parse_env_config_toml(toml_str: &str) -> Result NodeConfig { + NodeConfig { + name: name.into(), + register_incarnation: crate::util::text_id::encode_base32_crockford(&[0x5C_u8; 32]), + endpoint: endpoint.into(), + } } #[test] - fn validate_and_normalize_nodes_non_android_accepts_all() { + fn validate_and_normalize_nodes_non_android_accepts_every_https_endpoint() { let nodes = vec![ - NodeConfig { - name: "local".into(), - register_incarnation: crate::util::text_id::encode_base32_crockford(&[0x5C_u8; 32]), - endpoint: "http://127.0.0.1:8080".into(), - }, - NodeConfig { - name: "remote".into(), - register_incarnation: crate::util::text_id::encode_base32_crockford(&[0x5C_u8; 32]), - endpoint: "http://10.0.0.5:9090".into(), - }, + node_at("local", "https://127.0.0.1:8080"), + node_at("remote", "HTTPS://10.0.0.5:9090"), ]; let result = validate_and_normalize_nodes(nodes.clone(), false); - assert!(result.is_ok()); assert_eq!(result.unwrap().len(), 2); } + /// A member is known by its certificate, so an env config naming an + /// endpoint that is not `https://` is refused, and the refusal names the + /// endpoint. One plain endpoint among https ones refuses the whole config. + #[test] + fn an_endpoint_that_is_not_https_is_refused() { + let incarnation = crate::util::text_id::encode_base32_crockford(&[0x5C_u8; 32]); + let node = |name: &str, endpoint: &str| { + format!( + "[[nodes]]\nname = \"{name}\"\nendpoint = \"{endpoint}\"\n\ + register_incarnation = \"{incarnation}\"\n" + ) + }; + for endpoint in ["http://10.0.0.5:9090", "10.0.0.5:9090", "ftp://10.0.0.5"] { + let refused = parse_env_config_toml(&node("remote", endpoint)).unwrap_err(); + assert!( + refused.to_string().contains(endpoint), + "the refusal names {endpoint}: {refused}" + ); + } + let mixed = node("a", "https://10.0.0.1:8080") + &node("b", "http://10.0.0.2:8080"); + let refused = parse_env_config_toml(&mixed).unwrap_err(); + assert!( + refused.to_string().contains("http://10.0.0.2:8080"), + "{refused}" + ); + } + #[test] fn validate_and_normalize_nodes_rejects_empty() { assert!(validate_and_normalize_nodes(vec![], false).is_err()); @@ -410,7 +442,7 @@ register_incarnation = "BHE5RQ2WBHE5RQ2WBHE5RQ2WBHE5RQ2WBHE5RQ2WBHE5RQ2WBHE0" nodes: vec![NodeConfig { name: "n1".into(), register_incarnation: crate::util::text_id::encode_base32_crockford(&[0x5C_u8; 32]), - endpoint: "http://10.0.0.1:8080".into(), + endpoint: "https://10.0.0.1:8080".into(), }], allow_localhost: false, bitcoin_network: Some("signet".into()), 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 764da38c1..0866d5f06 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 @@ -160,7 +160,7 @@ pub(crate) fn kept_seal(message_id_b32: &str) -> Result, DsmError> { /// This device's Kyber secret, re-derived from `Smaster` under `DSM/kyber\0` — /// the key pair whose public half its directory entry publishes /// (`kyber_identity::local_kyber_public_key`). Never kept. -fn local_kyber_secret() -> Result, DsmError> { +pub(crate) fn local_kyber_secret() -> Result, DsmError> { let smaster = crate::init::current_smaster()?; let (.., secret) = dsm::crypto::kyber::generate_kyber_keypair_from_entropy(&smaster, "DSM/kyber\0")?; @@ -171,7 +171,11 @@ fn local_kyber_secret() -> Result, DsmError> { /// carries, once this device's Kyber secret decapsulates the encapsulation and /// the seal opens under the outer message id. The inner envelope carries the /// same message id; anything else did not come sealed for this device. +/// Every error here is about the envelope, never about this device: what +/// does not open with the device's Kyber secret (`kyber_secret`, from +/// [`local_kyber_secret`]) never will. pub(crate) fn open_sealed( + kyber_secret: &[u8], env: &dsm::types::proto::Envelope, ) -> Result { let Some(dsm::types::proto::envelope::Payload::Sealed(sealed)) = &env.payload else { @@ -183,7 +187,7 @@ pub(crate) fn open_sealed( DsmError::invalid_operation("the sealed envelope's message id is malformed") })?; let shared_secret = - dsm::crypto::kyber::kyber_decapsulate(&local_kyber_secret()?, &sealed.kem_ciphertext)?; + dsm::crypto::kyber::kyber_decapsulate(kyber_secret, &sealed.kem_ciphertext)?; let inner_bytes = dsm::crypto::spool_seal::open(&shared_secret, &message_id, &sealed.ciphertext) .map_err(|e| DsmError::invalid_operation(format!("spool seal: {e}")))?; @@ -352,7 +356,9 @@ pub struct B0xSDK { pub(crate) device_id: String, core_sdk: Arc, pub(crate) storage_node_endpoints: Vec, - http_client: reqwest::Client, + /// One client per endpoint, each bound to the member the canonical set + /// names there: it accepts only that member's certificate. + member_clients: HashMap, circuit_breaker: CircuitBreaker, salt_genesis: [u8; 32], salt_device: [u8; 32], @@ -529,6 +535,17 @@ impl B0xSDK { self.quorum_k } + /// The client for the member at `endpoint`, which accepts only that + /// member's certificate. + fn client_for(&self, endpoint: &str) -> Result<&reqwest::Client, DsmError> { + self.member_clients.get(endpoint).ok_or_else(|| { + DsmError::network( + format!("{endpoint} is not a member endpoint of this inbox"), + None::, + ) + }) + } + pub fn new( device_id_b32: String, core_sdk: Arc, @@ -559,9 +576,31 @@ impl B0xSDK { )); } - // Clockless: do not set wall-clock request timeouts here. - // Cancellation/limits are owned by the caller task lifetime. - let http_client = crate::sdk::storage_node_sdk::build_ca_aware_client()?; + // Each endpoint is a member of the canonical set, reached with a + // client that accepts only that member's certificate. An endpoint the + // set does not name has no member to verify, and is refused. + let canonical = + crate::sdk::storage_set::canonical_set(dsm::economic::register::BETA_NETWORK_ID)?; + let mut member_clients = HashMap::with_capacity(storage_endpoints.len()); + for endpoint in &storage_endpoints { + let member = canonical + .members() + .iter() + .find(|m| m.endpoint.trim_end_matches('/') == endpoint.trim_end_matches('/')) + .ok_or_else(|| { + DsmError::network( + format!( + "B0xSDK::new: {endpoint} is not an endpoint of the canonical \ + storage set" + ), + None::, + ) + })?; + member_clients.insert( + endpoint.clone(), + crate::sdk::storage_node_sdk::member_client(&member.member_id, endpoint)?, + ); + } // The delivery fan-out stops at K acknowledging members. K is the // register quorum of the network this device is built for — resolved @@ -587,7 +626,7 @@ impl B0xSDK { device_id: device_id_b32, core_sdk, storage_node_endpoints: storage_endpoints, - http_client, + member_clients, circuit_breaker: CircuitBreaker::new(), salt_genesis: Self::derive_salt(b"DSM/b0x-salt-G", &decoded), salt_device: Self::derive_salt(b"DSM/b0x-salt-D", &decoded), @@ -1891,7 +1930,7 @@ impl B0xSDK { let mut delay = std::time::Duration::from_millis(retry_config.base_delay_ms); loop { let resp = self - .http_client + .client_for(endpoint)? .post(&url) .header("Content-Type", "application/protobuf") .header("x-dsm-message-id", message_id_b32) @@ -2061,6 +2100,18 @@ impl B0xSDK { /// The next call continues from the stored position. const MAX_RETRIEVE_PAGES_PER_NODE: usize = 16; + /// The position after `seq` on a node's spool, when `seq` is one the node + /// could hold. A spool position is the node's database sequence number, + /// so it and the one after it are at most `i64::MAX`; a number past that + /// is not an answer from a spool, and nothing is advanced by it. + fn position_after(seq: u64) -> Option { + seq.checked_add(1) + .filter(|next| *next <= Self::MAX_SPOOL_POSITION) + } + + /// The largest position a node's spool can name. + const MAX_SPOOL_POSITION: u64 = i64::MAX as u64; + /// Read the spool at `b0x_address` from every member that answers. `Err` /// when no member answered: nothing was read, which is not an empty /// inbox. Otherwise the entries found and what the read covers @@ -2097,6 +2148,9 @@ 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(); // Members whose spool was read to an answer. None is not an empty // inbox: nothing was read (storage spec §4). @@ -2110,12 +2164,15 @@ impl B0xSDK { let mut cursor = position; let mut consumed_run = true; 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; let mut pages = 0; - while pages < Self::MAX_RETRIEVE_PAGES_PER_NODE { + 'pages: while pages < Self::MAX_RETRIEVE_PAGES_PER_NODE { pages += 1; let url = format!("{}/api/v2/b0x/retrieve/{}", epc, cursor); let resp = self - .http_client + .client_for(&epc)? .get(&url) .header("Accept", "application/protobuf") .header("x-dsm-b0x-address", b0x_address) @@ -2155,7 +2212,19 @@ impl B0xSDK { if batch.envelopes.is_empty() || batch.next_seq <= cursor { break; } + if batch.next_seq > Self::MAX_SPOOL_POSITION { + malformed = Some(format!( + "next position {} is not a spool position", + batch.next_seq + )); + break; + } for sequenced in batch.envelopes { + let Some(after) = Self::position_after(sequenced.seq_num) else { + malformed = + Some(format!("seq {} is not a spool position", sequenced.seq_num)); + break 'pages; + }; // The node never opens what it holds (storage spec §8); // this device decodes. Bytes that are not a canonical // envelope never will be, so nothing waits on them. @@ -2167,7 +2236,7 @@ impl B0xSDK { epc, sequenced.seq_num, e ); if consumed_run { - position = position.max(sequenced.seq_num + 1); + position = position.max(after); } continue; } @@ -2175,28 +2244,37 @@ impl B0xSDK { let id = text_id::encode_base32_crockford(&env.message_id); if b0x_consumed::is_consumed(b0x_address, &id).map_err(local)? { if consumed_run { - position = position.max(sequenced.seq_num + 1); + position = position.max(after); + } + continue; + } + // DSM Amendment A7: open the seal before anything reads + // the envelope. What opens says nothing about who sealed + // it: anyone can seal to this device, and the sender is + // whoever the ingestion boundary verifies. What does not + // open was not sealed to this device and never will open + // here, so it is passed over like bytes that are not an + // envelope: it holds up nothing behind it. + match open_sealed(&kyber_secret, &env) { + Ok(inner) => { + consumed_run = false; + map.entry(envelope_merge_key(&inner)).or_insert(inner); } - } else { - consumed_run = false; - // DSM Amendment A7: open the seal before anything reads - // the envelope. What does not open was not sealed to - // this device and is skipped. What opens says nothing - // about who sealed it: anyone can seal to this device, - // and the sender is whoever the ingestion boundary - // verifies. - match open_sealed(&env) { - Ok(inner) => { - map.entry(envelope_merge_key(&inner)).or_insert(inner); + Err(e) => { + warn!("b0x envelope {} does not open: {}", id, e); + if consumed_run { + position = position.max(after); } - Err(e) => warn!("b0x envelope {} does not open: {}", id, e), } } } cursor = batch.next_seq; } b0x_consumed::advance_read_position(b0x_address, &epc, position).map_err(local)?; - if failed { + if let Some(why) = &malformed { + warn!("b0x retrieve from {}: {}", epc, why); + } + if failed || malformed.is_some() { self.circuit_breaker.mark_node_failed(&epc).await; } else { answered += 1; @@ -2713,7 +2791,8 @@ mod tests { let sealed = kept_seal(&one.message_id).expect("A kept the sealed send"); p.b.enter(); let outer = dsm::envelope::from_canonical_bytes(&sealed).expect("the sealed envelope"); - let inner = open_sealed(&outer).expect("B opens it"); + let inner = + open_sealed(&local_kyber_secret().expect("B's key"), &outer).expect("B opens it"); let Some(dsm::types::proto::envelope::Payload::UniversalTx(tx)) = &inner.payload else { panic!("A's transfer envelope carries a UniversalTx"); }; @@ -2783,6 +2862,100 @@ mod tests { ); } + /// A spool position is a node's database sequence number. The position + /// after one a node could hold is the next number; past the largest a + /// node can hold there is none, rather than an overflow: the position + /// after `u64::MAX` panicked the poller task once, and a node chooses + /// the numbers it answers with. + #[test] + fn a_position_past_what_a_spool_can_hold_is_none() { + assert_eq!(B0xSDK::position_after(0), Some(1)); + assert_eq!(B0xSDK::position_after(41), Some(42)); + assert_eq!( + B0xSDK::position_after(B0xSDK::MAX_SPOOL_POSITION - 1), + Some(B0xSDK::MAX_SPOOL_POSITION) + ); + assert_eq!(B0xSDK::position_after(B0xSDK::MAX_SPOOL_POSITION), None); + assert_eq!(B0xSDK::position_after(u64::MAX), None); + } + + /// A route's read position moves past what will never open for this + /// device. An envelope sealed to another key, first on a route, is passed + /// over like bytes that are not an envelope, so it holds up nothing behind + /// it: the read used to stop there for good, and enough of them on a route + /// put everything after them out of reach. On the storage nodes' own app, + /// on Postgres, read as a created device. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[serial_test::serial] + async fn what_never_opens_for_this_device_holds_up_nothing_behind_it() { + use crate::storage::client_db::b0x_consumed; + use crate::util::text_id::encode_base32_crockford; + use dsm::types::proto::{envelope::Payload, Envelope, Headers, SealedEnvelopeV1}; + + let device = crate::test_support::one_device::Device::start(0x6B).await; + let mut sdk = B0xSDK::new( + encode_base32_crockford(&device.identity.device_id), + device.router.core_sdk.clone(), + device.fleet.endpoints(), + ) + .expect("the device's spool client"); + let route = encode_base32_crockford(&rand::random::<[u8; 32]>()); + for endpoint in device.fleet.endpoints() { + assert_eq!( + b0x_consumed::read_position(&route, &endpoint).expect("the position"), + 0, + "a new route is read from its start" + ); + } + + // A well-formed sealed envelope, sealed to a key this device does not + // hold. + let other = dsm::crypto::kyber::generate_kyber_keypair().expect("another device's key"); + let message_id: [u8; 16] = rand::random(); + let inner = Envelope { + version: 3, + headers: Some(Headers { + device_id: rand::random::<[u8; 32]>().to_vec(), + genesis_hash: rand::random::<[u8; 32]>().to_vec(), + }), + message_id: message_id.to_vec(), + payload: None, + } + .encode_to_vec(); + let (shared_secret, kem_ciphertext) = + dsm::crypto::kyber::kyber_encapsulate(&other.public_key).expect("encapsulate"); + let ciphertext = + dsm::crypto::spool_seal::seal(&shared_secret, &message_id, &inner).expect("seal"); + let sealed = Envelope { + version: 3, + headers: None, + message_id: message_id.to_vec(), + payload: Some(Payload::Sealed(SealedEnvelopeV1 { + kem_ciphertext, + ciphertext, + })), + } + .encode_to_vec(); + let id = encode_base32_crockford(&message_id); + for endpoint in device.fleet.endpoints() { + sdk.submit_with_retry(&endpoint, &sealed, &route, &id, &B0xRetryConfig::default()) + .await + .expect("the node spools it"); + } + + let read = sdk + .retrieve_from_b0x_v2(&route) + .await + .expect("the route is read"); + assert_eq!(read.entries.len(), 0, "nothing opened for this device"); + for endpoint in device.fleet.endpoints() { + assert!( + b0x_consumed::read_position(&route, &endpoint).expect("the position") > 0, + "the read at {endpoint} stopped at an envelope that will never open here" + ); + } + } + // ================================================================== // WIRE BUDGET (ADR 0003) — real-size encoded artifacts must fit the // storage node's MAX_ENVELOPE_BYTES. 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 b4052769e..a0543b580 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 @@ -104,6 +104,18 @@ pub(crate) fn poller_start_deferred() -> bool { POLLER_START_DEFERRED.load(Ordering::SeqCst) } +/// Held by the poller task for as long as it runs, and clears the running +/// flag when it ends, however it ends. A sync cycle that panics unwinds +/// through it, so a later start is not refused as "already running" by a +/// poller that no longer runs. +struct RunningFlag; + +impl Drop for RunningFlag { + fn drop(&mut self) { + POLLER_RUNNING.store(false, Ordering::SeqCst); + } +} + /// Start the inbox poller background task on the SDK runtime. /// /// Idempotent: if already running, returns immediately. @@ -125,6 +137,7 @@ pub fn start_poller() { let wake = POLLER_WAKE.clone(); crate::runtime::get_runtime().spawn(async move { + let _running = RunningFlag; log::info!("[inbox_poller] Background poller started"); // Initial delay before first poll (let bootstrap settle). @@ -185,7 +198,6 @@ pub fn start_poller() { } } - POLLER_RUNNING.store(false, Ordering::SeqCst); log::info!("[inbox_poller] Background poller stopped"); }); } @@ -538,6 +550,28 @@ mod tests { stop_poller(); } + /// A poller task that ends by panicking clears the running flag as it + /// unwinds: the flag says a poller runs only while one does. + #[tokio::test] + #[serial_test::serial] + async fn a_poller_that_panics_is_no_longer_running() { + // The real start marks a poller running; its task is stopped before + // it polls anything. + start_poller(); + stop_poller(); + assert!(poller_running(), "the poller was started"); + let ended = tokio::spawn(async { + let _running = RunningFlag; + panic!("a sync cycle panics"); + }) + .await; + assert!(ended.unwrap_err().is_panic()); + assert!( + !poller_running(), + "a poller that panicked is still reported running, so it can never start again" + ); + } + #[test] #[serial_test::serial] fn stop_poller_sets_flag() { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/route_seats.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/route_seats.rs index 42b2ad84f..030c574bf 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/route_seats.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/route_seats.rs @@ -879,7 +879,7 @@ mod tests { RoutedCell, ) { let nodes = crate::test_support::nodes::NodeSet::start().await; - let config = crate::economic_fixtures::point_sdk_at(&nodes.members()); + let config = crate::economic_fixtures::point_sdk_at(&nodes.members(), nodes.ca_pem()); let set = crate::sdk::storage_set::canonical_set(crate::economic_fixtures::NETWORK) .expect("the pinned set"); let members = crate::sdk::storage_set::as_ccb_members(&set).expect("members"); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs index b7462c117..523695186 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs @@ -36,11 +36,11 @@ fn ca_error(what: String) -> DsmError { DsmError::storage(what, None::) } -/// Read the env config and every CA certificate it names. With no env config -/// the client trusts the system store only. A config that exists but cannot -/// be read or parsed, or that names a certificate that cannot be read, is an -/// error: a client built without a certificate the config requires cannot -/// reach the members that use it. +/// Read the env config and every CA certificate it names. A config that +/// cannot be read or parsed, or that names a certificate that cannot be read, +/// is an error: a client built without a certificate the config requires +/// cannot reach the members that use it. With no env config there is no CA, +/// and [`member_client`] builds no client from no CA. fn resolve_ca_material() -> Result { let env_path = crate::network::resolved_env_config_path(); let Some(path) = env_path.as_ref() else { @@ -63,8 +63,9 @@ fn read_ca_certs(path: &str) -> Result)>, DsmEr let config: toml::Value = toml::from_str(&text) .map_err(|e| ca_error(format!("storage client: env config {path}: {e}")))?; let mut certs = Vec::new(); - // An absent key trusts the system store only; a key that is present but - // not a list of paths is a malformed config, not an empty one. + // An absent key names no CA, and no member can then be known by its + // certificate; a key that is present but not a list of paths is a + // malformed config, not an empty one. let entries = match config.get("custom_ca_certs") { None => None, Some(value) => Some(value.as_array().ok_or_else(|| { @@ -107,46 +108,165 @@ const MEMBER_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_se /// ordering reads it. const MEMBER_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); -/// Build a client from resolved material: the expensive step (TLS -/// configuration, connection pool), run only when the material changes. -fn build_client_from(material: &CaMaterial) -> Result { - let mut builder = reqwest::Client::builder() - .user_agent("DSM-SDK/1.0") - .connect_timeout(MEMBER_CONNECT_TIMEOUT) - .timeout(MEMBER_REQUEST_TIMEOUT); +/// A member is reached only over `https://`: its identity is the +/// certificate it presents, and plain HTTP presents none. +pub fn require_https(endpoint: &str) -> Result<(), DsmError> { + match endpoint.split_once("://") { + Some((scheme, _)) if scheme.eq_ignore_ascii_case("https") => Ok(()), + _ => Err(ca_error(format!( + "storage member endpoint {endpoint} is not https://: a member is known by its \ + certificate, and plain HTTP presents none" + ))), + } +} + +/// Accepts a member's certificate only when it chains to one of the CAs the +/// env config names AND names the member, as a DNS name among its subject +/// alternative names, whatever address the member was dialled at. A member +/// signs nothing (storage spec §2), so the certificate is the only thing that +/// says which member answered; a certificate for another member of the same +/// set, at this member's address, is refused. +#[derive(Debug)] +struct NamesTheMember { + chain: std::sync::Arc, + member: rustls::pki_types::ServerName<'static>, +} + +impl rustls::client::danger::ServerCertVerifier for NamesTheMember { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + intermediates: &[rustls::pki_types::CertificateDer<'_>], + _dialled: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + self.chain + .verify_server_cert(end_entity, intermediates, &self.member, ocsp_response, now) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.chain.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.chain.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.chain.supported_verify_schemes() + } +} + +/// The client for `member_id`, built from resolved material: it trusts the +/// CAs the material holds and nothing else (no system store), and accepts +/// only a certificate that names `member_id`. +fn build_member_client( + material: &CaMaterial, + member_id: &str, +) -> Result { + use rustls::pki_types::pem::PemObject; + if material.certs.is_empty() { + return Err(ca_error( + "storage client: the env config names no CA (`custom_ca_certs`), so no member \ + can be known by its certificate" + .to_string(), + )); + } + let mut roots = rustls::RootCertStore::empty(); for (cert_path, bytes) in &material.certs { - let cert = reqwest::Certificate::from_pem(bytes).map_err(|e| { - ca_error(format!( - "storage client: CA certificate {}: {e}", + let mut held = 0usize; + for cert in rustls::pki_types::CertificateDer::pem_slice_iter(bytes) { + let cert = cert.map_err(|e| { + ca_error(format!( + "storage client: CA certificate {}: {e}", + cert_path.display() + )) + })?; + roots.add(cert).map_err(|e| { + ca_error(format!( + "storage client: CA certificate {}: {e}", + cert_path.display() + )) + })?; + held += 1; + } + if held == 0 { + return Err(ca_error(format!( + "storage client: {} holds no certificate", cert_path.display() - )) - })?; - builder = builder.add_root_certificate(cert); + ))); + } } - builder + let provider = std::sync::Arc::new(rustls::crypto::ring::default_provider()); + let chain = rustls::client::WebPkiServerVerifier::builder_with_provider( + std::sync::Arc::new(roots), + provider.clone(), + ) + .build() + .map_err(|e| ca_error(format!("storage client: {e}")))?; + let member = rustls::pki_types::ServerName::try_from(member_id.to_string()).map_err(|e| { + ca_error(format!( + "storage member id {member_id} is not a name a certificate can carry: {e}" + )) + })?; + let config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| ca_error(format!("storage client: {e}")))? + .dangerous() + .with_custom_certificate_verifier(std::sync::Arc::new(NamesTheMember { chain, member })) + .with_no_client_auth(); + reqwest::Client::builder() + .user_agent("DSM-SDK/1.0") + .connect_timeout(MEMBER_CONNECT_TIMEOUT) + .timeout(MEMBER_REQUEST_TIMEOUT) + .use_preconfigured_tls(config) .build() .map_err(|e| ca_error(format!("storage client: {e}"))) } -/// The HTTP client for storage members, one per CA material. The material is -/// re-read on every call, so a re-pointed config or a replaced certificate -/// takes effect; the client itself is built once per material and shared. -pub fn build_ca_aware_client() -> Result { +/// The HTTP client for the member `member_id`, reached at `endpoint`: over +/// `https://` only, trusting only the CAs the env config names, and accepting +/// only a certificate that names `member_id`. The material is re-read on +/// every call, so a re-pointed config or a replaced certificate takes effect; +/// each member's client is built once per material and shared. +pub fn member_client(member_id: &str, endpoint: &str) -> Result { + require_https(endpoint)?; let material = resolve_ca_material()?; - let mut slot = CA_AWARE_CLIENT + let mut slot = MEMBER_CLIENTS .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some((cached, client)) = slot.as_ref() { - if *cached == material { + if let Some((cached, clients)) = slot.as_ref() { + if let Some(client) = clients.get(member_id).filter(|_| *cached == material) { return Ok(client.clone()); } } - let client = build_client_from(&material)?; - *slot = Some((material, client.clone())); + let client = build_member_client(&material, member_id)?; + match slot.as_mut() { + Some((cached, clients)) if *cached == material => { + clients.insert(member_id.to_string(), client.clone()); + } + _ => { + let clients = MemberClients::from([(member_id.to_string(), client.clone())]); + *slot = Some((material, clients)); + } + } Ok(client) } -static CA_AWARE_CLIENT: std::sync::Mutex> = +type MemberClients = std::collections::HashMap; + +static MEMBER_CLIENTS: std::sync::Mutex> = std::sync::Mutex::new(None); // ── One member ────────────────────────────────────────────────────────────── @@ -159,6 +279,20 @@ pub struct MemberClient { client: reqwest::Client, } +/// `error` and every error under it, outermost first: a transport error's +/// own text says only that a request failed, and the reason (a refused +/// certificate, a reset connection) is underneath it. +fn with_causes(error: &dyn std::error::Error) -> String { + let mut text = error.to_string(); + let mut cause = error.source(); + while let Some(under) = cause { + text.push_str(": "); + text.push_str(&under.to_string()); + cause = under.source(); + } + text +} + /// A member's answer: `Ok(Some)` for a `200`/`201` body, `Ok(None)` for `204` /// (the member answered with nothing), `Err` for anything else. A `404` is not /// an answer: no route of the member's answers it on success, so it means a @@ -170,7 +304,7 @@ async fn answer(request: reqwest::RequestBuilder) -> Result>, Str let response = request .send() .await - .map_err(|e| format!("transport: {e}"))?; + .map_err(|e| format!("transport: {}", with_causes(&e)))?; match response.status().as_u16() { 200 | 201 => response .bytes() @@ -213,12 +347,16 @@ pub struct LatestByteCommitRead { } impl MemberClient { - pub fn new(member_id: &str, endpoint: &str, client: reqwest::Client) -> Self { - Self { + /// The member `member_id` at `endpoint`, reached with a client that + /// accepts only `member_id`'s certificate ([`member_client`]). The client + /// is built here, from the member id, so a member's name and the identity + /// its connection checks cannot disagree. + pub fn new(member_id: &str, endpoint: &str) -> Result { + Ok(Self { member_id: member_id.to_string(), endpoint: endpoint.trim_end_matches('/').to_string(), - client, - } + client: member_client(member_id, endpoint)?, + }) } pub fn member_id(&self) -> &str { @@ -505,7 +643,7 @@ impl MemberClient { Ok(response) => response, Err(e) => { return LatestByteCommitRead { - answer: LatestByteCommit::Unanswered(format!("transport: {e}")), + answer: LatestByteCommit::Unanswered(format!("transport: {}", with_causes(&e))), answered_as: None, } } @@ -553,13 +691,12 @@ pub struct SetClient { impl SetClient { pub fn new(set: &StorageSet) -> Result { - let client = build_ca_aware_client()?; Ok(Self { members: set .members() .iter() - .map(|m| MemberClient::new(&m.member_id, &m.endpoint, client.clone())) - .collect(), + .map(|m| MemberClient::new(&m.member_id, &m.endpoint)) + .collect::>()?, }) } @@ -729,10 +866,7 @@ impl SetClient { #[cfg(test)] #[allow(clippy::disallowed_methods)] mod tests { - use super::{ - build_ca_aware_client, build_client_from, read_ca_certs, CaMaterial, LatestByteCommit, - MemberClient, - }; + use super::{build_member_client, read_ca_certs, CaMaterial, LatestByteCommit, MemberClient}; /// The env config the app bundles names the CA bundled beside it, and the /// storage client builds from that CA through the same reader and builder @@ -748,10 +882,13 @@ mod tests { let certs = read_ca_certs(path).expect("the bundled CA is named and readable"); assert_eq!(certs.len(), 1, "exactly the fleet's CA"); assert!(certs[0].0.ends_with("ca.crt")); - build_client_from(&CaMaterial { - env_path: Some(path.to_string()), - certs, - }) + build_member_client( + &CaMaterial { + env_path: Some(path.to_string()), + certs, + }, + "dsm-node-1", + ) .expect("the storage client builds from the bundled CA"); } @@ -870,15 +1007,11 @@ mod tests { fn a_member_that_answers_404_took_nothing() { let fleet = crate::test_support::one_device::Fleet::start(); let endpoint = fleet.endpoints()[0].clone(); - let client = build_ca_aware_client().expect("a client"); let ns = dsm::crypto::domain::TaggedHashDomain::try_new(b"DSM/test/404-is-not-an-answer") .expect("a domain"); crate::runtime::get_runtime().block_on(async { - let wrong = MemberClient::new( - "dsm-node-1", - &format!("{endpoint}/not-the-api"), - client.clone(), - ); + let wrong = MemberClient::new("dsm-node-1", &format!("{endpoint}/not-the-api")) + .expect("a client"); assert!(wrong.put_immutable(ns, b"bytes").await.is_err()); assert!(wrong .append_index(b"DSM/test/index", &[0x41; 32], &[0x42; 32]) @@ -890,7 +1023,7 @@ mod tests { LatestByteCommit::Unanswered("answered HTTP 404".to_string()) ); - let right = MemberClient::new("dsm-node-1", &endpoint, client); + let right = MemberClient::new("dsm-node-1", &endpoint).expect("a client"); right .put_immutable(ns, b"bytes") .await @@ -910,18 +1043,17 @@ mod tests { /// A member's latest ByteCommit is shown only as that member's own. The /// member states "no cycle" until one closes, then the ByteCommit it /// closed; the node that answers echoes the member id it is configured - /// as. Reached where the set names ANOTHER member, the same node's - /// ByteCommit names itself, so there is no ByteCommit of that member's to - /// show, and the echo says who answered. On the storage node's own app, - /// on Postgres. + /// as. Reached as ANOTHER member, at this node's address, nothing is + /// read at all: the node's certificate names the member it is, and a + /// client for another member refuses it before any request is sent. On + /// the storage node's own app, on Postgres. #[test] #[serial_test::serial] fn a_members_latest_bytecommit_is_its_own_or_there_is_none() { let fleet = crate::test_support::one_device::Fleet::start(); let endpoint = fleet.endpoints()[0].clone(); - let client = build_ca_aware_client().expect("a client"); crate::runtime::get_runtime().block_on(async { - let member = MemberClient::new("dsm-node-1", &endpoint, client.clone()); + let member = MemberClient::new("dsm-node-1", &endpoint).expect("a client"); let before = member.latest_bytecommit().await; assert_eq!(before.answer, LatestByteCommit::NoCycle); assert_eq!(before.answered_as.as_deref(), Some(&b"dsm-node-1"[..])); @@ -947,16 +1079,19 @@ mod tests { assert_eq!(commit.cycle_index, cycle); assert_eq!(after.answered_as.as_deref(), Some(&b"dsm-node-1"[..])); - let misnamed = MemberClient::new("dsm-node-2", &endpoint, client); + let misnamed = MemberClient::new("dsm-node-2", &endpoint).expect("a client"); let read = misnamed.latest_bytecommit().await; - assert_eq!( - read.answer, - LatestByteCommit::Unanswered( - "answered with a ByteCommit naming dsm-node-1".to_string() - ), - "another member's ByteCommit is never shown as this member's" + let LatestByteCommit::Unanswered(why) = &read.answer else { + panic!( + "dsm-node-1's node answered a client for dsm-node-2: {:?}", + read.answer + ); + }; + assert!( + why.contains("certificate not valid for name \"dsm-node-2\""), + "the connection is refused because the certificate names another member: {why}" ); - assert_eq!(read.answered_as.as_deref(), Some(&b"dsm-node-1"[..])); + assert_eq!(read.answered_as, None, "nothing was read from the node"); }); } @@ -969,10 +1104,65 @@ mod tests { (dir, path) } - /// An absent `custom_ca_certs` trusts the system store only; a present one - /// is a list of readable certificate paths or the config is malformed — - /// a scalar, a table, a non-string entry or an unreadable file is an - /// error, never "no certificates". + /// An absent `custom_ca_certs` names no CA; a present one is a list of + /// readable certificate paths or the config is malformed — a scalar, a + /// table, a non-string entry or an unreadable file is an error, never + /// "no certificates". + /// A member is known only by a certificate from a CA the env config + /// names, so no client is built from no CA (the system store is never + /// trusted in its place), nor from a named file that holds no + /// certificate, nor for a member id no certificate can carry. + #[test] + fn no_client_is_built_without_a_named_ca_or_for_an_unnameable_member() { + let none = CaMaterial { + env_path: None, + certs: Vec::new(), + }; + let refused = build_member_client(&none, "dsm-node-1").unwrap_err(); + assert!(refused.to_string().contains("names no CA"), "{refused}"); + + let not_pem = CaMaterial { + env_path: None, + certs: vec![(std::path::PathBuf::from("root.pem"), b"PEM".to_vec())], + }; + let refused = build_member_client(¬_pem, "dsm-node-1").unwrap_err(); + assert!( + refused.to_string().contains("holds no certificate"), + "{refused}" + ); + + let ca = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) + .expect("a CA") + .cert + .pem() + .into_bytes(); + let named = CaMaterial { + env_path: None, + certs: vec![(std::path::PathBuf::from("ca.pem"), ca)], + }; + build_member_client(&named, "dsm-node-1").expect("a client for a nameable member"); + let refused = build_member_client(&named, "not a name").unwrap_err(); + assert!( + refused + .to_string() + .contains("not a name a certificate can carry"), + "{refused}" + ); + } + + /// Only `https://` reaches a member: a plain or schemeless endpoint gets + /// no client at all. + #[test] + fn a_member_endpoint_that_is_not_https_gets_no_client() { + for endpoint in ["http://127.0.0.1:8080", "127.0.0.1:8080"] { + let refused = super::member_client("dsm-node-1", endpoint).unwrap_err(); + assert!( + refused.to_string().contains("not https://"), + "{endpoint}: {refused}" + ); + } + } + #[test] fn custom_ca_certs_is_a_list_of_readable_paths_or_an_error() { let (_d, path) = config("network_id = \"x\"\n"); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs index 5194879ff..aa9e9c315 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs @@ -9,7 +9,11 @@ //! is checked at start-up against the register incarnation its database holds, //! and it serves exactly the app the binary serves //! (`dsm_storage_node::build_app`, with the deployed fleet's limits), bound to -//! a local port. +//! a local port over TLS, as the binary serves it. Each node set has a CA of +//! its own, and each node a certificate from it naming its member and the +//! loopback address it is served at, as the fleet's certificates name their +//! member and IP (`dsm_storage_node/deploy/generate_node_configs.sh`). A +//! device trusts that CA and nothing else ([`NodeSet::ca_pem`]). //! //! The nodes are the network's pinned register members, one node each. A node //! draws its incarnation when it first starts on an empty database; these @@ -128,15 +132,68 @@ async fn restore_incarnation(pool: &db::DBPool, incarnation: &[u8; 32]) { /// A node's serving task and the signal that stops it. struct Serving { - stop: Arc, + handle: axum_server::Handle, task: tokio::task::JoinHandle<()>, } +/// A node set's CA: it issues every node's certificate, every set-mate is +/// pinned to it, and a device trusts it and nothing else. +struct TestCa { + issuer: rcgen::Issuer<'static, rcgen::KeyPair>, + pem: Vec, +} + +impl TestCa { + fn new() -> Self { + let key = rcgen::KeyPair::generate().expect("the CA's key"); + let mut params = + rcgen::CertificateParams::new(Vec::::new()).expect("the CA's parameters"); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "DSM-Storage-CA (test node set)"); + params.key_usages = vec![ + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::DigitalSignature, + ]; + let pem = params + .self_signed(&key) + .expect("the CA's certificate") + .pem(); + Self { + issuer: rcgen::Issuer::new(params, key), + pem: pem.into_bytes(), + } + } + + /// The TLS a node serves `member_id` with: a certificate from this CA + /// naming the member, as a DNS name, and the loopback address. + async fn tls_for(&self, member_id: &str) -> axum_server::tls_rustls::RustlsConfig { + let key = rcgen::KeyPair::generate().expect("the node's key"); + let mut params = + rcgen::CertificateParams::new(vec![member_id.to_string(), "127.0.0.1".to_string()]) + .expect("the node's parameters"); + params + .distinguished_name + .push(rcgen::DnType::CommonName, member_id); + let cert = params + .signed_by(&key, &self.issuer) + .expect("the node's certificate"); + axum_server::tls_rustls::RustlsConfig::from_pem( + cert.pem().into_bytes(), + key.serialize_pem().into_bytes(), + ) + .await + .expect("the node's TLS") + } +} + /// Serve the binary's app for `state` on `listener` until stopped. Every /// request the app is handed is recorded in `requests` first, as the method /// and the path: what an operator of this node can see the node was asked. fn serve( listener: tokio::net::TcpListener, + tls: axum_server::tls_rustls::RustlsConfig, state: Arc, requests: Arc>>, ) -> Serving { @@ -154,15 +211,18 @@ fn serve( } }, )); - let stop = Arc::new(tokio::sync::Notify::new()); - let signal = stop.clone(); + let handle = axum_server::Handle::new(); + let server = + axum_server::from_tcp_rustls(listener.into_std().expect("the node's listener"), tls) + .expect("the node's TLS server") + .handle(handle.clone()); let task = tokio::spawn(async move { - axum::serve(listener, app) - .with_graceful_shutdown(async move { signal.notified().await }) + server + .serve(app.into_make_service()) .await .expect("serve node"); }); - Serving { stop, task } + Serving { handle, task } } /// One node. @@ -171,6 +231,7 @@ pub struct Node { pub endpoint: String, pub incarnation: [u8; 32], address: SocketAddr, + tls: axum_server::tls_rustls::RustlsConfig, state: Arc, serving: Option, requests: Arc>>, @@ -227,7 +288,7 @@ impl Node { impl Drop for Node { fn drop(&mut self) { if let Some(serving) = self.serving.take() { - serving.stop.notify_one(); + serving.handle.shutdown(); } } } @@ -235,6 +296,7 @@ impl Drop for Node { /// The network's pinned register members, one node each. pub struct NodeSet { pub nodes: Vec, + ca_pem: Vec, } /// The process's rustls provider, installed as the node binary's `main` @@ -306,12 +368,9 @@ impl NodeSet { .map(|p| (p.member_id.clone(), endpoint_of(&p.address))) .collect(); - // The set's CA, the one anchor every member pins its peers to. - let set_ca_pem = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) - .expect("generate the set's CA") - .cert - .pem() - .into_bytes(); + // The set's CA: it issues every node's certificate, and every member + // pins its peers to it. + let ca = TestCa::new(); // Phase 2: each node, with the set checked against its own register, // serving the binary's app. @@ -326,24 +385,36 @@ impl NodeSet { AppState::new( p.member_id.clone(), p.pool, - set_client::pinned_set_client(&set_ca_pem).expect("pinned set client"), + set_client::pinned_set_client(&ca.pem).expect("pinned set client"), ) .expect("app state") .with_storage_set(set), ); let requests = Arc::new(Mutex::new(Vec::new())); - let serving = serve(p.listener, state.clone(), requests.clone()); + let tls = ca.tls_for(&p.member_id).await; + let serving = serve(p.listener, tls.clone(), state.clone(), requests.clone()); nodes.push(Node { member_id: p.member_id, endpoint, incarnation: p.incarnation, address: p.address, + tls, state, serving: Some(serving), requests, }); } - Self { nodes } + Self { + nodes, + ca_pem: ca.pem, + } + } + + /// The PEM of this node set's CA: what a device's env config names in + /// `custom_ca_certs` to reach these nodes, as the bundled config names the + /// fleet's. + pub fn ca_pem(&self) -> &[u8] { + &self.ca_pem } /// Every node as `(member id, endpoint, register incarnation)`, in pin @@ -368,7 +439,7 @@ impl NodeSet { .serving .take() .unwrap_or_else(|| panic!("node {member_id} is already down")); - serving.stop.notify_one(); + serving.handle.shutdown(); serving.task.await.expect("node task"); } } @@ -382,7 +453,12 @@ impl NodeSet { let listener = tokio::net::TcpListener::bind(node.address) .await .expect("rebind node address"); - node.serving = Some(serve(listener, node.state.clone(), node.requests.clone())); + node.serving = Some(serve( + listener, + node.tls.clone(), + node.state.clone(), + node.requests.clone(), + )); } } @@ -499,5 +575,5 @@ struct Prepared { } fn endpoint_of(address: &SocketAddr) -> String { - format!("http://{address}") + format!("https://{address}") } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/one_device.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/one_device.rs index 22dc15769..576d1aad4 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/one_device.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/one_device.rs @@ -26,7 +26,7 @@ impl Device { pub async fn start(seed: u8) -> Self { economic_fixtures::use_test_storage_dir(); let nodes = NodeSet::start().await; - let fleet = economic_fixtures::point_sdk_at(&nodes.members()); + let fleet = economic_fixtures::point_sdk_at(&nodes.members(), nodes.ca_pem()); let (router, identity) = economic_fixtures::empty_router_with(&fleet, seed, false).await; Self { nodes, @@ -75,7 +75,7 @@ impl Fleet { let nodes = std::thread::spawn(|| crate::runtime::get_runtime().block_on(NodeSet::start())) .join() .expect("start the nodes"); - let config = economic_fixtures::point_sdk_at(&nodes.members()); + let config = economic_fixtures::point_sdk_at(&nodes.members(), nodes.ca_pem()); Self { nodes, config } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs index d1a2671b0..15d19fe5d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs @@ -337,7 +337,7 @@ impl Pair { // Fresh nodes per pair: each test gets empty registers, so no earlier // test's claims sit where this one will write. let nodes = NodeSet::start().await; - let fleet = economic_fixtures::point_sdk_at(&nodes.members()); + let fleet = economic_fixtures::point_sdk_at(&nodes.members(), nodes.ca_pem()); let mut a = TestDevice::create("A", 0x0A); let mut b = TestDevice::create("B", 0x0B); a.boot(&fleet).await; diff --git a/dsm_storage_node/tests/immutable_store_round_trip.rs b/dsm_storage_node/tests/immutable_store_round_trip.rs index e7a37ffc0..ba20b1dcb 100644 --- a/dsm_storage_node/tests/immutable_store_round_trip.rs +++ b/dsm_storage_node/tests/immutable_store_round_trip.rs @@ -348,7 +348,10 @@ async fn puts_made_at_once_are_each_taken() { .await; let first = answers[0].1.clone(); for (status, addr) in &answers { - assert!(status.is_success(), "an identical put was refused: {status}"); + assert!( + status.is_success(), + "an identical put was refused: {status}" + ); assert_eq!(addr, &first, "every identical put names the one address"); } } diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index df8f85026..6cd964119 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2054,6 +2054,54 @@ The quote took 20 s. - **The chain walk of a vault the trade consumed, about 19 s.** Two passes over the sibling leg re-read the same four or five cells about four times each. - **Establishing the facts, 2.7 s.** +### 6.62 Security pre-audit, storage: what a client can hold on a node, who a member is, and an inbox junk cannot wedge (`fix/storage-dos-and-transport-hardening`, 2026-10-01) + +CORE's pre-audit at main 076792eac assigned items 7, 8, 10 and 11 to storage (owner, 2026-10-01: reduce the unknowns, build adversarial tests, close what is provably wrong). Each gate below was mutation-tested: the gate was removed, the named test went red, and the gate was restored. + +**Item 10, the node.** + +| What was wrong | Now | Test | Mutation control | +|---|---|---|---| +| `ConcurrencyLimitLayer` on an axum router is one limit per route | One `GlobalConcurrencyLimitLayer` for the node | `dsm_storage_node::resource_limits::one_count_of_requests_in_flight_holds_across_every_route` | per-route limit → red | +| No request or body timeout | `TimeoutLayer` outside the limit (408), `[http] request_timeout_secs` | `…::a_request_whose_body_never_arrives_is_answered_408` | disabled → red | +| No header-read bound; axum-server gave hyper no timer, so no bound could be kept | `bound_connections`: a timer and a header-read bound, `[http] header_read_timeout_secs` | `…::a_connection_that_never_finishes_its_headers_is_closed` | no bound → red; no timer → red (hyper drops the connection at once, which the test refuses as too early) | +| No pool bound; queued closers each held a connection waiting on the advisory lock | Pool of 32 with a 10 s wait; closers queue on `AppState::closing` before taking a connection | `…::closers_waiting_their_turn_leave_the_pool_to_everyone_else` | lock taken and dropped → red | +| A set-mate's answer was read unbounded | Read no further than the largest `ByteCommitV4` (221 bytes, from its fields) | `dsm_storage_node::bytecommit_chain::a_set_mate_whose_answer_never_ends_is_read_no_further_than_a_byte_commit` | uncapped → red | +| Concurrent immutable puts of different objects failed with 500 (SERIALIZABLE, no retry on 40001); measured by SOFI, 6 of 16 | Read committed, `ON CONFLICT DO NOTHING`, then the held tuple is read to tell identical from conflicting | `dsm_storage_node::immutable_store_round_trip::puts_made_at_once_are_each_taken` | the serializable put → red, 29 of 64 refused | +| Health answered the Postgres driver's error text | `postgres unavailable`; the error is logged | `dsm_storage_node::health::the_health_route_answers_ok_only_over_a_live_postgres` | old text → red | +| A spool key was kept as the writer spelled it; Base32 Crockford reads several spellings as one key | Kept under its canonical spelling | `dsm_storage_node::api::transport::b0x::tests::a_message_sent_under_another_spelling_of_its_key_reaches_its_spool` | raw key → red | + +Also, without a dedicated test: +- the store-wide SMT builds for a close and a proof run on `spawn_blocking`; +- the mirror write goes through `begin_durable_write`; +- the immutable put logs no caller header text; +- deploy: SSH `accept-new`; `node.key` owned by the image's uid and gid at 600, not 644; both services' logs rotate (5 × 50 MB). + +The timeouts are `Duration`s handed to the HTTP and pool libraries. `no_clock_reads` stays green. + +**Items 7 and 8, the device.** +- `https://` only: in the env config loader, and in every member client (`storage_node_sdk::require_https`). +- Each member client trusts only the CAs the env config names, with no system store. It accepts only a certificate that names the member id as a DNS subject alternative name, whatever address it dialled (`NamesTheMember`). `MemberClient::new(member_id, endpoint)` builds its own client, so a member's name and the identity its connection checks cannot disagree. The inbox client holds one such client per member endpoint. +- The SDK test harness serves its nodes over TLS from a per-set CA, each certificate naming its member and `127.0.0.1`, as the fleet's do; there is no localhost exception. The five live fleet certificates were read in a TLS handshake (2026-10-01): each carries `DNS:dsm-node-N` for its own member, issued by `DSM-Storage-CA`. +- Tests: + - `dsm_sdk::sdk::storage_node_sdk::tests::a_members_latest_bytecommit_is_its_own_or_there_is_none`: a client for `dsm-node-2` at `dsm-node-1`'s node is refused on the certificate before any request. Mutation: verifying the dialled address → red. + - `dsm_sdk::network::tests::an_endpoint_that_is_not_https_is_refused` and `dsm_sdk::sdk::storage_node_sdk::tests::a_member_endpoint_that_is_not_https_gets_no_client`. Mutation: `http://` accepted → both red. + - `…::no_client_is_built_without_a_named_ca_or_for_an_unnameable_member`. +- Transport errors now carry their causes, so a refused certificate says why. + +**Item 11, the inbox.** +- A node-supplied spool position is read only when a spool could hold it (`B0xSDK::position_after`): `u64::MAX + 1` panicked the poller task, and a position past `i64::MAX` aborted the whole read. Test: `dsm_sdk::sdk::b0x_sdk::tests::a_position_past_what_a_spool_can_hold_is_none`. Mutation: unchecked → red. +- An envelope that does not open with this device's key is passed over like bytes that are not an envelope, so it no longer stops the read position for good. The key is derived once per read; an unavailable key fails the read as this device's state. Test: `…::what_never_opens_for_this_device_holds_up_nothing_behind_it`. Mutation: no advance → red. +- The poller's running flag is cleared however its task ends. Test: `dsm_sdk::sdk::inbox_poller::tests::a_poller_that_panics_is_no_longer_running`. Mutation: never cleared → red. + +**Open, for the owner (not built):** +- **Paging the cell GET.** It contradicts MR-STOR-0029, "a get returns everything". +- **Write budgets** per key, per source or global. The credits and spend-gate subsystems are Missing. +- **Capping the mirror's distinct ByteCommits per member and cycle.** Spec §14 rule 6 keeps every one. +- **Committing the CA with the storage set.** It is configured today. + +**Open, SOFI's sync design:** in `pull_and_process_inbox`, an entry that never becomes a transfer counts against the sync's limit and is never consumed. Routes the limit leaves unread are not reported, and reporting them would make every limit-capped sync fail (`inbox_incomplete` stops the sync). Both depend on what a limit-capped sync means. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | From cacecdf1d85b12f0ae83c70183ba22606f918792 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:18:23 -0400 Subject: [PATCH 06/31] fix(wallet): a send names its recipient by device id, never by alias Pre-audit item 6 (P0), audited at main 076792eac. The flaw: the send screen chose a contact by its device id but sent the contact's alias. wallet.sendSmart resolved that alias to the first matching row, and aliases are not unique, so a send to the second "Bob" paid the first. The fix: - OnlineTransferSmartRequest reserves field 1 (recipient) and carries bytes recipient_device_id = 5 [(dsm_fixed_len)=32]. - The SDK takes those 32 bytes or refuses the request, and resolves nothing by alias. get_contact_by_alias had no caller left and is removed. - The frontend sends the chosen contact's device id, the key SendTab selects it by. - Practice mode finds recipients by device id. Test: a_send_pays_the_contact_chosen_not_the_first_of_its_alias. Two contacts share an alias, and each is paid what was sent to it. It was red on the old wire. Resolving by alias turns it red again. Frontend: type-check clean, 7 suites, 51 tests. make lint exit 0. --- .../src/handlers/sender_admission_tests.rs | 81 ++++++++++++++++++- .../dsm_sdk/src/handlers/wallet_routes.rs | 55 +++---------- .../dsm_sdk/src/storage/client_db/contacts.rs | 33 -------- .../dsm_sdk/src/test_support/two_device.rs | 7 +- .../EnhancedWalletScreen.events.test.tsx | 7 +- .../src/components/screens/wallet/SendTab.tsx | 2 +- .../tour/__tests__/practiceMode.test.ts | 8 +- .../src/components/tour/practiceMode.ts | 10 ++- .../src/dsm/__tests__/onlineTransfer.test.ts | 12 ++- dsm_client/frontend/src/dsm/transactions.ts | 17 ++-- dsm_client/frontend/src/proto/dsm_app_pb.ts | 17 ++-- .../src/tests/E2E.sendOnlineTransfer.test.ts | 5 +- .../src/tests/E2E.transferProof.test.ts | 13 +-- proto/dsm_app.proto | 9 ++- 14 files changed, 161 insertions(+), 115 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs index 5cc052860..25048a498 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs @@ -302,7 +302,7 @@ async fn sequential_admissions_stay_monotonic_across_operation_kinds() { async fn a_transfer_naming_no_token_or_a_misspelled_one_is_refused_and_nothing_moves() { let p = Pair::boot(100, 0).await; let request = |token_id: &str| crate::generated::OnlineTransferSmartRequest { - recipient: crate::util::text_id::encode_base32_crockford(&p.b.device_id), + recipient_device_id: p.b.device_id.to_vec(), amount: "10".to_string(), token_id: token_id.to_string(), memo: String::new(), @@ -416,6 +416,85 @@ async fn a_burn_disabled_token_refuses_its_burn() { ); } +/// Pre-audit item 6 (§6.63): a send pays the contact the user chose, never +/// whichever contact its alias finds. A holds two contacts named "B": B, +/// added first, and C, added under the same alias. The user pays each in +/// turn, 10 ERA to C and then 20 ERA to B, and the wallet sends the request +/// the frontend builds for each choice ([`chosen_contact_request`]). Each is +/// paid what was sent to it. Resolving by alias, in whatever order, sends +/// both payments to the same contact. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_send_pays_the_contact_chosen_not_the_first_of_its_alias() { + let p = Pair::boot(100, 0).await; + let mut c = crate::test_support::two_device::TestDevice::create("C", 0x0C); + c.boot(&p.fleet).await; + c.add_contact(&p.a).await; + let same_alias = + p.a.invoke( + "contacts.addManual", + &crate::generated::ContactManualAddRequest { + alias: p.b.slot.to_string(), + device_id: c.device_id.to_vec(), + genesis_hash: c.genesis.to_vec(), + signing_public_key: c.ak_pk.clone(), + }, + ) + .await; + assert!( + same_alias.success, + "A adds C under B's alias: {:?}", + same_alias.error_message + ); + + for (chosen, amount) in [(&c, "10"), (&p.b, "20")] { + let sent = + p.a.invoke( + "wallet.sendSmart", + &chosen_contact_request(&p.a, chosen, amount), + ) + .await; + assert!( + sent.success, + "the send to {}: {:?}", + chosen.slot, sent.error_message + ); + } + c.sync().await; + p.b.sync().await; + assert_eq!( + c.era_balance(), + economic_fixtures::whole_era(10), + "C is paid what was sent to C" + ); + assert_eq!( + p.b.era_balance(), + economic_fixtures::whole_era(20), + "B is paid what was sent to B" + ); +} + +/// The request the frontend builds when the user chooses `contact` in the +/// send screen (`SendTab.tsx` → `dsm/transactions.ts`): ERA, the amount as +/// typed, and the recipient by the chosen contact's device id, the key the +/// send screen selects it by. +fn chosen_contact_request( + sender: &crate::test_support::two_device::TestDevice, + contact: &crate::test_support::two_device::TestDevice, + amount: &str, +) -> crate::generated::OnlineTransferSmartRequest { + sender.enter(); + let chosen = client_db::get_contact_by_device_id(&contact.device_id) + .expect("A's contacts") + .expect("the chosen contact"); + crate::generated::OnlineTransferSmartRequest { + recipient_device_id: chosen.device_id, + amount: amount.to_string(), + token_id: "ERA".to_string(), + memo: String::new(), + } +} + /// MR-DSM-0074, Amendment A3: a device sends only over relationships it /// has pre-added. A device A never added as a contact cannot be sent to: /// `wallet.sendSmart` refuses it, nothing is debited, no position is diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs index 848b54b73..8799d9d54 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs @@ -1235,8 +1235,6 @@ impl AppRouterImpl { } "wallet.sendSmart" => { - use crate::storage::client_db::get_contact_by_alias; - // Decode ArgPack from args let arg_pack = match generated::ArgPack::decode(&*i.args) { Ok(p) => p, @@ -1253,47 +1251,20 @@ impl AppRouterImpl { Err(e) => return err(format!("decode OnlineTransferSmartRequest failed: {e}")), }; - // 1. Resolve Recipient (Crockford Base32 device_id OR Alias) - // Try base32 decode first — only accept if it produces exactly 32 bytes - // (a valid device ID). Otherwise fall through to alias lookup, since - // short aliases like "ej8w2khr" are valid base32 but decode to <32 bytes. - let to_device_id: [u8; 32] = { - let as_device_id = - crate::util::text_id::decode_base32_crockford(&smart_req.recipient) - .filter(|b| b.len() == 32); - - if let Some(bytes) = as_device_id { - match <[u8; 32]>::try_from(bytes.as_slice()) { - Ok(id) => id, - Err(e) => return err(format!("Recipient device id: {e}")), - } - } else { - match get_contact_by_alias(&smart_req.recipient) { - Ok(Some(c)) => match <[u8; 32]>::try_from(c.device_id.as_slice()) { - Ok(id) => id, - Err(e) => { - return err(format!( - "Contact {} has an invalid device id: {e}", - smart_req.recipient - )) - } - }, - Ok(None) => { - let recipient = &smart_req.recipient; - return err(format!( - "Recipient not found (not a device id or known alias): \ - {recipient}" - )); - } - Err(e) => { - return err(format!( - "Recipient {} could not be looked up: {e}", - smart_req.recipient - )) - } + // 1. The recipient: the device id of the contact the user chose. + // Never an alias — an alias is a label two contacts can share + // (pre-audit item 6, §6.63). That it is an added contact is + // checked where the relationship is read. + let to_device_id: [u8; 32] = + match <[u8; 32]>::try_from(smart_req.recipient_device_id.as_slice()) { + Ok(id) => id, + Err(..) => { + return err(format!( + "wallet.sendSmart: the recipient device id is {} bytes, not 32", + smart_req.recipient_device_id.len() + )) } - } - }; + }; // 2. Parse display amount into canonical base units in the backend. // The token is named exactly: an omitted token is not ERA. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/contacts.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/contacts.rs index 00a4b5f5f..48fc39c09 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/contacts.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/contacts.rs @@ -253,26 +253,6 @@ pub fn get_contact_by_device_id(device_id: &[u8]) -> Result Result> { - let binding = get_connection()?; - let conn = binding.lock().unwrap_or_else(|poisoned| { - log::warn!("DB lock poisoned, recovering"); - poisoned.into_inner() - }); - - let result = conn - .query_row( - &format!("SELECT {CONTACT_COLUMNS} FROM contacts WHERE alias = ?1"), - params![alias], - contact_from_row, - ) - .optional()?; - - Ok(result) -} - /// The signing key of the contact whose device id is `device_id_str` /// (Base32 Crockford), or `None` when no contact has that device id. pub fn get_contact_public_key_by_device_id(device_id_str: &str) -> Result>> { @@ -993,17 +973,4 @@ mod tests { assert!(all.iter().any(|c| c.alias == "bob")); assert!(all.iter().any(|c| c.alias == "carol")); } - - #[test] - #[serial] - fn get_contact_by_alias_finds_stored_contact() { - init_test_db(); - let contact = make_contact([0x04u8; 32], "dave"); - store_contact(&contact).expect("store"); - - let found = get_contact_by_alias("dave") - .expect("query") - .expect("contact exists"); - assert_eq!(found.device_id, [0x04u8; 32].to_vec()); - } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs index d1a2671b0..6e82afa88 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs @@ -234,8 +234,9 @@ impl TestDevice { /// As [`send`](Self::send), for any asset this device holds, by ticker — /// a created token moves through exactly the route ERA does. The request is - /// the one the frontend builds (`dsm/transactions.ts`): the recipient in - /// Base32, the amount as the display string in the token's own decimals. + /// the one the frontend builds (`dsm/transactions.ts`): the recipient by + /// its device id, the amount as the display string in the token's own + /// decimals. /// The SDK owns every protocol field. pub async fn send_token( &self, @@ -250,7 +251,7 @@ impl TestDevice { self.invoke( "wallet.sendSmart", &generated::OnlineTransferSmartRequest { - recipient: crate::util::text_id::encode_base32_crockford(&to.device_id), + recipient_device_id: to.device_id.to_vec(), amount: crate::handlers::wallet_routes::format_base_units_for_display( amount, decimals, ), diff --git a/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx index 35ccea42e..c4230a257 100644 --- a/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx +++ b/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx @@ -193,7 +193,12 @@ describe('EnhancedWalletScreen event-driven refresh', () => { fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); await waitFor(() => { - expect(dsmClient.sendOnlineTransferSmart).toHaveBeenCalledWith('Receiver', '25', undefined, 'ERA'); + expect(dsmClient.sendOnlineTransferSmart).toHaveBeenCalledWith( + encodeBase32Crockford(contact.deviceId), + '25', + undefined, + 'ERA', + ); expect(screen.queryByRole('heading', { name: 'Send Transaction' })).not.toBeInTheDocument(); expect(screen.getAllByText('75').length).toBeGreaterThanOrEqual(1); expect(screen.getByText(/Recent Activity/)).toBeInTheDocument(); diff --git a/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx b/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx index b9ec12088..ffbfd32ba 100644 --- a/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx +++ b/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx @@ -165,7 +165,7 @@ function SendTabInner({ } } else { const res = await dsmClient.sendOnlineTransferSmart( - contact.alias, + sendForm.selectedContactKey, sendForm.amount.trim(), sendForm.note || undefined, tokenId, diff --git a/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts b/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts index 4080c3966..978fa5410 100644 --- a/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts +++ b/dsm_client/frontend/src/components/tour/__tests__/practiceMode.test.ts @@ -5,7 +5,7 @@ //! send screen read both; and it sent a send naming no token as ERA. import { dsmClient } from '../../../services/dsmClient'; -import { practiceMode, PRACTICE_CONTACT_ALIAS } from '../practiceMode'; +import { practiceMode, PRACTICE_CONTACT_DEVICE_ID } from '../practiceMode'; const client = dsmClient as unknown as Record Promise>; @@ -22,7 +22,7 @@ describe('practice mode answers as the real calls do', () => { }); it('answers an offline send in the shape sendOfflineTransfer does', async () => { - const res = await client.sendOfflineTransfer({ tokenId: 'PLAY', to: PRACTICE_CONTACT_ALIAS, amount: '5' }); + const res = await client.sendOfflineTransfer({ tokenId: 'PLAY', to: PRACTICE_CONTACT_DEVICE_ID, amount: '5' }); expect(res).toEqual({ accepted: true, result: expect.any(String) }); const rows = await client.getAllBalances(); expect(rows.find((r: any) => r.tokenId === 'PLAY')).toEqual( @@ -31,9 +31,9 @@ describe('practice mode answers as the real calls do', () => { }); it('refuses a send that names no token, as Rust does', async () => { - const offline = await client.sendOfflineTransfer({ tokenId: '', to: PRACTICE_CONTACT_ALIAS, amount: '5' }); + const offline = await client.sendOfflineTransfer({ tokenId: '', to: PRACTICE_CONTACT_DEVICE_ID, amount: '5' }); expect(offline).toEqual({ accepted: false, result: expect.stringContaining('names no token') }); - const online = await client.sendOnlineTransferSmart(PRACTICE_CONTACT_ALIAS, '5', undefined, ''); + const online = await client.sendOnlineTransferSmart(PRACTICE_CONTACT_DEVICE_ID, '5', undefined, ''); expect(online).toEqual({ success: false, message: expect.stringContaining('names no token') }); }); diff --git a/dsm_client/frontend/src/components/tour/practiceMode.ts b/dsm_client/frontend/src/components/tour/practiceMode.ts index 030afa4b3..83dc9ccff 100644 --- a/dsm_client/frontend/src/components/tour/practiceMode.ts +++ b/dsm_client/frontend/src/components/tour/practiceMode.ts @@ -35,6 +35,8 @@ const PAD = '0'.repeat(52); const practiceId = (stem: string): string => (stem + PAD).slice(0, 52); export const PRACTICE_CONTACT_ALIAS = 'alice'; +/** The practice contact's device id: what the send screen names a recipient by. */ +export const PRACTICE_CONTACT_DEVICE_ID = practiceId('PRACT1CEA11CE'); export const PRACTICE_FAUCET_AMOUNT = 100; type PracticeState = { @@ -58,7 +60,7 @@ function freshState(): PracticeState { contacts: [ { alias: PRACTICE_CONTACT_ALIAS, - deviceId: practiceId('PRACT1CEA11CE'), + deviceId: PRACTICE_CONTACT_DEVICE_ID, genesisHash: practiceId('PRACT1CEA11CEGENES1S'), signingPublicKey: practiceId('PRACT1CEA11CEKEY'), // Practice contacts are never paired over BLE. @@ -126,7 +128,7 @@ function recordSend(state: PracticeState, to: string, tokenId: string, amount: s state.sequence += 1; const txId = `practice-${state.sequence}`; const units = wholeAmount(amount) ?? BigInt(0); - const contact = state.contacts.find((c) => c.alias === to || c.deviceId === to); + const contact = state.contacts.find((c) => c.deviceId === to); state.history = [ { txId, @@ -156,14 +158,14 @@ function simulations(state: PracticeState, emit: (event: PracticeEvent) => void) getAllBalances: async () => state.balances.map((b) => ({ ...b })), getContacts: async () => ({ contacts: state.contacts.map((c) => ({ ...c })) }), getWalletHistory: async () => ({ transactions: [...state.history] }), - sendOnlineTransferSmart: async (recipientAlias: string, scaledAmountStr: string | number | bigint, memo?: string, tokenId?: string) => { + sendOnlineTransferSmart: async (recipientDeviceId: string, scaledAmountStr: string | number | bigint, memo?: string, tokenId?: string) => { await pause(700); // As Rust answers: a send that names no token is refused, never sent as ERA. if (!tokenId) return { success: false, message: 'wallet.sendSmart: the request names no token' }; const token = tokenId; const result = debit(state, token, scaledAmountStr); if (!result.ok) return { success: false, error: { message: result.message } }; - recordSend(state, recipientAlias, token, scaledAmountStr, memo, 'online'); + recordSend(state, recipientDeviceId, token, scaledAmountStr, memo, 'online'); emit('sent'); return { success: true, newBalance: result.balance }; }, diff --git a/dsm_client/frontend/src/dsm/__tests__/onlineTransfer.test.ts b/dsm_client/frontend/src/dsm/__tests__/onlineTransfer.test.ts index 8cf6c35be..3ca88387e 100644 --- a/dsm_client/frontend/src/dsm/__tests__/onlineTransfer.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/onlineTransfer.test.ts @@ -6,6 +6,10 @@ import * as pb from '../../proto/dsm_app_pb'; import * as dsm from '../index'; import * as bridge from '../WebViewBridge'; +import { encodeBase32Crockford } from '../../utils/textId'; + +/** The contact the send screen chose: Alice, by her device id. */ +const ALICE = new Uint8Array(32).fill(0xa1); function framed(payload: pb.Envelope['payload']): Uint8Array { const env = new pb.Envelope({ version: 3, payload }).toBinary(); @@ -26,15 +30,15 @@ describe('sendOnlineTransferSmart', () => { }), ); - const res = await dsm.sendOnlineTransferSmart('alice', '10', 'lunch', 'RIGB'); + const res = await dsm.sendOnlineTransferSmart(encodeBase32Crockford(ALICE), '10', 'lunch', 'RIGB'); expect(res).toEqual({ success: true, message: 'sent', newBalance: 90n }); expect(invoke).toHaveBeenCalledTimes(1); const [method, args] = invoke.mock.calls[0]; expect(method).toBe('wallet.sendSmart'); const req = pb.OnlineTransferSmartRequest.fromBinary(pb.ArgPack.fromBinary(args as Uint8Array).body); - expect({ recipient: req.recipient, amount: req.amount, tokenId: req.tokenId, memo: req.memo }).toEqual({ - recipient: 'alice', + expect({ recipientDeviceId: req.recipientDeviceId, amount: req.amount, tokenId: req.tokenId, memo: req.memo }).toEqual({ + recipientDeviceId: ALICE, amount: '10', tokenId: 'RIGB', memo: 'lunch', @@ -46,7 +50,7 @@ describe('sendOnlineTransferSmart', () => { framed({ case: 'error', value: new pb.Error({ code: 1, message: 'wallet.sendSmart: the request names no token' }) }), ); - const res = await dsm.sendOnlineTransferSmart('alice', '10', undefined, ''); + const res = await dsm.sendOnlineTransferSmart(encodeBase32Crockford(ALICE), '10', undefined, ''); const req = pb.OnlineTransferSmartRequest.fromBinary( pb.ArgPack.fromBinary(invoke.mock.calls[0][1] as Uint8Array).body, diff --git a/dsm_client/frontend/src/dsm/transactions.ts b/dsm_client/frontend/src/dsm/transactions.ts index 0d2e31e39..49eb04839 100644 --- a/dsm_client/frontend/src/dsm/transactions.ts +++ b/dsm_client/frontend/src/dsm/transactions.ts @@ -51,20 +51,25 @@ function schedulePostAcceptRefreshes(): void { requestAnimationFrame(tick); } +/** + * `wallet.sendSmart`: an online send to the contact the user chose, named by + * its device id (the Base32 key the send screen selects it by, or its bytes). + * Never by alias: an alias is a label two contacts can share. Rust refuses a + * device id that is not 32 bytes, or not an added contact. + */ export async function sendOnlineTransferSmart( - alias: string, + to: string | Uint8Array, amount: string | number | bigint, memo?: string, tokenId?: string ): Promise<{ success: boolean; message?: string; newBalance?: bigint }> { try { - const recipient = String(alias ?? '').trim(); - if (!recipient) { - return { success: false, message: 'Recipient alias is required' }; - } + const recipientDeviceId = typeof to === 'string' + ? new Uint8Array(decodeBase32Crockford(to.trim())) + : new Uint8Array(to); const smartReq = new pb.OnlineTransferSmartRequest({ - recipient, + recipientDeviceId, amount: String(amount), tokenId: String(tokenId ?? '').trim(), memo: memo || '', diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts index 1d51f0931..db8ec203c 100644 --- a/dsm_client/frontend/src/proto/dsm_app_pb.ts +++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts @@ -21085,13 +21085,6 @@ export class RelationshipFinalizedV1 extends Message { * @generated from message dsm.OnlineTransferSmartRequest */ export class OnlineTransferSmartRequest extends Message { - /** - * Alias or Base32 ID - * - * @generated from field: string recipient = 1; - */ - recipient = ""; - /** * Decimal string * @@ -21109,6 +21102,14 @@ export class OnlineTransferSmartRequest extends Message) { super(); proto3.util.initPartial(data, this); @@ -21117,10 +21118,10 @@ export class OnlineTransferSmartRequest extends Message [ - { no: 1, name: "recipient", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 2, name: "amount", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 3, name: "token_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 4, name: "memo", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "recipient_device_id", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): OnlineTransferSmartRequest { diff --git a/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts b/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts index 4ce964363..ba64b45a9 100644 --- a/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts +++ b/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts @@ -121,12 +121,13 @@ describe('E2E: sendOnlineTransfer (unit-level, mocked storage)', () => { const mockAppRouterInvoke = jest.fn().mockResolvedValue(makeErrorResponseFramed(nativeError)); jest.spyOn(require('../dsm/WebViewBridge'), 'routerInvokeBin').mockImplementation(mockAppRouterInvoke); - const res = await dsm.sendOnlineTransferSmart('alice', 9n, 'smart path', 'ERA'); + const alice = new Uint8Array(32).fill(0xa1); + const res = await dsm.sendOnlineTransferSmart(encodeBase32Crockford(alice), 9n, 'smart path', 'ERA'); expect(mockAppRouterInvoke).toHaveBeenCalledWith('wallet.sendSmart', expect.any(Uint8Array)); const [, argPackBytes] = mockAppRouterInvoke.mock.calls[0]; const req = decodeOnlineTransferSmartRequest(argPackBytes); - expect(req.recipient).toBe('alice'); + expect(req.recipientDeviceId).toEqual(alice); expect(req.amount).toBe('9'); expect(req.memo).toBe('smart path'); expect(res.success).toBe(false); diff --git a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts index 08274750b..9a1005836 100644 --- a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts +++ b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts @@ -16,6 +16,9 @@ import * as pb from '../proto/dsm_app_pb'; import * as dsm from '../dsm/index'; import { emit, initializeEventBridge } from '../dsm/EventBridge'; import { encodeBase32Crockford } from '../utils/textId'; + +/** The contact the sends name: Bob, by his device id. */ +const BOB_DEVICE_ID = encodeBase32Crockford(new Uint8Array(32).fill(0xb0)); import { decodeFramedEnvelopeV3 } from '../dsm/decoding'; // ─────────────────────────── Constants ─────────────────────────── @@ -268,14 +271,14 @@ describe('Online Transfer — Full Cycle (wallet.sendSmart, the path the send sc onlineTransferOverride = () => makeOnlineResponseEnvelope(true, 'transfer ok', 500n); // A unique amount per test avoids dedup. - const res = await dsm.sendOnlineTransferSmart('bob', BigInt(1000 + testIndex), undefined, 'ERA'); + const res = await dsm.sendOnlineTransferSmart(BOB_DEVICE_ID, BigInt(1000 + testIndex), undefined, 'ERA'); expect(res.success).toBe(true); }); test('failure response returns success=false when inner OnlineTransferResponse.success=false', async () => { onlineTransferOverride = () => makeOnlineResponseEnvelope(false, 'insufficient funds', 0n); - const res = await dsm.sendOnlineTransferSmart('bob', BigInt(2000 + testIndex), undefined, 'ERA'); + const res = await dsm.sendOnlineTransferSmart(BOB_DEVICE_ID, BigInt(2000 + testIndex), undefined, 'ERA'); expect(res.success).toBe(false); expect(res.message).toContain('insufficient funds'); }); @@ -292,7 +295,7 @@ describe('Online Transfer — Full Cycle (wallet.sendSmart, the path the send sc } as any)); }; - const res = await dsm.sendOnlineTransferSmart('bob', BigInt(3000 + testIndex), undefined, 'ERA'); + const res = await dsm.sendOnlineTransferSmart(BOB_DEVICE_ID, BigInt(3000 + testIndex), undefined, 'ERA'); expect(res.success).toBe(false); expect(String(res.message)).toMatch(/internal error|DSM error/); }); @@ -309,7 +312,7 @@ describe('Online Transfer — Full Cycle (wallet.sendSmart, the path the send sc } as any)); }; - const res = await dsm.sendOnlineTransferSmart('bob', BigInt(4000 + testIndex), undefined, 'ERA'); + const res = await dsm.sendOnlineTransferSmart(BOB_DEVICE_ID, BigInt(4000 + testIndex), undefined, 'ERA'); expect(res.success).toBe(false); expect(String(res.message)).toMatch(/Expected onlineTransferResponse|unexpected/i); }); @@ -328,7 +331,7 @@ describe('Online Transfer — Full Cycle (wallet.sendSmart, the path the send sc } as any)); }; - const res = await dsm.sendOnlineTransferSmart('bob', BigInt(5000 + testIndex), undefined, 'ERA'); + const res = await dsm.sendOnlineTransferSmart(BOB_DEVICE_ID, BigInt(5000 + testIndex), undefined, 'ERA'); expect(res.success).toBe(false); expect(String(res.message)).toContain('quota exceeded'); }); diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto index 7e486a20a..292c53958 100644 --- a/proto/dsm_app.proto +++ b/proto/dsm_app.proto @@ -3382,10 +3382,17 @@ message RelationshipFinalizedV1 { } message OnlineTransferSmartRequest { - string recipient = 1; // Alias or Base32 ID + // The recipient was once named by alias or Base32 text. An alias is a label + // two contacts can share, so a send named by one could pay the wrong contact + // (pre-audit item 6, CONFORMANCE §6.63). + reserved 1; + reserved "recipient"; string amount = 2; // Decimal string string token_id = 3; string memo = 4; + // The contact the user chose, by its device id: the one identity a contact + // is keyed by. + bytes recipient_device_id = 5 [(dsm_fixed_len)=32]; } // wallet.sendOffline: what the user asked for, and nothing the SDK works out From ff953fcdce133e74c8fdc946e1468ab59c71bb34 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:18:24 -0400 Subject: [PATCH 07/31] =?UTF-8?q?docs(conformance):=20pre-audit=20item=206?= =?UTF-8?q?,=20a=20send=20names=20its=20recipient=20by=20device=20id=20(?= =?UTF-8?q?=C2=A76.63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/requirements/CONFORMANCE_GAPS.md | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 35dad780d..6b4ad7fd7 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2106,6 +2106,41 @@ This is the owner's beta security pre-audit, item 2 (P0), audited at main `07679 **Fix:** commit `1ef20cf7a` on `security/beta-pre-audit`; `make lint` exit 0. +### 6.63 A send names its recipient by device id, never by alias (`security/beta-pre-audit`, pre-audit item 6, 2026-10-01) + +This is the owner's beta security pre-audit, item 6 (P0), audited at main `076792eac`. CORE reported it and verified it at the code. It is not recorded elsewhere in this file. + +| Field | Record | +|---|---| +| Severity | High; the owner's list places it in P0. | +| Files | `proto/dsm_app.proto` · `OnlineTransferSmartRequest`. `dsm_sdk/src/handlers/wallet_routes.rs` · `wallet.sendSmart`. `dsm_sdk/src/storage/client_db/contacts.rs` · `get_contact_by_alias`, now removed. Frontend: `src/dsm/transactions.ts` · `sendOnlineTransferSmart` and `src/components/screens/wallet/SendTab.tsx`. | +| Exploit | The send screen chose a contact by its device id but sent that contact's alias. `wallet.sendSmart` resolved the alias with `SELECT … WHERE alias = ?1` and took the first row, and aliases are not unique (`idx_contacts_alias` is a plain index). With two contacts named "Bob", a send to the second paid whichever had been added first. A contact who gets added under the alias of someone the user pays receives that person's payments. | +| Violates | A transfer is made over a relationship, and a relationship is keyed by the counterparty's device (MR-DSM-0074: a device sends only over relationships it has pre-added). A label is not an identity. | +| Verification | CORE verified it at the code on 2026-10-01. The adversarial test below reproduced it on the old wire: the user chose C, and C received 0. | +| Closure condition | The wire carries only the recipient's 32-byte device id. The SDK resolves nothing by alias. The frontend sends the device id of the contact the user chose. | +| Status | Closed. | + +**The change.** +- **Wire.** `OnlineTransferSmartRequest` reserves field 1, `recipient` (alias or Base32 text), and names the recipient by `bytes recipient_device_id = 5 [(dsm_fixed_len)=32]`. This is a clean cut, as beta allows. +- **SDK.** `wallet.sendSmart` takes those 32 bytes or refuses the request. Whether the device is an added contact is checked where the relationship is read, as before. `get_contact_by_alias` had no caller left and is removed with its test. The non-unique alias index stays: nothing resolves through it. +- **Frontend.** `sendOnlineTransferSmart` takes the chosen contact's device id: the Base32 key the send screen selects the contact by, decoded to bytes as the offline send already did. `SendTab` passes `selectedContactKey`. Practice mode's simulated sends find the recipient by device id only. + +**Test.** `dsm_sdk::handlers::sender_admission_tests::a_send_pays_the_contact_chosen_not_the_first_of_its_alias`. +- A holds two contacts named "B": B, added first, then C, added under the same alias. +- The user pays C 10 ERA, then B 20 ERA, each through the request the frontend builds for that choice. +- Each is paid what was sent to it. +- On the old wire, the request named the alias and C received 0. + +**Mutation controls (2026-10-01, restored byte for byte).** +- The handler resolved the recipient through its alias, taking the first contact sharing the chosen contact's alias. The test went red: both payments went to one contact, and the second was refused, "relationship is not send-ready". +- An earlier, single-send form of the test stayed green under that mutation, because the contact order happened to favor the chosen one. That is why the test pays both contacts. + +**Suites.** +- SDK: `sender_admission_tests` including the new test. +- Frontend: type-check clean, and 7 touched suites, 51 tests (`onlineTransfer`, `E2E.sendOnlineTransfer`, `E2E.transferProof`, `EnhancedWalletScreen.events`, `practiceMode`, `SendTab.offline`, `SendTab.offlineFunding`). + +**Fix:** commit `cacecdf1d` on `security/beta-pre-audit`; `make lint` exit 0, frontend lint clean. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | From d64df70c298a0c254a40661d8298d8d25fb7927a Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:21:19 -0400 Subject: [PATCH 08/31] chore(deps): the client workspace's lock records the merged storage dependencies STORAGE's branch added dsm_sdk -> axum-server (a dev-dependency) and dsm_storage_node -> hyper-util, plus tower-http's timeout feature. These were recorded in the root lock only. The client workspace builds dsm_storage_node as a dev-dependency, so its lock carries the same three edges. No version changed. --- dsm_client/deterministic_state_machine/Cargo.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dsm_client/deterministic_state_machine/Cargo.lock b/dsm_client/deterministic_state_machine/Cargo.lock index ad955f6ad..262083aea 100644 --- a/dsm_client/deterministic_state_machine/Cargo.lock +++ b/dsm_client/deterministic_state_machine/Cargo.lock @@ -1180,6 +1180,7 @@ dependencies = [ "arrayref", "async-trait", "axum", + "axum-server", "base32", "bincode", "bip39", @@ -1264,6 +1265,7 @@ dependencies = [ "deadpool-postgres", "dsm", "futures", + "hyper-util", "log", "prost 0.14.3", "rand 0.8.6", @@ -4419,6 +4421,7 @@ dependencies = [ "http-body-util", "iri-string", "pin-project-lite", + "tokio", "tower", "tower-layer", "tower-service", From c6f95bc64854aa661b98ba774fbabb47a914007d Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:43:19 -0400 Subject: [PATCH 09/31] feat(core): a root-cell occupant proves its own authority (A10, S20) Owner ruling, 2026-10-01, on the audit finding that anyone could occupy any identity's next root cell: "If an object can win/occupy a root position, the object itself proves authority for that position." Every object recognized at K_root(q) is now signed by a key that, with the AttA the object carries, derives the device it names. Nothing is fetched. - **Root claim.** `EconomicRootClaimBody` moves to schema 2 and carries `claimant_att_a`. `decode_and_verify_economic_root_claim` refuses a claim whose key and AttA do not derive `trader_devid` (`KeyIsNotTheNamedDevices`). - **C_q.** It occupies K_root(q) only as the trader-signed `SignedSofiResolutionClaim` (class 0x0062, signed under `DSM/sofi/resolution-claim-sign/v1`). A bare C_q is refused by name. `sofi::signature::verify_resolution_claim` checks the signature and the binding, and `sign_resolution_claim` refuses a key that does not derive the named device. - **Identity at a root cell.** A conditional claim is identified by its derived body, so readers keep comparing `derive::resolution_claim(P, F)`. - **Producers.** The admission flow signs its root claim with this device's AttA. `sofi_register` signs the trader's C_q. The relay carries the trader's signed C_q from the cell, because it cannot author one. - **Tests.** The SoFi fixture device and the peer device have ids derived from their keys. Adversarial tests: - a root claim and a C_q signed by another device (with its own AttA or the victim's) name no cell; - an unsigned C_q names no cell; - a squatter's claims written first at every seat leave the cell open until the trader's own claim holds it. --- .../dsm/src/ccb/mod.rs | 4 + .../src/common/domain_tags/dsm/misc/sofi.rs | 6 + .../dsm/src/common/domain_tags/mod.rs | 2 +- .../dsm/src/economic/claim.rs | 16 +- .../dsm/src/economic/claim_envelope.rs | 341 ++++++++++++++---- .../dsm/src/economic/peer_lineage.rs | 70 ++-- .../dsm/src/economic/register.rs | 186 ++++++---- .../dsm/src/sofi/conformance.rs | 75 ++-- .../dsm/src/sofi/derive.rs | 41 ++- .../dsm/src/sofi/facts.rs | 6 +- .../dsm/src/sofi/lineage.rs | 40 +- .../dsm/src/sofi/publication.rs | 10 +- .../dsm/src/sofi/registration.rs | 35 +- .../dsm/src/sofi/signature.rs | 80 +++- .../dsm/src/sofi/validation.rs | 139 ++++--- .../dsm/src/sofi/wire/objects.rs | 113 ++++++ .../dsm/tests/economic_admission_lifecycle.rs | 24 +- .../dsm/tests/economic_lineage_register.rs | 21 +- .../dsm/tests/economic_root_primitives.rs | 8 +- .../handlers/frontier_verification_tests.rs | 1 + .../dsm_sdk/src/handlers/node_e2e_tests.rs | 40 +- .../src/sdk/economic_admission_flow.rs | 13 + .../dsm_sdk/src/sdk/sofi_register.rs | 15 +- .../dsm_sdk/src/sdk/sofi_relay.rs | 30 +- 24 files changed, 996 insertions(+), 320 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs index 72c159024..224ef71e6 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs @@ -239,6 +239,10 @@ pub mod class { /// `𝒞_E^pre` and accepted only because it hashes to the leaf the core /// states. pub const SOFI_TRADER_PRE_BALANCE: u16 = 0x0061; + /// `0x0062` — `C_q` as it occupies `K_root(q)`: the derived claim and the + /// trader's signature over it, under a key the claim's own `AttA` binds + /// to its `DevID` (DSM Amendment A10, SoFi Amendment S20). + pub const SOFI_SIGNED_RESOLUTION_CLAIM: u16 = 0x0062; } /// Discriminants **allocated but not encodable** — see [`class`] for the ones diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/sofi.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/sofi.rs index 009086dff..be15e5afd 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/sofi.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/sofi.rs @@ -66,6 +66,11 @@ pub const TAG_DSM_SOFI_FULFILLMENT_ID: TaggedHashDomain<'static> = /// `m_F = H(tag ‖ CCB(TraderFulfillmentBody))`. pub const TAG_DSM_SOFI_FULFILLMENT_SIGN: TaggedHashDomain<'static> = crate::tagged_domain!(b"DSM/sofi/fulfillment-sign/v1"); +/// `m_C = H(tag ‖ CCB(C_q) ‖ u16be(alg) ‖ u32be(|key|) ‖ key ‖ AttA)` — what +/// the trader signs to make `C_q` an occupant of `K_root(q)` (SoFi Amendment +/// S20). +pub const TAG_DSM_SOFI_RESOLUTION_CLAIM_SIGN: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/sofi/resolution-claim-sign/v1"); // ── Successor attempts and route outcome (F6, F7) ────────────────────────── @@ -196,6 +201,7 @@ pub(crate) const SOFI_TAGS: &[TaggedHashDomain<'static>] = &[ TAG_DSM_SOFI_FULFILLMENT, TAG_DSM_SOFI_FULFILLMENT_ID, TAG_DSM_SOFI_FULFILLMENT_SIGN, + TAG_DSM_SOFI_RESOLUTION_CLAIM_SIGN, TAG_DSM_SOFI_SUCC_CELL_V2, TAG_DSM_SOFI_SUCC_ATTEMPT, TAG_DSM_SOFI_STORAGE_SEED_V4, diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs index db62bde57..5d99235d0 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs @@ -171,7 +171,7 @@ mod tests { // network hash domain's only input. // +1 with the TraderPreBalance object namespace (SoFi Amendment S12). // +1 with the vault token locator (SoFi Amendment S16). - const EXPECTED_TAG_COUNT: usize = 352; + const EXPECTED_TAG_COUNT: usize = 353; /// Scan the crate source for every declared domain-tag constant. /// diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/claim.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/claim.rs index 48bf82f40..ccbdd5397 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/claim.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/claim.rs @@ -154,13 +154,19 @@ impl EconomicAdmissionManifest { } } -/// `0x001B` schema 1 — the signed body a trader registers at its economic +/// `0x001B` schema 2 — the signed body a trader registers at its economic /// position. /// /// `root_register_storage_set_id` is a member of the **signed** body, not /// transport context. A claim that did not commit the set it was written to /// could be lifted from one network's register into another's, which is /// precisely the substitution the network scoping exists to prevent. +/// +/// `claimant_att_a` is the trader device's `AttA` (DSM Amendment A10): the +/// claim proves its own authority for the cell, because `DevID = H(AK ‖ AttA)` +/// and recognition requires `derive_devid(claimant_public_key, claimant_att_a) +/// == trader_devid`. A key that does not derive the named device signs no +/// claim that can occupy that device's cell. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EconomicRootClaimBody { pub trader_genesis: [u8; 32], @@ -171,11 +177,12 @@ pub struct EconomicRootClaimBody { pub root_register_storage_set_id: [u8; 32], pub signature_alg: u16, pub claimant_public_key: Vec, + pub claimant_att_a: [u8; 32], } impl CcbObject for EconomicRootClaimBody { const CLASS: u16 = class::ECONOMIC_ROOT_CLAIM_BODY; - const SCHEMA: u16 = 1; + const SCHEMA: u16 = 2; } impl EconomicRootClaimBody { @@ -191,6 +198,7 @@ impl EconomicRootClaimBody { root_register_storage_set_id: [u8; 32], signature_alg: u16, claimant_public_key: &[u8], + claimant_att_a: [u8; 32], ) -> Result { let expected = sigalg::public_key_len(signature_alg) .ok_or(CcbError::UnknownSignatureAlg { alg: signature_alg })?; @@ -210,10 +218,11 @@ impl EconomicRootClaimBody { root_register_storage_set_id, signature_alg, claimant_public_key: claimant_public_key.to_vec(), + claimant_att_a, }) } - /// Fields 1..8 in registry order. + /// Fields 1..9 in registry order. pub fn encode(&self) -> Result, CcbError> { let mut out = Vec::new(); push_envelope::(&mut out); @@ -225,6 +234,7 @@ impl EconomicRootClaimBody { push_digest32(&mut out, &self.root_register_storage_set_id); // 6 push_u16(&mut out, self.signature_alg); // 7 push_bytes(&mut out, &self.claimant_public_key)?; // 8 + push_digest32(&mut out, &self.claimant_att_a); // 9 Ok(out) } diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/claim_envelope.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/claim_envelope.rs index 3abcd8674..fbee50536 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/claim_envelope.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/claim_envelope.rs @@ -19,10 +19,14 @@ //! What protects a trader's cell is recognition, not a member. The cell's //! coordinate is identity-scoped but derivable by anyone holding a trader's //! public `(G, DevID, position)`; bytes anyone sends there are kept, but only -//! a claim that verifies under the trader's own key is an object naming the -//! cell, and Core's leader-first read counts nothing else. A claim in the -//! trader's name that the trader never signed is not a rival and not a -//! winner, however early it arrived. +//! an object that proves its own authority for the cell names it, and Core's +//! leader-first read counts nothing else (DSM Amendment A10). A single-root +//! claim carries the trader device's `AttA`: its signature verifies under the +//! key it carries, and `derive_devid(key, AttA)` is the device it names. A +//! conditional claim is the signed `C_q` (SoFi Amendment S20), bound the same +//! way. A claim in the trader's name that the trader never signed, or signed +//! under a key that does not derive the trader's device, is not a rival and +//! not a winner, however early it arrived. use prost::Message; @@ -83,6 +87,14 @@ pub enum ClaimEnvelopeError { Body(DecodeError), /// The signature does not verify over the body under the body's own key. SignatureInvalid, + /// The key and `AttA` the claim carries do not derive the device it + /// names: `derive_devid(key, AttA) != trader_devid` (DSM Amendment A10). + KeyIsNotTheNamedDevices, + /// A conditional claim with no signature. `C_q` occupies `K_root(q)` only + /// as the trader-signed `0x0062` object (SoFi Amendment S20). + UnsignedConditionalClaim, + /// The signed `C_q` does not prove authority for the cell it names. + ConditionalClaim(crate::sofi::signature::SignatureError), /// SPHINCS+ signing failed. SignFailed(String), /// The body could not be re-encoded to check the signing digest. @@ -95,6 +107,16 @@ impl core::fmt::Display for ClaimEnvelopeError { Self::Malformed(why) => write!(f, "economic root claim malformed: {why}"), Self::Body(e) => write!(f, "economic root claim body: {e}"), Self::SignatureInvalid => write!(f, "economic root claim signature invalid"), + Self::KeyIsNotTheNamedDevices => write!( + f, + "economic root claim is signed by a key that, with the AttA it carries, does \ + not derive the device it names" + ), + Self::UnsignedConditionalClaim => write!( + f, + "an unsigned conditional claim proves no authority for the cell it names" + ), + Self::ConditionalClaim(e) => write!(f, "conditional claim: {e}"), Self::SignFailed(e) => write!(f, "economic root claim sign failed: {e}"), Self::Encode(e) => write!(f, "economic root claim body not encodable: {e}"), } @@ -103,7 +125,7 @@ impl core::fmt::Display for ClaimEnvelopeError { impl std::error::Error for ClaimEnvelopeError {} -/// Decode an `EconomicRootClaimBody` — class `0x001B`, schema 1, strict. +/// Decode an `EconomicRootClaimBody` — class `0x001B`, schema 2, strict. pub fn decode_economic_root_claim_body(bytes: &[u8]) -> Result { use crate::ccb::decode::{invalid, Cursor}; let mut c = Cursor { b: bytes, i: 0 }; @@ -117,6 +139,7 @@ pub fn decode_economic_root_claim_body(bytes: &[u8]) -> Result Result Result { if cell_bytes.is_empty() { return Err(ClaimEnvelopeError::Malformed("empty envelope")); } - if cell_bytes.len() >= 2 - && u16::from_be_bytes([cell_bytes[0], cell_bytes[1]]) == class::SOFI_RESOLUTION_CLAIM - { - let claim = crate::sofi::wire::SofiResolutionClaim::decode(cell_bytes) + let leading_class = + (cell_bytes.len() >= 2).then(|| u16::from_be_bytes([cell_bytes[0], cell_bytes[1]])); + if leading_class == Some(class::SOFI_RESOLUTION_CLAIM) { + return Err(ClaimEnvelopeError::UnsignedConditionalClaim); + } + if leading_class == Some(class::SOFI_SIGNED_RESOLUTION_CLAIM) { + let signed = crate::sofi::wire::SignedSofiResolutionClaim::decode(cell_bytes) .map_err(ClaimEnvelopeError::Body)?; - return Ok(RegisteredEconomicClaim::ConditionalSofi(claim)); + crate::sofi::signature::verify_resolution_claim(&signed) + .map_err(ClaimEnvelopeError::ConditionalClaim)?; + return Ok(RegisteredEconomicClaim::ConditionalSofi(*signed.claim())); } decode_and_verify_economic_root_claim(cell_bytes).map(RegisteredEconomicClaim::SingleRoot) } +/// A device as a genesis makes one, for tests that build register claims: +/// its id is `derive_devid(AK, AttA)` (DSM Amendment A10), so the claims it +/// signs name its own cells and nothing else. +#[cfg(test)] +pub(crate) mod device_fixture { + use crate::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F as ALG; + use crate::sofi::wire::{SignedSofiResolutionClaim, SofiResolutionClaim}; + use crate::types::error::DsmError; + + pub(crate) struct Device { + pub(crate) pk: Vec, + pub(crate) sk: Vec, + pub(crate) att_a: [u8; 32], + pub(crate) devid: [u8; 32], + } + + pub(crate) fn device(att_a: [u8; 32]) -> Result { + let (pk, sk) = crate::crypto::sphincs::generate_sphincs_keypair()?; + let devid = crate::core::identity::genesis_v2::derive_devid(&pk, &att_a); + Ok(Device { + pk, + sk, + att_a, + devid, + }) + } + + /// `C_q` as `K_root(q)` holds it, signed by the device it names. + pub(crate) fn signed_conditional( + claim: SofiResolutionClaim, + d: &Device, + ) -> Result, DsmError> { + Ok( + crate::sofi::signature::sign_resolution_claim(claim, ALG, &d.pk, d.att_a, &d.sk)? + .encode(), + ) + } + + /// `claim` signed under `signer`'s key and the given `AttA`, whatever + /// device it names: the object a squatter would try to place. + pub(crate) fn signed_by( + claim: SofiResolutionClaim, + signer: &Device, + att_a: [u8; 32], + ) -> Result, DsmError> { + let digest = + crate::sofi::derive::resolution_claim_signing_digest(&claim, ALG, &signer.pk, &att_a); + let signature = crate::crypto::sphincs::sphincs_sign(&signer.sk, &digest)?; + Ok( + SignedSofiResolutionClaim::new(claim, ALG, &signer.pk, att_a, &signature) + .map_err(|e| DsmError::invalid_operation(e.to_string()))? + .encode(), + ) + } +} + #[cfg(test)] #[allow(clippy::disallowed_methods)] // test asserts; a failure here is the signal mod tests { use super::*; + use crate::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F as ALG; + use crate::economic::claim::EconomicRootClaimBody; use crate::sofi::wire::SofiResolutionClaim; - fn conditional() -> SofiResolutionClaim { + use super::device_fixture::{device, Device}; + + fn conditional(device_id: [u8; 32]) -> SofiResolutionClaim { SofiResolutionClaim { genesis: [0x11; 32], - device_id: [0x22; 32], + device_id, position: 7, fulfillment_id: [0xF1; 32], realize_root: [0xA1; 32], @@ -361,20 +463,145 @@ mod tests { } } + /// `C_q` signed by the device it names, as `K_root(q)` holds it. + fn signed_conditional(d: &Device) -> Vec { + super::device_fixture::signed_conditional(conditional(d.devid), d).unwrap() + } + + fn conditional_signed_by( + claim: SofiResolutionClaim, + signer: &Device, + att_a: [u8; 32], + ) -> Vec { + super::device_fixture::signed_by(claim, signer, att_a).unwrap() + } + + fn root_body( + trader_devid: [u8; 32], + signer: &Device, + att_a: [u8; 32], + position: u64, + ) -> EconomicRootClaimBody { + EconomicRootClaimBody::new( + [0x11; 32], + trader_devid, + position, + [0xC0; 32], + [0xD0; 32], + [0x77; 32], + ALG, + &signer.pk, + att_a, + ) + .unwrap() + } + /// A conditional cell decodes AS a conditional claim — by class, not by - /// "whichever decoder happens to parse". It is not an error and it is not - /// a single-root claim with a missing field. + /// "whichever decoder happens to parse" — and yields the derived `C_q` + /// its trader signed. #[test] fn a_conditional_cell_decodes_by_class() { - let claim = conditional(); - let decoded = decode_registered_economic_claim(&claim.encode()).unwrap(); + let d = device([0xA7; 32]).unwrap(); + let decoded = decode_registered_economic_claim(&signed_conditional(&d)).unwrap(); assert_eq!( decoded, - RegisteredEconomicClaim::ConditionalSofi(claim), + RegisteredEconomicClaim::ConditionalSofi(conditional(d.devid)), "the cell holds C_q and says so" ); assert_eq!(decoded.economic_position(), 7); - assert_eq!(decoded.trader(), ([0x11; 32], [0x22; 32])); + assert_eq!(decoded.trader(), ([0x11; 32], d.devid)); + } + + /// DSM Amendment A10 / SoFi Amendment S20: an object that can occupy a + /// root position proves its own authority for it. A bare `C_q` carries + /// none, so it names no cell, whoever wrote it first. + #[test] + fn an_unsigned_conditional_claim_names_no_cell() { + let d = device([0xA7; 32]).unwrap(); + assert_eq!( + decode_registered_economic_claim(&conditional(d.devid).encode()), + Err(ClaimEnvelopeError::UnsignedConditionalClaim) + ); + let k_root = + crate::economic::register::economic_root_register_key(&[0x11; 32], &d.devid, 7); + assert!(crate::economic::register::root_claim_naming( + &conditional(d.devid).encode(), + &k_root + ) + .is_none()); + } + + /// A `C_q` naming the victim's device, signed under another device's key + /// — with that device's own `AttA`, or with the victim's — does not derive + /// the victim's device id, so it names no cell. + #[test] + fn a_conditional_claim_signed_by_another_device_names_no_cell() { + let victim = device([0xA7; 32]).unwrap(); + let squatter = device([0x5A; 32]).unwrap(); + let k_root = + crate::economic::register::economic_root_register_key(&[0x11; 32], &victim.devid, 7); + for att_a in [squatter.att_a, victim.att_a] { + let squat = conditional_signed_by(conditional(victim.devid), &squatter, att_a); + assert_eq!( + decode_registered_economic_claim(&squat), + Err(ClaimEnvelopeError::ConditionalClaim( + crate::sofi::signature::SignatureError::NotTheNamedDevice { + what: "SofiResolutionClaim" + } + )) + ); + assert!(crate::economic::register::root_claim_naming(&squat, &k_root).is_none()); + } + // The producer refuses to sign it at all. + let refused = crate::sofi::signature::sign_resolution_claim( + conditional(victim.devid), + ALG, + &squatter.pk, + squatter.att_a, + &squatter.sk, + ) + .expect_err("the producer refuses a key that does not derive the named device"); + assert!( + refused + .to_string() + .contains("does not derive the device it names"), + "{refused}" + ); + // And the victim's own C_q names the cell. + assert!(crate::economic::register::root_claim_naming( + &signed_conditional(&victim), + &k_root + ) + .is_some()); + } + + /// A single-root claim naming the victim's device, signed under another + /// device's key with a valid signature, does not derive the victim's + /// device id, so it is refused and names no cell. + #[test] + fn a_root_claim_under_a_key_that_does_not_derive_the_named_device_names_no_cell() { + let victim = device([0xA7; 32]).unwrap(); + let squatter = device([0x5A; 32]).unwrap(); + let k_root = + crate::economic::register::economic_root_register_key(&[0x11; 32], &victim.devid, 4); + for att_a in [squatter.att_a, victim.att_a] { + let squat = sign_economic_root_claim( + &root_body(victim.devid, &squatter, att_a, 4), + &squatter.sk, + ) + .unwrap(); + assert_eq!( + decode_and_verify_economic_root_claim(&squat), + Err(ClaimEnvelopeError::KeyIsNotTheNamedDevices) + ); + assert!(crate::economic::register::root_claim_naming(&squat, &k_root).is_none()); + } + let honest = sign_economic_root_claim( + &root_body(victim.devid, &victim, victim.att_a, 4), + &victim.sk, + ) + .unwrap(); + assert!(crate::economic::register::root_claim_naming(&honest, &k_root).is_some()); } /// THE ROOT IS NOT AVAILABLE, AND NOT GUESSED. Asking a conditional claim @@ -383,8 +610,9 @@ mod tests { /// every downstream inclusion proof succeed against an unchosen branch. #[test] fn a_conditional_claim_refuses_to_supply_a_root() { - let claim = conditional(); - let decoded = decode_registered_economic_claim(&claim.encode()).unwrap(); + let d = device([0xA7; 32]).unwrap(); + let claim = conditional(d.devid); + let decoded = decode_registered_economic_claim(&signed_conditional(&d)).unwrap(); let refusal = decoded.single_root().expect_err("no root is selected"); assert_eq!( refusal, @@ -421,19 +649,9 @@ mod tests { /// no runtime test can observe a field becoming public. #[test] fn the_only_verified_claim_is_one_whose_signature_verified() { - let (pk, sk) = crate::crypto::sphincs::generate_sphincs_keypair().unwrap(); - let body = crate::economic::claim::EconomicRootClaimBody::new( - [0x11; 32], - [0x22; 32], - 4, - [0xC0; 32], - [0xD0; 32], - [0x77; 32], - crate::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F, - &pk, - ) - .unwrap(); - let envelope = sign_economic_root_claim(&body, &sk).unwrap(); + let d = device([0xA7; 32]).unwrap(); + let body = root_body(d.devid, &d, d.att_a, 4); + let envelope = sign_economic_root_claim(&body, &d.sk).unwrap(); // The capability, and it carries exactly what it verified. let verified = decode_and_verify_economic_root_claim(&envelope).unwrap(); @@ -464,7 +682,7 @@ mod tests { /// THE OTHER ARM STILL WORKS, and the two framings cannot be confused. /// - /// A single-root claim is a protobuf envelope; `C_q` is a bare CCB object + /// A single-root claim is a protobuf envelope; `C_q` is a CCB object /// leading with its class. The dispatch is unambiguous for a structural /// reason worth stating: the class tag's high byte is `0x00`, and no /// canonical protobuf can begin with `0x00` because field number 0 is @@ -472,26 +690,21 @@ mod tests { /// conditional claim, and the test asserts that rather than assuming it. #[test] fn a_single_root_envelope_still_decodes_as_a_single_root() { - let (pk, sk) = crate::crypto::sphincs::generate_sphincs_keypair().unwrap(); - let body = crate::economic::claim::EconomicRootClaimBody::new( - [0x11; 32], - [0x22; 32], - 9, - [0xC0; 32], - [0xD0; 32], - [0x77; 32], - crate::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F, - &pk, - ) - .unwrap(); - let envelope = sign_economic_root_claim(&body, &sk).unwrap(); + let d = device([0xA7; 32]).unwrap(); + let body = root_body(d.devid, &d, d.att_a, 9); + let envelope = sign_economic_root_claim(&body, &d.sk).unwrap(); - // It cannot be read as the conditional arm, structurally. - assert_ne!( - u16::from_be_bytes([envelope[0], envelope[1]]), + // It cannot be read as either conditional class, structurally. + for conditional_class in [ class::SOFI_RESOLUTION_CLAIM, - "a protobuf envelope cannot lead with a CCB class tag" - ); + class::SOFI_SIGNED_RESOLUTION_CLAIM, + ] { + assert_ne!( + u16::from_be_bytes([envelope[0], envelope[1]]), + conditional_class, + "a protobuf envelope cannot lead with a CCB class tag" + ); + } assert_ne!(envelope[0], 0x00, "protobuf field number 0 is illegal"); match decode_registered_economic_claim(&envelope).unwrap() { @@ -520,13 +733,15 @@ mod tests { assert!(decode_registered_economic_claim(&tampered).is_err()); } - /// Bytes that are neither a canonical `C_q` nor a canonical single-root - /// envelope are refused, and a truncated conditional claim does not fall - /// through to the proto decoder: class dispatch commits to one reading. + /// Bytes that are neither a canonical signed `C_q` nor a canonical + /// single-root envelope are refused, and a truncated conditional claim + /// does not fall through to the proto decoder: class dispatch commits to + /// one reading. #[test] fn a_malformed_cell_does_not_fall_through_to_the_other_decoder() { assert!(decode_registered_economic_claim(&[]).is_err()); - let mut truncated = conditional().encode(); + let d = device([0xA7; 32]).unwrap(); + let mut truncated = signed_conditional(&d); truncated.pop(); match decode_registered_economic_claim(&truncated) { Err(ClaimEnvelopeError::Body(_)) => {} @@ -534,7 +749,7 @@ mod tests { } // Trailing bytes are not tolerated either — a write-once cell holds // exactly one canonical value. - let mut trailing = conditional().encode(); + let mut trailing = signed_conditional(&d); trailing.push(0x00); assert!(decode_registered_economic_claim(&trailing).is_err()); } diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs index 71226d702..47c088426 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs @@ -1025,8 +1025,26 @@ impl ProvenanceResolver for NoFurtherHop<'_, '_> { mod tests { use super::*; + use crate::economic::claim_envelope::device_fixture::{device, signed_conditional, Device}; + const PEER_G: [u8; 32] = [0x11; 32]; - const PEER_D: [u8; 32] = [0x22; 32]; + + /// The peer device: its id is derived from its key and AttA, so the + /// claims it signs name its own cells (DSM Amendment A10). + fn peer() -> &'static Device { + static PEER: std::sync::OnceLock = std::sync::OnceLock::new(); + PEER.get_or_init(|| device([0xA7; 32]).expect("the peer device")) + } + + fn peer_d() -> [u8; 32] { + peer().devid + } + + /// `claim` as the peer's root cell holds it: signed by the peer device + /// (SoFi Amendment S20). + fn peer_c_q(claim: SofiResolutionClaim) -> Vec { + signed_conditional(claim, peer()).expect("the peer signs its own C_q") + } const NETWORK: &[u8] = b"dsm-testnet"; /// A fetcher over the network's pinned register set that serves one root @@ -1100,7 +1118,7 @@ mod tests { fn below(position: u64) -> Self { Self(PeerFrontier::rehydrate_recorded( PEER_G, - PEER_D, + peer_d(), position - 1, [0x77; 32], ParentClaimRef::SingleRoot { @@ -1157,7 +1175,7 @@ mod tests { fn conditional_claim(position: u64) -> SofiResolutionClaim { SofiResolutionClaim { genesis: PEER_G, - device_id: PEER_D, + device_id: peer_d(), position, fulfillment_id: [0xF1; 32], realize_root: [0xA1; 32], @@ -1227,12 +1245,16 @@ mod tests { claim_ref: [0xF6; 32], }; let recorded = Recorded(PeerFrontier::rehydrate_recorded( - PEER_G, PEER_D, position, [0x66; 32], accepted, + PEER_G, + peer_d(), + position, + [0x66; 32], + accepted, )); let fetcher = CountingCells { inner: ConditionalCellFetcher { position, - writes: vec![conditional_claim(position).encode()], + writes: vec![peer_c_q(conditional_claim(position))], last: crate::route_chain::ROUTE_LEN - 1, }, reads: std::cell::Cell::new(0), @@ -1241,7 +1263,7 @@ mod tests { &fetcher, NETWORK, &PEER_G, - &PEER_D, + &peer_d(), position, &recorded, &NoResolution, @@ -1268,7 +1290,7 @@ mod tests { &fetcher, NETWORK, &PEER_G, - &PEER_D, + &peer_d(), 0, &NoneRecorded, &NoResolution, @@ -1292,14 +1314,14 @@ mod tests { let position = 4; let fetcher = ConditionalCellFetcher { position, - writes: vec![conditional_claim(position).encode()], + writes: vec![peer_c_q(conditional_claim(position))], last: crate::route_chain::ROUTE_LEN - 1, }; let err = validate_peer_lineage( &fetcher, NETWORK, &PEER_G, - &PEER_D, + &peer_d(), position, &Recorded::below(position), &NoResolution, @@ -1329,9 +1351,10 @@ mod tests { #[test] fn a_claim_naming_other_coordinates_never_holds_the_root_cell() { let position = 3; + let other = device([0x5A; 32]).expect("another device"); let foreign = SofiResolutionClaim { genesis: [0x99; 32], - device_id: [0x88; 32], + device_id: other.devid, position, fulfillment_id: [0xF1; 32], realize_root: [0xA1; 32], @@ -1339,7 +1362,7 @@ mod tests { }; let ours = SofiResolutionClaim { genesis: PEER_G, - device_id: PEER_D, + device_id: peer_d(), ..foreign }; let walk = |writes: Vec>| { @@ -1351,7 +1374,7 @@ mod tests { }, NETWORK, &PEER_G, - &PEER_D, + &peer_d(), position, &Recorded::below(position), &NoResolution, @@ -1359,14 +1382,17 @@ mod tests { }; assert!( matches!( - walk(vec![foreign.encode()]), + walk(vec![signed_conditional(foreign, &other).expect("its own C_q")]), Err(PeerLineageFailure::Incomplete(ref m)) if m.contains("no claim holds") ), "a cell holding only a foreign claim is open" ); assert!( matches!( - walk(vec![foreign.encode(), ours.encode()]), + walk(vec![ + signed_conditional(foreign, &other).expect("its own C_q"), + peer_c_q(ours), + ]), Err(PeerLineageFailure::Unresolved(_)) ), "the foreign claim first at the leader does not stop this trader's claim" @@ -1382,14 +1408,14 @@ mod tests { for last in [0, 1] { let fetcher = ConditionalCellFetcher { position, - writes: vec![conditional_claim(position).encode()], + writes: vec![peer_c_q(conditional_claim(position))], last, }; let outcome = validate_peer_lineage( &fetcher, NETWORK, &PEER_G, - &PEER_D, + &peer_d(), position, &Recorded::below(position), &NoResolution, @@ -1474,14 +1500,14 @@ mod tests { let claim = conditional_claim(position); let fetcher = ConditionalCellFetcher { position, - writes: vec![claim.encode()], + writes: vec![peer_c_q(claim)], last: crate::route_chain::ROUTE_LEN - 1, }; let outcome = validate_peer_lineage( &fetcher, NETWORK, &PEER_G, - &PEER_D, + &peer_d(), position, &Recorded::below(position), &NoResolution, @@ -1511,14 +1537,14 @@ mod tests { fn a_conditional_position_right_after_the_activation_root_is_invalid() { let fetcher = ConditionalCellFetcher { position: 1, - writes: vec![conditional_claim(1).encode()], + writes: vec![peer_c_q(conditional_claim(1))], last: crate::route_chain::ROUTE_LEN - 1, }; let outcome = validate_peer_lineage( &fetcher, NETWORK, &PEER_G, - &PEER_D, + &peer_d(), 2, &NoneRecorded, &NoResolution, @@ -1537,14 +1563,14 @@ mod tests { let position = 3; let fetcher = ConditionalCellFetcher { position, - writes: vec![conditional_claim(position).encode()], + writes: vec![peer_c_q(conditional_claim(position))], last: crate::route_chain::ROUTE_LEN - 1, }; let outcome = validate_peer_lineage( &fetcher, NETWORK, &PEER_G, - &PEER_D, + &peer_d(), position - 1, &Recorded::below(position), &NoResolution, diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/register.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/register.rs index aa2b291bf..7e689b360 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/register.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/register.rs @@ -153,12 +153,25 @@ pub fn read_root_cell( evaluate(&cell.cell, evidence, claim_at(cell)) } -/// The recognizer of a root cell: claims naming its key, identified by the -/// entry digest of their exact bytes. +/// The recognizer of a root cell: claims naming its key. +/// +/// A single-root claim is identified by the entry digest of its exact bytes, +/// which its writer retains and replays. A conditional claim is identified by +/// the entry digest of its derived body, `C_q`: the trader's signature makes +/// it an occupant (SoFi Amendment S20), but `C_q` is what every reader +/// recomputes from `(P, F)` and compares, and a verifier cannot reproduce the +/// signed bytes. fn claim_at(cell: &RootCell) -> impl Fn(&[u8]) -> Option<([u8; 32], RegisteredEconomicClaim)> + '_ { move |bytes| { - root_claim_naming(bytes, cell.cell.key()) - .map(|claim| (crate::storage_cell::entry_digest(bytes), claim)) + root_claim_naming(bytes, cell.cell.key()).map(|claim| { + let id = match &claim { + RegisteredEconomicClaim::SingleRoot(_) => crate::storage_cell::entry_digest(bytes), + RegisteredEconomicClaim::ConditionalSofi(c) => { + crate::storage_cell::entry_digest(&c.encode()) + } + }; + (id, claim) + }) } } @@ -597,7 +610,34 @@ impl RegisteredEconomicRoot { mod registered_root_construction_tests { use super::*; use crate::economic::claim::EconomicRootClaimBody; + use crate::economic::claim_envelope::device_fixture::{device, signed_by, signed_conditional}; use crate::economic::claim_envelope::{decode_registered_economic_claim, sign_economic_root_claim}; + use crate::route_chain::fixtures::{committed_set, committed_set_id, Cell}; + use crate::route_chain::{ChainState, ROUTE_LEN}; + use crate::sofi::wire::SofiResolutionClaim; + + fn conditional(device_id: [u8; 32], position: u64) -> SofiResolutionClaim { + SofiResolutionClaim { + genesis: [0x11; 32], + device_id, + position, + fulfillment_id: [0xF1; 32], + realize_root: [0xA1; 32], + void_root: [0xB1; 32], + } + } + + fn root_cell(device_id: &[u8; 32], position: u64) -> RootCell { + RootCell::new( + &[0x11; 32], + device_id, + position, + &[0x5E; 32], + &committed_set(), + &committed_set_id(), + ) + .expect("the committed set") + } /// THE ONLY WAY TO A REGISTERED ROOT IS A VERIFIED CLAIM. /// @@ -608,19 +648,20 @@ mod registered_root_construction_tests { /// verified claim, so there is no field for a caller to choose. #[test] fn a_registered_root_is_a_projection_of_a_verified_claim() { - let (pk, sk) = crate::crypto::sphincs::generate_sphincs_keypair().unwrap(); + let d = device([0xA7; 32]).unwrap(); let body = EconomicRootClaimBody::new( [0x11; 32], - [0x22; 32], + d.devid, 9, [0xC0; 32], [0xD0; 32], [0x77; 32], crate::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F, - &pk, + &d.pk, + d.att_a, ) .unwrap(); - let envelope = sign_economic_root_claim(&body, &sk).unwrap(); + let envelope = sign_economic_root_claim(&body, &d.sk).unwrap(); let verified = decode_registered_economic_claim(&envelope) .unwrap() .single_root() @@ -630,14 +671,14 @@ mod registered_root_construction_tests { let registered = RegisteredEconomicRoot::from_verified_single_root(&verified); // Every field is the claim's, not an argument. assert_eq!(registered.trader_genesis(), [0x11; 32]); - assert_eq!(registered.trader_devid(), [0x22; 32]); + assert_eq!(registered.trader_devid(), d.devid); assert_eq!(registered.economic_position(), 9); assert_eq!(registered.post_economic_root(), [0xC0; 32]); assert_eq!(registered.admission_manifest_addr(), [0xD0; 32]); assert_eq!(registered.storage_set_id(), [0x77; 32]); assert_eq!( registered.register_key(), - economic_root_register_key(&[0x11; 32], &[0x22; 32], 9) + economic_root_register_key(&[0x11; 32], &d.devid, 9) ); } @@ -648,51 +689,29 @@ mod registered_root_construction_tests { /// is what makes it impossible to forget. #[test] fn a_conditional_claim_has_nothing_to_construct_from() { - let conditional = crate::sofi::wire::SofiResolutionClaim { - genesis: [0x11; 32], - device_id: [0x22; 32], - position: 9, - fulfillment_id: [0xF1; 32], - realize_root: [0xA1; 32], - void_root: [0xB1; 32], - }; - let decoded = decode_registered_economic_claim(&conditional.encode()).unwrap(); + let d = device([0xA7; 32]).unwrap(); + let decoded = decode_registered_economic_claim( + &signed_conditional(conditional(d.devid, 9), &d).unwrap(), + ) + .unwrap(); // The only path to the constructor's argument refuses, and there is no // second path: `RegisteredEconomicRoot` has no public fields and no // other constructor. assert!(decoded.single_root().is_err()); } - /// A root cell names what holds it by the entry digest of its exact - /// bytes, and the read carries those bytes: this key's claim, not the - /// claim the leader took first, which names the next position's key. + /// A root cell names what holds it, and the read carries the exact bytes + /// that hold it: this key's claim, not the claim the leader took first, + /// which names the next position's key. A conditional claim is named by + /// its derived body, which every reader recomputes from `(P, F)`; the + /// signed bytes are what the cell holds. #[test] fn a_held_root_cell_carries_the_exact_bytes_that_hold_it() { - use crate::route_chain::fixtures::{committed_set, committed_set_id, Cell}; - use crate::route_chain::{ChainState, ROUTE_LEN}; - let claim = crate::sofi::wire::SofiResolutionClaim { - genesis: [0x11; 32], - device_id: [0x22; 32], - position: 9, - fulfillment_id: [0xF1; 32], - realize_root: [0xA1; 32], - void_root: [0xB1; 32], - }; - let bytes = claim.encode(); - let cell = RootCell::new( - &[0x11; 32], - &[0x22; 32], - 9, - &[0x5E; 32], - &committed_set(), - &committed_set_id(), - ) - .expect("the committed set"); - let elsewhere = crate::sofi::wire::SofiResolutionClaim { - position: 10, - ..claim - } - .encode(); + let d = device([0xA7; 32]).unwrap(); + let claim = conditional(d.devid, 9); + let bytes = signed_conditional(claim, &d).unwrap(); + let cell = root_cell(&d.devid, 9); + let elsewhere = signed_conditional(conditional(d.devid, 10), &d).unwrap(); let mut seats = Cell::at(cell.routed()); seats.write(&elsewhere, ROUTE_LEN - 1, &[]); seats.write(&bytes, ROUTE_LEN - 1, &[]); @@ -705,38 +724,69 @@ mod registered_root_construction_tests { assert_eq!( (id, value, state), ( - crate::storage_cell::entry_digest(&bytes), + crate::storage_cell::entry_digest(&claim.encode()), bytes, ChainState::Final ) ); } + /// DSM Amendment A10: a claim in the trader's name that the trader's + /// device did not sign counts for nothing at the trader's cell, however + /// early it arrived. A squatter's `C_q`, and a squatter's single-root + /// claim, written first at every seat, leave the cell to the trader's + /// own claim. + #[test] + fn a_squatters_claim_written_first_does_not_hold_the_cell() { + let trader = device([0xA7; 32]).unwrap(); + let squatter = device([0x5A; 32]).unwrap(); + let cell = root_cell(&trader.devid, 9); + let squat_conditional = + signed_by(conditional(trader.devid, 9), &squatter, trader.att_a).unwrap(); + let squat_root = sign_economic_root_claim( + &EconomicRootClaimBody::new( + [0x11; 32], + trader.devid, + 9, + [0xE0; 32], + [0xD0; 32], + [0x77; 32], + crate::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F, + &squatter.pk, + squatter.att_a, + ) + .unwrap(), + &squatter.sk, + ) + .unwrap(); + let ours = signed_conditional(conditional(trader.devid, 9), &trader).unwrap(); + + let mut seats = Cell::at(cell.routed()); + seats.write(&squat_conditional, ROUTE_LEN - 1, &[]); + seats.write(&squat_root, ROUTE_LEN - 1, &[]); + seats.write(&conditional(trader.devid, 9).encode(), ROUTE_LEN - 1, &[]); + // Nothing the squatter wrote holds the cell. + assert_eq!( + read_root_cell(&cell, &seats.evidence()), + Ok(CellReading::Open) + ); + + seats.write(&ours, ROUTE_LEN - 1, &[]); + let Ok(CellReading::Held { value, state, .. }) = read_root_cell(&cell, &seats.evidence()) + else { + panic!("the trader's own claim holds the cell") + }; + assert_eq!((value, state), (ours, ChainState::Final)); + } + /// A claim final at its root cell has a completion proof built from the /// reads, and the proof checks against them; a claim held at the leader /// alone has none. #[test] fn a_final_root_claim_has_a_completion_proof_that_checks() { - use crate::route_chain::fixtures::{committed_set, committed_set_id, Cell}; - use crate::route_chain::ROUTE_LEN; - let claim = crate::sofi::wire::SofiResolutionClaim { - genesis: [0x11; 32], - device_id: [0x22; 32], - position: 9, - fulfillment_id: [0xF1; 32], - realize_root: [0xA1; 32], - void_root: [0xB1; 32], - }; - let bytes = claim.encode(); - let cell = RootCell::new( - &[0x11; 32], - &[0x22; 32], - 9, - &[0x5E; 32], - &committed_set(), - &committed_set_id(), - ) - .expect("the committed set"); + let d = device([0xA7; 32]).unwrap(); + let bytes = signed_conditional(conditional(d.devid, 9), &d).unwrap(); + let cell = root_cell(&d.devid, 9); let mut held = Cell::at(cell.routed()); held.write(&bytes, 0, &[]); assert_eq!(root_completion(&cell, &held.evidence()), Ok(None)); diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs index ebedaa508..a199e47b9 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs @@ -28,8 +28,8 @@ use super::derive::{self, policy_fulfillment_id, precommit_id}; use super::publication::{recognize_setup, Signed}; use super::signature::{verify_fulfillment, verify_precommit, SignatureError}; use super::wire::{ - ParentClaimRef, next_position, DlvPolicyFulfillmentBody, SettlementPreimage, - SofiResolutionClaim, SofiWireError, TraderFulfillmentBody, TraderPrecommitBody, ValidationRef, + ParentClaimRef, next_position, DlvPolicyFulfillmentBody, SettlementPreimage, SofiWireError, + TraderFulfillmentBody, TraderPrecommitBody, ValidationRef, }; type D32 = [u8; 32]; @@ -508,12 +508,17 @@ fn closure_object_verifies(reference: &ValidationRef, bytes: &[u8]) -> Option SofiResolutionClaim::decode(bytes).is_ok_and(|c| { - c.genesis == *genesis - && c.device_id == *device_id - && c.position == *position - && c.fulfillment_id == *fulfillment_id - }), + // The conditional claim as `K_root(p)` holds it: the trader-signed + // `C_q`, recognized with its key bound to the device it names (DSM + // Amendment A10, SoFi Amendment S20). + } => matches!( + crate::economic::claim_envelope::decode_registered_economic_claim(bytes), + Ok(crate::economic::claim_envelope::RegisteredEconomicClaim::ConditionalSofi(c)) + if c.genesis == *genesis + && c.device_id == *device_id + && c.position == *position + && c.fulfillment_id == *fulfillment_id + ), ValidationRef::Setup { setup_ref } => { recognize_setup(bytes).is_some_and(|(rho, _)| rho == *setup_ref) } @@ -785,12 +790,12 @@ mod tests { use crate::crypto::sphincs::{generate_sphincs_keypair, sphincs_sign}; use crate::sofi::validation::fixtures::{ policies, policy_addr, setup_body_for, setup_envelope_for, setup_ref_for, - swap_fixture_with, swap_fixture_with_setups, trader_keys, vault_id_of, DEV, G, P_POS, - SETUP_POS, SIG_ALG, + swap_fixture_with, swap_fixture_with_setups, trader_keys, vault_id_of, dev, G, P_POS, + SETUP_POS, SIG_ALG, signed_c_q, }; use crate::sofi::wire::{ - AttemptEntry, PreEClosureIndex, PrecommitLeg, SofiSetupBody, MAX_AUTH_ENVELOPES, - MAX_CLOSURE_OBJECT_BYTES, MAX_VALIDATION_FETCH_BYTES, + AttemptEntry, PreEClosureIndex, PrecommitLeg, SofiResolutionClaim, SofiSetupBody, + MAX_AUTH_ENVELOPES, MAX_CLOSURE_OBJECT_BYTES, MAX_VALIDATION_FETCH_BYTES, }; #[test] @@ -937,7 +942,7 @@ mod tests { let market_bytes = market.encode(); let claim = SofiResolutionClaim { genesis: G, - device_id: DEV, + device_id: dev(), position: 3, fulfillment_id: token(0x42), realize_root: token(0x43), @@ -954,11 +959,11 @@ mod tests { ( ValidationRef::ConditionalClaim { genesis: G, - device_id: DEV, + device_id: dev(), position: 3, fulfillment_id: token(0x42), }, - claim.encode(), + signed_c_q(claim), ), ( ValidationRef::Setup { @@ -1204,7 +1209,7 @@ mod tests { /// A root claim of the fixture trader at `position` over `root`, naming /// the manifest `manifest`, signed under `(pk, sk)`. fn root_claim(position: u64, root: D32, manifest: D32, pk: &[u8], sk: &[u8]) -> Vec { - root_claim_of(G, DEV, position, root, manifest, pk, sk) + root_claim_of(G, dev(), position, root, manifest, pk, sk) } /// The same claim, naming the trader `(genesis, device)`. @@ -1226,6 +1231,7 @@ mod tests { token(0x77), SIG_ALG, pk, + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap(); crate::economic::claim_envelope::sign_economic_root_claim(&body, sk).unwrap() @@ -1329,13 +1335,27 @@ mod tests { } /// P conformance rules 2 and 3: a claim under the trader's key that names - /// another genesis, device, position or root is not the parent P extends. + /// another genesis, position or root is not the parent P extends. One that + /// names another device does not even verify: the trader's key with its + /// AttA derives only its own device (DSM Amendment A10). #[test] fn a_parent_claim_of_another_position_or_root_does_not_conform() { let root = one_hop_root(); + let another_device = root_claim_of(G, token(0x23), P_POS, root, token(0x78), pk(), sk()); + let reference = ValidationRef::SingleRootClaim { + claim_ref: derive::claim_ref(&another_device), + }; + let (f, closure) = one_hop_with(BTreeMap::from([(reference, another_device.clone())])); + let parent = ParentClaimRef::SingleRoot { + claim_ref: derive::claim_ref(&another_device), + }; + let (fb, f_sig, ev) = exercised(&f, closure, parent, pk(), sk()); + assert_eq!( + fulfillment_conformance(&fb, &f_sig, &ev), + invalid(FulfillmentConformanceError::ParentClaimDoesNotVerify) + ); for claim in [ - root_claim_of(token(0x12), DEV, P_POS, root, token(0x78), pk(), sk()), - root_claim_of(G, token(0x23), P_POS, root, token(0x78), pk(), sk()), + root_claim_of(token(0x12), dev(), P_POS, root, token(0x78), pk(), sk()), root_claim(P_POS + 1, root, token(0x78), pk(), sk()), root_claim(P_POS, token(0x5A), token(0x78), pk(), sk()), ] { @@ -1428,7 +1448,7 @@ mod tests { let fulfillment_id = derive::fulfillment_id(&parent); let claim = SofiResolutionClaim { genesis: G, - device_id: DEV, + device_id: dev(), position: P_POS, fulfillment_id, realize_root: token(0x34), @@ -1436,15 +1456,16 @@ mod tests { }; let reference = ValidationRef::ConditionalClaim { genesis: G, - device_id: DEV, + device_id: dev(), position: P_POS, fulfillment_id, }; - (parent, reference, claim.encode()) + (parent, reference, signed_c_q(claim)) } - /// P conformance rule 8 for a conditional parent: `C_p` carries no key, - /// so P's key is the key of the `F` whose id P names — in hand, or the + /// P conformance rule 8 for a conditional parent: `C_p` is signed by the + /// trader device (SoFi Amendment S20), and P's key is the key of the `F` + /// whose id P names — in hand, or the /// item is missing; another key, or an `F` at another position, does not /// conform. An `F` that is not the one P names binds nothing, whatever /// key it carries. @@ -1745,19 +1766,19 @@ mod tests { (rho, fulfillment_conformance(&f, &sign_f(&f), &ev)) }; // Another trader's setup of this vault. - let (rho, verdict) = with_hop_0_setup(setup_at(0, token(0x33), DEV, SETUP_POS)); + let (rho, verdict) = with_hop_0_setup(setup_at(0, token(0x33), dev(), SETUP_POS)); assert_eq!( verdict, invalid(FulfillmentConformanceError::SetupNamesAnotherTraderOrVault { setup_ref: rho }) ); // This trader's setup of another vault. - let (rho, verdict) = with_hop_0_setup(setup_at(1, G, DEV, SETUP_POS)); + let (rho, verdict) = with_hop_0_setup(setup_at(1, G, dev(), SETUP_POS)); assert_eq!( verdict, invalid(FulfillmentConformanceError::SetupNamesAnotherTraderOrVault { setup_ref: rho }) ); // The right setup, at the operation's own position: not before it. - let (rho, verdict) = with_hop_0_setup(setup_at(0, G, DEV, P_POS)); + let (rho, verdict) = with_hop_0_setup(setup_at(0, G, dev(), P_POS)); assert_eq!( verdict, invalid(FulfillmentConformanceError::SetupNotBeforeTheOperation { setup_ref: rho }) diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/derive.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/derive.rs index 801d15a89..817edea7a 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/derive.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/derive.rs @@ -14,14 +14,15 @@ use crate::common::domain_tags::{ TAG_DSM_SOFI_ATOMIC_EXT_MULTIVAULT_V5, TAG_DSM_SOFI_ATOMIC_EXT_V4, TAG_DSM_SOFI_DLV_CORE_V3, TAG_DSM_SOFI_DLV_POLICY_FULFILLMENT, TAG_DSM_SOFI_FULFILLMENT, TAG_DSM_SOFI_FULFILLMENT_ID, TAG_DSM_SOFI_FULFILLMENT_SIGN, TAG_DSM_SOFI_PREIMAGE_LOCATOR, TAG_DSM_SOFI_REL_GENESIS, - TAG_DSM_SOFI_REL_INDEX, TAG_DSM_SOFI_REL_KEY, TAG_DSM_SOFI_REL_LEAF, - TAG_DSM_SOFI_ROUTE_LEG_SET, TAG_DSM_SOFI_SETTLEMENT_CORE_V3, TAG_DSM_SOFI_SETUP_ID, - TAG_DSM_SOFI_SETUP_REF, TAG_DSM_SOFI_SETUP_SIGN, TAG_DSM_SOFI_VAULT_CREATION_KEY, - TAG_DSM_SOFI_STORAGE_SEED_V4, TAG_DSM_SOFI_SUCC_ATTEMPT, TAG_DSM_SOFI_SUCC_CELL_V2, - TAG_DSM_SOFI_TRADER_CORE_V3, TAG_DSM_SOFI_TRADER_PRECOMMIT_ID, TAG_DSM_SOFI_ROUTE_DIGEST, - TAG_DSM_SOFI_TRADER_PRECOMMIT_SIGN, TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR, TAG_DSM_SOFI_VAULT_ID, - TAG_DSM_SOFI_VAULT_TOKEN_LOCATOR, TAG_DSM_SOFI_TRADER_PRE_BALANCE_OBJECT, - TAG_DSM_SOFI_VAULT_LEAF_STATE, TAG_DSM_SOFI_VAULT_STATE_KEY, + TAG_DSM_SOFI_RESOLUTION_CLAIM_SIGN, TAG_DSM_SOFI_REL_INDEX, TAG_DSM_SOFI_REL_KEY, + TAG_DSM_SOFI_REL_LEAF, TAG_DSM_SOFI_ROUTE_LEG_SET, TAG_DSM_SOFI_SETTLEMENT_CORE_V3, + TAG_DSM_SOFI_SETUP_ID, TAG_DSM_SOFI_SETUP_REF, TAG_DSM_SOFI_SETUP_SIGN, + TAG_DSM_SOFI_VAULT_CREATION_KEY, TAG_DSM_SOFI_STORAGE_SEED_V4, TAG_DSM_SOFI_SUCC_ATTEMPT, + TAG_DSM_SOFI_SUCC_CELL_V2, TAG_DSM_SOFI_TRADER_CORE_V3, TAG_DSM_SOFI_TRADER_PRECOMMIT_ID, + TAG_DSM_SOFI_ROUTE_DIGEST, TAG_DSM_SOFI_TRADER_PRECOMMIT_SIGN, + TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR, TAG_DSM_SOFI_VAULT_ID, TAG_DSM_SOFI_VAULT_TOKEN_LOCATOR, + TAG_DSM_SOFI_TRADER_PRE_BALANCE_OBJECT, TAG_DSM_SOFI_VAULT_LEAF_STATE, + TAG_DSM_SOFI_VAULT_STATE_KEY, }; use crate::common::domain_tags::TAG_DSM_ECONOMIC_LEAF_STATE; use crate::storage_object::immutable_addr; @@ -232,6 +233,30 @@ pub fn resolution_claim( } } +/// `m_C = H(resolution-claim-sign/v1 ‖ CCB(C_q) ‖ u16be(alg) ‖ u32be(|key|) ‖ +/// key ‖ AttA)` — what the trader signs so `C_q` can occupy `K_root(q)` +/// (SoFi Amendment S20). The key and `AttA` are inside the digest, so a +/// signature cannot be moved to a claim presenting another binding. +pub fn resolution_claim_signing_digest( + claim: &SofiResolutionClaim, + signature_alg: u16, + claimant_public_key: &[u8], + claimant_att_a: &D32, +) -> D32 { + // A declared algorithm fixes the key width far below u32::MAX. + let key_len = claimant_public_key.len() as u32; + h( + TAG_DSM_SOFI_RESOLUTION_CLAIM_SIGN, + &[ + &claim.encode(), + &signature_alg.to_be_bytes(), + &key_len.to_be_bytes(), + claimant_public_key, + claimant_att_a, + ], + ) +} + // ── successor attempts and routing seed ──────────────────────────────────── /// `K^(0) = H(succ-cell/v2 ‖ v ‖ R_n)`. 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 b121b929e..fabf6331a 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs @@ -694,7 +694,11 @@ mod tests { ful.write(&envelope, ROUTE_LEN - 1, &[]); let mut root = Cell::at(cells.root().routed()); if let Some(claim) = root_holds { - root.write(&claim.encode(), ROUTE_LEN - 1, &[]); + root.write( + &crate::sofi::validation::fixtures::signed_c_q(claim), + ROUTE_LEN - 1, + &[], + ); } let lookup = BTreeMap::from([(derive::precommit_id(p), p.clone())]); fulfillment_registered(&cells, &ful.evidence(), &root.evidence(), &lookup).unwrap() 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 ff9b3f4d8..0bb9ace00 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs @@ -830,7 +830,7 @@ mod tests { use crate::sofi::wire::{VaultCreation, VaultGenesisPreimage, VAULT_STATUS_ACTIVE}; const G: D32 = [0x11; 32]; - const DEV: D32 = [0x22; 32]; + use crate::sofi::validation::fixtures::dev; const P_POS: u64 = 5; const SIG_ALG: u16 = 0x0001; @@ -841,7 +841,7 @@ mod tests { fn precommit(void_root: D32, realize_root: D32) -> TraderPrecommitBody { TraderPrecommitBody::new( G, - DEV, + dev(), P_POS, ParentClaimRef::SingleRoot { claim_ref: d(0x66) }, d(0xEE), @@ -968,7 +968,7 @@ mod tests { /// A receiver whose adoptions the advance never consults, for the same /// two cases. fn bare_receiver() -> DeviceState { - DeviceState::new(G, DEV, vec![0x01; 32]) + DeviceState::new(G, dev(), vec![0x01; 32]) } /// The fixture's own one-hop swap, and a receiver that HAS adopted what @@ -978,7 +978,7 @@ mod tests { /// literal no settlement could produce. fn realized_rig() -> (Fixture, DeviceState) { let fx = swap_fixture_n(1); - let mut receiver = DeviceState::new(G, DEV, vec![0x01; 32]); + let mut receiver = DeviceState::new(G, dev(), vec![0x01; 32]); for policy_commit in trader_credits(&fx.preimage, &fx.evidence).unwrap() { receiver = receiver.adopt_token(policy_commit).unwrap(); } @@ -1013,7 +1013,7 @@ mod tests { ), ( G, - DEV, + dev(), P_POS + 1, derive::claim_ref(&derive::resolution_claim(&p, &f).encode()) ) @@ -1442,7 +1442,7 @@ mod tests { fn genesis_state(reserve_a: u64, reserve_b: u64, generation: u64) -> VaultStateLeaf { VaultStateLeaf { owner_genesis: G, - owner_device_id: DEV, + owner_device_id: dev(), create_position: P_POS, market_policy: market().2, fee_policy: d(0x32), @@ -1469,7 +1469,7 @@ mod tests { let state = genesis_state(1_000, 2_000, 0); let preimage = VaultGenesisPreimage { owner_genesis: G, - owner_device_id: DEV, + owner_device_id: dev(), create_position: P_POS, state: state.clone(), }; @@ -1493,7 +1493,7 @@ mod tests { derive::vault_state_key(&vault_id), derive::vault_state_leaf_value(&preimage.state).unwrap(), ); - with_a_relationship.insert(derive::relationship_key(&G, &DEV, &vault_id), d(0x5B)); + with_a_relationship.insert(derive::relationship_key(&G, &dev(), &vault_id), d(0x5B)); assert_ne!(creation.genesis_root, with_a_relationship.root()); } @@ -1511,8 +1511,8 @@ mod tests { ); assert_eq!(state.encode().unwrap(), leaf.encode()); assert_eq!( - state.leaf_key(&G, &DEV), - derive::relationship_key(&G, &DEV, &d(0xC1)) + state.leaf_key(&G, &dev()), + derive::relationship_key(&G, &dev(), &d(0xC1)) ); // It carries no amount, so it can never be read as a credit needing a // funding source. @@ -1532,14 +1532,14 @@ mod tests { assert_eq!(state.class(), crate::ccb::class::SOFI_VAULT_CREATION); assert_eq!(state.encode().unwrap(), record.encode()); assert_eq!( - state.leaf_key(&G, &DEV), - derive::vault_creation_key(&G, &DEV, &d(0xC1)) + state.leaf_key(&G, &dev()), + derive::vault_creation_key(&G, &dev(), &d(0xC1)) ); // NOT the storage locator: an economic address and a storage // coordinate are different namespaces, and one derivation serving both // is how they collide. assert_ne!( - state.leaf_key(&G, &DEV), + state.leaf_key(&G, &dev()), derive::vault_genesis_locator(&d(0xC1)) ); assert_eq!(state.credit_amount(), None); @@ -1678,7 +1678,7 @@ mod tests { let f = fulfillment(&p); let credits = trader_credits(&fx.preimage, &fx.evidence).unwrap(); let facts = established(&p, &f, &fx.preimage, &fx.evidence, Shape::Realized); - let bare = DeviceState::new(G, DEV, vec![0x01; 32]); + let bare = DeviceState::new(G, dev(), vec![0x01; 32]); let previous_root = previous(*p.void_root()); assert_eq!( advance_resolved(&previous_root, &p, &f, p.parent_claim_ref(), &facts, &bare), @@ -1713,7 +1713,7 @@ mod tests { let facts = established(&p, &f, &fx.preimage, &fx.evidence, Shape::Realized); // A receiver that has adopted NOTHING. - let bare = DeviceState::new(G, DEV, vec![0x01; 32]); + let bare = DeviceState::new(G, dev(), vec![0x01; 32]); assert!(!bare.has_adopted(&credits[0])); assert_eq!( advance_resolved( @@ -1857,7 +1857,7 @@ mod tests { ); // A receiver that adopted the OUTPUT only — not the intermediate. - let receiver = DeviceState::new(G, DEV, vec![0x01; 32]) + let receiver = DeviceState::new(G, dev(), vec![0x01; 32]) .adopt_token(hops[1].token_out) .unwrap(); assert!(!receiver.has_adopted(&intermediate)); @@ -1926,7 +1926,7 @@ pub(crate) mod genesis_acceptance { use super::*; use crate::ccb::state::{FeePolicy, MarketPolicy, ReleasePolicy}; use crate::sofi::validation::fixtures::{ - committed, token_policy_bytes_with, tokens, trader_keys, DEV, G, P_CREATE, + committed, token_policy_bytes_with, tokens, trader_keys, dev, G, P_CREATE, }; use crate::sofi::wire::{VaultCreation, VAULT_STATUS_ACTIVE, VAULT_STATUS_RETIRED}; use crate::types::operations::Operation; @@ -1958,7 +1958,7 @@ pub(crate) mod genesis_acceptance { let release = ReleasePolicy::beta_owner_local_full_close(); let state = VaultStateLeaf { owner_genesis: G, - owner_device_id: DEV, + owner_device_id: dev(), create_position: P_CREATE, market_policy: addr(crate::ccb::class::MARKET_POLICY, &market_bytes), fee_policy: addr(crate::ccb::class::FEE_POLICY, &fee.encode()), @@ -1978,7 +1978,7 @@ pub(crate) mod genesis_acceptance { fn creation_of(state: VaultStateLeaf, market_bytes: Vec, pair: (D32, D32)) -> Creation { let preimage = VaultGenesisPreimage { owner_genesis: G, - owner_device_id: DEV, + owner_device_id: dev(), create_position: P_CREATE, state, }; @@ -2020,7 +2020,7 @@ pub(crate) mod genesis_acceptance { &c.preimage_bytes, &OwnerCreation { genesis: &G, - device_id: &DEV, + device_id: &dev(), position, operation: &c.operation, }, diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs index 4d822df34..5d370ced3 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs @@ -350,7 +350,7 @@ mod tests { use super::*; use crate::ccb::sigalg::SPHINCS_PLUS_SPX256F as ALG; use crate::sofi::signature::verify_signed_object; - use crate::sofi::validation::fixtures::{swap_fixture_n, trader_keys, DEV, G}; + use crate::sofi::validation::fixtures::{swap_fixture_n, trader_keys, dev, G}; use crate::sofi::wire::{AttemptEntry, PrecommitLeg}; fn d(byte: u8) -> D32 { @@ -368,7 +368,7 @@ mod tests { } fn setup() -> SofiSetupBody { - SofiSetupBody::new(G, DEV, 4, d(0x0A), d(0x0B), d(0x0C), ALG, key()).unwrap() + SofiSetupBody::new(G, dev(), 4, d(0x0A), d(0x0B), d(0x0C), ALG, key()).unwrap() } fn fulfillment(p: &TraderPrecommitBody) -> TraderFulfillmentBody { @@ -422,7 +422,7 @@ mod tests { assert_eq!(rho, derive::setup_ref(&s)); let (rel, _) = recognize_setup_by_relationship(&setup_pub.object_bytes().unwrap()).unwrap(); assert_eq!(locs[1].locator, rel); - assert_eq!(rel, derive::relationship_index_key(&G, &DEV, &d(0x0A))); + assert_eq!(rel, derive::relationship_index_key(&G, &dev(), &d(0x0A))); let p_pub = Publication::Precommit { body: &p, @@ -609,7 +609,7 @@ mod tests { } let state = crate::sofi::wire::VaultStateLeaf { owner_genesis: G, - owner_device_id: DEV, + owner_device_id: dev(), create_position: 7, market_policy: crate::ccb::decode::policy_object_address(class::MARKET_POLICY, &market) .unwrap(), @@ -627,7 +627,7 @@ mod tests { }; let preimage = VaultGenesisPreimage { owner_genesis: G, - owner_device_id: DEV, + owner_device_id: dev(), create_position: 7, state, }; 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 4ce89c053..0207bdd7e 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/registration.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/registration.rs @@ -369,7 +369,7 @@ mod tests { use super::*; use crate::ccb::sigalg::SPHINCS_PLUS_SPX256F as ALG; use crate::sofi::publication::Publication; - use crate::sofi::validation::fixtures::{swap_fixture_n, trader_keys, DEV, G}; + use crate::sofi::validation::fixtures::{signed_c_q, swap_fixture_n, trader_keys, dev, G}; use crate::route_chain::fixtures::{committed_set, committed_set_id, Cell}; use crate::route_chain::ROUTE_LEN; use crate::sofi::wire::AttemptEntry; @@ -425,17 +425,17 @@ mod tests { let bytes = envelope(&f); let known = lookup(&p); assert_eq!( - names_fulfillment_key(&bytes, &G, &DEV, q, &known).map(|s| s.body), + names_fulfillment_key(&bytes, &G, &dev(), q, &known).map(|s| s.body), Some(f.clone()) ); - assert!(names_fulfillment_key(&bytes, &G, &DEV, q + 1, &known).is_none()); - assert!(names_fulfillment_key(&bytes, &[0x33; 32], &DEV, q, &known).is_none()); + assert!(names_fulfillment_key(&bytes, &G, &dev(), q + 1, &known).is_none()); + assert!(names_fulfillment_key(&bytes, &[0x33; 32], &dev(), q, &known).is_none()); let unknown: BTreeMap = BTreeMap::new(); assert!( - names_fulfillment_key(&bytes, &G, &DEV, q, &unknown).is_none(), + names_fulfillment_key(&bytes, &G, &dev(), q, &unknown).is_none(), "without P neither the trader nor C_q is known" ); - assert!(names_fulfillment_key(b"not an envelope", &G, &DEV, q, &known).is_none()); + assert!(names_fulfillment_key(b"not an envelope", &G, &dev(), q, &known).is_none()); } /// `K_ful(q)` is named only by a fulfillment the trader signed: its @@ -455,7 +455,7 @@ mod tests { } .object_bytes() .unwrap(); - assert!(names_fulfillment_key(&unsigned, &G, &DEV, q, &known).is_none()); + assert!(names_fulfillment_key(&unsigned, &G, &dev(), q, &known).is_none()); let (other_pk, other_sk) = crate::crypto::sphincs::generate_sphincs_keypair().unwrap(); let foreign = TraderFulfillmentBody::new( @@ -477,19 +477,19 @@ mod tests { } .object_bytes() .unwrap(); - assert!(names_fulfillment_key(&foreign_bytes, &G, &DEV, q, &known).is_none()); + assert!(names_fulfillment_key(&foreign_bytes, &G, &dev(), q, &known).is_none()); // First at the leader, the unsigned F blocks nothing. let cells = PositionCells::new( &G, - &DEV, + &dev(), q, &[0x5E; 32], &committed_set(), &committed_set_id(), ) .expect("the committed set"); - let claim = derive::resolution_claim(&p, &f).encode(); + let claim = signed_c_q(derive::resolution_claim(&p, &f)); let mut ful = Cell::at(cells.fulfillment()); ful.write(&unsigned, ROUTE_LEN - 1, &[]); ful.write(&envelope(&f), ROUTE_LEN - 1, &[]); @@ -513,7 +513,7 @@ mod tests { let known = lookup(&p); let cells = PositionCells::new( &G, - &DEV, + &dev(), q, &[0x5E; 32], &committed_set(), @@ -549,7 +549,7 @@ mod tests { let f = fulfillment(&p, q); let bytes = envelope(&f); let known = lookup(&p); - let claim = derive::resolution_claim(&p, &f).encode(); + let claim = signed_c_q(derive::resolution_claim(&p, &f)); let rival = TraderFulfillmentBody::new( derive::precommit_id(&p), vec![[0x03; 32], [0x04; 32]], @@ -559,10 +559,10 @@ mod tests { key(), ) .unwrap(); - let other_claim = derive::resolution_claim(&p, &rival).encode(); + let other_claim = signed_c_q(derive::resolution_claim(&p, &rival)); let cells = PositionCells::new( &G, - &DEV, + &dev(), q, &[0x5E; 32], &committed_set(), @@ -609,8 +609,11 @@ mod tests { ); // No fulfillment final at K_ful(q): the root cell's claim is named, // and weighed by each F against its own C_q (SoFi Amendment S14). - let claim_id = crate::storage_cell::entry_digest(&claim); - let other_id = crate::storage_cell::entry_digest(&other_claim); + // A conditional claim is named by its derived body (SoFi Amendment S20). + let claim_id = + crate::storage_cell::entry_digest(&derive::resolution_claim(&p, &f).encode()); + let other_id = + crate::storage_cell::entry_digest(&derive::resolution_claim(&p, &rival).encode()); assert_eq!( reg(&ful_cell(&bytes, 1), &root_cell(&claim, ROUTE_LEN - 1)), Ok(Registration::RootTaken { claim: claim_id }) diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs index 790af5b36..f4c521ded 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs @@ -36,7 +36,10 @@ //! encoding; the binding comes from outside it. use super::derive; -use super::wire::{SofiSetupBody, TraderFulfillmentBody, TraderPrecommitBody}; +use super::wire::{ + SignedSofiResolutionClaim, SofiResolutionClaim, SofiSetupBody, TraderFulfillmentBody, + TraderPrecommitBody, +}; use crate::ccb::genesis::sigalg; use crate::types::operations::Operation; @@ -74,6 +77,11 @@ pub enum SignatureError { /// The body is signed and the envelope is not, so a disagreement is /// refused rather than resolved in the envelope's favour. EnvelopeAlgDisagreesWithBody { envelope: u16, body: u16 }, + /// The key does not derive the device the object names: + /// `derive_devid(key, AttA) != DevID`. A key and an `AttA` carried by the + /// object prove authority only for the device they derive (DSM Amendment + /// A10). + NotTheNamedDevice { what: &'static str }, } impl core::fmt::Display for SignatureError { @@ -115,6 +123,11 @@ impl core::fmt::Display for SignatureError { pre-commit and a trader fulfillment carry a trader signature" ) } + Self::NotTheNamedDevice { what } => write!( + f, + "the {what} is signed by a key that, with the AttA it carries, does not \ + derive the device it names" + ), Self::EnvelopeAlgDisagreesWithBody { envelope, body } => { write!( f, @@ -206,6 +219,71 @@ pub fn verify_fulfillment( ) } +/// `C_q` proves its own authority for `K_root(q)` (DSM Amendment A10, SoFi +/// Amendment S20): its signature verifies under the key it carries, over a +/// digest that commits that key and the carried `AttA`, and the key with that +/// `AttA` derives the device the claim names. Decided from the bytes in hand; +/// nothing is fetched. +pub fn verify_resolution_claim(signed: &SignedSofiResolutionClaim) -> Result<(), SignatureError> { + verify_bytes( + "SofiResolutionClaim", + signed.signature_alg(), + signed.claimant_public_key(), + &derive::resolution_claim_signing_digest( + signed.claim(), + signed.signature_alg(), + signed.claimant_public_key(), + signed.claimant_att_a(), + ), + signed.signature(), + )?; + if crate::core::identity::genesis_v2::derive_devid( + signed.claimant_public_key(), + signed.claimant_att_a(), + ) != signed.claim().device_id + { + return Err(SignatureError::NotTheNamedDevice { + what: "SofiResolutionClaim", + }); + } + Ok(()) +} + +/// Sign `C_q` as the trader device it names. Refused when the key and `AttA` +/// do not derive that device, so no producer emits a claim that recognition +/// would refuse. +pub fn sign_resolution_claim( + claim: SofiResolutionClaim, + signature_alg: u16, + claimant_public_key: &[u8], + claimant_att_a: [u8; 32], + claimant_secret_key: &[u8], +) -> Result { + if crate::core::identity::genesis_v2::derive_devid(claimant_public_key, &claimant_att_a) + != claim.device_id + { + return Err(SignatureError::NotTheNamedDevice { + what: "SofiResolutionClaim", + } + .into()); + } + let digest = derive::resolution_claim_signing_digest( + &claim, + signature_alg, + claimant_public_key, + &claimant_att_a, + ); + let signature = crate::crypto::sphincs::sphincs_sign(claimant_secret_key, &digest)?; + SignedSofiResolutionClaim::new( + claim, + signature_alg, + claimant_public_key, + claimant_att_a, + &signature, + ) + .map_err(|e| crate::types::error::DsmError::invalid_operation(e.to_string())) +} + /// `m_P` — a precommit signs its own body (F2 stage 1). /// /// Verified under the key `P` itself commits, which is the right and complete diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs index 13c17b3e0..ba844febc 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs @@ -2236,7 +2236,33 @@ pub(crate) mod fixtures { use crate::sofi::wire::{PreEClosureIndex, PrecommitLeg, SofiSetupBody, ValidationRef}; pub(crate) const G: D32 = [0x11; 32]; - pub(crate) const DEV: D32 = [0x22; 32]; + /// The fixture trader's attestation digest. Its device id is derived from + /// this and [`trader_keys`], as a genesis derives one (DSM Amendment A10). + pub(crate) const TRADER_ATT_A: D32 = [0xA7; 32]; + + /// The fixture trader's device id, `derive_devid(AK, AttA)` over + /// [`trader_keys`] and [`TRADER_ATT_A`]: every claim the fixture trader + /// signs names its own cells. + /// `claim` as `K_root(q)` holds it: signed by the fixture trader under its + /// key and [`TRADER_ATT_A`] (SoFi Amendment S20). + pub(crate) fn signed_c_q(claim: crate::sofi::wire::SofiResolutionClaim) -> Vec { + crate::sofi::signature::sign_resolution_claim( + claim, + SIG_ALG, + &trader_keys().0, + TRADER_ATT_A, + &trader_keys().1, + ) + .expect("the fixture trader signs its own C_q") + .encode() + } + + pub(crate) fn dev() -> D32 { + static DEV: std::sync::OnceLock = std::sync::OnceLock::new(); + *DEV.get_or_init(|| { + crate::core::identity::genesis_v2::derive_devid(&trader_keys().0, &TRADER_ATT_A) + }) + } pub(crate) const P_POS: u64 = 5; pub(crate) const P_CREATE: u64 = 7; pub(crate) const FEE_BPS: u32 = 30; @@ -2261,7 +2287,7 @@ pub(crate) mod fixtures { pub(crate) fn accepted_setup_claim() -> AcceptedClaim { AcceptedClaim::rehydrate_from_admitted_store( G, - DEV, + dev(), crate::economic::lineage::AdmittedEconomicPosition::SingleRoot { economic_position: SETUP_POS, economic_root: token(0x9A), @@ -2304,7 +2330,7 @@ pub(crate) mod fixtures { ReleaseRule::AllAtCreation.code(), ]; blob.extend_from_slice(&G); - blob.extend_from_slice(&DEV); + blob.extend_from_slice(&dev()); blob.push(1); blob.push(1); blob.extend_from_slice(&(signer.len() as u16).to_be_bytes()); @@ -2359,7 +2385,7 @@ pub(crate) mod fixtures { pub(crate) fn setup_body_for(vault_id: D32) -> SofiSetupBody { SofiSetupBody::new( G, - DEV, + dev(), SETUP_POS, vault_id, SETUP_CLAIM_REF, @@ -2416,7 +2442,7 @@ pub(crate) mod fixtures { } pub(crate) fn vault_id_of(j: usize) -> D32 { - derive::vault_id(&G, &DEV, P_CREATE + j as u64) + derive::vault_id(&G, &dev(), P_CREATE + j as u64) } pub(crate) fn vault_state( @@ -2439,7 +2465,7 @@ pub(crate) mod fixtures { let (market, fee, release) = policies_over(market_pair); VaultStateLeaf { owner_genesis: G, - owner_device_id: DEV, + owner_device_id: dev(), create_position: P_CREATE + j as u64, market_policy: policy_addr(crate::ccb::class::MARKET_POLICY, &market.encode()), fee_policy: policy_addr(crate::ccb::class::FEE_POLICY, &fee.encode()), @@ -2490,7 +2516,7 @@ pub(crate) mod fixtures { } pub(crate) fn base_of(j: usize) -> D32 { - derive::relationship_leaf_genesis(&derive::setup_id(&G, &DEV, P_POS, &vault_id_of(j))) + derive::relationship_leaf_genesis(&derive::setup_id(&G, &dev(), P_POS, &vault_id_of(j))) } /// A whole operation, built the way a trader builds one: the trees first, @@ -2542,13 +2568,14 @@ pub(crate) mod fixtures { pub(crate) fn parent_claim_envelope(root: D32) -> Vec { let body = crate::economic::claim::EconomicRootClaimBody::new( G, - DEV, + dev(), P_POS, root, token(0x78), token(0x77), SIG_ALG, &trader_keys().0, + TRADER_ATT_A, ) .expect("a root claim body"); crate::economic::claim_envelope::sign_economic_root_claim(&body, &trader_keys().1) @@ -2603,14 +2630,14 @@ pub(crate) mod fixtures { ) -> VaultParts { let (token_in, token_out) = market_pair; let vault_id = vault_id_of(j); - let rel_key = derive::relationship_key(&G, &DEV, &vault_id); + let rel_key = derive::relationship_key(&G, &dev(), &vault_id); let base = base_of(j); let state = vault_state_over(j, market_pair, RESERVE_A, RESERVE_B, VAULT_STATUS_ACTIVE); let amount_out = constant_product_output_classified(amount_in, RESERVE_A, RESERVE_B, FEE_BPS).unwrap(); let relationship = VaultRelationshipLeaf { trader_genesis: G, - trader_device_id: DEV, + trader_device_id: dev(), leaf: base, }; let state_key = derive::vault_state_key(&vault_id); @@ -2636,7 +2663,7 @@ pub(crate) mod fixtures { }, CoreEntry::Relationship { genesis: G, - device_id: DEV, + device_id: dev(), vault_id, base, path: path_of(&tree, &rel_key), @@ -2645,7 +2672,7 @@ pub(crate) mod fixtures { entries.sort_by_key(|e| e.key()); VaultParts { vault_id, - core: DlvCore::new(vault_id, tree.root(), G, DEV, base, entries).unwrap(), + core: DlvCore::new(vault_id, tree.root(), G, dev(), base, entries).unwrap(), hop: SwapHop { vault_id, parent_root: tree.root(), @@ -2706,8 +2733,8 @@ pub(crate) mod fixtures { let exact_out = parts[hops - 1].hop.amount_out; // The trader's tree: the token spent, and one relationship per vault. - let in_key = balance_key(&G, &DEV, &intent_in); - let out_key = balance_key(&G, &DEV, &intent_out); + let in_key = balance_key(&G, &dev(), &intent_in); + let out_key = balance_key(&G, &dev(), &intent_out); let mut trader_tree = EconomicSmt::new(); trader_tree.insert(in_key, balance_leaf_value(intent_in, 50_000)); for (j, part) in parts.iter().enumerate() { @@ -2718,7 +2745,7 @@ pub(crate) mod fixtures { trader_tree.insert(part.rel_key, derive::trader_relationship_leaf_value(&leaf)); } // The balance spent, as the exercise carries it (SoFi Amendment S12). - let pre_balance = TraderPreBalance::new(G, DEV, intent_in, 50_000).unwrap(); + let pre_balance = TraderPreBalance::new(G, dev(), intent_in, 50_000).unwrap(); let mut trader_entries = vec![ CoreEntry::Mutation { @@ -2737,7 +2764,7 @@ pub(crate) mod fixtures { for (j, part) in parts.iter().enumerate() { trader_entries.push(CoreEntry::Relationship { genesis: G, - device_id: DEV, + device_id: dev(), vault_id: part.vault_id, base: base_of(j), path: path_of(&trader_tree, &part.rel_key), @@ -2745,7 +2772,7 @@ pub(crate) mod fixtures { } trader_entries.sort_by_key(|e| e.key()); let trader_core = - TraderCore::new(G, DEV, P_POS + 1, trader_tree.root(), trader_entries).unwrap(); + TraderCore::new(G, dev(), P_POS + 1, trader_tree.root(), trader_entries).unwrap(); let parent_claim = parent_claim_envelope(trader_tree.root()); let closure = with_pre_balance(with_parent(closure, &parent_claim), &pre_balance); @@ -2824,7 +2851,7 @@ pub(crate) mod fixtures { }; let precommit = TraderPrecommitBody::new( G, - DEV, + dev(), P_POS, crate::sofi::wire::ParentClaimRef::SingleRoot { claim_ref: derive::claim_ref(&parent_claim), @@ -3003,7 +3030,7 @@ mod tests { ) -> (TraderPrecommitBody, SettlementPreimage) { let core = TraderCore::new( G, - DEV, + dev(), P_POS + 1, *f.preimage.trader_core().pre_root(), entries, @@ -3109,7 +3136,7 @@ mod tests { let forged = token(0x7C); let core = TraderCore::new( G, - DEV, + dev(), P_POS + 1, forged, f.preimage.trader_core().entries().to_vec(), @@ -3145,7 +3172,7 @@ mod tests { .unwrap(); let precommit = TraderPrecommitBody::new( G, - DEV, + dev(), P_POS, *f.precommit.parent_claim_ref(), derive::recompute_e(&preimage).unwrap(), @@ -3456,7 +3483,7 @@ mod tests { fn a_missing_trader_movement_is_invalid() { let f = swap_fixture(); let entries = { - let out_key = balance_key(&G, &DEV, &pair(0).1); + let out_key = balance_key(&G, &dev(), &pair(0).1); f.preimage .trader_core() .entries() @@ -3571,10 +3598,10 @@ mod tests { /// A close, built the same way: the vault retires and the trader takes /// back exactly both reserves. No debit, and no curve anywhere. fn close_fixture(authority: OwnerAuthority) -> Fixture { - close_fixture_for(authority, DEV) + close_fixture_for(authority, dev()) } - /// The same close, against a vault owned by `owner_device`. With `DEV` it + /// The same close, against a vault owned by `owner_device`. With `dev()` it /// is the trader's own vault; with anything else the operation is a close /// of someone else's, and every OTHER field stays self-consistent. fn close_fixture_for(authority: OwnerAuthority, owner_device: D32) -> Fixture { @@ -3592,8 +3619,9 @@ mod tests { ) -> Fixture { let (token_a, token_b) = pair(0); let vault_id = derive::vault_id(&G, &owner_device, P_CREATE); - let rel_key = derive::relationship_key(&G, &DEV, &vault_id); - let base = derive::relationship_leaf_genesis(&derive::setup_id(&G, &DEV, P_POS, &vault_id)); + let rel_key = derive::relationship_key(&G, &dev(), &vault_id); + let base = + derive::relationship_leaf_genesis(&derive::setup_id(&G, &dev(), P_POS, &vault_id)); let state = VaultStateLeaf { owner_device_id: owner_device, release_policy: match release_override.as_ref() { @@ -3605,7 +3633,7 @@ mod tests { let state_key = derive::vault_state_key(&vault_id); let relationship = VaultRelationshipLeaf { trader_genesis: G, - trader_device_id: DEV, + trader_device_id: dev(), leaf: base, }; let mut vault_tree = EconomicSmt::new(); @@ -3631,7 +3659,7 @@ mod tests { }, CoreEntry::Relationship { genesis: G, - device_id: DEV, + device_id: dev(), vault_id, base, path: path_of(&vault_tree, &rel_key), @@ -3639,10 +3667,10 @@ mod tests { ]; vault_entries.sort_by_key(|e| e.key()); let dlv_core = - DlvCore::new(vault_id, vault_tree.root(), G, DEV, base, vault_entries).unwrap(); + DlvCore::new(vault_id, vault_tree.root(), G, dev(), base, vault_entries).unwrap(); - let a_key = balance_key(&G, &DEV, &token_a); - let b_key = balance_key(&G, &DEV, &token_b); + let a_key = balance_key(&G, &dev(), &token_a); + let b_key = balance_key(&G, &dev(), &token_b); let trader_rel = TraderRelationshipLeaf { vault_id, leaf: base, @@ -3665,7 +3693,7 @@ mod tests { }, CoreEntry::Relationship { genesis: G, - device_id: DEV, + device_id: dev(), vault_id, base, path: path_of(&trader_tree, &rel_key), @@ -3673,7 +3701,7 @@ mod tests { ]; trader_entries.sort_by_key(|e| e.key()); let trader_core = - TraderCore::new(G, DEV, P_POS + 1, trader_tree.root(), trader_entries).unwrap(); + TraderCore::new(G, dev(), P_POS + 1, trader_tree.root(), trader_entries).unwrap(); let parent_claim = parent_claim_envelope(trader_tree.root()); let settlement = SettlementBody::Close { @@ -3720,7 +3748,7 @@ mod tests { }; let precommit = TraderPrecommitBody::new( G, - DEV, + dev(), P_POS, crate::sofi::wire::ParentClaimRef::SingleRoot { claim_ref: derive::claim_ref(&parent_claim), @@ -3894,7 +3922,7 @@ mod tests { vault_id, *core.pre_root(), G, - DEV, + dev(), *core.relationship_base(), entries, ) @@ -4173,7 +4201,7 @@ mod tests { bytes[n - 4..n - 2].copy_from_slice(&[0x00, 0x09]); bytes }; - let f = close_fixture_with(OwnerAuthority::Origin, DEV, Some(bogus)); + let f = close_fixture_with(OwnerAuthority::Origin, dev(), Some(bogus)); assert_eq!( validate(&f.precommit, &f.preimage, &f.evidence), Err(Refusal::Invalid(Invalid::PolicyDoesNotDecode { @@ -4190,8 +4218,8 @@ mod tests { let f = close_fixture(OwnerAuthority::Origin); assert_eq!(validate(&f.precommit, &f.preimage, &f.evidence), Ok(())); let (token_a, token_b) = pair(0); - let a_key = balance_key(&G, &DEV, &token_a); - let b_key = balance_key(&G, &DEV, &token_b); + let a_key = balance_key(&G, &dev(), &token_a); + let b_key = balance_key(&G, &dev(), &token_b); for (key, token, amount) in [(a_key, token_a, RESERVE_A), (b_key, token_b, RESERVE_B)] { let entry = f .preimage @@ -4586,7 +4614,7 @@ mod tests { let f = swap_fixture(); let core = &f.preimage.dlv_cores()[0]; let other_trader = token(0x3B); - let entries: Vec = core + let mut entries: Vec = core .entries() .iter() .map(|e| match e { @@ -4596,7 +4624,7 @@ mod tests { path, .. } => CoreEntry::Relationship { - // The core's marker still says (G, DEV); the entry says + // The core's marker still says (G, dev()); the entry says // another device, and so addresses another key. genesis: G, device_id: other_trader, @@ -4607,11 +4635,15 @@ mod tests { other => other.clone(), }) .collect(); + // The bent entry addresses another key, so the entries take that + // key's place in canonical order: the core is well formed, and only + // its relationship entry is wrong. + entries.sort_by_key(CoreEntry::key); let bent_core = DlvCore::new( *core.vault_id(), *core.pre_root(), G, - DEV, + dev(), *core.relationship_base(), entries, ) @@ -4658,7 +4690,7 @@ mod tests { let f = swap_fixture(); let core = &f.preimage.dlv_cores()[0]; let vault_id = *core.vault_id(); - let rel_key = derive::relationship_key(&G, &DEV, &vault_id); + let rel_key = derive::relationship_key(&G, &dev(), &vault_id); let mut evidence = Evidence { objects: f.evidence.objects.clone(), vault_leaves: f.evidence.vault_leaves.clone(), @@ -4747,12 +4779,17 @@ mod tests { #[test] fn a_setup_of_another_trader_or_vault_is_invalid() { - let f = with_setup(setup_at(token(0x33), DEV, vault_id_of(0), SETUP_CLAIM_REF)); + let f = with_setup(setup_at( + token(0x33), + dev(), + vault_id_of(0), + SETUP_CLAIM_REF, + )); assert_eq!( validate(&f.precommit, &f.preimage, &f.evidence), Err(Refusal::Invalid(Invalid::SetupNotThisTrader)) ); - let f = with_setup(setup_at(G, DEV, vault_id_of(1), SETUP_CLAIM_REF)); + let f = with_setup(setup_at(G, dev(), vault_id_of(1), SETUP_CLAIM_REF)); assert_eq!( validate(&f.precommit, &f.preimage, &f.evidence), Err(Refusal::Invalid(Invalid::SetupNotThisVault)) @@ -4797,7 +4834,7 @@ mod tests { let theirs = crate::sofi::wire::SofiSetupBody::new( G, - DEV, + dev(), SETUP_POS, vault_id_of(0), SETUP_CLAIM_REF, @@ -4832,7 +4869,7 @@ mod tests { /// at its position, and no other. #[test] fn a_setup_naming_another_claim_than_the_accepted_one_is_invalid() { - let f = with_setup(setup_at(G, DEV, vault_id_of(0), token(0xA1))); + let f = with_setup(setup_at(G, dev(), vault_id_of(0), token(0xA1))); assert_eq!( validate(&f.precommit, &f.preimage, &f.evidence), Err(Refusal::Invalid( @@ -4859,7 +4896,7 @@ mod tests { assert_eq!(validate(&f.precommit, &f.preimage, &evidence), missing); let foreign = crate::economic::lineage::AcceptedClaim::rehydrate_from_admitted_store( token(0x33), - DEV, + dev(), crate::economic::lineage::AdmittedEconomicPosition::SingleRoot { economic_position: SETUP_POS, economic_root: token(0x9A), @@ -4893,7 +4930,7 @@ mod tests { ] { let mut evidence = f.evidence.clone(); evidence.setup_lineages.clear(); - let lineage = setup_lineage(G, DEV, SETUP_POS, Err(verdict)); + let lineage = setup_lineage(G, dev(), SETUP_POS, Err(verdict)); evidence.setup_lineages.insert(SETUP_POS, lineage); // The refusal carries lineage validation's own reason. assert_eq!( @@ -4922,7 +4959,7 @@ mod tests { F::Unresolved("a conditional position has not resolved".to_string()), ] { let reason = pending.to_string(); - let lineage = setup_lineage(G, DEV, SETUP_POS, Err(pending)); + let lineage = setup_lineage(G, dev(), SETUP_POS, Err(pending)); assert_eq!( lineage, SetupLineage::NotEstablished { @@ -4963,7 +5000,7 @@ mod tests { let mut evidence = f.evidence.clone(); let lineage = setup_lineage( genesis, - DEV, + dev(), position, Err(F::Invalid("another lineage".to_string())), ); @@ -5355,7 +5392,7 @@ mod tests { fn a_trade_that_does_not_debit_the_trader_is_invalid_from_the_exercise_alone() { let f = swap_fixture(); let honest = carried_pre_balance(&f); - let spent_key = balance_key(&G, &DEV, honest.policy_commit()); + let spent_key = balance_key(&G, &dev(), honest.policy_commit()); let entries: Vec = f .preimage .trader_core() diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs index aca75b6d7..980d77452 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs @@ -638,6 +638,119 @@ impl SofiResolutionClaim { } } +// ── 0x0062 SignedSofiResolutionClaim ─────────────────────────────────────── + +/// `C_q` as it occupies `K_root(q)` (DSM Amendment A10, SoFi Amendment S20): +/// the derived claim, the key and `AttA` it is signed under, and the +/// signature. +/// +/// The claim is still derived: `C_q` is `derive::resolution_claim(P, F)`, and +/// every comparison is of that body. The signature is how the claim proves +/// its own authority for the cell, because an object that can occupy a root +/// position must prove authority for that position. Recognition +/// (`sofi::signature::verify_resolution_claim`) requires the signature to +/// verify and `derive_devid(key, AttA)` to be the device the claim names. This +/// type is the wire form only; holding one proves nothing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedSofiResolutionClaim { + claim: SofiResolutionClaim, + signature_alg: u16, + claimant_public_key: Vec, + claimant_att_a: D32, + signature: Vec, +} + +impl SignedSofiResolutionClaim { + /// Refuses an undeclared algorithm, a key of the wrong width and an empty + /// or oversized signature. It does not verify anything. + pub fn new( + claim: SofiResolutionClaim, + signature_alg: u16, + claimant_public_key: &[u8], + claimant_att_a: D32, + signature: &[u8], + ) -> Result { + check_key(signature_alg, claimant_public_key)?; + if signature.is_empty() || signature.len() > MAX_SIGNATURE_BYTES { + return Err(SofiWireError::ObjectTooLarge { + field: "signature", + bytes: signature.len(), + max: MAX_SIGNATURE_BYTES, + }); + } + Ok(Self { + claim, + signature_alg, + claimant_public_key: claimant_public_key.to_vec(), + claimant_att_a, + signature: signature.to_vec(), + }) + } + + /// The derived claim this signs. + pub fn claim(&self) -> &SofiResolutionClaim { + &self.claim + } + + pub fn signature_alg(&self) -> u16 { + self.signature_alg + } + + pub fn claimant_public_key(&self) -> &[u8] { + &self.claimant_public_key + } + + pub fn claimant_att_a(&self) -> &D32 { + &self.claimant_att_a + } + + pub fn signature(&self) -> &[u8] { + &self.signature + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + push_env(&mut out, class::SOFI_SIGNED_RESOLUTION_CLAIM); + push_digest32(&mut out, &self.claim.genesis); + push_digest32(&mut out, &self.claim.device_id); + push_u64(&mut out, self.claim.position); + push_digest32(&mut out, &self.claim.fulfillment_id); + push_digest32(&mut out, &self.claim.realize_root); + push_digest32(&mut out, &self.claim.void_root); + push_key(&mut out, self.signature_alg, &self.claimant_public_key); + push_digest32(&mut out, &self.claimant_att_a); + // `new` and `decode` bound the signature to MAX_SIGNATURE_BYTES, so + // its length fits the u32 prefix. + push_u32(&mut out, self.signature.len() as u32); + out.extend_from_slice(&self.signature); + out + } + + pub fn decode(bytes: &[u8]) -> Result { + let mut c = Cursor { b: bytes, i: 0 }; + c.envelope(class::SOFI_SIGNED_RESOLUTION_CLAIM, SCHEMA_V1)?; + let claim = SofiResolutionClaim { + genesis: c.digest32()?, + device_id: c.digest32()?, + position: c.u64()?, + fulfillment_id: c.digest32()?, + realize_root: c.digest32()?, + void_root: c.digest32()?, + }; + let (signature_alg, claimant_public_key) = read_key(&mut c)?; + let claimant_att_a = c.digest32()?; + let signature = read_var_bytes(&mut c, MAX_SIGNATURE_BYTES)?; + let v = Self { + claim, + signature_alg, + claimant_public_key, + claimant_att_a, + signature, + }; + finish(&c, v) + } +} + // ── 0x005C SignedSofiObject ──────────────────────────────────────────────── /// The largest signature any supported algorithm produces, with headroom. diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs index 8d4de9c64..693a92df6 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs @@ -467,30 +467,30 @@ fn registered_naming( post_root: [u8; 32], manifest_addr: [u8; 32], ) -> RegisteredEconomicRoot { - registered_for_trader(G, *dev(), position, post_root, manifest_addr) + registered_for_trader(G, trader(), ATTA, position, post_root, manifest_addr) } -/// A registered root of the trader `(genesis, device)`. +/// A registered root of the trader `(genesis, derive_devid(pk, att_a))`, +/// signed by that device (DSM Amendment A10). fn registered_for_trader( genesis: [u8; 32], - device: [u8; 32], + signer: &(Vec, Vec), + att_a: [u8; 32], position: u64, post_root: [u8; 32], manifest_addr: [u8; 32], ) -> RegisteredEconomicRoot { - static KEYS: std::sync::OnceLock<(Vec, Vec)> = std::sync::OnceLock::new(); - let (pk, sk) = KEYS.get_or_init(|| { - dsm::crypto::sphincs::generate_sphincs_keypair().expect("a claimant keypair") - }); + let (pk, sk) = signer; let body = dsm::economic::claim::EconomicRootClaimBody::new( genesis, - device, + dsm::core::identity::genesis_v2::derive_devid(pk, &att_a), position, post_root, manifest_addr, canonical_set_id(), dsm::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F, pk, + att_a, ) .expect("a claim body"); let envelope = @@ -597,8 +597,12 @@ fn a_registered_claim_of_another_trader_is_refused() { let manifest = manifest_for(&fx.witness); let accepted = accepted_for(&fx.op); let addr = manifest.addr().expect("addressable"); - for (genesis, device) in [([0x99; 32], *dev()), (G, [0x98; 32])] { - let foreign = registered_for_trader(genesis, device, 1, fx.post_root, addr); + static OTHER: std::sync::OnceLock<(Vec, Vec)> = std::sync::OnceLock::new(); + let other = OTHER.get_or_init(|| { + dsm::crypto::sphincs::generate_sphincs_keypair().expect("another trader's keypair") + }); + for (genesis, signer, att_a) in [([0x99; 32], trader(), ATTA), (G, other, [0x98; 32])] { + let foreign = registered_for_trader(genesis, signer, att_a, 1, fx.post_root, addr); assert!(matches!( run(&fx, &foreign, &manifest, &fx.witness, &accepted), Err(EconomicValidationError::RegisteredClaimNamesAnotherTrader) diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_lineage_register.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_lineage_register.rs index 8482f2f9d..472e7cfd7 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_lineage_register.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_lineage_register.rs @@ -164,16 +164,26 @@ fn keypair() -> (Vec, Vec) { dsm::crypto::sphincs::generate_sphincs_keypair().expect("keypair") } +/// The claimant device's attestation digest: its device id is +/// `derive_devid(pk, ATT_A)` (DSM Amendment A10). +const ATT_A: [u8; 32] = [0xA7; 32]; + +/// The device a key signs for: the id `pk` and [`ATT_A`] derive. +fn devid_of(pk: &[u8]) -> [u8; 32] { + dsm::core::identity::genesis_v2::derive_devid(pk, &ATT_A) +} + fn body(pk: &[u8], set_id: [u8; 32]) -> EconomicRootClaimBody { EconomicRootClaimBody::new( G, - DEV, + devid_of(pk), 7, [0x33; 32], [0x44; 32], set_id, sigalg::SPHINCS_PLUS_SPX256F, pk, + ATT_A, ) .expect("valid body") } @@ -227,8 +237,8 @@ fn a_claim_signed_for_one_position_does_not_verify_at_another() { assert_eq!(verified.body().economic_position, 7); assert_ne!( - economic_root_register_key(&G, &DEV, verified.body().economic_position), - economic_root_register_key(&G, &DEV, 8) + economic_root_register_key(&G, &devid_of(&pk), verified.body().economic_position), + economic_root_register_key(&G, &devid_of(&pk), 8) ); } @@ -278,7 +288,7 @@ fn registering_an_arbitrary_root_yields_nothing_validated() { let (pk, sk) = dsm::crypto::sphincs::generate_sphincs_keypair().expect("a keypair"); let body = dsm::economic::claim::EconomicRootClaimBody::new( G, - DEV, + devid_of(&pk), 1, [0xEE; 32], // invented [0xDD; 32], @@ -287,6 +297,7 @@ fn registering_an_arbitrary_root_yields_nothing_validated() { .storage_set_id, dsm::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F, &pk, + ATT_A, ) .expect("a claim body"); let envelope = @@ -299,7 +310,7 @@ fn registering_an_arbitrary_root_yields_nothing_validated() { ); assert_eq!( registered.register_key(), - economic_root_register_key(&G, &DEV, 1) + economic_root_register_key(&G, &devid_of(&pk), 1) ); // There is deliberately NO API turning that into a ValidatedEconomicRoot: diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_root_primitives.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_root_primitives.rs index 679a7c2b3..d79bef735 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_root_primitives.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_root_primitives.rs @@ -465,6 +465,7 @@ fn a_claim_binds_its_register_set_and_validates_its_key_width() { [0x55; 32], sigalg::SPHINCS_PLUS_SPX256F, &pk, + [0xA7; 32], ) .expect("valid"); let b = EconomicRootClaimBody::new( @@ -477,6 +478,7 @@ fn a_claim_binds_its_register_set_and_validates_its_key_width() { [0x66; 32], sigalg::SPHINCS_PLUS_SPX256F, &pk, + [0xA7; 32], ) .expect("valid"); assert_ne!( @@ -486,7 +488,10 @@ fn a_claim_binds_its_register_set_and_validates_its_key_width() { ); assert!(matches!( - EconomicRootClaimBody::new(G, DEV, 7, [0; 32], [0; 32], [0; 32], 0xFFFF, &pk).unwrap_err(), + EconomicRootClaimBody::new( + G, DEV, 7, [0x33; 32], [0x44; 32], [0x55; 32], 0xFFFF, &pk, [0xA7; 32] + ) + .unwrap_err(), CcbError::UnknownSignatureAlg { .. } )); assert!(matches!( @@ -499,6 +504,7 @@ fn a_claim_binds_its_register_set_and_validates_its_key_width() { [0; 32], sigalg::SPHINCS_PLUS_SPX256F, &[0xAB; 32], + [0xA7; 32], ) .unwrap_err(), CcbError::KeyLengthMismatch { .. } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs index 56e7ed451..432b72c8d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs @@ -139,6 +139,7 @@ async fn a_root_no_transition_explains_is_refused_where_it_sits() { profile.storage_set_id, dsm::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F, &crate::sdk::signing_authority::current_public_key().expect("the device's AK"), + crate::sdk::signing_authority::current_att_a().expect("the device's AttA"), ) .expect("a claim body"); let claim = dsm::economic::claim_envelope::sign_economic_root_claim( 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 d1ebbae43..4ef0e8ca6 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 @@ -531,8 +531,8 @@ async fn a_sofi_trade_executes_end_to_end() { } /// MR-SOFI-0241, SoFi Amendment S14: a vault key held final by an exercise -/// whose fulfillment can never register — a rival claim took the trader's -/// position first — is skipped on that fact alone. The walk reads the key's +/// whose fulfillment can never register — another claim of the trader's own +/// took its position first — is skipped on that fact alone. The walk reads the key's /// cell and the trader's position pair, and asks for none of the exercise's /// validation evidence; the next key is live. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -542,7 +542,9 @@ async fn a_key_whose_fulfillment_can_never_register_is_skipped_without_its_evide let m = open_market(&p).await; let set = canonical_set(NETWORK).expect("the pinned set"); - // A rival claim takes B's next position before B trades. + // B's own claim of another fulfillment takes B's next position before B + // trades. Only B can sign a claim that occupies its cell (DSM Amendment + // A10, SoFi Amendment S20). let q = admitted_position(&p.b) + 1; let (.., root) = { p.b.enter(); @@ -552,18 +554,30 @@ async fn a_key_whose_fulfillment_can_never_register_is_skipped_without_its_evide }; let pair = position_cells(&set, &p.b.genesis, &p.b.device_id, q, &root) .expect("B's next position pair"); - let rival = dsm::sofi::wire::SofiResolutionClaim { - genesis: p.b.genesis, - device_id: p.b.device_id, - position: q, - fulfillment_id: [0x77; 32], - realize_root: [0x78; 32], - void_root: root, - } - .encode(); + let rival = { + p.b.enter(); + 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( + dsm::sofi::wire::SofiResolutionClaim { + genesis: p.b.genesis, + device_id: p.b.device_id, + position: q, + fulfillment_id: [0x77; 32], + realize_root: [0x78; 32], + void_root: root, + }, + dsm::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F, + &pk, + att_a, + &sk, + ) + .expect("B signs its own claim") + .encode() + }; let taken = crate::sdk::route_seats::write_recorded(&set, pair.root().routed(), &rival) .await - .expect("any party may write a claim"); + .expect("the claim is written along its route"); assert!( taken.reached_leader(), "the rival claim holds B's root cell" diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs index 713accfdb..97671a67c 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs @@ -806,6 +806,18 @@ async fn finish_locked( { Some((_, bytes)) => bytes, None => { + // The claim proves its own authority for this device's cell: it + // carries the AttA under which this key derives the device id + // (DSM Amendment A10). A key that does not derive it would sign a + // claim recognition refuses, so none is signed. + let att_a = crate::sdk::signing_authority::current_att_a() + .map_err(|e| storage_err("signing authority", e))?; + if dsm::core::identity::genesis_v2::derive_devid(&public_key, &att_a) != devid { + return Err(DsmError::invalid_operation( + "root claim: this device's key and AttA do not derive its device id — \ + local identity incoherent; refusing to sign a claim for its cell", + )); + } let body = dsm::economic::claim::EconomicRootClaimBody::new( genesis, devid, @@ -815,6 +827,7 @@ async fn finish_locked( set.id(), dsm::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F, &public_key, + att_a, ) .map_err(|e| storage_err("root claim body", e))?; let bytes = sign_economic_root_claim(&body, &secret_key) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs index 8fdfdb5d0..ee3f1e832 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs @@ -187,7 +187,20 @@ pub async fn install_fulfillment( signature: request.fulfillment_signature, } .object_bytes()?; - let claim_bytes = derive::resolution_claim(request.precommit, request.fulfillment).encode(); + // C_q occupies K_root(q) only signed by this trader device, under the key + // and AttA that derive its device id (DSM Amendment A10, SoFi Amendment + // S20). The signer refuses a key that does not derive the device C_q + // names. + let (public_key, secret_key) = crate::sdk::signing_authority::current_keypair()?; + let att_a = crate::sdk::signing_authority::current_att_a()?; + let claim_bytes = dsm::sofi::signature::sign_resolution_claim( + derive::resolution_claim(request.precommit, request.fulfillment), + request.precommit.signature_alg(), + &public_key, + att_a, + &secret_key, + )? + .encode(); let reports = write_recorded_position(set, &cells, &fulfillment_bytes, &claim_bytes).await?; let [ful, root] = &reports; if !(ful.reached_leader() && root.reached_leader()) { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs index cdc4864fd..0092e41cb 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs @@ -73,9 +73,11 @@ async fn objects( } /// Carry the position pair of a registered-or-not fulfillment along the -/// route of `s(q)`: `F` in the envelope it was published in, and `C_q` -/// derived from the two verified objects. It establishes no registration — -/// Core derives that from the cells (R10). +/// route of `s(q)`: `F` in the envelope it was published in, and the +/// trader's signed `C_q` exactly as `K_root(q)` holds it. A relayer cannot +/// sign `C_q` (SoFi Amendment S20), so it carries the trader's own bytes: +/// recognized at the cell, and with the body `(P, F)` derive. It establishes +/// no registration — Core derives that from the cells (R10). pub async fn relay_position_pair( set: &StorageSet, fulfillment_id: &D32, @@ -102,7 +104,27 @@ async fn carry_pair( } .object_bytes() .map_err(refuse)?; - let claim = dsm::sofi::derive::resolution_claim(&precommit.body, &fulfillment.body).encode(); + let derived = dsm::sofi::derive::resolution_claim(&precommit.body, &fulfillment.body); + let seats = NodeSeats::new(set)?; + let evidence = read_cell(&seats, cells.root().routed()).await; + let claim = match dsm::economic::register::read_root_cell(cells.root(), &evidence) { + Ok(dsm::route_chain::CellReading::Held { + object: dsm::economic::claim_envelope::RegisteredEconomicClaim::ConditionalSofi(held), + value, + .. + }) if held == derived => value, + Ok(_) => { + return Err(refuse( + "K_root(q) holds no C_q of this fulfillment signed by its trader; only the \ + trader can write it", + )) + } + Err(missing) => { + return Err(refuse(format!( + "K_root(q) is not decided yet, so the trader's C_q is not in hand: {missing:?}" + ))) + } + }; write_recorded_position(set, &cells, &f_bytes, &claim).await } From d5b423d036636235680986868af790f2ac86d36f Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:47:46 -0400 Subject: [PATCH 10/31] docs(intent): MR-SOFI-0356 names held_at, the function that replaced parent_named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 2 (§6.62) replaced resolve.rs's parent_named with held_at, which counts a walked root only at the position it reached, and moved the comparison of the claim P names with the claim held into the facts (establish_ground). The manifest row still named parent_named, so the requirement map refused the root query and #1096's Code map failed before producing a map. The row now names held_at, evidenced by what_a_walk_holds_counts_only_at_the_position_it_reached. Mutation control (2026-10-01, restored): held_at taking a root of another position turns that test red. The matrix and the §8 row say what parent_named's test covered that no test covers now, a conditional parent held by another fulfillment, which pre-audit item 12 restores. --- specs/requirements/CONFORMANCE_GAPS.md | 2 +- specs/requirements/INTENT_MANIFEST.tsv | 2 +- specs/requirements/VERIFICATION_MATRIX.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 0b8f68f58..fe6d06bfe 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2842,7 +2842,7 @@ The timeouts are `Duration`s handed to the HTTP and pool libraries. `no_clock_re | MR-SOFI-0353 | Met | `dsm_sdk::sdk::sofi_flow::find_route`; `dsm_sdk::sdk::sofi_flow::vaults_of`; `dsm_sdk::handlers::sofi_routes`; `dsm::sofi::resolve::Verifier::vault_genesis`; `dsm_sdk::sdk::sofi_reads::VerifierContext::sharing` | `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::a_route_search_that_cannot_see_its_vaults_says_so`; `dsm_sdk::handlers::node_e2e_tests::a_request_reads_each_vaults_genesis_once`; `dsm::sofi::resolve::tests::a_genesis_verdict_is_reused_only_for_the_bytes_it_bound`; `dsm_sdk::handlers::node_e2e_tests::an_operations_verifiers_share_the_geneses_it_accepted` | SoFi Amendment S16 (§6.53). The search reads the two tokens' indexes, not the trader's relationships; B, set up with nothing, is quoted ERA→TKN→TKB and ERA→TKN, and its history records no setup until it trades. `SofiFindRouteResponse.search` says whether the discovery was complete. A request reads each vault's genesis once (§6.56). One operation walks each vault owner's lineage once, for the exact genesis bytes (§6.58). | | MR-SOFI-0354 | Met | `dsm_sdk::sdk::sofi_flow::set_up_with`; `dsm_sdk::sdk::sofi_flow::check_route`; `dsm_sdk::sdk::sofi_flow::trade`; `dsm_sdk::sdk::sofi_flow::close` | `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::a_realized_trade_shows_in_balances_and_history_at_once`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S16 (§6.53). A route through two vaults B has no setup with admits two setups, then the route; the next trade through one of them admits none; the owner's first close admits its own. The route is priced at the walked heads before any setup is admitted for it. | | MR-SOFI-0355 | Met | `dsm_sdk::sdk::sofi_flow::owned_vaults`; `dsm::sofi::resolve::LocalLeaves::vault_creations`; `dsm_sdk::handlers::sofi_routes` | `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S16 (§6.53). `sofi.vaults` reads the VaultCreation leaves of the device's validated root and walks each vault to its head: the reserves, fee, generation and status the owner sees are the head's, after other traders' trades and the owner's close. | -| MR-SOFI-0356 | Met | `dsm::sofi::resolve::Verifier::parent_for`; `dsm::sofi::resolve::parent_named`; `dsm::sofi::resolve::Verifier::chain_until`; `dsm::economic::peer_lineage::peer_root_at`; `dsm_sdk::sdk::sofi_reads::LiveSofiReads` (its trader root read) | `dsm::sofi::resolve::tests::only_the_position_and_fulfillment_p_names_resolve_its_parent`; `dsm::economic::peer_lineage::tests::a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read`; `dsm::economic::peer_lineage::tests::the_activation_root_names_no_parent`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S16 (§6.53). A walk meeting another trader's exercise whose P names a conditional parent resolves it through `peer_root_at` (the frontier-relative segment, the position itself resolved by S15); the parent counts only as the position and fulfillment P names. Resolving a position bounds each vault's chain at the parent its leg names (`chain_until`). Known cost: §6.53. | +| MR-SOFI-0356 | Met | `dsm::sofi::resolve::Verifier::parent_for`; `dsm::sofi::resolve::held_at`; `dsm::sofi::facts::establish_ground`; `dsm::sofi::resolve::Verifier::chain_until`; `dsm::economic::peer_lineage::peer_root_at`; `dsm_sdk::sdk::sofi_reads::LiveSofiReads` (its trader root read) | `dsm::sofi::resolve::tests::what_a_walk_holds_counts_only_at_the_position_it_reached`; `dsm::economic::peer_lineage::tests::a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read`; `dsm::economic::peer_lineage::tests::the_activation_root_names_no_parent`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S16 (§6.53). A walk meeting another trader's exercise whose P names a conditional parent resolves it through `peer_root_at` (the frontier-relative segment, the position itself resolved by S15); the parent counts only at the position P names (`held_at`) and only as the claim P names (`establish_ground`, §6.62). Gap: no unit test covers a conditional parent held by another fulfillment since §6.62 replaced `parent_named`; pre-audit item 12 restores it. Resolving a position bounds each vault's chain at the parent its leg names (`chain_until`). Known cost: §6.53. | | MR-SOFI-0357 | Met | `dsm::sofi::validation::route_endpoints`; `dsm::sofi::validation::swap_endpoints`; `dsm::sofi::validation::validate_swap`; `dsm::sofi::validation::route_shape`; `dsm::sofi::validation::split_sums`; `dsm::sofi::validation::chain_holds`; `dsm_sdk::sdk::sofi_sdk::draft_route`; `dsm::sofi::validation::movement_shape` | `dsm::sofi::validation::tests::a_route_chains_or_splits_and_its_endpoints_follow`; `dsm::sofi::validation::tests::a_split_whose_legs_sum_to_the_intent_is_not_refuted_by_its_shape`; `dsm::sofi::validation::tests::a_split_that_does_not_sum_to_the_intent_is_invalid`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). RouteValidation accepts hops that chain or hops that all trade the intent's pair with inputs and outputs summing to the intent in checked arithmetic (`Invalid::SplitDoesNotSum` otherwise); each hop is priced by its own vault. The producer states the intent by the same rule. `movement_shape` is the shape `route_endpoints` states a route's ends by (§6.56). | | MR-SOFI-0358 | Met | `dsm::sofi::validation::trader_movements`; `dsm::sofi::validation::validate_swap` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). On the nodes a split through two vaults of ERA/TKN realizes as one position: B is debited the whole input and credited the sum of both legs, and each vault moved by its own leg alone. | | MR-SOFI-0359 | Met | `dsm_sdk::sdk::sofi_flow::find_route`; `dsm_sdk::sdk::sofi_flow::plan`; `dsm_sdk::sdk::sofi_flow::plan_split`; `dsm_sdk::sdk::sofi_flow::trade`; `dsm_sdk::sdk::sofi_flow::route_ends` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). The search weighs each pair of direct vaults as a split beside every single hop and chain and proposes the most; the split share is searched over the inputs both legs price. The trade plans the same split again at the heads it walks; min-out bounds what it accepts. The quote reports the route's total by that rule (§6.56). | diff --git a/specs/requirements/INTENT_MANIFEST.tsv b/specs/requirements/INTENT_MANIFEST.tsv index 22c1f7638..032e4f426 100644 --- a/specs/requirements/INTENT_MANIFEST.tsv +++ b/specs/requirements/INTENT_MANIFEST.tsv @@ -551,7 +551,7 @@ MR-SOFI-0354 dsm_sdk::sdk::sofi_flow::check_route android MUST_REACH active dsm_ MR-SOFI-0355 dsm_sdk::sdk::sofi_flow::owned_vaults android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer;dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair - CONFORMANCE §8 MR-SOFI-0355 (Met) MR-SOFI-0355 dsm::sofi::resolve::LocalLeaves::vault_creations android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer;dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair - CONFORMANCE §8 MR-SOFI-0355 (Met) MR-SOFI-0356 dsm::sofi::resolve::Verifier::parent_for android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer;dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair - CONFORMANCE §8 MR-SOFI-0356 (Met) -MR-SOFI-0356 dsm::sofi::resolve::parent_named android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::resolve::tests::only_the_position_and_fulfillment_p_names_resolve_its_parent - CONFORMANCE §8 MR-SOFI-0356 (Met) +MR-SOFI-0356 dsm::sofi::resolve::held_at android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::resolve::tests::what_a_walk_holds_counts_only_at_the_position_it_reached - CONFORMANCE §8 MR-SOFI-0356 (Met) MR-SOFI-0356 dsm::sofi::resolve::Verifier::chain_until android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer - CONFORMANCE §8 MR-SOFI-0356 (Met) MR-SOFI-0356 dsm::economic::peer_lineage::peer_root_at android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::economic::peer_lineage::tests::a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read;dsm::economic::peer_lineage::tests::the_activation_root_names_no_parent;dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer;dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair - CONFORMANCE §8 MR-SOFI-0356 (Met) MR-SOFI-0357 dsm::sofi::validation::route_endpoints android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::validation::tests::a_route_chains_or_splits_and_its_endpoints_follow - CONFORMANCE §8 MR-SOFI-0357 (Met) diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index 27aa053a9..24445c651 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -146,7 +146,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-SOFI-0348, SoFi Amendment S15: adoption is the trader's own construction predicate, not re-run by another verifier | `dsm` · sofi/lineage.rs · `advance_peer_resolved` (no `adoption_admits`), `advance_resolved` (with it) | `dsm::sofi::lineage::tests::another_verifier_resolves_the_position_without_the_traders_adoptions` | The adoption check run on the peer path → that test red (2026-09-30, restored). | — | | MR-SOFI-0350, MR-SOFI-0351, SoFi Amendment S16: a vault is found under each of its tokens, and discovery carries no authority | `dsm` · sofi/publication.rs · `Publication::locators`; sofi/derive.rs · `vault_token_locator`; sofi/resolve.rs · `Verifier::vaults_of_token` | `dsm::sofi::publication::tests::vault_objects_land_where_the_verifier_looks`; `dsm_sdk::handlers::node_e2e_tests::discovery_passes_over_what_is_not_a_vault_of_the_token` | The market check removed from `vaults_of_token` → A's TKN/TKB vault, appended under ERA's locator, is counted as a vault of ERA → red (2026-10-01, restored). | — | | MR-SOFI-0354, SoFi Amendment S16: the first trade, route or close through a vault admits its setup first, and later ones reuse it | `dsm_sdk` · sdk/sofi_flow.rs · `set_up_with`, `check_route`, `trade`, `close` | `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::a_realized_trade_shows_in_balances_and_history_at_once` | `trade` without `set_up_with` → the route through two vaults B never set up with is refused ("no published setup with this vault is one this device admitted") → red (2026-10-01, restored). | — | -| MR-SOFI-0356, SoFi Amendment S16: a walk resolves another trader's conditional parent through frontier-relative verification, as only the position and fulfillment P names | `dsm` · sofi/resolve.rs · `Verifier::parent_for`, `parent_named`, `Verifier::chain_until`; economic/peer_lineage.rs · `peer_root_at`; `dsm_sdk` · sdk/sofi_reads.rs · `trader_root_at` | `dsm::sofi::resolve::tests::only_the_position_and_fulfillment_p_names_resolve_its_parent`; `dsm::economic::peer_lineage::tests::a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read`; `dsm::economic::peer_lineage::tests::the_activation_root_names_no_parent`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | `parent_named` accepting any fulfillment, or skipping the position check → `only_the_position_and_fulfillment_p_names_resolve_its_parent` red; `peer_root_at` standing below `position` instead of `position + 1` → `a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read` red; peer resolution walking each vault's chain to its head instead of `chain_until` the leg's parent → `every_sofi_route_reaches_its_producer` overflows its stack (2026-10-01, all restored). | — | +| MR-SOFI-0356, SoFi Amendment S16: a walk resolves another trader's conditional parent through frontier-relative verification, as only the position and fulfillment P names | `dsm` · sofi/resolve.rs · `Verifier::parent_for`, `held_at`, `Verifier::chain_until`; sofi/facts.rs · `establish_ground` (the claim P names against the claim held, §6.62); economic/peer_lineage.rs · `peer_root_at`; `dsm_sdk` · sdk/sofi_reads.rs · `trader_root_at` | `dsm::sofi::resolve::tests::what_a_walk_holds_counts_only_at_the_position_it_reached`; `dsm::economic::peer_lineage::tests::a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read`; `dsm::economic::peer_lineage::tests::the_activation_root_names_no_parent`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | `held_at` holding a root of another position → `what_a_walk_holds_counts_only_at_the_position_it_reached` red (2026-10-01, restored). Since §6.62 the fulfillment comparison is the facts' (`establish_ground`), and no unit test covers a conditional parent held by another fulfillment; the case left with `parent_named`'s test and returns with pre-audit item 12; `peer_root_at` standing below `position` instead of `position + 1` → `a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read` red; peer resolution walking each vault's chain to its head instead of `chain_until` the leg's parent → `every_sofi_route_reaches_its_producer` overflows its stack (2026-10-01, all restored). | — | | MR-SOFI-0357, MR-SOFI-0358, SoFi Amendment S19: a Swap route chains or splits, and a split sums to the intent | `dsm` · sofi/validation.rs · `validate_swap` (`route_shape`, `split_sums`), `route_endpoints`, `swap_endpoints` | `dsm::sofi::validation::tests::a_split_whose_legs_sum_to_the_intent_is_not_refuted_by_its_shape`; `dsm::sofi::validation::tests::a_split_that_does_not_sum_to_the_intent_is_invalid`; `dsm::sofi::validation::tests::a_route_chains_or_splits_and_its_endpoints_follow`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | No route read as a split → both split tests red; the sums unchecked → `a_split_that_does_not_sum_to_the_intent_is_invalid` red (2026-10-01, restored). | — | | MR-SOFI-0359, SoFi Amendment S19: the search proposes the split across two vaults of one pair when it gives the most | `dsm_sdk` · sdk/sofi_flow.rs · `find_route`, `plan`, `plan_split` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | No split candidate weighed → the search proposes one vault alone → red (2026-10-01, restored). | — | | MR-SOFI-0359, MR-SOFI-0357, SoFi Amendment S19: a quote reports the route's shape and what it takes and gives as one operation, by Core's endpoint rule | `dsm_sdk` · handlers/sofi_routes.rs, sdk/sofi_flow.rs · `route_ends`; `dsm` · sofi/validation.rs · `movement_shape` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm::sofi::validation::tests::a_route_chains_or_splits_and_its_endpoints_follow` | The route's total taken from its last hop → the split's quote reports one leg `(2, 31, 236)` against `(2, 60, 460)` → red (2026-10-01, restored). | — | From 91e8ca6bceba5de3bfbc69c6c5202ed7bdfa55d1 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:51:48 -0400 Subject: [PATCH 11/31] feat(core): a fulfillment proves its device at K_ful(q) (A10, S20) The owner's item-1 rule reaches the fulfillment cell: an F at K_ful(q) can win the position, so it proves its own authority for it. - `TraderFulfillmentBody` carries `claimant_att_a`. - `registration::fulfillment_proves_the_device` checks that F's key with that AttA derives the cell's device, from F's bytes alone. `names_fulfillment_key` applies it before it looks up P, so a foreign-key F neither holds the cell nor makes a reader fetch a P it names. - Its P key equality then binds P's key too. - The SDK's `build_fulfillment` takes this device's AttA and refuses a P whose key does not derive the device it names. - Test: a squatter's own P and F naming the victim's device, verifying under the squatter's key, name no cell with either AttA, even with that P in hand. The trader's own F names it. - The independent encoder and every F fixture carry the AttA. --- .../dsm/src/sofi/conformance.rs | 4 + .../dsm/src/sofi/exercise.rs | 2 + .../dsm/src/sofi/lineage.rs | 2 + .../dsm/src/sofi/publication.rs | 1 + .../dsm/src/sofi/registration.rs | 94 ++++++++++++++++++- .../dsm/src/sofi/signature.rs | 1 + .../dsm/src/sofi/wire/objects.rs | 18 +++- .../dsm/tests/sofi_v8_independent.rs | 10 +- .../dsm/tests/sofi_v8_operations.rs | 1 + .../dsm_sdk/src/sdk/sofi_flow.rs | 5 +- .../dsm_sdk/src/sdk/sofi_sdk.rs | 23 ++++- 11 files changed, 151 insertions(+), 10 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs index a199e47b9..2a49e26cc 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs @@ -907,6 +907,7 @@ mod tests { precommit.position() + 1, precommit.signature_alg(), precommit.claimant_public_key(), + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap() } @@ -927,6 +928,7 @@ mod tests { position, SIG_ALG, key, + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap() } @@ -1443,6 +1445,7 @@ mod tests { position, SIG_ALG, pk, + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap(); let fulfillment_id = derive::fulfillment_id(&parent); @@ -1509,6 +1512,7 @@ mod tests { P_POS, SIG_ALG, pk(), + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap(); assert_eq!( diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs index 1bbc210e7..4fdfb8b8f 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs @@ -405,6 +405,7 @@ pub(crate) mod fixtures { p.position() + 1, ALG, key(), + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap(); let x = SofiExercise::new( @@ -604,6 +605,7 @@ mod tests { fb.position(), ALG, &other_pk, + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap(); let foreign_sig = crate::crypto::sphincs::sphincs_sign( 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 0bb9ace00..9ae209183 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs @@ -870,6 +870,7 @@ mod tests { P_POS + 1, SIG_ALG, &[0x01; 64], + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap() } @@ -1349,6 +1350,7 @@ mod tests { P_POS + 2, SIG_ALG, &[0x01; 64], + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap(); assert!(matches!( diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs index 5d370ced3..38b2e1751 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs @@ -385,6 +385,7 @@ mod tests { p.position() + 1, ALG, key(), + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap() } 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 0207bdd7e..9ce17d0d7 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/registration.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/registration.rs @@ -46,11 +46,24 @@ impl PrecommitLookup for BTreeMap { } } +/// Whether `F` proves its own authority for a cell of `device_id` (DSM +/// Amendment A10, SoFi Amendment S20): its key, with the `AttA` it carries, +/// derives that device. Decided from `F`'s bytes alone, so a reader applies +/// it before fetching anything `F` names. +pub fn fulfillment_proves_the_device(body: &TraderFulfillmentBody, device_id: &D32) -> bool { + crate::core::identity::genesis_v2::derive_devid( + body.claimant_public_key(), + body.claimant_att_a(), + ) == *device_id +} + /// An object naming `K_ful(q)` of trader `(G, DevID)`: a fulfillment -/// envelope that recognizes (R8), whose body's position is `q`, and whose -/// `P` — fetched by the id the body names — is that trader's. A fulfillment -/// of another trader or another position written at this key is nothing: -/// neither a rival nor a winner, however early it arrived. +/// envelope that recognizes (R8), whose body's position is `q`, whose key and +/// `AttA` derive `DevID` ([`fulfillment_proves_the_device`]), and whose `P` — +/// fetched by the id the body names — is that trader's. A fulfillment of +/// another trader or another position, or one under a key that does not +/// derive the trader's device, written at this key is nothing: neither a +/// rival nor a winner, however early it arrived. pub fn names_fulfillment_key( bytes: &[u8], genesis: &D32, @@ -62,6 +75,9 @@ pub fn names_fulfillment_key( if signed.body.position() != position { return None; } + if !fulfillment_proves_the_device(&signed.body, device_id) { + return None; + } let precommit = precommits.precommit(signed.body.precommit_id())?; if precommit.genesis() != genesis || precommit.device_id() != device_id { return None; @@ -398,6 +414,7 @@ mod tests { position, ALG, key(), + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap() } @@ -438,6 +455,73 @@ mod tests { assert!(names_fulfillment_key(b"not an envelope", &G, &dev(), q, &known).is_none()); } + /// DSM Amendment A10, SoFi Amendment S20: `F` proves its own authority + /// for `K_ful(q)`. A squatter that publishes its own `P` naming the + /// victim's device, and an `F` of it — each verifying under the + /// squatter's key — names no cell of the victim's, with the squatter's + /// `AttA` or the victim's, even with that `P` in hand: the key does not + /// derive the victim's device, decided from `F`'s bytes alone. + #[test] + fn a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell() { + let p = swap_fixture_n(2).precommit; + let q = p.position() + 1; + let (squatter_pk, squatter_sk) = + crate::crypto::sphincs::generate_sphincs_keypair().unwrap(); + let squatters_p = TraderPrecommitBody::new( + *p.genesis(), + *p.device_id(), + p.position(), + *p.parent_claim_ref(), + *p.external_commitment(), + p.legs().to_vec(), + *p.realize_root(), + *p.void_root(), + *p.storage_set_id(), + ALG, + &squatter_pk, + ) + .unwrap(); + let known = lookup(&squatters_p); + for att_a in [[0x5A; 32], crate::sofi::validation::fixtures::TRADER_ATT_A] { + let squat = TraderFulfillmentBody::new( + derive::precommit_id(&squatters_p), + vec![[0x01; 32], [0x02; 32]], + squatters_p + .legs() + .iter() + .map(|l| AttemptEntry { + vault_id: l.vault_id, + attempt: 0, + }) + .collect(), + q, + ALG, + &squatter_pk, + att_a, + ) + .unwrap(); + assert!(!fulfillment_proves_the_device(&squat, &dev())); + let bytes = Publication::Fulfillment { + body: &squat, + signature: &crate::crypto::sphincs::sphincs_sign( + &squatter_sk, + &derive::fulfillment_signing_digest(&squat), + ) + .unwrap(), + } + .object_bytes() + .unwrap(); + assert!( + names_fulfillment_key(&bytes, &G, &dev(), q, &known).is_none(), + "a squatter's F names no cell of the victim's" + ); + } + // The trader's own F of its own P names the cell. + let f = fulfillment(&p, q); + assert!(fulfillment_proves_the_device(&f, &dev())); + assert!(names_fulfillment_key(&envelope(&f), &G, &dev(), q, &lookup(&p)).is_some()); + } + /// `K_ful(q)` is named only by a fulfillment the trader signed: its /// signature verifies under the key its body commits, and that key is the /// one its `P` commits. An unsigned or foreign-key `F` naming the right @@ -465,6 +549,7 @@ mod tests { q, ALG, &other_pk, + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap(); let foreign_bytes = Publication::Fulfillment { @@ -557,6 +642,7 @@ mod tests { q, ALG, key(), + crate::sofi::validation::fixtures::TRADER_ATT_A, ) .unwrap(); let other_claim = signed_c_q(derive::resolution_claim(&p, &rival)); diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs index f4c521ded..57c12b88c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs @@ -414,6 +414,7 @@ mod tests { POS + 1, ALG, key, + [0xA7; 32], ) .unwrap() } diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs index 980d77452..fc12310f6 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs @@ -493,6 +493,12 @@ pub struct AttemptEntry { } /// `F` — the trader's exercise. Never restates a field of `P`. +/// +/// `claimant_att_a` is the trader device's `AttA`. `F` occupies `K_ful(q)`, +/// so it proves its own authority for that position (DSM Amendment A10, SoFi +/// Amendment S20): `derive_devid(claimant_public_key, claimant_att_a)` must +/// be the device whose cell it is, decided from these bytes before `P` is +/// fetched. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TraderFulfillmentBody { precommit_id: D32, @@ -501,6 +507,7 @@ pub struct TraderFulfillmentBody { position: u64, signature_alg: u16, claimant_public_key: Vec, + claimant_att_a: D32, } impl TraderFulfillmentBody { @@ -511,6 +518,7 @@ impl TraderFulfillmentBody { position: u64, signature_alg: u16, claimant_public_key: &[u8], + claimant_att_a: D32, ) -> Result { check_count( "policy fulfillment set", @@ -530,6 +538,7 @@ impl TraderFulfillmentBody { position, signature_alg, claimant_public_key: claimant_public_key.to_vec(), + claimant_att_a, }) } @@ -551,6 +560,9 @@ impl TraderFulfillmentBody { pub fn claimant_public_key(&self) -> &[u8] { &self.claimant_public_key } + pub fn claimant_att_a(&self) -> &D32 { + &self.claimant_att_a + } pub fn encode(&self) -> Vec { let mut out = Vec::new(); @@ -567,6 +579,7 @@ impl TraderFulfillmentBody { } push_u64(&mut out, self.position); push_key(&mut out, self.signature_alg, &self.claimant_public_key); + push_digest32(&mut out, &self.claimant_att_a); out } @@ -589,8 +602,9 @@ impl TraderFulfillmentBody { } let position = c.u64()?; let (alg, key) = read_key(&mut c)?; - let v = - Self::new(precommit_id, set, attempts, position, alg, &key).map_err(wire_invalid)?; + let att_a = c.digest32()?; + let v = Self::new(precommit_id, set, attempts, position, alg, &key, att_a) + .map_err(wire_invalid)?; finish(&c, v) } } diff --git a/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_independent.rs b/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_independent.rs index d9d31cb18..c0e191463 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_independent.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_independent.rs @@ -156,6 +156,7 @@ mod indep { q: u64, alg: u16, k: &[u8], + att_a: &[u8; 32], ) -> Vec { let mut out = [env(0x0039), pid.to_vec(), u32be(set.len() as u32)].concat(); for id in set { @@ -166,7 +167,7 @@ mod indep { out.extend_from_slice(v); out.extend(u64be(*a)); } - [out, u64be(q), key(alg, k)].concat() + [out, u64be(q), key(alg, k), att_a.to_vec()].concat() } pub fn resolution_claim( @@ -302,6 +303,7 @@ fn conforming_fulfillment(p: &TraderPrecommitBody) -> TraderFulfillmentBody { 42, ALG, &KEY, + [0xA7; 32], ) .expect("fulfillment") } @@ -360,6 +362,7 @@ fn every_object_matches_the_independent_encoder_and_round_trips() { 42, ALG, &KEY, + &[0xA7; 32], ); assert_eq!(f.encode(), f_bytes); assert_eq!(TraderFulfillmentBody::decode(&f_bytes).expect("decode"), f); @@ -696,6 +699,7 @@ fn fulfillment_conformance_refuses_every_malformed_exercise() { 42, ALG, &KEY, + [0xA7; 32], ) .expect("structurally valid"); assert_eq!( @@ -715,6 +719,7 @@ fn fulfillment_conformance_refuses_every_malformed_exercise() { 42, ALG, &KEY, + [0xA7; 32], ) .expect("a third witness encodes; cardinality is not the codec's business"); assert_eq!( @@ -747,6 +752,7 @@ fn fulfillment_conformance_refuses_every_malformed_exercise() { 43, ALG, &KEY, + [0xA7; 32], ) .expect("structurally valid"); assert_eq!( @@ -765,6 +771,7 @@ fn fulfillment_conformance_refuses_every_malformed_exercise() { 42, ALG, &[0x56; 64], + [0xA7; 32], ) .expect("structurally valid"); assert_eq!( @@ -789,6 +796,7 @@ fn fulfillment_conformance_refuses_every_malformed_exercise() { 42, ALG, &KEY, + [0xA7; 32], ) .expect("structurally valid"); assert_eq!( diff --git a/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs b/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs index 5c7b1de95..8ca7dc65e 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs @@ -219,6 +219,7 @@ fn each_sofi_operation_signs_its_own_rule_and_not_the_other() { 6, 0x0001, &pk, + [0xA7; 32], ) .unwrap(); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs index 2cd6e801c..0ffebb923 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs @@ -1392,8 +1392,9 @@ async fn exercise_draft( live_attempt(&verifier, &chains, &leg.vault_id, &leg.parent_root)?, )); } - let produced = - build_fulfillment(&checked, precommit_signature.clone(), &attempts).map_err(refuse)?; + let att_a = crate::sdk::signing_authority::current_att_a()?; + let produced = build_fulfillment(&checked, precommit_signature.clone(), &attempts, att_a) + .map_err(refuse)?; let fulfillment_signature = sign(produced.signs.bytes())?; // Stages 4 and 5. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs index 56a72a36e..4ff07e53d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs @@ -17,7 +17,7 @@ //! ```text //! draft_trade / draft_route / draft_close -> PrecommitDraft (gives m_P) //! caller signs m_P -//! build_fulfillment(draft, p_signature, attempts) -> Produced (gives m_F) +//! build_fulfillment(draft, p_signature, attempts, att_a) -> Produced (gives m_F) //! ``` //! //! ## What each operation's signature covers @@ -96,6 +96,10 @@ pub enum BuildError { /// The producer's tree is not the predecessor's root, so `R_T^setup` /// computed from it would describe a mutation of some other state. PreTreeIsNotThePredecessor, + /// `P`'s key, with the `AttA` given, does not derive the device `P` names, + /// so `F` would occupy no cell of it (DSM Amendment A10, SoFi Amendment + /// S20). + KeyIsNotTheDevices, } impl core::fmt::Display for BuildError { @@ -121,6 +125,11 @@ impl core::fmt::Display for BuildError { "the producer's economic tree is not the validated predecessor's root: \ R_T^setup derived from it would describe a mutation of another state" ), + Self::KeyIsNotTheDevices => write!( + f, + "the precommit's key, with this device's AttA, does not derive the device it \ + names; the fulfillment would occupy no cell of it" + ), Self::SetupAlreadyExists => write!( f, "this identity already holds a relationship with that vault; h⁰ is the \ @@ -623,8 +632,19 @@ pub fn build_fulfillment( draft: &PrecommitDraft, precommit_signature: Vec, attempts: &[(D32, u64)], + claimant_att_a: D32, ) -> Result { let precommit = &draft.precommit; + // F occupies K_ful(q) only under a key that, with its AttA, derives the + // device P names (DSM Amendment A10, SoFi Amendment S20). A producer that + // built one otherwise would hand the trader an F that names no cell. + if dsm::core::identity::genesis_v2::derive_devid( + precommit.claimant_public_key(), + &claimant_att_a, + ) != *precommit.device_id() + { + return Err(BuildError::KeyIsNotTheDevices); + } // THE P SIGNATURE IS VERIFIED, NOT COUNTED. `F` names a `P` that storage // will refuse unless `m_P` verifies under the key `P` commits, so a // producer that only checked for non-empty bytes would hand the trader an @@ -659,6 +679,7 @@ pub fn build_fulfillment( dsm::sofi::wire::next_position(precommit.position())?, precommit.signature_alg(), precommit.claimant_public_key(), + claimant_att_a, )?; // The mechanical half of F ingress, run before the operation exists: a // complete canonical policy set, one attempt per leg, the successor From 6155608f9cc98bd1bea0636a2ad6e873814752bf Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:17:36 -0400 Subject: [PATCH 12/31] docs(specs): DSM A10 and SoFi S20, the occupant proves its own authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **DSM Amendment A10.** It follows the explainer's root-claim paragraph. "Only Alice can sign one" is enforced from the bytes in hand: every occupant of a device's position cell carries its key and AttA, and is recognized only when its signature verifies and `derive_devid(key, AttA)` is the cell's DevID. - **SoFi Amendment S20.** It sits at §17.4. - K_root(q) holds the trader-signed C_q (class 0x0062). - F carries AttA, and is bound before P is fetched. - The exercise carries the trader's signed C_q. - A relayer completes the pair with the trader's C_q and never authors one. - It names every section it amends. - **MASTER.** §1 re-pinned. MR-DSM-0277 and MR-SOFI-0360 to MR-SOFI-0362 added from the amendments. §7.1 entry added. - **CONFORMANCE §6.65 (item 1).** MR-DSM-0277, MR-SOFI-0360 and MR-SOFI-0361 are Met with mutation controls. MR-SOFI-0362 is Missing: the exercise's C_q lands on #1096. §7 totals regenerated. - **VERIFICATION_MATRIX.** Rows for the three gates and their mutations. - **Code.** - `RootClaimSettlement::Lost` boxes its holder: the claim grew by its AttA. - The node test's re-aimed F carries the honest F's AttA. Board: dsm lib 1381/0 (four runs), dsm integration all pass, SDK integration all pass, make lint 0. SDK lib: 1045/4 under load; the four (funding writes Preserved, not final, while the shared Postgres was busy) pass 4/0 when rerun alone. --- .../dsm_sdk/src/handlers/node_e2e_tests.rs | 1 + .../dsm_sdk/src/sdk/economic_registers.rs | 10 ++-- specs/DSM_High_Level_Explainer.md | 6 +++ specs/SoFi_Settlement_Specification.md | 11 +++++ specs/requirements/CONFORMANCE_GAPS.md | 49 +++++++++++++++++-- specs/requirements/MASTER_REQUIREMENTS.md | 13 +++-- specs/requirements/VERIFICATION_MATRIX.md | 3 ++ 7 files changed, 82 insertions(+), 11 deletions(-) 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 4ef0e8ca6..f48b337ca 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 @@ -780,6 +780,7 @@ fn reaimed( f.position(), f.signature_alg(), f.claimant_public_key(), + *f.claimant_att_a(), ) .expect("a well-formed F"); let fulfillment_signature = sign(derive::fulfillment_signing_digest(&fulfillment)); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs index 1b1d61a5e..407cfe2eb 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs @@ -83,7 +83,9 @@ pub enum RootClaimSettlement { Final, /// Another claim holds the cell's leader link. No other value will ever /// be final there (storage spec §9 finality 2), so this claim never will. - Lost { holder: RegisteredEconomicClaim }, + Lost { + holder: Box, + }, /// The cell is not decided for this claim yet: open, held by this claim /// but not final, or the evidence in hand does not decide it. A network /// status, retried with the same bytes. @@ -105,9 +107,9 @@ pub async fn root_claim_settlement( state: ChainState::Final, .. }) if id == ours => RootClaimSettlement::Final, - Ok(CellReading::Held { id, object, .. }) if id != ours => { - RootClaimSettlement::Lost { holder: object } - } + Ok(CellReading::Held { id, object, .. }) if id != ours => RootClaimSettlement::Lost { + holder: Box::new(object), + }, Ok(CellReading::Held { state, .. }) => { RootClaimSettlement::Pending(format!("the claim holds the cell, {state:?}")) } diff --git a/specs/DSM_High_Level_Explainer.md b/specs/DSM_High_Level_Explainer.md index fad1940a1..b24c714e5 100644 --- a/specs/DSM_High_Level_Explainer.md +++ b/specs/DSM_High_Level_Explainer.md @@ -555,6 +555,12 @@ accept. - The cell holds exactly the presented root. Accept. +> **Amendment A10 (owner, 2026-10-01) — an occupant proves its own authority for the cell.** Owner ruling on the 2026-10-01 security pre-audit. The audit found that a claim signed under any key, or a conditional SoFi claim with no signature, was recognized at another device's cell, so anyone could occupy a device's next position before it did. The ruling: "If an object can win/occupy a root position, the object itself proves authority for that position." +> +> "Only Alice can sign one" is enforced from the bytes in hand. Every object that can occupy one of a device's position cells carries its signing key and the device's attestation digest `AttA`, and is recognized only when its signature verifies over its canonical bytes under that key and `derive_devid(key, AttA)` is the `DevID` the cell belongs to (`DevID = H(DSM/devid ‖ AK ‖ AttA)`). That covers a root claim and a SoFi conditional claim `C_q` at `K_root(q)`, and a SoFi fulfillment at `K_ful(q)` (SoFi Amendment S20 gives their formats). Recognition fetches nothing. +> +> An object that fails either check is not an occupant: it is neither a rival nor a winner, however early it arrived. + > **Amendment A4 (owner, 2026-09-22) — order of checks at acceptance.** The receiver first evaluates everything it can decide from what it already holds: it decodes the presentation, verifies its signatures and the payer's signed root chain, and runs the precommitment, guard, linearity and policy checks as far as the evidence in hand allows. If any of these is Invalid, the transition is Invalid and the network is never touched. Only then does it read the register cell and fetch any other evidence it still needs. This supersedes the order drawn in Figure 2 (§12), which places the register read before the precommitment and guard checks. > > - **Why checks on what is in hand come first.** A transition that is invalid on what the receiver already holds never reaches the network part: no storage read is spent on it, and no one's cell is read on its behalf. diff --git a/specs/SoFi_Settlement_Specification.md b/specs/SoFi_Settlement_Specification.md index 6cdcaa206..f33a86bff 100644 --- a/specs/SoFi_Settlement_Specification.md +++ b/specs/SoFi_Settlement_Specification.md @@ -1110,6 +1110,17 @@ computed from F and P , no claim that disagrees with F can be F ’s claim. **Code** Cq is SofiResolutionClaim, CORE/sofi/wire/objects.rs:580, class 0x003A. +> **Amendment S20 (owner, 2026-10-01) — the position pair proves its own authority.** This applies DSM Amendment A10 to the position pair. The ruling, from the 2026-10-01 security pre-audit: "If an object can win/occupy a root position, the object itself proves authority for that position." Before it, anyone could write an unsigned `C_q`, or an `F` under its own key, at another trader's `K_root(q)` and `K_ful(q)`, and take or wedge that position. +> +> - **`C_q` is signed by the trader.** `K_root(q)` holds the trader-signed `C_q`, class `0x0062` (`SignedSofiResolutionClaim`), never a bare `CCB(C_q)`. It carries the six fields of `C_q`, then `signature_alg`, the claimant key, the trader device's `AttA`, and a signature over `H(DSM/sofi/resolution-claim-sign/v1 ‖ CCB(C_q) ‖ u16be(alg) ‖ u32be(|key|) ‖ key ‖ AttA)`. +> - It occupies the cell only when that signature verifies and `derive_devid(key, AttA)` is the trader's `DevID`. A bare `C_q` is refused by name. +> - `C_q` is still computed from `P` and `F`: every comparison is of that body (§19.6, §36), and the cell's claim is identified by the entry digest of the body. +> - **`F` carries the trader device's `AttA`**, after its key. `F` names `K_ful(q)` only when `derive_devid(F.key, F.AttA)` is the trader's `DevID`. That is decided from `F`'s bytes before `P` is fetched, so a foreign-key `F` neither holds the cell nor makes a reader wait on a `P` it names. With `F.key = P.key` (P conformance 8), it binds `P`'s key too. +> - **The exercise carries the trader's signed `C_q`.** Recognition requires that its body is the `C_q` of the exercise's `P` and `F`, under the same bound key, and that `derive_devid(F.key, F.AttA)` is `P`'s `DevID`. Anything that can hold a vault key therefore carries everything needed to register its own `F`. +> - **A relayer never authors `C_q`.** "Any caller MAY relay F" (above) stands, but the relayer carries the trader's signed `C_q` from the exercise, or from the cell, exactly as signed. It does not recompute it. +> +> This amends this section (`K_root(q)` holds the signed `C_q`, class `0x0062`), "What it accomplishes", the class registries of §14.2 and §15 (the occupant at `K_root(q)` is `0x0062`), §19.6, the crash table of §25, stages 7 and 8 of §31, §33, §36, and conditions 4 and 8 of §44: wherever a relayer or another caller "completes the cells", it does so with the trader's signed `C_q`. Bytes written in the previous format occupy nothing, and nothing written before this amendment carries over (a clean cut). + **Rule — registration** FulfillmentRegistered(F ) ⇐⇒ Final(Kful (q), F ) ∧ Final(Kroot (q), Cq ). It is a conclusion Core draws from raw diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index df8f85026..46246ef0b 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2054,16 +2054,55 @@ The quote took 20 s. - **The chain walk of a vault the trade consumed, about 19 s.** Two passes over the sibling leg re-read the same four or five cells about four times each. - **Establishing the facts, 2.7 s.** +### 6.65 A position cell's occupant proves its own authority (`security/core-root-cell-binding`, pre-audit item 1, 2026-10-01) + +**The finding (security pre-audit, 2026-10-01).** `economic::register::root_claim_naming` recognized any claim whose own coordinates derived the cell's key. +- A single-root claim only had to verify under the key it carried. A conditional `C_q` was recognized with no signature at all. +- An `F` at `K_ful(q)` only had to carry `P`'s key. +- So any party could write first at another device's next `K_root(q)` or `K_ful(q)` and occupy it for good. That froze the device's online funds, or registered an operation of the squatter's own at the victim's position. +- A probe outside the tree showed it: an unsigned `C_q` naming any device was recognized at that device's `K_root(q)`. +- The explainer already said "only Alice can sign one"; the code did not enforce it. + +**The ruling (owner, 2026-10-01; DSM Amendment A10, SoFi Amendment S20):** "If an object can win/occupy a root position, the object itself proves authority for that position." + +**What now holds.** +- **Recognition.** Every occupant carries its key and the device's `AttA`, and is recognized only when its signature verifies and `derive_devid(key, AttA)` is the cell's `DevID`. Nothing is fetched. +- **The root claim.** It is schema 2 with `claimant_att_a`. `decode_and_verify_economic_root_claim` refuses a key that does not derive `trader_devid` (`KeyIsNotTheNamedDevices`). +- **`C_q`.** It occupies `K_root(q)` only as `SignedSofiResolutionClaim` (`0x0062`), verified by `sofi::signature::verify_resolution_claim`. A bare `C_q` is refused (`UnsignedConditionalClaim`). A conditional claim at a root cell is identified by its derived body, so readers keep comparing `derive::resolution_claim(P, F)`. +- **`F`.** It carries `claimant_att_a`. `registration::fulfillment_proves_the_device` decides the binding from `F`'s bytes, and `names_fulfillment_key` applies it before it looks up `P`. +- **Producers.** + - The admission flow signs its root claim with this device's `AttA`. + - `sofi_register` signs the trader's `C_q`. + - `build_fulfillment` takes the `AttA` and refuses a `P` whose key does not derive its device. + - The relay carries the trader's signed `C_q` from the cell and authors none. + +**Formal model.** No model changes. The Lean recognition boundary (`lean4/DSMRecognition.lean`, `Authorized`) already required the signature under the owner key the state names. The code now checks that, with the owner key fixed by the DevID derivation. + +**Open, on #1096 (SOFI, MR-SOFI-0362).** S20 also makes the exercise carry the trader's signed `C_q`. Without it, a trader whose exercise is final at a vault key but who never publishes its `C_q` leaves that `F` unregistrable. No relayer can author the claim, so the vault waits at that root. `recognize_exercise` will bind the exercise's key to `P`'s device, and `read_registration` will dismiss a foreign-key `F` before fetching its `P`. + +| Row | Was | Now | Why | +|---|---|---|---| +| MR-DSM-0277 | — | Met | Added by Amendment A10. Root claims and `C_q` are recognized only when signed under a key that derives the cell's device. | +| MR-SOFI-0360 | — | Met | Added by Amendment S20. `K_root(q)` holds the trader-signed `C_q`, and a bare one occupies nothing. | +| MR-SOFI-0361 | — | Met | Added by Amendment S20. `F` binds its key to the cell's device from its own bytes, before `P` is fetched. | +| MR-SOFI-0362 | — | Missing | Added by Amendment S20. The exercise does not carry the signed `C_q` yet. | + +Mutation controls, each run and restored with `git checkout`: +- The binding check removed from root-claim recognition: `a_root_claim_under_a_key_that_does_not_derive_the_named_device_names_no_cell` red. +- A bare `C_q` decoded as an occupant: `an_unsigned_conditional_claim_names_no_cell` red. +- The binding check removed from `verify_resolution_claim`: `a_conditional_claim_signed_by_another_device_names_no_cell` red. +- `fulfillment_proves_the_device` removed from `names_fulfillment_key`: `a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell` red. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| -| DSM high-level (MR-DSM) | 276 | 95 | 95 | 39 | 0 | 29 | 18 | -| SoFi (MR-SOFI) | 359 | 234 | 86 | 18 | 4 | 17 | 0 | +| DSM high-level (MR-DSM) | 277 | 96 | 95 | 39 | 0 | 29 | 18 | +| SoFi (MR-SOFI) | 362 | 236 | 86 | 19 | 4 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 65 | 18 | 56 | 0 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **939** | **403** | **200** | **114** | **4** | **64** | **154** | +| **All** | **943** | **406** | **200** | **115** | **4** | **64** | **154** | ## 8 Per-requirement results @@ -2347,6 +2386,7 @@ The quote took 20 s. | MR-DSM-0274 | Met | `dsm::economic::peer_lineage::PeerFrontier`; `dsm_sdk::storage::client_db::economic_lineage::record_frontier_in_tx`; `dsm_sdk::storage::client_db::economic_lineage::frontier_below` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier`; `dsm::economic::peer_lineage::tests::a_frontier_at_the_target_is_this_verifiers_fault_not_the_peers` | Re-verified 2026-09-30 (§6.52). A frontier is the payer's activation root or the coordinate a verification reached (`PeerFrontier::reached_by`), recorded with the claim accepted there in the transaction that accepts a step from the payer, and read back only from that store. Its fields are private. | | MR-DSM-0275 | Met | `dsm::economic::peer_lineage::validate_peer_lineage`; `dsm_sdk::sdk::economic_registers::StoredFrontiers` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier`; `dsm_sdk::handlers::frontier_verification_tests::a_credits_source_is_validated_one_hop_back_and_no_further`; `dsm_sdk::handlers::frontier_verification_tests::a_root_no_transition_explains_is_refused_where_it_sits` | Re-verified 2026-09-30 (§6.52). A verification starts at the receiver's frontier and reads no cell at or behind it; a one-hop source's earlier positions are read as claims only, and none of the evidence behind them is read; nothing past a refused position is read. The replay from the activation root, the memo re-walk and the recursion into a source's lineage are gone. | | MR-DSM-0276 | Met | `dsm::economic::peer_lineage::ConditionalPositionResolver`; `dsm_sdk::sdk::sofi_reads::VerifierContext::peer_position_resolver` | `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay`; `dsm::economic::peer_lineage::tests::a_conditional_position_right_after_the_activation_root_is_invalid` | Re-verified 2026-09-30 (§6.52). A conditional position inside a chain is resolved from SoFi's public objects for that position (S15), and the chain continues from the root it selected; a trader who has traded can pay (P15-9). A conditional position right after the activation root is Invalid: no claim was accepted there for it to name. | +| MR-DSM-0277 | Met | `dsm::economic::claim_envelope::decode_and_verify_economic_root_claim`; `dsm::economic::claim_envelope::decode_registered_economic_claim`; `dsm::economic::register::root_claim_naming`; `dsm_sdk::sdk::economic_admission_flow` (root claim producer) | `dsm::economic::claim_envelope::tests::a_root_claim_under_a_key_that_does_not_derive_the_named_device_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell`; `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::register::registered_root_construction_tests::a_squatters_claim_written_first_does_not_hold_the_cell` | DSM Amendment A10 (§6.65). Every occupant of a device's position cell carries its key and `AttA` and is recognized only when its signature verifies and `derive_devid(key, AttA)` is the cell's `DevID`, from the bytes in hand. Mutations: each binding check removed turns its named test red. | ### 8.2 SoFi settlement specification @@ -2711,6 +2751,9 @@ The quote took 20 s. | MR-SOFI-0357 | Met | `dsm::sofi::validation::route_endpoints`; `dsm::sofi::validation::swap_endpoints`; `dsm::sofi::validation::validate_swap`; `dsm::sofi::validation::route_shape`; `dsm::sofi::validation::split_sums`; `dsm::sofi::validation::chain_holds`; `dsm_sdk::sdk::sofi_sdk::draft_route`; `dsm::sofi::validation::movement_shape` | `dsm::sofi::validation::tests::a_route_chains_or_splits_and_its_endpoints_follow`; `dsm::sofi::validation::tests::a_split_whose_legs_sum_to_the_intent_is_not_refuted_by_its_shape`; `dsm::sofi::validation::tests::a_split_that_does_not_sum_to_the_intent_is_invalid`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). RouteValidation accepts hops that chain or hops that all trade the intent's pair with inputs and outputs summing to the intent in checked arithmetic (`Invalid::SplitDoesNotSum` otherwise); each hop is priced by its own vault. The producer states the intent by the same rule. `movement_shape` is the shape `route_endpoints` states a route's ends by (§6.56). | | MR-SOFI-0358 | Met | `dsm::sofi::validation::trader_movements`; `dsm::sofi::validation::validate_swap` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). On the nodes a split through two vaults of ERA/TKN realizes as one position: B is debited the whole input and credited the sum of both legs, and each vault moved by its own leg alone. | | MR-SOFI-0359 | Met | `dsm_sdk::sdk::sofi_flow::find_route`; `dsm_sdk::sdk::sofi_flow::plan`; `dsm_sdk::sdk::sofi_flow::plan_split`; `dsm_sdk::sdk::sofi_flow::trade`; `dsm_sdk::sdk::sofi_flow::route_ends` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). The search weighs each pair of direct vaults as a split beside every single hop and chain and proposes the most; the split share is searched over the inputs both legs price. The trade plans the same split again at the heads it walks; min-out bounds what it accepts. The quote reports the route's total by that rule (§6.56). | +| MR-SOFI-0360 | Met | `dsm::sofi::wire::objects::SignedSofiResolutionClaim`; `dsm::sofi::signature::verify_resolution_claim`; `dsm::sofi::signature::sign_resolution_claim`; `dsm::economic::register::read_root_cell` (body identity); `dsm_sdk::sdk::sofi_register` (producer); `dsm_sdk::sdk::sofi_relay::carry_pair` | `dsm::economic::claim_envelope::tests::a_conditional_cell_decodes_by_class`; `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell`; `dsm::economic::register::registered_root_construction_tests::a_held_root_cell_carries_the_exact_bytes_that_hold_it` | SoFi Amendment S20 (§6.65). `K_root(q)` holds the trader-signed `C_q` (`0x0062`); a bare `C_q` is refused by name, and a conditional claim is identified by its derived body. | +| MR-SOFI-0361 | Met | `dsm::sofi::wire::objects::TraderFulfillmentBody`; `dsm::sofi::registration::fulfillment_proves_the_device`; `dsm::sofi::registration::names_fulfillment_key`; `dsm_sdk::sdk::sofi_sdk::build_fulfillment` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell` | SoFi Amendment S20 (§6.65). The binding is decided from `F`'s bytes before `P` is looked up. Mutation: the check removed → that test red. | +| MR-SOFI-0362 | Missing | — | — | SoFi Amendment S20 (§6.65). The exercise does not carry the trader's signed `C_q` yet; SOFI builds it on #1096 (recognition, producer, and the relay taking it from the exercise). | ### 8.3 dBTC native specification diff --git a/specs/requirements/MASTER_REQUIREMENTS.md b/specs/requirements/MASTER_REQUIREMENTS.md index 119667579..d7469c241 100644 --- a/specs/requirements/MASTER_REQUIREMENTS.md +++ b/specs/requirements/MASTER_REQUIREMENTS.md @@ -14,12 +14,12 @@ Every extraction in this round is taken against exactly these bytes: | File | `git hash-object` | Lines | |---|---|---| -| `specs/DSM_High_Level_Explainer.md` | `fad1940a13b806d53a0925e1bada409e5acc298c` | 4317 | -| `specs/SoFi_Settlement_Specification.md` | `6cdcaa206f57ed396b2d9e1cff084f59c11c4437` | 2678 | +| `specs/DSM_High_Level_Explainer.md` | `b24c714e5699dbfc5d124da0dec2ff9e8f480c28` | 4323 | +| `specs/SoFi_Settlement_Specification.md` | `f33a86bffc6cc520fc6f6e7c681251a83dc67ab0` | 2689 | | `specs/dBTC_Native_Specification.md` | `233a3e72a5b16a023af830f4c8ffaad4ba9391a8` | 2160 | | `specs/DSM_Storage_Node_Specification.md` | `415a9b9c67c7a2b4b6df78b0af85fb3bc7282ae8` | 636 | -Pins updated 2026-10-01 after SoFi Amendments S16 (vaults found by their tokens; the setup inside the first operation through a vault; another trader's conditional parent resolved by a walk) and S19 (a route chains or splits) and S18 (ERA has two decimals); before that, 2026-09-30 after DSM Amendments A8 (frontier-relative verification) and A9 (the relationship-key tag is `DSM/smt-key`) and SoFi Amendments S14 (a final cell whose fulfillment can never register is skipped) and S15 (a trader's position resolved from public objects); before that, 2026-09-29 after SoFi Amendments S12 (the trader's balances before the trade) and S13 (a lineage known invalid); before that, 2026-09-26 after SoFi Amendment S11 (ERA's canonical policy); before that, 2026-09-24 after SoFi Amendments S8, S9 and S10 and the storage §9 rule on the leader first, one chain in route order and the completion proof (#977); before that, 2026-09-23 after storage §14 (#974), the set-identity amendment (SoFi Amendment S6, storage §10), the replication amendment (storage §12.5), the vault-consistency recommendation (SoFi §31), Amendment S7 (SoFi §24) and Amendment A7 (DSM §11, storage §8). The extractions of 2026-09-22 were taken against SoFi `4c62ee78…` (2597 lines) and storage `8d43ac02…` (571 lines); their line-based IDs refer to those bytes. +Pins updated 2026-10-01 after DSM Amendment A10 and SoFi Amendment S20 (an occupant of a position cell proves its own authority); before that, 2026-10-01 after SoFi Amendments S16 (vaults found by their tokens; the setup inside the first operation through a vault; another trader's conditional parent resolved by a walk) and S19 (a route chains or splits) and S18 (ERA has two decimals); before that, 2026-09-30 after DSM Amendments A8 (frontier-relative verification) and A9 (the relationship-key tag is `DSM/smt-key`) and SoFi Amendments S14 (a final cell whose fulfillment can never register is skipped) and S15 (a trader's position resolved from public objects); before that, 2026-09-29 after SoFi Amendments S12 (the trader's balances before the trade) and S13 (a lineage known invalid); before that, 2026-09-26 after SoFi Amendment S11 (ERA's canonical policy); before that, 2026-09-24 after SoFi Amendments S8, S9 and S10 and the storage §9 rule on the leader first, one chain in route order and the completion proof (#977); before that, 2026-09-23 after storage §14 (#974), the set-identity amendment (SoFi Amendment S6, storage §10), the replication amendment (storage §12.5), the vault-consistency recommendation (SoFi §31), Amendment S7 (SoFi §24) and Amendment A7 (DSM §11, storage §8). The extractions of 2026-09-22 were taken against SoFi `4c62ee78…` (2597 lines) and storage `8d43ac02…` (571 lines); their line-based IDs refer to those bytes. The DSM and SoFi specifications were amended on 2026-09-22 (marked "Amendment" in their text). The storage-node specification was added to the corpus on 2026-09-22, before any other extractor started. The owner accepted it in full the same day. Extract its items marked **Open** with Flags `ambiguous` and a Requirement text that says so, never as settled requirements. @@ -155,6 +155,7 @@ Each extraction also has a Findings table: - **The trader's balances before the trade (2026-09-29).** Conformance finding (CONFORMANCE §6.39): once one trader had traded through a vault, no other reader could classify the trade, because `TraderSideValid` needs the trader's balances before the trade and `T°` states them only as hashes; the owner's close of that vault never resolved. Owner decisions: the exercise carries each such balance as a content-addressed `TraderPreBalance` that `𝒞_E^pre` names, every verifier (the trader included) reads the balances from those objects, and a balance the closure does not name is Invalid, not undecided (SoFi Amendment S12). MR-SOFI-0241 is a different defect and is unchanged. MR-SOFI-0338–0341 added with source `amendment`; §1 re-pinned. - **A lineage known invalid (2026-09-29).** Conformance finding (CONFORMANCE §6.40): under Amendment S9 a trader whose lineage validation finds Invalid never yields an accepted claim, so its setup was never evaluated and its trade held a vault key forever. Owner decision: not yet known waits; known invalid makes the setup, and so the route, Invalid; no accepted claim is synthesized (SoFi Amendment S13). MR-SOFI-0331 rewritten; MR-SOFI-0342 added with source `amendment`; §1 re-pinned. - **A fulfillment that can never register (2026-09-30).** Conformance finding (CONFORMANCE §8, MR-SOFI-0239): `route_impossible` carried a fifth arm, `PositionLost`, which §23.5 does not list and which depends on a particular F, not on P and E. Without it, an exercise whose fulfillment can never register, because another claim holds its position, strands the DLV parent's attempt chain (TLA `DSM_SofiFulfillment`, `LostPosition`). Owner decision: a separate skip, `RejectedFinalInadmissible`, which is not an arm of `RouteImpossible`; `RouteImpossible` stays scoped to P and E (SoFi Amendment S14). MR-SOFI-0343 added with source `amendment`; §1 re-pinned. +- **An occupant proves its own authority (2026-10-01).** Security pre-audit finding: a position cell recognized any claim whose coordinates derived its key, so a claim signed under any key, or a conditional claim with no signature at all, occupied another device's next position; an `F` under any key held `K_ful(q)` likewise. Owner ruling: "If an object can win/occupy a root position, the object itself proves authority for that position" (DSM Amendment A10, SoFi Amendment S20). MR-DSM-0277 and MR-SOFI-0360, MR-SOFI-0361 and MR-SOFI-0362 added with source `amendment`; §1 re-pinned. - **ERA has two decimals (2026-10-01).** Rig finding: ERA was whole-unit (decimals 0, SoFi Amendment S11), so a 0.30% fee on a 50 ERA trade could not be written as an ERA amount, and amounts were entered as raw base units. Owner ruling: ERA's decimals are 2, with the supply, faucet payout and creation fee unchanged in whole ERA; ERA's commitment changes (a clean cut); amounts are entered and shown in whole tokens with the token's decimals (SoFi Amendment S18). MR-SOFI-0335 rewritten; MR-SOFI-0349 added with source `amendment`; §1 re-pinned. - **Frontier-relative verification (2026-09-30).** Conformance finding (P15-9, and G16): a payee accepted a payer only by walking the payer's whole lineage from its activation root, re-validating every transition from evidence it fetched from the nodes, and a SoFi position in that history stopped the walk, so a trader who had traded could not pay. Owner decision: a receiver verifies forward from its own authenticated frontier: for each position of the segment it authenticates the authoritative root claim and verifies the transition producing that root with that transition's directly required evidence and provenance one hop back, never recursing behind that hop and never reading behind the frontier (DSM Amendment A8, corrected the same day: an authenticated root is not a valid transition, and a counterparty's acceptance does not stand for one); a SoFi position inside that segment is resolved from SoFi's public objects for that position alone, and adoption is not re-run by a later verifier (SoFi Amendment S15). MR-DSM-0273–0276 and MR-SOFI-0344–0348 added with source `amendment`; §1 re-pinned. - **The relationship-key tag (2026-09-30).** Conformance finding (CONFORMANCE §6.14, MR-DSM-0115, MR-DSM-0249): the explainer derived a relationship's SMT key under `DSM/smt-key/v1`, while the code and its golden vector use `DSM/smt-key`. Owner ruling: `DSM/smt-key` is the canonical relationship-SMT domain tag for beta, and `/v1` in the specification was an error (DSM Amendment A9). The formula in §26 and the example in §16 are corrected; no key migrates. MR-DSM-0115 rewritten; §1 re-pinned. @@ -173,7 +174,7 @@ Reconciled on 2026-09-22 from two extractions: `claude-chat` (798 rows) and `cha | DSM_Storage_Node_Specification.md | 128 | 122 | 6 | | **Total** | **859** | **652** | **207** | -Added afterwards by amendment (§7.1): DSM 7, SoFi 32, storage 30, for 928 canonical requirements in all. +Added afterwards by amendment (§7.1): DSM 8, SoFi 35, storage 30, for 932 canonical requirements in all. Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sources** are the extraction IDs merged into the row (`cc:` claude-chat, `gpt:` chatgpt); the first source locates the quote. **Flags** carry the findings in §8.7 that bear on the row. @@ -457,6 +458,7 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-DSM-0274 | invariant | explicit | A receiver's frontier for a payer is a coordinate it authenticated itself (a step it accepted from the payer, or one it authenticated on the way to such a step), or, where it has none, the payer's activation root authenticated by the payer's device identity. A root the payer merely claims is never a frontier. | amendment: DSM Amendment A8 (2026-09-30) | owner | none | | MR-DSM-0275 | prohibition | explicit | Frontier-relative verification never recurses behind a step's one-hop evidence, never replays the payer's history to genesis, and reads nothing behind the receiver's frontier and no position outside the segment. | amendment: DSM Amendment A8 (2026-09-30) | owner | none | | MR-DSM-0276 | obligation | explicit | A conditional position (C_q) inside the frontier-to-parent segment that the receiver has not resolved before is resolved from SoFi's public objects for that position (SoFi Amendment S15), and the root chain continues from the root it selected. | amendment: DSM Amendment A8 (2026-09-30) | owner | none | +| MR-DSM-0277 | invariant | explicit | An object occupies one of a device's position cells only if it proves its own authority for that cell from its bytes: its signature verifies over its canonical bytes under the key it carries, and that key with the device's AttA it carries derives the DevID the cell belongs to. Anything else is neither a rival nor a winner, however early it arrived. | amendment: DSM Amendment A10 (2026-10-01) | owner | none | ### 8.2 SoFi settlement specification @@ -821,6 +823,9 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0357 | obligation | explicit | A Swap route's hops either chain (§19.5) or split: every hop trades the intent's one pair through distinct vaults, the hops' inputs summing to `amount_in` and their outputs to `exact_out` in checked arithmetic, each hop priced by its own vault at its own head. | amendment: SoFi Amendment S19 (2026-10-01) | owner | none | | MR-SOFI-0358 | invariant | explicit | A split route is one operation: every hop lands at its own vault's cell, the route realizes only when every hop does, the trader's core moves only the intent's two endpoints with one relationship per vault, and `ROUTE_MAX_LEGS` bounds both shapes. | amendment: SoFi Amendment S19 (2026-10-01) | owner | none | | MR-SOFI-0359 | obligation | explicit | `sofi.findRoute` weighs a split across two vaults of the pair beside every single hop and chain and proposes the route that gives the trader the most; the trade prices the same split again at the heads it walks, and neither carries authority. | amendment: SoFi Amendment S19 (2026-10-01) | owner | none | +| MR-SOFI-0360 | invariant | explicit | `K_root(q)` holds the trader-signed C_q (class 0x0062): the derived C_q, the signing key, the trader device's AttA and a signature under `DSM/sofi/resolution-claim-sign/v1`; it occupies the cell only when the signature verifies and the key with that AttA derives the trader's DevID, and a bare C_q occupies nothing. Every comparison of C_q is of its derived body. | amendment: SoFi Amendment S20 (2026-10-01) | owner | none | +| MR-SOFI-0361 | invariant | explicit | F carries the trader device's AttA and names `K_ful(q)` only when its key with that AttA derives the trader's DevID, decided from F's bytes before P is fetched. | amendment: SoFi Amendment S20 (2026-10-01) | owner | none | +| MR-SOFI-0362 | obligation | explicit | The exercise carries the trader's signed C_q, whose body is the C_q of the exercise's P and F under the same bound key; a relayer completes the position pair with the trader's signed C_q from the exercise or the cell and never authors C_q. | amendment: SoFi Amendment S20 (2026-10-01) | owner | none | ### 8.3 dBTC native specification diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index 27aa053a9..da75b3a99 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -144,6 +144,8 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-SOFI-0241, SoFi §23.5: a final cell dead on facts that need no validation evidence is skipped while that evidence is outstanding | `dsm` · sofi/resolve.rs · `Verifier::facts_of` (the ground facts and `skip_without_evidence` before any evidence is acquired); sofi/resolution.rs · `skip_without_evidence`; sofi/facts.rs · `establish_ground` | `dsm_sdk::handlers::node_e2e_tests::a_key_whose_fulfillment_can_never_register_is_skipped_without_its_evidence` (the nodes' request logs: no evidence object is asked for, and the next key is live); `dsm::sofi::resolution::tests::a_dead_cell_skips_on_facts_that_need_no_evidence`; `dsm::sofi::resolution::tests::an_evidence_free_skip_answers_as_the_complete_facts_do` | Evidence acquired before the early skip → the node test red, naming the object read; the consumed-elsewhere arm removed → `a_dead_cell_skips_on_facts_that_need_no_evidence` red; the final-on-E check removed → that test and `an_evidence_free_skip_answers_as_the_complete_facts_do` red (2026-09-30, restored). | — | | MR-SOFI-0344, MR-SOFI-0347, SoFi Amendment S15 (P15-9): a verifier that is not the trader resolves the trader's SoFi position from public objects, and a trader who traded can pay | `dsm` · sofi/resolve.rs · `Verifier::peer_position`, `PeerPositionResolver`; sofi/lineage.rs · `advance_peer_resolved`; `dsm_sdk` · sdk/sofi_reads.rs · `accepted_claim_at` through `resolve_peer` | `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | The resolver refusing every C_q → red; the resolver selecting the void root → red; in both the payee never credits (2026-09-30, restored). | — | | MR-SOFI-0348, SoFi Amendment S15: adoption is the trader's own construction predicate, not re-run by another verifier | `dsm` · sofi/lineage.rs · `advance_peer_resolved` (no `adoption_admits`), `advance_resolved` (with it) | `dsm::sofi::lineage::tests::another_verifier_resolves_the_position_without_the_traders_adoptions` | The adoption check run on the peer path → that test red (2026-09-30, restored). | — | +| MR-SOFI-0360: `K_root(q)` holds the trader-signed `C_q`; a bare or foreign-key `C_q` occupies nothing (SoFi Amendment S20) | `dsm` · economic/claim_envelope.rs · `decode_registered_economic_claim`; sofi/signature.rs · `verify_resolution_claim` | `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell` | A bare `C_q` decoded as an occupant → `an_unsigned_conditional_claim_names_no_cell` red; the binding check removed from `verify_resolution_claim` → `a_conditional_claim_signed_by_another_device_names_no_cell` red (2026-10-01). | — | +| MR-SOFI-0361: `F` names `K_ful(q)` only under a key that derives the trader's device, decided before `P` is fetched (SoFi Amendment S20) | `dsm` · sofi/registration.rs · `fulfillment_proves_the_device`, `names_fulfillment_key` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell` (a squatter's own `P` and `F` naming the victim, each verifying under the squatter's key) | `fulfillment_proves_the_device` removed from `names_fulfillment_key` → that test red (2026-10-01). | — | | MR-SOFI-0350, MR-SOFI-0351, SoFi Amendment S16: a vault is found under each of its tokens, and discovery carries no authority | `dsm` · sofi/publication.rs · `Publication::locators`; sofi/derive.rs · `vault_token_locator`; sofi/resolve.rs · `Verifier::vaults_of_token` | `dsm::sofi::publication::tests::vault_objects_land_where_the_verifier_looks`; `dsm_sdk::handlers::node_e2e_tests::discovery_passes_over_what_is_not_a_vault_of_the_token` | The market check removed from `vaults_of_token` → A's TKN/TKB vault, appended under ERA's locator, is counted as a vault of ERA → red (2026-10-01, restored). | — | | MR-SOFI-0354, SoFi Amendment S16: the first trade, route or close through a vault admits its setup first, and later ones reuse it | `dsm_sdk` · sdk/sofi_flow.rs · `set_up_with`, `check_route`, `trade`, `close` | `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::a_realized_trade_shows_in_balances_and_history_at_once` | `trade` without `set_up_with` → the route through two vaults B never set up with is refused ("no published setup with this vault is one this device admitted") → red (2026-10-01, restored). | — | | MR-SOFI-0356, SoFi Amendment S16: a walk resolves another trader's conditional parent through frontier-relative verification, as only the position and fulfillment P names | `dsm` · sofi/resolve.rs · `Verifier::parent_for`, `parent_named`, `Verifier::chain_until`; economic/peer_lineage.rs · `peer_root_at`; `dsm_sdk` · sdk/sofi_reads.rs · `trader_root_at` | `dsm::sofi::resolve::tests::only_the_position_and_fulfillment_p_names_resolve_its_parent`; `dsm::economic::peer_lineage::tests::a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read`; `dsm::economic::peer_lineage::tests::the_activation_root_names_no_parent`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | `parent_named` accepting any fulfillment, or skipping the position check → `only_the_position_and_fulfillment_p_names_resolve_its_parent` red; `peer_root_at` standing below `position` instead of `position + 1` → `a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read` red; peer resolution walking each vault's chain to its head instead of `chain_until` the leg's parent → `every_sofi_route_reaches_its_producer` overflows its stack (2026-10-01, all restored). | — | @@ -172,6 +174,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-DSM-0116, MR-DSM-0117, MR-DSM-0121: the device root is the tree of exactly the leaves the device holds, however the tree was reached (a write at a time over its kept nodes, or one build over a restored head), and every path it gives is that tree's path | `dsm` · merkle/sparse_merkle_tree.rs · `SparseMerkleTree::update_leaf`, `SparseMerkleTree::from_leaves`, `SparseMerkleTree::get_inclusion_proof`; types/device_state.rs · `DeviceState::restore` | `dsm::merkle::sparse_merkle_tree::tests::a_written_root_is_the_recomputed_root`; `dsm::merkle::sparse_merkle_tree::tests::a_kept_path_is_the_recomputed_path`; `dsm::merkle::sparse_merkle_tree::tests::a_large_tree_keeps_the_recomputed_root`; `dsm::types::device_state::tests::a_restored_head_recomputes_the_live_root` (a tip or an extra leaf left out moves the root) | A rewrite leaving the branch's old child hash → `a_written_root_is_the_recomputed_root` red; a split putting the new leaf always left → the same test red; a path dropping the sibling where an absent key leaves the tree → `a_kept_path_is_the_recomputed_path` red; the one-pass build swapping a branch's children → `a_written_root_is_the_recomputed_root` and `a_large_tree_keeps_the_recomputed_root` red; `restore` building without the extra leaves → `a_restored_head_recomputes_the_live_root` red (2026-09-29, each restored). | — | | MR-DSM-0029: the receiver checks the token's committed policy, and with it the token's adoption, which it holds, before it reads the payer's register | `dsm_sdk` · handlers/storage_routes.rs · `complete_staged_pairs` (the policy check before prevalidation) | `dsm_sdk::handlers::sender_admission_tests::a_transfer_its_policy_refuses_is_refused_before_the_senders_register_is_read` (a hostile sender's signed transfer of its non-transferable token, with its advance and per-step EK receipt, binds; no cell is read); `dsm_sdk::handlers::recipient_admission_tests::a_receiver_that_never_added_the_token_is_not_credited_until_it_adopts` (a transfer of a token B never adopted is held; no member is asked for a cell) | The check removed → both red: the receiver asks a member for a register cell of the sender before it refuses (2026-09-30; the adoption test 2026-10-01). | — | | MR-DSM-0055: no protocol-relevant node path reads a clock (the same requirement as MR-STOR-0008) | `dsm_storage_node` · every file under src/ (parsed: paths, `use` trees, macro bodies) | `dsm_storage_node::no_clock_reads::no_node_source_reads_a_clock`; `dsm_storage_node::no_clock_reads::a_clock_read_is_found_and_a_comment_or_string_is_not` | `std::time::Instant::now()` added to `db/pg.rs` → `no_node_source_reads_a_clock` red, naming `db/pg.rs: Instant` (2026-10-01, CORE's run; STORAGE's two controls are in MR-STOR-0008's row). | — | +| MR-DSM-0277: an object occupies a device's position cell only if it proves its own authority (DSM Amendment A10) | `dsm` · economic/claim_envelope.rs · `decode_and_verify_economic_root_claim`, `decode_registered_economic_claim`; economic/register.rs · `root_claim_naming` | `dsm::economic::claim_envelope::tests::a_root_claim_under_a_key_that_does_not_derive_the_named_device_names_no_cell` (a squatter's root claim naming the victim, valid signature, its own AttA or the victim's); `dsm::economic::register::registered_root_construction_tests::a_squatters_claim_written_first_does_not_hold_the_cell` (squatter claims first at every seat leave the cell open until the trader's own claim) | The binding check removed from root-claim recognition → `a_root_claim_under_a_key_that_does_not_derive_the_named_device_names_no_cell` red (2026-10-01). | — | | MR-SOFI-0311: the receiving device refuses an online transfer its token's policy forbids, whatever its sender checked | `dsm_sdk` · sdk/core_sdk.rs · `execute_on_relationship_inner` → `enforce_policy_for_operation`, under the state-machine lock | `dsm_sdk::handlers::sender_admission_tests::a_non_transferable_token_refuses_its_transfer` (the receiver's canonical apply of a transfer the sender signed) | The check skipped for transfers addressed to this device → red: "reached acceptance: the policy did not refuse" (2026-09-30). | — | | MR-DSM-0273: a segment step whose transition does not produce its root is refused where it sits, however authentic its claim | `dsm` · economic/peer_lineage.rs · `Verifier::segment` → `full_step` | `dsm_sdk::handlers::frontier_verification_tests::a_root_no_transition_explains_is_refused_where_it_sits` (a final claim at position 2, signed by the device's own AK, naming its first step's manifest and an invented root) | Segment steps checked as claims only → red: the invented root is accepted and position 3 is read (2026-09-30). | — | | MR-DSM-0274, MR-DSM-0275: a receiver reads nothing of a peer at or behind its frontier | `dsm` · economic/peer_lineage.rs · `Verifier::frontier_below`, `start`; `dsm_sdk` · sdk/economic_registers.rs · `StoredFrontiers` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier` | Recorded frontiers discarded → red: B reads A's cell behind its frontier (2026-09-30). | — | From f5cd5d9d00852cfdfb6203e511ae7e71c29c507b Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:17:57 -0400 Subject: [PATCH 13/31] docs(conformance): the item-1 matrix row names its test once The negative-test column of MR-SOFI-0361 put P and F in backticks inside its prose, which the evidence check read as test names. Plain words now; conformance evidence passes. --- specs/requirements/VERIFICATION_MATRIX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index da75b3a99..012ceccbf 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -145,7 +145,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-SOFI-0344, MR-SOFI-0347, SoFi Amendment S15 (P15-9): a verifier that is not the trader resolves the trader's SoFi position from public objects, and a trader who traded can pay | `dsm` · sofi/resolve.rs · `Verifier::peer_position`, `PeerPositionResolver`; sofi/lineage.rs · `advance_peer_resolved`; `dsm_sdk` · sdk/sofi_reads.rs · `accepted_claim_at` through `resolve_peer` | `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | The resolver refusing every C_q → red; the resolver selecting the void root → red; in both the payee never credits (2026-09-30, restored). | — | | MR-SOFI-0348, SoFi Amendment S15: adoption is the trader's own construction predicate, not re-run by another verifier | `dsm` · sofi/lineage.rs · `advance_peer_resolved` (no `adoption_admits`), `advance_resolved` (with it) | `dsm::sofi::lineage::tests::another_verifier_resolves_the_position_without_the_traders_adoptions` | The adoption check run on the peer path → that test red (2026-09-30, restored). | — | | MR-SOFI-0360: `K_root(q)` holds the trader-signed `C_q`; a bare or foreign-key `C_q` occupies nothing (SoFi Amendment S20) | `dsm` · economic/claim_envelope.rs · `decode_registered_economic_claim`; sofi/signature.rs · `verify_resolution_claim` | `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell` | A bare `C_q` decoded as an occupant → `an_unsigned_conditional_claim_names_no_cell` red; the binding check removed from `verify_resolution_claim` → `a_conditional_claim_signed_by_another_device_names_no_cell` red (2026-10-01). | — | -| MR-SOFI-0361: `F` names `K_ful(q)` only under a key that derives the trader's device, decided before `P` is fetched (SoFi Amendment S20) | `dsm` · sofi/registration.rs · `fulfillment_proves_the_device`, `names_fulfillment_key` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell` (a squatter's own `P` and `F` naming the victim, each verifying under the squatter's key) | `fulfillment_proves_the_device` removed from `names_fulfillment_key` → that test red (2026-10-01). | — | +| MR-SOFI-0361: `F` names `K_ful(q)` only under a key that derives the trader's device, decided before `P` is fetched (SoFi Amendment S20) | `dsm` · sofi/registration.rs · `fulfillment_proves_the_device`, `names_fulfillment_key` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell` (a squatter's own P and F naming the victim, each verifying under the squatter's key) | `fulfillment_proves_the_device` removed from `names_fulfillment_key` → that test red (2026-10-01). | — | | MR-SOFI-0350, MR-SOFI-0351, SoFi Amendment S16: a vault is found under each of its tokens, and discovery carries no authority | `dsm` · sofi/publication.rs · `Publication::locators`; sofi/derive.rs · `vault_token_locator`; sofi/resolve.rs · `Verifier::vaults_of_token` | `dsm::sofi::publication::tests::vault_objects_land_where_the_verifier_looks`; `dsm_sdk::handlers::node_e2e_tests::discovery_passes_over_what_is_not_a_vault_of_the_token` | The market check removed from `vaults_of_token` → A's TKN/TKB vault, appended under ERA's locator, is counted as a vault of ERA → red (2026-10-01, restored). | — | | MR-SOFI-0354, SoFi Amendment S16: the first trade, route or close through a vault admits its setup first, and later ones reuse it | `dsm_sdk` · sdk/sofi_flow.rs · `set_up_with`, `check_route`, `trade`, `close` | `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::a_realized_trade_shows_in_balances_and_history_at_once` | `trade` without `set_up_with` → the route through two vaults B never set up with is refused ("no published setup with this vault is one this device admitted") → red (2026-10-01, restored). | — | | MR-SOFI-0356, SoFi Amendment S16: a walk resolves another trader's conditional parent through frontier-relative verification, as only the position and fulfillment P names | `dsm` · sofi/resolve.rs · `Verifier::parent_for`, `parent_named`, `Verifier::chain_until`; economic/peer_lineage.rs · `peer_root_at`; `dsm_sdk` · sdk/sofi_reads.rs · `trader_root_at` | `dsm::sofi::resolve::tests::only_the_position_and_fulfillment_p_names_resolve_its_parent`; `dsm::economic::peer_lineage::tests::a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read`; `dsm::economic::peer_lineage::tests::the_activation_root_names_no_parent`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | `parent_named` accepting any fulfillment, or skipping the position check → `only_the_position_and_fulfillment_p_names_resolve_its_parent` red; `peer_root_at` standing below `position` instead of `position + 1` → `a_frontier_recorded_at_the_position_is_its_root_and_nothing_is_read` red; peer resolution walking each vault's chain to its head instead of `chain_until` the leg's parent → `every_sofi_route_reaches_its_producer` overflows its stack (2026-10-01, all restored). | — | From 96b870fddfd3ffdbdf8e1cf176508f9cdeeb225a Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:29:33 -0400 Subject: [PATCH 14/31] fix(sofi): junk can no longer exhaust a walk or hold a key or a position for good MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-audit item 12. Four paths by which cheap junk froze a vault or a trader's position, each with a node or unit test that went red on the old code: - The walk's budget ended walks. Conformance item 5 read K^(0) to K^(min(a,16)-1), though it needs K^(a-1) alone, so a fulfillment at attempt 17 never had the key it names. Extending a chain and a leg's liveness walk both stopped at Continue. Junk exercises at the first 17 keys of a root therefore froze the vault there. Item 5 now reads exactly K^(a-1), and every caller resumes at the cursor (SoFi §23.6). - Recognition counted an exercise's closure objects and checked nothing else, so a copy of an honest exercise with other closure bytes, written first at a leg's leader, held the key and could never be classified. Each closure object must now be the object its reference names, by conformance item 8's rule (§17.5). - A registration read fetched the precommit of every fulfillment any seat held at K_ful(q), and failed when one could not be read, so a value at any member, written at any time, stopped every read of that position. Only the leader's log decides the cell: precommits are fetched only for its copies, in arrival order, until one names the key. route_chain::leader_copies is the one rule the leader link and this read share. - A conditional parent another claim holds at p was "not established" forever, so no key its exercise held was ever skipped. It selects no root, ever: ConditionalNoRoot, impossible, skipped without evidence (§23.3). Tests: junk_at_more_attempt_keys_than_one_walk_examines_is_walked_past, an_exercise_carrying_other_bytes_than_its_closure_holds_no_key, nothing_but_the_leaders_first_fulfillment_decides_k_ful (node); a_conditional_parent_another_claim_holds_selects_no_root (facts). Seven mutation controls, one per gate, each red and restored. --- .../dsm/src/route_chain.rs | 35 +- .../dsm/src/sofi/conformance.rs | 2 +- .../dsm/src/sofi/exercise.rs | 13 +- .../dsm/src/sofi/facts.rs | 106 ++++- .../dsm/src/sofi/resolution.rs | 3 +- .../dsm/src/sofi/resolve.rs | 201 +++++---- .../dsm_sdk/src/handlers/node_e2e_tests.rs | 421 +++++++++++++++++- lean4/DSMSofiAtomicity.lean | 4 +- 8 files changed, 663 insertions(+), 122 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs b/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs index eb956fa15..1afbc643b 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs @@ -580,6 +580,33 @@ fn is_cell_copy_at(entry: &RouteEntry, cell: &RoutedCell, position: usize) -> bo && entry.fits(&cell.route) } +/// The cell's position-0 copies in the leader's arrival log, in arrival +/// order: the values the leader link is chosen from (§9 route chains, rule +/// 3). A reader that must fetch what its recognizer needs before evaluating +/// the cell fetches it for these, in this order, and for nothing any other +/// seat holds. Nothing when the leader is unread. +pub fn leader_copies<'e>( + cell: &'e RoutedCell, + ev: &'e CellEvidence, +) -> impl Iterator + 'e { + leader_log_copies(cell, ev).map(|(.., entry)| entry) +} + +/// [`leader_copies`], each with its index in the leader's log. +fn leader_log_copies<'e>( + cell: &'e RoutedCell, + ev: &'e CellEvidence, +) -> impl Iterator + 'e { + ev.seats + .first() + .and_then(|leader| leader.values.as_ref()) + .into_iter() + .flatten() + .enumerate() + .filter_map(|(n, bytes)| RouteEntry::decode(bytes).map(|entry| (n, entry))) + .filter(move |(.., entry)| is_cell_copy_at(entry, cell, 0)) +} + /// The leader link of a cell: the first recognized value in the leader's /// arrival log, stored there as the cell's position-0 copy, with the record /// the leader returned for it (§9 route chains, rule 3). @@ -603,13 +630,7 @@ where { let leader = ev.seats.first().ok_or(Missing::LeaderUnread)?; let log = leader.values.as_ref().ok_or(Missing::LeaderUnread)?; - for (n, bytes) in log.iter().enumerate() { - let Some(entry) = RouteEntry::decode(bytes) else { - continue; - }; - if !is_cell_copy_at(&entry, cell, 0) { - continue; - } + for (n, entry) in leader_log_copies(cell, ev) { let Some((id, object)) = recognize(&entry.value) else { continue; }; diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs index ebedaa508..0af85cd7c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/conformance.rs @@ -497,7 +497,7 @@ impl Items { /// Whether `bytes` are the object a closure reference names, under that /// variant's own rule. `None` when no bytes could ever satisfy the reference. -fn closure_object_verifies(reference: &ValidationRef, bytes: &[u8]) -> Option { +pub(crate) fn closure_object_verifies(reference: &ValidationRef, bytes: &[u8]) -> Option { Some(match reference { ValidationRef::ContentAddr { object_class, addr } => { derive::closure_content_address(*object_class, bytes)? == *addr diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs index 1bbc210e7..b51acd0b7 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs @@ -22,6 +22,7 @@ use crate::route_chain::{ check_completion_proof, completion_proof, evaluate, CellError, CellEvidence, CellFact, CellReading, CompletionProof, Missing, ProofRefusal, RoutedCell, }; +use super::conformance::closure_object_verifies; use super::derive; use super::publication::{ recognize_policy_fulfillment, recognize_precommit, recognize_fulfillment, Signed, @@ -142,9 +143,19 @@ pub fn recognize_exercise(bytes: &[u8]) -> Option { if fulfillment.body.policy_fulfillment_set() != ids.as_slice() { return None; } - if exercise.closure().len() != preimage.settlement().closure().refs().len() { + // The closure is bound to `F` through `E` too (§17.5): each object is the + // one its reference in `𝒞_E^pre` names, by the rule conformance item 8 + // decides it by. Bytes carrying any other object are not this + // exercise, so they hold no key and can never stand where it should. + let refs = preimage.settlement().closure().refs(); + if exercise.closure().len() != refs.len() { return None; } + for (reference, bytes) in refs.iter().zip(exercise.closure()) { + if !closure_object_verifies(reference, bytes).is_some_and(|verifies| verifies) { + return None; + } + } Some(RecognizedExercise { fulfillment, precommit, 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 331f2723f..240430333 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs @@ -497,6 +497,36 @@ pub fn establish(reads: &ExerciseReads<'_>) -> Result, +) -> Option { + let held = held.filter(|held| held.economic_position == position)?; + Some(match named { + ParentClaimRef::SingleRoot { .. } => ParentPosition::SingleRoot { + named, + held: held.named, + held_root: held.selected_root, + }, + ParentClaimRef::Conditional { .. } if held.named == named => { + ParentPosition::ConditionalSelected { + selected_root: held.selected_root, + } + } + ParentClaimRef::Conditional { .. } => ParentPosition::ConditionalNoRoot, + }) +} + /// The facts of one exercise that need none of its validation evidence /// ([`GroundFacts`]), over the reads a verifier makes before it acquires /// any: the registration from the position pair, the trader parent from this @@ -539,23 +569,8 @@ pub fn establish_ground(reads: &GroundReads<'_>) -> Result match named { - ParentClaimRef::SingleRoot { .. } => ParentPosition::SingleRoot { - named, - held: held.named, - held_root: held.selected_root, - }, - ParentClaimRef::Conditional { .. } if held.named == named => { - ParentPosition::ConditionalSelected { - selected_root: held.selected_root, - } - } - ParentClaimRef::Conditional { .. } => return Err(unresolved()), - }, - Some(..) | None => return Err(unresolved()), - }; + let parent = parent_position(named, precommit.position(), reads.parent) + .ok_or(NotEstablished::ParentUnresolved { parent: named })?; // Every leg of P at the attempt F fixed for it. The attempts cover the // legs exactly: that is conformance item 4, decided in hand. @@ -642,7 +657,8 @@ mod tests { use crate::sofi::publication::Publication; use crate::sofi::registration::{fulfillment_registered, PositionCells}; use crate::sofi::resolution::{ - classify_attempt, resolve_position, walk, AttemptClass, Incomplete, KeyFacts, Resolution, + classify_attempt, resolve_position, trader_parent_impossible, walk, AttemptClass, + Incomplete, KeyFacts, Resolution, }; use crate::sofi::validation::fixtures::{swap_fixture_n, Fixture}; use crate::sofi::validation::trader_credits; @@ -1063,6 +1079,60 @@ mod tests { ); } + /// Pre-audit item 12, §23.3: a conditional parent counts only as the + /// claim the trader's lineage holds at `p`. Held as `P` names it, it + /// selected the root it holds. Held by another fulfillment's claim, or by + /// an ordinary claim, the claim `P` names never registered at `p` and + /// never will: it selects no root, the parent is impossible, and the key + /// its exercise holds is skipped rather than left waiting for a parent + /// that cannot come. What a walk holds at another position is no fact + /// about `p`, and neither is a lineage not walked there. + #[test] + fn a_conditional_parent_another_claim_holds_selects_no_root() { + let (position, root) = (7, [0x77; 32]); + let named = ParentClaimRef::Conditional { + fulfillment_id: [0xF7; 32], + }; + let held = |claim| { + Some(ResolvedParent { + economic_position: position, + selected_root: root, + named: claim, + }) + }; + assert_eq!( + parent_position(named, position, held(named)), + Some(ParentPosition::ConditionalSelected { + selected_root: root + }) + ); + for other in [ + ParentClaimRef::Conditional { + fulfillment_id: [0xF8; 32], + }, + ParentClaimRef::SingleRoot { + claim_ref: [0xC7; 32], + }, + ] { + let parent = parent_position(named, position, held(other)); + assert_eq!( + parent, + Some(ParentPosition::ConditionalNoRoot), + "{other:?} holds p" + ); + assert!( + parent.is_some_and(|parent| trader_parent_impossible(&parent, &root)), + "terminal: the position is Invalid and its keys are skipped" + ); + } + assert_eq!( + parent_position(named, position + 1, held(named)), + None, + "the claim held at another position" + ); + assert_eq!(parent_position(named, position, None), None); + } + /// 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. 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 b291ecbad..8a7aa6557 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs @@ -67,7 +67,8 @@ pub enum ParentPosition { /// `C_p` resolved and selected this root: `R_realize` when `p` realized, /// `R_void` when it voided. ConditionalSelected { selected_root: [u8; 32] }, - /// `C_p` resolved Invalid, so no root was ever selected. Terminal. + /// 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, } 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 399c21093..fab32afbf 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs @@ -21,6 +21,7 @@ //! lineage walker (`economic::peer_lineage::PeerEvidenceFetcher`), which //! walks a foreign lineage the same way. +use std::collections::btree_map::Entry; use std::collections::{BTreeMap, BTreeSet}; use crate::ccb::StorageSetMembers; @@ -30,7 +31,7 @@ use crate::economic::register::root_completion; use crate::economic::state::EconomicLeafState; use crate::economic::tree::EconomicSmt; use crate::route_chain::{ - CellEvidence, CellFact, ChainState, CompletionProof, Missing as CellMissing, RouteEntry, + leader_copies, CellEvidence, CellFact, ChainState, CompletionProof, Missing as CellMissing, RoutedCell, }; @@ -52,7 +53,8 @@ use super::lineage::{ }; use super::publication::{recognize_fulfillment, recognize_setup, Signed}; use super::registration::{ - fulfillment_completion, fulfillment_registered, PositionCells, Registration, RegistrationRead, + fulfillment_completion, fulfillment_registered, names_fulfillment_key, PositionCells, + Registration, RegistrationRead, }; use super::resolution::{ skip_without_evidence, walk, AttemptClass, AttemptWalk, KeyFacts, RecordedGeneration, @@ -102,13 +104,9 @@ pub const SIBLING_DEPTH: usize = 2; /// Acquisition rounds before the evidence is `Exhausted`. pub const ACQUIRE_ROUNDS: usize = 3; -/// Cells read per leg when acquiring what an attempt skipped past: the same -/// bound the walk uses, for the same reason. Past it the earlier keys are -/// unread, never assumed skipped. -pub const PRIOR_ATTEMPT_BUDGET: usize = WALK_BUDGET; - -/// Fulfillment candidates one read of `K_ful(q)` may name before the pair is -/// unavailable to this verifier: each names a `P` fetched by id (R8). +/// Fulfillments in the leader's log at `K_ful(q)` whose `P` one read may +/// fetch before the pair is unavailable to this verifier: each names a `P` +/// fetched by id (R8). pub const NAMED_PRECOMMIT_BUDGET: usize = 64; /// A read that could not be made: a member did not answer, a local store @@ -253,19 +251,6 @@ pub trait SofiReads { ) -> Result<(), ReadFailure>; } -/// Every value the cell's copies carry, seat by seat in route order and in -/// each seat's arrival order: the bytes a recognizer is shown. What does not -/// decode as a route entry carries nothing. -pub fn carried_values(evidence: &CellEvidence) -> impl Iterator> + '_ { - evidence - .seats - .iter() - .filter_map(|seat| seat.values.as_ref()) - .flatten() - .filter_map(|bytes| RouteEntry::decode(bytes)) - .map(|entry| entry.value) -} - /// This device's own `R_econ` leaves, checked against the root they claim to /// form. Built from the device's leaf cache for its validated root, or by a /// test from leaves it holds — never from a root somebody sent. @@ -694,13 +679,17 @@ impl Verifier<'_, R> { /// Once registered, the completion proofs of both cells are kept (SoFi /// Amendment S10). /// - /// Recognizing a value at `K_ful(q)` needs the `P` it names: every - /// fulfillment envelope any seat holds at the key names one, and those - /// are read by id (R8), at most [`NAMED_PRECOMMIT_BUDGET`] of them. A `P` - /// the read could not decide leaves the cell undecided — the call fails, - /// and a later read can answer — so that no later value is read as the - /// first recognized one while an earlier one's `P` is merely not in - /// hand. The inner `Err` is what the reads do not yet show. + /// Recognizing a value at `K_ful(q)` needs the `P` it names, read by id + /// (R8). Only the leader's log decides which value holds the cell: the + /// first copy there that names the key (`route_chain::leader_copies`, the + /// candidates the leader link is chosen from). So the `P`s read are those + /// of the fulfillments in that log, in arrival order, until one names the + /// key, and at most [`NAMED_PRECOMMIT_BUDGET`] of them; nothing another + /// member holds is read. A `P` the read could not decide leaves the cell + /// undecided — the call fails, and a later read can answer — so that no + /// later value is read as the first recognized one while an earlier + /// one's `P` is merely not in hand. The inner `Err` is what the reads do + /// not yet show. pub fn read_registration( &self, genesis: &D32, @@ -713,35 +702,43 @@ impl Verifier<'_, R> { let root_evidence = self.reads.cell(cells.root().routed())?; let mut precommits: BTreeMap = BTreeMap::new(); - let mut named: Vec = Vec::new(); - for value in carried_values(&ful_evidence) { - if let Some((.., signed)) = recognize_fulfillment(&value) { - let id = *signed.body.precommit_id(); - if !named.contains(&id) { - named.push(id); - } + let mut fetched = 0; + for copy in leader_copies(cells.fulfillment(), &ful_evidence) { + let Some((.., signed)) = recognize_fulfillment(©.value) else { + continue; + }; + if signed.body.position() != position { + continue; } - } - if named.len() > NAMED_PRECOMMIT_BUDGET { - return Err(VerifierFailure::Read(format!( - "fulfillment register: {} precommits are named at K_ful({position}), past the \ - budget of {NAMED_PRECOMMIT_BUDGET}", - named.len() - ))); - } - for id in named { - match self.reads.precommit(&id)? { - Resolved::Kept(precommit) => { - precommits.insert(id, precommit.body); + let id = *signed.body.precommit_id(); + if let Entry::Vacant(slot) = precommits.entry(id) { + if fetched == NAMED_PRECOMMIT_BUDGET { + return Err(VerifierFailure::Read(format!( + "fulfillment register: the leader's log at K_ful({position}) names more \ + precommits than the budget of {NAMED_PRECOMMIT_BUDGET}" + ))); } - Resolved::None => {} - Resolved::Unavailable => { - return Err(VerifierFailure::Read( - "fulfillment register: a precommit a candidate names could not be read" - .to_string(), - )) + fetched += 1; + match self.reads.precommit(&id)? { + Resolved::Kept(precommit) => { + slot.insert(precommit.body); + } + // No `P`, so the value names no key: the leader link is + // past it. + Resolved::None => continue, + Resolved::Unavailable => { + return Err(VerifierFailure::Read( + "fulfillment register: a precommit a candidate names could not be read" + .to_string(), + )) + } } } + if names_fulfillment_key(©.value, genesis, device_id, position, &precommits) + .is_some() + { + break; + } } let registration = @@ -776,24 +773,23 @@ impl Verifier<'_, R> { Ok(Ok(registration)) } - /// The cells an attempt above zero skips past, read from the committed - /// set: for every leg the fulfillment names at attempt `a`, the storage - /// fact at `K^(0) … K^(a-1)` of that leg's vault at its parent root. + /// The cell an attempt above zero skips past, read from the committed + /// set: for every leg the fulfillment names at attempt `a > 0`, the + /// storage fact at `K^(a-1)` of that leg's vault at its parent root. /// /// ONE PATH, SHARED (owner ruling, §44.4). The producer's install (R9) - /// and the verifier's resolution (R12) read the same cells the same way, + /// and the verifier's resolution (R12) read the same cell the same way, /// because they answer the same question: conformance item 5 requires - /// the key before this one to have a permanent storage resolution. A key - /// whose reads do not decide it yet, or past `budget`, is absent from the - /// map, and conformance names it missing. An attempt of zero has no - /// earlier key and contributes no entry. + /// the key before this one to have a permanent storage resolution + /// (§20.2), and that key alone, however many keys come before it. A key + /// whose reads do not decide it yet is absent from the map, and + /// conformance names it missing. An attempt of zero has no earlier key + /// and contributes no entry. pub fn acquire_prior_attempts( &self, precommit: &TraderPrecommitBody, fulfillment: &TraderFulfillmentBody, - budget: usize, ) -> Result, VerifierFailure> { - let reach = u64::try_from(budget).unwrap_or(u64::MAX); let mut cells = BTreeMap::new(); for entry in fulfillment.attempts() { // F naming a leg P does not is conformance item 4's refusal; @@ -805,16 +801,15 @@ impl Verifier<'_, R> { else { continue; }; - for earlier in 0..entry.attempt.min(reach) { - match self.read_attempt_cell(&leg.vault_id, &leg.parent_root, earlier)? { - Ok(read) => { - cells.insert((entry.vault_id, earlier), read.fact()); - } - Err(undecided) => { - log::info!( - "[sofi verifier] K^({earlier}) is not decided yet: {undecided:?}" - ) - } + let Some(earlier) = entry.attempt.checked_sub(1) else { + continue; + }; + match self.read_attempt_cell(&leg.vault_id, &leg.parent_root, earlier)? { + Ok(read) => { + cells.insert((entry.vault_id, earlier), read.fact()); + } + Err(undecided) => { + log::info!("[sofi verifier] K^({earlier}) is not decided yet: {undecided:?}") } } } @@ -874,11 +869,7 @@ impl Verifier<'_, R> { preimage: objects.preimage.clone(), closure, setups, - prior_attempts: self.acquire_prior_attempts( - objects.precommit, - objects.fulfillment, - PRIOR_ATTEMPT_BUDGET, - )?, + prior_attempts: self.acquire_prior_attempts(objects.precommit, objects.fulfillment)?, parent_fulfillment, }) } @@ -1362,7 +1353,7 @@ impl Verifier<'_, R> { // parent is not established yet, which is not a fact about this // vault; the second runs once those chains have been walked. for pass in 0..2 { - let walked = self.walk_chain( + let mut walked = self.walk_chain( &*chains, *vault_id, *current, @@ -1371,6 +1362,11 @@ impl Verifier<'_, R> { CHAIN_DEPTH, BTreeMap::new(), )?; + // The budget chunks the walk and never ends it (§23.6): however + // many keys junk holds, the walk resumes past them. + while let WalkOutcome::Continue { .. } = walked.outcome { + walked = self.continue_walk(&*chains, walked, WALK_BUDGET)?; + } match walked.outcome { WalkOutcome::Consumed { .. } => { let Some(exercise) = walked.consumed else { @@ -1759,7 +1755,10 @@ impl Verifier<'_, R> { } }; // `AttemptLive`: every earlier key of this leg's chain is - // skipped, established by walking them. + // skipped, established by walking them. The keys are read + // in chunks of the walk's budget, which never ends the + // walk (§23.6), and then walked once from the first key + // over everything read. let walk = if attempt == 0 { None } else { @@ -1770,21 +1769,47 @@ impl Verifier<'_, R> { let Some(below) = depth.checked_sub(1) else { return Ok(Err(not_live)); }; - let earlier = - usize::try_from(attempt).map_or(WALK_BUDGET, |a| a.min(WALK_BUDGET)); - let chain = self.walk_chain( + let chunk_from = |cursor: u64| { + usize::try_from(attempt - cursor) + .map_or(WALK_BUDGET, |keys| keys.min(WALK_BUDGET)) + }; + let mut chunk = self.walk_chain( chains, leg.vault_id, leg.parent_root, 0, - earlier, + chunk_from(0), below, BTreeMap::new(), )?; - if let Some(why) = chain.not_established { - return Ok(Err(why)); + loop { + if let Some(why) = chunk.not_established { + return Ok(Err(why)); + } + match chunk.outcome { + WalkOutcome::Continue { cursor } if cursor < attempt => { + chunk = self.walk_chain( + chains, + leg.vault_id, + leg.parent_root, + cursor, + chunk_from(cursor), + below, + chunk.known, + )?; + } + WalkOutcome::Continue { .. } + | WalkOutcome::Consumed { .. } + | WalkOutcome::Unresolved { .. } + | WalkOutcome::CounterExhausted { .. } => break, + } } - Some(chain.walk) + let keys_below = usize::try_from(attempt).map_err(|e| { + VerifierFailure::Refused(format!("attempt {attempt}: {e}")) + })?; + Some(walk(&leg.vault_id, &leg.parent_root, 0, keys_below, |a| { + chunk.known.get(&a).and_then(KeyKnown::key_facts) + })) }; (cell, walk) } 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 d1ebbae43..5e46b2bce 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 @@ -9,15 +9,18 @@ use std::collections::BTreeMap; +use dsm::common::domain_tags::{TAG_DSM_SOFI_PRECOMMIT_OBJECT, TAG_DSM_SOFI_TRADER_PRECOMMIT_ID}; use dsm::economic::lineage::AdmittedEconomicPosition; -use dsm::route_chain::{CellFact, ChainState}; +use dsm::route_chain::{CellFact, ChainState, RouteEntry}; use dsm::sofi::conformance::{ conformance_invalid_in_hand, derive_policy_fulfillments, FulfillmentConformanceError, }; use dsm::sofi::derive; use dsm::sofi::exercise::{recognize_exercise, RecognizedExercise}; use dsm::sofi::publication::Publication; +use dsm::sofi::registration::Registration; use dsm::sofi::resolution::WalkOutcome; +use dsm::sofi::storage::Resolved; use dsm::sofi::resolve::WALK_BUDGET; use dsm::sofi::wire::{ AttemptEntry, DlvPolicyFulfillmentBody, PrecommitLeg, SofiExercise, TraderFulfillmentBody, @@ -32,8 +35,10 @@ use crate::bridge::{AppInvoke, AppQuery, AppResult, AppRouter as _}; use crate::economic_fixtures::NETWORK; use crate::sdk::sofi_advance::{complete_pending_fulfillment, Completion, NotTaken}; use crate::sdk::sofi_exercise::{attempt_cell, write_exercise}; +use crate::sdk::sofi_publish::{fetch_precommit, fetch_preimage}; use crate::sdk::sofi_reads::VerifierContext; use crate::sdk::sofi_register::position_cells; +use crate::sdk::storage_node_sdk::SetClient; use crate::sdk::storage_set::canonical_set; use crate::storage::client_db::economic_lineage; use crate::test_support::two_device::{Pair, TestDevice}; @@ -689,7 +694,7 @@ async fn a_trader_who_has_traded_can_pay() { } /// B's own exercise re-aimed at the vault's next generation: `P` names the -/// next parent root, `F` names attempt 0 there, the witnesses derive from the +/// next parent root, `F` names `attempt` there, the witnesses derive from the /// re-aimed `P`, and `sign` signs both new bodies' digests. Signed by B, every /// signature verifies, so it is one operation's exercise signed by the trader /// it names; and its own bytes refute it, because `P`'s legs are no longer @@ -698,6 +703,7 @@ fn reaimed( honest: &RecognizedExercise, vault_id: &[u8; 32], parent_root: &[u8; 32], + attempt: u64, sign: &dyn Fn([u8; 32]) -> Vec, ) -> SofiExercise { let p = &honest.precommit().body; @@ -752,7 +758,7 @@ fn reaimed( .iter() .map(|a| AttemptEntry { attempt: if a.vault_id == *vault_id { - 0 + attempt } else { a.attempt }, @@ -831,7 +837,7 @@ async fn a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_byte .expect("B's exercise holds the key it consumed"); p.b.enter(); let secret_key = crate::sdk::signing_authority::current_secret_key().expect("B's signing key"); - let hostile = reaimed(&honest, &m.vault_id, &r1, &|digest| { + let hostile = reaimed(&honest, &m.vault_id, &r1, 0, &|digest| { dsm::crypto::sphincs::sphincs_sign(&secret_key, &digest).expect("B signs") }); let recognized = recognize_exercise(&hostile.encode()) @@ -959,7 +965,7 @@ async fn an_unsigned_exercise_at_a_successor_key_takes_nothing() { .into_exercise() .expect("B's exercise holds the key it consumed"); - let unsigned = reaimed(&honest, &m.vault_id, &r1, &|_| vec![0x77; 8]); + let unsigned = reaimed(&honest, &m.vault_id, &r1, 0, &|_| vec![0x77; 8]); assert!(recognize_exercise(&unsigned.encode()).is_none()); let cell = attempt_cell(&set, &m.vault_id, &r1, 0).expect("the first key at R1"); let write = crate::sdk::route_seats::write_recorded(&set, cell.routed(), &unsigned.encode()) @@ -987,6 +993,411 @@ async fn an_unsigned_exercise_at_a_successor_key_takes_nothing() { head_agrees_with_admitted_root(&p.b, &[m.era, m.tkn]); } +/// Pre-audit item 12, SoFi §23.6 and §20.2 item 5: junk at more of a vault's +/// attempt keys than one walk examines is walked past, and a trade past it +/// realizes. Exercises refuted by their own bytes are written final at +/// `K^(0)` through `K^(16)` of the vault's next generation: one key more than +/// [`WALK_BUDGET`], so a walk that stops where its budget ends never reaches +/// the open key, and `K^(16)`, the key a fulfillment at `K^(17)` must find +/// final, lies past the budget too. B's next trade takes `K^(17)` and +/// realizes, and the vault's owner, walking the chain afresh, reaches the +/// generation that trade made. +/// +/// The junk is B's own exercise re-aimed ([`reaimed`]): writing it takes one +/// signed exercise per key and no economic value, so any identity can write +/// it with its own key. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn junk_at_more_attempt_keys_than_one_walk_examines_is_walked_past() { + let p = Pair::boot(500, 200).await; + let m = open_market(&p).await; + realized_trade(&p, &m, 10).await; + let out1 = dsm::dlv::route_commit::constant_product_output(10, 100, 1_000, 30) + .expect("the vault prices the trade"); + let set = canonical_set(NETWORK).expect("the pinned set"); + let (own, parents) = standing_of(&p.b); + let ctx = VerifierContext::new(&set, Some(own), parents.as_ref()).expect("a verifier"); + let verifier = ctx.verifier(); + let chain = verifier.chain(&m.vault_id).expect("the vault's chain"); + assert_eq!(chain.roots().len(), 2, "genesis and one consumption"); + let (r0, r1) = (chain.roots()[0], chain.roots()[1]); + let honest = verifier + .read_attempt_cell(&m.vault_id, &r0, 0) + .expect("read") + .expect("decided") + .into_exercise() + .expect("B's exercise holds the key it consumed"); + + p.b.enter(); + let secret_key = crate::sdk::signing_authority::current_secret_key().expect("B's signing key"); + let junk_keys = u64::try_from(WALK_BUDGET).expect("the walk budget is small") + 1; + for attempt in 0..junk_keys { + let junk = reaimed(&honest, &m.vault_id, &r1, attempt, &|digest| { + dsm::crypto::sphincs::sphincs_sign(&secret_key, &digest).expect("B signs") + }); + let recognized = + recognize_exercise(&junk.encode()).expect("the junk is one operation's exercise"); + let writes = write_exercise(&set, &junk, &recognized) + .await + .expect("any party may write an exercise"); + assert!(writes.iter().all(|w| w.reached_leader), "{writes:?}"); + } + let last = verifier + .read_attempt_cell(&m.vault_id, &r1, junk_keys - 1) + .expect("read") + .expect("decided"); + assert!( + matches!( + last.fact(), + CellFact::Held { + state: ChainState::Final, + .. + } + ), + "junk is final at the last key before the open one: {:?}", + last.fact() + ); + + // B's next trade walks past every junk key and takes the first open one. + let q2 = realized_trade(&p, &m, 10).await; + let next = verifier + .read_attempt_cell(&m.vault_id, &r1, junk_keys) + .expect("read") + .expect("decided") + .into_exercise() + .expect("B's exercise holds the first key past the junk"); + assert_eq!(next.fulfillment().body.position(), q2); + let out2 = dsm::dlv::route_commit::constant_product_output(10, 110, 1_000 - out1, 30) + .expect("the vault prices the second trade"); + assert_eq!( + balance(&p.b, &m.era), + crate::economic_fixtures::whole_era(200) - 20 + ); + assert_eq!(balance(&p.b, &m.tkn), out1 + out2); + head_agrees_with_admitted_root(&p.b, &[m.era, m.tkn]); + + // The vault's owner walks the chain afresh, past the junk. + let (own_a, parents_a) = standing_of(&p.a); + let owner = VerifierContext::new(&set, Some(own_a), parents_a.as_ref()).expect("a verifier"); + let walked = owner + .verifier() + .chain(&m.vault_id) + .expect("the owner's chain"); + assert_eq!( + walked.roots().len(), + 3, + "genesis, B's first trade, and B's trade past the junk" + ); +} + +/// Pre-audit item 12, SoFi §17.5: an exercise carries one object for each +/// reference `P(E)` commits, and bytes carrying any other object are no +/// exercise, so they hold no key. B trades while the vault's first key +/// refuses writes at its leader: the pair registers and the leg waits. A +/// third party rebuilds B's exercise from what the network holds — `F` at +/// `K_ful(q)`, `P` by its id, `P(E)` under its locator, the witnesses `P` +/// derives — with other bytes in place of every closure object, and writes +/// it first at the key once its leader takes writes again. B's own exercise +/// lands second. B's exercise holds the key, and the position realizes. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn an_exercise_carrying_other_bytes_than_its_closure_holds_no_key() { + let p = Pair::boot(500, 200).await; + let m = open_market(&p).await; + let set = canonical_set(NETWORK).expect("the pinned set"); + let realized = generated::SofiPositionState::Realized as i32; + let exhausted = generated::SofiPositionState::RetriesExhausted as i32; + + let position = admitted_position(&p.b); + let q = position + 1; + let (.., root) = { + p.b.enter(); + economic_lineage::get_admitted_coordinate() + .expect("read admitted") + .expect("an admitted position") + }; + let (own, parents) = standing_of(&p.b); + let ctx = VerifierContext::new(&set, Some(own), parents.as_ref()).expect("a verifier"); + let verifier = ctx.verifier(); + let chain = verifier.chain(&m.vault_id).expect("the vault's chain"); + assert_eq!(chain.roots().len(), 1, "the vault is at its genesis"); + let r0 = chain.roots()[0]; + let attempt = attempt_cell(&set, &m.vault_id, &r0, 0).expect("the first attempt key"); + let attempt_leader = member_name(attempt.routed().route().leader()); + + p.nodes + .refuse_cell_writes(&attempt_leader, &[*attempt.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)); + + // B's exercise, rebuilt from the network with other closure bytes. + let registration = verifier + .read_registration(&p.b.genesis, &p.b.device_id, q, &root) + .expect("read") + .expect("decided"); + let fulfillment = match registration.registration() { + Registration::Registered(signed) => signed.clone(), + other => panic!("B's fulfillment is registered at q: {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 preimage = match fetch_preimage(&set, precommit.body.external_commitment()) + .await + .expect("read") + { + Resolved::Kept(preimage) => preimage, + other => panic!("P(E) is stored under its locator: {other:?}"), + }; + let canonical = derive::canonical_legs(&preimage).expect("P(E) derives its legs"); + let shadows: Vec<[u8; 32]> = precommit + .body + .legs() + .iter() + .map(|leg| { + canonical + .iter() + .find(|l| l.vault_id == leg.vault_id) + .expect("a leg P(E) derives") + .shadow_core + }) + .collect(); + let witnesses = + derive_policy_fulfillments(&precommit.body, &shadows).expect("the canonical witnesses"); + let references = preimage.settlement().closure().refs().len(); + assert!(references > 0, "the closure has objects to replace"); + let other_bytes = SofiExercise::new( + Publication::Fulfillment { + body: &fulfillment.body, + signature: &fulfillment.signature, + } + .object_bytes() + .expect("an F envelope"), + Publication::Precommit { + body: &precommit.body, + signature: &precommit.signature, + } + .object_bytes() + .expect("a P envelope"), + preimage.encode().expect("P(E) bytes"), + witnesses + .iter() + .map(DlvPolicyFulfillmentBody::encode) + .collect(), + vec![b"not the object its reference names".to_vec(); references], + ) + .expect("an exercise's shape") + .encode(); + + // The leader takes writes again, and the copy is written there first. + p.nodes.accept_cell_writes(&attempt_leader).await; + p.a.enter(); + let write = crate::sdk::route_seats::write_recorded(&set, attempt.routed(), &other_bytes) + .await + .expect("the nodes keep whatever they are given"); + assert!(write.reached_leader()); + + // B's own exercise lands after it, holds the key, and realizes. + assert!(matches!(complete(&p).await, Completion::Written(..))); + let held = verifier + .read_attempt_cell(&m.vault_id, &r0, 0) + .expect("read") + .expect("decided"); + assert_ne!( + held.value(), + Some(other_bytes.as_slice()), + "the copy with other closure bytes holds the key" + ); + assert_eq!(resolve(&p).await, (q, realized)); + let out = dsm::dlv::route_commit::constant_product_output(10, 100, 1_000, 30) + .expect("the vault prices the trade"); + assert_eq!( + balance(&p.b, &m.era), + crate::economic_fixtures::whole_era(200) - 10 + ); + assert_eq!(balance(&p.b, &m.tkn), out); + head_agrees_with_admitted_root(&p.b, &[m.era, m.tkn]); +} + +/// 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 +/// asked B's key to sign, and B's crashed or superseded attempts are of this +/// kind. +fn signed_by_b( + p: &Pair, + like: &TraderFulfillmentBody, + vault_id: &[u8; 32], + q: u64, + precommit_id: [u8; 32], +) -> Vec { + let stray = TraderFulfillmentBody::new( + precommit_id, + vec![[0x5D; 32]], + vec![AttemptEntry { + vault_id: *vault_id, + attempt: 0, + }], + q, + like.signature_alg(), + like.claimant_public_key(), + ) + .expect("a well-formed F"); + p.b.enter(); + let secret_key = crate::sdk::signing_authority::current_secret_key().expect("B's signing key"); + let signature = dsm::crypto::sphincs::sphincs_sign( + &secret_key, + &derive::fulfillment_signing_digest(&stray), + ) + .expect("B signs"); + Publication::Fulfillment { + body: &stray, + signature: &signature, + } + .object_bytes() + .expect("an F envelope") +} + +/// Pre-audit item 12, SoFi §17.4 and storage §9: the value holding `K_ful(q)` +/// is the first one the leader's log recognizes, so a fulfillment anywhere +/// else at the key, or after that value in the leader's log, cannot stop the +/// registration being read. B trades and realizes at `q`. Fulfillments B +/// signed naming a precommit that can never be read — the one candidate +/// under its locator is an object one member alone holds — are then written +/// at the leader of `K_ful(q)`, after B's own, and at a member that does not +/// lead `K_ful(q + 1)`, where nothing else is. The vault's owner, walking the +/// vault afresh, reads B's registration and reaches the generation B's trade +/// made; and `q + 1` reads as the open position it is. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn nothing_but_the_leaders_first_fulfillment_decides_k_ful() { + let p = Pair::boot(500, 200).await; + let m = open_market(&p).await; + let set = canonical_set(NETWORK).expect("the pinned set"); + let position = admitted_position(&p.b); + let q = position + 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 position pair at q"); + assert_eq!(realized_trade(&p, &m, 10).await, q); + let (.., realized_root) = { + p.b.enter(); + economic_lineage::get_admitted_coordinate() + .expect("read admitted") + .expect("an admitted position") + }; + let next = position_cells(&set, &p.b.genesis, &p.b.device_id, q + 1, &realized_root) + .expect("B's position pair at q + 1"); + + // A precommit nobody can read: its one candidate is held by one member. + let clients = SetClient::new(&set).expect("the set's members"); + let member = |id: &[u8]| { + clients + .members() + .iter() + .find(|member| member.member_id().as_bytes() == id) + .expect("a member of the set") + }; + let not_leading = clients + .members() + .iter() + .find(|member| member.member_id().as_bytes() != next.fulfillment().route().leader()) + .expect("a member that does not lead K_ful(q + 1)"); + let unreadable = [0x5C; 32]; + let lone = b"bytes one member holds"; + not_leading + .put_immutable(TAG_DSM_SOFI_PRECOMMIT_OBJECT, lone) + .await + .expect("the member keeps what it is given"); + not_leading + .append_index( + TAG_DSM_SOFI_TRADER_PRECOMMIT_ID.source_bytes(), + &unreadable, + &dsm::storage_object::immutable_addr(TAG_DSM_SOFI_PRECOMMIT_OBJECT, lone), + ) + .await + .expect("the member appends what it holds"); + assert!( + matches!( + fetch_precommit(&set, &unreadable).await.expect("read"), + Resolved::Unavailable + ), + "the precommit can never be read" + ); + + let (own, parents) = standing_of(&p.b); + let ctx = VerifierContext::new(&set, Some(own), parents.as_ref()).expect("a verifier"); + let registered = match ctx + .verifier() + .read_registration(&p.b.genesis, &p.b.device_id, q, &root) + .expect("read") + .expect("decided") + .registration() + { + Registration::Registered(signed) => signed.body.clone(), + other => panic!("B's fulfillment is registered at q: {other:?}"), + }; + + // After B's own fulfillment, in the leader's log at K_ful(q). + let at_q = pair.fulfillment(); + let behind = RouteEntry::at_leader( + at_q.namespace().to_vec(), + *at_q.key(), + signed_by_b(&p, ®istered, &m.vault_id, q, unreadable), + at_q.route(), + ); + member(at_q.route().leader()) + .put_cells(&[(behind.namespace.clone(), behind.key, behind.encode())]) + .await + .expect("the leader keeps what it is given"); + + // Alone, at a member that does not lead K_ful(q + 1). + let at_next = next.fulfillment(); + let aside = RouteEntry::at_leader( + at_next.namespace().to_vec(), + *at_next.key(), + signed_by_b(&p, ®istered, &m.vault_id, q + 1, unreadable), + at_next.route(), + ); + not_leading + .put_cells(&[(aside.namespace.clone(), aside.key, aside.encode())]) + .await + .expect("the member keeps what it is given"); + + // The vault's owner reads B's registration at q past what follows it. + let (own_a, parents_a) = standing_of(&p.a); + let owner = VerifierContext::new(&set, Some(own_a), parents_a.as_ref()).expect("a verifier"); + let walked = owner + .verifier() + .chain(&m.vault_id) + .expect("the owner's chain"); + assert_eq!(walked.roots().len(), 2, "genesis and B's trade"); + + // And q + 1 is the open position it is: nothing at its leader. + let (own, parents) = standing_of(&p.b); + let fresh = VerifierContext::new(&set, Some(own), parents.as_ref()).expect("a verifier"); + let open = fresh + .verifier() + .read_registration(&p.b.genesis, &p.b.device_id, q + 1, &realized_root) + .expect("a fulfillment at a member that does not lead the cell stops no read") + .expect("decided"); + assert!( + matches!(open.registration(), Registration::Unresolved), + "{:?}", + open.registration() + ); +} + /// B's own completion of its pending position, stages 7 and 8 from what /// storage holds: what the network took, or what it did not. async fn complete(p: &Pair) -> Completion { diff --git a/lean4/DSMSofiAtomicity.lean b/lean4/DSMSofiAtomicity.lean index 825bf4fa8..2517bbdcc 100644 --- a/lean4/DSMSofiAtomicity.lean +++ b/lean4/DSMSofiAtomicity.lean @@ -1049,7 +1049,9 @@ inductive ParentState where | taken /-- A conditional claim that selected the opposite branch. -/ | otherBranch - /-- A conditional claim that resolved Invalid: no root was ever selected. -/ + /-- A conditional claim that selects no root, ever: another claim holds `p`, + so it never registered there (CONFORMANCE §6.66 12d), or it resolved + Invalid. -/ | noRoot deriving DecidableEq, Repr From 232f2512eaeb2eb65c0d1ec3b8972721f053fbf2 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:36:28 -0400 Subject: [PATCH 15/31] fix(core): decoders sized by their input; a nonce spent in its relationship Security pre-audit items 15 and 5. - **Operation decoder.** `dec_vec_bytes` no longer preallocates from a sender-written count. A recovery operation claiming u32::MAX authority signatures asked for ~103 GB before decoding any: a panic on 32-bit, and an abort under a refusing allocator. The vector now grows only as elements decode. - **Proof decoders.** `DevTreeProof` and `SmtInclusionProof` compute their length checks in u64. On 32-bit, `count * 32` wrapped for count 2^27 and the copies indexed past the input. - **Nonce scope.** A transfer nonce is spent under `relationship_nonce_hash(rel_key, nonce)` (`DSM/relationship-nonce/v1`) in the canonical apply's identity, in `spent_nonces` and in staging. - Before, a nonce was spent device-wide. Nonces derive from public inputs, so one contact could spend another contact's predicted nonce and have that contact's real transfer dropped as decided. - Replay is still refused: the same transfer carries the same nonce in the same relationship, and the (relationship, parent) record holds. - The unused device-wide `is_nonce_spent` and `mark_nonce_spent` are deleted. - **Tests:** the three hostile decoder inputs, and a nonce spent in one relationship staying unspent in another. --- .../dsm/src/common/device_tree.rs | 20 +- .../common/domain_tags/dsm/crypto_keys/mod.rs | 1 + .../dsm/crypto_keys/nonce_randomness.rs | 5 + .../dsm/src/common/domain_tags/mod.rs | 2 +- .../dsm/src/merkle/sparse_merkle_tree.rs | 24 ++- .../dsm/src/types/operations.rs | 31 +++- .../src/handlers/recipient_dispatch.rs | 6 +- .../dsm_sdk/src/sdk/core_sdk.rs | 28 ++- .../src/storage/client_db/canonical_apply.rs | 5 +- .../dsm_sdk/src/storage/client_db/nonces.rs | 173 +++++++----------- 10 files changed, 175 insertions(+), 120 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/device_tree.rs b/dsm_client/deterministic_state_machine/dsm/src/common/device_tree.rs index 16a58b637..5adba2c1c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/device_tree.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/device_tree.rs @@ -159,7 +159,10 @@ impl DevTreeProof { path_bits.push(bit); } - if offset + num_siblings * 32 != data.len() { + // In u64: on a 32-bit target `num_siblings * 32` wraps for a hostile + // count, and a wrapped length would pass this check while the copies + // below index past the input. + if offset as u64 + num_siblings as u64 * 32 != data.len() as u64 { return None; } @@ -312,6 +315,21 @@ impl DeviceTree { mod tests { use super::*; + /// A proof that names more siblings than it carries is refused. The count + /// here is `2^27`, whose byte length `2^32` wraps to 0 on a 32-bit target, + /// so a length check in `usize` would accept these nine bytes there. + #[test] + fn a_proof_naming_more_siblings_than_it_carries_is_refused() { + assert!( + DevTreeProof::from_bytes(&[0x00, 0x00, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00]) + .is_none() + ); + assert!( + DevTreeProof::from_bytes(&[0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x00, 0x00, 0x00, 0x00]) + .is_none() + ); + } + #[test] fn test_single_device_tree() { let devid = [42u8; 32]; diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/crypto_keys/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/crypto_keys/mod.rs index 6e5607935..ae372136d 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/crypto_keys/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/crypto_keys/mod.rs @@ -41,6 +41,7 @@ pub(super) const TAGS: &[TaggedHashDomain<'static>] = &[ TAG_DSM_ML_KEM_SEED, TAG_DSM_NEXT_ENTROPY, TAG_DSM_NONCE, + TAG_DSM_RELATIONSHIP_NONCE, TAG_DSM_PK_HASH, TAG_DSM_PRECOMMIT, TAG_DSM_PRECOMMIT_INVALIDATION_PROOF_V2, diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/crypto_keys/nonce_randomness.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/crypto_keys/nonce_randomness.rs index ccd2b074c..7f37aa144 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/crypto_keys/nonce_randomness.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/crypto_keys/nonce_randomness.rs @@ -10,5 +10,10 @@ pub const TAG_DSM_DETERMINISTIC_NONCE_32: TaggedHashDomain<'static> = pub const TAG_DSM_DETERMINISTIC_NONCE_GCM: TaggedHashDomain<'static> = crate::tagged_domain!(b"DSM/deterministic-nonce-gcm"); pub const TAG_DSM_NONCE: TaggedHashDomain<'static> = TaggedHashDomain::from_static(b"DSM/nonce"); +/// A transfer nonce as it is spent: `H(tag ‖ relationship_key ‖ nonce)`. A +/// nonce is unique within the relationship that carries it, so a transfer of +/// another relationship cannot spend it. +pub const TAG_DSM_RELATIONSHIP_NONCE: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/relationship-nonce/v1"); pub const TAG_DSM_WALK_SEED: TaggedHashDomain<'static> = crate::tagged_domain!(b"DSM/walk-seed"); pub const TAG_DSM_WALK_STEP: TaggedHashDomain<'static> = crate::tagged_domain!(b"DSM/walk-step"); diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs index db62bde57..5d99235d0 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs @@ -171,7 +171,7 @@ mod tests { // network hash domain's only input. // +1 with the TraderPreBalance object namespace (SoFi Amendment S12). // +1 with the vault token locator (SoFi Amendment S16). - const EXPECTED_TAG_COUNT: usize = 352; + const EXPECTED_TAG_COUNT: usize = 353; /// Scan the crate source for every declared domain-tag constant. /// diff --git a/dsm_client/deterministic_state_machine/dsm/src/merkle/sparse_merkle_tree.rs b/dsm_client/deterministic_state_machine/dsm/src/merkle/sparse_merkle_tree.rs index 24e963dd5..ec7d3705f 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/merkle/sparse_merkle_tree.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/merkle/sparse_merkle_tree.rs @@ -634,7 +634,11 @@ impl SmtInclusionProof { } let count = u32::from_le_bytes(data[offset..offset + 4].try_into().ok()?) as usize; offset += 4; - if data.len() < offset + count * 32 { + // In u64: on a 32-bit target `count * 32` wraps for a hostile count, + // and a wrapped length would pass this check while the copies below + // index past the input. + let needed = offset as u64 + count as u64 * 32; + if (data.len() as u64) < needed { return None; } let mut siblings = Vec::with_capacity(count); @@ -645,7 +649,7 @@ impl SmtInclusionProof { } // Canonical decode requires full byte exhaustion: reject trailing bytes // so a proof has exactly one byte encoding. (issue #450) - if offset + count * 32 != data.len() { + if needed != data.len() as u64 { return None; } Some(SmtInclusionProof { @@ -665,6 +669,22 @@ mod tests { use super::*; use proptest::prelude::*; + /// An inclusion proof that names more siblings than it carries is + /// refused. The count `2^27` has a byte length that wraps to 0 on a + /// 32-bit target, so a length check in `usize` would accept it there. + #[test] + fn an_inclusion_proof_naming_more_siblings_than_it_carries_is_refused() { + for count in [0x0800_0000u32, u32::MAX] { + let mut bytes = vec![0x5A; 32]; + bytes.push(0x00); + bytes.extend_from_slice(&count.to_le_bytes()); + assert!( + SmtInclusionProof::from_bytes(&bytes).is_none(), + "count {count:#x}" + ); + } + } + /// The tree as it was computed before it kept its nodes: every leaf, split /// at each of the 256 levels, on every call. The reference each kept root /// and path is checked against. diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs b/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs index 2d4932e9a..23c823b47 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs @@ -1380,7 +1380,10 @@ impl Operation { } fn dec_vec_bytes(inp: &mut &[u8]) -> Result>, DsmError> { let n = get_u32(inp)? as usize; - let mut v = Vec::with_capacity(n); + // Not preallocated from `n`: the count is the sender's, and every + // element must still decode from bytes in hand, so the vector + // grows only as far as the input carries it. + let mut v = Vec::new(); for _ in 0..n { v.push(get_bytes(inp)?); } @@ -2150,6 +2153,32 @@ impl Operation { mod tests { use super::*; + /// A count the bytes that follow cannot carry is refused, and decoding it + /// allocates nothing from the count: a recovery operation claiming + /// `u32::MAX` authority signatures and carrying none. + #[test] + fn a_count_the_remaining_bytes_cannot_carry_is_refused() { + let op = Operation::Recovery { + message: String::new(), + state_number: 0, + state_hash: Vec::new(), + state_entropy: Vec::new(), + invalidation_data: Vec::new(), + new_state_data: Vec::new(), + new_state_number: 0, + new_state_hash: Vec::new(), + new_state_entropy: Vec::new(), + compromise_proof: Vec::new(), + authority_sigs: Vec::new(), + }; + let mut bytes = op.to_bytes(); + let n = bytes.len(); + bytes[n - 4..].copy_from_slice(&u32::MAX.to_be_bytes()); + let refused = Operation::from_bytes(&bytes) + .expect_err("no signature follows the count, so nothing decodes"); + assert!(refused.to_string().contains("short input"), "{refused}"); + } + fn test_balance(value: u64) -> Balance { Balance::amount(value) } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs index 2af3a1437..ee60169dc 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs @@ -224,7 +224,11 @@ pub fn recognize_transfer(wire: &[u8], header_sender: &str) -> Result Result<(), DsmError> { // Nonce consumption INSIDE the full-state transaction. - let spent = cdb::is_nonce_spent_with_conn(tx, &nonce).map_err(|e| { + let spent = cdb::is_nonce_spent_with_conn(tx, &rel_key, &nonce).map_err(|e| { DsmError::internal( format!("in-tx nonce check failed: {e}"), None::, @@ -2791,13 +2792,20 @@ impl CoreSDK { "full-state apply race: nonce consumed concurrently (fail closed)", )); } - cdb::mark_nonce_spent_with_conn(tx, &nonce, &tx_id_str, &sender_arr, amount_val) - .map_err(|e| { - DsmError::internal( - format!("in-tx nonce consume failed: {e}"), - None::, - ) - })?; + cdb::mark_nonce_spent_with_conn( + tx, + &rel_key, + &nonce, + &tx_id_str, + &sender_arr, + amount_val, + ) + .map_err(|e| { + DsmError::internal( + format!("in-tx nonce consume failed: {e}"), + None::, + ) + })?; // Canonical apply record with the AUTHORITATIVE applied B roots // and B pair from the state mutation itself. let record = record_for(outcome); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/canonical_apply.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/canonical_apply.rs index c6ee24028..1251c73df 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/canonical_apply.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/canonical_apply.rs @@ -35,8 +35,9 @@ pub struct CanonicalApplyRecord { pub operation_digest: [u8; 32], pub sender_device: [u8; 32], pub recipient_device: [u8; 32], - /// BLAKE3 of the op nonce — recipient-device-wide replay scope (matches the - /// existing `spent_nonces` rule; no relationship/sender scoping). + /// `relationship_nonce_hash(relationship_key, nonce)`: the op nonce, scoped + /// to its relationship as `spent_nonces` keys it, so a transfer of another + /// relationship never collides with it (security pre-audit item 5). pub nonce_hash: [u8; 32], /// The EXECUTING device's (B's) authoritative pre-state root produced by the /// state mutation (`advance_outcome.parent_r_a`). diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/nonces.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/nonces.rs index ce600a41e..723c10e7e 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/nonces.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/nonces.rs @@ -1,78 +1,41 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Nonce tracking and atomic receive transfer (replay prevention). +//! Spent transfer nonces (replay prevention), each scoped to the relationship +//! that carries it. use anyhow::{anyhow, Result}; -use log::{info, warn}; use rusqlite::params; -use super::get_connection; -use crate::storage::codecs::hash_blake3_bytes; - -/// Check if a nonce has already been spent (replay attack prevention). -/// Returns true if the nonce is already in the spent_nonces table. -pub fn is_nonce_spent(nonce: &[u8]) -> Result { - if nonce.is_empty() { - return Ok(false); // Empty nonce cannot be checked - } - let nonce_hash = hash_blake3_bytes(nonce); - let binding = get_connection()?; - let conn = binding.lock().unwrap_or_else(|poisoned| { - log::warn!("DB lock poisoned in is_nonce_spent, recovering"); - poisoned.into_inner() - }); - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM spent_nonces WHERE nonce_hash = ?1", - params![&nonce_hash[..]], - |row| row.get(0), - )?; - Ok(count > 0) -} - -/// Mark a nonce as spent (must be called atomically with balance credit). -/// Returns error if nonce was already spent (replay attack detected). -pub fn mark_nonce_spent(nonce: &[u8], tx_id: &str, sender_id: &[u8], amount: u64) -> Result<()> { - if nonce.is_empty() { - return Err(anyhow!("Cannot mark empty nonce as spent")); - } - let nonce_hash = hash_blake3_bytes(nonce); - let binding = get_connection()?; - let conn = binding.lock().unwrap_or_else(|poisoned| { - log::warn!("DB lock poisoned in mark_nonce_spent, recovering"); - poisoned.into_inner() - }); - - // Use INSERT without OR IGNORE - will fail if nonce already exists (replay attack) - let result = conn.execute( - "INSERT INTO spent_nonces(nonce_hash, tx_id, sender_id, amount) VALUES(?1, ?2, ?3, ?4)", - params![&nonce_hash[..], tx_id, sender_id, amount as i64], +/// `H(DSM/relationship-nonce/v1 ‖ relationship_key ‖ nonce)`: the key a +/// transfer nonce is spent under. +/// +/// A nonce is unique within the relationship that carries it. The replay rule +/// itself is the relationship's own (relationship, parent) record +/// (MR-DSM-0170); this is the nonce check beside it, and it must not reach +/// across relationships. A sender's nonce derives from public inputs, so with +/// a nonce spent across every relationship, another contact could spend this +/// relationship's next nonce first and have its transfer dropped as decided +/// (security pre-audit item 5). +pub fn relationship_nonce_hash(relationship_key: &[u8; 32], nonce: &[u8]) -> [u8; 32] { + let mut h = dsm::crypto::blake3::dsm_domain_hasher( + dsm::common::domain_tags::TAG_DSM_RELATIONSHIP_NONCE, ); - - match result { - Ok(_) => { - info!("[spent_nonces] Marked nonce as spent for tx {}", tx_id); - Ok(()) - } - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - warn!( - "[spent_nonces] REPLAY ATTACK DETECTED: nonce already spent for tx {}", - tx_id - ); - Err(anyhow!("Replay attack detected: nonce already spent")) - } - Err(e) => Err(e.into()), - } + h.update(relationship_key); + h.update(nonce); + *h.finalize().as_bytes() } -/// `is_nonce_spent` against a caller-supplied connection/transaction — for use +/// Whether `nonce` is spent in the relationship `relationship_key`, read /// INSIDE the single full-state apply transaction (§16.6 single-commit apply). /// `&rusqlite::Transaction` derefs to `&Connection`, so pass `&tx`. -pub fn is_nonce_spent_with_conn(conn: &rusqlite::Connection, nonce: &[u8]) -> Result { +pub fn is_nonce_spent_with_conn( + conn: &rusqlite::Connection, + relationship_key: &[u8; 32], + nonce: &[u8], +) -> Result { if nonce.is_empty() { - return Ok(false); + return Err(anyhow!("an empty nonce is not a nonce")); } - let nonce_hash = hash_blake3_bytes(nonce); + let nonce_hash = relationship_nonce_hash(relationship_key, nonce); let count: i64 = conn.query_row( "SELECT COUNT(*) FROM spent_nonces WHERE nonce_hash = ?1", params![&nonce_hash[..]], @@ -81,11 +44,14 @@ pub fn is_nonce_spent_with_conn(conn: &rusqlite::Connection, nonce: &[u8]) -> Re Ok(count > 0) } -/// `mark_nonce_spent` against a caller-supplied connection/transaction — the -/// nonce consumption commits (or rolls back) WITH the rest of the full-state -/// apply transaction, never as a separate durability boundary. +/// Spend `nonce` in the relationship `relationship_key` against a +/// caller-supplied connection/transaction: the consumption commits (or rolls +/// back) WITH the rest of the full-state apply transaction, never as a +/// separate durability boundary. A nonce already spent in this relationship +/// is refused. pub fn mark_nonce_spent_with_conn( conn: &rusqlite::Connection, + relationship_key: &[u8; 32], nonce: &[u8], tx_id: &str, sender_id: &[u8], @@ -94,7 +60,7 @@ pub fn mark_nonce_spent_with_conn( if nonce.is_empty() { return Err(anyhow!("Cannot mark empty nonce as spent")); } - let nonce_hash = hash_blake3_bytes(nonce); + let nonce_hash = relationship_nonce_hash(relationship_key, nonce); let result = conn.execute( "INSERT INTO spent_nonces(nonce_hash, tx_id, sender_id, amount) VALUES(?1, ?2, ?3, ?4)", params![&nonce_hash[..], tx_id, sender_id, amount as i64], @@ -115,6 +81,9 @@ mod tests { use super::*; use serial_test::serial; + const REL_A: [u8; 32] = [0xA1; 32]; + const REL_B: [u8; 32] = [0xB2; 32]; + fn init_test_db() { crate::economic_fixtures::use_test_storage_dir(); crate::storage::client_db::reset_database_for_tests(); @@ -122,52 +91,52 @@ mod tests { } #[test] - fn is_nonce_spent_returns_false_for_empty_nonce() { - assert!(!is_nonce_spent(&[]).unwrap()); - } - - #[test] - fn mark_nonce_spent_rejects_empty_nonce() { - let err = mark_nonce_spent(&[], "tx-1", b"sender", 100).unwrap_err(); + #[serial] + fn an_empty_nonce_is_refused() { + init_test_db(); + let binding = crate::storage::client_db::get_connection().expect("db"); + let conn = binding.lock().expect("db lock"); + let err = mark_nonce_spent_with_conn(&conn, &REL_A, &[], "tx-1", b"sender", 100) + .expect_err("an empty nonce is not spendable"); + assert!(err.to_string().contains("empty nonce")); + let err = is_nonce_spent_with_conn(&conn, &REL_A, &[]).expect_err("nor checkable"); assert!(err.to_string().contains("empty nonce")); - } - - #[test] - fn hash_blake3_bytes_is_deterministic() { - let h1 = hash_blake3_bytes(b"test-nonce-data"); - let h2 = hash_blake3_bytes(b"test-nonce-data"); - assert_eq!(h1, h2); - assert_ne!(h1, [0u8; 32]); - } - - #[test] - fn hash_blake3_bytes_different_inputs_differ() { - let h1 = hash_blake3_bytes(b"nonce-alpha"); - let h2 = hash_blake3_bytes(b"nonce-beta"); - assert_ne!(h1, h2); } #[test] #[serial] - fn mark_and_check_nonce_spent() { + fn a_nonce_is_spent_once_in_its_relationship() { init_test_db(); - + let binding = crate::storage::client_db::get_connection().expect("db"); + let conn = binding.lock().expect("db lock"); let nonce = b"unique-nonce-42"; - assert!(!is_nonce_spent(nonce).unwrap()); - - mark_nonce_spent(nonce, "tx-42", b"sender-a", 500).expect("mark spent"); - assert!(is_nonce_spent(nonce).unwrap()); + assert!(!is_nonce_spent_with_conn(&conn, &REL_A, nonce).expect("read")); + mark_nonce_spent_with_conn(&conn, &REL_A, nonce, "tx-42", b"sender-a", 500) + .expect("mark spent"); + assert!(is_nonce_spent_with_conn(&conn, &REL_A, nonce).expect("read")); + let err = mark_nonce_spent_with_conn(&conn, &REL_A, nonce, "tx-again", b"sender-a", 500) + .expect_err("a second spend in the relationship is a replay"); + assert!(err.to_string().contains("Replay attack")); } + /// Security pre-audit item 5: the same nonce bytes spent in one + /// relationship are not spent in another, so a contact cannot spend + /// another contact's predicted nonce first and have its transfer dropped. #[test] #[serial] - fn mark_nonce_spent_detects_replay() { + fn a_nonce_spent_in_one_relationship_is_not_spent_in_another() { init_test_db(); - - let nonce = b"replay-nonce"; - mark_nonce_spent(nonce, "tx-first", b"sender", 100).expect("first mark"); - - let err = mark_nonce_spent(nonce, "tx-duplicate", b"sender", 100).unwrap_err(); - assert!(err.to_string().contains("Replay attack")); + let binding = crate::storage::client_db::get_connection().expect("db"); + let conn = binding.lock().expect("db lock"); + let nonce = b"predicted-nonce"; + mark_nonce_spent_with_conn(&conn, &REL_A, nonce, "tx-a", b"contact-y", 1) + .expect("spent in A"); + assert!(!is_nonce_spent_with_conn(&conn, &REL_B, nonce).expect("read")); + mark_nonce_spent_with_conn(&conn, &REL_B, nonce, "tx-b", b"contact-x", 100) + .expect("still spendable in B"); + assert_ne!( + relationship_nonce_hash(&REL_A, nonce), + relationship_nonce_hash(&REL_B, nonce) + ); } } From b3bacd66f114175aedaaaa5564d7d14a1edfcdb3 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:40:06 -0400 Subject: [PATCH 16/31] =?UTF-8?q?docs(conformance):=20=C2=A76.67,=20core?= =?UTF-8?q?=20decoders=20and=20the=20nonce=20scope=20(pre-audit=20items=20?= =?UTF-8?q?15,=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Item 15. Each fix and its test are recorded. So is what this 64-bit macOS host cannot show: the old failures were 32-bit or allocator-specific, so no mutation turns these tests red here. Out of this round's scope: recovery capsule counts. The phone's response sizes are STORAGE's. - Item 5. The cross-sender nonce finding, its relationship-scoped fix, and its mutation control (the scope removed: the cross-relationship test red). --- specs/requirements/CONFORMANCE_GAPS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index df8f85026..21f2d1324 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2054,6 +2054,21 @@ The quote took 20 s. - **The chain walk of a vault the trade consumed, about 19 s.** Two passes over the sibling leg re-read the same four or five cells about four times each. - **Establishing the facts, 2.7 s.** +### 6.67 Core decoders bounded by their input, and a transfer nonce spent in its relationship only (`security/core-decoders-and-nonce-scope`, pre-audit items 15 and 5, 2026-10-01) + +**Item 15: decoders whose work a count in the bytes decided.** +- **The operation decoder.** `types/operations.rs` `dec_vec_bytes` preallocated `Vec::with_capacity(n)` from a 4-byte count the sender writes, before any element decoded. A recovery operation claiming `u32::MAX` authority signatures asked for about 103 GB. That is a capacity-overflow panic on a 32-bit target, and on 64-bit an abort wherever the allocator refuses. It is reached through BLE prepare `operation_data` (out of scope this round) and any other operation decode. The vector now grows only as elements decode from bytes in hand. +- **The proof decoders.** `common/device_tree.rs` `DevTreeProof::from_bytes` and `merkle/sparse_merkle_tree.rs` `SmtInclusionProof::from_bytes` checked `offset + count * 32` in `usize`. On a 32-bit target, count `2^27` wraps that to the input length, and the copies after it index past the input (a panic). The checks are now computed in `u64`. +- **Tests:** `a_count_the_remaining_bytes_cannot_carry_is_refused`, `a_proof_naming_more_siblings_than_it_carries_is_refused` and `an_inclusion_proof_naming_more_siblings_than_it_carries_is_refused` feed the hostile inputs. +- **What the dev host cannot show.** The failures were 32-bit or allocator-specific. On this 64-bit macOS host the old code also returned an error for these inputs, because macOS reserves memory lazily, so no mutation turns these tests red there. The fixes are structural: no allocation is sized by an unread count, and no length arithmetic can wrap. An armv7 run of the same tests would show the old behaviour. +- **Not in this round's scope:** `recovery/capsule.rs` sizes `HashMap::with_capacity` from capsule counts (recovery), and the phone's storage response sizes belong to STORAGE (`storage_node_sdk.rs`, `b0x_sdk.rs`). + +**Item 5: a nonce spent across every relationship.** +- **The finding.** `spent_nonces` and the canonical apply's identity lookup keyed a transfer's nonce by `H(nonce)` alone, across every relationship. A sender's nonce is `H(DSM/nonce ‖ tip ‖ amount ‖ token ‖ recipient)`, and a relationship's first tip derives from public identifiers. So contact Y could predict contact X's first payment to V, send V a small real transfer carrying that nonce, and V would drop X's payment as decided. X is debited, V is never credited, and the relationship stays blocked. +- **The fix.** The explainer names no nonce rule; replay is refused by the relationship's (relationship, parent) record (MR-DSM-0170). A nonce is now spent under `relationship_nonce_hash(rel_key, nonce) = H(DSM/relationship-nonce/v1 ‖ rel_key ‖ nonce)` in the apply's identity, in `spent_nonces` and in staging. A replay of the same transfer is still refused, because it carries the same nonce in the same relationship. The unused device-wide `is_nonce_spent` and `mark_nonce_spent` are deleted. +- **Test:** `a_nonce_spent_in_one_relationship_is_not_spent_in_another`. +- **Mutation control:** `relationship_nonce_hash` made to ignore the relationship key turns that test red. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | From 1e7b2651d9c3aee44c08fb6577a04662a72fef21 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:49:15 -0400 Subject: [PATCH 17/31] fix(core): a credit's source is held to account through its own segment (A8) Security pre-audit item 3. Owner ruling 2026-10-01, "Segment now, proofs later". - **The gap.** A credit's source step was validated, but the source's own history was authenticated by root claims alone. "Admitted" means only that the source signed its root, so two wallets of one owner could create value one hop back: C registers an invented root, pays its own A out of it, and A pays honest B. - **The fix.** `source_step` validates the source's segment from this receiver's frontier for it with `chain_through`, exactly as a payer's segment is validated. That segment's own credits' sources are held to account the same way, back to frontiers this verifier already validated. The source step itself is a debit that names no source. - **Fail closed.** When the ancestry exceeds the shared walk budget before reaching a frontier, the result is Incomplete, never weaker evidence. - **Renames.** The provenance types now say what they do: `SourceAncestry`, `DebitHasNoSources`, `StepRole::Source`, `source_step`. - **Tests.** - The source's step at 1 is validated from its witness and successor evidence. This was the old "one hop and no further" test, now inverted. - A source's segment stops at the frontier the receiver holds: nothing of C at or behind it is read. --- .../dsm/src/economic/lineage.rs | 4 +- .../dsm/src/economic/peer_lineage.rs | 99 +++++++++---------- .../handlers/frontier_verification_tests.rs | 90 +++++++++++++++-- 3 files changed, 129 insertions(+), 64 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs index 5fd58f9de..a34bd62af 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs @@ -153,8 +153,8 @@ impl ValidatedEconomicRoot { } /// A peer coordinate THIS verifier authenticated (DSM Amendment A8): its - /// recorded frontier for the peer (`peer_lineage::PeerFrontier`), or a - /// root its root chain authenticated on the way to a one-hop source. + /// recorded frontier for the peer (`peer_lineage::PeerFrontier`), whose + /// whole segment it validated before recording it. /// **Only the peer lineage verifier may call this**, from one place /// (`peer_lineage::authenticated_root`). Nothing behind a frontier is /// read again: a verification that fails from one fails. diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs index 71226d702..f07a0caea 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs @@ -18,12 +18,15 @@ //! authenticated parent, from that step's own evidence, with the SAME //! `advance_validated` any device runs. //! -//! ## One hop +//! ## Sources //! -//! A credit's direct source is checked one hop back and no further: the -//! source step is validated as a step, its parent is authenticated by the -//! source's root chain (claims only, from this verifier's frontier for the -//! source), and nothing the source step's evidence depends on is followed. +//! A credit's source is held to account like a payer (DSM Amendment A8 as +//! corrected for sources, owner 2026-10-01): its own segment from this +//! verifier's frontier for it is validated step by step, its credits' +//! sources the same way, back to frontiers this verifier already validated. +//! A root the source only registered proves nothing about where its value +//! came from. The source step itself is a debit, so nothing further is asked +//! of it. //! //! ## Conditional positions //! @@ -259,7 +262,8 @@ pub trait ConditionalPositionResolver { /// `target_position` and return the target step's validated transition. /// /// Every step from the frontier to the target is validated from its own -/// evidence; a credit's source is validated one hop back; a conditional +/// evidence; a credit's source is validated through its own segment back +/// to a frontier this receiver holds; a conditional /// position before the target is resolved by `conditional`. Nothing behind /// the frontier is read. pub fn validate_peer_lineage( @@ -461,12 +465,12 @@ struct Authenticated { /// questions go. #[derive(Clone, Copy)] enum StepRole { - /// A step of the segment from the frontier to the target: its credits' - /// sources are validated one hop back. + /// A step of a segment from a frontier: its credits' sources are held + /// to account through their own segments. Segment, - /// A credit's source, one hop back: it must be an online transfer, and - /// nothing its evidence depends on is followed. - OneHopSource, + /// A credit's source step: it must be an online transfer, a debit that + /// names no source of its own. + Source, } struct Verifier<'a> { @@ -729,7 +733,7 @@ impl Verifier<'_> { let verified = verify_dsm_successor_evidence(&successor_bytes, genesis, device_id, &facts.proven_ak) .map_err(|e| invalid(format!("successor evidence at {position}: {e}")))?; - if let StepRole::OneHopSource = role { + if let StepRole::Source = role { // A credit's source is a peer's online transfer, and only its own // step is validated here. Its write set is a debit and names no // credit source, so nothing behind it is asked for. @@ -752,11 +756,11 @@ impl Verifier<'_> { successor_addr, ); let registered = RegisteredEconomicRoot::from_verified_single_root(claim); - let one_hop = OneHop { verifier: self }; - let no_further = NoFurtherHop { verifier: self }; + let ancestry = SourceAncestry { verifier: self }; + let no_sources = DebitHasNoSources { verifier: self }; let provenance: &dyn ProvenanceResolver = match role { - StepRole::Segment => &one_hop, - StepRole::OneHopSource => &no_further, + StepRole::Segment => &ancestry, + StepRole::Source => &no_sources, }; let advanced = advance_validated( &point.root, @@ -849,10 +853,20 @@ impl Verifier<'_> { Ok(point) } - /// A credit's source, one hop back: the source's root chain from this - /// receiver's frontier for it to the position before, claims only, then - /// the source step itself validated from its own evidence. - fn one_hop_source( + /// A credit's source, held to account (DSM Amendment A8 as corrected for + /// sources, owner 2026-10-01): the source's own segment from this + /// receiver's frontier for it to the position before, every step + /// validated in full from its own evidence exactly as a payer's segment + /// is (`chain_through`, whose credits' sources are held to account the + /// same way), then the source step itself. + /// + /// A root the source merely registered is not thereby valid: "admitted" + /// means only that the source signed it, so a source walked by root + /// claims alone could invent a root and pay out of it through a second + /// wallet of its own. The walk stops at a frontier this receiver already + /// validated, and when the ancestry exceeds the budget before reaching + /// one it is `Incomplete`, never accepted on weaker evidence. + fn source_step( &self, genesis: &[u8; 32], device_id: &[u8; 32], @@ -864,26 +878,7 @@ impl Verifier<'_> { )); } let frontier = self.frontier_below(genesis, device_id, position)?; - let mut point = self.start(&frontier)?; - for chained in frontier.economic_position() + 1..position { - self.spend_step()?; - let claim = self.final_claim(genesis, device_id, chained, &point.root)?; - point = match claim { - RegisteredEconomicClaim::SingleRoot(claim) => { - self.authenticate(genesis, device_id, chained, &claim)?; - let registered = RegisteredEconomicRoot::from_verified_single_root(&claim); - ChainPoint { - root: authenticated_root(chained, registered.post_economic_root()), - accepted: Some(ParentClaimRef::SingleRoot { - claim_ref: registered.claim_ref(), - }), - } - } - RegisteredEconomicClaim::ConditionalSofi(held) => { - self.conditional_position(genesis, device_id, chained, &point, &held)? - } - }; - } + let point = self.chain_through(genesis, device_id, &frontier, position - 1)?; self.spend_step()?; match self.final_claim(genesis, device_id, position, &point.root)? { RegisteredEconomicClaim::SingleRoot(claim) => self.full_step( @@ -892,7 +887,7 @@ impl Verifier<'_> { position, &point, &claim, - StepRole::OneHopSource, + StepRole::Source, ), RegisteredEconomicClaim::ConditionalSofi(_) => Err(invalid(format!( "position {position}: the credit's source is a conditional SoFi position, not an \ @@ -902,27 +897,27 @@ impl Verifier<'_> { } } -/// A root this verifier authenticated at `position`: its frontier, or a root -/// its root chain authenticated (DSM Amendment A8). Never a root read from a -/// network without that chain, and never a peer's word. +/// A root this verifier authenticated at `position`: its recorded frontier, +/// whose whole segment it validated before recording it (DSM Amendment A8). +/// Never a root read from a network, and never a peer's word. fn authenticated_root(position: u64, root: [u8; 32]) -> ValidatedEconomicRoot { ValidatedEconomicRoot::from_verifier_memo(position, root) } -/// The provenance of a segment step's credits: each source validated one hop -/// back. -struct OneHop<'v, 'a> { +/// The provenance of a segment step's credits: each source validated +/// through its own segment, back to a frontier this verifier holds. +struct SourceAncestry<'v, 'a> { verifier: &'v Verifier<'a>, } -/// The provenance of a one-hop source step. That step is an online transfer, +/// The provenance of a credit's source step. That step is an online transfer, /// whose write set is a debit: it names no credit source, so a question about /// one means the step claims a credit an online transfer does not carry. -struct NoFurtherHop<'v, 'a> { +struct DebitHasNoSources<'v, 'a> { verifier: &'v Verifier<'a>, } -impl ProvenanceResolver for OneHop<'_, '_> { +impl ProvenanceResolver for SourceAncestry<'_, '_> { fn validated_peer_transition( &self, peer_genesis: &[u8; 32], @@ -930,7 +925,7 @@ impl ProvenanceResolver for OneHop<'_, '_> { peer_economic_position: u64, ) -> Result { self.verifier - .one_hop_source(peer_genesis, peer_devid, peer_economic_position) + .source_step(peer_genesis, peer_devid, peer_economic_position) } fn native_reserve_release( @@ -968,7 +963,7 @@ impl ProvenanceResolver for OneHop<'_, '_> { } } -impl ProvenanceResolver for NoFurtherHop<'_, '_> { +impl ProvenanceResolver for DebitHasNoSources<'_, '_> { fn validated_peer_transition( &self, peer_genesis: &[u8; 32], diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs index 56e7ed451..9d8431dc0 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs @@ -227,15 +227,16 @@ async fn a_receiver_reads_nothing_behind_its_frontier() { assert!(asked.contains(&past), "B read A's cell past its frontier"); } -/// A credit's source is validated one hop back and no further. C pays A, -/// and A pays B; B meets both first. B validates A's credit step, and for -/// its source validates C's paying step from its own evidence, over C's -/// root chain from C's activation root: C's claim at 1 is read and -/// authenticated by its manifest, and none of the evidence behind C's step -/// at 1 is read. +/// A credit's source is held to account through its own segment (DSM +/// Amendment A8 as corrected for sources, owner 2026-10-01). C pays A, and A +/// pays B; B meets both first. B validates A's credit step and, for its +/// source, C's whole segment from C's activation root: C's step at 1 is +/// validated from its own evidence, its witness and successor evidence read, +/// before C's paying step at 2. A root C only registered would prove nothing +/// about where its value came from. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[serial] -async fn a_credits_source_is_validated_one_hop_back_and_no_further() { +async fn a_credits_source_is_validated_through_its_own_segment() { use dsm::common::domain_tags::{ TAG_DSM_ECONOMIC_ADMISSION_MANIFEST, TAG_DSM_ECONOMIC_SUCCESSOR_EVIDENCE, TAG_DSM_ECONOMIC_TRANSITION_WITNESS_OBJ, @@ -276,7 +277,7 @@ async fn a_credits_source_is_validated_one_hop_back_and_no_further() { else { panic!("C's faucet claim rides a DSM successor"); }; - let behind_the_hop = [ + let the_sources_first_step = [ object_read( TAG_DSM_ECONOMIC_TRANSITION_WITNESS_OBJ, &manifest.transition_witness_addr, @@ -303,10 +304,79 @@ async fn a_credits_source_is_validated_one_hop_back_and_no_further() { asked.contains(&authenticated), "B authenticated C's claim at 1 by its manifest" ); - for read in &behind_the_hop { + for read in &the_sources_first_step { + assert!( + asked.contains(read), + "B did not validate the source's step at 1 from its evidence: {read}" + ); + } +} + +/// A source's segment stops at the frontier the receiver already holds for +/// it (owner 2026-10-01: "validate that source identity's economic segment +/// backward until reaching a frontier that this verifier has already fully +/// validated"). C pays B first, so B holds a frontier for C at C's paying +/// step. Then C pays A, and A pays B. Validating A's credit, B walks C from +/// that frontier: it reads C's cell past it, and none of C's cells at or +/// behind it. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_sources_segment_stops_at_the_frontier_the_receiver_holds() { + let p = Pair::boot(100, 0).await; + let mut c = TestDevice::create("C", 0x0C); + c.boot(&p.fleet).await; + for peer in [&p.a, &p.b] { + c.add_contact(peer).await; + peer.add_contact(&c).await; + } + c.fund_admitted(100).await; + let to_b = c.send(&p.b, 7).await; + assert!(to_b.success, "{:?}", to_b.error_message); + let first = p.b.sync().await; + assert!(first.success, "{:?}", first.errors); + assert_eq!(p.b.era_balance(), 7); + + c.enter(); + let (paid_b_at, _) = client_db::economic_lineage::get_admitted_coordinate() + .expect("read") + .expect("C admitted its debit to B"); + p.b.enter(); + let frontier = + client_db::economic_lineage::frontier_below(&c.genesis, &c.device_id, paid_b_at + 1) + .expect("the frontier store") + .expect("B's frontier for C"); + assert_eq!(frontier.economic_position(), paid_b_at); + + let to_a = c.send(&p.a, 30).await; + assert!(to_a.success, "{:?}", to_a.error_message); + let credited = p.a.sync().await; + assert!(credited.success, "{:?}", credited.errors); + let sent = p.a.send(&p.b, 20).await; + assert!(sent.success, "{:?}", sent.error_message); + + c.enter(); + let mut parent = dsm::economic::tree::empty_economic_root(); + let mut behind = Vec::new(); + for position in 1..=paid_b_at { + behind.push(cell_read(&c.genesis, &c.device_id, position, &parent)); + parent = admitted(position).0; + } + let past = cell_read(&c.genesis, &c.device_id, paid_b_at + 1, &parent); + for node in &p.nodes.nodes { + node.forget_requests(); + } + let received = p.b.sync().await; + assert!(received.success, "{:?}", received.errors); + assert_eq!(p.b.era_balance(), 27); + let asked = requests(&p.nodes); + assert!( + asked.contains(&past), + "B read C's paying step to A, past its frontier" + ); + for read in &behind { assert!( !asked.contains(read), - "B read the evidence behind the one-hop source: {read}" + "B read C's cell behind the frontier it holds for C: {read}" ); } } From f53af76794c63bc6b45cf4cf7e9d906a25967f6b Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:54:06 -0400 Subject: [PATCH 18/31] test(core): a source walk past the budget is Incomplete and reads nothing Owner ruling 2026-10-01, pre-audit item 3, test 4: a source's ancestry that exceeds the walk budget before a frontier this verifier holds is Incomplete, never accepted on weaker evidence. --- .../dsm/src/economic/peer_lineage.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs index f07a0caea..17a99c567 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs @@ -1211,6 +1211,38 @@ mod tests { } } + /// Owner ruling 2026-10-01 (pre-audit item 3): a credit's source whose + /// ancestry exceeds the walk budget before reaching a frontier this + /// verifier holds is `Incomplete`, never accepted on weaker evidence, and + /// nothing past the spent budget is read. + #[test] + fn a_source_walk_past_the_budget_is_incomplete_and_reads_nothing() { + let fetcher = CountingCells { + inner: ConditionalCellFetcher { + position: 3, + writes: Vec::new(), + last: crate::route_chain::ROUTE_LEN - 1, + }, + reads: std::cell::Cell::new(0), + }; + let verifier = Verifier { + fetcher: &fetcher, + expected_network_id: NETWORK, + register: Register::resolve(&fetcher, NETWORK).expect("the network's register"), + frontiers: &NoneRecorded, + conditional: &NoResolution, + steps_remaining: std::cell::Cell::new(0), + }; + match verifier.source_step(&PEER_G, &PEER_D, 3) { + Err(PeerLineageFailure::Incomplete(why)) => { + assert!(why.contains("budget exhausted"), "{why}") + } + Err(other) => panic!("a spent budget is Incomplete, got {other:?}"), + Ok(_) => panic!("a spent budget validated a source"), + } + assert_eq!(fetcher.reads.get(), 0, "nothing of the source was read"); + } + /// DSM Amendment A8, `peer_root_at`: a frontier this receiver recorded AT /// the position is the answer, and nothing of the peer is read: the walk /// stands below `position + 1`, which the recorded frontier already is. A From b9401fc91fe086eaa9ab9c0a3189d0ce68a8a92c Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:03:11 -0400 Subject: [PATCH 19/31] test(sdk): a walk an outage cut short records no frontier; recovery records one A frontier becomes trusted only once the whole segment to it has passed (A8, owner ruling 2026-10-01, pre-audit item 3 test 5). --- .../src/handlers/recipient_admission_tests.rs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_admission_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_admission_tests.rs index 97d6e6365..7fc28ef7a 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_admission_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_admission_tests.rs @@ -367,9 +367,11 @@ async fn the_same_sender_debit_cannot_fund_a_second_credit() { } /// An outage during prevalidation HOLDS the transfer with zero durable state -/// (no pending admission, no journal, staging not accepted), and the SAME row -/// completes when the fleet returns — an outage is never an attack and never -/// a wedge. +/// (no pending admission, no journal, no frontier, staging not accepted), and +/// the SAME row completes when the fleet returns — an outage is never an +/// attack and never a wedge. A frontier becomes trusted only once the whole +/// segment to it has passed (A8, owner ruling 2026-10-01): a walk the outage +/// cut short leaves none. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[serial] async fn an_outage_holds_the_transfer_cleanly_and_it_recovers() { @@ -408,6 +410,12 @@ async fn an_outage_holds_the_transfer_cleanly_and_it_recovers() { 0, "no journal row during the hold" ); + assert!( + client_db::economic_lineage::frontier_below(&p.a.genesis, &p.a.device_id, EVERY_POSITION) + .unwrap() + .is_none(), + "no frontier for A from a walk the outage cut short" + ); // Fleet back: the SAME row proceeds through the full admission. p.nodes.bring_up(&down).await; @@ -423,4 +431,13 @@ async fn an_outage_holds_the_transfer_cleanly_and_it_recovers() { 1, "the credit admitted" ); + assert!( + client_db::economic_lineage::frontier_below(&p.a.genesis, &p.a.device_id, EVERY_POSITION) + .unwrap() + .is_some(), + "the frontier for A recorded with the acceptance" + ); } + +/// Every position a frontier can be recorded at lies below this one. +const EVERY_POSITION: u64 = i64::MAX as u64; From b45eb77d88bc781957ec57d967b8a64ed46ffdaa Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:06:11 -0400 Subject: [PATCH 20/31] =?UTF-8?q?docs(conformance):=20pre-audit=20item=201?= =?UTF-8?q?2,=20junk=20cannot=20exhaust=20a=20walk=20or=20hold=20a=20key?= =?UTF-8?q?=20(=C2=A76.66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §6.66 records item 12's findings apart from their fixes: - 12a–12d closed by `96b870fdd`, each with its adversarial test, red on the old code, and its mutation controls. - 12j: a fulfillment's registration at K_ful(q) depended on when the precommit it names is published. A trader can make early verifiers see its trade realized and later ones see the position unresolvable, with the vault key frozen. Confirmed by a probe on the nodes. The closure is agreed with CORE: decide K_ful(q) from F's bytes alone, per the item-1 ruling. In progress. - Open: 12e (the exercise carries the signed C_q, S20), 12f (a parent on a lineage known invalid), 12g and 12h (owner rulings), and 12i (locators computable in advance). §8 corrections: - MR-SOFI-0244 was Met on the pure walk while three of its callers stopped at the budget. - MR-SOFI-0213, MR-SOFI-0170, MR-STOR-0132 and MR-SOFI-0241 name the new gates and tests. - MR-SOFI-0234 is Partial on 12f. §7 is regenerated. Matrix rows for items 2, 6 and 12, the first two of which had none. --- specs/requirements/CONFORMANCE_GAPS.md | 112 ++++++++++++++++++++-- specs/requirements/VERIFICATION_MATRIX.md | 6 ++ 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index fe6d06bfe..868f34280 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2189,16 +2189,112 @@ The timeouts are `Duration`s handed to the HTTP and pool libraries. `no_clock_re **Open, SOFI's sync design:** in `pull_and_process_inbox`, an entry that never becomes a transfer counts against the sync's limit and is never consumed. Routes the limit leaves unread are not reported, and reporting them would make every limit-capped sync fail (`inbox_incomplete` stops the sync). Both depend on what a limit-capped sync means. +### 6.66 Junk can no longer exhaust a SoFi walk, or hold a key or a position for good (`security/beta-pre-audit`, pre-audit item 12, 2026-10-01) + +This is the owner's beta security pre-audit, item 12 (P1): "cheap garbage must not exhaust bounded walks or occupy critical positions indefinitely". It was audited at main `076792eac`. CORE named the paths, and each was confirmed here before anything changed: the four that are closed by an adversarial test that went red on the old code, 12j by a probe on the nodes, and the rest by reading the code, as each says. An adversarial pass over the fixes (2026-10-01; the independent critique twice rated 12c fatal, both times on premises the code refutes) found 12j. + +12a to 12i are about availability: none lets junk move value or change a verdict. What junk could do was make a vault's key, a trader's position or a registration read undecidable for good, which stops every trade at that vault or every payment that walks that trader's lineage. 12j is about safety: a verdict that depends on when it is read. + +**12a. The walk's budget ended walks (closed).** + +| Field | Record | +|---|---| +| Severity | High | +| Files | `dsm/src/sofi/resolve.rs` · `Verifier::acquire_prior_attempts`, `PRIOR_ATTEMPT_BUDGET` (removed), `Verifier::next_generation`, `Verifier::read_legs` | +| Exploit | Any identity writes signed exercises that its own bytes refute, one per key, final at `K^(0)` through `K^(16)` of a vault's current root. Each costs one signed exercise and consumes nothing. Three readers stopped at the walk's budget of 16 keys. (1) Conformance item 5 needs only `K^(a-1)`, but the verifier read `K^(0)` through `K^(min(a,16)-1)`, so a fulfillment at `K^(17)` never had `K^(16)` and was missing evidence forever. (2) Extending a vault's chain treated `Continue` as a stop, so no verifier could extend the chain past a root whose first 16 keys junk held. (3) A leg's liveness walk examined at most 16 keys, so a leg at attempt 17 or later never established its liveness. Together they froze the vault at that root: no trade could realize there, and no reader could find the head past it. | +| Violates | SoFi §23.6 (the walk is budgeted, and running it in chunks gives the same result as running it at once); §20.2 item 5 (`K^(a_j-1)`, that key alone); §5.2. MR-SOFI-0244, MR-SOFI-0213, MR-SOFI-0232. MR-SOFI-0244 was Met on the pure `walk` function alone; its callers broke it. | +| Verification | `dsm_sdk::handlers::node_e2e_tests::junk_at_more_attempt_keys_than_one_walk_examines_is_walked_past`. On the old code, B's trade past the junk ended `RetriesExhausted`. | +| Closure condition | The budget chunks every walk and ends none. Item 5 reads exactly `K^(a-1)`. A trade past any amount of junk realizes, and a fresh verifier walks the chain past it. | +| Status | Closed. Residual cost: a walk now passes any number of junk keys. Each costs the walker one cell read and, while that walk lasts, the exercise held there. Nothing yet bounds how much an attacker can write: write budgets are open for the owner (§6.64). | + +**12b. An exercise's closure was bound only by its count (closed).** + +| Field | Record | +|---|---| +| Severity | High | +| Files | `dsm/src/sofi/exercise.rs` · `recognize_exercise`; `dsm/src/sofi/conformance.rs` · `closure_object_verifies` (now `pub(crate)`) | +| Exploit | Once `F` registers, `F`, `P`, `P(E)` and the witnesses are public. Anyone could rebuild the trader's exercise with other bytes in place of its closure objects. It recognized as the same exercise, because recognition counted the closure objects and checked nothing else, and identity is `E`. Written first at a leg's leader, before the trader's own write landed or while it could not reach the leader, it held the key. Every verifier then took the closure from those bytes, the trader included (R13 reads the exercise back from the cell). Conformance item 8 found the objects missing forever: bytes that do not re-derive are not evaluated (S3, S12). The position never resolved, and the vault's walk stopped at that key. | +| Violates | SoFi §17.5: everything in the exercise besides `F` is bound to `F` by hashed preimages, and a value that names nothing Core can classify cannot occupy a successor key. MR-SOFI-0170. | +| Verification | `dsm_sdk::handlers::node_e2e_tests::an_exercise_carrying_other_bytes_than_its_closure_holds_no_key`. On the old code, the copy held the key. | +| Closure condition | An exercise recognizes only when each closure object is the object its reference in `𝒞_E^pre` names, by the rule conformance item 8 uses. A copy carrying any other object is not that exercise, holds no key, and the trader's exercise behind it does. | +| Status | Closed. | + +**12c. A registration read fetched the precommit of every fulfillment any seat held (closed for everything outside the leader's first one; the rest is item 1).** + +| Field | Record | +|---|---| +| Severity | High | +| Files | `dsm/src/sofi/resolve.rs` · `Verifier::read_registration` (and `carried_values`, removed); `dsm/src/route_chain.rs` · `leader_copies` (new), `leader_link` | +| Exploit | `read_registration` collected every fulfillment envelope any seat held at `K_ful(q)` and fetched the `P` each named. The read failed if any `P` could not be read, or if more than 64 were named. Only the leader's log decides the cell: the first copy there that names the key (MR-STOR-0132). So values at other seats, or after that copy, decided nothing, yet each could stop the read. `K_ful(q)`'s key is `(G, DevID, q)`, and members keep everything, so anyone could write such values at any time, even after the position resolved. One fulfillment naming a precommit whose only index candidate one member alone holds was enough. Every later read of that registration then failed: a walk meeting the trader's exercise stopped there, and so did any A8 walk through the trader's position. Before item 1 any key could sign the fulfillment. After it, only the trader's own key can, and the trader's own superseded or crashed fulfillments (§25) are exactly such values. | +| Violates | SoFi §17.4 (registration is `Final(K_ful(q), F) ∧ Final(K_root(q), C_q)`); storage §9 and MR-STOR-0132 (the leader link is the first recognized object in the leader's log; nothing else counts); §5.2. | +| Verification | `dsm_sdk::handlers::node_e2e_tests::nothing_but_the_leaders_first_fulfillment_decides_k_ful`. On the old code, the vault owner's walk failed with "a precommit a candidate names could not be read". | +| Closure condition | `P` is fetched only for the fulfillments in the leader's log, in arrival order, and only until one names the key. The reader takes those copies from the same function the leader link is chosen from, `route_chain::leader_copies`, so it cannot fetch for a different set than the evaluation considers. | +| Status | Closed for every value outside the leader's first recognized copy. A fulfillment ahead of the trader's in the leader's log, naming a precommit that cannot be read, still stops the read. That needs no race: the key is known in advance. It is closed by pre-audit item 1 (CORE, §6.65): a fulfillment must prove the device (`fulfillment_proves_the_device`), checked before any fetch, so another key's fulfillment is dismissed from its own bytes. The call goes into this loop when item 1 lands. 12j's closure supersedes the loop: once registration reads no `P`, nothing a fulfillment names can stop it. | + +**12d. A conditional parent held by another claim left the key waiting (closed).** + +| Field | Record | +|---|---| +| Severity | High | +| Files | `dsm/src/sofi/facts.rs` · `parent_position` (new), `establish_ground`; `dsm/src/sofi/resolution.rs` · `ParentPosition::ConditionalNoRoot` (doc) | +| Exploit | A `P` whose conditional parent names a fulfillment that does not hold `p`, because another fulfillment's claim or an ordinary claim does, was "not established" forever. The facts returned `ParentUnresolved`. So the key its exercise held was never skipped, and a walk stopped there for good. Anyone can make one with an identity of its own: its own `P`, naming a fulfillment its own lineage does not hold. `ParentPosition::ConditionalNoRoot` (impossible, §23.5 arm iv) existed in the ladder, but nothing produced it. | +| Violates | SoFi §23.3: `TraderParentImpossible(P)` holds when `C_p` is terminal and selected no root. A claim `P` names that another claim holds at `p` can never register there (pair mutual exclusion), so it selects no root, ever. §23.5 arm (iv) exists so that an impossible operation cannot strand a DLV key. MR-SOFI-0235. | +| Verification | `dsm::sofi::facts::tests::a_conditional_parent_another_claim_holds_selects_no_root`. The ladder's tests (`the_trader_parent_arm_is_monotone` and the exhaustive ladder sets) show that `ConditionalNoRoot` resolves Invalid and skips. No node test: the fixtures build only single-root parents. | +| Closure condition | A conditional parent counts only as what the lineage holds at `p`. Held as `P` names it, it selected the root it holds. Held by any other claim, it selects no root, so the position is Invalid and its keys are skipped without evidence. This also restores the case `parent_named`'s test covered before §6.62 replaced it (MR-SOFI-0356). | +| Status | Closed. | + +**Mutation controls (2026-10-01, each removed alone; source restored byte for byte).** + +| Mutation | Result | +|---|---| +| Item 5 reads only the keys below the walk's budget | `junk_at_more_attempt_keys_than_one_walk_examines_is_walked_past` red: B's trade past the junk ends `RetriesExhausted` | +| Extending a chain stops where the budget ends | The same test red: the owner's fresh walk stops at 2 roots, not 3 | +| A leg's liveness walk stops where the budget ends | The same test red: B's trade ends `RetriesExhausted` | +| Recognition checks only the closure's count | `an_exercise_carrying_other_bytes_than_its_closure_holds_no_key` red: the copy holds the key | +| Every fulfillment in the leader's log has its precommit fetched | `nothing_but_the_leaders_first_fulfillment_decides_k_ful` red: the owner's walk fails, "a precommit a candidate names could not be read" | +| Every seat's fulfillments have their precommit fetched | The same test red: the read of `q + 1` fails the same way | +| A conditional parent another claim holds is no fact | `a_conditional_parent_another_claim_holds_selects_no_root` red: `None` where `ConditionalNoRoot` belongs | + +**12j. Which fulfillment holds `K_ful(q)` depended on when a precommit is published (open; the fix is agreed and in progress).** + +| Field | Record | +|---|---| +| Severity | Critical: a verdict that depends on when it is read, and a vault key that freezes | +| Files | `dsm/src/sofi/registration.rs` · `names_fulfillment_key`, `fulfillment_at`, `registration_of`; `dsm/src/sofi/resolve.rs` · `Verifier::read_registration`; `dsm/src/sofi/facts.rs` · `establish_ground` (S14's exception for `F`'s own claim) | +| Exploit | Recognizing a fulfillment at `K_ful(q)` read the `P` it names. A `P` with nothing yet published under its id (`Resolved::None`) made that fulfillment "not recognized", and the reader moved on to the next copy. But `P` can be published later. A trader writes `F_a`, naming its own unpublished `P_a`, first at its `K_ful(q)` leader, then trades normally. Early verifiers read `F_b` registered, and the trade realizes. Once the trader publishes `P_a`, a fresh verifier reads `F_a` as the leader link (leader-held only), and `K_root(q)` holding `C_q(F_b)`: `RootTaken`. `F_b` is then neither registered nor lost, because S14 exempts `F`'s own claim, so `q` can never resolve and the key `F_b`'s exercise holds is frozen. A counterparty paid in the meantime holds what no later verifier accepts, and since nothing is recorded (A1, S1) the views never reconcile. S20's device binding does not stop it: the trader signs both. | +| Violates | SoFi §24 (resolution is local, deterministic and permanent); storage §4 (a fact not established is never read as its negation); the item-1 ruling (owner, 2026-10-01): "If an object can win/occupy a root position, the object itself proves authority for that position." Reading `P` to decide the winner contradicts it. | +| Verification | Probe on the nodes at `96b870fdd`. B's trade is `(q, Realized)`, and a fresh verifier reads `Registered(F_b)`. After `P_a` is published, another fresh verifier reads `RootTaken`. The probe is not committed: it lands as the adversarial test, red to green, with the fix. | +| Closure condition | Agreed with CORE (2026-10-01). (1) Which `F` holds `K_ful(q)` is decided from `F`'s bytes alone: it verifies under its own key, names `q`, and its key and `AttA` derive the cell's `DevID`. (2) Registration pairs the two cells by `F`'s id, without `P`. (3) A position is lost for `F` whenever another `F` holds `K_ful(q)`'s leader link, in any state (§23.1: leader-held already settles it), or `K_root(q)` names another fulfillment. (4) The full `C_q` body is checked against `(P, F)` at resolution, and is Invalid when it differs (S15). CORE writes the S20 extension; SOFI implements it, with the probe as the test. | +| Status | Open, in progress. | + +**Open: found here, not built.** +- **12e. The exercise must carry the trader's signed `C_q` (S20, MR-SOFI-0362, Missing).** Once item 1 makes `C_q` trader-signed, a relayer can no longer author it. A trader that writes its exercise at a vault's key and never publishes its signed `C_q` would then hold that key forever: nobody else can register its `F`, so rung 0 holds and no skip applies. The closure is that the exercise carries the signed `C_q` and recognition checks it, so any relayer completes the pair from the exercise's own bytes. SOFI builds it after CORE's `F` change lands. +- **12f. A conditional parent that resolved Invalid.** §23.3 makes this parent impossible too (`C_p` terminal, no root). The A8 walk reports an Invalid position as a failure, so `trader_root_at` fails, the facts see no parent, and the key waits forever. A single-root parent whose lineage is known invalid at or before `p` is the same case, as Amendment S13 already decides for setups. The walk's verdict has to reach the facts as a terminal parent. Mine, next. Verification: read at the code. +- **12g. A defeated route strands its other legs (owner ruling).** A route is Void when a reserved key's leader holds another exercise first (§24 rung 8). That is not an arm of `RouteImpossible` (§23.5 arms i–iv), so the route's other final cells are skipped only once the lost leg's parent is consumed elsewhere or orphaned. A trader that owns a vault no one else trades can hold another vault's key indefinitely: route through both, leg 1 final, leg 2's key taken first by its own other exercise. Like S14, this is a fact about `F`, not about `P` and `E`. It needs an amendment: a final cell whose fulfillment's reserved key went to another exercise is skipped. Verification: read at the code (`route_impossible`, `classify_attempt`). +- **12h. Withheld evidence holds a key (owner ruling).** A registered exercise whose trader never publishes an object conformance or validation needs, such as the setup a leg names, can never be classified. §5.3 lets that position stay unresolved, but none of the skips applies, so it also holds the vault's key forever. The spec's remedy is the challenge rule (Amendment S1, storage §9.1), and Amendment S7 puts it outside beta. Verification: read at the code. +- **12i. Locators anyone can compute in advance can be flooded first (owner ruling, and one fix in reach).** An index scan that exceeds its budget is Unavailable (§11). A locator computable before the honest object exists can be filled first with appends of junk, past the budget, for good. + - **The relationship index `H(G, DevID, v)`.** `own_setup_ref` finds the device's own setup there. 64 appends before the device's setup make every later trade or close with that vault fail, and an owner that has not set up with its own vault can then never close it. In reach: the device keeps its own setup's `ρ`. Mine, next. + - **`vault_token_locator(t)` (S16).** Vaults created after a flood are not found by discovery. This needs a design decision. + + Verification: read at the code. + +**Suites.** +- `dsm` `sofi::` and `route_chain::`: 267 passed, 0 failed. +- SDK at `96b870fdd` (node e2e, sender and recipient admission, token create, faucet, frontier verification, realized records, sofi_flow, route_seats, storage_node_sdk, storage_io, sofi_reads, sofi_advance, b0x_sdk, contacts): 129 passed, 1 failed at harness boot. `a_realized_trade_shows_in_balances_and_history_at_once` stopped at its funding claim ("no final release established at reserve generation 1") while other sessions' boards loaded the shared Postgres, the same flake CORE saw; run alone at the same commit it passed. +- `make lint` exit 0; the guard clean. + +**Fix:** commit `96b870fdd` on `security/beta-pre-audit`. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| | DSM high-level (MR-DSM) | 276 | 95 | 95 | 39 | 0 | 29 | 18 | -| SoFi (MR-SOFI) | 359 | 234 | 86 | 18 | 4 | 17 | 0 | +| SoFi (MR-SOFI) | 359 | 233 | 87 | 18 | 4 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 65 | 18 | 56 | 0 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **939** | **403** | **200** | **114** | **4** | **64** | **154** | +| **All** | **939** | **402** | **201** | **114** | **4** | **64** | **154** | ## 8 Per-requirement results @@ -2656,7 +2752,7 @@ The timeouts are `Duration`s handed to the HTTP and pool libraries. `no_clock_re | MR-SOFI-0167 | Met | `dsm::sofi::derive::resolution_claim`; `dsm::sofi::registration` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim` | — | | MR-SOFI-0168 | Met | `dsm::sofi::registration::fulfillment_registered` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim` | — | | MR-SOFI-0169 | Met | `dsm::sofi::registration` (Final adapter, first-writer-wins) | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim` | — | -| MR-SOFI-0170 | Met | `dsm::sofi::wire::objects::SofiExercise`; `dsm::sofi::exercise::recognize_exercise` | `dsm::sofi::exercise::tests::an_exercise_round_trips_and_rebuilds_into_bound_objects`; `dsm::sofi::exercise::tests::an_exercise_counts_only_when_f_and_p_are_signed_under_the_key_p_commits`; `dsm::sofi::publication::tests::an_envelope_is_recognized_only_when_its_signature_verifies_under_its_bodys_key` | "The signed F, the signed P": recognition verifies both (§6.23). | +| MR-SOFI-0170 | Met | `dsm::sofi::wire::objects::SofiExercise`; `dsm::sofi::exercise::recognize_exercise` | `dsm::sofi::exercise::tests::an_exercise_round_trips_and_rebuilds_into_bound_objects`; `dsm::sofi::exercise::tests::an_exercise_counts_only_when_f_and_p_are_signed_under_the_key_p_commits`; `dsm::sofi::publication::tests::an_envelope_is_recognized_only_when_its_signature_verifies_under_its_bodys_key`; `dsm_sdk::handlers::node_e2e_tests::an_exercise_carrying_other_bytes_than_its_closure_holds_no_key` | "The signed F, the signed P": recognition verifies both (§6.23). "Every closure object": recognition checks each against its reference in `𝒞_E^pre` by conformance item 8's rule (§6.66 12b); until 2026-10-01 it counted them. | | MR-SOFI-0171 | Met | `dsm::sofi::exercise::exercise_names_key` | `dsm::sofi::exercise::tests::an_exercise_names_exactly_the_keys_its_fulfillment_and_precommit_name`; `dsm::sofi::exercise::tests::a_successor_key_resolves_to_the_e_of_the_exercise_that_names_it_or_stays_open` | — | | MR-SOFI-0172 | Met | `dsm::sofi::wire::objects::SettlementPreimage` (strict decode, L2095-2121) | `dsm::sofi_v8_independent::decoders_refuse_wrong_envelopes_truncation_and_trailing_bytes` | — | | MR-SOFI-0173 | Met | `dsm::sofi::wire::objects::SettlementBody`; `dsm::sofi::validation::check_settlement_core_references` | `dsm::sofi_v8_vault_bytes::both_settlement_branches_and_both_authorities_match_and_round_trip`; `dsm::sofi::validation::tests::a_settlement_body_naming_another_core_is_invalid`; `dsm::sofi::validation::tests::a_settlement_body_naming_another_trader_core_or_parent_is_invalid` | The cited every_union_variant_round_trips never builds a SettlementBody; the independent-encoder test checks the Swap fields, and validation binds the trader and vault-sorted core digests. | @@ -2699,7 +2795,7 @@ The timeouts are `Duration`s handed to the HTTP and pool libraries. `no_clock_re | MR-SOFI-0210 | Met | `dsm::sofi::conformance::key_is_the_precommitted_one` | `dsm::sofi::conformance::tests::item_2_f_is_signed_under_the_key_p_committed` | — | | MR-SOFI-0211 | Met | `dsm::sofi::conformance::policy_set_is_canonical` | `dsm::sofi::conformance::tests::item_3_the_policy_fulfillment_set_is_the_canonical_set_of_p` | — | | MR-SOFI-0212 | Met | `dsm::sofi::conformance::attempts_cover_legs` | `dsm::sofi::conformance::tests::item_4_the_attempts_cover_exactly_the_legs_of_p_with_no_holes` | — | -| MR-SOFI-0213 | Met | `dsm::sofi::conformance::fulfillment_conformance` | `dsm::sofi::conformance::tests::item_5_an_earlier_attempt_must_be_final` | The cited test was renamed to item_5_an_earlier_attempt_must_be_final; it waits on an earlier key that is open, leader-held or preserved, and accepts one final on any E. | +| MR-SOFI-0213 | Met | `dsm::sofi::conformance::fulfillment_conformance`; `dsm::sofi::resolve::Verifier::acquire_prior_attempts` | `dsm::sofi::conformance::tests::item_5_an_earlier_attempt_must_be_final`; `dsm_sdk::handlers::node_e2e_tests::junk_at_more_attempt_keys_than_one_walk_examines_is_walked_past` | The cited test was renamed to item_5_an_earlier_attempt_must_be_final; it waits on an earlier key that is open, leader-held or preserved, and accepts one final on any E. Corrected 2026-10-01 (§6.66 12a): the acquisition read `K^(0)` to `K^(min(a,16)-1)`, so a fulfillment at attempt 17 or later never had the one key item 5 names; it reads exactly `K^(a-1)`. | | MR-SOFI-0214 | Met | `dsm::sofi::conformance` (item 6, L438-475) | `dsm::sofi::conformance::tests::item_6_setup_registered_holds_for_every_leg` | — | | MR-SOFI-0215 | Met | `dsm::sofi::conformance` (items 7/8, L477-549) | `dsm::sofi::conformance::tests::item_7_identities_and_bounds_hold` | — | | MR-SOFI-0216 | Met | `dsm::sofi::conformance::fulfillment_conformance`; `dsm::sofi::resolution::resolve_position` | `dsm::sofi::resolution::tests::registration_is_not_conformance`; `dsm::sofi::resolution::tests::realized_requires_conformance` | fulfillment_conformance takes no registration input, and the resolution ladder resolves a registered but non-conforming F as Invalid, never Realized. | @@ -2720,17 +2816,17 @@ The timeouts are `Duration`s handed to the HTTP and pool libraries. `no_clock_re | MR-SOFI-0231 | Met | `dsm::sofi::resolution::walk`; `dsm::sofi::resolution::classify_attempt` | `dsm::sofi::resolution::tests::the_walk_is_chunking_equivalent` | — | | MR-SOFI-0232 | Met | `dsm::sofi::resolution::walk`; `dsm::sofi::resolution::classify_attempt` | `dsm::sofi::resolution::tests::the_walk_is_chunking_equivalent` | — | | MR-SOFI-0233 | Met | `dsm::sofi::resolution::consumed_route` | `dsm::sofi::resolution::tests::a_route_with_one_leg_still_open_is_not_consumed` | — | -| MR-SOFI-0234 | Met | `dsm::sofi::resolution::trader_parent_compatible`; `dsm::sofi::resolution::trader_parent_impossible` | `dsm::sofi::resolution::tests::the_trader_parent_arm_is_monotone` | — | +| MR-SOFI-0234 | Partial | `dsm::sofi::resolution::trader_parent_compatible`; `dsm::sofi::resolution::trader_parent_impossible`; `dsm::sofi::facts::parent_position` | `dsm::sofi::resolution::tests::the_trader_parent_arm_is_monotone`; `dsm::sofi::facts::tests::a_conditional_parent_another_claim_holds_selects_no_root` | Terminal absence is established when another claim holds `p` (§6.66 12d; until 2026-10-01 that parent was not established, so nothing built on it was ever skipped). Partial: a conditional parent that resolved Invalid still reaches the facts as not established, because the A8 walk's Invalid verdict does not reach them (§6.66 12f). | | MR-SOFI-0235 | Met | `dsm::sofi::resolution::trader_parent_compatible`; `dsm::sofi::resolution::trader_parent_impossible` | `dsm::sofi::resolution::tests::the_trader_parent_arm_is_monotone` | — | | MR-SOFI-0236 | Met | `dsm::sofi::resolution::classify_attempt`; `dsm::sofi::resolution::consumed_route` (one E per route) | `dsm::sofi::resolution::tests::legs_final_on_different_commitments_are_not_one_route` | — | | MR-SOFI-0237 | Met | `dsm::sofi::resolution::route_impossible` | `dsm::sofi::resolution::tests::a_stranded_final_cell_of_an_impossible_route_is_skipped` | — | | MR-SOFI-0238 | Met | `dsm::sofi::resolution::fulfillment_impossible`; `dsm::sofi::resolution::classify_attempt` | `dsm::sofi::resolution::tests::a_final_cell_of_a_conformance_invalid_fulfillment_is_skipped`; `dsm::sofi::resolution::tests::an_in_hand_refutation_answers_as_the_complete_facts_do` | Re-verified on main 2026-09-30 (`docs/sofi-stale-violated-rows-reverified`). `fulfillment_impossible` is §23.5's `FulfillmentImpossible(F, E)`: the conformance arm first, then `RouteImpossible(P(F), E)`. `classify_attempt` skips a final cell through it, so a conformance-Invalid fulfillment's final cell is skipped (`SkipReason::RejectedFinalConformance`). Conformance is binary (`Validation::{Valid, Invalid}`) and is evaluated only over complete facts. Mutation: the conformance arm removed → both tests red; restored. The fifth arm of `route_impossible` is MR-SOFI-0239's defect, not this row's. | | MR-SOFI-0239 | Met | `dsm::sofi::resolution::route_impossible` | `dsm::sofi::resolution::tests::route_impossibility_reads_nothing_about_a_particular_fulfillment`; `dsm::sofi::resolution::tests::a_final_cell_whose_fulfillment_lost_its_position_is_skipped` | SoFi Amendment S14 (owner, 2026-09-30, `fix/sofi-beta-blockers`). `route_impossible` has the four arms of §23.5, each a fact about P and E; `ImpossibleArm::PositionLost` is deleted. The case it covered is the skip `RejectedFinalInadmissible` (MR-SOFI-0343). The first test compares every combination of what is known about one fulfillment (registered, position lost, conformance) with the same facts known about none, for each arm, and finds `route_impossible` equal. Mutation: the lost position put back into `route_impossible` → both tests red; restored. Lean: `route_impossibility_reads_no_fact_about_F` (by `rfl`); the same mutation → red. | | MR-SOFI-0240 | Met | `dsm::sofi::resolution::route_impossible` | `dsm::sofi::resolution::tests::a_statically_invalid_route_is_invalid_before_storage_resolves`; `dsm::sofi::resolution::tests::the_orphan_and_consumed_elsewhere_arms_hold_on_a_valid_route`; `dsm::sofi::resolution::tests::a_conditional_parent_that_selected_another_root_is_invalid` | The cited test was renamed; all four arms are implemented and tested, and the code adds a fifth arm (PositionLost) the spec does not list, recorded under MR-SOFI-0239. | -| MR-SOFI-0241 | Partial | `dsm::sofi::resolution::skip_without_evidence`; `dsm::sofi::facts::establish_ground`; `dsm::sofi::resolution::classify_attempt` | `dsm::sofi::resolution::tests::a_dead_cell_skips_on_facts_that_need_no_evidence`; `dsm::sofi::resolution::tests::nothing_that_needs_evidence_skips_without_it`; `dsm::sofi::resolution::tests::an_evidence_free_skip_answers_as_the_complete_facts_do`; `dsm::sofi::resolution::tests::a_walk_passes_a_dead_key_whose_evidence_is_not_in_hand`; `dsm_sdk::handlers::node_e2e_tests::a_key_whose_fulfillment_can_never_register_is_skipped_without_its_evidence` | Fixed as a classification order (`fix/sofi-beta-blockers`, 2026-09-30). While a walk classifies a key, the verifier establishes the facts that need no validation evidence first (`establish_ground`: registration, the trader parent, each leg's cell and walk) and skips a final cell dead on them (`skip_without_evidence`: arm (iii) a named parent consumed elsewhere, arm (iv) a trader parent that can never be compatible, and Amendment S14's lost position) before it acquires any conformance or route evidence; only a cell not dead on them goes on to the evidence. Arm (iv) creates no Void (the ladder's rung 2 makes the position Invalid). On the nodes, the walk over a key held by an exercise whose position a rival claim took asks for the key, the next key, the position pair, their chains and the precommit the registration names, and nothing else; the next key is live. Mutations: the evidence acquired before the early skip → the node test red, naming the extra object read; the consumed-elsewhere arm removed → `a_dead_cell_skips_on_facts_that_need_no_evidence` red; the final-on-E check removed → that test and the consistency sweep red. Partial: arm (ii), a parent permanently orphaned, still needs evidence: refuting a parent needs the generation it sits at, and only the vault's pre-state leaf in the route evidence carries it. | +| MR-SOFI-0241 | Partial | `dsm::sofi::resolution::skip_without_evidence`; `dsm::sofi::facts::establish_ground`; `dsm::sofi::resolution::classify_attempt` | `dsm::sofi::resolution::tests::a_dead_cell_skips_on_facts_that_need_no_evidence`; `dsm::sofi::resolution::tests::nothing_that_needs_evidence_skips_without_it`; `dsm::sofi::resolution::tests::an_evidence_free_skip_answers_as_the_complete_facts_do`; `dsm::sofi::resolution::tests::a_walk_passes_a_dead_key_whose_evidence_is_not_in_hand`; `dsm_sdk::handlers::node_e2e_tests::a_key_whose_fulfillment_can_never_register_is_skipped_without_its_evidence` | Fixed as a classification order (`fix/sofi-beta-blockers`, 2026-09-30). While a walk classifies a key, the verifier establishes the facts that need no validation evidence first (`establish_ground`: registration, the trader parent, each leg's cell and walk) and skips a final cell dead on them (`skip_without_evidence`: arm (iii) a named parent consumed elsewhere, arm (iv) a trader parent that can never be compatible, and Amendment S14's lost position) before it acquires any conformance or route evidence; only a cell not dead on them goes on to the evidence. Arm (iv) creates no Void (the ladder's rung 2 makes the position Invalid). On the nodes, the walk over a key held by an exercise whose position a rival claim took asks for the key, the next key, the position pair, their chains and the precommit the registration names, and nothing else; the next key is live. Mutations: the evidence acquired before the early skip → the node test red, naming the extra object read; the consumed-elsewhere arm removed → `a_dead_cell_skips_on_facts_that_need_no_evidence` red; the final-on-E check removed → that test and the consistency sweep red. Partial: arm (ii), a parent permanently orphaned, still needs evidence: refuting a parent needs the generation it sits at, and only the vault's pre-state leaf in the route evidence carries it. Arm (iv) also covers a conditional parent another claim holds at `p` since 2026-10-01 (§6.66 12d, `dsm::sofi::facts::tests::a_conditional_parent_another_claim_holds_selects_no_root`); a conditional parent that resolved Invalid does not reach it yet (12f). | | MR-SOFI-0242 | Met | `dsm::sofi::resolution::classify_attempt` | `dsm::sofi::resolution::tests::a_final_leg_of_an_incomplete_route_is_not_consumed` | — | | MR-SOFI-0243 | Met | `dsm::sofi::resolution::walk` | `dsm::sofi::resolution::tests::the_walk_is_chunking_equivalent`; `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone` | On the nodes: a skipped key moves on, and the next trade takes the next key. | -| MR-SOFI-0244 | Met | `dsm::sofi::resolution::walk` | `dsm::sofi::resolution::tests::the_walk_is_chunking_equivalent` | — | +| MR-SOFI-0244 | Met | `dsm::sofi::resolution::walk`; `dsm::sofi::resolve::Verifier::continue_walk`; `dsm::sofi::resolve::Verifier::next_generation`; `dsm::sofi::resolve::Verifier::read_legs` | `dsm::sofi::resolution::tests::the_walk_is_chunking_equivalent`; `dsm_sdk::handlers::node_e2e_tests::junk_at_more_attempt_keys_than_one_walk_examines_is_walked_past` | Corrected 2026-10-01 (§6.66 12a). The Met verdict rested on the pure `walk` alone while its callers stopped where the budget ended: no verifier could extend a chain past a root whose first 16 keys junk held, and a leg's liveness past attempt 16 was never established. Every caller now resumes at the cursor. | | MR-SOFI-0245 | Met | `dsm::sofi::conformance::successor_position`; `dsm::sofi::conformance::fulfillment_conformance`; `dsm::sofi::conformance::derive_policy_fulfillments`; `dsm::sofi::wire::objects::PreEClosureIndex::new` | `dsm::sofi::conformance::tests::item_1_the_referenced_p_verifies_and_q_is_its_successor`; `dsm::sofi::conformance::tests::item_3_the_policy_fulfillment_set_is_the_canonical_set_of_p`; `dsm::sofi::conformance::tests::item_7_identities_and_bounds_hold` | q = p + 1, setups strictly before P, the G set derived from P and E, and a pre-E closure that excludes P, G and F each have a named test, replacing the verified-directly citation. | | MR-SOFI-0246 | Met | `dsm::sofi::resolution::parent_status` | `dsm::sofi::resolution::tests::a_generation_the_chain_has_not_reached_waits_and_is_never_orphaned` | — | | MR-SOFI-0247 | Met | `dsm::sofi::resolution::resolve_position` (first-matching-rung ladder) | `dsm::sofi::resolution::tests::a_terminal_resolution_is_never_reached_from_another_terminal_one` | — | @@ -2990,7 +3086,7 @@ The deferral also covers MR-DSM-0198 and MR-DSM-0221–0237 (§6.1), and the dBT | MR-STOR-0129 | Met | `dsm_storage_node::api::cells::put_cell`; `dsm_storage_node::api::cells::get_cell`; `dsm::storage_cell::ArrivalRecord` | `dsm_storage_node::cells_keep_everything::a_put_answers_with_the_arrival_record_a_verifier_replays` | Confirmed. | | MR-STOR-0130 | Met | `dsm_sdk::sdk::route_seats::write_recorded`; `dsm_sdk::sdk::route_seats::write_along` | `dsm_sdk::sdk::route_seats::tests::nothing_reaches_a_later_seat_before_the_leader_answers`; `dsm_sdk::sdk::route_seats::tests::a_seat_that_does_not_answer_is_recorded_empty_in_its_place`; `dsm_sdk::sdk::route_seats::tests::a_recorded_position_is_never_written_again` | Re-verified 2026-09-30 (§6.50): the writer goes leader first, each copy carries the chain built so far, and a stopped write continues from its own record, never from copies. `put_cell_leader_first`, which it cited, is deleted. | | MR-STOR-0131 | Met | `dsm::route_chain::evaluate`; `dsm_sdk::sdk::route_seats::write_recorded` | `dsm::route_chain::tests::an_entry_carries_one_slot_per_earlier_position_and_round_trips`; `dsm::route_chain::tests::a_copy_at_another_position_or_of_another_value_does_not_count`; `dsm::route_chain::tests::a_copy_whose_chain_does_not_begin_with_the_leader_link_does_not_count` | Re-verified 2026-10-01 (§6.54). A link is a seat's arrival record for the copy written there (`ChainSlot::Link`). The record names the member and the cell, and commits the entry, which carries its route position and the chain before it, so each link commits the previous one. | -| MR-STOR-0132 | Met | `dsm::route_chain::evaluate` | `dsm::route_chain::tests::the_first_recognized_value_at_the_leader_holds_the_cell`; `dsm::route_chain::tests::junk_first_at_the_leader_blocks_nothing`; `dsm::route_chain::tests::a_leader_holding_no_recognized_value_leaves_the_cell_open` | Re-verified 2026-09-30 (§6.50): the leader link is the first recognized object naming the cell in the leader's log, and junk first at the leader blocks nothing. The superseded rule in `sofi/arith.rs` is deleted. | +| MR-STOR-0132 | Met | `dsm::route_chain::evaluate`; `dsm::route_chain::leader_copies`; `dsm::sofi::resolve::Verifier::read_registration` | `dsm::route_chain::tests::the_first_recognized_value_at_the_leader_holds_the_cell`; `dsm::route_chain::tests::junk_first_at_the_leader_blocks_nothing`; `dsm::route_chain::tests::a_leader_holding_no_recognized_value_leaves_the_cell_open`; `dsm_sdk::handlers::node_e2e_tests::nothing_but_the_leaders_first_fulfillment_decides_k_ful` | Re-verified 2026-09-30 (§6.50): the leader link is the first recognized object naming the cell in the leader's log, and junk first at the leader blocks nothing. The superseded rule in `sofi/arith.rs` is deleted. 2026-10-01 (§6.66 12c): a reader that fetches what recognition needs fetches it only for the copies the leader link is chosen from (`leader_copies`), until one counts; `K_ful(q)`'s read fetched a precommit for every fulfillment any seat held. | | MR-STOR-0133 | Met | `dsm::route_chain::evaluate` | `dsm::route_chain::tests::a_copy_whose_chain_does_not_begin_with_the_leader_link_does_not_count`; `dsm::route_chain::tests::a_copy_at_another_position_or_of_another_value_does_not_count`; `dsm::route_chain::tests::a_later_link_needs_its_seats_mirror_of_the_leader_link`; `dsm::route_chain::tests::a_later_link_needs_its_own_byte_commit_and_valid_carried_links` | Re-verified 2026-10-01 (§6.54). A later link counts only on a chain from the leader link, at a position above every earlier link's, and when its seat's own mirror of the leader's ByteCommits covers the leader link. The copy-count rule it cited (`sofi/arith.rs`) is deleted. | | MR-STOR-0134 | Partial | `dsm_sdk::sdk::route_seats::write_along`; `dsm::route_chain::evaluate` | `dsm_sdk::sdk::route_seats::tests::a_seat_that_does_not_answer_is_recorded_empty_in_its_place`; `dsm::route_chain::tests::an_empty_neither_counts_nor_invalidates` | Re-verified 2026-10-01 (§6.54). A seat that does not answer is recorded as a no-response empty in its place, and Core counts no empty as a link. The writer never records a taken empty: nothing in the SDK produces `ChainSlot::Taken`, which would need the writer to read that another value's chain is already first at the seat. | | MR-STOR-0135 | Met | `dsm_storage_node::api::cells::put_cell`; `dsm_storage_node::api::cells::get_cell` | `dsm_storage_node::cells_keep_everything::a_second_value_at_a_key_is_kept_after_the_first_never_refused`; `dsm_storage_node::cells_keep_everything::an_identical_value_put_twice_is_held_twice` | Confirmed. | diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index 24445c651..e9c7b729a 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -176,3 +176,9 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-DSM-0273: a segment step whose transition does not produce its root is refused where it sits, however authentic its claim | `dsm` · economic/peer_lineage.rs · `Verifier::segment` → `full_step` | `dsm_sdk::handlers::frontier_verification_tests::a_root_no_transition_explains_is_refused_where_it_sits` (a final claim at position 2, signed by the device's own AK, naming its first step's manifest and an invented root) | Segment steps checked as claims only → red: the invented root is accepted and position 3 is read (2026-09-30). | — | | MR-DSM-0274, MR-DSM-0275: a receiver reads nothing of a peer at or behind its frontier | `dsm` · economic/peer_lineage.rs · `Verifier::frontier_below`, `start`; `dsm_sdk` · sdk/economic_registers.rs · `StoredFrontiers` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier` | Recorded frontiers discarded → red: B reads A's cell behind its frontier (2026-09-30). | — | | MR-DSM-0273, MR-DSM-0275: a credit's source is validated one hop back and nothing behind it is read | `dsm` · economic/peer_lineage.rs · `Verifier::one_hop_source` (claims-only chain), `NoFurtherHop` | `dsm_sdk::handlers::frontier_verification_tests::a_credits_source_is_validated_one_hop_back_and_no_further` | The source's earlier positions validated in full → red: B reads the evidence behind C's step at 1 (2026-09-30). | — | +| MR-SOFI-0144, MR-SOFI-0234, pre-audit item 2 (§6.62): a single-root parent counts only as what the trader's lineage holds at `p` | `dsm` · sofi/facts.rs · `parent_position` (via `establish_ground`); sofi/resolution.rs · `trader_parent_compatible`, `trader_parent_impossible` over `ParentPosition::SingleRoot { named, held, held_root }`; sofi/resolve.rs · `Verifier::parent_for`, `held_at` | `dsm::sofi::facts::tests::a_parent_claim_the_trader_does_not_hold_at_p_never_realizes`; `dsm::sofi::facts::tests::a_parent_at_another_root_than_the_trader_holds_never_realizes`; `dsm::sofi::facts::tests::a_single_root_parent_not_established_at_p_is_no_fact`; `dsm::sofi::resolve::tests::what_a_walk_holds_counts_only_at_the_position_it_reached` | The facts ignoring what is held → both forged-parent tests red; the ladder dropping the claim comparison → the claim test red; an unestablished parent treated as held → the not-established test red; `held_at` holding a root of another position → `what_a_walk_holds_counts_only_at_the_position_it_reached` red (2026-10-01, all restored). | `lean4/DSMSofiAtomicity.lean` · `single_root_parent_not_held_is_invalid`: `.singleNotHeld` dropped from `parentImpossible` → it and the partition theorem fail to check | +| MR-DSM-0074, pre-audit item 6 (§6.63): a send names its recipient by device id, never by a non-unique alias | `proto` · `OnlineTransferSmartRequest.recipient_device_id` (field 5, exactly 32 bytes; field 1 reserved); `dsm_sdk` · handlers/wallet_routes.rs · `wallet.sendSmart` | `dsm_sdk::handlers::sender_admission_tests::a_send_pays_the_contact_chosen_not_the_first_of_its_alias` | The handler resolving the recipient through its alias → red: both payments went to one contact and the second was refused (2026-10-01, restored). | — | +| MR-SOFI-0244, MR-SOFI-0213, pre-audit item 12 (§6.66 12a): the walk's budget chunks a walk and ends none; conformance item 5 reads `K^(a-1)` | `dsm` · sofi/resolve.rs · `Verifier::acquire_prior_attempts`, `Verifier::next_generation`, `Verifier::read_legs` | `dsm_sdk::handlers::node_e2e_tests::junk_at_more_attempt_keys_than_one_walk_examines_is_walked_past` | Item 5 reading only the keys below the budget → red (B's trade `RetriesExhausted`); extending a chain stopping where the budget ends → red (the owner's walk stops at 2 roots); a leg's liveness walk stopping there → red (B's trade `RetriesExhausted`) (2026-10-01, all restored). | — | +| MR-SOFI-0170, pre-audit item 12 (§6.66 12b): an exercise's closure objects are the objects its references name | `dsm` · sofi/exercise.rs · `recognize_exercise` (`conformance::closure_object_verifies`) | `dsm_sdk::handlers::node_e2e_tests::an_exercise_carrying_other_bytes_than_its_closure_holds_no_key` | Recognition checking only the closure's count → red: the copy holds the key (2026-10-01, restored). | — | +| MR-STOR-0132, SoFi §17.4, pre-audit item 12 (§6.66 12c): only the leader's first fulfillment decides `K_ful(q)` | `dsm` · sofi/resolve.rs · `Verifier::read_registration`; route_chain.rs · `leader_copies` (shared with `leader_link`) | `dsm_sdk::handlers::node_e2e_tests::nothing_but_the_leaders_first_fulfillment_decides_k_ful` | Every fulfillment in the leader's log having its precommit fetched → red (the owner's walk fails); every seat's → red (the read of `q + 1` fails) (2026-10-01, both restored). | — | +| MR-SOFI-0234, MR-SOFI-0241, pre-audit item 12 (§6.66 12d): a conditional parent another claim holds selects no root | `dsm` · sofi/facts.rs · `parent_position` | `dsm::sofi::facts::tests::a_conditional_parent_another_claim_holds_selects_no_root` | A conditional parent another claim holds treated as no fact → red: `None` where `ConditionalNoRoot` belongs (2026-10-01, restored). | `lean4/DSMSofiAtomicity.lean` · `terminal_parent_with_no_root_is_invalid_without_evidence` (`ParentState.noRoot`) | From ecf6eb68689f6ec1530fb2e6660df361554eab84 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:18:21 -0400 Subject: [PATCH 21/31] feat(core): a source's validated frontier is cached with the acceptance (A8) Each credit source's segment that passes whole leaves a frontier at the source step. The walk reuses it for later sources of the same identity, and the accept transaction records it beside the sender's, so the next credit from that source validates only the suffix (owner ruling 2026-10-01: "Cache/update validated frontiers so subsequent interactions validate only the new suffix"). validate_peer_lineage returns ValidatedPeerLineage. --- .../dsm/src/economic/peer_lineage.rs | 123 +++++++++++--- .../handlers/frontier_verification_tests.rs | 152 +++++++++++++++++- .../dsm_sdk/src/handlers/storage_routes.rs | 28 ++-- .../src/sdk/economic_admission_flow.rs | 11 +- .../dsm_sdk/src/sdk/economic_registers.rs | 42 +++-- 5 files changed, 311 insertions(+), 45 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs index 17a99c567..0f3143acc 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs @@ -258,14 +258,48 @@ pub trait ConditionalPositionResolver { ) -> Result<(ValidatedEconomicRoot, AcceptedClaim), PeerLineageFailure>; } +/// A peer's lineage validated to its target step, with the frontier each +/// credit source's segment reached on the way. +/// +/// Every source frontier here stands at a step whose whole segment, from a +/// frontier this receiver held, passed validation (DSM Amendment A8; owner +/// ruling 2026-10-01: "A frontier becomes trusted only after the complete +/// segment to it has passed validation"). The receiver records them in the +/// transaction that accepts the target, beside the target's own frontier, so +/// a later credit from the same source validates only the suffix. +#[derive(Debug)] +pub struct ValidatedPeerLineage { + transition: ValidatedPeerTransition, + source_frontiers: Vec, +} + +impl ValidatedPeerLineage { + /// The target step's validated transition. + pub fn transition(&self) -> &ValidatedPeerTransition { + &self.transition + } + + /// The frontier each credit source's segment reached, in the order the + /// walk validated them. + pub fn source_frontiers(&self) -> &[PeerFrontier] { + &self.source_frontiers + } + + /// The target step's transition and the sources' frontiers. + pub fn into_parts(self) -> (ValidatedPeerTransition, Vec) { + (self.transition, self.source_frontiers) + } +} + /// Verify a peer's lineage from this receiver's frontier to -/// `target_position` and return the target step's validated transition. +/// `target_position`: the target step's validated transition, and the +/// frontiers its credits' sources reached. /// /// Every step from the frontier to the target is validated from its own /// evidence; a credit's source is validated through its own segment back -/// to a frontier this receiver holds; a conditional -/// position before the target is resolved by `conditional`. Nothing behind -/// the frontier is read. +/// to a frontier this receiver holds, or one this walk already validated; a +/// conditional position before the target is resolved by `conditional`. +/// Nothing behind a frontier is read. pub fn validate_peer_lineage( fetcher: &dyn PeerEvidenceFetcher, expected_network_id: &[u8], @@ -274,7 +308,7 @@ pub fn validate_peer_lineage( target_position: u64, frontiers: &dyn PeerFrontiers, conditional: &dyn ConditionalPositionResolver, -) -> Result { +) -> Result { if target_position == 0 { return Err(invalid( "position 0 is the activation root; it has no transition to validate", @@ -287,8 +321,13 @@ pub fn validate_peer_lineage( frontiers, conditional, steps_remaining: std::cell::Cell::new(WALK_STEP_BUDGET), + sources_reached: std::cell::RefCell::new(Vec::new()), }; - verifier.segment(peer_genesis, peer_devid, target_position) + let transition = verifier.segment(peer_genesis, peer_devid, target_position)?; + Ok(ValidatedPeerLineage { + transition, + source_frontiers: verifier.sources_reached.into_inner(), + }) } /// The root a peer's lineage selected AT `position`, and the reference a later @@ -317,6 +356,7 @@ pub fn peer_root_at( frontiers, conditional, steps_remaining: std::cell::Cell::new(WALK_STEP_BUDGET), + sources_reached: std::cell::RefCell::new(Vec::new()), }; let below = position.checked_add(1).ok_or_else(|| { invalid(format!( @@ -480,6 +520,9 @@ struct Verifier<'a> { frontiers: &'a dyn PeerFrontiers, conditional: &'a dyn ConditionalPositionResolver, steps_remaining: std::cell::Cell, + /// The frontier each credit source's segment reached in this walk, each + /// standing at a step whose whole segment passed. + sources_reached: std::cell::RefCell>, } impl Verifier<'_> { @@ -495,21 +538,55 @@ impl Verifier<'_> { Ok(()) } - /// This receiver's frontier for `(genesis, device_id)` below `position`, - /// or the activation root where it recorded none. A store that answers - /// with another identity's coordinate, or one not below `position`, is - /// this verifier's own fault and decides nothing about the peer. + /// This receiver's latest frontier for `(genesis, device_id)` below + /// `position`: the one it recorded, or one a source's segment reached + /// earlier in this walk, whichever is later; the activation root where + /// it holds neither. A store that answers with another identity's + /// coordinate, or one not below `position`, is this verifier's own fault + /// and decides nothing about the peer. fn frontier_below( &self, genesis: &[u8; 32], device_id: &[u8; 32], position: u64, ) -> Result { + let recorded = self.recorded_frontier_below(genesis, device_id, position)?; + let reached = self + .sources_reached + .borrow() + .iter() + .filter(|f| { + f.genesis() == genesis + && f.device_id() == device_id + && f.economic_position() < position + }) + .max_by_key(|f| f.economic_position()) + .cloned(); + Ok(match (recorded, reached) { + (Some(recorded), Some(reached)) + if reached.economic_position() > recorded.economic_position() => + { + reached + } + (Some(recorded), _) => recorded, + (None, Some(reached)) => reached, + (None, None) => PeerFrontier::activation(*genesis, *device_id), + }) + } + + /// The frontier this receiver recorded for `(genesis, device_id)` below + /// `position`, checked to be the one asked for. + fn recorded_frontier_below( + &self, + genesis: &[u8; 32], + device_id: &[u8; 32], + position: u64, + ) -> Result, PeerLineageFailure> { let Some(frontier) = self .frontiers .frontier_below(genesis, device_id, position)? else { - return Ok(PeerFrontier::activation(*genesis, *device_id)); + return Ok(None); }; if frontier.genesis() != genesis || frontier.device_id() != device_id @@ -525,7 +602,7 @@ impl Verifier<'_> { encode_base32_crockford(device_id), ))); } - Ok(frontier) + Ok(Some(frontier)) } /// The chain point a frontier stands at. @@ -880,7 +957,7 @@ impl Verifier<'_> { let frontier = self.frontier_below(genesis, device_id, position)?; let point = self.chain_through(genesis, device_id, &frontier, position - 1)?; self.spend_step()?; - match self.final_claim(genesis, device_id, position, &point.root)? { + let source = match self.final_claim(genesis, device_id, position, &point.root)? { RegisteredEconomicClaim::SingleRoot(claim) => self.full_step( genesis, device_id, @@ -888,12 +965,21 @@ impl Verifier<'_> { &point, &claim, StepRole::Source, - ), - RegisteredEconomicClaim::ConditionalSofi(_) => Err(invalid(format!( - "position {position}: the credit's source is a conditional SoFi position, not an \ - online transfer" - ))), + )?, + RegisteredEconomicClaim::ConditionalSofi(_) => { + return Err(invalid(format!( + "position {position}: the credit's source is a conditional SoFi position, \ + not an online transfer" + ))) + } + }; + // The whole segment to the source step has passed: its coordinate is + // a frontier for the rest of this walk, and for the receiver once it + // accepts the target. + if let Some(reached) = PeerFrontier::reached_by(&source) { + self.sources_reached.borrow_mut().push(reached); } + Ok(source) } } @@ -1232,6 +1318,7 @@ mod tests { frontiers: &NoneRecorded, conditional: &NoResolution, steps_remaining: std::cell::Cell::new(0), + sources_reached: std::cell::RefCell::new(Vec::new()), }; match verifier.source_step(&PEER_G, &PEER_D, 3) { Err(PeerLineageFailure::Incomplete(why)) => { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs index 9d8431dc0..aa0d8163b 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs @@ -1,10 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -//! Frontier-relative verification (DSM Amendment A8, owner ruling -//! 2026-09-30) on the storage nodes. A verifier starts at its own frontier -//! for a peer, validates every step from there from that step's own -//! evidence, checks a credit's source one hop back and no further, and reads -//! nothing behind its frontier. +//! Frontier-relative verification (DSM Amendment A8, owner rulings +//! 2026-09-30 and 2026-10-01) on the storage nodes. A verifier starts at its +//! own frontier for a peer, validates every step from there from that step's +//! own evidence, validates a credit's source through the source's own +//! segment back to a frontier it holds for the source, records the +//! frontiers it reached with the acceptance, and reads nothing behind a +//! frontier. use serial_test::serial; @@ -380,3 +382,143 @@ async fn a_sources_segment_stops_at_the_frontier_the_receiver_holds() { ); } } + +/// C's three-device setup: A and B as a pair, C booted on the same fleet, +/// all three contacts of each other, and C funded. +async fn three_devices() -> (Pair, TestDevice) { + let p = Pair::boot(100, 0).await; + let mut c = TestDevice::create("C", 0x0C); + c.boot(&p.fleet).await; + for peer in [&p.a, &p.b] { + c.add_contact(peer).await; + peer.add_contact(&c).await; + } + c.fund_admitted(100).await; + (p, c) +} + +/// C pays A `amount`, A takes it in, and the position C admitted the debit +/// at. +async fn c_pays_a(p: &Pair, c: &TestDevice, amount: u64) -> u64 { + let sent = c.send(&p.a, amount).await; + assert!(sent.success, "{:?}", sent.error_message); + let credited = p.a.sync().await; + assert!(credited.success, "{:?}", credited.errors); + c.enter(); + client_db::economic_lineage::get_admitted_coordinate() + .expect("read") + .expect("C admitted its debit to A") + .0 +} + +/// The reads of C's root cells at positions `1..=through`, in C's own +/// order, as the members log them. +fn cs_cells_through(c: &TestDevice, through: u64) -> Vec { + c.enter(); + let mut parent = dsm::economic::tree::empty_economic_root(); + let mut reads = Vec::new(); + for position in 1..=through { + reads.push(cell_read(&c.genesis, &c.device_id, position, &parent)); + parent = admitted(position).0; + } + reads +} + +/// A source's frontier is cached with the acceptance (owner 2026-10-01: +/// "Cache/update validated frontiers so subsequent interactions validate +/// only the new suffix"). C has never paid B. C pays A and A pays B: B +/// validates C's segment to the paying step and records its frontier for C +/// there with the acceptance. C pays A again and A pays B again: B walks C +/// from that frontier, reading C's cells past it and none at or behind it. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_sources_frontier_is_recorded_and_the_next_credit_walks_only_its_suffix() { + let (p, c) = three_devices().await; + let first_source = c_pays_a(&p, &c, 30).await; + let sent = p.a.send(&p.b, 20).await; + assert!(sent.success, "{:?}", sent.error_message); + let received = p.b.sync().await; + assert!(received.success, "{:?}", received.errors); + assert_eq!(p.b.era_balance(), 20); + p.b.enter(); + let frontier = + client_db::economic_lineage::frontier_below(&c.genesis, &c.device_id, first_source + 1) + .expect("the frontier store") + .expect("B's frontier for C, recorded from the source's segment alone"); + assert_eq!( + frontier.economic_position(), + first_source, + "B's frontier for C is the source step its acceptance validated" + ); + + let second_source = c_pays_a(&p, &c, 10).await; + let again = p.a.send(&p.b, 5).await; + assert!(again.success, "{:?}", again.error_message); + let behind = cs_cells_through(&c, first_source); + let past = cs_cells_through(&c, second_source) + .pop() + .expect("C's cell at its second paying step"); + for node in &p.nodes.nodes { + node.forget_requests(); + } + let received = p.b.sync().await; + assert!(received.success, "{:?}", received.errors); + assert_eq!(p.b.era_balance(), 25); + let asked = requests(&p.nodes); + assert!( + asked.contains(&past), + "B read C's second paying step, past its frontier" + ); + for read in &behind { + assert!( + !asked.contains(read), + "B read C's cell at or behind the frontier its first acceptance recorded: {read}" + ); + } +} + +/// One walk validates a source's segment once. C pays A twice, and A pays +/// B out of both: validating A's segment, B walks C to the first paying +/// step, and the walk to the second starts there. Every member is asked for +/// each of C's cells at or behind the first paying step exactly as often as +/// for C's second paying step, which any walk reads once: never once more +/// for the second source. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn one_walk_validates_a_sources_segment_once() { + let (p, c) = three_devices().await; + let first_source = c_pays_a(&p, &c, 30).await; + let second_source = c_pays_a(&p, &c, 10).await; + assert!(second_source > first_source); + let sent = p.a.send(&p.b, 35).await; + assert!(sent.success, "{:?}", sent.error_message); + let behind = cs_cells_through(&c, first_source); + let second = cs_cells_through(&c, second_source) + .pop() + .expect("C's cell at its second paying step"); + for node in &p.nodes.nodes { + node.forget_requests(); + } + let received = p.b.sync().await; + assert!(received.success, "{:?}", received.errors); + assert_eq!(p.b.era_balance(), 35); + let per_member = |read: &String| -> Vec { + p.nodes + .nodes + .iter() + .map(|n| n.requests().iter().filter(|r| *r == read).count()) + .collect() + }; + let once = per_member(&second); + assert!( + once.iter().any(|n| *n > 0), + "B validated C's second paying step" + ); + for read in &behind { + assert_eq!( + per_member(read), + once, + "B walked C's segment through {read} more often than C's second paying step" + ); + } +} 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 b3e34d578..e572bdbf5 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 @@ -1652,18 +1652,24 @@ impl AppRouterImpl { })?; // The sender's coordinate this acceptance // verified is this device's frontier for it - // from now on (DSM Amendment A8), recorded - // with the acceptance and never before it. - crate::storage::client_db::economic_lineage::record_frontier_in_tx( - tx, - &prereqs.sender_frontier, - ) - .map_err(|e| { - dsm::types::error::DsmError::internal( - format!("in-tx sender frontier failed: {e}"), - None::, + // from now on (DSM Amendment A8), and so is + // each coordinate its credits' sources + // reached, every one at the end of a segment + // that passed whole: recorded with the + // acceptance and never before it. + for frontier in std::iter::once(&prereqs.sender_frontier) + .chain(&prereqs.source_frontiers) + { + crate::storage::client_db::economic_lineage::record_frontier_in_tx( + tx, frontier, ) - })?; + .map_err(|e| { + dsm::types::error::DsmError::internal( + format!("in-tx peer frontier failed: {e}"), + None::, + ) + })?; + } Ok(()) }, Some(crate::sdk::core_sdk::AdmissionPlan { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs index 713accfdb..44d36131f 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs @@ -1309,6 +1309,11 @@ pub(crate) struct RecipientAdmissionPrereqs { /// frontier for the sender once the transfer is accepted (DSM Amendment /// A8), recorded in the accept transaction and never before it. pub sender_frontier: dsm::economic::peer_lineage::PeerFrontier, + /// The frontier each of the sender's credit sources reached in this + /// prevalidation, each at a step whose whole segment passed: recorded in + /// the same accept transaction, so a later credit from that source + /// validates only the suffix (DSM Amendment A8). + pub source_frontiers: Vec, } /// Prevalidate an inbound online transfer BEFORE any durable local state @@ -1383,14 +1388,14 @@ pub(crate) async fn prevalidate_incoming_transfer_admission( let walk = { let sofi = crate::sdk::sofi_reads::VerifierContext::new(&set, None, None) .map_err(|e| incomplete(format!("SoFi reads: {e}")))?; - recorder.validated_peer_transition( + recorder.validated_peer_lineage( peer_genesis, peer_devid, sender_economic_position, &sofi.peer_position_resolver(), ) }; - let peer = walk.map_err(|e| match e { + let lineage = walk.map_err(|e| match e { dsm::economic::provenance::PeerLineageFailure::Invalid(m) => { terminal(format!("sender lineage INVALID: {m}")) } @@ -1409,6 +1414,7 @@ pub(crate) async fn prevalidate_incoming_transfer_admission( incomplete(format!("sender lineage is undecided: {m}")) } })?; + let (peer, source_frontiers) = lineage.into_parts(); // ── The sender-side conjuncts — the SAME implementation the verifier's // credit arm runs post-accept, so the two can never drift ────────────── @@ -1512,6 +1518,7 @@ pub(crate) async fn prevalidate_incoming_transfer_admission( prepared, pinned_canonical_bytes: canonical_operation_bytes.to_vec(), sender_frontier, + source_frontiers, }) } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs index 1b1d61a5e..a02ef8b3d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs @@ -21,7 +21,7 @@ use dsm::economic::claim_envelope::RegisteredEconomicClaim; use dsm::economic::peer_lineage::{ validate_peer_lineage, ConditionalPositionResolver, PeerEvidenceFetcher, PeerFrontier, - PeerFrontiers, + ValidatedPeerLineage, PeerFrontiers, }; use dsm::economic::provenance::{ PeerLineageFailure, ProvenanceResolver, ReserveReleaseWin, ValidatedPeerTransition, @@ -258,10 +258,9 @@ impl PeerFrontiers for StoredFrontiers { } /// A peer's lineage verified to `peer_economic_position` from this -/// receiver's frontier (DSM Amendment A8), over WHATEVER fetcher the caller -/// supplies, so a recording fetcher observes exactly the closure the -/// verification consumed. Nothing is recorded here: a frontier is recorded -/// only in the transaction that accepts a step from the peer. +/// receiver's frontier (DSM Amendment A8): the target step's validated +/// transition, for a caller that accepts nothing from the peer and so records +/// none of the frontiers the walk reached. pub(crate) fn resolve_peer( fetcher: &F, expected_network_id: &[u8], @@ -270,6 +269,31 @@ pub(crate) fn resolve_peer( peer_economic_position: u64, conditional: &dyn ConditionalPositionResolver, ) -> Result { + resolve_peer_lineage( + fetcher, + expected_network_id, + peer_genesis, + peer_devid, + peer_economic_position, + conditional, + ) + .map(|lineage| lineage.into_parts().0) +} + +/// A peer's lineage verified to `peer_economic_position` from this +/// receiver's frontier (DSM Amendment A8), over WHATEVER fetcher the caller +/// supplies, so a recording fetcher observes exactly the closure the +/// verification consumed, with the frontiers its credits' sources reached. +/// Nothing is recorded here: a frontier, the peer's or a source's, is +/// recorded only in the transaction that accepts a step from the peer. +pub(crate) fn resolve_peer_lineage( + fetcher: &F, + expected_network_id: &[u8], + peer_genesis: &[u8; 32], + peer_devid: &[u8; 32], + peer_economic_position: u64, + conditional: &dyn ConditionalPositionResolver, +) -> Result { let validated = validate_peer_lineage( fetcher, expected_network_id, @@ -313,15 +337,15 @@ impl<'a> RecordingResolver<'a> { } /// The peer's lineage verified from this receiver's frontier, recorded at - /// the fetch boundary. - pub fn validated_peer_transition( + /// the fetch boundary, with the frontiers its credits' sources reached. + pub fn validated_peer_lineage( &self, peer_genesis: &[u8; 32], peer_devid: &[u8; 32], peer_economic_position: u64, conditional: &dyn ConditionalPositionResolver, - ) -> Result { - resolve_peer( + ) -> Result { + resolve_peer_lineage( self, &self.inner.expected_network_id, peer_genesis, From ce167115359b906508ab4ac3ebcb07e8762e7107 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:27:34 -0400 Subject: [PATCH 22/31] =?UTF-8?q?docs(spec):=20A8=20holds=20a=20credit's?= =?UTF-8?q?=20source=20to=20account=20through=20its=20own=20segment;=20?= =?UTF-8?q?=C2=A76.68?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner ruling 2026-10-01 (pre-audit item 3, "Segment now, proofs later"), quoted in A8. The One hop bullet becomes Credit sources; S15's SetupValid wording follows. MR-DSM-0273-0275 and MR-SOFI-0347 rewritten; §1 re-pinned. Tests 1 and 2 of the ruling recorded as owed (hostile-device harness). --- specs/DSM_High_Level_Explainer.md | 10 +++--- specs/SoFi_Settlement_Specification.md | 2 +- specs/requirements/CONFORMANCE_GAPS.md | 38 ++++++++++++++++++++--- specs/requirements/MASTER_REQUIREMENTS.md | 15 ++++----- specs/requirements/VERIFICATION_MATRIX.md | 5 ++- 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/specs/DSM_High_Level_Explainer.md b/specs/DSM_High_Level_Explainer.md index fad1940a1..313b59bfa 100644 --- a/specs/DSM_High_Level_Explainer.md +++ b/specs/DSM_High_Level_Explainer.md @@ -561,14 +561,14 @@ accept. > - **Why authentication comes before the register read.** The cell's leader is derived from the payer's validated root at the previous position, so the receiver cannot even compute which member to ask until that root is verified. > - **The verdict does not depend on the order.** Accept is a conjunction, so every order gives the same result. The order binds only what the receiver does on the way: nothing is fetched from the network for a transition that is already Invalid on what the receiver holds. -> **Amendment A8 (owner, 2026-09-30; corrected the same day) — frontier-relative verification.** A receiver never replays a payer's history to genesis, and it never takes an authenticated root for a valid transition: they are different facts. Root-chain continuity says which ordered roots are authoritative; each step of the receiver's segment must still explain `ρ_n → ρ_{n+1}`. +> **Amendment A8 (owner, 2026-09-30; corrected the same day, and for credit sources 2026-10-01) — frontier-relative verification.** A receiver never replays a payer's history to genesis, and it never takes an authenticated root for a valid transition: they are different facts. Root-chain continuity says which ordered roots are authoritative; each step of the receiver's segment must still explain `ρ_n → ρ_{n+1}`. > -> - **The frontier.** The receiver's frontier for a payer is the last coordinate `(ρ, u)` of that payer it authenticated itself: the coordinate of a step it accepted from the payer, or one it authenticated on the way to such a step. Where it has none, its frontier is the payer's activation root, authenticated by the payer's device identity. A root the payer merely claims is never a frontier. -> - **The segment.** For each position in the frontier-to-parent segment, the receiver authenticates the authoritative signed root claim and verifies the transition producing that root against its authenticated parent, using only that transition's directly required evidence and provenance one hop back. The claim is final at the register cell the receiver derives from `(G, DevID, position)` and the root it authenticated at the position before, its signature verifies under the payer's authenticated key, and the positions follow in order; the register proves which signed claim occupies each authoritative position in that lineage and excludes a conflicting claim from also being authoritative there. -> - **One hop.** A credit's direct source is checked; a debit, a policy change and a relationship leaf are each checked by their own transition predicate. Provenance reached from a step stops after one hop: verification never recurses behind that one-hop evidence. +> - **The frontier.** The receiver's frontier for an identity is the last coordinate `(ρ, u)` of that identity it validated itself: the coordinate of a step it accepted from it, or the end of a segment of it the receiver validated whole on the way to such a step, a credit source's step included. Where it has none, its frontier is the identity's activation root, authenticated by its device identity. A root an identity merely claims is never a frontier, and a frontier becomes trusted only after the complete segment to it has passed validation. +> - **The segment.** For each position in the frontier-to-parent segment, the receiver authenticates the authoritative signed root claim and verifies the transition producing that root against its authenticated parent, using only that transition's directly required evidence and the provenance of its credits, each held to account as below. The claim is final at the register cell the receiver derives from `(G, DevID, position)` and the root it authenticated at the position before, its signature verifies under the payer's authenticated key, and the positions follow in order; the register proves which signed claim occupies each authoritative position in that lineage and excludes a conflicting claim from also being authoritative there. +> - **Credit sources (owner, 2026-10-01).** A credit's source is held to account the way the payer is. The receiver validates the source identity's segment backward from the referenced source step to the latest frontier it holds for that identity, verifying every economic transition in that segment, and the source step itself is an online transfer's debit. A root the source merely registered is not thereby valid ancestry: "admitted" means only that the source signed it, and a source walked by its root claims alone could invent a root and pay out of it through a second wallet of its own. The coordinate each source step reaches is a frontier for that source for the rest of the verification, and the receiver records it with the acceptance, so a later credit from that source is validated only over the new suffix. When the walk reaches its budget before a trusted frontier, the result is incomplete and retried, never an accepted credit. A debit, a policy change and a relationship leaf are each checked by their own transition predicate. Owner ruling, 2026-10-01: “Apply A8 transitively to credit sources. Validate each source identity backward from the referenced source step to the latest locally trusted frontier for that identity. Every economic transition in that segment must be verified. A frontier becomes trusted only after the complete segment to it has passed validation. Never treat "admitted" or "signed" alone as proof of valid economic ancestry. If validation reaches the walk budget before a trusted frontier, fail closed as incomplete/retry; do not accept the credit. Cache/update validated frontiers so subsequent interactions validate only the new suffix.” Succinct recursive validity proofs are the intended longer-term replacement for the walk and are not part of this amendment. > - **The offered step.** The transition presented to the receiver is validated the same way, with the precommitment, guard, linearity and policy checks (Amendment A4). > - **SoFi positions in the chain.** A position inside that segment whose claim is conditional (`C_q`) selects no root by its bytes alone. The receiver derives the root it selected from SoFi's public objects for that position only (SoFi Amendment S15) and continues the chain from it. It does so only for such positions inside its own frontier-to-parent segment that it has not resolved before. -> - **Never read.** Anything behind the receiver's frontier, anything behind a step's one-hop evidence, and any position outside the segment. +> - **Never read.** Anything behind a frontier the receiver holds for the identity being walked, the payer or a source, and any position outside the segments it validates. diff --git a/specs/SoFi_Settlement_Specification.md b/specs/SoFi_Settlement_Specification.md index 6cdcaa206..9b1bc7a0a 100644 --- a/specs/SoFi_Settlement_Specification.md +++ b/specs/SoFi_Settlement_Specification.md @@ -1612,7 +1612,7 @@ resolve_position, CORE/sofi/resolution.rs:284; effect_of, :92. > - **The result.** The one root `q` selected — `P.realize_root` when Realized, the root authenticated at `q − 1` when Void — or Invalid, or not established yet (Amendment S3). > - **The claim at `K_root(q)`.** It counts only as the claim `(P, F)` derive: its two roots are the ones the resolution chooses between. > - **What it reads.** No private object of the trader's, and no position of the trader's outside the verifier's frontier-to-parent segment. Within that segment, resolution may read position `q`, the authenticated predecessor root at `q − 1`, and the accepted claim at each setup position explicitly named by `P`'s legs, each authenticated through DSM Amendment A8. It reads no unrelated trader position and has no fallback lineage walk (owner ruling, 2026-09-30). -> - **SetupValid.** The trader's accepted claim at a setup's position (Amendments S9 and S13) is read by frontier-relative verification of the trader's lineage (DSM Amendment A8), never by replaying the trader's history to genesis or recursing behind a step's one-hop evidence. +> - **SetupValid.** The trader's accepted claim at a setup's position (Amendments S9 and S13) is read by frontier-relative verification of the trader's lineage (DSM Amendment A8), never by replaying the trader's history to genesis; a credit's source on the way is held to account through its own segment to a frontier the verifier holds (DSM Amendment A8, credit sources, 2026-10-01). > - **Adoption.** Where the trader's own transition requires that a realized position credit only tokens its device adopted before, that is a construction predicate of the trader's own state transition, enforced when the trader installs the realized position. Adoption is not part of frontier-relative resolution of a historical position: a later verifier resolving which root `q` selected does not re-run the trader's adoption check, and this resolution requires no adoption leaf or other private trader state. diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 21f2d1324..52db58a3a 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2069,6 +2069,36 @@ The quote took 20 s. - **Test:** `a_nonce_spent_in_one_relationship_is_not_spent_in_another`. - **Mutation control:** `relationship_nonce_hash` made to ignore the relationship key turns that test red. +### 6.68 A credit's source held to account through its own segment (`security/core-decoders-and-nonce-scope`, pre-audit item 3, 2026-10-01) + +**The finding.** Under A8's one-hop rule, a receiver validated a credit's source step from its own evidence but authenticated the source's earlier positions by their root claims alone. A registered claim proves only that the source signed it. So a source could register a root no transition produced, pay out of it to a second wallet of its own, and have that wallet pay an honest receiver. The receiver validated the second wallet's segment and the source's paying step in full, and nothing ever checked the invented root behind them. + +**The ruling.** Owner, 2026-10-01 ("Segment now, proofs later"), quoted in DSM Amendment A8. A source identity is validated backward from the referenced source step to the latest frontier the receiver trusts for it, with every economic transition in that segment verified. A frontier is trusted only after the complete segment to it has passed. "Admitted" or "signed" is never proof of valid ancestry. Budget exhaustion before a trusted frontier is incomplete and retried, never an accepted credit. Validated frontiers are cached. Succinct recursive validity proofs are the longer-term replacement for the walk. + +**The fix.** +- **The source walk.** `peer_lineage::source_step` (formerly the one-hop source) walks the source's segment from the receiver's latest frontier for it with `chain_through`. Every step is validated in full from its own evidence, and the credits in it are held to account the same way (`SourceAncestry`). Then comes the source step itself, which must be a single-root debit (`DebitHasNoSources`). +- **The budget.** One walk budget (512 steps) covers the whole verification, sources included. Past it the result is `Incomplete`, nothing more is read, and nothing is accepted. +- **The cache.** Each source step that passes leaves a frontier (`PeerFrontier::reached_by`). The walk reuses it for any later source of the same identity, and `validate_peer_lineage` returns it in `ValidatedPeerLineage`. The accept transaction records it beside the sender's frontier, so the next credit from that source validates only the suffix. A walk that does not finish records nothing. +- **The records.** A8 is corrected and S15's SetupValid wording aligned; MR-DSM-0273–0275 and MR-SOFI-0347 are rewritten (MASTER §7.1). + +**The owner's five tests.** +1. Invented root, then the attacker's own second wallet, then an honest receiver: **owed**. +2. Invented root several hops back: **owed**. Both need a device that builds a valid debit on top of a root it invented: local economic state matching the invented root, with its own next step produced from it. The harness's devices produce only valid transitions. `a_root_no_transition_explains_is_refused_where_it_sits` registers an invented root but cannot pay out of it. +3. A valid source ending at a trusted frontier: `a_sources_segment_stops_at_the_frontier_the_receiver_holds`, plus `a_sources_frontier_is_recorded_and_the_next_credit_walks_only_its_suffix` for a frontier that only the source walk recorded. +4. Budget exhaustion: `a_source_walk_past_the_budget_is_incomplete_and_reads_nothing`. +5. No frontier from partial ancestry: `an_outage_holds_the_transfer_cleanly_and_it_recovers` asserts that a walk cut short by the outage leaves no frontier for the payer, and that recovery records one. + +Also: `a_credits_source_is_validated_through_its_own_segment` (B validates C's first step from its evidence) and `one_walk_validates_a_sources_segment_once`. + +**Mutation controls.** +- **Claims-only source ancestry** (the old walk): `a_credits_source_is_validated_through_its_own_segment` goes red. +- **Budget check removed:** `a_source_walk_past_the_budget_is_incomplete_and_reads_nothing` goes red. +- **Source frontiers not recorded:** `a_sources_frontier_is_recorded_and_the_next_credit_walks_only_its_suffix` goes red. +- **Reuse within one walk disabled:** `one_walk_validates_a_sources_segment_once` goes red; every member was asked three times for C's early cells against once for its second paying step. +- **Test 5 has none.** The only writer of a frontier is the accept transaction, so there is no gate to remove. The test stands against a future writer. + +**Still open.** `peer_root_at`, the SoFi walk, records nothing, so §6.53's liveness bound on vault owners stands. The ruling's "Cache/update validated frontiers" answers the owner question recorded there, and SOFI holds the change. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | @@ -2358,9 +2388,9 @@ The quote took 20 s. | MR-DSM-0270 | Met | `dsm::route_chain::evaluate`; `dsm_storage_node::api::cells::put_cell` | `dsm::route_chain::tests::three_links_are_final_two_preserved_one_leader_held`; `dsm::route_chain::tests::only_links_of_one_chain_count_toward_final`; `dsm::route_chain::tests::a_byte_commit_backs_a_link_only_when_it_follows_its_parent`; `dsm_storage_node::cells_keep_everything::a_second_value_at_a_key_is_kept_after_the_first_never_refused` | sofi/arith.rs was deleted in #976; route_chain::evaluate counts three links of one chain from raw seat reads, and the node stores what it is given without checking or deciding. | | MR-DSM-0271 | Partial | dsm_sdk · storage/client_db/b0x_consumed.rs; sdk/b0x_sdk.rs · `retrieve_from_b0x_v2`, `record_consumed_b0x` | `b0x_consumed::tests` (not yet run) | Added by Amendment A7. Built, not yet compiled or exercised end to end. | | MR-DSM-0272 | Partial | dsm_sdk · `dsm_sdk::sdk::b0x_sdk::seal_for`; dsm · `dsm::crypto::spool_seal::seal`, `dsm::crypto::spool_seal::seal_key` | `dsm::crypto::spool_seal::tests::a_sealed_payload_opens_to_its_bytes`; `dsm::crypto::spool_seal::tests::it_opens_under_nothing_else`; `dsm::crypto::spool_seal::tests::every_message_has_its_own_key`; `dsm_sdk::handlers::node_e2e_tests::a_transfer_reaches_the_nodes_only_sealed_and_arrives` | Added by Amendment A7; traced 2026-09-29 (§6.39). Every spool payload is sealed end to end, under a domain-tagged key used for one message id only, with an authenticated cipher; a node holds ciphertext only. The key's shared secret comes from a fresh encapsulation per payload, not from the step's own Kyber exchange as A7 requires. | -| MR-DSM-0273 | Met | `dsm::economic::peer_lineage::validate_peer_lineage`; `dsm_sdk::sdk::economic_registers::resolve_peer`; `dsm_sdk::sdk::economic_admission_flow::prevalidate_incoming_transfer_admission` | `dsm_sdk::handlers::frontier_verification_tests::a_root_no_transition_explains_is_refused_where_it_sits`; `dsm_sdk::handlers::frontier_verification_tests::a_credits_source_is_validated_one_hop_back_and_no_further` | Re-verified 2026-09-30 (§6.52). Every position from the frontier to the target: the claim final at the cell routed from the root authenticated before it, authenticated (its manifest by address, its key the AK the manifest's authority evidence proves, the canonical register set), and the transition that produced its root validated from that step's own evidence; the offered step the same way. A credit's source is validated one hop back. A root its transition does not produce is refused where it sits. | -| MR-DSM-0274 | Met | `dsm::economic::peer_lineage::PeerFrontier`; `dsm_sdk::storage::client_db::economic_lineage::record_frontier_in_tx`; `dsm_sdk::storage::client_db::economic_lineage::frontier_below` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier`; `dsm::economic::peer_lineage::tests::a_frontier_at_the_target_is_this_verifiers_fault_not_the_peers` | Re-verified 2026-09-30 (§6.52). A frontier is the payer's activation root or the coordinate a verification reached (`PeerFrontier::reached_by`), recorded with the claim accepted there in the transaction that accepts a step from the payer, and read back only from that store. Its fields are private. | -| MR-DSM-0275 | Met | `dsm::economic::peer_lineage::validate_peer_lineage`; `dsm_sdk::sdk::economic_registers::StoredFrontiers` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier`; `dsm_sdk::handlers::frontier_verification_tests::a_credits_source_is_validated_one_hop_back_and_no_further`; `dsm_sdk::handlers::frontier_verification_tests::a_root_no_transition_explains_is_refused_where_it_sits` | Re-verified 2026-09-30 (§6.52). A verification starts at the receiver's frontier and reads no cell at or behind it; a one-hop source's earlier positions are read as claims only, and none of the evidence behind them is read; nothing past a refused position is read. The replay from the activation root, the memo re-walk and the recursion into a source's lineage are gone. | +| MR-DSM-0273 | Met | `dsm::economic::peer_lineage::validate_peer_lineage`; `dsm_sdk::sdk::economic_registers::resolve_peer_lineage`; `dsm_sdk::sdk::economic_admission_flow::prevalidate_incoming_transfer_admission` | `dsm_sdk::handlers::frontier_verification_tests::a_root_no_transition_explains_is_refused_where_it_sits`; `dsm_sdk::handlers::frontier_verification_tests::a_credits_source_is_validated_through_its_own_segment` | Re-verified 2026-10-01 (§6.68). Every position from the frontier to the target: the claim final at the cell routed from the root authenticated before it, authenticated (its manifest by address, its key the AK the manifest's authority evidence proves, the canonical register set), and the transition that produced its root validated from that step's own evidence; the offered step the same way. A credit's source is validated through its own segment from the receiver's frontier for it, every step in full, and the source step must be a single-root debit. A root its transition does not produce is refused where it sits. The owner's tests 1 and 2 (an invented root behind a source, one hop or several) are owed: they need a device that builds a valid debit on top of a root it invented. | +| MR-DSM-0274 | Met | `dsm::economic::peer_lineage::PeerFrontier`; `dsm::economic::peer_lineage::ValidatedPeerLineage`; `dsm_sdk::storage::client_db::economic_lineage::record_frontier_in_tx`; `dsm_sdk::storage::client_db::economic_lineage::frontier_below` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier`; `dsm_sdk::handlers::frontier_verification_tests::a_sources_frontier_is_recorded_and_the_next_credit_walks_only_its_suffix`; `dsm_sdk::handlers::recipient_admission_tests::an_outage_holds_the_transfer_cleanly_and_it_recovers`; `dsm::economic::peer_lineage::tests::a_frontier_at_the_target_is_this_verifiers_fault_not_the_peers` | Re-verified 2026-10-01 (§6.68). A frontier is an identity's activation root or the coordinate a verification reached (`PeerFrontier::reached_by`), the payer's target step or a credit source's step, each at the end of a segment that passed whole. Both are recorded with the claim accepted there in the transaction that accepts a step from the payer, read back only from that store, and reused within one walk. A walk that does not finish records none. Its fields are private. | +| MR-DSM-0275 | Met | `dsm::economic::peer_lineage::validate_peer_lineage`; `dsm_sdk::sdk::economic_registers::StoredFrontiers` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier`; `dsm_sdk::handlers::frontier_verification_tests::a_sources_segment_stops_at_the_frontier_the_receiver_holds`; `dsm_sdk::handlers::frontier_verification_tests::one_walk_validates_a_sources_segment_once`; `dsm::economic::peer_lineage::tests::a_source_walk_past_the_budget_is_incomplete_and_reads_nothing`; `dsm_sdk::handlers::frontier_verification_tests::a_root_no_transition_explains_is_refused_where_it_sits` | Re-verified 2026-10-01 (§6.68). A verification starts at the receiver's frontier for each identity it walks, the payer or a source, and reads no cell at or behind it; nothing past a refused position is read. No root is taken as valid ancestry because it is admitted or signed: a source's earlier positions are validated in full. Past the walk budget the result is `Incomplete`, nothing more is read, and nothing is accepted. | | MR-DSM-0276 | Met | `dsm::economic::peer_lineage::ConditionalPositionResolver`; `dsm_sdk::sdk::sofi_reads::VerifierContext::peer_position_resolver` | `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay`; `dsm::economic::peer_lineage::tests::a_conditional_position_right_after_the_activation_root_is_invalid` | Re-verified 2026-09-30 (§6.52). A conditional position inside a chain is resolved from SoFi's public objects for that position (S15), and the chain continues from the root it selected; a trader who has traded can pay (P15-9). A conditional position right after the activation root is Invalid: no claim was accepted there for it to name. | ### 8.2 SoFi settlement specification @@ -2713,7 +2743,7 @@ The quote took 20 s. | MR-SOFI-0344 | Met | `dsm::sofi::resolve::Verifier::peer_position`; `dsm::sofi::resolve::PeerPositionResolver`; `dsm::sofi::lineage::advance_peer_resolved`; `dsm_sdk::sdk::sofi_reads::VerifierContext::peer_position_resolver` | `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | SoFi Amendment S15 (`feat/frontier-relative-peer-verification`, 2026-09-30). `peer_position` runs the trader's own stages for position q over the verifier's reads (registration from the pair, P by its id, the exercise at the first leg's cell, each vault's chain, the facts) and the ladder inside `advance_peer_resolved`, which shares `advance_resolved`'s derivation. Failures keep their class: Incomplete for a read not in hand, Unresolved for facts that do not decide q, Invalid only for a verified contradiction. On the nodes, a trader who traded pays a third party and the payee accepts (P15-9). Mutations: the resolver refusing every C_q, and the resolver selecting the void root: both red, the payee never credits. | | MR-SOFI-0345 | Met | `dsm::sofi::registration::fulfillment_registered`; `dsm::sofi::resolve::Verifier::peer_position` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim`; `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | SoFi Amendment S15. `peer_position` requires the pair to hold registered the fulfillment the held claim names, and registration holds F registered only while `K_root(q)` is final on exactly `C_q(P, F)`; a final value is the cell's only one, so the held claim is that claim and its two roots are the ones the resolution chooses between. A separate equality check in `peer_position` could never fail and was removed. | | MR-SOFI-0346 | Partial | `dsm::sofi::resolve::Verifier::peer_position`; `dsm_sdk::sdk::economic_registers::resolve_peer` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier`; `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | SoFi Amendment S15 (wording made exact 2026-09-30: the setup positions the legs name are inside the segment and read through A8). The resolver reads q's position pair, P, the first leg's cell, the vault chains and q's evidence, and the trader's accepted claims at its setup positions through `resolve_peer`, which DSM core's walk bounds to the frontier (that test). Partial: no request-log test of the resolver's own read set yet. | -| MR-SOFI-0347 | Met | `dsm_sdk::sdk::economic_registers::resolve_peer`; `dsm::sofi::validation::setup_valid` | `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay`; `dsm::sofi::validation::tests::a_setup_on_a_lineage_known_invalid_is_invalid` | SoFi Amendment S15 (`feat/frontier-relative-peer-verification`). `SofiReads::accepted_claim_at` reads another trader's accepted claim at a setup position through `resolve_peer`, DSM core's frontier-relative walk with this resolver; the whole-lineage walk (`resolve_peer_with_cache`) no longer exists. A payee resolving a trader's SoFi position runs SetupValid this way in the P15-9 node test. | +| MR-SOFI-0347 | Met | `dsm_sdk::sdk::economic_registers::resolve_peer`; `dsm::sofi::validation::setup_valid` | `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay`; `dsm::sofi::validation::tests::a_setup_on_a_lineage_known_invalid_is_invalid` | SoFi Amendment S15 (`feat/frontier-relative-peer-verification`); re-verified 2026-10-01 (§6.68). `SofiReads::accepted_claim_at` reads another trader's accepted claim at a setup position through `resolve_peer`, DSM core's frontier-relative walk with this resolver, whose credit sources are held to account through their own segments as for a payer; the whole-lineage walk (`resolve_peer_with_cache`) no longer exists. A payee resolving a trader's SoFi position runs SetupValid this way in the P15-9 node test. | | MR-SOFI-0348 | Met | `dsm::sofi::lineage::advance_resolved`; `dsm::sofi::lineage::advance_peer_resolved` | `dsm::sofi::lineage::tests::a_realized_position_crediting_an_unadopted_token_is_refused`; `dsm::sofi::lineage::tests::another_verifier_resolves_the_position_without_the_traders_adoptions`; `dsm::sofi::lineage::tests::a_void_needs_no_adoption` | SoFi Amendment S15. The trader's own advance refuses a realized position crediting a token it has not adopted; another verifier's `advance_peer_resolved` derives the same root and accepted claim from the same facts with no adoption state of the trader's. Mutation: the adoption check run on the peer path → `another_verifier_resolves_the_position_without_the_traders_adoptions` red. | | MR-SOFI-0349 | Met | `dsm::core::token::era_policy::era_policy`; `dsm::economic::native_reserve::ERA_FAUCET_PAYOUT`; `dsm::core::token::token_state_manager::TOKEN_CREATION_FEE_ERA`; `dsm_sdk::handlers::wallet_routes::parse_display_amount_to_base_units`; `dsm_sdk::handlers::sofi_routes::entered`; `dsm_sdk::handlers::sofi_routes::shown`; `dsm_sdk::handlers::token_routes`; `dsm_sdk::handlers::faucet_routes` | `dsm::core::token::era_policy::tests::eras_policy_states_what_the_specification_fixes`; `dsm::core::token::era_policy::tests::eras_commitment_is_derived_from_its_bytes_and_is_the_specifications`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair`; `dsm_sdk::handlers::token_create_tests::the_fee_schedule_reports_an_unfunded_device_as_not_covering_the_fee` | SoFi Amendment S18 (2026-10-01). ERA's policy carries decimals 2 and its supply in base units, the faucet pays 10,000 base units and the creation fee is 1,000, and sends take amounts in whole tokens with the point. The SoFi routes take amounts the same way (§6.53): createVault, findRoute, trade and route carry `*_entered` text parsed against each token's committed decimals, and report `*_display`; the SoFi screen enters and shows whole tokens. The faucet message and the fee schedule show ERA at its decimals too (§6.56). | | MR-SOFI-0350 | Met | `dsm::sofi::derive::vault_token_locator`; `dsm::sofi::publication::Publication::locators`; `dsm_sdk::sdk::sofi_flow::create_vault` | `dsm::sofi::publication::tests::vault_objects_land_where_the_verifier_looks`; `dsm_sdk::handlers::node_e2e_tests::discovery_passes_over_what_is_not_a_vault_of_the_token`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer` | SoFi Amendment S16 (§6.53). The genesis publication carries the market policy its state commits and is indexed under the genesis locator and under `vault_token_locator(t)` for each token of the pair; a market that is not the committed one indexes nothing (`SofiWireError::MarketNotCommitted`). | diff --git a/specs/requirements/MASTER_REQUIREMENTS.md b/specs/requirements/MASTER_REQUIREMENTS.md index 119667579..01845c037 100644 --- a/specs/requirements/MASTER_REQUIREMENTS.md +++ b/specs/requirements/MASTER_REQUIREMENTS.md @@ -14,12 +14,12 @@ Every extraction in this round is taken against exactly these bytes: | File | `git hash-object` | Lines | |---|---|---| -| `specs/DSM_High_Level_Explainer.md` | `fad1940a13b806d53a0925e1bada409e5acc298c` | 4317 | -| `specs/SoFi_Settlement_Specification.md` | `6cdcaa206f57ed396b2d9e1cff084f59c11c4437` | 2678 | +| `specs/DSM_High_Level_Explainer.md` | `313b59bfacea02cf90061e67ae37db171e9c13b5` | 4317 | +| `specs/SoFi_Settlement_Specification.md` | `9b1bc7a0a769dd4fd75f98e43953ed7e7b18d29d` | 2678 | | `specs/dBTC_Native_Specification.md` | `233a3e72a5b16a023af830f4c8ffaad4ba9391a8` | 2160 | | `specs/DSM_Storage_Node_Specification.md` | `415a9b9c67c7a2b4b6df78b0af85fb3bc7282ae8` | 636 | -Pins updated 2026-10-01 after SoFi Amendments S16 (vaults found by their tokens; the setup inside the first operation through a vault; another trader's conditional parent resolved by a walk) and S19 (a route chains or splits) and S18 (ERA has two decimals); before that, 2026-09-30 after DSM Amendments A8 (frontier-relative verification) and A9 (the relationship-key tag is `DSM/smt-key`) and SoFi Amendments S14 (a final cell whose fulfillment can never register is skipped) and S15 (a trader's position resolved from public objects); before that, 2026-09-29 after SoFi Amendments S12 (the trader's balances before the trade) and S13 (a lineage known invalid); before that, 2026-09-26 after SoFi Amendment S11 (ERA's canonical policy); before that, 2026-09-24 after SoFi Amendments S8, S9 and S10 and the storage §9 rule on the leader first, one chain in route order and the completion proof (#977); before that, 2026-09-23 after storage §14 (#974), the set-identity amendment (SoFi Amendment S6, storage §10), the replication amendment (storage §12.5), the vault-consistency recommendation (SoFi §31), Amendment S7 (SoFi §24) and Amendment A7 (DSM §11, storage §8). The extractions of 2026-09-22 were taken against SoFi `4c62ee78…` (2597 lines) and storage `8d43ac02…` (571 lines); their line-based IDs refer to those bytes. +Pins updated 2026-10-01 after DSM Amendment A8's correction for credit sources (a source is held to account through its own segment) and the matching SoFi S15 SetupValid wording; before that, the same day, after SoFi Amendments S16 (vaults found by their tokens; the setup inside the first operation through a vault; another trader's conditional parent resolved by a walk) and S19 (a route chains or splits) and S18 (ERA has two decimals); before that, 2026-09-30 after DSM Amendments A8 (frontier-relative verification) and A9 (the relationship-key tag is `DSM/smt-key`) and SoFi Amendments S14 (a final cell whose fulfillment can never register is skipped) and S15 (a trader's position resolved from public objects); before that, 2026-09-29 after SoFi Amendments S12 (the trader's balances before the trade) and S13 (a lineage known invalid); before that, 2026-09-26 after SoFi Amendment S11 (ERA's canonical policy); before that, 2026-09-24 after SoFi Amendments S8, S9 and S10 and the storage §9 rule on the leader first, one chain in route order and the completion proof (#977); before that, 2026-09-23 after storage §14 (#974), the set-identity amendment (SoFi Amendment S6, storage §10), the replication amendment (storage §12.5), the vault-consistency recommendation (SoFi §31), Amendment S7 (SoFi §24) and Amendment A7 (DSM §11, storage §8). The extractions of 2026-09-22 were taken against SoFi `4c62ee78…` (2597 lines) and storage `8d43ac02…` (571 lines); their line-based IDs refer to those bytes. The DSM and SoFi specifications were amended on 2026-09-22 (marked "Amendment" in their text). The storage-node specification was added to the corpus on 2026-09-22, before any other extractor started. The owner accepted it in full the same day. Extract its items marked **Open** with Flags `ambiguous` and a Requirement text that says so, never as settled requirements. @@ -157,6 +157,7 @@ Each extraction also has a Findings table: - **A fulfillment that can never register (2026-09-30).** Conformance finding (CONFORMANCE §8, MR-SOFI-0239): `route_impossible` carried a fifth arm, `PositionLost`, which §23.5 does not list and which depends on a particular F, not on P and E. Without it, an exercise whose fulfillment can never register, because another claim holds its position, strands the DLV parent's attempt chain (TLA `DSM_SofiFulfillment`, `LostPosition`). Owner decision: a separate skip, `RejectedFinalInadmissible`, which is not an arm of `RouteImpossible`; `RouteImpossible` stays scoped to P and E (SoFi Amendment S14). MR-SOFI-0343 added with source `amendment`; §1 re-pinned. - **ERA has two decimals (2026-10-01).** Rig finding: ERA was whole-unit (decimals 0, SoFi Amendment S11), so a 0.30% fee on a 50 ERA trade could not be written as an ERA amount, and amounts were entered as raw base units. Owner ruling: ERA's decimals are 2, with the supply, faucet payout and creation fee unchanged in whole ERA; ERA's commitment changes (a clean cut); amounts are entered and shown in whole tokens with the token's decimals (SoFi Amendment S18). MR-SOFI-0335 rewritten; MR-SOFI-0349 added with source `amendment`; §1 re-pinned. - **Frontier-relative verification (2026-09-30).** Conformance finding (P15-9, and G16): a payee accepted a payer only by walking the payer's whole lineage from its activation root, re-validating every transition from evidence it fetched from the nodes, and a SoFi position in that history stopped the walk, so a trader who had traded could not pay. Owner decision: a receiver verifies forward from its own authenticated frontier: for each position of the segment it authenticates the authoritative root claim and verifies the transition producing that root with that transition's directly required evidence and provenance one hop back, never recursing behind that hop and never reading behind the frontier (DSM Amendment A8, corrected the same day: an authenticated root is not a valid transition, and a counterparty's acceptance does not stand for one); a SoFi position inside that segment is resolved from SoFi's public objects for that position alone, and adoption is not re-run by a later verifier (SoFi Amendment S15). MR-DSM-0273–0276 and MR-SOFI-0344–0348 added with source `amendment`; §1 re-pinned. +- **Credit sources held to account (2026-10-01).** Security pre-audit item 3: under A8's one-hop rule a credit's source step was validated, but the source's earlier positions were authenticated as root claims only, so a source could register an invented root and pay out of it through a second wallet of its own to an honest receiver. Owner ruling (2026-10-01, quoted in A8): "Apply A8 transitively to credit sources", every transition in the source's segment verified back to the latest frontier the receiver trusts for that identity, a frontier trusted only after the complete segment to it passed, "admitted" or "signed" never proof of valid ancestry, budget exhaustion before a trusted frontier incomplete and never accepted, validated frontiers cached so later interactions validate only the suffix; succinct recursive validity proofs are the longer-term replacement. DSM Amendment A8 corrected (the One hop bullet replaced by Credit sources; the frontier and never-read bullets follow it) and SoFi S15's SetupValid wording aligned. MR-DSM-0273, 0274 and 0275 and MR-SOFI-0347 rewritten; §1 re-pinned. - **The relationship-key tag (2026-09-30).** Conformance finding (CONFORMANCE §6.14, MR-DSM-0115, MR-DSM-0249): the explainer derived a relationship's SMT key under `DSM/smt-key/v1`, while the code and its golden vector use `DSM/smt-key`. Owner ruling: `DSM/smt-key` is the canonical relationship-SMT domain tag for beta, and `/v1` in the specification was an error (DSM Amendment A9). The formula in §26 and the example in §16 are corrected; no key migrates. MR-DSM-0115 rewritten; §1 re-pinned. - **Vaults found by their tokens, setup inside the trade, and routes that split (2026-10-01).** Phone-rig findings: `sofi.findRoute` searched only the vaults the trader had set up with, so the rig's first trade went through only because the trader set up by hand; and the owner ruled that two vaults of one pair are no different from two of different pairs, so one order may fill through both. Owner decisions: a vault is indexed under each of its tokens and found there with no authority taken from the index, path search reads the two tokens' indexes, the first operation through a vault admits its setup first, `sofi.vaults` replaces `sofi.setup` among the eight routes, a walk resolves another trader's conditional parent through frontier-relative verification (SoFi Amendment S16); and a Swap route's hops chain or split (SoFi Amendment S19). MR-SOFI-0350–0359 added with source `amendment`; MR-SOFI-0255 updated; §1 re-pinned. - **Post-reconciliation amendment (2026-09-22): route-chain finality.** For finding GPT-4, finality was redefined as a route chain (storage spec §9, §12.6, §14, §22; DSM Amendment A6; SoFi Amendment S4). §1 pins the amended files. Canonical rows restating the old rule were rewritten, and rows for the new rules were added at the end of §8.1, §8.2 and §8.4 with source `amendment`. The extraction files in `extractions/` remain as extracted against the earlier hashes. @@ -453,9 +454,9 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-DSM-0270 | invariant | explicit | Finality is a route chain: a value is final when its chain has the leader's link and two further links along the cell's Fisher–Yates route; the three links are not a vote, and nodes still check, sign and decide nothing. | amendment: DSM Amendment A6 (2026-09-22) | amendment | none | | MR-DSM-0271 | invariant | explicit | Which spool messages a device has consumed is the device's own state; no node holds read state, and no party can change another's. | amendment: DSM Amendment A7 | owner | none | | MR-DSM-0272 | obligation | explicit | Every spool payload is encrypted end to end between the two parties of its relationship, under a per-step message key derived from that step's Kyber exchange under its own domain tag and never reused for another step, sealed with an authenticated cipher; a node holds ciphertext only. SoFi objects and cells stay public. | amendment: DSM Amendment A7 | owner | none | -| MR-DSM-0273 | obligation | explicit | For each position of the frontier-to-parent segment, a receiver authenticates the authoritative signed root claim (final at the register cell derived from (G, DevID, position) and the root authenticated at the position before, signed under the payer's authenticated key, the positions in order) and verifies the transition producing that root against its authenticated parent, using only that transition's directly required evidence and provenance one hop back; the offered transition is validated the same way (Amendment A4). An authenticated root is never taken for a valid transition. | amendment: DSM Amendment A8 (2026-09-30) | owner | none | -| MR-DSM-0274 | invariant | explicit | A receiver's frontier for a payer is a coordinate it authenticated itself (a step it accepted from the payer, or one it authenticated on the way to such a step), or, where it has none, the payer's activation root authenticated by the payer's device identity. A root the payer merely claims is never a frontier. | amendment: DSM Amendment A8 (2026-09-30) | owner | none | -| MR-DSM-0275 | prohibition | explicit | Frontier-relative verification never recurses behind a step's one-hop evidence, never replays the payer's history to genesis, and reads nothing behind the receiver's frontier and no position outside the segment. | amendment: DSM Amendment A8 (2026-09-30) | owner | none | +| MR-DSM-0273 | obligation | explicit | For each position of the frontier-to-parent segment, a receiver authenticates the authoritative signed root claim (final at the register cell derived from (G, DevID, position) and the root authenticated at the position before, signed under the payer's authenticated key, the positions in order) and verifies the transition producing that root against its authenticated parent from that transition's directly required evidence; the offered transition is validated the same way (Amendment A4). A credit's source is held to account the same way: the source identity's segment from the latest frontier the receiver holds for it to the referenced source step, every economic transition in it verified, the source step an online transfer's debit. An authenticated root is never taken for a valid transition. | amendment: DSM Amendment A8 (2026-09-30; credit sources 2026-10-01) | owner | none | +| MR-DSM-0274 | invariant | explicit | A receiver's frontier for an identity (a payer or a credit source) is a coordinate it validated itself: a step it accepted from it, or the end of a segment of it validated whole on the way to such a step; where it has none, the identity's activation root authenticated by its device identity. A frontier becomes trusted only after the complete segment to it has passed validation; the receiver records it with the acceptance, so a later verification validates only the suffix. A root an identity merely claims is never a frontier. | amendment: DSM Amendment A8 (2026-09-30; credit sources 2026-10-01) | owner | none | +| MR-DSM-0275 | prohibition | explicit | Frontier-relative verification never replays an identity's history to genesis, reads nothing behind a frontier the receiver holds for the identity being walked and no position outside the segments it validates, and never treats a root as valid economic ancestry because it is admitted or signed; when the walk reaches its budget before a trusted frontier, the credit is incomplete and retried, never accepted. | amendment: DSM Amendment A8 (2026-09-30; credit sources 2026-10-01) | owner | none | | MR-DSM-0276 | obligation | explicit | A conditional position (C_q) inside the frontier-to-parent segment that the receiver has not resolved before is resolved from SoFi's public objects for that position (SoFi Amendment S15), and the root chain continues from the root it selected. | amendment: DSM Amendment A8 (2026-09-30) | owner | none | ### 8.2 SoFi settlement specification @@ -808,7 +809,7 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0344 | obligation | explicit | Another trader's conditional position q is resolved from SoFi's public proof-carrying objects for q alone (the claim final at K_root(q), the registration pair, P and F, the exercise read back from the first leg's cell, and the vault cells and canonical chains the facts need, with their finality evidence) by the ladder over the facts Core establishes from them. The result is the one root q selected (P.realize_root when Realized, the root authenticated at q − 1 when Void), Invalid, or not established yet. | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | | MR-SOFI-0345 | invariant | explicit | The claim final at K_root(q) counts, for a verifier resolving q, only as the claim (P, F) derive: its two roots are the ones the resolution chooses between. | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | | MR-SOFI-0346 | prohibition | explicit | Resolving another trader's position q reads no private object of the trader's and no position of the trader's outside the verifier's frontier-to-parent segment (within it, only q, the authenticated predecessor root at q − 1, and the accepted claim at each setup position explicitly named by P's legs, each authenticated through DSM Amendment A8); it reads no unrelated trader position and has no fallback lineage walk (owner ruling, 2026-09-30). | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | -| MR-SOFI-0347 | obligation | explicit | SetupValid reads the trader's accepted claim at a setup's position by frontier-relative verification of the trader's lineage (DSM Amendment A8), never by replaying the trader's history to genesis or recursing behind a step's one-hop evidence. | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | +| MR-SOFI-0347 | obligation | explicit | SetupValid reads the trader's accepted claim at a setup's position by frontier-relative verification of the trader's lineage (DSM Amendment A8), never by replaying the trader's history to genesis; a credit's source on the way is held to account through its own segment to a frontier the verifier holds. | amendment: SoFi Amendment S15 (2026-09-30; DSM A8 credit sources 2026-10-01) | owner | none | | MR-SOFI-0348 | invariant | explicit | Adoption is a construction predicate of the trader's own transition, enforced when the trader installs a realized position; a later verifier resolving which root a historical position selected does not re-run it, and that resolution requires no adoption leaf or other private trader state. | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | | MR-SOFI-0349 | obligation | explicit | ERA has two decimals: a faucet claim releases 100.00 ERA (10,000 base units), the token creation fee is 10.00 ERA (1,000 base units), and every amount is entered and shown in whole tokens with the token's decimals after a point, never as base units. | amendment: SoFi Amendment S18 (2026-10-01) | owner | none | | MR-SOFI-0350 | obligation | explicit | Creating a vault indexes its genesis preimage under `vault_token_locator(t) = H(vault-token-locator/v1; t)` for each of the two tokens `t` (policy commits) of its market pair, beside its genesis locator. | amendment: SoFi Amendment S16 (2026-10-01) | owner | none | diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index 27aa053a9..80bf2f50d 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -175,4 +175,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-SOFI-0311: the receiving device refuses an online transfer its token's policy forbids, whatever its sender checked | `dsm_sdk` · sdk/core_sdk.rs · `execute_on_relationship_inner` → `enforce_policy_for_operation`, under the state-machine lock | `dsm_sdk::handlers::sender_admission_tests::a_non_transferable_token_refuses_its_transfer` (the receiver's canonical apply of a transfer the sender signed) | The check skipped for transfers addressed to this device → red: "reached acceptance: the policy did not refuse" (2026-09-30). | — | | MR-DSM-0273: a segment step whose transition does not produce its root is refused where it sits, however authentic its claim | `dsm` · economic/peer_lineage.rs · `Verifier::segment` → `full_step` | `dsm_sdk::handlers::frontier_verification_tests::a_root_no_transition_explains_is_refused_where_it_sits` (a final claim at position 2, signed by the device's own AK, naming its first step's manifest and an invented root) | Segment steps checked as claims only → red: the invented root is accepted and position 3 is read (2026-09-30). | — | | MR-DSM-0274, MR-DSM-0275: a receiver reads nothing of a peer at or behind its frontier | `dsm` · economic/peer_lineage.rs · `Verifier::frontier_below`, `start`; `dsm_sdk` · sdk/economic_registers.rs · `StoredFrontiers` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier` | Recorded frontiers discarded → red: B reads A's cell behind its frontier (2026-09-30). | — | -| MR-DSM-0273, MR-DSM-0275: a credit's source is validated one hop back and nothing behind it is read | `dsm` · economic/peer_lineage.rs · `Verifier::one_hop_source` (claims-only chain), `NoFurtherHop` | `dsm_sdk::handlers::frontier_verification_tests::a_credits_source_is_validated_one_hop_back_and_no_further` | The source's earlier positions validated in full → red: B reads the evidence behind C's step at 1 (2026-09-30). | — | +| MR-DSM-0273, MR-DSM-0275: a credit's source is validated through its own segment back to a frontier the receiver holds, and nothing behind that frontier is read | `dsm` · economic/peer_lineage.rs · `Verifier::source_step`, `SourceAncestry`, `DebitHasNoSources` | `dsm_sdk::handlers::frontier_verification_tests::a_credits_source_is_validated_through_its_own_segment`; `dsm_sdk::handlers::frontier_verification_tests::a_sources_segment_stops_at_the_frontier_the_receiver_holds` | The source's earlier positions authenticated by claims only (the one-hop walk) → red: B does not read C's first step's evidence (2026-10-01). | Tests 1 and 2 of the owner's ruling (invented root behind a source) owed: hostile-device harness. | +| MR-DSM-0275: a walk past its budget before a trusted frontier is incomplete and reads nothing more | `dsm` · economic/peer_lineage.rs · `Verifier::spend_step` | `dsm::economic::peer_lineage::tests::a_source_walk_past_the_budget_is_incomplete_and_reads_nothing` | Budget check removed → red: the source walk reads C's cells (2026-10-01). | — | +| MR-DSM-0274: a source's frontier is recorded with the acceptance and reused | `dsm_sdk` · handlers/storage_routes.rs (accept transaction); `dsm` · economic/peer_lineage.rs · `Verifier::frontier_below` | `dsm_sdk::handlers::frontier_verification_tests::a_sources_frontier_is_recorded_and_the_next_credit_walks_only_its_suffix`; `dsm_sdk::handlers::frontier_verification_tests::one_walk_validates_a_sources_segment_once` | Source frontiers not recorded → red; reuse within a walk disabled → red (2026-10-01). | — | +| MR-DSM-0274: no frontier from a walk that did not finish | `dsm_sdk` · handlers/storage_routes.rs (the only writer is the accept transaction) | `dsm_sdk::handlers::recipient_admission_tests::an_outage_holds_the_transfer_cleanly_and_it_recovers` | No gate to remove: structural (2026-10-01). | — | From 483b2c3693a77e868cceff289f8960fe1fd887e0 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:31:27 -0400 Subject: [PATCH 23/31] docs(spec): S20 decides and matches the position pair from its own bytes (12j) A reader passed over an F whose P it did not hold yet, so an F naming an unpublished P, written ahead of the F a trader traded with, flipped the position between verifiers reading before and after the P appeared, and froze the vault key the trade's exercise held (SOFI's node probe). Applying the item-1 ruling ("the object itself proves authority for that position"): K_ful(q) is decided from F's bytes alone; the pair is matched by FulfillmentId(F); an F whose K_ful(q) another F holds in any state is lost; the C_q body is compared with derive(P, F) where P is in hand, a difference losing F and making the position Invalid. MR-SOFI-0168, 0343, 0360, 0361 rewritten and Partial until SOFI's change lands on #1096. --- specs/SoFi_Settlement_Specification.md | 10 ++++++--- specs/requirements/CONFORMANCE_GAPS.md | 26 ++++++++++++++++------- specs/requirements/MASTER_REQUIREMENTS.md | 14 ++++++------ 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/specs/SoFi_Settlement_Specification.md b/specs/SoFi_Settlement_Specification.md index f33a86bff..148c0942e 100644 --- a/specs/SoFi_Settlement_Specification.md +++ b/specs/SoFi_Settlement_Specification.md @@ -1114,12 +1114,16 @@ Cq is SofiResolutionClaim, CORE/sofi/wire/objects.rs:580, class 0x003A. > > - **`C_q` is signed by the trader.** `K_root(q)` holds the trader-signed `C_q`, class `0x0062` (`SignedSofiResolutionClaim`), never a bare `CCB(C_q)`. It carries the six fields of `C_q`, then `signature_alg`, the claimant key, the trader device's `AttA`, and a signature over `H(DSM/sofi/resolution-claim-sign/v1 ‖ CCB(C_q) ‖ u16be(alg) ‖ u32be(|key|) ‖ key ‖ AttA)`. > - It occupies the cell only when that signature verifies and `derive_devid(key, AttA)` is the trader's `DevID`. A bare `C_q` is refused by name. -> - `C_q` is still computed from `P` and `F`: every comparison is of that body (§19.6, §36), and the cell's claim is identified by the entry digest of the body. -> - **`F` carries the trader device's `AttA`**, after its key. `F` names `K_ful(q)` only when `derive_devid(F.key, F.AttA)` is the trader's `DevID`. That is decided from `F`'s bytes before `P` is fetched, so a foreign-key `F` neither holds the cell nor makes a reader wait on a `P` it names. With `F.key = P.key` (P conformance 8), it binds `P`'s key too. +> - `C_q` is still computed from `P` and `F`. The cell's claim is identified by the entry digest of the body, the pair is matched by the body's `fulfillment_id`, and the body is compared with `derive(P, F)` wherever `P` is in hand (below; §19.6, §36). +> - **`F` carries the trader device's `AttA`**, after its key. `F` names `K_ful(q)` only when `derive_devid(F.key, F.AttA)` is the trader's `DevID`. That is decided from `F`'s bytes alone (below), so a foreign-key `F` neither holds the cell nor makes a reader wait on a `P` it names. With `F.key = P.key` (P conformance 8), it binds `P`'s key too. > - **The exercise carries the trader's signed `C_q`.** Recognition requires that its body is the `C_q` of the exercise's `P` and `F`, under the same bound key, and that `derive_devid(F.key, F.AttA)` is `P`'s `DevID`. Anything that can hold a vault key therefore carries everything needed to register its own `F`. > - **A relayer never authors `C_q`.** "Any caller MAY relay F" (above) stands, but the relayer carries the trader's signed `C_q` from the exercise, or from the cell, exactly as signed. It does not recompute it. +> - **`K_ful(q)` is decided from `F`'s bytes alone.** An object names `K_ful(q)` when it is a fulfillment envelope that recognizes under its own key (R8), its position is `q`, and `derive_devid(F.key, F.AttA)` is the trader's `DevID`. `P` is never read to decide it. `F` carries no genesis: the cell's key is derived from `(G, DevID, q)`, and only the holder of `DevID`'s key can write an `F` that passes, so whatever holds the trader's `K_ful(q)` is the trader's own act. Before this, a reader passed over an `F` whose `P` it did not hold yet. A trader could write `F_a`, naming a `P_a` it had not published, ahead of the `F_b` it traded with: a verifier reading before `P_a` appeared saw `F_b` registered and the trade realized, and one reading after saw `F_a` holding the cell and `F_b` neither registered nor lost. +> - **Registration is matched by `F`'s id.** `FulfillmentRegistered(F)` holds when `K_ful(q)` is final on `F` and `K_root(q)` is final on a trader-signed `C_q` whose `fulfillment_id` is `FulfillmentId(F)`. The pair is matched from the two cells' bytes, so registration is the same for every verifier at every time. +> - **Lost.** `F` is lost at `q` when another `F` holds `K_ful(q)`'s leader link in any state, leader-held, preserved or final (§23.1: once the leader keeps a value, no other value is final at that cell), or when `K_root(q)`'s leader link is held by a claim other than one naming `FulfillmentId(F)`, another fulfillment's or an ordinary transition's. A lost `F` is what S14's skip frees: its exception for `F`'s own `C_q`, "whose fulfillment may still be relayed", holds only while no other `F` holds `K_ful(q)`'s leader link. +> - **The body is checked where `P` is in hand.** In an exercise's facts (`P` comes with the exercise) and in a position's resolution (Amendment S15), the `C_q` at `K_root(q)` is compared with `derive(P, F)`. A difference means `K_root(q)` holds a claim that is not `F`'s `C_q`: `F` is lost there, by S14's skip with no Void, and the position resolves Invalid for the trader's lineage. The vault and the lineage therefore agree on the one operation. `P`'s genesis, `DevID` and key are checked against the position and `F` in the same place (conformance). A trader that withholds the `P` its winning `F` names wedges only its own lineage, the same way for every verifier, and the vault keys held by its other fulfillments' exercises are freed by the skip. > -> This amends this section (`K_root(q)` holds the signed `C_q`, class `0x0062`), "What it accomplishes", the class registries of §14.2 and §15 (the occupant at `K_root(q)` is `0x0062`), §19.6, the crash table of §25, stages 7 and 8 of §31, §33, §36, and conditions 4 and 8 of §44: wherever a relayer or another caller "completes the cells", it does so with the trader's signed `C_q`. Bytes written in the previous format occupy nothing, and nothing written before this amendment carries over (a clean cut). +> This amends this section (`K_root(q)` holds the signed `C_q`, class `0x0062`), "What it accomplishes", the class registries of §14.2 and §15 (the occupant at `K_root(q)` is `0x0062`), §19.6, the crash table of §25, stages 7 and 8 of §31, §33, §36, and conditions 4 and 8 of §44: wherever a relayer or another caller "completes the cells", it does so with the trader's signed `C_q`. It also amends §23.6 (the pair is matched by `FulfillmentId(F)` from the two cells' bytes) and Amendment S14's skip (a fulfillment whose `K_ful(q)` another fulfillment holds is lost, as is one whose `C_q` body differs from `derive(P, F)`). Bytes written in the previous format occupy nothing, and nothing written before this amendment carries over (a clean cut). **Rule — registration** diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 46246ef0b..5e918df31 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2078,13 +2078,23 @@ The quote took 20 s. **Formal model.** No model changes. The Lean recognition boundary (`lean4/DSMRecognition.lean`, `Authorized`) already required the signature under the owner key the state names. The code now checks that, with the owner key fixed by the DevID derivation. +**S20 extended the same day (12j, SOFI's §6.66).** SOFI found, and confirmed with a node probe, that `read_registration` passed over an `F` whose `P` it did not hold yet. A trader writes `F_a`, naming an unpublished `P_a`, ahead of the `F_b` it trades with. A verifier reading before `P_a` appears sees `F_b` registered and the trade realized. One reading after sees `F_a` holding `K_ful(q)`, leader-held only, and `F_b` neither registered nor lost, so the vault key `F_b`'s exercise holds freezes. The binding above does not stop this, because `F_a` is signed under the trader's own key. Applying the same ruling ("the object itself proves authority for that position"), S20 now has four further rules: +- `K_ful(q)` is decided from `F`'s bytes alone: it recognizes under its own key, its position is `q`, and its key and `AttA` derive the `DevID`. +- The pair is matched by `FulfillmentId(F)`. +- An `F` whose `K_ful(q)` another `F` holds in any state is lost. +- The `C_q` body is compared with `derive(P, F)` where `P` is in hand. A difference loses `F` (S14's skip, no Void) and makes the position Invalid for the lineage, so the vault and the lineage agree. + +MR-SOFI-0168, 0343, 0360 and 0361 are rewritten and set to Partial until SOFI's change and its probe test land on #1096. + **Open, on #1096 (SOFI, MR-SOFI-0362).** S20 also makes the exercise carry the trader's signed `C_q`. Without it, a trader whose exercise is final at a vault key but who never publishes its `C_q` leaves that `F` unregistrable. No relayer can author the claim, so the vault waits at that root. `recognize_exercise` will bind the exercise's key to `P`'s device, and `read_registration` will dismiss a foreign-key `F` before fetching its `P`. | Row | Was | Now | Why | |---|---|---|---| | MR-DSM-0277 | — | Met | Added by Amendment A10. Root claims and `C_q` are recognized only when signed under a key that derives the cell's device. | -| MR-SOFI-0360 | — | Met | Added by Amendment S20. `K_root(q)` holds the trader-signed `C_q`, and a bare one occupies nothing. | -| MR-SOFI-0361 | — | Met | Added by Amendment S20. `F` binds its key to the cell's device from its own bytes, before `P` is fetched. | +| MR-SOFI-0360 | — | Partial | Added by Amendment S20. `K_root(q)` holds the trader-signed `C_q`, and a bare one occupies nothing. The body comparison where `P` is in hand (12j) lands with SOFI's change. | +| MR-SOFI-0361 | — | Partial | Added by Amendment S20. `F` binds its key to the cell's device from its own bytes, before `P` is fetched; deciding `K_ful(q)` without `P` (12j) lands with SOFI's change. | +| MR-SOFI-0168 | Met | Partial | S20 extended (12j): the pair is matched by `FulfillmentId(F)`; the code still matches the derived body, which needs `P`. | +| MR-SOFI-0343 | Met | Partial | S20 extended (12j): an `F` whose `K_ful(q)` another `F` holds in any state is lost; the code waits for that `F` to be final. | | MR-SOFI-0362 | — | Missing | Added by Amendment S20. The exercise does not carry the signed `C_q` yet. | Mutation controls, each run and restored with `git checkout`: @@ -2098,11 +2108,11 @@ Mutation controls, each run and restored with `git checkout`: | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| | DSM high-level (MR-DSM) | 277 | 96 | 95 | 39 | 0 | 29 | 18 | -| SoFi (MR-SOFI) | 362 | 236 | 86 | 19 | 4 | 17 | 0 | +| SoFi (MR-SOFI) | 362 | 232 | 90 | 19 | 4 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 65 | 18 | 56 | 0 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **943** | **406** | **200** | **115** | **4** | **64** | **154** | +| **All** | **943** | **402** | **204** | **115** | **4** | **64** | **154** | ## 8 Per-requirement results @@ -2559,7 +2569,7 @@ Mutation controls, each run and restored with `git checkout`: | MR-SOFI-0165 | Partial | dsm_sdk · sdk/sofi_register.rs (leader-first batch write, L257-296) | dsm_sdk sofi_register.rs tests | The leader-first paired write lives in `sofi_register`. SoFi has no production entry (§3 G1); this obligation has to happen in the running system, and its only implementation is unreachable. | | MR-SOFI-0166 | Met | `dsm_storage_node::api::cells::put_cell`; `dsm_storage_node::api::cells::get_cell`; `dsm::sofi::conformance::fulfillment_conformance` | `dsm_storage_node::cells_keep_everything::a_second_value_at_a_key_is_kept_after_the_first_never_refused`; `dsm_storage_node::cells_keep_everything::only_malformed_requests_are_refused` | The storage node has no SoFi or route-chain code; specific cell tests confirm this, not just file-level absence: the node keeps every value and refuses only malformed requests. | | MR-SOFI-0167 | Met | `dsm::sofi::derive::resolution_claim`; `dsm::sofi::registration` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim` | — | -| MR-SOFI-0168 | Met | `dsm::sofi::registration::fulfillment_registered` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim` | — | +| MR-SOFI-0168 | Partial | `dsm::sofi::registration::fulfillment_registered` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim` | S20 extended 2026-10-01 (§6.65, 12j): the pair is still matched by the entry digest of `derive(P, F)`, which needs `P`; matching by `FulfillmentId(F)` from the two cells' bytes lands with SOFI's change on #1096. | | MR-SOFI-0169 | Met | `dsm::sofi::registration` (Final adapter, first-writer-wins) | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim` | — | | MR-SOFI-0170 | Met | `dsm::sofi::wire::objects::SofiExercise`; `dsm::sofi::exercise::recognize_exercise` | `dsm::sofi::exercise::tests::an_exercise_round_trips_and_rebuilds_into_bound_objects`; `dsm::sofi::exercise::tests::an_exercise_counts_only_when_f_and_p_are_signed_under_the_key_p_commits`; `dsm::sofi::publication::tests::an_envelope_is_recognized_only_when_its_signature_verifies_under_its_bodys_key` | "The signed F, the signed P": recognition verifies both (§6.23). | | MR-SOFI-0171 | Met | `dsm::sofi::exercise::exercise_names_key` | `dsm::sofi::exercise::tests::an_exercise_names_exactly_the_keys_its_fulfillment_and_precommit_name`; `dsm::sofi::exercise::tests::a_successor_key_resolves_to_the_e_of_the_exercise_that_names_it_or_stays_open` | — | @@ -2734,7 +2744,7 @@ Mutation controls, each run and restored with `git checkout`: | MR-SOFI-0340 | Met | `dsm::sofi::validation::trader_pre_balances`; `dsm::sofi::validation::Evidence::pre_balance` | `dsm::sofi::validation::tests::a_balance_the_closure_does_not_carry_is_invalid`; `dsm::sofi::validation::tests::bytes_at_a_named_address_that_are_not_a_balance_are_invalid`; `dsm::sofi::validation::tests::a_named_balance_not_in_hand_waits`; `dsm::sofi::validation::tests::bytes_that_do_not_re_derive_the_named_address_prove_nothing` | Amendment S12 (§6.40): a missing number is Invalid in hand (`route_invalid_in_hand`); a named object not in hand, or not re-deriving its address, is Unavailable. | | MR-SOFI-0341 | Met | `dsm::sofi::validation::trader_pre_balances`; `dsm::sofi::validation::realize_root`; `dsm::sofi::resolve::Verifier::acquire_evidence`; `dsm_sdk::sdk::sofi_reads::LiveSofiReads` | `dsm::sofi::validation::tests::a_trade_that_does_not_debit_the_trader_is_invalid_from_the_exercise_alone`; `dsm_sdk::handlers::node_e2e_tests::a_vault_traded_through_closes_for_its_owner`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer` | Amendment S12 (§6.40): one path for every verifier; the trader's own route is judged from the same objects. | | MR-SOFI-0342 | Met | `dsm::sofi::validation::setup_lineage`; `dsm::sofi::validation::InvalidLineage`; `dsm::sofi::validation::setup_valid` | `dsm::sofi::validation::tests::a_setup_on_a_lineage_known_invalid_is_invalid`; `dsm::sofi::validation::tests::an_invalid_verdict_about_another_lineage_or_position_supplies_nothing`; `dsm::sofi::validation::tests::a_lineage_not_established_leaves_the_setup_waiting` | Amendment S13 (§6.40): no accepted claim is synthesized; the verdict of lineage validation is the negative fact, and a verdict about another trader or position establishes nothing. | -| MR-SOFI-0343 | Met | `dsm::sofi::resolution::classify_attempt`; `dsm::sofi::resolution::SkipReason`; `dsm::sofi::facts::establish` | `dsm::sofi::resolution::tests::a_final_cell_whose_fulfillment_lost_its_position_is_skipped`; `dsm::sofi::resolution::tests::an_in_hand_refutation_answers_as_the_complete_facts_do`; `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim`; `dsm_sdk::handlers::node_e2e_tests::a_key_whose_fulfillment_can_never_register_is_skipped_without_its_evidence` | SoFi Amendment S14 (owner, 2026-09-30, `fix/sofi-beta-blockers`). `establish` reads from the position pair whether another claim holds this F's position (`position_lost`). A final cell whose exercise carries such an F, one that can never register, is Skipped with `SkipReason::RejectedFinalInadmissible`; the position resolves only through the claim that holds it (`resolve_position` answers `NotRegistered` for this F). It follows the `FulfillmentImpossible` check, so an exercise refuted in hand is classified exactly as before. Mutation: the skip removed from `classify_attempt` → the first test red; restored. TLA `DSM_SofiFulfillment`: `LostPosition` is a disjunct of `Skipped` beside `RouteImpossibleAt`, and `_LostPositionDropped` produces the counterexample. Lean: `a_lost_position_makes_a_final_cell_skippable`, for a route or a single leg, with validation unavailable; dropping `positionLost` from `finalSkippable` → red. The position is also lost while no fulfillment is final at `K_ful(q)`: registration then names the claim holding `K_root(q)` (`Registration::RootTaken`), and an F whose own `C_q` is another claim is lost (MR-SOFI-0241's node test takes that route, a rival claim written before the trade). | +| MR-SOFI-0343 | Partial | `dsm::sofi::resolution::classify_attempt`; `dsm::sofi::resolution::SkipReason`; `dsm::sofi::facts::establish` | `dsm::sofi::resolution::tests::a_final_cell_whose_fulfillment_lost_its_position_is_skipped`; `dsm::sofi::resolution::tests::an_in_hand_refutation_answers_as_the_complete_facts_do`; `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim`; `dsm_sdk::handlers::node_e2e_tests::a_key_whose_fulfillment_can_never_register_is_skipped_without_its_evidence` | SoFi Amendment S14 (owner, 2026-09-30, `fix/sofi-beta-blockers`). `establish` reads from the position pair whether another claim holds this F's position (`position_lost`). A final cell whose exercise carries such an F, one that can never register, is Skipped with `SkipReason::RejectedFinalInadmissible`; the position resolves only through the claim that holds it (`resolve_position` answers `NotRegistered` for this F). It follows the `FulfillmentImpossible` check, so an exercise refuted in hand is classified exactly as before. Mutation: the skip removed from `classify_attempt` → the first test red; restored. TLA `DSM_SofiFulfillment`: `LostPosition` is a disjunct of `Skipped` beside `RouteImpossibleAt`, and `_LostPositionDropped` produces the counterexample. Lean: `a_lost_position_makes_a_final_cell_skippable`, for a route or a single leg, with validation unavailable; dropping `positionLost` from `finalSkippable` → red. The position is also lost while no fulfillment is final at `K_ful(q)`: registration then names the claim holding `K_root(q)` (`Registration::RootTaken`), and an F whose own `C_q` is another claim is lost (MR-SOFI-0241's node test takes that route, a rival claim written before the trade). S20 extended 2026-10-01 (§6.65, 12j): an `F` whose `K_ful(q)` another `F` holds only leader-held or preserved is not yet lost, so its key can freeze (SOFI's probe); that and the body mismatch as a loss land with SOFI's change on #1096. | | MR-SOFI-0344 | Met | `dsm::sofi::resolve::Verifier::peer_position`; `dsm::sofi::resolve::PeerPositionResolver`; `dsm::sofi::lineage::advance_peer_resolved`; `dsm_sdk::sdk::sofi_reads::VerifierContext::peer_position_resolver` | `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | SoFi Amendment S15 (`feat/frontier-relative-peer-verification`, 2026-09-30). `peer_position` runs the trader's own stages for position q over the verifier's reads (registration from the pair, P by its id, the exercise at the first leg's cell, each vault's chain, the facts) and the ladder inside `advance_peer_resolved`, which shares `advance_resolved`'s derivation. Failures keep their class: Incomplete for a read not in hand, Unresolved for facts that do not decide q, Invalid only for a verified contradiction. On the nodes, a trader who traded pays a third party and the payee accepts (P15-9). Mutations: the resolver refusing every C_q, and the resolver selecting the void root: both red, the payee never credits. | | MR-SOFI-0345 | Met | `dsm::sofi::registration::fulfillment_registered`; `dsm::sofi::resolve::Verifier::peer_position` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim`; `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | SoFi Amendment S15. `peer_position` requires the pair to hold registered the fulfillment the held claim names, and registration holds F registered only while `K_root(q)` is final on exactly `C_q(P, F)`; a final value is the cell's only one, so the held claim is that claim and its two roots are the ones the resolution chooses between. A separate equality check in `peer_position` could never fail and was removed. | | MR-SOFI-0346 | Partial | `dsm::sofi::resolve::Verifier::peer_position`; `dsm_sdk::sdk::economic_registers::resolve_peer` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier`; `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | SoFi Amendment S15 (wording made exact 2026-09-30: the setup positions the legs name are inside the segment and read through A8). The resolver reads q's position pair, P, the first leg's cell, the vault chains and q's evidence, and the trader's accepted claims at its setup positions through `resolve_peer`, which DSM core's walk bounds to the frontier (that test). Partial: no request-log test of the resolver's own read set yet. | @@ -2751,8 +2761,8 @@ Mutation controls, each run and restored with `git checkout`: | MR-SOFI-0357 | Met | `dsm::sofi::validation::route_endpoints`; `dsm::sofi::validation::swap_endpoints`; `dsm::sofi::validation::validate_swap`; `dsm::sofi::validation::route_shape`; `dsm::sofi::validation::split_sums`; `dsm::sofi::validation::chain_holds`; `dsm_sdk::sdk::sofi_sdk::draft_route`; `dsm::sofi::validation::movement_shape` | `dsm::sofi::validation::tests::a_route_chains_or_splits_and_its_endpoints_follow`; `dsm::sofi::validation::tests::a_split_whose_legs_sum_to_the_intent_is_not_refuted_by_its_shape`; `dsm::sofi::validation::tests::a_split_that_does_not_sum_to_the_intent_is_invalid`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). RouteValidation accepts hops that chain or hops that all trade the intent's pair with inputs and outputs summing to the intent in checked arithmetic (`Invalid::SplitDoesNotSum` otherwise); each hop is priced by its own vault. The producer states the intent by the same rule. `movement_shape` is the shape `route_endpoints` states a route's ends by (§6.56). | | MR-SOFI-0358 | Met | `dsm::sofi::validation::trader_movements`; `dsm::sofi::validation::validate_swap` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). On the nodes a split through two vaults of ERA/TKN realizes as one position: B is debited the whole input and credited the sum of both legs, and each vault moved by its own leg alone. | | MR-SOFI-0359 | Met | `dsm_sdk::sdk::sofi_flow::find_route`; `dsm_sdk::sdk::sofi_flow::plan`; `dsm_sdk::sdk::sofi_flow::plan_split`; `dsm_sdk::sdk::sofi_flow::trade`; `dsm_sdk::sdk::sofi_flow::route_ends` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). The search weighs each pair of direct vaults as a split beside every single hop and chain and proposes the most; the split share is searched over the inputs both legs price. The trade plans the same split again at the heads it walks; min-out bounds what it accepts. The quote reports the route's total by that rule (§6.56). | -| MR-SOFI-0360 | Met | `dsm::sofi::wire::objects::SignedSofiResolutionClaim`; `dsm::sofi::signature::verify_resolution_claim`; `dsm::sofi::signature::sign_resolution_claim`; `dsm::economic::register::read_root_cell` (body identity); `dsm_sdk::sdk::sofi_register` (producer); `dsm_sdk::sdk::sofi_relay::carry_pair` | `dsm::economic::claim_envelope::tests::a_conditional_cell_decodes_by_class`; `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell`; `dsm::economic::register::registered_root_construction_tests::a_held_root_cell_carries_the_exact_bytes_that_hold_it` | SoFi Amendment S20 (§6.65). `K_root(q)` holds the trader-signed `C_q` (`0x0062`); a bare `C_q` is refused by name, and a conditional claim is identified by its derived body. | -| MR-SOFI-0361 | Met | `dsm::sofi::wire::objects::TraderFulfillmentBody`; `dsm::sofi::registration::fulfillment_proves_the_device`; `dsm::sofi::registration::names_fulfillment_key`; `dsm_sdk::sdk::sofi_sdk::build_fulfillment` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell` | SoFi Amendment S20 (§6.65). The binding is decided from `F`'s bytes before `P` is looked up. Mutation: the check removed → that test red. | +| MR-SOFI-0360 | Partial | `dsm::sofi::wire::objects::SignedSofiResolutionClaim`; `dsm::sofi::signature::verify_resolution_claim`; `dsm::sofi::signature::sign_resolution_claim`; `dsm::economic::register::read_root_cell` (body identity); `dsm_sdk::sdk::sofi_register` (producer); `dsm_sdk::sdk::sofi_relay::carry_pair` | `dsm::economic::claim_envelope::tests::a_conditional_cell_decodes_by_class`; `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell`; `dsm::economic::register::registered_root_construction_tests::a_held_root_cell_carries_the_exact_bytes_that_hold_it` | SoFi Amendment S20 (§6.65). `K_root(q)` holds the trader-signed `C_q` (`0x0062`); a bare `C_q` is refused by name, and a conditional claim is identified by its derived body. S20 extended 2026-10-01 (§6.65, 12j): the body is compared with `derive(P, F)` inside registration, where a difference reads as not registered; the comparison where `P` is in hand, a difference losing `F` and making the position Invalid, lands with SOFI's change on #1096. | +| MR-SOFI-0361 | Partial | `dsm::sofi::wire::objects::TraderFulfillmentBody`; `dsm::sofi::registration::fulfillment_proves_the_device`; `dsm::sofi::registration::names_fulfillment_key`; `dsm_sdk::sdk::sofi_sdk::build_fulfillment` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell` | SoFi Amendment S20 (§6.65). The binding is decided from `F`'s bytes before `P` is looked up. Mutation: the check removed → that test red. S20 extended 2026-10-01 (§6.65, 12j): `names_fulfillment_key` still reads `P` (`PrecommitLookup`) after the binding, so which `F` holds `K_ful(q)` depends on whether its `P` is published yet; the `P`-free recognition lands with SOFI's change on #1096. | | MR-SOFI-0362 | Missing | — | — | SoFi Amendment S20 (§6.65). The exercise does not carry the trader's signed `C_q` yet; SOFI builds it on #1096 (recognition, producer, and the relay taking it from the exercise). | ### 8.3 dBTC native specification diff --git a/specs/requirements/MASTER_REQUIREMENTS.md b/specs/requirements/MASTER_REQUIREMENTS.md index d7469c241..a3d2e4ead 100644 --- a/specs/requirements/MASTER_REQUIREMENTS.md +++ b/specs/requirements/MASTER_REQUIREMENTS.md @@ -15,11 +15,11 @@ Every extraction in this round is taken against exactly these bytes: | File | `git hash-object` | Lines | |---|---|---| | `specs/DSM_High_Level_Explainer.md` | `b24c714e5699dbfc5d124da0dec2ff9e8f480c28` | 4323 | -| `specs/SoFi_Settlement_Specification.md` | `f33a86bffc6cc520fc6f6e7c681251a83dc67ab0` | 2689 | +| `specs/SoFi_Settlement_Specification.md` | `148c0942e8f7749430ccc6cdb0db090bd556ee79` | 2693 | | `specs/dBTC_Native_Specification.md` | `233a3e72a5b16a023af830f4c8ffaad4ba9391a8` | 2160 | | `specs/DSM_Storage_Node_Specification.md` | `415a9b9c67c7a2b4b6df78b0af85fb3bc7282ae8` | 636 | -Pins updated 2026-10-01 after DSM Amendment A10 and SoFi Amendment S20 (an occupant of a position cell proves its own authority); before that, 2026-10-01 after SoFi Amendments S16 (vaults found by their tokens; the setup inside the first operation through a vault; another trader's conditional parent resolved by a walk) and S19 (a route chains or splits) and S18 (ERA has two decimals); before that, 2026-09-30 after DSM Amendments A8 (frontier-relative verification) and A9 (the relationship-key tag is `DSM/smt-key`) and SoFi Amendments S14 (a final cell whose fulfillment can never register is skipped) and S15 (a trader's position resolved from public objects); before that, 2026-09-29 after SoFi Amendments S12 (the trader's balances before the trade) and S13 (a lineage known invalid); before that, 2026-09-26 after SoFi Amendment S11 (ERA's canonical policy); before that, 2026-09-24 after SoFi Amendments S8, S9 and S10 and the storage §9 rule on the leader first, one chain in route order and the completion proof (#977); before that, 2026-09-23 after storage §14 (#974), the set-identity amendment (SoFi Amendment S6, storage §10), the replication amendment (storage §12.5), the vault-consistency recommendation (SoFi §31), Amendment S7 (SoFi §24) and Amendment A7 (DSM §11, storage §8). The extractions of 2026-09-22 were taken against SoFi `4c62ee78…` (2597 lines) and storage `8d43ac02…` (571 lines); their line-based IDs refer to those bytes. +Pins updated 2026-10-01 after DSM Amendment A10 and SoFi Amendment S20 (an occupant of a position cell proves its own authority), with S20 extended the same day (the position pair decided and matched from its own bytes); before that, 2026-10-01 after SoFi Amendments S16 (vaults found by their tokens; the setup inside the first operation through a vault; another trader's conditional parent resolved by a walk) and S19 (a route chains or splits) and S18 (ERA has two decimals); before that, 2026-09-30 after DSM Amendments A8 (frontier-relative verification) and A9 (the relationship-key tag is `DSM/smt-key`) and SoFi Amendments S14 (a final cell whose fulfillment can never register is skipped) and S15 (a trader's position resolved from public objects); before that, 2026-09-29 after SoFi Amendments S12 (the trader's balances before the trade) and S13 (a lineage known invalid); before that, 2026-09-26 after SoFi Amendment S11 (ERA's canonical policy); before that, 2026-09-24 after SoFi Amendments S8, S9 and S10 and the storage §9 rule on the leader first, one chain in route order and the completion proof (#977); before that, 2026-09-23 after storage §14 (#974), the set-identity amendment (SoFi Amendment S6, storage §10), the replication amendment (storage §12.5), the vault-consistency recommendation (SoFi §31), Amendment S7 (SoFi §24) and Amendment A7 (DSM §11, storage §8). The extractions of 2026-09-22 were taken against SoFi `4c62ee78…` (2597 lines) and storage `8d43ac02…` (571 lines); their line-based IDs refer to those bytes. The DSM and SoFi specifications were amended on 2026-09-22 (marked "Amendment" in their text). The storage-node specification was added to the corpus on 2026-09-22, before any other extractor started. The owner accepted it in full the same day. Extract its items marked **Open** with Flags `ambiguous` and a Requirement text that says so, never as settled requirements. @@ -155,7 +155,7 @@ Each extraction also has a Findings table: - **The trader's balances before the trade (2026-09-29).** Conformance finding (CONFORMANCE §6.39): once one trader had traded through a vault, no other reader could classify the trade, because `TraderSideValid` needs the trader's balances before the trade and `T°` states them only as hashes; the owner's close of that vault never resolved. Owner decisions: the exercise carries each such balance as a content-addressed `TraderPreBalance` that `𝒞_E^pre` names, every verifier (the trader included) reads the balances from those objects, and a balance the closure does not name is Invalid, not undecided (SoFi Amendment S12). MR-SOFI-0241 is a different defect and is unchanged. MR-SOFI-0338–0341 added with source `amendment`; §1 re-pinned. - **A lineage known invalid (2026-09-29).** Conformance finding (CONFORMANCE §6.40): under Amendment S9 a trader whose lineage validation finds Invalid never yields an accepted claim, so its setup was never evaluated and its trade held a vault key forever. Owner decision: not yet known waits; known invalid makes the setup, and so the route, Invalid; no accepted claim is synthesized (SoFi Amendment S13). MR-SOFI-0331 rewritten; MR-SOFI-0342 added with source `amendment`; §1 re-pinned. - **A fulfillment that can never register (2026-09-30).** Conformance finding (CONFORMANCE §8, MR-SOFI-0239): `route_impossible` carried a fifth arm, `PositionLost`, which §23.5 does not list and which depends on a particular F, not on P and E. Without it, an exercise whose fulfillment can never register, because another claim holds its position, strands the DLV parent's attempt chain (TLA `DSM_SofiFulfillment`, `LostPosition`). Owner decision: a separate skip, `RejectedFinalInadmissible`, which is not an arm of `RouteImpossible`; `RouteImpossible` stays scoped to P and E (SoFi Amendment S14). MR-SOFI-0343 added with source `amendment`; §1 re-pinned. -- **An occupant proves its own authority (2026-10-01).** Security pre-audit finding: a position cell recognized any claim whose coordinates derived its key, so a claim signed under any key, or a conditional claim with no signature at all, occupied another device's next position; an `F` under any key held `K_ful(q)` likewise. Owner ruling: "If an object can win/occupy a root position, the object itself proves authority for that position" (DSM Amendment A10, SoFi Amendment S20). MR-DSM-0277 and MR-SOFI-0360, MR-SOFI-0361 and MR-SOFI-0362 added with source `amendment`; §1 re-pinned. +- **An occupant proves its own authority (2026-10-01).** Security pre-audit finding: a position cell recognized any claim whose coordinates derived its key, so a claim signed under any key, or a conditional claim with no signature at all, occupied another device's next position; an `F` under any key held `K_ful(q)` likewise. Owner ruling: "If an object can win/occupy a root position, the object itself proves authority for that position" (DSM Amendment A10, SoFi Amendment S20). MR-DSM-0277 and MR-SOFI-0360, MR-SOFI-0361 and MR-SOFI-0362 added with source `amendment`; §1 re-pinned. **Extended the same day (CONFORMANCE §6.66, 12j).** SOFI found, and confirmed by a node probe, that a reader passed over an `F` whose `P` it did not hold yet. A trader could put an `F` naming an unpublished `P` ahead of the `F` it traded with: verifiers reading before and after that `P` appeared disagreed about the position, and the vault key the trade's exercise held froze. Applying the same ruling, S20 now says `K_ful(q)` is decided from `F`'s bytes alone, the pair is matched by `FulfillmentId(F)`, an `F` whose `K_ful(q)` another `F` holds in any state is lost, and the `C_q` body is compared with `derive(P, F)` where `P` is in hand, a difference losing `F` and making the position Invalid. MR-SOFI-0168, 0343, 0360 and 0361 rewritten; §1 re-pinned. - **ERA has two decimals (2026-10-01).** Rig finding: ERA was whole-unit (decimals 0, SoFi Amendment S11), so a 0.30% fee on a 50 ERA trade could not be written as an ERA amount, and amounts were entered as raw base units. Owner ruling: ERA's decimals are 2, with the supply, faucet payout and creation fee unchanged in whole ERA; ERA's commitment changes (a clean cut); amounts are entered and shown in whole tokens with the token's decimals (SoFi Amendment S18). MR-SOFI-0335 rewritten; MR-SOFI-0349 added with source `amendment`; §1 re-pinned. - **Frontier-relative verification (2026-09-30).** Conformance finding (P15-9, and G16): a payee accepted a payer only by walking the payer's whole lineage from its activation root, re-validating every transition from evidence it fetched from the nodes, and a SoFi position in that history stopped the walk, so a trader who had traded could not pay. Owner decision: a receiver verifies forward from its own authenticated frontier: for each position of the segment it authenticates the authoritative root claim and verifies the transition producing that root with that transition's directly required evidence and provenance one hop back, never recursing behind that hop and never reading behind the frontier (DSM Amendment A8, corrected the same day: an authenticated root is not a valid transition, and a counterparty's acceptance does not stand for one); a SoFi position inside that segment is resolved from SoFi's public objects for that position alone, and adoption is not re-run by a later verifier (SoFi Amendment S15). MR-DSM-0273–0276 and MR-SOFI-0344–0348 added with source `amendment`; §1 re-pinned. - **The relationship-key tag (2026-09-30).** Conformance finding (CONFORMANCE §6.14, MR-DSM-0115, MR-DSM-0249): the explainer derived a relationship's SMT key under `DSM/smt-key/v1`, while the code and its golden vector use `DSM/smt-key`. Owner ruling: `DSM/smt-key` is the canonical relationship-SMT domain tag for beta, and `/v1` in the specification was an error (DSM Amendment A9). The formula in §26 and the example in §16 are corrected; no key migrates. MR-DSM-0115 rewritten; §1 re-pinned. @@ -631,7 +631,7 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0165 | obligation | explicit | Any caller may relay F; the writer puts F at Kful(q) and Cq at Kroot(q) in one local transaction at the leader of s(q), both or neither, then puts the same bytes on the other members. | cc: SOFI-017-4/L1082; gpt: SOFI-017-4/L1081 | 2/2 | none | | MR-SOFI-0166 | authority | explicit | The member stores bytes and establishes nothing about F or the route; only Core establishes conformance. | cc: SOFI-017-4/L1084 | 1/2: claude-chat | none | | MR-SOFI-0167 | invariant | explicit | Because Cq is computed from F and P, no claim that disagrees with F can be F's claim. | cc: SOFI-017-4/L1085; gpt: SOFI-017-4/L1085 | 2/2 | none | -| MR-SOFI-0168 | invariant | explicit | FulfillmentRegistered(F) holds iff Final(Kful(q), F) and Final(Kroot(q), Cq); Core concludes it from raw reads, and no member computes it or writes a registration record. | cc: SOFI-017-4/L1093; gpt: SOFI-017-4/L1093, SOFI-013/L707 | 2/2 | none | +| MR-SOFI-0168 | invariant | explicit | FulfillmentRegistered(F) holds iff Final(Kful(q), F) and Final(Kroot(q), Cq), where Cq is the trader-signed claim whose fulfillment_id is FulfillmentId(F), the pair matched from the two cells' bytes (Amendment S20); Core concludes it from raw reads, and no member computes it or writes a registration record. | cc: SOFI-017-4/L1093; gpt: SOFI-017-4/L1093, SOFI-013/L707 | 2/2 | none | | MR-SOFI-0169 | invariant | explicit | At most one fulfillment is registered per position q. | cc: SOFI-017-4/L1095 | 1/2: claude-chat | none | | MR-SOFI-0170 | invariant | explicit | The value written to each successor key of a route is the exercise: one canonical object carrying the signed F, the signed P, P(E), every Gj in leg order, and every closure object. | cc: SOFI-017-5/L1100; gpt: SOFI-017-5/L1099 | 2/2 | none | | MR-SOFI-0171 | evidence | explicit | At successor key K(a) of vault v at parent Rn, the value that counts is the first exercise at the leader whose F names (v, a) in attempts and whose P names (v, Rn) in legs; everything else at K counts as nothing. | cc: SOFI-017-5/L1111; gpt: SOFI-017-5/L1111 | 2/2 | ambiguous(GPT-12) | @@ -806,7 +806,7 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0340 | prohibition | explicit | A present balance of T° with no TraderPreBalance in 𝒞_E^pre, an object that disagrees with its entry, and an object that matches no entry are each Invalid, known from the committed bytes alone; an object 𝒞_E^pre names whose bytes are not in hand, or do not re-derive its address, is not evaluated yet and proves nothing. | amendment: SoFi Amendment S12 (2026-09-29) | owner | none | | MR-SOFI-0341 | evidence | explicit | Every verifier, the trader included, reads the trader's balances before the trade only from the TraderPreBalance objects 𝒞_E^pre names; an entry that states its balance as absent before the trade is absent, and a relationship entry's leaf before the trade is the one its base names, proven by the fold against T°.pre_root. A value counts only because it hashes to the leaf the core states; storage decides nothing. | amendment: SoFi Amendment S12 (2026-09-29) | owner | none | | MR-SOFI-0342 | prohibition | explicit | No accepted claim is synthesized for a lineage known invalid: the negative fact is the lineage verdict itself, and a verdict about another trader or another position establishes nothing about a setup. No trade whose trader's lineage is known invalid holds a vault key indefinitely because that lineage can never yield an accepted claim. | amendment: SoFi Amendment S13 (2026-09-29) | owner | none | -| MR-SOFI-0343 | obligation | explicit | A final DLV cell whose exercise carries a fulfillment that can never register, because `K_root(q)` for its position is final on a claim other than its own `C_q`, is skipped (`RejectedFinalInadmissible`). The skip is not an arm of `RouteImpossible`, reads no validation evidence, creates no Void and no Invalid, and once it holds it holds forever. | amendment: SoFi Amendment S14 (2026-09-30) | owner | none | +| MR-SOFI-0343 | obligation | explicit | A final DLV cell whose exercise carries a fulfillment F that can never register is skipped (`RejectedFinalInadmissible`): another fulfillment holds `K_ful(q)`'s leader link in any state, or `K_root(q)`'s leader link is held by a claim other than F's own `C_q` (another fulfillment's claim, an ordinary transition, or a claim naming F's id whose body is not derive(P, F)) (Amendment S20). The skip is not an arm of `RouteImpossible`, reads no validation evidence, creates no Void and no Invalid, and once it holds it holds forever. | amendment: SoFi Amendment S14 (2026-09-30) | owner | none | | MR-SOFI-0344 | obligation | explicit | Another trader's conditional position q is resolved from SoFi's public proof-carrying objects for q alone (the claim final at K_root(q), the registration pair, P and F, the exercise read back from the first leg's cell, and the vault cells and canonical chains the facts need, with their finality evidence) by the ladder over the facts Core establishes from them. The result is the one root q selected (P.realize_root when Realized, the root authenticated at q − 1 when Void), Invalid, or not established yet. | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | | MR-SOFI-0345 | invariant | explicit | The claim final at K_root(q) counts, for a verifier resolving q, only as the claim (P, F) derive: its two roots are the ones the resolution chooses between. | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | | MR-SOFI-0346 | prohibition | explicit | Resolving another trader's position q reads no private object of the trader's and no position of the trader's outside the verifier's frontier-to-parent segment (within it, only q, the authenticated predecessor root at q − 1, and the accepted claim at each setup position explicitly named by P's legs, each authenticated through DSM Amendment A8); it reads no unrelated trader position and has no fallback lineage walk (owner ruling, 2026-09-30). | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | @@ -823,8 +823,8 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0357 | obligation | explicit | A Swap route's hops either chain (§19.5) or split: every hop trades the intent's one pair through distinct vaults, the hops' inputs summing to `amount_in` and their outputs to `exact_out` in checked arithmetic, each hop priced by its own vault at its own head. | amendment: SoFi Amendment S19 (2026-10-01) | owner | none | | MR-SOFI-0358 | invariant | explicit | A split route is one operation: every hop lands at its own vault's cell, the route realizes only when every hop does, the trader's core moves only the intent's two endpoints with one relationship per vault, and `ROUTE_MAX_LEGS` bounds both shapes. | amendment: SoFi Amendment S19 (2026-10-01) | owner | none | | MR-SOFI-0359 | obligation | explicit | `sofi.findRoute` weighs a split across two vaults of the pair beside every single hop and chain and proposes the route that gives the trader the most; the trade prices the same split again at the heads it walks, and neither carries authority. | amendment: SoFi Amendment S19 (2026-10-01) | owner | none | -| MR-SOFI-0360 | invariant | explicit | `K_root(q)` holds the trader-signed C_q (class 0x0062): the derived C_q, the signing key, the trader device's AttA and a signature under `DSM/sofi/resolution-claim-sign/v1`; it occupies the cell only when the signature verifies and the key with that AttA derives the trader's DevID, and a bare C_q occupies nothing. Every comparison of C_q is of its derived body. | amendment: SoFi Amendment S20 (2026-10-01) | owner | none | -| MR-SOFI-0361 | invariant | explicit | F carries the trader device's AttA and names `K_ful(q)` only when its key with that AttA derives the trader's DevID, decided from F's bytes before P is fetched. | amendment: SoFi Amendment S20 (2026-10-01) | owner | none | +| MR-SOFI-0360 | invariant | explicit | `K_root(q)` holds the trader-signed C_q (class 0x0062): the derived C_q, the signing key, the trader device's AttA and a signature under `DSM/sofi/resolution-claim-sign/v1`; it occupies the cell only when the signature verifies and the key with that AttA derives the trader's DevID, and a bare C_q occupies nothing. The pair is matched by the body's fulfillment_id; wherever P is in hand (an exercise's facts, a position's resolution) the body is compared with derive(P, F), and a difference loses F and makes the position Invalid. | amendment: SoFi Amendment S20 (2026-10-01) | owner | none | +| MR-SOFI-0361 | invariant | explicit | F carries the trader device's AttA. Which F holds `K_ful(q)` is decided from F's bytes alone: it recognizes under its own key, its position is q, and its key with that AttA derives the trader's DevID; P is never read to decide it. | amendment: SoFi Amendment S20 (2026-10-01) | owner | none | | MR-SOFI-0362 | obligation | explicit | The exercise carries the trader's signed C_q, whose body is the C_q of the exercise's P and F under the same bound key; a relayer completes the position pair with the trader's signed C_q from the exercise or the cell and never authors C_q. | amendment: SoFi Amendment S20 (2026-10-01) | owner | none | ### 8.3 dBTC native specification From 51359a6b6b5344e3b7d181bb10d8fb3022323343 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:11:57 -0400 Subject: [PATCH 24/31] fix(sofi): a position pair is decided from its own bytes, the same whenever it is read (12j) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-audit item 12, 12j (SoFi Amendment S20, extended). Recognizing a fulfillment at K_ful(q) used to need the precommit it names, and a precommit not published yet (Resolved::None) made the reader pass over that fulfillment. A trader could write F_a, naming an unpublished P_a, ahead of the F_b it traded with: - verifiers reading before P_a appeared saw F_b registered and the trade realized; - verifiers reading after saw F_a holding the cell and F_b neither registered nor lost, with the vault key frozen. Now: - names_fulfillment_key decides from F's bytes alone: it verifies under its own key, names q, and its key and AttA derive the cell's DevID. No precommit is read, so read_registration fetches none, and NAMED_PRECOMMIT_BUDGET and PrecommitLookup go. - The pair is matched by FulfillmentId(F). Registration::Held names the fulfillment that holds K_ful(q)'s leader link before the pair is final, and RegistrationRead carries the id of the claim at K_root(q). - RegistrationRead::standing_of (P in hand) loses F to any other fulfillment holding K_ful(q)'s leader link, in any state (§23.1), and to any claim at K_root(q) other than derive(P, F). That covers rule 4: a claim naming F with another body. The facts read the pair through it. - The S15 resolver compares the held claim with derive(P, F) once P is in hand: Invalid when they differ. - route_chain's public leader_copies wrapper had no caller left and goes; leader_link keeps its iterator. Tests: - a_position_reads_the_same_before_and_after_a_precommit_is_published (node): red on the old code by the probe, where the read flipped and the key froze. - a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own (unit). - Mutations: another fulfillment's leader link losing nothing turns both red; skipping the body check of a registered pair turns the unit test red. --- .../dsm/src/route_chain.rs | 14 +- .../dsm/src/sofi/facts.rs | 34 +- .../dsm/src/sofi/registration.rs | 524 ++++++++++-------- .../dsm/src/sofi/resolve.rs | 99 +--- .../dsm_sdk/src/handlers/node_e2e_tests.rs | 206 +++++++ .../dsm_sdk/src/sdk/sofi_advance.rs | 11 +- .../dsm_sdk/src/sdk/sofi_flow.rs | 2 +- 7 files changed, 557 insertions(+), 333 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs b/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs index 1afbc643b..44bbd941b 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs @@ -581,18 +581,8 @@ fn is_cell_copy_at(entry: &RouteEntry, cell: &RoutedCell, position: usize) -> bo } /// The cell's position-0 copies in the leader's arrival log, in arrival -/// order: the values the leader link is chosen from (§9 route chains, rule -/// 3). A reader that must fetch what its recognizer needs before evaluating -/// the cell fetches it for these, in this order, and for nothing any other -/// seat holds. Nothing when the leader is unread. -pub fn leader_copies<'e>( - cell: &'e RoutedCell, - ev: &'e CellEvidence, -) -> impl Iterator + 'e { - leader_log_copies(cell, ev).map(|(.., entry)| entry) -} - -/// [`leader_copies`], each with its index in the leader's log. +/// order, each with its index in that log: the values the leader link is +/// chosen from (§9 route chains, rule 3). Nothing when the leader is unread. fn leader_log_copies<'e>( cell: &'e RoutedCell, ev: &'e CellEvidence, 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 eb17b9066..e98790b6b 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs @@ -35,7 +35,7 @@ use super::conformance::{ }; use super::derive; use super::exercise::{AttemptCellRead, RecognizedExercise}; -use super::registration::{Registration, RegistrationRead}; +use super::registration::{PairStanding, RegistrationRead}; use super::resolution::{ AttemptWalk, GroundRouteFacts, LegFacts, ParentPosition, ParentStatus, RefutedInHand, RouteFacts, VaultChain, @@ -377,7 +377,10 @@ impl Established { fulfillment_id: refutation.fulfillment_id, external_commitment: refutation.external_commitment, refuted: refutation.refuted, - registered: registration.is_registered_as(&exercise.fulfillment().body), + registered: matches!( + registration.standing_of(&exercise.precommit().body, &exercise.fulfillment().body), + PairStanding::Registered + ), })) } @@ -543,24 +546,12 @@ pub fn establish_ground(reads: &GroundReads<'_>) -> Result { - let ours = signed.body == *fulfillment; - (ours, !ours) - } - Registration::NeverRegistered { .. } => (false, true), - // SoFi Amendment S14: the position went to the claim holding K_root(q) - // unless that claim is this F's own C_q, whose fulfillment may still be - // relayed. - Registration::RootTaken { claim } => ( - false, - *claim - != crate::storage_cell::entry_digest( - &derive::resolution_claim(precommit, fulfillment).encode(), - ), - ), - Registration::Unresolved => (false, false), - }; + // Where this F stands at its pair, with P in hand (SoFi Amendments S14, + // S20): lost to any other fulfillment holding K_ful(q)'s leader link, or + // to any claim at K_root(q) other than derive(P, F). + let standing = reads.registration.standing_of(precommit, fulfillment); + let registered = matches!(standing, PairStanding::Registered); + let position_lost = matches!(standing, PairStanding::Lost); // The trader parent: what `P` names must be what the trader's lineage // holds at `p` — the claim final at its `K_root(p)` and the root that @@ -742,8 +733,7 @@ mod tests { &[], ); } - let lookup = BTreeMap::from([(derive::precommit_id(p), p.clone())]); - fulfillment_registered(&cells, &ful.evidence(), &root.evidence(), &lookup).unwrap() + fulfillment_registered(&cells, &ful.evidence(), &root.evidence()).unwrap() } /// `K^(attempt)` of `vault_id` at `parent_root`, holding `bytes` final 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 9ce17d0d7..921b1abf3 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/registration.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/registration.rs @@ -10,18 +10,20 @@ //! It is a conclusion, never a record: no member computes it, no member //! writes it, and nothing here consults anything but what the members hold. //! `Final` at each cell is the route-chain rule of storage spec §9 over the -//! recognized view — an object naming `K_ful(q)` is a fulfillment envelope -//! whose body sits at position `q` and whose `P` names the trader whose key it -//! is; an object naming `K_root(q)` is a registered claim whose coordinates -//! derive that key. Bytes that are neither count as nothing anywhere. +//! recognized view. An object naming `K_ful(q)` is a fulfillment envelope at +//! position `q` under a key that derives the trader's device; an object +//! naming `K_root(q)` is a registered claim whose coordinates derive that +//! key. Both are decided from the bytes alone (SoFi Amendment S20), so what +//! holds each cell, and whether the pair is registered, is the same for every +//! reader at every time. Bytes that are neither count as nothing anywhere. //! //! Because at most one value has a valid leader link at `K_ful(q)`, at most //! one fulfillment is registered per position. Registered is not conforming //! and not valid (Section 20.2): what this module answers is only whether the -//! exercise happened. - -use std::collections::BTreeMap; +//! exercise happened. Whether the claim at `K_root(q)` is exactly +//! `derive(P, F)` is checked where `P` is in hand ([`RegistrationRead::standing_of`]). +use crate::economic::claim_envelope::RegisteredEconomicClaim; use crate::economic::register::{position_seed, read_root_cell, RootCell}; use crate::route_chain::{ check_completion_proof, completion_proof, evaluate, CellError, CellEvidence, CellReading, @@ -33,23 +35,9 @@ use super::wire::{TraderFulfillmentBody, TraderPrecommitBody}; type D32 = [u8; 32]; -/// The precommits a reader fetched by `PrecommitId` (R8) for the fulfillment -/// candidates it saw. A candidate whose `P` is not in hand names nothing -/// yet: without `P`, neither the trader it belongs to nor `C_q` is known. -pub trait PrecommitLookup { - fn precommit(&self, id: &D32) -> Option<&TraderPrecommitBody>; -} - -impl PrecommitLookup for BTreeMap { - fn precommit(&self, id: &D32) -> Option<&TraderPrecommitBody> { - self.get(id) - } -} - /// Whether `F` proves its own authority for a cell of `device_id` (DSM /// Amendment A10, SoFi Amendment S20): its key, with the `AttA` it carries, -/// derives that device. Decided from `F`'s bytes alone, so a reader applies -/// it before fetching anything `F` names. +/// derives that device. Decided from `F`'s bytes alone. pub fn fulfillment_proves_the_device(body: &TraderFulfillmentBody, device_id: &D32) -> bool { crate::core::identity::genesis_v2::derive_devid( body.claimant_public_key(), @@ -57,39 +45,23 @@ pub fn fulfillment_proves_the_device(body: &TraderFulfillmentBody, device_id: &D ) == *device_id } -/// An object naming `K_ful(q)` of trader `(G, DevID)`: a fulfillment -/// envelope that recognizes (R8), whose body's position is `q`, whose key and -/// `AttA` derive `DevID` ([`fulfillment_proves_the_device`]), and whose `P` — -/// fetched by the id the body names — is that trader's. A fulfillment of -/// another trader or another position, or one under a key that does not -/// derive the trader's device, written at this key is nothing: neither a +/// An object naming `K_ful(q)` of the trader whose device is `device_id` +/// (SoFi Amendment S20): a fulfillment envelope that recognizes under its own +/// key (R8), whose body's position is `q`, and whose key and `AttA` derive +/// `device_id` ([`fulfillment_proves_the_device`]). Decided from `F`'s bytes +/// alone: `P` is never read here. `F` carries no genesis; the cell's key is +/// derived from `(G, DevID, q)`, and only the holder of `DevID`'s key can +/// write an `F` that passes, so whatever holds the trader's `K_ful(q)` is the +/// trader's own act. Anything else written at this key is nothing: neither a /// rival nor a winner, however early it arrived. pub fn names_fulfillment_key( bytes: &[u8], - genesis: &D32, device_id: &D32, position: u64, - precommits: &impl PrecommitLookup, ) -> Option> { let signed = recognize_fulfillment(bytes)?.1; - if signed.body.position() != position { - return None; - } - if !fulfillment_proves_the_device(&signed.body, device_id) { - return None; - } - let precommit = precommits.precommit(signed.body.precommit_id())?; - if precommit.genesis() != genesis || precommit.device_id() != device_id { - return None; - } - // F is signed under the key its P commits; each was verified under its - // own body's key when it was recognized. - if signed.body.signature_alg() != precommit.signature_alg() - || signed.body.claimant_public_key() != precommit.claimant_public_key() - { - return None; - } - Some(signed) + (signed.body.position() == position && fulfillment_proves_the_device(&signed.body, device_id)) + .then_some(signed) } /// The two cells of trader `(G, DevID)`'s position `q` as Core derives them @@ -165,6 +137,11 @@ pub struct RegistrationRead { position: u64, parent_root: D32, registration: Registration, + /// The id of the claim holding `K_root(q)`'s leader link, in any state: + /// the entry digest of its exact bytes, or of its derived body for a + /// conditional claim (SoFi Amendment S20). `None` while the leader holds + /// no claim. + root_claim: Option, } impl RegistrationRead { @@ -192,6 +169,11 @@ impl RegistrationRead { self.registration } + /// The id of the claim holding `K_root(q)`'s leader link, if any. + pub fn root_claim(&self) -> Option<&D32> { + self.root_claim.as_ref() + } + /// Whether this read is of the position `fulfillment` claims for /// `precommit`'s trader, routed by the root `precommit` was built on. pub fn is_of( @@ -205,13 +187,58 @@ impl RegistrationRead { && self.parent_root == *precommit.void_root() } - /// `FulfillmentRegistered(q, F)` for exactly `fulfillment`: registered, - /// and the registered envelope's body is this one. - pub fn is_registered_as(&self, fulfillment: &TraderFulfillmentBody) -> bool { - matches!(&self.registration, Registration::Registered(signed) if signed.body == *fulfillment) + /// Where `fulfillment`, of `precommit`, stands at this pair, with `P` in + /// hand (SoFi Amendments S14 and S20). `F` is lost when another + /// fulfillment holds `K_ful(q)`'s leader link, in any state (§23.1: once + /// 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. + pub fn standing_of( + &self, + precommit: &TraderPrecommitBody, + fulfillment: &TraderFulfillmentBody, + ) -> PairStanding { + let own = crate::storage_cell::entry_digest( + &derive::resolution_claim(precommit, fulfillment).encode(), + ); + let root_is_another = self.root_claim.is_some_and(|claim| claim != own); + let holder = match &self.registration { + Registration::Registered(signed) + | Registration::Held(signed) + | Registration::NeverRegistered { + fulfillment: signed, + .. + } => Some(&signed.body), + Registration::RootTaken { .. } | Registration::Unresolved => None, + }; + if root_is_another || holder.is_some_and(|held| held != fulfillment) { + return PairStanding::Lost; + } + match self.registration { + Registration::Registered(..) => PairStanding::Registered, + Registration::Held(..) + | Registration::NeverRegistered { .. } + | Registration::RootTaken { .. } + | Registration::Unresolved => PairStanding::Pending, + } } } +/// Where one fulfillment stands at its position pair, with its `P` in hand +/// ([`RegistrationRead::standing_of`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PairStanding { + /// `FulfillmentRegistered(F)`, the pair's claim being exactly + /// `derive(P, F)`. + Registered, + /// Not yet: `F` may still register. + Pending, + /// `F` can never register at `q` (SoFi Amendments S14, S20). Permanent. + Lost, +} + /// Which of the two cells settled the answer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PositionCell { @@ -219,56 +246,50 @@ pub enum PositionCell { Root, } -/// `FulfillmentRegistered` at position `q`, as the reads establish it. +/// `FulfillmentRegistered` at position `q`, as the reads establish it from +/// the two cells' bytes alone (SoFi Amendment S20). #[derive(Debug, Clone, PartialEq, Eq)] pub enum Registration { - /// `Final(K_ful(q), F)` and `Final(K_root(q), C_q)`: the exercise. The - /// registered fulfillment, in the envelope the members hold. + /// `Final(K_ful(q), F)` and `Final(K_root(q), C_q)`, the claim naming + /// `FulfillmentId(F)`: the exercise. The registered fulfillment, in the + /// envelope the members hold. Whether that claim is exactly + /// `derive(P, F)` is checked where `P` is in hand. Registered(Signed), - /// `F` is final at `K_ful(q)` but `K_root(q)` is final on another claim - /// — an ordinary transition, or another fulfillment's claim, got to the - /// leader first. This `F` is not registered and never will be - /// (PairMutualExclusion): the position went to the other claim. + /// `F` holds `K_ful(q)`'s leader link, and `K_root(q)`'s leader link is + /// held by a claim that does not name it: an ordinary transition, or + /// another fulfillment's claim, got to the leader first. This `F` is not + /// registered and never will be (PairMutualExclusion): the position went + /// to the other claim. NeverRegistered { fulfillment: Signed, settled_at: PositionCell, }, - /// No fulfillment is final at `K_ful(q)`, and `K_root(q)` is held by the - /// claim whose entry digest is `claim`: final, or holding the leader link, - /// so no other value will ever be final there (§9 finality 2). An `F` at - /// `q` whose own `C_q` is another claim can never register (SoFi - /// Amendment S14); one whose `C_q` it is waits for its fulfillment to be - /// relayed. + /// `F` holds `K_ful(q)`'s leader link, leader-held, preserved or final, + /// and the pair is not final on it yet. `F` may still register, and no + /// other fulfillment ever will (§23.1). + Held(Signed), + /// No fulfillment holds `K_ful(q)`'s leader link, and `K_root(q)`'s is + /// held by the claim whose id is `claim`. An `F` at `q` whose own `C_q` + /// it is not can never register (SoFi Amendment S14); one whose `C_q` it + /// is waits for its fulfillment to be relayed. RootTaken { claim: D32 }, - /// Not registered yet: a cell is open, or the value holding it is not - /// final yet. + /// Neither cell's leader holds a recognized value yet. Unresolved, } -/// A fulfillment recognized at `K_ful(q)`: its entry digest, the signed -/// fulfillment, and the precommit it names. -type RecognizedFulfillment = (D32, (Signed, TraderPrecommitBody)); - /// The recognizer of `K_ful(q)`: fulfillment envelopes naming the key -/// ([`names_fulfillment_key`]), with the precommit each names, identified by -/// the entry digest of their exact bytes. -fn fulfillment_at<'a>( - cells: &'a PositionCells, - precommits: &'a impl PrecommitLookup, -) -> impl Fn(&[u8]) -> Option + 'a { +/// ([`names_fulfillment_key`]), identified by the entry digest of their exact +/// bytes. +fn fulfillment_at( + cells: &PositionCells, +) -> impl Fn(&[u8]) -> Option<(D32, Signed)> + '_ { move |bytes| { let signed = names_fulfillment_key( bytes, - cells.root.genesis(), cells.root.device_id(), cells.root.economic_position(), - precommits, )?; - let precommit = precommits.precommit(signed.body.precommit_id())?.clone(); - Some(( - crate::storage_cell::entry_digest(bytes), - (signed, precommit), - )) + Some((crate::storage_cell::entry_digest(bytes), signed)) } } @@ -278,14 +299,8 @@ fn fulfillment_at<'a>( pub fn fulfillment_completion( cells: &PositionCells, evidence: &CellEvidence, - precommits: &impl PrecommitLookup, ) -> Result, CompletionProof)>, Missing> { - Ok(completion_proof( - &cells.fulfillment, - evidence, - fulfillment_at(cells, precommits), - )? - .map(|((signed, ..), proof)| (signed, proof))) + completion_proof(&cells.fulfillment, evidence, fulfillment_at(cells)) } /// Check a kept completion proof of `K_ful(q)` against the reads in @@ -294,89 +309,82 @@ pub fn check_fulfillment_completion( cells: &PositionCells, evidence: &CellEvidence, proof: &CompletionProof, - precommits: &impl PrecommitLookup, ) -> Result, ProofRefusal> { - check_completion_proof( - &cells.fulfillment, - evidence, - proof, - fulfillment_at(cells, precommits), - ) - .map(|(signed, ..)| signed) + check_completion_proof(&cells.fulfillment, evidence, proof, fulfillment_at(cells)) } /// Derive `FulfillmentRegistered(q)` from the route-chain evidence of the /// two position cells: `K_ful(q)` over [`names_fulfillment_key`], `K_root(q)` /// over the register reader's rule /// ([`crate::economic::register::root_claim_naming`]; `C_q` is one such -/// claim). `Err` names what the evidence does not yet show at a cell whose -/// answer is needed: a network status, never an answer about the position. +/// claim), the pair matched by `FulfillmentId(F)`. `Err` names what the +/// evidence does not yet show at a cell whose answer is needed: a network +/// status, never an answer about the position. pub fn fulfillment_registered( cells: &PositionCells, fulfillment_evidence: &CellEvidence, root_evidence: &CellEvidence, - precommits: &impl PrecommitLookup, ) -> Result { - let registration = registration_of(cells, fulfillment_evidence, root_evidence, precommits)?; + let root = read_root_cell(&cells.root, root_evidence)?; + let root_claim = match &root { + CellReading::Held { id, .. } => Some(*id), + CellReading::Open => None, + }; + let registration = match evaluate( + &cells.fulfillment, + fulfillment_evidence, + fulfillment_at(cells), + )? { + CellReading::Open => match root { + CellReading::Held { id, .. } => Registration::RootTaken { claim: id }, + CellReading::Open => Registration::Unresolved, + }, + CellReading::Held { + object: signed, + state, + .. + } => match root { + CellReading::Held { + object: claim, + state: root_state, + .. + } if names_the_fulfillment(&claim, &signed.body) => { + if matches!(state, ChainState::Final) && matches!(root_state, ChainState::Final) { + Registration::Registered(signed) + } else { + Registration::Held(signed) + } + } + // Another claim holds the leader link at K_root(q): final or not + // yet, no other value will ever be final there (§9 finality 2). + CellReading::Held { .. } => Registration::NeverRegistered { + fulfillment: signed, + settled_at: PositionCell::Root, + }, + CellReading::Open => Registration::Held(signed), + }, + }; Ok(RegistrationRead { genesis: *cells.root.genesis(), device_id: *cells.root.device_id(), position: cells.root.economic_position(), parent_root: cells.parent_root, registration, + root_claim, }) } -fn registration_of( - cells: &PositionCells, - fulfillment_evidence: &CellEvidence, - root_evidence: &CellEvidence, - precommits: &impl PrecommitLookup, -) -> Result { - let fulfillment = evaluate( - &cells.fulfillment, - fulfillment_evidence, - fulfillment_at(cells, precommits), - )?; - let (signed, precommit) = match fulfillment { - CellReading::Held { - object, - state: ChainState::Final, - .. - } => object, - CellReading::Held { - state: ChainState::LeaderHeld | ChainState::Preserved, - .. - } - | CellReading::Open => { - // No fulfillment is final at K_ful(q) yet. The position may still - // have gone to a claim at K_root(q), and whatever holds it is the - // claim every F at q is measured against (SoFi Amendment S14). - return Ok(match read_root_cell(&cells.root, root_evidence)? { - CellReading::Held { id, .. } => Registration::RootTaken { claim: id }, - CellReading::Open => Registration::Unresolved, - }); - } - }; - // The root cell's reading identifies its claim by the entry digest of - // its exact bytes, so `C_q` holds the cell exactly when the ids agree. - let claim = crate::storage_cell::entry_digest( - &derive::resolution_claim(&precommit, &signed.body).encode(), - ); - Ok(match read_root_cell(&cells.root, root_evidence)? { - CellReading::Held { - id, - state: ChainState::Final, - .. - } if id == claim => Registration::Registered(signed), - // Another claim holds the leader link at K_root(q): final or not - // yet, no other value will ever be final there (§9 finality 2). - CellReading::Held { id, .. } if id != claim => Registration::NeverRegistered { - fulfillment: signed, - settled_at: PositionCell::Root, - }, - CellReading::Held { .. } | CellReading::Open => Registration::Unresolved, - }) +/// Whether the claim at `K_root(q)` names `fulfillment` (SoFi Amendment +/// S20): a conditional claim whose `fulfillment_id` is `FulfillmentId(F)`. +fn names_the_fulfillment( + claim: &RegisteredEconomicClaim, + fulfillment: &TraderFulfillmentBody, +) -> bool { + matches!( + claim, + RegisteredEconomicClaim::ConditionalSofi(c) + if c.fulfillment_id == derive::fulfillment_id(fulfillment) + ) } #[cfg(test)] @@ -388,7 +396,7 @@ mod tests { use crate::sofi::validation::fixtures::{signed_c_q, swap_fixture_n, trader_keys, dev, G}; use crate::route_chain::fixtures::{committed_set, committed_set_id, Cell}; use crate::route_chain::ROUTE_LEN; - use crate::sofi::wire::AttemptEntry; + use crate::sofi::wire::{AttemptEntry, SofiResolutionClaim}; /// The trader's key, as every fixture `P` commits it. fn key() -> &'static [u8] { @@ -428,31 +436,24 @@ mod tests { .unwrap() } - fn lookup(p: &TraderPrecommitBody) -> BTreeMap { - BTreeMap::from([(derive::precommit_id(p), p.clone())]) - } - - /// The key is `(G, DevID, q)`: an envelope at another position, of a - /// precommit not in hand, or of another trader's precommit is nothing. + /// The key is `(G, DevID, q)`, and what names it is decided from `F`'s + /// bytes alone (SoFi Amendment S20): an envelope at another position, or + /// under a key that does not derive this device, is nothing, and no `P` + /// is read to decide it, so whether the `P` it names is published yet + /// changes nothing. #[test] fn only_a_fulfillment_of_this_trader_at_this_position_names_the_key() { let p = swap_fixture_n(2).precommit; let q = p.position() + 1; let f = fulfillment(&p, q); let bytes = envelope(&f); - let known = lookup(&p); assert_eq!( - names_fulfillment_key(&bytes, &G, &dev(), q, &known).map(|s| s.body), + names_fulfillment_key(&bytes, &dev(), q).map(|s| s.body), Some(f.clone()) ); - assert!(names_fulfillment_key(&bytes, &G, &dev(), q + 1, &known).is_none()); - assert!(names_fulfillment_key(&bytes, &[0x33; 32], &dev(), q, &known).is_none()); - let unknown: BTreeMap = BTreeMap::new(); - assert!( - names_fulfillment_key(&bytes, &G, &dev(), q, &unknown).is_none(), - "without P neither the trader nor C_q is known" - ); - assert!(names_fulfillment_key(b"not an envelope", &G, &dev(), q, &known).is_none()); + assert!(names_fulfillment_key(&bytes, &dev(), q + 1).is_none()); + assert!(names_fulfillment_key(&bytes, &[0x33; 32], q).is_none()); + assert!(names_fulfillment_key(b"not an envelope", &dev(), q).is_none()); } /// DSM Amendment A10, SoFi Amendment S20: `F` proves its own authority @@ -481,7 +482,6 @@ mod tests { &squatter_pk, ) .unwrap(); - let known = lookup(&squatters_p); for att_a in [[0x5A; 32], crate::sofi::validation::fixtures::TRADER_ATT_A] { let squat = TraderFulfillmentBody::new( derive::precommit_id(&squatters_p), @@ -512,34 +512,33 @@ mod tests { .object_bytes() .unwrap(); assert!( - names_fulfillment_key(&bytes, &G, &dev(), q, &known).is_none(), + names_fulfillment_key(&bytes, &dev(), q).is_none(), "a squatter's F names no cell of the victim's" ); } // The trader's own F of its own P names the cell. let f = fulfillment(&p, q); assert!(fulfillment_proves_the_device(&f, &dev())); - assert!(names_fulfillment_key(&envelope(&f), &G, &dev(), q, &lookup(&p)).is_some()); + assert!(names_fulfillment_key(&envelope(&f), &dev(), q).is_some()); } /// `K_ful(q)` is named only by a fulfillment the trader signed: its - /// signature verifies under the key its body commits, and that key is the - /// one its `P` commits. An unsigned or foreign-key `F` naming the right - /// trader, position and `P` is nothing, so first at the leader it holds - /// nothing. + /// signature verifies under the key its body commits, and that key, with + /// the `AttA` it carries, derives the trader's device (SoFi Amendment + /// S20). An unsigned or foreign-key `F` naming the right position and + /// `P` is nothing, so first at the leader it holds nothing. #[test] - fn only_a_fulfillment_signed_under_its_precommits_key_names_the_key() { + fn only_a_fulfillment_signed_under_a_key_of_this_device_names_the_key() { let p = swap_fixture_n(2).precommit; let q = p.position() + 1; let f = fulfillment(&p, q); - let known = lookup(&p); let unsigned = Publication::Fulfillment { body: &f, signature: &[0x77; 8], } .object_bytes() .unwrap(); - assert!(names_fulfillment_key(&unsigned, &G, &dev(), q, &known).is_none()); + assert!(names_fulfillment_key(&unsigned, &dev(), q).is_none()); let (other_pk, other_sk) = crate::crypto::sphincs::generate_sphincs_keypair().unwrap(); let foreign = TraderFulfillmentBody::new( @@ -562,7 +561,7 @@ mod tests { } .object_bytes() .unwrap(); - assert!(names_fulfillment_key(&foreign_bytes, &G, &dev(), q, &known).is_none()); + assert!(names_fulfillment_key(&foreign_bytes, &dev(), q).is_none()); // First at the leader, the unsigned F blocks nothing. let cells = PositionCells::new( @@ -581,7 +580,7 @@ mod tests { let mut root = Cell::at(cells.root().routed()); root.write(&claim, ROUTE_LEN - 1, &[]); assert!(matches!( - fulfillment_registered(&cells, &ful.evidence(), &root.evidence(), &known) + fulfillment_registered(&cells, &ful.evidence(), &root.evidence()) .map(|r| r.into_registration()), Ok(Registration::Registered(ref s)) if s.body == f )); @@ -595,7 +594,6 @@ mod tests { let q = p.position() + 1; let f = fulfillment(&p, q); let bytes = envelope(&f); - let known = lookup(&p); let cells = PositionCells::new( &G, &dev(), @@ -608,53 +606,37 @@ mod tests { let mut seats = Cell::at(cells.fulfillment()); seats.write(&bytes, 1, &[]); assert!(matches!( - fulfillment_completion(&cells, &seats.evidence(), &known), + fulfillment_completion(&cells, &seats.evidence()), Ok(None) )); let mut seats = Cell::at(cells.fulfillment()); seats.write(&bytes, ROUTE_LEN - 1, &[]); - let Ok(Some((proven, proof))) = fulfillment_completion(&cells, &seats.evidence(), &known) - else { + let Ok(Some((proven, proof))) = fulfillment_completion(&cells, &seats.evidence()) else { panic!("a final fulfillment has a completion proof") }; assert_eq!(proven.body, f); - let checked = check_fulfillment_completion(&cells, &seats.evidence(), &proof, &known) + let checked = check_fulfillment_completion(&cells, &seats.evidence(), &proof) .expect("the kept proof checks"); assert_eq!(checked.body, f); } - /// The predicate over the two cells: both final, the root on this F's - /// own claim — registered; the root held by another claim of this - /// position, final or not — never; anything short of final at either - /// cell — unresolved; an unread leader — not decided on this evidence. + /// The predicate over the two cells, from their bytes alone (SoFi + /// Amendment S20): both final, the root on a claim naming this `F` — + /// registered; the root held by a claim naming another fulfillment, + /// final or not — never; this `F` holding the fulfillment cell's leader + /// link with the pair not final on it — held; no fulfillment holding it + /// — the root cell's claim named, or nothing decided; an unread leader — + /// not decided on this evidence. #[test] fn registration_needs_both_cells_final_and_the_root_on_this_claim() { let p = swap_fixture_n(2).precommit; let q = p.position() + 1; let f = fulfillment(&p, q); let bytes = envelope(&f); - let known = lookup(&p); let claim = signed_c_q(derive::resolution_claim(&p, &f)); - let rival = TraderFulfillmentBody::new( - derive::precommit_id(&p), - vec![[0x03; 32], [0x04; 32]], - f.attempts().to_vec(), - q, - ALG, - key(), - crate::sofi::validation::fixtures::TRADER_ATT_A, - ) - .unwrap(); + let rival = rival_of(&p, &f); let other_claim = signed_c_q(derive::resolution_claim(&p, &rival)); - let cells = PositionCells::new( - &G, - &dev(), - q, - &[0x5E; 32], - &committed_set(), - &committed_set_id(), - ) - .expect("the committed set"); + let cells = cells_at(q); let written = |at: &RoutedCell, value: &[u8], last: usize| { let mut c = Cell::at(at); c.write(value, last, &[]); @@ -663,7 +645,7 @@ mod tests { let ful_cell = |value: &[u8], last: usize| written(cells.fulfillment(), value, last); let root_cell = |value: &[u8], last: usize| written(cells.root().routed(), value, last); let reg = |ful: &CellEvidence, root: &CellEvidence| { - fulfillment_registered(&cells, ful, root, &known).map(|r| r.into_registration()) + fulfillment_registered(&cells, ful, root).map(|r| r.into_registration()) }; let registered = Signed { body: f.clone(), @@ -686,24 +668,26 @@ mod tests { } assert_eq!( reg(&final_ful, &root_cell(&claim, 1)), - Ok(Registration::Unresolved), + Ok(Registration::Held(registered.clone())), "the claim is preserved, not final" ); assert_eq!( reg(&final_ful, &root_cell(b"not a claim", ROUTE_LEN - 1)), - Ok(Registration::Unresolved) + Ok(Registration::Held(registered.clone())), + "no claim yet at K_root(q)" ); - // No fulfillment final at K_ful(q): the root cell's claim is named, - // and weighed by each F against its own C_q (SoFi Amendment S14). - // A conditional claim is named by its derived body (SoFi Amendment S20). + assert_eq!( + reg(&ful_cell(&bytes, 1), &root_cell(&claim, ROUTE_LEN - 1)), + Ok(Registration::Held(registered.clone())), + "the fulfillment is preserved, not final" + ); + // No fulfillment holds K_ful(q): the root cell's claim is named, and + // weighed by each F against its own C_q (SoFi Amendment S14). A + // conditional claim is named by its derived body (SoFi Amendment S20). let claim_id = crate::storage_cell::entry_digest(&derive::resolution_claim(&p, &f).encode()); let other_id = crate::storage_cell::entry_digest(&derive::resolution_claim(&p, &rival).encode()); - assert_eq!( - reg(&ful_cell(&bytes, 1), &root_cell(&claim, ROUTE_LEN - 1)), - Ok(Registration::RootTaken { claim: claim_id }) - ); assert_eq!( reg( &ful_cell(b"garbage", ROUTE_LEN - 1), @@ -718,7 +702,7 @@ mod tests { &root_cell(&other_claim, last) ), Ok(Registration::RootTaken { claim: other_id }), - "no fulfillment final, another claim at K_root(q) through position {last}" + "no fulfillment, another claim at K_root(q) through position {last}" ); } assert_eq!( @@ -733,4 +717,100 @@ mod tests { unread_root.seats[0].values = None; assert_eq!(reg(&final_ful, &unread_root), Err(Missing::LeaderUnread)); } + + /// Pre-audit item 12, 12j (SoFi Amendments S14 and S20): where a + /// fulfillment stands at its pair, with its `P` in hand. Registered when + /// the pair is final on it and on exactly `derive(P, F)`. Lost, for good, + /// when another fulfillment holds `K_ful(q)`'s leader link in any state — + /// leader-held alone is enough (§23.1) — or when `K_root(q)`'s leader link + /// is held by a claim other than `derive(P, F)`: another fulfillment's, + /// or one naming this `F` whose body is not `F`'s. Pending otherwise. + #[test] + fn a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own() { + let p = swap_fixture_n(2).precommit; + let q = p.position() + 1; + let f = fulfillment(&p, q); + let rival = rival_of(&p, &f); + let cells = cells_at(q); + let own_claim = derive::resolution_claim(&p, &f); + let at = |ful: Option<(&TraderFulfillmentBody, usize)>, + root: Option<(SofiResolutionClaim, usize)>| { + let mut ful_cell = Cell::at(cells.fulfillment()); + if let Some((body, last)) = ful { + ful_cell.write(&envelope(body), last, &[]); + } + let mut root_cell = Cell::at(cells.root().routed()); + if let Some((claim, last)) = root { + root_cell.write(&signed_c_q(claim), last, &[]); + } + fulfillment_registered(&cells, &ful_cell.evidence(), &root_cell.evidence()) + .expect("both leaders read") + }; + let last = ROUTE_LEN - 1; + let standing = + |read: &RegistrationRead, body: &TraderFulfillmentBody| read.standing_of(&p, body); + + let registered = at(Some((&f, last)), Some((own_claim, last))); + assert_eq!(standing(®istered, &f), PairStanding::Registered); + assert_eq!(standing(®istered, &rival), PairStanding::Lost); + + // The rival holds K_ful(q)'s leader link, only leader-held, and + // K_root(q) holds F's own claim: F can never be final behind it. + let behind = at(Some((&rival, 0)), Some((own_claim, last))); + assert_eq!(standing(&behind, &f), PairStanding::Lost); + assert_eq!(standing(&behind, &rival), PairStanding::Lost); + + let held = at(Some((&f, 0)), None); + assert_eq!(standing(&held, &f), PairStanding::Pending); + assert_eq!(standing(&held, &rival), PairStanding::Lost); + + let root_only = at(None, Some((own_claim, last))); + assert_eq!(standing(&root_only, &f), PairStanding::Pending); + 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. + let forged = SofiResolutionClaim { + realize_root: [0xBD; 32], + ..own_claim + }; + let mismatched = at(Some((&f, last)), Some((forged, last))); + assert!(matches!( + mismatched.registration(), + Registration::Registered(signed) if signed.body == f + )); + assert_eq!(standing(&mismatched, &f), PairStanding::Lost); + + let nothing = at(None, None); + assert_eq!(standing(¬hing, &f), PairStanding::Pending); + } + + /// The pair at position `q` of the fixture trader, routed by a fixed + /// parent root. + fn cells_at(q: u64) -> PositionCells { + PositionCells::new( + &G, + &dev(), + q, + &[0x5E; 32], + &committed_set(), + &committed_set_id(), + ) + .expect("the committed set") + } + + /// Another fulfillment of the same `P` at the same position, signed by + /// the same trader: a different policy-fulfillment set, so a different id. + fn rival_of(p: &TraderPrecommitBody, f: &TraderFulfillmentBody) -> TraderFulfillmentBody { + TraderFulfillmentBody::new( + derive::precommit_id(p), + vec![[0x03; 32], [0x04; 32]], + f.attempts().to_vec(), + f.position(), + ALG, + key(), + crate::sofi::validation::fixtures::TRADER_ATT_A, + ) + .unwrap() + } } 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 fab32afbf..9e20897ee 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs @@ -21,7 +21,6 @@ //! lineage walker (`economic::peer_lineage::PeerEvidenceFetcher`), which //! walks a foreign lineage the same way. -use std::collections::btree_map::Entry; use std::collections::{BTreeMap, BTreeSet}; use crate::ccb::StorageSetMembers; @@ -31,8 +30,7 @@ use crate::economic::register::root_completion; use crate::economic::state::EconomicLeafState; use crate::economic::tree::EconomicSmt; use crate::route_chain::{ - leader_copies, CellEvidence, CellFact, ChainState, CompletionProof, Missing as CellMissing, - RoutedCell, + CellEvidence, CellFact, ChainState, CompletionProof, Missing as CellMissing, RoutedCell, }; use super::conformance::{ @@ -51,10 +49,9 @@ use super::lineage::{ advance_peer_resolved, AdvanceError, PeerResolvedAdvance, genesis_accepted, genesis_root, vault_leaves_at_genesis, AcceptedVaultGenesis, GenesisInvalid, GenesisMissing, GenesisRefusal, }; -use super::publication::{recognize_fulfillment, recognize_setup, Signed}; +use super::publication::{recognize_setup, Signed}; use super::registration::{ - fulfillment_completion, fulfillment_registered, names_fulfillment_key, PositionCells, - Registration, RegistrationRead, + fulfillment_completion, fulfillment_registered, PositionCells, Registration, RegistrationRead, }; use super::resolution::{ skip_without_evidence, walk, AttemptClass, AttemptWalk, KeyFacts, RecordedGeneration, @@ -104,11 +101,6 @@ pub const SIBLING_DEPTH: usize = 2; /// Acquisition rounds before the evidence is `Exhausted`. pub const ACQUIRE_ROUNDS: usize = 3; -/// Fulfillments in the leader's log at `K_ful(q)` whose `P` one read may -/// fetch before the pair is unavailable to this verifier: each names a `P` -/// fetched by id (R8). -pub const NAMED_PRECOMMIT_BUDGET: usize = 64; - /// A read that could not be made: a member did not answer, a local store /// failed. A network status, never a verdict, and never a fact about the /// operation. @@ -679,17 +671,10 @@ impl Verifier<'_, R> { /// Once registered, the completion proofs of both cells are kept (SoFi /// Amendment S10). /// - /// Recognizing a value at `K_ful(q)` needs the `P` it names, read by id - /// (R8). Only the leader's log decides which value holds the cell: the - /// first copy there that names the key (`route_chain::leader_copies`, the - /// candidates the leader link is chosen from). So the `P`s read are those - /// of the fulfillments in that log, in arrival order, until one names the - /// key, and at most [`NAMED_PRECOMMIT_BUDGET`] of them; nothing another - /// member holds is read. A `P` the read could not decide leaves the cell - /// undecided — the call fails, and a later read can answer — so that no - /// later value is read as the first recognized one while an earlier - /// one's `P` is merely not in hand. The inner `Err` is what the reads do - /// not yet show. + /// Both cells are decided from their bytes alone (SoFi Amendment S20): + /// no precommit is read, so nothing a fulfillment names can make this + /// read wait, and the answer is the same at every time. The inner `Err` is + /// what the reads do not yet show. pub fn read_registration( &self, genesis: &D32, @@ -700,54 +685,12 @@ impl Verifier<'_, R> { let cells = self.position_cells(genesis, device_id, position, parent_root)?; let ful_evidence = self.reads.cell(cells.fulfillment())?; let root_evidence = self.reads.cell(cells.root().routed())?; - - let mut precommits: BTreeMap = BTreeMap::new(); - let mut fetched = 0; - for copy in leader_copies(cells.fulfillment(), &ful_evidence) { - let Some((.., signed)) = recognize_fulfillment(©.value) else { - continue; - }; - if signed.body.position() != position { - continue; - } - let id = *signed.body.precommit_id(); - if let Entry::Vacant(slot) = precommits.entry(id) { - if fetched == NAMED_PRECOMMIT_BUDGET { - return Err(VerifierFailure::Read(format!( - "fulfillment register: the leader's log at K_ful({position}) names more \ - precommits than the budget of {NAMED_PRECOMMIT_BUDGET}" - ))); - } - fetched += 1; - match self.reads.precommit(&id)? { - Resolved::Kept(precommit) => { - slot.insert(precommit.body); - } - // No `P`, so the value names no key: the leader link is - // past it. - Resolved::None => continue, - Resolved::Unavailable => { - return Err(VerifierFailure::Read( - "fulfillment register: a precommit a candidate names could not be read" - .to_string(), - )) - } - } - } - if names_fulfillment_key(©.value, genesis, device_id, position, &precommits) - .is_some() - { - break; - } - } - - let registration = - match fulfillment_registered(&cells, &ful_evidence, &root_evidence, &precommits) { - Ok(registration) => registration, - Err(missing) => return Ok(Err(missing)), - }; + let registration = match fulfillment_registered(&cells, &ful_evidence, &root_evidence) { + Ok(registration) => registration, + Err(missing) => return Ok(Err(missing)), + }; if let Registration::Registered(..) = registration.registration() { - let (.., ful_proof) = fulfillment_completion(&cells, &ful_evidence, &precommits) + let (.., ful_proof) = fulfillment_completion(&cells, &ful_evidence) .map_err(|missing| { VerifierFailure::Read(format!("fulfillment completion: {missing:?}")) })? @@ -2030,11 +1973,19 @@ impl Verifier<'_, R> { ))) } }; - // `held` counts only as the claim (P, F) derive: registration holds F - // registered only while `K_root(q)` is final on exactly that claim - // (`registration::fulfillment_registered`), and a final value is the - // cell's only one, so its two roots are the ones the resolution - // chooses between. + // `held` counts only as the claim (P, F) derive (Amendment S15). + // Registration pairs the cells by `FulfillmentId(F)` from their bytes + // alone (Amendment S20), so the body is compared here, where `P` is in + // hand: a claim naming F with another body is not F's `C_q`, and the + // position is Invalid for the trader's lineage. A final value is the + // cell's only one, so once they agree its two roots are the ones the + // resolution chooses between. + if derive::resolution_claim(&precommit, &fulfillment.body) != *held { + return Err(Invalid(format!( + "position {q}: the claim at K_root(q) names the fulfillment, but it is not the \ + claim its P and F derive" + ))); + } // The exercise, read back from the first leg's cell. let first = precommit 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 9963f5812..6260fe15d 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 @@ -1414,6 +1414,212 @@ async fn nothing_but_the_leaders_first_fulfillment_decides_k_ful() { ); } +/// What a fresh verifier reads of B's position `q` and of the vault's first +/// key at `r0`: the registration, as its variant and which fulfillment it +/// names, and where the walk over that key ends. +fn read_position_and_key( + p: &Pair, + set: &crate::sdk::storage_set::StorageSet, + q: u64, + root: &[u8; 32], + vault_id: &[u8; 32], + r0: &[u8; 32], +) -> (String, WalkOutcome) { + 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 verifier = ctx.verifier(); + let read = verifier + .read_registration(&p.b.genesis, &p.b.device_id, q, root) + .expect("read") + .expect("decided"); + let named = |signed: &dsm::sofi::publication::Signed| { + crate::util::text_id::encode_base32_crockford(&derive::fulfillment_id(&signed.body)) + }; + let registration = match read.registration() { + Registration::Registered(signed) => format!("Registered({})", named(signed)), + Registration::Held(signed) => format!("Held({})", named(signed)), + Registration::NeverRegistered { fulfillment, .. } => { + format!("NeverRegistered({})", named(fulfillment)) + } + Registration::RootTaken { .. } => "RootTaken".to_string(), + Registration::Unresolved => "Unresolved".to_string(), + }; + let chain = verifier + .chain_until(vault_id, r0) + .expect("the vault's chain"); + let walked = verifier + .walk_parent( + &BTreeMap::from([(*vault_id, chain)]), + vault_id, + r0, + 0, + WALK_BUDGET, + ) + .expect("the walk"); + assert_eq!( + walked.not_established, None, + "the walk decides every key it meets" + ); + (registration, walked.outcome) +} + +/// Pre-audit item 12, 12j (SoFi Amendment S20): which fulfillment holds +/// `K_ful(q)`, and so the position and every key its exercises hold, is +/// decided from the cells' bytes alone, the same whenever it is read. B first +/// writes, at the leader of its own `K_ful(q)`, a fulfillment `F_a` of its own +/// naming a precommit `P_a` it has not published; then it trades, and its pair +/// and its exercise land behind `F_a`. A fresh verifier reads the position and +/// the vault's first key before `P_a` is published and again after: the two +/// readings agree; `F_a` holds `K_ful(q)` while `K_root(q)` holds B's other +/// claim, so neither fulfillment ever registers and B has wedged only its own +/// position; and the key B's exercise holds is passed over, never consumed and +/// never left waiting. Before S20 the first read saw B's trade registered, and +/// the second saw it neither registered nor lost, with the key frozen. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_position_reads_the_same_before_and_after_a_precommit_is_published() { + let p = Pair::boot(500, 200).await; + let m = open_market(&p).await; + let set = canonical_set(NETWORK).expect("the pinned set"); + let position = admitted_position(&p.b); + let q = position + 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("pair"); + let r0 = { + let (own, parents) = standing_of(&p.b); + let ctx = VerifierContext::new(&set, Some(own), parents.as_ref()).expect("a verifier"); + ctx.verifier() + .chain(&m.vault_id) + .expect("the vault's chain") + .roots()[0] + }; + + // B's own P_a at p, not published, and B's own F_a naming it at q. + p.b.enter(); + let public_key = crate::sdk::signing_authority::current_public_key().expect("B's key"); + let secret_key = crate::sdk::signing_authority::current_secret_key().expect("B's key"); + let att_a = crate::sdk::signing_authority::current_att_a().expect("B's AttA"); + let alg = dsm::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F; + let parent = match economic_lineage::get_admitted() + .expect("read admitted") + .expect("an admitted position") + { + AdmittedEconomicPosition::SingleRoot { claim_ref, .. } => { + dsm::sofi::wire::ParentClaimRef::SingleRoot { claim_ref } + } + other => panic!("B's p is ordinary: {other:?}"), + }; + let p_a = TraderPrecommitBody::new( + p.b.genesis, + p.b.device_id, + position, + parent, + [0xE1; 32], + vec![PrecommitLeg { + vault_id: m.vault_id, + parent_root: r0, + setup_ref: [0x5E; 32], + }], + [0xA1; 32], + root, + set.id(), + alg, + &public_key, + ) + .expect("P_a"); + let f_a = TraderFulfillmentBody::new( + derive::precommit_id(&p_a), + vec![[0x5D; 32]], + vec![AttemptEntry { + vault_id: m.vault_id, + attempt: 0, + }], + q, + alg, + &public_key, + att_a, + ) + .expect("F_a"); + let f_sig = + dsm::crypto::sphincs::sphincs_sign(&secret_key, &derive::fulfillment_signing_digest(&f_a)) + .expect("B signs F_a"); + let envelope = Publication::Fulfillment { + body: &f_a, + signature: &f_sig, + } + .object_bytes() + .expect("F_a envelope"); + let cell = pair.fulfillment(); + let entry = RouteEntry::at_leader( + cell.namespace().to_vec(), + *cell.key(), + envelope, + cell.route(), + ); + let clients = SetClient::new(&set).expect("the set's members"); + clients + .members() + .iter() + .find(|member| member.member_id().as_bytes() == cell.route().leader()) + .expect("the pair's leader") + .put_cells(&[(entry.namespace.clone(), entry.key, entry.encode())]) + .await + .expect("the leader keeps it"); + + // B trades: its pair and its exercise land behind F_a. + invoke(&p.b, "sofi.trade", args(&trade_request(&p, &m, 10))).await; + let held = { + let (own, parents) = standing_of(&p.b); + let ctx = VerifierContext::new(&set, Some(own), parents.as_ref()).expect("a verifier"); + ctx.verifier() + .read_attempt_cell(&m.vault_id, &r0, 0) + .expect("read") + .expect("decided") + }; + let exercise = held + .exercise() + .expect("B's exercise holds the vault's first key"); + assert_eq!(exercise.fulfillment().body.position(), q); + assert_ne!(exercise.fulfillment().body, f_a); + + let before = read_position_and_key(&p, &set, q, &root, &m.vault_id, &r0); + p.b.enter(); + let p_sig = + dsm::crypto::sphincs::sphincs_sign(&secret_key, &derive::precommit_signing_digest(&p_a)) + .expect("B signs P_a"); + crate::sdk::sofi_publish::publish( + &set, + &Publication::Precommit { + body: &p_a, + signature: &p_sig, + }, + ) + .await + .expect("P_a is published"); + let after = read_position_and_key(&p, &set, q, &root, &m.vault_id, &r0); + + assert_eq!( + before, after, + "the position and the key read the same whenever" + ); + let f_a_id = crate::util::text_id::encode_base32_crockford(&derive::fulfillment_id(&f_a)); + assert_eq!( + after.0, + format!("NeverRegistered({f_a_id})"), + "F_a holds K_ful(q) and K_root(q) holds B's other claim: neither ever registers" + ); + assert_eq!( + after.1, + WalkOutcome::Unresolved { attempt: 1 }, + "the key B's exercise holds is passed over: its fulfillment can never register" + ); +} + /// B's own completion of its pending position, stages 7 and 8 from what /// storage holds: what the network took, or what it did not. async fn complete(p: &Pair) -> Completion { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs index 724755266..73e000c2e 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs @@ -738,12 +738,19 @@ pub async fn resolve_pending_position( { signed.clone() } - Registration::Registered(..) | Registration::NeverRegistered { .. } => { + // This device's own fulfillment holds K_ful(q), and the pair is not + // final on it yet. + Registration::Held(signed) if derive::fulfillment_id(&signed.body) == fulfillment_id => { + return not_yet(NotResolved::NotRegistered) + } + Registration::Registered(..) + | Registration::Held(..) + | Registration::NeverRegistered { .. } => { return Err(refuse(format!( "position {q} is held by a claim other than the pending fulfillment" ))) } - // No fulfillment is final at q yet; the root cell's claim is weighed + // No fulfillment holds K_ful(q) yet; the root cell's claim is weighed // once this device's own fulfillment is read back there. Registration::RootTaken { .. } | Registration::Unresolved => { return not_yet(NotResolved::NotRegistered) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs index 0ffebb923..38b4ba3ce 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs @@ -1729,7 +1729,7 @@ pub async fn relay(set: &StorageSet, intent: &RelayIntent) -> Result { return Err(refuse("the position holds no fulfillment")) } - Registration::RootTaken { .. } | Registration::Unresolved => { + Registration::Held(..) | Registration::RootTaken { .. } | Registration::Unresolved => { return Err(refuse("no fulfillment is registered at the position yet")) } }; From c1e4e6f8d73cff46fd2a925b90f8884552d75fa5 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:11:58 -0400 Subject: [PATCH 25/31] test(sdk): the nonce tests spend the nonce a transfer derives, not a literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged `let nonce = b"predicted-nonce"` (hard-coded cryptographic value) in the item-5 test, and `b"unique-nonce-42"` beside it has the same shape. Both tests now spend the nonce the SDK derives for a transfer (`transfer_nonce`, §4.1) from a relationship's public inputs. That is the exact attack the item-5 test describes: a contact predicts relationship B's next nonce from B's public inputs and spends it in A first. Mutation: the relationship key left out of relationship_nonce_hash turns a_nonce_spent_in_one_relationship_is_not_spent_in_another red at the attack's assertion. Restored byte for byte. --- .../dsm_sdk/src/storage/client_db/nonces.rs | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/nonces.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/nonces.rs index 723c10e7e..66532638d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/nonces.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/nonces.rs @@ -83,6 +83,16 @@ mod tests { const REL_A: [u8; 32] = [0xA1; 32]; const REL_B: [u8; 32] = [0xB2; 32]; + /// Each relationship's chain tip, one of the public inputs its next + /// transfer's nonce derives from. + const TIP_A: [u8; 32] = [0xA7; 32]; + const TIP_B: [u8; 32] = [0xB7; 32]; + + /// The nonce the next transfer of `amount` ERA carries in a relationship + /// whose chain tip is `tip`, as the SDK derives it (§4.1). + fn next_nonce(tip: &[u8; 32], amount: u64) -> Vec { + crate::handlers::app_router_impl::transfer_nonce(tip, amount, "ERA", &[0xD1; 32]) + } fn init_test_db() { crate::economic_fixtures::use_test_storage_dir(); @@ -109,12 +119,12 @@ mod tests { init_test_db(); let binding = crate::storage::client_db::get_connection().expect("db"); let conn = binding.lock().expect("db lock"); - let nonce = b"unique-nonce-42"; - assert!(!is_nonce_spent_with_conn(&conn, &REL_A, nonce).expect("read")); - mark_nonce_spent_with_conn(&conn, &REL_A, nonce, "tx-42", b"sender-a", 500) + let nonce = next_nonce(&TIP_A, 500); + assert!(!is_nonce_spent_with_conn(&conn, &REL_A, &nonce).expect("read")); + mark_nonce_spent_with_conn(&conn, &REL_A, &nonce, "tx-42", b"sender-a", 500) .expect("mark spent"); - assert!(is_nonce_spent_with_conn(&conn, &REL_A, nonce).expect("read")); - let err = mark_nonce_spent_with_conn(&conn, &REL_A, nonce, "tx-again", b"sender-a", 500) + assert!(is_nonce_spent_with_conn(&conn, &REL_A, &nonce).expect("read")); + let err = mark_nonce_spent_with_conn(&conn, &REL_A, &nonce, "tx-again", b"sender-a", 500) .expect_err("a second spend in the relationship is a replay"); assert!(err.to_string().contains("Replay attack")); } @@ -122,21 +132,23 @@ mod tests { /// Security pre-audit item 5: the same nonce bytes spent in one /// relationship are not spent in another, so a contact cannot spend /// another contact's predicted nonce first and have its transfer dropped. + /// The nonce is the one relationship B's next transfer carries, derived + /// from B's public inputs; contact Y spends those bytes in A first. #[test] #[serial] fn a_nonce_spent_in_one_relationship_is_not_spent_in_another() { init_test_db(); let binding = crate::storage::client_db::get_connection().expect("db"); let conn = binding.lock().expect("db lock"); - let nonce = b"predicted-nonce"; - mark_nonce_spent_with_conn(&conn, &REL_A, nonce, "tx-a", b"contact-y", 1) + let predicted = next_nonce(&TIP_B, 100); + mark_nonce_spent_with_conn(&conn, &REL_A, &predicted, "tx-a", b"contact-y", 1) .expect("spent in A"); - assert!(!is_nonce_spent_with_conn(&conn, &REL_B, nonce).expect("read")); - mark_nonce_spent_with_conn(&conn, &REL_B, nonce, "tx-b", b"contact-x", 100) + assert!(!is_nonce_spent_with_conn(&conn, &REL_B, &predicted).expect("read")); + mark_nonce_spent_with_conn(&conn, &REL_B, &predicted, "tx-b", b"contact-x", 100) .expect("still spendable in B"); assert_ne!( - relationship_nonce_hash(&REL_A, nonce), - relationship_nonce_hash(&REL_B, nonce) + relationship_nonce_hash(&REL_A, &predicted), + relationship_nonce_hash(&REL_B, &predicted) ); } } From 57d8dd6e682220277988e6cd098849fd8aabd8e0 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:24:50 -0400 Subject: [PATCH 26/31] docs(conformance): 12j closed; S20's pair rows Met; the nonce tests' note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §6.66 12j is closed by `51359a6b6`, with its change, both tests, and two mutation controls. The resolution's body check has no test of its own yet, and the record says so. 12c's leader-log loop is superseded: registration reads no precommit at all. - §8: - MR-SOFI-0168, 0343, 0360 and 0361 are Met (CORE left them Partial pending this change). - MR-STOR-0132 no longer names the removed `leader_copies`. - The renamed test `only_a_fulfillment_signed_under_a_key_of_this_device_names_the_key` is cited by its new name. - §7 is regenerated. - §6.67 notes why item 5's tests now spend a derived nonce (CodeQL, `c1e4e6f8d`). - The matrix gains a 12j row, and the 12c row no longer cites `leader_copies`. SDK suites at `c1e4e6f8d`: 465 passed, 0 failed. --- specs/requirements/CONFORMANCE_GAPS.md | 44 ++++++++++++++++------- specs/requirements/VERIFICATION_MATRIX.md | 5 +-- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 89f22b8c9..d19e67f80 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -750,7 +750,7 @@ The audit's "Exercise and fulfillment-key recognisers never check signatures" (H | `dsm/src/sofi/validation.rs` · `setup_valid` | A junk-signed copy of the trader's own setup was Invalid. | Not a setup: not established (`NonVerifyingObject`), never Invalid — one body has exactly one valid envelope, and a copy anyone can publish cannot condemn a route. A setup that verifies under a key other than `P`'s stays Invalid (`NotTheExpectedSigner`). | | `dsm/src/sofi/signature.rs` module doc | Said the storage member verifies `m_F` at `K_ful` ingress. | The member verifies nothing (storage §9 rule 6); every reader verifies it, through recognition. | -Tests: `dsm::sofi::exercise::tests::an_exercise_counts_only_when_f_and_p_are_signed_under_the_key_p_commits`, `dsm::sofi::registration::tests::only_a_fulfillment_signed_under_its_precommits_key_names_the_key`, `dsm::sofi::publication::tests::an_envelope_is_recognized_only_when_its_signature_verifies_under_its_bodys_key`, `dsm::sofi::validation::tests::a_setup_is_the_traders_only_under_the_key_p_commits`, `dsm_sdk::handlers::node_e2e_tests::an_unsigned_exercise_at_a_successor_key_takes_nothing`. Mutations: signature check removed from `signed_body` → the four Core tests and the e2e red; `F` key = `P` key removed from `recognize_exercise` → the exercise test red; from `names_fulfillment_key` → the registration test red. +Tests: `dsm::sofi::exercise::tests::an_exercise_counts_only_when_f_and_p_are_signed_under_the_key_p_commits`, `dsm::sofi::registration::tests::only_a_fulfillment_signed_under_a_key_of_this_device_names_the_key`, `dsm::sofi::publication::tests::an_envelope_is_recognized_only_when_its_signature_verifies_under_its_bodys_key`, `dsm::sofi::validation::tests::a_setup_is_the_traders_only_under_the_key_p_commits`, `dsm_sdk::handlers::node_e2e_tests::an_unsigned_exercise_at_a_successor_key_takes_nothing`. Mutations: signature check removed from `signed_body` → the four Core tests and the e2e red; `F` key = `P` key removed from `recognize_exercise` → the exercise test red; from `names_fulfillment_key` → the registration test red. ### 6.24 A reserve release is constructible only for the amount its release rule names (shortcut audit, 2026-09-25) @@ -2278,7 +2278,7 @@ This is the owner's beta security pre-audit, item 12 (P1): "cheap garbage must n | Violates | SoFi §17.4 (registration is `Final(K_ful(q), F) ∧ Final(K_root(q), C_q)`); storage §9 and MR-STOR-0132 (the leader link is the first recognized object in the leader's log; nothing else counts); §5.2. | | Verification | `dsm_sdk::handlers::node_e2e_tests::nothing_but_the_leaders_first_fulfillment_decides_k_ful`. On the old code, the vault owner's walk failed with "a precommit a candidate names could not be read". | | Closure condition | `P` is fetched only for the fulfillments in the leader's log, in arrival order, and only until one names the key. The reader takes those copies from the same function the leader link is chosen from, `route_chain::leader_copies`, so it cannot fetch for a different set than the evaluation considers. | -| Status | Closed for every value outside the leader's first recognized copy. A fulfillment ahead of the trader's in the leader's log, naming a precommit that cannot be read, still stops the read. That needs no race: the key is known in advance. It is closed by pre-audit item 1 (CORE, §6.65): a fulfillment must prove the device (`fulfillment_proves_the_device`), checked before any fetch, so another key's fulfillment is dismissed from its own bytes. The call goes into this loop when item 1 lands. 12j's closure supersedes the loop: once registration reads no `P`, nothing a fulfillment names can stop it. | +| Status | Closed for every value outside the leader's first recognized copy. A fulfillment ahead of the trader's in the leader's log, naming a precommit that cannot be read, still stops the read. That needs no race: the key is known in advance. It is closed by pre-audit item 1 (CORE, §6.65): a fulfillment must prove the device (`fulfillment_proves_the_device`), checked before any fetch, so another key's fulfillment is dismissed from its own bytes. 12j's closure supersedes the loop (`51359a6b6`): registration reads no `P` at all, so nothing a fulfillment names can stop it. | **12d. A conditional parent held by another claim left the key waiting (closed).** @@ -2304,7 +2304,7 @@ This is the owner's beta security pre-audit, item 12 (P1): "cheap garbage must n | Every seat's fulfillments have their precommit fetched | The same test red: the read of `q + 1` fails the same way | | A conditional parent another claim holds is no fact | `a_conditional_parent_another_claim_holds_selects_no_root` red: `None` where `ConditionalNoRoot` belongs | -**12j. Which fulfillment holds `K_ful(q)` depended on when a precommit is published (open; the fix is agreed and in progress).** +**12j. Which fulfillment holds `K_ful(q)` depended on when a precommit is published (closed).** | Field | Record | |---|---| @@ -2313,8 +2313,27 @@ This is the owner's beta security pre-audit, item 12 (P1): "cheap garbage must n | Exploit | Recognizing a fulfillment at `K_ful(q)` read the `P` it names. A `P` with nothing yet published under its id (`Resolved::None`) made that fulfillment "not recognized", and the reader moved on to the next copy. But `P` can be published later. A trader writes `F_a`, naming its own unpublished `P_a`, first at its `K_ful(q)` leader, then trades normally. Early verifiers read `F_b` registered, and the trade realizes. Once the trader publishes `P_a`, a fresh verifier reads `F_a` as the leader link (leader-held only), and `K_root(q)` holding `C_q(F_b)`: `RootTaken`. `F_b` is then neither registered nor lost, because S14 exempts `F`'s own claim, so `q` can never resolve and the key `F_b`'s exercise holds is frozen. A counterparty paid in the meantime holds what no later verifier accepts, and since nothing is recorded (A1, S1) the views never reconcile. S20's device binding does not stop it: the trader signs both. | | Violates | SoFi §24 (resolution is local, deterministic and permanent); storage §4 (a fact not established is never read as its negation); the item-1 ruling (owner, 2026-10-01): "If an object can win/occupy a root position, the object itself proves authority for that position." Reading `P` to decide the winner contradicts it. | | Verification | Probe on the nodes at `96b870fdd`. B's trade is `(q, Realized)`, and a fresh verifier reads `Registered(F_b)`. After `P_a` is published, another fresh verifier reads `RootTaken`. The probe is not committed: it lands as the adversarial test, red to green, with the fix. | -| Closure condition | Agreed with CORE (2026-10-01). (1) Which `F` holds `K_ful(q)` is decided from `F`'s bytes alone: it verifies under its own key, names `q`, and its key and `AttA` derive the cell's `DevID`. (2) Registration pairs the two cells by `F`'s id, without `P`. (3) A position is lost for `F` whenever another `F` holds `K_ful(q)`'s leader link, in any state (§23.1: leader-held already settles it), or `K_root(q)` names another fulfillment. (4) The full `C_q` body is checked against `(P, F)` at resolution, and is Invalid when it differs (S15). CORE writes the S20 extension; SOFI implements it, with the probe as the test. | -| Status | Open, in progress. | +| Closure condition | Agreed with CORE (2026-10-01). (1) Which `F` holds `K_ful(q)` is decided from `F`'s bytes alone: it verifies under its own key, names `q`, and its key and `AttA` derive the cell's `DevID`. (2) Registration pairs the two cells by `F`'s id, without `P`. (3) A position is lost for `F` whenever another `F` holds `K_ful(q)`'s leader link, in any state (§23.1: leader-held already settles it), or `K_root(q)` names another fulfillment. (4) The full `C_q` body is checked against `(P, F)` at resolution, and is Invalid when it differs (S15). CORE wrote the S20 extension (§6.65); SOFI implemented it, with the probe as the test. | +| Status | Closed (`51359a6b6`). | + +**The change (12j).** +- `names_fulfillment_key` decides from `F`'s bytes alone: it verifies under its own key, names `q`, and its key and `AttA` derive the cell's `DevID`. `read_registration` reads no precommit, and `NAMED_PRECOMMIT_BUDGET` and `PrecommitLookup` are gone. +- The pair is matched by `FulfillmentId(F)`. `Registration::Held` names the fulfillment holding `K_ful(q)`'s leader link before the pair is final, and `RegistrationRead` carries the id of the claim at `K_root(q)`. +- `RegistrationRead::standing_of` (with `P` in hand) returns Registered, Pending or Lost. `F` is lost to any other fulfillment holding `K_ful(q)`'s leader link, in any state, and to any claim at `K_root(q)` other than `derive(P, F)`. The facts read the pair through it. +- The S15 resolver compares the held claim with `derive(P, F)` once `P` is in hand: Invalid when they differ. + +**Tests (12j).** +- `dsm_sdk::handlers::node_e2e_tests::a_position_reads_the_same_before_and_after_a_precommit_is_published`. B writes its own `F_a` first at its `K_ful(q)` leader, then trades. A fresh verifier reads the position and the vault's first key before and after `P_a` is published, and the readings agree: `F_a` holds `K_ful(q)` while `K_root(q)` holds B's other claim (`NeverRegistered(F_a)`), and the key B's exercise holds is passed over. On the old code, the same scenario (the probe) flipped from `Registered(F_b)` to `RootTaken` with the key frozen. +- `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own`: Registered, Pending and Lost for each arrangement of the two cells, including a claim naming `F` with another body. + +**Mutation controls (12j, 2026-10-01, restored byte for byte).** + +| Mutation | Result | +|---|---| +| Another fulfillment's leader link loses nothing (the old S14 exception) | Both tests red | +| A registered pair's body is not compared | The unit test red | + +The resolution's body check (Invalid in `peer_position`) has no test of its own yet. **Open: found here, not built.** - **12e. The exercise must carry the trader's signed `C_q` (S20, MR-SOFI-0362, Missing).** Once item 1 makes `C_q` trader-signed, a relayer can no longer author it. A trader that writes its exercise at a vault's key and never publishes its signed `C_q` would then hold that key forever: nobody else can register its `F`, so rung 0 holds and no skip applies. The closure is that the exercise carries the signed `C_q` and recognition checks it, so any relayer completes the pair from the exercise's own bytes. SOFI builds it after CORE's `F` change lands. @@ -2348,6 +2367,7 @@ This is the owner's beta security pre-audit, item 12 (P1): "cheap garbage must n - **The fix.** The explainer names no nonce rule; replay is refused by the relationship's (relationship, parent) record (MR-DSM-0170). A nonce is now spent under `relationship_nonce_hash(rel_key, nonce) = H(DSM/relationship-nonce/v1 ‖ rel_key ‖ nonce)` in the apply's identity, in `spent_nonces` and in staging. A replay of the same transfer is still refused, because it carries the same nonce in the same relationship. The unused device-wide `is_nonce_spent` and `mark_nonce_spent` are deleted. - **Test:** `a_nonce_spent_in_one_relationship_is_not_spent_in_another`. - **Mutation control:** `relationship_nonce_hash` made to ignore the relationship key turns that test red. +- **Update (2026-10-01, `c1e4e6f8d`).** CodeQL flagged the test's literal nonce bytes as a hard-coded cryptographic value. Both nonce tests now spend the nonce the SDK derives for a transfer (`transfer_nonce`, §4.1) from a relationship's public inputs, which is the attack this test describes: relationship B's next nonce, predicted and spent in A first. The mutation above was run again and turns the test red at that assertion. ### 6.68 A credit's source held to account through its own segment (`security/core-decoders-and-nonce-scope`, pre-audit item 3, 2026-10-01) @@ -2384,11 +2404,11 @@ Also: `a_credits_source_is_validated_through_its_own_segment` (B validates C's f | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| | DSM high-level (MR-DSM) | 277 | 96 | 95 | 39 | 0 | 29 | 18 | -| SoFi (MR-SOFI) | 362 | 231 | 91 | 19 | 4 | 17 | 0 | +| SoFi (MR-SOFI) | 362 | 235 | 87 | 19 | 4 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 65 | 18 | 56 | 0 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **943** | **401** | **205** | **115** | **4** | **64** | **154** | +| **All** | **943** | **405** | **201** | **115** | **4** | **64** | **154** | ## 8 Per-requirement results @@ -2845,7 +2865,7 @@ Also: `a_credits_source_is_validated_through_its_own_segment` (B validates C's f | MR-SOFI-0165 | Partial | dsm_sdk · sdk/sofi_register.rs (leader-first batch write, L257-296) | dsm_sdk sofi_register.rs tests | The leader-first paired write lives in `sofi_register`. SoFi has no production entry (§3 G1); this obligation has to happen in the running system, and its only implementation is unreachable. | | MR-SOFI-0166 | Met | `dsm_storage_node::api::cells::put_cell`; `dsm_storage_node::api::cells::get_cell`; `dsm::sofi::conformance::fulfillment_conformance` | `dsm_storage_node::cells_keep_everything::a_second_value_at_a_key_is_kept_after_the_first_never_refused`; `dsm_storage_node::cells_keep_everything::only_malformed_requests_are_refused` | The storage node has no SoFi or route-chain code; specific cell tests confirm this, not just file-level absence: the node keeps every value and refuses only malformed requests. | | MR-SOFI-0167 | Met | `dsm::sofi::derive::resolution_claim`; `dsm::sofi::registration` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim` | — | -| MR-SOFI-0168 | Partial | `dsm::sofi::registration::fulfillment_registered` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim` | S20 extended 2026-10-01 (§6.65, 12j): the pair is still matched by the entry digest of `derive(P, F)`, which needs `P`; matching by `FulfillmentId(F)` from the two cells' bytes lands with SOFI's change on #1096. | +| MR-SOFI-0168 | Met | `dsm::sofi::registration::fulfillment_registered` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim`; `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own`; `dsm_sdk::handlers::node_e2e_tests::a_position_reads_the_same_before_and_after_a_precommit_is_published` | S20 extended 2026-10-01 (§6.65, §6.66 12j): the pair is matched by `FulfillmentId(F)` from the two cells' bytes alone, so registration reads the same for every verifier at every time (`51359a6b6`). | | MR-SOFI-0169 | Met | `dsm::sofi::registration` (Final adapter, first-writer-wins) | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim` | — | | MR-SOFI-0170 | Met | `dsm::sofi::wire::objects::SofiExercise`; `dsm::sofi::exercise::recognize_exercise` | `dsm::sofi::exercise::tests::an_exercise_round_trips_and_rebuilds_into_bound_objects`; `dsm::sofi::exercise::tests::an_exercise_counts_only_when_f_and_p_are_signed_under_the_key_p_commits`; `dsm::sofi::publication::tests::an_envelope_is_recognized_only_when_its_signature_verifies_under_its_bodys_key`; `dsm_sdk::handlers::node_e2e_tests::an_exercise_carrying_other_bytes_than_its_closure_holds_no_key` | "The signed F, the signed P": recognition verifies both (§6.23). "Every closure object": recognition checks each against its reference in `𝒞_E^pre` by conformance item 8's rule (§6.66 12b); until 2026-10-01 it counted them. | | MR-SOFI-0171 | Met | `dsm::sofi::exercise::exercise_names_key` | `dsm::sofi::exercise::tests::an_exercise_names_exactly_the_keys_its_fulfillment_and_precommit_name`; `dsm::sofi::exercise::tests::a_successor_key_resolves_to_the_e_of_the_exercise_that_names_it_or_stays_open` | — | @@ -3020,7 +3040,7 @@ Also: `a_credits_source_is_validated_through_its_own_segment` (B validates C's f | MR-SOFI-0340 | Met | `dsm::sofi::validation::trader_pre_balances`; `dsm::sofi::validation::Evidence::pre_balance` | `dsm::sofi::validation::tests::a_balance_the_closure_does_not_carry_is_invalid`; `dsm::sofi::validation::tests::bytes_at_a_named_address_that_are_not_a_balance_are_invalid`; `dsm::sofi::validation::tests::a_named_balance_not_in_hand_waits`; `dsm::sofi::validation::tests::bytes_that_do_not_re_derive_the_named_address_prove_nothing` | Amendment S12 (§6.40): a missing number is Invalid in hand (`route_invalid_in_hand`); a named object not in hand, or not re-deriving its address, is Unavailable. | | MR-SOFI-0341 | Met | `dsm::sofi::validation::trader_pre_balances`; `dsm::sofi::validation::realize_root`; `dsm::sofi::resolve::Verifier::acquire_evidence`; `dsm_sdk::sdk::sofi_reads::LiveSofiReads` | `dsm::sofi::validation::tests::a_trade_that_does_not_debit_the_trader_is_invalid_from_the_exercise_alone`; `dsm_sdk::handlers::node_e2e_tests::a_vault_traded_through_closes_for_its_owner`; `dsm_sdk::handlers::node_e2e_tests::every_sofi_route_reaches_its_producer` | Amendment S12 (§6.40): one path for every verifier; the trader's own route is judged from the same objects. | | MR-SOFI-0342 | Met | `dsm::sofi::validation::setup_lineage`; `dsm::sofi::validation::InvalidLineage`; `dsm::sofi::validation::setup_valid` | `dsm::sofi::validation::tests::a_setup_on_a_lineage_known_invalid_is_invalid`; `dsm::sofi::validation::tests::an_invalid_verdict_about_another_lineage_or_position_supplies_nothing`; `dsm::sofi::validation::tests::a_lineage_not_established_leaves_the_setup_waiting` | Amendment S13 (§6.40): no accepted claim is synthesized; the verdict of lineage validation is the negative fact, and a verdict about another trader or position establishes nothing. | -| MR-SOFI-0343 | Partial | `dsm::sofi::resolution::classify_attempt`; `dsm::sofi::resolution::SkipReason`; `dsm::sofi::facts::establish` | `dsm::sofi::resolution::tests::a_final_cell_whose_fulfillment_lost_its_position_is_skipped`; `dsm::sofi::resolution::tests::an_in_hand_refutation_answers_as_the_complete_facts_do`; `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim`; `dsm_sdk::handlers::node_e2e_tests::a_key_whose_fulfillment_can_never_register_is_skipped_without_its_evidence` | SoFi Amendment S14 (owner, 2026-09-30, `fix/sofi-beta-blockers`). `establish` reads from the position pair whether another claim holds this F's position (`position_lost`). A final cell whose exercise carries such an F, one that can never register, is Skipped with `SkipReason::RejectedFinalInadmissible`; the position resolves only through the claim that holds it (`resolve_position` answers `NotRegistered` for this F). It follows the `FulfillmentImpossible` check, so an exercise refuted in hand is classified exactly as before. Mutation: the skip removed from `classify_attempt` → the first test red; restored. TLA `DSM_SofiFulfillment`: `LostPosition` is a disjunct of `Skipped` beside `RouteImpossibleAt`, and `_LostPositionDropped` produces the counterexample. Lean: `a_lost_position_makes_a_final_cell_skippable`, for a route or a single leg, with validation unavailable; dropping `positionLost` from `finalSkippable` → red. The position is also lost while no fulfillment is final at `K_ful(q)`: registration then names the claim holding `K_root(q)` (`Registration::RootTaken`), and an F whose own `C_q` is another claim is lost (MR-SOFI-0241's node test takes that route, a rival claim written before the trade). S20 extended 2026-10-01 (§6.65, 12j): an `F` whose `K_ful(q)` another `F` holds only leader-held or preserved is not yet lost, so its key can freeze (SOFI's probe); that and the body mismatch as a loss land with SOFI's change on #1096. | +| MR-SOFI-0343 | Met | `dsm::sofi::resolution::classify_attempt`; `dsm::sofi::resolution::SkipReason`; `dsm::sofi::facts::establish`; `dsm::sofi::registration::RegistrationRead::standing_of` | `dsm::sofi::resolution::tests::a_final_cell_whose_fulfillment_lost_its_position_is_skipped`; `dsm::sofi::resolution::tests::an_in_hand_refutation_answers_as_the_complete_facts_do`; `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim`; `dsm_sdk::handlers::node_e2e_tests::a_key_whose_fulfillment_can_never_register_is_skipped_without_its_evidence`; `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own`; `dsm_sdk::handlers::node_e2e_tests::a_position_reads_the_same_before_and_after_a_precommit_is_published` | SoFi Amendment S14 (owner, 2026-09-30, `fix/sofi-beta-blockers`). `establish` reads from the position pair whether another claim holds this F's position (`position_lost`). A final cell whose exercise carries such an F, one that can never register, is Skipped with `SkipReason::RejectedFinalInadmissible`; the position resolves only through the claim that holds it (`resolve_position` answers `NotRegistered` for this F). It follows the `FulfillmentImpossible` check, so an exercise refuted in hand is classified exactly as before. Mutation: the skip removed from `classify_attempt` → the first test red; restored. TLA `DSM_SofiFulfillment`: `LostPosition` is a disjunct of `Skipped` beside `RouteImpossibleAt`, and `_LostPositionDropped` produces the counterexample. Lean: `a_lost_position_makes_a_final_cell_skippable`, for a route or a single leg, with validation unavailable; dropping `positionLost` from `finalSkippable` → red. The position is also lost while no fulfillment is final at `K_ful(q)`: registration then names the claim holding `K_root(q)` (`Registration::RootTaken`), and an F whose own `C_q` is another claim is lost (MR-SOFI-0241's node test takes that route, a rival claim written before the trade). S20 extended 2026-10-01 (§6.65, §6.66 12j): an `F` whose `K_ful(q)` another `F` holds in any state is lost, and so is one whose `K_root(q)` holds a claim other than `derive(P, F)` (`RegistrationRead::standing_of`, `51359a6b6`). Mutation: another fulfillment's leader link losing nothing → both new tests red. | | MR-SOFI-0344 | Met | `dsm::sofi::resolve::Verifier::peer_position`; `dsm::sofi::resolve::PeerPositionResolver`; `dsm::sofi::lineage::advance_peer_resolved`; `dsm_sdk::sdk::sofi_reads::VerifierContext::peer_position_resolver` | `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | SoFi Amendment S15 (`feat/frontier-relative-peer-verification`, 2026-09-30). `peer_position` runs the trader's own stages for position q over the verifier's reads (registration from the pair, P by its id, the exercise at the first leg's cell, each vault's chain, the facts) and the ladder inside `advance_peer_resolved`, which shares `advance_resolved`'s derivation. Failures keep their class: Incomplete for a read not in hand, Unresolved for facts that do not decide q, Invalid only for a verified contradiction. On the nodes, a trader who traded pays a third party and the payee accepts (P15-9). Mutations: the resolver refusing every C_q, and the resolver selecting the void root: both red, the payee never credits. | | MR-SOFI-0345 | Met | `dsm::sofi::registration::fulfillment_registered`; `dsm::sofi::resolve::Verifier::peer_position` | `dsm::sofi::registration::tests::registration_needs_both_cells_final_and_the_root_on_this_claim`; `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | SoFi Amendment S15. `peer_position` requires the pair to hold registered the fulfillment the held claim names, and registration holds F registered only while `K_root(q)` is final on exactly `C_q(P, F)`; a final value is the cell's only one, so the held claim is that claim and its two roots are the ones the resolution chooses between. A separate equality check in `peer_position` could never fail and was removed. | | MR-SOFI-0346 | Partial | `dsm::sofi::resolve::Verifier::peer_position`; `dsm_sdk::sdk::economic_registers::resolve_peer` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier`; `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | SoFi Amendment S15 (wording made exact 2026-09-30: the setup positions the legs name are inside the segment and read through A8). The resolver reads q's position pair, P, the first leg's cell, the vault chains and q's evidence, and the trader's accepted claims at its setup positions through `resolve_peer`, which DSM core's walk bounds to the frontier (that test). Partial: no request-log test of the resolver's own read set yet. | @@ -3037,8 +3057,8 @@ Also: `a_credits_source_is_validated_through_its_own_segment` (B validates C's f | MR-SOFI-0357 | Met | `dsm::sofi::validation::route_endpoints`; `dsm::sofi::validation::swap_endpoints`; `dsm::sofi::validation::validate_swap`; `dsm::sofi::validation::route_shape`; `dsm::sofi::validation::split_sums`; `dsm::sofi::validation::chain_holds`; `dsm_sdk::sdk::sofi_sdk::draft_route`; `dsm::sofi::validation::movement_shape` | `dsm::sofi::validation::tests::a_route_chains_or_splits_and_its_endpoints_follow`; `dsm::sofi::validation::tests::a_split_whose_legs_sum_to_the_intent_is_not_refuted_by_its_shape`; `dsm::sofi::validation::tests::a_split_that_does_not_sum_to_the_intent_is_invalid`; `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). RouteValidation accepts hops that chain or hops that all trade the intent's pair with inputs and outputs summing to the intent in checked arithmetic (`Invalid::SplitDoesNotSum` otherwise); each hop is priced by its own vault. The producer states the intent by the same rule. `movement_shape` is the shape `route_endpoints` states a route's ends by (§6.56). | | MR-SOFI-0358 | Met | `dsm::sofi::validation::trader_movements`; `dsm::sofi::validation::validate_swap` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). On the nodes a split through two vaults of ERA/TKN realizes as one position: B is debited the whole input and credited the sum of both legs, and each vault moved by its own leg alone. | | MR-SOFI-0359 | Met | `dsm_sdk::sdk::sofi_flow::find_route`; `dsm_sdk::sdk::sofi_flow::plan`; `dsm_sdk::sdk::sofi_flow::plan_split`; `dsm_sdk::sdk::sofi_flow::trade`; `dsm_sdk::sdk::sofi_flow::route_ends` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). The search weighs each pair of direct vaults as a split beside every single hop and chain and proposes the most; the split share is searched over the inputs both legs price. The trade plans the same split again at the heads it walks; min-out bounds what it accepts. The quote reports the route's total by that rule (§6.56). | -| MR-SOFI-0360 | Partial | `dsm::sofi::wire::objects::SignedSofiResolutionClaim`; `dsm::sofi::signature::verify_resolution_claim`; `dsm::sofi::signature::sign_resolution_claim`; `dsm::economic::register::read_root_cell` (body identity); `dsm_sdk::sdk::sofi_register` (producer); `dsm_sdk::sdk::sofi_relay::carry_pair` | `dsm::economic::claim_envelope::tests::a_conditional_cell_decodes_by_class`; `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell`; `dsm::economic::register::registered_root_construction_tests::a_held_root_cell_carries_the_exact_bytes_that_hold_it` | SoFi Amendment S20 (§6.65). `K_root(q)` holds the trader-signed `C_q` (`0x0062`); a bare `C_q` is refused by name, and a conditional claim is identified by its derived body. S20 extended 2026-10-01 (§6.65, 12j): the body is compared with `derive(P, F)` inside registration, where a difference reads as not registered; the comparison where `P` is in hand, a difference losing `F` and making the position Invalid, lands with SOFI's change on #1096. | -| MR-SOFI-0361 | Partial | `dsm::sofi::wire::objects::TraderFulfillmentBody`; `dsm::sofi::registration::fulfillment_proves_the_device`; `dsm::sofi::registration::names_fulfillment_key`; `dsm_sdk::sdk::sofi_sdk::build_fulfillment` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell` | SoFi Amendment S20 (§6.65). The binding is decided from `F`'s bytes before `P` is looked up. Mutation: the check removed → that test red. S20 extended 2026-10-01 (§6.65, 12j): `names_fulfillment_key` still reads `P` (`PrecommitLookup`) after the binding, so which `F` holds `K_ful(q)` depends on whether its `P` is published yet; the `P`-free recognition lands with SOFI's change on #1096. | +| MR-SOFI-0360 | Met | `dsm::sofi::wire::objects::SignedSofiResolutionClaim`; `dsm::sofi::signature::verify_resolution_claim`; `dsm::sofi::signature::sign_resolution_claim`; `dsm::economic::register::read_root_cell` (body identity); `dsm_sdk::sdk::sofi_register` (producer); `dsm_sdk::sdk::sofi_relay::carry_pair`; `dsm::sofi::registration::RegistrationRead::standing_of`; `dsm::sofi::resolve::Verifier::peer_position` | `dsm::economic::claim_envelope::tests::a_conditional_cell_decodes_by_class`; `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell`; `dsm::economic::register::registered_root_construction_tests::a_held_root_cell_carries_the_exact_bytes_that_hold_it`; `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own` | SoFi Amendment S20 (§6.65). `K_root(q)` holds the trader-signed `C_q` (`0x0062`); a bare `C_q` is refused by name, and a conditional claim is identified by its derived body. S20 extended 2026-10-01 (§6.65, §6.66 12j): registration matches the pair by `FulfillmentId(F)`; where `P` is in hand the body is compared with `derive(P, F)`. In the facts a difference loses `F` (`standing_of`); in a position's resolution it is Invalid (`peer_position`) (`51359a6b6`). Mutation: the body check skipped for a registered pair → the unit test red. The resolution check has no test of its own yet. | +| MR-SOFI-0361 | Met | `dsm::sofi::wire::objects::TraderFulfillmentBody`; `dsm::sofi::registration::fulfillment_proves_the_device`; `dsm::sofi::registration::names_fulfillment_key`; `dsm_sdk::sdk::sofi_sdk::build_fulfillment` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell`; `dsm::sofi::registration::tests::only_a_fulfillment_of_this_trader_at_this_position_names_the_key`; `dsm_sdk::handlers::node_e2e_tests::a_position_reads_the_same_before_and_after_a_precommit_is_published` | SoFi Amendment S20 (§6.65). The binding is decided from `F`'s bytes before `P` is looked up. Mutation: the check removed → that test red. S20 extended 2026-10-01 (§6.65, §6.66 12j): `names_fulfillment_key` decides from `F`'s bytes alone and reads no `P`, so which `F` holds `K_ful(q)` is the same at every time (`51359a6b6`). | | MR-SOFI-0362 | Missing | — | — | SoFi Amendment S20 (§6.65). The exercise does not carry the trader's signed `C_q` yet; SOFI builds it on #1096 (recognition, producer, and the relay taking it from the exercise). | ### 8.3 dBTC native specification @@ -3184,7 +3204,7 @@ The deferral also covers MR-DSM-0198 and MR-DSM-0221–0237 (§6.1), and the dBT | MR-STOR-0129 | Met | `dsm_storage_node::api::cells::put_cell`; `dsm_storage_node::api::cells::get_cell`; `dsm::storage_cell::ArrivalRecord` | `dsm_storage_node::cells_keep_everything::a_put_answers_with_the_arrival_record_a_verifier_replays` | Confirmed. | | MR-STOR-0130 | Met | `dsm_sdk::sdk::route_seats::write_recorded`; `dsm_sdk::sdk::route_seats::write_along` | `dsm_sdk::sdk::route_seats::tests::nothing_reaches_a_later_seat_before_the_leader_answers`; `dsm_sdk::sdk::route_seats::tests::a_seat_that_does_not_answer_is_recorded_empty_in_its_place`; `dsm_sdk::sdk::route_seats::tests::a_recorded_position_is_never_written_again` | Re-verified 2026-09-30 (§6.50): the writer goes leader first, each copy carries the chain built so far, and a stopped write continues from its own record, never from copies. `put_cell_leader_first`, which it cited, is deleted. | | MR-STOR-0131 | Met | `dsm::route_chain::evaluate`; `dsm_sdk::sdk::route_seats::write_recorded` | `dsm::route_chain::tests::an_entry_carries_one_slot_per_earlier_position_and_round_trips`; `dsm::route_chain::tests::a_copy_at_another_position_or_of_another_value_does_not_count`; `dsm::route_chain::tests::a_copy_whose_chain_does_not_begin_with_the_leader_link_does_not_count` | Re-verified 2026-10-01 (§6.54). A link is a seat's arrival record for the copy written there (`ChainSlot::Link`). The record names the member and the cell, and commits the entry, which carries its route position and the chain before it, so each link commits the previous one. | -| MR-STOR-0132 | Met | `dsm::route_chain::evaluate`; `dsm::route_chain::leader_copies`; `dsm::sofi::resolve::Verifier::read_registration` | `dsm::route_chain::tests::the_first_recognized_value_at_the_leader_holds_the_cell`; `dsm::route_chain::tests::junk_first_at_the_leader_blocks_nothing`; `dsm::route_chain::tests::a_leader_holding_no_recognized_value_leaves_the_cell_open`; `dsm_sdk::handlers::node_e2e_tests::nothing_but_the_leaders_first_fulfillment_decides_k_ful` | Re-verified 2026-09-30 (§6.50): the leader link is the first recognized object naming the cell in the leader's log, and junk first at the leader blocks nothing. The superseded rule in `sofi/arith.rs` is deleted. 2026-10-01 (§6.66 12c): a reader that fetches what recognition needs fetches it only for the copies the leader link is chosen from (`leader_copies`), until one counts; `K_ful(q)`'s read fetched a precommit for every fulfillment any seat held. | +| MR-STOR-0132 | Met | `dsm::route_chain::evaluate`; `dsm::sofi::resolve::Verifier::read_registration` | `dsm::route_chain::tests::the_first_recognized_value_at_the_leader_holds_the_cell`; `dsm::route_chain::tests::junk_first_at_the_leader_blocks_nothing`; `dsm::route_chain::tests::a_leader_holding_no_recognized_value_leaves_the_cell_open`; `dsm_sdk::handlers::node_e2e_tests::nothing_but_the_leaders_first_fulfillment_decides_k_ful` | Re-verified 2026-09-30 (§6.50): the leader link is the first recognized object naming the cell in the leader's log, and junk first at the leader blocks nothing. The superseded rule in `sofi/arith.rs` is deleted. 2026-10-01 (§6.66 12c, 12j): `K_ful(q)`'s read fetched a precommit for every fulfillment any seat held, and one junk fulfillment at any member wedged it; it now reads no precommit at all, each cell decided from its leader's log and its own bytes. | | MR-STOR-0133 | Met | `dsm::route_chain::evaluate` | `dsm::route_chain::tests::a_copy_whose_chain_does_not_begin_with_the_leader_link_does_not_count`; `dsm::route_chain::tests::a_copy_at_another_position_or_of_another_value_does_not_count`; `dsm::route_chain::tests::a_later_link_needs_its_seats_mirror_of_the_leader_link`; `dsm::route_chain::tests::a_later_link_needs_its_own_byte_commit_and_valid_carried_links` | Re-verified 2026-10-01 (§6.54). A later link counts only on a chain from the leader link, at a position above every earlier link's, and when its seat's own mirror of the leader's ByteCommits covers the leader link. The copy-count rule it cited (`sofi/arith.rs`) is deleted. | | MR-STOR-0134 | Partial | `dsm_sdk::sdk::route_seats::write_along`; `dsm::route_chain::evaluate` | `dsm_sdk::sdk::route_seats::tests::a_seat_that_does_not_answer_is_recorded_empty_in_its_place`; `dsm::route_chain::tests::an_empty_neither_counts_nor_invalidates` | Re-verified 2026-10-01 (§6.54). A seat that does not answer is recorded as a no-response empty in its place, and Core counts no empty as a link. The writer never records a taken empty: nothing in the SDK produces `ChainSlot::Taken`, which would need the writer to read that another value's chain is already first at the seat. | | MR-STOR-0135 | Met | `dsm_storage_node::api::cells::put_cell`; `dsm_storage_node::api::cells::get_cell` | `dsm_storage_node::cells_keep_everything::a_second_value_at_a_key_is_kept_after_the_first_never_refused`; `dsm_storage_node::cells_keep_everything::an_identical_value_put_twice_is_held_twice` | Confirmed. | diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index b7188443d..40b502b93 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -84,7 +84,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | SoFi Amendment S7, storage §3, §6: a trade cut short by a refused write is `RetriesExhausted` with the position fenced while the pair or the exercise cannot land, records nothing, and realizes when the writes land; a refusal is an error, never the network status | `dsm_sdk` · sdk/sofi_register.rs · `install_fulfillment` (`LeaderUnreached`); sdk/sofi_advance.rs · `install_failure`, `complete_pending_fulfillment` (`Completion::NotTaken`), `exercise_legs`; sdk/sofi_flow.rs · `complete_and_settle`, `settle` | `dsm_sdk::handlers::node_e2e_tests::a_trade_cut_short_by_a_refused_write_is_the_network_status_until_it_lands`; `dsm_sdk::sdk::sofi_advance::tests::only_an_install_the_network_did_not_take_is_the_network_status` | `LeaderUnreached` not refused at install → the trade reports a position it never installed → red (2026-09-24). `install_failure` answering `NotTaken::Evidence` for `NotConforming` → the mapping test red; `LeaderUnreached` refused again (the old mapping) → the mapping test and the e2e test red; `exercise_legs` reporting `Written` for a leg that did not reach its leader → the e2e test red at completion's answer for the refused leg (all 2026-09-26, restored, the named tests green again). | — | | SoFi §28–§29: a vault genesis or a setup is admitted only at the successor of the predecessor it was built on and names; a predecessor that moved since — a pending admission resumed on the way in — refuses the admission before its advance | `dsm_sdk` · sdk/economic_admission_flow.rs · `BuiltOn::admits` in `stage_admission`; sdk/sofi_flow.rs · `create_vault`, `setup` | `dsm_sdk::handlers::sender_admission_tests::a_creation_built_on_a_predecessor_the_device_no_longer_stands_on_is_refused_before_its_advance` | `BuiltOn::admits` evaluated but its refusal discarded in `stage_admission` → the creation built on position 1 is admitted at 3 under a genesis naming 2 → red (2026-09-26, restored). | — | | MR-SOFI-0352, MR-SOFI-0256, SoFi §30, Amendment S16, storage §4: a route search over vaults the reads do not establish answers what it found and says it is partial, never a complete search with no route | `dsm_sdk` · sdk/sofi_flow.rs · `find_route`, `vaults_of`; `dsm` · sofi/resolve.rs · `Verifier::vaults_of_token` | `dsm_sdk::handlers::node_e2e_tests::a_route_search_that_cannot_see_its_vaults_says_so` | The partial discovery not marking the search (`vaults_of` dropping `Search::Partial`) → below quorum the search reports complete with no route → red (2026-10-01, restored). | — | -| SoFi §9, §17.5; storage §9 rule 3: a signed SoFi object is recognized only when its signature verifies under the key its body commits, and an exercise or `K_ful(q)` candidate only when `F`'s key is `P`'s; unsigned bytes first at a leader hold nothing | `dsm` · sofi/publication.rs · `signed_body`; sofi/exercise.rs · `recognize_exercise`; sofi/registration.rs · `names_fulfillment_key` | `dsm::sofi::exercise::tests::an_exercise_counts_only_when_f_and_p_are_signed_under_the_key_p_commits`; `dsm::sofi::registration::tests::only_a_fulfillment_signed_under_its_precommits_key_names_the_key`; `dsm::sofi::publication::tests::an_envelope_is_recognized_only_when_its_signature_verifies_under_its_bodys_key`; `dsm::sofi::validation::tests::a_setup_is_the_traders_only_under_the_key_p_commits`; `dsm_sdk::handlers::node_e2e_tests::an_unsigned_exercise_at_a_successor_key_takes_nothing` | Signature check removed → 4 Core tests + e2e red; `F` key = `P` key removed at either recognizer → its test red (2026-09-25). | — | +| SoFi §9, §17.5; storage §9 rule 3: a signed SoFi object is recognized only when its signature verifies under the key its body commits, and an exercise or `K_ful(q)` candidate only when `F`'s key is `P`'s; unsigned bytes first at a leader hold nothing | `dsm` · sofi/publication.rs · `signed_body`; sofi/exercise.rs · `recognize_exercise`; sofi/registration.rs · `names_fulfillment_key` | `dsm::sofi::exercise::tests::an_exercise_counts_only_when_f_and_p_are_signed_under_the_key_p_commits`; `dsm::sofi::registration::tests::only_a_fulfillment_signed_under_a_key_of_this_device_names_the_key`; `dsm::sofi::publication::tests::an_envelope_is_recognized_only_when_its_signature_verifies_under_its_bodys_key`; `dsm::sofi::validation::tests::a_setup_is_the_traders_only_under_the_key_p_commits`; `dsm_sdk::handlers::node_e2e_tests::an_unsigned_exercise_at_a_successor_key_takes_nothing` | Signature check removed → 4 Core tests + e2e red; `F` key = `P` key removed at either recognizer → its test red (2026-09-25). | — | | MR-SOFI-0316, SoFi §51: a reserve release the claim policy does not allow never holds a successor cell; a whole-supply release first at the leader holds nothing and the claim lands | `dsm` · economic/native_reserve.rs · `release_constructible` | `dsm::economic::native_reserve::tests::a_release_of_any_amount_but_the_payout_never_holds_the_cell`; `dsm_sdk::handlers::faucet_flow_tests::a_release_of_the_whole_supply_holds_nothing_and_the_claim_lands` | Release-rule amount check removed → Core test and e2e red (2026-09-25). | — | | MR-STOR-0021, DSM Amendment A1: a register set not established, a member's 404, an unreadable cert-chain head, unreadable settlement state and an inbox no member answered for are never read as verdicts | `dsm` · economic/lineage.rs · `advance_validated`; `dsm_sdk` · sdk/storage_node_sdk.rs · `answer`; handlers/online_finalize.rs · `verify_acceptance_receipt`; sdk/inbox_poller.rs · `has_pending_settlement_work`; sdk/b0x_sdk.rs · `retrieve_from_b0x_v2` | `dsm::economic_admission_lifecycle::a_register_set_not_established_is_not_a_verdict_about_the_claimant`; `dsm_sdk::sdk::storage_node_sdk::tests::a_member_that_answers_404_took_nothing`; `dsm_sdk::handlers::online_finalize::tests::an_unreadable_counterparty_head_is_never_read_as_genesis`; `dsm_sdk::sdk::inbox_poller::tests::a_lifecycle_stop_is_declined_while_settlement_state_is_unreadable`; `dsm_sdk::handlers::node_e2e_tests::an_inbox_read_that_did_not_cover_every_delivery_is_not_a_complete_sync`; `dsm_sdk::sdk::b0x_sdk::tests::a_read_covers_every_delivery_only_from_the_quorum_intersection_up` | Each fix reverted → its test red (2026-09-25). | — | | MR-SOFI-0316, owner ruling 2026-09-25: a faucet release occupies a reserve cell only when `derive_devid(claimant_public_key, AttA) == recipient_devid`, decided from the bytes | `dsm` · economic/native_reserve.rs · `release_constructible` | `dsm::economic::native_reserve::tests::a_release_signed_by_any_key_but_the_recipient_devices_never_holds_the_cell`; `dsm_sdk::handlers::faucet_flow_tests::a_release_naming_a_device_its_key_does_not_derive_holds_nothing` | Binding removed → Core test and e2e red (2026-09-25). | — | @@ -182,9 +182,10 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-DSM-0074, pre-audit item 6 (§6.63): a send names its recipient by device id, never by a non-unique alias | `proto` · `OnlineTransferSmartRequest.recipient_device_id` (field 5, exactly 32 bytes; field 1 reserved); `dsm_sdk` · handlers/wallet_routes.rs · `wallet.sendSmart` | `dsm_sdk::handlers::sender_admission_tests::a_send_pays_the_contact_chosen_not_the_first_of_its_alias` | The handler resolving the recipient through its alias → red: both payments went to one contact and the second was refused (2026-10-01, restored). | — | | MR-SOFI-0244, MR-SOFI-0213, pre-audit item 12 (§6.66 12a): the walk's budget chunks a walk and ends none; conformance item 5 reads `K^(a-1)` | `dsm` · sofi/resolve.rs · `Verifier::acquire_prior_attempts`, `Verifier::next_generation`, `Verifier::read_legs` | `dsm_sdk::handlers::node_e2e_tests::junk_at_more_attempt_keys_than_one_walk_examines_is_walked_past` | Item 5 reading only the keys below the budget → red (B's trade `RetriesExhausted`); extending a chain stopping where the budget ends → red (the owner's walk stops at 2 roots); a leg's liveness walk stopping there → red (B's trade `RetriesExhausted`) (2026-10-01, all restored). | — | | MR-SOFI-0170, pre-audit item 12 (§6.66 12b): an exercise's closure objects are the objects its references name | `dsm` · sofi/exercise.rs · `recognize_exercise` (`conformance::closure_object_verifies`) | `dsm_sdk::handlers::node_e2e_tests::an_exercise_carrying_other_bytes_than_its_closure_holds_no_key` | Recognition checking only the closure's count → red: the copy holds the key (2026-10-01, restored). | — | -| MR-STOR-0132, SoFi §17.4, pre-audit item 12 (§6.66 12c): only the leader's first fulfillment decides `K_ful(q)` | `dsm` · sofi/resolve.rs · `Verifier::read_registration`; route_chain.rs · `leader_copies` (shared with `leader_link`) | `dsm_sdk::handlers::node_e2e_tests::nothing_but_the_leaders_first_fulfillment_decides_k_ful` | Every fulfillment in the leader's log having its precommit fetched → red (the owner's walk fails); every seat's → red (the read of `q + 1` fails) (2026-10-01, both restored). | — | +| MR-STOR-0132, SoFi §17.4, pre-audit item 12 (§6.66 12c): only the leader's first fulfillment decides `K_ful(q)` | `dsm` · sofi/resolve.rs · `Verifier::read_registration` (since 12j it reads no precommit at all) | `dsm_sdk::handlers::node_e2e_tests::nothing_but_the_leaders_first_fulfillment_decides_k_ful` | Every fulfillment in the leader's log having its precommit fetched → red (the owner's walk fails); every seat's → red (the read of `q + 1` fails) (2026-10-01, both restored). | — | | MR-SOFI-0234, MR-SOFI-0241, pre-audit item 12 (§6.66 12d): a conditional parent another claim holds selects no root | `dsm` · sofi/facts.rs · `parent_position` | `dsm::sofi::facts::tests::a_conditional_parent_another_claim_holds_selects_no_root` | A conditional parent another claim holds treated as no fact → red: `None` where `ConditionalNoRoot` belongs (2026-10-01, restored). | `lean4/DSMSofiAtomicity.lean` · `terminal_parent_with_no_root_is_invalid_without_evidence` (`ParentState.noRoot`) | | MR-DSM-0273, MR-DSM-0275: a credit's source is validated through its own segment back to a frontier the receiver holds, and nothing behind that frontier is read | `dsm` · economic/peer_lineage.rs · `Verifier::source_step`, `SourceAncestry`, `DebitHasNoSources` | `dsm_sdk::handlers::frontier_verification_tests::a_credits_source_is_validated_through_its_own_segment`; `dsm_sdk::handlers::frontier_verification_tests::a_sources_segment_stops_at_the_frontier_the_receiver_holds` | The source's earlier positions authenticated by claims only (the one-hop walk) → red: B does not read C's first step's evidence (2026-10-01). | Tests 1 and 2 of the owner's ruling (invented root behind a source) owed: hostile-device harness. | | MR-DSM-0275: a walk past its budget before a trusted frontier is incomplete and reads nothing more | `dsm` · economic/peer_lineage.rs · `Verifier::spend_step` | `dsm::economic::peer_lineage::tests::a_source_walk_past_the_budget_is_incomplete_and_reads_nothing` | Budget check removed → red: the source walk reads C's cells (2026-10-01). | — | | MR-DSM-0274: a source's frontier is recorded with the acceptance and reused | `dsm_sdk` · handlers/storage_routes.rs (accept transaction); `dsm` · economic/peer_lineage.rs · `Verifier::frontier_below` | `dsm_sdk::handlers::frontier_verification_tests::a_sources_frontier_is_recorded_and_the_next_credit_walks_only_its_suffix`; `dsm_sdk::handlers::frontier_verification_tests::one_walk_validates_a_sources_segment_once` | Source frontiers not recorded → red; reuse within a walk disabled → red (2026-10-01). | — | | MR-DSM-0274: no frontier from a walk that did not finish | `dsm_sdk` · handlers/storage_routes.rs (the only writer is the accept transaction) | `dsm_sdk::handlers::recipient_admission_tests::an_outage_holds_the_transfer_cleanly_and_it_recovers` | No gate to remove: structural (2026-10-01). | — | +| MR-SOFI-0168, MR-SOFI-0343, MR-SOFI-0360, MR-SOFI-0361, SoFi Amendment S20 extended, pre-audit item 12 (§6.66 12j): a position pair is decided from its own bytes, the same whenever it is read | `dsm` · sofi/registration.rs · `names_fulfillment_key`, `fulfillment_registered`, `RegistrationRead::standing_of`; sofi/resolve.rs · `Verifier::read_registration`, `Verifier::peer_position` (the body check) | `dsm_sdk::handlers::node_e2e_tests::a_position_reads_the_same_before_and_after_a_precommit_is_published`; `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own` | Another fulfillment's leader link losing nothing → both red; a registered pair's body not compared → the unit test red (2026-10-01, restored). The resolution's body check: not run. | — | From 5a5f911eede78ce592b8689676fb09bee2b0c60d Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:31:23 -0400 Subject: [PATCH 27/31] test(storage): a cell takes the largest route entry and refuses a byte more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No test covered the edge of the cell value limit. A cell put, alone or in a batch, takes a value of exactly dsm::route_chain::MAX_ENTRY_BYTES, refuses one byte more with 413, and holds nothing from the refused put. The test reads the constant, so it follows a change to Core's largest value (SoFi 12e raises it for the exercise that carries the trader's signed C_q). Both values are under the request body limit, so the refusal is the cell's own. Mutation controls, red and then restored: - the node's limit one byte looser → 200 where 413 is required; - the limit set to the value length instead of the entry length → a value at the limit refused. cells_keep_everything: 9/0. fmt and node clippy are clean. --- .../tests/cells_keep_everything.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/dsm_storage_node/tests/cells_keep_everything.rs b/dsm_storage_node/tests/cells_keep_everything.rs index f8e799702..265895812 100644 --- a/dsm_storage_node/tests/cells_keep_everything.rs +++ b/dsm_storage_node/tests/cells_keep_everything.rs @@ -334,3 +334,40 @@ async fn a_put_answers_with_the_arrival_record_a_verifier_replays() { .collect(); assert_eq!(got, returned, "get reports what put returned"); } + +/// A cell takes a value up to Core's largest route entry and refuses one +/// byte more, alone and in a batch, and holds nothing from a refused put. The +/// limit is `dsm::route_chain::MAX_ENTRY_BYTES`, so it moves with Core's +/// largest value (a SoFi exercise) and with nothing else. Both values are +/// well under the request body limit, so the refusal is the cell's own. +#[tokio::test] +async fn a_cell_takes_the_largest_route_entry_and_refuses_a_byte_more() { + let app = member("cells_value_cap").await; + let at_cap = vec![0xC5u8; dsm::route_chain::MAX_ENTRY_BYTES]; + let over = vec![0xC5u8; dsm::route_chain::MAX_ENTRY_BYTES + 1]; + + assert_eq!( + put(&app, Some(NS), &key(0xC1), &over).await, + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!(get(&app, &key(0xC1)).await.1, Vec::>::new()); + let taken = put(&app, Some(NS), &key(0xC1), &at_cap).await; + assert!( + taken.is_success(), + "a value at the limit was refused: {taken}" + ); + assert_eq!(get(&app, &key(0xC1)).await.1, vec![at_cap.clone()]); + + let one = |value: &[u8]| vec![(NS.as_bytes().to_vec(), [0xC2u8; 32], value.to_vec())]; + assert_eq!( + put_batch(&app, one(&over)).await, + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!(get(&app, &key(0xC2)).await.1, Vec::>::new()); + let taken = put_batch(&app, one(&at_cap)).await; + assert!( + taken.is_success(), + "a batched value at the limit was refused: {taken}" + ); + assert_eq!(get(&app, &key(0xC2)).await.1, vec![at_cap]); +} From 2d1b010d85719a1d1be2a70432b3b3d4d7ae0cb0 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:00:15 -0400 Subject: [PATCH 28/31] fix(sofi): the exercise carries the trader's signed C_q, so a withheld pair can be registered from it (12e) SoFi Amendment S20 makes C_q trader-signed, so no relayer can author it. A trader that wrote its exercise at a vault's key and withheld its position pair held that key for good: its F could never register, rung 0 waited, and every later trade at the vault waited behind it. - SofiExercise carries the trader's signed C_q (class 0x0062) after F. - recognize_exercise requires F to prove P's device from its own bytes, and the carried claim to be recognized exactly as K_root(q) recognizes it (root_claim_naming) with the body derive(P, F). An exercise failing either is nothing at the key. - The install returns the signed C_q it wrote, and the exercise carries those bytes. - sofi_relay::relay_exercise registers the fulfillment whose exercise holds a vault key from the exercise's bytes alone, and carries the exercise. relay_fulfillment carries the pair with the claim K_root(q) holds, or the one its exercise carries. relay_position_pair had no caller and is gone. - The cell value cap rises from 256 KiB to 320 KiB (route_chain::MAX_VALUE_LEN, the proto bounds, MAX_EXERCISE_BYTES): a two-leg exercise measured 218,735 bytes before C_q, about 269 KB with it. STORAGE agreed; the node's cap follows MAX_ENTRY_BYTES. Tests: node test an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes; unit tests an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f and an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing. Mutations, each red: the relay ignoring the exercise's claim; the body comparison dropped; the claim's verification dropped; the device check dropped. --- .../dsm/src/route_chain.rs | 2 +- .../dsm/src/sofi/exercise.rs | 198 +++++++++++++++- .../dsm/src/sofi/wire/mod.rs | 25 +- .../dsm/src/sofi/wire/objects.rs | 35 ++- .../dsm_sdk/src/handlers/node_e2e_tests.rs | 219 +++++++++++++++++- .../dsm_sdk/src/sdk/sofi_advance.rs | 12 +- .../dsm_sdk/src/sdk/sofi_exercise.rs | 13 +- .../dsm_sdk/src/sdk/sofi_register.rs | 27 ++- .../dsm_sdk/src/sdk/sofi_relay.rs | 210 +++++++++++------ proto/dsm_app.proto | 6 +- 10 files changed, 628 insertions(+), 119 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs b/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs index 44bbd941b..ad05b5afc 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/route_chain.rs @@ -43,7 +43,7 @@ pub const MAX_MEMBER_ID_LEN: usize = 128; /// Largest value a route entry may carry (the proto bound): the largest /// object a cell holds, such as a SoFi exercise (`MAX_EXERCISE_BYTES`). -pub const MAX_VALUE_LEN: usize = 262_144; +pub const MAX_VALUE_LEN: usize = 327_680; /// A bound on everything an entry adds around its value: the cell, the seat, /// the position, and up to four chain slots, each an arrival record with diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs index 70183d59b..d8109f987 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/exercise.rs @@ -4,8 +4,9 @@ //! successor key. //! //! The value written to each successor key of a route is one canonical -//! object, [`SofiExercise`], carrying the signed envelope of `F`, the signed -//! envelope of `P`, `P(E)`, every `G_j` and every closure object. At the key +//! object, [`SofiExercise`], carrying the signed envelope of `F`, the +//! trader's signed `C_q`, the signed envelope of `P`, `P(E)`, every `G_j` and +//! every closure object. At the key //! `K^(a)` of vault `v` at parent `R_n`, the value that counts is the first //! exercise at the leader whose `F` names `(v, a)` in its attempts and whose //! `P` names `(v, R_n)` in its legs — everything else at the key counts as @@ -18,6 +19,8 @@ //! is not validation: whether the route it carries realizes is the ladder's //! question (rebuild step R12), answered from the same bytes. +use crate::economic::claim_envelope::RegisteredEconomicClaim; +use crate::economic::register::{economic_root_register_key, root_claim_naming}; use crate::route_chain::{ check_completion_proof, completion_proof, evaluate, CellError, CellEvidence, CellFact, CellReading, CompletionProof, Missing, ProofRefusal, RoutedCell, @@ -27,6 +30,7 @@ use super::derive; use super::publication::{ recognize_policy_fulfillment, recognize_precommit, recognize_fulfillment, Signed, }; +use super::registration::fulfillment_proves_the_device; use super::wire::{ DlvPolicyFulfillmentBody, SettlementPreimage, SofiExercise, TraderFulfillmentBody, TraderPrecommitBody, ValidationRef, @@ -35,14 +39,16 @@ use super::wire::{ type D32 = [u8; 32]; /// An exercise whose bytes rebuilt into the objects it carries, bound to one -/// another: `F` is `P`'s, `P(E)` recomputes `P`'s `E`, the witnesses are the -/// set `F` commits, and the closure carries one object per reference in -/// `𝒞_E^pre`. [`recognize_exercise`] is its only constructor and the fields -/// are read-only, so every one of these bindings holds for every value of -/// the type. +/// another: `F` is `P`'s and proves `P`'s device, the signed `C_q` is the +/// claim of this `P` and `F` and proves its own authority, `P(E)` recomputes +/// `P`'s `E`, the witnesses are the set `F` commits, and the closure carries +/// one object per reference in `𝒞_E^pre`. [`recognize_exercise`] is its only +/// constructor and the fields are read-only, so every one of these bindings +/// holds for every value of the type. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RecognizedExercise { fulfillment: Signed, + resolution_claim: Vec, precommit: Signed, preimage: SettlementPreimage, witnesses: Vec, @@ -56,6 +62,13 @@ impl RecognizedExercise { &self.fulfillment } + /// The trader's signed `C_q`, exactly as the trader signed it: what a + /// relayer writes at `K_root(q)`, since only the trader can sign it (SoFi + /// Amendment S20). Its body is `derive::resolution_claim(P, F)`. + pub fn resolution_claim(&self) -> &[u8] { + &self.resolution_claim + } + /// `P`, signed. pub fn precommit(&self) -> &Signed { &self.precommit @@ -115,6 +128,30 @@ pub fn recognize_exercise(bytes: &[u8]) -> Option { { return None; } + // F proves P's device from its own bytes (SoFi Amendment S20): its key, + // with the AttA it carries, derives the DevID P names. Any other F can + // never hold the trader's K_ful(q), so its exercise holds no vault key. + if !fulfillment_proves_the_device(&fulfillment.body, precommit.body.device_id()) { + return None; + } + // The trader's signed C_q (S20), recognized exactly as K_root(q) would + // recognize it: a claim that proves its own authority there, the + // signature verifying under the key it carries and that key with its + // AttA deriving the device it names, and whose body is the C_q of this P + // and F. That device is P's, as F's key with F's AttA derives it, and one + // DevID has one (key, AttA), so C_q is signed under the key F is. + // Whatever holds a vault key therefore carries everything a relayer needs + // to register its F, and a trader that withholds its pair cannot hold the + // key forever. + let own_claim = derive::resolution_claim(&precommit.body, &fulfillment.body); + let k_root = + economic_root_register_key(&own_claim.genesis, &own_claim.device_id, own_claim.position); + if !matches!( + root_claim_naming(exercise.resolution_claim(), &k_root), + Some(RegisteredEconomicClaim::ConditionalSofi(held)) if held == own_claim + ) { + return None; + } let preimage = SettlementPreimage::decode(exercise.preimage()).ok()?; let e = derive::recompute_e(&preimage).ok()?; if e != *precommit.body.external_commitment() { @@ -158,6 +195,7 @@ pub fn recognize_exercise(bytes: &[u8]) -> Option { } Some(RecognizedExercise { fulfillment, + resolution_claim: exercise.resolution_claim().to_vec(), precommit, preimage, witnesses, @@ -359,7 +397,7 @@ pub(crate) mod fixtures { use crate::sofi::conformance::derive_policy_fulfillments; use crate::sofi::derive::precommit_id; use crate::sofi::publication::Publication; - use crate::sofi::validation::fixtures::{trader_keys, Fixture}; + use crate::sofi::validation::fixtures::{signed_c_q, trader_keys, Fixture}; use crate::sofi::wire::AttemptEntry; /// The trader's key, as every fixture `P` commits it. @@ -379,9 +417,9 @@ pub(crate) mod fixtures { pub(crate) fulfillment: TraderFulfillmentBody, } - /// One operation as an exercise: F over `attempts`, the canonical - /// witnesses over the shadows P(E) commits, and the parent claim its - /// closure references. + /// One operation as an exercise: F over `attempts`, the trader's signed + /// `C_q` of `P` and `F`, the canonical witnesses over the shadows P(E) + /// commits, and the parent claim its closure references. pub(crate) fn exercise(f: &Fixture, attempts: &[u64]) -> Built { let p = &f.precommit; let canonical = derive::canonical_legs(&f.preimage).unwrap(); @@ -426,6 +464,7 @@ pub(crate) mod fixtures { } .object_bytes() .unwrap(), + signed_c_q(derive::resolution_claim(p, &fb)), Publication::Precommit { body: p, signature: &signed(derive::precommit_signing_digest(p)), @@ -456,8 +495,11 @@ mod tests { use super::fixtures::*; use super::*; use crate::ccb::sigalg::SPHINCS_PLUS_SPX256F as ALG; + use crate::crypto::sphincs::{generate_sphincs_keypair, sphincs_sign}; use crate::sofi::publication::Publication; - use crate::sofi::validation::fixtures::swap_fixture_n; + use crate::sofi::registration::fulfillment_proves_the_device; + use crate::sofi::validation::fixtures::{signed_c_q, swap_fixture_n, TRADER_ATT_A}; + use crate::sofi::wire::SignedSofiResolutionClaim; use crate::route_chain::fixtures::{committed_set, committed_set_id, Cell}; use crate::route_chain::{CellFact, ChainState, ROUTE_LEN}; @@ -516,6 +558,7 @@ mod tests { // F of another P. let bent = SofiExercise::new( ox.fulfillment().to_vec(), + ox.resolution_claim().to_vec(), x.precommit().to_vec(), x.preimage().to_vec(), x.witnesses().to_vec(), @@ -526,6 +569,7 @@ mod tests { // A preimage that is not this P's. let bent = SofiExercise::new( x.fulfillment().to_vec(), + x.resolution_claim().to_vec(), x.precommit().to_vec(), other.preimage.encode().unwrap(), x.witnesses().to_vec(), @@ -538,6 +582,7 @@ mod tests { ws.swap(0, 1); let bent = SofiExercise::new( x.fulfillment().to_vec(), + x.resolution_claim().to_vec(), x.precommit().to_vec(), x.preimage().to_vec(), ws, @@ -554,6 +599,7 @@ mod tests { ws[0] = wrong_shadow.encode(); let bent = SofiExercise::new( x.fulfillment().to_vec(), + x.resolution_claim().to_vec(), x.precommit().to_vec(), x.preimage().to_vec(), ws, @@ -582,6 +628,7 @@ mod tests { } .object_bytes() .unwrap(), + honest.exercise.resolution_claim().to_vec(), Publication::Precommit { body: p, signature: p_sig, @@ -655,6 +702,132 @@ mod tests { ); } + /// SoFi Amendment S20: the exercise carries the trader's signed `C_q` of + /// its own `P` and `F`, so a relayer can register `F` from the exercise's + /// bytes alone. An exercise counts only when that claim is there, is the + /// claim `P` and `F` derive, and proves its own authority. Each bent + /// alone is nothing. + #[test] + fn an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f() { + let f = swap_fixture_n(2); + let honest = exercise(&f, &[0, 1]); + let (p, fb) = (&honest.precommit, &honest.fulfillment); + let x = &honest.exercise; + let with = |claim: Vec| { + SofiExercise::new( + x.fulfillment().to_vec(), + claim, + x.precommit().to_vec(), + x.preimage().to_vec(), + x.witnesses().to_vec(), + x.closure().to_vec(), + ) + .unwrap() + .encode() + }; + let own = derive::resolution_claim(p, fb); + let recognized = recognize_exercise(&x.encode()).unwrap(); + assert_eq!( + recognized.resolution_claim(), + x.resolution_claim(), + "the claim a relayer carries is the trader's bytes exactly" + ); + assert_eq!( + *SignedSofiResolutionClaim::decode(recognized.resolution_claim()) + .unwrap() + .claim(), + own + ); + + // No signed claim: bytes that are none, and the bare C_q. + assert!(recognize_exercise(&with(b"not a claim".to_vec())).is_none()); + assert!(recognize_exercise(&with(own.encode())).is_none()); + + // The trader's signed claim of another fulfillment of this P: it + // proves its own authority, and it is not this F's claim. Mutation: + // drop the body comparison. + let other_f = exercise(&f, &[1, 0]).fulfillment; + let other = derive::resolution_claim(p, &other_f); + assert_ne!(other, own); + assert!(recognize_exercise(&with(signed_c_q(other))).is_none()); + + // This F's claim under the trader's signature of another claim: the + // signature does not verify over this one. Mutation: drop the + // verification. + let misplaced = SignedSofiResolutionClaim::new( + own, + ALG, + key(), + TRADER_ATT_A, + &signed(derive::resolution_claim_signing_digest( + &other, + ALG, + key(), + &TRADER_ATT_A, + )), + ) + .unwrap(); + assert!(recognize_exercise(&with(misplaced.encode())).is_none()); + + // This F's claim signed by another device: the signature verifies + // under the key it carries, and that key does not derive the device + // the claim names. + let (other_pk, other_sk) = generate_sphincs_keypair().unwrap(); + let foreign = SignedSofiResolutionClaim::new( + own, + ALG, + &other_pk, + TRADER_ATT_A, + &sphincs_sign( + &other_sk, + &derive::resolution_claim_signing_digest(&own, ALG, &other_pk, &TRADER_ATT_A), + ) + .unwrap(), + ) + .unwrap(); + assert!(recognize_exercise(&with(foreign.encode())).is_none()); + } + + /// SoFi Amendment S20: `F` proves `P`'s device from its own bytes, its key + /// with the `AttA` it carries deriving the DevID `P` names. An `F` under + /// the trader's key with another `AttA` verifies, and so does the claim + /// the trader signs for it under its own `AttA`; but that `F` can never + /// hold the trader's `K_ful(q)`, so an exercise carrying it must hold no + /// vault key. Mutation: drop the device check. + #[test] + fn an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing() { + let f = swap_fixture_n(2); + let honest = exercise(&f, &[0, 1]); + let (p, fb) = (&honest.precommit, &honest.fulfillment); + let x = &honest.exercise; + let stray = TraderFulfillmentBody::new( + *fb.precommit_id(), + fb.policy_fulfillment_set().to_vec(), + fb.attempts().to_vec(), + fb.position(), + ALG, + key(), + [0x5A; 32], + ) + .unwrap(); + assert!(!fulfillment_proves_the_device(&stray, p.device_id())); + let bent = SofiExercise::new( + Publication::Fulfillment { + body: &stray, + signature: &signed(derive::fulfillment_signing_digest(&stray)), + } + .object_bytes() + .unwrap(), + signed_c_q(derive::resolution_claim(p, &stray)), + x.precommit().to_vec(), + x.preimage().to_vec(), + x.witnesses().to_vec(), + x.closure().to_vec(), + ) + .unwrap(); + assert!(recognize_exercise(&bent.encode()).is_none()); + } + /// An exercise final at its attempt cell has a completion proof built /// from the reads, and the proof checks against them. #[test] @@ -719,6 +892,7 @@ mod tests { } .object_bytes() .unwrap(), + built.exercise.resolution_claim().to_vec(), Publication::Precommit { body: &built.precommit, signature: &junk, diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs index 58aa8dfec..5fd0707db 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs @@ -98,12 +98,14 @@ //! because the leaf's own envelope is inside the preimage. //! //! `0x005E SofiExercise` (the value at a successor key, Section 17.5): 1 -//! `fulfillment` var bytes (a `0x005C` envelope over `F`) · 2 `precommit` var -//! bytes (a `0x005C` envelope over `P`) · 3 `preimage` var bytes (`0x0059`) · -//! 4 `witnesses` `seq` of `0x0038`, `1..=CANONICAL_MAX_LEGS`, in -//! P's leg order · 5 `closure` `seq`, `0..=MAX_CLOSURE_REFS`, in -//! `𝒞_E^pre` reference order. The whole object is bounded by -//! `MAX_EXERCISE_BYTES`, a member's cell value cap. +//! `fulfillment` var bytes (a `0x005C` envelope over `F`) · 2 +//! `resolution_claim` var bytes (the trader's signed `C_q`, `0x0062`, SoFi +//! Amendment S20) · 3 `precommit` var bytes (a `0x005C` envelope over `P`) · +//! 4 `preimage` var bytes (`0x0059`) · 5 `witnesses` `seq` of +//! `0x0038`, `1..=CANONICAL_MAX_LEGS`, in P's leg order · 6 `closure` +//! `seq`, `0..=MAX_CLOSURE_REFS`, in `𝒞_E^pre` reference order. +//! The whole object is bounded by `MAX_EXERCISE_BYTES`, a member's cell value +//! cap. //! //! `0x004D TraderRelationshipLeaf` (the `R_econ` leaf state, at `k_{T,v}`): //! 1 `vault_id` digest32 · 2 `leaf` digest32 (`hʲ`). @@ -216,9 +218,14 @@ pub const VAULT_STATUS_RETIRED: u16 = 0x0002; /// Distinct `ValidationRef` values in one closure; duplicates are malformed. pub const MAX_CLOSURE_REFS: usize = 64; -/// The largest exercise a member's cell takes (`MAX_CELL_VALUE_BYTES` at the -/// node). Two SPHINCS+ envelopes and a beta preimage sit well inside it. -pub const MAX_EXERCISE_BYTES: usize = 256 * 1024; +/// The largest exercise a member's cell takes (`route_chain::MAX_VALUE_LEN`, +/// which the node's cell limit follows). An exercise carries four SPHINCS+ +/// signatures, about 50 KB each: `F`'s, `P`'s, the trader's signed `C_q` +/// (SoFi Amendment S20) and the parent claim its closure holds. With a +/// two-leg beta preimage that is about 269 KB (a two-leg route measured +/// 218,735 bytes before `C_q`), inside this bound with room for seven more +/// core entries. +pub const MAX_EXERCISE_BYTES: usize = 320 * 1024; /// Canonical encoded bytes of one referenced object (transport excluded). pub const MAX_CLOSURE_OBJECT_BYTES: usize = 256 * 1024; /// Authorization envelopes one candidate may require. diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs index fc12310f6..d98b107d5 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs @@ -903,6 +903,14 @@ fn read_var_bytes(c: &mut Cursor<'_>, max: usize) -> Result, DecodeError Ok(c.take(n)?.to_vec()) } +/// The encoding twin of [`read_var_bytes`], for a part its type already +/// bounds: `new` and `decode` both refuse a part over its maximum, which is +/// far below `u32::MAX`, so the length always fits its prefix. +fn push_part(out: &mut Vec, part: &[u8]) { + push_u32(out, part.len() as u32); + out.extend_from_slice(part); +} + // ── ValidationRef: 0x003D..=0x0040 ───────────────────────────────────────── // ── 0x005E SofiExercise ───────────────────────────────────────────────────── @@ -912,10 +920,14 @@ fn read_var_bytes(c: &mut Cursor<'_>, max: usize) -> Result, DecodeError /// and `P` in the envelopes their signatures travel in, `P(E)`, the witnesses /// and the closure objects — and everything is bound to `F` by hashed /// preimages. It names its own attempt through `F` and its own parents -/// through `P`, so it cannot count at another key. +/// through `P`, so it cannot count at another key. It also carries the +/// trader's signed `C_q` (SoFi Amendment S20), so whatever holds a vault key +/// carries everything needed to register its own `F`: no relayer can sign +/// `C_q`, and a trader that withholds its pair cannot hold the key forever. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SofiExercise { fulfillment: Vec, + resolution_claim: Vec, precommit: Vec, preimage: Vec, witnesses: Vec>, @@ -932,6 +944,7 @@ impl SofiExercise { /// not construction. pub fn new( fulfillment: Vec, + resolution_claim: Vec, precommit: Vec, preimage: Vec, witnesses: Vec>, @@ -939,6 +952,7 @@ impl SofiExercise { ) -> Result { for (field, bytes) in [ ("fulfillment", &fulfillment), + ("resolution claim", &resolution_claim), ("precommit", &precommit), ("preimage", &preimage), ] { @@ -965,6 +979,7 @@ impl SofiExercise { } let v = Self { fulfillment, + resolution_claim, precommit, preimage, witnesses, @@ -985,6 +1000,11 @@ impl SofiExercise { &self.fulfillment } + /// The trader's signed `C_q`, as `K_root(q)` takes it (class `0x0062`). + pub fn resolution_claim(&self) -> &[u8] { + &self.resolution_claim + } + pub fn precommit(&self) -> &[u8] { &self.precommit } @@ -1004,16 +1024,17 @@ impl SofiExercise { pub fn encode(&self) -> Vec { let mut out = Vec::new(); push_env(&mut out, class::SOFI_EXERCISE); - let _ = push_bytes(&mut out, &self.fulfillment); - let _ = push_bytes(&mut out, &self.precommit); - let _ = push_bytes(&mut out, &self.preimage); + push_part(&mut out, &self.fulfillment); + push_part(&mut out, &self.resolution_claim); + push_part(&mut out, &self.precommit); + push_part(&mut out, &self.preimage); push_u32(&mut out, self.witnesses.len() as u32); for w in &self.witnesses { - let _ = push_bytes(&mut out, w); + push_part(&mut out, w); } push_u32(&mut out, self.closure.len() as u32); for o in &self.closure { - let _ = push_bytes(&mut out, o); + push_part(&mut out, o); } out } @@ -1028,6 +1049,7 @@ impl SofiExercise { let mut c = Cursor { b: bytes, i: 0 }; c.envelope(class::SOFI_EXERCISE, SCHEMA_V1)?; let fulfillment = read_var_bytes(&mut c, MAX_EXERCISE_PART_BYTES)?; + let resolution_claim = read_var_bytes(&mut c, MAX_EXERCISE_PART_BYTES)?; let precommit = read_var_bytes(&mut c, MAX_EXERCISE_PART_BYTES)?; let preimage = read_var_bytes(&mut c, MAX_EXERCISE_PART_BYTES)?; let n = read_count(&mut c, "witnesses", 1, CANONICAL_MAX_LEGS)?; @@ -1042,6 +1064,7 @@ impl SofiExercise { } let v = Self { fulfillment, + resolution_claim, precommit, preimage, witnesses, 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 6260fe15d..73408757f 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 @@ -23,8 +23,8 @@ use dsm::sofi::resolution::WalkOutcome; use dsm::sofi::storage::Resolved; use dsm::sofi::resolve::WALK_BUDGET; use dsm::sofi::wire::{ - AttemptEntry, DlvPolicyFulfillmentBody, PrecommitLeg, SofiExercise, TraderFulfillmentBody, - TraderPrecommitBody, + AttemptEntry, DlvPolicyFulfillmentBody, PrecommitLeg, SignedSofiResolutionClaim, SofiExercise, + TraderFulfillmentBody, TraderPrecommitBody, }; use dsm::types::proto as generated; use generated::envelope::Payload; @@ -791,6 +791,22 @@ fn reaimed( .expect("a well-formed F"); let fulfillment_signature = sign(derive::fulfillment_signing_digest(&fulfillment)); let precommit_signature = sign(derive::precommit_signing_digest(&precommit)); + // The claim of the re-aimed pair, signed as `C_q` is (SoFi Amendment S20). + let claim = derive::resolution_claim(&precommit, &fulfillment); + let claim_signature = sign(derive::resolution_claim_signing_digest( + &claim, + fulfillment.signature_alg(), + fulfillment.claimant_public_key(), + fulfillment.claimant_att_a(), + )); + let signed_claim = SignedSofiResolutionClaim::new( + claim, + fulfillment.signature_alg(), + fulfillment.claimant_public_key(), + *fulfillment.claimant_att_a(), + &claim_signature, + ) + .expect("a signed C_q"); SofiExercise::new( Publication::Fulfillment { body: &fulfillment, @@ -798,6 +814,7 @@ fn reaimed( } .object_bytes() .expect("an F envelope"), + signed_claim.encode(), Publication::Precommit { body: &precommit, signature: &precommit_signature, @@ -1186,6 +1203,22 @@ async fn an_exercise_carrying_other_bytes_than_its_closure_holds_no_key() { derive_policy_fulfillments(&precommit.body, &shadows).expect("the canonical witnesses"); let references = preimage.settlement().closure().refs().len(); assert!(references > 0, "the closure has objects to replace"); + // B's own signed C_q of the pair: what K_root(q) holds, signed again by B + // (SPHINCS+ signing here is deterministic, so the bytes are the same). + let signed_claim = { + p.b.enter(); + 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( + derive::resolution_claim(&precommit.body, &fulfillment.body), + fulfillment.body.signature_alg(), + &pk, + att_a, + &sk, + ) + .expect("B signs its own C_q") + .encode() + }; let other_bytes = SofiExercise::new( Publication::Fulfillment { body: &fulfillment.body, @@ -1193,6 +1226,7 @@ async fn an_exercise_carrying_other_bytes_than_its_closure_holds_no_key() { } .object_bytes() .expect("an F envelope"), + signed_claim, Publication::Precommit { body: &precommit.body, signature: &precommit.signature, @@ -1239,6 +1273,187 @@ async fn an_exercise_carrying_other_bytes_than_its_closure_holds_no_key() { head_agrees_with_admitted_root(&p.b, &[m.era, m.tkn]); } +/// SoFi Amendment S20, MR-SOFI-0362 (pre-audit 12e): a trader that writes +/// its exercise at a vault's key and withholds its position pair cannot keep +/// the key. Only the trader can sign `C_q`, so before the exercise carried it +/// no relayer could register the trader's `F`: the key was held by an +/// exercise neither registered nor lost, rung 0 waited on it, and every later +/// trade at the vault waited behind it. +/// +/// B trades with its pair's leader refusing the pair, so nothing of the pair +/// is written; then B, as a hostile trader would, writes its exercise at the +/// vault's first key anyway. The key is held and the pair is empty. A, with +/// nothing from B, relays from the key: the exercise's own bytes register +/// B's `F`, the vault's parent is consumed at that key, and B's position +/// realizes. Mutation: the relay ignores the exercise's `C_q` and only +/// carries one `K_root(q)` already holds. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes() { + let p = Pair::boot(500, 200).await; + let m = open_market(&p).await; + let set = canonical_set(NETWORK).expect("the pinned set"); + let realized = generated::SofiPositionState::Realized as i32; + let exhausted = generated::SofiPositionState::RetriesExhausted 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()); + let (own, parents) = standing_of(&p.b); + let ctx = VerifierContext::new(&set, Some(own), parents.as_ref()).expect("a verifier"); + let r0 = ctx + .verifier() + .chain(&m.vault_id) + .expect("the vault's chain") + .roots()[0]; + + // B trades; its pair's leader refuses the pair, so the trade stops + // before anything is written at the pair or the vault. + 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)); + + // B writes its exercise, carrying its signed C_q, at the vault's key, and + // never writes its pair. + 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 preimage = match fetch_preimage(&set, precommit.body.external_commitment()) + .await + .expect("read") + { + Resolved::Kept(preimage) => preimage, + other => panic!("P(E) is stored under its locator: {other:?}"), + }; + let own_objects = + crate::sdk::sofi_advance::own_closure_objects(&set, &precommit.body, &preimage) + .await + .expect("B's own closure objects"); + let request = crate::sdk::sofi_register::InstallRequest { + precommit: &precommit.body, + precommit_signature: &precommit.signature, + preimage: &preimage, + fulfillment: &fulfillment.body, + fulfillment_signature: &fulfillment.signature, + own_objects: &own_objects, + }; + let evidence = match ctx + .verifier() + .acquire_conformance_evidence(&request.objects()) + .expect("acquire") + { + dsm::sofi::resolve::Acquired::Complete(evidence) => evidence, + dsm::sofi::resolve::Acquired::Exhausted(missing) => { + panic!("the evidence is published: {missing:?}") + } + }; + 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( + derive::resolution_claim(&precommit.body, &fulfillment.body), + fulfillment.body.signature_alg(), + &pk, + att_a, + &sk, + ) + .expect("B signs its own C_q") + .encode() + }; + let exercise = crate::sdk::sofi_exercise::build_exercise(&request, &claim, &evidence) + .expect("B's exercise"); + let recognized = recognize_exercise(&exercise.encode()).expect("it is B's exercise"); + let legs = write_exercise(&set, &exercise, &recognized) + .await + .expect("written along its route"); + assert!(legs.iter().all(|leg| leg.reached_leader)); + p.nodes.accept_cell_writes(&pair_leader).await; + + // The key is held by B's exercise, B's pair is empty, and the walk stops + // at the key: the fulfillment is neither registered nor lost. + p.a.enter(); + let (own_a, parents_a) = standing_of(&p.a); + let read_vault = |label: &str| { + let ctx = VerifierContext::new(&set, Some(own_a), parents_a.as_ref()).expect("a verifier"); + let verifier = ctx.verifier(); + let registration = verifier + .read_registration(&p.b.genesis, &p.b.device_id, q, &root) + .expect("read") + .expect("decided"); + let chains = BTreeMap::from([( + m.vault_id, + verifier.chain(&m.vault_id).expect("the vault's chain"), + )]); + let walked = verifier + .walk_parent(&chains, &m.vault_id, &r0, 0, WALK_BUDGET) + .expect("the walk"); + eprintln!("[{label}] {:?} / {:?}", registration.registration(), walked.outcome); + (registration.into_registration(), walked.outcome) + }; + let (before, walked) = read_vault("withheld"); + assert!( + matches!(before, Registration::Unresolved), + "B wrote no pair: {before:?}" + ); + assert_eq!(walked, WalkOutcome::Unresolved { attempt: 0 }); + + // A relays from the key, with nothing from B. + let relayed = crate::sdk::sofi_relay::relay_exercise(&set, &m.vault_id, &r0, 0) + .await + .expect("the exercise carries everything its pair needs"); + assert_eq!(relayed.fulfillment_id, fulfillment_id); + assert_eq!(relayed.position, q); + assert!(relayed.pair.iter().all(|report| report.reached_leader())); + let (after, walked) = read_vault("relayed"); + assert!( + matches!(&after, Registration::Registered(signed) if signed.body == fulfillment.body), + "B's F registers from its exercise: {after:?}" + ); + assert_eq!(walked, WalkOutcome::Consumed { attempt: 0 }); + + // B's position realizes on what the relayer wrote. + assert_eq!(resolve(&p).await, (q, realized)); + let out = dsm::dlv::route_commit::constant_product_output(10, 100, 1_000, 30) + .expect("the vault prices the trade"); + assert_eq!( + balance(&p.b, &m.era), + crate::economic_fixtures::whole_era(200) - 10 + ); + assert_eq!(balance(&p.b, &m.tkn), out); + head_agrees_with_admitted_root(&p.b, &[m.era, m.tkn]); +} + /// 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/sdk/sofi_advance.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs index 73e000c2e..3a2ac69b7 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs @@ -326,12 +326,14 @@ async fn install_pair( } /// Stage 8: the exercise, built over the evidence its conformance was -/// decided on, at every leg's key (R11). Written when the leader of every -/// leg's key answered with its link. +/// decided on and carrying the signed `C_q` the install wrote, at every +/// leg's key (R11). Written when the leader of every leg's key answered with +/// its link. async fn exercise_legs( ctx: &VerifierContext<'_>, set: &StorageSet, request: &FulfillRequest<'_>, + resolution_claim: &[u8], ) -> Result, NotTaken>, DsmError> { let install = install_request(request); let evidence = match ctx @@ -342,7 +344,7 @@ async fn exercise_legs( Acquired::Complete(evidence) => evidence, Acquired::Exhausted(missing) => return Ok(Err(NotTaken::Evidence(missing))), }; - let exercise = build_exercise(&install, &evidence)?; + let exercise = build_exercise(&install, resolution_claim, &evidence)?; let recognized = dsm::sofi::exercise::recognize_exercise(&exercise.encode()) .ok_or_else(|| refuse("the exercise built here does not recognize"))?; let writes = write_exercise(set, &exercise, &recognized).await?; @@ -528,7 +530,7 @@ async fn pending_objects( /// want — only the owning device may touch its own admission, fence, lineage /// and leaf cache (owner ruling, §44.4). What any device may do is carry /// already-signed material to storage: `sofi_relay::{relay_fulfillment, -/// relay_position_pair}`, which hold no `CoreSDK` and write nothing local. +/// relay_exercise}`, which hold no `CoreSDK` and write nothing local. pub async fn complete_pending_fulfillment( core: &CoreSDK, set: &StorageSet, @@ -564,7 +566,7 @@ pub async fn complete_pending_fulfillment( Ok(installed) => installed, Err(why) => return not_taken(why), }; - let exercise = match exercise_legs(&ctx, set, &request).await? { + let exercise = match exercise_legs(&ctx, set, &request, &installed.resolution_claim).await? { Ok(exercise) => exercise, Err(why) => return not_taken(why), }; diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_exercise.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_exercise.rs index 8f2748113..ed525960c 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_exercise.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_exercise.rs @@ -31,13 +31,15 @@ fn err(what: &str, e: impl core::fmt::Display) -> DsmError { } /// The exercise of a request, over the evidence its conformance was decided -/// on: `F` and `P` in their envelopes, `P(E)`, the canonical witnesses -/// derived from `P` and the shadows `P(E)` commits, and every closure object -/// in reference order. A closure object the evidence does not hold is an -/// error here — the exercise carries the closure, so it cannot be built -/// without it. +/// on: `F` and `P` in their envelopes, the trader's signed `C_q` exactly as +/// the install wrote it at `K_root(q)` (SoFi Amendment S20), `P(E)`, the +/// canonical witnesses derived from `P` and the shadows `P(E)` commits, and +/// every closure object in reference order. A closure object the evidence +/// does not hold is an error here — the exercise carries the closure, so it +/// cannot be built without it. pub fn build_exercise( request: &InstallRequest<'_>, + resolution_claim: &[u8], evidence: &ConformanceEvidence, ) -> Result { let canonical = @@ -74,6 +76,7 @@ pub fn build_exercise( } .object_bytes() .map_err(|e| err("fulfillment envelope", e))?, + resolution_claim.to_vec(), Publication::Precommit { body: request.precommit, signature: request.precommit_signature, diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs index ee3f1e832..23a1cef8f 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs @@ -4,11 +4,13 @@ //! its position — the two position cells, written together. //! //! The writer puts the signed envelope of `F` at `K_ful(q)` and -//! `C_q = SofiResolutionClaim(G, DevID, q, FulfillmentId, R_realize, R_void)` -//! at `K_root(q)`: both in one transaction at the leader of `s(q)`, then the -//! same bytes at each later seat of the route (storage spec §9). `C_q` is -//! computed from the verified `P` and `F` and never caller-supplied, so no -//! claim that disagrees with `F` can be `F`'s claim. A member stores bytes; +//! `C_q = SofiResolutionClaim(G, DevID, q, FulfillmentId, R_realize, R_void)`, +//! signed by the trader (SoFi Amendment S20), at `K_root(q)`: both in one +//! transaction at the leader of `s(q)`, then the same bytes at each later +//! seat of the route (storage spec §9). `C_q` is computed from the verified +//! `P` and `F` and never caller-supplied, so no claim that disagrees with `F` +//! can be `F`'s claim; the exercise carries the same signed bytes. A member +//! stores bytes; //! it establishes nothing. Whether `F` registered is Core's conclusion from //! the cells' route chains (`FulfillmentRegistered`, rebuild step R10), read //! by the verifier (`dsm::sofi::resolve::Verifier::read_registration`), never @@ -146,13 +148,16 @@ pub fn cells_of( ) } -/// What the install wrote: the pair's cells, and what the write produced at -/// each route position — `K_ful(q)` first, then `K_root(q)`. Registration is -/// not among these: the verifier reads it from the cells. +/// What the install wrote: the pair's cells, what the write produced at +/// each route position — `K_ful(q)` first, then `K_root(q)` — and the +/// trader's signed `C_q` it wrote at `K_root(q)`, which the exercise carries +/// (SoFi Amendment S20). Registration is not among these: the verifier reads +/// it from the cells. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Installed { pub cells: PositionCells, pub reports: [WriteReport; 2], + pub resolution_claim: Vec, } /// Install `F` at its position: conformance first — over evidence the @@ -206,5 +211,9 @@ pub async fn install_fulfillment( if !(ful.reached_leader() && root.reached_leader()) { return Err(InstallError::LeaderUnreached); } - Ok(Installed { cells, reports }) + Ok(Installed { + cells, + reports, + resolution_claim: claim_bytes, + }) } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs index 0092e41cb..f40a34202 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs @@ -10,16 +10,22 @@ //! half — `complete_pending_fulfillment` and `resolve_pending_position` — //! stays in `sofi_advance`. //! -//! A relay creates nothing and decides nothing. `F` is signed by the trader -//! and `C_q` is a function of the two verified objects, so a relayer carrying -//! them adds no authority of its own; the exercise it carries is the value -//! Core reads as holding one of the fulfillment's leg cells, relayed verbatim -//! rather than rebuilt, because the trader's own closure objects are not a -//! relayer's to hold. Every write goes along its cell's route from the +//! A relay creates nothing and decides nothing. `F` and `C_q` are signed by +//! the trader (SoFi Amendment S20), so a relayer carrying them adds no +//! authority of its own and can author neither; the exercise it carries is +//! the value Core reads as holding one of the fulfillment's leg cells, +//! relayed verbatim rather than rebuilt, because the trader's own closure +//! objects are not a relayer's to hold. The exercise carries the trader's +//! signed `C_q` too, so a fulfillment whose exercise holds a vault key can +//! always be registered from that exercise's bytes, whatever its trader +//! withholds. Every write goes along its cell's route from the //! leader (storage spec §9). Nothing here re-gates on conformance: that gate //! is the PRODUCER's discipline before it publishes, not a second opinion at //! every carrier. -use dsm::sofi::exercise::attempt_resolution; +use dsm::economic::claim_envelope::RegisteredEconomicClaim; +use dsm::economic::register::read_root_cell; +use dsm::route_chain::CellReading; +use dsm::sofi::exercise::{attempt_resolution, AttemptCell, RecognizedExercise}; use dsm::sofi::publication::{Publication, Signed}; use dsm::sofi::storage::Resolved; use dsm::sofi::wire::{TraderFulfillmentBody, TraderPrecommitBody}; @@ -72,30 +78,16 @@ async fn objects( Ok((fulfillment, precommit)) } -/// Carry the position pair of a registered-or-not fulfillment along the -/// route of `s(q)`: `F` in the envelope it was published in, and the -/// trader's signed `C_q` exactly as `K_root(q)` holds it. A relayer cannot -/// sign `C_q` (SoFi Amendment S20), so it carries the trader's own bytes: -/// recognized at the cell, and with the body `(P, F)` derive. It establishes -/// no registration — Core derives that from the cells (R10). -pub async fn relay_position_pair( - set: &StorageSet, - fulfillment_id: &D32, -) -> Result { - let (fulfillment, precommit) = objects(set, fulfillment_id).await?; - let pair = carry_pair(set, &fulfillment, &precommit).await?; - Ok(Relayed { - fulfillment_id: *fulfillment_id, - position: fulfillment.body.position(), - pair, - legs: Vec::new(), - }) -} - +/// The trader's signed `C_q` of a position pair, exactly as signed (SoFi +/// Amendment S20): the bytes `K_root(q)` holds when its claim is this +/// fulfillment's, and otherwise the bytes the fulfillment's exercise carries. +/// A relayer never authors it. `K_root(q)` held by another claim loses `F` +/// there, so there is no pair to carry. async fn carry_pair( set: &StorageSet, fulfillment: &Signed, precommit: &Signed, + carried: Option<&[u8]>, ) -> Result<[WriteReport; 2], DsmError> { let cells = cells_of(set, &precommit.body, &fulfillment.body)?; let f_bytes = Publication::Fulfillment { @@ -107,43 +99,44 @@ async fn carry_pair( let derived = dsm::sofi::derive::resolution_claim(&precommit.body, &fulfillment.body); let seats = NodeSeats::new(set)?; let evidence = read_cell(&seats, cells.root().routed()).await; - let claim = match dsm::economic::register::read_root_cell(cells.root(), &evidence) { - Ok(dsm::route_chain::CellReading::Held { - object: dsm::economic::claim_envelope::RegisteredEconomicClaim::ConditionalSofi(held), + let claim = match read_root_cell(cells.root(), &evidence) { + Ok(CellReading::Held { + object: RegisteredEconomicClaim::ConditionalSofi(held), value, .. }) if held == derived => value, - Ok(_) => { + Ok(CellReading::Held { .. }) => { return Err(refuse( - "K_root(q) holds no C_q of this fulfillment signed by its trader; only the \ - trader can write it", + "K_root(q) holds another claim: the fulfillment is lost at its position, and \ + there is no pair to carry", )) } - Err(missing) => { - return Err(refuse(format!( - "K_root(q) is not decided yet, so the trader's C_q is not in hand: {missing:?}" - ))) - } + Ok(CellReading::Open) => carried_claim(carried, "K_root(q) holds no claim yet")?, + Err(missing) => carried_claim( + carried, + &format!("K_root(q) is not decided yet ({missing:?})"), + )?, }; write_recorded_position(set, &cells, &f_bytes, &claim).await } -/// §33: complete a fulfillment whose hops are not all final. -/// -/// The exercise is the value Core reads as holding one of the fulfillment's -/// leg cells, taken from that cell's reads and carried VERBATIM to every leg -/// key the fulfillment names — never rebuilt, because building one needs the -/// trader's own closure objects and those are not a relayer's to hold. If no -/// leg cell is held by this fulfillment's exercise there is nothing to relay, -/// and that is reported rather than papered over: the trader must write it -/// once before any relayer can carry it. -pub async fn relay_fulfillment( - set: &StorageSet, - fulfillment_id: &D32, -) -> Result { - let (fulfillment, precommit) = objects(set, fulfillment_id).await?; - let pair = carry_pair(set, &fulfillment, &precommit).await?; +/// The signed `C_q` an exercise carries, or why there is none to carry. +fn carried_claim(carried: Option<&[u8]>, why: &str) -> Result, DsmError> { + carried.map(<[u8]>::to_vec).ok_or_else(|| { + refuse(format!( + "{why}, and no exercise of this fulfillment is in hand to carry the trader's \ + signed C_q: only the trader can sign it" + )) + }) +} +/// Every leg key a fulfillment names: `K^(a_j)` of `v_j` at the parent `R_j` +/// its `P` names. +fn leg_cells( + set: &StorageSet, + fulfillment: &Signed, + precommit: &Signed, +) -> Result, DsmError> { let mut cells = Vec::new(); for attempt in fulfillment.body.attempts() { let Some(leg) = precommit @@ -161,10 +154,19 @@ pub async fn relay_fulfillment( attempt_cell(set, &leg.vault_id, &leg.parent_root, attempt.attempt)?, )); } + Ok(cells) +} +/// The exercise of `fulfillment` holding one of its leg keys, with the exact +/// bytes Core read as holding it. `None` when no leg key is held by this +/// fulfillment's exercise. +async fn held_exercise( + set: &StorageSet, + cells: &[(D32, D32, u64, AttemptCell)], + fulfillment: &Signed, +) -> Result)>, DsmError> { let seats = NodeSeats::new(set)?; - let mut carried: Option> = None; - for (.., cell) in &cells { + for (.., cell) in cells { let evidence = read_cell(&seats, cell.routed()).await; let read = match attempt_resolution(cell, &evidence) { Ok(read) => read, @@ -177,33 +179,32 @@ pub async fn relay_fulfillment( // they are is this fulfillment's. if let (Some(object), Some(value)) = (read.exercise(), read.value()) { if object.fulfillment().body == fulfillment.body { - carried = Some(value.to_vec()); - break; + return Ok(Some((object.clone(), value.to_vec()))); } } } - let Some(bytes) = carried else { - return Ok(Relayed { - fulfillment_id: *fulfillment_id, - position: fulfillment.body.position(), - pair, - legs: Vec::new(), - }); - }; + Ok(None) +} +/// Carry the exercise `bytes` to every leg key its `F` names. +async fn carry_exercise( + set: &StorageSet, + cells: Vec<(D32, D32, u64, AttemptCell)>, + bytes: &[u8], +) -> Result, DsmError> { let mut legs = Vec::new(); for (vault_id, parent_root, attempt, cell) in cells { // The one exercise names every leg's key, so the same bytes belong at // each of them; Core counts only an exercise naming the key it sits // at, so carrying it where it names nothing would carry nothing. - if dsm::sofi::exercise::exercise_names_key(&bytes, &vault_id, &parent_root, attempt) + if dsm::sofi::exercise::exercise_names_key(bytes, &vault_id, &parent_root, attempt) .is_none() { return Err(refuse( "the exercise found does not name every key its F does", )); } - let write = write_recorded(set, cell.routed(), &bytes).await?; + let write = write_recorded(set, cell.routed(), bytes).await?; legs.push(LegWrite { vault_id, parent_root, @@ -212,6 +213,39 @@ pub async fn relay_fulfillment( reached_leader: write.reached_leader(), }); } + Ok(legs) +} + +/// §33: complete a fulfillment whose hops are not all final. +/// +/// The exercise is the value Core reads as holding one of the fulfillment's +/// leg cells, taken from that cell's reads and carried VERBATIM to every leg +/// key the fulfillment names — never rebuilt, because building one needs the +/// trader's own closure objects and those are not a relayer's to hold. The +/// position pair is carried with the trader's signed `C_q`: the one +/// `K_root(q)` holds, or the one that exercise carries. If no leg cell is +/// held by this fulfillment's exercise only the pair is carried, and only +/// when `K_root(q)` already holds the trader's claim; that is reported +/// rather than papered over. +pub async fn relay_fulfillment( + set: &StorageSet, + fulfillment_id: &D32, +) -> Result { + let (fulfillment, precommit) = objects(set, fulfillment_id).await?; + let cells = leg_cells(set, &fulfillment, &precommit)?; + let held = held_exercise(set, &cells, &fulfillment).await?; + let pair = carry_pair( + set, + &fulfillment, + &precommit, + held.as_ref() + .map(|(exercise, _)| exercise.resolution_claim()), + ) + .await?; + let legs = match held { + Some((_, bytes)) => carry_exercise(set, cells, &bytes).await?, + None => Vec::new(), + }; Ok(Relayed { fulfillment_id: *fulfillment_id, position: fulfillment.body.position(), @@ -219,3 +253,45 @@ pub async fn relay_fulfillment( legs, }) } + +/// Register the fulfillment whose exercise holds `vault_id`'s key +/// `K^(attempt)` at `parent_root`, from that exercise's bytes alone (SoFi +/// Amendment S20), and carry the exercise to every key its `F` names. +/// +/// Everything comes from the exercise: `F` in its envelope, the trader's +/// signed `C_q`, and `P`, whose parent root routes the pair. Nothing is +/// fetched from the trader or by content address, so a trader that wrote its +/// exercise and withheld its pair — or never published `F` on its own — +/// cannot keep the key held: any device that reads the key completes the +/// pair. +pub async fn relay_exercise( + set: &StorageSet, + vault_id: &D32, + parent_root: &D32, + attempt: u64, +) -> Result { + let cell = attempt_cell(set, vault_id, parent_root, attempt)?; + let seats = NodeSeats::new(set)?; + let evidence = read_cell(&seats, cell.routed()).await; + let read = attempt_resolution(&cell, &evidence) + .map_err(|undecided| refuse(format!("the key is not decided yet: {undecided:?}")))?; + let (Some(exercise), Some(bytes)) = (read.exercise(), read.value()) else { + return Err(refuse("no exercise holds the key; there is nothing to relay")); + }; + let (fulfillment, precommit) = (exercise.fulfillment(), exercise.precommit()); + let cells = leg_cells(set, fulfillment, precommit)?; + let pair = carry_pair( + set, + fulfillment, + precommit, + Some(exercise.resolution_claim()), + ) + .await?; + let legs = carry_exercise(set, cells, bytes).await?; + Ok(Relayed { + fulfillment_id: dsm::sofi::derive::fulfillment_id(&fulfillment.body), + position: fulfillment.body.position(), + pair, + legs, + }) +} diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto index 292c53958..a2072ec4b 100644 --- a/proto/dsm_app.proto +++ b/proto/dsm_app.proto @@ -4243,7 +4243,7 @@ message BleConnectionFailed { // Everything a member holds at a key, in arrival order. An empty list is an // asserted absence. The member decodes none of the values. message CellValuesV1 { - repeated bytes values = 1 [(dsm_max_len)=262144]; + repeated bytes values = 1 [(dsm_max_len)=327680]; // One arrival record per value, in the same order (storage spec §14). repeated ArrivalRecordV1 records = 2; } @@ -4271,7 +4271,7 @@ message ArrivalRecordsV1 { message RouteEntryV1 { bytes namespace = 1 [(dsm_max_len)=128]; bytes key = 2 [(dsm_fixed_len)=32]; - bytes value = 3 [(dsm_max_len)=262144]; + bytes value = 3 [(dsm_max_len)=327680]; bytes seat_member_id = 4 [(dsm_max_len)=128]; // the seat at `position`, as the set commits it uint32 position = 5; // 0 = the leader, up to 4 repeated ChainSlotV1 chain = 6; // one slot per earlier position, in route order @@ -4319,7 +4319,7 @@ message CellCommitProofV1 { message CellPutV1 { bytes namespace = 1 [(dsm_max_len)=128]; bytes key = 2 [(dsm_fixed_len)=32]; - bytes value = 3 [(dsm_max_len)=262144]; + bytes value = 3 [(dsm_max_len)=327680]; } // Several keys taken in ONE local transaction at the member: all of them or From 680eb8c0708a2b785ba2c5aaefc9c35a1c68d78b Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:14:32 -0400 Subject: [PATCH 29/31] test(sdk): the member that answers first with other bytes is reached over TLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1094's a_fetch_passes_over_a_member_that_answers_first_with_other_bytes stood up member 0 as a plain-HTTP responder at the scheme of the member's endpoint. Under https-only member clients that scheme is https, so the device's TLS ClientHello reached a responder that read it as an HTTP head ("the request ended before its head did"). The responder never answered, the relays never opened, and the fetch returned None. The test was red at #1096's head. - The node set keeps its CA, and NodeSet::tls_for (with Fleet::tls_for) issues the certificate a member serves, naming it, for a test that stands a member up at another address. - The substitute serves that certificate (tokio-rustls, already an SDK dependency), so the device reaches it as it reaches the member, and the test again exercises fetch_verified. - Mutation control: fetch_verified keeping the first answer whatever it hashes to → red ("other bytes" kept). The test is load-bearing again. storage_node_sdk tests: 8/0 (release, Postgres, TLS harness). fmt and dsm_sdk clippy --all-targets are clean. --- .../dsm_sdk/src/handlers/node_e2e_tests.rs | 17 ++++++++++++----- .../dsm_sdk/src/sdk/sofi_relay.rs | 4 +++- .../dsm_sdk/src/sdk/storage_node_sdk.rs | 15 ++++++++++----- .../dsm_sdk/src/test_support/nodes.rs | 18 ++++++++++++------ .../dsm_sdk/src/test_support/one_device.rs | 5 +++++ 5 files changed, 42 insertions(+), 17 deletions(-) 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 351bdbd50..74ce15998 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 @@ -1333,10 +1333,13 @@ async fn an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_ // never writes its pair. 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 - } + 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) @@ -1422,7 +1425,11 @@ async fn an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_ let walked = verifier .walk_parent(&chains, &m.vault_id, &r0, 0, WALK_BUDGET) .expect("the walk"); - eprintln!("[{label}] {:?} / {:?}", registration.registration(), walked.outcome); + eprintln!( + "[{label}] {:?} / {:?}", + registration.registration(), + walked.outcome + ); (registration.into_registration(), walked.outcome) }; let (before, walked) = read_vault("withheld"); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs index f40a34202..dc7cb25dd 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs @@ -276,7 +276,9 @@ pub async fn relay_exercise( let read = attempt_resolution(&cell, &evidence) .map_err(|undecided| refuse(format!("the key is not decided yet: {undecided:?}")))?; let (Some(exercise), Some(bytes)) = (read.exercise(), read.value()) else { - return Err(refuse("no exercise holds the key; there is nothing to relay")); + return Err(refuse( + "no exercise holds the key; there is nothing to relay", + )); }; let (fulfillment, precommit) = (exercise.fulfillment(), exercise.precommit()); let cells = leg_cells(set, fulfillment, precommit)?; diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs index ebf473a99..cc42d23dc 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs @@ -1035,8 +1035,10 @@ mod tests { /// A member that answers the first request it is sent at once, whatever /// was asked, with `payload` under `namespace`, and then opens `opened`. + /// It serves `tls`, a certificate naming the member it stands in for, so + /// a device reaches it as it reaches that member. async fn substituting( - scheme: &str, + tls: axum_server::tls_rustls::RustlsConfig, namespace: &'static [u8], payload: &'static [u8], opened: std::sync::Arc, @@ -1046,8 +1048,10 @@ mod tests { .await .expect("a member port"); let port = listener.local_addr().expect("its address").port(); + let acceptor = tokio_rustls::TlsAcceptor::from(tls.get_inner()); tokio::spawn(async move { - let (mut inbound, ..) = listener.accept().await.expect("the member accepts"); + let (inbound, ..) = listener.accept().await.expect("the member accepts"); + let mut inbound = acceptor.accept(inbound).await.expect("the TLS handshake"); let mut request = Vec::new(); let mut chunk = [0u8; 1024]; while !request.windows(4).any(|end| end == b"\r\n\r\n") { @@ -1068,7 +1072,7 @@ mod tests { inbound.flush().await.expect("the answer sent"); opened.add_permits(1); }); - format!("{scheme}://127.0.0.1:{port}") + format!("https://127.0.0.1:{port}") } /// A fetch keeps the first bytes that re-hash to the address, not the @@ -1081,7 +1085,7 @@ mod tests { #[test] #[serial_test::serial] fn a_fetch_passes_over_a_member_that_answers_first_with_other_bytes() { - let _fleet = crate::test_support::one_device::Fleet::start(); + let fleet = crate::test_support::one_device::Fleet::start(); let set = crate::sdk::storage_set::canonical_set(crate::economic_fixtures::NETWORK) .expect("the pinned set"); const NAMESPACE: &[u8] = b"DSM/test/first-bytes-that-verify"; @@ -1102,7 +1106,8 @@ mod tests { .split_once("://") .expect("an endpoint names its scheme"); let endpoint = if position == 0 { - substituting(scheme, NAMESPACE, b"other bytes", opened.clone()).await + let tls = fleet.tls_for(&member.member_id).await; + substituting(tls, NAMESPACE, b"other bytes", opened.clone()).await } else { relay_after(scheme, target.to_string(), opened.clone()).await }; diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs index aa9e9c315..351398639 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/nodes.rs @@ -296,7 +296,7 @@ impl Drop for Node { /// The network's pinned register members, one node each. pub struct NodeSet { pub nodes: Vec, - ca_pem: Vec, + ca: TestCa, } /// The process's rustls provider, installed as the node binary's `main` @@ -404,17 +404,23 @@ impl NodeSet { requests, }); } - Self { - nodes, - ca_pem: ca.pem, - } + Self { nodes, ca } + } + + /// The TLS a member serves, from this node set's CA: a certificate naming + /// `member_id` and the loopback address. For a test that stands a member + /// up at another address, as an operator moving its node would: a device + /// that reaches it there knows it by this certificate, as it knows the + /// node. + pub async fn tls_for(&self, member_id: &str) -> axum_server::tls_rustls::RustlsConfig { + self.ca.tls_for(member_id).await } /// The PEM of this node set's CA: what a device's env config names in /// `custom_ca_certs` to reach these nodes, as the bundled config names the /// fleet's. pub fn ca_pem(&self) -> &[u8] { - &self.ca_pem + &self.ca.pem } /// Every node as `(member id, endpoint, register incarnation)`, in pin diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/one_device.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/one_device.rs index 576d1aad4..188299428 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/one_device.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/one_device.rs @@ -89,4 +89,9 @@ impl Fleet { ); endpoints } + + /// The TLS a member serves, from the nodes' CA ([`NodeSet::tls_for`]). + pub async fn tls_for(&self, member_id: &str) -> axum_server::tls_rustls::RustlsConfig { + self.nodes.tls_for(member_id).await + } } From 7f5ad528839fb33fbdc10b8bd420a763d7205de3 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:24:24 -0400 Subject: [PATCH 30/31] fix(sofi): the next operation at a vault registers a withheld pair from its exercise before passing the key (12e) Owner ruling, 2026-10-01: "If a vault key is held by an exercise whose pair is not yet registered, the relayer MUST use the signed C_q carried by that exercise to register the trader's missing pair before advancing past that held key. ... After successful pair registration, the relayer retries progression." - The walk a trade, route or close makes for its live attempt (walk_for_attempt) checks each held key it would pass: when the exercise's pair stands Pending (neither registered nor lost), it registers the pair with relay_exercise, carrying the trader's bytes exactly, and walks again. A registered or lost pair, or one the reads do not decide, is not written. - A trade and a close walk each vault's chain past withheld pairs before drafting (chain_past_withheld_pairs), so they draft at the head those registrations leave. Tests: - an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes now has the vault's owner close the vault. A claim A signs for B's pair occupies nothing; A's close registers B's pair from B's exercise, closes at the head B's trade left, and B's position realizes. - a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair: every member holds the same values at B's two cells before and after A's close. Mutations, each red: the walk never relays (A's close is RetriesExhausted behind the key); a registered pair counts as withheld (A's close is refused after relaying it round after round). --- .../dsm_sdk/src/handlers/node_e2e_tests.rs | 177 +++++++++++++++--- .../dsm_sdk/src/sdk/sofi_flow.rs | 165 ++++++++++++++-- .../dsm_sdk/src/sdk/sofi_relay.rs | 4 +- 3 files changed, 308 insertions(+), 38 deletions(-) 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 351bdbd50..2576c968f 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 @@ -1282,15 +1282,17 @@ async fn an_exercise_carrying_other_bytes_than_its_closure_holds_no_key() { /// the key. Only the trader can sign `C_q`, so before the exercise carried it /// no relayer could register the trader's `F`: the key was held by an /// exercise neither registered nor lost, rung 0 waited on it, and every later -/// trade at the vault waited behind it. +/// operation at the vault waited behind it. /// /// B trades with its pair's leader refusing the pair, so nothing of the pair /// is written; then B, as a hostile trader would, writes its exercise at the -/// vault's first key anyway. The key is held and the pair is empty. A, with -/// nothing from B, relays from the key: the exercise's own bytes register -/// B's `F`, the vault's parent is consumed at that key, and B's position -/// realizes. Mutation: the relay ignores the exercise's `C_q` and only -/// carries one `K_root(q)` already holds. +/// vault's first key anyway. The key is held and the pair is empty. A writes +/// a claim of its own for B's pair at `K_root(q)`: signed by A, it occupies +/// nothing. Then A closes the vault. Before its walk goes past the held key, +/// A registers B's pair from the `C_q` B's exercise carries (owner ruling, +/// 2026-10-01), walks the chain again, and closes at the head B's trade left; +/// B's position realizes on what A wrote. Mutation: the relay ignores the +/// exercise's `C_q` and only carries one `K_root(q)` already holds. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[serial] async fn an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes() { @@ -1333,10 +1335,13 @@ async fn an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_ // never writes its pair. 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 - } + 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) @@ -1408,7 +1413,7 @@ async fn an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_ // at the key: the fulfillment is neither registered nor lost. p.a.enter(); let (own_a, parents_a) = standing_of(&p.a); - let read_vault = |label: &str| { + let read_vault = || { let ctx = VerifierContext::new(&set, Some(own_a), parents_a.as_ref()).expect("a verifier"); let verifier = ctx.verifier(); let registration = verifier @@ -1422,34 +1427,69 @@ async fn an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_ let walked = verifier .walk_parent(&chains, &m.vault_id, &r0, 0, WALK_BUDGET) .expect("the walk"); - eprintln!("[{label}] {:?} / {:?}", registration.registration(), walked.outcome); (registration.into_registration(), walked.outcome) }; - let (before, walked) = read_vault("withheld"); + let (before, walked) = read_vault(); assert!( matches!(before, Registration::Unresolved), "B wrote no pair: {before:?}" ); assert_eq!(walked, WalkOutcome::Unresolved { attempt: 0 }); - // A relays from the key, with nothing from B. - let relayed = crate::sdk::sofi_relay::relay_exercise(&set, &m.vault_id, &r0, 0) + // A signs a claim of its own for B's pair: it is not the trader's, so + // at K_root(q) it occupies nothing. + let substitute = { + let (pk, sk) = crate::sdk::signing_authority::current_keypair().expect("A's AK"); + let att_a = crate::sdk::signing_authority::current_att_a().expect("A's AttA"); + let claim = derive::resolution_claim(&precommit.body, &fulfillment.body); + let alg = fulfillment.body.signature_alg(); + let signature = dsm::crypto::sphincs::sphincs_sign( + &sk, + &derive::resolution_claim_signing_digest(&claim, alg, &pk, &att_a), + ) + .expect("A signs"); + SignedSofiResolutionClaim::new(claim, alg, &pk, att_a, &signature) + .expect("a claim's shape") + .encode() + }; + let written = crate::sdk::route_seats::write_recorded(&set, pair.root().routed(), &substitute) .await - .expect("the exercise carries everything its pair needs"); - assert_eq!(relayed.fulfillment_id, fulfillment_id); - assert_eq!(relayed.position, q); - assert!(relayed.pair.iter().all(|report| report.reached_leader())); - let (after, walked) = read_vault("relayed"); + .expect("the nodes keep whatever they are given"); + assert!(written.reached_leader()); + let (still, ..) = read_vault(); + assert!( + matches!(still, Registration::Unresolved), + "a relayer's own claim registers nothing: {still:?}" + ); + + // A closes the vault: its walk registers B's pair from B's exercise + // before going past the key, walks the chain again, and closes at the + // head B's trade left. + let era_before = balance(&p.a, &m.era); + realized_through( + &p.a, + "sofi.close", + args(&generated::SofiCloseRequest { + vault_id: m.vault_id.to_vec(), + }), + ) + .await; + let out = dsm::dlv::route_commit::constant_product_output(10, 100, 1_000, 30) + .expect("the vault prices the trade"); + assert_eq!( + balance(&p.a, &m.era), + era_before + 110, + "A closed at the head B's trade left: its reserve and all of B's input" + ); + let (after, walked) = read_vault(); assert!( matches!(&after, Registration::Registered(signed) if signed.body == fulfillment.body), "B's F registers from its exercise: {after:?}" ); assert_eq!(walked, WalkOutcome::Consumed { attempt: 0 }); - // B's position realizes on what the relayer wrote. + // B's position realizes on what A wrote. assert_eq!(resolve(&p).await, (q, realized)); - let out = dsm::dlv::route_commit::constant_product_output(10, 100, 1_000, 30) - .expect("the vault prices the trade"); assert_eq!( balance(&p.b, &m.era), crate::economic_fixtures::whole_era(200) - 10 @@ -1458,6 +1498,97 @@ async fn an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_ head_agrees_with_admitted_root(&p.b, &[m.era, m.tkn]); } +/// Pre-audit 12e, the owner's ruling (2026-10-01): a pair that is already +/// registered is not written again. B's trade registers its pair, and its +/// exercise reaches only the leader of the vault's first key, so the key is +/// held and not yet final. A then closes the vault: its walk meets the held +/// key, finds B's pair registered, and goes past it without writing the +/// pair. Every member holds exactly the values it held at B's two cells +/// before A's close. Mutation: a registered pair counts as withheld. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair() { + let p = Pair::boot(500, 200).await; + let m = open_market(&p).await; + set_up(&p.a, &m.vault_id).await; + let set = canonical_set(NETWORK).expect("the pinned set"); + let exhausted = generated::SofiPositionState::RetriesExhausted 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 (own, parents) = standing_of(&p.b); + let ctx = VerifierContext::new(&set, Some(own), parents.as_ref()).expect("a verifier"); + let r0 = ctx + .verifier() + .chain(&m.vault_id) + .expect("the vault's chain") + .roots()[0]; + let attempt = attempt_cell(&set, &m.vault_id, &r0, 0).expect("the first attempt key"); + // Every seat after the leader refuses B's exercise. + for seat in &attempt.routed().route().seats()[1..] { + p.nodes + .refuse_cell_writes(&member_name(seat), &[*attempt.routed().key()]) + .await; + } + + // B's pair registers; its exercise reaches only the key's leader. + let r = invoke(&p.b, "sofi.trade", args(&trade_request(&p, &m, 10))).await; + assert_eq!(position_of(&r, "sofi.trade"), (q, exhausted)); + let client = SetClient::new(&set).expect("a client of the set"); + let held_at = |cell: &dsm::route_chain::RoutedCell| { + let (namespace, key) = (cell.namespace().to_vec(), *cell.key()); + let client = &client; + async move { client.get_cell(&namespace, &key).await } + }; + let ful_before = held_at(pair.fulfillment()).await; + let root_before = held_at(pair.root().routed()).await; + { + 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(..)), + "B's pair is registered: {:?}", + registration.registration() + ); + let held = ctx + .verifier() + .read_attempt_cell(&m.vault_id, &r0, 0) + .expect("read") + .expect("decided"); + assert!( + held.exercise().is_some(), + "B's exercise holds the first key" + ); + assert_ne!(held.fact(), CellFact::Open); + } + + // A closes: the walk goes past the held key and writes nothing at B's pair. + let r = invoke( + &p.a, + "sofi.close", + args(&generated::SofiCloseRequest { + vault_id: m.vault_id.to_vec(), + }), + ) + .await; + assert!(r.success, "A's close is taken: {:?}", r.error_message); + assert_eq!(held_at(pair.fulfillment()).await, ful_before); + assert_eq!(held_at(pair.root().routed()).await, root_before); +} + /// 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/sdk/sofi_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs index b2c7f9133..2317480d6 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs @@ -22,7 +22,7 @@ use dsm::economic::tree::{EconomicSmt, ABSENT_LEAF}; use dsm::economic::write_set::CreditSourceFacts; use dsm::sofi::derive; use dsm::sofi::publication::{Publication, VaultPolicyClass}; -use dsm::sofi::registration::Registration; +use dsm::sofi::registration::{PairStanding, Registration}; use dsm::sofi::resolution::{VaultChain, WalkOutcome}; use dsm::sofi::resolve::{AcceptedGeneses, Acquired, LocalLeaves, VaultGenesis, Verifier, WALK_BUDGET}; use dsm::sofi::storage::Discovered; @@ -1159,14 +1159,35 @@ async fn own_setup_ref( } } -/// The live attempt of a leg at `parent_root`: the walk's first unresolved -/// key, advanced past keys an exercise still in flight holds (§31 stage 6). -fn live_attempt( +/// What the walk at a parent found for a new attempt. +enum Walked { + /// The live attempt: the walk's first unresolved key, advanced past keys + /// an exercise still in flight holds. + Live(u64), + /// A key the walk would have passed was held by an exercise whose pair + /// its trader withheld, and that pair is now registered from the + /// exercise. The walk is run again. + Registered, +} + +/// The walk at `parent_root` of `vault_id` (§31 stage 6): its first +/// unresolved key, advanced past keys an exercise still in flight holds. +/// +/// Before it goes past a key held by an exercise whose position pair is not +/// registered, it registers that pair from the trader's signed `C_q` the +/// exercise carries (owner ruling, 2026-10-01, pre-audit 12e: "If a vault key +/// is held by an exercise whose pair is not yet registered, the relayer MUST +/// use the signed C_q carried by that exercise to register the trader's +/// missing pair before advancing past that held key"). The relayer authors +/// nothing: it carries the trader's bytes exactly. A pair already +/// registered, lost, or not decided by the reads is not written. +async fn walk_for_attempt( + set: &StorageSet, verifier: &Verifier<'_, LiveSofiReads<'_>>, chains: &BTreeMap, vault_id: &D32, parent_root: &D32, -) -> Result { +) -> Result { let mut walked = verifier .walk_parent(chains, vault_id, parent_root, 0, WALK_BUDGET) .map_err(verifier_error)?; @@ -1193,28 +1214,136 @@ fn live_attempt( let mut attempt = first; let mut advanced = 0; while advanced <= ATTEMPT_ADVANCE { - match verifier + let read = match verifier .read_attempt_cell(vault_id, parent_root, attempt) .map_err(verifier_error)? { - Ok(read) if read.exercise().is_none() => return Ok(attempt), - Ok(..) => { - attempt = next_attempt(attempt).map_err(refuse)?; - advanced += 1; - } + Ok(read) => read, Err(missing) => { return Err(storage( "attempt cell", format!("the reads do not decide attempt {attempt}: {missing:?}"), )) } + }; + let Some(exercise) = read.exercise() else { + return Ok(Walked::Live(attempt)); + }; + if pair_withheld(verifier, exercise, attempt)? { + let relayed = + crate::sdk::sofi_relay::relay_exercise(set, vault_id, parent_root, attempt).await?; + if !relayed.pair.iter().all(|report| report.reached_leader()) { + return Err(storage( + "withheld pair", + format!( + "the leader of the pair the exercise at attempt {attempt} carries did not \ + take it" + ), + )); + } + log::info!( + "[sofi] registered the withheld pair of the exercise at attempt {attempt} from \ + its own signed C_q" + ); + return Ok(Walked::Registered); } + attempt = next_attempt(attempt).map_err(refuse)?; + advanced += 1; } Err(refuse( "every nearby attempt key is held by an exercise in flight", )) } +/// Whether the position pair of the exercise holding `attempt` is still to +/// be registered: neither registered nor lost, as its cells read now. Reads +/// that do not decide the pair establish nothing, so the pair is not +/// written on them. +fn pair_withheld( + verifier: &Verifier<'_, LiveSofiReads<'_>>, + exercise: &dsm::sofi::exercise::RecognizedExercise, + attempt: u64, +) -> Result { + let (precommit, fulfillment) = (&exercise.precommit().body, &exercise.fulfillment().body); + let standing = match verifier + .read_registration( + precommit.genesis(), + precommit.device_id(), + fulfillment.position(), + precommit.void_root(), + ) + .map_err(verifier_error)? + { + Ok(read) => Some(read.standing_of(precommit, fulfillment)), + Err(missing) => { + log::info!( + "[sofi] the pair of the exercise at attempt {attempt} is not decided yet: \ + {missing:?}" + ); + None + } + }; + Ok(standing == Some(PairStanding::Pending)) +} + +/// The live attempt of a leg at `parent_root` (§31 stage 6), every withheld +/// pair the walk meets registered first. A registration can consume the +/// parent: the walk run again then says the head moved. +async fn live_attempt( + set: &StorageSet, + verifier: &Verifier<'_, LiveSofiReads<'_>>, + chains: &mut BTreeMap, + vault_id: &D32, + parent_root: &D32, +) -> Result { + // Each round registers a pair the walk met at one of the keys it + // examines, at most ATTEMPT_ADVANCE + 1 of them. + for _ in 0..=ATTEMPT_ADVANCE { + match walk_for_attempt(set, verifier, chains, vault_id, parent_root).await? { + Walked::Live(attempt) => return Ok(attempt), + Walked::Registered => { + chains.insert(*vault_id, verifier.chain(vault_id).map_err(verifier_error)?); + } + } + } + Err(refuse( + "every nearby attempt key is held by an exercise whose withheld pair was registered", + )) +} + +/// `vault_id`'s chain, walked again past every withheld pair the walk at its +/// head meets: each is registered from its exercise and the chain is walked +/// again, so a trade or a close is drafted at the head those registrations +/// leave (owner ruling, 2026-10-01: "After successful pair registration, the +/// relayer retries progression"). +async fn chain_past_withheld_pairs( + set: &StorageSet, + verifier: &Verifier<'_, LiveSofiReads<'_>>, + vault_id: &D32, + chain: VaultChain, +) -> Result { + let mut chains = BTreeMap::from([(*vault_id, chain)]); + for _ in 0..=ATTEMPT_ADVANCE { + let (.., head) = chains + .get(vault_id) + .and_then(VaultChain::head) + .ok_or_else(|| refuse("no head of this vault is established"))?; + match walk_for_attempt(set, verifier, &chains, vault_id, &head).await? { + Walked::Live(..) => { + return chains + .remove(vault_id) + .ok_or_else(|| refuse("the vault's chain is not in hand")) + } + Walked::Registered => { + chains.insert(*vault_id, verifier.chain(vault_id).map_err(verifier_error)?); + } + } + } + Err(refuse( + "every nearby attempt key is held by an exercise whose withheld pair was registered", + )) +} + /// `V°` of one vault: its state mutation to `post_state`, and this trader's /// relationship advancement from `base`, against the vault's tree at its head. fn vault_core( @@ -1428,7 +1557,7 @@ async fn exercise_draft( for leg in checked.precommit().legs() { attempts.push(( leg.vault_id, - live_attempt(&verifier, &chains, &leg.vault_id, &leg.parent_root)?, + live_attempt(set, &verifier, &mut chains, &leg.vault_id, &leg.parent_root).await?, )); } let att_a = crate::sdk::signing_authority::current_att_a()?; @@ -1613,7 +1742,8 @@ pub async fn trade( .iter() .zip(chains_at_once(&verifier, &intent.vault_ids)) { - heads.push(head_of(set, &verifier, vault_id, chain?).await?.0); + let chain = chain_past_withheld_pairs(set, &verifier, vault_id, chain?).await?; + heads.push(head_of(set, &verifier, vault_id, chain).await?.0); } log::info!("[sofi] trade: walked {} vault heads", heads.len()); // A chain, or a split across two vaults of the pair (Amendment S19), @@ -1680,7 +1810,14 @@ pub async fn close( let standing = standing(core)?; let ctx = standing.context(set, accepted)?; let verifier = ctx.verifier(); - let (vault, ..) = vault_at_head(set, &verifier, &intent.vault_id).await?; + let chain = chain_past_withheld_pairs( + set, + &verifier, + &intent.vault_id, + verifier.chain(&intent.vault_id).map_err(verifier_error)?, + ) + .await?; + let (vault, ..) = head_of(set, &verifier, &intent.vault_id, chain).await?; only_the_owner(&standing, &vault)?; let setup_ref = own_setup_ref( set, diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs index f40a34202..dc7cb25dd 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs @@ -276,7 +276,9 @@ pub async fn relay_exercise( let read = attempt_resolution(&cell, &evidence) .map_err(|undecided| refuse(format!("the key is not decided yet: {undecided:?}")))?; let (Some(exercise), Some(bytes)) = (read.exercise(), read.value()) else { - return Err(refuse("no exercise holds the key; there is nothing to relay")); + return Err(refuse( + "no exercise holds the key; there is nothing to relay", + )); }; let (fulfillment, precommit) = (exercise.fulfillment(), exercise.precommit()); let cells = leg_cells(set, fulfillment, precommit)?; From f17b6be2bfd6158064b6ffe4de154ee914667d6f Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:36:03 -0400 Subject: [PATCH 31/31] docs(conformance): 12e closed, with the owner's ruling on who relays a withheld pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §6.66 12e: the record (exploit, closure, the owner's 2026-10-01 ruling "Choose \"Next trader.\" ... MUST ... before advancing past that held key", the cell value cap raised to 320 KiB and why), and six mutation controls. - §8 MR-SOFI-0362 Missing -> Met. MR-DSM-0213 cites relay_exercise. - §6.64: the new per-entry maximum beside the cell GET paging item (STORAGE). - §6.65: its open note points at the closure. - VERIFICATION_MATRIX: the 12e row. - §7 regenerated. --- specs/requirements/CONFORMANCE_GAPS.md | 35 +++++++++++++++++++---- specs/requirements/VERIFICATION_MATRIX.md | 1 + 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index e8803d05a..3e436f1b9 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2302,7 +2302,7 @@ The timeouts are `Duration`s handed to the HTTP and pool libraries. `no_clock_re - The poller's running flag is cleared however its task ends. Test: `dsm_sdk::sdk::inbox_poller::tests::a_poller_that_panics_is_no_longer_running`. Mutation: never cleared → red. **Open, for the owner (not built):** -- **Paging the cell GET.** It contradicts MR-STOR-0029, "a get returns everything". +- **Paging the cell GET.** It contradicts MR-STOR-0029, "a get returns everything". Since 12e (§6.66) a value at a key may be up to 320 KiB (`route_chain::MAX_VALUE_LEN`, the node's cap through `MAX_ENTRY_BYTES`), up from 256 KiB, so a flooded key's unpaged GET grows in step. - **Write budgets** per key, per source or global. The credits and spend-gate subsystems are Missing. - **Capping the mirror's distinct ByteCommits per member and cycle.** Spec §14 rule 6 keeps every one. - **Committing the CA with the storage set.** It is configured today. @@ -2341,7 +2341,7 @@ The timeouts are `Duration`s handed to the HTTP and pool libraries. `no_clock_re MR-SOFI-0168, 0343, 0360 and 0361 are rewritten and set to Partial until SOFI's change and its probe test land on #1096. -**Open, on #1096 (SOFI, MR-SOFI-0362).** S20 also makes the exercise carry the trader's signed `C_q`. Without it, a trader whose exercise is final at a vault key but who never publishes its `C_q` leaves that `F` unregistrable. No relayer can author the claim, so the vault waits at that root. `recognize_exercise` will bind the exercise's key to `P`'s device, and `read_registration` will dismiss a foreign-key `F` before fetching its `P`. +**Open, on #1096 (SOFI, MR-SOFI-0362).** S20 also makes the exercise carry the trader's signed `C_q`. Without it, a trader whose exercise is final at a vault key but who never publishes its `C_q` leaves that `F` unregistrable. No relayer can author the claim, so the vault waits at that root. `recognize_exercise` will bind the exercise's key to `P`'s device, and `read_registration` will dismiss a foreign-key `F` before fetching its `P`. Closed on #1096: §6.66, 12e. | Row | Was | Now | Why | |---|---|---|---| @@ -2455,8 +2455,31 @@ This is the owner's beta security pre-audit, item 12 (P1): "cheap garbage must n The resolution's body check (Invalid in `peer_position`) has no test of its own yet. +**12e. A trader that withheld its pair held the vault's key (closed).** + +| Field | Record | +|---|---| +| Severity | High | +| Files | `dsm/src/sofi/wire/objects.rs` · `SofiExercise` (`resolution_claim`); `dsm/src/sofi/exercise.rs` · `recognize_exercise`; `dsm/src/route_chain.rs` · `MAX_VALUE_LEN`; `dsm/src/sofi/wire/mod.rs` · `MAX_EXERCISE_BYTES`; `proto/dsm_app.proto` (the cell values' bound); `dsm_sdk/src/sdk/sofi_register.rs` · `Installed::resolution_claim`; `sofi_exercise.rs` · `build_exercise`; `sofi_relay.rs` · `relay_exercise`, `relay_fulfillment` (`relay_position_pair`, which had no caller, removed); `sofi_flow.rs` · `walk_for_attempt`, `pair_withheld`, `live_attempt`, `chain_past_withheld_pairs`; `ci/sofi_relay_is_party_neutral.sh` | +| Exploit | Item 1 made `C_q` trader-signed, so no relayer can author it. A trader writes its exercise at a vault's key and never writes its position pair. Its `F` can never register, nothing makes it lost, so rung 0 waits and every later operation at the vault waits behind the key: a vault frozen by one trader at the cost of one exercise. | +| Violates | SoFi Amendment S20 ("The exercise carries the trader's signed `C_q`… Anything that can hold a vault key therefore carries everything needed to register its own `F`"; "A relayer never authors `C_q`"). MR-SOFI-0362. | +| Verification | `dsm_sdk::handlers::node_e2e_tests::an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes`: B trades with its pair's leader refusing the pair, then writes its exercise at the vault's first key anyway. The key is held, the pair empty, the walk stops at the key. A claim A signs for B's pair occupies nothing. A closes the vault: its walk registers B's pair from B's exercise, walks the chain again, and closes at the head B's trade left; B's position realizes. On the old code no relayer could write B's `C_q`, so A's close waited behind the key (the relay's refusal is the mutation below). `dsm_sdk::handlers::node_e2e_tests::a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair`. `dsm::sofi::exercise::tests::an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f` (no claim, a bare claim, the trader's claim of another fulfillment, this claim under another claim's signature, this claim signed by another device: each is nothing). `dsm::sofi::exercise::tests::an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing`. | +| Closure condition | The exercise carries the trader's signed `C_q`, and recognizes only when its `F` proves `P`'s device and the claim is recognized exactly as `K_root(q)` recognizes it (`root_claim_naming`) with the body `derive(P, F)`. The install returns the claim it wrote and the exercise carries those bytes. `relay_exercise` registers the pair of the exercise holding a vault key from the exercise's bytes alone. Owner ruling (2026-10-01), answering which device relays: "Choose "Next trader." If a vault key is held by an exercise whose pair is not yet registered, the relayer MUST use the signed C_q carried by that exercise to register the trader's missing pair before advancing past that held key. The relayer does not author or modify C_q. It only republishes the exact trader-signed bytes already committed in the exercise. After successful pair registration, the relayer retries progression." So the walk a trade, route or close makes for its live attempt registers every pending pair it would pass, and a trade or close walks each vault's chain past them before drafting. | +| Size | The signed `C_q` adds about 50 KB (one SPX256f signature). A two-leg exercise measured 218,735 bytes on the nodes before it, about 269 KB with it, past the 262,144-byte cell value cap. The cap is now 320 KiB (`route_chain::MAX_VALUE_LEN`, the proto's `dsm_max_len` on the three cell value fields, `MAX_EXERCISE_BYTES`), agreed with STORAGE; the node's cap follows `MAX_ENTRY_BYTES`, and STORAGE's `dsm_storage_node::cells_keep_everything::a_cell_takes_the_largest_route_entry_and_refuses_a_byte_more` holds its edge. The live fleet keeps the old cap until it is redeployed from a commit with the new constant, so a two-leg trade on the fleet needs that redeploy first. | +| Status | Closed (`2d1b010d8`, `7f5ad5288`). | + +**Mutation controls (12e, 2026-10-01, restored byte for byte).** + +| Mutation | Result | +|---|---| +| The relay ignores the exercise's `C_q` (only a claim `K_root(q)` holds is carried) | `an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes` red: "K_root(q) holds no claim yet, and no exercise of this fulfillment is in hand to carry the trader's signed C_q" | +| The walk never registers a withheld pair | The same test red: A's close is `RetriesExhausted` behind the key | +| A registered pair counts as withheld | `a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair` red: A's close is refused after relaying the registered pair round after round | +| Recognition compares the claim's device, not its body | `an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f` red: the trader's claim of another fulfillment is accepted | +| `verify_resolution_claim` dropped from claim recognition | The same test red: this claim under another claim's signature is accepted | +| Recognition drops `fulfillment_proves_the_device` | `an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing` red | + **Open: found here, not built.** -- **12e. The exercise must carry the trader's signed `C_q` (S20, MR-SOFI-0362, Missing).** Once item 1 makes `C_q` trader-signed, a relayer can no longer author it. A trader that writes its exercise at a vault's key and never publishes its signed `C_q` would then hold that key forever: nobody else can register its `F`, so rung 0 holds and no skip applies. The closure is that the exercise carries the signed `C_q` and recognition checks it, so any relayer completes the pair from the exercise's own bytes. SOFI builds it after CORE's `F` change lands. - **12f. A conditional parent that resolved Invalid.** §23.3 makes this parent impossible too (`C_p` terminal, no root). The A8 walk reports an Invalid position as a failure, so `trader_root_at` fails, the facts see no parent, and the key waits forever. A single-root parent whose lineage is known invalid at or before `p` is the same case, as Amendment S13 already decides for setups. The walk's verdict has to reach the facts as a terminal parent. Mine, next. Verification: read at the code. - **12g. A defeated route strands its other legs (owner ruling).** A route is Void when a reserved key's leader holds another exercise first (§24 rung 8). That is not an arm of `RouteImpossible` (§23.5 arms i–iv), so the route's other final cells are skipped only once the lost leg's parent is consumed elsewhere or orphaned. A trader that owns a vault no one else trades can hold another vault's key indefinitely: route through both, leg 1 final, leg 2's key taken first by its own other exercise. Like S14, this is a fact about `F`, not about `P` and `E`. It needs an amendment: a final cell whose fulfillment's reserved key went to another exercise is skipped. Verification: read at the code (`route_impossible`, `classify_attempt`). - **12h. Withheld evidence holds a key (owner ruling).** A registered exercise whose trader never publishes an object conformance or validation needs, such as the setup a leg names, can never be classified. §5.3 lets that position stay unresolved, but none of the skips applies, so it also holds the vault's key forever. The spec's remedy is the challenge rule (Amendment S1, storage §9.1), and Amendment S7 puts it outside beta. Verification: read at the code. @@ -2524,11 +2547,11 @@ Also: `a_credits_source_is_validated_through_its_own_segment` (B validates C's f | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| | DSM high-level (MR-DSM) | 277 | 96 | 95 | 39 | 0 | 29 | 18 | -| SoFi (MR-SOFI) | 362 | 235 | 87 | 19 | 4 | 17 | 0 | +| SoFi (MR-SOFI) | 362 | 236 | 87 | 18 | 4 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 65 | 18 | 56 | 0 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **943** | **405** | **201** | **115** | **4** | **64** | **154** | +| **All** | **943** | **406** | **201** | **114** | **4** | **64** | **154** | ## 8 Per-requirement results @@ -3179,7 +3202,7 @@ Also: `a_credits_source_is_validated_through_its_own_segment` (B validates C's f | MR-SOFI-0359 | Met | `dsm_sdk::sdk::sofi_flow::find_route`; `dsm_sdk::sdk::sofi_flow::plan`; `dsm_sdk::sdk::sofi_flow::plan_split`; `dsm_sdk::sdk::sofi_flow::trade`; `dsm_sdk::sdk::sofi_flow::route_ends` | `dsm_sdk::handlers::node_e2e_tests::one_order_fills_through_two_vaults_of_the_same_pair` | SoFi Amendment S19 (§6.53). The search weighs each pair of direct vaults as a split beside every single hop and chain and proposes the most; the split share is searched over the inputs both legs price. The trade plans the same split again at the heads it walks; min-out bounds what it accepts. The quote reports the route's total by that rule (§6.56). | | MR-SOFI-0360 | Met | `dsm::sofi::wire::objects::SignedSofiResolutionClaim`; `dsm::sofi::signature::verify_resolution_claim`; `dsm::sofi::signature::sign_resolution_claim`; `dsm::economic::register::read_root_cell` (body identity); `dsm_sdk::sdk::sofi_register` (producer); `dsm_sdk::sdk::sofi_relay::carry_pair`; `dsm::sofi::registration::RegistrationRead::standing_of`; `dsm::sofi::resolve::Verifier::peer_position` | `dsm::economic::claim_envelope::tests::a_conditional_cell_decodes_by_class`; `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell`; `dsm::economic::register::registered_root_construction_tests::a_held_root_cell_carries_the_exact_bytes_that_hold_it`; `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own` | SoFi Amendment S20 (§6.65). `K_root(q)` holds the trader-signed `C_q` (`0x0062`); a bare `C_q` is refused by name, and a conditional claim is identified by its derived body. S20 extended 2026-10-01 (§6.65, §6.66 12j): registration matches the pair by `FulfillmentId(F)`; where `P` is in hand the body is compared with `derive(P, F)`. In the facts a difference loses `F` (`standing_of`); in a position's resolution it is Invalid (`peer_position`) (`51359a6b6`). Mutation: the body check skipped for a registered pair → the unit test red. The resolution check has no test of its own yet. | | MR-SOFI-0361 | Met | `dsm::sofi::wire::objects::TraderFulfillmentBody`; `dsm::sofi::registration::fulfillment_proves_the_device`; `dsm::sofi::registration::names_fulfillment_key`; `dsm_sdk::sdk::sofi_sdk::build_fulfillment` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell`; `dsm::sofi::registration::tests::only_a_fulfillment_of_this_trader_at_this_position_names_the_key`; `dsm_sdk::handlers::node_e2e_tests::a_position_reads_the_same_before_and_after_a_precommit_is_published` | SoFi Amendment S20 (§6.65). The binding is decided from `F`'s bytes before `P` is looked up. Mutation: the check removed → that test red. S20 extended 2026-10-01 (§6.65, §6.66 12j): `names_fulfillment_key` decides from `F`'s bytes alone and reads no `P`, so which `F` holds `K_ful(q)` is the same at every time (`51359a6b6`). | -| MR-SOFI-0362 | Missing | — | — | SoFi Amendment S20 (§6.65). The exercise does not carry the trader's signed `C_q` yet; SOFI builds it on #1096 (recognition, producer, and the relay taking it from the exercise). | +| MR-SOFI-0362 | Met | `dsm::sofi::exercise::recognize_exercise`; `dsm::sofi::wire::objects::SofiExercise`; `dsm_sdk::sdk::sofi_exercise::build_exercise`; `dsm_sdk::sdk::sofi_relay::relay_exercise`; `dsm_sdk::sdk::sofi_flow::walk_for_attempt` | `dsm_sdk::handlers::node_e2e_tests::an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes`; `dsm_sdk::handlers::node_e2e_tests::a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair`; `dsm::sofi::exercise::tests::an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f`; `dsm::sofi::exercise::tests::an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing` | §6.66 12e. The exercise carries the trader's signed `C_q`, recognized as `K_root(q)` recognizes it with the body `derive(P, F)`; the next operation at a vault registers a withheld pair from it before passing the key (owner ruling, 2026-10-01). | ### 8.3 dBTC native specification diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index 40b502b93..a49a44dd9 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -189,3 +189,4 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-DSM-0274: a source's frontier is recorded with the acceptance and reused | `dsm_sdk` · handlers/storage_routes.rs (accept transaction); `dsm` · economic/peer_lineage.rs · `Verifier::frontier_below` | `dsm_sdk::handlers::frontier_verification_tests::a_sources_frontier_is_recorded_and_the_next_credit_walks_only_its_suffix`; `dsm_sdk::handlers::frontier_verification_tests::one_walk_validates_a_sources_segment_once` | Source frontiers not recorded → red; reuse within a walk disabled → red (2026-10-01). | — | | MR-DSM-0274: no frontier from a walk that did not finish | `dsm_sdk` · handlers/storage_routes.rs (the only writer is the accept transaction) | `dsm_sdk::handlers::recipient_admission_tests::an_outage_holds_the_transfer_cleanly_and_it_recovers` | No gate to remove: structural (2026-10-01). | — | | MR-SOFI-0168, MR-SOFI-0343, MR-SOFI-0360, MR-SOFI-0361, SoFi Amendment S20 extended, pre-audit item 12 (§6.66 12j): a position pair is decided from its own bytes, the same whenever it is read | `dsm` · sofi/registration.rs · `names_fulfillment_key`, `fulfillment_registered`, `RegistrationRead::standing_of`; sofi/resolve.rs · `Verifier::read_registration`, `Verifier::peer_position` (the body check) | `dsm_sdk::handlers::node_e2e_tests::a_position_reads_the_same_before_and_after_a_precommit_is_published`; `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own` | Another fulfillment's leader link losing nothing → both red; a registered pair's body not compared → the unit test red (2026-10-01, restored). The resolution's body check: not run. | — | +| MR-SOFI-0362, SoFi Amendment S20, pre-audit item 12 (§6.66 12e): the exercise carries the trader's signed `C_q`, and the next operation at a vault registers a withheld pair from it before passing the key (owner ruling, 2026-10-01) | `dsm` · sofi/exercise.rs · `recognize_exercise`; sofi/wire/objects.rs · `SofiExercise`. `dsm_sdk` · sdk/sofi_relay.rs · `relay_exercise`; sdk/sofi_flow.rs · `walk_for_attempt`, `chain_past_withheld_pairs` | `dsm_sdk::handlers::node_e2e_tests::an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes`; `dsm_sdk::handlers::node_e2e_tests::a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair`; `dsm::sofi::exercise::tests::an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f`; `dsm::sofi::exercise::tests::an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing` | The relay ignoring the exercise's `C_q` → red; the walk never registering a withheld pair → red (A's close `RetriesExhausted`); a registered pair counted as withheld → red; the claim's body not compared → red; its verification dropped → red; the device check dropped → red (2026-10-01, all restored). | — |