Aggregation Circuit and Instance Refactor — RFC

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

  1. Oversized journal — direct Ethereum gas cost. The aggregation receipt’s public output contains the fully-serialised bytes of every ComplianceInstance and every LogicInstance for 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.

  2. Duplicate data. Tags appear in both ComplianceInstance and LogicInstance.tag. resource_logic_ref appears in ComplianceInstance and again as logic_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_commitment is the same across all actions in a transaction but is stored once per compliance instance.

  3. 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 same across all actions
logic_instance[k].tag A×R 32 each duplicate of above
logic_instance[k].root A×R 32 each same within one action
logic_instance[k].is_consumed A×R 1 each deducible from position
logic_keys[k] A×R 32 each duplicate of logic_ref above
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.root matches the action-tree root recomputed from the compliance tags — are enforced only by verifier-side in arm/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_ref and equality is now proved in-circuit.
  • LogicInstance.tag — dropped; covered by resource_nullifier / resource_commitment in the aggregated resource structs.
  • LogicInstance.root — replaced by the single action_tree_root per action.
  • LogicInstance.is_consumed — dropped; field position in consumed_publics vs. created_publics encodes this.
  • resource_app_data: Vec<AppData> parallel vec — dropped; app_data is now a field on each ConsumedResourceAggregated / CreatedResourceAggregated, eliminating the index-alignment requirement.
  • kind_table_commitment per-action — lifted to AggregationInstance; 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 root field equal to the just-computed action_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 the resource_logic_ref declared 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 ConsumedResourceAggregated or CreatedResourceAggregated inline, embedding the app_data from 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 ComplianceInstance and LogicInstance inside the guest adds a small number of cycles (field-by-field serde reads and the action-tree Merkle hash). These are dominated by the recursive env::verify calls 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>,
}

Thanks for writing this up. I see that it makes sense to pass tags once and use them in both logic and compliance proof checks, but we do need to store the information about which action and which compliance proof the tag goes to. These layouts are in principle independent (resources from different actions can be in the same compliance proofs).

I’m a bit confused. Logic ref stores the hash of the verifying key and logic_keys contains the pre-images. Am I wrong? If not, we can’t skip logic keys.

All other duplication removals seem good.

A minor nitpick: this formula assumes we have fixed number of resources per action, which is not exactly true, so the formula is a bit simpler than it should be.

This post only covers arm-risc0-specific changes, since V2 enforces one compliance per action. To revert to Noir’s fixed-size compliance model, we’d need to retain the information you mentioned.

Same here: the logic ref and verifying key share the exact same hash in RISC0. If they are not the same hash in other proving systems, we’d need to pass the preimage and recompute the logic ref and drop the stored logic refs.

Correct, it’s an estimate only.

1 Like