From ed67d674e33ed25e662b51d9ffa8fb4427158641 Mon Sep 17 00:00:00 2001 From: Daniel Gorgonha Date: Tue, 28 Jul 2026 19:17:39 -0300 Subject: [PATCH 1/3] explore(anchor): proposal commitment groundwork (canonical serialization + placeholder commit) EXPLORATORY (branch explore/proposal-anchor, do NOT merge): first cut of the Zcash-anchored proposal timestamp direction. anchor::canonical is a deterministic, unambiguous (length-prefixed, versioned) serialization of a proposal's immutable content -- the hash-independent foundation, fully tested (determinism, per-field sensitivity, no boundary/None-vs-empty collisions). anchor::commit is a PLACEHOLDER SHA-256; the real commitment is Poseidon-BN254 with the Noir circuit's params (aligned with the collaborator). Not wired into the app. Design + open decisions in temp/ANCHOR-DESIGN.md (gitignored). 5 tests. --- orchestrator/Cargo.toml | 3 + orchestrator/src/anchor.rs | 179 +++++++++++++++++++++++++++++++++++++ orchestrator/src/lib.rs | 3 + 3 files changed, 185 insertions(+) create mode 100644 orchestrator/src/anchor.rs diff --git a/orchestrator/Cargo.toml b/orchestrator/Cargo.toml index a867aa7..66ea410 100644 --- a/orchestrator/Cargo.toml +++ b/orchestrator/Cargo.toml @@ -11,6 +11,9 @@ license = "MIT OR Apache-2.0" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +# Exploratory (proposal anchor): placeholder commitment hash. The final commitment is a zk-friendly +# Poseidon-BN254 matching the Noir circuit; sha2 stands in so the canonical + anchor flow is testable. +sha2 = "0.10" # At-rest protection for FROST shares (security debt): AEAD via a vetted library, # never hand-rolled crypto. XChaCha20-Poly1305 for authenticated encryption. chacha20poly1305 = "0.10" diff --git a/orchestrator/src/anchor.rs b/orchestrator/src/anchor.rs new file mode 100644 index 0000000..dca31a6 --- /dev/null +++ b/orchestrator/src/anchor.rs @@ -0,0 +1,179 @@ +//! Exploratory (branch `explore/proposal-anchor`, NOT merged): an immutable, private commitment to +//! a proposal's content, for a Zcash-anchored timestamp + a future zkTimestamp proof. See +//! `temp/ANCHOR-DESIGN.md` for the full flow and the open decisions to align on. +//! +//! `c = commit(canonical(content))`. `canonical` is a DETERMINISTIC serialization of a proposal's +//! **immutable content**; the commitment `c` is public (written into a shielded Orchard memo, the +//! mined block being the timestamp). A separate Noir/BN254 circuit later proves +//! `Poseidon(content) == c` without revealing the content, so anyone can verify "this proposal +//! existed at block N" without seeing it. +//! +//! Two deliberate boundaries: +//! - `canonical` is **hash-independent** and is the security-relevant part: the field set + order +//! must be stable across devices so every device commits to the same bytes. This is the real +//! foundation and is fully tested. +//! - `commit` uses SHA-256 as a **PLACEHOLDER algorithm**. The real commitment must be a zk-friendly +//! **Poseidon over BN254** with the exact params the Noir circuit uses; that swap is trivial once +//! the circuit is chosen. Do NOT treat this hash as final. + +use sha2::{Digest, Sha256}; + +/// A proposal's IMMUTABLE content — exactly what the anchor commits to (never the mutable state or +/// votes). The precise field set is an open decision (see the design note); kept minimal + explicit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposalContent { + pub vault_id: String, + pub kind: String, // "payment" | "payroll" + pub to_address: Option, + pub value_zat: u64, + pub memo: Option, + pub created_at: i64, +} + +/// Bumped if the canonical layout ever changes, so old and new commitments never collide silently. +const CANON_VERSION: u8 = 1; + +fn put_bytes(out: &mut Vec, b: &[u8]) { + out.extend_from_slice(&(b.len() as u32).to_le_bytes()); + out.extend_from_slice(b); +} + +fn put_opt(out: &mut Vec, v: &Option) { + match v { + None => out.push(0), + Some(s) => { + out.push(1); + put_bytes(out, s.as_bytes()); + } + } +} + +/// Deterministic, unambiguous serialization of the immutable content: a version byte then +/// length-prefixed fields in a fixed order, so two devices always produce identical bytes for the +/// same proposal (and a different field always yields different bytes). Hash-independent. +pub fn canonical(c: &ProposalContent) -> Vec { + let mut out = Vec::new(); + out.push(CANON_VERSION); + put_bytes(&mut out, c.vault_id.as_bytes()); + put_bytes(&mut out, c.kind.as_bytes()); + put_opt(&mut out, &c.to_address); + out.extend_from_slice(&c.value_zat.to_le_bytes()); + put_opt(&mut out, &c.memo); + out.extend_from_slice(&c.created_at.to_le_bytes()); + out +} + +/// PLACEHOLDER commitment: SHA-256 over a domain-separated canonical serialization. The final +/// commitment is Poseidon-BN254 (params to match the Noir circuit); this stands in so the +/// canonical + memo-anchor flow can be built and tested now. Returns the 32-byte commitment `c`. +pub fn commit(c: &ProposalContent) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"konclave:proposal-anchor:v1"); // domain separation + h.update(canonical(c)); + h.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> ProposalContent { + ProposalContent { + vault_id: "vault-1".into(), + kind: "payment".into(), + to_address: Some("u1recipient".into()), + value_zat: 50_000, + memo: Some("reembolso".into()), + created_at: 1_900_000_000, + } + } + + #[test] + fn canonical_is_deterministic() { + assert_eq!(canonical(&sample()), canonical(&sample())); + assert_eq!(commit(&sample()), commit(&sample())); + } + + #[test] + fn any_field_change_changes_the_commitment() { + let base = commit(&sample()); + let variants = [ + ProposalContent { + vault_id: "vault-2".into(), + ..sample() + }, + ProposalContent { + kind: "payroll".into(), + ..sample() + }, + ProposalContent { + to_address: Some("u1other".into()), + ..sample() + }, + ProposalContent { + value_zat: 50_001, + ..sample() + }, + ProposalContent { + memo: Some("outro".into()), + ..sample() + }, + ProposalContent { + created_at: 1_900_000_001, + ..sample() + }, + ]; + for v in &variants { + assert_ne!( + commit(v), + base, + "a changed field must change the commitment" + ); + } + } + + #[test] + fn none_and_empty_are_distinguishable() { + // A missing optional field must not collide with an empty-string one (length-prefix + tag). + let a = ProposalContent { + memo: None, + ..sample() + }; + let b = ProposalContent { + memo: Some(String::new()), + ..sample() + }; + assert_ne!(commit(&a), commit(&b)); + let c = ProposalContent { + to_address: None, + ..sample() + }; + let d = ProposalContent { + to_address: Some(String::new()), + ..sample() + }; + assert_ne!(commit(&c), commit(&d)); + } + + #[test] + fn field_boundaries_are_unambiguous() { + // Moving a byte across a field boundary must change the bytes (length-prefixing prevents the + // classic "ab|c" vs "a|bc" collision). + let x = ProposalContent { + vault_id: "ab".into(), + kind: "c".into(), + ..sample() + }; + let y = ProposalContent { + vault_id: "a".into(), + kind: "bc".into(), + ..sample() + }; + assert_ne!(canonical(&x), canonical(&y)); + } + + #[test] + fn commitment_is_32_bytes() { + assert_eq!(commit(&sample()).len(), 32); + } +} diff --git a/orchestrator/src/lib.rs b/orchestrator/src/lib.rs index 56620b3..33a8b61 100644 --- a/orchestrator/src/lib.rs +++ b/orchestrator/src/lib.rs @@ -10,6 +10,9 @@ // --- domain core (dependency-free) --- pub mod money; +// --- exploratory (branch explore/proposal-anchor): proposal commitment for a Zcash-anchored +// timestamp + a future zkTimestamp proof. NOT wired into the app; see temp/ANCHOR-DESIGN.md. --- +pub mod anchor; pub mod payroll; pub mod proposal; pub mod reconcile; From e32e02e6719b405ae64f058bd564476e8667aa7d Mon Sep 17 00:00:00 2001 From: Daniel Gorgonha Date: Tue, 28 Jul 2026 19:17:57 -0300 Subject: [PATCH 2/3] explore(anchor): commit Cargo.lock (sha2) for the exploratory branch --- orchestrator/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/orchestrator/Cargo.lock b/orchestrator/Cargo.lock index c85aeb7..626dd61 100644 --- a/orchestrator/Cargo.lock +++ b/orchestrator/Cargo.lock @@ -415,6 +415,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "tiny_http", "zcash_address", "zcash_protocol", From 58de0cebe7dbfa46df6c7bf7b4d2d7825b58d3e5 Mon Sep 17 00:00:00 2001 From: Daniel Gorgonha Date: Wed, 29 Jul 2026 15:28:37 -0300 Subject: [PATCH 3/3] explore(anchor): memo envelope for the on-chain anchor (encode/decode + anchor_memo_for) EXPLORATORY (branch explore/proposal-anchor, do NOT merge). The commitment -> memo step: encode_anchor_memo/decode_anchor_memo write and recover the commitment as a tagged, versioned hex ('zkanchor:v1:', fits the 512-byte Orchard memo, greppable on explorers), and anchor_memo_for(content) = encode(commit(content)). Hash-independent (works with any 32-byte commitment; the Poseidon-BN254 swap and the field set stay the open decisions to align on). Not wired into the send path yet. 8 anchor tests (round-trip + rejects non-anchor memos). --- orchestrator/src/anchor.rs | 79 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/orchestrator/src/anchor.rs b/orchestrator/src/anchor.rs index dca31a6..e984bf9 100644 --- a/orchestrator/src/anchor.rs +++ b/orchestrator/src/anchor.rs @@ -73,6 +73,52 @@ pub fn commit(c: &ProposalContent) -> [u8; 32] { h.finalize().into() } +/// Tag for the anchor memo. Bump the version if the memo layout changes. +const ANCHOR_MEMO_PREFIX: &str = "zkanchor:v1:"; + +/// Encode the text written into a shielded Orchard memo: a tagged, versioned hex of the commitment. +/// Readable on explorers, well within the 512-byte memo, and `--memo`-friendly. The block that mines +/// the carrying transaction is the timestamp. Hash-independent (any 32-byte commitment). +pub fn encode_anchor_memo(c: &[u8; 32]) -> String { + let mut s = String::with_capacity(ANCHOR_MEMO_PREFIX.len() + 64); + s.push_str(ANCHOR_MEMO_PREFIX); + for b in c { + s.push(char::from_digit((b >> 4) as u32, 16).unwrap()); + s.push(char::from_digit((b & 0xf) as u32, 16).unwrap()); + } + s +} + +fn hex_val(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +/// Recover the 32-byte commitment from a memo, or `None` if it is not a Konclave anchor (wrong tag, +/// length, or hex). A verifier reads this from the on-chain transaction and checks it against a +/// freshly recomputed commitment of the proposal it was shown. +pub fn decode_anchor_memo(memo: &str) -> Option<[u8; 32]> { + let hex = memo.strip_prefix(ANCHOR_MEMO_PREFIX)?.as_bytes(); + if hex.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = (hex_val(hex[2 * i])? << 4) | hex_val(hex[2 * i + 1])?; + } + Some(out) +} + +/// The full anchor memo for a proposal: `encode_anchor_memo(commit(content))`. This is the string a +/// device would write into the shielded output that anchors the proposal on-chain. +pub fn anchor_memo_for(content: &ProposalContent) -> String { + encode_anchor_memo(&commit(content)) +} + #[cfg(test)] mod tests { use super::*; @@ -176,4 +222,37 @@ mod tests { fn commitment_is_32_bytes() { assert_eq!(commit(&sample()).len(), 32); } + + #[test] + fn anchor_memo_round_trips() { + let c = commit(&sample()); + let memo = encode_anchor_memo(&c); + assert!(memo.starts_with("zkanchor:v1:")); + assert_eq!(memo.len(), "zkanchor:v1:".len() + 64); + assert_eq!(decode_anchor_memo(&memo), Some(c)); + } + + #[test] + fn anchor_memo_for_matches_commit() { + let content = sample(); + assert_eq!( + decode_anchor_memo(&anchor_memo_for(&content)), + Some(commit(&content)) + ); + } + + #[test] + fn decode_rejects_non_anchor_memos() { + assert_eq!(decode_anchor_memo("just a normal memo"), None); + assert_eq!(decode_anchor_memo("zkanchor:v1:tooshort"), None); + assert_eq!(decode_anchor_memo("zkanchor:v1:"), None); // empty hex + assert_eq!( + decode_anchor_memo(&format!("zkanchor:v2:{}", "ab".repeat(32))), + None + ); // wrong version + assert_eq!( + decode_anchor_memo(&format!("zkanchor:v1:{}", "zz".repeat(32))), + None + ); // right length, non-hex + } }