Implementing an Arbitrable

Warning

Smart contracts in this tutorial are not intended for production but educational purposes. Beware of using them on main network.

When developing arbitrable contracts, we need to:

  • Implement rule function to define an action to be taken when a ruling is received by the contract.
  • Develop a logic to create disputes (via calling createDispute on Arbitrable)

Consider a case where two parties trade ether for goods. Payer wants to pay only if payee provides promised goods. So payer deposits payment amount into an escrow and if a dispute arises an arbitrator will resolve it.

There will be two scenarios:
  1. No dispute arises, payee withdraws the funds.
  2. payer reclaims funds by depositing arbitration fee…
    1. payee fails to deposit arbitration fee in arbitrationFeeDepositPeriod and payer wins by default. The arbitration fee deposit paid by payer refunded.
    2. payee deposits arbitration fee in time. Dispute gets created. arbitrator rules. Winner gets the arbitration fee refunded.

Notice that only in scenario 2b arbitrator intervenes. In other scenarios we don’t create a dispute thus don’t await for a ruling. Also, notice that in case of a dispute, the winning side gets reimbursed for the arbitration fee deposit. So in effect, the loser will be paying for the arbitration.

Let’s start:

/**
 * @authors: [@ferittuncer, @hbarcelos]
 * @reviewers: []
 * @auditors: []
 * @bounties: []
 * @deployments: []
 * SPDX-License-Identifier: MIT
 */
pragma solidity ^0.8.9;

import "../IArbitrable.sol";
import "../IArbitrator.sol";

contract SimpleEscrow is IArbitrable {
    error InvalidStatus();
    error ReleasedTooEarly();
    error NotPayer();
    error NotArbitrator();
    error PayeeDepositStillPending();
    error ReclaimedTooLate();
    error InsufficientPayment(uint256 _available, uint256 _required);

payer deploys the contract depositing the payment amount and specifying payee address, arbitrator that is authorized to rule and agreement string. Notice that payer = msg.sender.

We made reclamationPeriod and arbitrationFeeDepositPeriod constant for sake of simplicity, they could be set by payer in the constructor too.

Let’s implement the first scenario:

/**
 * @authors: [@ferittuncer, @hbarcelos]
 * @reviewers: []
 * @auditors: []
 * @bounties: []
 * @deployments: []
 * SPDX-License-Identifier: MIT
 */
pragma solidity ^0.8.9;

import "../IArbitrable.sol";
import "../IArbitrator.sol";

contract SimpleEscrow is IArbitrable {
    address payable public payer = payable(msg.sender);
    address payable public payee;
    uint256 public value;
    IArbitrator public arbitrator;
    string public agreement;
    error InvalidStatus();
    error ReleasedTooEarly();
    error NotPayer();
    error NotArbitrator();
    error PayeeDepositStillPending();
    error ReclaimedTooLate();
    error InsufficientPayment(uint256 _available, uint256 _required);
    error InvalidRuling(uint256 _ruling, uint256 _numberOfChoices);

    enum Status {
        Initial,
        Reclaimed,
        Disputed,
        Resolved
    }
    Status public status;

    uint256 public reclaimedAt;

In releaseFunds function, first we do state checks: transaction should be in Status.Initial and reclamationPeriod should be passed unless the caller is payer. If so, we update status to Status.Resolved and send the funds to payee.

Moving forward to second scenario:

/**
 * @authors: [@ferittuncer, @hbarcelos]
 * @reviewers: []
 * @auditors: []
 * @bounties: []
 * @deployments: []
 * SPDX-License-Identifier: MIT
 */
pragma solidity ^0.8.9;

import "../IArbitrable.sol";
import "../IArbitrator.sol";

contract SimpleEscrow is IArbitrable {
    address payable public payer = payable(msg.sender);
    address payable public payee;
    uint256 public value;
    IArbitrator public arbitrator;
    string public agreement;
    uint256 public createdAt;
    uint256 public constant reclamationPeriod = 3 minutes; // Timeframe is short on purpose to be able to test it quickly. Not for production use.
    uint256 public constant arbitrationFeeDepositPeriod = 3 minutes; // Timeframe is short on purpose to be able to test it quickly. Not for production use.

    error InvalidStatus();
    error ReleasedTooEarly();
    error NotPayer();
    error NotArbitrator();
    error PayeeDepositStillPending();
    error ReclaimedTooLate();
    error InsufficientPayment(uint256 _available, uint256 _required);
    error InvalidRuling(uint256 _ruling, uint256 _numberOfChoices);

    enum Status {
        Initial,
        Reclaimed,
        Disputed,
        Resolved
    }
    Status public status;

    uint256 public reclaimedAt;

    enum RulingOptions {
        RefusedToArbitrate,
        PayerWins,
        PayeeWins
    }
    uint256 constant numberOfRulingOptions = 2; // Notice that option 0 is reserved for RefusedToArbitrate.

    constructor(
        address payable _payee,
        IArbitrator _arbitrator,
        string memory _agreement
    ) payable {
        value = msg.value;
        payee = _payee;
        arbitrator = _arbitrator;
        agreement = _agreement;
        createdAt = block.timestamp;
    }

    function releaseFunds() public {
        if (status != Status.Initial) {
            revert InvalidStatus();
        }

        if (msg.sender != payer && block.timestamp - createdAt <= reclamationPeriod) {
            revert ReleasedTooEarly();
        }

        status = Status.Resolved;
        payee.send(value);
    }

    function reclaimFunds() public payable {
        if (status != Status.Initial && status != Status.Reclaimed) {
            revert InvalidStatus();
        }

        if (msg.sender != payer) {
            revert NotPayer();
        }

        if (status == Status.Reclaimed) {
            if (block.timestamp - reclaimedAt <= arbitrationFeeDepositPeriod) {
                revert PayeeDepositStillPending();
            }
            payer.send(address(this).balance);
            status = Status.Resolved;
        } else {
            if (block.timestamp - createdAt > reclamationPeriod) {
                revert ReclaimedTooLate();
            }
            uint256 requiredAmount = arbitrator.arbitrationCost("");
            if (msg.value < requiredAmount) {
                revert InsufficientPayment(msg.value, requiredAmount);
            }
            reclaimedAt = block.timestamp;
            status = Status.Reclaimed;
        }
    }

    function depositArbitrationFeeForPayee() public payable {
        if (status != Status.Reclaimed) {
            revert InvalidStatus();
        }
        arbitrator.createDispute{value: msg.value}(numberOfRulingOptions, "");
        status = Status.Disputed;
    }

    function rule(uint256 _disputeID, uint256 _ruling) public override {
        if (msg.sender != address(arbitrator)) {
            revert NotArbitrator();
        }
        if (status != Status.Disputed) {
            revert InvalidStatus();
        }
        if (_ruling > numberOfRulingOptions) {
            revert InvalidRuling(_ruling, numberOfRulingOptions);
        }

        status = Status.Resolved;
        if (_ruling == uint256(RulingOptions.PayerWins)) payer.send(address(this).balance);
        else if (_ruling == uint256(RulingOptions.PayeeWins)) payee.send(address(this).balance);
        emit Ruling(arbitrator, _disputeID, _ruling);
    }

    function remainingTimeToReclaim() public view returns (uint256) {
        if (status != Status.Initial) {
            revert InvalidStatus();
        }
        return
            (block.timestamp - createdAt) > reclamationPeriod ? 0 : (createdAt + reclamationPeriod - block.timestamp);
    }

    function remainingTimeToDepositArbitrationFee() public view returns (uint256) {
        if (status != Status.Reclaimed) {
            revert InvalidStatus();
        }
        return
            (block.timestamp - reclaimedAt) > arbitrationFeeDepositPeriod
                ? 0
                : (reclaimedAt + arbitrationFeeDepositPeriod - block.timestamp);
    }
}

reclaimFunds function lets payer to reclaim their funds. After payer calls this function for the first time the window (arbitrationFeeDepositPeriod) for payee to deposit arbitration fee starts. If they fail to deposit in time, payer can call the function for the second time and get the funds back. In case if payee deposits the arbitration fee in time a dispute gets created and the contract awaits arbitrator’s decision.

We define enforcement of rulings in rule function. Whoever wins the dispute should get the funds and should get reimbursed for the arbitration fee. Recall that we took the arbitration fee deposit from both sides and used one of them to pay for the arbitrator. Thus the balance of the contract is at least funds plus arbitration fee. Therefore we send address(this).balance to the winner. Lastly, we emit Ruling as required in the standard.

And also we have two view functions to get remaining times, which will be useful for front-end development.

That’s it! We implemented a very simple escrow using ERC-792.