Skip to content

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.

json
{
  "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": "" }
  }
}
ts
interface EVMQueryChallenge {
  contractAddress: string;
  calldata: string;
  expectedResult?: string;
  comparisonOperator?: string;
  gasLimit: string;
  uri?: string;
  customData?: string;
}
FieldTypeRequiredDescription
contractAddressstringyes0x hex address or bb1 address of the contract
calldatastringyesFunction selector plus ABI-encoded arguments, hex without 0x. Placeholders allowed.
expectedResultstringnoHex without 0x. Empty means any non-reverting result passes.
comparisonOperatorstringnoeq (default), ne, gt, gte, lt, lte
gasLimitUintnoGas for the call. 0 means the default 250000. Maximum 500000.
uri, customDatastringnoMetadata. 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:

text
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

  1. Replace placeholders in calldata with values from the transfer.
  2. Execute an EVM query to contractAddress with the calldata under gasLimit, using the keeper's non-committing call path.
  3. Compare the returned bytes with expectedResult using comparisonOperator.
  4. 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.

PlaceholderReplaced with
$initiatorInitiator's 20-byte address as hex (no 0x)
$senderSender's address as hex
$recipientRecipient's address as hex
$collectionIdCollection 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

OperatorMeaning
eqReturn value equals expectedResult
neNot equal
gtGreater than (numeric)
gteGreater than or equal
ltLess than
lteLess than or equal

Only eq and ne are reliable for non-numeric return types.

Gas

LimitValue
Default per query (gasLimit = 0)250000
Maximum per query500000
Maximum total across all challenges on one approval2500000 (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

  1. Selector: first 4 bytes of keccak256(signature). balanceOf(address) is 70a08231.
  2. ABI-encode each argument to 32 bytes. A 20-byte address is left-padded with 12 zero bytes (24 hex characters).
  3. Concatenate selector and arguments.
  4. 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):

json
{
  "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):

json
{
  "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 CallEVMWithData with commit: false; this is a non-committing query, not a guarantee that the EVM STATICCALL opcode 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.

Edit this page on GitHub