Skip to main content

Compiling and Testing Contracts

The three Solidity contracts that form the RealEstateToken project are set out in full here:
  1. Whitelist - manages the Whitelist of approved investors.
  2. RealEstateFactory - deploys RealEstateToken contracts.
  3. RealEstateToken - Tokenization of a real estate development project.
Compiling and testing these contracts is simple. Foundry with it’s Solidity centric test scripts, and builtin helpers and assertions, make it straightforward to write unit tests for our contracts. The example project has a basic suite of tests for each contract.

Building

To build the Solidity contracts are built simply with:
forge build

Testing

Foundry unit tests are found in the test directory. There are a number of ways in which you can structure your tests. For simplicity, we’ve written one test script per contract. Test scripts are written in Solidity and the filename must have the form FileName.t.sol. You can read more about testing with Foundry here: https://getfoundry.sh/forge/tests/overview You can run your unit tests in Foundry with a simple:
forge test
This will run all test scripts int the test directory. The sample project includes the following tests scripts:

Whitelist.t.sol

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

import "forge-std/Test.sol";
import {Whitelist} from "../src/Whitelist.sol";

contract WhitelistTest is Test {
    Whitelist wl;

    address INVESTOR_A = vm.addr(1);
    address INVESTOR_B = vm.addr(2);

    address PROJECT_A = address(0xA1);
    address PROJECT_B = address(0xB2);

    event Approved(address indexed project, address indexed investor);
    event Revoked(address indexed project, address indexed investor);

    function setUp() public {
        wl = new Whitelist();
    }

    function test_defaultFalse() public view {
        assertFalse(wl.isApproved(PROJECT_A, INVESTOR_A));
        assertFalse(wl.isApproved(PROJECT_A, INVESTOR_B));
    }

    function test_approve() public {
        vm.expectEmit(true, true, false, false);
        emit Approved(PROJECT_A, INVESTOR_A);
        wl.approve(PROJECT_A, INVESTOR_A);
        assertTrue(wl.isApproved(PROJECT_A, INVESTOR_A));
    }

    function test_revoke() public {
        wl.approve(PROJECT_A, INVESTOR_A);
        vm.expectEmit(true, true, false, false);
        emit Revoked(PROJECT_A, INVESTOR_A);
        wl.revoke(PROJECT_A, INVESTOR_A);
        assertFalse(wl.isApproved(PROJECT_A, INVESTOR_A));
    }
}

RealEstateFactory.t.sol

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

import "forge-std/Test.sol";
import {RealEstateFactory} from "../src/RealEstateFactory.sol";
import {RealEstateToken} from "../src/RealEstateToken.sol";

interface IWhiteList {
    function isApproved(address, address) external view returns (bool);
}
contract MockWhitelistAlwaysTrue is IWhiteList {
    function isApproved(address, address) external pure returns (bool) {
        return true;
    }
}

contract RealEstateFactoryTest is Test {
    RealEstateFactory factory;
    MockWhitelistAlwaysTrue wl;

    address INVESTOR_A = vm.addr(1); // used as issuer in one test
    address INVESTOR_B = vm.addr(2);

    string NAME = "Project A";
    string SYMBOL = "PJA";
    string PROPERTY_ID = "PJA-001";
    string JURIS = "AU";
    string META_URI = "ipfs://cid-a";

    function setUp() public {
        wl = new MockWhitelistAlwaysTrue();
        factory = new RealEstateFactory(address(wl));
    }

    function test_constructor() public view {
        assertEq(factory.whitelist(), address(wl));
        assertEq(factory.owner(), address(this));
    }

    function test_deployProject() public {
        address tokenAddr = factory.deployProject(
            NAME,
            SYMBOL,
            PROPERTY_ID,
            JURIS,
            META_URI
        );
        assertTrue(tokenAddr != address(0));

        address[] memory all = factory.getAllProjects();
        assertEq(all.length, 1);
        assertEq(all[0], tokenAddr);

        RealEstateToken t = RealEstateToken(tokenAddr);
        assertEq(t.name(), NAME);
        assertEq(t.symbol(), SYMBOL);
        assertEq(t.propertyId(), PROPERTY_ID);
        assertEq(t.jurisdiction(), JURIS);
        assertEq(t.metadataUri(), META_URI);
        assertEq(address(t.whitelist()), address(wl));
        assertEq(t.owner(), address(this));
    }

    function test_multipleDeployers() public {
        vm.prank(INVESTOR_A);
        address a = factory.deployProject("A", "A", "A", "AU", "ipfs://a");
        vm.prank(INVESTOR_B);
        address b = factory.deployProject("B", "B", "B", "AU", "ipfs://b");

        address[] memory all = factory.getAllProjects();
        assertEq(all.length, 2);
        assertEq(RealEstateToken(a).owner(), INVESTOR_A);
        assertEq(RealEstateToken(b).owner(), INVESTOR_B);
    }
}

RealEstateToken.t.sol

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

import "forge-std/Test.sol";
import {RealEstateToken} from "../src/RealEstateToken.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

interface IWhiteList {
    function isApproved(
        address project,
        address investor
    ) external view returns (bool);
}
contract MockWhitelist is IWhiteList {
    mapping(address => mapping(address => bool)) public approved;
    function set(address project, address investor, bool ok) external {
        approved[project][investor] = ok;
    }
    function isApproved(
        address project,
        address investor
    ) external view returns (bool) {
        return approved[project][investor];
    }
}

contract RealEstateTokenTest is Test {
    MockWhitelist wl;
    RealEstateToken token;

    address INVESTOR_A = vm.addr(1);
    address INVESTOR_B = vm.addr(2);

    string NAME = "Project A";
    string SYMBOL = "PJA";
    string PROPERTY_ID = "PJA-001";
    string JURIS = "AU";
    string META_URI = "ipfs://cid-a";

    function setUp() public {
        wl = new MockWhitelist();
        token = new RealEstateToken(
            NAME,
            SYMBOL,
            address(wl),
            PROPERTY_ID,
            JURIS,
            META_URI,
            address(this) // project owner
        );
    }

    function test_metadata() public view {
        assertEq(token.name(), NAME);
        assertEq(token.symbol(), SYMBOL);
        assertEq(token.propertyId(), PROPERTY_ID);
        assertEq(token.jurisdiction(), JURIS);
        assertEq(token.metadataUri(), META_URI);
        assertEq(token.decimals(), 0);
        assertEq(token.MAX_SUPPLY(), 100);
        assertEq(address(token.whitelist()), address(wl));
        assertEq(token.owner(), address(this));
    }

    function test_mintWhenApproved() public {
        wl.set(address(token), INVESTOR_A, true);
        token.mint(INVESTOR_A, 10);
        assertEq(token.totalSupply(), 10);
        assertEq(token.balanceOf(INVESTOR_A), 10);
        assertTrue(token.isShareholder(INVESTOR_A));
    }

    function test_mintWhenNotApprovedReverts() public {
        vm.expectRevert(bytes("Investor not approved for this project."));
        token.mint(INVESTOR_A, 1);
    }

    function test_transferRequiresWhitelist() public {
        wl.set(address(token), INVESTOR_A, true);
        wl.set(address(token), INVESTOR_B, true);

        token.mint(INVESTOR_A, 5);

        vm.prank(INVESTOR_A);
        token.transfer(INVESTOR_B, 3);

        assertEq(token.balanceOf(INVESTOR_A), 2);
        assertEq(token.balanceOf(INVESTOR_B), 3);
        assertTrue(token.isShareholder(INVESTOR_B));
    }

    function test_transferToUnapprovedReverts() public {
        wl.set(address(token), INVESTOR_A, true);
        token.mint(INVESTOR_A, 1);

        // INVESTOR_B not approved
        vm.prank(INVESTOR_A);
        vm.expectRevert(bytes("Recipient is not approved for this project"));
        token.transfer(INVESTOR_B, 1);
    }

    function test_adminBurnOnlyOwner() public {
        wl.set(address(token), INVESTOR_A, true);
        token.mint(INVESTOR_A, 4);

        // non-owner cannot call adminBurn
        vm.prank(INVESTOR_A);
        vm.expectRevert(
            abi.encodeWithSelector(
                Ownable.OwnableUnauthorizedAccount.selector,
                INVESTOR_A
            )
        );
        token.adminBurn(INVESTOR_A, 1);

        // owner can burn
        token.adminBurn(INVESTOR_A, 2);
        assertEq(token.totalSupply(), 2);
        assertEq(token.balanceOf(INVESTOR_A), 2);
    }
}

Whitelist Contract

The Whitelist contract is responsible for managing the account addressed of approved investors. This is intended to represent a basic KYC/AML process. Only the dApp owner can approve, and revoke an investor’s account address. The ‘Whitelistcontract is the first to be deployed as its contract address is needed by theRealEstateFactory` constructor.

Whitelist

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

import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";
// import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

// A simple whitelist for real estate tokenisation projects
// Provides a whitelist of investor accounts for real estate projects (identified by contract address)
// WARNING: This contract is an example only - do not use in production
contract Whitelist is Ownable2Step {
    // Mapping of project => investor => approved?
    mapping(address => mapping(address => bool)) private _approved;

    // Emitted when an investor is approved for a project
    event Approved(address indexed project, address indexed investor);

    // Emitted when an investor approval is revoked
    event Revoked(address indexed project, address indexed investor);

    constructor() Ownable(msg.sender) {}

    // Add an investor's account address to the whitelist for a specified project
    function approve(address project, address investor) external onlyOwner {
        _approved[project][investor] = true;
        emit Approved(project, investor);
    }

    // Remove an investor's account address from the whitelist for a specified project
    function revoke(address project, address investor) external onlyOwner {
        _approved[project][investor] = false;
        emit Revoked(project, investor);
    }

    // Check if an investor's account address is whitelisted for a specified project
    function isApproved(
        address project,
        address investor
    ) external view returns (bool) {
        return _approved[project][investor];
    }
}

RealEstateFactory Contract

The RealEstateFactory contract is responsible for deploying new RealEstateToken contracts with the deployProject() function. It keeps a record of deployed RealEstateToken contracts deployed in the allProjects state variable which can be accessed by the getAllProjects() function. The Whitelist contract must be deployed before the RealEstateFactory contract. RealEstateFactory requires the address of the deployed Whitelist contract that manages approved investor addresses.

RealEstateFactory

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

import {RealEstateToken} from "./RealEstateToken.sol";
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";

// A factory contract to deploy new RealEstateToken contracts
// Factory deploys RealEstateToken contracts representing tokenisation of a real estate assets
// WARNING: This contract is an example only - do not use in production
contract RealEstateFactory is Ownable2Step {
    // Address of the deployed Whitelist contract
    address public whitelist;
    // Array of all RealEstateToken contract addresses
    address[] public allProjects;

    // Event emitted when a new RealEstateToken contract is deployed
    event ProjectCreated(
        address indexed projectAddress,
        address indexed issuer,
        string projectName,
        string projectSymbol,
        string propertyId,
        string jurisdiction,
        string metadataUri
    );

    constructor(address whiteListAddress) Ownable(msg.sender) {
        whitelist = whiteListAddress;
    }

    // Factory function to deploy a new RealEstateToken contract
    /// Returns the address of the deployed RealEstateToken contract
    function deployProject(
        string memory name,
        string memory symbol,
        string memory propertyId,
        string memory jurisdiction,
        string memory metadataUri
    ) external returns (address) {
        RealEstateToken token = new RealEstateToken(
            name,
            symbol,
            whitelist,
            propertyId,
            jurisdiction,
            metadataUri,
            msg.sender
        );

        allProjects.push(address(token));

        emit ProjectCreated(
            address(token),
            msg.sender,
            name,
            symbol,
            propertyId,
            jurisdiction,
            metadataUri
        );

        return address(token);
    }

    // Get an array of all deployed RealEstateToken contracts
    function getAllProjects() external view returns (address[] memory) {
        return allProjects;
    }
}

RealEstateToken Contract

The RealEstateToken contract represents a Real World Asset (RWA). Real estate, in this instance. It is a basic example of RWA tokenization. It allows the owner/adminstrator of the project to mint tokens that represent a share in a real estate devlopment project. The RealEstateToken is a modified ERC20 token. It overrides some of the standard behaviours of an ERC20 token. In particular, your attention is directed to the mint(), adminBurn, decimals(), and _update() functions. Essentially, the changes mean 1 token is a 1% share of the project. There are no fractional shares. Only the owner/admin of the project can mint tokens to an approved investor’s account address. And, a token holder is restricted to transferring their tokens to other approved investors. Finally, given the nature of the tokens and what they are intended to represent, only the owner/admin can burn tokens. They cannot be burned by the token holder.

RealEstateToken

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

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";

// Interface for the Whitelist contract isApproved function
interface IWhiteList {
    function isApproved(
        address project,
        address investor
    ) external view returns (bool);
}

// A modified ERC20 token representing 100 shares in a real estate project
// RealEstateToken represents shares in a real estate project
// WARNING: This contract is an example only - do not use in production
contract RealEstateToken is ERC20, Ownable2Step {
    IWhiteList public whitelist;

    // Issuer's property identifier
    string public propertyId;
    // The legal jurisdiction of the real estate
    string public jurisdiction;
    // IPFS CID to legal documents (unused)
    string public metadataUri;

    // Hard cap: 100 tokens max supply. Each token = 1 whole share of the property.
    uint256 public constant MAX_SUPPLY = 100;

    mapping(address => bool) private _isShareholder;
    address[] private _shareholders;

    // The number of investors that hold tokens in the project
    uint256 public shareholderCount;

    constructor(
        string memory name,
        string memory symbol,
        address whiteListAddress,
        string memory _propertyId,
        string memory _jurisdiction,
        string memory _metadataUri,
        address projectOwner
    ) ERC20(name, symbol) Ownable(projectOwner) {
        whitelist = IWhiteList(whiteListAddress);
        propertyId = _propertyId;
        jurisdiction = _jurisdiction;
        metadataUri = _metadataUri;
    }

    // Check whether an address is an investor in this project
    // Returns true if the address is an investor, otherwise false
    function isShareholder(address account) external view returns (bool) {
        return _isShareholder[account];
    }

    // Get a list of shareholders in this project
    // Returns an array of the investor account addresses for this project
    function getShareholders() external view returns (address[] memory) {
        return _shareholders;
    }

    // Check whether an investor's address is on the whitelist
    // Returns true if the address is whitelisted, otherwise false
    function isApprovedHolder(address investor) external view returns (bool) {
        return whitelist.isApproved(address(this), investor);
    }

    // Mints new tokens to a specified address. There are 100 tokens only. Only the issuer (owner) can mint
    function mint(address to, uint256 amount) external onlyOwner {
        require(to != address(0), "Invalid recipient");

        // Investor (to) must be approved
        require(
            whitelist.isApproved(address(this), to),
            "Investor not approved for this project."
        );

        // Enforce hard cap (MAX_SUPPLY = 100 tokens)
        require(
            totalSupply() + amount <= MAX_SUPPLY,
            "Maximum shares exceeded."
        );

        _mint(to, amount);
    }

    // Admin burn. This is the only way to reduce supply.
    // Used in emergency/problem circumstances (fraud, regulatory unwind etc)
    function adminBurn(address from, uint256 amount) external onlyOwner {
        require(from != address(0), "Invalid from address.");
        _burn(from, amount);
    }

    // Override decimals to ensure 1 token = 1 whole share. No fractional shares
    function decimals() public pure override returns (uint8) {
        return 0;
    }

    // Override some behaviours of the regular _update function for this specific use case
    // Burn only allowed if msg.sender is contract owner (via adminBurn).
    // Mint and transfer sender and recipients must be whitelisted for THIS project
    function _update(
        address from,
        address to,
        uint256 value
    ) internal override {
        bool isMint = (from == address(0));
        bool isBurn = (to == address(0));

        if (isBurn) {
            require(msg.sender == owner(), "Burn restricted to administrator.");
        }

        // Whitelist enforcement
        // Sender must be approved if it's not mint
        if (!isMint) {
            // from is a real holder in transfer OR burn
            require(
                whitelist.isApproved(address(this), from),
                "Sender not approved for this project"
            );
        }

        // Recipient must be approved if it's not burn
        if (!isBurn) {
            // to is a real holder in transfer OR mint
            require(
                whitelist.isApproved(address(this), to),
                "Recipient is not approved for this project"
            );
        }

        // Snapshot balances before
        uint256 fromBalanceBefore = isMint ? 0 : balanceOf(from);
        uint256 toBalanceBefore = isBurn ? 0 : balanceOf(to);

        // Execute state update
        super._update(from, to, value);

        // Snapshot balances after transfer
        uint256 fromBalanceAfter = isMint ? 0 : balanceOf(from);
        uint256 toBalanceAfter = isBurn ? 0 : balanceOf(to);

        // Shareholder bookkeeping

        // Recipient becomes a shareholder
        if (!isBurn) {
            if (
                toBalanceBefore == 0 &&
                toBalanceAfter > 0 &&
                !_isShareholder[to]
            ) {
                _isShareholder[to] = true;
                _shareholders.push(to);
                shareholderCount += 1;
            }
        }

        // Sender ceases to be a shareholder
        if (!isMint) {
            if (
                fromBalanceBefore > 0 &&
                fromBalanceAfter == 0 &&
                _isShareholder[from]
            ) {
                _isShareholder[from] = false;
                shareholderCount -= 1;
                // We do not prune shareholders for gas saving reasons
            }
        }
    }
}