Cross Contract Calls

Aurora ecosystem allows EVM contracts to interact between each other, as long as they all are deployed inside Aurora. However Aurora on itself is a NEAR smart contract, so interaction with other smart contracts is definitely possible by only using interfaces provided on NEAR blockchain.

This is a proposal that allows smart contracts created in Aurora to interact with other smart contracts on NEAR: https://github.com/aurora-is-near/AIPs/pull/2

6 Likes

Thanks for writing this up @marcelo.near !

I have a suggestion for the Promise data structure and corresponding interfaces of the precompile and async contract.

I think Promise should be defined as a recursive data structure

pub enum Promise {
    Then {base: Box<Promise>, callback: Box<Promise>},
    And(Box<Promise>, Box<Promise>),
    Call {target: AccountId, ...}
}

This makes writing invalid promises impossible. When the combinators are done via the index indirection it would be possible to write a promise that depends on an index that does not exist, or even was created as part of a different batch (which could maybe lead to some frontrunning problems?).

With this change the interfaces also become simpler because only a single identifier needs to be returned by the schedule call, and similarly only a single identifier needs to be passed to the execute call.

trait AuroraRouter {
    pub fn schedule(&mut self, promise: Promise) -> PromiseId;
    pub fn pull(&mut self, promise: PromiseId, total_gas: Gas, total_balance: Balance) -> Option<Promise>;
}

trait AsyncAurora {
    pub fn pull_and_execute(&mut self, aurora: AccountId, promise: PromiseId, total_gas: Gas, total_balance: Balance);
    pub fn submit_and_execute(&mut self, aurora: AccountId, submit_payload: Vec<u8>, total_gas: Gas, total_balance: Balance);
}

Note that it is still possible to put multiple calls in a single promise because And and Then combinators are baked in to the Promise type. For example instead of passing vec![0, 1] to execute I would call schedule with Promise::And(..., ...), getting back a single identifier to pass to execute that still does both promises.

2 Likes

I also have a question about attaching NEAR to the promises. Apologies if this was mentioned in the spec and I missed it, but it didn’t seem clear to me where the NEAR to attach to the user-defined promises comes from. Ideally it would come from the user who defined the promise, but doing so from inside the EVM seems tricky. Maybe they would need to approve the precompile address to spend some wnear ERC-20? Even if the precompile takes possession of the wnear, it then still needs to somehow transfer it as real NEAR to async.aurora since that is what actually makes the real promises.

Excellent idea, agree with all your points. Will update the AIP to use this approach.

When I was thinking about this, it was to enable some use cases that are not supposed to be executed through our relayer. The main point is that either the user relays the tx (through the app) or the app itself relays the tx, and charge the attached NEAR in some alternative way (app-specific). This standard won’t provide a mechanism that forces the user to pay this to the relayer, but it will force the account (relayer) to pay for it if it wants to consume the tx.

We can consider using wNEAR as you suggested, but I’m afraid it will make the whole design more complex, and it will require at least one more cross contract call (to wnear contract), and we need to make sure this tx can’t fail (gas-wise) but at the same time, it needs to be reverted if the whole tx fails.

I see three potential options:

  1. Remove this functionality (attaching NEAR) all-together. No NEAR can be attached for the cross contract calls, and apps that might need this should use an alternative mechanism, attach the NEAR in a different step of the transaction execution.
  2. Keep this mechanism as is. It won’t be useful immediately since the main relayer (our relayer) will not attach NEAR, but specific apps can provide their own relayers which could attach NEAR if required.
  3. Extend the design to make sure the user pays the relayer in wNEAR for the equivalent balance attached.

My preference regarding this options are 2, 1, 3. The main rationale for 2) is that having this feature won’t affect us, even if not used at all, but it can be used in the future by some unforseen applications. Option 1) seems reasonable as well. I’d personally avoid 3), increasing the complexity of the proposal, for a feature that might not be required or used.

app itself relays the tx, and charge the attached NEAR in some alternative way (app-specific)

Makes sense, thanks for the clarification! I agree that 2 seems like the best option for now.

1 Like

In the current proposal, an Aurora transaction needs to be submitted in a special way to permit calls to NEAR. This breaks composability: with this design, it is not possible to encapsulate calls to NEAR into an Aurora contract that can be called normally. Why not make a precompile for NEAR calls instead? There are already precompiles that do NEAR calls internally (exit to NEAR/Ethereum precompiles), so the new one would be very similar.

One question is how to handle the result of the call. One approach is just to store it and make it possible to query it using another precompile call. Some kind of relayer would need to watch the blockchain and submit an appropriate transaction when the result is available. With this approach, if the transaction runs out of gas, it can be resubmitted with more gas. If some EVM code is called directly from the callback, it’s not possible to do anything if it runs out of gas.

1 Like

After reading the proposal I am still not sure why do we need two contracts AsyncRouter and AsyncAurora where one schedules promises, and another pull them from the queue, if we can just launch a promise directly from the Aurora contract.

Btw, don’t forget that many cross-contract calls need callbacks, so make sure to make declaring callback easy from the Solidity code.

1 Like

@eatmore @nearmax The main reason to use cross contract calls in two steps (schedule / pull) is due to security consideration described in this comment. Basically we don’t want users to trigger arbitrary cross contract calls on behalf of aurora contract (with aurora as predecessor_account_id).

There is a separate suggestion to always go through a proxy contract (async.aurora) that should not have any special privileges (i.e all promises from aurora are redirected through async.aurora to the target contract). I think this could be a good compromise.

The current proposal, tries to decrease the surface of attacks and issues, by moving the promise creation altogether outside of aurora contract.

The interface we are proposing already allows NEAR combinators. We plan to expose this to the users through an Aurora SDK in solidity, so the don’t need to interact with the low level primitives.

What is the reason for choosing this design over the proxy contract design? It seems that developer experience may be better in the proxy contract design

The main reason was simplicity in the engine contract, and less exposure to potential issues introducing promises directly in aurora.

I’ve been fine with the internal discussions we had before the EIP has been created. But thinking out of the box, I’m concerned about the upgradability and how future proofed it is. I can’t imagine that we would be able to upgrade everything simultaneously, especially in the future as we grow.

Say that there is a better way to do promises in the future, and multiple contracts end up using these cross contract calls.

I can’t imagine that we could simulataneously upgrade everything at the same time. Especially if we introduce something in the future where the community can be a part of Engine releases (not saying that will happen, but there was a couple discussions about it before). This would make an upgrade extremely difficult to do.

So, the basis of doing a proper proxy contract is the ability to use something like an ETH delegate call. However, when we do a call through Aurora, as far as we could tell, we can not emulate the behaviour of passing the caller as the user and not the contract. This can lead to absolutely dire outcomes to the security of the contract, could it not?

There were two alternative proposals discussed during NEAR Protocol meeting.


Proposal from @eatmore

Initiate promises from aurora contract. Provide authentication of the message sender in the input, and only allow calling a single method name: on_aurora_message Pros:

  • Simple to implement.
  • Cheap (in term of cross contract call overhead)

Cons:

  • Requires target contract to support an special interface

Proposal from @illia

Aurora contract will be a factory, that will deploy (on demand) one contract per 0x address inside aurora. Cross contract calls initiated by this aurora address, will call the child contract, and initiate the arbitrary cross contract call from it.Pros:

  • Authentication is provided using predecessor_account_id
  • It can call any contract from an arbitrary method and payload.

Cons:

  • One more cross contract call of overhead

It is worth highlighting to proposals that makes this proposal cheaper (with regards to factory and deployed subcontracts):

1 Like

Ultimately, less overhead the better imo. I think we can all agree to that though.

RE Aurora contract as a factory design:

It will take quite an effort to maintain all these sub-contract and update them in case of new features and/or some possible security vulnerabilities. For sure, in case we urgently need to pause all the proxy sub-contracts, all of them might be utilizing a design on having another cross-contract call to the factory (Aurora) and verifying if the promise scheduling feature is paused or not. In this case, Aurora contract factory could be the only point to control all of them, but this still adds another overhead of having one more cross-contract call and doesn’t solve the maintainability issue (and the storage issue).
What we can do to solve the maintainability issue is to let every proxy contract work through the other smart-contract that will actually contain the proxy implementation (one implementation for all proxy sub-contracts).

RE composability: in case we store the scheduled promises in Aurora Router, even if the promise (promise 1) after execution calls Aurora contract that schedules another promise (promise 2), it will be still possible to execute one in the next NEAR transaction (using pull_and_execute(promise_2) functionality)