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.
pragma solidity =0.5.16; // for the pool
pragma solidity =0.6.6; // for the router