Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
28 changes: 26 additions & 2 deletions protocol-contracts/staking/contracts/OperatorStaking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

pragma solidity ^0.8.27;

import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
import {ERC1363Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC1363Upgradeable.sol";
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {ERC4626, IERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
Expand Down Expand Up @@ -141,6 +142,29 @@ contract OperatorStaking is ERC1363Upgradeable, ReentrancyGuardTransient, UUPSUp
return shares;
}

/**
* @notice Deposit assets and receive shares with ERC-20 Permit extension (ERC-2612).
* @param assets Amount of assets to deposit.
* @param receiver Address to receive the minted shares.
* @param deadline Timestamp in the future until which the permit is valid.
* @param v `secp256k1` signature parameter.
* @param r `secp256k1` signature parameter.
* @param s `secp256k1` signature parameter.
* @return shares Amount of shares minted.
*/
function depositWithPermit(
uint256 assets,
address receiver,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public virtual returns (uint256) {
IERC20Permit(asset()).permit(msg.sender, address(this), assets, deadline, v, r, s);

return deposit(assets, receiver);
}

/**
* @notice Request to redeem shares for assets, subject to cooldown.
* @param shares Amount of shares to redeem.
Expand Down
5 changes: 3 additions & 2 deletions protocol-contracts/staking/contracts/mocks/ERC20Mock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
pragma solidity ^0.8.20;

import {ERC20, ERC1363} from "@openzeppelin/contracts/token/ERC20/extensions/ERC1363.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

contract ERC20Mock is ERC1363 {
contract ERC20Mock is ERC1363, ERC20Permit {
uint8 private immutable _decimals;

constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) {
constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) ERC20Permit(name_) {
_decimals = decimals_;
}

Expand Down
120 changes: 119 additions & 1 deletion protocol-contracts/staking/test/OperatorStaking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,25 @@ describe('OperatorStaking', function () {
]),
);

// Mint tokens and approve mock contract
await Promise.all(
[delegator1, delegator2].flatMap(account => [
token.mint(account, ethers.parseEther('1000')),
token.$_approve(account, mock, ethers.MaxUint256),
]),
);

Object.assign(this, { delegator1, delegator2, admin, beneficiary, anyone, accounts, token, protocolStaking, mock });
Object.assign(this, {
delegator1,
delegator2,
admin,
beneficiary,
anyone,
accounts,
token,
protocolStaking,
mock,
});
});

describe('Access Control', function () {
Expand Down Expand Up @@ -89,6 +100,113 @@ describe('OperatorStaking', function () {
});
});

describe('depositWithPermit', async function () {
beforeEach(async function () {
// Define delegator, mint tokens but don't approve mock contract
const delegatorNoApproval = this.accounts[0];
await Promise.all(
[delegatorNoApproval].flatMap(account => [this.token.mint(account, ethers.parseEther('1000'))]),
);

// Get deposit with permit inputs
const owner = delegatorNoApproval.address;
const spender = this.mock.target;
const value = ethers.parseEther('1');
const deadline = ethers.MaxUint256;

// Get permit parameters from the token
const tokenAddress = this.token.target;
const nonce = await this.token.nonces(owner);
const name = await this.token.name();

// Get chain ID
const { chainId } = await ethers.provider.getNetwork();

// Define EIP-712 domain
const domain = {
name: name,
version: '1',
chainId: chainId,
verifyingContract: tokenAddress,
};

// Define EIP-712 Permit type
const types = {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};

// Define EIP-712 Permit message
const message = {
owner: owner,
spender: spender,
value: value,
nonce: nonce,
deadline: deadline,
};

// Sign EIP-712 Permit message
const flatSig = await delegatorNoApproval.signTypedData(domain, types, message);

// Split into v, r, s
const sig = ethers.Signature.from(flatSig);

// Deposit with permit
const depositWithPermitTx = this.mock
.connect(delegatorNoApproval)
.depositWithPermit(value, delegatorNoApproval, deadline, sig.v, sig.r, sig.s);

Object.assign(this, {
depositWithPermitTx,
permitValue: value,
delegatorNoApproval: delegatorNoApproval,
permitDeadline: deadline,
v: sig.v,
r: sig.r,
s: sig.s,
});
});

it('should stake into protocol staking with permit', async function () {
await expect(this.depositWithPermitTx)
.to.emit(this.token, 'Transfer')
.withArgs(this.mock, this.protocolStaking, this.permitValue);
});

it('should mint shares with permit', async function () {
await expect(this.depositWithPermitTx)
.to.emit(this.mock, 'Transfer')
.withArgs(ethers.ZeroAddress, this.delegatorNoApproval, this.permitValue);
});

it('should pull tokens with permit', async function () {
await expect(this.depositWithPermitTx)
.to.emit(this.token, 'Transfer')
.withArgs(this.delegatorNoApproval, this.mock, this.permitValue);
});

it('should revert if signature is invalid', async function () {
await expect(
this.mock
.connect(this.delegatorNoApproval)
.depositWithPermit(this.permitValue, this.delegatorNoApproval, this.permitDeadline, 0, this.r, this.s),
).to.be.revertedWithCustomError(this.token, 'ECDSAInvalidSignature');
});

it('should revert if signer is invalid', async function () {
await expect(
this.mock
.connect(this.delegator1)
.depositWithPermit(this.permitValue, this.delegator1, this.permitDeadline, this.v, this.r, this.s),
).to.be.revertedWithCustomError(this.token, 'ERC2612InvalidSigner');
});
});

describe('redeem', async function () {
it('simple redemption', async function () {
await this.mock.connect(this.delegator1).deposit(ethers.parseEther('1'), this.delegator1);
Expand Down