Skip to content

Commit 6ad7189

Browse files
committed
Merge branch '63-implement-stake-withdrawal' into 'main'
Resolve "Implement stake withdrawal for UTT endorsements" Closes #63 See merge request ututrust/utu-trust-token-solidity!62
2 parents f35fd84 + 80d6eeb commit 6ad7189

11 files changed

Lines changed: 272 additions & 8 deletions

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Default network to work on; can be overridden with --network param
22
HARDHAT_NETWORK="testnet_ethereum"
33

4-
ETHERSCAN_API_KEY=<Etherscan API Key>
4+
ETHERSCAN_API_KEY=<Etherscan API Key>
55

66
TESTNET_ETHEREUM_URL=<ETHEREUM testnet (Sepolia) node URL>
77

@@ -21,4 +21,4 @@ TESTNET_LISK_URL=<Lisk testnet node URL>
2121
LISK_URL=<Lisk mainnet node URL>
2222

2323
TESTNET_BASE_URL=<Base testnet node URL>
24-
BASE_URL=<Base mainnet node URL>
24+
BASE_URL=<Base mainnet node URL>

contracts/Endorsement.sol

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,42 @@ abstract contract Endorsement is
232232
_triggerEndorse(msg.sender, target, amount, transactionId);
233233
}
234234

235+
function _withdrawStake(
236+
address source,
237+
address target,
238+
uint256 amount,
239+
string memory transactionId
240+
) internal {
241+
require(amount > 0, "UTT: withdraw amount must be greater than zero");
242+
243+
uint256 currentStake = previousEndorserStakes[target][source];
244+
require(currentStake >= amount, "UTT: withdraw amount exceeds stake");
245+
require(
246+
totalStake[target] >= amount,
247+
"UTT: withdraw amount exceeds total stake"
248+
);
249+
250+
previousEndorserStakes[target][source] = currentStake - amount;
251+
totalStake[target] -= amount;
252+
253+
_mint(source, amount);
254+
255+
emit WithdrawStake(source, target, amount, transactionId);
256+
}
257+
258+
/**
259+
* @inheritdoc EndorsementInterface
260+
*/
261+
function withdrawStake(
262+
address target,
263+
uint256 amount,
264+
string memory transactionId
265+
) public override virtual {
266+
require(msg.sender == tx.origin, "should be a user");
267+
268+
_withdrawStake(msg.sender, target, amount, transactionId);
269+
}
270+
235271
/**
236272
* @dev This is called via oracle to forward an endorse call from a proxy contract. The caller must have the
237273
* PROXY_ENDORSER_ROLE.

contracts/EndorsementInterface.sol

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ interface EndorsementInterface {
2424
string memory transactionId
2525
) external;
2626

27+
/**
28+
* @notice Reduces or removes part of the caller's existing staked endorsement for a target.
29+
* A WithdrawStake event is emitted once the stake is withdrawn.
30+
* @param target the endorsed entity (address is just used as an id here)
31+
* @param amount the stake amount to withdraw
32+
* @param transactionId an id representing the "business transaction" for which the stake withdrawal was made; this is
33+
* _not_ necessarily an Ethereum transaction id.
34+
*/
35+
function withdrawStake(
36+
address target,
37+
uint256 amount,
38+
string memory transactionId
39+
) external;
40+
2741
// Events that might be emitted during the endorsement process.
2842

2943
/** A new staked endorsement was created. */
@@ -34,6 +48,14 @@ interface EndorsementInterface {
3448
string _transactionId
3549
);
3650

51+
/** Existing staked endorsement was reduced or removed. */
52+
event WithdrawStake(
53+
address indexed _from,
54+
address indexed _to,
55+
uint256 _value,
56+
string _transactionId
57+
);
58+
3759
/** A first-level previous endorser was rewarded */
3860
event RewardPreviousEndorserLevel1(address endorser, uint256 reward);
3961

contracts/MigratableEndorsement.sol

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ abstract contract MigratableEndorsement is Migratable, Endorsement {
5252
super.endorse(target, amount, transactionId);
5353
}
5454

55+
function withdrawStake(
56+
address target,
57+
uint256 amount,
58+
string memory transactionId
59+
) public virtual override onlyNotMigrating {
60+
super.withdrawStake(target, amount, transactionId);
61+
}
62+
5563
function proxyEndorse(
5664
address source,
5765
address target,

contracts/MigratableReward.sol

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,19 @@ contract MigratableReward is
117117
super.endorse(target, amount, transactionId);
118118
}
119119

120+
function withdrawStake(
121+
address target,
122+
uint256 amount,
123+
string memory transactionId
124+
)
125+
public
126+
virtual
127+
override(Endorsement, MigratableEndorsement)
128+
onlyNotMigrating
129+
{
130+
super.withdrawStake(target, amount, transactionId);
131+
}
132+
120133
function proxyEndorse(
121134
address source,
122135
address target,

contracts/UTTProxy.sol

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ contract UTTProxy is Initializable, OwnableUpgradeable, ChainlinkClient, Endorse
126126
});
127127
}
128128

129+
function withdrawStake(
130+
address,
131+
uint256,
132+
string memory
133+
) external pure override {
134+
revert("UTTProxy: withdrawStake not supported");
135+
}
136+
129137
function fulfill(
130138
bytes32 _requestId
131139
) external recordChainlinkFulfillment(_requestId) {

hardhat.config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,10 @@ const config = {
194194
testnet_aurora: process.env.AURORA_ETHERSCAN_API_KEY,
195195
optimism: process.env.OPTIMISM_ETHERSCAN_API_KEY,
196196
testnet_optimism: process.env.OPTIMISM_ETHERSCAN_API_KEY,
197-
lisk: null, // lisk's blockscout currently doesn't require one
197+
lisk: null, // lisk's blockscout currently doesn't require one
198198
testnet_lisk: null, // lisk's blockscout currently doesn't require one
199-
base: null,
200-
testnet_base: null
199+
base: null,
200+
testnet_base: null
201201
},
202202
customChains: [
203203
{
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
2+
module.exports = [
3+
"0xCa5cD80157334dAc231B65d886467B036CDf0024", // UTT token address
4+
];

scripts/upgrade.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ async function upgradeUTT() {
2121

2222
await utt.waitForDeployment();
2323
const uttAddress = await utt.getAddress();
24+
const implementationAddress =
25+
await upgrades.erc1967.getImplementationAddress(uttAddress);
2426

2527
console.log("UTT upgraded to:", uttAddress);
28+
console.log("Implementation at:", implementationAddress);
2629
}
2730

2831
// We recommend this pattern to be able to use async/await everywhere

test/UTT.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,168 @@ describe("UTT", function () {
236236
});
237237
});
238238

239+
describe("Stake withdrawal", function () {
240+
async function deployWithUserStake(stakeAmount = 1000) {
241+
const fixture = await loadFixture(deployUTT);
242+
const { utt, mockOperator, user1, service1, connector } = fixture;
243+
244+
await addConnection(utt, connector, user1.address);
245+
await endorse(
246+
utt,
247+
mockOperator,
248+
user1,
249+
service1.address,
250+
stakeAmount,
251+
mockTransactionId,
252+
[],
253+
[]
254+
);
255+
256+
return fixture;
257+
}
258+
259+
async function deployWithClaimableRewards() {
260+
const fixture = await loadFixture(deployUTT);
261+
const { utt, mockOperator, admin, user1, service1, connector } =
262+
fixture;
263+
264+
await addConnection(utt, connector, user1.address);
265+
await endorse(
266+
utt,
267+
mockOperator,
268+
user1,
269+
service1.address,
270+
200,
271+
mockTransactionId,
272+
[],
273+
[]
274+
);
275+
await endorse(
276+
utt,
277+
mockOperator,
278+
admin,
279+
service1.address,
280+
200,
281+
mockTransactionId,
282+
[user1.address],
283+
[]
284+
);
285+
286+
return fixture;
287+
}
288+
289+
it("should allow a user to withdraw part of their stake", async function () {
290+
const { utt, user1, service1 } = await deployWithUserStake();
291+
const balanceBefore = await utt.balanceOf(user1.address);
292+
const stakeBefore = await utt.previousEndorserStakes(
293+
service1.address,
294+
user1.address
295+
);
296+
const totalStakeBefore = await utt.totalStake(service1.address);
297+
298+
const withdrawP = utt
299+
.connect(user1)
300+
.withdrawStake(service1.address, 400, mockTransactionId);
301+
302+
await expect(withdrawP)
303+
.to.emit(utt, "WithdrawStake")
304+
.withArgs(user1.address, service1.address, 400, mockTransactionId);
305+
expect(await utt.balanceOf(user1.address)).to.eq(balanceBefore + 400n);
306+
expect(
307+
await utt.previousEndorserStakes(service1.address, user1.address)
308+
).to.eq(stakeBefore - 400n);
309+
expect(await utt.totalStake(service1.address)).to.eq(
310+
totalStakeBefore - 400n
311+
);
312+
});
313+
314+
it("should allow a user to withdraw all of their stake", async function () {
315+
const { utt, user1, service1 } = await deployWithUserStake();
316+
317+
await utt
318+
.connect(user1)
319+
.withdrawStake(service1.address, 1000, mockTransactionId);
320+
321+
expect(
322+
await utt.previousEndorserStakes(service1.address, user1.address)
323+
).to.eq(0);
324+
expect(await utt.totalStake(service1.address)).to.eq(0);
325+
});
326+
327+
it("should revert when withdrawing an invalid amount", async function () {
328+
const { utt, user1, service1 } = await deployWithUserStake();
329+
330+
await expect(
331+
utt.connect(user1).withdrawStake(service1.address, 0, mockTransactionId)
332+
).to.be.revertedWith("UTT: withdraw amount must be greater than zero");
333+
await expect(
334+
utt
335+
.connect(user1)
336+
.withdrawStake(service1.address, 1001, mockTransactionId)
337+
).to.be.revertedWith("UTT: withdraw amount exceeds stake");
338+
});
339+
340+
it("should revert when withdrawing stake for a target the user never endorsed", async function () {
341+
const { utt, user1, service1 } = await loadFixture(deployUTT);
342+
343+
await expect(
344+
utt.connect(user1).withdrawStake(service1.address, 1, mockTransactionId)
345+
).to.be.revertedWith("UTT: withdraw amount exceeds stake");
346+
});
347+
348+
it("should not emit reward events", async function () {
349+
const { utt, user1, service1 } = await deployWithUserStake();
350+
351+
const withdrawP = utt
352+
.connect(user1)
353+
.withdrawStake(service1.address, 250, mockTransactionId);
354+
355+
await expect(withdrawP)
356+
.to.not.emit(utt, "RewardPreviousEndorserLevel1")
357+
.to.not.emit(utt, "RewardPreviousEndorserLevel2")
358+
.to.not.emit(utt, "RewardUTUCoin");
359+
});
360+
361+
it("should not change claimable UTU Coin", async function () {
362+
const { utt, user1, service1 } = await deployWithClaimableRewards();
363+
const claimableBefore = await utt.claimableUTUCoin(user1.address);
364+
const totalClaimableBefore = await utt.totalClaimableUTUCoin();
365+
expect(claimableBefore).to.be.gt(0);
366+
expect(totalClaimableBefore).to.be.gt(0);
367+
368+
await utt
369+
.connect(user1)
370+
.withdrawStake(service1.address, 50, mockTransactionId);
371+
372+
expect(await utt.claimableUTUCoin(user1.address)).to.eq(claimableBefore);
373+
expect(await utt.totalClaimableUTUCoin()).to.eq(totalClaimableBefore);
374+
});
375+
376+
it("should block stake withdrawal while migration is active", async function () {
377+
const { utt, admin, user1, service1 } = await deployWithUserStake();
378+
379+
await utt.connect(admin).startMigrationToNewContract();
380+
381+
await expect(
382+
utt
383+
.connect(user1)
384+
.withdrawStake(service1.address, 250, mockTransactionId)
385+
).to.be.revertedWith("Contract is migrating");
386+
});
387+
388+
it("should revert stake withdrawal while the contract is paused", async function () {
389+
const { utt, admin, user1, service1 } = await deployWithUserStake();
390+
391+
await utt.connect(admin).pause();
392+
393+
await expect(
394+
utt
395+
.connect(user1)
396+
.withdrawStake(service1.address, 250, mockTransactionId)
397+
).to.be.reverted;
398+
});
399+
});
400+
239401
describe("User tries to addConnection", function () {
240402
it("should not allow a user to add a connection by themselves", async function () {
241403
const { utt, user1 } = await loadFixture(deployUTT);

0 commit comments

Comments
 (0)