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
- 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.
- 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.
- Copy the test key. Your agent sends it with every call.
How a payment goes
| Warns says | Your agent |
|---|---|
allow, under the contract's limit | Pays with the agent key. |
allow, at or above the limit | The contract needs the owner's signature too. Warns never signs for the owner: approving in Warns and signing as owner stay two acts. |
review | Nothing 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. |
block | Nothing 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.
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.
Link the decision to your record
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 onchain | What Warns does |
|---|---|
| A payment with an allowed decision behind it | Matches 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 key | Records an owner payment and alerts you. The agent keeps running. |
| Any other payment, like the agent key paying without asking Warns | Records 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.
Next: Policies