diff --git a/packages/hardhat/contracts/RentToOwn.sol b/packages/hardhat/contracts/RentToOwn.sol index bfe18e6..8dd6171 100644 --- a/packages/hardhat/contracts/RentToOwn.sol +++ b/packages/hardhat/contracts/RentToOwn.sol @@ -2,10 +2,17 @@ pragma solidity ^0.8.19; import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; -import "./security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import "@openzeppelin/contracts/utils/Address.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; +import "./security/ReentrancyGuard.sol"; contract RentToOwn is ReentrancyGuard, Ownable { + using Address for address; + using SafeERC20 for IERC20; + struct Agreement { address borrower; address lender; @@ -18,7 +25,16 @@ contract RentToOwn is ReentrancyGuard, Ownable { bool isActive; } + struct AirdropClaim { + address tokenContract; + uint256 amount; + bytes32 merkleRoot; + uint256 expiryTime; + } + mapping(uint256 => Agreement) public agreements; + mapping(bytes32 => AirdropClaim) public airdrops; + mapping(uint256 => mapping(bytes32 => bool)) public claimedAirdrops; uint256 public agreementCounter; event AgreementCreated( @@ -32,6 +48,20 @@ contract RentToOwn is ReentrancyGuard, Ownable { event PaymentMade(uint256 agreementId, uint256 amount, uint256 remaining); event AgreementCompleted(uint256 agreementId, address newOwner); event AgreementDefaulted(uint256 agreementId); + event AirdropRegistered( + bytes32 indexed airdropId, + address tokenContract, + uint256 amount, + bytes32 merkleRoot + ); + event AirdropClaimed( + uint256 indexed agreementId, + address indexed borrower, + uint256 nftId, + address tokenContract, + uint256 amount, + bytes32 indexed airdropId + ); constructor(address initialOwner) Ownable(initialOwner) {} @@ -142,7 +172,7 @@ contract RentToOwn is ReentrancyGuard, Ownable { // Transfer NFT back to lender IERC721(agreement.nftContract).transferFrom( - agreement.borrower, + address(this), agreement.lender, agreement.nftId ); @@ -151,9 +181,78 @@ contract RentToOwn is ReentrancyGuard, Ownable { emit AgreementDefaulted(_agreementId); } - // View remaining balance + // Register new airdrop + function registerAirdrop( + address tokenContract, + uint256 amount, + bytes32 merkleRoot, + uint256 duration + ) external onlyOwner { + bytes32 airdropId = keccak256(abi.encodePacked( + tokenContract, + amount, + merkleRoot, + block.timestamp + )); + + airdrops[airdropId] = AirdropClaim({ + tokenContract: tokenContract, + amount: amount, + merkleRoot: merkleRoot, + expiryTime: block.timestamp + duration + }); + + emit AirdropRegistered(airdropId, tokenContract, amount, merkleRoot); + } + + // Claim airdrop + function claimAirdrop( + uint256 agreementId, + bytes32 airdropId, + bytes32[] calldata merkleProof + ) external nonReentrant { + Agreement storage agreement = agreements[agreementId]; + AirdropClaim storage airdrop = airdrops[airdropId]; + + require(agreement.isActive, "Agreement not active"); + require(agreement.borrower == msg.sender, "Not the borrower"); + require(!claimedAirdrops[agreementId][airdropId], "Airdrop already claimed"); + require(block.timestamp <= airdrop.expiryTime, "Airdrop expired"); + + // Verify merkle proof + bytes32 leaf = keccak256(abi.encodePacked(agreementId, msg.sender)); + require( + MerkleProof.verify(merkleProof, airdrop.merkleRoot, leaf), + "Invalid merkle proof" + ); + + // Mark as claimed + claimedAirdrops[agreementId][airdropId] = true; + + // Transfer tokens + IERC20(airdrop.tokenContract).safeTransfer(msg.sender, airdrop.amount); + + emit AirdropClaimed( + agreementId, + msg.sender, + agreement.nftId, + airdrop.tokenContract, + airdrop.amount, + airdropId + ); + } + + // Helper functions function getRemainingBalance(uint256 _agreementId) external view returns (uint256) { Agreement storage agreement = agreements[_agreementId]; return agreement.totalPrice - agreement.totalPaid; } + + function isAirdropClaimed(uint256 agreementId, bytes32 airdropId) + external + view + returns (bool) + { + return claimedAirdrops[agreementId][airdropId]; + } } diff --git a/packages/hardhat/contracts/test/MockER20.sol b/packages/hardhat/contracts/test/MockER20.sol new file mode 100644 index 0000000..3600e03 --- /dev/null +++ b/packages/hardhat/contracts/test/MockER20.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) { + _mint(msg.sender, 1000000 * 10 ** 18); + } +} diff --git a/packages/hardhat/test/RentToOwn.ts b/packages/hardhat/test/RentToOwn.ts index 306a246..27b66fe 100644 --- a/packages/hardhat/test/RentToOwn.ts +++ b/packages/hardhat/test/RentToOwn.ts @@ -2,12 +2,14 @@ import { expect } from "chai"; import { ethers } from "hardhat"; import { MyNFT, RentToOwn } from "../typechain-types"; import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { ContractTransactionReceipt, EventLog } from "ethers"; describe("RentToOwn", function () { let rentToOwn: RentToOwn; let rentToOwnAddress: any; let myNFT: MyNFT; let myNFTAddress: any; + let mockToken: any; let lender: SignerWithAddress; let borrower: SignerWithAddress; let tokenId: any; //BigNumberish //import { BigNumberish } from "ethers"; @@ -95,4 +97,70 @@ describe("RentToOwn", function () { ).to.be.revertedWith("Payment is late"); }); }); + + describe("Airdrop functionality", function () { + let merkleRoot: string; + let merkleProof: string[]; + let airdropId: string; + + beforeEach(async function () { + // Deploy mock ERC20 token for airdrop testing + const MockERC20Factory = await ethers.getContractFactory("MockERC20"); + mockToken = await MockERC20Factory.deploy("Mock Token", "MTK"); + await mockToken.waitForDeployment(); + + // Setup existing agreement like in other tests + await myNFT.connect(lender).approve(rentToOwnAddress, tokenId); + await rentToOwn.connect(lender).listNFT(myNFTAddress, tokenId, monthlyPayment, 12 as any); + await rentToOwn.connect(borrower).startAgreement(0 as any, { value: monthlyPayment } as any); + + // Create merkle root and proof (simplified for testing) + const agreementId = 0n; + const leaf = ethers.keccak256(ethers.solidityPacked(["uint256", "address"], [agreementId, borrower.address])); + merkleRoot = leaf; + merkleProof = []; + + // Register airdrop + const amount = ethers.parseEther("100"); + const duration = 86400n; + const tx = await rentToOwn + .connect(lender) + .registerAirdrop(await mockToken.getAddress(), amount as any, merkleRoot as any, duration as any); + + const receipt = (await tx.wait()) as ContractTransactionReceipt; + if (!receipt) throw new Error("No receipt available"); + + const registeredEvent = receipt.logs[0] as EventLog; + if (!registeredEvent) throw new Error("AirdropRegistered event not found"); + airdropId = registeredEvent.args[0]; + + // Fund contract with airdrop tokens + await mockToken.transfer(rentToOwnAddress, amount); + }); + + it("Should register new airdrop", async function () { + const airdrop = await rentToOwn.airdrops(airdropId as any); + expect(airdrop.tokenContract).to.equal(await mockToken.getAddress()); + }); + + it("Should allow borrower to claim airdrop", async function () { + const initialBalance = await mockToken.balanceOf(borrower.address); + await rentToOwn.connect(borrower).claimAirdrop(0n as any, airdropId as any, merkleProof as any); + const finalBalance = await mockToken.balanceOf(borrower.address); + expect(finalBalance).to.be.gt(initialBalance); + }); + + it("Should prevent double claiming", async function () { + await rentToOwn.connect(borrower).claimAirdrop(0n as any, airdropId as any, merkleProof as any); + await expect( + rentToOwn.connect(borrower).claimAirdrop(0n as any, airdropId as any, merkleProof as any), + ).to.be.revertedWith("Airdrop already claimed"); + }); + + it("Should prevent non-borrowers from claiming", async function () { + await expect( + rentToOwn.connect(lender).claimAirdrop(0n as any, airdropId as any, merkleProof as any), + ).to.be.revertedWith("Not the borrower"); + }); + }); }); diff --git a/packages/nextjs/app/borrow/page.tsx b/packages/nextjs/app/borrow/page.tsx index 538bb52..fa6524b 100644 --- a/packages/nextjs/app/borrow/page.tsx +++ b/packages/nextjs/app/borrow/page.tsx @@ -84,7 +84,7 @@ export default function BorrowPage() {
Loading agreements...
} diff --git a/packages/nextjs/app/layout.tsx b/packages/nextjs/app/layout.tsx index b8d2d47..9d6808f 100644 --- a/packages/nextjs/app/layout.tsx +++ b/packages/nextjs/app/layout.tsx @@ -4,7 +4,7 @@ import { ThemeProvider } from "~~/components/ThemeProvider"; import "~~/styles/globals.css"; import { getMetadata } from "~~/utils/scaffold-eth/getMetadata"; -export const metadata = getMetadata({ title: "Scaffold-ETH 2 App", description: "Built with 🏗 Scaffold-ETH 2" }); +export const metadata = getMetadata({ title: "Rent2Own", description: "Rent NFTs" }); const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => { return (