@defi-wonderland/keep3r-v2
Version:
The Keep3r Network is a decentralized network for projects that need external devops, and for external teams to find keeper jobs
631 lines (510 loc) • 30.1 kB
text/typescript
import { FakeContract, MockContract, MockContractFactory, smock } from '@defi-wonderland/smock';
import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers';
import ERC20Artifact from '@openzeppelin/contracts/build/contracts/ERC20.json';
import IUniswapV3PoolForTestArtifact from '@solidity/for-test/IUniswapV3PoolForTest.sol/IUniswapV3PoolForTest.json';
import IKeep3rV1Artifact from '@solidity/interfaces/external/IKeep3rV1.sol/IKeep3rV1.json';
import IKeep3rV1ProxyArtifact from '@solidity/interfaces/external/IKeep3rV1Proxy.sol/IKeep3rV1Proxy.json';
import IKeep3rHelperArtifact from '@solidity/interfaces/IKeep3rHelper.sol/IKeep3rHelper.json';
import {
ERC20,
IKeep3rV1,
IKeep3rV1Proxy,
IUniswapV3PoolForTest,
Keep3rHelper,
Keep3rJobWorkableForTest,
Keep3rJobWorkableForTest__factory,
} from '@types';
import { evm } from '@utils';
import { toGwei, toUnit } from '@utils/bn';
import { readArgFromEvent, readArgsFromEvent } from '@utils/event-utils';
import { MathUtils, mathUtilsFactory } from '@utils/math';
import chai, { expect } from 'chai';
import { BigNumber } from 'ethers';
import { ethers } from 'hardhat';
import moment from 'moment';
chai.use(smock.matchers);
describe('Keep3rJobWorkable', () => {
let jobWorkable: MockContract<Keep3rJobWorkableForTest>;
let helper: FakeContract<Keep3rHelper>;
let keep3rV1: FakeContract<IKeep3rV1>;
let keep3rV1Proxy: FakeContract<IKeep3rV1Proxy>;
let randomLiquidity: FakeContract<IUniswapV3PoolForTest>;
let randomKeeper: SignerWithAddress;
let approvedJob: SignerWithAddress;
let jobWorkableFactory: MockContractFactory<Keep3rJobWorkableForTest__factory>;
let kp3rWethPool: FakeContract<IUniswapV3PoolForTest>;
let oraclePool: FakeContract<IUniswapV3PoolForTest>;
// Parameter and function equivalent to contract's
let rewardPeriodTime: number;
let inflationPeriodTime: number;
let mathUtils: MathUtils;
before(async () => {
[, randomKeeper, approvedJob] = await ethers.getSigners();
jobWorkableFactory = await smock.mock('Keep3rJobWorkableForTest');
});
beforeEach(async () => {
helper = await smock.fake(IKeep3rHelperArtifact);
keep3rV1 = await smock.fake(IKeep3rV1Artifact);
keep3rV1Proxy = await smock.fake(IKeep3rV1ProxyArtifact);
randomLiquidity = await smock.fake(IUniswapV3PoolForTestArtifact);
oraclePool = await smock.fake(IUniswapV3PoolForTestArtifact);
kp3rWethPool = await smock.fake(IUniswapV3PoolForTestArtifact);
helper.isKP3RToken0.returns(true);
jobWorkable = await jobWorkableFactory.deploy(helper.address, keep3rV1.address, keep3rV1Proxy.address, kp3rWethPool.address);
await jobWorkable.setJob(approvedJob.address);
rewardPeriodTime = (await jobWorkable.rewardPeriodTime()).toNumber();
inflationPeriodTime = (await jobWorkable.inflationPeriod()).toNumber();
mathUtils = mathUtilsFactory(rewardPeriodTime, inflationPeriodTime);
const blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
const testPeriodTime = mathUtils.calcPeriod(blockTimestamp + rewardPeriodTime) + rewardPeriodTime / 2;
// set the test to start mid-period
evm.advanceToTime(testPeriodTime);
evm.advanceBlock();
// set kp3rWethPool to be set and updated
await jobWorkable.setVariable('_tick', { [kp3rWethPool.address]: { period: mathUtils.calcPeriod(testPeriodTime) } });
});
describe('isKeeper', () => {
it('should return false if keeper is not registered', async () => {
expect(await jobWorkable.callStatic.isKeeper(randomKeeper.address)).to.be.false;
});
it('should return true if keeper is registered', async () => {
await jobWorkable.setKeeper(randomKeeper.address);
expect(await jobWorkable.callStatic.isKeeper(randomKeeper.address)).to.be.true;
});
it('should initialize the gas accountance', async () => {
await jobWorkable.setKeeper(randomKeeper.address);
expect(await jobWorkable.callStatic.viewGas()).to.be.eq(0);
await jobWorkable.isKeeper(randomKeeper.address);
expect(await jobWorkable.callStatic.viewGas()).to.be.gt(0);
});
it('should emit event', async () => {
await jobWorkable.setKeeper(randomKeeper.address);
const gasLimit = BigNumber.from(30_000_000);
const tx = await jobWorkable.isKeeper(randomKeeper.address, { gasLimit: gasLimit.mul(63).div(64) });
const gasUsed = (await tx.wait()).gasUsed;
const gasRecord = await readArgFromEvent(tx, 'KeeperValidation', '_gasLeft');
await expect(tx).to.emit(jobWorkable, 'KeeperValidation');
expect(gasRecord).to.be.closeTo(gasLimit.sub(gasUsed), 50000);
});
});
describe('isBondedKeeper', () => {
it('should return false if address is not a keeper', async () => {
expect(await jobWorkable.callStatic.isBondedKeeper(randomKeeper.address, randomLiquidity.address, 0, 0, 0)).to.be.false;
});
it('should return false if keeper does not fulfill bonds', async () => {
await jobWorkable.setKeeper(randomKeeper.address);
expect(await jobWorkable.callStatic.isBondedKeeper(randomKeeper.address, randomLiquidity.address, toUnit(1), 0, 0)).to.be.false;
});
it('should return false if keeper does not fulfill earned', async () => {
await jobWorkable.setKeeper(randomKeeper.address);
expect(await jobWorkable.callStatic.isBondedKeeper(randomKeeper.address, randomLiquidity.address, 0, toUnit(1), 0)).to.be.false;
});
it('should return false if keeper does not fulfill age', async () => {
await jobWorkable.setKeeper(randomKeeper.address);
const blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
await jobWorkable.setVariable('firstSeen', { [randomKeeper.address]: blockTimestamp });
expect(await jobWorkable.callStatic.isBondedKeeper(randomKeeper.address, randomLiquidity.address, 0, 0, 1)).to.be.false;
});
it('should return true if keeper fulfill all the requirements', async () => {
await jobWorkable.setKeeper(randomKeeper.address);
const blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
await jobWorkable.setVariable('bonds', { [randomKeeper.address]: { [randomLiquidity.address]: toUnit(1) } });
await jobWorkable.setVariable('workCompleted', { [randomKeeper.address]: toUnit(1) });
await jobWorkable.setVariable('firstSeen', { [randomKeeper.address]: blockTimestamp - 1 });
expect(await jobWorkable.callStatic.isBondedKeeper(randomKeeper.address, randomLiquidity.address, toUnit(1), toUnit(1), 1)).to.be.true;
});
it('should emit event', async () => {
await jobWorkable.setKeeper(randomKeeper.address);
const gasLimit = BigNumber.from(30_000_000);
const tx = await jobWorkable.isBondedKeeper(randomKeeper.address, randomLiquidity.address, 0, 0, 0, {
gasLimit: gasLimit.mul(63).div(64),
});
const gasUsed = (await tx.wait()).gasUsed;
const gasRecord = await readArgFromEvent(tx, 'KeeperValidation', '_gasLeft');
await expect(tx).to.emit(jobWorkable, 'KeeperValidation');
expect(gasRecord).to.be.closeTo(gasLimit.sub(gasUsed), 50000);
});
});
describe('worked', () => {
it('should revert when called with unallowed job', async () => {
await expect(jobWorkable.worked(randomKeeper.address)).to.be.revertedWith('JobUnapproved()');
});
it('should revert if job is disputed', async () => {
await jobWorkable.setVariable('disputes', {
[approvedJob.address]: true,
});
await expect(jobWorkable.connect(approvedJob).worked(randomKeeper.address)).to.be.revertedWith('JobDisputed()');
});
context('when job is allowed', () => {
let blockTimestamp: number;
let jobCredits: BigNumber;
let oneTick: number;
beforeEach(async () => {
oneTick = rewardPeriodTime;
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
await jobWorkable.setJob(approvedJob.address);
await jobWorkable.setVariable('_isKP3RToken0', { [oraclePool.address]: true });
await jobWorkable.setApprovedLiquidity(randomLiquidity.address);
await jobWorkable.setVariable('_liquidityPool', { [randomLiquidity.address]: oraclePool.address });
await jobWorkable.setJobLiquidity(approvedJob.address, randomLiquidity.address);
await jobWorkable.setVariable('_initialGas', 1_500_000);
const liquidityToAdd = toUnit(1);
jobCredits = mathUtils.calcPeriodCredits(liquidityToAdd);
await jobWorkable.setVariable('liquidityAmount', { [approvedJob.address]: { [randomLiquidity.address]: liquidityToAdd } });
helper.observe.returns([oneTick, 0, true]);
});
it('should emit event', async () => {
// work pays no gas to the keeper
helper.getRewardBoostFor.returns(0);
const gasLimit = BigNumber.from(30_000_000);
await jobWorkable.setVariable('_initialGas', gasLimit);
const tx = await jobWorkable.connect(approvedJob).worked(randomKeeper.address, { gasLimit: gasLimit.mul(63).div(64) });
const eventArgs = (await readArgsFromEvent(tx, 'KeeperWork'))[0];
const gasUsed = (await tx.wait()).gasUsed;
const gasRecord = await readArgFromEvent(tx, 'KeeperWork', '_gasLeft');
expect(eventArgs.slice(0, -1)).to.be.deep.eq([keep3rV1.address, approvedJob.address, randomKeeper.address, BigNumber.from(0)]);
expect(gasRecord).to.be.closeTo(gasLimit.sub(gasUsed), 50000);
});
it('should update KP3R/WETH quote if needed', async () => {
// let a period pass to outdate the current quote
await evm.advanceTimeAndBlock(moment.duration(10, 'days').as('seconds'));
// set oracle response
const currentTick = oneTick;
const previousTick = 0;
const tickDifference = currentTick - previousTick;
kp3rWethPool.observe.returns([[currentTick, previousTick], []]);
// job awards no credits to keeper
helper.getRewardBoostFor.returns(0);
await jobWorkable.connect(approvedJob).worked(randomKeeper.address, { gasLimit: 1_000_000 });
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
expect(helper.observe).to.have.been.calledWith(kp3rWethPool.address, [
blockTimestamp - mathUtils.calcPeriod(blockTimestamp),
blockTimestamp - mathUtils.calcPeriod(blockTimestamp - rewardPeriodTime),
]);
expect(await jobWorkable.viewTickCache(kp3rWethPool.address)).to.deep.equal([
BigNumber.from(currentTick),
BigNumber.from(tickDifference),
BigNumber.from(mathUtils.calcPeriod(blockTimestamp)),
]);
});
it('should update job credits if needed', async () => {
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
// job rewarded mid last period but less than a rewardPeriodTime ago
const previousRewardedAt = blockTimestamp + 100 - rewardPeriodTime;
await jobWorkable.setVariable('rewardedAt', { [approvedJob.address]: previousRewardedAt });
await jobWorkable.setVariable('_jobLiquidityCredits', { [approvedJob.address]: jobCredits });
await jobWorkable.setVariable('_jobPeriodCredits', { [approvedJob.address]: jobCredits });
// work pays no gas to the keeper
helper.getRewardBoostFor.returns(0);
helper.getKP3RsAtTick.returns(([amount]: [BigNumber]) => {
return mathUtils.increase1Tick(amount);
});
await jobWorkable.connect(approvedJob).worked(randomKeeper.address, { gasLimit: 1_000_000 });
// work updates jobCredits to current twap price
expect(await jobWorkable.jobLiquidityCredits(approvedJob.address)).to.be.closeTo(
mathUtils.increase1Tick(jobCredits),
mathUtils.blockShiftPrecision
);
// work does not reward the job
expect(await jobWorkable.rewardedAt(approvedJob.address)).to.be.eq(previousRewardedAt);
});
context('when credits are outdated', () => {
beforeEach(async () => {
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
await jobWorkable.setVariable('_jobPeriodCredits', { [approvedJob.address]: jobCredits });
await jobWorkable.setVariable('_jobLiquidityCredits', { [approvedJob.address]: jobCredits });
// work pays no gas to the keeper
helper.getRewardBoostFor.returns(0);
// job was rewarded last period >> should be rewarded this period
const previousRewardedAt = mathUtils.calcPeriod(blockTimestamp - rewardPeriodTime);
await jobWorkable.setVariable('rewardedAt', { [approvedJob.address]: previousRewardedAt });
});
it('should reward job with period credits', async () => {
await jobWorkable.setVariable('_jobPeriodCredits', { [approvedJob.address]: jobCredits });
await jobWorkable.setVariable('_jobLiquidityCredits', { [approvedJob.address]: jobCredits });
helper.getRewardBoostFor.returns(0);
await jobWorkable.connect(approvedJob).worked(randomKeeper.address, { gasLimit: 1_000_000 });
expect(await jobWorkable.jobLiquidityCredits(approvedJob.address)).to.be.eq(await jobWorkable.jobPeriodCredits(approvedJob.address));
});
it('should emit event', async () => {
await jobWorkable.setVariable('_jobPeriodCredits', { [approvedJob.address]: jobCredits });
const tx = await jobWorkable.connect(approvedJob).worked(randomKeeper.address, { gasLimit: 1_000_000 });
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
await expect(tx)
.to.emit(jobWorkable, 'LiquidityCreditsReward')
.withArgs(
approvedJob.address,
mathUtils.calcPeriod(blockTimestamp),
await jobWorkable.jobLiquidityCredits(approvedJob.address),
await jobWorkable.jobPeriodCredits(approvedJob.address)
);
});
});
context('when job credits are not enough for payment', () => {
const oneEthQuote = toUnit(0.1); // 1 ETH = 10 KP3R
const extraGas = 0;
beforeEach(async () => {
helper.getKP3RsAtTick.returns(([amount]: [BigNumber]) => {
return amount.div(10);
});
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
// work pays more gas than current credits
// rewardETH = gasUsed * 120% * 20Gwei
// rewardKP3R = rewardETH / oneTenth
const boost = toGwei(20).mul(1.2 * 10_000);
helper.getPaymentParams.returns([boost, oneEthQuote, extraGas]);
// job rewarded mid last period but less than a rewardPeriodTime ago
const previousRewardedAt = blockTimestamp + 15 - rewardPeriodTime;
await jobWorkable.setVariable('rewardedAt', { [approvedJob.address]: previousRewardedAt });
});
it('should reward job', async () => {
await jobWorkable.setVariable('_jobPeriodCredits', { [approvedJob.address]: toUnit(1) });
await jobWorkable.setVariable('rewardedAt', { [approvedJob.address]: mathUtils.calcPeriod(blockTimestamp) });
await jobWorkable.connect(approvedJob).worked(randomKeeper.address, { gasLimit: 1_000_000 });
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
// work does reward the job at current timestamp
expect(await jobWorkable.rewardedAt(approvedJob.address)).to.be.eq(blockTimestamp);
// work rewards job and pays the keeper
expect(await jobWorkable.jobLiquidityCredits(approvedJob.address)).to.be.gt(0);
});
it('should reward job twice if credits where outdated', async () => {
await evm.advanceTimeAndBlock(moment.duration(1, 'days').as('seconds'));
await jobWorkable.setVariable('rewardedAt', { [approvedJob.address]: mathUtils.calcPeriod(blockTimestamp - rewardPeriodTime) });
const biggerBoost = toGwei(200).mul(1.2 * 10_000);
helper.getPaymentParams.returns([biggerBoost, oneEthQuote, extraGas]);
const tx = await jobWorkable.connect(approvedJob).worked(randomKeeper.address, { gasLimit: 1_000_000 });
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
const jobPeriodCredits = await jobWorkable.jobPeriodCredits(approvedJob.address);
/* Expectation: 2 event emitted
// 1- rewarding the job with current period credits
// 2- rewarding the job with minted credits since current period
*/
await expect(tx)
.to.emit(jobWorkable, 'LiquidityCreditsReward')
.withArgs(approvedJob.address, mathUtils.calcPeriod(blockTimestamp), jobPeriodCredits, jobPeriodCredits);
await expect(tx)
.to.emit(jobWorkable, 'LiquidityCreditsReward')
.withArgs(
approvedJob.address,
blockTimestamp,
jobPeriodCredits.add(mathUtils.calcMintedCredits(jobPeriodCredits, blockTimestamp - mathUtils.calcPeriod(blockTimestamp))),
jobPeriodCredits
);
});
it('should update payment with extra gas used by the keeper', async () => {
await jobWorkable.setVariable('_jobPeriodCredits', { [approvedJob.address]: toUnit(10) });
await jobWorkable.setVariable('rewardedAt', { [approvedJob.address]: mathUtils.calcPeriod(blockTimestamp) });
await jobWorkable.connect(approvedJob).worked(randomKeeper.address, { gasLimit: 1_000_000 });
const bondsAcc1 = await jobWorkable.bonds(randomKeeper.address, keep3rV1.address);
// second job shouldn't reward the job and earn less KP3R
await jobWorkable.connect(approvedJob).worked(randomKeeper.address, { gasLimit: 1_000_000 });
const bondsAcc2 = await jobWorkable.bonds(randomKeeper.address, keep3rV1.address);
expect(bondsAcc1).to.be.gt(bondsAcc2.sub(bondsAcc1));
});
});
});
});
describe('bondedPayment', () => {
it('should revert when called with unallowed job', async () => {
await expect(jobWorkable.bondedPayment(randomKeeper.address, toUnit(1))).to.be.revertedWith('JobUnapproved()');
});
it('should revert when the job does not have any liquidity', async () => {
await expect(jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1))).to.be.revertedWith('InsufficientFunds()');
});
it('should revert if job is disputed', async () => {
await jobWorkable.setVariable('disputes', {
[approvedJob.address]: true,
});
await expect(jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1))).to.be.revertedWith('JobDisputed()');
});
context('when job has updated liquidity credits', () => {
let blockTimestamp: number;
const initialJobCredits = toUnit(100);
beforeEach(async () => {
evm.advanceTime(moment.duration(1, 'days').as('seconds'));
await jobWorkable.setVariable('_jobLiquidityCredits', { [approvedJob.address]: initialJobCredits });
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
await jobWorkable.setVariable('rewardedAt', { [approvedJob.address]: mathUtils.calcPeriod(blockTimestamp) });
});
it('should not revert', async () => {
await expect(jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1))).not.to.be.reverted;
});
});
context('when job has added liquidity', () => {
let blockTimestamp: number;
const initialJobLiquidity = toUnit(100);
beforeEach(async () => {
await jobWorkable.setJobLiquidity(approvedJob.address, randomLiquidity.address);
await jobWorkable.setVariable('liquidityAmount', { [approvedJob.address]: { [randomLiquidity.address]: initialJobLiquidity } });
await jobWorkable.setVariable('_jobPeriodCredits', { [approvedJob.address]: initialJobLiquidity });
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
await jobWorkable.setVariable('_tick', { [randomLiquidity.address]: { period: mathUtils.calcPeriod(blockTimestamp) } });
});
context('when liquidity is not approved', () => {
it('should revert with InsufficientFunds', async () => {
await expect(jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1))).to.be.revertedWith('InsufficientFunds');
});
});
context('when liquidity is approved', () => {
beforeEach(async () => {
await jobWorkable.setApprovedLiquidity(randomLiquidity.address);
helper.getKP3RsAtTick.returns(([amount]: [BigNumber]) => amount);
});
it('should substract payed credits', async () => {
await jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1));
const jobCredits = mathUtils.calcPeriodCredits(initialJobLiquidity);
expect(await jobWorkable.jobLiquidityCredits(approvedJob.address)).to.be.eq(jobCredits.sub(toUnit(1)));
});
it('should emit event', async () => {
const payment = toUnit(1);
const gasLimit = BigNumber.from(30_000_000);
const tx = await jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, payment, { gasLimit: gasLimit.mul(63).div(64) });
const gasUsed = (await tx.wait()).gasUsed;
const eventArgs = (await readArgsFromEvent(tx, 'KeeperWork'))[0];
const gasRecord = await readArgFromEvent(tx, 'KeeperWork', '_gasLeft');
expect(eventArgs.slice(0, -1)).to.be.deep.eq([keep3rV1.address, approvedJob.address, randomKeeper.address, payment]);
expect(gasRecord).to.be.closeTo(gasLimit.sub(gasUsed), 3000);
});
it('should record job last worked timestamp', async () => {
await jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1));
const workBlock = await ethers.provider.getBlock('latest');
expect(await jobWorkable.workedAt(approvedJob.address)).to.be.equal(workBlock.timestamp);
});
context('when liquidity credits are less than payment amount', () => {
beforeEach(async () => {
await jobWorkable.setVariable('_jobLiquidityCredits', { [approvedJob.address]: toUnit(0) });
});
context('when job has not minted enough credits to pay', async () => {
beforeEach(async () => {
jobWorkable.setVariable('rewardedAt', { [approvedJob.address]: blockTimestamp });
});
it('should revert with InsufficientFunds', async () => {
await expect(jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1))).to.be.reverted;
});
});
context('when job has minted enough credits', () => {
beforeEach(async () => {
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
await jobWorkable.setVariable('rewardedAt', { [approvedJob.address]: blockTimestamp - 0.9 * rewardPeriodTime });
});
it('should reward the job with pending credits and pay the keeper', async () => {
const jobCredits = mathUtils.calcPeriodCredits(initialJobLiquidity);
const previousJobLiquidityCredits = await jobWorkable.jobLiquidityCredits(approvedJob.address);
// A second has passed
await jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1));
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
expect(await jobWorkable.jobPeriodCredits(approvedJob.address)).to.be.eq(jobCredits);
expect(await jobWorkable.jobLiquidityCredits(approvedJob.address)).to.be.gt(previousJobLiquidityCredits);
});
it('should update the job last rewarded timestamp', async () => {
await jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1));
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
expect(await jobWorkable.rewardedAt(approvedJob.address)).to.be.eq(blockTimestamp);
});
it('should not have any pending credits', async () => {
await jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1));
expect(await jobWorkable.jobLiquidityCredits(approvedJob.address)).to.be.eq(
await jobWorkable.totalJobCredits(approvedJob.address)
);
});
});
context('when job has minted more than a period of credits', () => {
beforeEach(async () => {
await jobWorkable.setVariable('rewardedAt', { [approvedJob.address]: blockTimestamp - 1.1 * rewardPeriodTime });
});
it('should reward the job with a full period of credits', async () => {
let jobPeriodCredits = await jobWorkable.jobPeriodCredits(approvedJob.address);
await jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1));
expect(await jobWorkable.jobLiquidityCredits(approvedJob.address)).to.be.eq(jobPeriodCredits.sub(toUnit(1)));
});
it('should still have some pending credits', async () => {
await jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1));
blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp;
expect(await jobWorkable.rewardedAt(approvedJob.address)).not.to.be.eq(blockTimestamp);
expect(await jobWorkable.jobLiquidityCredits(approvedJob.address)).to.be.lt(
await jobWorkable.totalJobCredits(approvedJob.address)
);
});
});
});
context('reward', () => {
it('should increase keeper KP3R bonds', async () => {
await jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1));
expect(await jobWorkable.bonds(randomKeeper.address, keep3rV1.address)).to.equal(toUnit(1));
});
it('should increase total KP3R bonded', async () => {
await jobWorkable.connect(approvedJob).bondedPayment(randomKeeper.address, toUnit(1));
expect(await jobWorkable.bonds(randomKeeper.address, keep3rV1.address)).to.equal(toUnit(1));
});
});
});
});
});
describe('directTokenPayment', () => {
let token: FakeContract<ERC20>;
beforeEach(async () => {
token = await smock.fake(ERC20Artifact.abi);
token.transfer.returns(true);
});
it('should revert when called with unallowed job', async () => {
await expect(jobWorkable.directTokenPayment(token.address, randomKeeper.address, toUnit(1))).to.be.revertedWith('JobUnapproved()');
});
it('should revert when the keeper is disputed', async () => {
await jobWorkable.setVariable('disputes', {
[randomKeeper.address]: true,
});
await expect(jobWorkable.connect(approvedJob).directTokenPayment(token.address, randomKeeper.address, toUnit(1))).to.be.revertedWith(
'Disputed()'
);
});
it('should revert when the job does not have enought credits', async () => {
await expect(jobWorkable.connect(approvedJob).directTokenPayment(token.address, randomKeeper.address, toUnit(1))).to.be.revertedWith(
'InsufficientFunds()'
);
});
it('should revert if job is disputed', async () => {
await jobWorkable.setVariable('disputes', {
[approvedJob.address]: true,
});
await expect(jobWorkable.connect(approvedJob).directTokenPayment(token.address, randomKeeper.address, toUnit(1))).to.be.revertedWith(
'JobDisputed()'
);
});
context('when job has token credits', () => {
const initialJobCredits = toUnit(5);
beforeEach(async () => {
await jobWorkable.setVariable('jobTokenCredits', {
[approvedJob.address]: {
[token.address]: initialJobCredits,
},
});
});
it('should revert if transfer fails', async () => {
token.transfer.returns(false);
await expect(jobWorkable.connect(approvedJob).directTokenPayment(token.address, randomKeeper.address, toUnit(1))).to.be.revertedWith(
'SafeERC20: ERC20 operation did not succeed'
);
});
it('should substract payed credits', async () => {
await jobWorkable.connect(approvedJob).directTokenPayment(token.address, randomKeeper.address, toUnit(1));
expect(await jobWorkable.jobTokenCredits(approvedJob.address, token.address)).to.be.equal(initialJobCredits.sub(toUnit(1)));
});
it('should transfer tokens to the keeper', async () => {
await jobWorkable.connect(approvedJob).directTokenPayment(token.address, randomKeeper.address, toUnit(1));
expect(token.transfer).to.be.calledOnceWith(randomKeeper.address, toUnit(1));
});
it('should emit event', async () => {
const payment = toUnit(1);
const gasLimit = BigNumber.from(30_000_000);
const tx = await jobWorkable
.connect(approvedJob)
.directTokenPayment(token.address, randomKeeper.address, payment, { gasLimit: gasLimit.mul(63).div(64) });
const gasUsed = (await tx.wait()).gasUsed;
const gasRecord = await readArgFromEvent(tx, 'KeeperWork', '_gasLeft');
const eventArgs = await readArgsFromEvent(tx, 'KeeperWork');
expect(eventArgs[0].slice(0, -1)).to.be.deep.eq([token.address, approvedJob.address, randomKeeper.address, payment]);
expect(gasRecord).to.be.closeTo(gasLimit.sub(gasUsed), 3000);
});
});
});
});