@vechain/vebetterdao-contracts
Version:
Open-source repository that houses the smart contracts powering the decentralized VeBetterDAO on the VeChain Thor blockchain.
413 lines (412 loc) • 22.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const chai_1 = require("chai");
const hardhat_1 = require("hardhat");
const helpers_1 = require("../../scripts/helpers");
const libraries_1 = require("../../scripts/libraries");
const STAKE_AMOUNT = hardhat_1.ethers.parseEther("100");
const MIN_BET_AMOUNT = hardhat_1.ethers.parseEther("100");
const INITIAL_BALANCE = hardhat_1.ethers.parseEther("1000");
const APP_1 = hardhat_1.ethers.keccak256(hardhat_1.ethers.toUtf8Bytes("app-1"));
const ChallengeKind = { Stake: 0, Sponsored: 1 };
const ChallengeVisibility = { Public: 0, Private: 1 };
const ChallengeType = { MaxActions: 0, SplitWin: 1 };
const ChallengeStatus = { Pending: 0, Active: 1, Completed: 2, Cancelled: 3, Invalid: 4 };
const SettlementMode = { None: 0, TopWinners: 1, CreatorRefund: 2, SplitWinCompleted: 3 };
async function deployFixture() {
const [admin, alice, bob, carol] = await hardhat_1.ethers.getSigners();
const b3tr = (await (await hardhat_1.ethers.getContractFactory("B3TR")).deploy(admin.address, admin.address, admin.address));
await b3tr.waitForDeployment();
const roundGovernor = (await (await hardhat_1.ethers.getContractFactory("MockRoundGovernor")).deploy());
await roundGovernor.waitForDeployment();
const passport = (await (await hardhat_1.ethers.getContractFactory("MockPassportActions")).deploy());
await passport.waitForDeployment();
const x2EarnApps = (await (await hardhat_1.ethers.getContractFactory("MockX2EarnApps")).deploy());
await x2EarnApps.waitForDeployment();
await x2EarnApps.setAppExists(APP_1, true);
const { ChallengeCoreLogic: challengeCoreLogic, ChallengeSettlementLogic: challengeSettlementLogic } = await (0, libraries_1.challengesLibraries)({ logOutput: false });
const challenges = (await (0, helpers_1.deployProxy)("B3TRChallenges", [
{
b3trAddress: await b3tr.getAddress(),
veBetterPassportAddress: await passport.getAddress(),
xAllocationVotingAddress: await roundGovernor.getAddress(),
x2EarnAppsAddress: await x2EarnApps.getAddress(),
maxChallengeDuration: 4,
maxSelectedApps: 5,
maxParticipants: 100,
minBetAmount: MIN_BET_AMOUNT,
},
{
admin: admin.address,
upgrader: admin.address,
contractsAddressManager: admin.address,
settingsManager: admin.address,
},
], {
ChallengeCoreLogic: await challengeCoreLogic.getAddress(),
ChallengeSettlementLogic: await challengeSettlementLogic.getAddress(),
}));
for (const signer of [admin, alice, bob, carol]) {
await b3tr.mint(signer.address, INITIAL_BALANCE);
await b3tr.connect(signer).approve(await challenges.getAddress(), INITIAL_BALANCE);
}
return { admin, alice, bob, carol, b3tr, roundGovernor, passport, x2EarnApps, challenges };
}
describe("B3TRChallenges - Passport gating - @shard9b", function () {
// ──── joinChallenge: block non-persons in both private and public flows ────
it("rejects joinChallenge from a non-person in a private (Stake) challenge", async function () {
const { admin, alice, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
// Stake/Private/MaxActions; admin auto-joins as creator.
await challenges.createChallenge({
kind: ChallengeKind.Stake,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
stakeAmount: STAKE_AMOUNT,
startRound: 2,
endRound: 3,
threshold: 0,
numWinners: 0,
appIds: [APP_1],
invitees: [alice.address],
title: "",
description: "",
imageURI: "",
metadataURI: "",
});
await passport.setIsPerson(alice.address, false, "User is blacklisted");
await (0, chai_1.expect)(challenges.connect(alice).joinChallenge(1))
.to.be.revertedWithCustomError(challenges, "NotVerifiedPerson")
.withArgs(alice.address, "User is blacklisted");
(0, chai_1.expect)(admin.address).to.not.equal(alice.address); // sanity
});
it("rejects joinChallenge from a non-person in a public (Sponsored SplitWin) challenge", async function () {
const { alice, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await challenges.createChallenge({
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("300"),
startRound: 2,
endRound: 3,
threshold: 3,
numWinners: 2,
appIds: [],
invitees: [],
title: "",
description: "",
imageURI: "",
metadataURI: "",
});
await passport.setIsPerson(alice.address, false, "User has been signaled too many times");
await (0, chai_1.expect)(challenges.connect(alice).joinChallenge(1))
.to.be.revertedWithCustomError(challenges, "NotVerifiedPerson")
.withArgs(alice.address, "User has been signaled too many times");
});
// ──── createChallenge: block non-person creators of stake challenges ────
it("rejects createChallenge when a non-person creator would auto-join a Stake challenge", async function () {
const { admin, alice, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await passport.setIsPerson(admin.address, false, "User is blacklisted");
await (0, chai_1.expect)(challenges.createChallenge({
kind: ChallengeKind.Stake,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
stakeAmount: STAKE_AMOUNT,
startRound: 2,
endRound: 3,
threshold: 0,
numWinners: 0,
appIds: [APP_1],
invitees: [alice.address],
title: "",
description: "",
imageURI: "",
metadataURI: "",
}))
.to.be.revertedWithCustomError(challenges, "NotVerifiedPerson")
.withArgs(admin.address, "User is blacklisted");
});
it("allows createChallenge with a non-person creator for Sponsored challenges (no auto-join)", async function () {
const { admin, alice, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await passport.setIsPerson(admin.address, false, "User is blacklisted");
// Sponsored creators do not participate, so personhood is not required to fund the prize pool.
await challenges.createChallenge({
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
stakeAmount: STAKE_AMOUNT,
startRound: 2,
endRound: 3,
threshold: 0,
numWinners: 0,
appIds: [APP_1],
invitees: [alice.address],
title: "",
description: "",
imageURI: "",
metadataURI: "",
});
(0, chai_1.expect)(await challenges.challengeCount()).to.equal(1n);
});
// ──── claimChallengePayout: block non-person winners; refund branch stays open ────
it("honours the frozen winner snapshot: a winner who later loses personhood can still claim", async function () {
const { admin, alice, bob, b3tr, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await challenges.createChallenge({
kind: ChallengeKind.Stake,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
stakeAmount: STAKE_AMOUNT,
startRound: 2,
endRound: 3,
threshold: 0,
numWinners: 0,
appIds: [APP_1],
invitees: [alice.address, bob.address],
title: "",
description: "",
imageURI: "",
metadataURI: "",
});
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
// alice wins outright at completion.
await passport.setUserRoundActionCountApp(admin.address, 2, APP_1, 1);
await passport.setUserRoundActionCountApp(alice.address, 2, APP_1, 10);
await passport.setUserRoundActionCountApp(bob.address, 2, APP_1, 5);
await roundGovernor.setCurrentRoundId(4);
await challenges.completeChallenge(1);
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.settlementMode).to.equal(SettlementMode.TopWinners);
(0, chai_1.expect)(challenge.bestScore).to.equal(10n);
(0, chai_1.expect)(challenge.bestCount).to.equal(1n);
// alice is blacklisted AFTER the challenge ended. The winner set was frozen at completion,
// so the verdict cannot retroactively revoke her slot — otherwise the pot would be stranded.
await passport.setIsPerson(alice.address, false, "User is blacklisted");
const balanceBefore = await b3tr.balanceOf(alice.address);
await challenges.connect(alice).claimChallengePayout(1);
(0, chai_1.expect)(await b3tr.balanceOf(alice.address)).to.equal(balanceBefore + hardhat_1.ethers.parseEther("300"));
});
it("regression: a tied non-person at completion cannot steal the prize after regaining personhood", async function () {
// The bug: completeChallenge filtered non-persons out of bestScore/bestCount, but the original
// claimChallengePayout re-derived eligibility live from `actions == bestScore` + a live isPerson check.
// A non-person tied with the verified winner at completion could become a person again afterwards and
// claim a slot that bestCount never accounted for, taking the full pot and leaving the real winner
// to revert with TransferFailed on an empty contract.
const { admin, alice, bob, b3tr, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await challenges.createChallenge({
kind: ChallengeKind.Stake,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
stakeAmount: STAKE_AMOUNT,
startRound: 2,
endRound: 3,
threshold: 0,
numWinners: 0,
appIds: [APP_1],
invitees: [alice.address, bob.address],
title: "",
description: "",
imageURI: "",
metadataURI: "",
});
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
// A (alice) and B (bob) both have 10 actions — tied top score. B is a non-person at completion.
await passport.setUserRoundActionCountApp(admin.address, 2, APP_1, 0);
await passport.setUserRoundActionCountApp(alice.address, 2, APP_1, 10);
await passport.setUserRoundActionCountApp(bob.address, 2, APP_1, 10);
await passport.setIsPerson(bob.address, false, "User is blacklisted");
await roundGovernor.setCurrentRoundId(4);
await challenges.completeChallenge(1);
// B is skipped at completion → bestCount == 1, snapshot only contains A.
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.bestScore).to.equal(10n);
(0, chai_1.expect)(challenge.bestCount).to.equal(1n);
(0, chai_1.expect)(challenge.settlementMode).to.equal(SettlementMode.TopWinners);
// B regains personhood after the snapshot.
await passport.setIsPerson(bob.address, true, "");
// B is NOT in the eligible-winner snapshot, so the live attempt reverts.
// Without the fix, B would pass _isEligibleForPayout (actions == bestScore) and take the entire pot.
await (0, chai_1.expect)(challenges.connect(bob).claimChallengePayout(1))
.to.be.revertedWithCustomError(challenges, "NothingToClaim")
.withArgs(1, bob.address);
// A claims the full pot exactly once. With the bug present, this would revert with TransferFailed.
const balanceBefore = await b3tr.balanceOf(alice.address);
await challenges.connect(alice).claimChallengePayout(1);
(0, chai_1.expect)(await b3tr.balanceOf(alice.address)).to.equal(balanceBefore + hardhat_1.ethers.parseEther("300"));
});
it("allows the creator to collect the CreatorRefund branch even if they are no longer a person", async function () {
const { admin, alice, bob, b3tr, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
// Sponsored Private MaxActions. Alice and bob join but both are non-persons at settlement, so
// completeChallenge skips them, bestCount stays 0, and the contract enters CreatorRefund mode.
await challenges.createChallenge({
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
stakeAmount: STAKE_AMOUNT,
startRound: 2,
endRound: 3,
threshold: 0,
numWinners: 0,
appIds: [APP_1],
invitees: [alice.address, bob.address],
title: "",
description: "",
imageURI: "",
metadataURI: "",
});
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
await passport.setUserRoundActionCountApp(alice.address, 2, APP_1, 5);
await passport.setUserRoundActionCountApp(bob.address, 2, APP_1, 7);
await passport.setIsPerson(alice.address, false, "User is blacklisted");
await passport.setIsPerson(bob.address, false, "User is blacklisted");
await roundGovernor.setCurrentRoundId(4);
await challenges.completeChallenge(1);
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.bestCount).to.equal(0n);
(0, chai_1.expect)(challenge.settlementMode).to.equal(SettlementMode.CreatorRefund);
// Creator also becomes non-person; the refund branch must still pay out.
await passport.setIsPerson(admin.address, false, "User is blacklisted");
const balanceBefore = await b3tr.balanceOf(admin.address);
await challenges.claimChallengePayout(1);
(0, chai_1.expect)(await b3tr.balanceOf(admin.address)).to.equal(balanceBefore + STAKE_AMOUNT);
});
// ──── claimSplitWinPrize: block non-persons ────
it("rejects claimSplitWinPrize when a participant is no longer a person", async function () {
const { alice, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await challenges.createChallenge({
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("300"),
startRound: 2,
endRound: 3,
threshold: 3,
numWinners: 2,
appIds: [],
invitees: [],
title: "",
description: "",
imageURI: "",
metadataURI: "",
});
await challenges.connect(alice).joinChallenge(1);
await roundGovernor.setCurrentRoundId(2);
await challenges.syncChallenge(1);
await passport.setUserRoundActionCount(alice.address, 2, 5);
await passport.setIsPerson(alice.address, false, "User does not meet the criteria to be considered a person");
await (0, chai_1.expect)(challenges.connect(alice).claimSplitWinPrize(1))
.to.be.revertedWithCustomError(challenges, "NotVerifiedPerson")
.withArgs(alice.address, "User does not meet the criteria to be considered a person");
});
// ──── completeChallenge: skip non-persons from bestScore selection ────
it("completeChallenge skips non-persons so the prize goes to the top verified participant", async function () {
const { admin, alice, bob, b3tr, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await challenges.createChallenge({
kind: ChallengeKind.Stake,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
stakeAmount: STAKE_AMOUNT,
startRound: 2,
endRound: 3,
threshold: 0,
numWinners: 0,
appIds: [APP_1],
invitees: [alice.address, bob.address],
title: "",
description: "",
imageURI: "",
metadataURI: "",
});
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
// alice has the highest score, but she is non-person at settlement.
// bob is verified with the runner-up score, and should become the sole top-winner.
await passport.setUserRoundActionCountApp(admin.address, 2, APP_1, 1);
await passport.setUserRoundActionCountApp(alice.address, 2, APP_1, 100);
await passport.setUserRoundActionCountApp(bob.address, 2, APP_1, 7);
await passport.setIsPerson(alice.address, false, "User is blacklisted");
await roundGovernor.setCurrentRoundId(4);
await challenges.completeChallenge(1);
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.bestScore).to.equal(7n);
(0, chai_1.expect)(challenge.bestCount).to.equal(1n);
(0, chai_1.expect)(challenge.settlementMode).to.equal(SettlementMode.TopWinners);
// alice cannot claim (both NotEligible — score < bestScore — and would also fail personhood).
await (0, chai_1.expect)(challenges.connect(alice).claimChallengePayout(1)).to.be.revertedWithCustomError(challenges, "NothingToClaim");
// bob collects the full pot (3 stakes = 300).
const bobBalanceBefore = await b3tr.balanceOf(bob.address);
await challenges.connect(bob).claimChallengePayout(1);
(0, chai_1.expect)(await b3tr.balanceOf(bob.address)).to.equal(bobBalanceBefore + hardhat_1.ethers.parseEther("300"));
});
// ──── Refund paths stay open for non-persons ────
it("claimChallengeRefund still succeeds for a non-person participant on a cancelled stake challenge", async function () {
const { alice, b3tr, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await challenges.createChallenge({
kind: ChallengeKind.Stake,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
stakeAmount: STAKE_AMOUNT,
startRound: 2,
endRound: 3,
threshold: 0,
numWinners: 0,
appIds: [APP_1],
invitees: [alice.address],
title: "",
description: "",
imageURI: "",
metadataURI: "",
});
await challenges.connect(alice).joinChallenge(1);
await challenges.cancelChallenge(1);
(0, chai_1.expect)(await challenges.getChallengeStatus(1)).to.equal(ChallengeStatus.Cancelled);
// alice becomes non-person AFTER joining — she should still get her stake back.
await passport.setIsPerson(alice.address, false, "User is blacklisted");
const balanceBefore = await b3tr.balanceOf(alice.address);
await challenges.connect(alice).claimChallengeRefund(1);
(0, chai_1.expect)(await b3tr.balanceOf(alice.address)).to.equal(balanceBefore + STAKE_AMOUNT);
});
it("claimCreatorSplitWinRefund still succeeds for a non-person creator after end round", async function () {
const { admin, alice, b3tr, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
const sponsorAmount = hardhat_1.ethers.parseEther("300");
await challenges.createChallenge({
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: sponsorAmount,
startRound: 2,
endRound: 3,
threshold: 3,
numWinners: 2,
appIds: [],
invitees: [],
title: "",
description: "",
imageURI: "",
metadataURI: "",
});
// alice joins so the challenge becomes Active rather than Invalid — but does not claim a slot.
await challenges.connect(alice).joinChallenge(1);
await roundGovernor.setCurrentRoundId(2);
await challenges.syncChallenge(1);
(0, chai_1.expect)(await challenges.getChallengeStatus(1)).to.equal(ChallengeStatus.Active);
// After endRound, no winners; creator reclaims the whole pool via the refund path even when non-person.
await roundGovernor.setCurrentRoundId(4);
await passport.setIsPerson(admin.address, false, "User is blacklisted");
const balanceBefore = await b3tr.balanceOf(admin.address);
await challenges.claimCreatorSplitWinRefund(1);
(0, chai_1.expect)(await b3tr.balanceOf(admin.address)).to.equal(balanceBefore + sponsorAmount);
});
});