# ARM-RISC0 V2: Compliance Merge and Kind Table Optimization

**URL:** <https://forum.anoma.net/t/arm-risc0-v2-compliance-merge-and-kind-table-optimization/2693>\
**Category:** RM\
**Created:** [June 15, 2026, 5:15am UTC](https://forum.anoma.net/t/arm-risc0-v2-compliance-merge-and-kind-table-optimization/2693 "2026-06-15T05:15:15Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![xuyang](https://avatars.discourse-cdn.com/v4/letter/x/c68b51/32.png) [@xuyang](https://forum.anoma.net/u/xuyang)\
**Post date:** [June 15, 2026, 5:15am UTC](https://forum.anoma.net/t/arm-risc0-v2-compliance-merge-and-kind-table-optimization/2693/1 "2026-06-15T05:15:15Z")

</div>

## Background

The ARM (Anoma Resource Machine) compliance circuit is the core proving primitive: given a set of consumed and created resources, it proves that the conservation law holds (balanced kinds and quantities) and publishes the nullifiers, commitments, and an EC point delta for later verification.

In V1, every compliance unit was fixed at exactly **one consumed resource + one created resource** (1:1). The circuit recomputed each resource’s _kind_ — an elliptic-curve point derived via `hash_to_curve(logic_ref || label_ref)` — inside the proof for every resource processed.

V2 ships two improvements together: variable-size compliance units and a kind lookup table.

* * *

## 1. Variable-Size Compliance Units (compliance merge, PR #220)

The 1:1 constraint was not fundamental — it was just what the original circuit assumed. PR #220 lifts it.

A compliance unit now holds `N` consumed resources and `M` created resources. The `ComplianceInstance` (the unit’s public output) went from flat singular fields to vectors:

```rust
// V1
consumed_nullifier: Digest,
consumed_logic_ref: Digest,
consumed_commitment_tree_root: Digest,
created_commitment: Digest,
created_logic_ref: Digest,

// V2
consumed_publics: Vec<ConsumedResourcePublic>,
created_publics: Vec<CreatedResourcePublic>,

```

The circuit still enforces the same rules — each created resource’s nonce must be derived from the batch of consumed nullifiers, and the conservation law must hold across all N+M resources. The difference is that one RISC Zero proof now covers an entire N:M batch instead of always exactly two.

This matters because proof overhead (witness serialization, host↔guest I/O, circuit fixed cost) is amortized across the whole batch. A transaction that previously needed K compliance proofs for K consumed–created pairs can now issue one proof per _logical group_, reducing total proof count.

The `Action` struct reflects this: it now holds a single `ComplianceUnit` (which internally represents the full N:M batch) rather than a `Vec<ComplianceUnit>`.

* * *

## 2. Kind Lookup Table Optimization (PR #220, commit `10927b8`)

`hash_to_curve` is expensive inside a zero-knowledge circuit. Every resource in the compliance unit requires one call to derive its kind point, and the circuit must trace every intermediate field operation.

The optimization: precompute kind points _outside the circuit_ and pass them in a lookup table. Inside the circuit, the guest code does a simple table scan (EC point comparison) rather than recomputing `hash_to_curve`. For resource types not in the table, it falls back to the full computation.

**Table structure:**

```rust
pub struct KindTableEntry {
    pub logic_ref: Digest, // key part 1
    pub label_ref: Digest, // key part 2
    pub kind_point: Vec<u8>, // 65-byte uncompressed SEC1 EC point
}

```

The table is committed to in the public instance (`kind_table_commitment: Digest` — a SHA-256 hash of all entries), so verifiers can confirm which table was used.

**Benchmark results (from `arm_circuits/compliance/README.md`):**

| Resources in unit | Without table | With table | Speedup |
| --- | --- | --- | --- |
| 1 | 2.1 s | 0.81 s | 2.6× |
| 2 | 3.1 s | 0.84 s | 3.7× |
| 4 | 6.3 s | 1.24 s | 5.1× |
| 8 | 11.5 s | 1.29 s | **8.9×** |

The near-flat cost at larger batch sizes (0.84 s for 2 vs 1.29 s for 8) shows the table amortizes almost all hash-to-curve overhead. The remaining growth is dominated by delta accumulation and merkle path checks.

* * *

## Changes Required in `pa-evm` to Upgrade from arm-risc0 v1.1.1 → v2

### Bindings (`bindings/src/conversion.rs`)

**Change 1 — `From<Action>`**

V1 had `action.compliance_units: Vec<ComplianceUnit>`. V2 has `action.compliance_unit: ComplianceUnit` (singular, since the unit is now the whole batch).

```rust
// before
complianceVerifierInputs: action
    .compliance_units
    .into_iter()
    .map(|cu| cu.into())
    .collect(),

// after
complianceVerifierInputs: vec![action.compliance_unit.into()],

```

**Change 2 — `From<ComplianceInstance>`**

V1’s `ComplianceInstance` had flat scalar fields. V2 uses `consumed_publics: Vec<_>` and `created_publics: Vec<_>`. Once the Solidity contracts are updated to arrays (see below), the conversion becomes:

```rust
// before (v1 field names no longer exist)
consumed: Compliance::ConsumedRefs {
    nullifier: B256::from_slice(instance.consumed_nullifier.as_bytes()),
    logicRef: B256::from_slice(instance.consumed_logic_ref.as_bytes()),
    commitmentTreeRoot: B256::from_slice(instance.consumed_commitment_tree_root.as_bytes()),
},
created: Compliance::CreatedRefs {
    commitment: B256::from_slice(instance.created_commitment.as_bytes()),
    logicRef: B256::from_slice(instance.created_logic_ref.as_bytes()),
},

// after
consumed: instance.consumed_publics.into_iter().map(|p| Compliance::ConsumedRefs {
    nullifier: B256::from_slice(p.resource_nullifier.as_bytes()),
    logicRef: B256::from_slice(p.resource_logic_ref.as_bytes()),
    commitmentTreeRoot: B256::from_slice(p.commitment_tree_root.as_bytes()),
}).collect(),
created: instance.created_publics.into_iter().map(|p| Compliance::CreatedRefs {
    commitment: B256::from_slice(p.resource_commitment.as_bytes()),
    logicRef: B256::from_slice(p.resource_logic_ref.as_bytes()),
}).collect(),
kindTableCommitment: B256::from_slice(instance.kind_table_commitment.as_bytes()),

```

After updating the contracts, re-run `forge bind` to regenerate `bindings/src/generated/`.

* * *

### Contracts

**`Compliance.sol`** — three things need to change:

1. **`struct Instance`** : Change from fixed 1:1 to arrays and add the kind table commitment:

2. **`_RESOURCES_PER_COMPLIANCE_UNIT`** : Remove — resource counts are now dynamic per unit.

3. **`_VERIFYING_KEY`** : Update to the V2 compliance circuit image ID (the circuit binary changed; the current value is for V1).

**`ProtocolAdapter.sol`** — four areas affected:

1. **Kind table hash storage and access control** : Store a `_kindTableHash` on-chain. Only an authorized committee can update it. `_processCompliance` must reject any proof whose `kindTableCommitment` does not match the stored hash.

2. **`_processCompliance`** : Add a `kindTableCommitment` check against `_kindTableHash`. Also loop over `input.instance.consumed` for root checks (was singular), and update compliance instance indexing which currently divides by `_RESOURCES_PER_COMPLIANCE_UNIT`.

3. **`_execute` inner loop** : Currently calls `_processLogic` exactly twice per compliance unit — once for `instance.consumed` and once for `instance.created`. This needs to become a loop over all entries in `instance.consumed` and `instance.created` arrays.

4. **`_initializeVars`** : Array sizing for `complianceInstances` divides by `_RESOURCES_PER_COMPLIANCE_UNIT`; needs to be derived from actual compliance unit counts instead.

**`TagUtils.sol`** : `collectTags()` and `countTags()` need to account for variable resource counts per compliance unit rather than assuming each unit contributes exactly 2 tags.

**`RiscZeroUtils.sol`** — two functions break:

1. **`Compliance.Instance.toJournal()`**: Currently encodes exactly one consumed and one created entry flat:

2. **`Aggregation.Instance.toJournal()`**: Has two hardcoded assumptions that break:

**`Aggregation._VERIFYING_KEY`** : Must be updated to the V2 aggregation circuit image ID. The updated compliance VK flows through correctly because `toJournal()` embeds `Compliance._VERIFYING_KEY` directly.

* * *

### Summary

| Location | What changes | Why |
| --- | --- | --- |
| `conversion.rs` | `action.compliance_units` → `action.compliance_unit` | Action now holds one N:M unit |
| `conversion.rs` | Flat `ComplianceInstance` fields → `consumed_publics[i]` / `created_publics[i]` | V2 uses `Vec` |
| `conversion.rs` | Add `kindTableCommitment` mapping | New public output in V2 |
| `Compliance.sol` | `Instance` struct: scalar → arrays + `kindTableCommitment` | Matches V2 `ComplianceInstance` |
| `Compliance.sol` | Remove `_RESOURCES_PER_COMPLIANCE_UNIT` | No longer fixed |
| `Compliance.sol` | Update `_VERIFYING_KEY` | New circuit binary in V2 |
| `ProtocolAdapter.sol` | Add `_kindTableHash` + `_committee` state; `updateKindTableHash()` restricted to committee | On-chain enforcement of approved kind tables |
| `ProtocolAdapter.sol` | `_processCompliance`: check `kindTableCommitment == _kindTableHash` | Reject proofs using unapproved kind tables |
| `ProtocolAdapter.sol` | `_processCompliance`: loop over `consumed[]` array | Was singular field access |
| `ProtocolAdapter.sol` | `_execute`: loop over variable consumed+created counts | Was hardcoded 2-call pattern |
| `ProtocolAdapter.sol` | `_initializeVars`: dynamic array sizing | No more fixed divisor |
| `TagUtils.sol` | Variable tag counting | Units no longer always have 2 tags |
| `RiscZeroUtils.sol` | `Compliance.Instance.toJournal()`: loop over arrays + include `kindTableCommitment` | V2 circuit journal format changed |
| `RiscZeroUtils.sol` | `Aggregation.Instance.toJournal()`: dynamic logic index + tag count | Hardcoded `_RESOURCES_PER_COMPLIANCE_UNIT` assumptions |
| `Aggregation._VERIFYING_KEY` | Update to V2 image ID | Aggregation circuit rebuilt for V2 |

The contract changes are a breaking ABI change — a new deployment is required. The bindings changes are entirely in `conversion.rs` plus the regenerated `generated/` files.

cc @Michael @vveiln

---

<div class="post-metadata">

**Author:** ![xuyang](https://avatars.discourse-cdn.com/v4/letter/x/c68b51/32.png) [@xuyang](https://forum.anoma.net/u/xuyang)\
**Post date:** [July 31, 2026, 9:01am UTC](https://forum.anoma.net/t/arm-risc0-v2-compliance-merge-and-kind-table-optimization/2693/2 "2026-07-31T09:01:16Z")

</div>

# ARM-RISC0: Bumping to v2.0.0-rc.3

This post covers what changed from rc.1 through rc.3.

* * *

## rc.1 → rc.2

### Aggregation Refactor ([#236](https://github.com/anoma/arm-risc0/pull/236))

This is the most significant change in the rc.1→rc.3 window. It has two parts.

**Moving cross-checks into the ZK guest.** Previously, consistency checks between compliance units and logic proofs — for example, verifying that the nullifiers a compliance proof consumed match the resource data the logic proof saw — were performed in host Rust code inside `transaction.rs`. That meant they were _asserted_ by the prover’s host, not _proven_. This PR moves those checks inside the RISC Zero batch-aggregation guest, so they become part of the ZK statement. The `batch-aggregation-guest.bin` grew substantially as a result, and `BATCH_AGGREGATION_VK` changed.

**Eliminating redundant witness data.** The aggregation witness previously carried both the serialised logic proof and a separately constructed `LogicInstance`. The guest now re-derives the `LogicInstance` in-circuit from the proof journal, removing the duplication and the possibility of the two drifting apart. A new `AggregationWitness` type encapsulates this cleanly.

**New public output type: `AggregationInstance`.** Moving the cross-checks into the guest required a new journal format. The guest now commits an `AggregationInstance` — a typed struct that replaces the old flat parallel arrays with a per-action layout merging compliance and logic public data:

```rust
pub struct AggregationInstance {
    pub compliance_key: Digest, // VK of the compliance circuit
    pub kind_table_commitment: Digest, // shared across all actions, enforced in-circuit
    pub actions: Vec<ActionAggregated>,
}

pub struct ActionAggregated {
    pub consumed_publics: Vec<ConsumedResourceAggregated>, // nullifier + logic_ref + tree_root + app_data
    pub created_publics: Vec<CreatedResourceAggregated>, // commitment + logic_ref + app_data
    pub delta_x: [u32; 8],
    pub delta_y: [u32; 8],
    pub action_tree_root: Digest, // recomputed in-circuit from compliance tags
}

```

This replaces the old `Aggregation.Instance` shape (flat `logicRefs`, `complianceInstances`, `logicInstances` arrays) and drives the changes to `Aggregation.sol` and `RiscZeroUtils.toJournal` described in the pa-evm section below.

* * *

## rc.2 → rc.3

### Delta Proof Refactor ([#240](https://github.com/anoma/arm-risc0/pull/240))

`DeltaInstance` now carries the signed message alongside the verifying key. Previously, `DeltaProof::verify(message, proof, instance)` took the message as a separate parameter, making it easy to accidentally call with a message that didn’t match what was actually signed. The new signature is `DeltaProof::verify(proof, &instance)` — the instance is self-describing.

The delta message itself is now the concatenated **action tree roots** rather than **commitments and nullifiers**. The action tree root is the canonical commitment to a transaction’s structure, so using it as the binding signature message ties the delta key precisely to the set of actions being authorised.

* * *

## Required Changes in pa-evm

### `Cargo.toml` — dependency version

```toml
anoma-rm-risc0 = { version = "2.0.0-rc.3", features = ["bonsai", "aggregation"] }

```

### `Compliance.sol` — updated verifying key

The `_VERIFYING_KEY` constant encodes the compliance guest image ID in the RISC Zero EVM format. It must be updated to match `COMPLIANCE_VK` from rc.3.

```solidity
// contracts/src/libs/proving/Compliance.sol
bytes32 internal constant _VERIFYING_KEY = <rc.3 value>;

```

### `Aggregation.sol` — updated verifying key and new instance struct

The `_VERIFYING_KEY` must be updated to match `BATCH_AGGREGATION_VK` from rc.3.

More significantly, the `Instance` struct must be redesigned to match the new `AggregationInstance` Rust type. The old flat parallel arrays are replaced by a per-action nested layout that merges the compliance and logic public data together:

```solidity
// Old — flat arrays
struct Instance {
    bytes32[] logicRefs;
    Compliance.Instance[] complianceInstances;
    Logic.Instance[] logicInstances;
}

// New — per-action, merged with logic app_data; mirrors AggregationInstance in Rust
struct ConsumedPublic {
    bytes32 resourceNullifier;
    bytes32 resourceLogicRef;
    bytes32 commitmentTreeRoot;
    Logic.AppData appData;
}

struct CreatedPublic {
    bytes32 resourceCommitment;
    bytes32 resourceLogicRef;
    Logic.AppData appData;
}

struct ActionAggregated {
    ConsumedPublic[] consumedPublics;
    CreatedPublic[] createdPublics;
    bytes32 deltaX;
    bytes32 deltaY;
    bytes32 actionTreeRoot;
}

struct Instance {
    bytes32 complianceKey;
    bytes32 kindTableCommitment;
    ActionAggregated[] actions;
}

```

### `RiscZeroUtils.sol` — rewrite `toJournal(Aggregation.Instance)`

The on-chain journal serialisation must exactly reproduce what the aggregation guest writes via `env::commit(&AggregationInstance{...})`. The old implementation assumed flat arrays of compliance and logic instances; the new one must walk the per-action structure and serialize RISC Zero’s binary encoding for each `AggregationInstance` field in order. This function needs a complete rewrite.

### `Delta.sol` + `ProtocolAdapter.sol` — delta message change

The signed message changed from `keccak256(nullifiers || commitments)` to `keccak256(action_tree_root_1 || ... || action_tree_root_N)`.

@Michael
