Skip to main content

zescrow_core/
commitment.rs

1//! Binding public commitment for the zero-knowledge proof journal.
2//!
3//! The guest commits a [`PublicCommitment`] to the receipt journal so a valid
4//! receipt is cryptographically tied to exactly one escrow settlement: an
5//! on-chain verifier reconstructs the same bytes from its own state, checks the
6//! seal against the pinned guest image id over `sha256(journal)`, and releases
7//! only on a match. The witness that satisfies the condition never enters the journal;
8//! only the binding fields and the pass/fail outcome are public.
9
10use sha2::{Digest, Sha256};
11
12use crate::error::CommitmentError;
13use crate::{Asset, AssetKind, BigNumber, Chain, Condition, Escrow, ExecutionState, ID};
14
15type Result<T> = std::result::Result<T, CommitmentError>;
16
17const JOURNAL_VERSION: u8 = 1;
18
19// Width, in bytes, of the big-endian amount field (256-bit).
20const AMOUNT_WIDTH: usize = 32;
21
22/// Outcome of escrow execution as committed to the public journal.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ExecutionResult {
25    /// Conditions were satisfied; the escrow reached the given terminal state.
26    Success(ExecutionState),
27    /// Execution failed; funds must not be released.
28    Failure,
29}
30
31/// The private input handed to the guest: the full escrow context plus the
32/// on-chain identifiers needed to bind the proof to a specific settlement.
33#[derive(Debug, Clone, bincode::Encode, bincode::Decode)]
34pub struct ProofInput {
35    /// The escrow whose conditions the guest verifies.
36    pub escrow: Escrow,
37    /// Network the escrow settles on.
38    pub chain: Chain,
39    /// On-chain escrow program id (Solana) or contract address (Ethereum).
40    pub agent_id: ID,
41    /// Unique on-chain identifier for this escrow instance.
42    pub escrow_id: u64,
43}
44
45/// The public commitment the guest writes to the receipt journal.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct PublicCommitment {
48    /// Network the escrow settles on.
49    pub chain: Chain,
50    /// Raw bytes of the on-chain program id / contract address.
51    pub agent_id: Vec<u8>,
52    /// Unique on-chain identifier for this escrow instance.
53    pub escrow_id: u64,
54    /// Raw address bytes of the funding party.
55    pub sender: Vec<u8>,
56    /// Raw address bytes of the recipient.
57    pub recipient: Vec<u8>,
58    /// Kind of asset under escrow.
59    pub asset_kind: AssetKind,
60    /// Raw bytes of the token contract / mint (empty for the native coin).
61    pub asset_token: Vec<u8>,
62    /// Amount under escrow, in the smallest unit.
63    pub amount: BigNumber,
64    /// Witness-free commitment to the release condition (all-zero when none).
65    pub condition: [u8; 32],
66    /// Pass/fail outcome of executing the escrow.
67    pub outcome: ExecutionResult,
68}
69
70impl PublicCommitment {
71    /// Builds the commitment from the guest input and the execution outcome.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`CommitmentError`] if any participant or asset identity cannot
76    /// be decoded into raw bytes.
77    pub fn new(input: &ProofInput, outcome: ExecutionResult) -> crate::Result<Self> {
78        let Asset {
79            kind,
80            agent_id,
81            amount,
82            ..
83        } = &input.escrow.asset;
84
85        let asset_token = agent_id
86            .as_ref()
87            .map(ID::to_bytes)
88            .transpose()?
89            .unwrap_or_default();
90
91        Ok(Self {
92            chain: input.chain,
93            agent_id: input.agent_id.to_bytes()?,
94            escrow_id: input.escrow_id,
95            sender: input.escrow.sender.to_bytes()?,
96            recipient: input.escrow.recipient.to_bytes()?,
97            asset_kind: *kind,
98            asset_token,
99            amount: amount.clone(),
100            condition: condition_commitment(input.escrow.condition.as_ref()),
101            outcome,
102        })
103    }
104
105    /// Serializes the commitment into the journal byte layout.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`CommitmentError::AmountTooLarge`] if the amount exceeds 256
110    /// bits, or [`CommitmentError::FieldTooLong`] if a length-prefixed field
111    /// exceeds `u16::MAX` bytes.
112    pub fn to_journal_bytes(&self) -> Result<Vec<u8>> {
113        let mut out = Vec::new();
114        out.push(JOURNAL_VERSION);
115        out.push(chain_tag(self.chain));
116
117        match self.outcome {
118            ExecutionResult::Success(state) => {
119                out.push(1);
120                out.push(state_tag(state));
121            }
122            ExecutionResult::Failure => out.push(0),
123        }
124
125        push_bytes(&mut out, &self.agent_id)?;
126        out.extend_from_slice(&self.escrow_id.to_be_bytes());
127        push_bytes(&mut out, &self.sender)?;
128        push_bytes(&mut out, &self.recipient)?;
129        out.push(asset_kind_tag(self.asset_kind));
130        push_bytes(&mut out, &self.asset_token)?;
131        out.extend_from_slice(&amount_be_bytes(&self.amount)?);
132        out.extend_from_slice(&self.condition);
133
134        Ok(out)
135    }
136
137    /// Parses a commitment back from journal bytes.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`CommitmentError`] if `bytes` is truncated, carries an unknown
142    /// version, or contains an unrecognized enum tag.
143    pub fn from_journal_bytes(bytes: &[u8]) -> Result<Self> {
144        let mut reader = Reader::new(bytes);
145
146        let version = reader.u8()?;
147        if version != JOURNAL_VERSION {
148            return Err(CommitmentError::UnknownVersion(version));
149        }
150
151        let chain = chain_from_tag(reader.u8()?)?;
152        let outcome = match reader.u8()? {
153            0 => ExecutionResult::Failure,
154            1 => ExecutionResult::Success(state_from_tag(reader.u8()?)?),
155            other => return Err(CommitmentError::unknown_tag("outcome", other)),
156        };
157
158        let agent_id = reader.length_prefixed()?.to_vec();
159        let escrow_id = reader.u64()?;
160        let sender = reader.length_prefixed()?.to_vec();
161        let recipient = reader.length_prefixed()?.to_vec();
162        let asset_kind = asset_kind_from_tag(reader.u8()?)?;
163        let asset_token = reader.length_prefixed()?.to_vec();
164        let amount = BigNumber(num_bigint::BigUint::from_bytes_be(
165            reader.take(AMOUNT_WIDTH)?,
166        ));
167        let condition = reader.array32()?;
168        reader.finish()?;
169
170        Ok(Self {
171            chain,
172            agent_id,
173            escrow_id,
174            sender,
175            recipient,
176            asset_kind,
177            asset_token,
178            amount,
179            condition,
180            outcome,
181        })
182    }
183
184    /// Returns `sha256` of the journal bytes; the digest an on-chain
185    /// verifier checks the receipt seal against.
186    ///
187    /// # Errors
188    ///
189    /// Propagates [`Self::to_journal_bytes`] errors.
190    pub fn digest(&self) -> Result<[u8; 32]> {
191        Ok(Sha256::digest(self.to_journal_bytes()?).into())
192    }
193}
194
195/// Commitment to an optional release condition: the condition's own commitment,
196/// or all-zero when the escrow has no cryptographic condition.
197fn condition_commitment(condition: Option<&Condition>) -> [u8; 32] {
198    condition.map(Condition::commitment).unwrap_or([0u8; 32])
199}
200
201/// Encodes `amount` as fixed-width 32-byte big-endian (`uint256`).
202fn amount_be_bytes(amount: &BigNumber) -> Result<[u8; AMOUNT_WIDTH]> {
203    let be = amount.0.to_bytes_be();
204    let offset = AMOUNT_WIDTH
205        .checked_sub(be.len())
206        .ok_or(CommitmentError::AmountTooLarge)?;
207    let mut padded = [0u8; AMOUNT_WIDTH];
208    padded[offset..].copy_from_slice(&be);
209    Ok(padded)
210}
211
212/// Appends a `u16` big-endian length prefix followed by `bytes`.
213fn push_bytes(out: &mut Vec<u8>, bytes: &[u8]) -> Result<()> {
214    let len = u16::try_from(bytes.len()).map_err(|_| CommitmentError::FieldTooLong(bytes.len()))?;
215    out.extend_from_slice(&len.to_be_bytes());
216    out.extend_from_slice(bytes);
217    Ok(())
218}
219
220fn chain_tag(chain: Chain) -> u8 {
221    match chain {
222        Chain::Ethereum => 0,
223        Chain::Solana => 1,
224    }
225}
226
227fn chain_from_tag(tag: u8) -> Result<Chain> {
228    match tag {
229        0 => Ok(Chain::Ethereum),
230        1 => Ok(Chain::Solana),
231        other => Err(CommitmentError::unknown_tag("chain", other)),
232    }
233}
234
235fn asset_kind_tag(kind: AssetKind) -> u8 {
236    match kind {
237        AssetKind::Native => 0,
238        AssetKind::Token => 1,
239    }
240}
241
242fn asset_kind_from_tag(tag: u8) -> Result<AssetKind> {
243    match tag {
244        0 => Ok(AssetKind::Native),
245        1 => Ok(AssetKind::Token),
246        other => Err(CommitmentError::unknown_tag("asset_kind", other)),
247    }
248}
249
250fn state_tag(state: ExecutionState) -> u8 {
251    match state {
252        ExecutionState::Initialized => 0,
253        ExecutionState::Funded => 1,
254        ExecutionState::ConditionsMet => 2,
255    }
256}
257
258fn state_from_tag(tag: u8) -> Result<ExecutionState> {
259    match tag {
260        0 => Ok(ExecutionState::Initialized),
261        1 => Ok(ExecutionState::Funded),
262        2 => Ok(ExecutionState::ConditionsMet),
263        other => Err(CommitmentError::unknown_tag("execution_state", other)),
264    }
265}
266
267/// Bounds-checked forward cursor over the journal bytes.
268struct Reader<'a> {
269    bytes: &'a [u8],
270    offset: usize,
271}
272
273impl<'a> Reader<'a> {
274    fn new(bytes: &'a [u8]) -> Self {
275        Self { bytes, offset: 0 }
276    }
277
278    fn take(&mut self, len: usize) -> Result<&'a [u8]> {
279        let end = self
280            .offset
281            .checked_add(len)
282            .ok_or(CommitmentError::Truncated(self.offset))?;
283        let slice = self
284            .bytes
285            .get(self.offset..end)
286            .ok_or(CommitmentError::Truncated(self.offset))?;
287        self.offset = end;
288        Ok(slice)
289    }
290
291    fn u8(&mut self) -> Result<u8> {
292        self.take(1).map(|s| s[0])
293    }
294
295    fn u64(&mut self) -> Result<u64> {
296        let bytes: [u8; 8] = self.take(8)?.try_into().expect("slice of length 8");
297        Ok(u64::from_be_bytes(bytes))
298    }
299
300    fn array32(&mut self) -> Result<[u8; 32]> {
301        Ok(self.take(32)?.try_into().expect("slice of length 32"))
302    }
303
304    fn length_prefixed(&mut self) -> Result<&'a [u8]> {
305        let len = u16::from_be_bytes(self.take(2)?.try_into().expect("slice of length 2"));
306        self.take(usize::from(len))
307    }
308
309    fn finish(self) -> Result<()> {
310        (self.offset == self.bytes.len())
311            .then_some(())
312            .ok_or(CommitmentError::TrailingBytes(self.offset))
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::{Party, Result};
320
321    fn sample(condition: Option<Condition>, outcome: ExecutionResult) -> Result<PublicCommitment> {
322        let escrow = Escrow::new(
323            Party::for_chain(
324                Chain::Ethereum,
325                "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
326            )?,
327            Party::for_chain(
328                Chain::Ethereum,
329                "0xEA674fdDe714fd979de3EdF0F56AA9716B898ec8",
330            )?,
331            Asset::native(BigNumber::from(1_000_000_000u64)),
332            condition,
333        );
334        let input = ProofInput {
335            escrow,
336            chain: Chain::Ethereum,
337            agent_id: ID::for_chain(
338                Chain::Ethereum,
339                "0x00000000000000000000000000000000DeaDBeef",
340            )?,
341            escrow_id: 42,
342        };
343        PublicCommitment::new(&input, outcome)
344    }
345
346    #[test]
347    fn journal_roundtrip_success() {
348        let commitment = sample(
349            None,
350            ExecutionResult::Success(ExecutionState::ConditionsMet),
351        )
352        .unwrap();
353        let bytes = commitment.to_journal_bytes().unwrap();
354        let decoded = PublicCommitment::from_journal_bytes(&bytes).unwrap();
355        assert_eq!(decoded, commitment);
356        assert_eq!(decoded.escrow_id, 42);
357        assert_eq!(decoded.amount, BigNumber::from(1_000_000_000u64));
358    }
359
360    #[test]
361    fn journal_roundtrip_failure_and_condition() {
362        let preimage = b"zkEscrow".to_vec();
363        let hash = sha2::Sha256::digest(&preimage).into();
364        let condition = Condition::hashlock(hash, preimage);
365        let expected = condition.commitment();
366
367        let commitment = sample(Some(condition), ExecutionResult::Failure).unwrap();
368        assert_eq!(commitment.condition, expected);
369
370        let bytes = commitment.to_journal_bytes().unwrap();
371        let decoded = PublicCommitment::from_journal_bytes(&bytes).unwrap();
372        assert_eq!(decoded, commitment);
373        assert_eq!(decoded.outcome, ExecutionResult::Failure);
374        assert!(decoded.condition != [0u8; 32]);
375    }
376
377    #[test]
378    fn no_condition_commits_zero() {
379        let commitment = sample(
380            None,
381            ExecutionResult::Success(ExecutionState::ConditionsMet),
382        )
383        .unwrap();
384        assert_eq!(commitment.condition, [0u8; 32]);
385    }
386
387    #[test]
388    fn digest_matches_sha256_of_journal() {
389        let commitment = sample(
390            None,
391            ExecutionResult::Success(ExecutionState::ConditionsMet),
392        )
393        .unwrap();
394        let bytes = commitment.to_journal_bytes().unwrap();
395        let expected: [u8; 32] = sha2::Sha256::digest(&bytes).into();
396        assert_eq!(commitment.digest().unwrap(), expected);
397    }
398
399    #[test]
400    fn truncated_journal_is_rejected() {
401        let commitment = sample(
402            None,
403            ExecutionResult::Success(ExecutionState::ConditionsMet),
404        )
405        .unwrap();
406        let bytes = commitment.to_journal_bytes().unwrap();
407        assert!(PublicCommitment::from_journal_bytes(&bytes[..bytes.len() - 1]).is_err());
408    }
409
410    #[test]
411    fn trailing_bytes_are_rejected() {
412        let commitment = sample(
413            None,
414            ExecutionResult::Success(ExecutionState::ConditionsMet),
415        )
416        .unwrap();
417        let mut bytes = commitment.to_journal_bytes().unwrap();
418        bytes.push(0);
419        assert!(PublicCommitment::from_journal_bytes(&bytes).is_err());
420    }
421
422    #[test]
423    fn unknown_version_is_rejected() {
424        let commitment = sample(
425            None,
426            ExecutionResult::Success(ExecutionState::ConditionsMet),
427        )
428        .unwrap();
429        let mut bytes = commitment.to_journal_bytes().unwrap();
430        bytes[0] = 0xFF;
431        assert!(matches!(
432            PublicCommitment::from_journal_bytes(&bytes),
433            Err(CommitmentError::UnknownVersion(0xFF))
434        ));
435    }
436}