> ## Documentation Index
> Fetch the complete documentation index at: https://docs.metalntwx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy an application contract

> An escrow for one payment, built with Solidity and Foundry

> [!NOTE]
>
> **What you'll be able to do**
>
> * Test an escrow contract locally.
> * Deploy it to Metal Testnet.
> * Verify the deployed code, configuration.

Deploy a contract when an integration needs its own onchain rules. This guide
builds an escrow for one payment. A buyer deposits tokens, then an approver can
release them to a recipient before a deadline. From the deadline onward, the
buyer can reclaim the deposit.

The escrow calls an existing MIP20 token to move funds. To deploy a MIP20
token, use [Create a MIP20 token](https://docs.metalntwx.com/build/contracts/create-a-mip20-token.md).

## Before you start

* Install [Foundry](https://getfoundry.sh/introduction/installation/) and confirm
  `forge --version` and `cast --version` work. The commands use Foundry 1.8.5.
* Have Node.js 22 or later for the commands that read timestamps and deployment output.
* Set `METAL_RPC_URL` to your testnet RPC endpoint.
* Load the test signer's key into `PRIVATE_KEY` through local secret configuration.
* Set `RECIPIENT_ADDRESS` to a different test account that will receive the
  payment.

The commands use one disposable account as contract deployer, buyer, and approver.
The contract accepts separate buyer and approver addresses when an integration
needs distinct roles.

## Contract roles and rules

| Role or state | Rule |
| - | - |
| Buyer | Approves the escrow to spend tokens, deposits once, and may reclaim the deposit from the deadline onward |
| Approver | Releases a funded payment before the deadline |
| Recipient | Receives the released tokens without signing |
| Deadline | Unix timestamp in seconds. Release stops and refund becomes available at this time |
| Completion | Each escrow accepts one deposit and ends with either release or refund |

Each deployment serves one payment. Associate its chain ID and contract address
with a single payment ID in your application, enforced by a database uniqueness
constraint.

The buyer calls `approve` on the token contract to let the escrow spend the
deposit amount, then calls `deposit`. The escrow collects the tokens with
`transferFrom`. A reentrancy guard prevents the token from calling back into
the escrow's deposit, release, or refund functions during a transfer.

Each transfer must succeed and increase the destination's balance by the full
amount. If a token call fails or quarantines the funds, the escrow call reverts
all changes, including token balances, allowance, and any quarantine receipt.
After a failed deposit, the escrow remains `Empty`. After a failed release or
refund, it remains `Funded`, so the caller can resolve the failure and retry.

## Create the contract project

Use a separate contract directory in the application, or create an empty one:

```bash theme={null}
mkdir metal-escrow
cd metal-escrow
mkdir src test
```

Configure the compiler in `foundry.toml`:

```toml theme={null}
[profile.default]
src = "src"
test = "test"
out = "out"
solc_version = "0.8.31"
evm_version = "osaka"
optimizer = true
optimizer_runs = 200
```

This configuration compiles for Metal's
[Osaka EVM revision](https://docs.metalntwx.com/start/ethereum-compatibility.md). Forge downloads the selected
compiler on the first build. The contract declares its token interface directly,
so no library dependencies are needed.

Define `PaymentEscrow` in `src/PaymentEscrow.sol`. Its constructor fixes the
token, participants, and deadline for that payment:

```solidity theme={null}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.31;

interface Token {
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

contract PaymentEscrow {
    enum State { Empty, Funded, Released, Refunded }
    Token public immutable token;
    address public immutable buyer;
    address public immutable recipient;
    address public immutable approver;
    uint256 public immutable deadline;
    uint256 public amount;
    State public state;
    bool private entered;

    event Deposited(uint256 amount);
    event Released(uint256 amount);
    event Refunded(uint256 amount);

    modifier guarded() {
        require(!entered, "reentrant call");
        entered = true;
        _;
        entered = false;
    }

    constructor(address token_, address buyer_, address recipient_, address approver_, uint256 deadline_) {
        require(token_.code.length != 0, "token code");
        require(buyer_ != address(0) && recipient_ != address(0) && approver_ != address(0), "zero address");
        require(deadline_ > block.timestamp, "deadline");
        token = Token(token_);
        buyer = buyer_;
        recipient = recipient_;
        approver = approver_;
        deadline = deadline_;
    }

    function deposit(uint256 amount_) external guarded {
        require(msg.sender == buyer, "buyer only");
        require(state == State.Empty, "state");
        require(block.timestamp < deadline, "expired");
        require(amount_ > 0, "zero amount");
        uint256 beforeBalance = token.balanceOf(address(this));
        amount = amount_;
        state = State.Funded;
        require(token.transferFrom(buyer, address(this), amount_), "token transfer");
        require(token.balanceOf(address(this)) == beforeBalance + amount_, "deposit credit");
        emit Deposited(amount_);
    }

    function release() external guarded {
        require(msg.sender == approver, "approver only");
        require(state == State.Funded, "state");
        require(block.timestamp < deadline, "expired");
        state = State.Released;
        pay(recipient);
        emit Released(amount);
    }

    function refund() external guarded {
        require(msg.sender == buyer, "buyer only");
        require(state == State.Funded, "state");
        require(block.timestamp >= deadline, "too early");
        state = State.Refunded;
        pay(buyer);
        emit Refunded(amount);
    }

    function pay(address to) private {
        uint256 beforeBalance = token.balanceOf(to);
        require(token.transfer(to, amount), "token transfer");
        require(token.balanceOf(to) == beforeBalance + amount, "recipient credit");
    }
}
```

## Test the rules locally

The tests in `test/PaymentEscrow.t.sol` use a token stub to check release,
refund, and rollback locally. The `Vm` interface exposes Foundry's controls
for advancing time and checking that calls revert:

```solidity theme={null}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.31;

import {PaymentEscrow} from "../src/PaymentEscrow.sol";

interface Vm {
    function warp(uint256) external;
    function expectRevert(bytes calldata) external;
}

contract TestToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    bool public quarantine;
    function setQuarantine(bool value) external { quarantine = value; }
    function mint(address to, uint256 amount) external { balanceOf[to] += amount; }
    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        return true;
    }
    function transfer(address to, uint256 amount) external returns (bool) {
        balanceOf[msg.sender] -= amount;
        balanceOf[quarantine ? address(99) : to] += amount;
        return true;
    }
    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[quarantine ? address(99) : to] += amount;
        return true;
    }
}

contract PaymentEscrowTest {
    Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
    address constant RECIPIENT = address(11);
    TestToken token;
    PaymentEscrow escrow;

    function setUp() public {
        token = new TestToken();
        escrow = new PaymentEscrow(address(token), address(this), RECIPIENT, address(this), block.timestamp + 3600);
        token.mint(address(this), 100);
        token.approve(address(escrow), 100);
    }

    function testQuarantinedReleaseRollsBack() public {
        escrow.deposit(100);
        token.setQuarantine(true);
        vm.expectRevert(bytes("recipient credit"));
        escrow.release();
        require(escrow.state() == PaymentEscrow.State.Funded);
        require(token.balanceOf(address(escrow)) == 100);
        require(token.balanceOf(RECIPIENT) == 0);
        require(token.balanceOf(address(99)) == 0);
    }

    function testReleaseOnce() public {
        escrow.deposit(100);
        require(token.balanceOf(address(escrow)) == 100);
        escrow.release();
        require(token.balanceOf(RECIPIENT) == 100);
        require(token.balanceOf(address(escrow)) == 0);
        require(escrow.state() == PaymentEscrow.State.Released);
        vm.expectRevert(bytes("state")); escrow.release();
        vm.warp(escrow.deadline());
        vm.expectRevert(bytes("state")); escrow.refund();
    }

    function testRefundOnceAtDeadline() public {
        escrow.deposit(100);
        vm.expectRevert(bytes("too early")); escrow.refund();
        vm.warp(escrow.deadline());
        vm.expectRevert(bytes("expired")); escrow.release();
        escrow.refund();
        require(token.balanceOf(address(this)) == 100);
        require(escrow.state() == PaymentEscrow.State.Refunded);
        vm.expectRevert(bytes("state")); escrow.refund();
        vm.expectRevert(bytes("state")); escrow.release();
    }
}
```

Compile the contract and run the tests:

```bash theme={null}
forge build
forge test -vv
```

After these tests pass, extend the suite to cover the remaining cases below
before deploying:

| Test | Required observation |
| - | - |
| Release once (above) | Recipient receives the deposit, leaving the escrow empty. Further payouts fail |
| Refund at deadline (above) | Early refund fails. At the deadline, release fails and refund succeeds once |
| Role permissions | Calls from another address fail for deposit, release, and refund |
| Deposit constraints | Zero deposit and a second deposit fail |
| Expired deposit | Deposit at the deadline fails |
| Token call fails | Failed deposit preserves `Empty` and zero amount. Failed release preserves `Funded` and the escrow balance |
| Quarantined credit | If the token returns true while crediting another account, deposit, release, and refund revert. State, balances, and allowance remain unchanged |

To test caller permissions, declare the external `prank(address)` function in
`Vm`, then call `vm.prank(otherAddress)` before the escrow call.

To test rollback, add a boolean failure flag and setter to `TestToken`. Make
its transfer methods return `false` when the flag is set. Use
`vm.expectRevert(bytes("token transfer"))` to check the rejection, then assert
that state and balances are unchanged.

The quarantine flag models a transfer that returns true while crediting
another address. Extend the release test to cover deposit and refund, checking
`deposit credit` and `recipient credit` respectively. For a failed deposit,
also check that the allowance and stored amount remain unchanged. Run the
completed suite with `forge test -vv`.

## Deploy to Metal Testnet

This deployment uses `*USDZ` for both deposits and transaction fees. It submits
standard Ethereum transactions, which use the default gas token and must set
native `value` to zero on Metal. See
[Fees](https://docs.metalntwx.com/build/prepare/estimate-and-record-fees.md) to select another gas token.

Run these commands in the same shell, with the prerequisite variables set:

```bash theme={null}
: "${METAL_RPC_URL:?Set METAL_RPC_URL}"
: "${PRIVATE_KEY:?Set the disposable test signing key}"
: "${RECIPIENT_ADDRESS:?Set a different test recipient}"
export ETH_RPC_URL="$METAL_RPC_URL"
test "$(cast chain-id)" = "63825" || exit 1
export BUYER_ADDRESS="$(cast wallet address --private-key "$PRIVATE_KEY")"
export TOKEN_ADDRESS="0x000000000000000000000000000000000000b5d5"
export DEADLINE="$(node -e 'console.log(Math.floor(Date.now() / 1000) + 3600)')"
cast call "$TOKEN_ADDRESS" 'balanceOf(address)(uint256)' "$BUYER_ADDRESS"
cast call "$TOKEN_ADDRESS" 'decimals()(uint8)'
```

Check that the signing account's balance in the gas token, `*USDZ`, covers
deployment fees, and leave enough for deposits and later transactions. The
balance is returned in base units. `*USDZ` uses 18 decimals, so one token equals
`10^18` base units.

The deadline is one hour from the system's current time. Use synchronized
system time and choose a deadline that leaves enough time to integrate the
contract before deposits and releases expire.

```bash theme={null}
forge create src/PaymentEscrow.sol:PaymentEscrow \
  --private-key "$PRIVATE_KEY" --broadcast --json \
  --gas-limit 1500000 --gas-price 1 --priority-gas-price 1 \
  --constructor-args "$TOKEN_ADDRESS" "$BUYER_ADDRESS" \
    "$RECIPIENT_ADDRESS" "$BUYER_ADDRESS" "$DEADLINE" \
  > deployment.json

export ESCROW_ADDRESS="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("deployment.json")).deployedTo)')"
export DEPLOY_TX="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("deployment.json")).transactionHash)')"
```

The explicit gas limit is sized for this contract and compiler configuration.
It avoids gas estimates that can fail when tooling checks the native balance.
The signing account's balance in the gas token, `*USDZ`, must cover the maximum
gas cost.

## Verify deployment and finality

Retrieve the deployment transaction's receipt and the latest finalized block:

```bash theme={null}
cast receipt "$DEPLOY_TX" --json
cast rpc consensus_status
```

Require receipt `status` equal to `0x1` and its block number (decoded from hex)
at or below `finalized.number`. Copy the receipt's `blockNumber` into
`RECEIPT_BLOCK`, then check its canonical block:

```bash theme={null}
export RECEIPT_BLOCK="0x<receipt-block-number>"
cast rpc eth_getBlockByNumber "$RECEIPT_BLOCK" false
```

The returned block's `hash` must equal the receipt's `blockHash`. If the
finalized head has not reached the receipt's block, wait and repeat the checks.
[Settlement and Finality](https://docs.metalntwx.com/reference/network-fundamentals/settlement.md) describes the field encodings
and completion rules.

```bash theme={null}
cast code "$ESCROW_ADDRESS"
cast call "$ESCROW_ADDRESS" 'token()(address)'
cast call "$ESCROW_ADDRESS" 'buyer()(address)'
cast call "$ESCROW_ADDRESS" 'recipient()(address)'
cast call "$ESCROW_ADDRESS" 'approver()(address)'
cast call "$ESCROW_ADDRESS" 'deadline()(uint256)'
cast call "$ESCROW_ADDRESS" 'state()(uint8)'
```

Confirm that the receipt's `contractAddress` equals `ESCROW_ADDRESS` and that
`cast code` returns more than `0x`. The configuration values must match the
constructor inputs, and the initial state must be `0` (`Empty`).

Keep a deployment record with the chain ID, contract address, deployment
transaction hash, deployer, constructor inputs, compiler settings, and source
version. Include the ABI generated at
`out/PaymentEscrow.sol/PaymentEscrow.json` so the application can call the
deployed contract.

Follow [Integrate a contract](https://docs.metalntwx.com/build/contracts/integrate-a-contract.md) with this
deployment's address and ABI to submit calls and record their verified results.

## Diagnose failures

| Symptom | Check |
| - | - |
| Compiler rejects the EVM version | Use the pinned Solidity compiler and a current Foundry installation |
| Deployment fails before broadcast | Check the chain ID, the signing account's balance in the gas token (`*USDZ`), gas limits, and that native `value` is zero |
| Receipt has failed status | Inspect the revert and fix its cause before signing another transaction |
| Broadcast or finality times out | Check the existing transaction hash and receipt before retrying deployment |

Deployment is complete when the local tests pass, the deployment is finalized,
the contract address has code, and its initial configuration matches the
constructor inputs.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.