Several functions in the protocol call Rescuable::_safe_transfer or Rescuable::_safe_transfer_from functions. But these functions will not work properly on ZKsync Era chain.
The TokenManager::tillIn, TokenManager::withdraw and TokenManager::_transfer functions rely on Rescuable::_safe_transfer or Rescuable::_safe_transfer_from functions:
TokenManager::tillIn:
TokenManager::withdraw:
TokenManager::_transfer:
The Rescuable contract is not in scope but the _safe_transfer and _safe_transfer_from functions are used in scope in very important for the protocol functionality functions. So the issues related to these two functions will break the protocol functionality.
Let's consider the _safe_transfer and _safe_transfer_from functions:
The purpose of these functions is to transfer a given token or ethers. According to the documentation of the contest the protocol should be compatible with every EVM-compatible chain:
The problem is that the _safe_transfer and _safe_transfer_from functions transfer tokens or ethers using the call function. But the way in which they use the call function will not work on ZKsync chain. The call on ZKsync chain works differently according to the docs:
https://docs.zksync.io/build/developer-reference/ethereum-differences/evm-instructions#call-staticcall-delegatecall
For calls, you specify a memory slice to write the return data to, e.g. out and outsize arguments for call(g, a, v, in, insize, out, outsize). In EVM, if outsize != 0, the allocated memory will grow to out + outsize (rounded up to the words) regardless of the returndatasize. On ZKsync Era, returndatacopy, similar to calldatacopy, is implemented as a cycle iterating over return data with a few additional checks and triggering a panic if out + outsize > returndatasize to simulate the same behavior as in EVM.
Thus, unlike EVM where memory growth occurs before the call itself, on ZKsync Era, the necessary copying of return data happens only after the call has ended, leading to a difference in msize() and sometimes ZKsync Era not panicking where EVM would panic due to the difference in memory growth.
Additionally, there is no native support for passing Ether on ZKsync Era, so it is handled by a special system contract called MsgValueSimulator. The simulator receives the callee address and Ether amount, performs all necessary balance changes, and then calls the callee.
Let's take a look on a differences between call on Ethereum and on ZKsync:
In Ethereum when using call(gas(), target, value, in, insize, out, outsize), the EVM will allocate memory up to out + outsize regardless of the actual size of - the returned data (returndatasize). This allocation happens before the call is made. If outsize is non-zero, the memory growth occurs immediately.
In ZKsync the memory allocation is performed for return data only after the call ends. Instead of pre-allocating memory, ZKsync Era copies the return data with additional checks to ensure that out + outsize does not exceed returndatasize. This means msize() (memory size) differs between ZKsync Era and EVM during the call, and ZKsync might not trigger a panic in scenarios where EVM would, due to the difference in memory growth timing.
Also, if the functions should work with ethers like the comments describe, that will be impossible on ZKsync. That is because there is no native support for passing Ether on ZKsync Era, so it is handled by a special system contract called MsgValueSimulator.
The different behaviour of call method on ZKsync Era chain could lead to failed transactions and loss funds.
Certain calls that should fail due to excessive memory usage might initially appear successful but will eventually fail due to an EVM error. Conversely, because the _safe_transfer and _safe_transfer_from functions store returned data in memory, some operations that should succeed might end up failing.
Manual Review
Use the assembly call instead of the standard .call that limits the return data to length 0.
I would require a more explicit example on zkSync mainnet to prove that such a low level call is indeed an issue, i.e. a example live contract on zkSync that shows the usual low level `call()` cannot be utilized.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.