Skip to content

Commit 7ed017e

Browse files
committed
feat(api): improve proof ergonomics
Bind fetched non-inclusion proofs to their requested state IDs and add explicit verification for decoded proofs. Add proof-carrying membership status, public terminal accessors, typed certificate construction, and an additive non-inclusion client extension trait.
1 parent cd291d7 commit 7ed017e

9 files changed

Lines changed: 338 additions & 57 deletions

File tree

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ Non-inclusion has a relation-specific API; applications never need to know
6767
that its Merkle path has internal machinery in common with inclusion:
6868

6969
```rust
70-
use unicity_token::client::AggregatorClient;
70+
use unicity_token::client::NonInclusionAggregatorClient;
7171

7272
let proof = aggregator.get_non_inclusion_proof(&state_id)?;
73-
proof.verify(&state_id, &trust_base)?;
73+
proof.verify(&trust_base)?; // verifies the StateId bound by the client
7474
```
7575

7676
Verification authenticates the terminal leaf and every branch choice against
@@ -81,6 +81,18 @@ acceptable certified round or timestamp. The HTTP client distinguishes an
8181
already-included state (`HttpError::StateIncluded`) from the absence of any
8282
certified root (`HttpError::CertifiedStateUnavailable`).
8383

84+
If the caller does not know which relation holds, the HTTP client hides the
85+
endpoint selection:
86+
87+
```rust
88+
use unicity_token::client::MembershipStatus;
89+
90+
match aggregator.membership_status(&state_id)? {
91+
MembershipStatus::Included(proof) => proof.verify_for(&state_id, &trust_base)?,
92+
MembershipStatus::Absent(proof) => proof.verify(&trust_base)?,
93+
}
94+
```
95+
8496
## Payment tokens & splits
8597

8698
A token can carry a fungible **payment payload** (a canonical set of asset id →

src/api/inclusion_proof.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
44
use alloc::vec::Vec;
55

6-
use super::bft::UnicityCertificate;
6+
use super::bft::{RootTrustBase, UnicityCertificate};
77
use super::certification::CertificationData;
88
use super::inclusion_certificate::InclusionCertificate;
9+
use super::StateId;
910
use crate::cbor::{
1011
encode_array, encode_byte_string, encode_nullable, encode_tag, encode_uint, Decoder,
1112
};
1213
use crate::error::Error;
14+
use crate::verify::{self, VerificationError};
1315

1416
/// CBOR tag for [`InclusionProof`].
1517
pub const INCLUSION_PROOF_TAG: u64 = 39033;
@@ -59,4 +61,17 @@ impl InclusionProof {
5961
]),
6062
)
6163
}
64+
65+
/// Verify that this proof includes `state_id` at its certified root.
66+
///
67+
/// This verifies the state relation, certification data, shard, quorum UC,
68+
/// and unlock witness. Transaction/token verification may impose additional
69+
/// application-level constraints.
70+
pub fn verify_for(
71+
&self,
72+
state_id: &StateId,
73+
trust_base: &RootTrustBase,
74+
) -> Result<(), VerificationError> {
75+
verify::verify_inclusion_proof_for(trust_base, self, state_id)
76+
}
6277
}

src/api/non_inclusion_certificate.rs

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,40 @@ struct Body {
3232
}
3333

3434
impl NonInclusionCertificate {
35+
/// Construct the distinguished certificate for an empty tree.
36+
pub fn empty_tree() -> Self {
37+
Self { body: None }
38+
}
39+
40+
/// Construct a non-empty certificate from its typed components.
41+
///
42+
/// Siblings are ordered root-to-leaf and their count must equal the bitmap
43+
/// population count. The fixed terminal-value width is a Unicity profile
44+
/// restriction; the generic RSMT format permits arbitrary byte strings.
45+
pub fn from_parts(
46+
bitmap: [u8; BITMAP_SIZE],
47+
siblings: Vec<[u8; HASH_SIZE]>,
48+
terminal_key: [u8; TERMINAL_KEY_SIZE],
49+
terminal_value: [u8; AGGREGATION_TREE_VALUE_SIZE],
50+
) -> Result<Self, Error> {
51+
let expected = bitmap.iter().map(|byte| byte.count_ones()).sum::<u32>() as usize;
52+
if siblings.len() != expected {
53+
return Err(Error::InvalidLength {
54+
what: "NonInclusionCertificate siblings",
55+
expected,
56+
actual: siblings.len(),
57+
});
58+
}
59+
Ok(Self {
60+
body: Some(Body {
61+
bitmap,
62+
siblings,
63+
terminal_key,
64+
terminal_value,
65+
}),
66+
})
67+
}
68+
3569
/// Decode the canonical raw-byte representation.
3670
///
3771
/// The empty byte string is the distinguished certificate for an empty
@@ -40,7 +74,7 @@ impl NonInclusionCertificate {
4074
/// where `n` is the bitmap population count.
4175
pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
4276
if bytes.is_empty() {
43-
return Ok(Self { body: None });
77+
return Ok(Self::empty_tree());
4478
}
4579
if bytes.len() < BITMAP_SIZE {
4680
return Err(Error::InvalidLength {
@@ -92,14 +126,7 @@ impl NonInclusionCertificate {
92126
.try_into()
93127
.expect("length checked");
94128

95-
Ok(Self {
96-
body: Some(Body {
97-
bitmap,
98-
siblings,
99-
terminal_key,
100-
terminal_value,
101-
}),
102-
})
129+
Self::from_parts(bitmap, siblings, terminal_key, terminal_value)
103130
}
104131

105132
/// Encode to the canonical raw-byte representation.
@@ -123,10 +150,26 @@ impl NonInclusionCertificate {
123150
}
124151

125152
/// Whether this is the distinguished certificate for an empty tree.
126-
pub fn is_empty(&self) -> bool {
153+
pub fn is_empty_tree(&self) -> bool {
127154
self.body.is_none()
128155
}
129156

157+
/// The authenticated terminal key, or `None` for an empty-tree certificate.
158+
///
159+
/// Treat this as untrusted data until certificate or proof verification has
160+
/// succeeded.
161+
pub fn terminal_key(&self) -> Option<&[u8; TERMINAL_KEY_SIZE]> {
162+
self.body.as_ref().map(|body| &body.terminal_key)
163+
}
164+
165+
/// The authenticated terminal value, or `None` for an empty-tree certificate.
166+
///
167+
/// Treat this as untrusted data until certificate or proof verification has
168+
/// succeeded.
169+
pub fn terminal_value(&self) -> Option<&[u8; AGGREGATION_TREE_VALUE_SIZE]> {
170+
self.body.as_ref().map(|body| &body.terminal_value)
171+
}
172+
130173
/// Verify both the authenticated path and the non-inclusion relation.
131174
/// `expected_root` is `None` only for an empty certified tree.
132175
///
@@ -167,10 +210,6 @@ impl NonInclusionCertificate {
167210
.as_ref()
168211
.is_some_and(|body| &body.terminal_key == target.bytes())
169212
}
170-
171-
pub(crate) fn terminal_key(&self) -> Option<&[u8; TERMINAL_KEY_SIZE]> {
172-
self.body.as_ref().map(|body| &body.terminal_key)
173-
}
174213
}
175214

176215
#[cfg(test)]
@@ -196,9 +235,9 @@ mod tests {
196235

197236
#[test]
198237
fn empty_certificate_only_verifies_empty_root() {
199-
let certificate = NonInclusionCertificate::decode(&[]).unwrap();
238+
let certificate = NonInclusionCertificate::empty_tree();
200239
let target = state_id([7u8; 32]);
201-
assert!(certificate.is_empty());
240+
assert!(certificate.is_empty_tree());
202241
assert_eq!(certificate.verify(&target, None), Ok(()));
203242
assert_eq!(
204243
certificate.verify(&target, Some(&leaf_root(&[1; 32], &[2; 32]))),
@@ -211,10 +250,16 @@ mod tests {
211250
fn singleton_terminal_proves_another_key_absent() {
212251
let terminal_key = [1u8; 32];
213252
let terminal_value = [2u8; 32];
253+
let certificate = NonInclusionCertificate::from_parts(
254+
[0u8; BITMAP_SIZE],
255+
vec![],
256+
terminal_key,
257+
terminal_value,
258+
)
259+
.unwrap();
214260
let mut encoded = vec![0u8; BITMAP_SIZE];
215261
encoded.extend_from_slice(&terminal_key);
216262
encoded.extend_from_slice(&terminal_value);
217-
let certificate = NonInclusionCertificate::decode(&encoded).unwrap();
218263
let root = leaf_root(&terminal_key, &terminal_value);
219264

220265
assert_eq!(
@@ -225,6 +270,8 @@ mod tests {
225270
certificate.verify(&state_id(terminal_key), Some(&root)),
226271
Err(VerificationError::StateIncluded)
227272
);
273+
assert_eq!(certificate.terminal_key(), Some(&terminal_key));
274+
assert_eq!(certificate.terminal_value(), Some(&terminal_value));
228275
assert_eq!(certificate.encode(), encoded);
229276
}
230277

@@ -245,12 +292,15 @@ mod tests {
245292
.update(right_hash.data())
246293
.finalize();
247294

248-
let mut encoded = vec![0u8; BITMAP_SIZE];
249-
encoded[0] = 0x80;
250-
encoded.extend_from_slice(right_hash.data());
251-
encoded.extend_from_slice(&left_key);
252-
encoded.extend_from_slice(&left_value);
253-
let certificate = NonInclusionCertificate::decode(&encoded).unwrap();
295+
let mut bitmap = [0u8; BITMAP_SIZE];
296+
bitmap[0] = 0x80;
297+
let certificate = NonInclusionCertificate::from_parts(
298+
bitmap,
299+
vec![right_hash.data().try_into().expect("SHA-256 length")],
300+
left_key,
301+
left_value,
302+
)
303+
.unwrap();
254304

255305
let mut target_left = [0u8; 32];
256306
target_left[31] = 1;
@@ -270,6 +320,16 @@ mod tests {
270320

271321
#[test]
272322
fn decoder_rejects_wrong_terminal_or_sibling_lengths() {
323+
let mut bitmap = [0u8; BITMAP_SIZE];
324+
bitmap[0] = 0x80;
325+
assert!(NonInclusionCertificate::from_parts(
326+
bitmap,
327+
vec![],
328+
[1u8; 32],
329+
[2u8; AGGREGATION_TREE_VALUE_SIZE],
330+
)
331+
.is_err());
332+
273333
assert!(NonInclusionCertificate::decode(&[0u8; 32]).is_err());
274334

275335
let mut one_sibling_without_terminal = vec![0u8; 64];

src/api/non_inclusion_proof.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const VERSION: u64 = 1;
2323
pub struct NonInclusionProof {
2424
certificate: NonInclusionCertificate,
2525
unicity_certificate: UnicityCertificate,
26+
/// Local request context. This is deliberately not part of the wire format.
27+
requested_state_id: Option<StateId>,
2628
}
2729

2830
impl NonInclusionProof {
@@ -34,6 +36,7 @@ impl NonInclusionProof {
3436
Self {
3537
certificate,
3638
unicity_certificate,
39+
requested_state_id: None,
3740
}
3841
}
3942

@@ -52,6 +55,7 @@ impl NonInclusionProof {
5255
Ok(Self {
5356
certificate,
5457
unicity_certificate,
58+
requested_state_id: None,
5559
})
5660
}
5761

@@ -77,12 +81,57 @@ impl NonInclusionProof {
7781
&self.unicity_certificate
7882
}
7983

80-
/// Verify the complete proof for `target` against `trust_base`.
81-
pub fn verify(
84+
/// Bind this proof to the state id used to request it.
85+
///
86+
/// The binding is local misuse-prevention metadata and is not serialized by
87+
/// [`to_cbor`](Self::to_cbor). Verification still authenticates the bound
88+
/// id cryptographically against the certificate.
89+
pub fn for_state(mut self, state_id: &StateId) -> Result<Self, VerificationError> {
90+
if self
91+
.requested_state_id
92+
.as_ref()
93+
.is_some_and(|requested| requested != state_id)
94+
{
95+
return Err(VerificationError::NonInclusionTargetMismatch);
96+
}
97+
self.requested_state_id = Some(state_id.clone());
98+
Ok(self)
99+
}
100+
101+
/// The state id stamped onto this proof by the client, if any.
102+
pub fn requested_state_id(&self) -> Option<&StateId> {
103+
self.requested_state_id.as_ref()
104+
}
105+
106+
/// Verify a client-bound proof against `trust_base`.
107+
///
108+
/// Proofs returned by the provided clients are bound automatically. A proof
109+
/// decoded directly from untrusted bytes has no request context; use
110+
/// [`verify_for`](Self::verify_for) for that case.
111+
pub fn verify(&self, trust_base: &RootTrustBase) -> Result<(), VerificationError> {
112+
let target = self
113+
.requested_state_id
114+
.as_ref()
115+
.ok_or(VerificationError::NonInclusionTargetMissing)?;
116+
verify::verify_non_inclusion_proof(trust_base, self, target)
117+
}
118+
119+
/// Verify a directly decoded proof for an explicit `target`.
120+
///
121+
/// If the proof is already client-bound, a different target is rejected so
122+
/// request context cannot be silently replaced.
123+
pub fn verify_for(
82124
&self,
83125
target: &StateId,
84126
trust_base: &RootTrustBase,
85127
) -> Result<(), VerificationError> {
128+
if self
129+
.requested_state_id
130+
.as_ref()
131+
.is_some_and(|requested| requested != target)
132+
{
133+
return Err(VerificationError::NonInclusionTargetMismatch);
134+
}
86135
verify::verify_non_inclusion_proof(trust_base, self, target)
87136
}
88137
}

src/client/http.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use crate::api::inclusion_proof::InclusionProof;
2121
use crate::api::{CertificationData, NonInclusionProof, StateId};
2222
use crate::cbor::Decoder;
2323

24-
use super::AggregatorClient;
24+
use super::{AggregatorClient, MembershipStatus, NonInclusionAggregatorClient};
2525

2626
const MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024;
2727
const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
@@ -189,6 +189,22 @@ impl HttpAggregatorClient {
189189
self
190190
}
191191

192+
/// Determine whether `state_id` is included or absent at a certified root.
193+
///
194+
/// This hides the endpoint-selection round trip: absence normally takes one
195+
/// RPC, while an included state triggers an inclusion-proof lookup after the
196+
/// non-inclusion endpoint reports that the relation is false. The two RPCs
197+
/// are not an atomic read if the certified root advances between them; each
198+
/// returned proof authenticates the root it carries.
199+
pub fn membership_status(&self, state_id: &StateId) -> Result<MembershipStatus, HttpError> {
200+
match NonInclusionAggregatorClient::get_non_inclusion_proof(self, state_id) {
201+
Ok(proof) => Ok(MembershipStatus::Absent(proof)),
202+
Err(HttpError::StateIncluded) => AggregatorClient::get_inclusion_proof(self, state_id)
203+
.map(MembershipStatus::Included),
204+
Err(error) => Err(error),
205+
}
206+
}
207+
192208
fn validate_endpoint(&self) -> Result<url::Url, HttpError> {
193209
let parsed = url::Url::parse(&self.url)
194210
.map_err(|e| HttpError::Configuration(format!("invalid gateway URL: {e}")))?;
@@ -411,7 +427,9 @@ impl AggregatorClient for HttpAggregatorClient {
411427
}
412428
Err(HttpError::Timeout)
413429
}
430+
}
414431

432+
impl NonInclusionAggregatorClient for HttpAggregatorClient {
415433
fn get_non_inclusion_proof(&self, state_id: &StateId) -> Result<NonInclusionProof, HttpError> {
416434
let params = serde_json::json!({ "stateId": hex::encode(state_id.bytes()) });
417435
let result = match self.rpc("get_non_inclusion_proof.v1", params, &[]) {
@@ -433,7 +451,9 @@ impl AggregatorClient for HttpAggregatorClient {
433451
});
434452
}
435453
let bytes = hex::decode(encoded).map_err(|e| HttpError::Decode(e.to_string()))?;
436-
decode_non_inclusion_proof_response(&bytes)
454+
decode_non_inclusion_proof_response(&bytes)?
455+
.for_state(state_id)
456+
.map_err(|error| HttpError::Decode(error.to_string()))
437457
}
438458
}
439459

0 commit comments

Comments
 (0)