├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github ├── actions │ └── setup │ │ └── action.yml └── workflows │ └── test.yml ├── .gitignore ├── .solcover.js ├── .solhint.json ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── contracts ├── ERC20Plugins.sol ├── Plugin.sol ├── interfaces │ ├── IERC20Plugins.sol │ └── IPlugin.sol ├── libs │ └── ReentrancyGuard.sol └── mocks │ ├── BadPluginMock.sol │ ├── ERC20PluginsMock.sol │ ├── GasLimitedPluginMock.sol │ └── PluginMock.sol ├── hardhat.config.js ├── package.json ├── src └── img │ ├── PluginsDiagram.png │ ├── TokenTransferDiagram.png │ ├── _updateBalances2.png │ └── scheme-n1.png ├── test ├── ERC20Plugins.js └── behaviors │ └── ERC20Plugins.behavior.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{json,yml,xml,yaml}] 12 | indent_size = 2 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends" : [ 3 | "standard", 4 | "plugin:promise/recommended" 5 | ], 6 | "plugins": [ 7 | "promise" 8 | ], 9 | "env": { 10 | "browser" : true, 11 | "node" : true, 12 | "mocha" : true, 13 | "jest" : true 14 | }, 15 | "globals" : { 16 | "artifacts": false, 17 | "contract": false, 18 | "assert": false, 19 | "web3": false 20 | }, 21 | "rules": { 22 | 23 | // Strict mode 24 | "strict": [2, "global"], 25 | 26 | // Code style 27 | "indent": [2, 4], 28 | "quotes": [2, "single"], 29 | "semi": ["error", "always"], 30 | "space-before-function-paren": ["error", "always"], 31 | "no-use-before-define": 0, 32 | "no-unused-expressions": "off", 33 | "eqeqeq": [2, "smart"], 34 | "dot-notation": [2, {"allowKeywords": true, "allowPattern": ""}], 35 | "no-redeclare": [2, {"builtinGlobals": true}], 36 | "no-trailing-spaces": [2, { "skipBlankLines": true }], 37 | "eol-last": 1, 38 | "comma-spacing": [2, {"before": false, "after": true}], 39 | "camelcase": [2, {"properties": "always"}], 40 | "no-mixed-spaces-and-tabs": [2, "smart-tabs"], 41 | "comma-dangle": [1, "always-multiline"], 42 | "no-dupe-args": 2, 43 | "no-dupe-keys": 2, 44 | "no-debugger": 0, 45 | "no-undef": 2, 46 | "object-curly-spacing": [2, "always"], 47 | "max-len": [2, 200, 2], 48 | "generator-star-spacing": ["error", "before"], 49 | "promise/avoid-new": 0, 50 | "promise/always-return": 0 51 | } 52 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sol linguist-language=Solidity 2 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | 3 | runs: 4 | using: composite 5 | steps: 6 | - uses: actions/setup-node@v3 7 | with: 8 | node-version: 20 9 | 10 | - run: npm install -g yarn 11 | shell: bash 12 | 13 | - id: yarn-cache 14 | run: echo "::set-output name=dir::$(yarn cache dir)" 15 | shell: bash 16 | 17 | - uses: actions/cache@v3 18 | with: 19 | path: ${{ steps.yarn-cache.outputs.dir }} 20 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | ${{ runner.os }}-yarn- 23 | 24 | - run: yarn 25 | shell: bash 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: ./.github/actions/setup 14 | - run: yarn lint 15 | 16 | test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: ./.github/actions/setup 21 | - run: yarn test:ci 22 | 23 | coverage: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: ./.github/actions/setup 28 | - run: yarn coverage 29 | - uses: codecov/codecov-action@v3 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | artifacts 2 | cache 3 | node_modules 4 | hardhat-dependency-compiler 5 | coverage 6 | coverage.json 7 | build 8 | .coverage_contracts 9 | .coverage_artifacts 10 | .idea 11 | .env 12 | -------------------------------------------------------------------------------- /.solcover.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | configureYulOptimizer: true, 3 | solcOptimizerDetails: { 4 | yul: true, 5 | yulDetails: { 6 | optimizerSteps: 7 | "dhfoDgvlfnTUtnIf" + // None of these can make stack problems worse 8 | "[" + 9 | "xa[r]EsLM" + // Turn into SSA and simplify 10 | "CTUtTOntnfDIl" + // Perform structural simplification 11 | "Ll" + // Simplify again 12 | "Vl [j]" + // Reverse SSA 13 | 14 | // should have good "compilability" property here. 15 | 16 | "Tpel" + // Run functional expression inliner 17 | "xa[rl]" + // Prune a bit more in SSA 18 | "xa[r]L" + // Turn into SSA again and simplify 19 | "gvf" + // Run full inliner 20 | "CTUa[r]LSsTFOtfDna[r]Il" + // SSA plus simplify 21 | "]" + 22 | "jml[jl] VTOl jml : fDnTOm", 23 | }, 24 | }, 25 | skipFiles: [ 26 | 'mocks', 'tests', 'interfaces', 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "rules": { 4 | "compiler-version": ["error", "^0.8.0"], 5 | "private-vars-leading-underscore": "error", 6 | "func-visibility": ["error", { "ignoreConstructors": true }] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "solidity.compileUsingRemoteVersion": "v0.8.15" 3 | } 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to 1inch 2 | ======= 3 | 4 | Thanks for taking the time to contribute! All types of contributions are encouraged and valued. Please make sure to read the sections below before making your contribution. It will make it a lot easier for maintainers and speeds up the merge of your contribution. 5 | 6 | ## Creating Pull Requests (PRs) 7 | 8 | As a contributor, you are expected to fork this repository, work on your own fork and then submit pull requests. The pull requests will be reviewed and eventually merged into the main repo. 9 | 10 | ## A typical workflow 11 | 12 | 1) Before contributing any changes it is a good practice to open an issue and provide the reasoning for the changes 13 | 1) Make sure your fork is up to date with the main repository 14 | 2) Update all dependencies to the latest version 15 | ``` 16 | yarn 17 | ``` 18 | 3) Branch out from `master` into `fix/some-bug-#123` 19 | (Postfixing #123 will associate your PR with the issue #123) 20 | 4) Make your changes, add your files, commit and push to your fork. 21 | Before pushing the branch ensure that: 22 | * JS and Solidity linting tests pass 23 | ``` 24 | yarn lint 25 | ``` 26 | * New and/or fixed features are covered with relevant tests and all existing tests pass 27 | ``` 28 | yarn test 29 | ``` 30 | 5) Go to the GitHub repo in your web browser and issue a new pull request. 31 | 6) Maintainers will review your code and possibly ask for changes before your code is pulled into the main repository. We'll check that all tests pass, review the coding style, and check for general code correctness. If everything is OK, we'll merge your pull request. 32 | 33 | ## All done! 34 | 35 | If you have any questions feel free to post them in the issues section. 36 | Thanks for your time and code! 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | © 2022, 1inch. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/1inch/token-plugins/workflows/CI/badge.svg)](https://github.com/1inch/token-plugins/actions) 2 | [![Coverage Status](https://codecov.io/gh/1inch/token-plugins/branch/master/graph/badge.svg?token=Z3D5O3XUYV)](https://codecov.io/gh/1inch/token-plugins) 3 | [![NPM Package](https://img.shields.io/npm/v/@1inch/token-plugins.svg)](https://www.npmjs.org/package/@1inch/token-plugins) 4 | 5 | # 1inch Token Plugins: A Comprehensive Guide for Extending ERC20 Functionalities 6 | 7 | [Overview](#overview) 8 | 9 | [Primary Benefits](#primary-benefits) 10 | 11 | [Implementation](#implementation) 12 | 13 | [Generic Examples](#generic-examples) 14 | 15 | [Deployed Examples](#deployed-examples) 16 | 17 | [Helpful Links](#other-helpful-links) 18 | 19 | ## Overview 20 | Token plugins are smart contracts that extend the capabilities of ERC20 tokens and wrappers by adding custom accounting features to the original token. Inspired by the plugin concept widely used in the web 2.0 world, these plugins enable users to dynamically increase the functionality of their tokens without the need to transfer tokens to a special smart contract. 21 | 22 | The major benefit, and a key difference from existing solutions, is that these do not require token transfers to a special smart contract, as is commonly seen in farming or delegating protocols. Another beneficial point is that once an ERC20 plugin code is deployed, it can be reused by any tokens that support the 1inch plugin standard. 23 | 24 | Support for plugins on the token side is similar to the implementation of classic ERC20 extensions (i.e., OpenZeppelin ERC20 extensions). The deployment and usage are permissionless from the perspective of a token contract owner, since the holder is the actor who decides which plugin to subscribe to. 25 | 26 | Technically, plugins are a collection of smart contracts that track changes in ERC20 token balances and perform supplementary accounting tasks for those balances. They are particularly useful when you need to track, for example, token shares without actually transferring tokens to a dedicated accounting contract. 27 | 28 | The token plugins standard is designed to be secure and to prevent asset loss, gas, and DoS attacks on transfers. 29 | 30 | ***Note: ERC721 (NFT) and ERC1155 (Multi-token) support is coming soon!*** 31 | 32 | ## Primary Benefits 33 | - **100% permissionless from the token contract owner**: Open to all participants. 34 | - **Risk-free participation**: Token plugins do not require any approval, deposit, or transfer of funds into an external contract for participation. 35 | - **Multiple plugin connections**: Users can connect with multiple plugins, allowing for simultaneous involvement in multiple incentive programs or governance systems, etc. (subject to a predefined limit, set at deployment). 36 | - **Simple to adopt**: Implementation is only 150 lines of code. 37 | - **High security**: 1inch Token Plugins have gone through extensive [audits](https://github.com/1inch/1inch-audits/tree/master/Fusion%20mode%20and%20Token-plugins) by multiple top-tier companies. 38 | - **Built-in reentrancy protection**: This feature ensures that the balances cannot be tampered with by manipulating plugin accounting. 39 | - **Custom ERC20 representation**: A plugin can be represented by its own associated ERC20 (custom inheritance), enabling building complex and multi-layered accounting systems like 1inch Fusion. 40 | 41 | ## Use-Cases 42 | Here are some examples of how Token Plugins is currently being (or could be used) today: 43 | 44 | - **st1INCH resolver delegation** 45 | Through staking 1INCH, token holders receive Unicorn Power (UP), and can earn rewards from Resolvers in the Intent Swap system. In order to earn these rewards, the UP received from staking can be delegated (see contract) to a specific Resolver. The resolver is incentivized to have UP delegated to them, so they will reward delegators with some amount of funds. The delegation of st1INCH is done with a token plugin, so there is no need to transfer the tokens to another contract. ([see dst1inch contract](https://etherscan.io/token/0xAccfAc2339e16DC80c50d2fa81b5c2B049B4f947#code)) 46 | 47 | - **Weighted voting power** 48 | VE governance models like veCRV require the user to lock tokens for a certain amount of time to earn voting rights. This signals to the protocol a long-term vested interest and greatly reduces the surface area for governance attacks. With Token Plugins, the VE token model can be replaced with logic that gives the wallet ramping voting power by simply holding the base governance token for long periods of time. When a wallet first holds the governance token, its voting power will be nearly zero, but over time (e.g. 2 years), it will increase until it reaches a set maximum. 49 | 50 | - **LP-Token farming** 51 | Some protocols incentivize LP token holders with additional rewards beyond swap fees through an additional yield contract that holds the LP tokens and distributes the rewards proportionally to the participating LPs. With token plugins, these extra rewards for LP holders can continue to be opt-in without the need to deposit those LP tokens into a secondary contract. ([See 1inch Fusion pods](https://etherscan.io/address/0x1A87c0F9CCA2f0926A155640e8958a8A6B0260bE#code)) 52 | 53 | - **Shadow staking** 54 | If a protocol wanted to simply reward holders of their token, they could reward them similarly to the weighted voting power method, but instead of increasing voting power over time, the APR of holding the token can increase. Long-term holders will receive rewards and short-term holders/traders would not receive the same benefit. 55 | 56 | - **Borrow/lending rewards** 57 | In traditional lending protocols, users must transfer assets and hold both lending and debt tokens in their wallets, limiting farming opportunities. With 1inch Token Plugins, users are able to maintain custody of their assets while a plugin tracks balances, distributing rewards seamlessly and securely without ever having to move the assets. 58 | 59 | ## Limitations 60 | - Any plugin's processing logic consumes additional gas, with external operations that change an account balance incurring higher costs. To mitigate this, the plugin extension sets a limit on the gas consumption per plugin and caps the maximum amount of gas that can be spent. 61 | - **Plugin Quantity**: The contract deployer should establish a limit on the number of plugins managed under the plugin management contract. 62 | - **Maximum gas usage**: The plugin management contract limits the amount of gas any plugin can use to avoid overspent and gas attacks. It is highly recommended not to change beyond the recommended amount of 140,000. 63 | - **Only works with transferrable tokens**: By fundamental design, plugins are unable to integrate with tokens whose balances can update without transfers (such as rebase tokens). 64 | 65 | ## Implementation 66 | 67 | ![ERC20Plugins](/src/img/PluginsDiagram.png) 68 | 69 | Connecting a token contract with the 1inch Token Plugins is a straightforward process. If you’re creating a brand new token contract or migrating an existing one, you can simply inherit from the plugin-enabled ERC20 contract OR wrap an existing token and inherit plugin functionality within the wrapper (`contract MyWrapper is ERC20Wrapper, ERC20Plugins { ... }`). Subsequently, any plugin (deployed as a separate contract) can be connected to your plugin-enabled ERC20, enabling it to track balance updates of the underlying asset efficiently. 70 | 71 | In other words, 1inch Token Plugins require inheritance from an independent, “plugin-enabled” ERC20 contract, which manages all related dependent plugin contracts. The plugin-enabled ERC20 contract is responsible for calling the `updateBalance` function with every change in an account’s balance. 72 | 73 | All plugins will only track the balances of participating accounts. So all non-participants are represented as “0 addresses”. If an account is not participating in a plugin and receives a plugin-enabled token, the `From` and `To` amounts under `_updateBalances` will be represented as 0. Therefore, if a non-participant sends a plugin-enabled token to an existing participant, it will effectively “mint” the tracked balance. If a participant sends a plugin-enabled token to a non-participant, it will effectively “burn” the tracked balance. 74 | 75 | ![Token Transfers](/src/img/TokenTransferDiagram.png) 76 | 77 | For security purposes, plugins are designed with several fail-safes, including a maximum number of usable plugins, custom gas limits, a reentrancy guard, and native isolation from the main contract state. The maximum plugins and gas limit can be initialized as state variables using `MAX_PLUGINS_PER_ACCOUNT` and `PLUGIN_CALL_GAS_LIMIT`, respectively. For reentrancy prevention, `ReentrancyGuardExt` is included from OpenZeppelin’s library. Finally, for native isolation from the token contract, a single method with only three arguments (`To`, `From`, and `Amount`) is used. This simple architecture results in a dynamic (and risk-free!) enhancement of any ERC20 contract’s capabilities. 78 | 79 | ## Integrating plugin support in your token implementation 80 | To integrate plugins in a smart contract, a "mothership" or parent contract must be used to manage all related plugins. This includes adding, removing, and viewing plugins, as well as connecting multiple plugins. The parent contract calls the `updateBalance` function for each pod on every update of an account’s balance. The pod then executes its logic based on the updated balance information. An account must connect a plugin to utilize its logic. 81 | 82 | - **Inherit token**: `contract MyToken is ERC20Plugins { ... }` 83 | - **Or wrap it**: `contract MyWrapper is ERC20Wrapper, ERC20Plugins { ... }` 84 | 85 | This will add support for the plugin infrastructure. 86 | 87 | - **Wallets can plugin**: `MyToken.addPlugin(plugin)`, where `plugin` is the address of your or a third-party deployed plugin. 88 | - Now every time a wallet balance changes, the plugin will know about it. 89 | 90 | ## How to create your own plugin 91 | To create your own plugin, it is necessary to inherit the Plugin contract and implement its abstract function `_updateBalances`. 92 | 93 | - **Inherit plugin**: `contract MyPlugin is Plugin { ... }` 94 | - **Implement _updateBalances** function to process wallet balance changes. 95 | 96 | ## Generic Examples 97 | 98 | Below is an example of an ERC20 token implementing plugin support and a simple plugin that mints and burns its own token based on the parent’s token balance. 99 | 100 | **Simple plugin-enabled token contract** 101 | ``` 102 | contract NewToken is ERC20Plugins { 103 | constructor(string memory name, string memory symbol, uint256 maxPluginsPerAccount, uint256 pluginCallGasLimit) 104 | ERC20(name, symbol) 105 | ERC20Plugins(maxPluginsPerAccount, pluginCallGasLimit) 106 | {} // solhint-disable-line no-empty-blocks 107 | 108 | function mint(address account, uint256 amount) external { 109 | _mint(account, amount); 110 | } 111 | } 112 | ``` 113 | **Simple plugin contract** 114 | ``` 115 | contract MyPlugin is ERC20, Plugin { 116 | constructor(string memory name, string memory symbol, IERC20Plugins token_) 117 | ERC20(name, symbol) 118 | Plugin(token_) 119 | {} // solhint-disable-line no-empty-blocks 120 | 121 | function _updateBalances(address from, address to, uint256 amount) internal override { 122 | if (from == address(0)) { 123 | _mint(to, amount); 124 | } else if (to == address(0)) { 125 | _burn(from, amount); 126 | } else { 127 | _transfer(from, to, amount); 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | ## Deployed Examples 134 | - [Plugin-enabled ERC20 contract](https://arbiscan.io/token/0x36a8747fc5F09cDE48e7b8Eb073Ae911b2cBa933#code) 135 | - [Simple Plugin contract](https://arbiscan.io/address/0x7f75495bf9a3f20b253a68a34a152c5f5587a742#code) 136 | - [1inch Fusion (Delegated Staked 1INCH) Plugin Contract](https://etherscan.io/address/0x806d9073136c8A4A3fD21E0e708a9e17C87129e8#code) 137 | - [1inch Fusion Staking Farm](https://etherscan.io/address/0x1A87c0F9CCA2f0926A155640e8958a8A6B0260bE#code) 138 | 139 | ## Other Helpful Links 140 | - [Plugin-enabled ERC20 Token contract (abstract)](https://github.com/1inch/token-plugins/blob/master/contracts/ERC20Plugins.sol) 141 | - [Plugin contract (abstract)](https://github.com/1inch/token-plugins/blob/master/contracts/Plugin.sol) 142 | - [Anton Bukov speech at ETHCC](https://youtu.be/Is-T5Q2E0A8?feature=shared) 143 | - [Kirill Kuznetcov speech at Nethermind Summit, Istanbul](https://youtu.be/BwehZHhR8Z4?feature=shared) 144 | -------------------------------------------------------------------------------- /contracts/ERC20Plugins.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import { IERC20, ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 6 | import { AddressSet, AddressArray } from "@1inch/solidity-utils/contracts/libraries/AddressSet.sol"; 7 | 8 | import { IERC20Plugins } from "./interfaces/IERC20Plugins.sol"; 9 | import { IPlugin } from "./interfaces/IPlugin.sol"; 10 | import { ReentrancyGuardExt, ReentrancyGuardLib } from "./libs/ReentrancyGuard.sol"; 11 | 12 | /** 13 | * @title ERC20Plugins 14 | * @dev A base implementation of token contract to hold and manage plugins of an ERC20 token with a limited number of plugins per account. 15 | * Each plugin is a contract that implements IPlugin interface (and/or derived from plugin). 16 | */ 17 | abstract contract ERC20Plugins is ERC20, IERC20Plugins, ReentrancyGuardExt { 18 | using AddressSet for AddressSet.Data; 19 | using AddressArray for AddressArray.Data; 20 | using ReentrancyGuardLib for ReentrancyGuardLib.Data; 21 | 22 | /// @dev Limit of plugins per account 23 | uint256 public immutable MAX_PLUGINS_PER_ACCOUNT; 24 | /// @dev Gas limit for a single plugin call 25 | uint256 public immutable PLUGIN_CALL_GAS_LIMIT; 26 | 27 | ReentrancyGuardLib.Data private _guard; 28 | mapping(address => AddressSet.Data) private _plugins; 29 | 30 | /** 31 | * @dev Constructor that sets the limit of plugins per account and the gas limit for a plugin call. 32 | * @param pluginsLimit_ The limit of plugins per account. 33 | * @param pluginCallGasLimit_ The gas limit for a plugin call. Intended to prevent gas bomb attacks 34 | */ 35 | constructor(uint256 pluginsLimit_, uint256 pluginCallGasLimit_) { 36 | if (pluginsLimit_ == 0) revert ZeroPluginsLimit(); 37 | MAX_PLUGINS_PER_ACCOUNT = pluginsLimit_; 38 | PLUGIN_CALL_GAS_LIMIT = pluginCallGasLimit_; 39 | _guard.init(); 40 | } 41 | 42 | /** 43 | * @notice See {IERC20Plugins-hasPlugin}. 44 | */ 45 | function hasPlugin(address account, address plugin) public view virtual returns(bool) { 46 | return _plugins[account].contains(plugin); 47 | } 48 | 49 | /** 50 | * @notice See {IERC20Plugins-pluginsCount}. 51 | */ 52 | function pluginsCount(address account) public view virtual returns(uint256) { 53 | return _plugins[account].length(); 54 | } 55 | 56 | /** 57 | * @notice See {IERC20Plugins-pluginAt}. 58 | */ 59 | function pluginAt(address account, uint256 index) public view virtual returns(address) { 60 | return _plugins[account].at(index); 61 | } 62 | 63 | /** 64 | * @notice See {IERC20Plugins-plugins}. 65 | */ 66 | function plugins(address account) public view virtual returns(address[] memory) { 67 | return _plugins[account].items.get(); 68 | } 69 | 70 | /** 71 | * @dev Returns the balance of a given account. 72 | * @param account The address of the account. 73 | * @return balance The account balance. 74 | */ 75 | function balanceOf(address account) public nonReentrantView(_guard) view override(IERC20, ERC20) virtual returns(uint256) { 76 | return super.balanceOf(account); 77 | } 78 | 79 | /** 80 | * @notice See {IERC20Plugins-pluginBalanceOf}. 81 | */ 82 | function pluginBalanceOf(address plugin, address account) public nonReentrantView(_guard) view virtual returns(uint256) { 83 | if (hasPlugin(account, plugin)) { 84 | return super.balanceOf(account); 85 | } 86 | return 0; 87 | } 88 | 89 | /** 90 | * @notice See {IERC20Plugins-addPlugin}. 91 | */ 92 | function addPlugin(address plugin) public virtual { 93 | _addPlugin(msg.sender, plugin); 94 | } 95 | 96 | /** 97 | * @notice See {IERC20Plugins-removePlugin}. 98 | */ 99 | function removePlugin(address plugin) public virtual { 100 | _removePlugin(msg.sender, plugin); 101 | } 102 | 103 | /** 104 | * @notice See {IERC20Plugins-removeAllPlugins}. 105 | */ 106 | function removeAllPlugins() public virtual { 107 | _removeAllPlugins(msg.sender); 108 | } 109 | 110 | function _addPlugin(address account, address plugin) internal virtual { 111 | if (plugin == address(0)) revert InvalidPluginAddress(); 112 | if (IPlugin(plugin).TOKEN() != IERC20Plugins(address(this))) revert InvalidTokenInPlugin(); 113 | if (!_plugins[account].add(plugin)) revert PluginAlreadyAdded(); 114 | if (_plugins[account].length() > MAX_PLUGINS_PER_ACCOUNT) revert PluginsLimitReachedForAccount(); 115 | 116 | emit PluginAdded(account, plugin); 117 | uint256 balance = balanceOf(account); 118 | if (balance > 0) { 119 | _updateBalances(plugin, address(0), account, balance); 120 | } 121 | } 122 | 123 | function _removePlugin(address account, address plugin) internal virtual { 124 | if (!_plugins[account].remove(plugin)) revert PluginNotFound(); 125 | 126 | emit PluginRemoved(account, plugin); 127 | uint256 balance = balanceOf(account); 128 | if (balance > 0) { 129 | _updateBalances(plugin, account, address(0), balance); 130 | } 131 | } 132 | 133 | function _removeAllPlugins(address account) internal virtual { 134 | address[] memory pluginItems = _plugins[account].items.get(); 135 | uint256 balance = balanceOf(account); 136 | unchecked { 137 | for (uint256 i = pluginItems.length; i > 0; i--) { 138 | address item = pluginItems[i-1]; 139 | _plugins[account].remove(item); 140 | emit PluginRemoved(account, item); 141 | if (balance > 0) { 142 | _updateBalances(item, account, address(0), balance); 143 | } 144 | } 145 | } 146 | } 147 | 148 | /// @notice Assembly implementation of the gas limited call to avoid return gas bomb, 149 | // moreover call to a destructed plugin would also revert even inside try-catch block in Solidity 0.8.17 150 | /// @dev try IPlugin(plugin).updateBalances{gas: _PLUGIN_CALL_GAS_LIMIT}(from, to, amount) {} catch {} 151 | function _updateBalances(address plugin, address from, address to, uint256 amount) private { 152 | bytes4 selector = IPlugin.updateBalances.selector; 153 | uint256 gasLimit = PLUGIN_CALL_GAS_LIMIT; 154 | assembly ("memory-safe") { // solhint-disable-line no-inline-assembly 155 | let ptr := mload(0x40) 156 | mstore(ptr, selector) 157 | mstore(add(ptr, 0x04), from) 158 | mstore(add(ptr, 0x24), to) 159 | mstore(add(ptr, 0x44), amount) 160 | 161 | let gasLeft := gas() 162 | if iszero(call(gasLimit, plugin, 0, ptr, 0x64, 0, 0)) { 163 | if lt(div(mul(gasLeft, 63), 64), gasLimit) { 164 | returndatacopy(ptr, 0, returndatasize()) 165 | revert(ptr, returndatasize()) 166 | } 167 | } 168 | } 169 | } 170 | 171 | function _update(address from, address to, uint256 amount) internal nonReentrant(_guard) override virtual { 172 | super._update(from, to, amount); 173 | 174 | unchecked { 175 | if (amount > 0 && from != to) { 176 | address[] memory pluginsFrom = _plugins[from].items.get(); 177 | address[] memory pluginsTo = _plugins[to].items.get(); 178 | uint256 pluginsFromLength = pluginsFrom.length; 179 | uint256 pluginsToLength = pluginsTo.length; 180 | 181 | for (uint256 i = 0; i < pluginsFromLength; i++) { 182 | address plugin = pluginsFrom[i]; 183 | 184 | uint256 j; 185 | for (j = 0; j < pluginsToLength; j++) { 186 | if (plugin == pluginsTo[j]) { 187 | // Both parties are participating in the same plugin 188 | _updateBalances(plugin, from, to, amount); 189 | pluginsTo[j] = address(0); 190 | break; 191 | } 192 | } 193 | 194 | if (j == pluginsToLength) { 195 | // Sender is participating in a plugin, but receiver is not 196 | _updateBalances(plugin, from, address(0), amount); 197 | } 198 | } 199 | 200 | for (uint256 j = 0; j < pluginsToLength; j++) { 201 | address plugin = pluginsTo[j]; 202 | if (plugin != address(0)) { 203 | // Receiver is participating in a plugin, but sender is not 204 | _updateBalances(plugin, address(0), to, amount); 205 | } 206 | } 207 | } 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /contracts/Plugin.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import { IPlugin } from "./interfaces/IPlugin.sol"; 6 | import { IERC20Plugins } from "./interfaces/IERC20Plugins.sol"; 7 | 8 | 9 | /// @dev ERC20 extension enabling external smart contract based plugins to track balances of those users who opted-in to these plugins. 10 | /// Could be useful for farming / DAO voting and every case where you need to track user's balances without moving tokens to another contract. 11 | abstract contract Plugin is IPlugin { 12 | error AccessDenied(); 13 | 14 | IERC20Plugins public immutable TOKEN; 15 | 16 | /// @dev Throws an error if the caller is not the token contract 17 | modifier onlyToken { 18 | if (msg.sender != address(TOKEN)) revert AccessDenied(); 19 | _; 20 | } 21 | 22 | /** 23 | * @dev Creates a new plugin contract, initialized with a reference to the parent token contract. 24 | * @param token_ The address of the token contract 25 | */ 26 | constructor(IERC20Plugins token_) { 27 | TOKEN = token_; 28 | } 29 | 30 | /** 31 | * @notice See {IPlugin-updateBalances}. 32 | */ 33 | function updateBalances(address from, address to, uint256 amount) external onlyToken { 34 | _updateBalances(from, to, amount); 35 | } 36 | 37 | /** 38 | * @dev Updates the balances of two addresses in the plugin as a result of any balance changes. 39 | * Only the Token contract is allowed to call this function. 40 | * @param from The address from which tokens were transferred 41 | * @param to The address to which tokens were transferred 42 | * @param amount The amount of tokens transferred 43 | */ 44 | function _updateBalances(address from, address to, uint256 amount) internal virtual; 45 | } 46 | -------------------------------------------------------------------------------- /contracts/interfaces/IERC20Plugins.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 6 | 7 | interface IERC20Plugins is IERC20 { 8 | event PluginAdded(address account, address plugin); 9 | event PluginRemoved(address account, address plugin); 10 | 11 | error PluginAlreadyAdded(); 12 | error PluginNotFound(); 13 | error InvalidPluginAddress(); 14 | error InvalidTokenInPlugin(); 15 | error PluginsLimitReachedForAccount(); 16 | error ZeroPluginsLimit(); 17 | 18 | /** 19 | * @dev Returns the maximum allowed number of plugins per account. 20 | * @return pluginsLimit The maximum allowed number of plugins per account. 21 | */ 22 | function MAX_PLUGINS_PER_ACCOUNT() external view returns(uint256 pluginsLimit); // solhint-disable-line func-name-mixedcase 23 | 24 | /** 25 | * @dev Returns the gas limit allowed to be spend by plugin per call. 26 | * @return gasLimit The gas limit allowed to be spend by plugin per call. 27 | */ 28 | function PLUGIN_CALL_GAS_LIMIT() external view returns(uint256 gasLimit); // solhint-disable-line func-name-mixedcase 29 | 30 | /** 31 | * @dev Returns whether an account has a specific plugin. 32 | * @param account The address of the account. 33 | * @param plugin The address of the plugin. 34 | * @return hasPlugin A boolean indicating whether the account has the specified plugin. 35 | */ 36 | function hasPlugin(address account, address plugin) external view returns(bool hasPlugin); 37 | 38 | /** 39 | * @dev Returns the number of plugins registered for an account. 40 | * @param account The address of the account. 41 | * @return count The number of plugins registered for the account. 42 | */ 43 | function pluginsCount(address account) external view returns(uint256 count); 44 | 45 | /** 46 | * @dev Returns the address of a plugin at a specified index for a given account. 47 | * The function will revert if index is greater or equal than `pluginsCount(account)`. 48 | * @param account The address of the account. 49 | * @param index The index of the plugin to retrieve. 50 | * @return plugin The address of the plugin. 51 | */ 52 | function pluginAt(address account, uint256 index) external view returns(address plugin); 53 | 54 | /** 55 | * @dev Returns an array of all plugins owned by a given account. 56 | * @param account The address of the account to query. 57 | * @return plugins An array of plugin addresses. 58 | */ 59 | function plugins(address account) external view returns(address[] memory plugins); 60 | 61 | /** 62 | * @dev Returns the balance of a given account if a specified plugin is added or zero. 63 | * @param plugin The address of the plugin to query. 64 | * @param account The address of the account to query. 65 | * @return balance The account balance if the specified plugin is added and zero otherwise. 66 | */ 67 | function pluginBalanceOf(address plugin, address account) external view returns(uint256 balance); 68 | 69 | /** 70 | * @dev Adds a new plugin for the calling account. 71 | * @param plugin The address of the plugin to add. 72 | */ 73 | function addPlugin(address plugin) external; 74 | 75 | /** 76 | * @dev Removes a plugin for the calling account. 77 | * @param plugin The address of the plugin to remove. 78 | */ 79 | function removePlugin(address plugin) external; 80 | 81 | /** 82 | * @dev Removes all plugins for the calling account. 83 | */ 84 | function removeAllPlugins() external; 85 | } 86 | -------------------------------------------------------------------------------- /contracts/interfaces/IPlugin.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import { IERC20Plugins } from "./IERC20Plugins.sol"; 6 | 7 | interface IPlugin { 8 | /** 9 | * @dev Returns the token which this plugin belongs to. 10 | * @return erc20 The IERC20Plugins token. 11 | */ 12 | function TOKEN() external view returns(IERC20Plugins erc20); // solhint-disable-line func-name-mixedcase 13 | 14 | /** 15 | * @dev Updates the balances of two addresses in the plugin as a result of any balance changes. 16 | * Only the Token contract is allowed to call this function. 17 | * @param from The address from which tokens were transferred. 18 | * @param to The address to which tokens were transferred. 19 | * @param amount The amount of tokens transferred. 20 | */ 21 | function updateBalances(address from, address to, uint256 amount) external; 22 | } 23 | -------------------------------------------------------------------------------- /contracts/libs/ReentrancyGuard.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | // solhint-disable one-contract-per-file 4 | 5 | pragma solidity ^0.8.0; 6 | 7 | /** 8 | * @title ReentrancyGuardLib 9 | * @dev Library that provides reentrancy protection for functions. 10 | */ 11 | library ReentrancyGuardLib { 12 | 13 | /// @dev Emit when reentrancy detected 14 | error ReentrantCall(); 15 | 16 | uint256 private constant _NOT_ENTERED = 1; 17 | uint256 private constant _ENTERED = 2; 18 | 19 | /// @dev Struct to hold the current status of the contract. 20 | struct Data { 21 | uint256 _status; 22 | } 23 | 24 | /** 25 | * @dev Initializes the struct with the current status set to not entered. 26 | * @param self The storage reference to the struct. 27 | */ 28 | function init(Data storage self) internal { 29 | self._status = _NOT_ENTERED; 30 | } 31 | 32 | /** 33 | * @dev Sets the status to entered if it is not already entered, otherwise reverts. 34 | * @param self The storage reference to the struct. 35 | */ 36 | function enter(Data storage self) internal { 37 | if (self._status == _ENTERED) revert ReentrantCall(); 38 | self._status = _ENTERED; 39 | } 40 | 41 | /** 42 | * @dev Resets the status to not entered. 43 | * @param self The storage reference to the struct. 44 | */ 45 | function exit(Data storage self) internal { 46 | self._status = _NOT_ENTERED; 47 | } 48 | 49 | /** 50 | * @dev Checks the current status of the contract to ensure that it is not already entered. 51 | * @param self The storage reference to the struct. 52 | * @return Whether or not the contract is currently entered. 53 | */ 54 | function check(Data storage self) internal view returns (bool) { 55 | return self._status == _ENTERED; 56 | } 57 | } 58 | 59 | /** 60 | * @title ReentrancyGuardExt 61 | * @dev Contract that uses the ReentrancyGuardLib to provide reentrancy protection. 62 | */ 63 | contract ReentrancyGuardExt { 64 | using ReentrancyGuardLib for ReentrancyGuardLib.Data; 65 | 66 | /** 67 | * @dev Modifier that prevents a contract from calling itself, directly or indirectly. 68 | * @param self The storage reference to the struct. 69 | */ 70 | modifier nonReentrant(ReentrancyGuardLib.Data storage self) { 71 | self.enter(); 72 | _; 73 | self.exit(); 74 | } 75 | 76 | /** 77 | * @dev Modifier that prevents calls to a function from `nonReentrant` functions, directly or indirectly. 78 | * @param self The storage reference to the struct. 79 | */ 80 | modifier nonReentrantView(ReentrancyGuardLib.Data storage self) { 81 | if (self.check()) revert ReentrancyGuardLib.ReentrantCall(); 82 | _; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /contracts/mocks/BadPluginMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 6 | import { IERC20Plugins, Plugin } from "../Plugin.sol"; 7 | 8 | contract BadPluginMock is ERC20, Plugin { 9 | error PluginsUpdateBalanceRevert(); 10 | 11 | bool public isRevert; 12 | bool public isOutOfGas; 13 | bool public isReturnGasBomb; 14 | 15 | constructor(string memory name, string memory symbol, IERC20Plugins token_) ERC20(name, symbol) Plugin(token_) {} // solhint-disable-line no-empty-blocks 16 | 17 | function _updateBalances(address /*from*/, address /*to*/, uint256 /*amount*/) internal view override { 18 | if (isRevert) revert PluginsUpdateBalanceRevert(); 19 | if (isOutOfGas) assert(false); 20 | if (isReturnGasBomb) { assembly ("memory-safe") { return(0, 1000000) } } // solhint-disable-line no-inline-assembly 21 | } 22 | 23 | function setIsRevert(bool isRevert_) external { 24 | isRevert = isRevert_; 25 | } 26 | 27 | function setOutOfGas(bool isOutOfGas_) external { 28 | isOutOfGas = isOutOfGas_; 29 | } 30 | 31 | function setReturnGasBomb(bool isReturnGasBomb_) external { 32 | isReturnGasBomb = isReturnGasBomb_; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /contracts/mocks/ERC20PluginsMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 6 | import { ERC20Plugins } from "../ERC20Plugins.sol"; 7 | 8 | contract ERC20PluginsMock is ERC20Plugins { 9 | constructor(string memory name, string memory symbol, uint256 maxPluginsPerAccount, uint256 pluginCallGasLimit) 10 | ERC20(name, symbol) 11 | ERC20Plugins(maxPluginsPerAccount, pluginCallGasLimit) 12 | {} // solhint-disable-line no-empty-blocks 13 | 14 | function mint(address account, uint256 amount) external { 15 | _mint(account, amount); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /contracts/mocks/GasLimitedPluginMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 6 | import { IERC20Plugins, Plugin } from "../Plugin.sol"; 7 | 8 | contract GasLimitedPluginMock is ERC20, Plugin { 9 | error InsufficientGas(); 10 | 11 | uint256 public immutable GAS_LIMIT; 12 | 13 | constructor(uint256 gasLimit_, IERC20Plugins token) 14 | ERC20(type(GasLimitedPluginMock).name, "GLPM") 15 | Plugin(token) 16 | { 17 | GAS_LIMIT = gasLimit_; 18 | } 19 | 20 | function _updateBalances(address from, address to, uint256 amount) internal override { 21 | if (from == address(0)) { 22 | _mint(to, amount); 23 | } else if (to == address(0)) { 24 | _burn(from, amount); 25 | } else { 26 | _transfer(from, to, amount); 27 | } 28 | 29 | if (gasleft() < GAS_LIMIT) { 30 | revert InsufficientGas(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /contracts/mocks/PluginMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 6 | import { IERC20Plugins, Plugin } from "../Plugin.sol"; 7 | 8 | contract PluginMock is ERC20, Plugin { 9 | constructor(string memory name, string memory symbol, IERC20Plugins token_) 10 | ERC20(name, symbol) 11 | Plugin(token_) 12 | {} // solhint-disable-line no-empty-blocks 13 | 14 | function _updateBalances(address from, address to, uint256 amount) internal override { 15 | if (from == address(0)) { 16 | _mint(to, amount); 17 | } else if (to == address(0)) { 18 | _burn(from, amount); 19 | } else { 20 | _transfer(from, to, amount); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /hardhat.config.js: -------------------------------------------------------------------------------- 1 | require('@nomicfoundation/hardhat-ethers'); 2 | require('@nomicfoundation/hardhat-verify'); 3 | require('@nomicfoundation/hardhat-chai-matchers'); 4 | require('solidity-coverage'); 5 | require('hardhat-deploy'); 6 | require('hardhat-gas-reporter'); 7 | require('dotenv').config(); 8 | const { Networks, getNetwork } = require('@1inch/solidity-utils/hardhat-setup'); 9 | 10 | const { networks, etherscan } = (new Networks()).registerAll(); 11 | 12 | module.exports = { 13 | etherscan, 14 | solidity: { 15 | version: '0.8.23', 16 | settings: { 17 | optimizer: { 18 | enabled: true, 19 | runs: 1000000, 20 | }, 21 | evmVersion: networks[getNetwork()]?.hardfork || 'shanghai', 22 | viaIR: true, 23 | }, 24 | }, 25 | networks, 26 | namedAccounts: { 27 | deployer: { 28 | default: 0, 29 | }, 30 | }, 31 | gasReporter: { 32 | enable: true, 33 | currency: 'USD', 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@1inch/token-plugins", 3 | "version": "1.3.0", 4 | "description": "ERC20 extension enabling external smart contract based plugins to track balances of those users who opted-in to those plugins", 5 | "repository": { 6 | "type": "git", 7 | "url": "git@github.com:1inch/token-plugins.git" 8 | }, 9 | "bugs": { 10 | "url": "https://github.com/1inch/token-plugins/issues" 11 | }, 12 | "homepage": "https://github.com/1inch/token-plugins#readme", 13 | "author": "1inch", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@1inch/solidity-utils": "3.5.5", 17 | "@openzeppelin/contracts": "5.0.1" 18 | }, 19 | "devDependencies": { 20 | "@nomicfoundation/hardhat-chai-matchers": "2.0.2", 21 | "@nomicfoundation/hardhat-ethers": "3.0.5", 22 | "@nomicfoundation/hardhat-verify": "2.0.2", 23 | "@openzeppelin/test-helpers": "0.5.16", 24 | "chai": "4.3.10", 25 | "dotenv": "16.3.1", 26 | "eslint": "8.56.0", 27 | "eslint-config-standard": "17.1.0", 28 | "eslint-plugin-import": "2.29.1", 29 | "eslint-plugin-n": "16.4.0", 30 | "eslint-plugin-promise": "6.1.1", 31 | "ethers": "6.9.0", 32 | "hardhat": "2.19.2", 33 | "hardhat-deploy": "0.11.45", 34 | "hardhat-gas-reporter": "1.0.9", 35 | "rimraf": "5.0.5", 36 | "solhint": "3.6.2", 37 | "solidity-coverage": "0.8.5" 38 | }, 39 | "scripts": { 40 | "clean": "rimraf artifacts cache coverage coverage.json contracts/hardhat-dependency-compiler", 41 | "coverage": "hardhat coverage", 42 | "deploy": "hardhat deploy --network", 43 | "lint": "yarn run lint:js && yarn run lint:sol", 44 | "lint:fix": "yarn run lint:js:fix && yarn run lint:sol:fix", 45 | "lint:js": "eslint .", 46 | "lint:js:fix": "eslint . --fix", 47 | "lint:sol": "solhint --max-warnings 0 \"contracts/**/*.sol\"", 48 | "lint:sol:fix": "solhint --max-warnings 0 \"contracts/**/*.sol\" --fix", 49 | "test": "hardhat test --parallel", 50 | "test:ci": "hardhat test" 51 | }, 52 | "files": [ 53 | "contracts", 54 | "test/behaviors/*.js" 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /src/img/PluginsDiagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1inch/token-plugins/48d0c29c6acc32e19cb01266e79360a7e6fc8deb/src/img/PluginsDiagram.png -------------------------------------------------------------------------------- /src/img/TokenTransferDiagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1inch/token-plugins/48d0c29c6acc32e19cb01266e79360a7e6fc8deb/src/img/TokenTransferDiagram.png -------------------------------------------------------------------------------- /src/img/_updateBalances2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1inch/token-plugins/48d0c29c6acc32e19cb01266e79360a7e6fc8deb/src/img/_updateBalances2.png -------------------------------------------------------------------------------- /src/img/scheme-n1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1inch/token-plugins/48d0c29c6acc32e19cb01266e79360a7e6fc8deb/src/img/scheme-n1.png -------------------------------------------------------------------------------- /test/ERC20Plugins.js: -------------------------------------------------------------------------------- 1 | const { expect, ether } = require('@1inch/solidity-utils'); 2 | const hre = require('hardhat'); 3 | const { ethers } = hre; 4 | const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); 5 | const { shouldBehaveLikeERC20Plugins, shouldBehaveLikeERC20PluginsTransfers } = require('./behaviors/ERC20Plugins.behavior'); 6 | 7 | const PLUGIN_COUNT_LIMITS = 10; 8 | const PLUGIN_GAS_LIMIT = 200_000; 9 | 10 | describe('ERC20Plugins', function () { 11 | let wallet1; 12 | 13 | before(async function () { 14 | [wallet1] = await ethers.getSigners(); 15 | }); 16 | 17 | async function initContracts () { 18 | const ERC20PluginsMock = await ethers.getContractFactory('ERC20PluginsMock'); 19 | const erc20Plugins = await ERC20PluginsMock.deploy('ERC20PluginsMock', 'EPM', PLUGIN_COUNT_LIMITS, PLUGIN_GAS_LIMIT); 20 | await erc20Plugins.waitForDeployment(); 21 | 22 | const amount = ether('1'); 23 | await erc20Plugins.mint(wallet1, amount); 24 | return { erc20Plugins, PLUGIN_COUNT_LIMITS, amount }; 25 | }; 26 | 27 | async function initPlugin () { 28 | const { erc20Plugins, amount } = await initContracts(); 29 | const PluginMock = await ethers.getContractFactory('PluginMock'); 30 | const plugin = await PluginMock.deploy('PluginMock', 'PM', erc20Plugins); 31 | await plugin.waitForDeployment(); 32 | return { erc20Plugins, plugin, amount }; 33 | }; 34 | 35 | async function initWrongPlugin () { 36 | const { erc20Plugins, amount } = await initContracts(); 37 | const BadPluginMock = await ethers.getContractFactory('BadPluginMock'); 38 | const wrongPlugin = await BadPluginMock.deploy('BadPluginMock', 'WPM', erc20Plugins); 39 | await wrongPlugin.waitForDeployment(); 40 | return { erc20Plugins, wrongPlugin, amount }; 41 | }; 42 | 43 | async function initGasLimitPluginMock () { 44 | const { erc20Plugins, amount } = await initContracts(); 45 | const GasLimitedPluginMock = await ethers.getContractFactory('GasLimitedPluginMock'); 46 | const gasLimitPluginMock = await GasLimitedPluginMock.deploy(100_000, erc20Plugins); 47 | await gasLimitPluginMock.waitForDeployment(); 48 | return { erc20Plugins, gasLimitPluginMock, amount }; 49 | }; 50 | 51 | shouldBehaveLikeERC20Plugins(initContracts); 52 | 53 | shouldBehaveLikeERC20PluginsTransfers(initContracts); 54 | 55 | it('should work with MockPlugin with small gas limit', async function () { 56 | const { erc20Plugins, plugin } = await loadFixture(initPlugin); 57 | const estimateGas = await erc20Plugins.addPlugin.estimateGas(plugin); 58 | expect(estimateGas).to.be.lt(PLUGIN_GAS_LIMIT); 59 | 60 | const receipt = await (await erc20Plugins.addPlugin(plugin, { gasLimit: estimateGas })).wait(); 61 | expect(receipt.gasUsed).to.be.lt(PLUGIN_GAS_LIMIT); 62 | 63 | expect(await erc20Plugins.plugins(wallet1)).to.have.deep.equals([await plugin.getAddress()]); 64 | }); 65 | 66 | it('should not fail when updateBalance returns gas bomb', async function () { 67 | if (hre.__SOLIDITY_COVERAGE_RUNNING) { this.skip(); } 68 | const { erc20Plugins, wrongPlugin } = await loadFixture(initWrongPlugin); 69 | await wrongPlugin.setReturnGasBomb(true); 70 | const receipt = await (await erc20Plugins.addPlugin(wrongPlugin)).wait(); 71 | expect(receipt.gasUsed).to.be.lt(PLUGIN_GAS_LIMIT * 2); 72 | expect(await erc20Plugins.plugins(wallet1)).to.have.deep.equals([await wrongPlugin.getAddress()]); 73 | }); 74 | 75 | it('should handle low-gas-related reverts in plugins', async function () { 76 | if (hre.__SOLIDITY_COVERAGE_RUNNING) { this.skip(); } 77 | const { erc20Plugins, gasLimitPluginMock } = await loadFixture(initGasLimitPluginMock); 78 | 79 | const estimateGas = await erc20Plugins.addPlugin.estimateGas(gasLimitPluginMock); 80 | expect(estimateGas).to.be.lt(PLUGIN_GAS_LIMIT * 2); 81 | 82 | const receipt = await (await erc20Plugins.addPlugin(gasLimitPluginMock, { gasLimit: estimateGas })).wait(); 83 | expect(receipt.gasUsed).to.be.lt(PLUGIN_GAS_LIMIT * 2); 84 | 85 | expect(await erc20Plugins.plugins(wallet1)).to.have.deep.equals( 86 | [await gasLimitPluginMock.getAddress()], 87 | ); 88 | }); 89 | 90 | it('should fail with low-gas-related reverts in plugins', async function () { 91 | if (hre.__SOLIDITY_COVERAGE_RUNNING) { this.skip(); } 92 | const { erc20Plugins, gasLimitPluginMock } = await loadFixture(initGasLimitPluginMock); 93 | 94 | await expect(erc20Plugins.addPlugin(gasLimitPluginMock, { gasLimit: PLUGIN_GAS_LIMIT })) 95 | .to.be.revertedWithCustomError(gasLimitPluginMock, 'InsufficientGas'); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /test/behaviors/ERC20Plugins.behavior.js: -------------------------------------------------------------------------------- 1 | const { expect, constants } = require('@1inch/solidity-utils'); 2 | const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); 3 | const { ethers } = require('hardhat'); 4 | 5 | function shouldBehaveLikeERC20Plugins (initContracts) { 6 | // Behavior test scenarios 7 | describe('should behave like ERC20 plugins', function () { 8 | let wallet1, wallet2; 9 | 10 | before(async function () { 11 | [wallet1, wallet2] = await ethers.getSigners(); 12 | }); 13 | 14 | async function initAndCreatePlugins () { 15 | const { erc20Plugins, PLUGIN_COUNT_LIMITS, amount } = await initContracts(); 16 | 17 | const PluginMock = await ethers.getContractFactory('PluginMock'); 18 | const plugins = []; 19 | for (let i = 0; i < PLUGIN_COUNT_LIMITS; i++) { 20 | plugins[i] = await PluginMock.deploy(`PLUGIN_TOKEN_${i}`, `PT${i}`, erc20Plugins); 21 | await plugins[i].waitForDeployment(); 22 | } 23 | return { erc20Plugins, plugins, amount }; 24 | } 25 | 26 | async function initAndAddAllPlugins () { 27 | const { erc20Plugins, plugins, amount } = await initAndCreatePlugins(); 28 | for (let i = 0; i < plugins.length; i++) { 29 | await erc20Plugins.connect(wallet1).addPlugin(plugins[i]); 30 | await erc20Plugins.connect(wallet2).addPlugin(plugins[i]); 31 | } 32 | return { erc20Plugins, plugins, amount }; 33 | } 34 | 35 | async function initAndAddOnePlugin () { 36 | const { erc20Plugins, plugins, amount } = await initAndCreatePlugins(); 37 | await erc20Plugins.connect(wallet1).addPlugin(plugins[0]); 38 | await erc20Plugins.connect(wallet2).addPlugin(plugins[0]); 39 | return { erc20Plugins, plugins, amount }; 40 | }; 41 | 42 | async function initWrongPlugin () { 43 | const { erc20Plugins, amount } = await initContracts(); 44 | const BadPluginMock = await ethers.getContractFactory('BadPluginMock'); 45 | const wrongPlugin = await BadPluginMock.deploy('BadPluginMock', 'WPM', erc20Plugins); 46 | await wrongPlugin.waitForDeployment(); 47 | return { erc20Plugins, wrongPlugin, amount }; 48 | }; 49 | 50 | describe('view methods', function () { 51 | it('hasPlugin should return true when plugin added by wallet', async function () { 52 | const { erc20Plugins, plugins } = await loadFixture(initAndCreatePlugins); 53 | await erc20Plugins.addPlugin(plugins[0]); 54 | expect(await erc20Plugins.hasPlugin(wallet1, plugins[0])).to.be.true; 55 | expect(await erc20Plugins.hasPlugin(wallet2, plugins[0])).to.be.false; 56 | }); 57 | 58 | it('pluginsCount should return plugins amount which wallet using', async function () { 59 | const { erc20Plugins, plugins } = await loadFixture(initAndCreatePlugins); 60 | for (let i = 0; i < plugins.length; i++) { 61 | await erc20Plugins.addPlugin(plugins[i]); 62 | expect(await erc20Plugins.pluginsCount(wallet1)).to.be.equals(i + 1); 63 | } 64 | for (let i = 0; i < plugins.length; i++) { 65 | await erc20Plugins.removePlugin(plugins[i]); 66 | expect(await erc20Plugins.pluginsCount(wallet1)).to.be.equals(plugins.length - (i + 1)); 67 | } 68 | }); 69 | 70 | it('pluginAt should return plugin by added plugins index', async function () { 71 | const { erc20Plugins, plugins } = await loadFixture(initAndCreatePlugins); 72 | for (let i = 0; i < plugins.length; i++) { 73 | await erc20Plugins.addPlugin(plugins[i]); 74 | expect(await erc20Plugins.pluginAt(wallet1, i)).to.be.equals(await plugins[i].getAddress()); 75 | expect(await erc20Plugins.pluginAt(wallet1, i + 1)).to.be.equals(constants.ZERO_ADDRESS); 76 | } 77 | for (let i = plugins.length - 1; i >= 0; i--) { 78 | await erc20Plugins.removePlugin(plugins[i]); 79 | for (let j = 0; j < plugins.length; j++) { 80 | expect(await erc20Plugins.pluginAt(wallet1, j)) 81 | .to.be.equals( 82 | j >= i 83 | ? constants.ZERO_ADDRESS 84 | : await plugins[j].getAddress(), 85 | ); 86 | }; 87 | } 88 | }); 89 | 90 | it('plugins should return array of plugins by wallet', async function () { 91 | const { erc20Plugins, plugins } = await loadFixture(initAndCreatePlugins); 92 | const pluginsAddrs = await Promise.all(plugins.map(plugin => plugin.getAddress())); 93 | for (let i = 0; i < plugins.length; i++) { 94 | await erc20Plugins.addPlugin(plugins[i]); 95 | expect(await erc20Plugins.plugins(wallet1)).to.be.deep.equals(pluginsAddrs.slice(0, i + 1)); 96 | } 97 | }); 98 | 99 | describe('pluginBalanceOf', function () { 100 | it('should not return balance for non-added plugin', async function () { 101 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndCreatePlugins); 102 | expect(await erc20Plugins.balanceOf(wallet1)).to.be.equals(amount); 103 | expect(await erc20Plugins.pluginBalanceOf(plugins[0], wallet1)).to.be.equals('0'); 104 | }); 105 | 106 | it('should return balance for added plugin', async function () { 107 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndCreatePlugins); 108 | await erc20Plugins.addPlugin(plugins[0]); 109 | expect(await erc20Plugins.balanceOf(wallet1)).to.be.equals(amount); 110 | expect(await erc20Plugins.pluginBalanceOf(plugins[0], wallet1)).to.be.equals(amount); 111 | }); 112 | 113 | it('should not return balance for removed plugin', async function () { 114 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndCreatePlugins); 115 | await erc20Plugins.addPlugin(plugins[0]); 116 | await erc20Plugins.removePlugin(plugins[0]); 117 | expect(await erc20Plugins.balanceOf(wallet1)).to.be.equals(amount); 118 | expect(await erc20Plugins.pluginBalanceOf(plugins[0], wallet1)).to.be.equals('0'); 119 | }); 120 | }); 121 | }); 122 | 123 | describe('addPlugin', function () { 124 | it('should not add plugin with zero-address', async function () { 125 | const { erc20Plugins } = await loadFixture(initContracts); 126 | await expect(erc20Plugins.addPlugin(constants.ZERO_ADDRESS)) 127 | .to.be.revertedWithCustomError(erc20Plugins, 'InvalidPluginAddress'); 128 | }); 129 | 130 | it('should add plugin', async function () { 131 | const { erc20Plugins, plugins } = await loadFixture(initAndCreatePlugins); 132 | expect(await erc20Plugins.hasPlugin(wallet1, plugins[0])).to.be.false; 133 | await erc20Plugins.addPlugin(plugins[0]); 134 | expect(await erc20Plugins.hasPlugin(wallet1, plugins[0])).to.be.true; 135 | }); 136 | 137 | it('should not add plugin twice from one wallet', async function () { 138 | const { erc20Plugins, plugins } = await loadFixture(initAndCreatePlugins); 139 | await erc20Plugins.addPlugin(plugins[0]); 140 | await expect(erc20Plugins.addPlugin(plugins[0])) 141 | .to.be.revertedWithCustomError(erc20Plugins, 'PluginAlreadyAdded'); 142 | }); 143 | 144 | it('should add the same plugin for different wallets', async function () { 145 | const { erc20Plugins, plugins } = await loadFixture(initAndCreatePlugins); 146 | expect(await erc20Plugins.hasPlugin(wallet1, plugins[0])).to.be.false; 147 | expect(await erc20Plugins.hasPlugin(wallet2, plugins[0])).to.be.false; 148 | await erc20Plugins.addPlugin(plugins[0]); 149 | await erc20Plugins.connect(wallet2).addPlugin(plugins[0]); 150 | expect(await erc20Plugins.hasPlugin(wallet1, plugins[0])).to.be.true; 151 | expect(await erc20Plugins.hasPlugin(wallet2, plugins[0])).to.be.true; 152 | }); 153 | 154 | it('should add different plugin', async function () { 155 | const { erc20Plugins, plugins } = await loadFixture(initAndCreatePlugins); 156 | expect(await erc20Plugins.hasPlugin(wallet1, plugins[0])).to.be.false; 157 | expect(await erc20Plugins.hasPlugin(wallet1, plugins[1])).to.be.false; 158 | await erc20Plugins.addPlugin(plugins[0]); 159 | await erc20Plugins.addPlugin(plugins[1]); 160 | expect(await erc20Plugins.plugins(wallet1)).to.have.deep.equals([await plugins[0].getAddress(), await plugins[1].getAddress()]); 161 | }); 162 | 163 | it('should updateBalance via plugin only for wallets with non-zero balance', async function () { 164 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndCreatePlugins); 165 | expect(await erc20Plugins.balanceOf(wallet1)).to.be.equals(amount); 166 | expect(await erc20Plugins.balanceOf(wallet2)).to.be.equals('0'); 167 | // addPlugin for wallet with balance 168 | expect(await plugins[0].balanceOf(wallet1)).to.be.equals('0'); 169 | await erc20Plugins.addPlugin(plugins[0]); 170 | expect(await plugins[0].balanceOf(wallet1)).to.be.equals(amount); 171 | // addPlugin for wallet without balance 172 | expect(await plugins[0].balanceOf(wallet2)).to.be.equals('0'); 173 | await erc20Plugins.connect(wallet2).addPlugin(plugins[0]); 174 | expect(await plugins[0].balanceOf(wallet2)).to.be.equals('0'); 175 | }); 176 | }); 177 | 178 | describe('removePlugin', function () { 179 | it('should not remove non-added plugin', async function () { 180 | const { erc20Plugins, plugins } = await loadFixture(initAndAddOnePlugin); 181 | await expect(erc20Plugins.removePlugin(plugins[1])) 182 | .to.be.revertedWithCustomError(erc20Plugins, 'PluginNotFound'); 183 | }); 184 | 185 | it('should remove plugin', async function () { 186 | const { erc20Plugins, plugins } = await loadFixture(initAndAddOnePlugin); 187 | expect(await erc20Plugins.hasPlugin(wallet1, plugins[0])).to.be.true; 188 | await erc20Plugins.removePlugin(plugins[0]); 189 | expect(await erc20Plugins.hasPlugin(wallet1, plugins[0])).to.be.false; 190 | }); 191 | 192 | it('should updateBalance via plugin only for wallets with non-zero balance', async function () { 193 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndAddOnePlugin); 194 | expect(await erc20Plugins.balanceOf(wallet1)).to.be.equals(amount); 195 | expect(await erc20Plugins.balanceOf(wallet2)).to.be.equals('0'); 196 | // removePlugin for wallet with balance 197 | await erc20Plugins.removePlugin(plugins[0]); 198 | expect(await plugins[0].balanceOf(wallet1)).to.be.equals('0'); 199 | // removePlugin for wallet without balance 200 | await erc20Plugins.connect(wallet2).removePlugin(plugins[0]); 201 | expect(await plugins[0].balanceOf(wallet2)).to.be.equals('0'); 202 | }); 203 | }); 204 | 205 | describe('removeAllPlugins', function () { 206 | it('should remove all plugins', async function () { 207 | const { erc20Plugins, plugins } = await loadFixture(initAndAddAllPlugins); 208 | expect(await erc20Plugins.pluginsCount(wallet1)).to.be.equals(plugins.length); 209 | await erc20Plugins.removeAllPlugins(); 210 | expect(await erc20Plugins.pluginsCount(wallet1)).to.be.equals(0); 211 | }); 212 | }); 213 | 214 | describe('_updateBalances', function () { 215 | it('should not fail when updateBalance in plugin reverts', async function () { 216 | const { erc20Plugins, wrongPlugin } = await loadFixture(initWrongPlugin); 217 | await wrongPlugin.setIsRevert(true); 218 | await erc20Plugins.addPlugin(wrongPlugin); 219 | expect(await erc20Plugins.plugins(wallet1)).to.have.deep.equals([await wrongPlugin.getAddress()]); 220 | }); 221 | 222 | it('should not fail when updateBalance in plugin has OutOfGas', async function () { 223 | const { erc20Plugins, wrongPlugin } = await loadFixture(initWrongPlugin); 224 | await wrongPlugin.setOutOfGas(true); 225 | await erc20Plugins.addPlugin(wrongPlugin); 226 | expect(await erc20Plugins.plugins(wallet1)).to.have.deep.equals([await wrongPlugin.getAddress()]); 227 | }); 228 | }); 229 | 230 | it('should not add more plugins than limit', async function () { 231 | const { erc20Plugins, plugins } = await loadFixture(initAndCreatePlugins); 232 | const maxPluginsPerAccount = await erc20Plugins.MAX_PLUGINS_PER_ACCOUNT(); 233 | for (let i = 0; i < maxPluginsPerAccount; i++) { 234 | await erc20Plugins.addPlugin(plugins[i]); 235 | } 236 | 237 | const PluginMock = await ethers.getContractFactory('PluginMock'); 238 | const extraPlugin = await PluginMock.deploy('EXTRA_PLUGIN_TOKEN', 'EPT', erc20Plugins); 239 | await extraPlugin.waitForDeployment(); 240 | 241 | await expect(erc20Plugins.addPlugin(extraPlugin)) 242 | .to.be.revertedWithCustomError(erc20Plugins, 'PluginsLimitReachedForAccount'); 243 | }); 244 | }); 245 | }; 246 | 247 | function shouldBehaveLikeERC20PluginsTransfers (initContracts) { 248 | // Behavior test scenarios 249 | describe('transfers should behave like ERC20 plugins transfers', function () { 250 | let wallet1, wallet2, wallet3; 251 | 252 | before(async function () { 253 | [wallet1, wallet2, wallet3] = await ethers.getSigners(); 254 | }); 255 | 256 | async function initAndCreatePlugins () { 257 | const { erc20Plugins, PLUGIN_COUNT_LIMITS, amount } = await initContracts(); 258 | 259 | const PluginMock = await ethers.getContractFactory('PluginMock'); 260 | const plugins = []; 261 | for (let i = 0; i < PLUGIN_COUNT_LIMITS; i++) { 262 | plugins[i] = await PluginMock.deploy(`PLUGIN_TOKEN_${i}`, `PT${i}`, erc20Plugins); 263 | await plugins[i].waitForDeployment(); 264 | } 265 | return { erc20Plugins, plugins, amount }; 266 | } 267 | 268 | async function initAndAddPlugins () { 269 | const { erc20Plugins, plugins, amount } = await initAndCreatePlugins(); 270 | for (let i = 0; i < plugins.length; i++) { 271 | await erc20Plugins.connect(wallet1).addPlugin(plugins[i]); 272 | } 273 | return { erc20Plugins, plugins, amount }; 274 | }; 275 | 276 | describe('_afterTokenTransfer', function () { 277 | it('should not affect when amount is zero', async function () { 278 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndAddPlugins); 279 | for (let i = 0; i < plugins.length; i++) { 280 | expect(await plugins[i].balanceOf(wallet1)).to.be.equals(amount); 281 | expect(await plugins[i].balanceOf(wallet2)).to.be.equals('0'); 282 | } 283 | await erc20Plugins.transfer(wallet2, '0'); 284 | for (let i = 0; i < plugins.length; i++) { 285 | expect(await plugins[i].balanceOf(wallet1)).to.be.equals(amount); 286 | expect(await plugins[i].balanceOf(wallet2)).to.be.equals('0'); 287 | } 288 | }); 289 | 290 | it('should not affect when sender equals to recipient', async function () { 291 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndAddPlugins); 292 | await erc20Plugins.transfer(wallet1, amount); 293 | for (let i = 0; i < plugins.length; i++) { 294 | expect(await plugins[i].balanceOf(wallet1)).to.be.equals(amount); 295 | } 296 | }); 297 | 298 | it('should not affect recipient and affect sender: recipient without plugins, sender with plugins', async function () { 299 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndAddPlugins); 300 | const wallet1beforeBalance = await erc20Plugins.balanceOf(wallet1); 301 | const wallet2beforeBalance = await erc20Plugins.balanceOf(wallet2); 302 | for (let i = 0; i < plugins.length; i++) { 303 | expect(await plugins[i].balanceOf(wallet1)).to.be.equals(amount); 304 | expect(await plugins[i].balanceOf(wallet2)).to.be.equals('0'); 305 | } 306 | await erc20Plugins.transfer(wallet2, amount); 307 | for (let i = 0; i < plugins.length; i++) { 308 | expect(await plugins[i].balanceOf(wallet1)).to.be.equals('0'); 309 | expect(await plugins[i].balanceOf(wallet2)).to.be.equals('0'); 310 | } 311 | expect(await erc20Plugins.balanceOf(wallet1)).to.be.equals(wallet1beforeBalance - amount); 312 | expect(await erc20Plugins.balanceOf(wallet2)).to.be.equals(wallet2beforeBalance + amount); 313 | }); 314 | 315 | it('should affect recipient and not affect sender: recipient with plugins, sender without plugins', async function () { 316 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndAddPlugins); 317 | await erc20Plugins.transfer(wallet2, amount); 318 | for (let i = 0; i < plugins.length; i++) { 319 | expect(await plugins[i].balanceOf(wallet1)).to.be.equals('0'); 320 | expect(await plugins[i].balanceOf(wallet2)).to.be.equals('0'); 321 | } 322 | const wallet1beforeBalance = await erc20Plugins.balanceOf(wallet1); 323 | const wallet2beforeBalance = await erc20Plugins.balanceOf(wallet2); 324 | await erc20Plugins.connect(wallet2).transfer(wallet1, amount); 325 | for (let i = 0; i < plugins.length; i++) { 326 | expect(await plugins[i].balanceOf(wallet1)).to.be.equals(amount); 327 | expect(await plugins[i].balanceOf(wallet2)).to.be.equals('0'); 328 | } 329 | expect(await erc20Plugins.balanceOf(wallet1)).to.be.equals(wallet1beforeBalance + amount); 330 | expect(await erc20Plugins.balanceOf(wallet2)).to.be.equals(wallet2beforeBalance - amount); 331 | }); 332 | 333 | it('should not affect recipient and sender: recipient without plugins, sender without plugins', async function () { 334 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndAddPlugins); 335 | await erc20Plugins.mint(wallet2, amount); 336 | const wallet2beforeBalance = await erc20Plugins.balanceOf(wallet2); 337 | const wallet3beforeBalance = await erc20Plugins.balanceOf(wallet3); 338 | await erc20Plugins.connect(wallet2).transfer(wallet3, amount); 339 | for (let i = 0; i < plugins.length; i++) { 340 | expect(await plugins[i].balanceOf(wallet2)).to.be.equals('0'); 341 | expect(await plugins[i].balanceOf(wallet3)).to.be.equals('0'); 342 | } 343 | expect(await erc20Plugins.balanceOf(wallet2)).to.be.equals(wallet2beforeBalance - amount); 344 | expect(await erc20Plugins.balanceOf(wallet3)).to.be.equals(wallet3beforeBalance + amount); 345 | }); 346 | 347 | it('should affect recipient and sender with different plugins', async function () { 348 | const { erc20Plugins, plugins, amount } = await loadFixture(initAndCreatePlugins); 349 | 350 | const pluginsBalancesBeforeWallet1 = []; 351 | const pluginsBalancesBeforeWallet2 = []; 352 | for (let i = 0; i < plugins.length; i++) { 353 | if (i <= plugins.length / 2 + 2) { 354 | await erc20Plugins.connect(wallet1).addPlugin(plugins[i]); 355 | } 356 | if (i >= plugins.length / 2 - 2) { 357 | await erc20Plugins.connect(wallet2).addPlugin(plugins[i]); 358 | } 359 | pluginsBalancesBeforeWallet1[i] = await plugins[i].balanceOf(wallet1); 360 | pluginsBalancesBeforeWallet2[i] = await plugins[i].balanceOf(wallet2); 361 | } 362 | 363 | const wallet1beforeBalance = await erc20Plugins.balanceOf(wallet1); 364 | const wallet2beforeBalance = await erc20Plugins.balanceOf(wallet2); 365 | 366 | await erc20Plugins.connect(wallet1).transfer(wallet2, amount); 367 | 368 | for (let i = 0; i < plugins.length; i++) { 369 | expect(await plugins[i].balanceOf(wallet1)) 370 | .to.be.equals( 371 | i <= plugins.length / 2 + 2 372 | ? pluginsBalancesBeforeWallet1[i] - amount 373 | : '0', 374 | ); 375 | expect(await plugins[i].balanceOf(wallet2)) 376 | .to.be.equals( 377 | i >= plugins.length / 2 - 2 378 | ? pluginsBalancesBeforeWallet2[i] + amount 379 | : '0', 380 | ); 381 | } 382 | expect(await erc20Plugins.balanceOf(wallet1)).to.be.equals(wallet1beforeBalance - amount); 383 | expect(await erc20Plugins.balanceOf(wallet2)).to.be.equals(wallet2beforeBalance + amount); 384 | }); 385 | }); 386 | }); 387 | }; 388 | 389 | module.exports = { 390 | shouldBehaveLikeERC20Plugins, 391 | shouldBehaveLikeERC20PluginsTransfers, 392 | }; 393 | --------------------------------------------------------------------------------