Gas-less way to purchase ETH for USDC.

Part 3 — testing

Alexander Koval
Coinmonks
Published in
10 min readOct 13, 2023

--

Previous parts

Previous parts

Source code:

In a previous part Gas Broker contract been implemented, let’s verify that it works as expected.

Unit test

Infrastructure

First let’s create a unit test — we want to test Gas Broker in isolated environment, that’s why we need to prepare 2 mock contracts — one for Price Oracle and another for ERC-20 token

Here is code for Test token contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "solmate/tokens/ERC20.sol";

/**
* @title TestToken
*/
contract TestToken is ERC20 {
constructor() ERC20("TestToken", "TST", 6) {
_mint(msg.sender, 100_000e6);
}
}

Test token has 6 decimals as USDC token and upon deployment 100K of TST is minted and given to deployer

Here is code for mock of Price Oracle:

contract MockPriceOracle is IPriceOracle {
function getPriceInEth(address token, uint amount) external pure returns (uint256) {
return 1 ether;
}
}

Instead of checking real price it just returns 1 ether regardless of input — this is done for simplicity, the workflow with real prices will be a part of integration test.

We also need a helper contract to prepare signatures for permit and reward — this make sense only within test as signatures always calculated off-chain.

Let’s take a contract SigUtils from Foundry documentation as a prototype.

https://book.getfoundry.sh/tutorials/testing-eip712?highlight=EIP#testing-eip-712-signatures

There will be 2 contracts: PermitSigUtils for preparing permit signature and RewardSigUtils for preparing reward signature. PermitSigUtils is identical to Foundry docs — see the link above.

Here is an implementation for RewardSigUtils :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "../src/GasBroker.sol";

contract RewardSigUtils {
bytes32 internal DOMAIN_SEPARATOR;

constructor(bytes32 _DOMAIN_SEPARATOR) {
DOMAIN_SEPARATOR = _DOMAIN_SEPARATOR;
}

// computes the hash of a reward
function getStructHash(Reward memory _reward)
internal
pure
returns (bytes32)
{
return
keccak256(
abi.encode(
keccak256("Reward(uint256 value,bytes32 permitHash)"),
_reward.value,
_reward.permitHash
)
);
}

// computes the hash of the fully encoded EIP-712 message for the domain, which can be used to recover the signer
function getTypedDataHash(Reward memory _reward)
public
view
returns (bytes32)
{
return
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
getStructHash(_reward)
)
);
}
}

Who wants to understand in-deep the details of implementation can check out this article:

Testing successful scenario

Since there is only single external function in contract, the test will consist only form a single call and checks of balances afterwards. Here is the plan of test:

  • Deploy mocked contacts for Price Oracle and Test token
  • Deploy Gas Broker contract
  • Fund Customer with test tokens
  • Prepare signature for permit of spending message
  • Compute a hash of first signature
  • Prepare signature for reward message
  • Call swap function on Gas Broker
  • Check balances

Setup function:

function setUp() public {
deadline = block.timestamp + 1 days;
// deploy PriceOracle
priceOracle = new MockPriceOracle();
// deploy GasBroker
gasBroker = new GasBroker(block.chainid, address(priceOracle));
signer = vm.addr(SIGNER_PRIVATE_KEY);
// deploy test token
token = new TestToken();

// fund wallet with tokens
token.transfer(signer, SIGNER_TOKEN_BALANCE);
// burn the rest of tokens
token.transfer(address(0), token.balanceOf(address(this)));

// deploy sigUtils
permitSigUtils = new PermitSigUtils(token.DOMAIN_SEPARATOR());
rewardSigUtils = new RewardSigUtils(gasBroker.DOMAIN_SEPARATOR());

// prepare signature for permit
(permitV, permitR, permitS) = getPermitSignature(signer, VALUE);

permitHash = keccak256(abi.encode(permitV,permitR,permitS));
// prepare signature for reward
(rewardV, rewardR, rewardS) = getRewardSignature(REWARD, permitHash);
}

Once Test token contract deployed, the Test contract will have all awailible TST tokens. After transferring TST tokens to signer the rest of tokens held by Test contract are burned — this is done in order to make a balance zero and to simplify check at the end of test.

Here is the code for signature functions:

function getPermitSignature(address _signer, uint256 _value) internal view returns (uint8 v, bytes32 r, bytes32 s) {
PermitSigUtils.Permit memory permit = PermitSigUtils.Permit({
owner: _signer,
spender: address(gasBroker),
value: _value,
nonce: 0,
deadline: deadline
});
bytes32 digest = permitSigUtils.getTypedDataHash(permit);
(v, r, s) = vm.sign(SIGNER_PRIVATE_KEY, digest);
}

function getRewardSignature(uint256 reward, bytes32 permitHash) internal view returns (uint8 v, bytes32 r, bytes32 s) {
Reward memory reward = Reward({
value: reward,
permitHash: permitHash
});
bytes32 digest = rewardSigUtils.getTypedDataHash(reward);

(v, r, s) = vm.sign(SIGNER_PRIVATE_KEY, digest);
}

Now we can write a test:

function test_shouldSwapTokensToETH() public {
gasBroker.swap{ value: 1 ether }(
signer,
address(token),
VALUE,
deadline,
REWARD,
permitV,
permitR,
permitS,
rewardV,
rewardR,
rewardS
);

assertEq(token.balanceOf(address(this)), VALUE);
assertEq(token.balanceOf(signer), SIGNER_TOKEN_BALANCE - VALUE);
assertEq(signer.balance, 1 ether);
}

Value of 1 ETH is passed with call cause that’s what mocked Price Oracle returns. Since swap is called from Test contract — the Test contract is Gas provider. Once swap completed, we expect VALUE of tokens to be transferred to Test contract and 1 ETH to be sent to signer

Testing reject scenarios

It is important to cover any edge-case during testing, that’s why following scenarios when swap should revert needs to be tested:

  • reward exceeds value
  • permit signature is invalid
  • reward message is signed not by signer
  • value in reward message doesn’t match with value in parameter
  • permitHash in reward message doesn’t match with hashe of permit signature in parameters

As you might notice — we have 5 test cases when reward message signature is invalid and only one test case when permit signature is invalid. That’s rational as permit signature is verified by token contract and we don’t have to test token contract.

When testing revert scenarios always check the reason of revert not just the fact that transaction been reverted — cause if transaction reverted not due to the same reason you anticipated then it is a bug.

The complete code of test contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

import './PermitSigUtils.sol';
import './RewardSigUtils.sol';
import './TestToken.sol';
import "../src/GasBroker.sol";

contract MockPriceOracle is IPriceOracle {
function getPriceInEth(address token, uint amount) external pure returns (uint256) {
return 1 ether;
}
}

contract GasBrokerTest is Test {
uint256 constant SIGNER_PRIVATE_KEY = 0xA11CE;
uint256 constant SIGNER_TOKEN_BALANCE = 150e6;
uint256 constant VALUE = 110e6;
uint256 constant REWARD = 10e6;

PermitSigUtils permitSigUtils;
RewardSigUtils rewardSigUtils;
GasBroker gasBroker;
TestToken token;
IPriceOracle priceOracle;
address signer;
uint256 deadline;

uint8 permitV;
bytes32 permitR;
bytes32 permitS;
uint8 rewardV;
bytes32 rewardR;
bytes32 rewardS;

bytes32 permitHash;

function setUp() public {
deadline = block.timestamp + 1 days;
// deploy PriceOracle
priceOracle = new MockPriceOracle();
// deploy GasBroker
gasBroker = new GasBroker(block.chainid, address(priceOracle));
signer = vm.addr(SIGNER_PRIVATE_KEY);
// deploy test token
token = new TestToken();

// fund wallet with tokens
token.transfer(signer, SIGNER_TOKEN_BALANCE);
// burn the rest of tokens
token.transfer(address(0), token.balanceOf(address(this)));

// deploy sigUtils
permitSigUtils = new PermitSigUtils(token.DOMAIN_SEPARATOR());
rewardSigUtils = new RewardSigUtils(gasBroker.DOMAIN_SEPARATOR());



// prepare signature for permit
(permitV, permitR, permitS) = getPermitSignature(signer, VALUE);

permitHash = keccak256(abi.encode(permitV,permitR,permitS));
// prepare signature for reward
(rewardV, rewardR, rewardS) = getRewardSignature(REWARD, permitHash);
}

function getPermitSignature(address _signer, uint256 _value) internal view returns (uint8 v, bytes32 r, bytes32 s) {
PermitSigUtils.Permit memory permit = PermitSigUtils.Permit({
owner: _signer,
spender: address(gasBroker),
value: _value,
nonce: 0,
deadline: deadline
});
bytes32 digest = permitSigUtils.getTypedDataHash(permit);
(v, r, s) = vm.sign(SIGNER_PRIVATE_KEY, digest);
}

function getRewardSignature(uint256 reward, bytes32 permitHash) internal view returns (uint8 v, bytes32 r, bytes32 s) {
Reward memory reward = Reward({
value: reward,
permitHash: permitHash
});
bytes32 digest = rewardSigUtils.getTypedDataHash(reward);

(v, r, s) = vm.sign(SIGNER_PRIVATE_KEY, digest);
}

function test_shouldRevertWhenRewardExceedsValue() public {
(uint8 rewardV, bytes32 rewardR, bytes32 rewardS) = getRewardSignature(VALUE + 1, permitHash);
vm.expectRevert("Reward could not exceed value");
gasBroker.swap{ value: 1 ether }(
signer,
address(token),
VALUE,
deadline,
VALUE + 1,
permitV,
permitR,
permitS,
rewardV,
rewardR,
rewardS
);
}

function test_shouldRevertWhenPermitHashInRewardMessageIsInvalid() public {
// prepare signature for permit
(uint8 v, bytes32 r, bytes32 s) = getPermitSignature(signer, VALUE + 1);


bytes32 permitHash = keccak256(abi.encode(v,r,s));
// prepare signature for reward
(uint8 rewardV, bytes32 rewardR, bytes32 rewardS) = getRewardSignature(REWARD, permitHash);
vm.expectRevert("Reward signature is invalid");
gasBroker.swap{ value: 1 ether }(
signer,
address(token),
VALUE,
deadline,
REWARD,
permitV,
permitR,
permitS,
rewardV,
rewardR,
rewardS
);
}

function test_shouldRevertWhenSignerInRewardMessageIsInvalid() public {
// prepare signature for reward
Reward memory reward = Reward({
value: REWARD,
permitHash: permitHash
});
bytes32 digest = rewardSigUtils.getTypedDataHash(reward);

(uint8 rewardV, bytes32 rewardR, bytes32 rewardS) = vm.sign(0xB22DF, digest);
vm.expectRevert("Reward signature is invalid");
gasBroker.swap{ value: 1 ether }(
signer,
address(token),
VALUE,
deadline,
REWARD,
permitV,
permitR,
permitS,
rewardV,
rewardR,
rewardS
);
}


function test_shouldRevertWhenValueInRewardMessageIsInvalid() public {
(uint8 rewardV, bytes32 rewardR, bytes32 rewardS) = getRewardSignature(REWARD + 1, permitHash);
vm.expectRevert("Reward signature is invalid");
gasBroker.swap{ value: 1 ether }(
signer,
address(token),
VALUE,
deadline,
REWARD,
permitV,
permitR,
permitS,
rewardV,
rewardR,
rewardS
);
}

function test_shouldRevertWhenNotEnouthETHisProvided() public {
vm.expectRevert("Not enough ETH provided");
gasBroker.swap{ value: 1 ether - 1 }(
signer,
address(token),
VALUE,
deadline,
REWARD,
permitV,
permitR,
permitS,
rewardV,
rewardR,
rewardS
);
}


function test_shouldSwapTokensToETH() public {
gasBroker.swap{ value: 1 ether }(
signer,
address(token),
VALUE,
deadline,
REWARD,
permitV,
permitR,
permitS,
rewardV,
rewardR,
rewardS
);

assertEq(token.balanceOf(address(this)), VALUE);
assertEq(token.balanceOf(signer), SIGNER_TOKEN_BALANCE - VALUE);
assertEq(signer.balance, 1 ether);
}
}

The output:

Running 6 tests for test/GasBroker.t.sol:GasBrokerTest
[PASS] test_shouldRevertWhenNotEnouthETHisProvided() (gas: 108423)
[PASS] test_shouldRevertWhenPermitHashInRewardMessageIsInvalid() (gas: 51039)
[PASS] test_shouldRevertWhenRewardExceedsValue() (gas: 39424)
[PASS] test_shouldRevertWhenSignerInRewardMessageIsInvalid() (gas: 43376)
[PASS] test_shouldRevertWhenValueInRewardMessageIsInvalid() (gas: 43569)
[PASS] test_shouldSwapTokensToETH() (gas: 148939)
Test result: ok. 6 passed; 0 failed; 0 skipped; finished in 2.39s

As we can see all tests are passed, but we still cannot be sure if Gas Broker will behave correctly on real blockchain — remember we mocked Price Oracle and token contracts. Before deployment the contract has to be tested on fork from Mainnet, that’s why we need an integration test.

Integration test

In integration test we shouldn’t mock anything — we have to use the contracts those are deployed on Mainnet. This means that Price Oracle will be real contract that get’s ETH price from time-weighted average prices in Uniswap pool and token will be USDC contract.

I’ll use Price Oracle contract from my previous project, in this article you’ll find the code with explanation:

Also let’s verify the price given by Price oracle against Chainlink price feed. We need a helper contract for it:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface AggregatorV3Interface {
function decimals() external view returns (uint8);

function latestRoundData()
external
view
returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
);
}

contract ChainlinkPriceFeed {
AggregatorV3Interface constant ethPriceFeed = AggregatorV3Interface(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419);

function getEthPriceInUsd() external view returns (uint256 ethPrice) {
ethPrice = chainlinkPrice(ethPriceFeed) / 10**6;
}

function chainlinkPrice(AggregatorV3Interface priceFeed) internal view returns (uint256) {
(
/* uint80 roundID */,
int price,
/*uint startedAt*/,
/*uint timeStamp*/,
/*uint80 answeredInRound*/
) = priceFeed.latestRoundData();
return uint256(price);
}
}

Here is a reference to Chainlink docs:

Now the only difference with unit test will be in setUp function where real contracts are used instead of their mocked versions:

contract IntegrationTest is Test {
using Address for address payable;

uint256 constant VALUE = 100e6;
uint256 constant REWARD = 10e6;
uint256 constant SIGNER_USDC_BALANCE = 150e6;
uint256 constant SIGNER_PRIVATE_KEY = 0xA11CE;
ERC20 constant usdc = ERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
address constant USDC_WHALE = address(0xDa9CE944a37d218c3302F6B82a094844C6ECEb17);

address signer;
uint256 deadline;
IPriceOracle priceOracle;
ChainlinkPriceFeed chainlinkPriceFeed;
GasBroker gasBroker;
PermitSigUtils permitSigUtils;
RewardSigUtils rewardSigUtils;


function setUp() public {
chainlinkPriceFeed = new ChainlinkPriceFeed();
bytes memory bytecode = abi.encodePacked(vm.getCode("PriceOracle.sol"));
address deployed;
assembly {
deployed := create(0, add(bytecode, 0x20), mload(bytecode))
}
priceOracle = IPriceOracle(deployed);

gasBroker = new GasBroker(1, address(priceOracle));

// deploy sigUtils
permitSigUtils = new PermitSigUtils(usdc.DOMAIN_SEPARATOR());
rewardSigUtils = new RewardSigUtils(gasBroker.DOMAIN_SEPARATOR());

signer = vm.addr(SIGNER_PRIVATE_KEY);
deadline = block.timestamp + 1 days;

vm.prank(USDC_WHALE);
usdc.transfer(signer, SIGNER_USDC_BALANCE);
}

USDC_WHALE is an account that has a lot of USDC — found via etherscan.io

To transfer USDC from whale to signer without knowing whale’s private key vm.prank cheat-code is used — obviously this trick will work only within virtual test environment.

Here is the code of Integration test contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";

import "@openzeppelin/contracts/utils/Address.sol";
import "solmate/tokens/ERC20.sol";

import './ChainlinkPriceFeed.sol';
import './PermitSigUtils.sol';
import './RewardSigUtils.sol';
import "../src/GasBroker.sol";

contract IntegrationTest is Test {
using Address for address payable;

uint256 constant VALUE = 100e6;
uint256 constant REWARD = 10e6;
uint256 constant SIGNER_USDC_BALANCE = 150e6;
uint256 constant SIGNER_PRIVATE_KEY = 0xA11CE;
ERC20 constant usdc = ERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
address constant USDC_WHALE = address(0xDa9CE944a37d218c3302F6B82a094844C6ECEb17);

address signer;
uint256 deadline;
IPriceOracle priceOracle;
ChainlinkPriceFeed chainlinkPriceFeed;
GasBroker gasBroker;
PermitSigUtils permitSigUtils;
RewardSigUtils rewardSigUtils;


function setUp() public {
chainlinkPriceFeed = new ChainlinkPriceFeed();
bytes memory bytecode = abi.encodePacked(vm.getCode("PriceOracle.sol"));
address deployed;
assembly {
deployed := create(0, add(bytecode, 0x20), mload(bytecode))
}
priceOracle = IPriceOracle(deployed);

gasBroker = new GasBroker(1, address(priceOracle));

// deploy sigUtils
permitSigUtils = new PermitSigUtils(usdc.DOMAIN_SEPARATOR());
rewardSigUtils = new RewardSigUtils(gasBroker.DOMAIN_SEPARATOR());

signer = vm.addr(SIGNER_PRIVATE_KEY);
deadline = block.timestamp + 1 days;

vm.prank(USDC_WHALE);
usdc.transfer(signer, SIGNER_USDC_BALANCE);
}

function test_shouldSwapTokensToETH() public {
// prepare signature for permit
(uint8 permitV, bytes32 permitR, bytes32 permitS) = getPermitSignature(signer, VALUE);

bytes32 permitHash = keccak256(abi.encode(permitV,permitR,permitS));
// prepare signature for reward
(uint8 rewardV, bytes32 rewardR, bytes32 rewardS) = getRewardSignature(REWARD, permitHash);
uint256 value = gasBroker.getEthAmount(address(usdc), VALUE - REWARD);
gasBroker.swap{ value: value }(
signer,
address(usdc),
VALUE,
deadline,
REWARD,
permitV,
permitR,
permitS,
rewardV,
rewardR,
rewardS
);

assertEq(usdc.balanceOf(address(this)), VALUE);
assertEq(usdc.balanceOf(signer), SIGNER_USDC_BALANCE - VALUE);
assertEq(signer.balance, value);

uint256 usdWorth = chainlinkPriceFeed.getEthPriceInUsd() * signer.balance / 10**18;
console2.log("100 USDC been exchanged with comission of 10 USDC to %s wei worth of %s cents", signer.balance, usdWorth);

}

function getPermitSignature(address _signer, uint256 _value) internal view returns (uint8 v, bytes32 r, bytes32 s) {
PermitSigUtils.Permit memory permit = PermitSigUtils.Permit({
owner: _signer,
spender: address(gasBroker),
value: _value,
nonce: 0,
deadline: deadline
});
bytes32 digest = permitSigUtils.getTypedDataHash(permit);
(v, r, s) = vm.sign(SIGNER_PRIVATE_KEY, digest);
}

function getRewardSignature(uint256 reward, bytes32 permitHash) internal view returns (uint8 v, bytes32 r, bytes32 s) {
Reward memory reward = Reward({
value: reward,
permitHash: permitHash
});
bytes32 digest = rewardSigUtils.getTypedDataHash(reward);

(v, r, s) = vm.sign(SIGNER_PRIVATE_KEY, digest);
}

}

At the end of test the $ worth of ETH that signer received is calculated using Chainlink price feed to make sure the Price oracle gives correct price

uint256 usdWorth = chainlinkPriceFeed.getEthPriceInUsd() * signer.balance / 10**18;
console2.log("100 USDC been exchanged with comission of 10 USDC to %s wei worth of %s cents", signer.balance, usdWorth);

Remember, Integration test should be launched only on Mainnet fork:

forge test --fork-url [privider url]

Output:

[PASS] test_shouldSwapTokensToETH() (gas: 297779)
Logs:
100 USDC been exchanged with comission of 10 USDC to 58330748393200040 wei worth of 9003 cents

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.67s

As we can see value — reward=90 USDC and signer received $90.03 there is only 3 cents price fluctuation — that’s a good result

--

--

Alexander Koval
Coinmonks

I'm full-stack web3.0 developer, dedicated to bring more justice in this world by adopting blockchain technologies