diff --git a/contracts/mTokenPermissioned.sol b/contracts/mTokenPermissioned.sol new file mode 100644 index 00000000..88d9a5a8 --- /dev/null +++ b/contracts/mTokenPermissioned.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "./mToken.sol"; + +/** + * @title mTokenPermissioned + * @notice mToken with fully permissioned transfers + * @author RedDuck Software + */ +//solhint-disable contract-name-camelcase +abstract contract mTokenPermissioned is mToken { + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @dev overrides _beforeTokenTransfer function to allow + * greenlisted users to use the token transfers functions + */ + function _beforeTokenTransfer( + address from, + address to, + uint256 amount + ) internal virtual override(mToken) { + if (to != address(0)) { + if (from != address(0)) { + _onlyGreenlisted(from); + } + _onlyGreenlisted(to); + } + + mToken._beforeTokenTransfer(from, to, amount); + } + + /** + * @notice AC role of a greenlist + * @return role bytes32 role + */ + function _greenlistedRole() internal pure virtual returns (bytes32); + + /** + * @dev checks that a given `account` + * have `greenlistedRole()` + */ + function _onlyGreenlisted(address account) + private + view + onlyRole(_greenlistedRole(), account) + {} +} diff --git a/contracts/testers/mTokenPermissionedTest.sol b/contracts/testers/mTokenPermissionedTest.sol new file mode 100644 index 00000000..9abc7d0e --- /dev/null +++ b/contracts/testers/mTokenPermissionedTest.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../mTokenPermissioned.sol"; + +//solhint-disable contract-name-camelcase +contract mTokenPermissionedTest is mTokenPermissioned { + bytes32 public constant M_TOKEN_TEST_MINT_OPERATOR_ROLE = + keccak256("M_TOKEN_TEST_MINT_OPERATOR_ROLE"); + + bytes32 public constant M_TOKEN_TEST_BURN_OPERATOR_ROLE = + keccak256("M_TOKEN_TEST_BURN_OPERATOR_ROLE"); + + bytes32 public constant M_TOKEN_TEST_PAUSE_OPERATOR_ROLE = + keccak256("M_TOKEN_TEST_PAUSE_OPERATOR_ROLE"); + + bytes32 public constant M_TOKEN_TEST_GREENLISTED_ROLE = + keccak256("M_TOKEN_TEST_GREENLISTED_ROLE"); + + function _disableInitializers() internal override {} + + function _getNameSymbol() + internal + pure + override + returns (string memory, string memory) + { + return ("mTokenPermissionedTest", "mTokenPermissionedTest"); + } + + function _minterRole() internal pure override returns (bytes32) { + return M_TOKEN_TEST_MINT_OPERATOR_ROLE; + } + + function _burnerRole() internal pure override returns (bytes32) { + return M_TOKEN_TEST_BURN_OPERATOR_ROLE; + } + + function _pauserRole() internal pure override returns (bytes32) { + return M_TOKEN_TEST_PAUSE_OPERATOR_ROLE; + } + + function _greenlistedRole() internal pure override returns (bytes32) { + return M_TOKEN_TEST_GREENLISTED_ROLE; + } +} diff --git a/helpers/mtokens-metadata.ts b/helpers/mtokens-metadata.ts index 1e53654f..65089bbb 100644 --- a/helpers/mtokens-metadata.ts +++ b/helpers/mtokens-metadata.ts @@ -2,7 +2,7 @@ import { MTokenName } from '../config'; export const mTokensMetadata: Record< MTokenName, - { name: string; symbol: string } + { name: string; symbol: string; isPermissioned?: boolean } > = { mTBILL: { name: 'Midas US Treasury Bill Token', diff --git a/helpers/roles.ts b/helpers/roles.ts index 2c57b29b..1c900e09 100644 --- a/helpers/roles.ts +++ b/helpers/roles.ts @@ -84,6 +84,7 @@ type TokenRoles = { depositVaultAdmin: string; redemptionVaultAdmin: string; customFeedAdmin: string | null; + greenlisted: string; }; type CommonRoles = { @@ -119,6 +120,7 @@ export const getRolesNamesForToken = (token: MTokenName): TokenRoles => { : `${tokenPrefix}_CUSTOM_AGGREGATOR_FEED_ADMIN_ROLE`, depositVaultAdmin: `${restPrefix}DEPOSIT_VAULT_ADMIN_ROLE`, redemptionVaultAdmin: `${restPrefix}REDEMPTION_VAULT_ADMIN_ROLE`, + greenlisted: `${restPrefix}GREENLISTED_ROLE`, }; }; export const getRolesNamesCommon = (): CommonRoles => { diff --git a/scripts/deploy/codegen/common/index.ts b/scripts/deploy/codegen/common/index.ts index 1794dac8..0817ea76 100644 --- a/scripts/deploy/codegen/common/index.ts +++ b/scripts/deploy/codegen/common/index.ts @@ -30,6 +30,8 @@ import { import { getConfigFromUser, getContractsToGenerateFromUser, + getShouldUseTokenLevelGreenListFromUser, + getShouldUseTokenPermissionedFromUser, } from './ui/deployment-contracts'; import { MTokenName } from '../../../../config'; @@ -45,7 +47,10 @@ export type CodeExpr = { [EXPR]: string }; const generatorPerContract: Partial< Record< keyof TokenContractNames | 'layerZeroMinterBurner', - (mToken: MTokenName) => + ( + mToken: MTokenName, + optionalParams?: Record, + ) => | Promise< | { name: string; @@ -78,12 +83,14 @@ export const updateConfigFiles = ( name, symbol, mToken, + isPermissioned, }: { contractNamePrefix: string; rolesPrefix: string; name: string; symbol: string; mToken: string; + isPermissioned?: true; }, ) => { const project = new Project(); @@ -159,7 +166,12 @@ export const updateConfigFiles = ( initializer: (writer) => writer.write(`{ name: '${name}', - symbol: '${symbol}' + symbol: '${symbol}'${ + isPermissioned + ? `, + isPermissioned: true` + : '' + } }`), }); } @@ -505,6 +517,20 @@ export const generateContracts = async (hre: HardhatRuntimeEnvironment) => { const contractsToGenerate = await getContractsToGenerateFromUser(); + let shouldUseTokenLevelGreenList = false; + let shouldUseTokenPermissioned = false; + + if ( + contractsToGenerate.find((v) => v.startsWith('dv') || v.startsWith('rv')) + ) { + shouldUseTokenLevelGreenList = + await getShouldUseTokenLevelGreenListFromUser(); + } + + if (contractsToGenerate.includes('token')) { + shouldUseTokenPermissioned = await getShouldUseTokenPermissionedFromUser(); + } + const mToken = config.tokenContractName; const folder = path.join( @@ -523,11 +549,12 @@ export const generateContracts = async (hre: HardhatRuntimeEnvironment) => { name: config.tokenName, symbol: config.tokenSymbol, mToken, + isPermissioned: shouldUseTokenPermissioned ? true : undefined, }); }, }, { - title: 'Generation files', + title: 'Generating files', task: async () => { const isFolderExists = await fs .access(folder) @@ -547,7 +574,12 @@ export const generateContracts = async (hre: HardhatRuntimeEnvironment) => { ].filter((v) => v !== undefined); const generatedContracts = await Promise.all( - generators.map((generator) => generator(mToken as MTokenName)), + generators.map((generator) => + generator(mToken as MTokenName, { + vaultUseTokenLevelGreenList: shouldUseTokenLevelGreenList, + isPermissionedMToken: shouldUseTokenPermissioned, + }), + ), ); for (const contract of generatedContracts) { diff --git a/scripts/deploy/codegen/common/templates/dv.template.ts b/scripts/deploy/codegen/common/templates/dv.template.ts index a6b8fb54..c8de0470 100644 --- a/scripts/deploy/codegen/common/templates/dv.template.ts +++ b/scripts/deploy/codegen/common/templates/dv.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getDvContractFromTemplate = async (mToken: MTokenName) => { +export const getDvContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -38,6 +43,19 @@ export const getDvContractFromTemplate = async (mToken: MTokenName) => { function vaultRole() public pure override returns (bytes32) { return ${roles.depositVaultAdmin}; } + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` + : '' + } }`, }; }; diff --git a/scripts/deploy/codegen/common/templates/mtoken.template.ts b/scripts/deploy/codegen/common/templates/mtoken.template.ts index b2271498..c27ce66b 100644 --- a/scripts/deploy/codegen/common/templates/mtoken.template.ts +++ b/scripts/deploy/codegen/common/templates/mtoken.template.ts @@ -1,7 +1,11 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getTokenContractFromTemplate = async (mToken: MTokenName) => { +export const getTokenContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { isPermissionedMToken = false } = optionalParams || {}; const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -25,14 +29,17 @@ export const getTokenContractFromTemplate = async (mToken: MTokenName) => { // SPDX-License-Identifier: MIT pragma solidity 0.8.9; - import "../../mToken.sol"; + import "../../mToken${isPermissionedMToken ? 'Permissioned' : ''}.sol"; + ${isPermissionedMToken ? `import "./${contractNames.roles}.sol";` : ''} /** * @title ${contractNames.token} * @author RedDuck Software */ //solhint-disable contract-name-camelcase - contract ${contractNames.token} is mToken { + contract ${contractNames.token} is mToken${ + isPermissionedMToken ? `Permissioned, ${contractNames.roles}` : '' + } { /** * @notice actor that can mint ${contractNames.token} */ @@ -88,6 +95,18 @@ export const getTokenContractFromTemplate = async (mToken: MTokenName) => { function _pauserRole() internal pure override returns (bytes32) { return ${roles.pauser}; } + + ${ + isPermissionedMToken + ? ` + /** + * @inheritdoc mTokenPermissioned + */ + function _greenlistedRole() internal pure override returns (bytes32) { + return ${roles.greenlisted}; + }` + : '' + } }`, }; }; diff --git a/scripts/deploy/codegen/common/templates/rv-swapper.template.ts b/scripts/deploy/codegen/common/templates/rv-swapper.template.ts index a0fe39c4..07422069 100644 --- a/scripts/deploy/codegen/common/templates/rv-swapper.template.ts +++ b/scripts/deploy/codegen/common/templates/rv-swapper.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getRvSwapperContractFromTemplate = async (mToken: MTokenName) => { +export const getRvSwapperContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -41,6 +46,19 @@ export const getRvSwapperContractFromTemplate = async (mToken: MTokenName) => { function vaultRole() public pure override returns (bytes32) { return ${roles.redemptionVaultAdmin}; } + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` + : '' + } }`, }; }; diff --git a/scripts/deploy/codegen/common/templates/rv-ustb.template.ts b/scripts/deploy/codegen/common/templates/rv-ustb.template.ts index 043eaddc..fb7cf6af 100644 --- a/scripts/deploy/codegen/common/templates/rv-ustb.template.ts +++ b/scripts/deploy/codegen/common/templates/rv-ustb.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getRvUstbContractFromTemplate = async (mToken: MTokenName) => { +export const getRvUstbContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -41,6 +46,19 @@ export const getRvUstbContractFromTemplate = async (mToken: MTokenName) => { function vaultRole() public pure override returns (bytes32) { return ${roles.redemptionVaultAdmin}; } + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` + : '' + } }`, }; }; diff --git a/scripts/deploy/codegen/common/templates/rv.template.ts b/scripts/deploy/codegen/common/templates/rv.template.ts index 2a9ad4ec..8a66775c 100644 --- a/scripts/deploy/codegen/common/templates/rv.template.ts +++ b/scripts/deploy/codegen/common/templates/rv.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getRvContractFromTemplate = async (mToken: MTokenName) => { +export const getRvContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -42,6 +47,19 @@ export const getRvContractFromTemplate = async (mToken: MTokenName) => { function vaultRole() public pure override returns (bytes32) { return ${roles.redemptionVaultAdmin}; } + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` + : '' + } }`, }; }; diff --git a/scripts/deploy/codegen/common/templates/token-roles.template.ts b/scripts/deploy/codegen/common/templates/token-roles.template.ts index f23d965b..1120bb96 100644 --- a/scripts/deploy/codegen/common/templates/token-roles.template.ts +++ b/scripts/deploy/codegen/common/templates/token-roles.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getTokenRolesContractFromTemplate = async (mToken: MTokenName) => { +export const getTokenRolesContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -21,7 +26,9 @@ export const getTokenRolesContractFromTemplate = async (mToken: MTokenName) => { /** * @title ${contractNames.roles} - * @notice Base contract that stores all roles descriptors for ${contractNames.token} contracts + * @notice Base contract that stores all roles descriptors for ${ + contractNames.token + } contracts * @author RedDuck Software */ abstract contract ${contractNames.roles} { @@ -38,10 +45,24 @@ export const getTokenRolesContractFromTemplate = async (mToken: MTokenName) => { keccak256("${roles.redemptionVaultAdmin}"); /** - * @notice actor that can manage ${contractNames.customAggregator} and ${contractNames.dataFeed} + * @notice actor that can manage ${contractNames.customAggregator} and ${ + contractNames.dataFeed + } */ bytes32 public constant ${roles.customFeedAdmin} = keccak256("${roles.customFeedAdmin}"); + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @notice greenlist role for ${contractNames.token} + */ + bytes32 public constant ${roles.greenlisted} = + keccak256("${roles.greenlisted}"); + ` + : '' + } }`, }; }; diff --git a/scripts/deploy/codegen/common/ui/deployment-contracts.ts b/scripts/deploy/codegen/common/ui/deployment-contracts.ts index 321d6ce4..f83773b0 100644 --- a/scripts/deploy/codegen/common/ui/deployment-contracts.ts +++ b/scripts/deploy/codegen/common/ui/deployment-contracts.ts @@ -1,4 +1,4 @@ -import { group, multiselect, text } from '@clack/prompts'; +import { group, multiselect, text, confirm } from '@clack/prompts'; import { requireNotCancelled } from '..'; import { TokenContractNames } from '../../../../../helpers/contracts'; @@ -109,3 +109,17 @@ export const getContractsToGenerateFromUser = async () => { required: true, }).then(requireNotCancelled); }; + +export const getShouldUseTokenLevelGreenListFromUser = async () => { + return confirm({ + message: 'Should use token level green list for vaults?', + initialValue: false, + }).then(requireNotCancelled); +}; + +export const getShouldUseTokenPermissionedFromUser = async () => { + return confirm({ + message: 'Should use permissioned mToken variant?', + initialValue: false, + }).then(requireNotCancelled); +}; diff --git a/test/common/fixtures.ts b/test/common/fixtures.ts index b8bb899d..f960a944 100644 --- a/test/common/fixtures.ts +++ b/test/common/fixtures.ts @@ -48,6 +48,7 @@ import { MidasLzOFT__factory, MidasLzOFTAdapter__factory, MidasLzVaultComposerSyncTester, + MTokenPermissionedTest__factory, AxelarInterchainTokenServiceMock__factory, MidasAxelarVaultExecutableTester, LzEndpointV2Mock__factory, @@ -606,6 +607,111 @@ export const defaultDeploy = async () => { }; }; +/** + * mTokenPermissionedTest + dedicated deposit/redemption vaults (for integration-style tests). + */ +export const mTokenPermissionedFixture = async ( + baseFixture?: Awaited>, +) => { + const fx = baseFixture ?? (await defaultDeploy()); + const { + owner, + accessControl, + mockedSanctionsList, + feeReceiver, + tokensReceiver, + requestRedeemer, + mTokenToUsdDataFeed, + } = fx; + + const mTokenPermissioned = await new MTokenPermissionedTest__factory( + owner, + ).deploy(); + await mTokenPermissioned.initialize(accessControl.address); + + const mintRole = await mTokenPermissioned.M_TOKEN_TEST_MINT_OPERATOR_ROLE(); + const burnRole = await mTokenPermissioned.M_TOKEN_TEST_BURN_OPERATOR_ROLE(); + const pauseRole = await mTokenPermissioned.M_TOKEN_TEST_PAUSE_OPERATOR_ROLE(); + const mTokenPermissionedGreenlistedRole = + await mTokenPermissioned.M_TOKEN_TEST_GREENLISTED_ROLE(); + + await accessControl.grantRole(mintRole, owner.address); + await accessControl.grantRole(burnRole, owner.address); + await accessControl.grantRole(pauseRole, owner.address); + + const mTokenPermissionedDepositVault = await new DepositVaultTest__factory( + owner, + ).deploy(); + await mTokenPermissionedDepositVault.initialize( + accessControl.address, + { + mToken: mTokenPermissioned.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + parseUnits('100'), + 0, + constants.MaxUint256, + ); + await accessControl.grantRole( + mintRole, + mTokenPermissionedDepositVault.address, + ); + + const mTokenPermissionedRedemptionVault = + await new RedemptionVaultTest__factory(owner).deploy(); + await mTokenPermissionedRedemptionVault.initialize( + accessControl.address, + { + mToken: mTokenPermissioned.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 0, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + 1000, + { + fiatAdditionalFee: 100, + fiatFlatFee: parseUnits('1'), + minFiatRedeemAmount: 1000, + }, + requestRedeemer.address, + ); + await accessControl.grantRole( + burnRole, + mTokenPermissionedRedemptionVault.address, + ); + + return { + ...fx, + mTokenPermissioned, + mTokenPermissionedRoles: { + mint: mintRole, + burn: burnRole, + pause: pauseRole, + greenlisted: mTokenPermissionedGreenlistedRole, + }, + mTokenPermissionedDepositVault, + mTokenPermissionedRedemptionVault, + }; +}; + export const acreAdapterFixture = async () => { const defaultFixture = await defaultDeploy(); diff --git a/test/common/mTBILL.helpers.ts b/test/common/mTBILL.helpers.ts index 102eb254..8613a8b3 100644 --- a/test/common/mTBILL.helpers.ts +++ b/test/common/mTBILL.helpers.ts @@ -5,10 +5,10 @@ import { defaultAbiCoder, solidityKeccak256 } from 'ethers/lib/utils'; import { Account, OptionalCommonParams, getAccount } from './common.helpers'; -import { MTBILL, MToken } from '../../typechain-types'; +import { MTBILL, MToken, MTokenPermissioned } from '../../typechain-types'; type CommonParams = { - tokenContract: MToken | MTBILL; + tokenContract: MToken | MTBILL | MTokenPermissioned; owner: SignerWithAddress; }; diff --git a/test/unit/DepositVault.test.ts b/test/unit/DepositVault.test.ts index 6375435c..53f390e8 100644 --- a/test/unit/DepositVault.test.ts +++ b/test/unit/DepositVault.test.ts @@ -32,7 +32,7 @@ import { safeBulkApproveRequestTest, setMaxSupplyCapTest, } from '../common/deposit-vault.helpers'; -import { defaultDeploy } from '../common/fixtures'; +import { defaultDeploy, mTokenPermissionedFixture } from '../common/fixtures'; import { greenListEnable } from '../common/greenlist.helpers'; import { addPaymentTokenTest, @@ -2575,6 +2575,97 @@ describe('DepositVault', function () { }, ); }); + + it('with permissioned mToken - deposit instant mints mToken to greenlisted user', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + mockedAggregator, + mTokenPermissioned, + mTokenPermissionedRoles, + mTokenPermissionedDepositVault, + } = await loadFixture(mTokenPermissionedFixture.bind(this, baseFixture)); + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + owner.address, + ); + await addPaymentTokenTest( + { vault: mTokenPermissionedDepositVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + + await mintToken(stableCoins.dai, owner, 100_000); + await approveBase18( + owner, + stableCoins.dai, + mTokenPermissionedDepositVault, + 100_000, + ); + + await depositInstantTest( + { + depositVault: mTokenPermissionedDepositVault, + owner, + mTBILL: mTokenPermissioned, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1000, + ); + }); + + it('should fail: with permissioned mToken - deposit instant mints mToken to non-greenlisted user', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + mockedAggregator, + mTokenPermissioned, + mTokenPermissionedDepositVault, + } = await loadFixture(mTokenPermissionedFixture.bind(this, baseFixture)); + + await addPaymentTokenTest( + { vault: mTokenPermissionedDepositVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + + await mintToken(stableCoins.dai, owner, 100_000); + await approveBase18( + owner, + stableCoins.dai, + mTokenPermissionedDepositVault, + 100_000, + ); + + await depositInstantTest( + { + depositVault: mTokenPermissionedDepositVault, + owner, + mTBILL: mTokenPermissioned, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1000, + { + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); }); describe('depositRequest()', async () => { diff --git a/test/unit/RedemptionVault.test.ts b/test/unit/RedemptionVault.test.ts index 14ba9949..424610e4 100644 --- a/test/unit/RedemptionVault.test.ts +++ b/test/unit/RedemptionVault.test.ts @@ -22,7 +22,7 @@ import { setRoundDataGrowth, } from '../common/custom-feed-growth.helpers'; import { setRoundData } from '../common/data-feed.helpers'; -import { defaultDeploy } from '../common/fixtures'; +import { defaultDeploy, mTokenPermissionedFixture } from '../common/fixtures'; import { addPaymentTokenTest, addWaivedFeeAccountTest, @@ -2521,6 +2521,257 @@ describe('RedemptionVault', function () { }, ); }); + + it('with permissioned mToken - burns/transfers mToken from greenlisted user and fee recipient', async () => { + const { + owner, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + mockedAggregator, + mockedAggregatorMToken, + mTokenPermissioned, + mTokenPermissionedRoles, + accessControl, + mTokenPermissionedRedemptionVault, + } = await loadFixture(mTokenPermissionedFixture); + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + owner.address, + ); + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + await mTokenPermissionedRedemptionVault.feeReceiver(), + ); + await mintToken(mTokenPermissioned, owner, 100_000); + await setInstantFeeTest( + { vault: mTokenPermissionedRedemptionVault, owner }, + 1000, + ); + await approveBase18( + owner, + mTokenPermissioned, + mTokenPermissionedRedemptionVault, + 100_000, + ); + + await mintToken( + stableCoins.dai, + mTokenPermissionedRedemptionVault, + 100_000, + ); + await addPaymentTokenTest( + { vault: mTokenPermissionedRedemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + await redeemInstantTest( + { + redemptionVault: mTokenPermissionedRedemptionVault, + owner, + mTBILL: mTokenPermissioned, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 999, + ); + }); + + it('with permissioned mToken - instant fee is 0, burns/transfers mToken from non-greenlisted user', async () => { + const { + owner, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + mockedAggregator, + mockedAggregatorMToken, + mTokenPermissioned, + mTokenPermissionedRoles, + accessControl, + mTokenPermissionedRedemptionVault, + } = await loadFixture(mTokenPermissionedFixture); + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + owner.address, + ); + await mintToken(mTokenPermissioned, owner, 100_000); + await accessControl.revokeRole( + mTokenPermissionedRoles.greenlisted, + owner.address, + ); + await setInstantFeeTest( + { vault: mTokenPermissionedRedemptionVault, owner }, + 0, + ); + await approveBase18( + owner, + mTokenPermissioned, + mTokenPermissionedRedemptionVault, + 100_000, + ); + + await mintToken( + stableCoins.dai, + mTokenPermissionedRedemptionVault, + 100_000, + ); + await addPaymentTokenTest( + { vault: mTokenPermissionedRedemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + await redeemInstantTest( + { + redemptionVault: mTokenPermissionedRedemptionVault, + owner, + mTBILL: mTokenPermissioned, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 999, + ); + }); + + it('should fail: with permissioned mToken - burns/transfers mToken from greenlisted user but fee recipient is not greenlisted', async () => { + const { + owner, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + mockedAggregator, + mockedAggregatorMToken, + mTokenPermissioned, + mTokenPermissionedRoles, + accessControl, + mTokenPermissionedRedemptionVault, + } = await loadFixture(mTokenPermissionedFixture); + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + owner.address, + ); + await mintToken(mTokenPermissioned, owner, 100_000); + await setInstantFeeTest( + { vault: mTokenPermissionedRedemptionVault, owner }, + 1000, + ); + await approveBase18( + owner, + mTokenPermissioned, + mTokenPermissionedRedemptionVault, + 100_000, + ); + + await mintToken( + stableCoins.dai, + mTokenPermissionedRedemptionVault, + 100_000, + ); + await addPaymentTokenTest( + { vault: mTokenPermissionedRedemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + await redeemInstantTest( + { + redemptionVault: mTokenPermissionedRedemptionVault, + owner, + mTBILL: mTokenPermissioned, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 999, + { + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('should fail: with permissioned mToken - redeem instant burns/transfers mToken from non-greenlisted user', async () => { + const { + owner, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + mockedAggregator, + mockedAggregatorMToken, + mTokenPermissioned, + mTokenPermissionedRedemptionVault, + mTokenPermissionedRoles, + accessControl, + } = await loadFixture(mTokenPermissionedFixture); + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + owner.address, + ); + await mintToken(mTokenPermissioned, owner, 100_000); + await setInstantFeeTest( + { vault: mTokenPermissionedRedemptionVault, owner }, + 1000, + ); + await accessControl.revokeRole( + mTokenPermissionedRoles.greenlisted, + owner.address, + ); + await approveBase18( + owner, + mTokenPermissioned, + mTokenPermissionedRedemptionVault, + 100_000, + ); + + await mintToken( + stableCoins.dai, + mTokenPermissionedRedemptionVault, + 100_000, + ); + await addPaymentTokenTest( + { vault: mTokenPermissionedRedemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + await redeemInstantTest( + { + redemptionVault: mTokenPermissionedRedemptionVault, + owner, + mTBILL: mTokenPermissioned, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 999, + { + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); }); describe('redeemRequest()', () => { @@ -6911,8 +7162,7 @@ describe('RedemptionVault', function () { describe('_calcAndValidateRedeem', () => { it('should fail: when tokenOut is not MANUAL_FULLFILMENT_TOKEN but isFiat = true', async () => { - const { redemptionVault, stableCoins, owner, dataFeed } = - await loadFixture(defaultDeploy); + const { redemptionVault, stableCoins } = await loadFixture(defaultDeploy); await expect( redemptionVault.calcAndValidateRedeemTest( diff --git a/test/unit/mtoken.test.ts b/test/unit/mtoken.test.ts index 295ae8db..57fc40fa 100644 --- a/test/unit/mtoken.test.ts +++ b/test/unit/mtoken.test.ts @@ -1,4 +1,11 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { parseUnits } from 'ethers/lib/utils'; + import { MTokenNameEnum } from '../../config'; +import { acErrors, blackList } from '../common/ac.helpers'; +import { defaultDeploy, mTokenPermissionedFixture } from '../common/fixtures'; +import { burn, mint } from '../common/mTBILL.helpers'; import { tokenContractsTests } from '../common/token.tests'; const mProducts = Object.values(MTokenNameEnum); @@ -9,4 +16,434 @@ describe('Token contracts', () => { tokenContractsTests(product); }); }); + describe('mTokenPermissioned (mTokenPermissionedTest)', () => { + describe('transfer()', () => { + it('should fail: transfer when sender is not greenlisted', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + regularAccounts, + mTokenPermissioned, + mTokenPermissionedRoles, + } = await loadFixture( + mTokenPermissionedFixture.bind(this, baseFixture), + ); + + const from = regularAccounts[0]; + const to = regularAccounts[1]; + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + from.address, + ); + await mint({ tokenContract: mTokenPermissioned, owner }, from, 1); + await accessControl.revokeRole( + mTokenPermissionedRoles.greenlisted, + from.address, + ); + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + to.address, + ); + + await expect( + mTokenPermissioned.connect(from).transfer(to.address, 1), + ).revertedWith(acErrors.WMAC_HASNT_ROLE); + }); + + it('should fail: transfer when recipient is not greenlisted', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + regularAccounts, + mTokenPermissioned, + mTokenPermissionedRoles, + } = await loadFixture( + mTokenPermissionedFixture.bind(this, baseFixture), + ); + + const from = regularAccounts[0]; + const to = regularAccounts[1]; + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + from.address, + ); + await mint({ tokenContract: mTokenPermissioned, owner }, from, 1); + + await expect( + mTokenPermissioned.connect(from).transfer(to.address, 1), + ).revertedWith(acErrors.WMAC_HASNT_ROLE); + }); + + it('should fail: transfer when from is blacklisted', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + regularAccounts, + mTokenPermissioned, + mTokenPermissionedRoles, + } = await loadFixture( + mTokenPermissionedFixture.bind(this, baseFixture), + ); + + const from = regularAccounts[0]; + const to = regularAccounts[1]; + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + from.address, + ); + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + to.address, + ); + await mint({ tokenContract: mTokenPermissioned, owner }, from, 1); + await blackList( + { + blacklistable: mTokenPermissioned, + accessControl, + owner, + }, + from, + ); + + await expect( + mTokenPermissioned.connect(from).transfer(to.address, 1), + ).revertedWith(acErrors.WMAC_HAS_ROLE); + }); + + it('should fail: transfer when token is paused', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + regularAccounts, + mTokenPermissioned, + mTokenPermissionedRoles, + } = await loadFixture( + mTokenPermissionedFixture.bind(this, baseFixture), + ); + + const from = regularAccounts[0]; + const to = regularAccounts[1]; + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + from.address, + ); + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + to.address, + ); + await mint({ tokenContract: mTokenPermissioned, owner }, from, 1); + + await mTokenPermissioned.connect(owner).pause(); + + await expect( + mTokenPermissioned.connect(from).transfer(to.address, 1), + ).revertedWith('ERC20Pausable: token transfer while paused'); + }); + + it('should fail: mint when receiver is not greenlisted', async () => { + const baseFixture = await defaultDeploy(); + const { owner, regularAccounts, mTokenPermissioned } = + await loadFixture(mTokenPermissionedFixture.bind(this, baseFixture)); + + await mint( + { tokenContract: mTokenPermissioned, owner }, + regularAccounts[0], + 1, + { revertMessage: acErrors.WMAC_HASNT_ROLE }, + ); + }); + + it('transfer when both parties are greenlisted', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + regularAccounts, + mTokenPermissioned, + mTokenPermissionedRoles, + } = await loadFixture( + mTokenPermissionedFixture.bind(this, baseFixture), + ); + + const from = regularAccounts[0]; + const to = regularAccounts[1]; + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + from.address, + ); + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + to.address, + ); + await mint({ tokenContract: mTokenPermissioned, owner }, from, 1); + + await expect(mTokenPermissioned.connect(from).transfer(to.address, 1)) + .not.reverted; + expect(await mTokenPermissioned.balanceOf(to.address)).eq(1); + }); + + it('mint when receiver is greenlisted', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + regularAccounts, + mTokenPermissioned, + mTokenPermissionedRoles, + } = await loadFixture( + mTokenPermissionedFixture.bind(this, baseFixture), + ); + + const to = regularAccounts[0]; + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + to.address, + ); + + await mint( + { tokenContract: mTokenPermissioned, owner }, + to, + parseUnits('1'), + ); + }); + + it('burn without greenlist on holder', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + regularAccounts, + mTokenPermissioned, + mTokenPermissionedRoles, + } = await loadFixture( + mTokenPermissionedFixture.bind(this, baseFixture), + ); + + const holder = regularAccounts[0]; + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + holder.address, + ); + await mint({ tokenContract: mTokenPermissioned, owner }, holder, 1); + await accessControl.revokeRole( + mTokenPermissionedRoles.greenlisted, + holder.address, + ); + + await burn({ tokenContract: mTokenPermissioned, owner }, holder, 1); + }); + }); + + describe('transferFrom()', () => { + const greenlistComboCases: { + fromGreenlisted: boolean; + toGreenlisted: boolean; + callerGreenlisted: boolean; + expectSuccess: boolean; + }[] = [ + { + fromGreenlisted: true, + toGreenlisted: true, + callerGreenlisted: true, + expectSuccess: true, + }, + { + fromGreenlisted: true, + toGreenlisted: true, + callerGreenlisted: false, + expectSuccess: true, + }, + { + fromGreenlisted: false, + toGreenlisted: true, + callerGreenlisted: true, + expectSuccess: false, + }, + { + fromGreenlisted: false, + toGreenlisted: true, + callerGreenlisted: false, + expectSuccess: false, + }, + { + fromGreenlisted: false, + toGreenlisted: false, + callerGreenlisted: true, + expectSuccess: false, + }, + { + fromGreenlisted: false, + toGreenlisted: false, + callerGreenlisted: false, + expectSuccess: false, + }, + { + fromGreenlisted: true, + toGreenlisted: false, + callerGreenlisted: true, + expectSuccess: false, + }, + { + fromGreenlisted: true, + toGreenlisted: false, + callerGreenlisted: false, + expectSuccess: false, + }, + ]; + + greenlistComboCases.forEach( + ({ + fromGreenlisted, + toGreenlisted, + callerGreenlisted, + expectSuccess, + }) => { + const fromL = fromGreenlisted ? 'greenlisted' : 'not greenlisted'; + const toL = toGreenlisted ? 'greenlisted' : 'not greenlisted'; + const callerL = callerGreenlisted ? 'greenlisted' : 'not greenlisted'; + + it( + expectSuccess + ? `succeeds: from ${fromL}, to ${toL}, caller ${callerL}` + : `should fail: from ${fromL}, to ${toL}, caller ${callerL}`, + async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + regularAccounts, + mTokenPermissioned, + mTokenPermissionedRoles, + } = await loadFixture( + mTokenPermissionedFixture.bind(this, baseFixture), + ); + + const from = regularAccounts[0]; + const caller = regularAccounts[1]; + const to = regularAccounts[2]; + const { greenlisted } = mTokenPermissionedRoles; + + await accessControl.grantRole(greenlisted, from.address); + await mint({ tokenContract: mTokenPermissioned, owner }, from, 1); + await mTokenPermissioned.connect(from).approve(caller.address, 1); + + if (!fromGreenlisted) { + await accessControl.revokeRole(greenlisted, from.address); + } + if (toGreenlisted) { + await accessControl.grantRole(greenlisted, to.address); + } + if (callerGreenlisted) { + await accessControl.grantRole(greenlisted, caller.address); + } + + const tx = mTokenPermissioned + .connect(caller) + .transferFrom(from.address, to.address, 1); + + if (expectSuccess) { + await expect(tx).not.reverted; + expect(await mTokenPermissioned.balanceOf(to.address)).eq(1); + } else { + await expect(tx).revertedWith(acErrors.WMAC_HASNT_ROLE); + } + }, + ); + }, + ); + + it('should fail: transferFrom when from is blacklisted', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + regularAccounts, + mTokenPermissioned, + mTokenPermissionedRoles, + } = await loadFixture( + mTokenPermissionedFixture.bind(this, baseFixture), + ); + + const from = regularAccounts[0]; + const spender = regularAccounts[1]; + const to = regularAccounts[2]; + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + from.address, + ); + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + to.address, + ); + await mint({ tokenContract: mTokenPermissioned, owner }, from, 1); + await blackList( + { + blacklistable: mTokenPermissioned, + accessControl, + owner, + }, + from, + ); + await mTokenPermissioned.connect(from).approve(spender.address, 1); + + await expect( + mTokenPermissioned + .connect(spender) + .transferFrom(from.address, to.address, 1), + ).revertedWith(acErrors.WMAC_HAS_ROLE); + }); + + it('should fail: transferFrom when to is blacklisted', async () => { + const baseFixture = await defaultDeploy(); + const { + owner, + accessControl, + regularAccounts, + mTokenPermissioned, + mTokenPermissionedRoles, + } = await loadFixture( + mTokenPermissionedFixture.bind(this, baseFixture), + ); + + const from = regularAccounts[0]; + const spender = regularAccounts[1]; + const to = regularAccounts[2]; + + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + from.address, + ); + await accessControl.grantRole( + mTokenPermissionedRoles.greenlisted, + to.address, + ); + await mint({ tokenContract: mTokenPermissioned, owner }, from, 1); + await blackList( + { + blacklistable: mTokenPermissioned, + accessControl, + owner, + }, + to, + ); + await mTokenPermissioned.connect(from).approve(spender.address, 1); + + await expect( + mTokenPermissioned + .connect(spender) + .transferFrom(from.address, to.address, 1), + ).revertedWith(acErrors.WMAC_HAS_ROLE); + }); + }); + }); });