@0x/contracts-asset-proxy: Switch Eth2DaiBridge to support arbitrary tokens.

`@0x/contracts-asset-proxy`: Support non-conformant tokens in Eth2DaiBridge
This commit is contained in:
Lawrence Forman 2019-09-30 16:08:42 -07:00
parent 48f7a24505
commit bb87c8e7b5
7 changed files with 277 additions and 177 deletions

View File

@ -20,9 +20,9 @@ pragma solidity ^0.5.9;
pragma experimental ABIEncoderV2;
import "@0x/contracts-erc20/contracts/src/interfaces/IERC20Token.sol";
import "@0x/contracts-exchange/contracts/src/interfaces/IWallet.sol";
import "./ERC20Bridge.sol";
import "../interfaces/IEth2Dai.sol";
import "../interfaces/IWallet.sol";
// solhint-disable space-after-comma
@ -30,17 +30,11 @@ contract Eth2DaiBridge is
ERC20Bridge,
IWallet
{
bytes4 private constant LEGACY_WALLET_MAGIC_VALUE = 0xb0671381;
/* Mainnet addresses */
address constant public ETH2DAI_ADDRESS = 0x39755357759cE0d7f32dC8dC45414CCa409AE24e;
address constant public WETH_ADDRESS = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address constant public DAI_ADDRESS = 0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359;
constructor() public {
// Grant the Eth2Dai contract unlimited weth and dai allowances.
_getWethContract().approve(address(_getEth2DaiContract()), uint256(-1));
_getDaiContract().approve(address(_getEth2DaiContract()), uint256(-1));
}
/// @dev Whether we've granted an allowance to a spender for a token.
mapping (address => mapping (address => bool)) private _hasAllowance;
/// @dev Callback for `IERC20Bridge`. Tries to buy `amount` of
/// `toTokenAddress` tokens by selling the entirety of the opposing asset
@ -49,38 +43,34 @@ contract Eth2DaiBridge is
/// @param toTokenAddress The token to give to `to` (either DAI or WETH).
/// @param to The recipient of the bought tokens.
/// @param amount Minimum amount of `toTokenAddress` tokens to buy.
/// @param bridgeData The abi-encoeded "from" token address.
/// @return success The magic bytes if successful.
function withdrawTo(
address toTokenAddress,
address /* from */,
address to,
uint256 amount,
bytes calldata /* bridgeData */
bytes calldata bridgeData
)
external
returns (bytes4 success)
{
// The "from" token is the opposite of the "to" token.
IERC20Token fromToken = _getWethContract();
IERC20Token toToken = _getDaiContract();
// Swap them if necessary.
if (toTokenAddress == address(fromToken)) {
(fromToken, toToken) = (toToken, fromToken);
} else {
require(
toTokenAddress == address(toToken),
"INVALID_ETH2DAI_TOKEN"
);
}
// Try to sell all of this contract's `fromToken` balance.
// Decode the bridge data to get the `fromTokenAddress`.
(address fromTokenAddress) = abi.decode(bridgeData, (address));
IEth2Dai exchange = _getEth2DaiContract();
// Grant an allowance to the exchange to spend `fromTokenAddress` token.
_grantAllowanceForToken(address(exchange), fromTokenAddress);
// Try to sell all of this contract's `fromTokenAddress` token balance.
uint256 boughtAmount = _getEth2DaiContract().sellAllAmount(
address(fromToken),
fromToken.balanceOf(address(this)),
address(toToken),
address(fromTokenAddress),
IERC20Token(fromTokenAddress).balanceOf(address(this)),
toTokenAddress,
amount
);
// Transfer the converted `toToken`s to `to`.
toToken.transfer(to, boughtAmount);
_transferERC20Token(toTokenAddress, to, boughtAmount);
return BRIDGE_SUCCESS;
}
@ -98,26 +88,6 @@ contract Eth2DaiBridge is
return LEGACY_WALLET_MAGIC_VALUE;
}
/// @dev Overridable way to get the weth contract.
/// @return weth The WETH contract.
function _getWethContract()
internal
view
returns (IERC20Token weth)
{
return IERC20Token(WETH_ADDRESS);
}
/// @dev Overridable way to get the dai contract.
/// @return token The token contract.
function _getDaiContract()
internal
view
returns (IERC20Token token)
{
return IERC20Token(DAI_ADDRESS);
}
/// @dev Overridable way to get the eth2dai contract.
/// @return exchange The Eth2Dai exchange contract.
function _getEth2DaiContract()
@ -127,4 +97,66 @@ contract Eth2DaiBridge is
{
return IEth2Dai(ETH2DAI_ADDRESS);
}
/// @dev Grants an unlimited allowance to `spender` for `tokenAddress` token,
/// if we haven't done so already.
/// @param spender The spender address.
/// @param tokenAddress The token address.
function _grantAllowanceForToken(
address spender,
address tokenAddress
)
private
{
mapping (address => bool) storage spenderHasAllowance = _hasAllowance[spender];
if (!spenderHasAllowance[tokenAddress]) {
spenderHasAllowance[tokenAddress] = true;
IERC20Token(tokenAddress).approve(spender, uint256(-1));
}
}
/// @dev Permissively transfers an ERC20 token that may not adhere to
/// specs.
/// @param tokenAddress The token contract address.
/// @param to The token recipient.
/// @param amount The amount of tokens to transfer.
function _transferERC20Token(
address tokenAddress,
address to,
uint256 amount
)
private
{
// Transfer tokens.
// We do a raw call so we can check the success separate
// from the return data.
(bool didSucceed, bytes memory returnData) = tokenAddress.call(
abi.encodeWithSelector(
IERC20Token(0).transfer.selector,
to,
amount
)
);
if (!didSucceed) {
assembly { revert(add(returnData, 0x20), mload(returnData)) }
}
// Check return data.
// If there is no return data, we assume the token incorrectly
// does not return a bool. In this case we expect it to revert
// on failure, which was handled above.
// If the token does return data, we require that it is a single
// value that evaluates to true.
assembly {
if returndatasize {
didSucceed := 0
if eq(returndatasize, 32) {
// First 64 bytes of memory are reserved scratch space
returndatacopy(0, 0, 32)
didSucceed := mload(0)
}
}
}
require(didSucceed, "ERC20_TRANSFER_FAILED");
}
}

View File

@ -0,0 +1,38 @@
/*
Copyright 2019 ZeroEx Intl.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
pragma solidity ^0.5.9;
pragma experimental ABIEncoderV2;
contract IWallet {
bytes4 internal constant LEGACY_WALLET_MAGIC_VALUE = 0xb0671381;
/// @dev Validates a hash with the `Wallet` signature type.
/// @param hash Message hash that is signed.
/// @param signature Proof of signing.
/// @return magicValue `bytes4(0xb0671381)` if the signature check succeeds.
function isValidSignature(
bytes32 hash,
bytes calldata signature
)
external
view
returns (bytes4 magicValue);
}

View File

@ -25,15 +25,41 @@ import "../src/interfaces/IEth2Dai.sol";
// solhint-disable no-simple-event-func-name
/// @dev Interface that allows `TestToken` to call `raiseTransferEvent` on
/// the `TestEth2DaiBridge` contract.
interface IRaiseTransferEvent {
function raiseTransferEvent(
contract TestEvents {
event TokenTransfer(
address token,
address from,
address to,
uint256 amount
);
event TokenApprove(
address token,
address spender,
uint256 allowance
);
function raiseTokenTransfer(
address from,
address to,
uint256 amount
)
external;
external
{
emit TokenTransfer(
msg.sender,
from,
to,
amount
);
}
function raiseTokenApprove(address spender, uint256 allowance)
external
{
emit TokenApprove(msg.sender, spender, allowance);
}
}
@ -41,15 +67,20 @@ interface IRaiseTransferEvent {
contract TestToken {
mapping (address => uint256) public balances;
mapping (address => mapping (address => uint256)) public allowances;
string private _nextTransferRevertReason;
bytes private _nextTransferReturnData;
/// @dev Just calls `raiseTransferEvent()` on the caller.
/// @dev Just calls `raiseTokenTransfer()` on the caller.
function transfer(address to, uint256 amount)
external
returns (bool)
{
IRaiseTransferEvent(msg.sender).raiseTransferEvent(msg.sender, to, amount);
return true;
TestEvents(msg.sender).raiseTokenTransfer(msg.sender, to, amount);
if (bytes(_nextTransferRevertReason).length != 0) {
revert(_nextTransferRevertReason);
}
bytes memory returnData = _nextTransferReturnData;
assembly { return(add(returnData, 0x20), mload(returnData)) }
}
/// @dev Set the balance for `owner`.
@ -59,12 +90,23 @@ contract TestToken {
balances[owner] = balance;
}
/// @dev Records allowance values.
/// @dev Set the behavior of the `transfer()` call.
function setTransferBehavior(
string calldata revertReason,
bytes calldata returnData
)
external
{
_nextTransferRevertReason = revertReason;
_nextTransferReturnData = returnData;
}
/// @dev Just calls `raiseTokenApprove()` on the caller.
function approve(address spender, uint256 allowance)
external
returns (bool)
{
allowances[msg.sender][spender] = allowance;
TestEvents(msg.sender).raiseTokenApprove(spender, allowance);
return true;
}
@ -82,6 +124,7 @@ contract TestToken {
/// @dev Eth2DaiBridge overridden to mock tokens and
/// implement IEth2Dai.
contract TestEth2DaiBridge is
TestEvents,
IEth2Dai,
Eth2DaiBridge
{
@ -92,24 +135,19 @@ contract TestEth2DaiBridge is
uint256 minimumFillAmount
);
event TokenTransfer(
address token,
address from,
address to,
uint256 amount
);
TestToken public wethToken = new TestToken();
TestToken public daiToken = new TestToken();
mapping (address => TestToken) public testTokens;
string private _nextRevertReason;
uint256 private _nextFillAmount;
/// @dev Set token balances for this contract.
function setTokenBalances(uint256 wethBalance, uint256 daiBalance)
/// @dev Create a token and set this contract's balance.
function createToken(uint256 balance)
external
returns (address tokenAddress)
{
wethToken.setBalance(address(this), wethBalance);
daiToken.setBalance(address(this), daiBalance);
TestToken token = new TestToken();
testTokens[address(token)] = token;
token.setBalance(address(this), balance);
return address(token);
}
/// @dev Set the behavior for `IEth2Dai.sellAllAmount()`.
@ -120,6 +158,17 @@ contract TestEth2DaiBridge is
_nextFillAmount = fillAmount;
}
/// @dev Set the behavior of a token's `transfer()`.
function setTransferBehavior(
address tokenAddress,
string calldata revertReason,
bytes calldata returnData
)
external
{
testTokens[tokenAddress].setTransferBehavior(revertReason, returnData);
}
/// @dev Implementation of `IEth2Dai.sellAllAmount()`
function sellAllAmount(
address sellTokenAddress,
@ -142,50 +191,6 @@ contract TestEth2DaiBridge is
return _nextFillAmount;
}
function raiseTransferEvent(
address from,
address to,
uint256 amount
)
external
{
emit TokenTransfer(
msg.sender,
from,
to,
amount
);
}
/// @dev Retrieves the allowances of the test tokens.
function getEth2DaiTokenAllowances()
external
view
returns (uint256 wethAllowance, uint256 daiAllowance)
{
wethAllowance = wethToken.allowances(address(this), address(this));
daiAllowance = daiToken.allowances(address(this), address(this));
return (wethAllowance, daiAllowance);
}
// @dev Use `wethToken`.
function _getWethContract()
internal
view
returns (IERC20Token)
{
return IERC20Token(address(wethToken));
}
// @dev Use `daiToken`.
function _getDaiContract()
internal
view
returns (IERC20Token)
{
return IERC20Token(address(daiToken));
}
// @dev This contract will double as the Eth2Dai contract.
function _getEth2DaiContract()
internal

View File

@ -16,6 +16,7 @@ import * as IAssetProxyDispatcher from '../generated-artifacts/IAssetProxyDispat
import * as IAuthorizable from '../generated-artifacts/IAuthorizable.json';
import * as IERC20Bridge from '../generated-artifacts/IERC20Bridge.json';
import * as IEth2Dai from '../generated-artifacts/IEth2Dai.json';
import * as IWallet from '../generated-artifacts/IWallet.json';
import * as MixinAssetProxyDispatcher from '../generated-artifacts/MixinAssetProxyDispatcher.json';
import * as MixinAuthorizable from '../generated-artifacts/MixinAuthorizable.json';
import * as MultiAssetProxy from '../generated-artifacts/MultiAssetProxy.json';
@ -40,6 +41,7 @@ export const artifacts = {
IAuthorizable: IAuthorizable as ContractArtifact,
IERC20Bridge: IERC20Bridge as ContractArtifact,
IEth2Dai: IEth2Dai as ContractArtifact,
IWallet: IWallet as ContractArtifact,
TestERC20Bridge: TestERC20Bridge as ContractArtifact,
TestEth2DaiBridge: TestEth2DaiBridge as ContractArtifact,
TestStaticCallTarget: TestStaticCallTarget as ContractArtifact,

View File

@ -14,6 +14,7 @@ export * from '../generated-wrappers/i_asset_proxy_dispatcher';
export * from '../generated-wrappers/i_authorizable';
export * from '../generated-wrappers/i_erc20_bridge';
export * from '../generated-wrappers/i_eth2_dai';
export * from '../generated-wrappers/i_wallet';
export * from '../generated-wrappers/mixin_asset_proxy_dispatcher';
export * from '../generated-wrappers/mixin_authorizable';
export * from '../generated-wrappers/multi_asset_proxy';

View File

@ -4,6 +4,7 @@ import {
expect,
filterLogsToArguments,
getRandomInteger,
hexLeftPad,
hexRandom,
Numberish,
randomAddress,
@ -18,14 +19,13 @@ import {
TestEth2DaiBridgeContract,
TestEth2DaiBridgeEvents,
TestEth2DaiBridgeSellAllAmountEventArgs,
TestEth2DaiBridgeTokenApproveEventArgs,
TestEth2DaiBridgeTokenTransferEventArgs,
} from '../src';
blockchainTests.resets('Eth2DaiBridge unit tests', env => {
blockchainTests.resets.only('Eth2DaiBridge unit tests', env => {
const txHelper = new TransactionHelper(env.web3Wrapper, artifacts);
let testContract: TestEth2DaiBridgeContract;
let daiTokenAddress: string;
let wethTokenAddress: string;
before(async () => {
testContract = await TestEth2DaiBridgeContract.deployFrom0xArtifactAsync(
@ -34,18 +34,6 @@ blockchainTests.resets('Eth2DaiBridge unit tests', env => {
env.txDefaults,
artifacts,
);
[daiTokenAddress, wethTokenAddress] = await Promise.all([
testContract.daiToken.callAsync(),
testContract.wethToken.callAsync(),
]);
});
describe('deployment', () => {
it('sets Eth2Dai allowances to maximum', async () => {
const [wethAllowance, daiAllowance] = await testContract.getEth2DaiTokenAllowances.callAsync();
expect(wethAllowance).to.bignumber.eq(constants.MAX_UINT256);
expect(daiAllowance).to.bignumber.eq(constants.MAX_UINT256);
});
});
describe('isValidSignature()', () => {
@ -57,109 +45,126 @@ blockchainTests.resets('Eth2DaiBridge unit tests', env => {
});
describe('withdrawTo()', () => {
interface TransferOpts {
toTokenAddress: string;
interface WithdrawToOpts {
toTokenAddress?: string;
fromTokenAddress?: string;
toAddress: string;
amount: Numberish;
fromTokenBalance: Numberish;
revertReason: string;
fillAmount: Numberish;
toTokentransferRevertReason: string;
toTokenTransferReturnData: string;
}
function createTransferOpts(opts?: Partial<TransferOpts>): TransferOpts {
interface WithdrawToResult {
opts: WithdrawToOpts;
result: string;
logs: DecodedLogs;
}
function createWithdrawToOpts(opts?: Partial<WithdrawToOpts>): WithdrawToOpts {
return {
toTokenAddress: _.sampleSize([wethTokenAddress, daiTokenAddress], 1)[0],
toAddress: randomAddress(),
amount: getRandomInteger(1, 100e18),
revertReason: '',
fillAmount: getRandomInteger(1, 100e18),
fromTokenBalance: getRandomInteger(1, 100e18),
toTokentransferRevertReason: '',
toTokenTransferReturnData: hexLeftPad(1),
...opts,
};
}
async function transferAsync(opts?: Partial<TransferOpts>): Promise<[string, DecodedLogs]> {
const _opts = createTransferOpts(opts);
async function withdrawToAsync(opts?: Partial<WithdrawToOpts>): Promise<WithdrawToResult> {
const _opts = createWithdrawToOpts(opts);
// Set the fill behavior.
await testContract.setFillBehavior.awaitTransactionSuccessAsync(
_opts.revertReason,
new BigNumber(_opts.fillAmount),
);
// Set the token balance for the token we're converting from.
await testContract.setTokenBalances.awaitTransactionSuccessAsync(
_opts.toTokenAddress === daiTokenAddress
? new BigNumber(_opts.fromTokenBalance)
: constants.ZERO_AMOUNT,
_opts.toTokenAddress === wethTokenAddress
? new BigNumber(_opts.fromTokenBalance)
: constants.ZERO_AMOUNT,
// Create tokens and balances.
if (_opts.fromTokenAddress === undefined) {
[_opts.fromTokenAddress] = await txHelper.getResultAndReceiptAsync(
testContract.createToken,
new BigNumber(_opts.fromTokenBalance),
);
}
if (_opts.toTokenAddress === undefined) {
[_opts.toTokenAddress] = await txHelper.getResultAndReceiptAsync(
testContract.createToken,
constants.ZERO_AMOUNT,
);
}
// Set the transfer behavior of `toTokenAddress`.
await testContract.setTransferBehavior.awaitTransactionSuccessAsync(
_opts.toTokenAddress,
_opts.toTokentransferRevertReason,
_opts.toTokenTransferReturnData,
);
// Call withdrawTo().
const [result, { logs }] = await txHelper.getResultAndReceiptAsync(
testContract.withdrawTo,
// "to" token address
_opts.toTokenAddress,
// Random from address.
randomAddress(),
// To address.
_opts.toAddress,
new BigNumber(_opts.amount),
'0x',
// ABI-encode the "from" token address as the bridge data.
hexLeftPad(_opts.fromTokenAddress as string),
);
return [result, (logs as any) as DecodedLogs];
}
function getOppositeToken(tokenAddress: string): string {
if (tokenAddress === daiTokenAddress) {
return wethTokenAddress;
}
return daiTokenAddress;
return {
opts: _opts,
result,
logs: (logs as any) as DecodedLogs,
};
}
it('returns magic bytes on success', async () => {
const BRIDGE_SUCCESS_RETURN_DATA = '0xdc1600f3';
const [result] = await transferAsync();
const { result } = await withdrawToAsync();
expect(result).to.eq(BRIDGE_SUCCESS_RETURN_DATA);
});
it('calls `Eth2Dai.sellAllAmount()`', async () => {
const opts = createTransferOpts();
const [, logs] = await transferAsync(opts);
const { opts, logs } = await withdrawToAsync();
const transfers = filterLogsToArguments<TestEth2DaiBridgeSellAllAmountEventArgs>(
logs,
TestEth2DaiBridgeEvents.SellAllAmount,
);
expect(transfers.length).to.eq(1);
expect(transfers[0].sellToken).to.eq(getOppositeToken(opts.toTokenAddress));
expect(transfers[0].sellToken).to.eq(opts.fromTokenAddress);
expect(transfers[0].buyToken).to.eq(opts.toTokenAddress);
expect(transfers[0].sellTokenAmount).to.bignumber.eq(opts.fromTokenBalance);
expect(transfers[0].minimumFillAmount).to.bignumber.eq(opts.amount);
});
it('can swap DAI for WETH', async () => {
const opts = createTransferOpts({ toTokenAddress: wethTokenAddress });
const [, logs] = await transferAsync(opts);
const transfers = filterLogsToArguments<TestEth2DaiBridgeSellAllAmountEventArgs>(
it('sets an unlimited allowance on the `fromTokenAddress` token', async () => {
const { opts, logs } = await withdrawToAsync();
const approvals = filterLogsToArguments<TestEth2DaiBridgeTokenApproveEventArgs>(
logs,
TestEth2DaiBridgeEvents.SellAllAmount,
TestEth2DaiBridgeEvents.TokenApprove,
);
expect(transfers.length).to.eq(1);
expect(transfers[0].sellToken).to.eq(daiTokenAddress);
expect(transfers[0].buyToken).to.eq(wethTokenAddress);
expect(approvals.length).to.eq(1);
expect(approvals[0].token).to.eq(opts.fromTokenAddress);
expect(approvals[0].spender).to.eq(testContract.address);
expect(approvals[0].allowance).to.bignumber.eq(constants.MAX_UINT256);
});
it('can swap WETH for DAI', async () => {
const opts = createTransferOpts({ toTokenAddress: daiTokenAddress });
const [, logs] = await transferAsync(opts);
const transfers = filterLogsToArguments<TestEth2DaiBridgeSellAllAmountEventArgs>(
it('does not set an unlimited allowance on the `fromTokenAddress` token if already set', async () => {
const { opts } = await withdrawToAsync();
const { logs } = await withdrawToAsync({ fromTokenAddress: opts.fromTokenAddress });
const approvals = filterLogsToArguments<TestEth2DaiBridgeTokenApproveEventArgs>(
logs,
TestEth2DaiBridgeEvents.SellAllAmount,
TestEth2DaiBridgeEvents.TokenApprove,
);
expect(transfers.length).to.eq(1);
expect(transfers[0].sellToken).to.eq(wethTokenAddress);
expect(transfers[0].buyToken).to.eq(daiTokenAddress);
expect(approvals.length).to.eq(0);
});
it('transfers filled amount to `to`', async () => {
const opts = createTransferOpts();
const [, logs] = await transferAsync(opts);
const { opts, logs } = await withdrawToAsync();
const transfers = filterLogsToArguments<TestEth2DaiBridgeTokenTransferEventArgs>(
logs,
TestEth2DaiBridgeEvents.TokenTransfer,
@ -172,9 +177,25 @@ blockchainTests.resets('Eth2DaiBridge unit tests', env => {
});
it('fails if `Eth2Dai.sellAllAmount()` reverts', async () => {
const opts = createTransferOpts({ revertReason: 'FOOBAR' });
const tx = transferAsync(opts);
const opts = createWithdrawToOpts({ revertReason: 'FOOBAR' });
const tx = withdrawToAsync(opts);
return expect(tx).to.revertWith(opts.revertReason);
});
it('fails if `toTokenAddress.transfer()` reverts', async () => {
const opts = createWithdrawToOpts({ toTokentransferRevertReason: 'FOOBAR' });
const tx = withdrawToAsync(opts);
return expect(tx).to.revertWith(opts.toTokentransferRevertReason);
});
it('fails if `toTokenAddress.transfer()` returns falsey', async () => {
const opts = createWithdrawToOpts({ toTokenTransferReturnData: hexLeftPad(0) });
const tx = withdrawToAsync(opts);
return expect(tx).to.revertWith('ERC20_TRANSFER_FAILED');
});
it('succeeds if `toTokenAddress.transfer()` returns truthy', async () => {
await withdrawToAsync({ toTokenTransferReturnData: hexLeftPad(100) });
});
});
});

View File

@ -14,6 +14,7 @@
"generated-artifacts/IAuthorizable.json",
"generated-artifacts/IERC20Bridge.json",
"generated-artifacts/IEth2Dai.json",
"generated-artifacts/IWallet.json",
"generated-artifacts/MixinAssetProxyDispatcher.json",
"generated-artifacts/MixinAuthorizable.json",
"generated-artifacts/MultiAssetProxy.json",