Skip to content

Commit c3cf7c5

Browse files
Merge pull request #231 from TokenySolutions/BT-360-investor-country-cap-module
BT-360 investor country cap module
2 parents 8f979ae + dc035b6 commit c3cf7c5

File tree

5 files changed

+612
-0
lines changed

5 files changed

+612
-0
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ All notable changes to this project will be documented in this file.
1010
WHITELISTING: investors must whitelist/allow the token address in order to receive it.
1111
BLACKLISTING: investors can receive the token by default. If they do not want to receive it, they need to blacklist/disallow it.
1212

13+
- Introduced **Investor Country Cap Module**: to limit the number of identities per country.
14+
- The module allows the token owner to set a maximum number of identities per country.
15+
1316
- **Default Allowance Mechanism**:
1417
- Introduced a new feature allowing the contract owner to set certain addresses as trusted external smart contracts, enabling them to use `transferFrom` without requiring an explicit allowance from users. By default, users are opted in, allowing these contracts to have an "infinite allowance". Users can opt-out if they prefer to control allowances manually.
1518
- Added custom errors and events to provide better feedback and traceability:
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
// SPDX-License-Identifier: GPL-3.0
2+
// This contract is also licensed under the Creative Commons Attribution-NonCommercial 4.0 International License.
3+
//
4+
// :+#####%%%%%%%%%%%%%%+
5+
// .-*@@@%+.:+%@@@@@%%#***%@@%=
6+
// :=*%@@@#=. :#@@% *@@@%=
7+
// .-+*%@%*-.:+%@@@@@@+. -*+: .=#. :%@@@%-
8+
// :=*@@@@%%@@@@@@@@@%@@@- .=#@@@%@%= =@@@@#.
9+
// -=+#%@@%#*=:. :%@@@@%. -*@@#*@@@@@@@#=:- *@@@@+
10+
// =@@%=:. :=: *@@@@@%#- =%*%@@@@#+-. =+ :%@@@%-
11+
// -@@%. .+@@@ =+=-. @@#- +@@@%- =@@@@%:
12+
// :@@@. .+@@#%: : .=*=-::.-%@@@+*@@= +@@@@#.
13+
// %@@: +@%%* =%@@@@@@@@@@@#. .*@%- +@@@@*.
14+
// #@@= .+@@@@%:=*@@@@@- :%@%: .*@@@@+
15+
// *@@* +@@@#-@@%-:%@@* +@@#. :%@@@@-
16+
// -@@% .:-=++*##%%%@@@@@@@@@@@@*. :@+.@@@%: .#@@+ =@@@@#:
17+
// .@@@*-+*#%%%@@@@@@@@@@@@@@@@%%#**@@%@@@. *@=*@@# :#@%= .#@@@@#-
18+
// -%@@@@@@@@@@@@@@@*+==-:-@@@= *@# .#@*-=*@@@@%= -%@@@* =@@@@@%-
19+
// -+%@@@#. %@%%= -@@:+@: -@@* *@@*-:: -%@@%=. .*@@@@@#
20+
// *@@@* +@* *@@##@@- #@*@@+ -@@= . :+@@@#: .-+@@@%+-
21+
// +@@@%*@@:..=@@@@* .@@@* .#@#. .=+- .=%@@@*. :+#@@@@*=:
22+
// =@@@@%@@@@@@@@@@@@@@@@@@@@@@%- :+#*. :*@@@%=. .=#@@@@%+:
23+
// .%@@= ..... .=#@@+. .#@@@*: -*%@@@@%+.
24+
// +@@#+===---:::... .=%@@*- +@@@+. -*@@@@@%+.
25+
// -@@@@@@@@@@@@@@@@@@@@@@%@@@@= -@@@+ -#@@@@@#=.
26+
// ..:::---===+++***###%%%@@@#- .#@@+ -*@@@@@#=.
27+
// @@@@@@+. +@@*. .+@@@@@%=.
28+
// -@@@@@= =@@%: -#@@@@%+.
29+
// +@@@@@. =@@@= .+@@@@@*:
30+
// #@@@@#:%@@#. :*@@@@#-
31+
// @@@@@%@@@= :#@@@@+.
32+
// :@@@@@@@#.:#@@@%-
33+
// +@@@@@@-.*@@@*:
34+
// #@@@@#.=@@@+.
35+
// @@@@+-%@%=
36+
// :@@@#%@%=
37+
// +@@@@%-
38+
// :#%%=
39+
//
40+
/**
41+
* NOTICE
42+
*
43+
* The T-REX software is licensed under a proprietary license or the GPL v.3.
44+
* If you choose to receive it under the GPL v.3 license, the following applies:
45+
* T-REX is a suite of smart contracts implementing the ERC-3643 standard and
46+
* developed by Tokeny to manage and transfer financial assets on EVM blockchains
47+
*
48+
* Copyright (C) 2024, Tokeny sàrl.
49+
*
50+
* This program is free software: you can redistribute it and/or modify
51+
* it under the terms of the GNU General Public License as published by
52+
* the Free Software Foundation, either version 3 of the License, or
53+
* (at your option) any later version.
54+
*
55+
* This program is distributed in the hope that it will be useful,
56+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
57+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
58+
* GNU General Public License for more details.
59+
*
60+
* You should have received a copy of the GNU General Public License
61+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
62+
*
63+
* This specific smart contract is also licensed under the Creative Commons
64+
* Attribution-NonCommercial 4.0 International License (CC-BY-NC-4.0),
65+
* which prohibits commercial use. For commercial inquiries, please contact
66+
* Tokeny sàrl for licensing options.
67+
*/
68+
69+
pragma solidity 0.8.27;
70+
71+
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
72+
import "./AbstractModuleUpgradeable.sol";
73+
import "../IModularCompliance.sol";
74+
import "../../../token/IToken.sol";
75+
import "../../../roles/AgentRole.sol";
76+
77+
error BatchInitializeTooManyHolders(uint256 holdersCount, uint256 maxHolders);
78+
event CountryCapSet(uint16 indexed country, uint256 cap);
79+
event BypassedIdentityAdded(address indexed identity);
80+
event BypassedIdentityRemoved(address indexed identity);
81+
82+
error ExpectedPause();
83+
error IdentityNotBypassed(address identity);
84+
error CapLowerThanCurrent(uint16 country, uint256 cap, uint256 currentCap);
85+
error WalletCountLimitReached(address identity, uint256 maxWallets);
86+
87+
88+
uint256 constant MAX_WALLET_PER_IDENTITY = 20;
89+
uint256 constant MAX_HOLDERS_BATCH_INITIALIZE = 50;
90+
91+
contract InvestorCountryCapModule is AbstractModuleUpgradeable {
92+
using EnumerableSet for EnumerableSet.AddressSet;
93+
using EnumerableSet for EnumerableSet.UintSet;
94+
95+
struct CountryParams {
96+
bool capped;
97+
uint256 cap;
98+
uint256 count;
99+
mapping(address identity => bool counted) identities;
100+
}
101+
102+
EnumerableSet.UintSet internal _countries;
103+
mapping(address identity => bool bypassed) internal _bypassedIdentities;
104+
105+
mapping(address compliance => mapping(uint16 country => CountryParams params)) internal _countryParams;
106+
mapping(address compliance => mapping(address identity => EnumerableSet.AddressSet wallets)) internal _identityToWallets;
107+
108+
/// @notice Used only during batchInitialize / canComplianceBind
109+
mapping(address token => uint256 supply) public calculatedSupply;
110+
111+
112+
/// @dev initializes the contract and sets the initial state.
113+
/// @notice This function should only be called once during the contract deployment, and after (optionally) batchInitialize.
114+
function initialize() external initializer {
115+
__AbstractModule_init();
116+
}
117+
118+
/// @dev Initialize the module for a compliance and a list of holders
119+
/// @param _compliance Address of the compliance.
120+
/// @param _holders Addresses of the holders already holding tokens (addresses should be unique - no control is done on that).
121+
function batchInitialize(address _compliance, address[] memory _holders) external onlyOwner {
122+
require(
123+
_holders.length < MAX_HOLDERS_BATCH_INITIALIZE,
124+
BatchInitializeTooManyHolders(_holders.length, MAX_HOLDERS_BATCH_INITIALIZE)
125+
);
126+
127+
IToken token = IToken(IModularCompliance(_compliance).getTokenBound());
128+
require(token.paused(), ExpectedPause());
129+
130+
uint256 holdersCount = _holders.length;
131+
for (uint256 i; i < holdersCount; i++) {
132+
address holder = _holders[i];
133+
address idTo = _getIdentity(_compliance, holder);
134+
135+
if (!_bypassedIdentities[idTo]) {
136+
_registerWallet(_compliance, holder, idTo, _getCountry(_compliance, holder));
137+
}
138+
139+
calculatedSupply[address(token)] += token.balanceOf(holder);
140+
}
141+
}
142+
143+
/// @dev Set the cap for a country
144+
/// @param _country Country code
145+
/// @param _cap New cap
146+
function setCountryCap(uint16 _country, uint256 _cap) external onlyComplianceCall {
147+
CountryParams storage params = _countryParams[msg.sender][_country];
148+
149+
// Can't set cap lower than current cap
150+
if (_cap < params.cap) {
151+
revert CapLowerThanCurrent(_country, _cap, params.cap);
152+
}
153+
154+
params.capped = true;
155+
params.cap = _cap;
156+
157+
_countries.add(_country);
158+
159+
emit CountryCapSet(_country, _cap);
160+
}
161+
162+
/// @dev Add an identity to the list of bypassed identities
163+
/// @param _identity Address of the identity
164+
function addBypassedIdentity(address _identity) external onlyComplianceCall {
165+
_bypassedIdentities[_identity] = true;
166+
167+
emit BypassedIdentityAdded(_identity);
168+
}
169+
170+
/// @dev Remove an identity from the list of bypassed identities
171+
/// @param _identity Address of the identity
172+
function removeBypassedIdentity(address _identity) external onlyComplianceCall {
173+
require(_bypassedIdentities[_identity], IdentityNotBypassed(_identity));
174+
_bypassedIdentities[_identity] = false;
175+
176+
emit BypassedIdentityRemoved(_identity);
177+
}
178+
179+
/// @inheritdoc IModule
180+
function moduleBurnAction(address _from, uint256 /*_value*/) external onlyComplianceCall {
181+
address _idFrom = _getIdentity(msg.sender, _from);
182+
183+
uint16 country = _getCountry(msg.sender, _from);
184+
_removeWalletIfNoBalance(_idFrom, country);
185+
}
186+
187+
/// @inheritdoc IModule
188+
function moduleMintAction(address _to, uint256 /*_value*/) external onlyComplianceCall {
189+
address _idTo = _getIdentity(msg.sender, _to);
190+
191+
if (_bypassedIdentities[_idTo]) {
192+
return;
193+
}
194+
195+
uint16 country = _getCountry(msg.sender, _to);
196+
_registerWallet(msg.sender, _to, _idTo, country);
197+
}
198+
199+
/// @inheritdoc IModule
200+
function moduleTransferAction(address _from, address _to, uint256 /*_value*/) external onlyComplianceCall {
201+
address _idTo = _getIdentity(msg.sender, _to);
202+
203+
if (_bypassedIdentities[_idTo]) {
204+
return;
205+
}
206+
207+
uint16 country = _getCountry(msg.sender, _to);
208+
if (!_countryParams[msg.sender][country].capped) {
209+
return;
210+
}
211+
212+
_registerWallet(msg.sender, _to, _idTo, country);
213+
_removeWalletIfNoBalance(_getIdentity(msg.sender, _from), country);
214+
}
215+
216+
/// @inheritdoc IModule
217+
function moduleCheck(address /*_from*/, address _to, uint256 /*_value*/, address _compliance) external view returns (bool) {
218+
address _idTo = _getIdentity(_compliance, _to);
219+
220+
// Bypassed identity are always allowed
221+
if (_bypassedIdentities[_idTo]) {
222+
return true;
223+
}
224+
225+
uint16 country = _getCountry(_compliance, _to);
226+
CountryParams storage params = _countryParams[_compliance][country];
227+
228+
// If country is not capped, allow transfer
229+
if (!params.capped) {
230+
return true;
231+
}
232+
233+
// If identity is not already counted, check cap
234+
if (!params.identities[_idTo]) {
235+
return params.count < params.cap;
236+
}
237+
238+
// Check max wallets per identity
239+
if (!_identityToWallets[_compliance][_idTo].contains(_to)) {
240+
return _identityToWallets[_compliance][_idTo].length() + 1 < MAX_WALLET_PER_IDENTITY;
241+
}
242+
243+
return true;
244+
}
245+
246+
/// @inheritdoc IModule
247+
function canComplianceBind(address _compliance) external view override returns (bool) {
248+
IToken token = IToken(IModularCompliance(_compliance).getTokenBound());
249+
250+
return token.paused() && calculatedSupply[address(token)] == token.totalSupply();
251+
}
252+
253+
/// @inheritdoc IModule
254+
function isPlugAndPlay() public pure override returns (bool) {
255+
return false;
256+
}
257+
258+
/// @inheritdoc IModule
259+
function name() public pure override returns (string memory) {
260+
return "InvestorCountryCapModule";
261+
}
262+
263+
/// @dev Register a wallet for an identity, and check for country change
264+
/// @param _compliance Address of the compliance
265+
/// @param _wallet Address of the wallet
266+
/// @param _identity Address of the identity
267+
/// @param _country Country code
268+
function _registerWallet(address _compliance, address _wallet, address _identity, uint16 _country) internal {
269+
IToken token = IToken(IModularCompliance(_compliance).getTokenBound());
270+
CountryParams storage params = _countryParams[_compliance][_country];
271+
272+
// Register wallet for this country if not already registered
273+
if (!params.identities[_identity]) {
274+
if (token.balanceOf(_wallet) > 0) {
275+
// Wallet has a balance, either:
276+
// - User have several countries (Identity already registered)
277+
// - User country has changed
278+
if (_identityToWallets[_compliance][_identity].length() == 0) {
279+
uint256 countryCount = _countries.length();
280+
for (uint16 i; i < countryCount; i++) {
281+
uint16 otherCountry = uint16(_countries.at(i));
282+
if (otherCountry != _country && _countryParams[_compliance][otherCountry].identities[_identity]) {
283+
// Unlink previous country
284+
_countryParams[_compliance][otherCountry].identities[_identity] = false;
285+
_countryParams[_compliance][otherCountry].count--;
286+
}
287+
}
288+
}
289+
}
290+
291+
params.count++;
292+
params.identities[_identity] = true;
293+
}
294+
295+
_identityToWallets[_compliance][_identity].add(_wallet);
296+
}
297+
298+
/// @dev Remove a wallet from an identity if no balance
299+
/// @param _identity Address of the identity
300+
/// @param _country Country code
301+
function _removeWalletIfNoBalance(address _identity, uint16 _country) internal {
302+
if (_bypassedIdentities[_identity]) {
303+
return;
304+
}
305+
306+
IToken token = IToken(IModularCompliance(msg.sender).getTokenBound());
307+
uint256 walletCount = _identityToWallets[msg.sender][_identity].length();
308+
uint256 balance;
309+
for (uint256 i; i < walletCount; i++) {
310+
balance += token.balanceOf(_identityToWallets[msg.sender][_identity].at(i));
311+
}
312+
313+
// If balance is 0, the identity has no more wallets and should be uncounted
314+
if (balance == 0) {
315+
_countryParams[msg.sender][_country].count--;
316+
_countryParams[msg.sender][_country].identities[_identity] = false;
317+
}
318+
}
319+
320+
/// @dev Returns the country code of the wallet owner
321+
/// @param _compliance Address of the compliance
322+
/// @param _userAddress Address of the wallet
323+
function _getCountry(address _compliance, address _userAddress) internal view returns (uint16) {
324+
return IToken(IModularCompliance(_compliance).getTokenBound()).identityRegistry().investorCountry(_userAddress);
325+
}
326+
327+
/// @dev Returns the ONCHAINID (Identity) of the _userAddress
328+
/// @param _compliance Address of the compliance
329+
/// @param _userAddress Address of the wallet
330+
function _getIdentity(address _compliance, address _userAddress) internal view returns (address) {
331+
return address(IToken(IModularCompliance(_compliance).getTokenBound()).identityRegistry().identity
332+
(_userAddress));
333+
}
334+
335+
}

index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export namespace contracts {
7272
export const TransferFeesModule: ContractJSON;
7373
export const TransferRestrictModule: ContractJSON;
7474
export const TokenListingRestrictionsModule: ContractJSON;
75+
export const InvestorCountryCapModule: ContractJSON;
7576
}
7677

7778
export namespace interfaces {

index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const SupplyLimitModule = require('./artifacts/contracts/compliance/modular/modu
7474
const TransferFeesModule = require('./artifacts/contracts/compliance/modular/modules/TransferFeesModule.sol/TransferFeesModule.json');
7575
const TransferRestrictModule = require('./artifacts/contracts/compliance/modular/modules/TransferRestrictModule.sol/TransferRestrictModule.json');
7676
const TokenListingRestrictionsModule = require('./artifacts/contracts/compliance/modular/modules/TokenListingRestrictionsModule.sol/TokenListingRestrictionsModule.json');
77+
const InvestorCountryCapModule = require('./artifacts/contracts/compliance/modular/modules/InvestorCountryCapModule.sol/InvestorCountryCapModule.json');
7778

7879
module.exports = {
7980
contracts: {
@@ -139,6 +140,7 @@ module.exports = {
139140
TransferFeesModule,
140141
TransferRestrictModule,
141142
TokenListingRestrictionsModule,
143+
InvestorCountryCapModule,
142144
},
143145
interfaces: {
144146
IToken,

0 commit comments

Comments
 (0)