From 1458d81569db29d506d70f5d17455197b28f8958 Mon Sep 17 00:00:00 2001 From: Aurora Poppyseed <30662672+poppyseedDev@users.noreply.github.com> Date: Mon, 23 Dec 2024 02:19:53 +0900 Subject: [PATCH] feat: added Blind Auctions contract to the dApps with tests (#16) * blind auction tests work with one error * feat: fix missing edge case in select and use euint256 for tickets (#17) * remove one comment --------- Co-authored-by: jat <153528475+jatZama@users.noreply.github.com> --- hardhat/contracts/auctions/BlindAuction.sol | 244 ++++++++++++++++++ .../auctions/MyConfidentialERC20.sol | 46 ++++ .../test/blindAuction/BlindAuction.fixture.ts | 18 ++ hardhat/test/blindAuction/BlindAuction.ts | 164 ++++++++++++ .../blindAuction/ConfidentialERC20.fixture.ts | 14 + hardhat/test/fhevmjsMocked.ts | 6 +- 6 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 hardhat/contracts/auctions/BlindAuction.sol create mode 100644 hardhat/contracts/auctions/MyConfidentialERC20.sol create mode 100644 hardhat/test/blindAuction/BlindAuction.fixture.ts create mode 100644 hardhat/test/blindAuction/BlindAuction.ts create mode 100644 hardhat/test/blindAuction/ConfidentialERC20.fixture.ts diff --git a/hardhat/contracts/auctions/BlindAuction.sol b/hardhat/contracts/auctions/BlindAuction.sol new file mode 100644 index 0000000..8291210 --- /dev/null +++ b/hardhat/contracts/auctions/BlindAuction.sol @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.24; + +import "fhevm/lib/TFHE.sol"; +import { ConfidentialERC20 } from "fhevm-contracts/contracts/token/ERC20/ConfidentialERC20.sol"; +import "@openzeppelin/contracts/access/Ownable2Step.sol"; +import "fhevm/config/ZamaFHEVMConfig.sol"; +import "fhevm/config/ZamaGatewayConfig.sol"; +import "fhevm/gateway/GatewayCaller.sol"; + +/// @notice Main contract for the blind auction +contract BlindAuction is SepoliaZamaFHEVMConfig, SepoliaZamaGatewayConfig, GatewayCaller, Ownable2Step { + /// @notice Auction end time + uint256 public endTime; + + /// @notice Address of the beneficiary + address public beneficiary; + + /// @notice Current highest bid + euint64 private highestBid; + + /// @notice Ticket corresponding to the highest bid + /// @dev Used during reencryption to know if a user has won the bid + euint256 private winningTicket; + + /// @notice Decryption of winningTicket + /// @dev Can be requested by anyone after auction ends + uint256 private decryptedWinningTicket; + + /// @notice Ticket randomly sampled for each user + mapping(address account => euint256 ticket) private userTickets; + + /// @notice Mapping from bidder to their bid value + mapping(address account => euint64 bidAmount) private bids; + + /// @notice Number of bids + uint256 public bidCounter; + + /// @notice The token contract used for encrypted bids + ConfidentialERC20 public tokenContract; + + /// @notice Flag indicating whether the auction object has been claimed + /// @dev WARNING : If there is a draw, only the first highest bidder will get the prize + /// An improved implementation could handle this case differently + ebool private objectClaimed; + + /// @notice Flag to check if the token has been transferred to the beneficiary + bool public tokenTransferred; + + /// @notice Flag to determine if the auction can be stopped manually + bool public stoppable; + + /// @notice Flag to check if the auction has been manually stopped + bool public manuallyStopped = false; + + /// @notice Error thrown when a function is called too early + /// @dev Includes the time when the function can be called + error TooEarly(uint256 time); + + /// @notice Error thrown when a function is called too late + /// @dev Includes the time after which the function cannot be called + error TooLate(uint256 time); + + /// @notice Constructor to initialize the auction + /// @param _beneficiary Address of the beneficiary who will receive the highest bid + /// @param _tokenContract Address of the ConfidentialERC20 token contract used for bidding + /// @param biddingTime Duration of the auction in seconds + /// @param isStoppable Flag to determine if the auction can be stopped manually + constructor( + address _beneficiary, + ConfidentialERC20 _tokenContract, + uint256 biddingTime, + bool isStoppable + ) Ownable(msg.sender) { + // TFHE.setFHEVM(FHEVMConfig.defaultConfig()); + // Gateway.setGateway(GatewayConfig.defaultGatewayContract()); + beneficiary = _beneficiary; + tokenContract = _tokenContract; + endTime = block.timestamp + biddingTime; + objectClaimed = TFHE.asEbool(false); + TFHE.allowThis(objectClaimed); + tokenTransferred = false; + bidCounter = 0; + stoppable = isStoppable; + } + + /// @notice Submit a bid with an encrypted value + /// @dev Transfers tokens from the bidder to the contract + /// @param encryptedValue The encrypted bid amount + /// @param inputProof Proof for the encrypted input + function bid(einput encryptedValue, bytes calldata inputProof) external onlyBeforeEnd { + euint64 value = TFHE.asEuint64(encryptedValue, inputProof); + euint64 existingBid = bids[msg.sender]; + euint64 sentBalance; + if (TFHE.isInitialized(existingBid)) { + euint64 balanceBefore = tokenContract.balanceOf(address(this)); + ebool isHigher = TFHE.lt(existingBid, value); + euint64 toTransfer = TFHE.sub(value, existingBid); + + // Transfer only if bid is higher, also to avoid overflow from previous line + euint64 amount = TFHE.select(isHigher, toTransfer, TFHE.asEuint64(0)); + TFHE.allowTransient(amount, address(tokenContract)); + tokenContract.transferFrom(msg.sender, address(this), amount); + + euint64 balanceAfter = tokenContract.balanceOf(address(this)); + sentBalance = TFHE.sub(balanceAfter, balanceBefore); + euint64 newBid = TFHE.add(existingBid, sentBalance); + bids[msg.sender] = newBid; + } else { + bidCounter++; + euint64 balanceBefore = tokenContract.balanceOf(address(this)); + TFHE.allowTransient(value, address(tokenContract)); + tokenContract.transferFrom(msg.sender, address(this), value); + euint64 balanceAfter = tokenContract.balanceOf(address(this)); + sentBalance = TFHE.sub(balanceAfter, balanceBefore); + bids[msg.sender] = sentBalance; + } + euint64 currentBid = bids[msg.sender]; + TFHE.allowThis(currentBid); + TFHE.allow(currentBid, msg.sender); + + euint256 randTicket = TFHE.randEuint256(); + euint256 userTicket; + if (TFHE.isInitialized(highestBid)) { + if (TFHE.isInitialized(userTickets[msg.sender])) { + userTicket = TFHE.select(TFHE.ne(sentBalance, 0), randTicket, userTickets[msg.sender]); // don't update ticket if sentBalance is null (or else winner sending an additional zero bid would lose the prize) + } else { + userTicket = TFHE.select(TFHE.ne(sentBalance, 0), randTicket, TFHE.asEuint256(0)); + } + } else { + userTicket = randTicket; + } + userTickets[msg.sender] = userTicket; + + if (!TFHE.isInitialized(highestBid)) { + highestBid = currentBid; + winningTicket = userTicket; + } else { + ebool isNewWinner = TFHE.lt(highestBid, currentBid); + highestBid = TFHE.select(isNewWinner, currentBid, highestBid); + winningTicket = TFHE.select(isNewWinner, userTicket, winningTicket); + } + TFHE.allowThis(highestBid); + TFHE.allowThis(winningTicket); + TFHE.allowThis(userTicket); + TFHE.allow(userTicket, msg.sender); + } + + /// @notice Get the encrypted bid of a specific account + /// @dev Can be used in a reencryption request + /// @param account The address of the bidder + /// @return The encrypted bid amount + function getBid(address account) external view returns (euint64) { + return bids[account]; + } + + /// @notice Manually stop the auction + /// @dev Can only be called by the owner and if the auction is stoppable + function stop() external onlyOwner { + require(stoppable); + manuallyStopped = true; + } + + /// @notice Get the encrypted ticket of a specific account + /// @dev Can be used in a reencryption request + /// @param account The address of the bidder + /// @return The encrypted ticket + function ticketUser(address account) external view returns (euint256) { + return userTickets[account]; + } + + /// @notice Initiate the decryption of the winning ticket + /// @dev Can only be called after the auction ends + function decryptWinningTicket() public onlyAfterEnd { + uint256[] memory cts = new uint256[](1); + cts[0] = Gateway.toUint256(winningTicket); + Gateway.requestDecryption(cts, this.setDecryptedWinningTicket.selector, 0, block.timestamp + 100, false); + } + + /// @notice Callback function to set the decrypted winning ticket + /// @dev Can only be called by the Gateway + /// @param resultDecryption The decrypted winning ticket + function setDecryptedWinningTicket(uint256, uint256 resultDecryption) public onlyGateway { + decryptedWinningTicket = resultDecryption; + } + + /// @notice Get the decrypted winning ticket + /// @dev Can only be called after the winning ticket has been decrypted - if `userTickets[account]` is an encryption of decryptedWinningTicket, then `account` won and can call `claim` succesfully + /// @return The decrypted winning ticket + function getDecryptedWinningTicket() external view returns (uint256) { + require(decryptedWinningTicket != 0, "Winning ticket has not been decrypted yet"); + return decryptedWinningTicket; + } + + /// @notice Claim the auction object + /// @dev Succeeds only if the caller was the first to get the highest bid + function claim() public onlyAfterEnd { + ebool canClaim = TFHE.and(TFHE.eq(winningTicket, userTickets[msg.sender]), TFHE.not(objectClaimed)); + objectClaimed = TFHE.or(canClaim, objectClaimed); + TFHE.allowThis(objectClaimed); + euint64 newBid = TFHE.select(canClaim, TFHE.asEuint64(0), bids[msg.sender]); + bids[msg.sender] = newBid; + TFHE.allowThis(bids[msg.sender]); + TFHE.allow(bids[msg.sender], msg.sender); + } + + /// @notice Transfer the highest bid to the beneficiary + /// @dev Can only be called once after the auction ends + function auctionEnd() public onlyAfterEnd { + require(!tokenTransferred); + tokenTransferred = true; + TFHE.allowTransient(highestBid, address(tokenContract)); + tokenContract.transfer(beneficiary, highestBid); + } + + /// @notice Withdraw a bid from the auction + /// @dev Can only be called after the auction ends and by non-winning bidders + function withdraw() public onlyAfterEnd { + euint64 bidValue = bids[msg.sender]; + ebool canWithdraw = TFHE.ne(winningTicket, userTickets[msg.sender]); + euint64 amount = TFHE.select(canWithdraw, bidValue, TFHE.asEuint64(0)); + TFHE.allowTransient(amount, address(tokenContract)); + tokenContract.transfer(msg.sender, amount); + euint64 newBid = TFHE.select(canWithdraw, TFHE.asEuint64(0), bids[msg.sender]); + bids[msg.sender] = newBid; + TFHE.allowThis(newBid); + TFHE.allow(newBid, msg.sender); + } + + /// @notice Modifier to ensure function is called before auction ends + /// @dev Reverts if called after the auction end time or if manually stopped + modifier onlyBeforeEnd() { + if (block.timestamp >= endTime || manuallyStopped == true) revert TooLate(endTime); + _; + } + + /// @notice Modifier to ensure function is called after auction ends + /// @dev Reverts if called before the auction end time and not manually stopped + modifier onlyAfterEnd() { + if (block.timestamp < endTime && manuallyStopped == false) revert TooEarly(endTime); + _; + } +} diff --git a/hardhat/contracts/auctions/MyConfidentialERC20.sol b/hardhat/contracts/auctions/MyConfidentialERC20.sol new file mode 100644 index 0000000..2e9a77b --- /dev/null +++ b/hardhat/contracts/auctions/MyConfidentialERC20.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.24; + +import "fhevm/lib/TFHE.sol"; +import "fhevm/config/ZamaFHEVMConfig.sol"; +import "fhevm/config/ZamaGatewayConfig.sol"; +import "fhevm/gateway/GatewayCaller.sol"; +import "fhevm-contracts/contracts/token/ERC20/extensions/ConfidentialERC20Mintable.sol"; + +/// @notice This contract implements an encrypted ERC20-like token with confidential balances using Zama's FHE library. +/// @dev It supports typical ERC20 functionality such as transferring tokens, minting, and setting allowances, +/// @dev but uses encrypted data types. +contract BlindAuctionConfidentialERC20 is + SepoliaZamaFHEVMConfig, + SepoliaZamaGatewayConfig, + GatewayCaller, + ConfidentialERC20Mintable +{ + // @note `SECRET` is not so secret, since it is trivially encrypted and just to have a decryption test + euint64 internal immutable SECRET; + + // @note `revealedSecret` will hold the decrypted result once the Gateway will relay the decryption of `SECRET` + uint64 public revealedSecret; + + /// @notice Constructor to initialize the token's name and symbol, and set up the owner + /// @param name_ The name of the token + /// @param symbol_ The symbol of the token + constructor(string memory name_, string memory symbol_) ConfidentialERC20Mintable(name_, symbol_, msg.sender) { + SECRET = TFHE.asEuint64(42); + TFHE.allowThis(SECRET); + } + + /// @notice Request decryption of `SECRET` + function requestSecret() public { + uint256[] memory cts = new uint256[](1); + cts[0] = Gateway.toUint256(SECRET); + Gateway.requestDecryption(cts, this.callbackSecret.selector, 0, block.timestamp + 100, false); + } + + /// @notice Callback function for `SECRET` decryption + /// @param `decryptedValue` The decrypted 64-bit unsigned integer + function callbackSecret(uint256, uint64 decryptedValue) public onlyGateway { + revealedSecret = decryptedValue; + } +} diff --git a/hardhat/test/blindAuction/BlindAuction.fixture.ts b/hardhat/test/blindAuction/BlindAuction.fixture.ts new file mode 100644 index 0000000..9aa1421 --- /dev/null +++ b/hardhat/test/blindAuction/BlindAuction.fixture.ts @@ -0,0 +1,18 @@ +import { AddressLike, BigNumberish, Signer } from "ethers"; +import { ethers } from "hardhat"; + +import type { BlindAuction } from "../../types"; + +export async function deployBlindAuctionFixture( + account: Signer, + tokenContract: AddressLike, + biddingTime: BigNumberish, + isStoppable: boolean, +): Promise { + const contractFactory = await ethers.getContractFactory("BlindAuction"); + const contract = await contractFactory + .connect(account) + .deploy(account.getAddress(), tokenContract, biddingTime, isStoppable); + await contract.waitForDeployment(); + return contract; +} diff --git a/hardhat/test/blindAuction/BlindAuction.ts b/hardhat/test/blindAuction/BlindAuction.ts new file mode 100644 index 0000000..688e4c5 --- /dev/null +++ b/hardhat/test/blindAuction/BlindAuction.ts @@ -0,0 +1,164 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { awaitAllDecryptionResults, initGateway } from "../asyncDecrypt"; +import { createInstance } from "../instance"; +import { reencryptEuint64, reencryptEuint256 } from "../reencrypt"; +import { getSigners, initSigners } from "../signers"; +import { deployBlindAuctionFixture } from "./BlindAuction.fixture"; +import { deployConfidentialERC20Fixture } from "./ConfidentialERC20.fixture"; + +describe("BlindAuction", function () { + before(async function () { + await initSigners(); + this.signers = await getSigners(); + await initGateway(); + }); + + beforeEach(async function () { + // Deploy ERC20 contract with Alice account + const contractErc20 = await deployConfidentialERC20Fixture(); + this.contractERC20Address = await contractErc20.getAddress(); + this.erc20 = contractErc20; + this.instance = await createInstance(); + + // Mint with Alice account + const tx1 = await this.erc20.mint(this.signers.alice, 1000); + tx1.wait(); + + // Transfer 100 tokens to Bob + const input = this.instance.createEncryptedInput(this.contractERC20Address, this.signers.alice.address); + input.add64(100); + const encryptedTransferAmount = await input.encrypt(); + const tx = await this.erc20["transfer(address,bytes32,bytes)"]( + this.signers.bob.address, + encryptedTransferAmount.handles[0], + encryptedTransferAmount.inputProof, + ); + + // Transfer 100 tokens to Carol + const tx2 = await this.erc20["transfer(address,bytes32,bytes)"]( + this.signers.carol.address, + encryptedTransferAmount.handles[0], + encryptedTransferAmount.inputProof, + ); + await Promise.all([tx.wait(), tx2.wait()]); + + // Deploy blind auction + const blindAuctionContract = await deployBlindAuctionFixture( + this.signers.alice, + this.contractERC20Address, + 1000000, + true, + ); + + this.contractAddress = await blindAuctionContract.getAddress(); + this.blindAuction = blindAuctionContract; + }); + + it.only("should check Carol won the bid", async function () { + // Create encrypted bid amounts + const input1 = this.instance.createEncryptedInput(this.contractERC20Address, this.signers.bob.address); + input1.add64(10); + const bobBidAmount = await input1.encrypt(); + + const input2 = this.instance.createEncryptedInput(this.contractERC20Address, this.signers.carol.address); + input2.add64(20); + const carolBidAmount = await input2.encrypt(); + + // Approve auction contract to spend tokens + const txBobApprove = await this.erc20 + .connect(this.signers.bob) + ["approve(address,bytes32,bytes)"](this.contractAddress, bobBidAmount.handles[0], bobBidAmount.inputProof); + const txCarolApprove = await this.erc20 + .connect(this.signers.carol) + ["approve(address,bytes32,bytes)"](this.contractAddress, carolBidAmount.handles[0], carolBidAmount.inputProof); + await Promise.all([txBobApprove.wait(), txCarolApprove.wait()]); + + // Submit bids + // Need to add gasLimit to avoid a gas limit issue for two parallel bids + // When two tx are consecutive in the same block, if the similar second is asking more gas the tx will fail + // because the allocated gas will be the first one gas amount. + // This is typically the case for the bid method and the if, else branch inside, i.e. the first bid has no further computation + // concerning the highestBid but all the following need to check against the current one. + + // part 1 + const input3 = this.instance.createEncryptedInput(this.contractAddress, this.signers.bob.address); + input3.add64(10); + const bobBidAmount_auction = await input3.encrypt(); + + const txBobBid = await this.blindAuction + .connect(this.signers.bob) + .bid(bobBidAmount_auction.handles[0], bobBidAmount_auction.inputProof, { gasLimit: 5000000 }); + txBobBid.wait(); + + // part 2 + const input4 = this.instance.createEncryptedInput(this.contractAddress, this.signers.carol.address); + input4.add64(20); + const carolBidAmount_auction = await input4.encrypt(); + + const txCarolBid = await this.blindAuction + .connect(this.signers.carol) + .bid(carolBidAmount_auction.handles[0], carolBidAmount_auction.inputProof, { gasLimit: 5000000 }); + txCarolBid.wait(); + + // Stop auction and verify results + const txAliceStop = await this.blindAuction.connect(this.signers.alice).stop(); + await txAliceStop.wait(); + + // Get and verify bids + const bobBidHandle = await this.blindAuction.getBid(this.signers.bob.address); + const bobBidDecrypted = await reencryptEuint64(this.signers.bob, this.instance, bobBidHandle, this.contractAddress); + expect(bobBidDecrypted).to.equal(10); + + const carolBidHandle = await this.blindAuction.getBid(this.signers.carol.address); + const carolBidDecrypted = await reencryptEuint64( + this.signers.carol, + this.instance, + carolBidHandle, + this.contractAddress, + ); + expect(carolBidDecrypted).to.equal(20); + + const bobTicketHandle = await this.blindAuction.ticketUser(this.signers.bob.address); + const bobTicketDecrypted = await reencryptEuint256( + this.signers.bob, + this.instance, + bobTicketHandle, + this.contractAddress, + ); + expect(bobTicketDecrypted).to.not.equal(0); + + const carolTicketHandle = await this.blindAuction.ticketUser(this.signers.carol.address); + const carolTicketDecrypted = await reencryptEuint256( + this.signers.carol, + this.instance, + carolTicketHandle, + this.contractAddress, + ); + expect(carolTicketDecrypted).to.not.equal(0); + + // Decrypt winning ticket and verify winner + await this.blindAuction.decryptWinningTicket(); + await awaitAllDecryptionResults(); + const winningTicket = await this.blindAuction.getDecryptedWinningTicket(); + expect(winningTicket).to.equal(carolTicketDecrypted); + + // Carol claims and ends auction + const txCarolClaim = await this.blindAuction.connect(this.signers.carol).claim(); + await txCarolClaim.wait(); + + const txCarolWithdraw = await this.blindAuction.connect(this.signers.carol).auctionEnd(); + await txCarolWithdraw.wait(); + + // Verify final balances + const aliceBalanceHandle = await this.erc20.balanceOf(this.signers.alice); + const aliceBalance = await reencryptEuint64( + this.signers.alice, + this.instance, + aliceBalanceHandle, + this.contractERC20Address, + ); + expect(aliceBalance).to.equal(1000 - 100 - 100 + 20); + }); +}); diff --git a/hardhat/test/blindAuction/ConfidentialERC20.fixture.ts b/hardhat/test/blindAuction/ConfidentialERC20.fixture.ts new file mode 100644 index 0000000..71b5761 --- /dev/null +++ b/hardhat/test/blindAuction/ConfidentialERC20.fixture.ts @@ -0,0 +1,14 @@ +import { ethers } from "hardhat"; + +import type { BlindAuctionConfidentialERC20 } from "../../types"; +import { getSigners } from "../signers"; + +export async function deployConfidentialERC20Fixture(): Promise { + const signers = await getSigners(); + + const contractFactory = await ethers.getContractFactory("BlindAuctionConfidentialERC20"); + const contract = await contractFactory.connect(signers.alice).deploy("Naraggara", "NARA"); // City of Zama's battle + await contract.waitForDeployment(); + + return contract; +} diff --git a/hardhat/test/fhevmjsMocked.ts b/hardhat/test/fhevmjsMocked.ts index 5c3f014..30a9caf 100644 --- a/hardhat/test/fhevmjsMocked.ts +++ b/hardhat/test/fhevmjsMocked.ts @@ -143,10 +143,12 @@ export const reencryptRequestMocked = async ( const acl = await hre.ethers.getContractAt(aclArtifact.abi, ACL_ADDRESS); const userAllowed = await acl.persistAllowed(handle, userAddress); const contractAllowed = await acl.persistAllowed(handle, contractAddress); - const isAllowed = userAllowed && contractAllowed; - if (!isAllowed) { + if (!userAllowed) { throw new Error("User is not authorized to reencrypt this handle!"); } + if (!contractAllowed) { + throw new Error("dApp contract is not authorized to reencrypt this handle!"); + } if (userAddress === contractAddress) { throw new Error("userAddress should not be equal to contractAddress when requesting reencryption!"); }