Skip to content

Commit 66a8d88

Browse files
committed
TokenGrant is able to compute withdrawableAmount and withdraw it
1 parent fb7f36b commit 66a8d88

File tree

4 files changed

+299
-24
lines changed

4 files changed

+299
-24
lines changed

contracts/grant/GrantStakingPolicy.sol

+3-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ interface IGrantStakingPolicy {
99
uint256 duration,
1010
uint256 start,
1111
uint256 cliff,
12-
uint256 withdrawn) external view returns (uint256);
12+
uint256 withdrawn
13+
) external view returns (uint256);
1314
}
1415

15-
// TODO: add more policies
16+
// TODO: add more policies

contracts/grant/TokenGrant.sol

+54-11
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,36 @@
33
pragma solidity 0.8.4;
44

55
import "./GrantStakingPolicy.sol";
6+
import "../token/T.sol";
7+
8+
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
69

710
contract TokenGrant {
11+
using SafeERC20 for T;
12+
13+
T public token;
814

915
address public grantee;
1016
bool public revocable;
1117
uint256 public amount;
1218
uint256 public duration;
1319
uint256 public start;
1420
uint256 public cliff;
15-
21+
1622
uint256 public withdrawn;
1723
uint256 public staked;
1824

19-
uint256 public revokedAt;
20-
uint256 public revokedAmount;
21-
uint256 public revokedWithdrawn;
22-
2325
IGrantStakingPolicy public stakingPolicy;
2426

27+
event Withdrawn(uint256 amount);
28+
29+
modifier onlyGrantee() {
30+
require(msg.sender == grantee, "Not authorized");
31+
_;
32+
}
33+
2534
function initialize(
35+
T _token,
2636
address _grantee,
2737
bool _revocable,
2838
uint256 _amount,
@@ -31,29 +41,62 @@ contract TokenGrant {
3141
uint256 _cliff,
3242
IGrantStakingPolicy _stakingPolicy
3343
) public {
44+
token = _token;
3445
grantee = _grantee;
3546
revocable = _revocable;
3647
amount = _amount;
3748
duration = _duration;
3849
start = _start;
3950
cliff = _cliff;
4051
stakingPolicy = _stakingPolicy;
52+
53+
token.safeTransferFrom(msg.sender, address(this), amount);
54+
}
55+
56+
function stake(uint256 amountToStake) external onlyGrantee {
57+
staked += amountToStake;
58+
59+
// TODO: implement
60+
}
61+
62+
function withdraw() external onlyGrantee {
63+
uint256 withdrawable = withdrawableAmount();
64+
require(withdrawable > 0, "There is nothing to withdraw");
65+
66+
emit Withdrawn(withdrawable);
67+
withdrawn += withdrawable;
68+
token.safeTransfer(grantee, withdrawable);
4169
}
4270

4371
function unlockedAmount() public view returns (uint256) {
44-
if (block.timestamp < start) { // start reached?
72+
/* solhint-disable-next-line not-rely-on-time */
73+
if (block.timestamp < start) {
4574
return 0;
4675
}
4776

48-
if (block.timestamp < cliff) { // cliff reached?
49-
return 0;
77+
/* solhint-disable-next-line not-rely-on-time */
78+
if (block.timestamp < cliff) {
79+
return 0;
5080
}
5181

82+
/* solhint-disable-next-line not-rely-on-time */
5283
uint256 timeElapsed = block.timestamp - start;
5384

5485
bool unlockingPeriodFinished = timeElapsed >= duration;
55-
if (unlockingPeriodFinished) { return amount; }
86+
if (unlockingPeriodFinished) {
87+
return amount;
88+
}
5689

57-
return amount * timeElapsed / duration;
90+
return (amount * timeElapsed) / duration;
91+
}
92+
93+
function withdrawableAmount() public view returns (uint256) {
94+
uint256 unlocked = unlockedAmount();
95+
96+
if (withdrawn + staked >= unlocked) {
97+
return 0;
98+
} else {
99+
return unlocked - withdrawn - staked;
100+
}
58101
}
59-
}
102+
}

test/grant/TokenGrant.test.js

+215-11
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@ const {
33
ZERO_ADDRESS,
44
to1e18,
55
lastBlockTime,
6+
increaseTime,
7+
pastEvents,
68
} = require("../helpers/contract-test-helpers")
79

810
describe("TokenGrant", () => {
11+
let token
912
let grantee
1013

1114
beforeEach(async () => {
12-
;[grantee] = await ethers.getSigners()
15+
;[deployer, thirdParty, grantee] = await ethers.getSigners()
16+
17+
const T = await ethers.getContractFactory("T")
18+
token = await T.deploy()
19+
await token.deployed()
1320
})
1421

1522
describe("unlockedAmount", () => {
@@ -27,7 +34,7 @@ describe("TokenGrant", () => {
2734
context("before the schedule start", () => {
2835
it("should return no tokens unlocked", async () => {
2936
const start = now + 60
30-
const cliff = now
37+
const cliff = now + 60
3138
const grant = await createGrant(false, amount, duration, start, cliff)
3239
expect(await grant.unlockedAmount()).to.equal(0)
3340
})
@@ -101,22 +108,219 @@ describe("TokenGrant", () => {
101108
)
102109
})
103110
})
111+
112+
context("when some tokens are staked", () => {
113+
it("should return token amount unlocked so far", async () => {
114+
const start = now - 3600 // one hour earlier
115+
const cliff = now - 3600
116+
const grant = await createGrant(false, amount, duration, start, cliff)
117+
await grant.connect(grantee).withdraw()
118+
expect(await grant.unlockedAmount()).is.closeTo(
119+
to1e18(23), // 3600 / 15552000 * 100k = ~23 tokens
120+
assertionPrecision
121+
)
122+
})
123+
})
124+
125+
context("when some tokens were withdrawn", () => {
126+
it("should return token amount unlocked so far", async () => {
127+
const start = now - 3600 // one hour earlier
128+
const cliff = now - 3600
129+
const grant = await createGrant(false, amount, duration, start, cliff)
130+
await grant.connect(grantee).stake(to1e18(20))
131+
expect(await grant.unlockedAmount()).is.closeTo(
132+
to1e18(23), // 3600 / 15552000 * 100k = ~23 tokens
133+
assertionPrecision
134+
)
135+
})
136+
})
137+
})
138+
139+
describe("withdrawableAmount", () => {
140+
const assertionPrecision = to1e18(1) // +- 1 token
141+
142+
const amount = to1e18(100000) // 100k tokens
143+
const duration = 15552000 // 180 days
144+
145+
let grant
146+
147+
beforeEach(async () => {
148+
const now = await lastBlockTime()
149+
const start = now - 7200 // two hours earlier
150+
const cliff = now - 7200
151+
grant = await createGrant(false, amount, duration, start, cliff)
152+
})
153+
154+
context("when no tokens were staked or withdrawn", () => {
155+
it("should return tokens unlocked so far", async () => {
156+
expect(await grant.withdrawableAmount()).is.closeTo(
157+
to1e18(46), // 7200 / 15552000 * 100k = ~46 tokens
158+
assertionPrecision
159+
)
160+
})
161+
})
162+
163+
context("when some tokens were staked", () => {
164+
it("should return tokens unlocked so far minus staked tokens", async () => {
165+
await grant.connect(grantee).stake(to1e18(5))
166+
expect(await grant.withdrawableAmount()).is.closeTo(
167+
to1e18(41), // 7200 / 15552000 * 100k - 5 = ~41 tokens
168+
assertionPrecision
169+
)
170+
})
171+
})
172+
173+
context("when some tokens were withdrawn", () => {
174+
it("should return tokens unlocked so far minus withdrawn tokens", async () => {
175+
await grant.connect(grantee).withdraw()
176+
await increaseTime(3600)
177+
expect(await grant.withdrawableAmount()).is.closeTo(
178+
to1e18(23), // 3600 / 15552000 * 100k = ~23 tokens
179+
assertionPrecision
180+
)
181+
})
182+
})
183+
184+
context("when tokens were withdrawn multiple times", () => {
185+
it("should return tokens unlocked so far minus withdrawn tokens", async () => {
186+
await grant.connect(grantee).withdraw()
187+
await increaseTime(7200)
188+
await grant.connect(grantee).withdraw()
189+
await increaseTime(7200)
190+
191+
expect(await grant.withdrawableAmount()).is.closeTo(
192+
to1e18(46), // 7200 / 15552000 * 100k = ~46 tokens
193+
assertionPrecision
194+
)
195+
})
196+
})
197+
198+
context("when tokens were staked and withdrawn", () => {
199+
it("should return tokens unlocked so far minus withdrawn and staked tokens", async () => {
200+
await grant.connect(grantee).withdraw()
201+
await increaseTime(7200)
202+
await grant.connect(grantee).stake(to1e18(20))
203+
expect(await grant.withdrawableAmount()).is.closeTo(
204+
to1e18(26), // 7200 / 15552000 * 100k - 20 = ~26 tokens
205+
assertionPrecision
206+
)
207+
})
208+
})
209+
210+
context("when tokens were staked and withdrawn multiple times", () => {
211+
it("should return tokens unlocked so far minus withdrawn and staked tokens", async () => {
212+
await grant.connect(grantee).withdraw()
213+
await increaseTime(7200)
214+
await grant.connect(grantee).withdraw()
215+
await grant.connect(grantee).stake(to1e18(10))
216+
await increaseTime(7200)
217+
expect(await grant.withdrawableAmount()).is.closeTo(
218+
to1e18(36), // 7200 / 15552000 * 100k - 10 = ~36 tokens
219+
assertionPrecision
220+
)
221+
})
222+
})
223+
})
224+
225+
describe("withdraw", () => {
226+
const assertionPrecision = to1e18(1) // +- 1 token
227+
228+
const amount = to1e18(200000) // 200k tokens
229+
const duration = 7776000 // 90 days
230+
231+
let grant
232+
233+
beforeEach(async () => {
234+
const now = await lastBlockTime()
235+
const start = now - 3888000 // 45 days earlier
236+
const cliff = now - 3888000
237+
grant = await createGrant(false, amount, duration, start, cliff)
238+
})
239+
240+
context("when called by a third party", () => {
241+
it("should revert", async () => {
242+
await expect(grant.connect(thirdParty).withdraw()).to.be.revertedWith(
243+
"Not authorized"
244+
)
245+
})
246+
})
247+
248+
context("when called by a grant creator", () => {
249+
it("should revert", async () => {
250+
await expect(grant.connect(deployer).withdraw()).to.be.revertedWith(
251+
"Not authorized"
252+
)
253+
})
254+
})
255+
256+
context("when called by grantee", () => {
257+
context("when there are no withdrawable tokens", () => {
258+
it("should revert", async () => {
259+
await grant.connect(grantee).stake(amount)
260+
await expect(grant.connect(grantee).withdraw()).to.be.revertedWith(
261+
"There is nothing to withdraw"
262+
)
263+
})
264+
})
265+
266+
context("when there are withdrawable tokens", () => {
267+
let tx
268+
269+
beforeEach(async () => {
270+
// 3888000/7776000 * 200k = 100k
271+
tx = await grant.connect(grantee).withdraw()
272+
})
273+
274+
it("should increase withdrawn amount", async () => {
275+
expect(await grant.withdrawn()).to.be.closeTo(
276+
to1e18(100000),
277+
assertionPrecision
278+
)
279+
})
280+
281+
it("should transfer tokens to grantee", async () => {
282+
expect(await token.balanceOf(grantee.address)).to.be.closeTo(
283+
to1e18(100000),
284+
assertionPrecision
285+
)
286+
expect(await token.balanceOf(grant.address)).to.be.closeTo(
287+
to1e18(100000),
288+
assertionPrecision
289+
)
290+
})
291+
292+
it("should emit Withdrawn event", async () => {
293+
const events = pastEvents(await tx.wait(), grant, "Withdrawn")
294+
expect(events.length).to.equal(1)
295+
expect(events[0].args["amount"]).to.be.closeTo(
296+
to1e18(100000),
297+
assertionPrecision
298+
)
299+
})
300+
})
301+
})
104302
})
105303

106304
async function createGrant(revocable, amount, duration, start, cliff) {
107305
const TokenGrant = await ethers.getContractFactory("TokenGrant")
108306
const tokenGrant = await TokenGrant.deploy()
109307
await tokenGrant.deployed()
110308

111-
await tokenGrant.initialize(
112-
grantee.address,
113-
revocable,
114-
amount,
115-
duration,
116-
start,
117-
cliff,
118-
ZERO_ADDRESS
119-
)
309+
await token.connect(deployer).mint(deployer.address, amount)
310+
await token.connect(deployer).approve(tokenGrant.address, amount)
311+
312+
await tokenGrant
313+
.connect(deployer)
314+
.initialize(
315+
token.address,
316+
grantee.address,
317+
revocable,
318+
amount,
319+
duration,
320+
start,
321+
cliff,
322+
ZERO_ADDRESS
323+
)
120324

121325
return tokenGrant
122326
}

0 commit comments

Comments
 (0)