diff --git a/.openzeppelin/base-sepolia.json b/.openzeppelin/base-sepolia.json index c14ae97..464a6d7 100644 --- a/.openzeppelin/base-sepolia.json +++ b/.openzeppelin/base-sepolia.json @@ -19,6 +19,16 @@ "address": "0x63B32CB34101E6512Cfd2C8D67CBa95ff0EcF773", "txHash": "0xfdbe9702af38efd503005771795e1c4e6c6da456a3d9536a2988d84eca188daa", "kind": "transparent" + }, + { + "address": "0xB66306c45313739d438700f7F5b3064C578c9B6A", + "txHash": "0xf49fbfc0493b8a7a713add3b4c9d7c62c9817f47a299fb3c0e4738f27aedff72", + "kind": "transparent" + }, + { + "address": "0xAf2866c4E8d2Cc19feaa56Fb270ec16574e38FF2", + "txHash": "0x4382701e107bda3ec402bf5598cbdc9845ec840f91ef14df1d10649659ac139a", + "kind": "transparent" } ], "impls": { @@ -928,6 +938,361 @@ }, "namespaces": {} } + }, + "8509a0e744ca9badb5a71bc33cacc54f4c32974b983199cbd0f3a00040cb03b2": { + "address": "0xCd1993502930950208fDbC66f7BD55EA35a2E2Ed", + "txHash": "0xa5f477f7d7952c78026606a84b9d2de146ea17539266fe162d073e076e2454ed", + "layout": { + "solcVersion": "0.8.4", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:63", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:68" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:40" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "configRegistry", + "offset": 0, + "slot": "101", + "type": "t_address", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:17" + }, + { + "label": "counter", + "offset": 0, + "slot": "102", + "type": "t_uint256", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:18" + }, + { + "label": "stakings", + "offset": 0, + "slot": "103", + "type": "t_mapping(t_bytes32,t_address)", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:19" + }, + { + "label": "__gap", + "offset": 0, + "slot": "104", + "type": "t_array(t_uint256)5_storage", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:20" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_array(t_uint256)5_storage": { + "label": "uint256[5]", + "numberOfBytes": "160" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_bytes32": { + "label": "bytes32", + "numberOfBytes": "32" + }, + "t_mapping(t_bytes32,t_address)": { + "label": "mapping(bytes32 => address)", + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + }, + "namespaces": {} + } + }, + "16160616b39555837efffbb6f63c01f9faab92f799d26114607ff95e53acfe59": { + "address": "0x4b9Ab2501065E4a81A7Efd572f19736aA15BE71f", + "txHash": "0x7be7aba9da787c2fe538573a6b4ff67c909c584e81d5ef23edd4c2fcdc4bb774", + "layout": { + "solcVersion": "0.8.4", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:63", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:68" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:40" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "configRegistry", + "offset": 0, + "slot": "101", + "type": "t_address", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:17" + }, + { + "label": "counter", + "offset": 0, + "slot": "102", + "type": "t_uint256", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:18" + }, + { + "label": "stakings", + "offset": 0, + "slot": "103", + "type": "t_mapping(t_bytes32,t_address)", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:19" + }, + { + "label": "__gap", + "offset": 0, + "slot": "104", + "type": "t_array(t_uint256)5_storage", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:20" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_array(t_uint256)5_storage": { + "label": "uint256[5]", + "numberOfBytes": "160" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_bytes32": { + "label": "bytes32", + "numberOfBytes": "32" + }, + "t_mapping(t_bytes32,t_address)": { + "label": "mapping(bytes32 => address)", + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + }, + "namespaces": {} + } + }, + "76ccb2f878153a2fd51702372208778122d5ed4f2aefe78586e7d27aea42323b": { + "address": "0xfFab21d276aC4eE682dB3e997e85025e256b0a5b", + "txHash": "0x48dde6e96c9a130b958268bc99a408f49e2a29752c45bd9e4aeeb9be1225dc5c", + "layout": { + "solcVersion": "0.8.4", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:63", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:68" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:40" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "configRegistry", + "offset": 0, + "slot": "101", + "type": "t_address", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:17" + }, + { + "label": "stakings", + "offset": 0, + "slot": "102", + "type": "t_mapping(t_bytes32,t_address)", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:18" + }, + { + "label": "__gap", + "offset": 0, + "slot": "103", + "type": "t_array(t_uint256)5_storage", + "contract": "LemonadeStakePayment", + "src": "contracts/payment/stake/LemonadeStakePayment.sol:19" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_array(t_uint256)5_storage": { + "label": "uint256[5]", + "numberOfBytes": "160" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_bytes32": { + "label": "bytes32", + "numberOfBytes": "32" + }, + "t_mapping(t_bytes32,t_address)": { + "label": "mapping(bytes32 => address)", + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + }, + "namespaces": {} + } } } } diff --git a/contracts/payment/stake/LemonadeStakePayment.sol b/contracts/payment/stake/LemonadeStakePayment.sol index 5f9d188..9e3c976 100644 --- a/contracts/payment/stake/LemonadeStakePayment.sol +++ b/contracts/payment/stake/LemonadeStakePayment.sol @@ -3,49 +3,27 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "hardhat/console.sol"; +import "../../utils/Data.sol"; import "../PaymentConfigRegistry.sol"; +import "./StakeVault.sol"; bytes32 constant STAKE_REFUND = keccak256(abi.encode("STAKE_REFUND")); bytes32 constant STAKE_SLASH = keccak256(abi.encode("STAKE_SLASH")); -contract LemonadeStakePayment is OwnableUpgradeable { - error NotAvailable(); - error InvalidData(); - error AlreadyStaked(); - error CannotPayFee(); - error CannotRelease(); - error CannotStake(); - - event VaultRegistered(uint256 id); - - struct StakeConfig { - address owner; - address vault; - uint256 refundPPM; - } - - struct Staking { - uint256 configId; - address guest; - address currency; - uint256 amount; - uint256 stakeAmount; - uint256 refundAmount; - bool slashed; - bool refunded; - } - +contract LemonadeStakePayment is OwnableUpgradeable, Transferable { + //-- STORAGE address public configRegistry; - uint256 public counter; - address[] currencies; //-- all the currency ever staked - uint256[5] _gap; - - mapping(address => uint256) currencyIndex; - mapping(bytes32 => Staking) stakings; - mapping(uint256 => StakeConfig) public configs; + mapping(bytes32 => address) public stakings; //-- key is payment id uint256[5] __gap; + //-- ERRORS + error InvalidData(); + + //-- EVENTS + event VaultRegistered(address vault); + function initialize(address registry) public initializer { __Ownable_init(); configRegistry = registry; @@ -55,33 +33,33 @@ contract LemonadeStakePayment is OwnableUpgradeable { configRegistry = registry; } - function register(address vault, uint256 refundPPM) external { - if (refundPPM > 1000000 || refundPPM == 0) { - revert InvalidData(); - } - + function register(address payout, uint256 refundPPM) external { address owner = _msgSender(); - StakeConfig memory config = StakeConfig(owner, vault, refundPPM); - counter += 1; - configs[counter] = config; + StakeVault vault = new StakeVault( + owner, + payout, + address(this), + refundPPM + ); - emit VaultRegistered(counter); + emit VaultRegistered(address(vault)); } function stake( - uint256 configId, - string memory eventId, - string memory paymentId, + address vault, + string calldata eventId, + string calldata paymentId, address currency, uint256 amount ) external payable { if (amount == 0) { revert InvalidData(); } - bytes32 id = _toId(paymentId); - if (stakings[id].amount > 0) { + bytes32 stakeId = stringToId(paymentId); + + if (stakings[stakeId] != address(0)) { revert AlreadyStaked(); } @@ -95,86 +73,109 @@ contract LemonadeStakePayment is OwnableUpgradeable { payable(configRegistry) ); - StakeConfig storage config = configs[configId]; - uint256 stakeAmount = (amount * 1000000) / (registry.feePPM() + 1000000); uint256 feeAmount = amount - stakeAmount; - uint256 refundAmount = (stakeAmount * config.refundPPM) / 1000000; + + //-- transfer fee and notify + _transfer(configRegistry, currency, feeAmount); + registry.notifyFee(eventId, currency, feeAmount); address guest = _msgSender(); - if (isNative) { - (bool success, ) = payable(configRegistry).call{value: feeAmount}( - "" - ); + _stake(stakeId, guest, vault, stakeAmount, currency); + } - if (!success) revert CannotPayFee(); - } else { - bool success = IERC20(currency).transferFrom( - guest, - configRegistry, - feeAmount - ); + function _stake( + bytes32 stakeId, + address guest, + address vault, + uint256 stakeAmount, + address currency + ) internal { + bool isNative = currency == address(0); - if (!success) revert CannotPayFee(); + StakeVault stakeVault = StakeVault(payable(vault)); - success = IERC20(currency).transferFrom( + if (!isNative) { + //-- first transfer the ERC20 to the contract + bool success = IERC20(currency).transferFrom( guest, address(this), stakeAmount ); - if (!success) revert CannotStake(); - } + if (!success) { + revert CannotTransfer(); + } - if (currencyIndex[currency] == 0) { - uint256 index = currencies.length + 1; - currencies.push(currency); - currencyIndex[currency] = index; + //-- then allow the vault to transfer the amount to itself + IERC20(currency).approve(vault, stakeAmount); } - stakings[id] = Staking( - configId, - guest, + stakeVault.stake{value: isNative ? stakeAmount : 0}( + stakeId, currency, - amount, stakeAmount, - refundAmount, - false, - false + guest ); - registry.notifyFee(eventId, currency, feeAmount); + stakings[stakeId] = vault; } function getStakings( - string[] memory ids + string[] calldata ids ) public view returns (Staking[] memory result) { uint256 length = ids.length; result = new Staking[](length); for (uint256 i = 0; i < length; ) { - bytes32 id = _toId(ids[i]); - result[i] = stakings[id]; + bytes32 stakeId = stringToId(ids[i]); + address vault = stakings[stakeId]; + + if (vault == address(0)) { + result[i] = Staking(address(0), address(0), 0, 0, false, false); + } else { + StakeVault stakeVault = StakeVault(payable(vault)); + + ( + address guest, + address currency, + uint256 stakeAmount, + uint256 refundAmount, + bool slashed, + bool refunded + ) = stakeVault.stakings(stakeId); + + result[i] = Staking( + guest, + currency, + stakeAmount, + refundAmount, + slashed, + refunded + ); + } unchecked { ++i; } } + + return result; } function refund( string calldata paymentId, bytes calldata signature ) external { - bytes32 id = _toId(paymentId); + bytes32 stakeId = stringToId(paymentId); - Staking storage staking = stakings[id]; + address vault = stakings[stakeId]; - if (staking.slashed || staking.refunded) { - revert NotAvailable(); + if (vault == address(0)) { + revert NoStaking(); } //-- verify signature @@ -185,102 +186,47 @@ contract LemonadeStakePayment is OwnableUpgradeable { bytes32[] memory data = new bytes32[](2); data[0] = STAKE_REFUND; - data[1] = id; + data[1] = stakeId; registry.assertSignature(data, signature); - //-- let's refund - staking.refunded = true; + StakeVault stakeVault = StakeVault(payable(vault)); - _release(staking.guest, staking.currency, staking.refundAmount); + stakeVault.refund(stakeId); } function slash( - uint256 configId, + address vault, string[] memory paymentIds, bytes memory signature ) external { uint256 idsLength = paymentIds.length; - StakeConfig storage config = configs[configId]; - PaymentConfigRegistry registry = PaymentConfigRegistry( payable(configRegistry) ); - uint256 currenciesLength = currencies.length; - uint256[] memory slashes = new uint256[](currenciesLength); - //-- collect data to verify the signature bytes32[] memory data = new bytes32[](paymentIds.length + 1); + bytes32[] memory stakeIds = new bytes32[](paymentIds.length); data[0] = STAKE_SLASH; for (uint256 i = 0; i < idsLength; ) { - bytes32 id = _toId(paymentIds[i]); + bytes32 stakeId = stringToId(paymentIds[i]); - Staking storage staking = stakings[id]; - - if (staking.configId != configId) { - revert InvalidData(); - } - - if (staking.slashed || staking.refunded) { - revert NotAvailable(); - } - - //-- add to slash sum - uint256 index = currencyIndex[staking.currency] - 1; - slashes[index] += staking.stakeAmount; - - //-- update the staking - staking.slashed = true; + stakeIds[i] = stakeId; unchecked { ++i; } - data[i] = id; + data[i] = stakeId; } registry.assertSignature(data, signature); - //-- release - - for (uint256 i = 0; i < currenciesLength; ) { - address currency = currencies[i]; - uint256 amount = slashes[currencyIndex[currency] - 1]; - - if (amount > 0) { - _release(config.vault, currency, amount); - } - - unchecked { - ++i; - } - } - } - - function _release( - address destination, - address currency, - uint256 amount - ) internal { - if (currency == address(0)) { - (bool success, ) = payable(destination).call{value: amount}(""); - - if (!success) revert CannotRelease(); - } else { - bool success = IERC20(currency).transferFrom( - address(this), - destination, - amount - ); - - if (!success) revert CannotRelease(); - } - } + StakeVault stakeVault = StakeVault(payable(vault)); - function _toId(string memory id) internal pure returns (bytes32) { - return keccak256(abi.encode(id)); + stakeVault.slash(stakeIds); } } diff --git a/contracts/payment/stake/StakeVault.sol b/contracts/payment/stake/StakeVault.sol new file mode 100644 index 0000000..553eeeb --- /dev/null +++ b/contracts/payment/stake/StakeVault.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../../utils/Vault.sol"; + +struct Staking { + address guest; + address currency; + uint256 stakeAmount; + uint256 refundAmount; + bool slashed; + bool refunded; +} + +error NoStaking(); +error AlreadyStaked(); +error FundsNotAvailable(); + +bytes32 constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + +contract StakeVault is Vault { + //-- STORAGE + address public payoutAddress; + uint256 public refundPPM; //-- key is setting id and value is refund PPM value + mapping(bytes32 => Staking) public stakings; //-- key is stake id and value is staking info + + bytes32[] stakingIds; + address[] currencies; //-- all the currency ever staked + mapping(address => uint256) currencyIndex; + + uint256[5] _gap; + + //-- ERRORS + error AccessDenied(); + error InvalidData(); + + constructor(address owner, address payout, address operator, uint256 ppm) { + _grantRole(DEFAULT_ADMIN_ROLE, owner); + _grantRole(OPERATOR_ROLE, operator); + + _setRefundPPM(ppm); + payoutAddress = payout; + } + + function setPayoutAddress( + address payout + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + payoutAddress = payout; + } + + function setRefundPPM(uint256 ppm) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setRefundPPM(ppm); + } + + function stake( + bytes32 stakeId, + address currency, + uint256 stakeAmount, + address guest + ) external payable onlyRole(OPERATOR_ROLE) { + address sender = _msgSender(); + + if (guest == address(0)) { + revert InvalidData(); + } + + if (stakings[stakeId].guest != address(0)) { + revert AlreadyStaked(); + } + + bool isNative = currency == address(0); + + //-- transfer the amount from caller to vault + if (isNative) { + if (msg.value != stakeAmount) { + revert InvalidData(); + } + } else { + bool success = IERC20(currency).transferFrom( + sender, + address(this), + stakeAmount + ); + + if (!success) { + revert CannotTransfer(); + } + } + + if (currencyIndex[currency] == 0) { + uint256 index = currencies.length + 1; + currencies.push(currency); + currencyIndex[currency] = index; + } + + uint256 refundAmount = (stakeAmount * refundPPM) / 1000000; + + Staking memory staking = Staking( + guest, + currency, + stakeAmount, + refundAmount, + false, + false + ); + + stakingIds.push(stakeId); + stakings[stakeId] = staking; + } + + function refund(bytes32 stakeId) external onlyRole(OPERATOR_ROLE) { + Staking storage staking = stakings[stakeId]; + + if (staking.guest == address(0)) { + revert NoStaking(); + } + + staking.refunded = true; + + _transfer(staking.guest, staking.currency, staking.refundAmount); + } + + function slash( + bytes32[] calldata stakeIds + ) external onlyRole(OPERATOR_ROLE) { + uint256 idsLength = stakeIds.length; + + uint256 currenciesLength = currencies.length; + uint256[] memory slashes = new uint256[](currenciesLength); + + for (uint256 i = 0; i < idsLength; ) { + bytes32 stakeId = stakeIds[i]; + + Staking storage staking = stakings[stakeId]; + + if (staking.guest == address(0)) continue; + + if (staking.slashed || staking.refunded) { + revert FundsNotAvailable(); + } + + //-- add to slash sum + uint256 index = currencyIndex[staking.currency] - 1; + slashes[index] += staking.stakeAmount; + + //-- update the staking + staking.slashed = true; + + unchecked { + ++i; + } + } + + //-- release + for (uint256 i = 0; i < currenciesLength; ) { + address currency = currencies[i]; + uint256 amount = slashes[currencyIndex[currency] - 1]; + + if (amount > 0) { + _transfer(payoutAddress, currency, amount); + } + + unchecked { + ++i; + } + } + } + + function _setRefundPPM(uint256 ppm) internal { + if (ppm > 1000000 || ppm == 0) { + revert InvalidData(); + } + + refundPPM = ppm; + } +} diff --git a/contracts/utils/Data.sol b/contracts/utils/Data.sol new file mode 100644 index 0000000..5ac8964 --- /dev/null +++ b/contracts/utils/Data.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +function stringToId(string memory id) pure returns (bytes32) { + return keccak256(abi.encode(id)); +} diff --git a/contracts/utils/Transferable.sol b/contracts/utils/Transferable.sol new file mode 100644 index 0000000..5bd02fc --- /dev/null +++ b/contracts/utils/Transferable.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +abstract contract Transferable { + error CannotTransfer(); + + function _transfer( + address destination, + address currency, + uint256 amount + ) internal { + if (currency == address(0)) { + (bool success, ) = payable(destination).call{value: amount}(""); + + if (!success) revert CannotTransfer(); + } else { + bool success = IERC20(currency).transferFrom( + address(this), + destination, + amount + ); + + if (!success) revert CannotTransfer(); + } + } +} diff --git a/contracts/utils/Vault.sol b/contracts/utils/Vault.sol new file mode 100644 index 0000000..36edfcc --- /dev/null +++ b/contracts/utils/Vault.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "./Transferable.sol"; + +abstract contract Vault is AccessControl, Transferable { + function withdraw( + address destination, + address currency, + uint256 amount + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + _transfer(destination, currency, amount); + } + + receive() external payable {} +} diff --git a/test/LemonadeStakePaymentV1.ts b/test/LemonadeStakePaymentV1.ts index 240e4a4..c9ac0f4 100644 --- a/test/LemonadeStakePaymentV1.ts +++ b/test/LemonadeStakePaymentV1.ts @@ -38,7 +38,7 @@ const deployStake = async (signer: SignerWithAddress) => { return { configRegistry, stakePayment }; } -const register = async (ppm: number) => { +const register = async (ppm: bigint) => { const [signer, signer2] = await ethers.getSigners(); const { configRegistry, stakePayment } = await deployStake(signer); @@ -60,15 +60,15 @@ const register = async (ppm: number) => { }) .find(event => event?.name === 'VaultRegistered'); - const id = event?.args[0] as bigint | undefined; + const vault = event?.args[0] as string; - assert.ok(id); + assert.ok(vault); - return { configRegistry, stakePayment, id, signer, signer2 }; + return { configRegistry, stakePayment, vault, signer, signer2 }; } const stake = async ( - id: bigint, + vault: string, configRegistry: Contract, stakePayment: Contract, paymentId: string, @@ -89,7 +89,7 @@ const stake = async ( ); const response: ContractTransactionResponse = await stakePayment.connect(signer2).stake( - id, + vault, eventId, paymentId, currency, @@ -101,7 +101,7 @@ const stake = async ( const feeInfo = await feeCollected; - return { receipt, feeInfo, total, feePPM, eventId, paymentId, currency, amount, address: signer2.address }; + return { receipt, feeInfo, total, feePPM, eventId, paymentId, currency, amount, guest: signer2.address }; } const createSignature = (signer: SignerWithAddress, type: string, paymentIds: string[]) => { @@ -120,43 +120,44 @@ const createSignature = (signer: SignerWithAddress, type: string, paymentIds: st describe('LemonadeRelayPaymentV1', () => { it('should allow register config', async () => { - const ppm = 800000; - const { id, stakePayment } = await register(ppm); + const ppm = 800000n; + const { vault } = await register(ppm); - const config = await stakePayment.configs(id); + const stakeVault = await ethers.getContractAt("StakeVault", vault); - assert.ok(config[2] === BigInt(ppm)); + const refundPPM = await stakeVault.refundPPM(); + + assert.strictEqual(refundPPM, ppm); }); it('should accept stake', async () => { const ppm = 900000; - const { id, stakePayment, configRegistry } = await register(ppm); + const { vault, stakePayment, configRegistry } = await register(ppm); - const { paymentId, address, amount, total } = await stake(id, configRegistry, stakePayment, "1"); + const { paymentId, guest, amount } = await stake(vault, configRegistry, stakePayment, "1"); const [stakeInfo] = await stakePayment.getStakings([paymentId]); - assert.strictEqual(stakeInfo[0], id); - assert.strictEqual(stakeInfo[1], address); - assert.strictEqual(stakeInfo[2], ethers.ZeroAddress); - assert.strictEqual(stakeInfo[3], BigInt(total)); - assert.strictEqual(stakeInfo[4], BigInt(amount)); + assert.strictEqual(stakeInfo[0], guest); + assert.strictEqual(stakeInfo[1], ethers.ZeroAddress); + assert.strictEqual(stakeInfo[2], BigInt(amount)); + assert.strictEqual(stakeInfo[3], BigInt(amount * ppm / 1000000)); }); it('should throw for already stake payment', async () => { const percent = 90; - const { id, stakePayment, configRegistry } = await register(percent); + const { vault, stakePayment, configRegistry } = await register(percent); - await stake(id, configRegistry, stakePayment, "1"); - await assert.rejects(stake(id, configRegistry, stakePayment, "1")); + await stake(vault, configRegistry, stakePayment, "1"); + await assert.rejects(stake(vault, configRegistry, stakePayment, "1")); }); it('should refund correctly', async () => { const ppm = 900000; const [_, signer2] = await ethers.getSigners() - const { id, stakePayment, configRegistry, signer } = await register(ppm); + const { vault, stakePayment, configRegistry, signer } = await register(ppm); - const { paymentId, amount } = await stake(id, configRegistry, stakePayment, "3"); + const { paymentId, amount } = await stake(vault, configRegistry, stakePayment, "3"); const expectedRefund = BigInt(amount * ppm / 1000000); @@ -180,9 +181,9 @@ describe('LemonadeRelayPaymentV1', () => { it('should not refund twice', async () => { const ppm = 900000; const [_, signer2] = await ethers.getSigners() - const { id, stakePayment, configRegistry, signer } = await register(ppm); + const { vault, stakePayment, configRegistry, signer } = await register(ppm); - const { paymentId } = await stake(id, configRegistry, stakePayment, "3"); + const { paymentId } = await stake(vault, configRegistry, stakePayment, "3"); //-- const generate refund signature const signature = await createSignature(signer, "STAKE_REFUND", [paymentId]); @@ -193,10 +194,10 @@ describe('LemonadeRelayPaymentV1', () => { it('should slash multiple payments', async () => { const ppm = 900000; - const { id, stakePayment, configRegistry, signer, signer2 } = await register(ppm); + const { vault, stakePayment, configRegistry, signer, signer2 } = await register(ppm); - const stake1 = await stake(id, configRegistry, stakePayment, "5"); - const stake2 = await stake(id, configRegistry, stakePayment, "6"); + const stake1 = await stake(vault, configRegistry, stakePayment, "5"); + const stake2 = await stake(vault, configRegistry, stakePayment, "6"); const expectedRefund = BigInt(stake1.amount + stake2.amount); @@ -206,7 +207,7 @@ describe('LemonadeRelayPaymentV1', () => { const balanceBefore = await ethers.provider.getBalance(signer2.address); const response: ContractTransactionResponse = await stakePayment.connect(signer).slash( - id, + vault, [stake1.paymentId, stake2.paymentId], signature, ); @@ -222,20 +223,20 @@ describe('LemonadeRelayPaymentV1', () => { it('should not slash twice', async () => { const ppm = 900000; - const { id, stakePayment, configRegistry, signer } = await register(ppm); + const { vault, stakePayment, configRegistry, signer } = await register(ppm); - const { paymentId } = await stake(id, configRegistry, stakePayment, "5"); + const { paymentId } = await stake(vault, configRegistry, stakePayment, "5"); const signature = await createSignature(signer, "STAKE_SLASH", [paymentId]); await stakePayment.connect(signer).slash( - id, + vault, [paymentId], signature, ); await assert.rejects(stakePayment.connect(signer).slash( - id, + vault, [paymentId], signature, ));