EVM contracts

Experimental: profiles for Solidity ABIs and EIP-712 typed messages, consent text for a call or a signature, and ERC-7730 clear-signing files.

Experimental: spec §15, phase 1 and the first part of phase 2

A profile can describe an EVM contract's functions. From it come consent text for a call's calldata and an ERC-7730 clear-signing file with its ERC-8176 descriptorHash. A profile can also describe the EIP-712 messages a wallet is asked to sign, with consent text for a signing request and an ERC-7730 file of the messages. The consent text comes with what the call or signature grants, for a wallet that checks requests against terms the person confirmed (D-056). Agent tools, outcome classes over reverts, idempotency by nonce and ERC-7730 import are later phases, each its own decision; signing, checking signatures and Permit2's witness messages are not in it. The preregistered eval uses none of it. Decisions D-054 and D-055; the text is spec §15 and its §15.9.

The same resolver reads an ABI where it would read a .did, so addresses, tags, layers and the resolved form work as they do for a canister. This resolves a token's ABI on a local chain with the standard packs, encodes a transfer, and turns the calldata into the text a person confirms:

js
import { createSchemaValidator, loadPacks, resolveService } from "@meantjs/profile";
import { parseAbi, encodeCall } from "@meantjs/profile/evm";
import { evmConsent } from "@meantjs/consent/evm";

const abi = [
  "function symbol() view returns (string)",
  "function decimals() view returns (uint8)",
  "function balanceOf(address owner) view returns (uint256)",
  "function allowance(address owner, address spender) view returns (uint256)",
  "function transfer(address to, uint256 value) returns (bool)",
  "function approve(address spender, uint256 value) returns (bool)",
  "function transferFrom(address from, address to, uint256 value) returns (bool)",
];
const validateSchema = await createSchemaValidator();
const packs = (await loadPacks()).map(({ doc }) => ({ doc }));
const { resolved } = await resolveService({ abi, validateSchema, packs }); // the erc20 pack matches
if (resolved === null) throw new Error("the ABI cannot be read");

const transfer = parseAbi(abi).functions.find((f) => f.name === "transfer");
if (transfer === undefined) throw new Error("no transfer function");
const calldata = encodeCall(transfer, { to: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", value: "1500000" });

const consent = evmConsent({
  resolved,
  calldata,
  to: "0x5fbdb2315678afecb367f032d93f642f64180aa3", // the token, on a local Anvil chain
  chainId: 31337,
  refs: { "query:decimals": 6, "query:symbol": "TKA" }, // read by the caller with eth_call
});
console.log(consent.message);
text
# Send tokens

Send `1.5 TKA` to `0x70997970C51812dc3A010C7d01b50e0d17dc79C8`

- **Amount:** `1.5 TKA`
- **Recipient:** `0x70997970C51812dc3A010C7d01b50e0d17dc79C8`

**Contract:** `0x5FbDB2315678afecb367f032d93F642f64180aa3` on chain 31337

The ABI as an interface#

fact A function is matched by its selector, the first 4 bytes of Keccak-256 (Ethereum's, not NIST SHA3-256) of its type-only signature: transfer(address,uint256) is 0xa9059cbb. Parameter names are not part of it; ERC-7730 files carry them in their keys (transfer(address to,uint256 value)), and so does a profile, in binds.abi (ERC-7730).

  • binds.abi is an array of human-readable fragments (function transfer(address to, uint256 value) returns (bool)) or JSON ABI entries. Only functions are read: events, errors, the constructor, fallback and receive are skipped.
  • Aliases read as their canonical types (uint is uint256, constant is view). A function's canonical fragment is function NAME(type name,…) [view|pure|payable] returns (…).
  • Overloaded names are refused (EE_ABI; open O1): an address names a function by its name alone.
ABIAs a profile sees it
a functiona method: view and pure are queries, nonpayable and payable updates
its parametersone record at argument index 0: transfer(0).to; a function without parameters has no argument
its outputsthe result tuple: balanceOf->(0)
a tuplea record, its components in ABI order. A named component's id is the Candid hash of its name; an unnamed one's is its position, addressed #i
T[], T[k]a vector, []; T[k] keeps its length
anything elsea primitive named by its canonical type: uint256, int64, address, bool, string, bytes, bytes32

Template placeholders read {to} and {value}, as ERC-7730's do. See Addresses.

Identity#

An ABI's identity is candid-profile:evm-abi:v0:sha256:<hex>: SHA-256 over the RFC 8785 (JCS) form of the sorted canonical fragments. It is the resolved form's interface_id, and what a contract profile binds to. Because the fragments are sorted, the order of binds.abi does not move it. The ERC-20 pack's seven functions:

interface_idcandid-profile:evm-abi:v0:sha256:81471b5efd3e34c464b5dda12ccdd454d8ca6e0d0c069871c8152bc6b154be4b

When the interface has EIP-712 types, the hash covers them with the functions: one more line per struct, struct <Name>(<type> <name>,…), sorted with the fragments. With no types it is the hash above, so a resolved form without messages keeps its id. A token whose ABI has ERC-20's functions and EIP-2612's permit, nonces and DOMAIN_SEPARATOR resolves to …6ad2a3fd… with the ERC-20 pack alone and to …c0cd422e… once the EIP-2612 pack adds its Permit type (both measured by resolving that ABI with @meantjs/profile 0.4.0). Whether a contract profile is rebound or stale is decided by its functions alone, so the messages a pack adds never make it stale.

Values#

  • Integers are uintN and intN for N = 8, 16, …, 256, with the bounds of their N bits. amount input and display are the same as on a canister, with no rounding: EE_EXCESS_PRECISION, never a rounded value.
  • Addresses have one text form, 0x and 40 hex digits, shown with their EIP-55 checksum. On input, an address in mixed case must match its checksum; an address in one case carries no checksum and is accepted (open O3).
  • Calldata is decoded strictly: bytes that are not the canonical ABI encoding of the values read (dirty padding, an offset that does not point where the encoder would write, bytes left over) are refused. The consent text re-encodes the values it read and compares the bytes.

How a recipient is written differs by chain:

An ICRC-1 account: the account tag reads the textual encoding (principal-checksum.subaccounthex, or a bare principal for the default subaccount) and verifies its checksum. See Internet Computer.

An address needs no tag. It is read as 0x and 40 hex digits and shown in its EIP-55 form, 0x7099…79C8. The account and subaccount tags do not apply (EE_TAG_NOT_APPLICABLE).

Contract profiles and EVM packs#

A contract profile has kind: "contract", an origin of author, third_party or inferred, its ABI in binds.abi and, optionally, the contracts it describes in binds.deployments, as ERC-7730's context.contract.deployments does. It may also have binds.eip712, the typed messages it describes, beside binds.abi or instead of it. A mixed-case address that fails its EIP-55 checksum is EE_ADDRESS_CHECKSUM; a deployment listed twice is WW_DUPLICATE. An example, written for this page, for a token on a local chain:

json
{
  "format": "candid-profile",
  "format_version": 0,
  "kind": "contract",
  "name": "tka-token",
  "origin": "author",
  "binds": {
    "abi": [
      "function decimals() view returns (uint8)",
      "function transfer(address to, uint256 value) returns (bool)"
    ],
    "deployments": [{ "chain_id": 31337, "address": "0x5FbDB2315678afecb367f032d93F642f64180aa3" }]
  },
  "annotations": [
    { "at": "transfer", "tag": "intent", "args": { "title": "Send TKA", "template": "Send {value} to {to}" } },
    { "at": "transfer", "tag": "effect", "args": { "kind": "transfer", "irreversible": true } },
    { "at": "transfer(0).value", "tag": "amount", "args": { "decimals": 6, "symbol": "TKA" } },
    { "at": "transfer(0).to", "tag": "describe", "args": { "label": "Recipient" } }
  ]
}
bash
node profile/scripts/validate.mjs tka.profile.json
text
PASS tka.profile.json  (0 error, 0 warning, 0 info)
     profile_id   candid-profile:v0:sha256:9c0218adc5b268dedaa8cab3e6352f01d6b3ec67db2a2e784eaddc49f809e774
     interface_id candid-profile:evm-abi:v0:sha256:b48b7f710812dc95d11fda7e3e24f467f6f0fecdfb201006067b290a92483017  [2 methods, 4 annotations]
  • A contract profile is checked against its own binds.abi. Resolved against another ABI (candid-profile resolve --abi), it is rebound or stale by the same rules as a canister profile.
  • An EVM pack has kind: "pack" and binds.abi instead of binds.requires. It applies to a contract that has every function of the pack with the same selector (so the same name and parameter types), the same output types and the same kind of call: a query, or an update that is payable exactly when the pack's is. There is no subtyping.
  • A pack's addresses and placeholders use the pack's parameter names; when it applies, they are rewritten to the contract's, component by component, by position. EIP-20 names them _to and _value, WETH dst and wad: on an ABI with transfer(address dst, uint256 wad), the ERC-20 pack's template reads Send {wad} to {dst}.
  • A profile or pack for the other kind of interface is EE_CHAIN when given explicitly, and skipped in a pack search.
  • The standard pack for calls is ERC-20: transfer, approve, transferFrom, balanceOf, allowance, decimals and symbol. fact decimals() and symbol() are optional in EIP-20, so a token without them matches no pack and needs a contract profile with literal values, like the one above. The EIP-712 standard files, an EIP-2612 pack and the Permit2 and DAI permit contract profiles, are under Standard files.
A pack matches any contract with the right functions

A hostile contract can match ERC-20, call its drain "Send tokens" and answer "USDT" to symbol(). A consumer must show the contract's address, with its EIP-55 checksum, whenever it asks a person to confirm a call matched by a pack; the consent text ends with it. fact ERC-7730's Security Considerations name the same risk, "parameter injection using legitimate, high-profile contract formats". query: refs on an EVM contract are eth_calls answered by one RPC node. See Security model.

Tags on EVM#

TagOn an EVM contract
amountuint8…uint256, int8…int256. query: refs are eth_calls of a view or pure function of the same contract with no argument and one result. token names the token by an address field instead (below).
timestamp, durationuint32…uint256, int64…int256. timestamp's words (threshold with message, labels) are for EVM deadlines first: uint256 max reads "Never" (Values).
bytesbytes, bytes1…bytes32
textstring
rangean integer type, string, an array
describe, default, ui, intent, effectas on a canister
account, subaccountnone (EE_TAG_NOT_APPLICABLE): an address needs no tag
idempotency, outcome, list, names, namednot yet (EE_TAG_TARGET): they need nonces, receipts and reads that a later phase adds

An ABI value is never absent, so an EVM template has no [ ] segments (EE_TEMPLATE); the escapes {{ }} [[ ]] are unchanged. effect defaults as on a canister: a view or pure function is read, any other write and irreversible. The native value a payable function receives has no position yet (open O2). Every tag: Tags.

The resolved form: chain evm#

An EVM contract's resolved form has "chain": "evm" at the top. Its nodes carry abi where a Candid form's carry candid: the canonical type of a primitive, "tuple" with fields, or "array" with element and, for T[k], length. Each method also has selector, signature and state_mutability, and each entry of profiles that binds deployments lists them. A consumer must refuse a resolved form whose chain it does not know rather than read it as Candid; @meantjs/consent (main entry) and @meantjs/tools do.

jsonprofile/fixtures/erc20.resolved.json
"transfer": {
  "mode": "update",
  "selector": "0xa9059cbb",
  "signature": "transfer(address to,uint256 value)",
  "state_mutability": "nonpayable",

EIP-712 messages are under a top-level messages map, never methods. Each has encode_type, type_hash, tags and args, and each entry of profiles that binds a domain lists its name and version as eip712_domain. Without messages the map is absent, so a form without them is unchanged. DAI's permit, which has no functions:

jsonprofile/fixtures/dai-permit.resolved.json
"eip712_domain": {
  "name": "Dai Stablecoin",
  "version": "1"
}
…
"methods": {},
"messages": {
  "Permit": {
    "encode_type": "Permit(address holder,address spender,uint256 nonce,uint256 expiry,bool allowed)",
    "type_hash": "0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb",

Field by field: Profile and resolved form.

ICRC-21 is the Internet Computer's, so an EVM call gets the same model and the same two layouts without the ICRC-21 envelope: evmConsent in @meantjs/consent/evm. The selector picks the function, the calldata is decoded strictly, and the text ends with the contract's address and chain. The generic form is Markdown, as in the first example; the fields form is an intent line and [label, text] rows:

CaseResult
A profile lists deployments, and the call's contract and chain are not among themEE_DEPLOYMENT
A payable function (its value has no position yet, O2)EE_UNSUPPORTED_TYPE
Calldata that is not the canonical encoding of its valuesEE_DECODE
A selector the resolved form does not haveEE_METHOD_UNKNOWN
A Candid resolved form given to @meantjs/consent/evm, or an EVM one to the main entryEE_RESOLVED_FORM

With the text, evmConsent returns values, the decoded arguments by parameter name, and grant, what the call grants (below). For the transfer in the first example, values is { to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", value: "1500000" } and grant is { kind: "transfer", to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", token: "0x5FbDB2315678afecb367f032d93F642f64180aa3", amount: { units: "1500000", unlimited: false }, ends: { now: true } }: the token is the contract called.

evmConsent also takes tokens, the decimals and symbol of each token an amount's token names (below). decodeCalldata(resolved, calldata) alone returns the function and its arguments keyed by parameter name. The API: @meantjs/consent/evm.

ERC-7730 files and ERC-8176 attestations#

fact An ERC-8176 attestation is an EAS attestation, under the schema bytes32 descriptorHash (UID 0xe023eef1…b5c2), that a party reviewed the ERC-7730 file whose RFC 8785 serialization has that Keccak-256. It describes no call; it vouches for a file (ERC-8176, Draft).

erc7730Descriptor writes that file from an EVM contract's resolved form. The same resolved form always gives the same bytes, so the same descriptorHash. $schema names the exact version erc7730-v2.1.0.schema.json, so a new release of the v2 alias cannot move the hash. There is one entry per function a transaction calls (an update), keyed by its signature. The messages of the same resolved form are a file of their own, with context: "eip712" (below).

js
import { erc7730Descriptor, erc8176Attestation } from "@meantjs/consent/erc7730";

const { descriptor, descriptorHash } = erc7730Descriptor(resolved, {
  deployments: [{ chainId: 31337, address: "0x5FbDB2315678afecb367f032d93F642f64180aa3" }],
});
const { typedData, digest } = erc8176Attestation({
  descriptorHash,
  time: 1777630591,
  expirationTime: 1785406591,
  salt: "0x" + "11".repeat(32), // 32 random bytes, chosen by the caller
});
// A reviewer signs `digest` with their own key. Nothing here signs or sends it.
console.log(descriptor.display.formats["transfer(address to,uint256 value)"], typedData.message.data);
json
{
  "intent": "Send tokens",
  "interpolatedIntent": "Send {value} to {to}",
  "fields": [
    {
      "path": "to",
      "label": "Recipient",
      "format": "addressName"
    },
    {
      "path": "value",
      "label": "Amount",
      "format": "tokenAmount",
      "params": {
        "tokenPath": "@.to"
      }
    }
  ]
}
ProfileERC-7730
intent.title (else describe.label of the method, else Call <name>)intent
intent.templateinterpolatedIntent: {(0).to} reads {to}, [[ and ]] are plain brackets, {{ and }} stay. A template ERC-7730 cannot interpolate gets none, with a note
each leaf of the parameters, in ABI orderone entry of fields; path in the parameter names, name.[] through an array; label is describe.label, else the name
amount with query:decimals and query:symbol (the ERC-20 functions)tokenAmount, tokenPath: "@.to"
amount with literal decimals and symbolthe same, with metadata.token (only when the caller says the contract is that token)
amount with tokentokenAmount, tokenPath the token's path; inside the field group of an array when the token is in the same element
amount's threshold, messagethreshold (0x hex), message
timestamp in sdate, encoding: "timestamp"; its words are left out, with a note
duration in sduration
bytes in hex, untagged bytesraw
an untagged addressaddressName
anything elseraw
a payable functionone more field, @.value as amount (the native value, which an ERC-7730 wallet knows)
binds.deploymentscontext.contract.deployments (the caller may pick a subset; a file needs at least one, else EE_DEPLOYMENT)

What a profile says that an ERC-7730 file cannot say is refused (EE_EXPORT), never written as something near it: a time in another unit, utf8 or base64 bytes, an amount whose decimals and symbol are not the contract's own (or whose token is outside the array group it is in), an unnamed parameter. effect and the agent tags have no ERC-7730 form: the file carries what a call says, not what it does.

Some words have no ERC-7730 form but change no value. They are left out and listed in the result's notes instead of refusing the file (D-055): a timestamp's threshold, message and labels (the file shows the date), and a template ERC-7730 cannot interpolate, such as a placeholder over an array or a whole tuple, which gets no interpolatedIntent. fact interpolatedIntent is optional in ERC-7730, and a wallet falls back to intent when interpolation fails. Phase 1 refused the whole file for such a template; every file phase 1 wrote is written the same now. The ERC-20 file above has no notes.

erc8176Attestation gives the unsigned offchain attestation (EAS offchain v2 typed data: domain EAS Attestation, version 0.26, chain 1, the canonical EAS contract; data is the hash) and its EIP-712 digest. A reviewer signs it with their own key. This repository never signs or posts one: it holds no reviewer's key, and an onchain attestation spends mainnet ETH. Checking attestations is a later phase (open OQ-7, OQ-9).

Typed messages (EIP-712)#

fact A signing request (eth_signTypedData_v4) has types, primaryType, domain and message. The signature signs keccak256(0x1901 ‖ domainSeparator ‖ hashStruct(message)). A struct's typeHash is the Keccak-256 of its encodeType: the struct with its members, then every struct it refers to, sorted by name. The domain's fields are name, version, chainId, verifyingContract and salt, in that order, each optional, and a user agent should refuse to sign when chainId is not the active chain (EIP-712, Final).

A profile can describe those messages, so a wallet shows what a signature allows before the person signs it. This is the first part of phase 2 (D-055): consent text for a signing request and an ERC-7730 file of the messages. Signing, checking signatures, witness messages (O9) and agent tools are not in it. An example, written for this page: a payout contract on a local chain whose owner signs a list of payments, each in its own token.

jsonpayouts.profile.json
{
  "format": "candid-profile",
  "format_version": 0,
  "kind": "contract",
  "name": "payouts",
  "origin": "author",
  "binds": {
    "eip712": {
      "domain": { "name": "Payouts", "version": "1" },
      "types": {
        "Payout": [
          { "name": "legs", "type": "Leg[]" },
          { "name": "deadline", "type": "uint256" }
        ],
        "Leg": [
          { "name": "token", "type": "address" },
          { "name": "amount", "type": "uint256" },
          { "name": "to", "type": "address" }
        ]
      }
    },
    "deployments": [{ "chain_id": 31337, "address": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" }]
  },
  "annotations": [
    { "at": "Payout", "tag": "intent", "args": { "title": "Sign a payout", "template": "Pay {legs[].amount} to {legs[].to}. Signature expires: {deadline}." } },
    { "at": "Payout", "tag": "effect", "args": { "kind": "transfer", "irreversible": true } },
    { "at": "Payout(0).legs[].amount", "tag": "amount", "args": { "token": "legs[].token" } },
    { "at": "Payout(0).legs[].amount", "tag": "describe", "args": { "label": "Amount" } },
    { "at": "Payout(0).legs[].to", "tag": "describe", "args": { "label": "Recipient" } },
    { "at": "Payout(0).deadline", "tag": "timestamp", "args": { "unit": "s", "epoch": "unix", "threshold": "115792089237316195423570985008687907853269984665640564039457584007913129639935", "message": "Never" } },
    { "at": "Payout(0).deadline", "tag": "describe", "args": { "label": "Signature expires" } }
  ]
}
bash
node profile/scripts/validate.mjs payouts.profile.json
text
PASS payouts.profile.json  (0 error, 0 warning, 0 info)
     profile_id   candid-profile:v0:sha256:38b066cac457466332e45f2b27a61005feffacd3808d1728f2a145d08c461140
     interface_id candid-profile:evm-abi:v0:sha256:2af8cc17ecfaf8f4be342c6aa0ab0f085ff24f20422e33a6aaea462f0d7f06ee  [1 methods, 7 annotations]

The validator's summary counts the one message as a method.

Types#

  • binds.eip712 is { domain, types }, in a contract profile or an EVM pack. types has the request's own JSON shape, without EIP712Domain.
  • domain is optional and binds only the name and the version; chainId and verifyingContract come from binds.deployments. A pack has no domain, since each token's is its own, and keeps its binds.abi, by which it matches.
  • A struct name is an identifier that does not read as an ABI type (uint256, bytes32, tuple, …) and is not EIP712Domain. Members have unique names.
  • A member type is a canonical ABI type as written, uint256 and never uint, because the type hash hashes the text; or a struct of the same map. Either may have array suffixes, T[] or T[k] with k ≥ 1. No struct contains itself.
  • Anything else is EE_EIP712 at its place, and a struct that refers to a refused one is refused too.

Messages#

  • A message is a type the same document tags with intent at its bare name (Payout). Its struct is argument 0, as a function's parameters are: Payout(0).legs[].amount, PermitSingle(0).details.amount.
  • A type without an intent is a type only. An address that names it is EE_ADDR_UNRESOLVED; Leg above is described inside Payout.
  • A message may not share its name with a function, and two profiles may not give one type name different members (the later profile is invalid). Types with no message and no function describe nothing. Each is EE_EIP712.

Matched by type hash, never by name#

The resolver computes each message's encode_type and type_hash, and a request is matched to a message by its type hash. DAI's Permit and EIP-2612's share a name and not a hash, so neither profile's words can describe the other's signature:

js
import { eip712TypeHash, encodeType } from "@meantjs/profile/evm";

const eip2612 = {
  Permit: [
    { name: "owner", type: "address" },
    { name: "spender", type: "address" },
    { name: "value", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint256" },
  ],
};
const dai = {
  Permit: [
    { name: "holder", type: "address" },
    { name: "spender", type: "address" },
    { name: "nonce", type: "uint256" },
    { name: "expiry", type: "uint256" },
    { name: "allowed", type: "bool" },
  ],
};
encodeType(eip2612, "Permit"); // "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
eip712TypeHash(eip2612, "Permit"); // "0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9"
eip712TypeHash(dai, "Permit"); // "0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb"

fact The second hash is the PERMIT_TYPEHASH constant in MakerDAO's dss/src/dai.sol; the resolver's test checks it. parseEip712Types(types) reads a types map and lists its problems instead of throwing.

Tags on a message#

  • intent and effect work as on a function. A signature runs nothing when it is signed, so effect says what it allows, of any kind: approve for an allowance, transfer for a one-time transfer. The default is write, irreversible.
  • replicated does not apply to a signature (EE_EFFECT_MODE).
  • An effect's grant names a message's positions from the struct, as its template does (What a call or signature grants).
  • The value tags apply to a message's members as to a function's parameters (Tags on EVM), and the tags that are not defined for EVM yet are not defined here either.
  • The interface id covers the message types with the functions (Identity).

An amount in the token a field names#

A Permit2 message names its token in a field, so the amount's decimals and symbol cannot be the contract's own. amount.token is a relative path from the argument to an address field of the same argument, whose path up to its last [] is the amount's own: the token is in the same array element as the amount, or outside every array. The payout above has "token": "legs[].token" for legs[].amount.

  • decimals and symbol are not given (the schema refuses them): they are that token's decimals() and symbol().
  • The consumer reads them and passes them per token address, as tokens; the libraries do no I/O. Without an entry there is no text (EE_REF_UNRESOLVED), never a number read in the wrong decimals.
  • A token's symbol() is its own claim, so its address is always shown beside it: 1.5 TKA (0x5FbDB2315678afecb367f032d93F642f64180aa3).
  • A path that breaks these rules is EE_TOKEN_PATH, such as "token": "deadline", a uint256. So is token on a Candid service, whose ledger a field cannot name yet (OQ-6).
  • token works on functions too, and evmConsent takes the same tokens.
js
import { readFileSync } from "node:fs";
import { createSchemaValidator, validateProfile } from "@meantjs/profile";
import { eip712Consent } from "@meantjs/consent/evm";

const doc = JSON.parse(readFileSync("payouts.profile.json", "utf8"));
const { resolved } = await validateProfile(doc, { validateSchema: await createSchemaValidator(), resolved: true });
if (resolved === undefined) throw new Error("the profile is not valid");

const TKA = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; // a token on the same local Anvil chain
const consent = eip712Consent({
  resolved,
  typedData: {
    types: {
      Payout: [
        { name: "legs", type: "Leg[]" },
        { name: "deadline", type: "uint256" },
      ],
      Leg: [
        { name: "token", type: "address" },
        { name: "amount", type: "uint256" },
        { name: "to", type: "address" },
      ],
    },
    primaryType: "Payout",
    domain: { name: "Payouts", version: "1", chainId: 31337, verifyingContract: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" },
    message: {
      legs: [{ token: TKA, amount: "1500000", to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" }],
      deadline: (2n ** 256n - 1n).toString(),
    },
  },
  chainId: 31337, // the wallet's active chain
  tokens: { [TKA]: { decimals: 6, symbol: "TKA" } }, // that token's decimals() and symbol(), read with eth_call
  form: "fields",
});
console.log(consent.message);
text
{
  intent: 'Sign a payout: Pay 1.5 TKA (0x5FbDB2315678afecb367f032d93F642f64180aa3) to 0x70997970C51812dc3A010C7d01b50e0d17dc79C8. Signature expires: Never.',
  fields: [
    [
      'Amount',
      '1.5 TKA (0x5FbDB2315678afecb367f032d93F642f64180aa3)'
    ],
    [ 'Recipient', '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' ],
    [ 'Signature expires', 'Never' ],
    [
      'Contract',
      '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 on chain 31337'
    ],
    [
      'Signature',
      'This is a signature, not a transaction: whoever holds it can submit it'
    ],
    [
      'EIP-712 digest',
      '0x13ea593c82421ed8e18779b8085a647c0a8cf1c79bf6854fe7d719c040a22cf7'
    ]
  ]
}

With tokens: {} the same call is refused: E_REF_UNRESOLVED: Payout(0).legs[].amount is an amount of the token 0x5FbDB2315678afecb367f032d93F642f64180aa3, whose decimals and symbol were not given (tokens); without them it has no text.

eip712Consent in @meantjs/consent/evm takes the resolved form, the request as the wallet received it and the wallet's active chain. This is a token on a local Anvil chain with EIP-2612's permit: the ERC-20 and EIP-2612 packs both match its ABI, and the request asks Alice (0xf39F…2266) to let Bob (0x7099…79C8) spend 2.5 TKA.

js
import { createSchemaValidator, loadPacks, resolveService } from "@meantjs/profile";
import { eip712Consent } from "@meantjs/consent/evm";

const abi = [
  "function symbol() view returns (string)",
  "function decimals() view returns (uint8)",
  "function balanceOf(address owner) view returns (uint256)",
  "function allowance(address owner, address spender) view returns (uint256)",
  "function transfer(address to, uint256 value) returns (bool)",
  "function approve(address spender, uint256 value) returns (bool)",
  "function transferFrom(address from, address to, uint256 value) returns (bool)",
  "function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)",
  "function nonces(address owner) view returns (uint256)",
  "function DOMAIN_SEPARATOR() view returns (bytes32)",
];
const validateSchema = await createSchemaValidator();
const packs = (await loadPacks()).map(({ doc }) => ({ doc }));
const { resolved } = await resolveService({ abi, validateSchema, packs }); // erc20 and erc2612 apply
if (resolved === null) throw new Error("the ABI cannot be read");

const typedData = {
  types: {
    Permit: [
      { name: "owner", type: "address" },
      { name: "spender", type: "address" },
      { name: "value", type: "uint256" },
      { name: "nonce", type: "uint256" },
      { name: "deadline", type: "uint256" },
    ],
  },
  primaryType: "Permit",
  domain: { name: "Token A", version: "1", chainId: 31337, verifyingContract: "0x5FbDB2315678afecb367f032d93F642f64180aa3" },
  message: {
    owner: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
    spender: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
    value: "2500000",
    nonce: "0",
    deadline: "1798761600",
  },
};
const consent = eip712Consent({
  resolved,
  typedData,
  chainId: 31337, // the wallet's active chain
  refs: { "query:decimals": 6, "query:symbol": "TKA" }, // eth_calls to the verifying contract, the token
});
console.log(consent.message);
text
# Permit token spending

Allow `0x70997970C51812dc3A010C7d01b50e0d17dc79C8` to spend up to `2.5 TKA` from `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266`. Signature expires: `2027-01-01 00:00:00 UTC`.

- **Spender:** `0x70997970C51812dc3A010C7d01b50e0d17dc79C8`
- **Amount:** `2.5 TKA`
- **Owner:** `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266`
- **Signature expires:** `2027-01-01 00:00:00 UTC`

**Contract:** `0x5FbDB2315678afecb367f032d93F642f64180aa3` on chain 31337

**EIP-712 digest:** `0xf5f7c7ab25ddf658e38e97e75f692da15053528f00300b959122fcdf74b593c4`

This is a signature, not a transaction: whoever holds it can submit it.

The text is the same model and the same two forms as a call's, then the verifying contract and its chain, the EIP-712 digest, and the note that a signature is not a transaction. A hardware wallet that shows the hash can be checked against the digest. With the result come primaryType, typeHash (here 0x6e71edae…26c9), digest, verifyingContract in its EIP-55 form, chainId, diagnostics, and provenance, here { origin: "pack", profile: "erc2612" } for the intent: the words come from a pack, not from the token.

The same request for an unlimited amount that never expires, value and deadline both uint256 max, in the fields form:

What is refused before anything is shown#

  1. EE_DOMAIN: a domain field EIP-712 does not define; a salt (O11); a types.EIP712Domain that does not list the domain's fields in EIP-712 order (it may be left out); no chainId, so the signature would be valid on every chain; no verifyingContract; a chainId other than the wallet's; a name or version other than that of every profile that binds a domain, absent where the profile's is absent (Permit2's domain has no version).
  2. EE_TYPE_HASH: no message has the request's type hash. EE_DEPLOYMENT: a profile lists deployments and the verifying contract on that chain is not one of them, so a look-alike contract named "Permit2" gets none of Permit2's words.
  3. EE_DECODE: values that are not exactly the members, or not canonical. Integers are decimal strings without leading zeros, or JSON numbers up to 253 − 1, within their type's range. Addresses follow Values. bytesN has exactly N bytes, as 0x hex. An array has its declared length, within the decoder's limits.
  4. EE_REF_UNRESOLVED: an amount in a token not in tokens, or a query: ref not in refs. For a pack's message the query: refs are calls to the verifying contract: resolve that contract's ABI with the packs, as above.

Run against the request above:

ChangeResult
The wallet is on chain 1E_DOMAIN: the domain is for chain 31337, the wallet is on chain 1
One more member in PermitE_TYPE_HASH: no message of the resolved form has the type hash 0x556c83ad…f8f2 (Permit); a request is matched by its type hash, never by its name
No refsE_REF_UNRESOLVED: amount.decimals needs the value of "query:decimals", which the caller did not supply …
value written "02500000"E_DECODE: message.value: uint256 needs a decimal string or a JSON integer below 2^53, got "02500000"
No chainId argumentE_REQUEST: give the wallet's active chainId: a signature for another chain is refused

Permit2#

The Permit2 profile is bound to its deployments on chains 1 and 42161, so its words appear only for a request whose verifying contract is Permit2 on one of them. This is the package test's PermitBatch request on chain 1, with each token's decimals and symbol in tokens; nothing is signed or sent. The first allowance expires at 0, which Permit2 reads as "only in the block it is used", and the second at uint48 max:

The text shows each token's full address in brackets after its amount: 1.5 USDC (0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48), Unlimited WETH (0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2). The same request with a verifying contract of 0x5FbD…0aa3 is E_DEPLOYMENT: profile permit2 describes 0x000000000022D473030F116dDEE9F6B43aC78BA3 on chain 1, 0x000000000022D473030F116dDEE9F6B43aC78BA3 on chain 42161, not 0x5FbDB2315678afecb367f032d93F642f64180aa3 on chain 1. The API: JavaScript API.

What a call or signature grants#

An effect of kind transfer or approve may carry grant: who may take tokens because of the call or the signature, which token, how many and until when (Intent, effects and confirmation; spec §5.3, D-056). On a function its paths name parameters. A message's grant names its positions as its template does, from the struct. Permit2's PermitSingle:

jsonprofile/packs/permit2.profile.json
{
  "at": "PermitSingle",
  "tag": "effect",
  "args": {
    "kind": "approve",
    "irreversible": false,
    "grant": {
      "spender": "spender",
      "amount": "details.amount",
      "ends": [
        "details.expiration",
        "sigDeadline"
      ]
    }
  }
}

fact Uniswap permit2 at main, read 2026-10-30, src/libraries/Allowance.sol (spec §15.9): an expiration of 0 is stored as the time of the block the permit is used in, and that block may come as late as sigDeadline. The latest of the two is the end, so 0 needs no rule of its own. The token is details.token, the field the amount's tag names, not the verifying contract.

eip712Consent returns the grant with the text, and values, the message by member name. A PermitSingle request built for this page lets Dex (0x9fE4…a6e0) spend 500 TKA (0x5FbD…0aa3), with a sigDeadline of 2026-11-02 12:30:00 UTC. It is on chain 1, since the profile lists Permit2 on chains 1 and 42161 only; nothing is signed or sent. With an expiration of 0 it grants:

json
{
  "kind": "approve",
  "spender": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
  "token": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
  "amount": { "units": "500000000", "unlimited": false },
  "ends": { "at": "1793622600" }
}
The same request withgrant
expiration 2026-11-16 12:00:00 UTC (1794830400)ends: { at: "1794830400" }
expiration uint48 max, the tag's thresholdends: { never: true }
amount uint160 max, the tag's thresholdamount: { units: "1461501637330902918203684832716283019655932542975", unlimited: true }
  • EIP-2612 and DAI. Their deadline and expiry bound only when the signature may be used, so they are not ends: the allowance a permit sets never ends, ends: { never: true }. DAI's allowed is a bool: true grants { units: null, unlimited: true }, false grants { units: "0", unlimited: false }, nothing.
  • ERC-20. approve names its spender and never ends; transfer names to and ends when it is made, { now: true }. transferFrom spends an allowance someone else gave and has no grant.
  • Why PermitBatch has none. PermitBatch and PermitBatchTransferFrom grant one amount per element of an array, each in its own token. A grant covers one token, and none of its positions may step into [], so these two messages carry no grant in v0. They keep their effects, approve and transfer; eip712Consent returns grant: null for them, with no diagnostic, and no terms can cover them.

A wallet compares a grant with terms the person confirmed for one task, and names the request by its EIP-712 digest: Confirm terms for a task.

ERC-7730 files of messages#

fact ERC-7730 binds a file to a contract or to messages, not both. context.eip712 has domain, each of whose values must match the request's, and deployments; formats are keyed by the primary type's encodeType, whose hash must match; and for a message, @.to is the verifying contract (ERC-7730). So erc7730Descriptor(resolved, { context: "eip712" }) writes the messages' file, with its own descriptorHash. Continuing the EIP-2612 example:

js
import { erc7730Descriptor } from "@meantjs/consent/erc7730";

const { descriptor, descriptorHash, notes } = erc7730Descriptor(resolved, {
  context: "eip712",
  deployments: [{ chainId: 31337, address: "0x5FbDB2315678afecb367f032d93F642f64180aa3" }],
});
console.log(JSON.stringify({ context: descriptor.context, formats: descriptor.display.formats, descriptorHash, notes }, null, 2));
json
{
  "context": {
    "eip712": {
      "deployments": [
        {
          "chainId": 31337,
          "address": "0x5FbDB2315678afecb367f032d93F642f64180aa3"
        }
      ]
    }
  },
  "formats": {
    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)": {
      "intent": "Permit token spending",
      "interpolatedIntent": "Allow {spender} to spend up to {value} from {owner}. Signature expires: {deadline}.",
      "fields": [
        {
          "path": "owner",
          "label": "Owner",
          "format": "addressName"
        },
        {
          "path": "spender",
          "label": "Spender",
          "format": "addressName"
        },
        {
          "path": "value",
          "label": "Amount",
          "format": "tokenAmount",
          "params": {
            "tokenPath": "@.to",
            "threshold": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
            "message": "Unlimited"
          }
        },
        {
          "path": "nonce",
          "label": "Nonce",
          "format": "raw"
        },
        {
          "path": "deadline",
          "label": "Signature expires",
          "format": "date",
          "params": {
            "encoding": "timestamp"
          }
        }
      ]
    }
  },
  "descriptorHash": "0x377785ec6a78cf2a24e16cd7132722289ca407c66e7a4e1833bb98302499e2c1",
  "notes": [
    "Permit(0).deadline: the timestamp's threshold has no ERC-7730 date form and is left out; the file shows the date"
  ]
}
  • context.eip712.domain is the name and version the profiles bind, absent for a pack (as here), and deployments as for a contract: the caller's subset, or every deployment the profiles list.
  • Formats are keyed by encode_type, and the export checks that its Keccak-256 is the type_hash; a resolved form where it is not is EE_RESOLVED_FORM.
  • An EIP-2612 amount is tokenPath: "@.to": the verifying contract is the token. An amount with token is tokenAmount with that token's path.
  • An array whose elements hold an amount in a token of the same element is a field group, its paths relative to the element, as ERC-7730's own PermitBatch example writes it. A message without a title is Sign <name>.
  • The notes are those of the contract file. Permit2's file shows its deadlines as dates, and its two batch messages get no interpolatedIntent: eight notes in all.

Permit2's PermitBatch entry, as erc7730Descriptor(permit2, { context: "eip712" }) writes it (the package's test pins the same entry):

json
{
  "intent": "Permit2: allow spending of several tokens",
  "fields": [
    {
      "path": "details.[]",
      "fields": [
        { "path": "token", "label": "Token", "format": "addressName" },
        { "path": "amount", "label": "Amount", "format": "tokenAmount", "params": { "tokenPath": "token", "threshold": "0xffffffffffffffffffffffffffffffffffffffff", "message": "Unlimited" } },
        { "path": "expiration", "label": "Allowance expires", "format": "date", "params": { "encoding": "timestamp" } },
        { "path": "nonce", "label": "Nonce", "format": "raw" }
      ]
    },
    { "path": "spender", "label": "Spender", "format": "addressName" },
    { "path": "sigDeadline", "label": "Signature expires", "format": "date", "params": { "encoding": "timestamp" } }
  ]
}

Standard files#

Three files ship beside the packs in @meantjs/profile. loadPacks() returns the packs only, so the EIP-2612 pack joins every pack search, while the two contract profiles are read by path (@meantjs/profile/packs/permit2.profile.json). Every annotation: Standard packs.

FileKindMessagesBound to
erc2612.profile.jsonpackPermit (approve), and the permit function that submits oneany token with permit, nonces, DOMAIN_SEPARATOR, decimals and symbol; each token's own domain
permit2.profile.jsoncontract, third_partyPermitSingle, PermitBatch (approve); PermitTransferFrom, PermitBatchTransferFrom (transfer)domain Permit2; 0x0000…8BA3 on chains 1 and 42161
dai-permit.profile.jsoncontractDAI's older Permit (approve)domain Dai Stablecoin version 1; 0x6B17…1d0F on chain 1
  • EIP-2612. Amounts read the token's decimals() and symbol(), and a deadline of uint256 max reads "Never". fact "The deadline argument can be set to uint(-1) to create Permits that effectively never expire" (EIP-2612).
  • Permit2. Amounts are in the token each message names. fact The deployments are those of ERC-7730's own Permit2 example (O10). fact Uniswap's permit2 (Allowance.sol, AllowanceTransfer.sol): "If the inputted expiration is 0, the allowance only lasts the duration of the block", so it reads "Only in the block it is used"; an allowance of type(uint160).max is never decremented, so it reads "Unlimited".
  • DAI. fact The address is MCD_DAI in the Sky chainlog, read 2026-10-28. fact MakerDAO dss/src/dai.sol: expiry == 0 never expires, so 0 reads "Never"; allowed sets the allowance to uint(-1) or 0, and the text says so through its template (O12).
Messagetype_hash
EIP-2612 Permit0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9
DAI Permit0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb
Permit2 PermitSingle0xf3841cd1ff0085026a6327b620b67997ce40f282c88a8e905a7a5626e310f3d0
Permit2 PermitBatch0xaf1b0d30d2cab0380e68f0689007e3254993c596f2fdd0aaa7f4d04f79440863
Permit2 PermitTransferFrom0x939c21a48a8dbe3a9a2404a1d46691e4d39f6583d6ec6b35714604c986d80106
Permit2 PermitBatchTransferFrom0xfcf35f5ac6a2c28868dc44c302166470266239195f02b0ee408334829333b766

fact These six type hashes, EIP-712's Mail type hash and digest, the consent tests' digests and the exported files' descriptorHashes were recomputed by an independent Python EIP-712 and Keccak-256, the phase 1 cross-check tool (spec §15.9).

Local tests#

The repository runs no EVM node: none of its tests starts Anvil or calls a deployed contract. What is tested runs in Node against fixed vectors:

  • Keccak-256 against an independent implementation (and the block boundary), the ERC-20 selectors, the ERC-8176 schema UID, EIP-55's examples and EIP-712's Mail example (profile/packages/resolver/test/evm.test.mjs).
  • Every exported ERC-7730 file in the consent tests is checked against the ERC-7730 2.1.0 JSON Schema (vendored, with its sha256), and the hashes and digests the tests pin were computed by an independent implementation.
  • EIP-712: encodeType and the type hash of EIP-712's Mail example, the types that are refused, the standard messages' type hashes, token paths and timestamp words (profile/packages/resolver/test/eip712.test.mjs); the consent text of Permit2, EIP-2612 and DAI requests and every refusal above (profile/packages/consent/test/eip712.test.mjs). No chain, no network, nothing signed.
  • Grants: the packs' grants and each way one is refused (profile/packages/resolver/test/grant.test.mjs); the grants of the Permit2, EIP-2612, DAI and ERC-20 requests, with real typed data and calldata, Permit2's expiration of 0, and the ways a grant is left out (profile/packages/consent/test/grant.test.mjs); those grants against confirmed terms (profile/packages/consent/test/terms.test.mjs).

A test against a contract would run on a local Anvil chain, as the examples on this page assume, never on mainnet.

Open items#

ItemStatusQuestion
O1openOverloaded function names (ERC-721 safeTransferFrom). Candidate: name@0x<selector>.
O2openThe native value of a payable call as a position, with the chain's native decimals and symbol.
O3openWhether a transfer recipient typed in one case (no checksum) is accepted everywhere.
O4openWhether EVM packs should apply only to deployments a consumer lists (meanwhile the consent text shows the address).
O5, O6settledabi beside candid with a top-level chain; an export that refuses what it cannot express.
O7openfact (ERC-7730): a wallet that finds no ticker "SHOULD display the raw value instead with an 'Unknown token' warning", where spec §12 rule 10 produces no message. An exported file cannot make a wallet refuse.
O8assumptionA wallet reads metadata.token as the token at @.to. ERC-7730 calls it the token "exported by this format"; the export writes it only when the caller says the contract is that token.
O9openPermit2 witness messages (PermitWitnessTransferFrom with a protocol's own witness type). Each needs its own profile; until then a request with a witness matches nothing and gets no text.
O10openPermit2's deployments beyond chains 1 and 42161: its address is the same on many chains, and each is added after checking Uniswap's list, never assumed.
O11openDomains with a salt, and contracts whose domain name or version changes after deployment: refused for now.
O12openDAI's allowed bool reads through its template ("to all of it if allowed is true, to none if false"); a case-like reading of true and false would need D-053 for bool.
O13openThe signer against an owner or holder member: whether the text should say so, or refuse, when they differ. The consent function does not take the signer yet.
O14openPermits that differ from EIP-2612 by a member match nothing and get no text; a pack per variant is added only when one is seen.

All open questions, the profile spec's and these: Open questions.