Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ERC20 preapproved conduit solady: flip conduit allowance value for permit() #22

Merged
merged 5 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/lib/Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@ uint256 constant SOLADY_ERC20_BALANCE_SLOT_SEED = 0x87a211a2;
/// @dev `keccak256(bytes("Transfer(address,address,uint256)"))`.
uint256 constant SOLADY_ERC20_TRANSFER_EVENT_SIGNATURE =
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef;
uint256 constant SOLADY_ERC20_APPROVAL_EVENT_SIGNATURE = 0;
/// @dev Solady ERC20 nonces slot seed with signature prefix.
uint256 constant SOLADY_ERC20_NONCES_SLOT_SEED_WITH_SIGNATURE_PREFIX = 0x383775081901;
/// @dev `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")`.
bytes32 constant SOLADY_ERC20_DOMAIN_TYPEHASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f;
/// @dev Solady ERC20 version hash: `keccak256("1")`.
bytes32 constant SOLADY_ERC20_VERSION_HASH = 0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6;
/// @dev `keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")`.
bytes32 constant SOLADY_ERC20_PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
10 changes: 10 additions & 0 deletions src/reference/tokens/erc20/ERC20Preapproved_Solady.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ contract ERC20_Solady is ERC20ConduitPreapproved_Solady {
_mint(to, amount);
}

/// @dev Exposed to test internal function
function spendAllowance(address owner, address spender, uint256 amount) public {
_spendAllowance(owner, spender, amount);
}

/// @dev Exposed to test internal function
function approve(address owner, address spender, uint256 amount) public {
_approve(owner, spender, amount);
}

function name() public pure override returns (string memory) {
return "Test";
}
Expand Down
164 changes: 139 additions & 25 deletions src/tokens/erc20/ERC20ConduitPreapproved_Solady.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
CONDUIT,
SOLADY_ERC20_ALLOWANCE_SLOT_SEED,
SOLADY_ERC20_BALANCE_SLOT_SEED,
SOLADY_ERC20_TRANSFER_EVENT_SIGNATURE
SOLADY_ERC20_TRANSFER_EVENT_SIGNATURE,
SOLADY_ERC20_NONCES_SLOT_SEED_WITH_SIGNATURE_PREFIX,
SOLADY_ERC20_DOMAIN_TYPEHASH,
SOLADY_ERC20_PERMIT_TYPEHASH,
SOLADY_ERC20_VERSION_HASH,
SOLADY_ERC20_APPROVAL_EVENT_SIGNATURE
} from "../../lib/Constants.sol";
import {IPreapprovalForAll} from "../../interfaces/IPreapprovalForAll.sol";

Expand All @@ -23,12 +28,10 @@ abstract contract ERC20ConduitPreapproved_Solady is ERC20, IPreapprovalForAll {
*/
function allowance(address owner, address spender) public view virtual override returns (uint256) {
uint256 allowance_ = super.allowance(owner, spender);
if (spender == CONDUIT) {
if (allowance_ == 0) {
return type(uint256).max;
} else if (allowance_ == type(uint256).max) {
return 0;
}
assembly {
// "flip" allowance if spender is CONDUIT and if allowance is 0 or type(uint256).max.
allowance_ :=
xor(allowance_, mul(and(eq(spender, CONDUIT), or(iszero(allowance_), iszero(not(allowance_)))), not(0)))
}
return allowance_;
}
Expand All @@ -40,12 +43,9 @@ abstract contract ERC20ConduitPreapproved_Solady is ERC20, IPreapprovalForAll {
* E.g. if 0 is passed, it is stored as `type(uint256).max`, and if `type(uint256).max` is passed, it is stored as 0.
*/
function approve(address spender, uint256 amount) public virtual override returns (bool) {
if (spender == CONDUIT) {
if (amount == 0) {
amount = type(uint256).max;
} else if (amount == type(uint256).max) {
amount = 0;
}
assembly {
// "flip" amount if spender is CONDUIT and if amount is 0 or type(uint256).max.
amount := xor(amount, mul(and(eq(spender, CONDUIT), or(iszero(amount), iszero(not(amount)))), not(0)))
}
super._approve(msg.sender, spender, amount);
return true;
Expand All @@ -64,17 +64,23 @@ abstract contract ERC20ConduitPreapproved_Solady is ERC20, IPreapprovalForAll {

// "flip" allowance if caller is CONDUIT and if allowance_ is 0 or type(uint256).max.
allowance_ :=
xor(allowance_, mul(and(eq(caller(), CONDUIT), iszero(and(allowance_, not(allowance_)))), not(0)))
xor(allowance_, mul(and(eq(caller(), CONDUIT), or(iszero(allowance_), iszero(not(allowance_)))), not(0)))

// If the allowance is not the maximum uint256 value:
if not(allowance_) {
// If the allowance is not the maximum uint256 value.
if add(allowance_, 1) {
ryanio marked this conversation as resolved.
Show resolved Hide resolved
// Revert if the amount to be transferred exceeds the allowance.
if gt(amount, allowance_) {
mstore(0x00, 0x13be252b) // `InsufficientAllowance()`.
revert(0x1c, 0x04)
}
// Subtract and store the updated allowance.
sstore(allowanceSlot, sub(allowance_, amount))
sstore(
allowanceSlot,
xor(
sub(allowance_, amount),
mul(and(eq(caller(), CONDUIT), iszero(sub(allowance_, amount))), not(0))
)
)
}

// Compute the balance slot and load its value.
Expand Down Expand Up @@ -103,16 +109,124 @@ abstract contract ERC20ConduitPreapproved_Solady is ERC20, IPreapprovalForAll {
return true;
}

function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
public
virtual
override
{
bytes32 nameHash = _constantNameHash();
// We simply calculate it on-the-fly to allow for cases where the `name` may change.
if (nameHash == bytes32(0)) nameHash = keccak256(bytes(name()));
/// @solidity memory-safe-assembly
assembly {
// Revert if the block timestamp greater than `deadline`.
if gt(timestamp(), deadline) {
mstore(0x00, 0x1a15a3cc) // `PermitExpired()`.
revert(0x1c, 0x04)
}
let m := mload(0x40) // Grab the free memory pointer.
// Clean the upper 96 bits.
owner := shr(96, shl(96, owner))
spender := shr(96, shl(96, spender))
// Compute the nonce slot and load its value.
mstore(0x0e, SOLADY_ERC20_NONCES_SLOT_SEED_WITH_SIGNATURE_PREFIX)
mstore(0x00, owner)
let nonceSlot := keccak256(0x0c, 0x20)
let nonceValue := sload(nonceSlot)
// Prepare the domain separator.
mstore(m, SOLADY_ERC20_DOMAIN_TYPEHASH)
mstore(add(m, 0x20), nameHash)
mstore(add(m, 0x40), SOLADY_ERC20_VERSION_HASH)
mstore(add(m, 0x60), chainid())
mstore(add(m, 0x80), address())
mstore(0x2e, keccak256(m, 0xa0))
// Prepare the struct hash.
mstore(m, SOLADY_ERC20_PERMIT_TYPEHASH)
mstore(add(m, 0x20), owner)
mstore(add(m, 0x40), spender)
mstore(add(m, 0x60), value)
mstore(add(m, 0x80), nonceValue)
mstore(add(m, 0xa0), deadline)
mstore(0x4e, keccak256(m, 0xc0))
// Prepare the ecrecover calldata.
mstore(0x00, keccak256(0x2c, 0x42))
mstore(0x20, and(0xff, v))
mstore(0x40, r)
mstore(0x60, s)
let t := staticcall(gas(), 1, 0, 0x80, 0x20, 0x20)
// If the ecrecover fails, the returndatasize will be 0x00,
// `owner` will be be checked if it equals the hash at 0x00,
// which evaluates to false (i.e. 0), and we will revert.
// If the ecrecover succeeds, the returndatasize will be 0x20,
// `owner` will be compared against the returned address at 0x20.
if iszero(eq(mload(returndatasize()), owner)) {
mstore(0x00, 0xddafbaef) // `InvalidPermit()`.
revert(0x1c, 0x04)
}
// Increment and store the updated nonce.
sstore(nonceSlot, add(nonceValue, t)) // `t` is 1 if ecrecover succeeds.
// Compute the allowance slot and store the value.
// The `owner` is already at slot 0x20.
mstore(0x40, or(shl(160, SOLADY_ERC20_ALLOWANCE_SLOT_SEED), spender))

// "flip" allowance value if spender is CONDUIT and if value is 0 or type(uint256).max.
value := xor(value, mul(and(eq(spender, CONDUIT), or(iszero(value), iszero(not(value)))), not(0)))

sstore(keccak256(0x2c, 0x34), value)
// Emit the {Approval} event.
log3(add(m, 0x60), 0x20, SOLADY_ERC20_APPROVAL_EVENT_SIGNATURE, owner, spender)
mstore(0x40, m) // Restore the free memory pointer.
mstore(0x60, 0) // Restore the zero pointer.
}
}

function _spendAllowance(address owner, address spender, uint256 amount) internal virtual override {
if (spender == CONDUIT) {
uint256 allowance_ = super.allowance(owner, spender);
if (allowance_ == type(uint256).max) {
// Max allowance, no need to spend.
return;
} else if (allowance_ == 0) {
revert InsufficientAllowance();
/// @solidity memory-safe-assembly
assembly {
// Compute the allowance slot and load its value.
mstore(0x20, spender)
mstore(0x0c, SOLADY_ERC20_ALLOWANCE_SLOT_SEED)
mstore(0x00, owner)
let allowanceSlot := keccak256(0x0c, 0x34)
let allowance_ := sload(allowanceSlot)

// "flip" allowance if spender is CONDUIT and if allowance_ is 0 or type(uint256).max.
allowance_ :=
xor(allowance_, mul(and(eq(spender, CONDUIT), or(iszero(allowance_), iszero(not(allowance_)))), not(0)))

// If the allowance is not the maximum uint256 value.
if add(allowance_, 1) {
// Revert if the amount to be transferred exceeds the allowance.
if gt(amount, allowance_) {
mstore(0x00, 0x13be252b) // `InsufficientAllowance()`.
revert(0x1c, 0x04)
}
// Subtract and store the updated allowance.
sstore(
allowanceSlot,
xor(
sub(allowance_, amount), mul(and(eq(spender, CONDUIT), iszero(sub(allowance_, amount))), not(0))
)
)
}
}
super._spendAllowance(owner, spender, amount);
}

function _approve(address owner, address spender, uint256 amount) internal virtual override {
/// @solidity memory-safe-assembly
assembly {
let owner_ := shl(96, owner)
// Compute the allowance slot and store the amount.
mstore(0x20, spender)
mstore(0x0c, or(owner_, SOLADY_ERC20_ALLOWANCE_SLOT_SEED))

// "flip" amount if spender is CONDUIT and if amount is 0 or type(uint256).max.
amount := xor(amount, mul(and(eq(spender, CONDUIT), or(iszero(amount), iszero(not(amount)))), not(0)))

sstore(keccak256(0x0c, 0x34), amount)
// Emit the {Approval} event.
mstore(0x00, amount)
log3(0x00, 0x20, SOLADY_ERC20_APPROVAL_EVENT_SIGNATURE, shr(96, owner_), shr(96, mload(0x2c)))
}
}
}
49 changes: 49 additions & 0 deletions test/tokens/ERC20ConduitPreapproved_OZ.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
pragma solidity ^0.8.17;

import {Test} from "forge-std/Test.sol";
import {IERC20Errors} from "openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol";
import {ERC20_OZ} from "src/reference/tokens/erc20/ERC20Preapproved_OZ.sol";
import {CONDUIT} from "src/lib/Constants.sol";
import {IPreapprovalForAll} from "src/interfaces/IPreapprovalForAll.sol";
Expand Down Expand Up @@ -58,4 +59,52 @@ contract ERC20ConduitPreapproved_OZTest is Test, IPreapprovalForAll {
vm.prank(CONDUIT);
test.transferFrom(address(acct), address(this), 1);
}

function testTransferReducesAllowance(address acct, address operator) public {
if (acct == address(0)) {
acct = address(1);
}
if (operator == address(0)) {
operator = address(1);
}
test.mint(acct, 1 ether);
vm.prank(acct);
test.approve(operator, 1 ether);
vm.prank(operator);
test.transferFrom(address(acct), address(this), 1 ether);
assertEq(test.allowance(acct, operator), 0);
assertEq(test.balanceOf(address(this)), 1 ether);

// Allowance shouldn't decrease if type(uint256).max
test.mint(acct, 1 ether);
vm.prank(acct);
test.approve(operator, type(uint256).max);
vm.prank(operator);
test.transferFrom(address(acct), address(this), 1 ether);
assertEq(test.allowance(acct, operator), type(uint256).max);
assertEq(test.balanceOf(address(this)), 2 ether);

// Test conduit which should have default allowance of type(uint256).max
test.mint(acct, 1 ether);
assertEq(test.allowance(acct, CONDUIT), type(uint256).max);
vm.prank(CONDUIT);
test.transferFrom(address(acct), address(this), 1 ether);
assertEq(test.allowance(acct, CONDUIT), type(uint256).max);
assertEq(test.balanceOf(address(this)), 3 ether);

// Test conduit with lower allowance that should be reduced
test.mint(acct, 1 ether);
vm.prank(acct);
test.approve(CONDUIT, 1 ether);
vm.prank(CONDUIT);
test.transferFrom(address(acct), address(this), 1 ether);
assertEq(test.allowance(acct, CONDUIT), 0);
assertEq(test.balanceOf(address(this)), 4 ether);

// Test conduit with now 0 allowance that should revert
test.mint(acct, 1 ether);
vm.prank(CONDUIT);
vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, CONDUIT, 0, 1 ether));
test.transferFrom(address(acct), address(this), 1 ether);
}
}
Loading
Loading