Warns

Arc

Arc testnet (eip155:5042002) is a test network: it takes test keys, and Protect is open. USDC is 0x3600000000000000000000000000000000000000, with 6 decimals.

This page covers an agent that pays invoices from a policy wallet: a contract that lets the agent pay under a limit, asks for the owner's signature above it, and lets the owner pay directly. Warns holds no key and never touches the contract: your agent asks Warns before it calls the contract, over the HTTP API.

Set up

  1. In Agents, create an agent on Arc testnet. As its wallet, enter the address the payments leave from: the policy wallet contract, not the agent's key.
  2. On the agent, click Add owner and enter the owner's address. When the owner pays from the contract with its own key, Warns alerts you and does not freeze the agent.
  3. Copy the test key. Your agent sends it with every call.

How a payment goes

Warns saysYour agent
allow, under the contract's limitPays with the agent key.
allow, at or above the limitThe contract needs the owner's signature too. Warns never signs for the owner: approving in Warns and signing as owner stay two acts.
reviewNothing leaves. Approve it in Reviews, have the owner sign if the amount needs it, then retry: the same payment goes through once within 15 minutes.
blockNothing leaves. The first reason says why.

Between the decision and the contract call, your agent puts the Warns decisionId in its decision record, hashes the record, and has Warns sign that hash: the hash your contract commits to now points to a decision Warns signed. Then your agent reports the transaction, so Warns learns the supplier's address and counts the payment against your budgets.

What gets held: a first payment to an address (NEW_DESTINATION), a supplier you already paid asking to be paid elsewhere (DESTINATION_CHANGED), and whatever your policies say, like an amount above a cap or a payment outside your pay runs.

Example

Python, with requests and web3. WARNS_KEY is your test key, TAKER_KEY the key your agent pays with, POLICY_WALLET the contract.

pay_invoice.py
import json, os, uuidimport requestsfrom web3 import Web3 WARNS_API = "https://api.warns.xyz"WARNS_KEY = os.environ["WARNS_KEY"]ARC = "eip155:5042002"USDC = "0x3600000000000000000000000000000000000000" w3 = Web3(Web3.HTTPProvider("https://rpc.testnet.arc.io"))taker = w3.eth.account.from_key(os.environ["TAKER_KEY"])wallet = w3.eth.contract(address=os.environ["POLICY_WALLET"], abi=json.load(open("abi.json")))LIMIT = wallet.functions.limit().call() def warns(path, body, idempotency_key=None):    headers = {"authorization": f"Bearer {WARNS_KEY}"}    if idempotency_key:        headers["idempotency-key"] = idempotency_key    r = requests.post(f"{WARNS_API}{path}", json=body, headers=headers, timeout=10)    r.raise_for_status()    return r.json() def pay(invoice, owner_sign=None):    decision = warns("/v1/decisions", {        "kind": "transfer", "agentId": "ap-agent",        "payer": wallet.address,  # the contract pays, not the agent key        "network": ARC, "token": USDC, "to": invoice["to"],        "amountAtomic": str(invoice["amount"]),        "intent": {"payee": invoice["supplier"], "invoiceId": invoice["id"],                   "purpose": "supplier invoice", "expectedMaxUsd": invoice["amount"] / 1e6},    }, idempotency_key=f"inv-{invoice['id']}-{uuid.uuid4().hex[:12]}")     if decision["enforcement"] == "enforced" and decision["outcome"] == "block":        raise RuntimeError(decision["reasons"][0]["message"])    if decision["enforcement"] == "enforced" and decision["outcome"] == "review":        return {"held": decision["reviewId"]}  # approve in Warns, then call pay() again     # The record names the Warns decision, and its hash is what the contract commits to.    record = {**invoice, "warns_decision_id": decision["decisionId"]}    decision_hash = Web3.keccak(text=json.dumps(record, sort_keys=True, separators=(",", ":")))    # Warns signs that hash: keep the signature with the record, outside what is hashed.    record["warns_signature"] = warns(f"/v1/decisions/{decision['decisionId']}/commitment",                                      {"decisionHash": w3.to_hex(decision_hash)})["commitment"]     if invoice["amount"] < LIMIT:        call = wallet.functions.spend(invoice["to"], invoice["amount"], decision_hash)    else:        signature = owner_sign(invoice["to"], invoice["amount"], decision_hash)        call = wallet.functions.spendApproved(invoice["to"], invoice["amount"], decision_hash, signature)    tx = call.build_transaction({"from": taker.address,                                 "nonce": w3.eth.get_transaction_count(taker.address)})    tx_hash = w3.eth.send_raw_transaction(taker.sign_transaction(tx).raw_transaction)    paid = w3.eth.wait_for_transaction_receipt(tx_hash).status == 1     warns("/v1/events/settlement", {"decisionId": decision["decisionId"], "success": paid,                                    "network": ARC, "transaction": w3.to_hex(tx_hash)})    return {"paid": paid, "transaction": w3.to_hex(tx_hash), "record": record}

The function names and the way the record is hashed are an example: use your contract's and your own. To check Warns' signatures, see Commit to a record hash and Check the attestation.

Put the Warns decisionId in the record your contract commits to, and keep Warns' signature of its hash next to it, as the example does. From the invoice to the transaction, each step names the one before: the record names the decision, Warns signed the record's hash, the contract committed to that hash. No change to the contract.

What Warns watches

Every payment out of the policy wallet is checked against a decision. On Arc, USDC is also the gas token and can move two ways; Warns sees both. When the contract says which authority paid, with a Spent(category, to, actor, amount, decisionHash, ...) event, Warns reads it: the owner is recognized even when someone else relays its transaction.

What happened onchainWhat Warns does
A payment with an allowed decision behind itMatches it to the decision: by record hash when the contract emits it with the payment, otherwise by recipient and amount.
The owner paid from the contract with its own keyRecords an owner payment and alerts you. The agent keeps running.
Any other payment, like the agent key paying without asking WarnsRecords an unguarded transfer and freezes the agent.

Circle Gateway

An agent that buys metered services with x402 on Arc testnet can pay through Circle Gateway. The SDK guards those payments like any other x402 payment, and makes deposits into Gateway through a decision, so a deposit never freezes the agent.