The agent10
Guarded payouts
Guarded Payouts is a treasury agent that cannot pay a payee its own oracle did not clear. Every invoice is screened, a pure policy decides PAY, LIMIT or HOLD, and on-chain GuardedTreasury.release re-checks isAllowed so a wrong agent still cannot move USDC to a HIGH address.
Status today: live on Arc mainnet - GuardedTreasury at 0x3B13186a1E4b1108eA5CB2f8853D84A2aeD71Cc5 (verified). First live tick: PAID 10 USDC (tx), LIMITED to the 10 USDC cap (tx), HELD for a sanctioned payee (no tx). The page is /payouts.
Policy
- maxTier
- MEDIUM - mirrors GuardedTreasury.maxTier; HIGH is never paid, LOW-only is one setMaxTier away
- Per payout
- 25 USDC (TREASURY_PER_PAYOUT_CAP_USDC)
- Per day
- 50 USDC (TREASURY_DAILY_CAP_USDC), rolling from the payouts recorded today
- requireAttestationOnChain
- true in live mode by default: the registry must hold a valid, non-expired entry at or below maxTier, which is exactly what release reads
- Payee book
- every payee paid before is trusted; a new address that merely resembles one is a lookalike and the verdict says so
// src/lib/treasury/policy.ts - pure; the agent proposes, the policy decides, the contract re-checks on-chain.
HIGH, or any tier above policy.maxTier → HOLD everything (bound: tier)
signed attestation expired → HOLD, re-screen required (bound: attestation_expired)
live mode: registry has no valid entry → HOLD, release would revert (bound: attestation_missing)
else pay = min(amount, analyst maxPerTx, per-payout cap 25, daily headroom 50 − spent today)
pay == amount → PAY · 0 < pay < amount → LIMIT (rest held) · pay == 0 → HOLDThe smallest cap wins and names itself in the reason (bound): analyst_max_per_tx, per_payout_cap or daily_cap. Every reason ends with the analyst's rationale, so the payouts page explains itself.
One tick
- Load pending invoices (nothing is seeded; invoices come from /payouts or the API, oldest first).
- Screen the payee with
clientId: treasury-agentand the trusted payee book; the screen does not write on-chain by itself. - Live only: mirror the attestation to the registry and wait for it, then read
tierOf(payee)back. decidePayout()→ PAY / LIMIT / HOLD with the amount, the held remainder and the bound.- Execute: dry-run returns
null; live simulatesrelease(a HIGH payee reverts withCompliR: payee tier too highbefore gas is spent), sends it, waits for the receipt (Arc finality is deterministic) and records the tx hash. - Append a
PAYOUTledger entry and settle the invoice (PAID,LIMITEDwith the rest re-queued, orHELD).
| Outcome | Means | Example |
|---|---|---|
| PAID | full amount released | 10 USDC to a LOW payee under every cap |
| LIMITED | part released, remainder held and re-queued | 120 USDC invoice, LOW payee → 25 USDC (per-payout cap), 95 held |
| HELD | nothing released; reason names the bound | unknown vendor graded HIGH → held by tier |
The contract
// contracts/src/examples/GuardedTreasury.sol - the integration is the require.
function release(address payee, uint256 amount) external onlyOwner {
require(compliR.isAllowed(payee, maxTier), "CompliR: payee tier too high");
(,, bytes32 decisionHash) = compliR.tierOf(payee);
token.safeTransfer(payee, amount);
emit Released(payee, amount, decisionHash);
}Ownable: only the treasury agent's key can call release; setMaxTier tightens or loosens the policy (UNKNOWN never pays); withdraw pulls funds back any time. CompliR never holds this float. See Solidity for the full example.
Dry-run → live
Mode is decided by env alone: live needs all four of TREASURY_MODE=live, TREASURY_PRIVATE_KEY, GUARDED_TREASURY_ADDRESS and REGISTRY_ADDRESS; GET /api/v1/payouts lists what is still missing in missingForLive.
- Deploy
AttestationRegistryon Arc mainnet (5042), thenGuardedTreasurywithOWNER_ADDRESS= the treasury agent key andTOKEN_ADDRESS = 0x3600…0000(contracts/README.md). Gas is USDC. - Fund the treasury with at most 50 USDC via a plain ERC-20 transfer (covers the demo: 10 paid + 25 limited) and give the agent key a little USDC for gas (
release≈ 60k gas). - Set the env below and redeploy.
- The next tick (
worker/treasury.ts, the cron every 15 min, or "Run agent now" on the page) runs live. RemoveTREASURY_MODE=liveto return to dry-run.
REGISTRY_ADDRESS=<AttestationRegistry on Arc mainnet>
ATTESTER_PRIVATE_KEY=<CompliR attester key> # verdicts are mirrored on-chain before release
GUARDED_TREASURY_ADDRESS=<GuardedTreasury>
TREASURY_PRIVATE_KEY=<owner key; the only account allowed to call release>
TREASURY_MODE=live
TREASURY_MAX_TIER=MEDIUM # optional overrides (defaults MEDIUM / 25 / 50)
TREASURY_PER_PAYOUT_CAP_USDC=25
TREASURY_DAILY_CAP_USDC=50
TREASURY_REQUIRE_ONCHAIN=1 # default in live mode
CRON_SECRET=<random> # protects /api/internal/treasury