Abstract
This EIP proposes a standard for trustless agent-mediated payments using zero-knowledge proofs for pre-execution verification. Unlike agent trust systems (as proposed by ERC-8004), this standard ensures that autonomous agents can only execute transactions that have been cryptographically committed to by the user before execution occurs. The system uses ZK-SNARKs to prove that derived transaction calldata matches the user's signed intent bundle, enabling secure delegation of payment execution to smart contract wallets.
Motivation
Current agent systems suffer from a trust problem: users must either trust agents to execute transactions correctly or verify behavior after execution has already occurred. Consider a user who wants an AI agent to rebalance her DeFi portfolio. She must either:
- Deposit funds directly into an agent-controlled wallet, taking on the risk of theft of loss; or
- Approve each transaction individually, defeating the purpose of automation.
ERC-8004 addresses some of these concerns by introducing agent reputation and allowing for validation of agent execution. However, reputable agents can still be compromised, and malicious or hallucinatory transactions can slip through the cracks, resulting in user funds permanently lost. In order to truly trust an agent with her funds, a user must be able to review and sign off on every transaction in an automated and secure manner.
This proposal introduces pre-execution zero-knowledge verification of agent transactions. Users sign off on an agent's batch of transactions, and agents must submit a zero-knowledge proof that their transactions match the user's signed intent. Only after the zero-knowledge proof is verified can any funds be transferred.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β USER FLOW β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β STEP 1 β β STEP 2 β β STEP 3 β β STEP 4 β
β Agent βββββββΆβ Alice βββββββΆβ Alice βββββββΆβ Alice β
β Proposes β β Reviews β β Signs β β Approves β
ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β β β β
βΌ βΌ βΌ βΌ
Intent Bundle: "Pay 3 merchants Sign(Hash( USDC.approve(
A: 10 USDC Total: 195 USDC" IntentBundle)) AgentWallet,
B: 150 USDC 195 USDC)
C: 35 USDC
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β STEP 5 β β STEP 6 β β STEP 7 β β STEP 8 β
β Agent βββββββΆβ Agent βββββββΆβ Verify βββββββΆβ On-chain β
β Generates β β Submits β β Proof β β Checks β
ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β β β β
βΌ βΌ βΌ βΌ
ZK circuit proves: Proof + Txs Verifier checks Nonce unused
- Hash matches to chain proof validity Not expired
- Calldata correct Hash matches
- Chain, payer, nonce,
expiry match
ββββββββββββββββ
β STEP 9 β
β Execute β
β Payments β
ββββββββ¬ββββββββ
β
βΌ
Multicall executes
all transfers
atomicallySpecification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Overview
The system consists of three main standardized components:
- Intent Bundle: A structured JSON object signed by the user
- ZK Circuit: Proves that derived calldata matches the signed intent
- Agent Wallet Contract: Verifies proofs and executes transactions atomically
Intent Bundle Structure
An intent bundle is a structured JSON object that MUST contain:
struct IntentBundle {
string version; // Protocol version (e.g., "1.0")
uint256 chainId; // Target blockchain ID
bytes32 nonce; // Unique nonce for replay protection
uint256 expiry; // Unix timestamp after which intent is invalid
address payer; // User's wallet address (EOA or smart contract)
Action[] actions; // Array of intended actions
}
struct Action {
string actionType; // e.g., "ERC20_TRANSFER"
address token; // Token contract address
address to; // Recipient address
uint256 amount; // Amount in token's smallest unit
}Example Intent Bundle:
{
"version": "1.0",
"chainId": 1,
"nonce": 0x42,
"expiry": 1736700000,
"payer": "0xAliceAddress...",
"actions": [
{"actionType": "ERC20_TRANSFER", "token": "0xUSDC", "to": "0xAAA", "amount": "10000000"},
{"actionType": "ERC20_TRANSFER", "token": "0xUSDC", "to": "0xBBB", "amount": "150000000"},
{"actionType": "ERC20_TRANSFER", "token": "0xUSDC", "to": "0xCCC", "amount": "35000000"}
]
}Commitment Generation
The user MUST generate a commitment by:
- ABI-encoding the intent bundle
- Hashing with
keccak256 - Signing the commitment with their private key
bytes memory encodedBundle = abi.encode(intentBundle);
bytes32 commitment = keccak256(encodedBundle);
bytes memory signature = userSign(commitment);Calldata Derivation
For each action in the intent bundle, canonical calldata MUST be deterministically derived:
function deriveCalldata(Action memory action, address payer)
internal
pure
returns (address target, bytes memory data)
{
require(keccak256(bytes(action.actionType)) == keccak256("ERC20_TRANSFER"), "Unsupported action type");
// Target is the token contract
target = action.token;
// ERC20 transferFrom function selector: 0x23b872dd
// transferFrom(address from, address to, uint256 amount)
data = abi.encodeWithSelector(
bytes4(0x23b872dd),
payer, // from: the user who approved
action.to, // to: the recipient
action.amount // amount: transfer amount
);
}Typed Data Signing (RECOMMENDED)
Implementations are RECOMMENDED to use EIP-712 typed data signing for improved user experience. Wallets that support EIP-712 display structured data in a human-readable format, allowing users to see exactly what they're approving. For reference, here are some of the relevant components:
Domain Separator
struct EIP712Domain { string name;string version; uint256 chainId; address verifyingContract; }
EIP712Domain domain = EIP712Domain({ name: "Agent Wallet", version: "1", chainId: 1, verifyingContract: agentWalletAddress });Type Hashes
bytes32 constant ACTION_TYPEHASH = keccak256(
"Action(string actionType,address token,address to,uint256 amount)"
);
bytes32 constant INTENT_BUNDLE_TYPEHASH = keccak256(
"IntentBundle(string version,uint256 chainId,bytes32 nonce,uint256 expiry,address payer,Action[] actions)"
"Action(string actionType,address token,address to,uint256 amount)"
);Example Signing Process
const domain = {
name: 'Agent Wallet',
version: '1',
chainId: 1,
verifyingContract: agentWalletAddress
};
const types = {
IntentBundle: [
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'nonce', type: 'bytes32' },
{ name: 'expiry', type: 'uint256' },
{ name: 'payer', type: 'address' },
{ name: 'actions', type: 'Action[]' }
],
Action: [
{ name: 'actionType', type: 'string' },
{ name: 'token', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'amount', type: 'uint256' }
]
};
const value = {
version: "1.0",
chainId: 1,
nonce: "0x42...",
expiry: 1736700000,
payer: "0xAlice...",
actions: [
{ actionType: "ERC20_TRANSFER", token: "0xUSDC", to: "0xAAA", amount: "10000000" },
{ actionType: "ERC20_TRANSFER", token: "0xUSDC", to: "0xBBB", amount: "150000000" },
{ actionType: "ERC20_TRANSFER", token: "0xUSDC", to: "0xCCC", amount: "35000000" }
]
};
const signature = await signer._signTypedData(domain, types, value);Commitment Generation
The commitment would become the EIP-712 struct hash:
function hashIntentBundle(IntentBundle memory bundle) internal pure returns (bytes32) {
bytes32[] memory actionHashes = new bytes32[](bundle.actions.length);
for (uint i = 0; i < bundle.actions.length; i++) {
actionHashes[i] = keccak256(abi.encode(
ACTION_TYPEHASH,
keccak256(bytes(bundle.actions[i].actionType)),
bundle.actions[i].token,
bundle.actions[i].to,
bundle.actions[i].amount
));
}
return keccak256(abi.encode(
INTENT_BUNDLE_TYPEHASH,
keccak256(bytes(bundle.version)),
bundle.chainId,
bundle.nonce,
bundle.expiry,
bundle.payer,
keccak256(abi.encodePacked(actionHashes))
));
}
function getCommitment(IntentBundle memory bundle) public view returns (bytes32) {
bytes32 domainSeparator = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("Agent Wallet"),
keccak256("1"),
block.chainid,
address(this)
));
bytes32 structHash = hashIntentBundle(bundle);
return keccak256(abi.encodePacked("\\x19\\x01", domainSeparator, structHash));
}Token Approval (Delegation)
Before the Agent Wallet can execute transfers, the user MUST approve the Agent Wallet contract to spend their tokens using the standard ERC-20 approve() function:
// User calls this on each token contract they want the agent to spend
IERC20(tokenAddress).approve(agentWalletAddress, approvalAmount);The Agent Wallet contract does NOT hold user funds - it operates as a non-custodial executor that can only spend approved amounts and only when accompanied by valid ZK proofs.
Zero-Knowledge Circuit
The ZK circuit MUST prove the following statements:
Public Inputs:
commitment: The keccak256 hash of the encoded intent bundle (32 bytes)chainId: Target blockchain IDsignerAddress: Alice's addressmulticallDataHash: Hash of the derived multicall calldatanonce: Fresh nonce valueexpiry: Expiry timestamp
Private Witness:
intentBundle: Full intent bundle with all action detailsderivationRandomness: Randomness used in derivation (if applicable)
Circuit Constraints:
The circuit MUST verify:
- Intent integrity:
keccak256(intentBundle) == commitment - Chain correctness:
intentBundle.chainId == chainId - Payer verification:
intentBundle.payer == signerAddress - Calldata correctness: For each action
iinintentBundle.actions:(target[i], calldata[i]) = deriveCalldata(intentBundle.actions[i], intentBundle.payer)
- Multicall hash:
keccak256(concat(calldata[])) == multicallDataHash - Nonce freshness:
intentBundle.nonce == nonce - Validity window:
block.timestamp < intentBundle.expiry
Circom Circuit Template
Below is a reference implementation using Circom:
template AgentPaymentVerifier(maxActions, maxBundleSize) {
// Public inputs
signal input commitment;
signal input chainId;
signal input signerAddress;
signal input multicallDataHash;
signal input nonce;
signal input expiry;
// Private inputs
signal input intentBundle[maxBundleSize];
signal input bundleLength;
signal input actions[maxActions][4]; // [token, to, amount, actionType]
signal input numActions;
// Step 1: Verify commitment hash
component hasher = Keccak256(maxBundleSize);
for (var i = 0; i < maxBundleSize; i++) {
hasher.in[i] <== intentBundle[i];
}
hasher.length <== bundleLength;
commitment === hasher.out;
// Step 2: Verify chain correctness
signal extractedChainId;
extractedChainId <== intentBundle[chainIdOffset];
chainId === extractedChainId;
// Step 3: Verify payer from intent bundle matches signer
signal payer;
payer <== intentBundle[payerOffset];
payer === signerAddress;
// Step 4: Verify calldata correctness
// For each action, derive the transferFrom calldata
component calldataHasher = Keccak256(maxActions * calldataSize);
var calldataIndex = 0;
for (var i = 0; i < maxActions; i++) {
// Only process if this action exists
signal actionExists;
actionExists <== LessThan(252)([i, numActions]);
if (actionExists) {
// Derive: transferFrom(payer, actions[i].to, actions[i].amount)
// Function selector: 0x23b872dd
signal selector[4] <== [0x23, 0xb8, 0x72, 0xdd];
// Encode parameters (simplified version here. actual implementation needs full ABI encoding)
// selector + abi.encode(payer, to, amount)
for (var j = 0; j < 4; j++) {
calldataHasher.in[calldataIndex++] <== selector[j];
}
// below add ABI-encoded parameters (signerAddress, actions[i].to, actions[i].amount)
...
}
}
// Step 5: Verify multicall hash
calldataHasher.length <== calldataIndex;
multicallDataHash === calldataHasher.out;
// Step 6: Verify nonce freshness
signal extractedNonce;
extractedNonce <== intentBundle[nonceOffset];
nonce === extractedNonce;
// Step 7: Verify expiry
signal extractedExpiry;
extractedExpiry <== intentBundle[expiryOffset];
expiry === extractedExpiry;
}
// Compile with specific parameters
component main {public [commitment, chainId, signerAddress, multicallDataHash, nonce, expiry]} = AgentPaymentVerifier(10, 2048);The above is a simplified template. A production template would need proper ABI encoding logic for calldata derivation.
Agent Wallet Contract
The Agent Wallet contract MUST implement the following interface:
interface IAgentWallet {
/**
* @notice Execute a batch of token transfers with ZK proof verification
* @param proof The ZK-SNARK proof
* @param signature User's ECDSA signature on the commitment
* @param publicInputs Public inputs for proof verification
* @param calls Array of calls (target contracts and calldata)
* @return success Whether execution succeeded
*/
function executeWithProof(
bytes calldata proof,
bytes calldata signature,
PublicInputs calldata publicInputs,
Call[] calldata calls
) external returns (bool success);
}
struct PublicInputs {
bytes32 commitment; // Hash of intent bundle
uint256 chainId; // Target blockchain ID
address signerAddress; // User's address
bytes32 multicallDataHash; // Hash of the call data
bytes32 nonce; // Unique nonce
uint256 expiry; // Expiry timestamp
}
struct Call {
address target; // Token contract address
bytes data; // transferFrom calldata
}Execution Flow
- Intent Creation: Agent proposes transactions
- User Signature: User reviews and signs intent bundle
- Proof Generation: Agent generates ZK proof showing calldata derivation correctness
- Approval Phase: User approves Agent Wallet contract to spend tokens
- Submission: Agent submits proof + signature + publicInputs + multicallData to Agent Wallet
- Verification: Contract verifies:
- Proof is valid for given public inputs
- User signature is valid for intent bundle
- Nonce hasn't been used
- Expiry hasn't passed
- Multicall data hash matches public input
- Atomic Execution: If verification passes, execute all transfers atomically
- Revert on Failure: If any transfer fails, revert entire transaction
Nonce Management
The Agent Wallet contract MUST track used nonces per user and SHOULD implement nonce range invalidation to enable batch cancellation:
mapping(address => bytes32) public minValidNonce;
mapping(address => mapping(bytes32 => bool)) public usedNonces;
function invalidateNonceRange(bytes32 newMinNonce) external {
require(
uint256(newMinNonce) > uint256(minValidNonce[msg.sender]),
"Must increase min nonce"
);
minValidNonce[msg.sender] = newMinNonce;
emit NonceRangeInvalidated(msg.sender, oldMinNonce, newMinNonce);
}
function _verifyAndConsumeNonce(address user, bytes32 nonce) internal {
require(uint256(nonce) >= uint256(minValidNonce[user]), "Nonce below minimum");
require(!usedNonces[user][nonce], "Nonce already used");
usedNonces[user][nonce] = true;
}
function isNonceUsed(address user, bytes32 nonce) external view returns (bool used) {
return usedNonces[user][nonce];
}
function isNonceInRange(address user, bytes32 nonce) internal view returns (bool inRange) {
return uint256(nonce) >= uint256(minValidNonce[user]);
}Intent Cancellation
Users MUST be able to cancel specific signed intents before execution:
mapping(address => mapping(bytes32 => bool)) public cancelledIntents;
// called by user to cancel an intent
// commitment = commitment hash of the intent to cancel
function cancelIntent(bytes32 nonce, bytes32 commitment) external {
require(!usedNonces[msg.sender][nonce], "Nonce already used");
require(uint256(nonce) >= uint256(minValidNonce[msg.sender]), "Nonce already invalidated");
usedNonces[msg.sender][nonce] = true;
emit IntentCancelled(msg.sender, commitment, nonce);
}
function isIntentCancelled(address user, bytes32 commitment) external view returns (bool cancelled) {
return cancelledIntents[user][commitment];
}Events
The Agent Wallet contract MUST emit the following events for off-chain indexing and monitoring:
-
IntentExecuted: Emitted when an intent is successfully executed.event IntentExecuted(address indexed user, bytes32 indexed commitment, bytes32 indexednonce, bytes32 multicallDataHash, uint256 numTransfers); -
NonceRangeInvalidated: Emitted when a user invalidates a range of nonces.event NonceRangeInvalidated(address indexeduser, bytes32 oldMinNonce, bytes32 newMinNonce); -
IntentFailed: Emitted when an intent execution fails validation or execution.event IntentFailed(address indexed user, bytes32 indexed commitment, string reason); -
IntentCancelled: Emitted when a user cancels an intent.event IntentCancelled(address indexed user, bytes32 indexed commitment, bytes32 indexed nonce);
Performance Characteristics
Gas Costs
The executeWithProof function has an approximate cost of: -
- Fixed overhead: ****~345,000 gas (proof verification, state checks)
- Per transfer: ****~65,000 gas
- Total:
345,000 + (n x 65,000)where n = number of transfers
Examples:
- Single transfer: ~410k gas
- Three transfers: ~540k gas
- Ten transfers: ~1,060k gas
The fixed verification overhead makes this approach most cost-efficient for batch payments (3+ transfers).
Latency
- Proof generation (off-chain): 2-5 seconds
- Block confirmation: ~12 seconds
- End-to-end time: ~15-20 seconds
Rationale
Why Pre-Execution Verification?
Pre-execution verification provides stronger guarantees than post-execution approaches:
- No Capital Risk: Malicious transactions are blocked before execution
- Cryptographic Guarantees: ZK proofs provide mathematical certainty, not just economic incentives
- User Control: Users explicitly approve exact transaction parameters
Why Zero-Knowledge Proofs?
ZK-SNARKs enable:
- Privacy: Full intent bundle remains private
- Efficiency: Verification is constant-time regardless of bundle complexity
- Composability: Proofs can be generated off-chain without network interaction
Why Use transferFrom Instead of Custodial Patterns?
Using transferFrom with prior approval offers significant advantages:
- Non-custodial: Users never send funds to the Agent Wallet
- Constrained Approval: Users can limit approvals to specific amounts
- Revocable: Users can revoke approvals at any time
Why Multicall Atomicity?
Atomic execution ensures:
- Consistency: Either all payments succeed or none do (no partial execution)
- Simplicity: No need for complex rollback or compensation mechanisms
Backwards Compatibility
This EIP introduces new contracts and does not modify existing token standards. It is designed to be extensible and fully backwards compatible with ERC-20 tokens and existing wallet infrastructure.
Users with existing EOA wallets can approve Agent Wallet contracts without changing their primary wallet. The Agent Wallet acts as a specialized smart contract wallet for agent-mediated payments.
Security Considerations
- Proof Malleability: Attackers might manipulate proofs to bypass verification. Users SHOULD use non-malleable proof systems (ex. PLONK)
- Replay Attacks: Nonce and chainId prevent same-chain and cross-chain replays
- Griefing: Agents could DOS users by consuming nonces, mitigated by intent expiry. Consider implementing nonce ranges or cancellation mechanisms
- Approval Abuse: Users should monitor transactions and revoke approval upon failure, otherwise a compromised contract could drain user funds
- Users SHOULD approve only the exact amount needed for their intent bundle
- Users SHOULD NOT use unlimited approvals
- Users MAY revoke approvals at any time by calling
approve(agentWallet, 0)
Copyright
Copyright and related rights waived via CC0.
