diff --git a/package-lock.json b/package-lock.json index 0286ca2..17d6dfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5776,18 +5776,6 @@ "node": ">=0.10.0" } }, - "node_modules/amdefine": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==", - "dev": true, - "license": "BSD-3-Clause OR MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.4.2" - } - }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -14714,20 +14702,6 @@ "license": "MIT", "peer": true }, - "node_modules/sc-istanbul/node_modules/source-map": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", - "integrity": "sha512-CBdZ2oa/BHhS4xj5DlhjWNHcan57/5YuvfdLf17iVmIpd9KRm+DFLmC6nBNj+6Ua7Kt3TmOjDpQT1aTYOQtoUA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "amdefine": ">=0.0.4" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/sc-istanbul/node_modules/supports-color": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", @@ -16876,21 +16850,6 @@ "node": ">=8" } }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index e225996..587c2e3 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "next start", "lint": "eslint", "compile": "hardhat compile", + "test": "hardhat test", "deploy:lending": "hardhat run scripts/deploy-lending.ts --network arcTestnet", "db:start": "supabase start", "db:stop": "supabase stop", diff --git a/test/LendingBorrowing.test.ts b/test/LendingBorrowing.test.ts new file mode 100644 index 0000000..8ca93ae --- /dev/null +++ b/test/LendingBorrowing.test.ts @@ -0,0 +1,225 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import type { LendingBorrowing, TestnetERC20 } from "../typechain-types"; + +const DECIMALS = 8; +const unit = (n: number) => ethers.parseUnits(n.toString(), DECIMALS); + +async function deployFixture() { + const [owner, alice, bob] = await ethers.getSigners(); + + const TokenFactory = await ethers.getContractFactory("TestnetERC20"); + const collateralToken = (await TokenFactory.deploy("Collateral", "cBTC", DECIMALS)) as TestnetERC20; + const lendingToken = (await TokenFactory.deploy("Lending", "ARCT", DECIMALS)) as TestnetERC20; + + const collateralFactor = 50; // 50% + const LendingFactory = await ethers.getContractFactory("LendingBorrowing"); + const lending = (await LendingFactory.deploy( + await collateralToken.getAddress(), + await lendingToken.getAddress(), + collateralFactor, + )) as LendingBorrowing; + + // Fund the pool so takeLoan has liquidity to draw from. + await lendingToken.allocateTo(owner.address, unit(100_000)); + await lendingToken.connect(owner).approve(await lending.getAddress(), unit(100_000)); + await lending.connect(owner).fundPool(unit(100_000)); + + // Give Alice and Bob collateral tokens. + await collateralToken.allocateTo(alice.address, unit(1_000)); + await collateralToken.allocateTo(bob.address, unit(1_000)); + + return { lending, collateralToken, lendingToken, owner, alice, bob, collateralFactor }; +} + +describe("LendingBorrowing", () => { + describe("constructor", () => { + it("reverts on a zero collateral token address", async () => { + const [, , , ] = await ethers.getSigners(); + const TokenFactory = await ethers.getContractFactory("TestnetERC20"); + const lendingToken = await TokenFactory.deploy("Lending", "ARCT", DECIMALS); + const LendingFactory = await ethers.getContractFactory("LendingBorrowing"); + await expect( + LendingFactory.deploy(ethers.ZeroAddress, await lendingToken.getAddress(), 50), + ).to.be.revertedWith("Invalid collateral token"); + }); + + it("reverts on a zero lending token address", async () => { + const TokenFactory = await ethers.getContractFactory("TestnetERC20"); + const collateralToken = await TokenFactory.deploy("Collateral", "cBTC", DECIMALS); + const LendingFactory = await ethers.getContractFactory("LendingBorrowing"); + await expect( + LendingFactory.deploy(await collateralToken.getAddress(), ethers.ZeroAddress, 50), + ).to.be.revertedWith("Invalid lending token"); + }); + + it("reverts when the collateral factor is out of the 1-100 range", async () => { + const TokenFactory = await ethers.getContractFactory("TestnetERC20"); + const collateralToken = await TokenFactory.deploy("Collateral", "cBTC", DECIMALS); + const lendingToken = await TokenFactory.deploy("Lending", "ARCT", DECIMALS); + const LendingFactory = await ethers.getContractFactory("LendingBorrowing"); + await expect( + LendingFactory.deploy(await collateralToken.getAddress(), await lendingToken.getAddress(), 0), + ).to.be.revertedWith("Factor must be 1-100"); + await expect( + LendingFactory.deploy(await collateralToken.getAddress(), await lendingToken.getAddress(), 101), + ).to.be.revertedWith("Factor must be 1-100"); + }); + }); + + describe("setCollateralFactor", () => { + it("allows the owner to update the factor within 1-100", async () => { + const { lending, owner } = await loadFixture(deployFixture); + await expect(lending.connect(owner).setCollateralFactor(75)) + .to.emit(lending, "CollateralFactorUpdated") + .withArgs(75); + expect(await lending.collateralFactor()).to.equal(75); + }); + + it("reverts for factor values outside 1-100", async () => { + const { lending, owner } = await loadFixture(deployFixture); + await expect(lending.connect(owner).setCollateralFactor(0)).to.be.revertedWith( + "Factor must be 1-100", + ); + await expect(lending.connect(owner).setCollateralFactor(101)).to.be.revertedWith( + "Factor must be 1-100", + ); + }); + + it("reverts when called by a non-owner", async () => { + const { lending, alice } = await loadFixture(deployFixture); + // OpenZeppelin Contracts v4 Ownable reverts with a string, not a custom error (that's v5). + await expect(lending.connect(alice).setCollateralFactor(60)).to.be.revertedWith( + "Ownable: caller is not the owner", + ); + }); + }); + + describe("takeLoan", () => { + it("allows borrowing up to exactly the collateral-factor limit", async () => { + const { lending, collateralToken, alice } = await loadFixture(deployFixture); + await collateralToken.connect(alice).approve(await lending.getAddress(), unit(100)); + await lending.connect(alice).depositCollateral(unit(100)); + + const maxBorrow = await lending.maxBorrow(alice.address); + expect(maxBorrow).to.equal(unit(50)); // 50% collateral factor + + await expect(lending.connect(alice).takeLoan(maxBorrow)).to.emit(lending, "LoanTaken"); + }); + + it("reverts when the requested amount exceeds maxBorrow", async () => { + const { lending, collateralToken, alice } = await loadFixture(deployFixture); + await collateralToken.connect(alice).approve(await lending.getAddress(), unit(100)); + await lending.connect(alice).depositCollateral(unit(100)); + + const maxBorrow = await lending.maxBorrow(alice.address); + await expect( + lending.connect(alice).takeLoan(maxBorrow + 1n), + ).to.be.revertedWith("Exceeds borrow limit"); + }); + + it("reverts when the user already has an active loan", async () => { + const { lending, collateralToken, alice } = await loadFixture(deployFixture); + await collateralToken.connect(alice).approve(await lending.getAddress(), unit(100)); + await lending.connect(alice).depositCollateral(unit(100)); + await lending.connect(alice).takeLoan(unit(10)); + + await expect(lending.connect(alice).takeLoan(unit(1))).to.be.revertedWith( + "Repay existing loan first", + ); + }); + + it("reverts when the pool lacks sufficient liquidity", async () => { + const { lending, collateralToken, alice } = await loadFixture(deployFixture); + const poolLiquidity = await lending.poolLiquidity(); + + // Mint Alice enough collateral that 50% of it exceeds the pool's balance. + const bigCollateral = poolLiquidity * 3n; + await collateralToken.allocateTo(alice.address, bigCollateral); + await collateralToken.connect(alice).approve(await lending.getAddress(), bigCollateral); + await lending.connect(alice).depositCollateral(bigCollateral); + + const maxBorrow = await lending.maxBorrow(alice.address); + expect(maxBorrow).to.be.gt(poolLiquidity); + await expect(lending.connect(alice).takeLoan(maxBorrow)).to.be.revertedWith( + "Insufficient pool liquidity", + ); + }); + }); + + describe("repayLoan", () => { + it("reverts when repaying more than the outstanding loan amount", async () => { + const { lending, collateralToken, lendingToken, alice } = await loadFixture(deployFixture); + await collateralToken.connect(alice).approve(await lending.getAddress(), unit(100)); + await lending.connect(alice).depositCollateral(unit(100)); + await lending.connect(alice).takeLoan(unit(10)); + + await lendingToken.connect(alice).approve(await lending.getAddress(), unit(20)); + await expect(lending.connect(alice).repayLoan(unit(11))).to.be.revertedWith( + "Amount exceeds outstanding loan", + ); + }); + + it("reverts when there is no active loan", async () => { + const { lending, lendingToken, alice } = await loadFixture(deployFixture); + await lendingToken.connect(alice).approve(await lending.getAddress(), unit(1)); + await expect(lending.connect(alice).repayLoan(unit(1))).to.be.revertedWith("No active loan"); + }); + + it("clears the loan and unlocks collateral on full repayment", async () => { + const { lending, collateralToken, lendingToken, alice } = await loadFixture(deployFixture); + await collateralToken.connect(alice).approve(await lending.getAddress(), unit(100)); + await lending.connect(alice).depositCollateral(unit(100)); + await lending.connect(alice).takeLoan(unit(50)); + + await lendingToken.connect(alice).approve(await lending.getAddress(), unit(50)); + await lending.connect(alice).repayLoan(unit(50)); + + const loan = await lending.loans(alice.address); + expect(loan.isActive).to.equal(false); + expect(await lending.availableCollateral(alice.address)).to.equal(unit(100)); + }); + + it("keeps collateral locked after a partial repayment", async () => { + const { lending, collateralToken, lendingToken, alice } = await loadFixture(deployFixture); + await collateralToken.connect(alice).approve(await lending.getAddress(), unit(100)); + await lending.connect(alice).depositCollateral(unit(100)); + await lending.connect(alice).takeLoan(unit(50)); + + await lendingToken.connect(alice).approve(await lending.getAddress(), unit(20)); + await lending.connect(alice).repayLoan(unit(20)); + + const loan = await lending.loans(alice.address); + expect(loan.isActive).to.equal(true); + expect(loan.amount).to.equal(unit(30)); + // Full collateral is still locked until the loan is fully repaid. + expect(await lending.availableCollateral(alice.address)).to.equal(0n); + }); + }); + + describe("withdrawCollateral", () => { + it("reverts when withdrawing more than the available (unlocked) collateral", async () => { + const { lending, collateralToken, alice } = await loadFixture(deployFixture); + await collateralToken.connect(alice).approve(await lending.getAddress(), unit(100)); + await lending.connect(alice).depositCollateral(unit(100)); + await lending.connect(alice).takeLoan(unit(50)); + + // All 100 is locked by the active loan; nothing should be withdrawable. + await expect(lending.connect(alice).withdrawCollateral(unit(1))).to.be.revertedWith( + "Insufficient available collateral", + ); + }); + + it("allows withdrawing unlocked collateral when there is no active loan", async () => { + const { lending, collateralToken, alice } = await loadFixture(deployFixture); + await collateralToken.connect(alice).approve(await lending.getAddress(), unit(100)); + await lending.connect(alice).depositCollateral(unit(100)); + + await expect(lending.connect(alice).withdrawCollateral(unit(40))) + .to.emit(lending, "CollateralWithdrawn") + .withArgs(alice.address, unit(40)); + expect(await lending.availableCollateral(alice.address)).to.equal(unit(60)); + }); + }); +});