Migration notes for anomapay-backend-multichain and anomapay-workers-queue.
- From:
anoma-rm-risc0 = "1.1.1"(crates.io) - To:
anoma-rm-risc0@ git tagv2.0.0-rc.3-abi_encode(commita7d28eb) —
to be replaced by a published crates.io release before this ships
This is a breaking major. Almost every type the two services touch —
Action, ComplianceWitness, ComplianceInstance, ComplianceUnit,
LogicVerifier, Transaction, DeltaInstance — changed shape.
Part 1 — What changed in arm-risc0
1. The action model: one compliance unit per action, N:M resources
The single biggest structural change.
| v1.1.1 | v2.0.0-rc.3 |
|---|---|
Action { compliance_units: Vec<ComplianceUnit>, .. } |
Action { compliance_unit: ComplianceUnit, .. } |
| One CU = exactly 1 consumed + 1 created resource | One CU = N consumed + M created resources |
ComplianceInstance { consumed_nullifier, consumed_logic_ref, consumed_commitment_tree_root, created_commitment, created_logic_ref, delta_x, delta_y } |
ComplianceInstance { consumed_publics: Vec<ConsumedResourcePublic>, created_publics: Vec<CreatedResourcePublic>, delta_x, delta_y, kind_table_commitment } |
ComplianceWitness { consumed_resource, merkle_path, nf_key, created_resource, ephemeral_root, rcv } |
ComplianceWitness { consumed_data: Vec<ConsumedResourceWitness>, created_resources: Vec<Resource>, ephemeral_root, rcv, kind_table } |
New public types in arm::resource:
pub struct ConsumedResourceWitness { resource: Resource, cm_merkle_path: MerklePath, nf_key: NullifierKey }
pub struct ConsumedResourcePublic { resource_nullifier: Digest, resource_logic_ref: Digest, commitment_tree_root: Digest }
pub struct CreatedResourcePublic { resource_commitment: Digest, resource_logic_ref: Digest }
Constructor change:
// v1
ComplianceWitness::from_resources_with_path(consumed, nf_key, path, created)
// v2
ComplianceWitness::from_resources_with_ephemeral_root(&consumed_data, &created_resources, valid_root, kind_table)
ComplianceWitness::from_resources(&consumed_data, &created_resources, kind_table) // uses INITIAL_ROOT
The per-field helpers on ComplianceWitness (consumed_commitment(),
created_commitment(), consumed_nullifier(&cm), consumed_commitment_tree_root(),
consumed_resource_logic(), created_resource_logic(), delta()) are all gone.
The only entry point is constrain() -> ComplianceInstance.
Consequences for tag ordering. The canonical tag order of an action is now
ComplianceInstance::tags() = all consumed nullifiers, then all created
commitments — no longer interleaved (consumed, created, consumed, created, …).
The action tree is built from that order, so action tree roots change for any
transaction with more than one resource pair, and logic_verifier_inputs must be
supplied in exactly that positional order. Action::get_logic_verifiers() and
Transaction::aggregate() now do positional matching (v1 did a HashMap/find
lookup by tag), and return ArmError::TagNotFound on mismatch.
ComplianceInstanceWords and COMPLIANCE_INSTANCE_SIZE were removed — the
instance is variable-size now.
2. Created-resource nonces are derived, not copied
v1 required created.nonce == consumed.nullifier (1:1). With N:M that no longer
works. v2 derives:
Resource::hash_nullifiers(&[Digest]) -> Result<Digest, ArmError> // rejects empty
Resource::derive_nonce(index: u32, nullifiers_digest: Digest) -> [u8; 32]
Resource::derive_nonce_from_nullifiers(index: u32, &[Digest]) -> Result<[u8; 32], ArmError>
with domain separator b"ARM_NONCE_DERIVATION" and a big-endian index.
ComplianceWitness::constrain() re-derives every created resource’s nonce and
fails with ArmError::InvalidResourceNonce on mismatch. Any host code that sets
created.nonce = consumed_nullifier must switch to derive_nonce.
3. Kind lookup table (skips hash_to_curve in the compliance circuit)
New KindTableEntry { logic_ref, label_ref, kind_point }, carried in
ComplianceWitness::kind_table and committed to as
ComplianceInstance::kind_table_commitment (SHA-256 over
logic_ref ‖ label_ref ‖ kind_point for every entry, in order).
New global loader in arm::constants:
init_kind_table_from_file(path: &Path) -> Result<(), ArmError> // first call wins
global_kind_table() -> &'static [KindTableEntry]
global_kind_table_hash() -> Option<&'static Digest>
kind_entry_for(table, resource) -> Option<KindTableEntry> // lookup, falls back to hash_to_curve
Entries are { "logic_ref": "<hex>", "label_ref": "<hex>" }; the kind point is
derived at load time via hash-to-curve so the file can’t drift out of sync.
A starter arm/data/kind_table.json ships in the repo (padding logic + test logic).
Operational impact: Transaction::verify() now calls
kind_table_commitment_check(), which requires the global table to be loaded and
to match the commitment in every compliance unit. Without
init_kind_table_from_file, verification fails with ArmError::KindTableNotLoaded.
New error variants: KindTableCommitmentMismatch, KindTableGlobalMismatch,
KindTableNotLoaded, KindTableLoadFailed.
Measured payoff (Groth16-less succinct compliance bench, from arm_circuits/compliance/README.md):
| batch | no table | with table |
|---|---|---|
| 1 | 2.125 s | 808.7 ms |
| 2 | 3.084 s | 844.7 ms |
| 4 | 6.320 s | 1.235 s |
| 8 | 11.464 s | 1.288 s |
4. Aggregation rewritten: in-circuit cross-checks + compact typed journal
v1: the host serialized every compliance instance, every logic instance and
every logic VK into the guest input; the journal was the raw
(compliance_instances, compliance_vk, logic_instances, logic_vks) tuple. Any
verifier had to re-derive that instance from the transaction and re-check the
compliance↔logic bindings itself.
v2: the guest receives one AggregationWitness and enforces the bindings
inside the circuit:
pub struct AggregationWitness { compliance_key: Digest, actions: Vec<ActionWitness> }
pub struct ActionWitness {
compliance_instance: ComplianceInstance,
consumed_app_data: Vec<AppData>, // the only data not already in the instance
created_app_data: Vec<AppData>,
}
The guest reconstructs each LogicInstance from the compliance instance itself,
so three constraints hold structurally rather than by assertion:
| Constraint | Enforced by |
|---|---|
| A — tag matches nullifier/commitment | tag read from the compliance instance |
| B — root matches action tree root | root recomputed in-circuit from the compliance tags |
C — VK matches resource_logic_ref |
resource_logic_ref used directly as the env::verify key |
It also asserts kind_table_commitment equality across all actions, and rejects
an empty action set.
The journal is now a typed, compact struct:
pub struct AggregationInstance {
compliance_key: Digest,
kind_table_commitment: Digest,
actions: Vec<ActionAggregated>,
}
pub struct ActionAggregated {
consumed_publics: Vec<ConsumedResourceAggregated>, // + app_data
created_publics: Vec<CreatedResourceAggregated>, // + app_data
delta_x: [u32; 8], delta_y: [u32; 8],
action_tree_root: Digest, // computed in-circuit
}
Transaction shape follows:
// v1
pub struct Transaction { actions: Vec<Action>, delta_proof: Delta, expected_balance: Option<Vec<u8>>, aggregation_proof: Option<Vec<u8>> }
// v2
pub struct Aggregation { proof: Vec<u8>, instance: AggregationInstance }
pub struct Transaction { actions: Option<Vec<Action>>, delta_proof: Delta, expected_balance: Option<Vec<u8>>, aggregation: Option<Aggregation> }
aggregate() sets aggregation = Some(..) and clears actions to None
(v1 kept the actions and nulled each individual proof). construct_aggregation_instance()
is gone — the instance is decoded from the journal and stored.
New invariant, enforced by Transaction::check_representation(): a transaction
must carry exactly one of actions / aggregation. Both → AmbiguousTransactionRepresentation;
neither → MissingActions. When aggregation is present it is authoritative for
delta, nullifier and kind-table checks — deliberately, so a crafted transaction can’t
pair a genuine aggregation proof with unverified attacker-controlled actions.
verify_aggregation() additionally pins instance.compliance_key == COMPLIANCE_VK.
base_proofs_are_empty() changed meaning: it is now simply self.actions.is_none()
(v1: “any individual proof is missing”).
5. EVM ABI-encoded aggregation output — the -abi_encode delta
Everything above is in v2.0.0-rc.3. The -abi_encode tag is purely additive on
top of it (arm/Cargo.toml +3, one new ELF, aggregation_instance.rs +350,
constants.rs +10, transaction.rs +57/−14) and entirely behind a new opt-in
feature:
abi_encoding = ["dep:alloy-sol-types", "dep:alloy-primitives"]
- New guest ELF + image ID:
BATCH_AGGREGATION_EVM_PK,BATCH_AGGREGATION_EVM_VK = 0x4c0a771d29fce1983f108f4509552bb7f950c226f173f8d3244ef952dcde6978. - New
alloysol!mirror of the journal:AggregationInstanceEvmwith
Action/Consumed/Created/Delta/AppData/ExpirableBlob,
plusabi_encode_instance/abi_decode_instance. - With the feature on,
aggregate()proves against the EVM guest and
verify_aggregation()reconstructs the journal viaabi_encode_instanceand
verifies againstBATCH_AGGREGATION_EVM_VK. With it off, byte-identical
behaviour tov2.0.0-rc.3.
Motivation: the EVM protocol adapter currently hand-rolls risc0-serde in Solidity
(RiscZeroUtils.toJournal, little-endian u32 counts, raw digests). ABI encoding
lets the contract use native abi.decode instead.
Benchmarks (RTX 4090, Groth16, prove,bonsai,cuda) — ABI encoding is free:
| batch | v1 baseline | v2 default | v2 + abi_encoding |
|---|---|---|---|
| 1 | 6.54 s | 6.43 s | 6.18 s |
| 2 | 8.59 s | 8.14 s | 8.02 s |
| 4 | 13.08 s | 12.18 s | 11.75 s |
6. Delta proof: message moved into the instance, message is action tree roots
// v1
pub struct DeltaInstance { verifying_key: VerifyingKey }
DeltaInstance::from_deltas(&[ProjectivePoint]) -> Result<_, _>
DeltaProof::verify(message: &[u8], proof: &DeltaProof, instance: DeltaInstance)
// v2
pub struct DeltaInstance { verifying_key: VerifyingKey, message: Vec<u8> }
DeltaInstance::new(&[ProjectivePoint], message: Vec<u8>) -> Result<_, _>
DeltaProof::verify(proof: &DeltaProof, instance: &DeltaInstance)
The delta message changed: v1 concatenated each compliance unit’s
delta_msg() (its tags); v2 concatenates each action’s action tree root
(32 bytes per action). Transaction::get_delta_msg() is now private.
Also in delta_proof.rs:
DeltaWitness::from_scalars,compose,compressnow returnResult(were
infallible / panicked); empty input →ArmError::EmptyDeltaWitnesses.DeltaProof::from_bytesvalidates length (65) and accepts both raw{0,1}and
Ethereum{27,28}v-bytes;to_bytesstill emits{27,28}.DeltaWitness’sDeserializewas fixed to match itsSerialize(bincode
round-trip was broken in v1 — it serialized as a seq and deserialized as[u8; 32]).
7. Proofs are no longer Option
// v1 // v2
ComplianceUnit { proof: Option<Vec<u8>>, .. } → { proof: Vec<u8>, .. }
LogicVerifier { proof: Option<Vec<u8>>, .. } → { proof: Vec<u8>, .. }
LogicVerifierInputs { proof: Option<Vec<u8>>, .. } → { proof: Vec<u8>, .. }
The Option existed only to model “erased after aggregation”; that state is now
Transaction::actions == None.
8. Renames, signature changes, hardening
action_tree::MerkleTree→action_tree::ActionTree(andgenerate_pathwas
de-recursed; newpadded_leaveshelper; unit tests added).ComplianceWitness::merkle_path→ConsumedResourceWitness::cm_merkle_path;
logic-side paths areaction_tree_path.Transaction::verify(self)→verify(&self);Action::verify(self)→verify(&self).Transaction::composereturnsResult<Transaction, ArmError>instead of
panicking on mismatched delta types; rejects already-aggregated inputs.Action::get_compliance_units()→get_compliance_unit();
get_logic_verifier_inputs()returns&[…]instead of&Vec<…>.Transaction::aggregate()is now gated onaggregationandprove.proving_system::instance_to_journal<T: Serialize>()added (inverse ofjournal_to_instance).utils::bytes_to_wordsswitched from big-endian to native-endian, and now
round-trips withwords_to_byteson any platform.TrivialLogicWitness::constrainreturnsArmError::InvalidPaddingResource
instead ofassert!-panicking.- New error variants:
EmptyNullifiers,IncompatibleDeltaTypes,
EmptyDeltaWitnesses,InvalidPaddingResource,MissingActions,
CannotComposeAggregated,AmbiguousTransactionRepresentation, plus the four
kind-table ones. arm/src/lib.rsre-exportsActionAggregated,AggregationInstance,
ConsumedResourceAggregated,CreatedResourceAggregated.
9. Feature flags
| Feature | v1.1.1 | v2.0.0-rc.3-abi_encode |
|---|---|---|
default |
transaction, prove |
unchanged |
transaction |
compliance_circuit, dep:sha3 |
unchanged |
aggregation |
aggregation_circuit, transaction |
transaction |
aggregation_circuit |
removed | |
abi_encoding |
— | new (alloy-sol-types, alloy-primitives) |
10. New verifying keys / image IDs
The three circuits that ship inside arm were rebuilt, so their image IDs change:
| Key | v1.1.1 | v2.0.0-rc.3-abi_encode |
|---|---|---|
COMPLIANCE_VK |
919e1300…86314d |
88df64fe…9d8434 |
PADDING_LOGIC_VK |
21fcc2fc…4a0bff |
4527548f…7fea8f |
BATCH_AGGREGATION_VK |
213b3f40…b00827 |
5dc2615f…69058e |
BATCH_AGGREGATION_EVM_VK |
— | 4c0a771d…de6978 |
The app circuits are deliberately not rebuilt. Transfer v1/v2 and generic-call
keep their existing ELFs and verifying keys; only the host-side libraries
(transfer_library*, generic_call_library) move to arm v2. This is already how
the two resource repos were bumped — 9b9076c and 106f360 touch only
Cargo.toml / Cargo.lock (plus migrate_tx.rs and a new kind_table.json) and
leave transfer_library/elf/ untouched. Those ELFs were last built against arm
1.1.1 and stay that way.
That keeps the on-chain logic refs stable, and it is sound because nothing the
guest and host must agree on changed between 1.1.1 and 2.x:
- Witness and instance layouts.
logic_instance.rs(LogicInstance,
AppData,ExpirableBlob) andnullifier_key.rsare untouched by the diff;
Resource’s field set andMerklePath’s representation are unchanged. Host and
frozen guest serialize identically. - Resource derivations. The
psi/rcmrefactor intoprf_expandemits the
same bytes (same personalization, same 1-byte discriminator, same seed, same
nonce), andkind()just delegates to the newgenerate_resource_kind. So
commitments, nullifiers and kinds are bit-identical. utils::bytes_to_words. The big-endian → native-endian rewrite looks like a
behaviour change and is worth checking, because the app witness crates call it
onforwarder_call_data.encode()insideconstrain()— code that runs both in
the frozen guest and on the rebuilt host. It is not: v1 computed
u32::from_be(from_be_bytes(chunk)), which on a little-endian target is exactly
from_le_bytes(chunk)— the same as v2’sfrom_ne_bytes(chunk). RISC-V and
every host platform here are little-endian, so the two agree. The change only
affects hypothetical big-endian builds.
Two things this does not excuse:
PADDING_LOGIC_VKis not frozen — it ships from arm and did change
(TrivialLogicWitness::constrainnow returnsInvalidPaddingResourceinstead of
assert!-panicking, and the ELF was rebuilt). The kind table and anything
on-chain referencing the padding logic ref must use4527548f….- The freeze is implicit and can break silently. The bump commits updated
transfer_circuit/methods/guest/Cargo.lock, so the nextcargo risczero build
of those circuits will emit a different ELF and image ID than the checked-in
constant, with nothing to catch it. Add a CI check that the built image ID
equals theDigest::from_hex(...)intransfer_library*/src/lib.rs, or the
freeze holds only by nobody happening to rebuild.
Part 2 — Required changes in the two services (backend and worker queue)
2.0 Blockers that are not in either repo
These have to land first; neither service can compile without them.
-
anoma-rm-risc0v2 is not on crates.io — publish it soon. Until then both
repos must move to a git dependency:anoma-rm-risc0 = { git = "https://github.com/anoma/arm-risc0", tag = "v2.0.0-rc.3-abi_encode", default-features = false } anoma-rm-risc0-gadgets = { git = "https://github.com/anoma/arm-risc0", tag = "v2.0.0-rc.3-abi_encode" }anoma-rm-risc0-gadgetshas no API change — only the version bump. -
Every crate in the graph must pin the same git rev, so re-pin the circuit
repos and pa-evm. Cargo will not unify two git checkouts at different revs even
though both declare version2.0.0-rc.3— you would get twoanoma-rm-risc0
crates, and aTransactionfrom one would not satisfy the other.Repo arm pin today needs anomapay-erc20-resource(transfer_library,transfer_witness,*_v2)git tag v2.0.0-rc.3(784fb37)re-pin to v2.0.0-rc.3-abi_encodegeneric-call-resource(anoma-generic-call-library/-witness)git tag v2.0.0-rc.3re-pin to v2.0.0-rc.3-abi_encodepa-evm(anoma-pa-evm-bindings3.0.0-rc.2)git rev 784fb37re-pin to v2.0.0-rc.3-abi_encodeAll three are unpublished at their v2 state, so both services also need git deps
fortransfer_library*,transfer_witness*,anoma-generic-call-*and
anoma-pa-evm-bindings— crates.iotransfer_library 2.0.0and
anoma-pa-evm-bindings 3.0.0-rc.2are still built against arm 1.1.1. -
alloy1.x → 2.x — confirm with @Michael whether this is necessary.
The v2 PA bindings requirealloy 2.2.0; backend-multichain is onalloy 1.1.2
and workers-queue onalloy 1.5.DynProvider,NamedChain, contract instances
and revert-decoding types do not cross the major boundary, so this is a separate,
non-trivial sub-migration touching every provider/broadcast/simulation call site. -
Kind table + on-chain constants. Decide the production kind table, ship it
to every process, and make sure it hashes to the PA’s stored
kindTableCommitment. It must include every logic ref actually used
(padding, transfer v1/v2, generic-call) and every token, not just arm’s sample file.
2.1 anomapay-backend-multichain
Cargo.toml (workspace)
anoma-rm-risc0/anoma-rm-risc0-gadgets→ git tag (above).transfer_library,transfer_library_v2,transfer_witness,transfer_witness_v2
→ git deps onanomapay-erc20-resource.anoma-generic-call-library,anoma-generic-call-witness→ git deps ongeneric-call-resource.anoma-pa-evm-bindings2.3.0→3.x(git onpa-evm).alloy1.1.2→2.x,alloy-primitives/alloy-sol-typesto match.- Feature sets
["transaction", "aggregation"]stay valid (aggregation_circuitwas never named here).
Decision: one N:M compliance unit per transaction
A transaction becomes exactly one compliance unit → one Action → one
action tree → one entry in the on-chain Action[]. This is the target shape
for everything below.
What it buys:
| v1 (1:1 units) | v2 (single N:M unit) | |
|---|---|---|
| Compliance proofs per tx | N (one per resource pair) | 1 |
| Base-proof jobs per tx | N + L | 1 + L |
| Aggregation batch size | N actions | 1 action (~6.2 s Groth16, flat) |
| Compliance proving cost | ~linear in N | ~flat with the kind table (1.29 s at 8 resources) |
| Delta message | concat of each unit’s tags | a single 32-byte action tree root |
| Padding resources | required to make counts match | no longer needed |
That last row is a real simplification: witness_data/mod.rs documents trivial
resources as “padding used to balance a transaction… sending 1 resource to 2
people consumes 1 but creates 2, so a created padding resource is inserted.”
An N:M unit has no count-matching requirement — constrain() iterates
consumed_data and created_resources independently and only the per-kind
quantity sums must balance. Padding resources are ephemeral and zero-quantity,
so dropping them changes no balance. Keep PaddingResourceLogic around (the
padding logic ref stays in the kind table), but stop inserting padding purely to
pair things up.
Construction order is now a hard sequence. Created nonces depend on the
consumed nullifiers, and created commitments depend on those nonces, so
parameters.rs must build in this order:
- Fix the consumed resource order (it defines nullifier order, which feeds nonce
derivation — it must be identical here and in the witness). nullifiers = consumed.map(|c| c.nullifier())digest = Resource::hash_nullifiers(&nullifiers)?- For each created resource at index
i:resource.nonce = Resource::derive_nonce(i as u32, digest) commitments = created.map(|r| r.commitment())— only valid after step 4action_tree = ActionTree::new([nullifiers…, commitments…].concat())- Logic witnesses for all resources, all against that one root, in
[consumed…, created…]order - One
ComplianceWitnessover all of them
crates/transfer_web/src/proving/parameters.rs — largest change
compliance_witnesses()→compliance_witness()(singular). Today itzips
consumed × created × merkle-proof into oneComplianceWitnessper pair;
it becomes one witness built fromVec<ConsumedResourceWitness>(resource +
cm_merkle_path+nf_key) andVec<Resource>, plus the kind table.
JobContext::compliance_witnesses: Vec<_>collapses to a single value.- One
rcvfor the whole transaction, soDeltaWitness::from_bytes_vec(&rcvs)
becomesDeltaWitness::from_bytes(&rcv). - All ephemeral consumed resources now share the unit’s single
ephemeral_root
(previously one per pair — in practice allINITIAL_ROOT, so no behaviour change). action_tree(): leaves are currently interleaved
[nf₀, cm₀, nf₁, cm₁, …]. Must become[all nullifiers…, all commitments…],
i.e.ComplianceInstance::tags()order.MerkleTree→ActionTree.logic_witnesses(): currently emits created first, then consumed. Must emit
consumed first, then created, matching the tag order —Action::newand
aggregate()now match positionally and will hard-fail otherwise.- Created-resource nonces: replace
nonce = consumed_nullifierwith the
derive_noncesequence above. generate_transaction():Action::new(compliance_units, logic_proofs)→
a singleAction::new(compliance_unit, logic_proofs);
Transaction::create(vec![action], …)keeps its one-element vec;
transaction.clone().verify()→transaction.verify()(takes&selfnow).- After
aggregate(),transaction.actionsisNone— anything downstream that
reads actions must readtransaction.aggregation.instance.actions(length 1). MAX_RESOURCESnow bounds N + M inside a single unit rather than the unit
count; re-check the limit against the compliance segment size.
crates/transfer_web/src/proving/simulation.rs — needs a redesign
ComplianceUnit { proof: None }/LogicVerifier { proof: None }→
proof: Vec::new().Action::new(Vec<ComplianceUnit>, …)→ the single unit.- The bigger problem:
From<Transaction> for IProtocolAdapter::Transactionin
the v2 bindings builds the sol transaction fromtx.aggregation.instance.actions
andpanic!s whenaggregationisNone. A proofless simulation transaction
has no aggregation. So the simulation path must construct an
AggregationInstancedirectly — with the singleActionAggregatedassembled
from the constrainedComplianceInstance(consumed_publics/created_publics,
delta_x,delta_y), each resource’s logicapp_data, and the locally computed
action_tree_root— rather than going throughTransaction. instance_journal()can be replaced byproving_system::instance_to_journal().
crates/transfer_web/src/protocol_adapter/evm.rs
ProtocolAdapter::Transaction→IProtocolAdapter::Transaction.fill_proof_selectors()must be rewritten: the v2 solTransactionhas no
logicVerifierInputs/complianceVerifierInputs— there is a single
aggregationProof._checkSelectorstill readsproof[0:4], so the 4-byte
verifier selector now needs to prefix that one field.transaction.actions.len()still works (sol-sideAction[]), but the elements
are the newConsumed/Createdshape.- Revert decoding moves to alloy 2 APIs.
crates/transfer_orchestrator/src/handlers/processing.rs
- The per-witness invariant loop uses
compliance_witness.created_resource,
.consumed_commitment(),.consumed_nullifier(&cm),.created_commitment(),
.delta()— none exist in v2. Rewrite overconstrain()'s
ComplianceInstance { consumed_publics, created_publics }. - The loop itself collapses: there is one witness, so the
“nullifier consumed in multiple CUs” / “commitment created in multiple CUs”
checks become duplicate checks within the single unit’s
consumed_publics/created_publics. - The “created resource nonce must match consumed nullifier” check becomes a
derive_noncecheck (andconstrain()already enforces it, so this can likely
just be dropped in favour of surfacingInvalidResourceNonce). compliance_witness.delta()is gone;constrain()computes the delta and
fails on a badrcv, so callingconstrain()once covers it.
crates/transfer_orchestrator/src/handlers/base_proofs.rs
- The compliance branch submits one job instead of N.
indexed_idempotency_key(tx_id, "compliance", i)
is now alwaysi = 0— either keep the index for key stability or switch to an
unindexed key, but do it deliberately: reused keys against the old worker would
return v1-shaped results.
crates/transfer_orchestrator/src/handlers/save_base_proofs.rs
LogicVerifier { proof: Some(receipt) }→proof: receipt;
ComplianceUnit { proof: Some(receipt) }→proof: receipt.Action::new(compliance_units, logic_proofs)→Action::new(compliance_unit, logic_proofs)
with the one unit fromctx.compliance_proof_results.- Ordering is now load-bearing:
ctx.logic_proof_resultsis filled by
try_join_allover the submitted job ids, so it preserves submission order —
that order must now equal[consumed…, created…]. Worth an explicit assertion
againstcompliance_instance.tags()before building the action. DeltaWitness::from_bytes_vec(&rcvs)→from_bytes(&rcv)(single unit, singlercv).Transaction::create(vec![action], …)unchanged in signature.
crates/transfer_orchestrator/src/handlers/context.rs
compliance_witnesses: Vec<ComplianceWitness>→ a singleOption<ComplianceWitness>;
compliance_proof_results: Vec<ComplianceUnit>→Option<ComplianceUnit>.
require_compliance_witnesses/all_compliance_proofs_completefollow.logic_proof_results: Vec<LogicVerifier>stays a vec, but now needs a documented
order contract ([consumed…, created…]) since matching is positional.
crates/transfer_orchestrator/src/services/queue.rs
- No API break, but
to_vec(compliance_witness)now serializes the new
variable-size witness — the queue worker must be on the same arm rev. COMPLIANCE_PKbytes changed (new ELF) → all cached/idempotent base-proof
results are invalidated.
Startup
- Call
anoma_rm_risc0::constants::init_kind_table_from_file(path)in
transfer_appbefore any verification, and ship the JSON in the Docker image.
Tests
MerkleTree→ActionTreeacrosscrates/transfer_web/src/tests/**
(helpers.rs,proving/{mint,burn,split,transfer}.rs,proving/v2/*,
queue_proving.rs) and fix the leaf ordering in each.queue_proving.rs:Action::new,ComplianceUnit { proof: Some },
LogicVerifier { proof: Some }.test/helpers/mocks.rs,test/handlers/validation_tx.rs: same fixups.
2.2 anomapay-workers-queue
Much smaller arm surface — 4 files — but bento is a full rewrite.
Cargo.toml (workspace)
anoma-rm-risc0/-gadgets→ git tag.anoma-pa-evm-bindings2.3.0→3.x(git),alloy1.5→2.x,
alloy-chainsto match. Keep thesppark = "=0.1.14"pin.
bento/src/lib.rs — rewrite aggregation_proof and finalize_transaction
This crate reimplements Transaction::aggregate() so it can set
segment_limit_po2. The v1 guest ABI it writes (four separate env.write calls:
compliance_instances_u32, compliance_key, lp_instances_u32, lp_vks) no
longer exists.
- Drop
ComplianceInstanceWordsandbytes_to_words(ComplianceInstanceWords
is gone). - Build a single
AggregationWitness. With one N:M unit per transaction its
actionsvec has exactly one element, but keep the loop — the guest, the
journal and the on-chainAction[]are all stillVec-shaped, and composed
transactions would carry more:
andAggregationWitness { compliance_key, actions: tx.actions.as_ref().ok_or(..)?.iter().map(|a| ActionWitness { compliance_instance: a.compliance_unit.get_instance()?, consumed_app_data: /* first n_consumed logic_verifier_inputs, in order */, created_app_data: /* the rest, in order */, }).collect(), }env_builder.write(&witnesses)once. Add assumptions from
a.compliance_unit.get_inner_receipt()and eachlvi.get_inner_receipt().
Mirror the tag/count assertionsTransaction::aggregate()does (tags.len() == logic_verifier_inputs.len(), positional tag equality) so a malformed job fails
fast instead of producing a bad proof. - Default proving key:
BATCH_AGGREGATION_PK(orBATCH_AGGREGATION_EVM_PKif
and whenabi_encodingis adopted). finalize_transaction: decode the journal intoAggregationInstance
(receipt.journal.decode(), orabi_decode_paramsunderabi_encoding), then
Note v1 serialized the wholetransaction.aggregation = Some(Aggregation { proof: bincode::serialize(&receipt.inner)?, instance }); transaction.actions = None;ProveInfointoaggregation_proof; v2 stores the
bincode ofInnerReceiptonly — the PA bindings’encode_seal(&aggregation.proof)
depends on that.base_proofs_are_empty()now meansactions.is_none(); the current
early-return still reads correctly but the error text should change.
protocol-adapter/src/lib.rs
generated::protocol_adapter::ProtocolAdapter::{ProtocolAdapterInstance, Transaction as PaTransaction}
→Transactionnow lives inIProtocolAdapter; keepProtocolAdapterInstance
fromProtocolAdapter.PaTransaction::from(transaction)now panics unlesstx.aggregation.is_some()
and the delta proof isDelta::Proof— add an explicit pre-check returning
BroadcastError::Deserialization/Fatalrather than letting it panic in a worker.- alloy 2 for
DynProvider,PendingTransactionBuilder,contract::Error,
as_revert_data,ReceiptResponse.
queue/src/handlers/broadcast/{mod.rs,rpc.rs}
- Type-compatible, but the test fixture
Transaction::create(vec![], Delta::Proof(DeltaProof { signature, recid }))
producesactions: Some(vec![]); if any test asserts on aggregation it needs the
new shape.DeltaProoffields are unchanged. - Gas estimation / simulation paths inherit the alloy 2 bump.
shared/, sdk/, gpu-worker/
AggregationProofPayload { batch_aggregation_pk, compliance_vk }still fits.
If the ABI variant is adopted later, add a flag (or let the backend send
BATCH_AGGREGATION_EVM_PKasbatch_aggregation_pk) — the worker must not be
able to silently prove the wrong guest.shared/src/codec.rsfixtures unaffected.
Follow up work
- Re-pin
anomapay-erc20-resource,generic-call-resourceandpa-evmto
v2.0.0-rc.3-abi_encode; - Agree the production kind table; regenerate the PA’s
kindTableCommitment;
publish the JSON as a shared artifact. - Land the
alloy1 → 2 bump in both services as its own PR (no arm changes) —
it is mechanically large and independent. - Bump arm in
anomapay-workers-queue(bentorewrite + PA bindings). Smaller
surface, and the backend depends on its behaviour. - Bump arm in
anomapay-backend-multichain, collapsing to the single N:M unit:
witness construction → nonce derivation → action tree ordering →
logic-witness ordering → simulation → orchestrator handlers → tests. - Drop the count-balancing padding resources once the N:M unit is in and the
end-to-end tests pass. - Check changes on the frontend.
Cutover note
Transaction is bincode-serialized across the backend↔queue boundary and
persisted in the DB (pre_aggregation_transaction, job payloads). The v2 struct
is not wire-compatible with v1 — actions became Option<Vec<Action>> and
aggregation_proof: Option<Vec<u8>> became aggregation: Option<Aggregation>,
so old blobs fail to deserialize rather than degrading gracefully. Drain all
in-flight jobs before cutover, or version the payload. COMPLIANCE_PK also
changed, so any cached or idempotency-keyed base-proof results from before the
bump are invalid.