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
pseudo
// 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 → HOLD

The 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

  1. Load pending invoices (nothing is seeded; invoices come from /payouts or the API, oldest first).
  2. Screen the payee with clientId: treasury-agent and the trusted payee book; the screen does not write on-chain by itself.
  3. Live only: mirror the attestation to the registry and wait for it, then read tierOf(payee) back.
  4. decidePayout() → PAY / LIMIT / HOLD with the amount, the held remainder and the bound.
  5. Execute: dry-run returns null; live simulates release (a HIGH payee reverts with CompliR: payee tier too high before gas is spent), sends it, waits for the receipt (Arc finality is deterministic) and records the tx hash.
  6. Append a PAYOUT ledger entry and settle the invoice (PAID, LIMITED with the rest re-queued, or HELD).
OutcomeMeansExample
PAIDfull amount released10 USDC to a LOW payee under every cap
LIMITEDpart released, remainder held and re-queued120 USDC invoice, LOW payee → 25 USDC (per-payout cap), 95 held
HELDnothing released; reason names the boundunknown vendor graded HIGH → held by tier

The contract

solidity
// 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.

  1. Deploy AttestationRegistry on Arc mainnet (5042), then GuardedTreasury with OWNER_ADDRESS = the treasury agent key and TOKEN_ADDRESS = 0x3600…0000 (contracts/README.md). Gas is USDC.
  2. 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).
  3. Set the env below and redeploy.
  4. The next tick (worker/treasury.ts, the cron every 15 min, or "Run agent now" on the page) runs live. Remove TREASURY_MODE=live to return to dry-run.
env
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