diff --git a/source/delegate/LICENSE b/source/delegate/LICENSE new file mode 100644 index 000000000..f963da81e --- /dev/null +++ b/source/delegate/LICENSE @@ -0,0 +1,19 @@ +Copyright 2024 AirSwap + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/source/delegate/README.md b/source/delegate/README.md new file mode 100644 index 000000000..ed1a9091b --- /dev/null +++ b/source/delegate/README.md @@ -0,0 +1,37 @@ +# Delegate + +[AirSwap](https://www.airswap.io/) is an open-source peer-to-peer trading network. + +[![Discord](https://img.shields.io/discord/590643190281928738.svg)](https://discord.gg/ecQbV7H) +[![License](https://img.shields.io/badge/License-MIT-blue)](https://opensource.org/licenses/MIT) +![Twitter Follow](https://img.shields.io/twitter/follow/airswap?style=social) + +## Resources + +- About → https://about.airswap.io/ +- Website → https://www.airswap.io/ +- Twitter → https://twitter.com/airswap +- Chat → https://chat.airswap.io/ + +## Usage + +:warning: This package may contain unaudited code. For all AirSwap contract deployments see [Deployed Contracts](https://about.airswap.io/technology/deployments). + +## Commands + +Environment variables are set in an `.env` file in the repository root. + +| Command | Description | +| :-------------- | :--------------------------------------- | +| `yarn` | Install dependencies | +| `yarn clean` | Delete the contract `build` folder | +| `yarn compile` | Compile all contracts to `build` folder | +| `yarn coverage` | Report test coverage | +| `yarn test` | Run all tests in `test` folder | +| `yarn test:ci` | Run CI tests in `test` folder | +| `yarn deploy` | Deploy on a network using --network flag | +| `yarn verify` | Verify on a network using --network flag | + +## Running Tests + +:bulb: Prior to testing locally, run `yarn compile` in the `airswap-protocols` project root to build required artifacts. diff --git a/source/delegate/contracts/Delegate.sol b/source/delegate/contracts/Delegate.sol new file mode 100644 index 000000000..c005d7691 --- /dev/null +++ b/source/delegate/contracts/Delegate.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.23; + +import "./interfaces/IDelegate.sol"; +import "@airswap/swap-erc20/contracts/interfaces/ISwapERC20.sol"; +import { ERC20 } from "solady/src/tokens/ERC20.sol"; +import { Ownable } from "solady/src/auth/Ownable.sol"; +import { SafeTransferLib } from "solady/src/utils/SafeTransferLib.sol"; + +/** + * @title Delegate: Deployable Trading Rules for the AirSwap Network + * @notice Supports fungible tokens (ERC-20) + * @dev inherits IDelegate, Ownable and uses SafeTransferLib + */ +contract Delegate is IDelegate, Ownable { + // The Swap contract to be used to settle trades + ISwapERC20 public swapERC20; + + // Mapping of sender to senderToken to to signerToken to Rule + mapping(address => mapping(address => mapping(address => Rule))) public rules; + + // Mapping of signer to authorized signatory + mapping(address => address) public authorized; + + /** + * @notice Contract Constructor + */ + constructor(ISwapERC20 _swapERC20) { + _initializeOwner(msg.sender); + swapERC20 = _swapERC20; + } + + /** + * @notice Set a Trading Rule + * @param _senderToken address Address of an ERC-20 token the consumer would send + * @param _senderAmount uint256 Maximum amount of ERC-20 token the sender wants to swap + * @param _signerToken address Address of an ERC-20 token the delegate would recieve + * @param _signerAmount uint256 Minimum amount of ERC-20 token the delegate would recieve + */ + function setRule( + address _senderWallet, + address _senderToken, + uint256 _senderAmount, + address _signerToken, + uint256 _signerAmount + ) external { + if (authorized[_senderWallet] != address(0)) { + if (authorized[_senderWallet] != msg.sender) revert SenderInvalid(); + } else { + if (_senderWallet != msg.sender) revert SenderInvalid(); + } + + rules[_senderWallet][_senderToken][_signerToken] = Rule( + _senderWallet, + _senderToken, + _senderAmount, + _signerToken, + _signerAmount + ); + + emit SetRule( + _senderWallet, + _senderToken, + _senderAmount, + _signerToken, + _signerAmount + ); + } + + /** + * @notice Unset a Trading Rule + * @dev only callable by the owner of the contract, removes from a mapping + * @param _senderToken address Address of an ERC-20 token the sender would send + * @param _signerToken address Address of an ERC-20 token the delegate would receive + */ + function unsetRule( + address _senderWallet, + address _senderToken, + address _signerToken + ) external { + if (authorized[_senderWallet] != address(0)) { + if (authorized[_senderWallet] != msg.sender) revert SenderInvalid(); + } else { + if (_senderWallet != msg.sender) revert SenderInvalid(); + } + Rule storage rule = rules[_senderWallet][_senderToken][_signerToken]; + delete rules[_senderWallet][_senderToken][_signerToken]; + + emit UnsetRule(_senderWallet, _senderToken, _signerToken); + } + + function swap( + address _senderWallet, + uint256 _nonce, + uint256 _expiry, + address _signerWallet, + address _signerToken, + uint256 _signerAmount, + address _senderToken, + uint256 _senderAmount, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external { + Rule storage rule = rules[_senderWallet][_senderToken][_signerToken]; + + if ( + _senderAmount > (_signerAmount * rule.senderAmount) / rule.signerAmount + ) { + revert InvalidSignerAmount(); + } + + if (rule.senderAmount < _senderAmount) { + revert InvalidSenderAmount(); + } + + SafeTransferLib.safeTransferFrom( + _senderToken, + _senderWallet, + address(this), + _senderAmount + ); + + ERC20(_senderToken).approve(address(swapERC20), _senderAmount); + + swapERC20.swapLight( + _nonce, + _expiry, + _signerWallet, + _signerToken, + _signerAmount, + _senderToken, + _senderAmount, + _v, + _r, + _s + ); + + SafeTransferLib.safeTransfer(_signerToken, _senderWallet, _signerAmount); + + rules[_senderWallet][_senderToken][_signerToken] + .senderAmount -= _senderAmount; + emit DelegateSwap(_nonce, _signerWallet); + } + + /** + * @notice Authorize a wallet to manage rules + * @param _manager address Wallet of the signatory to authorize + * @dev Emits an Authorize event + */ + function authorize(address _manager) external { + if (_manager == address(0)) revert ManagerInvalid(); + authorized[msg.sender] = _manager; + emit Authorize(_manager, msg.sender); + } + + /** + * @notice Revoke a manager + * @dev Emits a Revoke event + */ + function revoke() external { + address _tmp = authorized[msg.sender]; + delete authorized[msg.sender]; + emit Revoke(_tmp, msg.sender); + } + + /** + * @notice Sets the SwapERC20 contract + * @param _swapERC20 ISwapERC20 The SwapERC20 contract + */ + function setSwapERC20Contract(ISwapERC20 _swapERC20) external onlyOwner { + if (address(_swapERC20) == address(0)) revert InvalidAddress(); + swapERC20 = _swapERC20; + } +} diff --git a/source/delegate/contracts/interfaces/IDelegate.sol b/source/delegate/contracts/interfaces/IDelegate.sol new file mode 100644 index 000000000..825ff2690 --- /dev/null +++ b/source/delegate/contracts/interfaces/IDelegate.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT + +import "@airswap/swap-erc20/contracts/interfaces/ISwapERC20.sol"; + +pragma solidity 0.8.23; + +interface IDelegate { + struct Rule { + address sender; + address senderToken; + uint256 senderAmount; + address signerToken; + uint256 signerAmount; + } + + error InvalidAddress(); + error InvalidSenderAmount(); + error InvalidSignerAmount(); + error ManagerInvalid(); + error SenderInvalid(); + error TransferFromFailed(); + + event Authorize(address _signatory, address _signer); + event DelegateSwap(uint256 _nonce, address _signerWallet); + event Revoke(address _tmp, address _signer); + + event SetRule( + address _senderWallet, + address _senderToken, + uint256 _senderAmount, + address _signerToken, + uint256 _signerAmount + ); + + event UnsetRule(address _signer, address _signerToken, address _senderToken); + + function setRule( + address _sender, + address _senderToken, + uint256 _senderAmount, + address _signerToken, + uint256 _signerAmount + ) external; + + function swap( + address _senderWallet, + uint256 _nonce, + uint256 _expiry, + address _signerWallet, + address _signerToken, + uint256 _signerAmount, + address _senderToken, + uint256 _senderAmount, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external; + + function unsetRule( + address _sender, + address _signerToken, + address _senderToken + ) external; +} diff --git a/source/delegate/deploys-blocks.js b/source/delegate/deploys-blocks.js new file mode 100644 index 000000000..f69b9ec49 --- /dev/null +++ b/source/delegate/deploys-blocks.js @@ -0,0 +1,3 @@ +module.exports = { + 11155111: 5860563, +} diff --git a/source/delegate/deploys.js b/source/delegate/deploys.js new file mode 100644 index 000000000..f0c7d6a1a --- /dev/null +++ b/source/delegate/deploys.js @@ -0,0 +1,3 @@ +module.exports = { + 11155111: '0x4f4F5517Fd344A4abe84C91D8F80111Fb821B531', +} diff --git a/source/delegate/deploys.js.d.ts b/source/delegate/deploys.js.d.ts new file mode 100644 index 000000000..dff926ba4 --- /dev/null +++ b/source/delegate/deploys.js.d.ts @@ -0,0 +1,2 @@ +declare module '@airswap/delegate/deploys.js' +declare module '@airswap/delegate/deploys-blocks.js' diff --git a/source/delegate/hardhat.config.js b/source/delegate/hardhat.config.js new file mode 100644 index 000000000..4b553c55b --- /dev/null +++ b/source/delegate/hardhat.config.js @@ -0,0 +1,6 @@ +module.exports = { + typechain: { + outDir: 'typechain', + }, + ...require('../../hardhat.config.js'), +} diff --git a/source/delegate/package.json b/source/delegate/package.json new file mode 100644 index 000000000..5ca45656f --- /dev/null +++ b/source/delegate/package.json @@ -0,0 +1,38 @@ +{ + "name": "@airswap/delegate", + "version": "4.3.0-beta.0", + "description": "AirSwap: Delegate On-chain Trading Rules", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/airswap/airswap-protocols" + }, + "files": [ + "./build", + "./typechain", + "./deploys.js", + "./deploys-blocks.js", + "./deploys.js.d.ts" + ], + "scripts": { + "clean": "rm -rf ./cache && rm -rf ./build && rm -rf ./typechain", + "compile": "hardhat compile; yarn typechain", + "typechain": "tsc -b", + "coverage": "hardhat coverage", + "test": "hardhat test", + "test:ci": "hardhat test", + "deploy": "hardhat run ./scripts/deploy.js", + "verify": "hardhat run ./scripts/verify.js", + "owners": "hardhat run ./scripts/owner.js", + "migrate": "hardhat run ./scripts/migrate.js", + "balances": "hardhat run ./scripts/balances.js" + }, + "devDependencies": { + "@airswap/utils": "4.3.2", + "@airswap/swap-erc20": "4.3.1", + "prompt-confirm": "^2.0.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/source/delegate/scripts/deploy.js b/source/delegate/scripts/deploy.js new file mode 100644 index 000000000..a9464f620 --- /dev/null +++ b/source/delegate/scripts/deploy.js @@ -0,0 +1,73 @@ +/* eslint-disable no-console */ +const fs = require('fs') +const prettier = require('prettier') +const Confirm = require('prompt-confirm') +const { ethers, run } = require('hardhat') +const swapERC20Deploys = require('@airswap/swap-erc20/deploys.js') +const { ChainIds, chainLabels } = require('@airswap/utils') +const { getReceiptUrl } = require('@airswap/utils') +const delegateDeploys = require('../deploys.js') +const delegateBlocks = require('../deploys-blocks.js') +const { displayDeployerInfo } = require('../../../scripts/deployer-info') + +async function main() { + await run('compile') + const prettierConfig = await prettier.resolveConfig('../deploys.js') + const [deployer] = await ethers.getSigners() + const chainId = await deployer.getChainId() + if (chainId === ChainIds.HARDHAT) { + console.log('Value for --network flag is required') + return + } + await displayDeployerInfo(deployer) + + console.log(`swapERC20Contract: ${swapERC20Deploys[chainId]}\n`) + + const prompt = new Confirm('Proceed to deploy?') + if (await prompt.run()) { + const delegateFactory = await ethers.getContractFactory('Delegate') + const delegateContract = await delegateFactory.deploy( + swapERC20Deploys[chainId] + ) + console.log( + 'Deploying...', + getReceiptUrl(chainId, delegateContract.deployTransaction.hash) + ) + await delegateContract.deployed() + + delegateDeploys[chainId] = delegateContract.address + fs.writeFileSync( + './deploys.js', + prettier.format( + `module.exports = ${JSON.stringify(delegateDeploys, null, '\t')}`, + { ...prettierConfig, parser: 'babel' } + ) + ) + delegateBlocks[chainId] = ( + await delegateContract.deployTransaction.wait() + ).blockNumber + fs.writeFileSync( + './deploys-blocks.js', + prettier.format( + `module.exports = ${JSON.stringify(delegateBlocks, null, '\t')}`, + { ...prettierConfig, parser: 'babel' } + ) + ) + console.log( + `Deployed: ${delegateDeploys[chainId]} @ ${delegateBlocks[chainId]}` + ) + + console.log( + `\nVerify with "yarn verify --network ${chainLabels[ + chainId + ].toLowerCase()}"\n` + ) + } +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error) + process.exit(1) + }) diff --git a/source/delegate/scripts/owner.js b/source/delegate/scripts/owner.js new file mode 100644 index 000000000..bc2fc6a1b --- /dev/null +++ b/source/delegate/scripts/owner.js @@ -0,0 +1,14 @@ +const { check } = require('../../../scripts/owners-update') +const { Pool__factory } = require('@airswap/pool/typechain/factories/contracts') +const poolDeploys = require('../deploys.js') + +async function main() { + await check('Pool', Pool__factory, poolDeploys) +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error) + process.exit(1) + }) diff --git a/source/delegate/scripts/verify.js b/source/delegate/scripts/verify.js new file mode 100644 index 000000000..93fefecec --- /dev/null +++ b/source/delegate/scripts/verify.js @@ -0,0 +1,26 @@ +/* eslint-disable no-console */ +const { ethers, run } = require('hardhat') +const delegateDeploys = require('../deploys.js') +const swapERC20Deploys = require('@airswap/swap-erc20/deploys.js') +const { chainNames } = require('@airswap/utils') + +async function main() { + await run('compile') + const [deployer] = await ethers.getSigners() + console.log(`Deployer: ${deployer.address}`) + + const chainId = await deployer.getChainId() + + console.log(`Verifying on ${chainNames[chainId]}`) + await run('verify:verify', { + address: delegateDeploys[chainId], + constructorArguments: [swapERC20Deploys[chainId]], + }) +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error) + process.exit(1) + }) diff --git a/source/delegate/test/Delegate.js b/source/delegate/test/Delegate.js new file mode 100644 index 000000000..130b1ed30 --- /dev/null +++ b/source/delegate/test/Delegate.js @@ -0,0 +1,443 @@ +const { expect } = require('chai') + +const { ethers, waffle } = require('hardhat') +const { deployMockContract } = waffle +const IERC20 = require('@openzeppelin/contracts/build/contracts/IERC20.json') +const SWAP_ERC20 = require('@airswap/swap-erc20/build/contracts/SwapERC20.sol/SwapERC20.json') +const { + ADDRESS_ZERO, + createOrderERC20, + orderERC20ToParams, + createOrderERC20Signature, +} = require('@airswap/utils') +const CHAIN_ID = 31337 +const DEFAULT_BALANCE = '100000' +const DEFAULT_SENDER_AMOUNT = '10000' +const DEFAULT_SIGNER_AMOUNT = '10000' +const PROTOCOL_FEE = '5' +const REBATE_SCALE = '10' +const REBATE_MAX = '100' +const UPDATE_SWAP_ERC20_ADDRESS = '0x0000000000000000000000000000000000001337' + +describe('Delegate Unit', () => { + let deployer + let sender + let signer + let swapERC20 + let senderToken + let signerToken + let delegate + let manager + let snapshotId + + async function createSignedOrderERC20(params, signatory) { + const unsignedOrder = createOrderERC20({ + protocolFee: PROTOCOL_FEE, + signerWallet: signer.address, + signerToken: signerToken.address, + signerAmount: DEFAULT_SIGNER_AMOUNT, + senderWallet: delegate.address, + senderToken: senderToken.address, + senderAmount: DEFAULT_SENDER_AMOUNT, + ...params, + }) + return orderERC20ToParams({ + ...unsignedOrder, + ...(await createOrderERC20Signature( + unsignedOrder, + signatory, + swapERC20.address, + CHAIN_ID + )), + }) + } + + async function setUpAllowances( + senderWallet, + senderAmount, + signerWallet, + signerAmount + ) { + await senderToken.mock.allowance + .withArgs(senderWallet, delegate.address) + .returns(senderAmount) + await signerToken.mock.allowance + .withArgs(signerWallet, swapERC20.address) + .returns(signerAmount) + } + + async function setUpBalances(senderWallet, signerWallet) { + await senderToken.mock.balanceOf + .withArgs(senderWallet) + .returns(DEFAULT_BALANCE) + await signerToken.mock.balanceOf + .withArgs(signerWallet) + .returns(DEFAULT_BALANCE) + await signerToken.mock.balanceOf + .withArgs(delegate.address) + .returns(DEFAULT_SIGNER_AMOUNT) + } + + async function setUpApprovals() { + await senderToken.mock.approve + .withArgs(delegate.address, DEFAULT_SENDER_AMOUNT) + .returns(true) + + await senderToken.mock.approve + .withArgs(swapERC20.address, DEFAULT_SENDER_AMOUNT) + .returns(true) + + await signerToken.mock.approve + .withArgs(swapERC20.address, DEFAULT_SIGNER_AMOUNT) + .returns(true) + } + + beforeEach(async () => { + snapshotId = await ethers.provider.send('evm_snapshot') + }) + + afterEach(async () => { + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + before(async () => { + ;[deployer, sender, signer, manager, anyone] = await ethers.getSigners() + + const swapERC20Factory = await ethers.getContractFactory( + SWAP_ERC20.abi, + SWAP_ERC20.bytecode + ) + swapERC20 = await swapERC20Factory.deploy( + PROTOCOL_FEE, + PROTOCOL_FEE, + deployer.address, + REBATE_SCALE, + REBATE_MAX + ) + + delegate = await ( + await ethers.getContractFactory('Delegate') + ).deploy(swapERC20.address) + await delegate.deployed() + + senderToken = await deployMockContract(deployer, IERC20.abi) + signerToken = await deployMockContract(deployer, IERC20.abi) + await senderToken.mock.transferFrom.returns(true) + await signerToken.mock.transferFrom.returns(true) + await senderToken.mock.transfer.returns(true) + await signerToken.mock.transfer.returns(true) + + setUpApprovals() + }) + + describe('Constructor and admin functions', async () => { + it('swap ERC20 address is set', async () => { + expect(await delegate.swapERC20()).to.equal(swapERC20.address) + }) + + it('sets the swapERC20Contract address', async () => { + await delegate.setSwapERC20Contract(UPDATE_SWAP_ERC20_ADDRESS) + expect(await delegate.swapERC20()).to.equal(UPDATE_SWAP_ERC20_ADDRESS) + }) + + it('only the owner can set the swapERC20Contract address', async () => { + await expect( + delegate.connect(anyone).setSwapERC20Contract(UPDATE_SWAP_ERC20_ADDRESS) + ).to.be.revertedWith('Unauthorized') + }) + }) + + describe('Rules', async () => { + it('sets a Rule', async () => { + await expect( + delegate + .connect(sender) + .setRule( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + ) + .to.emit(delegate, 'SetRule') + .withArgs( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + }) + + it('unsets a Rule', async () => { + await expect( + delegate + .connect(sender) + .unsetRule(sender.address, senderToken.address, signerToken.address) + ) + .to.emit(delegate, 'UnsetRule') + .withArgs(sender.address, senderToken.address, signerToken.address) + }) + + it('a manager can set a Rule', async () => { + await delegate.connect(sender).authorize(manager.address) + await expect( + delegate + .connect(manager) + .setRule( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + ) + .to.emit(delegate, 'SetRule') + .withArgs( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + }) + + it('a manager can unset a Rule', async () => { + await delegate.connect(sender).authorize(manager.address) + await delegate + .connect(manager) + .setRule( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + + await expect( + delegate + .connect(manager) + .unsetRule(sender.address, senderToken.address, signerToken.address) + ) + .to.emit(delegate, 'UnsetRule') + .withArgs(sender.address, senderToken.address, signerToken.address) + }) + + it('setting a Rule updates the rule balance', async () => { + await delegate + .connect(sender) + .setRule( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + + const rule = await delegate.rules( + sender.address, + senderToken.address, + signerToken.address + ) + + expect(rule.senderAmount.toString()).to.equal(DEFAULT_SENDER_AMOUNT) + }) + + it('unsetting a Rule updates the rule balance', async () => { + await delegate + .connect(sender) + .setRule( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + + let rule = await delegate.rules( + sender.address, + senderToken.address, + signerToken.address + ) + + await delegate + .connect(sender) + .unsetRule(sender.address, senderToken.address, signerToken.address) + + rule = await delegate.rules( + sender.address, + senderToken.address, + signerToken.address + ) + + expect(rule.senderAmount.toString()).to.equal('0') + }) + }) + + describe('Test authorization', async () => { + it('test authorized is set', async () => { + await delegate.connect(anyone).authorize(signer.address) + expect(await delegate.authorized(anyone.address)).to.equal(signer.address) + }) + + it('test authorize with zero address', async () => { + await expect( + delegate.connect(deployer).authorize(ADDRESS_ZERO) + ).to.be.revertedWith('ManagerInvalid') + }) + + it('test revoke', async () => { + await delegate.connect(anyone).revoke() + expect(await delegate.authorized(anyone.address)).to.equal(ADDRESS_ZERO) + }) + }) + + describe('Swap', async () => { + it('successfully swaps', async () => { + await delegate + .connect(sender) + .setRule( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + + const order = await createSignedOrderERC20({}, signer) + + await setUpAllowances( + sender.address, + DEFAULT_SENDER_AMOUNT, + signer.address, + DEFAULT_SIGNER_AMOUNT + PROTOCOL_FEE + ) + await setUpBalances(signer.address, sender.address) + + await expect( + delegate.connect(signer).swap(sender.address, ...order) + ).to.emit(delegate, 'DelegateSwap') + }) + + it('successfully swaps with a manager', async () => { + await delegate.connect(sender).authorize(manager.address) + + await delegate + .connect(manager) + .setRule( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + + const order = await createSignedOrderERC20({}, signer) + + await setUpAllowances( + sender.address, + DEFAULT_SENDER_AMOUNT, + signer.address, + DEFAULT_SIGNER_AMOUNT + PROTOCOL_FEE + ) + await setUpBalances(signer.address, sender.address) + + await expect( + delegate.connect(signer).swap(sender.address, ...order) + ).to.emit(delegate, 'DelegateSwap') + }) + + it('fails to swap with no rule', async () => { + const order = await createSignedOrderERC20({}, signer) + + await setUpAllowances( + signer.address, + DEFAULT_SENDER_AMOUNT + PROTOCOL_FEE, + sender.address, + DEFAULT_SIGNER_AMOUNT + ) + await setUpBalances(signer.address, sender.address) + + await signerToken.mock.balanceOf + .withArgs(delegate.address) + .returns(DEFAULT_SIGNER_AMOUNT) + + await expect(delegate.connect(signer).swap(sender.address, ...order)).to + .be.reverted + }) + + it('fails to swap with insufficient remaining sender amount on Rule', async () => { + await senderToken.mock.approve + .withArgs(delegate.address, DEFAULT_SENDER_AMOUNT - 1) + .returns(true) + + await delegate + .connect(sender) + .setRule( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT - 1, + signerToken.address, + DEFAULT_SIGNER_AMOUNT - 1 + ) + + const order = await createSignedOrderERC20({}, signer) + + await setUpAllowances( + sender.address, + DEFAULT_SENDER_AMOUNT, + signer.address, + DEFAULT_SIGNER_AMOUNT + PROTOCOL_FEE + ) + await setUpBalances(signer.address, sender.address) + + await signerToken.mock.balanceOf + .withArgs(signer.address) + .returns(DEFAULT_SIGNER_AMOUNT - 1) + + await expect( + delegate.connect(signer).swap(sender.address, ...order) + ).to.be.revertedWith('InvalidSenderAmount') + }) + + it('fails to swap with insufficient signer amount on Rule', async () => { + await senderToken.mock.approve + .withArgs(delegate.address, DEFAULT_SENDER_AMOUNT - 1) + .returns(true) + + await delegate + .connect(sender) + .setRule( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + + const order = await createSignedOrderERC20( + { + signerAmount: DEFAULT_SIGNER_AMOUNT - 1, + }, + signer + ) + + await setUpAllowances( + sender.address, + DEFAULT_SENDER_AMOUNT, + signer.address, + DEFAULT_SIGNER_AMOUNT + PROTOCOL_FEE + ) + await setUpBalances(signer.address, sender.address) + + await signerToken.mock.balanceOf + .withArgs(signer.address) + .returns(DEFAULT_SIGNER_AMOUNT - 1) + + await expect( + delegate.connect(signer).swap(sender.address, ...order) + ).to.be.revertedWith('InvalidSignerAmount') + }) + }) +}) diff --git a/source/delegate/test/DelegateIntegration.js b/source/delegate/test/DelegateIntegration.js new file mode 100644 index 000000000..04f8f0c93 --- /dev/null +++ b/source/delegate/test/DelegateIntegration.js @@ -0,0 +1,135 @@ +const { expect } = require('chai') +const { + createOrderERC20, + orderERC20ToParams, + createOrderERC20Signature, +} = require('@airswap/utils') +const { ethers } = require('hardhat') +const ERC20 = require('@openzeppelin/contracts/build/contracts/ERC20PresetMinterPauser.json') +const SWAP_ERC20 = require('@airswap/swap-erc20/build/contracts/SwapERC20.sol/SwapERC20.json') + +describe('Delegate Integration', () => { + let snapshotId + let signerToken + let senderToken + + let deployer + let sender + let signer + + const CHAIN_ID = 31337 + const BONUS_SCALE = '10' + const BONUS_MAX = '100' + const PROTOCOL_FEE = '5' + const DEFAULT_SENDER_AMOUNT = '10000' + const DEFAULT_SIGNER_AMOUNT = '10000' + const DEFAULT_BALANCE = '1000000' + + async function createSignedOrderERC20(params, signer) { + const unsignedOrder = createOrderERC20({ + protocolFee: PROTOCOL_FEE, + signerWallet: signer.address, + signerToken: signerToken.address, + signerAmount: DEFAULT_SIGNER_AMOUNT, + senderWallet: delegate.address, + senderToken: senderToken.address, + senderAmount: DEFAULT_SENDER_AMOUNT, + ...params, + }) + return orderERC20ToParams({ + ...unsignedOrder, + ...(await createOrderERC20Signature( + unsignedOrder, + signer, + swapERC20.address, + CHAIN_ID + )), + }) + } + + beforeEach(async () => { + snapshotId = await ethers.provider.send('evm_snapshot') + }) + + afterEach(async () => { + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + before('get signers and deploy', async () => { + ;[deployer, sender, signer, protocolFeeWallet] = await ethers.getSigners() + + swapERC20 = await ( + await ethers.getContractFactory(SWAP_ERC20.abi, SWAP_ERC20.bytecode) + ).deploy( + PROTOCOL_FEE, + PROTOCOL_FEE, + deployer.address, + BONUS_SCALE, + BONUS_MAX + ) + await swapERC20.deployed() + + delegate = await ( + await ethers.getContractFactory('Delegate') + ).deploy(swapERC20.address) + await delegate.deployed() + + signerToken = await ( + await ethers.getContractFactory(ERC20.abi, ERC20.bytecode) + ).deploy('A', 'A') + await signerToken.deployed() + await signerToken.mint(signer.address, DEFAULT_BALANCE) + + senderToken = await ( + await ethers.getContractFactory(ERC20.abi, ERC20.bytecode) + ).deploy('B', 'B') + await senderToken.deployed() + await senderToken.mint(sender.address, DEFAULT_BALANCE) + + signerToken.connect(signer).approve(swapERC20.address, DEFAULT_BALANCE) + senderToken.connect(sender).approve(delegate.address, DEFAULT_BALANCE) + }) + + describe('Test transfers', async () => { + it('test a delegated swap', async () => { + await delegate + .connect(sender) + .setRule( + sender.address, + senderToken.address, + DEFAULT_SENDER_AMOUNT, + signerToken.address, + DEFAULT_SIGNER_AMOUNT + ) + + signerToken + .connect(signer) + .approve(swapERC20.address, DEFAULT_SIGNER_AMOUNT + PROTOCOL_FEE) + + const order = await createSignedOrderERC20({}, signer) + + await expect( + delegate.connect(signer).swap(sender.address, ...order) + ).to.emit(delegate, 'DelegateSwap') + + expect(await signerToken.balanceOf(sender.address)).to.equal( + DEFAULT_SIGNER_AMOUNT + ) + + expect(await signerToken.balanceOf(signer.address)).to.equal( + DEFAULT_BALANCE - DEFAULT_SIGNER_AMOUNT - PROTOCOL_FEE + ) + + expect(await senderToken.balanceOf(signer.address)).to.equal( + DEFAULT_SENDER_AMOUNT + ) + + expect(await senderToken.balanceOf(sender.address)).to.equal( + DEFAULT_BALANCE - DEFAULT_SENDER_AMOUNT + ) + + expect(await senderToken.balanceOf(delegate.address)).to.equal(0) + expect(await signerToken.balanceOf(delegate.address)).to.equal(0) + }) + }) +}) diff --git a/source/delegate/tsconfig.json b/source/delegate/tsconfig.json new file mode 100644 index 000000000..ce170734c --- /dev/null +++ b/source/delegate/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./typechain" + }, + "files": ["./typechain/index.ts"] +}