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).
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
What if
contractB.run
fails and I will like to update the state incontractA
to rollback changes fromcontractA.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