Skip to content

feat(contracts): add executeWithCallOrder - #740

Closed
jonaprieto wants to merge 16 commits into
nextfrom
jonaprieto/carried-call-order
Closed

jonaprieto wants to merge 16 commits into
nextfrom
jonaprieto/carried-call-order

Conversation

@jonaprieto

@jonaprieto jonaprieto commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

adds executeWithCallOrder(Transaction, uint256[]). a transaction can carry the order of its call lists, and the pa checks that order instead of sorting: each call list once, the calls of each action together, and every ordering constraint of #708. the delta proof signs keccak256(abi.encode(transactionId, callOrder)), so whoever relays the transaction cannot reorder or drop calls. execute, the journal and the existing abi don't change, so there are no new arm-risc0 keys and the deployed adapters gain the function by an upgrade. calls still run after all state changes, as in rc.12.

the order is the choice of whoever holds the delta witness, within the tags each logic proof binds. that is the user only when the user also proves compliance; in anomapay today the backend's prover receives the delta randomness, so it picks whatever the tags leave open. this is the gap #741 closes by tying the order to the action tree root. its two objections are about committing constraints in value_ref; tags in app data avoid both, since they are not in the nonce derivation and a persistent resource takes them at spend time, but only the logic proof covers them, not the user's signature.

closing that gap on top of this needs two changes outside the pa. the logics that want a user-fixed order, anomapay-erc20-resource and generic-call-resource after #20, check the owner's signature over their tags together with the action tree root, so the prover cannot choose them, and the tags can pin a full order if the user wants one. and the sdk signs the order with sign_call_order when the user composes the transaction. the order across actions stays with the composer, as it does with #741.

A transaction can carry the order of its call lists in callOrder, signed
by the delta proof. The adapter checks it instead of sorting the graph of
the ordering constraints, so an application can state an order that the
constraints of the generic call alone cannot.
@jonaprieto jonaprieto added the documentation Improvements or additions to documentation label Oct 23, 2026
@jonaprieto jonaprieto self-assigned this Oct 23, 2026
The carried order already keeps the actions in transaction order, so it
could live in each action. Record what that gains (numbers that survive
composition, an order that the user proves) and what it costs.
ForwarderCallOrdering.checkCallOrder checks an order of the call lists
instead of computing one, as ADR-0005 proposes: it names each call list
once, keeps the actions in the order of the transaction, and meets every
ordering constraint. It builds no graph, so it has no limit of 256 call
lists. Nothing calls it yet.
Transaction gets a callOrder field. When it is not empty, the delta proof
signs keccak256(abi.encode(transactionId, callOrder)) instead of the
transaction ID, and execute checks the order with checkCallOrder and runs
the call lists in it, as ADR-0005 proposes. An empty callOrder keeps the
current order and delta message.

The field changes the ABI and the selector of execute, so the bindings
and the gas report are regenerated.
Add the carried call order to the design of the external call ordering
and to the glossary: the delta proof signs it, the protocol adapter
checks it in three steps instead of sorting, and the actions keep the
order of the transaction.
@jonaprieto jonaprieto added the enhancement New feature or request label Oct 23, 2026
@jonaprieto jonaprieto changed the title docs(adr): propose that the transaction can carry the call order feat(contracts)!: let the transaction carry its call order Oct 23, 2026
@jonaprieto
jonaprieto marked this pull request as ready for review October 23, 2026 15:47
Moves the call order out of `Transaction` into a parameter of a new
entry point, `executeWithCallOrder(Transaction, uint256[])`. `execute`,
its selector and `Transaction` stay as on `next`, so existing clients,
bindings and indexers keep working, and only a transaction that comes
with a call order pays for it in calldata. The check and the delta
signing do not change.
Updates the ADR, the design of the external call ordering and the
glossary for the separate entry point. The ADR now states that the
composer, not the provers of an action, chooses the order inside it,
that an extra generic call reaches the same orders today, and that the
ARM must sign the new delta message before a client can carry an order.
@jonaprieto jonaprieto changed the title feat(contracts)!: let the transaction carry its call order feat(contracts): add executeWithCallOrder Oct 23, 2026
The design of the external call ordering already describes the entry
point and its check, and the team has not decided on the call order
yet, so the ADR can wait until it does.
Adds `sign_call_order`, which replaces the delta proof of a converted
transaction with one over `abi.encode(transactionId, callOrder)`, the
message that `executeWithCallOrder` checks. It signs with the delta
witness through ARM's `delta_proof::prove`, so clients can carry an
order without a change to the ARM.
The ordering constraints are the rules of the applications, which the
logic proofs bind through the app data, and the call order is the
choice of whoever holds the delta witness within them. Also points to
the bindings helper that signs the order.
The Rust test pins the delta proof that sign_call_order makes for a fixed
witness and order, and the Forge test verifies those bytes with
DeltaProof.verify against computeVerifyingKeyWithCallOrder. ECDSA in k256 is
deterministic, so a change on either side breaks one of the two tests.
executeWithCallOrder rejects a non-empty order for a transaction without call
lists with CallOrderNotAPermutation, as it rejects an order of the wrong
length. The NatSpec of the error now says so.
checkCallOrder required the action indices to never decrease along the
order, so a transaction whose constraints run a later action first could
use execute but not executeWithCallOrder. It now requires only what order
does: the call lists of an action run together. Every order that execute
computes is then a valid call order, and CallOrderSplitsAction replaces
CallOrderNotInActionOrder.
…ll order

A fuzz test builds up to 8 call lists over up to 8 actions with up to 8
afterTags and beforeTags constraints, and checks that checkCallOrder accepts
the order that order computes, unless order finds a cycle. With the earlier
rule that the action indices never decrease, it finds a counterexample.
checkCallOrder finds the call list of each tag by a linear search, so with no
limit on the call lists its cost grew with the product of tags and call
lists. Once a call carries ordering constraints, it now limits the call lists
to DirectedGraph.MAX_NODE_COUNT with TooManyCallCarryingResources, as order
does, so each tag costs at most 256 comparisons. Without constraints there is
no search and no limit, as in execute.
@jonaprieto

Copy link
Copy Markdown
Contributor Author

closing in favor of #741. the order here is the pick of whoever holds the delta witness, which in anomapay is the backend, so the user's signature still doesn't fix it. if #741 stalls on the vk change, the user-bound route is the logics checking an owner signature over their tags with the action tree root; that needs no pa change and makes executeWithCallOrder redundant.

@jonaprieto jonaprieto closed this Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant