Gas-less way to purchase ETH for USDC.
Part 2 — Gas Broker contract
Previous parts
Source code:
Interface
Let’s develop the core of system — Gas Broker contract that is trusted middleware between customer and Gas Provider.
As I mentioned in a previous article it should have only one function — swap. The function should take following parameters:
For spending approval:
- token address
- value
- deadline
- signature for permit message
For reward:
- reward amount
- signature for reward message
General:
- customer address
Having this data let’s create an interface of swap function:
function swap(
address signer,
address token,
uint256 value,
uint256 deadline,
uint256 reward,
uint8 permitV,
bytes32 permitR,
bytes32 permitS,
uint8 rewardV,
bytes32 rewardR,
bytes32 rewardS) external payable
It has so many parameters cause signatures are split into 3 parts (v, r, s)— that is done for gas efficiency. Function is payable cause Gas Provider have to provide ETH that will be transferred to Customer after verifications.
Swap logic
Inside the swap function the sender is Gas Provider, the signer is Customer. The function should do the following:
- check that value is greater than reward and revert otherwise
- make sure the reward message is signed by signer
- call permit on token contract with permit message signature
- call price oracle and calculate how much ETH needs to be sent to signer
- check that sender provided enough ETH and revert otherwise
- call transferFrom on token contract to move the tokens from signer to Gas Broker contract
- transfer tokens to sender
- transfer ETH to signer
- if more ETH than necessary been provided with this call than transfer change back to sender
We don’t have to check signature for permit message as it is done inside permit
function of token contract. Permit message structure is already defined in ERC-2612
Design the reward message structure
Reward message should be a typed-structured data and implement EIP-712 This is important cause we want our user to see the message with data he is about to sign instead of a byte-string.
⚠️ Warning — never sign any byte-string unless you are able to de-code it and have 100% understanding of data and protocol, otherwise your wallet could be drained in a matter of seconds
With new feature of MetaMask user don’t have to decode byte-string if the message implements EIP-712, so he will have more trust to system.
I followed this article to design a data structure compatible to EIP-712:
According to the article I have to define:
- Domain separator
- Type interface
- Hash function for each type
Careful! Always think about re-play attack
From the first glance it seems that reward message should only have one data field — the amount of reward. But be careful — this is not enough. Imagine this scenario:
- Customer A have published and order with 10 USDC reward and this order was executed
- Some Gas Provider keeps a dump of all orders and stores all parameters
- Few days later gas price drops and Customer A places another swap order with 5 USDC reward
- Gas provider picks this order but instead of using reward signature from actual order he founds the signature of previous order in dump — the reward was 2 times higher and he uses this value to perform the swap.
- Since message from dump was signed by the same Customer it passes the check and Customer A to his surprise ends up paying x2 reward that he expected
To prevent this type of attack we need to make reward signature to be valid only for particular permit signature. As in blockchain each subsequent block has a reference to previous one we can add another field to reward message that contains a reference to permit message. The reference is a hash of permit message signature. This way permit and reward messages produced for same order could be used only in couple and it makes re-play attack impossible. Here is a type interface for reward message:
struct Reward {
uint256 value;
bytes32 permitHash; //keccak256 for permit signature
}
Why don’t we do the same for permit message? There is no need — cause permit message already have nonce
field — token contract has a map in storage: mapping(address => uint256) public nonces
Once permit
is executed the nonce
for particular address increases. This prevents re-play attack.
Now let’s define DOMAN_SEPARATOR
for reward message:
bytes32 DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("Gas broker")),
keccak256("1"),
chainId,
address(this)
)
);
DOMAIN_SEPARATOR
will be defined in Gas Broker contract as the same contract will do the verification of this signature, from there it get’s it’s name
and verifyingContract
address
Hash function for reward message:
function hashReward(Reward memory reward) private view returns (bytes32) {
return keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(
abi.encode(
keccak256("Reward(uint256 value,bytes32 permitHash)"),
reward.value,
reward.permitHash
)
)
)
);
}
The verification function:
function verifyReward(
address signer,
Reward memory reward,
uint8 sigV,
bytes32 sigR,
bytes32 sigS
) private view returns (bool) {
return signer == ecrecover(hashReward(reward), sigV, sigR, sigS);
}
If you need more detailed explanation I’m refferring you to the article:
Coding swap function
Now we are ready to implement swap function:
function swap(
address signer,
address token,
uint256 value,
uint256 deadline,
uint256 reward,
uint8 permitV,
bytes32 permitR,
bytes32 permitS,
uint8 rewardV,
bytes32 rewardR,
bytes32 rewardS) external payable {
require(value > reward, "Reward could not exceed value");
bytes32 permitHash = keccak256(abi.encode(permitV,permitR,permitS));
require(
verifyReward(
signer,
Reward({
value: reward,
permitHash: permitHash
}),
rewardV,
rewardR,
rewardS
),
"Reward signature is invalid"
);
IERC2612(token).permit(
signer,
address(this),
value,
deadline,
permitV,
permitR,
permitS
);
SafeERC20.safeTransferFrom(IERC20(token), signer, address(this), value);
uint256 ethAmount = _getEthAmount(token, value - reward);
require(msg.value >= ethAmount, "Not enough ETH provided");
payable(signer).sendValue(ethAmount);
if (msg.value > ethAmount) {
payable(msg.sender).sendValue(msg.value - ethAmount);
}
SafeERC20.safeTransfer(IERC20(token), msg.sender, value);
}
permitHash
is calculated as keccak256
from full permit
signature and then used to check reward signature.
_getEthAmount
is internal function that takes price from oracle, we should also have public view function that Gas Providers can use to found out how much ETH they have to send:
function _getEthAmount(address token, uint256 amount) internal view returns (uint256 ethAmount) {
ethAmount = priceOracle.getPriceInEth(address(token), amount);
}
function getEthAmount(address token, uint256 amount) external view returns (uint256 ethAmount) {
ethAmount = _getEthAmount(token, amount);
}
The complete code of Gas Broker contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/Address.sol";
interface IPriceOracle {
function getPriceInEth(address token, uint amount) external view returns (uint256);
}
interface IERC2612 {
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external;
}
struct Reward {
uint256 value;
bytes32 permitHash; //keccak256 for permit signature
}
contract GasBroker {
using Address for address payable;
uint32 constant TWAP_PERIOD = 180;
bytes32 public immutable DOMAIN_SEPARATOR;
IPriceOracle immutable priceOracle;
constructor(uint256 chainId, address _priceOracle) {
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("Gas broker")),
keccak256("1"),
chainId,
address(this)
)
);
priceOracle = IPriceOracle(_priceOracle);
}
function swap(
address signer,
address token,
uint256 value,
uint256 deadline,
uint256 reward,
uint8 permitV,
bytes32 permitR,
bytes32 permitS,
uint8 rewardV,
bytes32 rewardR,
bytes32 rewardS) external payable {
require(value > reward, "Reward could not exceed value");
bytes32 permitHash = keccak256(abi.encode(permitV,permitR,permitS));
require(
verifyReward(
signer,
Reward({
value: reward,
permitHash: permitHash
}),
rewardV,
rewardR,
rewardS
),
"Reward signature is invalid"
);
IERC2612(token).permit(
signer,
address(this),
value,
deadline,
permitV,
permitR,
permitS
);
SafeERC20.safeTransferFrom(IERC20(token), signer, address(this), value);
uint256 ethAmount = _getEthAmount(token, value - reward);
require(msg.value >= ethAmount, "Not enough ETH provided");
payable(signer).sendValue(ethAmount);
if (msg.value > ethAmount) {
payable(msg.sender).sendValue(msg.value - ethAmount);
}
SafeERC20.safeTransfer(IERC20(token), msg.sender, value);
}
function hashReward(Reward memory reward) private view returns (bytes32) {
return keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(
abi.encode(
keccak256("Reward(uint256 value,bytes32 permitHash)"),
reward.value,
reward.permitHash
)
)
)
);
}
function verifyReward(
address signer,
Reward memory reward,
uint8 sigV,
bytes32 sigR,
bytes32 sigS
) private view returns (bool) {
return signer == ecrecover(hashReward(reward), sigV, sigR, sigS);
}
function _getEthAmount(address token, uint256 amount) internal view returns (uint256 ethAmount) {
ethAmount = priceOracle.getPriceInEth(address(token), amount);
}
function getEthAmount(address token, uint256 amount) external view returns (uint256 ethAmount) {
ethAmount = _getEthAmount(token, amount);
}
}
To be continue
In the next article I’ll show how to test Gas Broker contract.