The goal of this post is to propose a simple application that allows binding Ethereum ERC-20 assets to Anoma resources in a minimal way. This post is inspired by:
To deposit ERC-20 to Anoma, an Ethereum user creates an Anoma transaction, that includes:
created resources
call to the forwarder contract, sending their ERC-20 tokens
the amount of tokens they send corresponds to the quantity of the created resource
The transaction is sent to a remote prover for proving. The proven transaction is sent to the PA. The protocol adapter verifies the transaction, including the call to the forwarder contract. If all constraints pass, the resource is created. The assets the user sent to the forwarder contract are now locked there.
For native operations (transfer), the flow is the same, except that there are no calls to Ethereum included in the transaction.
To withdraw the assets, the transaction burns some resources and includes the call sending the assets from the forwarder contract to the specified address.
Resource fields
The logic is fixed for all ERC-20; the constraints are described below.
The label includes the associated forwarder contract address [1]. Different forwarder contracts = different labels = different resource kinds.
Value field contains the identity of the resource owner (public key). The corresponding private key will be used to authorize actions.
Constraints
Assumption: externalPayload has the following format: externalPayload = [senderAddress, receiverAddress, lockedQuantity, senderKey] Assumption: externalPayload is empty for Anoma internal transactions.
IF externalPayload.not_empty() = true
IF r.isConsumed = true AND r.isEphemeral = true[2]:
IF externalPayload.not_empty() = false AND r.isConsumed = true AND r.isEphemeral = false[7]:
verify(S, M, r.value.owner) = true
Note: M = commitments and nullifiers from the action and externalPayload passed in applicationPayload (when externalPayload was associated with the balancing resource from the action) or directly (when externalPayload is associated with self)
Thank you for the writeup. I have a couple of questions
In the original posts this was supposed to be a Permit2 signature allowing for the PA to forward the funds. Does this still have the same role here?
What is S here? Is it the signature?
Could you generally describe what the witness is for the application? That would make it more explicit I think.
Can you elaborate what M is? Are there any commitments or nullifiers inside the external Payload? Doesn’t seem like it. So I am unsure what “+” means here.
I guess “M” stands for some sort of a message?
If M contains all commitments and nullifiers in the action, shouldn’t we check this inside the logic?
Due to vagueness of S and M I am unsure what this means.
Did you by chance want it outside of the case where there is external calldata? Otherwise this breaks some stuff on the forwarder contract.
If my hunch here is correct and this should be moved into the case where the external payload is empty, then there should be a case for non-ephemeral creation in the below branch.
I am unsure what this means
I also wanted to say that for the calls to work, we also need to store the resource plaintext (and possibly its nullifier key) in the appdata for the PA to decode it. Otherwise the PA won’t be able to make sure that the forwarder can be called.
Do we want this particular plaintext resting in the resourcePayload or in the externalPayload instead? What about the nullifier key?
senderKey here refers to the Ethereum user’s public key. I have no opinion on where they get the keypair from as long as they can sign arbitrary messages with their keys.
Sorry, I didn’t specify this. In the statement verify(S, M, K) the function verify is the signature verification function, S is the signature, M is the message, and K is the key used to verify it. I added it as a footnote in the post.
No, externalPayload doesn’t contain any commitments or nullifiers, “+” is not used in a mathematical sense here. I sometimes use it instead of “and”.
What do you mean? We do check it inside the logic.
Not really. We just split the check into two: we verify authorisation on the consumption of a resource and verify the rest on the balancing resource (to allow burning multiple resources at once)
It means that the transaction on the RM side is valid (asset burned) but without external payload, no calls to the forwarder contract are made → no assets released from the forwarder contract
I would put both in resourcePayload
I was told that it is not necessarily the case. My assumption is yes, but if not, we need to add the ERC20 token contract in the label.
Aha, I see. That makes sense, but we certainly should also have a Permit2 signature there, otherwise no funds will be transferable.
I see, so it just contains all of this stuff encoded somewhere.
I see in the top post that we check the signature over M but I don’t see it specified anywhere in the logic that we are checking that the commitments and nullifiers of M actually result in the same root as given by the instance actionTreeRoot. Or is this checked in some other way?
I think that this part needs to be reworked a bit. It is important to remember here if there is anything in the non-ephemeral consumption external payload, the PA will make a call to the forwarder stored there. The burning mechanism (withdraw) seems to be implemented on the ephemeral creation side. What does the forwarder call do on the non-ephemeral consumption? Can you describe the semantics of the forwarder calls?
To reiterate: It seems that the semantics is to either lock in the forwarder contract on ephemeral consumption (mint) or withdraw funds to arbitrary address on ephemeral creation (burn). That part is basic and we used it in previous designs. But yeah, I am confused what it can then do on non-ephemeral consumption.
Note the comment is thinking about the developer experience beyond this specific thread so may fall out of scope.
@cwgoes this would mean that an application developer would need to deploy their own forwarder contracts in order for their particular token to interact with the PA, is that correct?
On ethereum main-net this would be around 1M gas. If you assume 10 gwei (currently much lower) at current eth price of $4000
These are estimates but to get an example of the historical gas price variability.
Year
Median
Mean (Average)
Mode
Range
2015
~10
~10
N/A
0-50
2016
~15
~20
N/A
5-60
2017
~20
~25
N/A
1-100
2018
~12
~22
N/A
3-80
2019
~10
~13
N/A
3-50
2020
~40
~64
N/A
10-540
2021
~100
~143
N/A
15-700+
2022
~30
~47
N/A
12-400
2023
~87~
~40
N/A
10-150+
2024
~1.9~
~20
N/A
1-83.1
2025
<1.2
~2
N/A
0.7-5
That being said Ethereum main-net validators are actively coordinating gas price increases currently at 45M gas limit per block and epecting 60M gas perhaps by year end or early next year.
Just want to note that this could be a devX pain point in a bull market setting where gas price is typically higher and ETH price is higher for sustained periods of time, making the $ cost higher.
The primary thing people like to do is trade and move ERC-20s around. We can quickly identify the top tokens and deploy forwarder contracts, but for the long-tail of tokens something to consider if we want to subsidize or not. However, we should note explicitly to developers they will have to deploy custom forwarder contracts if they want to either issue new ERC-20s and then use an application on the protocol adaptor with the tokens or use existing long-tail tokens in their application.
Excellent points @apriori, thank you for flagging.
In the current design, this is true. However, it is not a fundamental limitation, but rather a consequence of how we currently enforce RM:EVM state correspondence.
If we want to, I think it should be possible to support 1:n forwarder contract to ERC20 correspondence without any fundamental design changes – roughly, I think, we’d just need to:
Make the address of the ERC20 contract called part of the forwardCall data instead of a parameter fixed at creation of the forwarder contract.
Adjust the checks around resource kind to forwarder contract correspondence (instead of enforcing 1:1 resource kind to forwarder contract address correspondence, we’d enforce 1:1 resource kind to forwarder contract address and call target correspondence – I think all we need to change is calldataCarrierResourceKind(), which should take a callTarget parameter and compute the kind accordingly).
I’d be curious for thoughts from @vveiln or @ArtemG on this – I actually think this change should be very few LoC and maybe we should just do it now, but let’s ensure that the proposal is clear first.
There is a potential security issue: we cannot guarantee[1] that the address the user passes is ERC-20 and that the transfer function actually does a transfer. The only thing that the application can now grant is that that a contract with a transfer function name is being called but nothing more. This can change security assumptions as we now call an arbitrary user-specified contract with arbitrary logic! We do not know the logic so we do not know which security assumptions this breaks. Currently by hardcoding the address we can ensure that there is a specific ERC-20 we call with the verified transfer functionality.
As an additional minor point, this seems to be trying to solve a problem very specific to this application on the architecture layer. The architecture already supports allowing to put arbitrary data into input of a forwardCall which may or may not include some additional address on which the call is parameterized. But this change now impacts the core correspondence of a contract to a specific kind. I would want to see how this would benefit any other applications first to make this decision. Personally I do not see any problem with application developers deploying their own forwarders if needed.
I don’t know what that signature is or who verifies it, so I assume it can be safely kept outside of the backend application scope?
Actually I think I wrote it in a dumb way. Since we won’t assemble the hash of the message out of circuit, we can just put externalPayload in the applicationPayload, commitments and nullifiers have to be passed to the circuit anyway. Unless we implement the change I suggested below. Then we don’t have to pass anything in applicationPayload.
Non-ephemeral consumption and ephemeral creation happen together on the burn. The call to the forwarder contract is associated with ephemeral creation. The ephemeral resource specifies how much in total we want to withdraw, which can be provided from multiple resources (owned by multiple users). On the persistent consumption side, we just need to verify the signature of the owner. The owner of the resource signs a message that includes externalPayload associated with the balancing ephemeral resource. That implies that we either need to pass externalPayload associated with the balancing resource as applicationPayload, or perhaps we might want to associate externalPayload with actions instead of resources? That would practically limit the scope of the action - one external call per action - but would allow all resource logics involved to have access to the calldata. What do you think?
I think so, but @Michael will be the person to ask here probably.
I am a bit confused. Why do we need to do that? Doesn’t the externalPayload get passed to the circuit either way? Why put it inside applicationPayload? Does that also need an out-of-circuit check that externalPayload corresponds to the payload in applicationPayload tail?
But it does not see it, as it is not in its scope. If there is a forwarder call in its scope, the PA will currently make a call to the corresponding forwarder contract, which it seems we want to avoid.
I am very sceptical of this to be honest. This both highly limits the amount of stuff we can put into one action and also would need to reengineer the entire out-of-circuit correspondence checks.
I generally do not see the problem of just making the non=ephemeral consumption check just a check of signature of the value.owner as in the non-ephemeral creation. What is the problem in that? You already do that actually in the original post. There is no reason to involve external calldata in that.
I think that the original proposed design is 99% complete and the only thing I would change is move the non-ephemeral consumption case to the branch where there is no external payload in the app data.
This is a very quick fix and does jot require any architecture changes.
Because we use externalPayload data that is not associated with self.
Yes, we need to ensure consistency in some way.
Who doesn’t see what? I’m not sure I understand. Ephemeral and persistent resources are ultimately created by the same entity - the user that initiates the transaction. They create an ephemeral resource, associate the call with it, reuse the calldata they just created to authorise the consumption of their own persistent resources they are burning, sent all the data to the prover. What am I missing?
Could you elaborate on that? How does it limit it? And how does it change out-of-circuit correspondence checks?
Not sure I follow your point. That is exactly what we do. We just need to sign the calldata payload because it contains the action we are authorizing.
Ah yes, you are actually right about that: the external data associated with the consumed resource is empty, so we move the persistent signature check out.
But we do have to sign over all externalPayload fields for all resources involved in the transfer since the action we are authorising is encoded in externalPayload associated with some resource. It doesn’t seem right. To me it strongly suggests that externalPayload field should be associated with the action, not a single resource.
For any action, we want the user whose assets get consumed (on either side) to authorise it. The action has two parts:
what resources are consumed and created (RM side action authorised)
what external calls are made (Ethereum side action authorised)
The authorisation check is triggered by consumption (of a persistent resource or sending an Ethereum asset somewhere). The Ethereum call that has to be authorised is associated with an ephemeral resource - a different resource than the one that triggers the authorisation check. So the user has to authorise:
In Ethereum-land, transfer and the ERC20 standard more generally are treated as a message interface: any contract that implements the interface can be interacted with in this way. Indeed, there’s no enforcement that transfer “does a transfer” in some semantic sense, but we’re not promising that, we’re just promising that the transfer message will be processed (in whatever way the ERC20 contract implements it).
The user (or developer who configures another token) can ensure the same thing by checking the contract that they’re interacting with. Why would whether or not a new forwarder contract is deployed be a meaningful difference here?
We could also just create a new “multi-ERC20-forwarder” contract which takes the address of the ERC20 token to call as part of the input to forwardCall and thus supports multiple ERC20 tokens. However, this would still just correspond to one resource kind in the RM state (by our current PA logic), I believe, which would mean that we’d need to put the ERC20 contract address in the value, and use some additional logic in order to convert it to a resource of kind dependent on the ERC20 contract address (which I do think we want for the transfers app). This seems somewhat less efficient.
I agree that the current design works. However, I don’t think it’s as minimal as it could be. The most minimal version is probably one where a forwarder contract can (a) execute an arbitrary call on an arbitrary target and (b) compute the kind based on the target and call. Then we leave the question of kind:call correspondence up to individual forwarder contracts instead of “hardcoding” it as 1:1.
An evident additional question is: why parametrize only the label and not the logic then?
Generally, this is a fairly big change in the design in the sense that I need to do some discovery to understand all the consequences this has on the application, forwarder contract, and PA design.
Let’s decide on that after Michael is back and start a new thread. The decision re forwarder contract design is more general than AnomaPay design, so we should move the discussion from here for visibility.
You are saying that we want to parametrize the kind of the resource that can make a forwarder call to a specific forwarder contract.
Why then pass only a contract address for an ERC-20? What if a forwarder contract has a lot of parameters on which the label is dependent. Then a more principled design is to have a separate field for label info of arbitrary format.
Why pass only the label info and not the logic info as well? If one forwarder contract can support several kinds and several labels, why not several logics?
We already have a method calldataCarrierResourceKind (here). I’m not sure what field you’re talking about – all we need to do in order to make the kind parameterized over some data is to pass that data as a parameter to this method.
I’m not following this either, I’m not proposing passing the label info to the forwarder contract. I do not think we should be passing resource data (e.g. label or logic) to the forwarder contract, that seems quite odd. All I’m proposing is changing the signature of calldataCarrierResourceKind to allow for more parameters of the call executed by the forwarder contract to be parameterized (e.g. the target ERC20 contract address), and to allow for the kind of the corresponding resource to be computed based on those parameters.
But now you need to pass the label (ERC20 address) info to the forwardCall function as well as otherwise it won’t know which ERC20 contract to call without it.
That, or you have to manually check label to input correspondence on the PA label which we cannot do as inputs are arbitrary.
So it is not enough to just change the function to get the kind as the forwardCall is now parametrized by that data.
Re the logic: I apologize for siderailing and continuing the discussion here. I should really wait until the new thread is started and go from there.
Yes, you need to pass the ERC20 contract address to forwardCall, and the resource logic which checks the forwarder contract call will need to check that the ERC20 address in the ForwarderCalldata.
Quick documentation of decision by @cwgoes and @ArtemG: punt any forwarder contract structure changes to a future version, and do discovery on them in a separate topic.