I looked through all the categories and I hope this is the right place to put this. I am looking at a simple bridge contract for purposes of an exercise to learn solidity and auditing. The relevant contracts are below. The bridge accepts deposits and sends them to the vault contract where they are supposed to be locked (until the tokens come back from L2 to L1). These contracts use several of OZ's library.
What I am struggling with is how anyone, even the bridge owner, could withdraw from the vault. When the bridge contract is deployed, it deploys an instance of the vault contract and then makes a call to the only function in the vault contract, approveTo, which grants the owner the ability to spend type(uint256).max of tokens. And IERC20.sol is imported into the vault contract, but none of the functions are implemented.
So am I incorrect for thinking the tokens would be permanently locked in the vault?
Code to reproduce
This is the entire vault contract and as you will see below, the bridge contract calls approveTo in its constructor:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { IERC20 } from "../lib/openzeppelin-contracts/contracts/interfaces/IERC20.sol";
import { Ownable } from "../lib/openzeppelin-contracts/contracts/access/Ownable.sol";
/// @title L1Vault
/// @author Boss Bridge Peeps
/// @notice This contract is responsible for locking & unlocking tokens on the L1 or L2
/// @notice It will approve the bridge to move money in and out of this contract
/// @notice It's owner should be the bridge
contract L1Vault is Ownable {
IERC20 public token;
constructor(IERC20 _token) Ownable(msg.sender) {
token = _token;
}
function approveTo(address target, uint256 amount) external onlyOwner {
token.approve(target, amount);
}
}
Here is the bridge contract...you will see it calls approveTo in its constructor:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {IERC20} from "../lib/openzeppelin-contracts/contracts/interfaces/IERC20.sol";
import {Ownable} from "../lib/openzeppelin-contracts/contracts/access/Ownable.sol";
import {Pausable} from "../lib/openzeppelin-contracts/contracts/utils/Pausable.sol";
import {ReentrancyGuard} from "../lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol";
import {MessageHashUtils} from "../lib/openzeppelin-contracts/contracts/utils/cryptography/MessageHashUtils.sol";
import {ECDSA} from "../lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol";
import {SafeERC20} from "../lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import {L1Vault} from "./L1Vault.sol";
contract L1BossBridge is Ownable, Pausable, ReentrancyGuard {
using SafeERC20 for IERC20;
uint256 public DEPOSIT_LIMIT = 100_000 ether;
IERC20 public immutable token;
L1Vault public immutable vault;
mapping(address account => bool isSigner) public signers;
error L1BossBridge__DepositLimitReached();
error L1BossBridge__Unauthorized();
error L1BossBridge__CallFailed();
event Deposit(address from, address to, uint256 amount);
constructor(IERC20 _token) Ownable(msg.sender) {
token = _token;
vault = new L1Vault(token);
// Allows the bridge to move tokens out of the vault to facilitate withdrawals
vault.approveTo(address(this), type(uint256).max);
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
function setSigner(address account, bool enabled) external onlyOwner {
signers[account] = enabled;
}
/*
* @notice Locks tokens in the vault and emits a Deposit event
* the unlock event will trigger the L2 minting process. There are nodes listening
* for this event and will mint the corresponding tokens on L2. This is a centralized process.
*
* @param from The address of the user who is depositing tokens
* @param l2Recipient The address of the user who will receive the tokens on L2
* @param amount The amount of tokens to deposit
*/
function depositTokensToL2(
address from,
address l2Recipient,
uint256 amount
) external whenNotPaused {
if (token.balanceOf(address(vault)) + amount > DEPOSIT_LIMIT) {
revert L1BossBridge__DepositLimitReached();
}
token.safeTransferFrom(from, address(vault), amount);
// Our off-chain service picks up this event and mints the corresponding tokens on L2
emit Deposit(from, l2Recipient, amount);
}
/*
* @notice This is the function responsible for withdrawing tokens from L2 to L1.
* Our L2 will have a similar mechanism for withdrawing tokens from L1 to L2.
* @notice The signature is required to prevent replay attacks.
*
* @param to The address of the user who will receive the tokens on L1
* @param amount The amount of tokens to withdraw
* @param v The v value of the signature
* @param r The r value of the signature
* @param s The s value of the signature
*/
function withdrawTokensToL1(
address to,
uint256 amount,
uint8 v,
bytes32 r,
bytes32 s
) external {
sendToL1(
v,
r,
s,
abi.encode(
address(token),
0, // value
abi.encodeCall(
IERC20.transferFrom,
(address(vault), to, amount)
)
)
);
}
/*
* @notice This is the function responsible for withdrawing ETH from L2 to L1.
*
* @param v The v value of the signature
* @param r The r value of the signature
* @param s The s value of the signature
* @param message The message/data to be sent to L1 (can be blank)
*/
function sendToL1(
uint8 v,
bytes32 r,
bytes32 s,
bytes memory message
) public nonReentrant whenNotPaused {
address signer = ECDSA.recover(
MessageHashUtils.toEthSignedMessageHash(keccak256(message)),
v,
r,
s
);
if (!signers[signer]) {
revert L1BossBridge__Unauthorized();
}
(address target, uint256 value, bytes memory data) = abi.decode(
message,
(address, uint256, bytes)
);
(bool success, ) = target.call{value: value}(data);
if (!success) {
revert L1BossBridge__CallFailed();
}
}
}
Environment
I'm using Foundry. I have also tried writing tests to confirm that no one can withdraw from the vault, not even the owner, but I keep getting insufficient allowance errors.