Create an Escrow NFT Platform on Celo with Python

Create an Escrow NFT Platform on Celo with Python https://celo.academy/uploads/default/optimized/2X/d/dc9b5981af4b4b69e72f4377c20c9ad7edacbbbb_2_1024x576.png
none 0.0 0

Introduction​

In this tutorial, we will be creating an escrow NFT platform on the Celo network using Eth-Brownie Python. Escrow is a financial arrangement where a third party holds and regulates the transfer of funds or assets between two other parties. In this case, the third party is the smart contract. The two parties are the buyer and the seller. The escrow NFT platform will work like this, the seller will lock NFT in smart contract and the buyer will be able to buy the NFT by paying the price set by the seller. The smart contract will hold the NFT until the buyer pays the price. If the buyer pays the price, the NFT will be transferred to the buyer. If the buyer fails to pay the price, the NFT will be returned to the seller. In this tutorial, we will be using the Celo network, but the same concept can be applied to other evm-compatible blockchain networks.

Prerequisites

These tutorials assume that you have some basic knowledge of solidity and python.

Requirements

This is a list of what we’ll cover :spiral_notepad:

  • :white_check_mark: Step 1: Project setup
  • :white_check_mark: Step 2: Write project code
  • :white_check_mark: Step 3: Configure deployment settings
  • :white_check_mark: Step 4: Deploy your Contract
  • :white_check_mark: Step 5: Integration with frontend

Step 1: Project setup

First, we will create a new directory for our project. Open your terminal and run the following command to create a new directory called escrow-nft and change directory to it.

mkdir escrow-nft && cd escrow-nft

next, we will install eth-brownie, python-dotenv, and ganache-cli. Run the following command to install them.

# Install eth-brownie and python-dotenv
pip3 install eth-brownie python-dotenv

# Install ganache
npm install -g ganache

Now, after installing all the required packages, we need to initialize our project. Run the following command to initialize our project.

brownie init

Here’s what a successful initialization looks like:

Next, after initializing our project, we will create two files, brownie-config.yaml and .env in root directory.
brownie-config.yaml is a configuration file for brownie. It contains the default settings for our project. We will use this file to configure our project settings. .env is a file that contains environment variables. We will use this file to store our mnemonic phrase. We will use this mnemonic phrase to deploy our contract to the Celo network.

brownie-config.yaml file

reports:
    exclude_contracts:
        - SafeMath
depedencies:
    - OpenZeppelin/openzeppelin-contracts@4.8.0
compiler:
    solc:
        version: 0.8.15
        optimizer:
            enabled: true
            runs: 200
        remappings:
            - "@openzeppelin=OpenZeppelin/openzeppelin-contracts@4.8.0"
networks:
    default: celo-alfajores
console:
    show_colors: true
    color_style: monokai
    auto_suggest: true
    completions: true
    editing_mode: emacs
dotenv: .env
wallets:
    from_mnemonic: ${MNEMONIC}

.env file

MNEMONIC="your mnemonic phrase"

if you want to read more about brownie-config.yaml file, you can read it here.

lastly for this step, we will add Celo network to our brownie project. Run the following command to add Celo network to our brownie project.

brownie networks add Celo celo-mainnet host=https://forno.celo.org chainid=42220 explorer=https://explorer.celo.org

brownie networks add Celo celo-alfajores host=https://alfajores-forno.celo-testnet.org chainid=44787 explorer=https://alfajores-blockscout.celo-testnet.org

You can check if the network has been added by running the following command.

brownie networks list

result if the network has been added successfully.

1

You can see that we have added two networks, celo-mainnet and celo-alfajores to our brownie network list.

Step 2: Write project code

In this step, we will write the code for our smart contract. Create a new file called escrowNFT.sol in the contracts directory. This is where we will write our smart contract code. Here is an look at what our smart contract code will look like.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";

contract escrowNFT is Ownable {
    using SafeMath for uint256;

    // Declare state variables
    uint256 public fee;
    uint256 private escrowDigit = 16;
    uint256 private modulus = 10**escrowDigit;

    enum Status {
        Pending,
        Accepted,
        Rejected,
        Cancelled
    }

    struct Escrow {
        uint256 tokenId;
        uint256 paymentAmount;
        uint256 deadline;
        address tokenAddress;
        address buyerAddress;
        address sellerAddress;
        Status status;
    }

    mapping(uint256 => Escrow) public escrow;

    event NewEscrow(
        uint256 txId,
        uint256 tokenId,
        uint256 paymentAmount,
        address tokenAddress
    );

    event CancleEscrow(
        uint256 txId,
        uint256 tokenId,
        uint256 paymentAmount,
        address tokenAddress
    );

    event PayEscrow(
        uint256 txId,
        uint256 timestamp,
        uint256 tokenId,
        uint256 paymentAmount
    );

    event RejectEscrow(
        uint256 txId,
        uint256 tokenId,
        uint256 paymentAmount,
        address tokenAddress
    );



    modifier onlySeller(uint256 _txId) {
        require(
            msg.sender == escrow[_txId].sellerAddress,
            "Only seller can call this function"
        );
        _;
    }

    modifier onlyBuyer(uint256 _txId) {
        require(msg.sender == escrow[_txId].buyerAddress, "Only buyer can call this function");
        _;
    }

    constructor(uint256 _fee) {
        fee = _fee;
    }

    function updateFee(uint256 _fee) external onlyOwner {
        fee = _fee;
    }

    function claimFee() external onlyOwner {
        (bool status, ) = payable(msg.sender).call{value: address(this).balance}("");
        require(status, "Transfer failed");
    }

    function generateTxId(
        address _sellerAddress,
        address _buyerAddress,
        address _nftAddress,
        bytes memory _secret
    ) external view returns (uint256 txId) {
        txId =
            uint256(
                keccak256(
                    abi.encodePacked(
                        _sellerAddress,
                        _buyerAddress,
                        _nftAddress,
                        _secret
                    )
                )
            ) %
            modulus;
    }

    function createEscrow(
        uint256 _txId,
        uint256 _tokenId,
        uint256 _paymentAmount,
        address _tokenAddress,
        address _buyerAddress
    ) external {
        require(_paymentAmount > 0, "Payment amount must be greater than 0");
        require(_tokenAddress != address(0), "Token address cannot be 0x0");
        require(_buyerAddress != address(0), "Buyer address cannot be 0x0");
        IERC721 nft = IERC721(_tokenAddress);
        nft.transferFrom(msg.sender, address(this), _tokenId);
        escrow[_txId] = Escrow(
            _tokenId,
            _paymentAmount,
            block.timestamp + 1 days,
            _tokenAddress,
            _buyerAddress,
            msg.sender,
            Status.Pending
        );
        emit NewEscrow(_txId, _tokenId, _paymentAmount, _tokenAddress);
    }

    function cancleEscrow(uint256 _txId) external onlySeller(_txId) {
        require(
            block.timestamp > escrow[_txId].deadline,
            "Deadline not reached"
        );
        require(
            escrow[_txId].status == Status.Pending,
            "Escrow is not in pending status"
        );
        IERC721 nft = IERC721(escrow[_txId].tokenAddress);
        escrow[_txId].status = Status.Cancelled;
        nft.transferFrom(address(this), msg.sender, escrow[_txId].tokenId);
        emit CancleEscrow(
            _txId,
            escrow[_txId].tokenId,
            escrow[_txId].paymentAmount,
            escrow[_txId].tokenAddress
        );
    }

    function payEscrow(uint256 _txId) external payable onlyBuyer(_txId) {
        require(block.timestamp < escrow[_txId].deadline, "Deadline reached");
        require(
            escrow[_txId].status == Status.Pending,
            "Escrow is not in pending status"
        );
        IERC721 nft = IERC721(escrow[_txId].tokenAddress);
        uint256 amountAfterFee = _calculateFee(msg.value);
        escrow[_txId].status = Status.Accepted;
        (bool status, ) = payable(escrow[_txId].sellerAddress).call{value: amountAfterFee}("");
        require(status, "Transfer failed");
        nft.transferFrom(address(this), msg.sender, escrow[_txId].tokenId);
        emit PayEscrow(
            _txId,
            block.timestamp,
            escrow[_txId].tokenId,
            escrow[_txId].paymentAmount
        );
    }

    function rejectEscrow(uint256 _txId) external onlyBuyer(_txId) {
        require(block.timestamp < escrow[_txId].deadline, "Deadline reached");
        require(
            escrow[_txId].status == Status.Pending,
            "Escrow is not in pending status"
        );
        IERC721 nft = IERC721(escrow[_txId].tokenAddress);
        escrow[_txId].status = Status.Rejected;
        nft.transferFrom(address(this), escrow[_txId].sellerAddress, escrow[_txId].tokenId);
        emit RejectEscrow(
            _txId,
            escrow[_txId].tokenId,
            escrow[_txId].paymentAmount,
            escrow[_txId].tokenAddress);
    }
    
    function _calculateFee(uint256 _paymentAmount) private view returns(uint256 amountAfterFee) {
        uint256 feeAmount = _paymentAmount.mul(fee).div(100);
        amountAfterFee = _paymentAmount.sub(feeAmount);
    }
}

Let us analyze the code line by line.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;

First line, we declare the SPDX license identifier of the relevant license for the contract. The second line, we declare the solidity version we are using.

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";

We’ll be using OpenZeppelin contracts for our project. Ownable contract will be used to make sure only the owner of the contract can call certain functions. IERC721 is the interface for ERC721 tokens. SafeMath is a library that provides mathematical functions and prevents integer overflow.

uint256 public fee;
uint256 private escrowDigit = 16;
uint256 private modulus = 10**escrowDigit;

The fee variable will be used to store the fee percentage. The escrowDigit variable will be used to generate a unique transaction ID. The modulus variable will be used to generate a unique transaction ID.

enum Status {
        Pending,
        Accepted,
        Rejected,
        Cancelled
    }

Enum Status will be used to store the status of the escrow.

struct Escrow {
        uint256 tokenId;
        uint256 paymentAmount;
        uint256 deadline;
        address tokenAddress;
        address buyerAddress;
        address sellerAddress;
        Status status;
    }

Escrow struct will be used to store the details of the escrow.

mapping(uint256 => Escrow) public escrow;

The escrow mapping will be used to map the transaction ID to the Escrow struct.

event NewEscrow(
    uint256 txId,
    uint256 tokenId,
    uint256 paymentAmount,
    address tokenAddress
);

event CancleEscrow(
    uint256 txId,
    uint256 tokenId,
    uint256 paymentAmount,
    address tokenAddress
);

event PayEscrow(
    uint256 txId,
    uint256 timestamp,
    uint256 tokenId,
    uint256 paymentAmount
);

event RejectEscrow(
    uint256 txId,
    uint256 tokenId,
    uint256 paymentAmount,
    address tokenAddress
);

These events are emitted when a new escrow is created, an escrow is cancelled, an escrow is paid, and an escrow is rejected.

modifier onlySeller(uint256 _txId) {
    require(
        msg.sender == escrow[_txId].sellerAddress,
        "Only seller can call this function"
    );
    _;
}

modifier onlyBuyer(uint256 _txId) {
    require(msg.sender == escrow[_txId].buyerAddress, "Only buyer can call this function");
    _;
}

These modifiers are used to make sure only the seller or the buyer can call certain functions.

function updateFee(uint256 _fee) external onlyOwner {
    fee = _fee;
}

function claimFee() external onlyOwner {
    (bool status, ) = payable(msg.sender).call{value: address(this).balance}("");
    require(status, "Transfer failed");
}

The updateFee function is used to update the fee percentage. The claimFee function is used to claim the fee collected by the contract.

function generateTxId(
    address _sellerAddress,
    address _buyerAddress,
    address _nftAddress,
    bytes memory _secret
) external view returns (uint256 txId) {
    txId =
        uint256(
            keccak256(
                abi.encodePacked(
                    _sellerAddress,
                    _buyerAddress,
                    _nftAddress,
                    _secret
                )
            )
        ) %
        modulus;
}

generateTxId function is used to generate a unique transaction ID to be used for the escrow. The transaction ID is generated using the seller address, buyer address, NFT address, and the secret. secret parameter is used to make sure the transaction ID is unique for each escrow.

function createEscrow(
    uint256 _txId,
    uint256 _tokenId,
    uint256 _paymentAmount,
    address _tokenAddress,
    address _buyerAddress
) external {
    require(_paymentAmount > 0, "Payment amount must be greater than 0");
    require(_tokenAddress != address(0), "Token address cannot be 0x0");
    require(_buyerAddress != address(0), "Buyer address cannot be 0x0");
    IERC721 nft = IERC721(_tokenAddress);
    nft.transferFrom(msg.sender, address(this), _tokenId);
    escrow[_txId] = Escrow(
        _tokenId,
        _paymentAmount,
        block.timestamp + 1 days,
        _tokenAddress,
        _buyerAddress,
        msg.sender,
        Status.Pending
    );
    emit NewEscrow(_txId, _tokenId, _paymentAmount, _tokenAddress);
}

createEscrow is used to create a new escrow. The function takes the transaction ID, token ID, payment amount, token address, and buyer address as parameters. The function first checks if the payment amount is greater than 0, and check token, buyer address are not 0x0. Then it transfers the NFT from the seller to the contract. Then it creates a new escrow in the escrow mapping and emits the NewEscrow event.

function cancleEscrow(uint256 _txId) external onlySeller(_txId) {
    require(
        block.timestamp > escrow[_txId].deadline,
        "Deadline not reached"
    );
    require(
        escrow[_txId].status == Status.Pending,
        "Escrow is not in pending status"
    );
    IERC721 nft = IERC721(escrow[_txId].tokenAddress);
    escrow[_txId].status = Status.Cancelled;
    nft.transferFrom(address(this), msg.sender, escrow[_txId].tokenId);
    emit CancleEscrow(
        _txId,
        escrow[_txId].tokenId,
        escrow[_txId].paymentAmount,
        escrow[_txId].tokenAddress
    );
}

cancleEscrow function is used to cancel an escrow. The function takes the transaction ID as a parameter. First, function will check if the deadline is reached and the escrow status is pending. If both conditions are true, the function will transfer the NFT back to the seller and emit the CancleEscrow event.

function payEscrow(uint256 _txId) external payable onlyBuyer(_txId) {
    require(block.timestamp < escrow[_txId].deadline, "Deadline reached");
    require(
        escrow[_txId].status == Status.Pending,
        "Escrow is not in pending status"
    );
    IERC721 nft = IERC721(escrow[_txId].tokenAddress);
    uint256 amountAfterFee = _calculateFee(msg.value);
    escrow[_txId].status = Status.Accepted;
    (bool status, ) = payable(escrow[_txId].sellerAddress).call{value: amountAfterFee}("");
    require(status, "Transfer failed");
    nft.transferFrom(address(this), msg.sender, escrow[_txId].tokenId);
    emit PayEscrow(
        _txId,
        block.timestamp,
        escrow[_txId].tokenId,
        escrow[_txId].paymentAmount
    );
}

payEscrow function is used to pay an escrow. The function takes the transaction ID as a parameter. First, function will check if the deadline is not reached and the escrow status is pending and buyer only sends the payment amount equal to the payment amount in the escrow. If all conditions are true, the function will transfer the NFT to the buyer and pay the seller the payment amount minus the fee. Then it emits the PayEscrow event.

function rejectEscrow(uint256 _txId) external onlyBuyer(_txId) {
    require(block.timestamp < escrow[_txId].deadline, "Deadline reached");
    require(
        escrow[_txId].status == Status.Pending,
        "Escrow is not in pending status"
    );
    IERC721 nft = IERC721(escrow[_txId].tokenAddress);
    escrow[_txId].status = Status.Rejected;
    nft.transferFrom(address(this), escrow[_txId].sellerAddress, escrow[_txId].tokenId);
    emit RejectEscrow(
        _txId,
        escrow[_txId].tokenId,
        escrow[_txId].paymentAmount,
        escrow[_txId].tokenAddress);
}

rejectEscrow function is used to reject an escrow. The function takes the transaction ID as a parameter. First, function will check if the deadline is not reached and the escrow status is pending. If both conditions are true, the function will transfer the NFT back to the seller and emit the RejectEscrow event.

function _calculateFee(uint256 _paymentAmount) private view returns(uint256 amountAfterFee) {
    uint256 feeAmount = _paymentAmount.mul(fee).div(100);
    amountAfterFee = _paymentAmount.sub(feeAmount);
}

_calculateFee private function is used to calculate the fee amount. The function takes the payment amount as a parameter and returns the amount after fee.

Step 4: Deploy your Contract

Now that we have written our smart contract, we need to deploy it to the blockchain. First, we need to compile our smart contract, and then we will deploy it to the blockchain. Use the following command to compile the smart contract.

brownie compile

result if the compilation is successful

Now, we need to deploy our smart contract to the blockchain. First create a new file scripts/deploy.py and add the following code to it.

from brownie import accounts, config, escrowNFT

def main():
    # Get the account to use
    account = accounts.from_mnemonic(config["wallets"]["from_mnemonic"])

    # Deploy the contract
    contract = escrowNFT.deploy(2, {"from": account})

    # Wait for the transaction to be mined
    contract.tx.wait(3)

    print("Contract deployed to:", contract.address)

main function is used to deploy the contract. First, we get the account to use. Then we deploy the contract and wait for the transaction to be mined. Finally, we print the contract address. Now, we need to deploy the contract. Use the following command to deploy the contract.

# Deploy the contract to the Celo Alfajores testnet
brownie run scripts/main.py --network celo-alfajores

# Deploy the contract to the Celo Mainnet
brownie run scripts/main.py --network celo-mainnet

result if the deployment is successful

Step 5: Integration with frontend

Now that we have deployed our smart contract to the blockchain, we need to integrate it with our frontend. You can clone the frontend we have created for this tutorial. Use the following command to clone the frontend.

# Clone the frontend
git clone https://github.com/yafiabiyyu/CeloSageFE.git

# Go to the frontend directory
cd CeloSageFE

# Install the dependencies
npm install --save

After cloning the frontend, we need to update the src/utils/contract.tsx, You need to update the contract address and ABI. You can get the contract address from the deployment result and you can get the ABI from the brownie project folder build/contracts/escrowNFT.json.

after updating the contract address and ABI, You can run the frontend using the following command.

npm start

result if the frontend is running successfully

Conclusion

In this tutorial, we have learned how to create an escrow smart contract using Solidity and Brownie. We have also learned how to deploy the smart contract to the Celo blockchain.

Next Steps

For your next steps, if you a python developer and want to learn how to create a smart contract using python, you can check about vyper. Vyper is a python-based smart contract programming language. You can check the Vyper documentation to learn more about Vyper.

About the Author

I am a blockchain and crypto enthusiast. I am also a software engineer. I love to learn new things and share my knowledge with others. You can find me on GitHub and LinkedIn.

Referance

4 Likes

@yafiabiyyu Where can I find the full source code for the contract?

2 Likes

I have 2 versions, js and also py you can look in one of the following repos

JS

Pythons

Ok good. Can you embed it in the tutorial?

3 Likes

OK, go ahead

1 Like

Ou and Hey @yafiabiyyu You should ask for this piece’s ownership to be passed to you, then you can do a review and make any necessary changes you deem fit for the article’s perfection, also the repo link @bobelr mentioned,
Tag @Celo_Academy to pass on ownership :clinking_glasses:

2 Likes

I just contacted @Celo_Academy to make me the owner of this tutorial

1 Like

Ok great. Soon as you have the ownership access, you can effect the changes. :+1:

6 Likes