Skip to content

Oladayo-Ahmod/escrow-contract

Repository files navigation

zksync-logo_upscaled

A Step-by-Step Guide to Building a Decentralized Escrow System on zkSync.

This article will delve into the intricate step-by-step process of building a decentralized escrow system that empowers users with secure, transparent, and efficient transactions on zkSync, a cutting-edge layer 2 scaling solution for Ethereum.

Table of Contents:

Introduction

zkSync is a layer 2 scaling solution for Ethereum. It is designed not only to increase the transaction throughput of the Ethereum network by reducing transaction costs and latency but also to fully preserve its foundational values – freedom, self-sovereignty, and decentralization. It employs zero-knowledge proofs to achieve scalability and privacy. One of the greatest features of zkSync is hyperscalability which ensures that as transactional demands escalate, the system seamlessly accommodates them without compromising its robust security measures or incurring additional costs.

Prerequisites

  • Basic understanding of Solidity.
  • Visual studio code (VS code) or remix ide.
  • Faucet: Follow this guide to obtain zkSync faucet.
  • Node js is installed on your machine.

Environment Setup

zkSync provides easy ways to get started with setting your environment by giving great plugins on Hardhat and Foundry. You can get started by using use one of the two. However, we will be using hardhat for this tutorial.

Run the below command inside your terminal to create the project with the necessary dependencies.

npx zksync-cli create escrow-contract --template hardhat_solidity

You will be prompted to enter your private key for the project. Enter your private or skip to set it later.

escrow2

Next, you will prompted to select package manager. You can select the most comfortable one for you. However, for this tutorial, we will select npm.

escrow3

After selecting npm or your desired package manager, all the required dependencies will be installed.

Understanding the Code Structure

Project structure

📁 Escrow-contract

  • 📁 contracts
    • 📁 erc20
      • 📄 ERC20Token.sol
    • 📁 nft
      • 📄 NFTContract.sol
    • 📁 paymasters
      • 📄 Paymaster.sol
    • 📄 Greeter.sol
  • 📁 deploy
    • 📁 erc20
      • 📄 deployERC20.ts
    • 📁 nft
      • 📄 deployNFT.ts
    • 📄 deploy.ts
    • 📄 interact.ts
    • 📄 utils.ts
  • 📁 test
    • 📁 erc20
      • 📄 erc20Token.test.ts
    • 📁 nft
      • 📄 nftContract.test.ts
    • 📄 greeter.test.ts

Your folder structure should be similar to the one above. These are all pre-generated by zksync-cli.

Next, go to the terminal and navigate into the project directory to delete unrequired files inside the contract , test and deploy folder by running the command below:

cd escrow-contract && rm -rf ./contracts/* && rm -rf ./deploy/erc20 && rm -rf ./deploy/nft && rm -rf ./test/*

Finally, run npm install to install all the dependencies. If you are using yarn , run yarn install .

Writing the Escrow Contract

Now that we have installed our project dependencies. Let's begin writing our smart contract. Inside your contract folder, create a new file named Escrow.sol. This file will contain all the Escrow's contract codes.

SPDX License Identifier

In solidity, the first thing to declare when writing a smart contract is to declare the spdx-lisense-identifier .

// SPDX-License-Identifier: MIT

Solidity Version

Next, let's define the contract pragma solidity version. In our case, 0.8.17.

pragma solidity 0.8.17;

Contract's Name

Let's go ahead and name the contract Escrow.

contract Escrow {
}

Variables

Let's define the state variables needed for the contract functionalities.

    address public purchaser;
    address public vendor;
    address public intermediary;
    uint256 public totalAmount;
    bool public isFunded;
    bool public isFinished;
    uint8 totalAgreements;

Let's explain the usefulness of all these variables to the escrow contract.

  • address public purchaser;: This variable stores the purchaser's address, the party who initiates the transaction.

  • address public vendor;: This variable stores the vendor's address, the party who provides the goods or services.

  • address public intermediary;: This variable stores the intermediary's address, a trusted third party who oversees the transaction process. In our case, the deployer of the contract.

  • uint256 public totalAmount;: This variable keeps track of the total amount of funds deposited in the escrow.

  • bool public isFunded;: This variable indicates whether the escrow has been funded or not.

  • bool public isFinished;: This variable indicates whether the escrow transaction has been completed or not.

  • uint8 totalAgreements;: This variable keeps track of the total agreements in the contract.

Mapping

Next, let's create a mapping for the Agreement struct.

  mapping(uint256 => Agreement) public agreements;

This mapping makes it easy to store and retrieve agreements based on their unique identifiers.

Struct

Now, let's define the agreement's struct with the necessary fields.

 struct Agreement{
  string title;
  string description;
  uint256 amount;
  address purchaser;
  address vendor;
 }

The Agreement struct defines the structure of an agreement in the escrow contract. It contains fields such as title, description, amount, purchaser, and vendor to store essential information about the agreement.

Modifiers

Modifiers are very important to enforce access control in solidity. Let's go ahead and create these modifiers.

  modifier onlyPurchaser() {
        require(msg.sender == purchaser, "Only the purchaser can call this function");
        _;
    }
    modifier onlyVendor() {
        require(msg.sender == vendor, "Only the vendor can call this function");
        _;
    }
    modifier onlyIntermediary() {
        require(msg.sender == intermediary, "Only the intermediary can call this function");
        _;
    }
    modifier onlyPurchaserOrIntermediary() {
        require(msg.sender == purchaser || msg.sender == intermediary, "Only the purchaser or intermediary can call this function");
        _;
    }
    modifier onlyNotFinished() {
        require(!isFinished, "Escrow has already been completed");
        _;
    }

Let's explain the functions of these modifiers.

  • modifier onlyPurchaser(): This modifier ensures that only the purchaser can call the function it modifies by checking if the sender's address matches the address of the purchaser.

  • modifier onlyVendor(): This modifier ensures that only the vendor can call the function it modifies by checking if the sender's address matches the address of the vendor.

  • modifier onlyIntermediary(): This modifier ensures that only the intermediary can call the function it modifies by checking if the sender's address matches the address of the intermediary.

  • modifier onlyPurchaserOrIntermediary(): This modifier allows either the purchaser or the intermediary to call the function it modifies by checking if the sender's address matches the address of the purchaser or the intermediary.

  • modifier onlyNotFinished(): This modifier ensures that the function it modifies can only be called if the escrow has not been completed yet. It checks if the variable isFinished is false, indicating that the escrow is not finished yet.

Events

Events are very important in smart contracts. We will be using these events to monitor the behaviors of our contract and make it easy to communicate with external applications and off-chain systems.

    event DepositMade(address indexed depositor, uint256 amount);
    event PaymentReleased(address indexed recipient, uint256 amount);
    event EscrowClosed();

Below are the explanations of these events:

  • event DepositMade(address indexed depositor, uint256 amount);: This event is emitted when a deposit is made into the escrow. It includes the address of the depositor (purchaser) and the amount deposited.

  • event PaymentReleased(address indexed recipient, uint256 amount);: This event is emitted when funds are released from the escrow. It includes the address of the recipient (vendor) and the amount released.

  • event EscrowClosed();: This event is emitted when the escrow is closed, indicating that the transaction process has been completed or terminated.

Constructor

  constructor() {
        intermediary = msg.sender;
    }

By default, the constructor sets whoever deploys the contract as the intermediary who oversees the transaction processes.

Register Purchaser

Now, let's create a function to enable a new user to register a purchaser.

 function registerPurchaser() external onlyNotFinished {
        require(purchaser == address(0), "Purchaser already registered");
        require(msg.sender != vendor, "Address is already registered as a vendor");
        purchaser = msg.sender;
    }

The registerPurchaser function allows the caller to register as a purchaser in the escrow contract. It checks if there is no purchaser currently registered in the contract and if the caller is not already registered as a vendor.

Register Vendor

Let's also create a function to enable a new user to join as a vendor.

 function registerVendor() external onlyNotFinished {
        require(vendor == address(0), "Vendor already registered");
        require(msg.sender != purchaser, "Address is already registered as a purchaser");
        vendor = msg.sender;
    }

The registerVendor function allows the caller to register as a vendor in the escrow contract if no vendor is currently registered and if the caller is not already registered as the purchaser.

Create Agreement

Next, let's define a function that allows vendor to create an agreement.

 function createAgreement(string memory _title, string memory _description, uint256 _amount) external onlyVendor{
        totalAgreements++;
        uint8 agreementId = totalAgreements;
        agreements[agreementId] = Agreement(_title,_description,_amount,address(0),msg.sender);
    }

This function allows a vendor to create a new agreement in the escrow contract. It increments the total number of agreements, assigns a unique ID to the new agreement, initializes its details, and stores it in the agreements mapping.

Enter Agreement

After creating functionality to create an agreement with a vendor, let's go ahead to define a function that allows purchaser enters agreement.

  function enterAgreement(uint8 _agreementId) external onlyPurchaser{
        require(_agreementId <= totalAgreements && _agreementId > 0, "agreement not found");
        Agreement storage agreement = agreements[_agreementId]; 
        agreement.purchaser = msg.sender;
    }

This function allows a purchaser to enter an existing agreement by entering _agreementId. It verifies the validity of the agreement ID and then assigns the purchaser's address to the agreement.

Deposit Funds

It is important to create a function that allows a purchaser to deposit into the escrow.

 function depositFunds(uint8 _agreementId) external onlyPurchaser payable onlyNotFinished {
        require(!isFunded, "Funds have already been deposited");
        require(_agreementId <= totalAgreements && _agreementId > 0, "agreement not found");
        Agreement storage agreement = agreements[_agreementId];
        uint budgetedAmount = agreement.amount;
        require(msg.value >= budgetedAmount, "Invalid deposit amount");
        totalAmount += msg.value;
        isFunded = true;
        emit DepositMade(purchaser, totalAmount);
    }

This function allows a purchaser to deposit funds into the escrow for a specific agreement he enters. It verifies that funds have not already been deposited, checks the validity of the agreement ID, ensures the deposited amount meets the budgeted amount specified in the agreement, updates the total amount stored in the escrow, sets the isFunded flag to true, and emits an event about the deposit.

Release Payment

Let's create a function that allows the intermediary release funds from the escrow to the vendor.

function releasePayment(uint8 _agreementId) external onlyIntermediary onlyNotFinished payable  {
        require(isFunded, "Funds must be deposited before releasing");
        require(_agreementId <= totalAgreements && _agreementId > 0, "agreement not found");
        Agreement storage agreement = agreements[_agreementId];
        uint amount = agreement.amount;
        (bool success, ) = vendor.call{value :amount}("");
        require(success, "Transfer to vendor failed");
        isFunded = false;
        isFinished = true;
        emit PaymentReleased(vendor, totalAmount);
        emit EscrowClosed();
    }

The releasePayment function allows the intermediary i.e. the deployer of the contract to release funds from the escrow to the vendor for a specific agreement. It checks if funds have been deposited, validates the agreement ID, transfers the specified amount to the vendor, updates the state of the escrow, and emits events about the payment release and the closure of the escrow transaction.

Refund Payment

Finally, let's create a function that handles refunds of payment.

 function refundPayment(uint8 _agreementId) external onlyPurchaserOrIntermediary onlyNotFinished {
        require(isFunded, "Funds must be deposited before refunding");
        require(_agreementId <= totalAgreements && _agreementId > 0, "agreement not found");
        Agreement storage agreement = agreements[_agreementId];
        uint amount = agreement.amount;
        (bool success,) = purchaser.call{value : amount}("");
        require(success , "Refund failed to purchaser");
        isFunded = false;
        isFinished = true;
        emit PaymentReleased(purchaser, totalAmount);
        emit EscrowClosed();
    }

The refundPayment function allows either the purchaser or the intermediary to refund funds from the escrow to the purchaser for a specific agreement only if the escrow is not yet completed. It checks if funds have been deposited, validates the agreement ID, transfers the specified amount to the purchaser, updates the state of the escrow, and emits events about the payment release and the closure of the escrow transaction.

Complete Code

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

contract Escrow {
    address public purchaser;
    address public vendor;
    address public intermediary;
    uint256 public totalAmount;
    bool public isFunded;
    bool public isFinished;
    uint8 totalAgreements;

    event DepositMade(address indexed depositor, uint256 amount);
    event PaymentReleased(address indexed recipient, uint256 amount);
    event EscrowClosed();

    mapping(uint256 => Agreement) public agreements;

    modifier onlyPurchaser() {
        require(msg.sender == purchaser, "Only the purchaser can call this function");
        _;
    }

    modifier onlyVendor() {
        require(msg.sender == vendor, "Only the vendor can call this function");
        _;
    }

    modifier onlyIntermediary() {
        require(msg.sender == intermediary, "Only the intermediary can call this function");
        _;
    }

    modifier onlyPurchaserOrIntermediary() {
        require(msg.sender == purchaser || msg.sender == intermediary, "Only the purchaser or intermediary can call this function");
        _;
    }

    modifier onlyNotFinished() {
        require(!isFinished, "Escrow has already been completed");
        _;
    }

    struct Agreement{
        string title;
        string description;
        uint256 amount;
        address purchaser;
        address vendor;
    }

    constructor() {
        intermediary = msg.sender;
    }

    function createAgreement(string memory _title, string memory _description, uint256 _amount) external onlyVendor{
        totalAgreements++;
        uint8 agreementId = totalAgreements;
        agreements[agreementId] = Agreement(_title,_description,_amount,address(0),msg.sender);

    }

    function enterAgreement(uint8 _agreementId) external onlyPurchaser{
        require(_agreementId <= totalAgreements && _agreementId > 0, "agreement not found");
        Agreement storage agreement = agreements[_agreementId]; 
        agreement.purchaser = msg.sender;
    }

    function registerPurchaser() external onlyNotFinished {
        require(purchaser == address(0), "Purchaser already registered");
        require(msg.sender != vendor, "Address is already registered as a vendor");
        purchaser = msg.sender;
    }
 
    function registerVendor() external onlyNotFinished {
        require(vendor == address(0), "Vendor already registered");
        require(msg.sender != purchaser, "Address is already registered as a purchaser");
        vendor = msg.sender;
    }

    function depositFunds(uint8 _agreementId) external onlyPurchaser payable onlyNotFinished {
        require(!isFunded, "Funds have already been deposited");
        require(_agreementId <= totalAgreements && _agreementId > 0, "agreement not found");
        Agreement storage agreement = agreements[_agreementId];
        uint budgetedAmount = agreement.amount;
        require(msg.value >= budgetedAmount, "Invalid deposit amount");
        totalAmount += msg.value;
        isFunded = true;
        emit DepositMade(purchaser, totalAmount);
    }

    function releasePayment(uint8 _agreementId) external onlyIntermediary onlyNotFinished payable  {
        require(isFunded, "Funds must be deposited before releasing");
        require(_agreementId <= totalAgreements && _agreementId > 0, "agreement not found");
        Agreement storage agreement = agreements[_agreementId];
        uint amount = agreement.amount;
        (bool success, ) = vendor.call{value :amount}("");
        require(success, "Transfer to vendor failed");
        isFunded = false;
        isFinished = true;
        emit PaymentReleased(vendor, totalAmount);
        emit EscrowClosed();
    }

    function refundPayment(uint8 _agreementId) external onlyPurchaserOrIntermediary onlyNotFinished {
        require(isFunded, "Funds must be deposited before refunding");
        require(_agreementId <= totalAgreements && _agreementId > 0, "agreement not found");
        Agreement storage agreement = agreements[_agreementId];
        uint amount = agreement.amount;
        (bool success,) = purchaser.call{value : amount}("");
        require(success , "Refund failed to purchaser");
        isFunded = false;
        isFinished = true;
        emit PaymentReleased(purchaser, totalAmount);
        emit EscrowClosed();
    }

}

Compiling Smart Contract

To begin compiling the smart contract, let's pay attention to the hardhat.config.ts located in the root directory of the project folder.

import { HardhatUserConfig } from "hardhat/config";

import "@matterlabs/hardhat-zksync";

const config: HardhatUserConfig = {
  defaultNetwork: "zkSyncSepoliaTestnet",
  networks: {
    zkSyncSepoliaTestnet: {
      url: "https://sepolia.era.zksync.dev",
      ethNetwork: "sepolia",
      zksync: true,
      verifyURL: "https://explorer.sepolia.era.zksync.dev/contract_verification",
    },
    zkSyncMainnet: {
      url: "https://mainnet.era.zksync.io",
      ethNetwork: "mainnet",
      zksync: true,
      verifyURL: "https://zksync2-mainnet-explorer.zksync.io/contract_verification",
    },
    zkSyncGoerliTestnet: { // deprecated network
      url: "https://testnet.era.zksync.dev",
      ethNetwork: "goerli",
      zksync: true,
      verifyURL: "https://zksync2-testnet-explorer.zksync.dev/contract_verification",
    },
    dockerizedNode: {
      url: "http://localhost:3050",
      ethNetwork: "http://localhost:8545",
      zksync: true,
    },
    inMemoryNode: {
      url: "http://127.0.0.1:8011",
      ethNetwork: "localhost", // in-memory node doesn't support eth node; removing this line will cause an error
      zksync: true,
    },
    hardhat: {
      zksync: true,
    },
  },
  zksolc: {
    version: "latest",
    settings: {
      // find all available options in the official documentation
      // https://era.zksync.io/docs/tools/hardhat/hardhat-zksync-solc.html#configuration
    },
  },
  solidity: {
    version: "0.8.17",
  },
};

export default config;

By default, your hardhat.config.ts should be similar to the one above. This configuration file provides settings for the smart contract development environment.

Next, run the below command to compile the contract.

npm run compile

After running the command, your terminal should output a similar result to this. escrow3

This shows that the escrow smart contract has been successfully compiled.

Writing Tests

If you are on Windows, you need to install WSL2 to run zkSync nodes. Otherwise, you can skip this section.

Go ahead and create a file named escrow.test.ts inside your test folder.

Next, paste the following code to carry out the testing.

import { expect } from 'chai';
import { Contract, Wallet } from "zksync-ethers";
import { getWallet, deployContract, LOCAL_RICH_WALLETS } from '../deploy/utils';
import * as ethers from "ethers";

describe('Escrow Contract', ()=>{
    let escrowContract: Contract;
    let intermediary: Wallet;
    let vendor: Wallet;
    let purchaser: Wallet;

    before(async function () {
        intermediary = getWallet(LOCAL_RICH_WALLETS[0].privateKey);
        vendor = getWallet(LOCAL_RICH_WALLETS[1].privateKey);
        purchaser = getWallet(LOCAL_RICH_WALLETS[2].privateKey);
    
        escrowContract = await deployContract("Escrow", [], { wallet: intermediary, silent: true });
    });

    it('Should allow purchaser to register', async function () {
      await (escrowContract.connect(purchaser) as Contract).registerPurchaser();
      expect(await escrowContract.purchaser()).to.equal(purchaser.address);
    });

    it('Should allow vendor to register', async function () {
      await (escrowContract.connect(vendor) as Contract).registerVendor();
      expect(await escrowContract.vendor()).to.equal(vendor.address);
    });

    it('Should create an agreement', async function () {
        const title = 'Test Agreement';
        const description = 'This is a test agreement';
        const amount = ethers.parseEther('1');
    
        await (escrowContract.connect(vendor) as Contract).createAgreement(title, description, amount);
        const agreement = await escrowContract.agreements(1);
    
        expect(agreement.title).to.equal(title);
        expect(agreement.description).to.equal(description);
        expect(agreement.amount).to.equal(amount);
        expect(agreement.vendor).to.equal(await vendor.getAddress());
      });

      it('Should allow purchaser to enter an agreement', async function () {
        await (escrowContract.connect(purchaser)as Contract).enterAgreement(1);
    
        const agreement = await escrowContract.agreements(1);
        expect(agreement.purchaser).to.equal(await purchaser.getAddress());
      });

      
      it('Should allow purchaser to deposit funds', async function () {
        const amount = ethers.parseEther('1');
        await (escrowContract.connect(purchaser) as Contract).depositFunds(1, { value: amount })
        expect(await escrowContract.isFunded()).to.true
      });

      it('Should allow intermediary to release payment', async function () {
        await (escrowContract.connect(intermediary) as Contract).releasePayment(1)
        expect(await escrowContract.isFunded()).to.false
        expect(await escrowContract.isFinished()).to.true
      });

      it('Should not allow refund payment after escrow is finished', async function () {
        try {
            const release = await (escrowContract.connect(intermediary) as Contract).refundPayment(1)
            await release.wait()
            expect.fail('Expected payment to fail but it did not')
        } catch (error) {
            expect(error.message).to.include("Escrow has already been completed");
        }
        
      }) 

})

Next, run the below command in your terminal.

npm run test

You should see a similar output if all the tests passed. escrow4

Paymaster Introduction

A paymaster is a specialized smart contract designed to manage or sponsor transaction fees on behalf of other accounts. By acting as an intermediary, paymasters offer a range of benefits to users, developers, and the overall network

Paymaster originally focused on minimizing user transaction costs by covering transaction fees, making dApps more accessible and user-friendly because lower transaction costs can attract more users to a platform.

Paymaster is essentially a smart contract that can cover transaction costs for other users, making the dApp transactions free for end-users. One of the important aspects of Paymaster is the ability to use the ERC token instead of the Zksync native token.

Paymaster can be designed to handle transactions automatically or designed to require user interactions before execution.

Paymaster Integration

In this section, we will be creating and integrating the Paymaster contract in our dApp to sponsor every gas fee incurred by users.

Go ahead and create a file named Paymaster.sol in your contracts directory i.e in the same folder as Escrow.sol.

Next, let's import the necessary interfaces and libraries for paymaster functionality. You do not need to install these dependencies manually because they have already been generated when you run the cli command for installations.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {IPaymaster, ExecutionResult, PAYMASTER_VALIDATION_SUCCESS_MAGIC} from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymaster.sol";
import {IPaymasterFlow} from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymasterFlow.sol";
import {TransactionHelper, Transaction} from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol";

import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol";

import "@openzeppelin/contracts/access/Ownable.sol";

After that, let's define a contract named GaslessPaymaster that inherits from IPaymaster and Ownable.

contract GaslessPaymaster is IPaymaster, Ownable {

}

Next, let's define a modifier called onlyBootloader that restricts function calls to the bootloader contract.

  modifier onlyBootloader() {
        require(
            msg.sender == BOOTLOADER_FORMAL_ADDRESS,
            "Only bootloader can call this method"
        );
        // Continue execution if called from the bootloader.
        _;
    }

Let's begin writing the functions needed for our Paymaster's contract.

 function validateAndPayForPaymasterTransaction(
        bytes32,
        bytes32,
        Transaction calldata _transaction
    )
        external
        payable
        onlyBootloader
        returns (bytes4 magic, bytes memory context)
    {
        // By default we consider the transaction as accepted.
        magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC;
        require(
            _transaction.paymasterInput.length >= 4,
            "The standard paymaster input must be at least 4 bytes long"
        );

        bytes4 paymasterInputSelector = bytes4(
            _transaction.paymasterInput[0:4]
        );
        if (paymasterInputSelector == IPaymasterFlow.general.selector) {
            // Note, that while the minimal amount of ETH needed is tx.gasPrice * tx.gasLimit,
            // neither paymaster nor account are allowed to access this context variable.
            uint256 requiredETH = _transaction.gasLimit *
                _transaction.maxFeePerGas;

            // The bootloader never returns any data, so it can safely be ignored here.
            (bool success, ) = payable(BOOTLOADER_FORMAL_ADDRESS).call{
                value: requiredETH
            }("");
            require(
                success,
                "Failed to transfer tx fee to the Bootloader. Paymaster balance might not be enough."
            );
        } else {
            revert("Unsupported paymaster flow in paymasterParams.");
        }
    }

This function is called by the bootloader to validate and pay for a transaction.

Next, let's declare PostTransaction which is called after the transaction execution. Currently, it does nothing as refunds are not supported yet.

function postTransaction(
        bytes calldata _context,
        Transaction calldata _transaction,
        bytes32,
        bytes32,
        ExecutionResult _txResult,
        uint256 _maxRefundedGas
    ) external payable override onlyBootloader {
        // Refunds are not supported yet.
    }

Finally, let's create a withdraw function to allow the contract owner to withdraw any remaining funds.

 function withdraw(address payable _to) external onlyOwner {
        // send paymaster funds to the owner
        uint256 balance = address(this).balance;
        (bool success, ) = _to.call{value: balance}("");
        require(success, "Failed to withdraw funds from paymaster.");
    }

And lastly, let's create a recieve function to allow the contract to receive payments.

receive() external payable {}

Paymaster Complete Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {IPaymaster, ExecutionResult, PAYMASTER_VALIDATION_SUCCESS_MAGIC} from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymaster.sol";
import {IPaymasterFlow} from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymasterFlow.sol";
import {TransactionHelper, Transaction} from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol";

import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol";

import "@openzeppelin/contracts/access/Ownable.sol";

/// @author Matter Labs
/// @notice This contract does not include any validations other than using the paymaster general flow.
contract GaslessPaymaster is IPaymaster, Ownable {
    modifier onlyBootloader() {
        require(
            msg.sender == BOOTLOADER_FORMAL_ADDRESS,
            "Only bootloader can call this method"
        );
        // Continue execution if called from the bootloader.
        _;
    }

    function validateAndPayForPaymasterTransaction(
        bytes32,
        bytes32,
        Transaction calldata _transaction
    )
        external
        payable
        onlyBootloader
        returns (bytes4 magic, bytes memory context)
    {
        // By default we consider the transaction as accepted.
        magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC;
        require(
            _transaction.paymasterInput.length >= 4,
            "The standard paymaster input must be at least 4 bytes long"
        );

        bytes4 paymasterInputSelector = bytes4(
            _transaction.paymasterInput[0:4]
        );
        if (paymasterInputSelector == IPaymasterFlow.general.selector) {
            // Note, that while the minimal amount of ETH needed is tx.gasPrice * tx.gasLimit,
            // neither paymaster nor account are allowed to access this context variable.
            uint256 requiredETH = _transaction.gasLimit *
                _transaction.maxFeePerGas;

            // The bootloader never returns any data, so it can safely be ignored here.
            (bool success, ) = payable(BOOTLOADER_FORMAL_ADDRESS).call{
                value: requiredETH
            }("");
            require(
                success,
                "Failed to transfer tx fee to the Bootloader. Paymaster balance might not be enough."
            );
        } else {
            revert("Unsupported paymaster flow in paymasterParams.");
        }
    }

    function postTransaction(
        bytes calldata _context,
        Transaction calldata _transaction,
        bytes32,
        bytes32,
        ExecutionResult _txResult,
        uint256 _maxRefundedGas
    ) external payable override onlyBootloader {
        // Refunds are not supported yet.
    }

    function withdraw(address payable _to) external onlyOwner {
        // send paymaster funds to the owner
        uint256 balance = address(this).balance;
        (bool success, ) = _to.call{value: balance}("");
        require(success, "Failed to withdraw funds from paymaster.");
    }

    receive() external payable {}
}

Deploying Smart Contract

A few things are required to do to deploy this smart contract to zkSync.

Firstly, go ahead and enter your private key in the .env file in the root directory.

WALLET_PRIVATE_KEY=YOUR-PRIVATE-KEY

Note: You should replace YOUR-PRIVATE-KEY with your actual private key and ensure you have already some zksync faucets.

Next, open deploy.ts in your deploy folder and replace it with the code below.

import { deployContract } from "./utils";
export default async function () {
  const contractArtifactName = "Escrow";
  const constructorArguments = [];
  await deployContract(contractArtifactName, constructorArguments);
}

Finally, proceed to your terminal and run the deploy script by entering the following command.

npm run deploy

Your output should be similar to the below output if it is successfully deployed. However, estimated deployment cost,contract address, and verification ID may be different. escrow5

Deploying and Funding Paymaster

To deploy and fund the Paymaster, create a file named deploy-paymaster.ts inside your deploy folder.

and paste the following code there.

import { deployContract, getWallet, getProvider } from "./utils";
import * as ethers from "ethers";

export default async function () {
   await deployContract("Escrow",[]);
  const paymaster = await deployContract("GaslessPaymaster");

  const paymasterAddress = await paymaster.getAddress();

  // Supplying paymaster with ETH
  console.log("Funding paymaster with ETH...");
  const wallet = getWallet();
  await (
    await wallet.sendTransaction({
      to: paymasterAddress,
      value: ethers.parseEther("0.05"),
    })
  ).wait();

  const provider = getProvider();
  const paymasterBalance = await provider.getBalance(paymasterAddress);
  console.log(`Paymaster ETH balance is now ${paymasterBalance.toString()}`);

  console.log(`Done!`);
}

Our contract name Escrow is passed in deployContract function and empty array [] is passed as the second argument because the contract's constructor doesn't require an argument while paymaster contract's name GaslessPaymaster is passed in the second deployContract function.

However, you can comment out this line await deployContract("Escrow",[]); if you already have a deployed contract and you do not want to redeploy it.

Finally, the function below get the wallet through the supplied private key in the .env and funds it with 0.08 ETH.

 const wallet = getWallet();
  await (
    await wallet.sendTransaction({
      to: paymasterAddress,
      value: ethers.parseEther("0.05"),
    })
  ).wait();

Note: before you proceed to deploy your contracts, ensure wallet balance is more than the amount you are funding and re-compile your contracts by running npm run compile. Your result should be similar to the below result if your contracts compile successfully. You can ignore the warning errors, these won't affect the functionalities.

paymaster-compile

To make the deployment easy, go to your package.json and this command below to your deployscript.

"deploy-paymaster": "hardhat deploy-zksync --script deploy-paymaster.ts",

Your script commands should look like this now.

  "scripts": {
    "deploy": "hardhat deploy-zksync --script deploy.ts",
    "deploy-paymaster": "hardhat deploy-zksync --script deploy-paymaster.ts",
    "interact": "hardhat deploy-zksync --script interact.ts",
    "compile": "hardhat compile",
    "clean": "hardhat clean",
    "test": "hardhat test --network hardhat"
  },

Finally, proceed to your terminal and run the paymaster deployment script by entering the following command.

npm run deploy-paymaster

If your deployment is successful, you should get a similar result in your terminal like the one below :

paymaster-deploy

Congratulation! You have successfully written, tested, and deployed a gasless decentralized escrow system on zkSync.

Frontend Interaction with the Escrow Contracts

In this section, we will explore how to interact with the Escrow contract using Next.js and Ethers.js. We'll cover setting up the Next.js environment, connecting to the blockchain, and interfacing with various functions of the smart contract.

Setting Up Next.js Project

First, create a new Next.js project. You can set it up using the following command inside your project root directory:

npx create-next-app escrow-dapp
cd escrow-dapp

Once the project is set up, install ethers.js, a JavaScript library that helps interact with the Ethereum blockchain by running the command below :

npm install ethers

Also, let's add Bootstrap to the project to style our UI components. Install Bootstrap by running the following command:

npm install bootstrap

Setting Connection to the Blockchain

First, create a new folder 📂 utils, and create a new file ethers.js inside it to handle the connection setup:

import { ethers } from 'ethers';

let provider;
let signer;

if (typeof window !== 'undefined' && typeof window.ethereum !== 'undefined') {
  // We are in the browser and MetaMask is running
  provider = new ethers.BrowserProvider(window.ethereum);
} else {
  // We are on the server *OR* the user is not running MetaMask
  provider = new ethers.JsonRpcProvider('https://mainnet.infura.io/v3/YOUR_INFURA_PROJECT_ID');
}

const connectWallet = async () => {
  await provider.send('eth_requestAccounts', []);
  signer = await provider.getSigner();
};

export { provider, signer, connectWallet };

Note: Replace YOUR_INFURA_PROJECT_ID with your Infura project ID.

Next, create a new folder 📂 abi, and create a new file Escrow.json. Copy the ABI from your deployed Escrow contract and paste it into the file you created.

Interacting with the Escrow Contract

First, let's import Bootstrap CSS inside layout.js or layout.ts to make Bootstrap styles available globally in the application.

import 'bootstrap/dist/css/bootstrap.min.css';

Currently, your folder should be similar to the one in the image below :

folder-structure

Now, let's paste the following code into page.js or page.ts in case you are using Typescript.

import React, { useState } from 'react';
import { ethers } from 'ethers';
import EscrowABI from '../app/abi/Escrow.json';
import { connectWallet, signer } from '../app/utils/ethers';

const Escrow = () => {
  const [contract, setContract] = useState(null);
  const [status, setStatus] = useState('');
  const [agreementId, setAgreementId] = useState('');
  const [agreementDetails, setAgreementDetails] = useState({
    title: '',
    description: '',
    amount: '',
  });

  const contractAddress = 'YOUR_CONTRACT_ADDRESS_HERE';

  const initializeContract = async () => {
    await connectWallet();
    const escrowContract = new ethers.Contract(contractAddress, EscrowABI, signer);
    setContract(escrowContract);
    setStatus('Contract initialized!');
  };

  const handleInputChange = (e) => {
    setAgreementDetails({ ...agreementDetails, [e.target.name]: e.target.value });
  };

  const registerPurchaser = async () => {
    try {
      const tx = await contract.registerPurchaser();
      await tx.wait();
      setStatus('Purchaser registered!');
    } catch (err) {
      console.error(err);
      setStatus('Error registering purchaser');
    }
  };

  const registerVendor = async () => {
    try {
      const tx = await contract.registerVendor();
      await tx.wait();
      setStatus('Vendor registered!');
    } catch (err) {
      console.error(err);
      setStatus('Error registering vendor');
    }
  };

  const createAgreement = async () => {
    try {
      const { title, description, amount } = agreementDetails;
      const tx = await contract.createAgreement(title, description, ethers.parseEther(amount));
      await tx.wait();
      setStatus('Agreement created!');
    } catch (err) {
      console.error(err);
      setStatus('Error creating agreement');
    }
  };

  const enterAgreement = async () => {
    try {
      const tx = await contract.enterAgreement(agreementId);
      await tx.wait();
      setStatus('Entered into agreement!');
    } catch (err) {
      console.error(err);
      setStatus('Error entering agreement');
    }
  };

  const depositFunds = async () => {
    try {
      const tx = await contract.depositFunds(agreementId, {
        value: ethers.parseEther(agreementDetails.amount),
      });
      await tx.wait();
      setStatus('Funds deposited!');
    } catch (err) {
      console.error(err);
      setStatus('Error depositing funds');
    }
  };

  const releasePayment = async () => {
    try {
      const tx = await contract.releasePayment(agreementId);
      await tx.wait();
      setStatus('Payment released!');
    } catch (err) {
      console.error(err);
      setStatus('Error releasing payment');
    }
  };

  const refundPayment = async () => {
    try {
      const tx = await contract.refundPayment(agreementId);
      await tx.wait();
      setStatus('Payment refunded!');
    } catch (err) {
      console.error(err);
      setStatus('Error refunding payment');
    }
  };

  return (
    <div className="container">
      <h1 className="mt-4">Escrow DApp</h1>
      <button className="btn btn-primary" onClick={initializeContract}>
        Connect to Contract
      </button>
      <p>{status}</p>

      <button className="btn btn-success mt-3" onClick={registerPurchaser}>
        Register as Purchaser
      </button>

      <button className="btn btn-info mt-3" onClick={registerVendor}>
        Register as Vendor
      </button>

      <div className="form-group mt-3">
        <input
          type="text"
          className="form-control"
          placeholder="Title"
          name="title"
          value={agreementDetails.title}
          onChange={handleInputChange}
        />
        <textarea
          className="form-control mt-2"
          placeholder="Description"
          name="description"
          value={agreementDetails.description}
          onChange={handleInputChange}
        />
        <input
          type="text"
          className="form-control mt-2"
          placeholder="Amount (ETH)"
          name="amount"
          value={agreementDetails.amount}
          onChange={handleInputChange}
        />
        <button className="btn btn-warning mt-3" onClick={createAgreement}>
          Create Agreement
        </button>
      </div>

      <div className="form-group mt-3">
        <input
          type="text"
          className="form-control"
          placeholder="Agreement ID"
          value={agreementId}
          onChange={(e) => setAgreementId(e.target.value)}
        />
        <button className="btn btn-secondary mt-2" onClick={enterAgreement}>
          Enter Agreement
        </button>
      </div>

      <button className="btn btn-danger mt-3" onClick={depositFunds}>
        Deposit Funds
      </button>

      <button className="btn btn-success mt-3" onClick={releasePayment}>
        Release Payment
      </button>

      <button className="btn btn-dark mt-3" onClick={refundPayment}>
        Refund Payment
      </button>
    </div>
  );
};

export default Escrow;

Note: Do not forget to replace YOUR_CONTRACT_ADDRESS_HERE with your contract address.

Brief explanation of the functions.

The registerPurchaser function allows a user to register themselves as a purchaser. Only one purchaser can be registered at a time.

Similarly, the registerVendor function registers the caller as the vendor. Like the purchaser, only one vendor can be registered.

The createAgreement function allows the vendor to create an agreement by specifying a title, description, and amount. We will add form inputs to gather these details and a button to trigger the contract function.

The enterAgreement function allows the purchaser to enter into an agreement created by the vendor. This requires an agreement ID.

The depositFunds function allows the purchaser to deposit funds into the escrow for a specific agreement. This also requires an agreement ID and the ETH amount.

The releasePayment function is called by the intermediary to release the escrowed funds to the vendor. This function also requires an agreement ID.

The refundPayment function allows either the purchaser or intermediary to refund the funds back to the purchaser if the escrow is not completed.

Finally, start your application by running npm run dev inside your terminal inside your escrow-dapp directory.

frontend

Releases

No releases published

Packages

 
 
 

Contributors