Skip to content

Lab 6 ‐ Smart Contracts on Ethereum [WIP]

Mihály Dobos-Kovács edited this page May 5, 2025 · 9 revisions

Starter Project

You may find the starter project in the lab-6 branch of the lab repository.

Tasks

Task 0: Project Onboarding (0 points)

✏️ Switch to the lab-6 branch.

The project's current structure is as follows:

  • .vscode: Contains the VSCode configuration, recommended extensions and settings.
  • contracts: Contains some prewritten smart contracts.
  • frontend: Contains a React app that acts as dApp for the smart contracts written in this lab.
  • ignition: Contains the Ignition modules for the deployment of the smart contracts.
  • scripts: Contains Hardhat Scripts for automating useful tasks.
  • test: Contains the tests for this project.
  • .mocharc.json: Configuration file for Mocha (for testing)
  • .solhint.json: Configuration file for solhint (for static analysis)
  • eslint.config.mjs: Configuration file for ESLint (for JS/TS linting)
  • prettier.config.cjs: Configuration file for Prettier (linting)
  • hardhat.config.ts: Configuration file for Hardhat.

✏️ Install the project's dependencies with npm install. Move inside the frontend folder and install these dependencies as well with npm install.

✏️ Install the required VS Code dependencies from .vscode/extensions.json. Make sure not to install the extensions in unwantedRecommendations as they are not compatible with the rest of them 😞

Important

Restart VS Code afterwards.

✏️ Install the Brave browser. Later on, we will use Brave's integrated wallet for this lab. You are free to use any other wallet you want, but the instructions will cover Brave Wallet.

Installing Brave on Fedora
sudo dnf install dnf-plugins-core
sudo dnf config-manager addrepo --from-repofile=https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo
sudo dnf install brave-browser

Task 1: Simple Auction (3 points)

In the first part of this lab, we are going to develop a simple smart contract that can manage an open auction. The general idea of the contract is that everyone can send their bids during a bidding period. The bids include sending compensation (Ether) in order to bind the bidders to their bid. If the highest bid is raised, the previous highest bidder gets their Ether back. After the end of the bidding period, the contract has to be called manually for the beneficiary to receive their Ether.

Constructor

The constructor of the auction contract takes two arguments: _biddingEnd indicates when the bidding period is supposed to end and _beneficiary is the address of the account that will receive the winning bid of the auction.

🧟 Ownable Contracts

✏️ Finish the constructor so that it stores its parameters in the biddingEnd and beneficiary state variables.

Tip

We already created the beneficiary state varible for you, create a state variable for the bidding time similarly!


Bidding

In order to bid in the auction, users have to call the bid function and send their bid together with the transaction. The bid function has no arguments, all information (who makes the bid and with what amount) is already part of the transaction. The keyword payable is required for the function to be able to receive Ether.

🧟 Function Declarations, Msg.sender, Payable

✏️ Finish the bid function so that it updates the highest bidder (highestBidder) and the highest bid (highestBid) if the value received exceeds the highest bid. If the bid is too low, then the function should revert.

Tip

The contract already contains the highestBidder and highestBid state variables, use these!

Tip

You can access the account who sent the transaction with msg.sender and the value sent together with it with msg.value.

Tip

Use the revert statement to revert the transaction. The revert statement will revert all changes in the function execution including it having received the Ether.


Ending the auction

Whenever the auction ends, we transfer the winning bid to the beneficiary address. This is done by the auctionEnd function.

🧟 Withdraws

✏️ Finish the auctionEnd function so that it transfers the winning bid to the beneficiary address! Ensure that this can only happen once.

Tip

The winning bid is contained in the highestBid state variable.

Tip

You can ensure that the transfer can only happen once by using the ended state variable that contains whether this transfer has happened yet.

Tip

You can transfer amount Ether to address by calling address.transfer(amount).


Emitting events

Events provide an easy way to notify the applications interacting with our contract about state changes.

🧟 Events

✏️ Declare the following events and emit them when necessary:

  • HighestBidIncreased(address bidder, uint amount): emitted when the highest bid is increased
  • AuctionEnded(address winner, uint amount): emitted when the auction ends

Tip

You can declare events with the event keyword and emit them with the emit keyword. For example, event A() and emit A().


Creating custom errors

Errors in Solidity provide a convenient and gas-efficient way to explain to the user why an operation failed. They have to be used together with the revert statement or the require function.

✏️ Declare the BidNotHighEnough(uint highestBid) error and modify the bid function to revert with this error whenever the amount sent with the transaction is not higher than the highest bid.

✏️ Declare the AuctionEndAlreadyCalled() error and modify the auctionEnd function to revert with this error if the function was already called before.

Tip

You can declare errors with the error keyword and use them when reverting with the revert keyword. For example, error Err() and revert Err().


Handling the timing constraints

The auction is created with a timestamp marking the end of the bidding period that is set in the constructor. We only want to allow bids during this period and the winner can only be determined once the bidding period is over.

✏️ Declare the TooLate(uint time) error and revert the bid function with this error if it's called after the end of the bidding period. Indicate using the time parameter what the time limit for calling this function was!

Tip

You can access the timestamp of the block containing the transaction through block.timestamp.

Tip

You can declare errors with the error keyword and use them when reverting with the revert keyword. For example, error Err() and revert Err().

✏️ Declare the TooEarly(uint time) error and revert the auctionEnd function with this error if it's called before the end of the bidding period. Indicate using the time parameter when the call should be retried!


Withdrawing bids that were overbid

We would like to return bids that were overbid to whoever sent them. However, sending back the Ether in the bid function by simply using highestBidder.transfer(highestBid) is a security risk because it could execute an untrusted contract. It is always safer to let the recipients withdraw their Ether themselves, which is what we are going to implement.

🧟 Mappings and Addresses

✏️ Declare a mapping called pendingReturns that maps addresses to their allowed withdrawals and store bids that were overbid in this mapping!

Tip

You can create a mapping that maps keys of the address type to values of the uint type like this: mapping(address => uint).

Tip

You can index a mapping the following way: myMapping[key].

✏️ Update bid to add the previous highest bid to the previous highest bidder's pendingReturns.

✏️ Finish the withdraw function so that users can withdraw their corresponding amounts stored in pendingReturns. Ensure that the allowed amounts can only be withdrawn once.

Tip

msg.sender is not of type address payable and must be explicitly converted using payable(msg.sender) in order use the member function send().


Using modifiers

Modifiers can be used to change the behavior of functions in a declarative way. For example, you can use a modifier to automatically check a condition prior to executing the function.

🧟 Function Modifiers

✏️ Declare a modifier called onlyBefore(uint time) that reverts with the error TooLate(time) if called after time!

✏️ Update the bid function to use the onlyBefore(uint time) modifier!

✏️ Similarly to above, declare a modifier called onlyAfter(uint time) that reverts with the error TooEarly(time) if called before time and modify the auctionEnd function to use this modifier!


NFTs

Right now, our auction smart contract is able to collect bids, payout the winning bid to the beneficiary and let the others withdraw their losing bids. The only thing missing is the subject of the auction: a (possibly valuable) NFT.

🧟 ERC721 Standard, Multiple Inheritance

In this exercise we are going to use our own FTSRG NFT ERC721 token. You can find the implementation of this token in FtsrgNft.sol.

✏️ Take a moment to familiarize yourself with the FtsrgNft.sol contract. Pay attention to the balanceOf, mintTo, transferFrom and safeTransferFrom functions, what do these functions do?

Tip

The contract allows only a single token to be minted. This is ensured in the mintTo function through the check require(nextTokenIdToMint == 0, "!TooMany")

Tip

Some functions expect a tokenId argument. This parameter can be used to identify a specific token. As the contract only allows a single token to be minted, we are only going to use the id 0.

The subject of the auction will be an NFT, which is uniquely identified by its contract's address (tokenAddress) and a unique id (tokenId). The process of the auction will have multiple stages:

flowchart LR
    anchor1[ ]:::empty
    WaitingForFunding["Waiting for funding"]
    Bidding["Bidding"]
    WaitingForEnding["Waiting for ending"]
    Ended["Ended"]

    classDef empty width:0px,height:0px;

    anchor1 --> WaitingForFunding
    WaitingForFunding -- Transfer NFT --> Bidding
    Bidding -- block.timestamp > biddingEnd --> WaitingForEnding
    WaitingForEnding -- auctionEnd() --> Ended
Loading
  • Waiting for funding: Once a beneficiary creates an auction, they have to transfer the NFT to the auction contract so that it can be transferred to the winning bidder.
  • Bidding: Once the NFT is transferred to this contract, the public can place bids on it.
  • Waiting for ending: After the bidding time ends, someone has to call the auctionEnd function that transfers the NFT to the winner, and the bid to the beneficiary.
  • Ended: The auction is over, the owners of the losing bids can withdraw their funds.

First, let us store the necessary information about the NFT.

✏️ Add two new public state variables: tokenAddress (address) and tokenId (uint).

✏️ Add two additional parameters to the constructor: _tokenAddress (address) and _tokenId (uint) that set the previously added state variables.

Then, restrict certain operations to only allow them once the auction has been funded.

✏️ Add a new public view function called funded that returns true if and only if the auction has been funded.

Tip

The auction is funded, if owner of the NFT is the auction contract. Since we know the address of the NFT contract and the token ID, we can check the balance IERC721(tokenAddress).ownerOf(tokenId) == address(this)

✏️ Add a new modifier onlyFunded that only executes the function body, if the auction has been funded. Otherwise, it should raise a new custom error NotYetFunded().

✏️ Add the new modifier to the bid function.

Finally, we need to transfer the auction to the winner of the auction.

✏️ Modify the auctionEnd function to transfer the NFT to the winner.

Tip

Since we know both the winner and the NFT contract adress and token ID, we can transfer the NFT with: IERC721(tokenAddress).transferFrom(address(this), highestBidder, tokenId)

Right now the contract accepts any ERC721 token transfers, but we only want to accept transfers of the token specified through the tokenAddress and tokenId constructor parameters.

There is a special function at the end: onERC721Received. Whenever an ERC721 token is transferred to this contract via the safeTransferFrom method (docs), this function is called. This function returns a special value (its Solidity selector) to confirm the token transfer. If any other value is returned or the interface is not implemented by the recipient, the transfer will be reverted. The selector can be obtained in Solidity with IERC721Receiver.onERC721Received.selector. The rationale behind the existence of this method is that this way a contract can reject NFTs that it cannot handle for some reason (e.g., they are not the subject of the auction), preventing the tokens from being stuck in the contract.

✏️ Modify the onERC721Received method, so that it only allows the transfer of the NFT that is the target of the auction.

Tip

This function is called by our NFT contract during safeTransferFrom, so msg.sender should equal tokenAddress.

Tip

Either create custom errors, or use the require statement to revert on errors.


Factory

The Factory pattern is a design pattern used to create and manage multiple instances of a specific smart contract. A factory contract deploys new contracts on the blockchain and keeps track of the deployed instances, making it easier to manage and interact with them. Typically, a factory contract includes a function that deploys new instances and stores the addresses of the created contracts in an array or mapping.

We have created the skeleton of a factory contract in AuctionFactory.sol.

🧟 Working with Structs And Arrays

✏️ Finish the body of the createSimpleAuction function such that it creates a new SimpleAuction instance with the received parameters and stores its address in the corresponding array. Return the address of the created contract.

Tip

You can use the new keyword to create a new contract instance: new ContractName(...).

Tip

You can access the address of a contract instance with address(...).


Testing our Solution

✏️ Compile the smart contract and run the tests provided by us using npx hardhat compile and npx hardhat test test/SimpleAuction.ts

📋 CHECK: Do all tests succeed? If not, then check your implementation.


Task 2: Deployments & DApps (2 points)

We have created a working system of smart contracts in the previous part. In this part, we will work on deploying them. Moreover, we will discuss Wallets that are a crucial part of interacting with a block chain as an end user, and showcase a DApp (decentralized app) that provides a frontend for our system.

Test Networks

Blockchain testnets are specialized networks that serve as testing environments for developers to build, test, and deploy blockchain applications safely before launching on the mainnet (main network). These networks replicate the mainnet’s architecture, features, and protocols but operate in a controlled, sandboxed environment, allowing experimentation without financial risk or impacting live assets.

Testnets are crucial in the development lifecycle, especially for decentralized applications and smart contracts, as they help developers detect and fix issues early on. They also support testing different scenarios, including performance under heavy loads, smart contract bugs, and transaction processing. Typically, testnets use a different form of currency (often free and readily available through faucets), which has no real-world value, so users can conduct transactions and test without incurring real costs. Popular testnets include Holešky or Sepolia.

Apart from publicly available testnets, we can start a local network for ourselves as well. It is useful for debugging purposes, or running tests. Hardhat also has an integrated test network that it runs automatically for tests. We can start it in a standalone fassion as well to try out our smart contracts and applications locally.

During this lab, we will use the local Hardhat network.

Tip

While it is true that publicly available testnets have faucets available with which the developers can request free funds, there are always limitations. To combat malicious actors accumulating funds rendering the network unusable, faucets usually require authentication (e.g. Google account) or require users to mine the network to receive the requested funds. Moreover, the requestable funds are usually limited.

✏️ Start the local Hardhat network with npx hardhat node in a dedicated terminal. This will be a long running task. Make note, that the Hardhat Network has funded 20 accounts with 10.000 ETH to aid in testing.


Hardhat Ignition

We have a working test network. Now, let us deploy our smart contracts on the network. Hardhat Ignition is a declarative system for deploying smart contracts on Ethereum blockchains. The declarative nature of Ignition enables it to execute steps in parallel, manage and recover from errors, resume interrupted or partial deployments, and even adapt to modifications in your modules.

First, let us recall the architecture of the smart contracts:

classDiagram
    direction LR
    class AuctionFactory
    class SimpleAuction
    class IERC721["«interface»\nIERC721"]
    class FtsrgNft
    IERC721 <|.. FtsrgNft
    AuctionFactory ..> SimpleAuction: «create»
    SimpleAuction ..> IERC721: «use»
Loading

We provided a custom NFT implementation with FtsrgNft that we can use for testing purposes. The idea behind this NFT, that this contract only mints one token (tokenId: 0), so it must be valuable 😃

Out of our three contracts, we need to deploy AuctionFactory and FtsrgNft. We do not need to deploy SimpleAuction as AuctionFactory was created for this exact purpose.

Now, let us deploy the FtsrgNft contract.

🔍 Check out the documentation of Hardhat Ignition to see what you need to do.

✏️ Create FtsrgNft.ts under ignition/modules. Fill it with the definition of an Ignition module that deploys FtsrgNft.

✏️ Deploy FtsrgNft

  1. Run npx hardhat ignition deploy ignition/modules/FtsrgNft.ts --network localhost
  2. Make note of the deployed address!
  3. Run npx hardhat ignition deploy ignition/modules/FtsrgNft.ts --network localhost again.

📋 CHECK: Did it deploy anything now? Did the address change?

We have successfully deployed our FtsrgNft, but we are yet to mint it!

✏️ Modify FtsrgNft.ts under ignition/modules to also mint the NFT.

Tip

Use the mintTo method of FtsrgNft

Tip

You can use m.getAccount(0) to get the address of the deploying account.

💡 A generic rule in Hardhat, that unless specified otherwise, the first account (index 0) always the account that deploys your code.

✏️ Deploy FtsrgNft again

  1. Run npx hardhat ignition deploy ignition/modules/FtsrgNft.ts --network localhost

📋 CHECK: Did it deploy anything now? Did the address change?

We have learned how to deploy a contract with Hardhat Ignition, and how to change the deployment as long as the changes are compatible and doable incrementally. Let us deploy AuctionFactory in a similar fashion.

✏️ Create AuctionFactory.ts under ignition/modules. Fill it with the definition of an Ignition module that deploys AuctionFactory.

✏️ Deploy AuctionFactory

  1. Run npx hardhat ignition deploy ignition/modules/AuctionFactory.ts --network localhost
  2. Make note of the address!

📋 CHECK: Why did we get a warning?

We can deploy every contract by hand one-by-one, or we can make use of Ignition's declarative nature and write modules that depend on each other. Since in this case none of the contracts depend on the other, let us create a system module that contains both our smart contracts.

✏️ Create System.ts under ignition/modules. Fill it with the definition of an Ignition module that deploys both AuctionFactory and FtsrgNft.

✏️ Deploy the system

  1. Run npx hardhat ignition deploy ignition/modules/System.ts --network localhost

📋 CHECK: Did anything happen?

Incremental and declarative deployment is useful for making sure that we deploy one consistent system. However, during development, we might want to redeploy the whole application. Hardhat Ignition enables us to discard the previous deployment information and start deployment from scratch using the --reset argument.

✏️ Deploy the system

  1. Run npx hardhat ignition deploy ignition/modules/System.ts --network localhost --reset

📋 CHECK: Did anything happen? Did the addresses change?


Wallets (Brave Wallet)

A blockchain wallet is a digital tool that allows users to interact with blockchain networks, enabling them to store, send, and receive cryptocurrencies and digital assets securely, or interact with smart contracts and DApps. Unlike traditional wallets that hold physical money, blockchain wallets manage cryptographic keys—unique pairs of public and private keys—used to verify ownership and authorize transactions on the blockchain.

There are numerous wallets available that each tailor to various needs. There are wallets that integrate with a browser (Brave Wallet, MetaMask), and there are always offline hardware backed wallets as well (Ledger). During this lab, we will use Brave Wallet that integrates with the Brave Browser and develop an web-based frontend for our blind auction contract that integrates with it.

Warning

This is NOT a product endorsement. You should always consider questions about data security, privacy and trust before you choose a wallet you trust with your funds, as if it becomes compromised, you will lost your assets. We did not consider any of these aspects when choosing the wallet.

We chose Brave Wallet for the following reason alone. Blockchains are (usually) permanent, and wallets make use of that fact. When using local development networks, like the Hardhat Network, the wallet needs to be able to "reset" itself because the development networks are often restarted, wiping out the history. Brave Wallet can be "reset" quite easily and reliably.

Caution

DO NOT use the wallet you store your assets in for development. Development usually means that you have to "reset" the network, thus your wallet, meaning you are in constant danger of losing access to your assets without proper backup.

Now, let us configure the Brave Wallet.

✏️ Open up the Brave Browser. Then:

  1. Navigate to brave://wallet or click the wallet icon in the top-right corner
    1. Create a new wallet (Need a new wallet?)
    2. On the Supported networks page click Deselect all. Two nets will remain, the Ethereum Mainnet and Solana Mainnet.
    3. Create a password and set the auto-lock option for the highest possible value to prevent giving your password constantly (convenience of testing over security)
    4. In the Save your recovery phrase do not click on Skip. Save it to disable a warning afterwards. It will ask you for this phrase on the next screen.
    5. You should see your Portfolio
  2. After you got to the Portfolio, navigate to brave://settings/web3 in a new page (or access Web3 from Settings)
    1. Add a new Wallet Network with the following settings:
      • Search network: Search for 31337 then select `GoChain Testnet
      • After that, start overriding the settigns with the following values
        • The name of chain: Hardhat
        • Chain's currency name: Ethereum
        • Chain's currency symbol: ETH
        • Chain's currency decimals: 18
        • RPC URLs: http://127.0.0.1:8545 (make sure that the radio button is selected as well)
        • Remove the Block exporer URLs
    2. Make note of the Clear wallet transaction and nonce information option. You will need it later to reset the wallet.
  3. Navigate to Accounts in Brave Wallet
    1. Import an Ethereum account.
      • Import type: Private key
      • Private key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
      • Name this account: Hardhat 0
    2. Import another Ethereum account.
      • Import type: Private key
      • Private key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
      • Name this account: Hardhat 1

Important

Each time you restart your local development network, you need to navigate to brave://settings/web3 and click on Clear wallet transaction and nonce information.

Tip

If you remember, npx hardhat node emitted 20 account information. We got these private keys from the first two accounts. You can import the others as well if you wish to.

Tip

In Accounts, under Hardhat 0 and Hardhat 1 hide the assets apart from ETH on Hardhat to declutter the UI. Do the same in Portfolio.

✏️ Now that you have setup your wallet, try out the different features it provides.

  1. Use Send to send 1.000 ETH from Hardhat 0 to Hardhat 1.
  2. Check out the Activity tab afterwards.
  3. Check out the Transactions of both accounts!
  4. Import out NFT to Hardhat 0:
    • Select network: Hardhat
    • Contract address: From the last deployment
    • Token ID: 0
    • Token name: FTSRG NFT
    • Token symbol: FTSRG
  5. Send the NFT to Harhat 1
  6. Check out the Activity tab afterwards.
  7. Check out the Transactions of both accounts!

💡 Unfortunately, Brave Wallet will not be able to display nice information about our NFT, as it can only be found on our local network.


DApps

Up until this point, we have only interacted with the blockchain using custom scripts. To make Web3 applications more available, we will connect a frontend to our smart contracts.

This frontend was created using with React, a popular TypeScript library for building user interfaces, especially well-suited for single-page applications. React’s component-based architecture enables us to create a modular, responsive UI that interacts seamlessly with the blockchain backend.

✏️ Navigate into the frontend folder and run the app using npm run dev.

  1. Open the website on the URL written to the console.
  2. Try changing the network and the account in the wallet. You might need to give permission for your site to access your wallet.
  3. Simulate an auction
  4. Create a new simple auction with Hardhat 0. Let them be the beneficiary, while the target NFT should be the FtsrgNft. You can recover the address from the deployment logs. (The token ID is 0.)
  5. Transfer the NFT to the auction contract. You can use the GUI to do so. Make sure that the correct account is selected!
  6. Place some bids with Hardhat 0 and Hardhat 1. Make sure, that the latter is the winner.
  7. Forward the clock to simulate the end of the bidding period. You can use a custom helper script for this purpose: npm run debug-forward-clock-one-week
  8. End the auction, and withdraw the funds with Hardhat 0 and Hardhat 1.

📋 CHECK: Check in the wallet who owns the NFT, and what are the balances. You should see that the NFT changed hands, and the funds were allocated accordingly.


Task 3: Blind Auction (5 points)

In this task we are going to extend the previous open auction to a blind auction.

During the bidding period, a bidder does not actually send their bid, but only a hashed version of it. Since it is currently considered practically impossible to find two (sufficiently long) values whose hash values are equal, the bidder commits to the bid by that. After the end of the bidding period, the bidders have to reveal their bids during the reveal period: They send their values unencrypted, and the contract checks that the hash value is the same as the one provided during the bidding period. This pattern is known as the Commit-Reveal Scheme.

flowchart LR
    anchor1[ ]:::empty
    WaitingForFunding["Waiting for funding"]
    Bidding["Bidding"]
    Bidding["Revealing"]
    WaitingForEnding["Waiting for ending"]
    Ended["Ended"]

    classDef empty width:0px,height:0px;

    anchor1 --> WaitingForFunding
    WaitingForFunding -- Transfer NFT --> Bidding
    Bidding -- block.timestamp > biddingEnd --> Revealing
    Revealing -- block.timestamp > revealEnd --> WaitingForEnding
    WaitingForEnding -- auctionEnd() --> Ended
Loading

Another challenge is how to make the auction binding and blind at the same time: The only way to prevent the bidder from just not sending the Ether after they won the auction is to make them send it together with the bid. Since value transfers cannot be blinded in Ethereum, anyone can see the value.

We are going to solve this problem by accepting any value that is larger than the highest bid. Since this can of course only be checked during the reveal phase, some bids might be invalid, and this is on purpose (users can even use an explicit flag to place invalid bids with high-value transfers): Bidders can confuse competition by placing several high or low invalid bids.

Commit-Reveal Scheme

The commit-reveal scheme is a type of commitment scheme that can be used to store values in a smart contract and keep them secret until you choose to reveal them later.

💡 The commit-reveal scheme uses a two-step process:

  1. Commit: The user computes and sends hash(data, secret), where data is the data itself and secret is a secret phrase chosen by the user. The smart contract stores the hash.
    bytes32 hash = keccak256(abi.encodePacked("data", "secret"));
  2. Reveal: The user sends the same data and secret values, the contract computes the hash and compares it to the stored hash value.
    bytes32 newHash = keccak256(abi.encodePacked(newData, newSecret));
    require(hash == newHash);

💡 During the bidding phase, users can commit to bids that they are going to reveal in the reveal phase.

🔍 Further reading

🧟 Keccak256 and Typecasting

💡 The keccak256 hash function is a cryptographic hash algorithm used widely in Ethereum to securely encode data into a fixed 256-bit output.


Where to Start

The new blind auction contract is going to have a significantly different interface than our previous simple auction. In order to make this transition as easy as possible, we created the BlindAuction contract for you that will serve as starting point for the next exercise. This contract contains most of the code that we wrote during the previous exercise, but there are some changes:

  • The constructor received one further argument: uint _revealEnd indicates the end of the reveal period.
  • The bid function was transformed into the internal placeBid function that we are going to call whenever a bid is revealed. The functionality remained the same, but the function takes the bidder and value as arguments instead of extracting these from the transaction using msg.sender and msg.value.
  • A new bid function was added that takes a 32 byte hash value as input.
  • A new struct called Bid was added that stores the hash values received by the bid function along with the amount of Ether sent with them.
    struct Bid {
        bytes32 blindedBid;
        uint deposit;
    }
    The bids mapping stores an array of bids for each address:
    mapping(address => Bid[]) public bids;
  • A function was added for revealing bids (reveal).

Storing the Blinded Bids

During the bidding period, users can submit the hashes of their blinded bids. The hash is computed as keccak256(abi.encodePacked(value, fake, secret)), where value is the inteded bid, fake is a Boolean flag indicating whether the bid is real or fake and secret is a secret phrase chosen by the user.

✏️ Finish the bid function so that it stores the blinded bid received as the bytes32 blindedBid parameter in the bids mapping. Extract the sender and the Ether received along with the call from the transaction. Emit the BidReceived(address sender, bytes32 blindedBid, uint deposit) event based on the received bid!

Tip

You can create a new Bid struct instance the following way: Bid({ blindedBid: blindedBid, deposit: deposit }).

Tip

You can use the push function to add a new value to an array.

Tip

You can use msg.sender and msg.value to access the sender and the amount of Ether sent.


Revealing the Bids

During the reveal phase, the users have to submit the same values unencrypted and we check whether their hash value is the same as the one provided during the bidding period. This is done by the reveal function, which the contract already includes the skeleton of:

function reveal(
    uint[] calldata values,
    bool[] calldata fakes,
    bytes32[] calldata secrets
) external onlyAfter(biddingEnd) onlyBefore(revealEnd) {
    uint length = bids[msg.sender].length;
    require(values.length == length);
    require(fakes.length == length);
    require(secrets.length == length);

    uint refund;
    for (uint i = 0; i < length; i++) {
        // TODO process bids
    }
    payable(msg.sender).transfer(refund);
}

We first check if the arrays received as input have the appropriate size (the arrays must have the same size as the number of blinded bids we received). We then iterate over the stored bids, while collecting the amount of Ether that has to be refunded to the user.

🧟 Handling Multiple Return Values

✏️ Write the body of the loop such that it checks if the received bids are revealed correctly. If a bid is revealed correctly, then emit the RevealedBid(address sender, bytes32 blindedBid, bool fake, uint value) event, else proceed to the next bid!

Tip

You can use keccak256(abi.encodePacked(value, fake, secret)) to calculate the hash of the values value, fake and secret.

A bid is considered invalid if either the fake flag is set to true, or the deposited amount is lower than the value that was hashed. In these cases, the entire deposit has to be refunded. Valid bids are compared with the current highest bid, which they have to exceed in order to be registered as the new highest bid. If the highest bid is not exceeded, then the entire deposit is refunded, else only the surplus above value (i.e., deposit - value).

✏️ Modify the loop body such that bids that were properly revealed are processed using the placeBid function and invalid bids are refunded (add them to the refund variable). Also refund bids that do not exceed the current highest bid. For valid bids that are registered as the new highest bid, only refund the surplus deposit.

Tip

The placeBid function returns a Boolean value indicating whether the processed bid exceeded the highest bid.

Right now the contract allows users to reveal their bids multiple times and get refunds each time. The easiest way to counter this is to clear the stored hash from the contract after a bid was revealed.

✏️ Modify the reveal function such that the hashes of correctly revealed bids are cleared from the Bid struct instance that stored them.

Tip

You can use bytes32(0) to create a 32 bytes long sequence of zeroes.

🧟 Storage vs. Memory (Data Location)

Tip

By using Bid storage bidToCheck = bids[msg.sender][i] to store the bid that we check, we saved a reference to the Bid instance from the mapping. We can modify the original instance through this reference.


Handling the NFT

We would like to ensure that the auction can only start after the auction has been funded (meaning the contract received the NFT).

✏️ Finish the body of the funded function such that is returns true if the auction contract currently owns the NFT.

✏️ Create an onlyFunded modifier that reverts with a NotYetFunded() error if the auction isn't funded. Use the funded function to check if the auction is funded. Annotate the bid function with this modifier.

✏️ Modify the onERC721Received method, so that it only allows the transfer of the NFT that is the target of the auction.

✏️ Modify the auctionEnd function such that it also transfers the NFT to the winner of the auction.

Tip

These exercises should be fundamentally the same as they were for the simple auction.


Factory

✏️ Finish the body of the createBlindAuction function such that it creates a new BlindAuction instance with the received parameters and stores its address in the corresponding array. Return the address of the created contract.


Testing our Solution

✏️ Compile the smart contract and run the tests provided by us using npx hardhat compile and npx hardhat test test/BlindAuction.ts

📋 CHECK: Do all tests succeed? If not, then check your implementation.


Deployment

✏️ Deploy the system

  1. Run npx hardhat ignition deploy ignition/modules/System.ts --network localhost --reset

✏️ Refresh your browser, try creating a blind auction in the GUI, and perform a blind auction.


Extra Task: Reentrancy Attacks (3 IMSc points)

Reentrancy refers to a situation where a smart contract calls an external contract, and that external contract then calls back into the original contract before the first function has finished executing. This can lead to unexpected and unsafe changes in the contract’s state, especially if important state updates are performed after making an external call. A famous example of a reentrancy vulnerability was the DAO hack in 2016, where attackers repeatedly withdrew funds by recursively calling the withdrawal function before the contract could update the user’s balance.

🔍 Reentrancy Attacks and The DAO Hack

To prevent reentrancy attacks, developers use techniques such as the checks-effects-interactions pattern (updating state variables before making external calls) and using reentrancy guards to block repeated entries into sensitive functions during execution. Understanding reentrancy is critical for writing secure Ethereum smart contracts.

🔍 Robust Smart Contracts: Checks-Effects-Interactions Pattern for Secure dApps

🔍 OpenZeppelin Reentrancy Guard: A Quickstart Guide

✏️ Check your withdraw implementation (in either SimpleAuction or BlindAuction) if it is vulnerable to reentrancy attacks. If it is, fix it. If it is not, explain why not!

📋 CHECK: Document your findings and reasoning.

Clone this wiki locally