Browsing the crypto Twitter/X, I found a service smolrefuel.com, (tweet), which solves the problem of obtaining a gas token on Ethereum networks if you don’t have one, for example when you withdraw a stablecoin from an exchange. I did a little research on how it works. A high-level overview:
The foundation of this is ERC-2612. Its main part – the permit function, implemented for token T:
function permit(address owner,
address spender,
uint value,
uint deadline,
uint8 v, bytes32 r, bytes32 s)
This functions tells a contract: please allow spender spend value tokens from owner‘s wallet, here’s the signature. Triple (v, r, s) is essentially that signature A, signed by the owner (W). This is the output of secp256k1 algorithm. Its remarkable property is that it is easy to recover who exactly signed the signature. permit function also checks that signature is not expired thru deadline parameter.
To prevent replay attacks, when signature constructed on another chain or for another token can be reused, a unique domain key is used. According to EIP-712, in order for domain to be unique, token name, contract version, chain id, contract address, and sometimes salt are used. It is a constant value for each token and almost never changed.
Also, to make a signature, a nonce (an increment value) mechanism is used, so that every next signature will be unique regardless of other params.
Here is how signing is done (pseudocode):
// unique domain
domain = {
name: "Token Name",
version: "1",
chainId: "137",
verifyingContract: "0xAaAaa...."
}
// params for permit
values = {
owner: W, // wallet W
spender: S, // allowed spender
value: V, // how much to spend, e.g. 100000000
nonce: getNonce(T, W) // nonce (incremented value),
deadline: D // e.g., an hour from now on.
}
A = signTypedData(domain, values) // let's sign data and get A
Okay, we now have signature A. That’s how we can call contract’s permit method (pseudocode):
v, r, s = splitSignature(A) // get signature's A components v, r, s
T.permit(owner: W, spender: S, value: V, deadline: D, v, r, s)
It is important that permit does not check the caller – it means for the signer this can be completely gas-free – some another contract can call it. It is the case for smolrefuel.
Let’s examine permit‘s pseudocode (modified OpenZeppelin implementation):
function permit(address owner, address spender, uint value, uint deadline,
uint8 v, bytes32 r, bytes32 s) {
if (deadline > NOW())
throw error("deadline expired")
// let's recover the signer
address signer = recover(DOMAIN_SEPARATOR, v, r, s)
// signer is not owner
if (signer != owner)
throw error("signature invalid")
// all's as expected, let's give spender a right to withdraw.
approve(owner, spender, value)
}
DOMAIN_SEPARATOR here is a token-related constant derived from domain above.
To see how ERC2612 works I’ve created a prototype. It is the app consisting of two components.
front-end (in react directory) is a web app where user signs an off-chain permit with wallet like Metamask. This permit allows smart contract defined in src/constants.ts (spenderAddress – please set it yourself) to spend 0.1 Polygon USDC from current wallet address W. The most interesting part of code is in App.tsx, sendPermit function:
...
// token properties are loaded above
const values: ValuesDto = {
owner: eoaAddress!,
spender: constants.spenderAddress,
value: ethers.parseUnits("0.1", decimals),
nonce: nonce,
deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now on.
};
const domain: DomainDto = {
chainId: network!.chainId,
name: name,
verifyingContract: constants.tokenAddress,
version: version,
};
const signature = await signer.current!.signTypedData(domain, constants.permitTypes, values);
const payload: PermitDto = {
signature: signature,
values: values,
domain: domain,
};
// send to backend to call permit gasless.
const resp = await fetch("http://localhost:9001/permit", { method: "POST", headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
...
Backend app (console directory) receives the signature and parameters from front-end and calls permit function on token’s smart contract. In order to work, .env file must be created (use copy of .env.example) with RPC_URL parameter (can be obtained on services like Alchemy, Infura etc) and PK – a private key of an EOA / wallet that will send transaction. Please don’t store private keys as plain text in production like this – here it is for simplicity. Sender’s EOA must have gas token on it required to pay for gas.
The main code is in index.ts, POST handler:
...
// inp.signature – is a permit signature from front-end
const splitted = ethers.Signature.from(inp.signature);
const tokenContract = new ethers.Contract(
constants.tokenAddress,
ABI,
wallet
);
// call permit on token
const tx = await tokenContract.permit(
inp.values.owner,
constants.spenderAddress,
inp.values.value,
inp.values.deadline,
splitted.v,
splitted.r,
splitted.s,
{
gasLimit: 170000,
}
);
By default, the prototype works with Polygon USDC, but in constants.ts any token can be used instead.
This is probably the most interesting part. I did a research on how the ERC2612 is implemented for 10 most popular tokens on Ethereum, Polygon and Optimism (the networks I work with the most), and here are the results (sheet):
Green are the tokens that implement ERC2612 and EIP712. It is somewhat easy to implement a permit signing (*).
Yellow are the tokens that implement ERC2612 (i.e. permit function), but do not implement EIP712. It is a difficult case as signTypedData, implemented in ethers.js, strictly conforms to EIP712:
Moreover, Metamask implementation of eth_signTypedData_v4 receives domain object (see above) and calculates domain separator internally, that won’t match domain separator of such a token computed differently than in EIP-712. This problem can be partially solved if call is done on backend part when one has private key available to send a transaction. DOMAIN_SEPARATOR can be get from token’s contract and used in signTypedData. ethers.js does not support that, I made a PR to ask to include this functionality. It is difficult to say if this scenario has some adoption or not.
I didn’t include this in the prototype, although you can do it yourself – don’t forget to use modified ethers.js.
Red is one token (Ethereum DAI) that has permit implemented but it does not correspond to ERC-2612 (bummer!). It allows two cases: either no allowance or max allowed in Solidity (max uint256 value). I don’t know why they decided it this way; probably it was before ERC2612 went live. permit has allowed: bool parameter:
And that’s not all! In order to sign the permit, we need to construct domain and set its fields. It is trivial except for version field. Asterisk (*) marked tokens don’t have public version() function, so I had to brute force actual version and put it after the asterisk. But if token developer decides to change it, you get a trouble.
As far as I know, version() is not in the standards but rather voluntarily implemented by token’s developer. To solve this problem, EIP-5267 was introduced, but it is only supported by stETH (**), see the table…
A big shot-out to tenderly – an Ethereum debugger, that I used to debug permit calls when they weren’t successful, and also to simulate a transaction on Ethereum to save gas tokens – a killer feature IMO:
I got mixed feelings after completion of this research. On the one hand, there are standards that can ease usability of Ethereum dApps and increase adoption. On the other hand, out of 30 popular tokens only 8 support approve permits, and for three of them special handling is required (two for version() and one for non-standard permit() function). Looks like not nearly enough for mass adoption.
Also, in case of development of your dApp, I would budget in additional time for adding every token as it can be non-trivial if you work with something more fancy than ERC-20.
––––
Follow me on X / Twitter @TechGeorgii
I am also a co-founder of a software development company, so in case you have a custom project in Ethereum or Web2 space, or looking for additional bandwidth, please reach out on Telegram (preferred) or Twitter