EVM Query Challenges
evmQueryChallenges: gate an approval on a read-only EVM query whose result must compare as expected, with placeholders for the transfer parties.
An EVM query challenge queries a contract before the transfer and compares the return value against an expected one. It lets an approval depend on any EVM state: an ERC-20 balance, an NFT owner, a screening contract, a custom compliance oracle.
Shape
A complete approvalCriteria with the evmQueryChallenges array open. Folded lines are defaults.
{
"approvalAmounts": {
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"maxNumTransfers": {
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"overridesFromOutgoingApprovals": true,
"evmQueryChallenges": [
{
"contractAddress": "0x5fbdb2315678afecb367f032d93f642f64180aa3",
"calldata": "70a08231000000000000000000000000$initiator",
"expectedResult": "0000000000000000000000000000000000000000000000000000000000000001",
"comparisonOperator": "gte",
"gasLimit": "250000"
}
],
"userApprovalSettings": {
"userRoyalties": { "percentage": "0", "payoutAddress": "" }
}
}{
"merkleChallenges": [],
"predeterminedBalances": {
"manualBalances": [],
"incrementedBalances": {
"startBalances": [],
"incrementTokenIdsBy": "0",
"incrementOwnershipTimesBy": "0",
"durationFromTimestamp": "0",
"allowOverrideTimestamp": false,
"recurringOwnershipTimes": {
"startTime": "0",
"intervalLength": "0",
"chargePeriodLength": "0"
},
"allowOverrideWithAnyValidToken": false,
"allowAmountScaling": false,
"maxScalingMultiplier": "0"
},
"orderCalculationMethod": {
"useOverallNumTransfers": false,
"usePerToAddressNumTransfers": false,
"usePerFromAddressNumTransfers": false,
"usePerInitiatedByAddressNumTransfers": false,
"useMerkleChallengeLeafIndex": false,
"challengeTrackerId": ""
}
},
"approvalAmounts": {
"overallApprovalAmount": "0",
"perToAddressApprovalAmount": "0",
"perFromAddressApprovalAmount": "0",
"perInitiatedByAddressApprovalAmount": "0",
"amountTrackerId": "",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"maxNumTransfers": {
"overallMaxNumTransfers": "0",
"perToAddressMaxNumTransfers": "0",
"perFromAddressMaxNumTransfers": "0",
"perInitiatedByAddressMaxNumTransfers": "0",
"amountTrackerId": "",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"coinTransfers": [],
"requireToEqualsInitiatedBy": false,
"requireFromEqualsInitiatedBy": false,
"requireToDoesNotEqualInitiatedBy": false,
"requireFromDoesNotEqualInitiatedBy": false,
"overridesFromOutgoingApprovals": true,
"overridesToIncomingApprovals": false,
"autoDeletionOptions": {
"afterOneUse": false,
"afterOverallMaxNumTransfers": false,
"allowCounterpartyPurge": false,
"allowPurgeIfExpired": false
},
"mustOwnTokens": [],
"dynamicStoreChallenges": [],
"ethSignatureChallenges": [],
"senderChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"recipientChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"initiatorChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"altTimeChecks": {
"offlineHours": [],
"offlineDays": [],
"offlineMonths": [],
"offlineDaysOfMonth": [],
"offlineWeeksOfYear": [],
"timezoneOffsetMinutes": "0",
"timezoneOffsetNegative": false
},
"mustPrioritize": false,
"votingChallenges": [],
"allowBackedMinting": false,
"allowSpecialWrapping": false,
"evmQueryChallenges": [
{
"contractAddress": "0x5fbdb2315678afecb367f032d93f642f64180aa3",
"calldata": "70a08231000000000000000000000000$initiator",
"expectedResult": "0000000000000000000000000000000000000000000000000000000000000001",
"comparisonOperator": "gte",
"gasLimit": "250000",
"uri": "",
"customData": ""
}
],
"userApprovalSettings": {
"allowedDenoms": [],
"disableUserCoinTransfers": false,
"userRoyalties": { "percentage": "0", "payoutAddress": "" }
}
}interface EVMQueryChallenge {
contractAddress: string;
calldata: string;
expectedResult?: string;
comparisonOperator?: string;
gasLimit: string;
uri?: string;
customData?: string;
}| Field | Type | Required | Description |
|---|---|---|---|
contractAddress | string | yes | 0x hex address or bb1 address of the contract |
calldata | string | yes | Function selector plus ABI-encoded arguments, hex without 0x. Placeholders allowed. |
expectedResult | string | no | Hex without 0x. Empty means any non-reverting result passes. |
comparisonOperator | string | no | eq (default), ne, gt, gte, lt, lte |
gasLimit | Uint | no | Gas for the call. 0 means the default 250000. Maximum 500000. |
uri, customData | string | no | Metadata. Use uri to document what the check verifies. |
The same structure is used for post-transfer Invariants on the collection. This page covers the approval criterion.
Ask your agent:
Add a transfer approval to collection 1 that only lets addresses holding at least 100 units of the ERC-20 at 0x5fbdb2315678afecb367f032d93f642f64180aa3 send tokens.The MCP builder tools (add_approval) produce the objects on this page.
How It Works
- Replace placeholders in
calldatawith values from the transfer. - Execute an EVM query to
contractAddresswith the calldata undergasLimit, using the keeper's non-committing call path. - Compare the returned bytes with
expectedResultusingcomparisonOperator. - Pass or fail. All challenges on the approval must pass.
Placeholders
Approval checks run once per (sender, recipient) pair, so one recipient is in scope.
| Placeholder | Replaced with |
|---|---|
$initiator | Initiator's 20-byte address as hex (no 0x) |
$sender | Sender's address as hex |
$recipient | Recipient's address as hex |
$collectionId | Collection ID as a 32-byte padded uint256 |
$recipients is not available here; it exists only in invariants. Placeholders come from the transfer context and cannot be set by the user.
Placeholders are also accepted in expectedResult, which is how you check that a call returns a party's address.
Comparison Operators
| Operator | Meaning |
|---|---|
eq | Return value equals expectedResult |
ne | Not equal |
gt | Greater than (numeric) |
gte | Greater than or equal |
lt | Less than |
lte | Less than or equal |
Only eq and ne are reliable for non-numeric return types.
Gas
| Limit | Value |
|---|---|
Default per query (gasLimit = 0) | 250000 |
| Maximum per query | 500000 |
| Maximum total across all challenges on one approval | 2500000 (10 default-gas challenges) |
A query that runs out of gas fails the challenge. Contracts that call precompiles need more headroom than plain storage reads. Rough guide: about 30000 for a storage read, 50000 for an ERC-20 balance, more for logic that touches a precompile.
Building Calldata
- Selector: first 4 bytes of
keccak256(signature).balanceOf(address)is70a08231. - ABI-encode each argument to 32 bytes. A 20-byte address is left-padded with 12 zero bytes (24 hex characters).
- Concatenate selector and arguments.
- Substitute placeholders for the addresses you want filled at runtime:
70a08231000000000000000000000000$initiator.
Examples
Sender must hold at least 100 units of the ERC-20 at 0x5fbdb2315678afecb367f032d93f642f64180aa3 (0x64 = 100):
{
"evmQueryChallenges": [
{
"contractAddress": "0x5fbdb2315678afecb367f032d93f642f64180aa3",
"calldata": "70a08231000000000000000000000000$sender",
"expectedResult": "0000000000000000000000000000000000000000000000000000000000000064",
"comparisonOperator": "gte",
"gasLimit": "250000",
"uri": "",
"customData": ""
}
]
}Initiator must own NFT #1 of the ERC-721 at the same address (ownerOf(uint256) is 6352211e):
{
"evmQueryChallenges": [
{
"contractAddress": "0x5fbdb2315678afecb367f032d93f642f64180aa3",
"calldata": "6352211e0000000000000000000000000000000000000000000000000000000000000001",
"expectedResult": "$initiator",
"comparisonOperator": "eq",
"gasLimit": "250000",
"uri": "",
"customData": ""
}
]
}Failure Conditions
- Contract address is invalid or has no code
- Calldata is malformed or empty
- The call reverts or runs out of gas
- The result does not satisfy the comparison
- Invalid operator, or a numeric operator on non-numeric data
Security
- The keeper calls
CallEVMWithDatawithcommit: false; this is a non-committing query, not a guarantee that the EVMSTATICCALLopcode is used. Query view functions and do not depend on writes or events from challenge execution. - Gas limits bound the work a transfer can demand. Set them from measurement, not guesses.
- Query only contracts you trust. A malicious or upgraded proxy can return anything.
- Combine with other criteria for defense in depth.
- Test the calldata against the live contract before shipping the approval.