diff --git a/src/facets/ZapFacet.sol b/src/facets/ZapFacet.sol new file mode 100644 index 00000000..122a7260 --- /dev/null +++ b/src/facets/ZapFacet.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import { PermitSignature } from "../shared/FreeStructs.sol"; +import { Modifiers } from "../shared/Modifiers.sol"; +import { LibTokenizedVaultIO } from "../libs/LibTokenizedVaultIO.sol"; +import { LibEntity } from "../libs/LibEntity.sol"; +import { LibAdmin } from "../libs/LibAdmin.sol"; +import { LibObject } from "../libs/LibObject.sol"; +import { LibConstants as LC } from "../libs/LibConstants.sol"; +import { ReentrancyGuard } from "../utils/ReentrancyGuard.sol"; +import { LibTokenizedVaultStaking } from "../libs/LibTokenizedVaultStaking.sol"; +import { IERC20 } from "../interfaces/IERC20.sol"; +import { LibMarket } from "../libs/LibMarket.sol"; + +contract ZapFacet is Modifiers, ReentrancyGuard { + /** + * @notice Deposit and stake funds into msg.sender's Nayms platform entity in one transaction using permit + * @dev Uses permit to approve token transfer, deposits from msg.sender to their associated entity, and stakes the amount + * @param _externalTokenAddress Token address + * @param _entityId Staking entity ID + * @param _amountToDeposit Deposit amount + * @param _amountToStake Stake amount + * @param _permitSignature The permit signature parameters + */ + function zapStake( + address _externalTokenAddress, + bytes32 _entityId, + uint256 _amountToDeposit, + uint256 _amountToStake, + PermitSignature calldata _permitSignature + ) external notLocked nonReentrant { + // Check if it's a supported ERC20 token + require(LibAdmin._isSupportedExternalTokenAddress(_externalTokenAddress), "zapStake: invalid ERC20 token"); + + // Get the user's entity + bytes32 parentId = LibObject._getParentFromAddress(msg.sender); + require(LibEntity._isEntity(parentId), "zapStake: invalid receiver"); + + // Use permit to set allowance + IERC20(_externalTokenAddress).permit(msg.sender, address(this), _amountToDeposit, _permitSignature.deadline, _permitSignature.v, _permitSignature.r, _permitSignature.s); + + // Perform the deposit + LibTokenizedVaultIO._externalDeposit(parentId, _externalTokenAddress, _amountToDeposit); + + // Stake the deposited amount + LibTokenizedVaultStaking._stake(parentId, _entityId, _amountToStake); + } + + /** + * @notice Deposit tokens and execute a limit order in one transaction using permit + * @dev Uses permit to approve token transfer and performs external deposit and limit order execution + * @param _externalTokenAddress Token address + * @param _depositAmount Amount to deposit + * @param _sellToken Sell token ID + * @param _sellAmount Sell amount + * @param _buyToken Buy token ID + * @param _buyAmount Buy amount + * @param _permitSignature The permit signature parameters + * @return offerId_ The ID of the created offer + * @return buyTokenCommissionsPaid_ Commissions paid in buy token + * @return sellTokenCommissionsPaid_ Commissions paid in sell token + */ + function zapOrder( + address _externalTokenAddress, + uint256 _depositAmount, + bytes32 _sellToken, + uint256 _sellAmount, + bytes32 _buyToken, + uint256 _buyAmount, + PermitSignature calldata _permitSignature + ) + external + notLocked + nonReentrant + assertPrivilege(LibObject._getParentFromAddress(msg.sender), LC.GROUP_EXECUTE_LIMIT_OFFER) + returns (uint256 offerId_, uint256 buyTokenCommissionsPaid_, uint256 sellTokenCommissionsPaid_) + { + // Check if it's a supported ERC20 token + require(LibAdmin._isSupportedExternalTokenAddress(_externalTokenAddress), "zapOrder: invalid ERC20 token"); + + // Get the user's entity + bytes32 parentId = LibObject._getParentFromAddress(msg.sender); + require(LibEntity._isEntity(parentId), "zapOrder: invalid entity"); + + // Use permit to set allowance + IERC20(_externalTokenAddress).permit(msg.sender, address(this), _depositAmount, _permitSignature.deadline, _permitSignature.v, _permitSignature.r, _permitSignature.s); + + // Perform the external deposit + LibTokenizedVaultIO._externalDeposit(parentId, _externalTokenAddress, _depositAmount); + + // Execute the limit order + return LibMarket._executeLimitOffer(parentId, _sellToken, _sellAmount, _buyToken, _buyAmount, LC.FEE_TYPE_TRADING); + } +} diff --git a/src/shared/FreeStructs.sol b/src/shared/FreeStructs.sol index a10206ca..0fa40700 100644 --- a/src/shared/FreeStructs.sol +++ b/src/shared/FreeStructs.sol @@ -126,3 +126,10 @@ struct RewardsBalances { uint256[] amounts; uint64 lastPaidInterval; } + +struct PermitSignature { + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; +} diff --git a/test/T01LibERC20.t.sol b/test/T01LibERC20.t.sol index b9efd51e..4f84bf80 100644 --- a/test/T01LibERC20.t.sol +++ b/test/T01LibERC20.t.sol @@ -81,10 +81,6 @@ contract T01LibERC20 is D03ProtocolDefaults { vm.expectRevert("not enough balance"); fixture.transfer(tokenAddress, account0, 101); - // failed transfer of 0 - vm.expectRevert("LibERC20: transfer or transferFrom returned false"); - fixture.transfer(tokenAddress, account0, 0); - // successful transfer fixture.transfer(tokenAddress, account0, 100); @@ -113,10 +109,6 @@ contract T01LibERC20 is D03ProtocolDefaults { vm.expectRevert("not enough balance"); fixture.transferFrom(tokenAddress, signer1, account0, 101); - // failed transfer of 0 reverts with empty string - vm.expectRevert("LibERC20: transfer or transferFrom reverted"); - fixture.transferFrom(tokenAddress, signer1, account0, 0); - // successful transfer fixture.transferFrom(tokenAddress, signer1, account0, 100); diff --git a/test/T07Zaps.t.sol b/test/T07Zaps.t.sol new file mode 100644 index 00000000..a093b69b --- /dev/null +++ b/test/T07Zaps.t.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import { D03ProtocolDefaults, c, LC, LibHelpers, StdStyle } from "./defaults/D03ProtocolDefaults.sol"; +import { DummyToken } from "test/utils/DummyToken.sol"; +import { StakingConfig, PermitSignature } from "src/shared/FreeStructs.sol"; + +contract ZapFacetTest is D03ProtocolDefaults { + using LibHelpers for address; + using StdStyle for *; + + DummyToken internal naymToken = new DummyToken(); + DummyToken internal rewardToken; + + NaymsAccount bob = makeNaymsAcc("Bob"); + + NaymsAccount nlf = makeNaymsAcc(LC.NLF_IDENTIFIER); + + uint64 private constant SCALE_FACTOR = 1_000_000; // 6 digits because USDC + uint64 private constant A = (15 * SCALE_FACTOR) / 100; + uint64 private constant R = (85 * SCALE_FACTOR) / 100; + uint64 private constant I = 30 days; + bytes32 NAYM_ID = address(naymToken)._getIdForAddress(); + function initStaking(uint256 initDate) internal { + StakingConfig memory config = StakingConfig({ + tokenId: NAYM_ID, + initDate: initDate, + a: A, // Amplification factor + r: R, // Boost decay factor + divider: SCALE_FACTOR, + interval: I // Amount of time per interval in seconds + }); + + startPrank(sa); + nayms.initStaking(nlf.entityId, config); + vm.stopPrank(); + } + + uint256 internal stakeAmount = 1e18; + uint256 internal unstakeAmount = 1e18; + + function setUp() public { + naymToken.mint(bob.addr, stakeAmount); + + startPrank(sa); + nayms.addSupportedExternalToken(address(naymToken), 100); + + vm.startPrank(sm.addr); + hCreateEntity(bob.entityId, bob, entity, "Bob data"); + hCreateEntity(nlf.entityId, nlf, entity, "NLF"); + } + + function test_zapStake_Success() public { + initStaking(block.timestamp + 1 + 7 days); + + // Prepare permit data + uint256 deadline = block.timestamp; + + // Create permit digest + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + naymToken.DOMAIN_SEPARATOR(), + keccak256(abi.encode(naymToken.PERMIT_TYPEHASH(), bob.addr, address(nayms), stakeAmount, naymToken.nonces(owner), deadline)) + ) + ); + + // Sign the digest + (uint8 v, bytes32 r, bytes32 s) = vm.sign(bob.pk, digest); + + startPrank(bob); + + PermitSignature memory permitSignature = PermitSignature({ deadline: deadline, v: v, r: r, s: s }); + + vm.expectRevert("zapStake: invalid ERC20 token"); + nayms.zapStake(address(111), nlf.entityId, stakeAmount, stakeAmount, permitSignature); + + nayms.zapStake(address(naymToken), nlf.entityId, stakeAmount, stakeAmount, permitSignature); + + (uint256 staked, ) = nayms.getStakingAmounts(bob.entityId, nlf.entityId); + + assertEq(stakeAmount, staked, "bob's stake amount should increase"); + } + + function test_zapOrder_Success() public { + changePrank(sm.addr); + nayms.enableEntityTokenization(bob.entityId, "e1token", "e1token", 1e6); + + // Selling bob p tokens for weth + nayms.startTokenSale(bob.entityId, 1 ether, 1 ether); + + deal(address(weth), bob.addr, 10 ether); + + // Prepare permit data + uint256 deadline = block.timestamp; + bytes32 PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + uint256 nonce = weth.nonces(bob.addr); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, bob.addr, address(nayms), 10 ether, nonce, deadline)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", weth.DOMAIN_SEPARATOR(), structHash)); + // Sign the digest + (uint8 v, bytes32 r, bytes32 s) = vm.sign(bob.pk, digest); + PermitSignature memory permitSignature = PermitSignature({ deadline: deadline, v: v, r: r, s: s }); + + startPrank(bob); + + vm.expectRevert("zapOrder: invalid ERC20 token"); + nayms.zapOrder(address(111), 10 ether, wethId, 1 ether, bob.entityId, 1 ether, permitSignature); + + // Call zapOrder + // Caller should ensure they deposit enough to cover order fees. + nayms.zapOrder(address(weth), 10 ether, wethId, 1 ether, bob.entityId, 1 ether, permitSignature); + + assertEq(nayms.internalBalanceOf(bob.entityId, bob.entityId), 1 ether, "bob should've purchased 1e18 bob p tokens"); + } +} diff --git a/test/utils/DummyToken.sol b/test/utils/DummyToken.sol index 921d18e0..d1e0de1f 100644 --- a/test/utils/DummyToken.sol +++ b/test/utils/DummyToken.sol @@ -2,8 +2,11 @@ pragma solidity 0.8.20; import { IERC20 } from "src/interfaces/IERC20.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; contract DummyToken is IERC20 { + using ECDSA for bytes32; + string public name = "Dummy"; string public symbol = "DUM"; uint8 public decimals = 18; @@ -11,38 +14,80 @@ contract DummyToken is IERC20 { mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; - function transfer(address to, uint256 value) external returns (bool) { - if (value == 0) { - return false; + // EIP-2612 permit state variables + bytes32 public DOMAIN_SEPARATOR; + // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 public constant PERMIT_TYPEHASH = 0x6d47c92dbe9aa29a8e9e38d25f3f54ab645e5df690ddf0d3e2a24ec2445a44f0; + mapping(address => uint256) public nonces; + + constructor() { + uint256 chainId; + assembly { + chainId := chainid() } + DOMAIN_SEPARATOR = keccak256( + abi.encode( + // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") + 0x8b73e7bb5ba7313e92d4a46294e43b9c1bafabf1adbe7b6f4bdfd44c38a7e6d4, + keccak256(bytes(name)), + keccak256(bytes("1")), + chainId, + address(this) + ) + ); + } + function transfer(address to, uint256 value) external returns (bool) { require(balanceOf[msg.sender] >= value, "not enough balance"); balanceOf[msg.sender] -= value; balanceOf[to] += value; + emit Transfer(msg.sender, to, value); return true; } function approve(address spender, uint256 value) external returns (bool) { allowance[msg.sender][spender] = value; + emit Approval(msg.sender, spender, value); return true; } function transferFrom(address from, address to, uint256 value) external returns (bool) { - if (value == 0) { - revert(); - } - require(allowance[from][msg.sender] >= value, "not enough allowance"); require(balanceOf[from] >= value, "not enough balance"); + allowance[from][msg.sender] -= value; balanceOf[from] -= value; balanceOf[to] += value; + emit Transfer(from, to, value); return true; } function mint(address to, uint256 value) external { balanceOf[to] += value; totalSupply += value; + emit Transfer(address(0), to, value); } - function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external {} + /** + * @notice Approves tokens via signature, as per EIP-2612 + * @param owner The token owner's address + * @param spender The spender's address + * @param value The amount to approve + * @param deadline The deadline timestamp by which the permit must be used + * @param v The recovery byte of the signature + * @param r Half of the ECDSA signature pair + * @param s Half of the ECDSA signature pair + */ + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external override { + require(block.timestamp <= deadline, "permit: expired deadline"); + + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)); + + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); + + address recoveredAddress = digest.recover(v, r, s); + require(recoveredAddress == owner, "permit: invalid signature"); + + allowance[owner][spender] = value; + emit Approval(owner, spender, value); + } }