SECURITY / AGENT CANNOT BE MANIPULATED
All checks passingThe contract decides. Not the agent.
Assume the AI is prompt-injected or its key is stolen. It still cannot move a cent outside the owner’s policy.
ATTACK CLASSES15Each blocked on-chain
ADVERSARIAL ROUNDS300 × seedsRandom attacks, invariant after each
LIVE TESTNET E2E14 / 14Real Arc transactions
AGENT POWERS2 functionspay() and topUpGas(), both bounded
Threat model
What a compromised agent tries, and what stops it
| Attack | On-chain defense in ArcVault.sol | Proven by |
|---|---|---|
| Spend more than one payment allows | amount > maxPayment → PaymentLimit | Contract · Adversarial · Testnet |
| Drain the vault over many payments | spent + amount > maxTotal → TotalLimit | Contract · Adversarial · Testnet |
| Pay itself or an attacker address | recipient not on owner allowlist → RecipientDenied | Contract · Adversarial · Testnet |
| Replay an earlier payment | paymentId already used → DuplicatePayment | Contract · Adversarial · Testnet |
| Keep paying after permission ends | block.timestamp ≥ expiresAt → Expired | Contract · Adversarial · Testnet |
| Keep paying after the owner revokes | active = false → Unauthorized | Contract · Adversarial · Testnet |
| Raise its own limits | authorize() is onlyOwner → NotOwner | Adversarial · Testnet |
| Withdraw the vault balance | withdraw() is onlyOwner, pays owner only | Adversarial · Testnet |
| Cancel the owner’s revocation | revoke()/authorize() are onlyOwner | Adversarial · Testnet |
| Spend too fast after a key theft | daySpent + amount > maxPerDay → DailyLimit | Contract · Adversarial · Testnet |
| Drain through the gas allowance | gasSpent + amount > gasAllowance → GasLimit | Contract · Adversarial · Testnet |
| Add itself or an attacker as recipient | setRecipient()/setRegistry() are onlyOwner | Contract · Adversarial · Testnet |
| Follow a prompt injection in fetched content | Injected pay() to an unlisted address → RecipientDenied | AI agent run · Testnet |
| Re-enter during a transfer | ReentrancyGuard on every state-changing call | Contract |
| Reuse an old recipient list | Allowlist is versioned; each update starts fresh | Contract |
Why it holds
Trust lives in the contract
- The agent key can only call
pay()andtopUpGas(), both bounded by the owner's policy. Every owner function checksmsg.sender == owner. - Every rule is checked inside the same transaction that moves USDC. A failed check reverts and moves nothing.
- The SDK and UI pre-check a payment before signing, but they are a convenience. The contract is the authority.
- Budget spent is stored on-chain and survives re-authorization, so limits cannot be reset by the agent.
- The contracts are immutable: no admin key, no upgrade path, no owner change.
Adversarial invariant test
scripts/arc-adversarial.test.mjs
A simulated attacker sends hundreds of random transactions: oversized amounts, foreign recipients, replayed IDs, owner-only calls, time jumps, revocations. After every round the test checks that on-chain spend equals the sum of permitted payments, never exceeds the budget, and the attacker and agent hold zero USDC.
Arc mainnet receipts
Real USDC, chain 5042
- deployFactory
- 0x4bf4f62e…225f1e ↗
- createVault
- 0x3c9a9e4b…dae7c5 ↗
- deposit
- 0x8ab1dfee…e1efd6 ↗
- authorize
- 0xe057d38a…470f2f ↗
- agentPayment
- 0xee745efa…e1eedd ↗
Live Arc testnet receipts
From the latest end-to-end run
- createVault
- 0x19323be9…1c7d91 ↗
- deposit
- 0xf56c5a5b…70e385 ↗
- authorize
- 0xc34d9750…3f711e ↗
- revoke
- 0x2be9eba4…82c2f6 ↗
- withdraw
- 0xc4b6b2c4…9347b6 ↗
Verify it yourself
Every claim on this page is reproducible
| pnpm arc:test | Contract flow + adversarial invariant test on a local EVM |
| pnpm arc:testnet:e2e | Fresh owner, agent and vault on Arc testnet; 14 checks with real transactions |
| ARC_FUZZ_SEED=7 pnpm arc:test | Re-run the attacker with a different random seed |