use alloc::vec::Vec;
use starknet_crypto::{poseidon_hash_many, poseidon_permute_comp, Felt};
use crate::error::TranscriptError;
const WIDTH: usize = 3;
const RATE: usize = 2;
const CAPACITY_IDX: usize = 2;
pub fn domain_tag_felt() -> Felt {
bytes_to_felt(b"vauban-claim-v2-transcript-t2")
}
pub fn sentinel_absent() -> Felt {
bytes_to_felt(b"vauban::absent")
}
pub fn bytes_to_felt(data: &[u8]) -> Felt {
if data.is_empty() {
return poseidon_hash_many(&[Felt::ZERO]);
}
let felts: Vec<Felt> = data
.chunks(31)
.map(Felt::from_bytes_be_slice)
.collect();
poseidon_hash_many(&felts)
}
#[derive(Clone, Debug)]
pub struct SpongeState {
state: [Felt; WIDTH],
}
impl SpongeState {
pub fn init(domain_tag: &[u8]) -> Self {
let tag_felt = bytes_to_felt(domain_tag);
let mut state = [Felt::ZERO; WIDTH];
state[CAPACITY_IDX] = tag_felt;
Self { state }
}
pub fn absorb(&mut self, label: &[u8], value: &[Felt]) {
let label_felt = bytes_to_felt(label);
self.state[0] += label_felt;
poseidon_permute_comp(&mut self.state);
let n = value.len();
let mut idx = 0;
while idx < n {
let v0 = value[idx];
let v1 = if idx + 1 < n { value[idx + 1] } else { Felt::ZERO };
self.state[0] += v0;
self.state[1] += v1;
poseidon_permute_comp(&mut self.state);
idx += RATE;
}
let len_felt = Felt::from(n as u64);
self.state[0] += len_felt;
poseidon_permute_comp(&mut self.state);
}
pub fn squeeze(&mut self, n: usize) -> Vec<Felt> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
if i % RATE == 0 {
poseidon_permute_comp(&mut self.state);
}
out.push(self.state[i % RATE]);
}
out
}
}
#[derive(Clone, Debug)]
pub struct TranscriptT2 {
sponge: SpongeState,
}
impl TranscriptT2 {
pub fn new() -> Self {
Self {
sponge: SpongeState::init(b"vauban-claim-v2-transcript-t2"),
}
}
pub fn with_domain_tag(tag: &[u8]) -> Self {
Self {
sponge: SpongeState::init(tag),
}
}
pub fn absorb(&mut self, label: &[u8], value: &[Felt]) {
self.sponge.absorb(label, value);
}
pub fn squeeze(&mut self, n: usize) -> Vec<Felt> {
self.sponge.squeeze(n)
}
}
impl Default for TranscriptT2 {
fn default() -> Self {
Self::new()
}
}
pub mod canonical {
use alloc::vec::Vec;
use starknet_crypto::Felt;
use super::bytes_to_felt;
use super::sentinel_absent;
pub fn bytes_as_felt(data: &[u8]) -> Vec<Felt> {
alloc::vec![bytes_to_felt(data)]
}
pub fn u64_as_felt(v: u64) -> Vec<Felt> {
alloc::vec![Felt::from(v)]
}
pub fn absent() -> Vec<Felt> {
alloc::vec![sentinel_absent()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "std", serde(rename_all = "lowercase"))]
pub enum TranscriptVersion {
V1,
V2,
}
impl Default for TranscriptVersion {
fn default() -> Self {
Self::V1
}
}
pub fn bind_claim_v2(
claim: &crate::claim::Claim,
verifier_ctx: &[u8],
nonce: &[u8],
) -> Result<TranscriptT2, TranscriptError> {
use crate::codec::to_cbor_canonical;
let mut t = TranscriptT2::new();
let subject_bytes = to_cbor_canonical(&claim.subject)
.map_err(|e| TranscriptError::Encoding(e.to_string()))?;
t.absorb(b"subject", &canonical::bytes_as_felt(&subject_bytes));
let predicate_bytes = to_cbor_canonical(&claim.predicate)
.map_err(|e| TranscriptError::Encoding(e.to_string()))?;
t.absorb(b"predicate", &canonical::bytes_as_felt(&predicate_bytes));
let evidence_bytes = to_cbor_canonical(&claim.evidence)
.map_err(|e| TranscriptError::Encoding(e.to_string()))?;
t.absorb(b"evidence", &canonical::bytes_as_felt(&evidence_bytes));
{
let tf = &claim.temporal_frame;
t.absorb(b"temporal.not_before", &canonical::u64_as_felt(tf.not_before()));
let not_after_felts = match tf.not_after() {
Some(na) => canonical::u64_as_felt(na),
None => canonical::absent(),
};
t.absorb(b"temporal.not_after", ¬_after_felts);
}
let mask_bytes = to_cbor_canonical(&claim.revelation_mask)
.map_err(|e| TranscriptError::Encoding(e.to_string()))?;
t.absorb(b"revelation_mask", &canonical::bytes_as_felt(&mask_bytes));
let anchor_bytes = to_cbor_canonical(&claim.anchor)
.map_err(|e| TranscriptError::Encoding(e.to_string()))?;
t.absorb(b"anchor", &canonical::bytes_as_felt(&anchor_bytes));
t.absorb(b"verifier_ctx", &canonical::bytes_as_felt(verifier_ctx));
t.absorb(b"presentation_nonce", &canonical::bytes_as_felt(nonce));
Ok(t)
}
pub fn extract_challenge_v2(transcript: &mut TranscriptT2) -> Felt {
transcript.squeeze(1).into_iter().next()
.expect("invariant: squeeze(n=1) returns exactly 1 element")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::ClaimBuilder;
use crate::primitives::{
anchor::{Anchor, AnchorEntry, AnchorType},
evidence::{ByteString, Evidence, EvidenceEnvelope, EvidenceScheme, StarkProofEnvelope},
predicate::{Predicate, PredicateType},
revelation_mask::RevelationMask,
subject::Subject,
temporal::TemporalFrame,
};
#[test]
fn init_determinism() {
let t1 = TranscriptT2::new();
let t2 = TranscriptT2::new();
assert_eq!(
format!("{:?}", t1.sponge.state),
format!("{:?}", t2.sponge.state),
"TV-T2-001: init determinism"
);
let expected_tag = domain_tag_felt();
assert_eq!(t1.sponge.state[CAPACITY_IDX], expected_tag);
}
#[test]
fn single_field_absorb_determinism() {
let value = alloc::vec![Felt::from(42u64), Felt::from(99u64)];
let mut t1 = TranscriptT2::new();
t1.absorb(b"field", &value);
let c1 = t1.squeeze(1);
let mut t2 = TranscriptT2::new();
t2.absorb(b"field", &value);
let c2 = t2.squeeze(1);
assert_eq!(c1, c2, "TV-T2-002: single-field absorb determinism");
}
#[test]
fn multi_field_absorb_determinism() {
let fields: &[(&[u8], &[u64])] = &[
(b"subject", &[1, 2, 3]),
(b"predicate", &[42]),
(b"evidence", &[100, 200, 300, 400]),
];
let mut t1 = TranscriptT2::new();
let mut t2 = TranscriptT2::new();
for (label, nums) in fields {
let felts: Vec<Felt> = nums.iter().map(|&n| Felt::from(n)).collect();
t1.absorb(label, &felts);
t2.absorb(label, &felts);
}
let c1 = t1.squeeze(2);
let c2 = t2.squeeze(2);
assert_eq!(c1, c2, "TV-T2-003: multi-field absorb determinism");
}
#[test]
fn label_rebinding_rejection() {
let value = alloc::vec![Felt::from(1337u64)];
let mut t1 = TranscriptT2::new();
t1.absorb(b"subject", &value);
let c1 = t1.squeeze(1);
let mut t2 = TranscriptT2::new();
t2.absorb(b"predicate", &value);
let c2 = t2.squeeze(1);
assert_ne!(
c1, c2,
"TV-T2-004: different labels MUST produce different challenges"
);
}
#[test]
fn domain_tag_isolation() {
let value = alloc::vec![Felt::from(42u64)];
let mut t1 = TranscriptT2::new(); t1.absorb(b"field", &value);
let c1 = t1.squeeze(1);
let mut t2 = TranscriptT2::with_domain_tag(b"attacker-domain");
t2.absorb(b"field", &value);
let c2 = t2.squeeze(1);
assert_ne!(
c1, c2,
"TV-T2-005: different domain tags MUST produce different challenges (Q5 mitigation)"
);
}
#[test]
fn absent_field_sentinel_correctness() {
let mut t_sentinel = TranscriptT2::new();
t_sentinel.absorb(b"anchor", &canonical::absent());
let c_sentinel = t_sentinel.squeeze(1);
let mut t_empty = TranscriptT2::new();
t_empty.absorb(b"anchor", &[]);
let c_empty = t_empty.squeeze(1);
let mut t_zero = TranscriptT2::new();
t_zero.absorb(b"anchor", &[Felt::ZERO]);
let c_zero = t_zero.squeeze(1);
assert_ne!(
c_sentinel, c_empty,
"TV-T2-006a: sentinel ≠ empty-slice absorption"
);
assert_ne!(
c_sentinel, c_zero,
"TV-T2-006b: sentinel ≠ Felt::ZERO absorption"
);
}
#[test]
fn bind_claim_v2_determinism() {
let claim = minimal_claim();
let mut t1 = bind_claim_v2(&claim, b"verifier-1", b"nonce-1").unwrap();
let mut t2 = bind_claim_v2(&claim, b"verifier-1", b"nonce-1").unwrap();
let c1 = extract_challenge_v2(&mut t1);
let c2 = extract_challenge_v2(&mut t2);
assert_eq!(c1, c2, "TV-T2-007: bind_claim_v2 determinism");
}
#[test]
fn transcript_version_default_is_v1() {
assert_eq!(
TranscriptVersion::default(),
TranscriptVersion::V1,
"TV-T2-008: default transcript version must be V1 (backward compat)"
);
}
#[test]
fn nonce_binding() {
let claim = minimal_claim();
let mut t1 = bind_claim_v2(&claim, b"verifier-1", b"nonce-A").unwrap();
let mut t2 = bind_claim_v2(&claim, b"verifier-1", b"nonce-B").unwrap();
let c1 = extract_challenge_v2(&mut t1);
let c2 = extract_challenge_v2(&mut t2);
assert_ne!(c1, c2, "TV-T2-009: nonce binding");
}
#[test]
fn sextuplet_order_enforced() {
let s = alloc::vec![Felt::from(1u64)];
let p = alloc::vec![Felt::from(2u64)];
let mut t1 = TranscriptT2::new();
t1.absorb(b"subject", &s);
t1.absorb(b"predicate", &p);
let c1 = t1.squeeze(1);
let mut t2 = TranscriptT2::new();
t2.absorb(b"predicate", &p); t2.absorb(b"subject", &s);
let c2 = t2.squeeze(1);
assert_ne!(c1, c2, "TV-T2-010: sextuplet order must be canonical");
}
fn minimal_claim() -> crate::claim::Claim {
ClaimBuilder::default()
.subject(Subject::wallet(
hex::decode(
"bbbe91b88ff2842d7f7af15cd8154cdcc753dc3997e46be3568e7ef1ab5e90f4",
)
.unwrap(),
))
.predicate(
Predicate::new(
PredicateType::Equality,
"vauban.claim",
b"body".to_vec(),
)
.unwrap(),
)
.evidence(fixture_evidence_stark())
.temporal_frame(
TemporalFrame::new(1_700_000_000, None, None).unwrap(),
)
.revelation_mask(
RevelationMask::new(alloc::vec!["*".into()], alloc::vec![], None).unwrap(),
)
.anchor(
Anchor::new(alloc::vec![AnchorEntry {
anchor_type: AnchorType::StarknetL3,
r#ref: alloc::vec![0xab; 32],
epoch: Some(42),
nullifier: None,
meta: None,
}])
.unwrap(),
)
.build()
.expect("minimal_claim fixture")
}
fn fixture_evidence_stark() -> Evidence {
Evidence::new(
EvidenceScheme::Stark,
alloc::vec![0xcd; 64],
Some(EvidenceEnvelope::Stark(StarkProofEnvelope {
scheme: "stark".into(),
version: 1,
proof: alloc::vec![0xcd; 64],
public_inputs: alloc::vec![ByteString(alloc::vec![0x01; 32])],
verifier_params: None,
transcript_tag: None,
})),
)
.unwrap()
}
}