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.
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:
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);# Send tokens
Send `1.5 TKA` to `0x70997970C51812dc3A010C7d01b50e0d17dc79C8`
- **Amount:** `1.5 TKA`
- **Recipient:** `0x70997970C51812dc3A010C7d01b50e0d17dc79C8`
**Contract:** `0x5FbDB2315678afecb367f032d93F642f64180aa3` on chain 31337The 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.abiis 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,fallbackandreceiveare skipped.- Aliases read as their canonical types (
uintisuint256,constantisview). A function's canonical fragment isfunction NAME(type name,…) [view|pure|payable] returns (…). - Overloaded names are refused (EE_ABI; open O1): an address names a function by its name alone.
| ABI | As a profile sees it |
|---|---|
| a function | a method: view and pure are queries, nonpayable and payable updates |
| its parameters | one record at argument index 0: transfer(0).to; a function without parameters has no argument |
| its outputs | the result tuple: balanceOf->(0) |
| a tuple | a 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 else | a 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:
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
uintNandintNfor N = 8, 16, …, 256, with the bounds of their N bits.amountinput and display are the same as on a canister, with no rounding: EE_EXCESS_PRECISION, never a rounded value. - Addresses have one text form,
0xand 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:
{
"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" } }
]
}node profile/scripts/validate.mjs tka.profile.jsonPASS 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"andbinds.abiinstead ofbinds.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
_toand_value, WETHdstandwad: on an ABI withtransfer(address dst, uint256 wad), the ERC-20 pack's template readsSend {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,decimalsandsymbol. factdecimals()andsymbol()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 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#
| Tag | On an EVM contract |
|---|---|
amount | uint8…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, duration | uint32…uint256, int64…int256. timestamp's words (threshold with message, labels) are for EVM deadlines first: uint256 max reads "Never" (Values). |
bytes | bytes, bytes1…bytes32 |
text | string |
range | an integer type, string, an array |
describe, default, ui, intent, effect | as on a canister |
account, subaccount | none (EE_TAG_NOT_APPLICABLE): an address needs no tag |
idempotency, outcome, list, names, named | not 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.
"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:
"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.
Consent text for a call#
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:
| Case | Result |
|---|---|
| A profile lists deployments, and the call's contract and chain are not among them | EE_DEPLOYMENT |
A payable function (its value has no position yet, O2) | EE_UNSUPPORTED_TYPE |
| Calldata that is not the canonical encoding of its values | EE_DECODE |
| A selector the resolved form does not have | EE_METHOD_UNKNOWN |
A Candid resolved form given to @meantjs/consent/evm, or an EVM one to the main entry | EE_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).
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);{
"intent": "Send tokens",
"interpolatedIntent": "Send {value} to {to}",
"fields": [
{
"path": "to",
"label": "Recipient",
"format": "addressName"
},
{
"path": "value",
"label": "Amount",
"format": "tokenAmount",
"params": {
"tokenPath": "@.to"
}
}
]
}| Profile | ERC-7730 |
|---|---|
intent.title (else describe.label of the method, else Call <name>) | intent |
intent.template | interpolatedIntent: {(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 order | one 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 symbol | the same, with metadata.token (only when the caller says the contract is that token) |
amount with token | tokenAmount, tokenPath the token's path; inside the field group of an array when the token is in the same element |
amount's threshold, message | threshold (0x hex), message |
timestamp in s | date, encoding: "timestamp"; its words are left out, with a note |
duration in s | duration |
bytes in hex, untagged bytes | raw |
an untagged address | addressName |
| anything else | raw |
a payable function | one more field, @.value as amount (the native value, which an ERC-7730 wallet knows) |
binds.deployments | context.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.
{
"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" } }
]
}node profile/scripts/validate.mjs payouts.profile.jsonPASS 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.eip712is{ domain, types }, in a contract profile or an EVM pack.typeshas the request's own JSON shape, withoutEIP712Domain.domainis optional and binds only the name and the version;chainIdandverifyingContractcome frombinds.deployments. A pack has no domain, since each token's is its own, and keeps itsbinds.abi, by which it matches.- A struct name is an identifier that does not read as an ABI type (
uint256,bytes32,tuple, …) and is notEIP712Domain. Members have unique names. - A member type is a canonical ABI type as written,
uint256and neveruint, because the type hash hashes the text; or a struct of the same map. Either may have array suffixes,T[]orT[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
intentat 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;
Legabove is described insidePayout. - 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:
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#
intentandeffectwork as on a function. A signature runs nothing when it is signed, soeffectsays what it allows, of any kind: approve for an allowance, transfer for a one-time transfer. The default is write, irreversible.replicateddoes not apply to a signature (EE_EFFECT_MODE).- An
effect'sgrantnames 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.
decimalsandsymbolare not given (the schema refuses them): they are that token'sdecimals()andsymbol().- 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", auint256. So istokenon a Candid service, whose ledger a field cannot name yet (OQ-6). tokenworks on functions too, andevmConsenttakes the sametokens.
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);{
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.
Consent text for a signing request#
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.
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);# 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#
- EE_DOMAIN: a domain field EIP-712 does not define; a
salt(O11); atypes.EIP712Domainthat does not list the domain's fields in EIP-712 order (it may be left out); nochainId, so the signature would be valid on every chain; noverifyingContract; achainIdother than the wallet's; anameorversionother than that of every profile that binds a domain, absent where the profile's is absent (Permit2's domain has no version). - 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.
- 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.
bytesNhas exactly N bytes, as0xhex. An array has its declared length, within the decoder's limits. - EE_REF_UNRESOLVED: an amount in a token not in
tokens, or aquery:ref not inrefs. For a pack's message thequery:refs are calls to the verifying contract: resolve that contract's ABI with the packs, as above.
Run against the request above:
| Change | Result |
|---|---|
| The wallet is on chain 1 | E_DOMAIN: the domain is for chain 31337, the wallet is on chain 1 |
One more member in Permit | E_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 refs | E_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 argument | E_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:
{
"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:
{
"kind": "approve",
"spender": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
"token": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
"amount": { "units": "500000000", "unlimited": false },
"ends": { "at": "1793622600" }
}| The same request with | grant |
|---|---|
expiration 2026-11-16 12:00:00 UTC (1794830400) | ends: { at: "1794830400" } |
expiration uint48 max, the tag's threshold | ends: { never: true } |
amount uint160 max, the tag's threshold | amount: { units: "1461501637330902918203684832716283019655932542975", unlimited: true } |
- EIP-2612 and DAI. Their
deadlineandexpirybound only when the signature may be used, so they are not ends: the allowance a permit sets never ends,ends: { never: true }. DAI'sallowedis abool:truegrants{ units: null, unlimited: true },falsegrants{ units: "0", unlimited: false }, nothing. - ERC-20.
approvenames itsspenderand never ends;transfernamestoand ends when it is made,{ now: true }.transferFromspends an allowance someone else gave and has no grant. -
Why
PermitBatchhas none.PermitBatchandPermitBatchTransferFromgrant 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;eip712Consentreturnsgrant: nullfor 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:
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));{
"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.domainis the name and version the profiles bind, absent for a pack (as here), anddeploymentsas 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 thetype_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 withtokenistokenAmountwith 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
PermitBatchexample writes it. A message without a title isSign <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):
{
"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.
| File | Kind | Messages | Bound to |
|---|---|---|---|
erc2612.profile.json | pack | Permit (approve), and the permit function that submits one | any token with permit, nonces, DOMAIN_SEPARATOR, decimals and symbol; each token's own domain |
permit2.profile.json | contract, third_party | PermitSingle, PermitBatch (approve); PermitTransferFrom, PermitBatchTransferFrom (transfer) | domain Permit2; 0x0000…8BA3 on chains 1 and 42161 |
dai-permit.profile.json | contract | DAI's older Permit (approve) | domain Dai Stablecoin version 1; 0x6B17…1d0F on chain 1 |
- EIP-2612. Amounts read the token's
decimals()andsymbol(), and a deadline ofuint256max reads "Never". fact "Thedeadlineargument can be set touint(-1)to createPermits 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 oftype(uint160).maxis never decremented, so it reads "Unlimited". - DAI. fact The address is
MCD_DAIin the Sky chainlog, read 2026-10-28. fact MakerDAOdss/src/dai.sol:expiry == 0never expires, so 0 reads "Never";allowedsets the allowance touint(-1)or 0, and the text says so through its template (O12).
| Message | type_hash |
|---|---|
EIP-2612 Permit | 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9 |
DAI Permit | 0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb |
Permit2 PermitSingle | 0xf3841cd1ff0085026a6327b620b67997ce40f282c88a8e905a7a5626e310f3d0 |
Permit2 PermitBatch | 0xaf1b0d30d2cab0380e68f0689007e3254993c596f2fdd0aaa7f4d04f79440863 |
Permit2 PermitTransferFrom | 0x939c21a48a8dbe3a9a2404a1d46691e4d39f6583d6ec6b35714604c986d80106 |
Permit2 PermitBatchTransferFrom | 0xfcf35f5ac6a2c28868dc44c302166470266239195f02b0ee408334829333b766 |
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:
encodeTypeand 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#
| Item | Status | Question |
|---|---|---|
| O1 | open | Overloaded function names (ERC-721 safeTransferFrom). Candidate: name@0x<selector>. |
| O2 | open | The native value of a payable call as a position, with the chain's native decimals and symbol. |
| O3 | open | Whether a transfer recipient typed in one case (no checksum) is accepted everywhere. |
| O4 | open | Whether EVM packs should apply only to deployments a consumer lists (meanwhile the consent text shows the address). |
| O5, O6 | settled | abi beside candid with a top-level chain; an export that refuses what it cannot express. |
| O7 | open | fact (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. |
| O8 | assumption | A 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. |
| O9 | open | Permit2 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. |
| O10 | open | Permit2'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. |
| O11 | open | Domains with a salt, and contracts whose domain name or version changes after deployment: refused for now. |
| O12 | open | DAI'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. |
| O13 | open | The 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. |
| O14 | open | Permits 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.