# Batch Verifier

The Batch Verifier lets you check a Restake-built EIP-7702 batch **before the
user signs it**, with a free `eth_call` on your own RPC node. It answers one
question:

> Does this exact user operation do only what the user asked for, touch only
> allowlisted contracts, and is this the hash I am about to sign?

Source and full specification:
[unodev-core/restake-verify-contract](https://github.com/unodev-core/restake-verify-contract).

<Callout type="info" title="Status: design stage">
  The specification is public and under review. Contracts, tests and deploy
  scripts are not written yet, so there are no addresses to call. This page
  will list them per chain once they are deployed and audited - see the
  [roadmap](#roadmap).
</Callout>

## Why

The API builds unsigned ERC-4337 batches (EntryPoint v0.7, Alchemy Modular
Account v2 as the 7702 delegate) for staking, unstaking, stock trades and
bridging. Today your wallet has to trust whatever the API returns. A
compromised API could return a batch that:

- grants an infinite approval to an attacker,
- deposits or swaps with an attacker as `receiver`,
- delegates the user's address to a malicious 7702 implementation,
- installs a backdoor module through a call from the account to itself.

The verifier moves that trust from the API to **public, timelocked on-chain
code** that you call on your own node.

## How it works

```
 ┌──────────────┐  1. build intent   ┌──────────────┐
 │ Partner app  │ ─────────────────▶ │ Restake API  │
 │ (wallet, UI) │ ◀───────────────── │  (untrusted) │
 └──────┬───────┘  op, userOpHash,   └──────▲───────┘
        │          authorization            │
        │                                   │ 5. signed op
        │ 2. eth_call verify(op, hash,      │
        │    auth, intentFromUI)            │
        ▼                                   │
 ┌──────────────────────┐                   │
 │ Partner's own RPC    │                   │
 │  └▶ BatchVerifier    │                   │
 └──────┬───────────────┘                   │
        │ 3. verifiedHash, deadline         │
        │    (or a precise revert reason)   │
        ▼                                   │
 ┌──────────────────────┐  4. sign verifiedHash + 7702 auth
 │ User signs           │ ──────────────────┘
 └──────────────────────┘
```

1. You receive `{ op, userOpHash, authorization, intentId }` from the build
   call.
2. You call `verify(...)` on **your own node**, passing the intent the user
   actually saw in your UI - not values copied from the API response.
3. The verifier returns `verifiedHash` and the batch `deadline`, or reverts with
   a custom error.
4. The user signs **`verifiedHash`**, never the hash from the API response,
   plus the 7702 authorization when the bundle carries one (it is `null` once
   the wallet is delegated). The signature format is unchanged:
   `personal_sign` over the raw 32 bytes, sent as `0xff00 ‖ sig`. When there is
   an authorization, you also check `auth.nonce == eth_getTransactionCount(user)`
   off-chain.
5. You submit the signatures as usual, and the API submits the operation.

### Interface

```solidity
function verify(PackedUserOperation calldata op, bytes32 userOpHash,
                Authorization calldata auth, Intent calldata intent)
    external view returns (bytes32 verifiedHash, uint256 deadline); // reverts with a custom error

function check(/* same args */)
    external view returns (bool ok, uint8 errorCode, uint256 callIndex); // non-reverting, for UIs
```

`Intent` carries `action` (`STAKE`, `UNSTAKE`, `BUY`, `SELL`, `BRIDGE`),
`user`, `target`, `payToken`, `amount`, `minOut`, `maxFee`, and for bridges
`dstChainId` and `recipient`.

Use `check` when you want to show the user *which* call failed: it returns the
error code and the index of the offending call instead of reverting.

## Architecture

```
                  partners: eth_call
                         │
        ┌────────────────▼────────────────┐   reads   ┌─────────────────────────────┐
        │ BatchVerifier  (ERC-1967 proxy) │ ────────▶ │ AllowlistRegistry  (proxy)  │
        │  stateless view logic, UUPS     │           │  enumerable sets, pending   │
        │  decoder libraries per action   │           │  ops, config (ERC-7201)     │
        └────────────────┬────────────────┘           └──────────────┬──────────────┘
                         │ upgrade (48 h)                            │ upgrade (48 h)
                         └───────── TimelockController ◀─────────────┘
                                    (48 h, Safe multisig proposer)

        DeadlineGuard: immutable, admin-less; called as call 0 inside every signed batch
```

| Component | Role |
| --- | --- |
| **BatchVerifier** | Pure view logic with a stable proxy address you hard-code. EntryPoint v0.7 and the Modular Account v2 delegate are pinned as constants. One decoder library per action: account calls, allowances, fees, ERC-4626, Aave, 1inch, Across. |
| **AllowlistRegistry** | Per-chain lists of allowed addresses by category (paymasters, vaults, Aave pools, assets, Ondo tokens, swap routers and pools, bridges, fee recipients), plus bridge routes and fee/TTL caps. Every list can be read on-chain. |
| **[DeadlineGuard](/security#deadlineguard)** | `requireBefore(deadline)`. Binds an expiry into the signed batch itself. Never upgradeable. |
| **TimelockController** | The only upgrader of both proxies, with a 48-hour delay. |

One deployment per chain, each with its own registry. Base and BSC come first;
Ethereum, Arbitrum, Optimism, Polygon, Avalanche and Plasma follow with staking
only.

## What gets checked

The rule is **default deny**: every call in the batch must match a rule, and
anything else fails.

1. **Envelope** - `userOpHash` recomputed locally; delegate is Modular Account
   v2; `chainId ≠ 0`; `sender == user`; no `initCode`; paymaster allowlisted or
   gas capped; `callData` is `executeBatch` only, with no calls from the account
   to itself and no native value.
2. **Deadline** - call 0 is `DeadlineGuard.requireBefore(d)` with
   `now < d ≤ now + 600 s`.
3. **Action rules**, bound to the intent - see below.
4. **Shared rules** - exact approvals, no allowance left behind, and at most one
   fee transfer, placed last, within `intent.maxFee` and a hard cap.

### Accepted batch shape

```
[0] DeadlineGuard.requireBefore(d)       required
[1] token.approve(spender, 0)            optional, USDT-style reset
[2] token.approve(spender, amount)       optional, exact amount only
[3] main call                            deposit / supply / redeem / withdraw / 1inch swap / Across deposit
[4] token.approve(spender, 0)            revoke; required if an allowance would otherwise remain
[5] USDC.transfer(feeRecipient, fee)     optional, always last
```

### Supported actions (v1)

| Action | Main call | Key checks |
| --- | --- | --- |
| `STAKE` | ERC-4626 `deposit` / Aave v3 `supply` | Vault or pool allowlisted, asset read live, `receiver` / `onBehalfOf == user` |
| `UNSTAKE` | ERC-4626 `redeem` / `withdraw`, Aave `withdraw` | `receiver == owner == user`, no share approvals |
| `BUY` / `SELL` | 1inch v6 `swap` or `unoswap*` (Ondo stocks on BSC) | Tokens match the intent, `dstReceiver == user`, `minReturn ≥ intent.minOut`, no partial fill; every `unoswap` hop goes through an allowlisted pool |
| `BRIDGE` | Across SpokePool `deposit` (USDC Base ↔ BSC) | Allowlisted route pins the destination token, `recipient == refundTo == user`, `outputAmount ≥ minOut`, bounded fill deadline and quote age, no destination message |

## Security features

| Feature | Protects against |
| --- | --- |
| 7702 delegate pinned to Modular Account v2, for new and existing delegations | Account takeover through a malicious implementation. The most important check, because the v0.7 `userOpHash` does not commit to the delegate |
| `chainId ≠ 0` and `== block.chainid` | Cross-chain replay of the authorization |
| Local v0.7 `userOpHash` recomputation | Signing a hash that does not match the verified operation |
| Allowlist on the account entry point, `SelfCallForbidden()` | Module installs, `upgradeToAndCall`, `performCreate`, delegatecall |
| Exact approvals, zero leftover allowance | Infinite or leftover approvals; also cleans older infinite allowances |
| Receiver / owner / recipient / refund `== user` | Funds redirected to an attacker |
| `minOut` from your own quote | Sandwiching or bad routes through the API |
| Fee cap (`intent.maxFee` + 50 bps + $0.10 backstop) | Fee inflation |
| In-batch [DeadlineGuard](/security#deadlineguard) | A signed operation held and executed much later |
| Strict decoding: exact selectors and calldata length, no dynamic dispatch | Trailing-data and selector-confusion tricks |
| Live reads only from allowlisted contracts | Malicious contracts lying through view calls |
| Paymaster allowlist or gas caps | Gas griefing on the user's native balance |

## Governance

| Operation | Delay | Who |
| --- | --- | --- |
| Add an address or bridge route, change a config value | **48 h** | `MANAGER` schedules, anyone executes |
| Upgrade the verifier or the registry | **48 h** | `TimelockController` (Safe multisig proposer) |
| Remove an address or route, cancel a pending add | Instant | `GUARDIAN` or `MANAGER` |
| `pause()` - every verification fails | Instant | `GUARDIAN` |

Anything that loosens the rules waits 48 hours and emits an event, so you can
watch and object. Anything that tightens them is instant. Subscribe to
`AdditionScheduled`, `Upgraded` and `CallScheduled` on the deployments you use.

## Benefits

- **You no longer trust the API.** You trust public code you call on your own
  node.
- **48-hour public reaction window.** Even a compromised Restake admin cannot
  add a malicious target or change the logic without notice.
- **Intent binding.** The batch is checked against what the user saw, not
  against what the API claims.
- **Fail closed.** Unknown selectors, routes, pools, tokens or ABI changes all
  revert.
- **Precise errors.** Custom errors, and `check` with the failing `callIndex`,
  make UI messaging simple.
- **Free at runtime.** Verification is an `eth_call`; the only gas it adds to the
  user's operation is the small deadline call.

## Trust assumptions and limitations

- **Verifier bugs are the main risk**, hence strict decoders, fuzzing,
  invariant tests and an external audit before mainnet.
- **The 1inch executor route is opaque.** Safety rests on the exact approve,
  `dstReceiver == user`, `minReturn` and allowlisted pools. Also simulate
  balance changes (`eth_simulateV1` or `debug_traceCall`).
- **Bridge fills happen on the other chain.** The verifier relies on Across'
  fill-or-refund guarantee: the worst case after verification is funds locked
  until `fillDeadline`, then refunded.
- **Fees on non-USDC pools** are bounded only by `intent.maxFee`, so compute it
  from your fee tiers (partner panel → **Fees**; the platform defaults if you
  have not set any).
- **Standard ERC-20s only.** USDC is upgradeable and has a blocklist; Ondo
  tokens may restrict transfers - a verified operation can still revert, safely.
- **Your off-chain duties:** sign `verifiedHash`, check the 7702 nonce, and
  derive `minOut` and `maxFee` independently of the API.

## Roadmap

1. **Discovery** - capture real payloads; pin the Modular Account v2
   implementation, the 1inch flag layout and the Across ABI.
2. **Scaffold** - Foundry, OpenZeppelin Upgradeable v5, CI with Slither.
3. **AllowlistRegistry** - timelocked adds, instant removes, pause.
4. **BatchVerifier core** - envelope, DeadlineGuard, hash recomputation,
   account call decoder.
5. **Rules** - allowances and fees, then ERC-4626, Aave, 1inch, Across.
6. **Governance wiring** - timelock, Safe proposer, guardian, deploy scripts for
   Base and BSC.
7. **Tests** - unit, fork tests on real payloads, fuzzing of malicious
   mutations, invariants.
8. **Partner kit** - viem snippet, fee formula, ABI, addresses.
9. **Audit**, then Base and BSC mainnet.
