cyberia/research/events/ethereum/test/ReferralRewards.unit.test.ts

import { time } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";
import { ethers } from "hardhat";

const ZERO = "0x0000000000000000000000000000000000000000";

describe("ReferralRewards (unit)", () => {
  let snapshotId: string;

  beforeEach(async () => {
    snapshotId = await ethers.provider.send("evm_snapshot", []);
  });

  afterEach(async () => {
    await ethers.provider.send("evm_revert", [snapshotId]);
  });

  async function deployReferralRewards(
    rateSteps?: Array<{ refereeCount: number; rate: number }>,
  ) {
    const [admin, operator, a, b, c, d] = await ethers.getSigners();
    const token = await ethers.deployContract("MockUSDT");

    const ReferralRewards = await ethers.getContractFactory("ReferralRewards");
    const rr = await ReferralRewards.deploy(
      await token.getAddress(),
      await admin.getAddress(),
      await operator.getAddress(),
      10_000, // decimals (bps-like)
      300, // referral bonus: 3% of amount
      30 * 24 * 60 * 60, // seconds until inactive
      true, // only reward active uplines
      [6000, 3000, 1000], // 60/30/10 pool split
      rateSteps ?? [
        { refereeCount: 1, rate: 5000 },
        { refereeCount: 5, rate: 7500 },
        { refereeCount: 25, rate: 10_000 },
      ],
    );

    return { token, rr, admin, operator, a, b, c, d };
  }

  it("binds referrer once and increments directRefereeCount only once", async () => {
    const { rr, operator, a, b } = await deployReferralRewards();

    const aAddr = await a.getAddress();
    const bAddr = await b.getAddress();

    // First purchase binds b -> a.
    await rr.connect(operator).processPurchase(bAddr, aAddr, 1000);
    expect(await rr.referrerOf(bAddr)).to.equal(aAddr);
    expect(await rr.directRefereeCount(aAddr)).to.equal(1);

    // Second purchase with a different referrer hint should NOT change binding.
    await rr
      .connect(operator)
      .processPurchase(bAddr, await operator.getAddress(), 1000);
    expect(await rr.referrerOf(bAddr)).to.equal(aAddr);
    expect(await rr.directRefereeCount(aAddr)).to.equal(1);
  });

  it("does not bind self-referrals", async () => {
    const { rr, operator, a } = await deployReferralRewards();
    const aAddr = await a.getAddress();

    await rr.connect(operator).processPurchase(aAddr, aAddr, 1000);
    expect(await rr.referrerOf(aAddr)).to.equal(ZERO);
    expect(await rr.directRefereeCount(aAddr)).to.equal(0);
  });

  it("prevents short cycles within 3 levels", async () => {
    const { rr, operator, a, b, c } = await deployReferralRewards();

    const aAddr = await a.getAddress();
    const bAddr = await b.getAddress();
    const cAddr = await c.getAddress();

    // Bind a -> b, b -> c.
    await rr.connect(operator).processPurchase(aAddr, bAddr, 1000);
    await rr.connect(operator).processPurchase(bAddr, cAddr, 1000);

    // Attempt to bind c -> a would create cycle c->a->b->c (length 3), should be ignored.
    await rr.connect(operator).processPurchase(cAddr, aAddr, 1000);
    expect(await rr.referrerOf(cAddr)).to.equal(ZERO);
  });

  it("only rewards active uplines when onlyRewardActiveReferrers is enabled", async () => {
    const { rr, operator, a, b, c } = await deployReferralRewards([
      { refereeCount: 1, rate: 10_000 }, // 100% once >=1 direct referee
    ]);

    const aAddr = await a.getAddress();
    const bAddr = await b.getAddress();
    const cAddr = await c.getAddress();

    // A has not purchased yet -> inactive.
    await rr.connect(operator).processPurchase(bAddr, aAddr, 1000);

    // B is active (they purchased). A should still be inactive and thus not paid.
    const out1 = await rr
      .connect(operator)
      .processPurchase.staticCall(cAddr, bAddr, 1000);
    expect(out1.totalPaid).to.be.greaterThan(0);
    expect(out1.bonuses[1]).to.equal(0); // level-2 (A) should be 0 since inactive

    // Make A active by making them purchase.
    await rr.connect(operator).processPurchase(aAddr, ZERO, 1000);

    // Now C purchase should pay both B (L1) and A (L2).
    const out2 = await rr
      .connect(operator)
      .processPurchase.staticCall(cAddr, bAddr, 1000);
    expect(out2.bonuses[0]).to.be.greaterThan(0);
    expect(out2.bonuses[1]).to.be.greaterThan(0);
  });

  it("rateSteps gate rewards by directRefereeCount (referee count based)", async () => {
    // Require 2 direct referees to earn 100%.
    const { rr, operator, a, b, c } = await deployReferralRewards([
      { refereeCount: 2, rate: 10_000 },
    ]);

    const aAddr = await a.getAddress();
    const bAddr = await b.getAddress();
    const cAddr = await c.getAddress();

    // Make A active.
    await rr.connect(operator).processPurchase(aAddr, ZERO, 1000);

    // First direct referee: B binds to A, but A has only 1 direct referee (<2), so rate=0 => no bonus.
    const out1 = await rr
      .connect(operator)
      .processPurchase.staticCall(bAddr, aAddr, 1000);
    expect(out1.totalPaid).to.equal(0);

    await rr.connect(operator).processPurchase(bAddr, aAddr, 1000);
    expect(await rr.directRefereeCount(aAddr)).to.equal(1);

    // Second direct referee: C binds to A, now A has 2 referees => rate applies.
    await rr.connect(operator).processPurchase(cAddr, aAddr, 1000);
    expect(await rr.directRefereeCount(aAddr)).to.equal(2);

    const out2 = await rr
      .connect(operator)
      .processPurchase.staticCall(cAddr, aAddr, 1000);
    expect(out2.totalPaid).to.be.greaterThan(0);
  });

  it("stops rewarding after inactive window passes", async () => {
    const { rr, operator, a, b } = await deployReferralRewards([
      { refereeCount: 1, rate: 10_000 },
    ]);

    const aAddr = await a.getAddress();
    const bAddr = await b.getAddress();

    // Make A active and bind B -> A.
    await rr.connect(operator).processPurchase(aAddr, ZERO, 1000);
    await rr.connect(operator).processPurchase(bAddr, aAddr, 1000);

    // Advance time beyond the 30 day window; A becomes inactive.
    await time.increase(31 * 24 * 60 * 60);

    const out = await rr
      .connect(operator)
      .processPurchase.staticCall(bAddr, aAddr, 1000);
    // Still returns a recipient, but bonus should be 0 due to inactivity.
    expect(out.recipients[0]).to.equal(aAddr);
    expect(out.bonuses[0]).to.equal(0);
  });
});

Graph