← README · Architecture · Adversarial Testing
SpendArc's safety rests on two independent fences and a set of invariants that keep them independent. This document states the guarantees, the deliberate tradeoffs, and the known v1 limitations — honestly.
- An off-policy action can't be broadcast. The executor's private key lives only on the server and
only ever signs
SpendArcVault.executeSpendForafterevaluatePolicyapproves the request (service allowlist + per-tx cap + daily cap + dedup against the server ledger). No signature → the call is never broadcast. - Value only moves inside policy. For any executed call,
SpendArcVault._executeSpendchecks active/expiry, token allowlist, per-service allowlist (label, per-tx cap, daily cap, expiry), global per-tx cap, rolling-24h daily cap, andactionIddedup before the transfer (checks-effects-interactions). - Every decision is on-chain. Blocked actions emit
AgentActionBlocked(agent, target, token, amount, reason)and returnfalse— they never revert. Approved actions emitAgentActionApproved+ReceiptIssued. The dashboard and any auditor reconstruct the full history from events. - The executor is spend-within-policy only. The executor role (
executeSpendFor) cannot change policy, cannot deposit, and cannot withdraw. Only the vault owner holds those powers.
Policy violations emit + return false; they do not revert. A revert would erase the event and the
audit trail. Reverts are reserved for genuine safety: NotOwner(), NotAuthorized(), Reentrancy(),
NativeTransferFailed() (native-transfer atomicity), and constructor-invariant failures. The tradeoff — a
caller could ignore the returned approved bool — is closed by the server resolving outcomes from the
emitted events (never optimistically), and by the fact that a blocked action moves nothing regardless.
- Reentrancy: hand-rolled
nonReentrantguard onexecuteSpend/executeSpendFor; state (usedAction,spentToday) updates before the external transfer/call. A malicious token that re-enters hits the guard and the nested call reverts — proven by an explicit attacker test. - CEI: effects precede interactions; a failed transfer reverts the whole call (nothing partial).
- Owner controls:
setAgentPolicy,setAllowedService,setAllowedToken,revokeAgent(hard off-switch),withdrawTokens, andsetExecutorare allonlyOwner. - Per-service caps are validated:
setAllowedServicerequiresdailyCap == 0 || maxPerTx <= dailyCap, mirroring the same invariant the factory enforces on the global leash.
Fence 1 is now a server-side gate, not a paymaster:
- Policy evaluation reads the DB ledger (allowlist entries + budget rows) and the agent's requested spend, performs the same allowlist/dedup checks the vault enforces, and refuses cleanly with a structured JSON error when off-policy — it never throws, never partial-submits, and never signs.
- The
syncflow reconciles the DB against actual on-chain vault state (getService,remainingServiceDailyCap, etc.) and returns409 ONCHAIN_MISMATCHwhen they disagree, so a stale ledger can't silently drift further from the on-chain truth. - Because Fence 2 (the vault) re-checks everything on-chain anyway, a Fence 1 failure cannot move money:
at worst it signs a call the vault rejects — which emits a permanent
AgentActionBlockedartifact.
- The executor private key lives only in server env (the API routes run on the Node runtime).
It is never
NEXT_PUBLIC_, never shipped to the client, and never committed. The client bundle was grep-verified to contain zero key material and none of the server-only signer/send code. - The public endpoints are bounded (only
executeSpendForwithin an agent's policy) and the executor holds no other power on any vault. - Repo hygiene:
internal/(PRD +keys.json) and Foundrycache/(which holds forge-script "sensitive values") are gitignored. No private key is committed.
- The server gate is trust — it is not a cryptographic guarantee. A compromised server could sign
an on-policy-shaped call for any agent's vault (bounded by that vault's on-chain policy) or simply
refuse to serve. The on-chain vault is the hard safety boundary; the server gate is the convenience
- first-line filter. Per-user self-custodied vaults mean compromise of the executor cannot move funds off-policy, but a fully compromised server is still a liveness and policy-integrity risk.
- The executor is a single signing key. It is one point of failure and one attack surface. The intended hardening is a multisig + timelock operator (or threshold signing) so no single key can sign, plus immutable/ratchet-only policy so a live policy can never be silently weakened.
MockUSDis a test asset. No real funds are at risk in the hackathon deployment.
This is a hackathon testnet deployment using a MockUSD test asset — no real funds at risk.
Issues can be raised on the repository.