Skip to workspace
Workspace / Security proof
Arc · 5042
SECURITY / AGENT CANNOT BE MANIPULATED

The 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.

All checks passing
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

AttackOn-chain defense in ArcVault.solProven by
Spend more than one payment allowsamount > maxPayment → PaymentLimitContract · Adversarial · Testnet
Drain the vault over many paymentsspent + amount > maxTotal → TotalLimitContract · Adversarial · Testnet
Pay itself or an attacker addressrecipient not on owner allowlist → RecipientDeniedContract · Adversarial · Testnet
Replay an earlier paymentpaymentId already used → DuplicatePaymentContract · Adversarial · Testnet
Keep paying after permission endsblock.timestamp ≥ expiresAt → ExpiredContract · Adversarial · Testnet
Keep paying after the owner revokesactive = false → UnauthorizedContract · Adversarial · Testnet
Raise its own limitsauthorize() is onlyOwner → NotOwnerAdversarial · Testnet
Withdraw the vault balancewithdraw() is onlyOwner, pays owner onlyAdversarial · Testnet
Cancel the owner’s revocationrevoke()/authorize() are onlyOwnerAdversarial · Testnet
Spend too fast after a key theftdaySpent + amount > maxPerDay → DailyLimitContract · Adversarial · Testnet
Drain through the gas allowancegasSpent + amount > gasAllowance → GasLimitContract · Adversarial · Testnet
Add itself or an attacker as recipientsetRecipient()/setRegistry() are onlyOwnerContract · Adversarial · Testnet
Follow a prompt injection in fetched contentInjected pay() to an unlisted address → RecipientDeniedAI agent run · Testnet
Re-enter during a transferReentrancyGuard on every state-changing callContract
Reuse an old recipient listAllowlist is versioned; each update starts freshContract

Why it holds

Trust lives in the contract

  1. The agent key can only call pay() and topUpGas(), both bounded by the owner's policy. Every owner function checks msg.sender == owner.
  2. Every rule is checked inside the same transaction that moves USDC. A failed check reverts and moves nothing.
  3. The SDK and UI pre-check a payment before signing, but they are a convenience. The contract is the authority.
  4. Budget spent is stored on-chain and survives re-authorization, so limits cannot be reset by the agent.
  5. 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

Live Arc testnet receipts

From the latest end-to-end run

Verify it yourself

Every claim on this page is reproducible

pnpm arc:testContract flow + adversarial invariant test on a local EVM
pnpm arc:testnet:e2eFresh owner, agent and vault on Arc testnet; 14 checks with real transactions
ARC_FUZZ_SEED=7 pnpm arc:testRe-run the attacker with a different random seed