From 0296fc7e670a24c347c4d44f735dc976b6e314af Mon Sep 17 00:00:00 2001 From: Isaac Patka Date: Wed, 16 Feb 2022 12:24:27 -0500 Subject: [PATCH] loot fixes and tests --- contracts/LootERC20.sol | 15 +- contracts/mock/MockBaal.sol | 33 ++++ test/Baal.test.ts | 76 -------- test/LootERC20.test.ts | 334 ++++++++++++++++++++++++++++++++++++ 4 files changed, 377 insertions(+), 81 deletions(-) create mode 100644 contracts/mock/MockBaal.sol create mode 100644 test/LootERC20.test.ts diff --git a/contracts/LootERC20.sol b/contracts/LootERC20.sol index 3c5ea5c..5b41513 100644 --- a/contracts/LootERC20.sol +++ b/contracts/LootERC20.sol @@ -31,20 +31,23 @@ contract Loot is ERC20, Initializable { ); // Baal Config - IBaal baal; + IBaal public baal; modifier baalOnly() { require(msg.sender == address(baal), "!auth"); _; } - constructor() ERC20("Template", "T") {} /*Configure template to be unusable*/ + constructor() ERC20("Template", "T") initializer {} /*Configure template to be unusable*/ /// @notice Configure loot - called by Baal on summon /// @dev initializer should prevent this from being called again /// @param name_ Name for ERC20 token trackers /// @param symbol_ Symbol for ERC20 token trackers - function setUp(string memory name_, string memory symbol_) public initializer { + function setUp(string memory name_, string memory symbol_) + public + initializer + { baal = IBaal(msg.sender); /*Configure Baal to setup sender*/ _name = name_; _symbol = symbol_; @@ -80,7 +83,6 @@ contract Loot is ERC20, Initializable { return true; } - /// @notice Baal-only function to mint loot. /// @param recipient Address to receive loot /// @param amount Amount to mint @@ -143,6 +145,7 @@ contract Loot is ERC20, Initializable { } /// @notice Internal hook to restrict token transfers unless allowed by baal + /// @dev Allows transfers if msg.sender is Baal which enables minting and burning /// @param from The address of the source account. /// @param to The address of the destination account. /// @param amount The number of `loot` tokens to transfer. @@ -153,7 +156,9 @@ contract Loot is ERC20, Initializable { ) internal override(ERC20) { super._beforeTokenTransfer(from, to, amount); require( - from == address(0) || to == address(0) || !baal.lootPaused(), + from == address(0) || /*Minting allowed*/ + (msg.sender == address(baal) && to == address(0)) || /*Burning by Baal allowed*/ + !baal.lootPaused(), "!transferable" ); } diff --git a/contracts/mock/MockBaal.sol b/contracts/mock/MockBaal.sol new file mode 100644 index 0000000..4707fe5 --- /dev/null +++ b/contracts/mock/MockBaal.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +import "../Baal.sol"; + +contract MockBaal is CloneFactory { + bool public lootPaused; + ILoot public lootToken; /*Sub ERC20 for loot mgmt*/ + + constructor( + address payable _lootSingleton, + string memory _name, + string memory _symbol + ) { + lootToken = ILoot(createClone(_lootSingleton)); /*Clone loot singleton using EIP1167 minimal proxy pattern*/ + lootToken.setUp( + string(abi.encodePacked(_name, " LOOT")), + string(abi.encodePacked(_symbol, "-LOOT")) + ); + } + + function setLootPaused(bool paused) external { + lootPaused = paused; + } + + function mintLoot(address _to, uint256 _amount) external { + lootToken.mint(_to, _amount); + } + + function burnLoot(address _from, uint256 _amount) external { + lootToken.burn(_from, _amount); + } +} diff --git a/test/Baal.test.ts b/test/Baal.test.ts index 16d4958..1e0afe1 100644 --- a/test/Baal.test.ts +++ b/test/Baal.test.ts @@ -1503,82 +1503,6 @@ describe('Baal contract', function () { }) }) - describe('erc20 loot - increase allowance with permit', function() { - it('increase allowance with valid permit', async function() { - const deadline = await blockTime() + 10000 - const nonce = await lootToken.nonces(summoner.address) - const permitSignature = await signPermit(chainId,lootToken.address, summoner, await lootToken.name(), summoner.address, shaman.address,500,nonce,deadline) - await lootToken.permit(summoner.address, shaman.address, 500, deadline,permitSignature) - const shamanAllowance = await lootToken.allowance(summoner.address, shaman.address) - expect(shamanAllowance).to.equal(500) - }) - - it('Require fail - invalid nonce', async function() { - const deadline = await blockTime() + 10000 - const nonce = await lootToken.nonces(summoner.address) - const permitSignature = await signPermit(chainId,lootToken.address, summoner, await lootToken.name(), summoner.address, shaman.address,500,nonce.add(1),deadline) - expect(lootToken.permit(summoner.address, shaman.address, 500, deadline,permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) - }) - - it('Require fail - invalid chain Id', async function() { - const deadline = await blockTime() + 10000 - const nonce = await lootToken.nonces(summoner.address) - const permitSignature = await signPermit(420,lootToken.address, summoner, await lootToken.name(), summoner.address, shaman.address,500,nonce,deadline) - expect(lootToken.permit(summoner.address, shaman.address, 500, deadline,permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) - }) - - it('Require fail - invalid name', async function() { - const deadline = await blockTime() + 10000 - const nonce = await lootToken.nonces(summoner.address) - const permitSignature = await signPermit(chainId,lootToken.address, summoner, 'invalid', summoner.address, shaman.address,500,nonce,deadline) - expect(lootToken.permit(summoner.address, shaman.address, 500, deadline,permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) - }) - - it('Require fail - invalid address', async function() { - const deadline = await blockTime() + 10000 - const nonce = await lootToken.nonces(summoner.address) - const permitSignature = await signPermit(chainId,zeroAddress, summoner, await lootToken.name(), summoner.address, shaman.address,500,nonce,deadline) - expect(lootToken.permit(summoner.address, shaman.address, 500, deadline,permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) - }) - - it('Require fail - invalid owner', async function() { - const deadline = await blockTime() + 10000 - const nonce = await lootToken.nonces(summoner.address) - const permitSignature = await signPermit(chainId,lootToken.address, summoner, await lootToken.name(), s1.address, shaman.address,500,nonce,deadline) - expect(lootToken.permit(summoner.address, shaman.address, 500, deadline,permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) - }) - - it('Require fail - invalid spender', async function() { - const deadline = await blockTime() + 10000 - const nonce = await lootToken.nonces(summoner.address) - const permitSignature = await signPermit(chainId,lootToken.address, summoner, await lootToken.name(), summoner.address, s1.address,500,nonce,deadline) - expect(lootToken.permit(summoner.address, shaman.address, 500, deadline,permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) - }) - - it('Require fail - invalid amount', async function() { - const deadline = await blockTime() + 10000 - const nonce = await lootToken.nonces(summoner.address) - const permitSignature = await signPermit(chainId,lootToken.address, summoner, await lootToken.name(), summoner.address, shaman.address,499,nonce,deadline) - expect(lootToken.permit(summoner.address, shaman.address, 500, deadline,permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) - }) - - it('Require fail - invalid deadline', async function() { - const deadline = await blockTime() + 10000 - const nonce = await lootToken.nonces(summoner.address) - const permitSignature = await signPermit(chainId,lootToken.address, summoner, await lootToken.name(), summoner.address, shaman.address,500,nonce,deadline - 1) - expect(lootToken.permit(summoner.address, shaman.address, 500, deadline,permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) - }) - - it('Require fail - expired deadline', async function() { - const deadline = await blockTime() - 1 - const nonce = await lootToken.nonces(summoner.address) - const permitSignature = await signPermit(chainId,lootToken.address, summoner, await lootToken.name(), summoner.address, shaman.address,500,nonce,deadline) - expect(lootToken.permit(summoner.address, shaman.address, 500, deadline,permitSignature)).to.be.revertedWith(revertMessages.permitExpired) - }) - - }) - - describe('submitProposal', function () { it('happy case', async function () { // note - this also tests that members can submit proposals without offering tribute diff --git a/test/LootERC20.test.ts b/test/LootERC20.test.ts new file mode 100644 index 0000000..2154c31 --- /dev/null +++ b/test/LootERC20.test.ts @@ -0,0 +1,334 @@ +import { ethers } from 'hardhat' +import { solidity } from 'ethereum-waffle' +import { use, expect } from 'chai' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' + +import { Loot } from '../src/types/Loot' +import { MockBaal } from '../src/types/MockBaal' +import { BigNumber, BigNumberish } from '@ethersproject/bignumber' +import { ContractFactory, utils } from 'ethers' +import signPermit from '../src/signPermit' + +use(solidity) + +// chai +// .use(require('chai-as-promised')) +// .should(); + +const revertMessages = { + lootAlreadyInitialized: 'Initializable: contract is already initialized', + permitNotAuthorized: '!authorized', + permitExpired: 'expired', + lootNotBaal: '!auth', + notTransferable: '!transferable', + transferToZero: 'ERC20: transfer to the zero address' +} + +const zeroAddress = '0x0000000000000000000000000000000000000000' + +async function blockTime() { + const block = await ethers.provider.getBlock('latest') + return block.timestamp +} + +async function blockNumber() { + const block = await ethers.provider.getBlock('latest') + return block.number +} + +describe.only('Loot ERC20 contract', async function () { + let lootSingleton: Loot + let LootFactory: ContractFactory + let MockBaalFactory: ContractFactory + + let mockBaal: MockBaal + + let lootToken: Loot + let baalLootToken: Loot + + let summoner: SignerWithAddress + let member: SignerWithAddress + let chainId: number + + let s1: SignerWithAddress + let s2: SignerWithAddress + + let s1Loot: Loot + let s2Loot: Loot + + this.beforeAll(async function () { + LootFactory = await ethers.getContractFactory('Loot') + lootSingleton = (await LootFactory.deploy()) as Loot + MockBaalFactory = await ethers.getContractFactory('MockBaal') + const network = await ethers.provider.getNetwork() + chainId = network.chainId + }) + + beforeEach(async function () { + ;[summoner, member, s1, s2] = await ethers.getSigners() + mockBaal = (await MockBaalFactory.deploy(lootSingleton.address, 'NAME', 'SYMBOL')) as MockBaal + const lootTokenAddress = await mockBaal.lootToken() + lootToken = LootFactory.attach(lootTokenAddress) as Loot + s1Loot = lootToken.connect(s1) + s2Loot = lootToken.connect(s2) + await mockBaal.mintLoot(summoner.address, 500) + }) + + describe('constructor', async function () { + it('creates an unusable template', async function () { + expect(await lootSingleton.baal()).to.equal(zeroAddress) + }) + + it('require fail - initializer (setup) cant be called twice on loot', async function () { + expect(lootToken.setUp('NAME', 'SYMBOL')).to.be.revertedWith(revertMessages.lootAlreadyInitialized) + }) + + it('require fail - initializer (setup) cant be called on singleton', async function () { + expect(lootSingleton.setUp('NAME', 'SYMBOL')).to.be.revertedWith(revertMessages.lootAlreadyInitialized) + }) + }) + + describe('er20 loot - authorized minting, burning', async function () { + it('happy case - allows baal to mint when loot not paused', async function () { + expect(await mockBaal.lootPaused()).to.equal(false) + expect(await lootToken.balanceOf(s2.address)).to.equal(0) + await mockBaal.mintLoot(s2.address, 100) + expect(await lootToken.balanceOf(s2.address)).to.equal(100) + }) + + it('happy case - allows baal to mint when loot paused', async function () { + await mockBaal.setLootPaused(true) + expect(await mockBaal.lootPaused()).to.equal(true) + expect(await lootToken.balanceOf(s2.address)).to.equal(0) + await mockBaal.mintLoot(s2.address, 100) + expect(await lootToken.balanceOf(s2.address)).to.equal(100) + }) + + it('require fail - non baal tries to mint', async function () { + expect(s1Loot.mint(s1.address, 100)).to.be.revertedWith(revertMessages.lootNotBaal) + }) + + it('happy case - allows baal to burn when loot not paused', async function () { + expect(await mockBaal.lootPaused()).to.equal(false) + expect(await lootToken.balanceOf(summoner.address)).to.equal(500) + await mockBaal.burnLoot(summoner.address, 100) + expect(await lootToken.balanceOf(summoner.address)).to.equal(400) + }) + + it('happy case - allows baal to burn when loot paused', async function () { + await mockBaal.setLootPaused(true) + expect(await mockBaal.lootPaused()).to.equal(true) + expect(await lootToken.balanceOf(summoner.address)).to.equal(500) + await mockBaal.burnLoot(summoner.address, 100) + expect(await lootToken.balanceOf(summoner.address)).to.equal(400) + }) + + it('require fail - non baal tries to burn', async function () { + await mockBaal.mintLoot(s2.address, 100) + expect(s1Loot.burn(s2.address, 50)).to.be.revertedWith(revertMessages.lootNotBaal) + }) + + it('require fail - non baal tries to send to 0', async function () { + await mockBaal.mintLoot(s2.address, 100) + expect(s1Loot.transfer(zeroAddress, 50)).to.be.revertedWith(revertMessages.transferToZero) + }) + }) + + describe('er20 loot - restrict transfer', async function () { + it('happy case - allows loot to be transferred when enabled', async function () { + expect(await lootToken.balanceOf(summoner.address)).to.equal(500) + expect(await lootToken.balanceOf(s1.address)).to.equal(0) + expect(await mockBaal.lootPaused()).to.equal(false) + await lootToken.transfer(s1.address, 100) + expect(await lootToken.balanceOf(summoner.address)).to.equal(400) + expect(await lootToken.balanceOf(s1.address)).to.equal(100) + }) + + it('require fail - tries to transfer loot when paused', async function () { + await mockBaal.setLootPaused(true) + expect(await mockBaal.lootPaused()).to.equal(true) + expect(lootToken.transfer(s1.address, 100)).to.be.revertedWith(revertMessages.notTransferable) + }) + + it('happy case - allows loot to be transfered with approval when enabled', async function () { + expect(await lootToken.balanceOf(summoner.address)).to.equal(500) + expect(await lootToken.balanceOf(s1.address)).to.equal(0) + expect(await mockBaal.lootPaused()).to.equal(false) + await lootToken.approve(s2.address, 100) + await s2Loot.transferFrom(summoner.address, s1.address, 100) + expect(await lootToken.balanceOf(summoner.address)).to.equal(400) + expect(await lootToken.balanceOf(s1.address)).to.equal(100) + }) + + it('require fail - tries to transfer with approval loot when paused', async function () { + await mockBaal.setLootPaused(true) + await lootToken.approve(s2.address, 100) + expect(s2Loot.transferFrom(summoner.address, s1.address, 100)).to.be.revertedWith(revertMessages.notTransferable) + }) + + }) + + describe('erc20 loot - increase allowance with permit', async function () { + it('happy case - increase allowance with valid permit', async function () { + const deadline = (await blockTime()) + 10000 + const nonce = await lootToken.nonces(summoner.address) + const permitSignature = await signPermit( + chainId, + lootToken.address, + summoner, + await lootToken.name(), + summoner.address, + s1.address, + 500, + nonce, + deadline + ) + await lootToken.permit(summoner.address, s1.address, 500, deadline, permitSignature) + const s1Allowance = await lootToken.allowance(summoner.address, s1.address) + expect(s1Allowance).to.equal(500) + }) + + it('Require fail - invalid nonce', async function () { + const deadline = (await blockTime()) + 10000 + const nonce = await lootToken.nonces(summoner.address) + const permitSignature = await signPermit( + chainId, + lootToken.address, + summoner, + await lootToken.name(), + summoner.address, + s1.address, + 500, + nonce.add(1), + deadline + ) + expect(lootToken.permit(summoner.address, s1.address, 500, deadline, permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) + }) + + it('Require fail - invalid chain Id', async function () { + const deadline = (await blockTime()) + 10000 + const nonce = await lootToken.nonces(summoner.address) + const permitSignature = await signPermit( + 420, + lootToken.address, + summoner, + await lootToken.name(), + summoner.address, + s1.address, + 500, + nonce, + deadline + ) + expect(lootToken.permit(summoner.address, s1.address, 500, deadline, permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) + }) + + it('Require fail - invalid name', async function () { + const deadline = (await blockTime()) + 10000 + const nonce = await lootToken.nonces(summoner.address) + const permitSignature = await signPermit(chainId, lootToken.address, summoner, 'invalid', summoner.address, s1.address, 500, nonce, deadline) + expect(lootToken.permit(summoner.address, s1.address, 500, deadline, permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) + }) + + it('Require fail - invalid address', async function () { + const deadline = (await blockTime()) + 10000 + const nonce = await lootToken.nonces(summoner.address) + const permitSignature = await signPermit( + chainId, + zeroAddress, + summoner, + await lootToken.name(), + summoner.address, + s1.address, + 500, + nonce, + deadline + ) + expect(lootToken.permit(summoner.address, s1.address, 500, deadline, permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) + }) + + it('Require fail - invalid owner', async function () { + const deadline = (await blockTime()) + 10000 + const nonce = await lootToken.nonces(summoner.address) + const permitSignature = await signPermit( + chainId, + lootToken.address, + summoner, + await lootToken.name(), + s1.address, + s1.address, + 500, + nonce, + deadline + ) + expect(lootToken.permit(summoner.address, s1.address, 500, deadline, permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) + }) + + it('Require fail - invalid spender', async function () { + const deadline = (await blockTime()) + 10000 + const nonce = await lootToken.nonces(summoner.address) + const permitSignature = await signPermit( + chainId, + lootToken.address, + summoner, + await lootToken.name(), + summoner.address, + s2.address, + 500, + nonce, + deadline + ) + expect(lootToken.permit(summoner.address, s1.address, 500, deadline, permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) + }) + + it('Require fail - invalid amount', async function () { + const deadline = (await blockTime()) + 10000 + const nonce = await lootToken.nonces(summoner.address) + const permitSignature = await signPermit( + chainId, + lootToken.address, + summoner, + await lootToken.name(), + summoner.address, + s1.address, + 499, + nonce, + deadline + ) + expect(lootToken.permit(summoner.address, s1.address, 500, deadline, permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) + }) + + it('Require fail - invalid deadline', async function () { + const deadline = (await blockTime()) + 10000 + const nonce = await lootToken.nonces(summoner.address) + const permitSignature = await signPermit( + chainId, + lootToken.address, + summoner, + await lootToken.name(), + summoner.address, + s1.address, + 500, + nonce, + deadline - 1 + ) + expect(lootToken.permit(summoner.address, s1.address, 500, deadline, permitSignature)).to.be.revertedWith(revertMessages.permitNotAuthorized) + }) + + it('Require fail - expired deadline', async function () { + const deadline = (await blockTime()) - 1 + const nonce = await lootToken.nonces(summoner.address) + const permitSignature = await signPermit( + chainId, + lootToken.address, + summoner, + await lootToken.name(), + summoner.address, + s1.address, + 500, + nonce, + deadline + ) + expect(lootToken.permit(summoner.address, s1.address, 500, deadline, permitSignature)).to.be.revertedWith(revertMessages.permitExpired) + }) + }) +})