Skip to content

Commit ff99c06

Browse files
authored
Merge pull request #16 from hyli-org/withdraw
Add withdraw flow
2 parents 4c2fb4a + 3cf9dfd commit ff99c06

26 files changed

Lines changed: 950 additions & 87 deletions

contracts/hyli-utxo-state/src/state.rs

Lines changed: 155 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use acvm::FieldElement;
44
use borsh::{BorshDeserialize, BorshSerialize};
55
use hex;
66
use sdk::{
7-
merkle_utils::BorshableMerkleProof, utils::parse_raw_calldata, Calldata, ContractName,
8-
RunResult, StateCommitment,
7+
caller::ExecutionContext, merkle_utils::BorshableMerkleProof, utils::parse_calldata, Calldata,
8+
ContractName, RunResult, StateCommitment, StructuredBlobData,
99
};
1010
use sparse_merkle_tree::H256;
1111

@@ -20,6 +20,7 @@ const MAX_ROOTS: usize = 1000;
2020
pub struct ContractConfig {
2121
pub utxo_contract_name: ContractName,
2222
pub smt_incl_proof_contract_name: ContractName,
23+
pub smt_contract_name: ContractName,
2324
}
2425

2526
#[derive(Debug, Default, BorshSerialize, BorshDeserialize)]
@@ -273,7 +274,19 @@ impl HyliUtxoZkVmState {
273274
}
274275
}
275276

276-
fn check_noir_blobs(&self, calldata: &Calldata) -> Result<(), String> {
277+
fn parse_smt_incl_blob_payload(blob: &sdk::Blob) -> Result<Vec<u8>, String> {
278+
let structured: StructuredBlobData<Vec<u8>> =
279+
blob.data.clone().try_into().map_err(|_| {
280+
"failed to parse hyli_smt_incl_proof blob as structured blob".to_string()
281+
})?;
282+
Ok(structured.parameters)
283+
}
284+
285+
fn check_noir_blobs(
286+
&self,
287+
calldata: &Calldata,
288+
ctx: &mut ExecutionContext,
289+
) -> Result<(), String> {
277290
let Some((_, hyli_utxo_blob)) = calldata
278291
.blobs
279292
.iter()
@@ -282,23 +295,27 @@ impl HyliUtxoZkVmState {
282295
return Err("hyli_utxo_noir blob not provided in calldata".to_string());
283296
};
284297

285-
let Some((_, smt_blob)) = calldata
286-
.blobs
298+
let Some(smt_incl_blob_index) = ctx
299+
.callees_blobs
287300
.iter()
288-
.find(|(_, blob)| blob.contract_name == self.config.smt_incl_proof_contract_name)
301+
.position(|blob| blob.contract_name == self.config.smt_incl_proof_contract_name)
289302
else {
290-
return Err("hyli_smt_incl_proof_noir blob not provided in calldata".to_string());
303+
return Err(
304+
"hyli_smt_incl_proof_noir callee blob not provided in calldata".to_string(),
305+
);
291306
};
307+
let smt_incl_blob = ctx.callees_blobs.remove(smt_incl_blob_index);
292308

293-
// Step 1: Check that the smt_blob's notes root matches the computed notes root from the witness.
309+
// Step 1: Check that the smt_incl_blob's notes root matches the computed notes root from the witness.
310+
let smt_blob_payload = Self::parse_smt_incl_blob_payload(&smt_incl_blob)?;
294311
let (smt_nullifier0, smt_nullifier1, smt_blob_notes_root) =
295-
parse_hyli_smt_incl_blob(&smt_blob.data.0)?;
312+
parse_hyli_smt_incl_blob(&smt_blob_payload)?;
296313

297314
if !self.roots.contains(smt_blob_notes_root) {
298315
return Err("smt inclusion proof blob does not match notes root".to_string());
299316
}
300317

301-
// Step 2: Check that the nullifiers in the smt blob match those in the utxo blob.
318+
// Step 2: Check that the nullifiers in the smt_incl_blob match those in the utxo blob.
302319
let (_, utxo_nullifiers) = parse_hyli_utxo_blob(&hyli_utxo_blob.data.0)?;
303320

304321
if utxo_nullifiers[0] != smt_nullifier0 {
@@ -314,13 +331,35 @@ impl HyliUtxoZkVmState {
314331
);
315332
}
316333

334+
// Optional step 3: Check that the blob callee topology matches the expected topology for a withdraw transaction:
335+
let withdraw_callees = ctx
336+
.callees_blobs
337+
.iter()
338+
.enumerate()
339+
.filter(|(_, blob)| blob.contract_name == self.config.smt_contract_name)
340+
.collect::<Vec<_>>();
341+
342+
if withdraw_callees.len() > 1 {
343+
return Err("multiple withdraw callees found for hyli-utxo-state blob".to_string());
344+
}
345+
if let Some((token_blob_index, _)) = withdraw_callees.first() {
346+
ctx.callees_blobs.remove(*token_blob_index);
347+
}
348+
if !ctx.callees_blobs.is_empty() {
349+
return Err(format!(
350+
"hyli-utxo-state callee set mismatch: unexpected remaining callees {:?}",
351+
ctx.callees_blobs
352+
));
353+
}
354+
317355
Ok(())
318356
}
319357

320358
fn apply_action(&mut self, calldata: &Calldata) -> Result<(), String> {
321-
let hyli_utxo_blob = calldata
359+
let (_, hyli_utxo_blob) = calldata
322360
.blobs
323-
.get(&sdk::BlobIndex(1))
361+
.iter()
362+
.find(|(_, blob)| blob.contract_name == self.config.utxo_contract_name)
324363
.ok_or_else(|| "hyli_utxo blob not found in calldata".to_string())?;
325364

326365
let (created, nullified) = parse_hyli_utxo_blob(&hyli_utxo_blob.data.0)
@@ -368,12 +407,12 @@ impl HyliUtxoZkVmState {
368407

369408
impl sdk::ZkContract for HyliUtxoZkVmState {
370409
fn execute(&mut self, calldata: &Calldata) -> RunResult {
371-
let (_, ctx) = parse_raw_calldata::<HyliUtxoStateAction>(calldata)?;
410+
let (_, mut ctx) = parse_calldata::<HyliUtxoStateAction>(calldata)?;
372411

373412
self.created_notes.ensure_all_zero()?;
374413
self.nullified_notes.ensure_all_zero()?;
375414

376-
self.check_noir_blobs(calldata)?;
415+
self.check_noir_blobs(calldata, &mut ctx)?;
377416

378417
self.apply_action(calldata)?;
379418

@@ -484,11 +523,13 @@ pub fn parse_hyli_smt_incl_blob(
484523
#[cfg(test)]
485524
mod tests {
486525
use super::*;
526+
use sdk::{Blob, BlobData, BlobIndex, ContractName, StructuredBlobData, TxHash};
487527

488528
fn state_with_root(byte: u8) -> HyliUtxoZkVmState {
489529
let mut state = HyliUtxoZkVmState::new(ContractConfig {
490530
utxo_contract_name: "dummy_utxo".into(),
491531
smt_incl_proof_contract_name: "dummy_smt_incl".into(),
532+
smt_contract_name: "oranj".into(),
492533
});
493534
let root = BorshableH256::from([byte; 32]);
494535
state.created_notes.proof = Proof::CurrentRootHash(root);
@@ -528,4 +569,104 @@ mod tests {
528569
assert_root(&batch, 3);
529570
assert_eq!(batch.remaining.len(), 0);
530571
}
572+
573+
fn make_state_blob(callees: Vec<BlobIndex>) -> Blob {
574+
Blob {
575+
contract_name: ContractName("hyli-utxo-state".into()),
576+
data: BlobData::from(StructuredBlobData {
577+
caller: None,
578+
callees: Some(callees),
579+
parameters: HYLI_UTXO_STATE_ACTION,
580+
}),
581+
}
582+
}
583+
584+
fn make_utxo_blob(nullifier_byte: u8) -> Blob {
585+
let mut bytes = vec![0u8; 128];
586+
bytes[64..96].copy_from_slice(&[nullifier_byte; 32]);
587+
bytes[96..128].copy_from_slice(&[nullifier_byte.wrapping_add(1); 32]);
588+
Blob {
589+
contract_name: ContractName("dummy_utxo".into()),
590+
data: BlobData(bytes),
591+
}
592+
}
593+
594+
fn make_smt_blob(root_byte: u8, nullifier_byte: u8) -> Blob {
595+
let mut bytes = vec![0u8; 96];
596+
bytes[0..32].copy_from_slice(&[nullifier_byte; 32]);
597+
bytes[32..64].copy_from_slice(&[nullifier_byte.wrapping_add(1); 32]);
598+
bytes[64..96].copy_from_slice(&[root_byte; 32]);
599+
Blob {
600+
contract_name: ContractName("dummy_smt_incl".into()),
601+
data: BlobData::from(StructuredBlobData {
602+
caller: Some(BlobIndex(0)),
603+
callees: None,
604+
parameters: bytes,
605+
}),
606+
}
607+
}
608+
609+
fn make_token_blob(caller: Option<BlobIndex>) -> Blob {
610+
Blob {
611+
contract_name: ContractName("oranj".into()),
612+
data: BlobData::from(StructuredBlobData {
613+
caller,
614+
callees: None,
615+
parameters: vec![1u8, 2, 3],
616+
}),
617+
}
618+
}
619+
620+
#[test]
621+
fn check_noir_blobs_accepts_withdraw_topology() {
622+
let mut state = state_with_root(7);
623+
state.roots[0] = [7u8; 8];
624+
625+
let calldata = sdk::Calldata {
626+
tx_hash: TxHash(vec![0u8; 32]),
627+
identity: "alice".into(),
628+
blobs: vec![
629+
make_state_blob(vec![BlobIndex(2), BlobIndex(3)]),
630+
make_utxo_blob(9),
631+
make_smt_blob(7, 9),
632+
make_token_blob(Some(BlobIndex(0))),
633+
]
634+
.into(),
635+
tx_blob_count: 4,
636+
index: BlobIndex(0),
637+
tx_ctx: None,
638+
private_input: Vec::new(),
639+
};
640+
let (_, mut ctx) =
641+
parse_calldata::<HyliUtxoStateAction>(&calldata).expect("parse state calldata");
642+
643+
state
644+
.check_noir_blobs(&calldata, &mut ctx)
645+
.expect("withdraw topology should be accepted");
646+
}
647+
648+
#[test]
649+
fn check_noir_blobs_rejects_missing_withdraw_token_callee() {
650+
let mut state = state_with_root(7);
651+
state.roots[0] = [7u8; 8];
652+
653+
let calldata = sdk::Calldata {
654+
tx_hash: TxHash(vec![0u8; 32]),
655+
identity: "alice".into(),
656+
blobs: vec![
657+
make_state_blob(vec![BlobIndex(2)]),
658+
make_utxo_blob(9),
659+
make_smt_blob(7, 9),
660+
make_token_blob(Some(BlobIndex(0))),
661+
]
662+
.into(),
663+
tx_blob_count: 4,
664+
index: BlobIndex(0),
665+
tx_ctx: None,
666+
private_input: Vec::new(),
667+
};
668+
let err = parse_calldata::<HyliUtxoStateAction>(&calldata)
669+
.expect_err("withdraw topology should fail during calldata parsing");
670+
assert!(err.contains("Blob callees do not match actual callees"));
671+
}
531672
}

elf/hyli-utxo-state

23.5 KB
Binary file not shown.

elf/hyli-utxo-state_vk

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"vk":{"commit":{"value":[1881857812,633924928,699689120,1049858745,1663766023,1764895128,59520444,1218666378],"_marker":null},"pc_start":2301256,"initial_global_cumulative_sum":{"x":[1606369006,667206126,757975098,693428570,1674825,240236186,1306388389],"y":[1345908014,1162548307,643691799,1218344819,1845195723,1017298222,1251487222]},"chip_information":[["Program",{"log_n":19,"shift":1},{"width":14,"height":524288}],["Byte",{"log_n":16,"shift":1},{"width":11,"height":65536}]],"chip_ordering":{"Program":0,"Byte":1}}}
1+
{"vk":{"commit":{"value":[1615736029,749949995,1706672566,1144703530,1113824162,1884353694,1908126459,877274276],"_marker":null},"pc_start":2311996,"initial_global_cumulative_sum":{"x":[1369417406,226617487,1462444493,1237179567,80700820,243208994,1768031334],"y":[93794494,53725204,1552345321,1164825972,1400374087,1475413743,62129066]},"chip_information":[["Program",{"log_n":19,"shift":1},{"width":14,"height":524288}],["Byte",{"log_n":16,"shift":1},{"width":11,"height":65536}]],"chip_ordering":{"Program":0,"Byte":1}}}
0 Bytes
Binary file not shown.
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
E�4�x����)1���G�!xL�.��DFP�
1+
��sHZ�?~x�������EM�����T
2+
4�

fixtures/keys/hyli_utxo_key

0 Bytes
Binary file not shown.

fixtures/keys/hyli_utxo_key_hash

0 Bytes
Binary file not shown.

fixtures/programs/hyli_smt_incl_proof.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

fixtures/programs/hyli_utxo.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

front/src/App.css

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,16 @@ a:hover {
223223
}
224224

225225
.action-bar {
226-
display: flex;
226+
display: grid;
227+
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
227228
gap: 0.5rem;
228229
margin-top: 0.25rem;
230+
width: 100%;
231+
}
232+
233+
.action-bar .btn {
234+
width: 100%;
235+
min-width: 0;
229236
}
230237

231238
/* ─── Address card ──────────────────────────────────────────── */

0 commit comments

Comments
 (0)