mirror of
https://github.com/serai-dex/serai.git
synced 2024-12-30 15:39:23 +00:00
554c5778e4
This technically has a TOCTOU where we sync an Epoch's metadata (signifying we did sync to that point), then check if the Router was deployed, yet at that very moment the node resets to genesis. By ensuring the Router is deployed, we avoid this (and don't need to track the deployment block in-contract). Also uses a JoinSet to sync the 32 blocks in parallel.
240 lines
9.1 KiB
Solidity
240 lines
9.1 KiB
Solidity
// SPDX-License-Identifier: AGPL-3.0-only
|
|
pragma solidity ^0.8.26;
|
|
|
|
import "IERC20.sol";
|
|
|
|
import "Schnorr.sol";
|
|
|
|
// _ is used as a prefix for internal functions and smart-contract-scoped variables
|
|
contract Router {
|
|
// Nonce is incremented for each command executed, preventing replays
|
|
uint256 private _nonce;
|
|
|
|
// The nonce which will be used for the smart contracts we deploy, enabling
|
|
// predicting their addresses
|
|
uint256 private _smartContractNonce;
|
|
|
|
// The current public key, defined as per the Schnorr library
|
|
bytes32 private _seraiKey;
|
|
|
|
enum DestinationType {
|
|
Address,
|
|
Code
|
|
}
|
|
|
|
struct AddressDestination {
|
|
address destination;
|
|
}
|
|
|
|
struct CodeDestination {
|
|
uint32 gas_limit;
|
|
bytes code;
|
|
}
|
|
|
|
struct OutInstruction {
|
|
DestinationType destinationType;
|
|
bytes destination;
|
|
uint256 value;
|
|
}
|
|
|
|
struct Signature {
|
|
bytes32 c;
|
|
bytes32 s;
|
|
}
|
|
|
|
event SeraiKeyUpdated(uint256 indexed nonce, bytes32 indexed key);
|
|
event InInstruction(
|
|
address indexed from, address indexed coin, uint256 amount, bytes instruction
|
|
);
|
|
event Executed(uint256 indexed nonce, bytes32 indexed message_hash);
|
|
|
|
error InvalidSignature();
|
|
error InvalidAmount();
|
|
error FailedTransfer();
|
|
|
|
// Update the Serai key at the end of the current function.
|
|
modifier _updateSeraiKeyAtEndOfFn(uint256 nonceUpdatedWith, bytes32 newSeraiKey) {
|
|
// Run the function itself.
|
|
_;
|
|
|
|
// Update the key.
|
|
_seraiKey = newSeraiKey;
|
|
emit SeraiKeyUpdated(nonceUpdatedWith, newSeraiKey);
|
|
}
|
|
|
|
constructor(bytes32 initialSeraiKey) _updateSeraiKeyAtEndOfFn(0, initialSeraiKey) {
|
|
// We consumed nonce 0 when setting the initial Serai key
|
|
_nonce = 1;
|
|
// Nonces are incremented by 1 upon account creation, prior to any code execution, per EIP-161
|
|
// This is incompatible with any networks which don't have their nonces start at 0
|
|
_smartContractNonce = 1;
|
|
}
|
|
|
|
// updateSeraiKey validates the given Schnorr signature against the current public key, and if
|
|
// successful, updates the contract's public key to the one specified.
|
|
function updateSeraiKey(bytes32 newSeraiKey, Signature calldata signature)
|
|
external
|
|
_updateSeraiKeyAtEndOfFn(_nonce, newSeraiKey)
|
|
{
|
|
// This DST needs a length prefix as well to prevent DSTs potentially being substrings of each
|
|
// other, yet this fine for our very well-defined, limited use
|
|
bytes32 message =
|
|
keccak256(abi.encodePacked("updateSeraiKey", block.chainid, _nonce, newSeraiKey));
|
|
_nonce++;
|
|
|
|
if (!Schnorr.verify(_seraiKey, message, signature.c, signature.s)) {
|
|
revert InvalidSignature();
|
|
}
|
|
}
|
|
|
|
function inInstruction(address coin, uint256 amount, bytes memory instruction) external payable {
|
|
if (coin == address(0)) {
|
|
if (amount != msg.value) revert InvalidAmount();
|
|
} else {
|
|
(bool success, bytes memory res) = address(coin).call(
|
|
abi.encodeWithSelector(IERC20.transferFrom.selector, msg.sender, address(this), amount)
|
|
);
|
|
|
|
// Require there was nothing returned, which is done by some non-standard tokens, or that the
|
|
// ERC20 contract did in fact return true
|
|
bool nonStandardResOrTrue = (res.length == 0) || abi.decode(res, (bool));
|
|
if (!(success && nonStandardResOrTrue)) revert FailedTransfer();
|
|
}
|
|
|
|
/*
|
|
Due to fee-on-transfer tokens, emitting the amount directly is frowned upon. The amount
|
|
instructed to be transferred may not actually be the amount transferred.
|
|
|
|
If we add nonReentrant to every single function which can effect the balance, we can check the
|
|
amount exactly matches. This prevents transfers of less value than expected occurring, at
|
|
least, not without an additional transfer to top up the difference (which isn't routed through
|
|
this contract and accordingly isn't trying to artificially create events from this contract).
|
|
|
|
If we don't add nonReentrant, a transfer can be started, and then a new transfer for the
|
|
difference can follow it up (again and again until a rounding error is reached). This contract
|
|
would believe all transfers were done in full, despite each only being done in part (except
|
|
for the last one).
|
|
|
|
Given fee-on-transfer tokens aren't intended to be supported, the only token actively planned
|
|
to be supported is Dai and it doesn't have any fee-on-transfer logic, and how fee-on-transfer
|
|
tokens aren't even able to be supported at this time by the larger Serai network, we simply
|
|
classify this entire class of tokens as non-standard implementations which induce undefined
|
|
behavior.
|
|
|
|
It is the Serai network's role not to add support for any non-standard implementations.
|
|
*/
|
|
emit InInstruction(msg.sender, coin, amount, instruction);
|
|
}
|
|
|
|
/*
|
|
We on purposely do not check if these calls succeed. A call either succeeded, and there's no
|
|
problem, or the call failed due to:
|
|
A) An insolvency
|
|
B) A malicious receiver
|
|
C) A non-standard token
|
|
A is an invariant, B should be dropped, C is something out of the control of this contract.
|
|
It is again the Serai's network role to not add support for any non-standard tokens,
|
|
*/
|
|
|
|
// Perform an ERC20 transfer out
|
|
function _erc20TransferOut(address to, address coin, uint256 value) private {
|
|
coin.call{ gas: 100_000 }(abi.encodeWithSelector(IERC20.transfer.selector, msg.sender, value));
|
|
}
|
|
|
|
// Perform an ETH/ERC20 transfer out
|
|
function _transferOut(address to, address coin, uint256 value) private {
|
|
if (coin == address(0)) {
|
|
// Enough gas to service the transfer and a minimal amount of logic
|
|
to.call{ value: value, gas: 5_000 }("");
|
|
} else {
|
|
_erc20TransferOut(to, coin, value);
|
|
}
|
|
}
|
|
|
|
/*
|
|
Serai supports arbitrary calls out via deploying smart contracts (with user-specified code),
|
|
letting them execute whatever calls they're coded for. Since we can't meter CREATE, we call
|
|
CREATE from this function which we call not internally, but with CALL (which we can meter).
|
|
*/
|
|
function arbitaryCallOut(bytes memory code) external payable {
|
|
// Because we're creating a contract, increment our nonce
|
|
_smartContractNonce += 1;
|
|
|
|
uint256 msg_value = msg.value;
|
|
address contractAddress;
|
|
assembly {
|
|
contractAddress := create(msg_value, add(code, 0x20), mload(code))
|
|
}
|
|
}
|
|
|
|
// Execute a list of transactions if they were signed by the current key with the current nonce
|
|
function execute(
|
|
address coin,
|
|
uint256 fee,
|
|
OutInstruction[] calldata transactions,
|
|
Signature calldata signature
|
|
) external {
|
|
// Verify the signature
|
|
// We hash the message here as we need the message's hash for the Executed event
|
|
// Since we're already going to hash it, hashing it prior to verifying the signature reduces the
|
|
// amount of words hashed by its challenge function (reducing our gas costs)
|
|
bytes32 message =
|
|
keccak256(abi.encode("execute", block.chainid, _nonce, coin, fee, transactions));
|
|
if (!Schnorr.verify(_seraiKey, message, signature.c, signature.s)) {
|
|
revert InvalidSignature();
|
|
}
|
|
|
|
// Since the signature was verified, perform execution
|
|
emit Executed(_nonce, message);
|
|
// While this is sufficient to prevent replays, it's still technically possible for instructions
|
|
// from later batches to be executed before these instructions upon re-entrancy
|
|
_nonce++;
|
|
|
|
for (uint256 i = 0; i < transactions.length; i++) {
|
|
// If the destination is an address, we perform a direct transfer
|
|
if (transactions[i].destinationType == DestinationType.Address) {
|
|
// This may cause a panic and the contract to become stuck if the destination isn't actually
|
|
// 20 bytes. Serai is trusted to not pass a malformed destination
|
|
(AddressDestination memory destination) =
|
|
abi.decode(transactions[i].destination, (AddressDestination));
|
|
_transferOut(destination.destination, coin, transactions[i].value);
|
|
} else {
|
|
// Prepare for the transfer
|
|
uint256 eth_value = 0;
|
|
if (coin == address(0)) {
|
|
// If it's ETH, we transfer the value with the call
|
|
eth_value = transactions[i].value;
|
|
} else {
|
|
// If it's an ERC20, we calculate the hash of the will-be contract and transfer to it
|
|
// before deployment. This avoids needing to deploy, then call again, offering a few
|
|
// optimizations
|
|
address nextAddress =
|
|
address(uint160(uint256(keccak256(abi.encode(address(this), _smartContractNonce)))));
|
|
_erc20TransferOut(nextAddress, coin, transactions[i].value);
|
|
}
|
|
|
|
// Perform the deployment with the defined gas budget
|
|
(CodeDestination memory destination) =
|
|
abi.decode(transactions[i].destination, (CodeDestination));
|
|
address(this).call{ gas: destination.gas_limit, value: eth_value }(
|
|
abi.encodeWithSelector(Router.arbitaryCallOut.selector, destination.code)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Transfer to the caller the fee
|
|
_transferOut(msg.sender, coin, fee);
|
|
}
|
|
|
|
function nonce() external view returns (uint256) {
|
|
return _nonce;
|
|
}
|
|
|
|
function smartContractNonce() external view returns (uint256) {
|
|
return _smartContractNonce;
|
|
}
|
|
|
|
function seraiKey() external view returns (bytes32) {
|
|
return _seraiKey;
|
|
}
|
|
}
|