Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/contracts/attest/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions packages/contracts/attest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ publish = false
rust-version = "1.89.0"

[lib]
crate-type = ["cdylib", "rlib"]
crate-type = ["cdylib"]
doctest = false

[dependencies]
soroban-sdk = { version = "28.0.0" }
soroban-sdk = { version = "25.1.0" }

[dev-dependencies]
soroban-sdk = { version = "28.0.0", features = ["testutils"] }
soroban-sdk = { version = "25.1.0", features = ["testutils"] }

[profile.release]
opt-level = "z"
Expand Down
173 changes: 126 additions & 47 deletions packages/contracts/attest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,13 @@ use soroban_sdk::{

const VK_KEY: Symbol = symbol_short!("VK");
const ROOTS: Symbol = symbol_short!("ROOTS");
/// Registry root history kept by `set_root`: the newest MAX_ROOTS roots stay valid, so a
/// proof built against a recently replaced root still verifies while storage stays bounded.
pub const MAX_ROOTS: u32 = 32;

// Persistent entries (the per-content nullifier guard and the vouch count) are
// bumped to roughly a month of ledgers so they outlive the attestation window.
// Without this the default (much shorter) entry TTL would let a used nullifier
// be replayed and reset `get_vouches` for content that was genuinely vouched.
const PERSISTENT_TTL_THRESHOLD: u32 = 100_000;
const PERSISTENT_TTL_EXTEND_TO: u32 = 535_680;

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
Expand Down Expand Up @@ -156,8 +160,7 @@ fn parse_signals(env: &Env, bytes: &Bytes) -> Result<Vec<Fr>, Error> {

fn verify_proof(env: &Env, vk: VerificationKey, proof: Proof, pub_signals: Vec<Fr>) -> Result<bool, Error> {
if pub_signals.len() + 1 != vk.ic.len() {
// the key itself parsed fine: the fault is the number of public signals
return Err(Error::WrongSignalCount);
return Err(Error::MalformedVerifyingKey);
}
let bls = env.crypto().bls12_381();
let mut vk_x = vk.ic.get(0).unwrap();
Expand All @@ -171,30 +174,6 @@ fn verify_proof(env: &Env, vk: VerificationKey, proof: Proof, pub_signals: Vec<F
Ok(bls.pairing_check(vp1, vp2))
}

/// How long a recorded vouch and its nullifier must survive without being touched: ~30 days of
/// 5-second ledgers. Without an extension both persistent entries would expire after the network's
/// minimum TTL; an expired nullifier would let the same human vouch again (replay).
pub const VOUCH_TTL_LEDGERS: u32 = 518_400;

/// One human, one vouch per content: store the nullifier, bump the count, extend both TTLs.
fn record_vouch(env: &Env, content: BytesN<32>, nullifier: BytesN<32>) -> Result<u32, Error> {
let store = env.storage().persistent();
let nk = DataKey::Nullifier(content.clone(), nullifier);
if store.has(&nk) {
return Err(Error::AlreadyVouched);
}
store.set(&nk, &true);

let vkey = DataKey::Vouches(content);
let count: u32 = store.get(&vkey).unwrap_or(0) + 1;
store.set(&vkey, &count);

let extend_to = VOUCH_TTL_LEDGERS.min(env.storage().max_ttl());
store.extend_ttl(&nk, extend_to, extend_to);
store.extend_ttl(&vkey, extend_to, extend_to);
Ok(count)
}

#[contract]
pub struct AttestContract;

Expand All @@ -207,8 +186,7 @@ impl AttestContract {
Ok(())
}

/// Registry issuer publishes a valid Merkle root (idempotent). Keeps the newest
/// `MAX_ROOTS` roots and evicts the oldest beyond that.
/// Registry issuer publishes a valid Merkle root (idempotent).
pub fn set_root(env: Env, root: BytesN<32>) {
let mut roots: Vec<BytesN<32>> =
env.storage().instance().get(&ROOTS).unwrap_or(Vec::new(&env));
Expand All @@ -221,9 +199,6 @@ impl AttestContract {
}
if !found {
roots.push_back(root);
while roots.len() > MAX_ROOTS {
roots.pop_front(); // evict the oldest root
}
env.storage().instance().set(&ROOTS, &roots);
}
}
Expand Down Expand Up @@ -268,7 +243,23 @@ impl AttestContract {
return Err(Error::InvalidProof);
}

record_vouch(&env, content, nullifier)
// one human, one vouch per content
let nk = DataKey::Nullifier(content.clone(), nullifier);
if env.storage().persistent().has(&nk) {
return Err(Error::AlreadyVouched);
}
env.storage().persistent().set(&nk, &true);
env.storage()
.persistent()
.extend_ttl(&nk, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);

let vkey = DataKey::Vouches(content);
let count: u32 = env.storage().persistent().get(&vkey).unwrap_or(0) + 1;
env.storage().persistent().set(&vkey, &count);
env.storage()
.persistent()
.extend_ttl(&vkey, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
Ok(count)
}

pub fn get_vouches(env: Env, content_hash: BytesN<32>) -> u32 {
Expand All @@ -281,19 +272,107 @@ impl AttestContract {

#[cfg(test)]
mod test {
extern crate std;
use super::*;
use soroban_sdk::crypto::bls12_381::{G1Affine, G2Affine};
use soroban_sdk::testutils::storage::Persistent as _;

const VK_LEN: usize = G1_SERIALIZED_SIZE * 5 + G2_SERIALIZED_SIZE * 3 + 4;
const PROOF_LEN: usize = G1_SERIALIZED_SIZE * 2 + G2_SERIALIZED_SIZE;
const SIGNALS_LEN: usize = 4 + 32 * 3;

fn put(buf: &mut [u8], pos: &mut usize, bytes: &[u8]) {
buf[*pos..*pos + bytes.len()].copy_from_slice(bytes);
*pos += bytes.len();
}

fn identity_g1() -> [u8; G1_SERIALIZED_SIZE] {
let mut b = [0u8; G1_SERIALIZED_SIZE];
b[0] = 0x40; // uncompressed point-at-infinity encoding
b
}

fn identity_g2() -> [u8; G2_SERIALIZED_SIZE] {
let mut b = [0u8; G2_SERIALIZED_SIZE];
b[0] = 0x40;
b
}

/// Build a verifying key and proof whose pairing check succeeds without a
/// real trusted setup: the only non-identity pairs are `e(P, Q)` and
/// `e(-P, Q)`, whose product is the identity. Every public signal is fed
/// against an identity `ic` entry, so the signal values stay unconstrained —
/// enough to exercise the storage/TTL path under test.
fn vk_and_proof(env: &Env) -> (Bytes, Bytes) {
let bls = env.crypto().bls12_381();
let msg = Bytes::from_slice(env, b"humanvouch-attest-test");
let dst = Bytes::from_slice(env, b"HV_ATTEST_TEST_DST");
let p: G1Affine = bls.hash_to_g1(&msg, &dst);
let q: G2Affine = bls.hash_to_g2(&msg, &dst);
let neg_p = -p;

let mut vk = [0u8; VK_LEN];
let mut pos = 0usize;
put(&mut vk, &mut pos, &identity_g1()); // alpha
put(&mut vk, &mut pos, &identity_g2()); // beta
put(&mut vk, &mut pos, &q.to_array()); // gamma
put(&mut vk, &mut pos, &identity_g2()); // delta
put(&mut vk, &mut pos, &4u32.to_be_bytes()); // ic length
put(&mut vk, &mut pos, &neg_p.to_array()); // ic[0]
put(&mut vk, &mut pos, &identity_g1()); // ic[1]
put(&mut vk, &mut pos, &identity_g1()); // ic[2]
put(&mut vk, &mut pos, &identity_g1()); // ic[3]

let mut proof = [0u8; PROOF_LEN];
let mut pos = 0usize;
put(&mut proof, &mut pos, &neg_p.to_array()); // a -> neg_a = P
put(&mut proof, &mut pos, &q.to_array()); // b
put(&mut proof, &mut pos, &identity_g1()); // c

(Bytes::from_array(env, &vk), Bytes::from_array(env, &proof))
}

fn signals(env: &Env, root: &[u8; 32], nullifier: &[u8; 32], content: &[u8; 32]) -> Bytes {
let mut buf = [0u8; SIGNALS_LEN];
let mut pos = 0usize;
put(&mut buf, &mut pos, &3u32.to_be_bytes());
put(&mut buf, &mut pos, root);
put(&mut buf, &mut pos, nullifier);
put(&mut buf, &mut pos, content);
Bytes::from_array(env, &buf)
}

#[test]
fn test_harness_links() {
// The crate now builds as an rlib, so #[cfg(test)] modules link and run.
fn attest_extends_persistent_ttl() {
let env = Env::default();
let contract_id = env.register(AttestContract, ());
let client = AttestContractClient::new(&env, &contract_id);

let (vk, proof) = vk_and_proof(&env);
client.set_vk(&vk);
let root = [7u8; 32];
client.set_root(&BytesN::from_array(&env, &root));

let nullifier = [1u8; 32];
let content = [2u8; 32];
assert_eq!(
client.attest(&proof, &signals(&env, &root, &nullifier, &content)),
1
);

env.as_contract(&contract_id, || {
// A scratch entry written now carries the ledger's default persistent TTL.
let scratch = DataKey::Vouches(BytesN::from_array(&env, &[0u8; 32]));
env.storage().persistent().set(&scratch, &0u32);
let default_ttl = env.storage().persistent().get_ttl(&scratch);

let nk = DataKey::Nullifier(
BytesN::from_array(&env, &content),
BytesN::from_array(&env, &nullifier),
);
let vkey = DataKey::Vouches(BytesN::from_array(&env, &content));

assert!(env.storage().persistent().get_ttl(&nk) > default_ttl);
assert!(env.storage().persistent().get_ttl(&vkey) > default_ttl);
});
}
}

#[cfg(test)]
mod test_ttl;
mod test_bounded_roots;
mod test_signal_count;
mod test_malformed;
mod test_root_first;
mod test_roots;
mod test_no_vk;