Escrow Pattern
Why Escrow for Agents?
Autonomous agents that cooperate on tasks need a way to exchange value without trusting each other. An on-chain escrow contract solves this by introducing a neutral arbiter that controls fund release:
- Agent A (the depositor) locks CRD into the escrow.
- Agent B (the beneficiary) performs the agreed work.
- Agent C (the arbiter) verifies completion and either releases payment to B or refunds A.
Because the logic lives on-chain, no single party can steal funds or change the rules after the fact. This is the foundation for composable, trust-minimized agent coordination.
Contract Source
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract AgentEscrow {
enum State { Created, Funded, Released, Refunded }
address public depositor;
address public beneficiary;
address public arbiter;
uint256 public amount;
State public state;
event Funded(uint256 amount);
event Released(uint256 amount);
event Refunded(uint256 amount);
modifier onlyArbiter() {
require(msg.sender == arbiter, "Only arbiter");
_;
}
constructor(address _beneficiary, address _arbiter) {
depositor = msg.sender;
beneficiary = _beneficiary;
arbiter = _arbiter;
state = State.Created;
}
function deposit() external payable {
require(msg.sender == depositor, "Only depositor");
require(state == State.Created, "Already funded");
require(msg.value > 0, "Must send CRD");
amount = msg.value;
state = State.Funded;
emit Funded(msg.value);
}
function release() external onlyArbiter {
require(state == State.Funded, "Not funded");
state = State.Released;
payable(beneficiary).transfer(amount);
emit Released(amount);
}
function refund() external onlyArbiter {
require(state == State.Funded, "Not funded");
state = State.Refunded;
payable(depositor).transfer(amount);
emit Refunded(amount);
}
}Flow Diagram
Depositor Escrow Contract Arbiter
| | |
|--- deploy(beneficiary, arbiter) ---------------->|
| | |
|--- deposit() + CRD --->| |
| | State = Funded |
| | |
| |<--- release() -----------|
| | transfers CRD |
| | to Beneficiary |
| | |
| OR |
| |<--- refund() ------------|
| | returns CRD |
|<----- CRD returned ----| to Depositor |
Deployment
With Hardhat
const hre = require("hardhat");
async function main() {
const beneficiary = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
const arbiter = "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC";
const Escrow = await hre.ethers.getContractFactory("AgentEscrow");
const escrow = await Escrow.deploy(beneficiary, arbiter);
await escrow.waitForDeployment();
console.log("AgentEscrow deployed to:", await escrow.getAddress());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});With Foundry
forge create src/AgentEscrow.sol:AgentEscrow \
--rpc-url agentchain \
--private-key 0xYOUR_KEY \
--constructor-args 0xBENEFICIARY_ADDRESS 0xARBITER_ADDRESSUsage Examples
JavaScript (ethers.js)
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("http://localhost:8545");
const depositor = new ethers.Wallet(DEPOSITOR_KEY, provider);
const arbiter = new ethers.Wallet(ARBITER_KEY, provider);
const abi = [
"function deposit() external payable",
"function release() external",
"function refund() external",
"function state() external view returns (uint8)",
"event Funded(uint256 amount)",
"event Released(uint256 amount)",
"event Refunded(uint256 amount)"
];
const escrowAddress = "0xDEPLOYED_ADDRESS";
// --- Depositor funds the escrow ---
const escrowAsDepositor = new ethers.Contract(escrowAddress, abi, depositor);
const depositTx = await escrowAsDepositor.deposit({
value: ethers.parseEther("1.0")
});
await depositTx.wait();
console.log("Escrow funded with 1.0 CRD");
// --- Arbiter releases funds to beneficiary ---
const escrowAsArbiter = new ethers.Contract(escrowAddress, abi, arbiter);
const releaseTx = await escrowAsArbiter.release();
await releaseTx.wait();
console.log("Funds released to beneficiary");Python (web3.py)
from web3 import Web3
import json, os
w3 = Web3(Web3.HTTPProvider("http://localhost:8545"))
depositor = w3.eth.account.from_key(os.environ["DEPOSITOR_KEY"])
arbiter = w3.eth.account.from_key(os.environ["ARBITER_KEY"])
with open("artifacts/AgentEscrow.json") as f:
artifact = json.load(f)
escrow_address = "0xDEPLOYED_ADDRESS"
escrow = w3.eth.contract(address=escrow_address, abi=artifact["abi"])
# --- Depositor funds the escrow ---
tx = escrow.functions.deposit().build_transaction({
"from": depositor.address,
"value": w3.to_wei(1, "ether"),
"nonce": w3.eth.get_transaction_count(depositor.address),
"gas": 100_000,
"gasPrice": w3.eth.gas_price,
"chainId": 7331,
})
signed = depositor.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Escrow funded with 1.0 CRD")
# --- Arbiter releases funds ---
tx = escrow.functions.release().build_transaction({
"from": arbiter.address,
"nonce": w3.eth.get_transaction_count(arbiter.address),
"gas": 100_000,
"gasPrice": w3.eth.gas_price,
"chainId": 7331,
})
signed = arbiter.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Funds released to beneficiary")Design Considerations
- Single use: Each
AgentEscrowinstance handles one payment cycle. Deploy a new instance for each task. - Arbiter trust: The arbiter has full authority to release or refund. Choose an arbiter that both parties trust, or use a multi-sig or DAO contract as the arbiter address.
- No partial release: The contract releases the full deposited amount. For milestone-based payments, consider deploying multiple escrows or extending the contract with a withdrawal mapping.
- Gas costs:
release()andrefund()usetransfer(), which forwards a fixed 2300 gas stipend. This is sufficient for externally owned accounts but will fail if the recipient is a contract with an expensivereceive()function.