From e193fe2a3cedb83869ae59048c1b940dd21292d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renaud?= Date: Fri, 11 Oct 2024 16:19:53 +0200 Subject: [PATCH] feat(smart-contracts): use struct to parse purchase args (#14806) * refactor legacy purcahse function * basic testing * update main helper to use logic * fix lock helper * add docstrings to interface * pay referrers * lint fixes * fix lint * fix erc20 keys helper --- .../contracts/interfaces/IPublicLock.sol | 27 +++ .../contracts/mixins/MixinPurchase.sol | 164 ++++++++++++------ smart-contracts/hardhat.config.js | 1 - .../scripts/hooks/ERC1155BalanceOfHook.js | 1 - .../scripts/hooks/ERC20BalanceOfHook.js | 1 - .../scripts/hooks/ERC721BalanceOfHook.js | 1 - smart-contracts/scripts/keys/renew.js | 1 - smart-contracts/scripts/storage-layout.js | 1 - smart-contracts/tasks/accounts.js | 1 - smart-contracts/tasks/keys.js | 1 - smart-contracts/tasks/upgrade.js | 8 - smart-contracts/tasks/utils.js | 3 +- .../test/Lock/purchaseUsingStruct.js | 116 +++++++++++++ smart-contracts/test/Lock/setReferrerFee.js | 24 +-- smart-contracts/test/helpers/lock.js | 45 +++-- smart-contracts/test/helpers/multisig.js | 2 - 16 files changed, 294 insertions(+), 103 deletions(-) create mode 100644 smart-contracts/test/Lock/purchaseUsingStruct.js diff --git a/smart-contracts/contracts/interfaces/IPublicLock.sol b/smart-contracts/contracts/interfaces/IPublicLock.sol index 66a8d43e905..6866e835c57 100644 --- a/smart-contracts/contracts/interfaces/IPublicLock.sol +++ b/smart-contracts/contracts/interfaces/IPublicLock.sol @@ -7,6 +7,24 @@ pragma experimental ABIEncoderV2; */ interface IPublicLock { + /** + * @dev PurchaseArgs struct + * @param value (ERC20 only) value of the key + * @param recipient address of the recipient + * @param referrer the referrer that will be granted the governance tokens + * @param keyManager the manager of the key (can cancel, transfer, burn the key) + * @param data additional data to be used by jooks or other 3rd part contracts + * @return tokenIds the ids of the created tokens + */ + + struct PurchaseArgs { + uint value; + address recipient; + address referrer; + address keyManager; + bytes data; + } + /// Functions function initialize( address _lockCreator, @@ -165,6 +183,15 @@ interface IPublicLock { /** * @dev Purchase function + * @param purchaseArgs array of PurchaseArg + * @return tokenIds the ids of the created tokens + */ + function purchase( + PurchaseArgs[] memory purchaseArgs + ) external payable returns (uint256[] memory tokenIds); + + /** + * @dev Purchase function (legacy) * @param _values array of tokens amount to pay for this purchase >= the current keyPrice - any applicable discount * (_values is ignored when using ETH) * @param _recipients array of addresses of the recipients of the purchased key diff --git a/smart-contracts/contracts/mixins/MixinPurchase.sol b/smart-contracts/contracts/mixins/MixinPurchase.sol index 3d3153ea816..7d6ab5871f3 100644 --- a/smart-contracts/contracts/mixins/MixinPurchase.sol +++ b/smart-contracts/contracts/mixins/MixinPurchase.sol @@ -49,6 +49,14 @@ contract MixinPurchase is error TransferFailed(); + struct PurchaseArgs { + uint value; + address recipient; + address referrer; + address keyManager; + bytes data; + } + /** * @dev Set the value/price to be refunded to the sender on purchase */ @@ -232,6 +240,94 @@ contract MixinPurchase is } } + function _lockPurchaseIsPossible(uint nbOfKeysToPurchase) internal view { + _lockIsUpToDate(); + if (_totalSupply + nbOfKeysToPurchase > maxNumberOfKeys) { + revert LOCK_SOLD_OUT(); + } + } + + function _purchaseKey( + uint _value, + address _recipient, + address _keyManager, + address _referrer, + bytes memory _data + ) internal returns (uint tokenId, uint pricePaid) { + // create a new key, check for a non-expiring key + tokenId = _createNewKey( + _recipient, + _keyManager, + expirationDuration == type(uint).max + ? type(uint).max + : block.timestamp + expirationDuration + ); + + // price + pricePaid = purchasePriceFor(_recipient, _referrer, _data); + + // store values at purchase time + _recordTokenTerms(tokenId, pricePaid); + + // make sure erc20 price is correct + if (tokenAddress != address(0)) { + _checkValue(_value, pricePaid); + } + + // store in unlock + _recordKeyPurchase(pricePaid, _referrer); + + // fire hook + if (address(onKeyPurchaseHook) != address(0)) { + onKeyPurchaseHook.onKeyPurchase( + tokenId, + msg.sender, + _recipient, + _referrer, + _data, + pricePaid, + tokenAddress == address(0) ? msg.value : _value + ); + } + } + + function purchase( + PurchaseArgs[] memory purchaseArgs + ) external payable returns (uint[] memory) { + _lockPurchaseIsPossible(purchaseArgs.length); + + uint totalPriceToPay; + uint[] memory tokenIds = new uint[](purchaseArgs.length); + + for (uint256 i = 0; i < purchaseArgs.length; i++) { + (uint tokenId, uint pricePaid) = _purchaseKey( + purchaseArgs[i].value, + purchaseArgs[i].recipient, + purchaseArgs[i].keyManager, + purchaseArgs[i].referrer, + purchaseArgs[i].data + ); + totalPriceToPay = totalPriceToPay + pricePaid; + tokenIds[i] = tokenId; + } + + // transfer the ERC20 tokens + _transferValue(msg.sender, totalPriceToPay); + + // pay protocol + _payProtocol(totalPriceToPay); + + // refund gas + _refundGas(); + + // send what is due to referrers + for (uint256 i = 0; i < purchaseArgs.length; i++) { + _payReferrer(purchaseArgs[i].referrer); + } + + return tokenIds; + } + /** * @dev Purchase function * @param _values array of tokens amount to pay for this purchase >= the current keyPrice - any applicable discount @@ -252,10 +348,9 @@ contract MixinPurchase is address[] memory _keyManagers, bytes[] calldata _data ) external payable returns (uint[] memory) { - _lockIsUpToDate(); - if (_totalSupply + _recipients.length > maxNumberOfKeys) { - revert LOCK_SOLD_OUT(); - } + _lockPurchaseIsPossible(_recipients.length); + + // check for array mismatch if ( (_recipients.length != _referrers.length) || (_recipients.length != _keyManagers.length) @@ -267,50 +362,15 @@ contract MixinPurchase is uint[] memory tokenIds = new uint[](_recipients.length); for (uint256 i = 0; i < _recipients.length; i++) { - // check recipient address - address _recipient = _recipients[i]; - - // create a new key, check for a non-expiring key - tokenIds[i] = _createNewKey( - _recipient, + (uint tokenId, uint pricePaid) = _purchaseKey( + tokenAddress != address(0) ? _values[i] : 0, + _recipients[i], _keyManagers[i], - expirationDuration == type(uint).max - ? type(uint).max - : block.timestamp + expirationDuration - ); - - // price - uint inMemoryKeyPrice = purchasePriceFor( - _recipient, _referrers[i], _data[i] ); - totalPriceToPay = totalPriceToPay + inMemoryKeyPrice; - - // store values at purchase time - _recordTokenTerms(tokenIds[i], inMemoryKeyPrice); - - // make sure erc20 price is correct - if (tokenAddress != address(0)) { - _checkValue(_values[i], inMemoryKeyPrice); - } - - // store in unlock - _recordKeyPurchase(inMemoryKeyPrice, _referrers[i]); - - // fire hook - uint pricePaid = tokenAddress == address(0) ? msg.value : _values[i]; - if (address(onKeyPurchaseHook) != address(0)) { - onKeyPurchaseHook.onKeyPurchase( - tokenIds[i], - msg.sender, - _recipient, - _referrers[i], - _data[i], - inMemoryKeyPrice, - pricePaid - ); - } + totalPriceToPay = totalPriceToPay + pricePaid; + tokenIds[i] = tokenId; } // transfer the ERC20 tokens @@ -352,25 +412,21 @@ contract MixinPurchase is _extendKey(_tokenId, 0); // transfer the tokens - uint inMemoryKeyPrice = purchasePriceFor( - ownerOf(_tokenId), - _referrer, - _data - ); + uint pricePaid = purchasePriceFor(ownerOf(_tokenId), _referrer, _data); // make sure erc20 price is correct if (tokenAddress != address(0)) { - _checkValue(_value, inMemoryKeyPrice); + _checkValue(_value, pricePaid); } // process in unlock - _recordKeyPurchase(inMemoryKeyPrice, _referrer); + _recordKeyPurchase(pricePaid, _referrer); // pay value in ERC20 - _transferValue(msg.sender, inMemoryKeyPrice); + _transferValue(msg.sender, pricePaid); // if key params have changed, then update them - _recordTokenTerms(_tokenId, inMemoryKeyPrice); + _recordTokenTerms(_tokenId, pricePaid); // refund gas (if applicable) _refundGas(); @@ -379,7 +435,7 @@ contract MixinPurchase is _payReferrer(_referrer); // pay protocol - _payProtocol(inMemoryKeyPrice); + _payProtocol(pricePaid); } /** diff --git a/smart-contracts/hardhat.config.js b/smart-contracts/hardhat.config.js index babefff3688..2827ee45c47 100644 --- a/smart-contracts/hardhat.config.js +++ b/smart-contracts/hardhat.config.js @@ -29,7 +29,6 @@ require('hardhat-gas-reporter') // test coverage require('solidity-coverage') -// eslint-disable-next-line global-require require('@nomicfoundation/hardhat-verify') // check contract size diff --git a/smart-contracts/scripts/hooks/ERC1155BalanceOfHook.js b/smart-contracts/scripts/hooks/ERC1155BalanceOfHook.js index 8b783910d14..049526fc874 100644 --- a/smart-contracts/scripts/hooks/ERC1155BalanceOfHook.js +++ b/smart-contracts/scripts/hooks/ERC1155BalanceOfHook.js @@ -7,7 +7,6 @@ async function main() { const hook = await ERC1155BalanceOfHook.deploy() await hook.deployed() - // eslint-disable-next-line no-console console.log( `HOOK (ERC1155 BalanceOf) > deployed to : ${hook.address} (tx: ${hook.deployTransaction.hash})` ) diff --git a/smart-contracts/scripts/hooks/ERC20BalanceOfHook.js b/smart-contracts/scripts/hooks/ERC20BalanceOfHook.js index 5b8e10fb0da..9f5755109fd 100644 --- a/smart-contracts/scripts/hooks/ERC20BalanceOfHook.js +++ b/smart-contracts/scripts/hooks/ERC20BalanceOfHook.js @@ -6,7 +6,6 @@ async function main() { const hook = await ERC20BalanceOfHook.deploy() await hook.deployed() - // eslint-disable-next-line no-console console.log( `HOOK (ERC20 BalanceOf) > deployed to : ${hook.address} (tx: ${hook.deployTransaction.hash})` ) diff --git a/smart-contracts/scripts/hooks/ERC721BalanceOfHook.js b/smart-contracts/scripts/hooks/ERC721BalanceOfHook.js index 9bfb7e398fc..904bf8c3ec4 100644 --- a/smart-contracts/scripts/hooks/ERC721BalanceOfHook.js +++ b/smart-contracts/scripts/hooks/ERC721BalanceOfHook.js @@ -7,7 +7,6 @@ async function main() { const hook = await ERC721BalanceOfHook.deploy() await hook.deployed() - // eslint-disable-next-line no-console console.log( `HOOK (ERC721 BalanceOf) > deployed to : ${hook.address} (tx: ${hook.deployTransaction.hash})` ) diff --git a/smart-contracts/scripts/keys/renew.js b/smart-contracts/scripts/keys/renew.js index 8d754a2cdb8..f90583ded8f 100644 --- a/smart-contracts/scripts/keys/renew.js +++ b/smart-contracts/scripts/keys/renew.js @@ -13,7 +13,6 @@ async function main({ lockAddress, tokenId, referrer }) { const tx = await lock.renewMembershipFor(tokenId, referrer) const receipt = await tx.wait() - // eslint-disable-next-line no-console console.log(receipt) } diff --git a/smart-contracts/scripts/storage-layout.js b/smart-contracts/scripts/storage-layout.js index 11174216d1a..d4e571147b2 100644 --- a/smart-contracts/scripts/storage-layout.js +++ b/smart-contracts/scripts/storage-layout.js @@ -7,7 +7,6 @@ async function main() { main() .then(() => process.exit(0)) .catch((error) => { - // eslint-disable-next-line no-console console.error(error) process.exit(1) }) diff --git a/smart-contracts/tasks/accounts.js b/smart-contracts/tasks/accounts.js index 8404799f588..db722117e4d 100644 --- a/smart-contracts/tasks/accounts.js +++ b/smart-contracts/tasks/accounts.js @@ -5,7 +5,6 @@ task('accounts', 'Prints the list of accounts', async () => { const accounts = await ethers.getSigners() accounts.forEach((account, i) => { - // eslint-disable-next-line no-console console.log(`[${i}]: ${account.address}`) }) }) diff --git a/smart-contracts/tasks/keys.js b/smart-contracts/tasks/keys.js index 401a6e57fa7..0cac8343ddf 100644 --- a/smart-contracts/tasks/keys.js +++ b/smart-contracts/tasks/keys.js @@ -5,7 +5,6 @@ task('key:renew', 'Renew an expired key') .addParam('tokenId', 'The ID of the key to renew') .addOptionalParam('referrer', 'The address of the referrer') .setAction(async ({ lockAddress, tokenId, referrer }) => { - // eslint-disable-next-line global-require const renewKey = require('../scripts/keys/renew') await renewKey({ lockAddress, tokenId, referrer }) }) diff --git a/smart-contracts/tasks/upgrade.js b/smart-contracts/tasks/upgrade.js index f83063f52a3..d0226ce7888 100644 --- a/smart-contracts/tasks/upgrade.js +++ b/smart-contracts/tasks/upgrade.js @@ -20,7 +20,6 @@ task( const proxyAdminAddress = await getProxyAdminAddress({ network }) console.log(`proxyAdminAddress: ${proxyAdminAddress}`) - // eslint-disable-next-line global-require const simpleUpgrade = require(`../scripts/upgrade/simple`) await simpleUpgrade({ proxyAddress: proxy, @@ -42,10 +41,8 @@ task('upgrade:prepare', 'Deploy the implementation of an upgradeable contract') const { chainId } = await ethers.provider.getNetwork() const networkName = networks[chainId].name - // eslint-disable-next-line no-console console.log(`Deploying new implementation ${contract} on ${networkName}.`) - // eslint-disable-next-line global-require const prepareUpgrade = require('../scripts/upgrade/prepare') const contractName = contract.split('/')[1].replace('.sol', '') @@ -66,10 +63,8 @@ task('upgrade:import', 'Import a missing impl manifest from a proxy contract') const { chainId } = await ethers.provider.getNetwork() const networkName = networks[chainId].name - // eslint-disable-next-line no-console console.log(`Importing implementations from ${contract} on ${networkName}.`) - // eslint-disable-next-line global-require const prepareUpgrade = require('../scripts/upgrade/import') const contractName = contract.split('/')[1].replace('.sol', '') await prepareUpgrade({ @@ -92,7 +87,6 @@ task('upgrade:propose', 'Send an upgrade implementation proposal to multisig') .setAction(async ({ proxyAddress, implementation }, { network }) => { const proxyAdminAddress = await getProxyAdminAddress({ network }) - // eslint-disable-next-line global-require const proposeUpgrade = require('../scripts/upgrade/propose') await proposeUpgrade({ proxyAddress, @@ -119,7 +113,6 @@ task( unlockAddress, addOnly, }) => { - // eslint-disable-next-line global-require const submitLockVersion = require('../scripts/upgrade/submitLockVersion') await submitLockVersion({ publicLockAddress, @@ -137,7 +130,6 @@ task('proxy-admin', 'Retrieve the proxy admin address') 'Specify the template version to deploy (from contracts package)' ) .setAction(async (_, { ethers, network }) => { - // eslint-disable-next-line global-require const { getProxyAdminAddress } = require('../helpers/deployments') const proxyAdminAddress = await getProxyAdminAddress({ network }) const proxyAdmin = await ethers.getContractAt( diff --git a/smart-contracts/tasks/utils.js b/smart-contracts/tasks/utils.js index f8b4daaa23a..968135a14af 100644 --- a/smart-contracts/tasks/utils.js +++ b/smart-contracts/tasks/utils.js @@ -2,10 +2,9 @@ const { task } = require('hardhat/config') const { getImplementationAddress } = require('@openzeppelin/upgrades-core') task('node:reset', 'Reser node state').setAction(async () => { - // eslint-disable-next-line global-require const { resetNodeState } = require('../test/helpers/mainnet') await resetNodeState() - // eslint-disable-next-line no-console + console.log('Node state reset OK.') }) diff --git a/smart-contracts/test/Lock/purchaseUsingStruct.js b/smart-contracts/test/Lock/purchaseUsingStruct.js new file mode 100644 index 00000000000..40dd90ba37c --- /dev/null +++ b/smart-contracts/test/Lock/purchaseUsingStruct.js @@ -0,0 +1,116 @@ +const assert = require('assert') + +const { + getBalance, + deployERC20, + deployLock, + ADDRESS_ZERO, + MAX_UINT, + deployContracts, +} = require('../helpers') + +const { ethers } = require('hardhat') +const scenarios = [false, true] + +const keyPrice = ethers.parseUnits('0.01', 'ether') +// const tip = ethers.parseUnits('1', 'ether') + +describe('Lock / purchase using Struct signature', () => { + scenarios.forEach((isErc20) => { + let lock, unlock + let tokenAddress, expectedFee + let testToken, governanceToken + let deployer, spender, recipient + + describe(`Test ${isErc20 ? 'ERC20' : 'ETH'}`, () => { + beforeEach(async () => { + ;[deployer, spender, recipient] = await ethers.getSigners() + + if (isErc20) { + testToken = await deployERC20(deployer) + // Mint some tokens for testing + await testToken.mint( + await spender.getAddress(), + '100000000000000000000' + ) + } + ;({ unlock } = await deployContracts()) + tokenAddress = isErc20 ? await testToken.getAddress() : ADDRESS_ZERO + lock = await deployLock({ tokenAddress, unlock }) + + // configure unlock + governanceToken = await deployERC20(deployer) + await unlock.configUnlock( + await governanceToken.getAddress(), + await unlock.weth(), + 0, + 'KEY', + await unlock.globalBaseTokenURI(), + 1 // mainnet + ) + + // set 1% protocol fee + await unlock.setProtocolFee(100) + expectedFee = (keyPrice * 100n) / 10000n + + // default to spender + lock = lock.connect(spender) + + // Approve spending + if (isErc20) { + await testToken + .connect(spender) + .approve(await lock.getAddress(), MAX_UINT) + } + }) + + describe('purchase a single key', () => { + let lockBalanceBefore, unlockBalanceBefore + beforeEach(async () => { + lockBalanceBefore = await getBalance( + await lock.getAddress(), + tokenAddress + ) + unlockBalanceBefore = await getBalance( + await unlock.getAddress(), + tokenAddress + ) + + await lock.purchase( + [ + { + value: keyPrice, + recipient: await recipient.getAddress(), + referrer: ADDRESS_ZERO, + keyManager: ADDRESS_ZERO, + data: '0x', + }, + ], + { + value: isErc20 ? 0 : keyPrice, + } + ) + }) + + it('lock receveid the correct payment for the key', async () => { + assert.equal( + await getBalance(await lock.getAddress(), tokenAddress), + lockBalanceBefore + keyPrice - expectedFee + ) + }) + + it('user sucessfully received a key', async () => { + assert.equal(await lock.balanceOf(await recipient.getAddress()), 1) + }) + + it('protocol fee has been paid', async () => { + assert.equal( + (await getBalance(await unlock.getAddress(), tokenAddress)) - + unlockBalanceBefore, + expectedFee + ) + }) + }) + }) + }) +}) diff --git a/smart-contracts/test/Lock/setReferrerFee.js b/smart-contracts/test/Lock/setReferrerFee.js index 77ee5f45db8..66064f96717 100644 --- a/smart-contracts/test/Lock/setReferrerFee.js +++ b/smart-contracts/test/Lock/setReferrerFee.js @@ -38,18 +38,20 @@ describe('Lock / setReferrerFee', () => { const { args: eventArgs } = await getEvent(receipt, 'ReferrerFee') const balanceBefore = await getBalance(referrerAddress, tokenAddress) - const txPurchase = await lock - .connect(keyOwner) - .purchase( - isErc20 ? [keyPrice] : [], - [await keyOwner.getAddress()], - [referrerAddress], - [ADDRESS_ZERO], - ['0x'], + const txPurchase = await lock.connect(keyOwner).purchase( + [ { - value: isErc20 ? 0 : keyPrice, - } - ) + value: isErc20 ? keyPrice : 0, + recipient: await keyOwner.getAddress(), + referrer: referrerAddress, + keyManager: ADDRESS_ZERO, + data: '0x', + }, + ], + { + value: isErc20 ? 0 : keyPrice, + } + ) const receiptPurchase = await txPurchase.wait() const { args: purchaseEventArgs } = await getEvent( receiptPurchase, diff --git a/smart-contracts/test/helpers/lock.js b/smart-contracts/test/helpers/lock.js index 10a5584a517..2db2929472a 100644 --- a/smart-contracts/test/helpers/lock.js +++ b/smart-contracts/test/helpers/lock.js @@ -10,7 +10,7 @@ const purchaseKey = async ( lock, keyOwnerAddress, isErc20 = false, - keyPrice = DEFAULT_KEY_PRICE + keyPrice ) => { // make sure we got ethers lock lock = await ethers.getContractAt( @@ -18,16 +18,21 @@ const purchaseKey = async ( await lock.getAddress() ) + if (!keyPrice) { + keyPrice = await lock.keyPrice() + } + // get ethers signer const keyOwner = await ethers.getSigner(keyOwnerAddress) - const purchaseArgs = [ - isErc20 ? [keyPrice] : [], - [keyOwnerAddress], - [ADDRESS_ZERO], - [ADDRESS_ZERO], - ['0x'], - ] - const tx = await lock.connect(keyOwner).purchase(...purchaseArgs, { + const purchaseArgs = { + value: isErc20 ? keyPrice : 0, + recipient: keyOwnerAddress, + keyManager: ADDRESS_ZERO, + referrer: ADDRESS_ZERO, + data: '0x', + } + + const tx = await lock.connect(keyOwner).purchase([purchaseArgs], { value: isErc20 ? 0 : keyPrice, }) @@ -57,16 +62,20 @@ const purchaseKeys = async (lock, nbOfKeys = 1n, isErc20 = false, signer) => { if (signer) { lock = lock.connect(signer) } - const purchaseArgs = [ - isErc20 ? keyOwners.map(() => DEFAULT_KEY_PRICE) : [], - await Promise.all(keyOwners.map((keyOwner) => keyOwner.getAddress())), - keyOwners.map(() => ADDRESS_ZERO), - keyOwners.map(() => ADDRESS_ZERO), - keyOwners.map(() => '0x'), - ] - const tx = await lock.purchase(...purchaseArgs, { - value: isErc20 ? 0 : DEFAULT_KEY_PRICE * BigInt(nbOfKeys), + const keyPrice = await lock.keyPrice() + + const purchaseArgs = keyOwners.map((signer) => ({ + value: isErc20 ? keyPrice : 0n, + recipient: signer.getAddress(), + keyManager: ADDRESS_ZERO, + referrer: ADDRESS_ZERO, + data: '0x', + })) + + const tx = await lock.purchase(purchaseArgs, { + value: isErc20 ? 0 : keyPrice * BigInt(nbOfKeys), }) + // get token ids const receipt = await tx.wait() const { events, blockNumber } = await getEvents(receipt, 'Transfer') diff --git a/smart-contracts/test/helpers/multisig.js b/smart-contracts/test/helpers/multisig.js index e5524f64d7d..547e19214de 100644 --- a/smart-contracts/test/helpers/multisig.js +++ b/smart-contracts/test/helpers/multisig.js @@ -28,12 +28,10 @@ const confirmMultisigTx = async ({ const success = await getEvent(receipt, 'Execution') if (failure) { - // eslint-disable-next-line no-console console.log( `ERROR: Proposal ${transactionId} failed to execute (txid: ${transactionHash})` ) } else if (success) { - // eslint-disable-next-line no-console console.log( `Proposal ${transactionId} executed successfully (txid: ${transactionHash})` )