Search code examples
rustnearprotocolrust-wasm

How to handle exceptions in NEAR cross contract calls?


How can I catch and handle an exception in a chain of async call between contracts?

Suppose, that my transaction initiate the following calls:

contractA.run()
  -> do changes in contractA
  -> calls contractB.run()
     -> do changes in contractB
  -> then calls another method on contractA: contractA.callback()
     * callback() crashes

After an exception in a Promise, NEAR is not rolling back changes occured in past promises. I also don't see any method for handling exceptions in near-sdk.

One idea would be to return errors instead of throwing exceptions and create bunch of private functions to update the state after error value and adding / releasing mutexes. However this won't solve sometimes we can't control that, eg in external smart-contracts (eg, if contractB.do would panic in the example above).


Solution

  • The only way to catch an exception is to have a callback on the promise that generated the exception.

    In the explained scenario, the contractA.callback() shouldn't crash. You need to construct the contract carefully enough to avoid failing on the callback. Most of the time it's possible to do, since you control the input to the callback and the amount gas attached. If the callback fails, it's similar to having an exception within an exception handling code.

    Also note, that you can make sure the callback is scheduled properly with the enough gas attached in contractA.run(). If it's not the case and for example you don't have enough gas attached to run, the scheduling of callback and other promise will fail and the entire state from run changes is rolled back. But once run completes, the state changes from run are committed and callback has to be carefully processed.

    We have a few places in lockup contract where the callback is allowed to fail: https://github.com/near/core-contracts/blob/6fb13584d5c9eb1b372cfd80cd18f4a4ba8d15b6/lockup/src/owner_callbacks.rs#L7-L24

    And also most of the places where the callback doesn't fail: https://github.com/near/core-contracts/blob/6fb13584d5c9eb1b372cfd80cd18f4a4ba8d15b6/lockup/src/owner_callbacks.rs#L28-L61


    To point out there are some situation where the contract doesn't want to rely on the stability of other contracts, e.g. when the flow is A --> B --> A --> B. In this case B can't attach the callback to the resource given to A. For these scenarios we were discussing a possibility of adding a specific construct that is an atomic and has a resolving callback once it's dropped. We called it Safe: https://github.com/nearprotocol/NEPs/pull/26


    EDIT

    What if contractB.run fails and I will like to update the state in contractA to rollback changes from contractA.run?

    In this case contractA.callback() is still called, but it has PromiseResult::Failed for its dependency contractB.run.

    So callback() can modify the state of contractA to revert changes.

    For example, a callback from the lockup contract implementation to handle withdrawal from the staking pool contract: https://github.com/near/core-contracts/blob/6fb13584d5c9eb1b372cfd80cd18f4a4ba8d15b6/lockup/src/foundation_callbacks.rs#L143-L185

    If we adapt names to match the example:

    The lockup contract (contractA) tries to withdraws funds (run()) from the staking pool (contractB), but the funds might still be locked due to recent unstaking, so the withdrawal fails (contractB.run() fails). The callback is called (contractA.callback()) and it checks the success of the promise (of contractB.run). Since withdrawal failed, callback reverts the state back to the original (reverts the status).

    Actually, it's slightly more complicated because the actual sequence is A.withdraw_all -> B.get_amount -> A.on_amount_for_withdraw -> B.withdraw(amount) -> A.on_withdraw