# EVM Query Challenges

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 fold=2-91,103-107
{
  "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;
}
```

| 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](https://docs.bitbadges.io/token-standard/approval-criteria/invariants) on the collection. This page covers the approval criterion.

{% hint style="info" %}
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.
{% endhint %}

## 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.

| 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

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.

## Related

- [Invariants](https://docs.bitbadges.io/token-standard/approval-criteria/invariants)
- [Tokenization Precompile](https://docs.bitbadges.io/chain/evm/tokenization-precompile)
- [EVM](https://docs.bitbadges.io/chain/evm)
