A working reference for Custos. What each call does, what it verifies, and how to invoke it from a script. Read in order, or jump to the chapter you need.
Custos is a treasury operating system for autonomous agents, built as a single contract on Robinhood Chain. Capital will be held in a reserve contract. The reserve. Each agent is given a lane within that reserve, with its own balance, ledger, and constraint envelope.
The system replaces the server-side budget. Where most stacks ask you to trust an operator with rules and balances, Custos stores the rules on-chain and verifies them in the same call that moves the funds. There is no middleware. There is no key handed to the agent.
Posture. The chain holds the capital. The contract holds the rules. The agent only signs intent. Never authority.
The system is composed of four on-chain elements:
The authority is the wallet that deployed the reserve. Only the authority can create lanes, set constraints, or move the roster. The agent itself only invokes payment calls. Authority over the reserve is never transferred to it.
When an agent asks to pay, it does not transfer directly. It invokes the reserve contract with an intent: a lane, an amount, and a destination. The contract runs every check in a single call:
If any check fails, the entire transaction reverts. Nothing moves. If all pass, the contract signs the transfer from the reserve to the recipient and writes the new balance, spent count, and transaction count to the lane.
The reserve is the root account. It is created once per authority and lives for the lifetime of the system. It binds a name and a settlement token to your wallet; from then on, every lane you open draws from it.
function createReserve(string calldata name, address token) external {
if (reserves[msg.sender].exists) revert ReserveExists();
if (token == address(0)) revert ZeroAddress();
reserves[msg.sender] = Reserve({
name: name,
token: IERC20(token),
laneCount: 0,
exists: true
});
emit ReserveCreated(msg.sender, name, token);
}import { Contract } from "ethers";
import { RESERVE_ABI } from "./reserveAbi";
// RPC: https://rpc.mainnet.chain.robinhood.com
const reserve = new Contract(CUSTOS_RESERVE, RESERVE_ABI, wallet);
await reserve.createReserve("production", USDG_ADDRESS);Each agent gets a lane. A partition within the reserve. A lane binds one agent wallet to one budget: the address you register is the only address allowed to execute payments from it.
A lane carries balance, spent count, transaction count, and a status flag. Authority can create, pause, resume, or close a lane at any time. Closing a lane returns its remaining balance to the authority, and a closed lane cannot be reopened.
await reserve.openLane(
"research_agent", // label
agentWallet, // the only address that can pay
2_000n * 10n ** 6n, // lifetime budget · 2,000 USDG
);Governance is the constraint envelope on a lane. It defines spending discipline as account data:
if (lane.maxPerTx != 0 && amount > lane.maxPerTx)
revert ExceedsMaxPerTx();
if (lane.maxPerDay != 0) {
if (block.timestamp >= lane.dayStart + 1 days) {
lane.dayStart = block.timestamp;
lane.spentToday = 0;
}
if (lane.spentToday + amount > lane.maxPerDay)
revert ExceedsDailyCap();
lane.spentToday += amount;
}The roster is the per-lane allowlist of recipient addresses. Each entry is a small on-chain record. Address plus label plus the timestamp it was added. The contract checks every outbound payment against the lane's roster before signing.
const ROSTER = [
{ address: "0x4a2c…openai", label: "OpenAI · API" },
{ address: "0x7b3e…claude", label: "Anthropic · Claude" },
{ address: "0x9d17…market", label: "Feed · Market data" },
];
for (const { address, label } of ROSTER) {
await reserve.addToRoster(laneId, address, label);
}A compromised agent cannot exfiltrate to an unapproved address. The contract will not sign the transfer. Period.
Hours bound when a lane may move funds. Define a daily window in UTC; outside it, transactions revert. The window may wrap midnight. The block timestamp is the time source. The agent's server clock is irrelevant.
// Allow payments 09:00–17:00 UTC, every day
await reserve.setHours(
laneId,
9 * 3600, // windowStart · seconds into the UTC day
17 * 3600, // windowEnd
true, // enabled
);Ships in Phase 02. Replenish is part of contract v2 and is not in the deployed contract yet. The design below is the target behaviour.
Replenish is the auto-refill call. Set a floor and a target on a lane. When the lane's balance drops below the floor, anyone can crank the call. But only the contract can move the funds, and only up to the target.
await reserve.configureReplenish(
laneId,
500n * 10n ** 6n, // floor · refill when below 500 USDG
2_000n * 10n ** 6n, // target · refill up to 2,000 USDG
true,
);
// Permissionless crank. Anyone can call this
await reserve.crankReplenish(laneId);Ships in Phase 02. Quorum is part of contract v2 and is not in the deployed contract yet. The design below is the target behaviour.
Quorum is the multi-signer rule. Above a defined amount, a payment becomes a proposal account on-chain. Each required signer approves the proposal; once the threshold is met, anyone may execute. Proposals expire after a configurable TTL. Signer sets are stored on-chain and rotated by the authority.
await reserve.configureQuorum({
signers: [w1.address, w2.address, w3.address],
threshold: 2, // 2-of-3
amountThreshold: 5_000n * 10n ** 6n,
proposalTtl: 86_400, // 24h
});
const proposal = await reserve.createProposal({
destination: vendor,
amount: 10_000n * 10n ** 6n,
});
await proposal.approve(w1);
await proposal.approve(w2); // threshold met
await proposal.execute();The contract is the interface. Every operation below is a public function on the deployed reserve, callable from any EVM tooling. The console wraps them, and a script with ethers.js and the ABI reaches the exact same surface.
| Function | Action |
|---|---|
| createReserve(name, token) | Create your reserve. Once per authority. |
| openLane(name, agent, budget) | Open a per-agent lane. |
| setGovernance(laneId, maxPerTx, maxPerDay) | Set the spending caps. |
| setHours(laneId, start, end, enabled) | Configure the UTC window. |
| addToRoster(laneId, recipient, label) | Approve a recipient. |
| removeFromRoster(laneId, recipient) | Revoke a recipient. |
| deposit(laneId, amount) | Fund a lane from the authority wallet. |
| withdraw(laneId, amount) | Pull funds back. Authority only. |
| pauseLane / resumeLane / closeLane | Lane lifecycle. Kill switch included. |
| executePayment(authority, laneId, to, amount) | The agent's only entry point. |
Everything in this table is live on Robinhood Chain mainnet today. The agent holds no funds and no authority; it can only call executePayment, and the contract verifies every rule before anything moves.