From 338fba2be99ef8742d7e128470265daae5765276 Mon Sep 17 00:00:00 2001 From: David Roon Date: Thu, 17 Oct 2024 17:07:10 +0200 Subject: [PATCH] coupon update delegate key (#604) * coupon update delegate key * update date * update comments --- configs/contracts.config.ts | 20 ++ .../adapters/CouponUpdateDelegateKey.sol | 151 ++++++++ .../coupon-update-delegate-key.test.js | 339 ++++++++++++++++++ utils/dao-ids-util.ts | 1 + utils/offchain-voting-util.js | 28 ++ 5 files changed, 539 insertions(+) create mode 100644 contracts/adapters/CouponUpdateDelegateKey.sol create mode 100644 test/adapters/coupon-update-delegate-key.test.js diff --git a/configs/contracts.config.ts b/configs/contracts.config.ts index bd20c4af1..4ac8b1383 100644 --- a/configs/contracts.config.ts +++ b/configs/contracts.config.ts @@ -946,6 +946,26 @@ export const contracts: Array = [ ], ], }, + { + id: adaptersIdsMap.COUPON_UPDATE_DELEGATE_KEY_ADAPTER, + name: "CouponUpdateDelegateKeyContract", + alias: "couponUpdateDelegateKey", + path: "../../contracts/adapters/CouponUpdateDelegateKeyContract", + enabled: true, + version: "1.0.0", + type: ContractType.Adapter, + acls: { + dao: [daoAccessFlagsMap.UPDATE_DELEGATE_KEY], + extensions: {}, + }, + daoConfigs: [ + //config to mint coupons + [ + "daoAddress", + "couponCreatorAddress" + ], + ], + }, { id: adaptersIdsMap.KYC_ONBOARDING_ADAPTER, name: "KycOnboardingContract", diff --git a/contracts/adapters/CouponUpdateDelegateKey.sol b/contracts/adapters/CouponUpdateDelegateKey.sol new file mode 100644 index 000000000..bc2302907 --- /dev/null +++ b/contracts/adapters/CouponUpdateDelegateKey.sol @@ -0,0 +1,151 @@ +pragma solidity ^0.8.0; + +// SPDX-License-Identifier: MIT + +import "../core/DaoRegistry.sol"; +import "../extensions/bank/Bank.sol"; +import "../guards/AdapterGuard.sol"; +import "./modifiers/Reimbursable.sol"; +import "../utils/Signatures.sol"; +import "../helpers/DaoHelper.sol"; + +/** +MIT License + +Copyright (c) 2024 Openlaw + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +contract CouponUpdateDelegateKeyContract is + Reimbursable, + AdapterGuard, + Signatures +{ + struct Coupon { + address authorizedMember; + address newDelegateKey; + uint256 nonce; + } + + using SafeERC20 for IERC20; + + string public constant COUPON_MESSAGE_TYPE = + "Message(address authorizedMember,address newDelegateKey,uint256 nonce)"; + + bytes32 public constant COUPON_MESSAGE_TYPEHASH = + keccak256(abi.encodePacked(COUPON_MESSAGE_TYPE)); + + bytes32 constant SignerAddressConfig = + keccak256("coupon-update-delegate-key.signerAddress"); + + mapping(address => mapping(uint256 => uint256)) private _flags; + + event CouponRedeemed( + address daoAddress, + uint256 nonce, + address authorizedMember, + address newDelegateKey + ); + + /** + * @notice Configures the Adapter with the coupon signer address. + * @param signerAddress the address of the coupon signer + */ + function configureDao( + DaoRegistry dao, + address signerAddress + ) external onlyAdapter(dao) { + dao.setAddressConfiguration(SignerAddressConfig, signerAddress); + } + + /** + * @notice Hashes the provided coupon as an ERC712 hash. + * @param dao is the DAO instance to be configured + * @param coupon is the coupon to hash + */ + function hashCouponMessage( + DaoRegistry dao, + Coupon memory coupon + ) public view returns (bytes32) { + bytes32 message = keccak256( + abi.encode( + COUPON_MESSAGE_TYPEHASH, + coupon.authorizedMember, + coupon.newDelegateKey, + coupon.nonce + ) + ); + + return hashMessage(dao, address(this), message); + } + + /** + * @notice Redeems a coupon to update the delegate key + * @param dao is the DAO instance to be configured + * @param authorizedMember is the member that this coupon authorized to update the delegate key + * @param newDelegateKey is the new delegate key for the member + * @param nonce is a unique identifier for this coupon request + * @param signature is message signature for verification + */ + // function is protected against reentrancy attack with the reentrancyGuard(dao) + // slither-disable-next-line reentrancy-benign + function redeemCoupon( + DaoRegistry dao, + address authorizedMember, + address newDelegateKey, + uint256 nonce, + bytes memory signature + ) external reimbursable(dao) { + { + uint256 currentFlag = _flags[address(dao)][nonce / 256]; + _flags[address(dao)][nonce / 256] = DaoHelper.setFlag( + currentFlag, + nonce % 256, + true + ); + + require( + DaoHelper.getFlag(currentFlag, nonce % 256) == false, + "coupon already redeemed" + ); + } + + Coupon memory coupon = Coupon(authorizedMember, newDelegateKey, nonce); + bytes32 hash = hashCouponMessage(dao, coupon); + + require( + SignatureChecker.isValidSignatureNow( + dao.getAddressConfiguration(SignerAddressConfig), + hash, + signature + ), + "invalid sig" + ); + dao.updateDelegateKey(authorizedMember, newDelegateKey); + + //slither-disable-next-line reentrancy-events + emit CouponRedeemed( + address(dao), + nonce, + authorizedMember, + newDelegateKey + ); + } +} diff --git a/test/adapters/coupon-update-delegate-key.test.js b/test/adapters/coupon-update-delegate-key.test.js new file mode 100644 index 000000000..0bdf77e97 --- /dev/null +++ b/test/adapters/coupon-update-delegate-key.test.js @@ -0,0 +1,339 @@ +// Whole-script strict mode syntax +"use strict"; + +/** +MIT License + +Copyright (c) 2020 Openlaw + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ +const { expect } = require("chai"); +const { + sha3, + toBN, + toWei, + fromAscii, + UNITS, + GUILD, + ETH_TOKEN, +} = require("../../utils/contract-util"); + +const { + deployDefaultDao, + takeChainSnapshot, + revertChainSnapshot, + getAccounts, + web3, +} = require("../../utils/hardhat-test-util"); + +const { checkBalance } = require("../../utils/test-util"); + +const { + SigUtilSigner, + getMessageERC712Hash, +} = require("../../utils/offchain-voting-util"); + +const signer = { + address: "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1", + privKey: "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d", +}; + +describe("Adapter - Coupon Update Delegate Key", () => { + let accounts, daoOwner; + const chainId = 1337; + + before("deploy dao", async () => { + accounts = await getAccounts(); + daoOwner = accounts[0]; + + const { dao, adapters, extensions } = await deployDefaultDao({ + owner: daoOwner, + couponCreatorAddress: signer.address, + }); + this.dao = dao; + this.adapters = adapters; + this.extensions = extensions; + this.snapshotId = await takeChainSnapshot(); + }); + + beforeEach(async () => { + await revertChainSnapshot(this.snapshotId); + this.snapshotId = await takeChainSnapshot(); + }); + + it("should be possible to update the delegate key with a valid coupon", async () => { + const otherAccount = accounts[2]; + const account = accounts[0]; + const signerUtil = SigUtilSigner(signer.privKey); + + const dao = this.dao; + + let signerAddr = await dao.getAddressConfiguration( + sha3("coupon-update-delegate-key.signerAddress") + ); + expect(signerAddr).equal(signer.address); + + const couponUpdateDelegateKey = this.adapters.couponUpdateDelegateKey; + + const couponData = { + type: "coupon-delegate-key", + authorizedMember: account, + newDelegateKey: otherAccount, + nonce: 1, + }; + + let jsHash = getMessageERC712Hash( + couponData, + dao.address, + couponUpdateDelegateKey.address, + chainId + ); + let solHash = await couponUpdateDelegateKey.hashCouponMessage( + dao.address, + couponData + ); + expect(jsHash).equal(solHash); + + var signature = signerUtil( + couponData, + dao.address, + couponUpdateDelegateKey.address, + chainId + ); + + const delegateKeyBefore = await dao.getCurrentDelegateKey(daoOwner); + + await couponUpdateDelegateKey.redeemCoupon( + dao.address, + account, + otherAccount, + 1, + signature + ); + + const delegateKeyAfter = await dao.getCurrentDelegateKey(daoOwner); + + expect(delegateKeyBefore).equal(daoOwner); + expect(delegateKeyAfter).equal(otherAccount); + }); + + it("should not be possible to update the delegate key if not a member", async () => { + const otherAccount = accounts[2]; + + const signerUtil = SigUtilSigner(signer.privKey); + + const dao = this.dao; + + let signerAddr = await dao.getAddressConfiguration( + sha3("coupon-update-delegate-key.signerAddress") + ); + expect(signerAddr).equal(signer.address); + + const couponUpdateDelegateKey = this.adapters.couponUpdateDelegateKey; + + const couponData = { + type: "coupon-delegate-key", + authorizedMember: otherAccount, + newDelegateKey: accounts[3], + nonce: 1, + }; + + let jsHash = getMessageERC712Hash( + couponData, + dao.address, + couponUpdateDelegateKey.address, + 1 + ); + + var signature = signerUtil( + couponData, + dao.address, + couponUpdateDelegateKey.address, + 1 + ); + + const isValid = await couponUpdateDelegateKey.isValidSignature( + signer.address, + jsHash, + signature + ); + + expect(isValid).equal(true); + + await expect( + couponUpdateDelegateKey.redeemCoupon( + dao.address, + otherAccount, + accounts[3], + 1, + signature + ) + ).to.be.revertedWith("invalid sig"); + + const delegateKeyAfter = await dao.getCurrentDelegateKey(otherAccount); + + expect(delegateKeyAfter).equal(otherAccount); + }); + + it("should not be possible to update the delegate key for a member that doesn't match the coupon value", async () => { + const otherAccount = accounts[2]; + + const signerUtil = SigUtilSigner(signer.privKey); + + const dao = this.dao; + const bank = this.extensions.bankExt; + + let signerAddr = await dao.getAddressConfiguration( + sha3("coupon-update-delegate-key.signerAddress") + ); + expect(signerAddr).equal(signer.address); + + const couponUpdateDelegateKey = this.adapters.couponUpdateDelegateKey; + + const couponData = { + type: "coupon-delegate-key", + authorizedMember: daoOwner, + newDelegateKey: accounts[3], + nonce: 1, + }; + + let jsHash = getMessageERC712Hash( + couponData, + dao.address, + couponUpdateDelegateKey.address, + 1 + ); + + var signature = signerUtil( + couponData, + dao.address, + couponUpdateDelegateKey.address, + 1 + ); + + const isValid = await couponUpdateDelegateKey.isValidSignature( + signer.address, + jsHash, + signature + ); + + expect(isValid).equal(true); + + await expect( + couponUpdateDelegateKey.redeemCoupon( + dao.address, + daoOwner, + accounts[3], + 1, + signature + ) + ).to.be.revertedWith("invalid sig"); + }); + + it("should not be possible to replay coupon", async () => { + const otherAccount = accounts[2]; + const signerUtil = SigUtilSigner(signer.privKey); + + const dao = this.dao; + const bank = this.extensions.bankExt; + + let signerAddr = await dao.getAddressConfiguration( + sha3("coupon-onboarding.signerAddress") + ); + expect(signerAddr).equal(signer.address); + + const couponUpdateDelegateKey = this.adapters.couponUpdateDelegateKey; + + const couponData = { + type: "coupon-delegate-key", + authorizedMember: daoOwner, + newDelegateKey: otherAccount, + nonce: 1, + }; + + const couponData2 = { + type: "coupon", + authorizedMember: daoOwner, + newDelegateKey: accounts[3], + nonce: 1, + }; + + let jsHash = getMessageERC712Hash( + couponData, + dao.address, + couponUpdateDelegateKey.address, + chainId + ); + let solHash = await couponUpdateDelegateKey.hashCouponMessage( + dao.address, + couponData + ); + expect(jsHash).equal(solHash); + + var signature = signerUtil( + couponData, + dao.address, + couponUpdateDelegateKey.address, + chainId + ); + + await couponUpdateDelegateKey.redeemCoupon( + dao.address, + daoOwner, + otherAccount, + 1, + signature + ); + await expect( + couponUpdateDelegateKey.redeemCoupon( + dao.address, + daoOwner, + otherAccount, + 1, + signature + ) + ).to.be.revertedWith("coupon already redeemed"); + }); + + it("should not be possible to send ETH to the adapter via receive function", async () => { + const adapter = this.adapters.couponUpdateDelegateKey; + await expect( + web3.eth.sendTransaction({ + to: adapter.address, + from: daoOwner, + gasPrice: toBN("0"), + value: toWei("1"), + }) + ).to.be.revertedWith("revert"); + }); + + it("should not be possible to send ETH to the adapter via fallback function", async () => { + const adapter = this.adapters.couponUpdateDelegateKey; + await expect( + web3.eth.sendTransaction({ + to: adapter.address, + from: daoOwner, + gasPrice: toBN("0"), + value: toWei("1"), + data: fromAscii("should go to fallback func"), + }) + ).to.be.revertedWith("revert"); + }); +}); diff --git a/utils/dao-ids-util.ts b/utils/dao-ids-util.ts index b3ab4c231..10f927e77 100644 --- a/utils/dao-ids-util.ts +++ b/utils/dao-ids-util.ts @@ -26,6 +26,7 @@ export const adaptersIdsMap: Record = { KICK_BAD_REPORTER_ADAPTER: "kick-bad-reporter-adpt", COUPON_ONBOARDING_ADAPTER: "coupon-onboarding", COUPON_BURN_ADAPTER: "coupon-burn", + COUPON_UPDATE_DELEGATE_KEY_ADAPTER: "coupon-update-delegate-key", KYC_ONBOARDING_ADAPTER: "kyc-onboarding", LEND_NFT_ADAPTER: "lend-nft", ERC20_TRANSFER_STRATEGY_ADAPTER: "erc20-transfer-strategy", diff --git a/utils/offchain-voting-util.js b/utils/offchain-voting-util.js index 159533699..4db2d9af4 100644 --- a/utils/offchain-voting-util.js +++ b/utils/offchain-voting-util.js @@ -61,6 +61,7 @@ function getMessageERC712Hash(m, verifyingContract, actionId, chainId) { primaryType: "Message", types, }; + return "0x" + sigUtil.TypedDataUtils.sign(msgParams).toString("hex"); } @@ -82,6 +83,12 @@ function getDomainDefinition(message, verifyingContract, actionId, chainId) { return getCouponDomainDefinition(verifyingContract, actionId, chainId); case "coupon-kyc": return getCouponKycDomainDefinition(verifyingContract, actionId, chainId); + case "coupon-delegate-key": + return getCouponDelegateKeyDomainDefinition( + verifyingContract, + actionId, + chainId + ); case "manager": return getManagerDomainDefinition(verifyingContract, actionId, chainId); case "coupon-nft": @@ -272,6 +279,25 @@ function getCouponDomainDefinition(verifyingContract, actionId, chainId) { return { domain, types }; } +function getCouponDelegateKeyDomainDefinition( + verifyingContract, + actionId, + chainId +) { + const domain = getMessageDomainType(chainId, verifyingContract, actionId); + + const types = { + Message: [ + { name: "authorizedMember", type: "address" }, + { name: "newDelegateKey", type: "address" }, + { name: "nonce", type: "uint256" }, + ], + EIP712Domain: getDomainType(), + }; + + return { domain, types }; +} + function getDomainType() { return [ { name: "name", type: "string" }, @@ -399,6 +425,8 @@ function prepareMessage(message) { return message; case "coupon-kyc": return message; + case "coupon-delegate-key": + return message; case "manager": return message; case "coupon-nft":