@vechain/vebetterdao-contracts
Version:
Open-source repository that houses the smart contracts powering the decentralized VeBetterDAO on the VeChain Thor blockchain.
873 lines • 82 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 ChallengeCoreLogic__factory_1 = require("../../typechain-types/factories/contracts/challenges/libraries/ChallengeCoreLogic__factory");
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 APP_2 = hardhat_1.ethers.keccak256(hardhat_1.ethers.toUtf8Bytes("app-2"));
const APP_3 = hardhat_1.ethers.keccak256(hardhat_1.ethers.toUtf8Bytes("app-3"));
const APP_4 = hardhat_1.ethers.keccak256(hardhat_1.ethers.toUtf8Bytes("app-4"));
const APP_5 = hardhat_1.ethers.keccak256(hardhat_1.ethers.toUtf8Bytes("app-5"));
const APP_6 = hardhat_1.ethers.keccak256(hardhat_1.ethers.toUtf8Bytes("app-6"));
const TITLE_MAX_BYTES = 120;
const DESCRIPTION_MAX_BYTES = 500;
const IMAGE_URI_MAX_BYTES = 512;
const METADATA_URI_MAX_BYTES = 512;
const ChallengeKind = {
Stake: 0,
Sponsored: 1,
};
const ChallengeVisibility = {
Public: 0,
Private: 1,
};
const ChallengeType = {
MaxActions: 0,
SplitWin: 1,
};
const ParticipantStatus = {
None: 0,
Invited: 1,
Declined: 2,
Joined: 3,
};
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({ maxParticipants = 100, minBetAmount = MIN_BET_AMOUNT, } = {}) {
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();
const { ChallengeCoreLogic: challengeCoreLogic, ChallengeSettlementLogic: challengeSettlementLogic } = await (0, libraries_1.challengesLibraries)({ logOutput: false });
for (const appId of [APP_1, APP_2, APP_3, APP_4, APP_5, APP_6]) {
await x2EarnApps.setAppExists(appId, true);
}
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,
minBetAmount,
},
{
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 };
}
/**
* Default helper: Stake + Private + MaxActions (the only valid Bet combination in the new matrix).
* Pass `invitees` to allow others to join, or override fields explicitly for sponsored challenges.
*/
async function createChallenge(challenges, overrides = {}) {
return 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: [],
title: "",
description: "",
imageURI: "",
metadataURI: "",
...overrides,
});
}
describe("B3TRChallenges - @shard9a", function () {
// ──── Creation: matrix + basic fields ────
it("creates a Bet (Stake/Private/MaxActions) challenge and auto-adds the creator", async function () {
const { admin, b3tr, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
const tx = await createChallenge(challenges, { invitees: [] });
const receipt = await tx.wait();
const challengeCreated = receipt?.logs
.map(log => {
try {
return ChallengeCoreLogic__factory_1.ChallengeCoreLogic__factory.createInterface().parseLog(log);
}
catch {
return null;
}
})
.find(log => log?.name === "ChallengeCreated");
(0, chai_1.expect)(challengeCreated).to.not.equal(undefined);
(0, chai_1.expect)(challengeCreated?.args.challengeId).to.equal(1n);
(0, chai_1.expect)(challengeCreated?.args.creator).to.equal(admin.address);
(0, chai_1.expect)(challengeCreated?.args.endRound).to.equal(3n);
(0, chai_1.expect)(challengeCreated?.args.kind).to.equal(ChallengeKind.Stake);
(0, chai_1.expect)(challengeCreated?.args.visibility).to.equal(ChallengeVisibility.Private);
(0, chai_1.expect)(challengeCreated?.args.challengeType).to.equal(ChallengeType.MaxActions);
(0, chai_1.expect)(challengeCreated?.args.stakeAmount).to.equal(STAKE_AMOUNT);
(0, chai_1.expect)(challengeCreated?.args.startRound).to.equal(2n);
(0, chai_1.expect)(challengeCreated?.args.threshold).to.equal(0n);
(0, chai_1.expect)(challengeCreated?.args.allApps).to.equal(false);
(0, chai_1.expect)(challengeCreated?.args.selectedApps).to.deep.equal([APP_1]);
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.creator).to.equal(admin.address);
(0, chai_1.expect)(challenge.participantCount).to.equal(1n);
(0, chai_1.expect)(challenge.totalPrize).to.equal(STAKE_AMOUNT);
(0, chai_1.expect)(challenge.numWinners).to.equal(0n);
(0, chai_1.expect)(challenge.prizePerWinner).to.equal(0n);
(0, chai_1.expect)(await challenges.getParticipantStatus(1, admin.address)).to.equal(ParticipantStatus.Joined);
(0, chai_1.expect)(await b3tr.balanceOf(await challenges.getAddress())).to.equal(STAKE_AMOUNT);
});
it("creates a Sponsored Public Split Win challenge with locked prizePerWinner", async function () {
const { admin, b3tr, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
const sponsorAmount = hardhat_1.ethers.parseEther("300");
const tx = await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: sponsorAmount,
threshold: 5,
numWinners: 3,
appIds: [],
});
const receipt = await tx.wait();
const splitConfigured = receipt?.logs
.map(log => {
try {
return ChallengeCoreLogic__factory_1.ChallengeCoreLogic__factory.createInterface().parseLog(log);
}
catch {
return null;
}
})
.find(log => log?.name === "SplitWinConfigured");
(0, chai_1.expect)(splitConfigured?.args.numWinners).to.equal(3n);
(0, chai_1.expect)(splitConfigured?.args.prizePerWinner).to.equal(hardhat_1.ethers.parseEther("100"));
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.challengeType).to.equal(ChallengeType.SplitWin);
(0, chai_1.expect)(challenge.numWinners).to.equal(3n);
(0, chai_1.expect)(challenge.threshold).to.equal(5n);
(0, chai_1.expect)(challenge.prizePerWinner).to.equal(hardhat_1.ethers.parseEther("100"));
(0, chai_1.expect)(challenge.winnersClaimed).to.equal(0n);
(0, chai_1.expect)(challenge.participantCount).to.equal(0n);
(0, chai_1.expect)(await b3tr.balanceOf(await challenges.getAddress())).to.equal(sponsorAmount);
});
it("creates a Sponsored Private Max Actions challenge with no auto-creator", async function () {
const { roundGovernor, challenges, alice } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
invitees: [alice.address],
});
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.participantCount).to.equal(0n);
(0, chai_1.expect)(challenge.invitedCount).to.equal(1n);
});
// ──── Matrix rejection cases ────
it("rejects Bet (Stake) Public", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(challenges, {
kind: ChallengeKind.Stake,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.MaxActions,
})).to.be.revertedWithCustomError(challenges, "InvalidChallengeTypeForCombo");
});
it("rejects Bet (Stake) with SplitWin", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(challenges, {
kind: ChallengeKind.Stake,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.SplitWin,
threshold: 5,
numWinners: 3,
})).to.be.revertedWithCustomError(challenges, "InvalidChallengeTypeForCombo");
});
it("rejects Sponsored Public with MaxActions (only SplitWin allowed)", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.MaxActions,
})).to.be.revertedWithCustomError(challenges, "InvalidChallengeTypeForCombo");
});
it("rejects MaxActions with non-zero threshold or numWinners", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(challenges, { threshold: 5 })).to.be.revertedWithCustomError(challenges, "InvalidTypeConfiguration");
await (0, chai_1.expect)(createChallenge(challenges, { numWinners: 2 })).to.be.revertedWithCustomError(challenges, "InvalidTypeConfiguration");
});
it("rejects SplitWin with zero threshold or zero numWinners", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
threshold: 0,
numWinners: 3,
})).to.be.revertedWithCustomError(challenges, "InvalidTypeConfiguration");
await (0, chai_1.expect)(createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
threshold: 5,
numWinners: 0,
})).to.be.revertedWithCustomError(challenges, "InvalidTypeConfiguration");
});
it("rejects SplitWin when stakeAmount < numWinners * 1 B3TR (InsufficientPrizePerWinner)", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("100"),
threshold: 5,
numWinners: 101,
appIds: [],
})).to.be.revertedWithCustomError(challenges, "InsufficientPrizePerWinner");
const { roundGovernor: rg2, challenges: ch2 } = await deployFixture({ minBetAmount: hardhat_1.ethers.parseEther("1") });
await rg2.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(ch2, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("10"),
threshold: 5,
numWinners: 11,
appIds: [],
})).to.be.revertedWithCustomError(ch2, "InsufficientPrizePerWinner");
});
it("accepts SplitWin when stakeAmount == numWinners * 1 B3TR (exactly 1 B3TR per winner)", async function () {
const { roundGovernor, challenges } = await deployFixture({ minBetAmount: hardhat_1.ethers.parseEther("1") });
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("5"),
threshold: 1,
numWinners: 5,
appIds: [],
});
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.prizePerWinner).to.equal(hardhat_1.ethers.parseEther("1"));
});
it("rejects SplitWin when threshold > 1_000_000 (ThresholdTooHigh)", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("100"),
threshold: 1000001,
numWinners: 3,
appIds: [],
})).to.be.revertedWithCustomError(challenges, "ThresholdTooHigh");
});
it("accepts SplitWin when threshold == 1_000_000", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("100"),
threshold: 1000000,
numWinners: 3,
appIds: [],
});
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.threshold).to.equal(1000000n);
});
// ──── Metadata ────
it("stores metadata fields at their maximum allowed lengths", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
title: "t".repeat(TITLE_MAX_BYTES),
description: "d".repeat(DESCRIPTION_MAX_BYTES),
imageURI: "i".repeat(IMAGE_URI_MAX_BYTES),
metadataURI: "m".repeat(METADATA_URI_MAX_BYTES),
});
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.title).to.equal("t".repeat(TITLE_MAX_BYTES));
(0, chai_1.expect)(challenge.description).to.equal("d".repeat(DESCRIPTION_MAX_BYTES));
(0, chai_1.expect)(challenge.imageURI).to.equal("i".repeat(IMAGE_URI_MAX_BYTES));
(0, chai_1.expect)(challenge.metadataURI).to.equal("m".repeat(METADATA_URI_MAX_BYTES));
});
it("rejects metadata fields that exceed their maximum lengths", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(challenges, { title: "t".repeat(TITLE_MAX_BYTES + 1) }))
.to.be.revertedWithCustomError(challenges, "TitleTooLong")
.withArgs(TITLE_MAX_BYTES + 1, TITLE_MAX_BYTES);
await (0, chai_1.expect)(createChallenge(challenges, { description: "d".repeat(DESCRIPTION_MAX_BYTES + 1) }))
.to.be.revertedWithCustomError(challenges, "DescriptionTooLong")
.withArgs(DESCRIPTION_MAX_BYTES + 1, DESCRIPTION_MAX_BYTES);
await (0, chai_1.expect)(createChallenge(challenges, { imageURI: "i".repeat(IMAGE_URI_MAX_BYTES + 1) }))
.to.be.revertedWithCustomError(challenges, "ImageURITooLong")
.withArgs(IMAGE_URI_MAX_BYTES + 1, IMAGE_URI_MAX_BYTES);
await (0, chai_1.expect)(createChallenge(challenges, { metadataURI: "m".repeat(METADATA_URI_MAX_BYTES + 1) }))
.to.be.revertedWithCustomError(challenges, "MetadataURITooLong")
.withArgs(METADATA_URI_MAX_BYTES + 1, METADATA_URI_MAX_BYTES);
});
// ──── Participant cap (MaxActions only) ────
it("rejects joining a Sponsored Private Max Actions challenge after the participant cap", async function () {
const { alice, bob, carol, roundGovernor, challenges } = await deployFixture({ maxParticipants: 2 });
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
invitees: [alice.address, bob.address, carol.address],
});
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
await (0, chai_1.expect)(challenges.connect(carol).joinChallenge(1))
.to.be.revertedWithCustomError(challenges, "MaxParticipantsExceeded")
.withArgs(3, 2);
});
it("counts the creator toward the participant cap on Bet challenges", async function () {
const { alice, bob, carol, roundGovernor, challenges } = await deployFixture({ maxParticipants: 3 });
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, { invitees: [alice.address, bob.address, carol.address] });
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
await (0, chai_1.expect)(challenges.connect(carol).joinChallenge(1))
.to.be.revertedWithCustomError(challenges, "MaxParticipantsExceeded")
.withArgs(4, 3);
});
it("does NOT enforce the participant cap on Split Win challenges", async function () {
const { alice, bob, carol, roundGovernor, challenges } = await deployFixture({ maxParticipants: 1 });
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("300"),
threshold: 5,
numWinners: 3,
});
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
await challenges.connect(carol).joinChallenge(1);
(0, chai_1.expect)((await challenges.getChallenge(1)).participantCount).to.equal(3n);
});
// ──── Round / app validations ────
it("rejects challenges whose start round is not after the current round", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(2);
await (0, chai_1.expect)(createChallenge(challenges, { startRound: 2, endRound: 2 }))
.to.be.revertedWithCustomError(challenges, "InvalidStartRound")
.withArgs(2, 2);
});
it("rejects challenges whose end round is before the start round", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(challenges, { startRound: 3, endRound: 2 }))
.to.be.revertedWithCustomError(challenges, "InvalidEndRound")
.withArgs(3, 2);
});
it("allows selecting up to five apps", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, { appIds: [APP_1, APP_2, APP_3, APP_4, APP_5] });
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.allApps).to.equal(false);
(0, chai_1.expect)(challenge.selectedAppsCount).to.equal(5n);
});
it("rejects challenges with more than five selected apps", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await (0, chai_1.expect)(createChallenge(challenges, { appIds: [APP_1, APP_2, APP_3, APP_4, APP_5, APP_6] }))
.to.be.revertedWithCustomError(challenges, "MaxSelectedAppsExceeded")
.withArgs(6, 5);
});
it("treats an empty app selection as all apps", async function () {
const { roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, { appIds: [] });
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.allApps).to.equal(true);
(0, chai_1.expect)(challenge.selectedAppsCount).to.equal(0n);
});
// ──── Invitations / lifecycle ────
it("lets an invited user decline and later join a private sponsored challenge", async function () {
const { alice, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
invitees: [alice.address],
appIds: [],
});
await challenges.connect(alice).declineChallenge(1);
(0, chai_1.expect)(await challenges.getParticipantStatus(1, alice.address)).to.equal(ParticipantStatus.Declined);
await challenges.connect(alice).joinChallenge(1);
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.participantCount).to.equal(1n);
(0, chai_1.expect)(await challenges.getParticipantStatus(1, alice.address)).to.equal(ParticipantStatus.Joined);
});
it("marks an unjoined Bet challenge invalid and refunds the creator", async function () {
const { admin, b3tr, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, { endRound: 2 });
await roundGovernor.setCurrentRoundId(2);
await challenges.syncChallenge(1);
(0, chai_1.expect)(await challenges.getChallengeStatus(1)).to.equal(ChallengeStatus.Invalid);
await challenges.claimChallengeRefund(1);
(0, chai_1.expect)(await b3tr.balanceOf(admin.address)).to.equal(INITIAL_BALANCE);
});
it("cancels a Bet challenge and refunds creator and participant", async function () {
const { admin, alice, b3tr, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, { invitees: [alice.address] });
await challenges.connect(alice).joinChallenge(1);
await challenges.cancelChallenge(1);
await challenges.claimChallengeRefund(1);
await challenges.connect(alice).claimChallengeRefund(1);
(0, chai_1.expect)(await b3tr.balanceOf(admin.address)).to.equal(INITIAL_BALANCE);
(0, chai_1.expect)(await b3tr.balanceOf(alice.address)).to.equal(INITIAL_BALANCE);
});
// ──── Max Actions completion + payout ────
it("completes and splits the Bet pot between tied winners", async function () {
const { admin, alice, bob, b3tr, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
appIds: [APP_1, APP_2],
endRound: 3,
invitees: [alice.address, bob.address],
});
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
await passport.setUserRoundActionCountApp(admin.address, 2, APP_1, 1);
await passport.setUserRoundActionCountApp(alice.address, 2, APP_1, 2);
await passport.setUserRoundActionCountApp(alice.address, 3, APP_2, 3);
await passport.setUserRoundActionCountApp(bob.address, 2, APP_1, 4);
await passport.setUserRoundActionCountApp(bob.address, 3, APP_2, 1);
await roundGovernor.setCurrentRoundId(4);
await challenges.completeChallenge(1);
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.status).to.equal(ChallengeStatus.Completed);
(0, chai_1.expect)(challenge.bestScore).to.equal(5n);
(0, chai_1.expect)(challenge.bestCount).to.equal(2n);
(0, chai_1.expect)(challenge.settlementMode).to.equal(SettlementMode.TopWinners);
await (0, chai_1.expect)(challenges.claimChallengePayout(1)).to.be.revertedWithCustomError(challenges, "NothingToClaim");
await challenges.connect(alice).claimChallengePayout(1);
await challenges.connect(bob).claimChallengePayout(1);
(0, chai_1.expect)(await b3tr.balanceOf(alice.address)).to.equal(INITIAL_BALANCE + hardhat_1.ethers.parseEther("50"));
(0, chai_1.expect)(await b3tr.balanceOf(bob.address)).to.equal(INITIAL_BALANCE + hardhat_1.ethers.parseEther("50"));
});
it("refunds the sponsor when nobody participates in a Sponsored Private Max Actions challenge", async function () {
const { admin, b3tr, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
appIds: [],
endRound: 3,
});
await roundGovernor.setCurrentRoundId(4);
await challenges.syncChallenge(1);
(0, chai_1.expect)(await challenges.getChallengeStatus(1)).to.equal(ChallengeStatus.Invalid);
await challenges.claimChallengeRefund(1);
(0, chai_1.expect)(await b3tr.balanceOf(admin.address)).to.equal(INITIAL_BALANCE);
});
// ──── Split Win lifecycle ────
it("Split Win: joiners can claim a slot once they reach the threshold; first-come first-served", async function () {
const { admin, alice, bob, b3tr, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
const sponsorAmount = hardhat_1.ethers.parseEther("300");
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: sponsorAmount,
threshold: 3,
numWinners: 2,
appIds: [],
endRound: 3,
});
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
await roundGovernor.setCurrentRoundId(2);
await challenges.syncChallenge(1);
(0, chai_1.expect)(await challenges.getChallengeStatus(1)).to.equal(ChallengeStatus.Active);
// Below threshold → reverts
await passport.setUserRoundActionCount(alice.address, 2, 2);
await (0, chai_1.expect)(challenges.connect(alice).claimSplitWinPrize(1)).to.be.revertedWithCustomError(challenges, "NotEligibleForSplitWin");
// Reaches threshold → claims first slot
await passport.setUserRoundActionCount(alice.address, 2, 3);
await challenges.connect(alice).claimSplitWinPrize(1);
(0, chai_1.expect)(await b3tr.balanceOf(alice.address)).to.equal(INITIAL_BALANCE + hardhat_1.ethers.parseEther("150"));
(0, chai_1.expect)((await challenges.getChallenge(1)).winnersClaimed).to.equal(1n);
(0, chai_1.expect)(await challenges.isSplitWinWinner(1, alice.address)).to.equal(true);
// Bob claims second slot — flips status to Completed
await passport.setUserRoundActionCount(bob.address, 2, 4);
await challenges.connect(bob).claimSplitWinPrize(1);
(0, chai_1.expect)(await b3tr.balanceOf(bob.address)).to.equal(INITIAL_BALANCE + hardhat_1.ethers.parseEther("150"));
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.winnersClaimed).to.equal(2n);
(0, chai_1.expect)(challenge.status).to.equal(ChallengeStatus.Completed);
(0, chai_1.expect)(challenge.settlementMode).to.equal(SettlementMode.SplitWinCompleted);
(0, chai_1.expect)(await challenges.getChallengeWinners(1)).to.deep.equal([alice.address, bob.address]);
// Creator pool drained, contract holds nothing
(0, chai_1.expect)(await b3tr.balanceOf(await challenges.getAddress())).to.equal(0n);
(0, chai_1.expect)(await b3tr.balanceOf(admin.address)).to.equal(INITIAL_BALANCE - sponsorAmount);
});
it("Split Win: rejects claim once all slots are exhausted", async function () {
const { alice, bob, carol, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("100"),
threshold: 1,
numWinners: 1,
appIds: [],
endRound: 3,
});
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
await challenges.connect(carol).joinChallenge(1);
await roundGovernor.setCurrentRoundId(2);
await passport.setUserRoundActionCount(alice.address, 2, 1);
await passport.setUserRoundActionCount(bob.address, 2, 1);
await challenges.connect(alice).claimSplitWinPrize(1);
await (0, chai_1.expect)(challenges.connect(bob).claimSplitWinPrize(1)).to.be.revertedWithCustomError(challenges, "ChallengeInvalidStatus");
});
it("Split Win: same winner cannot claim twice", async function () {
const { alice, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("200"),
threshold: 1,
numWinners: 2,
appIds: [],
endRound: 3,
});
await challenges.connect(alice).joinChallenge(1);
await roundGovernor.setCurrentRoundId(2);
await passport.setUserRoundActionCount(alice.address, 2, 1);
await challenges.connect(alice).claimSplitWinPrize(1);
await (0, chai_1.expect)(challenges.connect(alice).claimSplitWinPrize(1)).to.be.revertedWithCustomError(challenges, "AlreadyClaimed");
});
it("Split Win: non-participant cannot claim", async function () {
const { alice, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("100"),
threshold: 1,
numWinners: 1,
appIds: [],
endRound: 3,
});
await roundGovernor.setCurrentRoundId(2);
await challenges.syncChallenge(1);
(0, chai_1.expect)(await challenges.getChallengeStatus(1)).to.equal(ChallengeStatus.Invalid);
await (0, chai_1.expect)(challenges.connect(alice).claimSplitWinPrize(1)).to.be.revertedWithCustomError(challenges, "ChallengeInvalidStatus");
});
it("Split Win: rejects claim after endRound", async function () {
const { alice, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("100"),
threshold: 1,
numWinners: 1,
appIds: [],
endRound: 2,
});
await challenges.connect(alice).joinChallenge(1);
await passport.setUserRoundActionCount(alice.address, 2, 1);
await roundGovernor.setCurrentRoundId(3);
await (0, chai_1.expect)(challenges.connect(alice).claimSplitWinPrize(1)).to.be.revertedWithCustomError(challenges, "ChallengeEnded");
});
it("Split Win: completeChallenge rejects with SplitWinCannotComplete", async function () {
const { alice, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("100"),
threshold: 1,
numWinners: 1,
appIds: [],
endRound: 2,
});
await challenges.connect(alice).joinChallenge(1);
await roundGovernor.setCurrentRoundId(3);
await (0, chai_1.expect)(challenges.completeChallenge(1)).to.be.revertedWithCustomError(challenges, "SplitWinCannotComplete");
});
it("Split Win: creator can refund unclaimed slots after endRound (incl. integer remainder)", async function () {
const { admin, alice, b3tr, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
// 100 / 3 = 33 per winner, 1 wei remainder
const sponsorAmount = hardhat_1.ethers.parseEther("100");
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: sponsorAmount,
threshold: 1,
numWinners: 3,
appIds: [],
endRound: 2,
});
await challenges.connect(alice).joinChallenge(1);
await passport.setUserRoundActionCount(alice.address, 2, 1);
await roundGovernor.setCurrentRoundId(2);
await challenges.connect(alice).claimSplitWinPrize(1);
const prizePerWinner = sponsorAmount / 3n;
(0, chai_1.expect)(await b3tr.balanceOf(alice.address)).to.equal(INITIAL_BALANCE + prizePerWinner);
// After endRound, creator reclaims 2 unclaimed slots + remainder
await roundGovernor.setCurrentRoundId(3);
await challenges.claimCreatorSplitWinRefund(1);
const refunded = sponsorAmount - prizePerWinner;
(0, chai_1.expect)(await b3tr.balanceOf(admin.address)).to.equal(INITIAL_BALANCE - sponsorAmount + refunded);
(0, chai_1.expect)(await b3tr.balanceOf(await challenges.getAddress())).to.equal(0n);
const challenge = await challenges.getChallenge(1);
(0, chai_1.expect)(challenge.status).to.equal(ChallengeStatus.Completed);
(0, chai_1.expect)(challenge.settlementMode).to.equal(SettlementMode.SplitWinCompleted);
});
it("Split Win: creator refund rejects before endRound", async function () {
const { alice, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("100"),
threshold: 1,
numWinners: 2,
appIds: [],
endRound: 3,
});
await challenges.connect(alice).joinChallenge(1);
await roundGovernor.setCurrentRoundId(2);
await challenges.syncChallenge(1);
await (0, chai_1.expect)(challenges.claimCreatorSplitWinRefund(1)).to.be.revertedWithCustomError(challenges, "ChallengeNotEnded");
});
it("Split Win: creator refund rejects when all slots already claimed", async function () {
const { alice, bob, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("200"),
threshold: 1,
numWinners: 2,
appIds: [],
endRound: 2,
});
await challenges.connect(alice).joinChallenge(1);
await challenges.connect(bob).joinChallenge(1);
await roundGovernor.setCurrentRoundId(2);
await passport.setUserRoundActionCount(alice.address, 2, 1);
await passport.setUserRoundActionCount(bob.address, 2, 1);
await challenges.connect(alice).claimSplitWinPrize(1);
await challenges.connect(bob).claimSplitWinPrize(1);
await roundGovernor.setCurrentRoundId(3);
await (0, chai_1.expect)(challenges.claimCreatorSplitWinRefund(1)).to.be.revertedWithCustomError(challenges, "NothingToRefund");
});
it("Split Win: only the creator can claim the refund", async function () {
const { alice, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("100"),
threshold: 1,
numWinners: 2,
appIds: [],
endRound: 2,
});
await challenges.connect(alice).joinChallenge(1);
await roundGovernor.setCurrentRoundId(3);
await (0, chai_1.expect)(challenges.connect(alice).claimCreatorSplitWinRefund(1)).to.be.revertedWithCustomError(challenges, "ChallengesUnauthorizedUser");
});
it("Split Win: claim across multiple rounds and selected apps reads live progress", async function () {
const { alice, roundGovernor, passport, challenges, b3tr } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Public,
challengeType: ChallengeType.SplitWin,
stakeAmount: hardhat_1.ethers.parseEther("100"),
threshold: 4,
numWinners: 1,
appIds: [APP_1, APP_2],
endRound: 4,
});
await challenges.connect(alice).joinChallenge(1);
await passport.setUserRoundActionCountApp(alice.address, 2, APP_1, 1);
await passport.setUserRoundActionCountApp(alice.address, 3, APP_2, 1);
await roundGovernor.setCurrentRoundId(3);
await (0, chai_1.expect)(challenges.connect(alice).claimSplitWinPrize(1)).to.be.revertedWithCustomError(challenges, "NotEligibleForSplitWin");
await passport.setUserRoundActionCountApp(alice.address, 4, APP_1, 2);
await roundGovernor.setCurrentRoundId(4);
await challenges.connect(alice).claimSplitWinPrize(1);
(0, chai_1.expect)(await b3tr.balanceOf(alice.address)).to.equal(INITIAL_BALANCE + hardhat_1.ethers.parseEther("100"));
});
// ──── View functions ────
it("returns version, challengeCount, and config getters", async function () {
const { challenges, roundGovernor } = await deployFixture();
(0, chai_1.expect)(await challenges.version()).to.equal("2");
(0, chai_1.expect)(await challenges.challengeCount()).to.equal(0n);
(0, chai_1.expect)(await challenges.maxChallengeDuration()).to.equal(4n);
(0, chai_1.expect)(await challenges.maxSelectedApps()).to.equal(5n);
(0, chai_1.expect)(await challenges.maxParticipants()).to.equal(100n);
(0, chai_1.expect)(await challenges.minBetAmount()).to.equal(MIN_BET_AMOUNT);
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges);
(0, chai_1.expect)(await challenges.challengeCount()).to.equal(1n);
});
it("returns participants, invited, declined, selectedApps, and invitation eligibility", async function () {
const { admin, alice, bob, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, {
kind: ChallengeKind.Sponsored,
visibility: ChallengeVisibility.Private,
challengeType: ChallengeType.MaxActions,
invitees: [alice.address, bob.address],
appIds: [APP_1, APP_2],
});
(0, chai_1.expect)(await challenges.getChallengeSelectedApps(1)).to.deep.equal([APP_1, APP_2]);
(0, chai_1.expect)(await challenges.isInvitationEligible(1, alice.address)).to.equal(true);
(0, chai_1.expect)(await challenges.isInvitationEligible(1, admin.address)).to.equal(false);
const invited = await challenges.getChallengeInvited(1);
(0, chai_1.expect)(invited).to.include(alice.address);
(0, chai_1.expect)(invited).to.include(bob.address);
await challenges.connect(alice).joinChallenge(1);
(0, chai_1.expect)(await challenges.getChallengeParticipants(1)).to.deep.equal([alice.address]);
await challenges.connect(bob).declineChallenge(1);
(0, chai_1.expect)(await challenges.getChallengeDeclined(1)).to.deep.equal([bob.address]);
});
it("returns participant actions via getParticipantActions", async function () {
const { admin, alice, roundGovernor, passport, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges, { appIds: [APP_1], endRound: 2, invitees: [alice.address] });
await challenges.connect(alice).joinChallenge(1);
await passport.setUserRoundActionCountApp(alice.address, 2, APP_1, 7);
(0, chai_1.expect)(await challenges.getParticipantActions(1, alice.address)).to.equal(7n);
(0, chai_1.expect)(await challenges.getParticipantActions(1, admin.address)).to.equal(0n);
});
it("reverts view functions for non-existent challenges", async function () {
const { admin, challenges } = await deployFixture();
await (0, chai_1.expect)(challenges.getChallenge(0)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
await (0, chai_1.expect)(challenges.getChallenge(99)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
await (0, chai_1.expect)(challenges.getChallengeParticipants(0)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
await (0, chai_1.expect)(challenges.getChallengeInvited(99)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
await (0, chai_1.expect)(challenges.getChallengeDeclined(99)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
await (0, chai_1.expect)(challenges.getChallengeSelectedApps(0)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
await (0, chai_1.expect)(challenges.getChallengeWinners(99)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
await (0, chai_1.expect)(challenges.getParticipantStatus(0, admin.address)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
await (0, chai_1.expect)(challenges.isInvitationEligible(0, admin.address)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
await (0, chai_1.expect)(challenges.isSplitWinWinner(99, admin.address)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
await (0, chai_1.expect)(challenges.getChallengeStatus(0)).to.be.revertedWithCustomError(challenges, "ChallengeDoesNotExist");
});
// ──── Admin setters ────
it("updates address setters and emits events", async function () {
const { alice, challenges } = await deployFixture();
await (0, chai_1.expect)(challenges.setB3TRAddress(alice.address)).to.emit(challenges, "B3TRAddressUpdated");
await (0, chai_1.expect)(challenges.setVeBetterPassportAddress(alice.address)).to.emit(challenges, "VeBetterPassportAddressUpdated");
await (0, chai_1.expect)(challenges.setXAllocationVotingAddress(alice.address)).to.emit(challenges, "XAllocationVotingAddressUpdated");
await (0, chai_1.expect)(challenges.setX2EarnAppsAddress(alice.address)).to.emit(challenges, "X2EarnAppsAddressUpdated");
});
it("reverts address setters with zero address", async function () {
const { challenges } = await deployFixture();
const zero = hardhat_1.ethers.ZeroAddress;
await (0, chai_1.expect)(challenges.setB3TRAddress(zero)).to.be.revertedWithCustomError(challenges, "ZeroAddress");
await (0, chai_1.expect)(challenges.setVeBetterPassportAddress(zero)).to.be.revertedWithCustomError(challenges, "ZeroAddress");
await (0, chai_1.expect)(challenges.setXAllocationVotingAddress(zero)).to.be.revertedWithCustomError(challenges, "ZeroAddress");
await (0, chai_1.expect)(challenges.setX2EarnAppsAddress(zero)).to.be.revertedWithCustomError(challenges, "ZeroAddress");
});
it("updates settings and emits events", async function () {
const { challenges } = await deployFixture();
await (0, chai_1.expect)(challenges.setMaxChallengeDuration(10))
.to.emit(challenges, "MaxChallengeDurationUpdated")
.withArgs(4, 10);
(0, chai_1.expect)(await challenges.maxChallengeDuration()).to.equal(10n);
await (0, chai_1.expect)(challenges.setMaxSelectedApps(8)).to.emit(challenges, "MaxSelectedAppsUpdated").withArgs(5, 8);
(0, chai_1.expect)(await challenges.maxSelectedApps()).to.equal(8n);
await (0, chai_1.expect)(challenges.setMaxParticipants(50)).to.emit(challenges, "MaxParticipantsUpdated").withArgs(100, 50);
(0, chai_1.expect)(await challenges.maxParticipants()).to.equal(50n);
await (0, chai_1.expect)(challenges.setMinBetAmount(hardhat_1.ethers.parseEther("150")))
.to.emit(challenges, "MinBetAmountUpdated")
.withArgs(MIN_BET_AMOUNT, hardhat_1.ethers.parseEther("150"));
(0, chai_1.expect)(await challenges.minBetAmount()).to.equal(hardhat_1.ethers.parseEther("150"));
});
it("allows admin to withdraw all funds from a pending challenge", async function () {
const { admin, alice, b3tr, roundGovernor, challenges } = await deployFixture();
await roundGovernor.setCurrentRoundId(1);
await createChallenge(challenges);
await (0, chai_1.expect)(challenges.withdraw(alice.address, STAKE_AMOUNT))
.to.emit(challenges, "AdminWithdrawal")
.withArgs(admin.address, alice.address, STAKE_AMOUNT);
(0, chai_1.expect)(await b3tr.balanceOf(await challenges.getAddress())).to.equal(0n);
(0, chai_1.expect)(await b3tr.balanceOf(alice.address)).to.equal(INITIAL_BALANCE + STAKE_AMOUNT);
});
it("reverts withdraw when amount exceeds contract balance", async function () {
const { admin, challenges } = await deployFixture();
await (0, chai_1.expect)(challenges.withdraw(admin.address, 1))
.to.be.revertedWithCustomError(challenges, "InsufficientWithdrawableFunds")
.withArgs(0, 1);
});
it("reverts settings setters with zero value", async function () {
const { challenges } = await deployFixture();
await (0, chai_1.expect)(challenges.setMaxChallengeDuration(0)).to.be