Skip to content

Commit

Permalink
Merge pull request #22 from ProjectOpenSea/ryan/erc20-preapproved-con…
Browse files Browse the repository at this point in the history
…duit-solady-permit

ERC20 preapproved conduit solady: flip conduit allowance value for permit()
  • Loading branch information
0age authored Nov 30, 2023
2 parents 057bb1f + b7d7c10 commit 1cc68ee
Show file tree
Hide file tree
Showing 5 changed files with 393 additions and 26 deletions.
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
162 changes: 138 additions & 24 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 the allowance is not the maximum uint256 value.
if not(allowance_) {
// 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 not(allowance_) {
// 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

0 comments on commit 1cc68ee

Please sign in to comment.