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:
// 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:
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).
// 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:
// 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:
-
struct Instance: Change from fixed 1:1 to arrays and add the kind table commitment:struct Instance { ConsumedRefs[] consumed; // was: ConsumedRefs consumed CreatedRefs[] created; // was: CreatedRefs created bytes32 unitDeltaX; bytes32 unitDeltaY; bytes32 kindTableCommitment; // new in V2 } -
_RESOURCES_PER_COMPLIANCE_UNIT: Remove — resource counts are now dynamic per unit. -
_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:
-
Kind table hash storage and access control: Store a
_kindTableHashon-chain. Only an authorized committee can update it._processCompliancemust reject any proof whosekindTableCommitmentdoes not match the stored hash. -
_processCompliance: Add akindTableCommitmentcheck against_kindTableHash. Also loop overinput.instance.consumedfor root checks (was singular), and update compliance instance indexing which currently divides by_RESOURCES_PER_COMPLIANCE_UNIT.// kind table check (new) if (input.instance.kindTableCommitment != _kindTableHash) { revert KindTableCommitmentMismatch(_kindTableHash, input.instance.kindTableCommitment); } -
_executeinner loop: Currently calls_processLogicexactly twice per compliance unit — once forinstance.consumedand once forinstance.created. This needs to become a loop over all entries ininstance.consumedandinstance.createdarrays. -
_initializeVars: Array sizing forcomplianceInstancesdivides 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:
-
Compliance.Instance.toJournal(): Currently encodes exactly one consumed and one created entry flat:journal = abi.encodePacked( instance.consumed.nullifier, instance.consumed.logicRef, instance.consumed.commitmentTreeRoot, instance.created.commitment, instance.created.logicRef, instance.unitDeltaX, instance.unitDeltaY );In V2 the circuit journal encodes variable-length
consumed[]andcreated[]arrays pluskindTableCommitment. This must be rewritten to loop over the arrays and match the RISC Zero serde format the V2 guest commits. If this encoding is wrong, proof verification always reverts even for valid proofs. -
Aggregation.Instance.toJournal(): Has two hardcoded assumptions that break:- Logic instances are indexed by
i * _RESOURCES_PER_COMPLIANCE_UNITand+1— assumes exactly 2 logic proofs per compliance unit. With variable N:M units the index must be tracked dynamically. tagCountPaddingiscomplianceUnitCount * _RESOURCES_PER_COMPLIANCE_UNIT— same fixed-2 assumption.
- Logic instances are indexed by
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.