[Ethereum] Experiencing an “invalid signature error” when trying to use the “permit” feature of ERC20 tokens that support EIP712

erc-20signaturesoliditytokensweb3js

I'm currently writing a few smart contracts in solidity and interacting with them through web3. The premise being that a token can be deposited into a pool and can then subsequently withdraw at a later date.

The deposit works fine – currently, the app requires the user to "approve" the app to spend the tokens on their behalf and they get deposited into the pool. Here is the relevant smart contract code that handles depositing tokens into the pool

function deposit(
    address token,
    uint tokenAmount,
    bytes32 request,
    address to,
    uint deadline
) external virtual override ensure(deadline) returns (uint amount, uint liquidity) {
    address pool = ISafepayFactory(factory).getPool(token);
    require(pool != address(0), 'SFPY_ROUTER: UNSUPPORTED POOL');
    TransferHelper.safeTransferFrom(token, msg.sender, pool, tokenAmount);
    liquidity = ISafepayPool(pool).mint(to);
    amount = tokenAmount;
    emit Deposit(msg.sender, to, token, request, amount);
}

This assumes that a pool has already been created in advance for that token. Once the tokens are transferred, the pool "mints" liquidity tokens to the user.

When the user wants to withdraw these tokens, the app first asks the user to sign a "Permit" message (currently using MetaMask). Here is the relevant JS code

const nonce = await poolContract.nonces(account)

const EIP712Domain = [
  { name: 'name', type: 'string' },
  { name: 'version', type: 'string' },
  { name: 'chainId', type: 'uint256' },
  { name: 'verifyingContract', type: 'address' }
]
const domain = {
  name: 'Safepay LP Token',
  version: '1',
  chainId: chainId,
  verifyingContract: pool.liquidityToken.address
}
const Permit = [
  { name: 'owner', type: 'address' },
  { name: 'spender', type: 'address' },
  { name: 'value', type: 'uint256' },
  { name: 'nonce', type: 'uint256' },
  { name: 'deadline', type: 'uint256' }
]
const message = {
  owner: account,
  spender: ROUTER_ADDRESS,
  value: liquidityAmount.raw.toString(),
  nonce: nonce.toHexString(),
  deadline: deadline.toNumber()
}
const data = JSON.stringify({
  types: {
    EIP712Domain,
    Permit
  },
  domain,
  primaryType: 'Permit',
  message
})

library
  .send('eth_signTypedData_v4', [account, data])
  .then(sig => {
    console.log(sig)
    const recovered = recoverTypedSignature_v4({
      data: JSON.parse(data),
      sig,
    })
    // Weirdly the address is recovered correctly here.
    console.log(getAddress(recovered) === getAddress(account))
  
    return splitSignature(sig)
  })
  .then(signature => {
    setSignatureData({
      v: signature.v,
      r: signature.r,
      s: signature.s,
      deadline: deadline.toNumber()
    })
  })
  .catch(error => {
    console.log(error)
    // for all errors other than 4001 (EIP-1193 user rejected request), fall back to manual approve
    if (error?.code !== 4001) {
      approveCallback()
    }
  })

After signing the message the app calls an "estimate gas" function on the Router smart contract with the signature params. Here is the withdrawWithPermit function

function withdrawWithPermit(
  address token,
  uint liquidity,
  uint amountMin,
  address to,
  uint deadline,
  bool approveMax, uint8 v, bytes32 r, bytes32 s
) external virtual override returns (uint amount) {
    address pool = ISafepayFactory(factory).getPool(token);
    require(pool != address(0), 'SFPY_ROUTER: UNSUPPORTED POOL');
    uint value = approveMax ? uint(-1) : liquidity;
    ISafepayPool(pool).permit(msg.sender, address(this), value, deadline, v, r, s);
    amount = withdraw(token, liquidity, amountMin, to, deadline);
}

The "Pool" contract inherits from an ERC20 contract and has some additional functionality to "mint" and "burn" liquidity tokens. But here is the relevant "Permit" function on the Pool

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
    require(deadline >= block.timestamp, 'SFPY: EXPIRED');

    bytes32 hashStruct = keccak256(
      abi.encode(
        PERMIT_TYPEHASH,
        owner,
        spender,
        value,
        nonces[owner]++,
        deadline
      )
    );

    bytes32 digest = keccak256(
      abi.encodePacked(
          '\x19\x01',
          DOMAIN_SEPARATOR,
          hashStruct
      )
    );
    
    address recoveredAddress = ecrecover(digest, v, r, s);
    require(recoveredAddress != address(0) && recoveredAddress == owner, 'SFPY: INVALID_SIGNATURE');
    _approve(owner, spender, value);
}

Here is the PERMIT_TYPEHASH

bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

and the DOMAIN_SEPARATOR

DOMAIN_SEPARATOR = keccak256(
   abi.encode(
        keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
        keccak256(bytes(name)),
        keccak256(bytes('1')),
        chainId,
        address(this)
   )
);

(Apologies for the extremely long context but if you've made it this far, this is where the fun starts)

Whenever the app makes a request to this function (through MetaMask) the smart contract throws either an SFPY: EXPIRED error or SFPY: INVALID_SIGNATURE error.`

The INVALID_SIGNATURE is interesting because before making the request, the app is able to correctly recover the signers address (as shown in the JS snippet)

However, the call to the smart contract always fails. I'm assuming there must be an error in either the DOMAIN_SEPARATOR, PERMIT_TYPEHASH or something else entirely that I'm missing but I hope someone can help!

Happy to add as many more code snippets, screenshots as needed.

(Example error screenshot attached)
metamask error

Best Answer

I was able to figure this out after much debugging. Turns out that the chainid OPCODE is not working correctly in Ganche (if you use their UI - the fix has been released in the ganache-cli project).

The lack of a proper functioning chainid results in the DOMAIN_TYPEHASH being computed incorrectly which prevents ecrecover from generating the correct address from a signature.

You can follow these issues

Chain ID opcode: hardcoded at 1? #1643

istanbul chainId opcode returns bad value #515

Hope this helps others