As agents become more capable — signing blockchain transactions, calling external APIs, spending tokens on behalf of users — the question of what the agent is allowed to do becomes critical. Without a permission system, any skill a user installs could make unlimited on-chain actions. There would be no way to limit scope, set an expiry, or revoke access after the fact.
three.ws solves this with ERC-7710 delegation: a standard for granting on-chain capabilities to agents with precise, enforceable constraints. Think of it as OAuth scopes, but for blockchain operations — the user signs once, the delegation encodes the full scope, and smart contracts enforce it on every redemption.
An ERC-7710 delegation is a signed authorization envelope with the following structure:
| Field | Type | Description |
|---|---|---|
delegate |
address |
The agent's smart account — the party authorized to act |
delegator |
address |
The owner wallet — the party granting authority |
authority |
bytes32 |
0xff...ff for root delegations (non-chained) |
caveats |
Caveat[] |
Array of { enforcer, terms } structs encoding scope constraints |
salt |
bytes32 |
Random value for replay protection |
signature |
bytes |
EIP-712 signature over the struct, produced by the delegator |
The user signs the delegation via EIP-712 in MetaMask (triggered by the wallet_grantPermissions JSON-RPC call defined in ERC-7715). The signature is off-chain — the envelope is stored in the database and pinned to IPFS. When an agent needs to take a scoped action, it submits the envelope to the DelegationManager contract, which verifies the signature and enforces every caveat before executing the call.
The trust model: the user signs once. Smart contracts enforce that scope on every redemption. The agent acts autonomously within the limits the owner approved — no further user interaction required until the scope needs to change or the delegation expires.
Scope is encoded as caveats on the delegation envelope. For off-chain indexing and manifest embedding, scope is also stored as a JSON object with these fields:
| Field | Type | Constraints | Description |
|---|---|---|---|
token |
string |
ERC-20 address (EIP-55) or "native" |
Token the agent may spend |
maxAmount |
string |
Non-negative integer in base units | Per-period spending cap |
period |
string |
"daily", "weekly", "once" |
When the allowance resets |
targets |
string[] |
EIP-55 checksummed addresses | Contracts the agent may call |
expiry |
number |
Unix seconds, must be in the future | When the delegation expires |
Each scope field maps to a deployed caveat enforcer contract:
- AllowedTargetsEnforcer — rejects any call whose
toaddress is not intargets - ERC20TransferAmountEnforcer — enforces the
maxAmountspending cap for an ERC-20 token - ERC20PeriodTransferEnforcer — enforces
maxAmountperperiod(resets the counter each cycle) - NativeTokenTransferAmountEnforcer / NativeTokenPeriodTransferEnforcer — same, for native ETH
- TimestampEnforcer — enforces
expiry(block.timestamp <= expiry, else reverts) - LimitedCallsEnforcer — caps total number of redemptions
Enforcer addresses are deterministic (CREATE2, from @metamask/delegation-deployments v1.2.0) and available in src/erc7710/abi.js as CAVEAT_ENFORCERS.
When a skill or agent action requires a delegation that doesn't yet exist, the grant modal appears. It is a 3-step flow:
- Scope builder — the user selects token, spend limit, period, target contracts, and expiry. Common contracts (e.g., Uniswap V3 Router) are offered by name.
- Plain-English review — the modal translates the scope into a human-readable summary (e.g., "Up to 10 USDC per day, to Uniswap V3 Router, expiring 2026-07-01") before opening the MetaMask prompt. This step must never be skipped — it gives the user a trusted-context preview before the raw EIP-712 data appears in MetaMask.
- EIP-712 sign — MetaMask opens the
wallet_grantPermissionsprompt; the user approves; the signed delegation is stored and pinned.
Use encodeScopedDelegation and signDelegation from @three-ws/sdk/permissions (or directly from src/permissions/toolkit.js):
import { encodeScopedDelegation, signDelegation } from '@three-ws/sdk/permissions';
import { CAVEAT_ENFORCERS } from '@three-ws/sdk/erc7710';
import { AbiCoder } from 'ethers';
const CHAIN_ID = 84532; // Base Sepolia
// Build the unsigned envelope with your caveats
const delegation = encodeScopedDelegation({
delegator: '0xOwnerWallet...',
delegate: '0xAgentAccount...',
chainId: CHAIN_ID,
expiry: Math.floor(Date.now() / 1000) + 7 * 24 * 3600, // 7 days
caveats: [
{
// Only allow calls to a specific contract
enforcer: CAVEAT_ENFORCERS.AllowedTargetsEnforcer[CHAIN_ID],
terms: '0x' + targetAddress.slice(2).toLowerCase().padStart(64, '0'),
},
{
// Cap at 10 USDC per period
enforcer: CAVEAT_ENFORCERS.ERC20PeriodTransferEnforcer[CHAIN_ID],
terms: AbiCoder.defaultAbiCoder().encode(
['address', 'uint256'],
[usdcAddress, 10_000_000n], // 10 USDC (6 decimals)
),
},
],
});
// Sign with the delegator's wallet (ethers v6 signer)
const signed = await signDelegation(delegation, signer);
// signed.signature — the EIP-712 bytes
// signed.hash — keccak256 of the struct (safe to log; never log the signature)Then store the signed delegation via POST /api/permissions/grant:
const res = await fetch('/api/permissions/grant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: '<uuid>',
chainId: CHAIN_ID,
delegation: signed,
scope: {
token: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', // USDC on Base Sepolia
maxAmount: '10000000',
period: 'daily',
targets: ['0xTargetContract...'],
expiry: signed.expiry,
},
}),
});
// → { ok: true, id: 'delegation-uuid', delegationHash: '0x...' }Skills call redeemFromSkill from src/runtime/delegation-redeem.js. This is the main entrypoint for any on-chain action a skill needs to take:
import { redeemFromSkill } from '@three-ws/sdk/runtime';
// Inside a skill handler:
export async function execute_swap({ tokenIn, tokenOut, amount }, ctx) {
const result = await redeemFromSkill({
agentId: ctx.agentId,
chainId: 84532,
skillId: 'my-swap-skill',
mode: 'auto', // 'client' | 'relayer' | 'auto'
calls: [{
to: '0xSwapRouter...',
value: 0n,
data: encodeSwapCall(tokenIn, tokenOut, amount),
}],
});
if (!result.ok) {
return { ok: false, error: result.code, message: result.message };
}
return { ok: true, txHash: result.txHash };
}redeemFromSkill handles the full lifecycle:
- Rate check — enforces a hard client-side limit of 5 redemptions per minute per tab. Exceeding it returns
{ ok: false, code: 'rate_limited' }without any network call. - Delegation lookup — fetches the active delegation for
agentId + chainIdfrom/api/permissions/metadatawith a 60-second in-memory cache. Returnsdelegation_not_foundif none. - Scope pre-flight — validates that every call in
callstargets an address inscope.targetsand that no call value exceedsscope.maxAmount. This runs off-chain before touching the chain, preventing wasted gas on obvious scope violations. - Mode resolution — in
automode, picksclientifwindow.ethereumis available and the signed delegation is present, orrelayerif a bearer token is configured inwindow.__AGENT_RUNTIME_CONFIG__. - Submission —
clientmode connects the wallet and callsDelegationManager.redeemDelegations()directly;relayermode POSTs to/api/permissions/redeemwith a bearer token. - Protocol events — emits
PERMISSIONS_REDEEM_START,PERMISSIONS_REDEEM_SUCCESS, orPERMISSIONS_REDEEM_ERRORon the agent protocol bus for observability.
To monitor redemption events:
import { subscribeRedeemEvents } from '@three-ws/sdk/runtime';
const unsubscribe = subscribeRedeemEvents((event) => {
console.log(event.type, event.payload);
});
// Later: unsubscribe()Mount the panel for an agent:
import { mountManagePanel } from '@three-ws/sdk/permissions';
const { unmount } = mountManagePanel({
container: document.getElementById('permissions-container'),
agentId: '<uuid>',
});The panel lists all active delegations with scope summaries, expiry dates, and revoke buttons. Revoking calls DelegationManager.disableDelegation() on-chain (via MetaMask) and then mirrors the status to the server.
Revoking has two parts: the on-chain transaction and the server-side mirror.
import { Contract, BrowserProvider } from 'ethers';
import { DELEGATION_MANAGER_DEPLOYMENTS, DELEGATION_MANAGER_ABI } from '@three-ws/sdk/erc7710';
// 1. Submit disableDelegation on-chain
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const dm = new Contract(
DELEGATION_MANAGER_DEPLOYMENTS[84532],
DELEGATION_MANAGER_ABI,
signer,
);
const tx = await dm.disableDelegation(delegationTuple);
const receipt = await tx.wait();
// 2. Mirror to the server
await fetch('/api/permissions/revoke', {
method: 'POST',
body: JSON.stringify({ id: 'delegation-uuid', txHash: receipt.hash }),
});Revocation is authoritative on-chain. The database mirrors state via an indexer that polls DelegationDisabled events, but there is a brief race window between the on-chain confirmation and the indexer catching up. During this window the database may still show the delegation as active. For a real-time view, use GET /api/permissions/verify?hash=0x...&chainId=N which reads disabledDelegations() directly from the chain.
All routes are under /api/permissions/. Responses use { ok: true, ... } on success and { ok: false, error: '<code>', message: '...' } on failure.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/permissions/grant |
Session cookie | Store a signed delegation + scope |
GET |
/api/permissions/list |
Session cookie | List delegations (?agentId=X&status=active) |
POST |
/api/permissions/revoke |
Session cookie | Mirror on-chain revocation to DB |
POST |
/api/permissions/redeem |
Agent bearer token | Server-side redemption via relayer |
GET |
/api/permissions/metadata |
Public (cached) | Public scope view for a given agentId |
GET |
/api/permissions/verify |
Public | Real-time on-chain validity check (?hash=0x...&chainId=N) |
The metadata endpoint returns { ok, spec: "erc-7715/0.1", delegations: [...] } with Cache-Control: public and Access-Control-Allow-Origin: * — it is safe to call from embed iframes.
All delegation enforcement runs through the DelegationManager contract, deployed at the same address on all supported chains via CREATE2:
0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3
Supported chains: Ethereum mainnet (1), Base mainnet (8453), Base Sepolia (84532), Sepolia (11155111), Arbitrum Sepolia (421614), Optimism Sepolia (11155420).
Key contract functions:
// Redeem one or more delegations to execute calls
function redeemDelegations(
bytes[] _permissionContexts,
bytes32[] _modes,
bytes[] _executionCallDatas
) external;
// Revoke — caller must be the delegator
function disableDelegation(Delegation _delegation) external;
// Read-only: check if a delegation hash has been revoked
function disabledDelegations(bytes32 delegationHash) external view returns (bool);ABIs and all caveat enforcer addresses are in src/erc7710/abi.js.
The skill registry applies trust rules when installing skills:
| Mode | Who can install |
|---|---|
any |
Any skill from any URL |
owned-only |
Only skills whose manifest.json author field matches the agent owner's wallet address |
whitelist |
Only skills on an explicit allow-list |
Skills installed from unverified URLs in any mode must still explicitly declare the permissions they need in their manifest, and the delegation must be granted by the user before those permissions can be exercised. Installing a skill does not automatically grant it any delegation.
If your skill redeems delegations or calls redeemFromSkill, declare this in manifest.json:
{
"spec": "skill/0.1",
"name": "dca-trader",
"version": "0.1.0",
"permissions": {
"requires_delegation": true,
"scope_hint": {
"token": "native",
"period": "daily",
"targets": ["0xExpectedContract..."]
}
},
"tools": [...]
}The skill registry uses this declaration to show users what a skill needs before they install it, and to pre-populate the grant modal with the suggested scope.
All error codes are OAuth-style strings returned as error in the { ok: false, error, message } response shape:
| Code | When | Recovery |
|---|---|---|
delegation_expired |
expiry has passed at redemption time |
Prompt user to grant a new delegation |
delegation_revoked |
disableDelegation confirmed on-chain, or DB status is revoked |
Prompt user to grant a new delegation |
scope_exceeded |
Call value exceeds maxAmount or period allowance |
Reduce call value or prompt scope update |
target_not_allowed |
A call target is not in scope.targets |
Restrict targets or prompt scope update |
delegation_not_found |
No active delegation for agentId + chainId |
Prompt user to grant permissions |
signature_invalid |
EIP-712 signature verification fails | Reject the envelope; prompt user to re-sign |
chain_not_supported |
chainId has no DelegationManager deployment |
Inform user; use a supported chain |
rate_limited |
Exceeded 5 redemptions per minute per tab | Back off and retry |
no_redemption_path |
No wallet connected and no relayer token | Connect wallet or configure relayer |
What the delegation enforces:
- Spending limits per token per period (
maxAmount,period) - Allowed call targets (
targets) — an agent cannot call contracts outside the approved list even if it tries - Hard expiry (
expiry) — enforced on-chain byblock.timestamp; no server-side clock drift matters - Replay protection — each delegation has a random 32-byte
saltand a domain separator bound tochainIdand theDelegationManageraddress; a delegation from Base cannot be replayed on Ethereum
Blast radius of a compromised agent key:
If an agent's signing key is exposed, an attacker is limited to: the contracts in targets, the maxAmount per period, and the time window before expiry. Revoke immediately via DelegationManager.disableDelegation() — this is enforceable on-chain regardless of what the server knows.
Relayer trust:
When POST /api/permissions/redeem is used instead of direct wallet submission, the relayer sees the raw calls before broadcasting. Scope is still enforced on-chain by the DelegationManager even if the relayer is compromised — an attacker cannot exceed the signed caveats. However, a compromised relayer can censor redemptions or front-run them. Treat the relayer with the same trust level as the agent's signing key.
No mainnet defaults: The grant modal and toolkit require the user to explicitly choose a chain. Mainnet support requires explicit opt-in through the agent owner's settings. Base Sepolia (84532) and Sepolia (11155111) are the default test chains.
No custodial delegator keys:
The delegator (owner) wallet key is never held server-side. The agent's smart account key may be held server-side only as defined in the agent trust boundary documentation (task 09). Never store or redeem a delegation whose EIP-712 signature has not been verified — signature_invalid is the correct error for any unverified envelope.
The smoke test script at scripts/smoke-permissions.js exercises the full delegation lifecycle against a deployed environment: encoding, EIP-712 signing, grant API, list API, verify API, on-chain revocation, and negative cases. Run it against a staging deployment before releasing permission-related changes:
SMOKE_BASE_URL=https://your-app.vercel.app \
SMOKE_AGENT_ID=<uuid> \
SMOKE_CHAIN_ID=84532 \
SMOKE_DELEGATOR_KEY=0x<testnet-private-key> \
SMOKE_SESSION_COOKIE=<session> \
node scripts/smoke-permissions.jsSet SMOKE_SKIP_REVOKE=1 to skip the on-chain revocation step if the test wallet has no testnet ETH.
- Architecture Overview
- Agent System
- ERC-8004 Blockchain Identity
- Skill System
- EIP-7710 — delegation envelope format
- EIP-7715 —
wallet_grantPermissionsJSON-RPC method - MetaMask Delegation Toolkit