Abstract
Move compliance↔logic consistency checks from out-of-circuit Rust into the aggregation guest, and commit a compact AggregationInstance instead of raw byte blobs. This shrinks the aggregation journal/instance, eliminates trusted host-side(verifier-side) cross-checks, and makes the relationship between compliance and logic proofs cryptographically enforced rather than asserted.
§1 Motivation
What the aggregation circuit does today
The guest reads four values, verifies each proof against its VK, then re-commits the inputs verbatim:
// arm_circuits/batch_aggregation/methods/guest/src/main.rs
let compliance_instances: Vec<Vec<u32>> = env::read();
let compliance_key: Digest = env::read();
let logic_instances: Vec<Vec<u32>> = env::read();
let logic_keys: Vec<Digest> = env::read();
// verify each proof against its VK …
env::commit(&(compliance_instances, compliance_key, logic_instances, logic_keys));
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Journal = everything re-serialised. No cross-checks performed.
Three problems this creates
-
Oversized journal — direct Ethereum gas cost. The aggregation receipt’s public output contains the fully-serialised bytes of every
ComplianceInstanceand everyLogicInstancefor every resource in the transaction. Size scales as O(A·R) where A = actions and R = resources per action. When the transaction is settled on Ethereum via a Groth16 proof, the full journal is submitted as calldata so the on-chain verifier can hash it into the Groth16 public input. At 16 gas per non-zero calldata byte (EIP-2028), every redundant byte is paid in gas on every settlement. On top of that, the on-chain contract currently re-runs O(A·R) compliance↔logic cross-checks in the EVM because no circuit enforces them — adding execution gas that also scales with transaction size. -
Duplicate data. Tags appear in both
ComplianceInstanceandLogicInstance.tag.resource_logic_refappears inComplianceInstanceand again aslogic_keys[i]. The action-tree root is stored once per resource even though it is the same for every resource in an action.kind_table_commitmentis the same across all actions in a transaction but is stored once per compliance instance. -
Host-trusted cross-checks. Three invariants that bind compliance to logic proofs are enforced only by Rust host code in
Action::get_logic_verifiers()— not by the circuit. On PA, these checks are specified in the contract during verificaiton.
§2 Current Aggregation Journal Layout and Duplicates
For a transaction with A actions and R resources per action (C consumed + P created, R = C+P):
| Field in journal | Count | Bytes | Also stored as … |
|---|---|---|---|
compliance_key |
1 | 32 | — |
compliance_instance[a].consumed_publics[i].nullifier |
A×C | 32 each | logic_instance[k].tag |
compliance_instance[a].created_publics[i].commitment |
A×P | 32 each | logic_instance[k].tag |
compliance_instance[a].*.resource_logic_ref |
A×R | 32 each | logic_keys[k] |
compliance_instance[a].delta_x/y |
A | 64 each | — |
compliance_instance[a].kind_table_commitment |
A | 32 each | |
logic_instance[k].tag |
A×R | 32 each | |
logic_instance[k].root |
A×R | 32 each | |
logic_instance[k].is_consumed |
A×R | 1 each | |
logic_keys[k] |
A×R | 32 each | |
logic_instance[k].app_data |
A×R | variable | — (genuinely new data) |
Current state (motivation for this RFC): The three cross-checks that bind compliance to logic proofs — (1) each tag has exactly one matching
LogicInstance, (2)logic_keys[k] == resource_logic_ref, and (3)LogicInstance.rootmatches the action-tree root recomputed from the compliance tags — are enforced only by verifier-side inarm/src/action.rs::get_logic_verifiers()(in the contract on PA) , not by any circuit. This RFC moves all three into the aggregation guest (see §4, Constraints A–C) so that a valid receipt cryptographically guarantees them.
§3 New AggregationInstance Struct
Add arm/src/aggregation_instance.rs. This is the type the aggregation guest commits and the verifier reads:
// arm/src/aggregation_instance.rs (new file)
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AggregationInstance {
/// VK of the compliance circuit — single for the whole transaction.
pub compliance_key: Digest,
/// Shared across all actions; equality enforced in-circuit.
pub kind_table_commitment: Digest,
/// One entry per compliance unit / action.
pub actions: Vec<ActionAggregated>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ActionAggregated {
pub consumed_publics: Vec<ConsumedResourceAggregated>,
pub created_publics: Vec<CreatedResourceAggregated>,
pub delta_x: [u32; 8],
pub delta_y: [u32; 8],
/// Merkle root of the action tree derived from the compliance tags.
/// Computed inside the aggregation circuit; not trusted from the prover.
pub action_tree_root: Digest,
}
/// Consumed resource public data merged with its logic circuit app_data.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ConsumedResourceAggregated {
pub resource_nullifier: Digest,
pub resource_logic_ref: Digest,
pub commitment_tree_root: Digest,
pub app_data: AppData,
}
/// Created resource public data merged with its logic circuit app_data.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CreatedResourceAggregated {
pub resource_commitment: Digest,
pub resource_logic_ref: Digest,
pub app_data: AppData,
}
Fields removed vs. today
logic_keys: Vec<Digest>— dropped entirely; the value is already in*.resource_logic_refand equality is now proved in-circuit.LogicInstance.tag— dropped; covered byresource_nullifier/resource_commitmentin the aggregated resource structs.LogicInstance.root— replaced by the singleaction_tree_rootper action.LogicInstance.is_consumed— dropped; field position inconsumed_publicsvs.created_publicsencodes this.resource_app_data: Vec<AppData>parallel vec — dropped;app_datais now a field on eachConsumedResourceAggregated/CreatedResourceAggregated, eliminating the index-alignment requirement.kind_table_commitmentper-action — lifted toAggregationInstance; all-equal constraint enforced in-circuit, saving (A−1)×32 bytes.
§4 Aggregation Guest — New Logic
The guest receives the same four raw inputs (no API change for the prover side), but now deserializes them into typed structs and enforces three consistency constraints before committing the compact AggregationInstance.
Step-by-step description
Step 1 — Proof verification (unchanged).
Each compliance proof is verified against the single compliance_key. Each logic proof is verified against its corresponding entry in logic_keys. This is identical to the existing circuit behaviour and is the only part that runs inside the recursive STARK verifier.
Step 2 — Deserialisation.
The raw word-vectors (Vec<u32>) are decoded into ComplianceInstance and LogicInstance structs using risc0_zkvm::serde::from_slice. From this point the guest operates on typed data rather than opaque bytes.
Step 3 — Ordered positional matching.
The prover supplies logic_instances in the same canonical order as the tags appear across all compliance instances: consumed nullifiers of action 0, then created commitments of action 0, then action 1, and so on — matching the order already produced by get_logic_verifiers(). The guest advances a single iterator over logic_instances in lockstep with the compliance tag sequence. For each tag the guest asserts li.tag == expected_tag; there is no hash map, no dynamic lookup, and no extra allocation. Mismatches, duplicates, and surplus logic instances are all caught: a tag mismatch panics immediately, and a final assertion that the iterator is exhausted rejects any extra trailing instances.
Step 4 — Constraint C: kind table consistency.
All compliance instances must reference the same kind_table_commitment. The guest reads the value from the first instance and asserts equality for every subsequent one. This is a new security property not checked anywhere today — without it a prover could mix compliance instances from different kind tables in one transaction, making the delta balance check ambiguous.
Step 5 — Per-action cross-check and struct construction.
For each compliance instance the guest performs:
-
Action tree root recomputation. The canonical tag sequence (consumed nullifiers in order, then created commitments in order) is assembled from the compliance instance and fed into
ActionTree::new(&tags).root(). The resulting root is computed deterministically inside the circuit and is never taken from the prover. -
Constraint A — root consistency. For each resource tag, the logic instance found in the index must carry a
rootfield equal to the just-computedaction_tree_root. This proves that the logic circuit ran against the same action tree as the compliance circuit. -
Constraint B — VK / logic_ref consistency. The verifying key used to verify the logic proof (
logic_keys[li_idx]) must equal theresource_logic_refdeclared in the compliance instance for that tag. This proves that the correct logic circuit was executed for each resource — a different circuit cannot substitute its proof. -
Struct assembly. On passing both constraints, the guest constructs
ConsumedResourceAggregatedorCreatedResourceAggregatedinline, embedding theapp_datafrom the matched logic instance directly into the resource struct. No separate parallel vector is needed.
Step 6 — Commit.
The guest commits a single AggregationInstance value containing compliance_key, the deduplicated kind_table_commitment, and the Vec<ActionAggregated> built above. The raw compliance and logic byte blobs are not committed.
After this change, a valid aggregation proof cryptographically guarantees: every logic instance’s root is consistent with its parent action’s compliance tags, every logic circuit VK matches its declared resource_logic_ref, and every compliance tag has exactly one logic instance.
§5 Check Migration
| Check | Currently in | After this change |
|---|---|---|
| Each compliance tag matches the logic instance at the same position | action.rs: get_logic_verifiers()- rust verifier/ solidity contract verifier |
aggregation guest (tag-order assert) |
| No surplus / missing logic instances | action.rs count check - rust verifier / solidity contract verifier |
aggregation guest (iterator exhaustion assert) |
logic_keys[k] == resource_logic_ref for tag |
action.rs: verifying_key comparison - rust verifier / solidity contract verifier |
aggregation guest (VK assert) |
LogicInstance.root == ActionTree(tags).root() |
action.rs: root injected - rust verifier / solidity contract verifier |
aggregation guest (root assert) |
LogicInstance.is_consumed matches tag origin |
action.rs: to_logic_verifier(is_consumed, …) |
position in consumed/created lists |
All actions share the same kind_table_commitment |
rust verifier / solidity contract verifier | aggregation guest (kind-table assert) |
| Nullifier deduplication across actions | transaction.rs: nf_duplication_check - rust verifier / solidity contract verifier |
remains out-of-circuit (set check) |
| Delta balance sum + ECDSA signature | delta_proof.rs: DeltaProof::verify - rust verifier / solidity contract verifier |
remains out-of-circuit (heavy crypto) |
The remaining out-of-circuit checks are acceptable: nullifier dedup is a simple set membership check and delta balance requires elliptic-curve arithmetic that is expensive to prove in a RISC0 guest. Both operate on data that is now part of AggregationInstance and thus authentically committed.
§6 Journal Size Analysis
For a transaction with A actions and R resources per action (ignoring variable-length AppData):
| Item | Before (bytes) | After (bytes) | Δ |
|---|---|---|---|
compliance_key |
32 | 32 | — |
| tags (compliance side) | A·R·32 | A·R·32 | — |
delta_x/y per action |
A·64 | A·64 | — |
kind_table_commitment per action |
A·32 | 32 (once) | −(A−1)·32 |
action_tree_root |
0 (not committed) | A·32 | +A·32 |
logic_keys |
A·R·32 | 0 | −A·R·32 |
LogicInstance.tag |
A·R·32 | 0 | −A·R·32 |
LogicInstance.root |
A·R·32 | 0 | −A·R·32 |
LogicInstance.is_consumed |
A·R·1 | 0 | −A·R |
| Net journal reduction | (3R−1)·A·32 + (A−1)·32 + A·R bytes |
Concrete example — 1 action, 4 resources
| Before | After | Saved | |
|---|---|---|---|
| Fixed overhead | 32 + 64 + 32 = 128 | 32 + 32 + 64 + 32 = 160 | −32 |
kind_table_commitment (deduplicated) |
1 × 32 = 32 | 32 (same; A=1) | — |
| Tags (compliance) | 4 × 32 = 128 | 4 × 32 = 128 | — |
logic_keys |
4 × 32 = 128 | 0 | 128 |
LogicInstance.tag |
4 × 32 = 128 | 0 | 128 |
LogicInstance.root |
4 × 32 = 128 | 0 | 128 |
LogicInstance.is_consumed |
4 × 1 = 4 | 0 | 4 |
| AppData (variable) | same | same | — |
| Total fixed-field saving | 516 bytes | 288 bytes | 228 bytes (44%) |
Savings grow with R and A. For 2 actions with 4 resources each: (3·4−1)·2·32 + (2−1)·32 + 2·4 = 704 + 32 + 8 = 744 bytes saved in fixed fields.
Ethereum gas impact
On-chain settlement costs gas in two distinct ways, and this RFC reduces both.
1 — Calldata cost (journal size)
The aggregation journal is posted as calldata to the on-chain verifier contract so it can SHA-256 hash it into the single 32-byte Groth16 public input. Under EIP-2028, each non-zero calldata byte costs 16 gas. A smaller journal directly lowers the calldata portion of the settlement transaction.
| Transaction | Journal before | Journal after | Bytes saved | Calldata gas saved (≈16/byte) |
|---|---|---|---|---|
| 1 action, 4 resources | 516 bytes (fixed) | 288 bytes | 228 | ~3 648 gas |
| 2 actions, 4 resources each | ~1 100 bytes | ~356 bytes | ~744 | ~11 904 gas |
| 4 actions, 4 resources each | ~2 220 bytes | ~680 bytes | ~1 540 | ~24 640 gas |
2 — Verifier contract execution cost (cross-checks removed)
The on-chain contract currently re-runs the compliance↔logic cross-checks that are not enforced by any circuit: matching tags to logic instances, asserting logic_keys[k] == resource_logic_ref, and verifying action-tree root consistency. These are O(A·R) loops executed in the EVM on every settlement call. Once these checks are enforced inside the aggregation guest, a valid Groth16 receipt guarantees them cryptographically. The contract needs only to verify the SNARK — the O(A·R) on-chain loops are eliminated entirely.
Note on proving cost. Deserializing
ComplianceInstanceandLogicInstanceinside the guest adds a small number of cycles (field-by-field serde reads and the action-tree Merkle hash). These are dominated by the recursiveenv::verifycalls already in the guest. No significant proving-time regression is expected.
§7 Transaction Struct Changes
New types
aggregation_proof: Option<Vec<u8>> and the new aggregation_instance are merged into a single Aggregation struct so that the two values can never be independently Some or None:
/// Aggregation proof and its decoded instance, always populated together.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Aggregation {
/// Serialised `InnerReceipt` from the aggregation prover.
pub proof: Vec<u8>,
/// Typed journal decoded from the receipt — the canonical public output.
pub instance: AggregationInstance,
}
pub struct Transaction {
/// Present before aggregation; set to None once aggregate() succeeds.
pub actions: Option<Vec<Action>>,
pub delta_proof: Delta,
pub expected_balance: Option<Vec<u8>>,
/// Present after aggregate() succeeds; None before aggregation.
pub aggregation: Option<Aggregation>,
}