Profiles
Standard packs
The ICRC-1, ICRC-2, ERC-20 and EIP-2612 packs, and the Permit2 and DAI permit profiles: what each matches and every annotation it carries.
A pack is a profile of kind: "pack" that describes a standard rather than one canister or contract.
@meantjs/profile ships four packs and, beside them, two EVM contract profiles for well-known signing
contracts, all copies of
profile/packs/. Every annotation
they carry is listed below. Since 0.4.0 each of the six also says what its calls and messages grant, in the
grant of their effect (spec §5.3, D-056; below), so every
profile_id changed with it. profile_id and the checked interface are the real output of the
validator, run from profile/:
cd profile
node scripts/validate.mjsPASS packs/dai-permit.profile.json (0 error, 0 warning, 0 info)
profile_id candid-profile:v0:sha256:942de20c5e311b6cd32b02c80c6aec2a80eeee50f9bcc5846120d23fac690dc8
interface_id candid-profile:evm-abi:v0:sha256:3ed4a3aea8a76d5194413a0196ab608414c8bf9687592f591d8ecffb53255b46 [1 methods, 8 annotations]
PASS packs/erc20.profile.json (0 error, 0 warning, 0 info)
profile_id candid-profile:v0:sha256:afc7d231076699406b2fc9f74da372f5da88465027ab1d46eca526e646368a81
interface_id candid-profile:evm-abi:v0:sha256:81471b5efd3e34c464b5dda12ccdd454d8ca6e0d0c069871c8152bc6b154be4b [7 methods, 23 annotations]
PASS packs/erc2612.profile.json (0 error, 0 warning, 0 info)
profile_id candid-profile:v0:sha256:136de06daafff15af3dc5010c97929854d8f02f72bcb71ff1ebcbe7088a2b705
interface_id candid-profile:evm-abi:v0:sha256:73c9721a3b8abd52830805c1ac5f848f1ef8918e9cf681b5d4e134333d0955ee [6 methods, 18 annotations]
PASS packs/icrc1.profile.json (0 error, 0 warning, 0 info)
profile_id candid-profile:v0:sha256:4eaa9312d60661248ff5627dba9906aa91240abe84cb1b1da8ca20a6fba2e3e6
interface_id candid-core:interface:v1:sha256:7fae0dd330b20f53ae1e4e2cb1947c1c57791da0ae20299ceb499f63228d0689 [5 methods, 29 annotations]
PASS packs/icrc2.profile.json (0 error, 0 warning, 0 info)
profile_id candid-profile:v0:sha256:f9d75e8bf5ff2edb0b167988feee494eeb54c386e6f62252c4bd96ac56346567
interface_id candid-core:interface:v1:sha256:c7e94a82863faf90ec6906e7b35a1b917b94a34664bb3a54cc3fca979b3bda3f [5 methods, 56 annotations]
PASS packs/permit2.profile.json (0 error, 0 warning, 0 info)
profile_id candid-profile:v0:sha256:d85261a93989a3f9cdaf7b8f3a93a3a508a388c9d8e808696bf867ace920b0c6
interface_id candid-profile:evm-abi:v0:sha256:e013c202cb76d4b7fefbb510ee6c1a806bbbdc1eee87dfb85c6e1a520c72d646 [4 methods, 40 annotations]The validator's methods count takes an EIP-712 message as a method; the table splits them.
| File | Kind | Standard or contract | Functions | Messages | Annotations |
|---|---|---|---|---|---|
icrc1 | pack | ICRC-1 | 5 | 0 | 29 |
icrc2 | pack | ICRC-2 | 5 | 0 | 56 |
erc20 | pack | ERC-20 experimental | 7 | 0 | 23 |
erc2612 | pack | ERC-2612 experimental | 5 | 1 | 18 |
permit2 | contract profile | Uniswap's Permit2 experimental | 0 | 4 | 40 |
dai-permit | contract profile | DAI's permit experimental | 0 | 1 | 8 |
The interface_id a pack is checked against is the identity of its own fragment: the validator compiles
binds.requires (or reads binds.abi, with the struct types of binds.eip712) and
resolves every annotation against it. It is not the identity of any ledger or token. A ledger that matches has its
own interface_id, and the pack is applied to the ledger's types. A contract profile is checked against
its own binds in the same way; the two shipped here bind EIP-712 types and no function.
How a pack matches#
A Candid pack applies to a canister whose service is a subtype of the fragment in binds.requires, with
result variants allowed to be wider (D-019); an EVM pack applies to a contract that has every function of its
binds.abi with the same selector, output types and kind of call, with no subtyping. A matched pack is
applied to the service's own types, so a position the pack names that the service lacks is skipped
(II_PACK_ANNOTATION_SKIPPED); the full rules are on Profiles, layers and
binding.
An EVM pack may also carry EIP-712 messages (binds.eip712, spec §15.9). It still matches by its
binds.abi, and its messages then join the contract's resolved form under messages. The
contract profiles permit2 and dai-permit are not packs: no pack search tries them, and they
apply when given as profiles (--profile, or profiles in resolveService).
What the packs grant#
A grant names who may take tokens because of a call or a signature, how many and until when, as
positions of its arguments (Tags). A wallet reads it with the
consent functions and checks it against terms the person confirmed for one task
(Confirm terms for a task). These are every grant the six files carry, read from
their effect annotations:
| File | Call or message | Kind | Who may take it | Amount | Ends |
|---|---|---|---|---|---|
icrc1 | icrc1_transfer | transfer | "to": "to" | amount | none: when it is made |
icrc2 | icrc2_approve | approve | "spender": "spender" | amount | expires_at; left out, never |
erc20 | transfer | transfer | "to": "to" | value | none: when it is made |
erc20 | approve | approve | "spender": "spender" | value | none: never |
erc2612 | Permit (message), permit | approve | "spender": "spender" | value | none: never |
permit2 | PermitSingle (message) | approve | "spender": "spender" | details.amount, in details.token | the latest of details.expiration and sigDeadline |
permit2 | PermitTransferFrom (message) | transfer | "spender": "spender" | permitted.amount, in permitted.token | deadline |
dai-permit | Permit (message) | approve | "spender": "spender" | allowed, a bool: true unlimited, false nothing | none: never |
The token is the one an amount.token names (Permit2's), else the ledger or contract called. An amount
at or above its tag's threshold is unlimited, and an end at or above its tag's threshold
never ends. EIP-2612's deadline and DAI's
expiry bound only when the signature may be used, so they are not ends: those allowances never end
(spec §5.3). The other transfers and approvals carry no grant: icrc2_transfer_from and ERC-20's
transferFrom; and Permit2's PermitBatch and PermitBatchTransferFrom, which grant
several tokens while a grant covers one. A request without a grant is not covered by terms and needs its
confirmation. A pack whose grant does not hold in the service it is applied to keeps its effect and loses the grant
(II_PACK_ANNOTATION_SKIPPED); in the ledger and token runs below every grant holds, and the resolved
forms carry them all.
Using the packs#
On the command line, --packs tries every pack the package ships and applies those that match:
npx candid-profile resolve ledger.did --packs > ledger.resolved.json
On the ICRC ledger's own .did (release ledger-suite-icrc-2026-03-09, the copy in
profile/packages/resolver/test/fixtures/ledger.did) it applies two packs and reports on stderr:
candid-profile: packages/resolver/test/fixtures/ledger.did candid-core:interface:v1:sha256:22eb4787e1a755acb9aabd0ed2121d754098bcd1cedb5d0ed8b5c4effb921846 [26 methods]
applied icrc1 pack candid-profile:v0:sha256:4eaa9312d60661248ff5627dba9906aa91240abe84cb1b1da8ca20a6fba2e3e6
applied icrc2 pack candid-profile:v0:sha256:f9d75e8bf5ff2edb0b167988feee494eeb54c386e6f62252c4bd96ac56346567
0 error, 0 warning, 3 info| Way | What it does |
|---|---|
--packs | Tries every pack in the packs directory and applies those that match; the others get II_PACK_NOT_MATCHED (info). A pack for the other kind of interface is skipped: the EVM packs are never tried on a .did, nor the ICRC packs on an ABI. The contract profiles in the same directory are not packs and are never tried. |
--packs-dir <dir> | The packs directory (default: the standard packs the package ships). |
--profile pack.json | Applies the pack as given: it must match, or the resolve fails with EE_PACK_NOT_MATCHED. A contract profile, such as permit2.profile.json, is applied this way too. |
loadPacks(dir?) | Every *.profile.json of kind "pack" in a directory, sorted by file name; by default DEFAULT_PACKS_DIR, the shipped packs. Packs only: erc20, erc2612, icrc1 and icrc2, never the two contract profiles. Node entry. |
STANDARD_PACKS | The same four packs as data, from @meantjs/profile/browser, for a bundle that cannot read files. |
@meantjs/profile/packs/* | The six files themselves, as a package export (./packs/* maps to the package's data/packs/*): the four packs, and the contract profiles @meantjs/profile/packs/permit2.profile.json and @meantjs/profile/packs/dai-permit.profile.json. |
import { createSchemaValidator, loadPacks, resolveService } from "@meantjs/profile";
import { readFileSync } from "node:fs";
const validateSchema = await createSchemaValidator(); // the schema this package ships
const packs = await loadPacks(); // the packs it ships, never its contract profiles
packs.map((p) => p.doc.name); // ["erc20", "erc2612", "icrc1", "icrc2"]
const r = await resolveService({
did: readFileSync("ledger.did", "utf8"),
validateSchema,
packs: packs.map(({ doc }) => ({ doc })), // tried; applied only if they match
});import icrc1 from "@meantjs/profile/packs/icrc1.profile.json" with { type: "json" };
const validateSchema = await createSchemaValidator(schema); // a document, not a path
const r = await resolveService({ did, validateSchema, packs: [{ doc: icrc1 }] });
On an EVM token with permit, the pack search applies both EVM packs, and the EIP-2612
Permit message joins the token's resolved form. token.abi.json here is a JSON array of the
ten functions of a local test token: the seven of ERC-20 and permit, nonces and
DOMAIN_SEPARATOR, as human-readable fragments.
npx candid-profile resolve --abi token.abi.json --packs > token.resolved.jsoncandid-profile: token.abi.json candid-profile:evm-abi:v0:sha256:adaf687bca6190caca23583655028c78a0ad4d9fca99fe1f1efdf96758b910eb [10 methods]
applied erc20 pack candid-profile:v0:sha256:afc7d231076699406b2fc9f74da372f5da88465027ab1d46eca526e646368a81
applied erc2612 pack candid-profile:v0:sha256:136de06daafff15af3dc5010c97929854d8f02f72bcb71ff1ebcbe7088a2b705
0 error, 0 warning, 2 info
The interface_id printed is the merged form's: the ten functions and the Permit struct the
EIP-2612 pack adds. A contract profile is given with --profile. The Permit2 profile binds no function, so
from profile/ it resolves against an empty ABI (empty.abi.json holds []):
npx candid-profile resolve --abi empty.abi.json --profile packs/permit2.profile.json > permit2.resolved.jsoncandid-profile: empty.abi.json candid-profile:evm-abi:v0:sha256:e013c202cb76d4b7fefbb510ee6c1a806bbbdc1eee87dfb85c6e1a520c72d646 [0 methods]
applied permit2 third_party candid-profile:v0:sha256:d85261a93989a3f9cdaf7b8f3a93a3a508a388c9d8e808696bf867ace920b0c6
0 error, 0 warning, 0 infoICRC-1#
Fungible token ledgers: transfer and balance. Spec:
ICRC-1. File:
profile/packs/icrc1.profile.json.
profile_id: profile_idcandid-profile:v0:sha256:4eaa9312d60661248ff5627dba9906aa91240abe84cb1b1da8ca20a6fba2e3e6- checked against (the fragment): interface_idcandid-core:interface:v1:sha256:7fae0dd330b20f53ae1e4e2cb1947c1c57791da0ae20299ceb499f63228d0689
- validator: 0 error, 0 warning, 0 info; 5 methods, 29 annotations
Required fragment#
type Account = record { owner : principal; subaccount : opt blob };
type TransferArgs = record {
from_subaccount : opt blob;
to : Account;
amount : nat;
fee : opt nat;
memo : opt blob;
created_at_time : opt nat64;
};
type TransferError = variant {
BadFee : record { expected_fee : nat };
BadBurn : record { min_burn_amount : nat };
InsufficientFunds : record { balance : nat };
TooOld;
CreatedInFuture : record { ledger_time : nat64 };
Duplicate : record { duplicate_of : nat };
TemporarilyUnavailable;
GenericError : record { error_code : nat; message : text };
};
service : {
icrc1_symbol : () -> (text) query;
icrc1_decimals : () -> (nat8) query;
icrc1_fee : () -> (nat) query;
icrc1_balance_of : (Account) -> (nat) query;
icrc1_transfer : (TransferArgs) -> (variant { Ok : nat; Err : TransferError });
}Annotations#
| Address | Tag | Args |
|---|---|---|
icrc1_transfer | intent | title: "Send tokens"template: "Send {amount} to {to}[ from subaccount {from_subaccount}][ with memo {memo}][ paying fee {fee}]" |
icrc1_transfer | effect | kind: "transfer"irreversible: truegrant: { "to": "to", "amount": "amount" } |
icrc1_transfer | idempotency | mode: "replay_same_payload"stamp: "created_at_time" |
icrc1_transfer | outcome | classes:"Ok" → success"Err.Duplicate" → already_done"Err.TemporarilyUnavailable" → retry_same"Err.TooOld" → retry_new"Err.CreatedInFuture" → retry_new"Err.BadFee" → fix_input"Err.BadBurn" → fix_input"Err.InsufficientFunds" → fail"Err.GenericError" → faildefault: fail |
icrc1_transfer(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc1_transfer(0). | describe | label: "Amount" |
icrc1_transfer(0). | account | encoding: "icrc1" |
icrc1_transfer(0). | describe | label: "Recipient" |
icrc1_transfer(0). | subaccount | {} |
icrc1_transfer(0). | describe | label: "From subaccount"help: "Omit to send from the default subaccount." |
icrc1_transfer(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc1_transfer(0). | default | value: "null"mode: "auto" |
icrc1_transfer(0). | describe | label: "Fee"help: "Leave empty to pay the ledger's current default fee. A wrong explicit fee fails with BadFee." |
icrc1_transfer(0). | bytes | encoding: "utf8" |
icrc1_transfer(0). | default | value: "null"mode: "suggest" |
icrc1_transfer(0). | describe | label: "Memo"help: "Transfers with different memos are never deduplicated against each other. Ledgers accept at least 32 bytes; longer memos may be rejected." |
icrc1_transfer(0). | timestamp | unit: "ns"epoch: "unix" |
icrc1_transfer(0). | describe | label: "Created at"help: "Set once per intent by the tool layer; enables the ledger's duplicate protection." |
icrc1_transfer(0). | ui | hidden: true |
icrc1_transfer->(0). | describe | label: "Block index" |
icrc1_transfer->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc1_transfer->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc1_transfer->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc1_transfer->(0). | timestamp | unit: "ns"epoch: "unix" |
icrc1_transfer->(0). | describe | label: "Existing block index"help: "The same transfer was already executed in this block." |
icrc1_balance_of | describe | label: "Balance of an account" |
icrc1_balance_of(0) | account | encoding: "icrc1" |
icrc1_balance_of->(0) | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc1_fee->(0) | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
ICRC-2#
Approve and transfer-from. ICRC-2 ledgers also implement ICRC-1, so both packs apply to them. Spec:
ICRC-2. File:
profile/packs/icrc2.profile.json.
profile_id: profile_idcandid-profile:v0:sha256:f9d75e8bf5ff2edb0b167988feee494eeb54c386e6f62252c4bd96ac56346567- checked against (the fragment): interface_idcandid-core:interface:v1:sha256:c7e94a82863faf90ec6906e7b35a1b917b94a34664bb3a54cc3fca979b3bda3f
- validator: 0 error, 0 warning, 0 info; 5 methods, 56 annotations
Required fragment#
type Account = record { owner : principal; subaccount : opt blob };
type ApproveArgs = record {
from_subaccount : opt blob;
spender : Account;
amount : nat;
expected_allowance : opt nat;
expires_at : opt nat64;
fee : opt nat;
memo : opt blob;
created_at_time : opt nat64;
};
type ApproveError = variant {
BadFee : record { expected_fee : nat };
InsufficientFunds : record { balance : nat };
AllowanceChanged : record { current_allowance : nat };
Expired : record { ledger_time : nat64 };
TooOld;
CreatedInFuture : record { ledger_time : nat64 };
Duplicate : record { duplicate_of : nat };
TemporarilyUnavailable;
GenericError : record { error_code : nat; message : text };
};
type TransferFromArgs = record {
spender_subaccount : opt blob;
from : Account;
to : Account;
amount : nat;
fee : opt nat;
memo : opt blob;
created_at_time : opt nat64;
};
type TransferFromError = variant {
BadFee : record { expected_fee : nat };
BadBurn : record { min_burn_amount : nat };
InsufficientFunds : record { balance : nat };
InsufficientAllowance : record { allowance : nat };
TooOld;
CreatedInFuture : record { ledger_time : nat64 };
Duplicate : record { duplicate_of : nat };
TemporarilyUnavailable;
GenericError : record { error_code : nat; message : text };
};
type AllowanceArgs = record { account : Account; spender : Account };
service : {
icrc1_symbol : () -> (text) query;
icrc1_decimals : () -> (nat8) query;
icrc2_approve : (ApproveArgs) -> (variant { Ok : nat; Err : ApproveError });
icrc2_transfer_from : (TransferFromArgs) -> (variant { Ok : nat; Err : TransferFromError });
icrc2_allowance : (AllowanceArgs) -> (record { allowance : nat; expires_at : opt nat64 }) query;
}Annotations#
| Address | Tag | Args |
|---|---|---|
icrc2_approve | intent | title: "Approve spending"template: "Set {spender}'s allowance to {amount}, replacing any existing allowance[ (only if the current allowance is {expected_allowance})][ until {expires_at}][ from subaccount {from_subaccount}][ with memo {memo}]" |
icrc2_approve | effect | kind: "approve"irreversible: falsegrant: { "spender": "spender", "amount": "amount", "ends": ["expires_at"] } |
icrc2_approve | idempotency | mode: "replay_same_payload"stamp: "created_at_time" |
icrc2_approve | outcome | classes:"Ok" → success"Err.Duplicate" → already_done"Err.TemporarilyUnavailable" → retry_same"Err.TooOld" → retry_new"Err.CreatedInFuture" → retry_new"Err.BadFee" → fix_input"Err.AllowanceChanged" → fix_input"Err.Expired" → fix_input"Err.InsufficientFunds" → fail"Err.GenericError" → faildefault: fail |
icrc2_approve(0). | account | encoding: "icrc1" |
icrc2_approve(0). | describe | label: "Spender" |
icrc2_approve(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_approve(0). | describe | label: "Allowance"help: "Maximum the spender may transfer. An approval replaces the previous allowance; it does not add to it." |
icrc2_approve(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_approve(0). | describe | label: "Expected current allowance"help: "Optional safety check: the call fails with AllowanceChanged if the current allowance differs." |
icrc2_approve(0). | timestamp | unit: "ns"epoch: "unix" |
icrc2_approve(0). | describe | label: "Expires at"help: "Omit for an allowance that does not expire." |
icrc2_approve(0). | subaccount | {} |
icrc2_approve(0). | describe | label: "From subaccount" |
icrc2_approve(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_approve(0). | default | value: "null"mode: "auto" |
icrc2_approve(0). | bytes | encoding: "utf8" |
icrc2_approve(0). | default | value: "null"mode: "suggest" |
icrc2_approve(0). | describe | label: "Memo"help: "Ledgers accept at least 32 bytes; longer memos may be rejected." |
icrc2_approve(0). | timestamp | unit: "ns"epoch: "unix" |
icrc2_approve(0). | ui | hidden: true |
icrc2_approve->(0). | describe | label: "Block index" |
icrc2_approve->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_approve->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_approve->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_approve->(0). | timestamp | unit: "ns"epoch: "unix" |
icrc2_approve->(0). | timestamp | unit: "ns"epoch: "unix" |
icrc2_transfer_from | intent | title: "Transfer using allowance"template: "Move {amount} from {from} to {to} using an allowance[ (spender subaccount {spender_subaccount})][ with memo {memo}]" |
icrc2_transfer_from | effect | kind: "transfer"irreversible: true |
icrc2_transfer_from | idempotency | mode: "replay_same_payload"stamp: "created_at_time" |
icrc2_transfer_from | outcome | classes:"Ok" → success"Err.Duplicate" → already_done"Err.TemporarilyUnavailable" → retry_same"Err.TooOld" → retry_new"Err.CreatedInFuture" → retry_new"Err.BadFee" → fix_input"Err.BadBurn" → fix_input"Err.InsufficientFunds" → fail"Err.InsufficientAllowance" → fail"Err.GenericError" → faildefault: fail |
icrc2_transfer_from(0). | account | encoding: "icrc1" |
icrc2_transfer_from(0). | describe | label: "From" |
icrc2_transfer_from(0). | account | encoding: "icrc1" |
icrc2_transfer_from(0). | describe | label: "Recipient" |
icrc2_transfer_from(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_transfer_from(0). | describe | label: "Amount" |
icrc2_transfer_from(0). | subaccount | {} |
icrc2_transfer_from(0). | describe | label: "Spender subaccount" |
icrc2_transfer_from(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_transfer_from(0). | default | value: "null"mode: "auto" |
icrc2_transfer_from(0). | bytes | encoding: "utf8" |
icrc2_transfer_from(0). | default | value: "null"mode: "suggest" |
icrc2_transfer_from(0). | describe | label: "Memo"help: "Ledgers accept at least 32 bytes; longer memos may be rejected." |
icrc2_transfer_from(0). | timestamp | unit: "ns"epoch: "unix" |
icrc2_transfer_from(0). | ui | hidden: true |
icrc2_transfer_from->(0). | describe | label: "Block index" |
icrc2_transfer_from->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_transfer_from->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_transfer_from->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_transfer_from->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_transfer_from->(0). | timestamp | unit: "ns"epoch: "unix" |
icrc2_allowance(0). | account | encoding: "icrc1" |
icrc2_allowance(0). | account | encoding: "icrc1" |
icrc2_allowance->(0). | amount | decimals: { "from": "query:icrc1_decimals" }symbol: { "from": "query:icrc1_symbol" } |
icrc2_allowance->(0). | timestamp | unit: "ns"epoch: "unix" |
ERC-20#
EVM support is spec §15, phase 1 and the first part of phase 2: profiles, consent text for calldata and for EIP-712 signing requests, and ERC-7730 export. See EVM contracts.
EIP-20 tokens on EVM chains. Spec: EIP-20. File:
profile/packs/erc20.profile.json.
Parameter names may differ from the pack's: they are matched by position, and the pack's addresses and placeholders
are rewritten to the contract's names. name() is left out: no annotation reads it and EIP-20 makes it
optional. fact EIP-20 makes decimals() and symbol() optional too, so a token
without them matches no pack and needs a contract profile with literal values.
profile_id: profile_idcandid-profile:v0:sha256:afc7d231076699406b2fc9f74da372f5da88465027ab1d46eca526e646368a81- checked against (the ABI): interface_idcandid-profile:evm-abi:v0:sha256:81471b5efd3e34c464b5dda12ccdd454d8ca6e0d0c069871c8152bc6b154be4b
- validator: 0 error, 0 warning, 0 info; 7 methods, 23 annotations
Required functions#
[
"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)"
]Annotations#
| Address | Tag | Args |
|---|---|---|
transfer | intent | title: "Send tokens"template: "Send {value} to {to}" |
transfer | effect | kind: "transfer"irreversible: truegrant: { "to": "to", "amount": "value" } |
transfer(0). | amount | decimals: { "from": "query:decimals" }symbol: { "from": "query:symbol" } |
transfer(0). | describe | label: "Amount" |
transfer(0). | describe | label: "Recipient" |
approve | intent | title: "Approve spending"template: "Set {spender}'s allowance to {value}, replacing any existing allowance" |
approve | effect | kind: "approve"irreversible: falsegrant: { "spender": "spender", "amount": "value" } |
approve(0). | describe | label: "Spender" |
approve(0). | amount | decimals: { "from": "query:decimals" }symbol: { "from": "query:symbol" }threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935"message: "Unlimited" |
approve(0). | describe | label: "Allowance"help: "Maximum the spender may transfer. An approval replaces the previous allowance; it does not add to it." |
transferFrom | intent | title: "Transfer using allowance"template: "Move {value} from {from} to {to} using an allowance" |
transferFrom | effect | kind: "transfer"irreversible: true |
transferFrom(0). | describe | label: "From" |
transferFrom(0). | describe | label: "Recipient" |
transferFrom(0). | amount | decimals: { "from": "query:decimals" }symbol: { "from": "query:symbol" } |
transferFrom(0). | describe | label: "Amount" |
balanceOf | describe | label: "Balance of an address" |
balanceOf(0). | describe | label: "Owner" |
balanceOf->(0) | amount | decimals: { "from": "query:decimals" }symbol: { "from": "query:symbol" } |
allowance | describe | label: "Allowance of a spender" |
allowance(0). | describe | label: "Owner" |
allowance(0). | describe | label: "Spender" |
allowance->(0) | amount | decimals: { "from": "query:decimals" }symbol: { "from": "query:symbol" }threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935"message: "Unlimited" |
The threshold on approve(0).value and allowance->(0) is 2256 − 1,
the largest uint256: an approval of that value displays as Unlimited and the symbol.
ERC-2612#
EIP-712 messages are spec §15.9 (D-055), the first part of the EVM binding's phase 2: profiles, consent text for a signing request and an ERC-7730 file of the messages. Signing and checking signatures are not in it. See EVM contracts.
EIP-2612 permits on EVM chains: the signed Permit message that sets an ERC-20 allowance, and the
permit function that submits one. Spec: EIP-2612.
File: profile/packs/erc2612.profile.json.
It applies to a token that has every function of its binds.abi with the same selector, outputs and kind
of call, decimals() and symbol() included, since the amounts read them. A signing request
matches Permit by its type hash, and its verifying contract is the token, so the query:
refs are calls to that token. The pack binds no domain: each token's name and version are its own.
fact (EIP-2612) "The deadline
argument can be set to uint(-1) to create Permits that effectively never expire", so a
deadline of 2256 − 1 reads Never.
profile_id: profile_idcandid-profile:v0:sha256:136de06daafff15af3dc5010c97929854d8f02f72bcb71ff1ebcbe7088a2b705- checked against (the ABI and the struct types): interface_idcandid-profile:evm-abi:v0:sha256:73c9721a3b8abd52830805c1ac5f848f1ef8918e9cf681b5d4e134333d0955ee
- validator: 0 error, 0 warning, 0 info; 6 methods (5 functions and 1 message), 18 annotations
Required functions and types#
"abi": [
"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)",
"function decimals() view returns (uint8)",
"function symbol() view returns (string)"
],
"eip712": {
"types": {
"Permit": [
{
"name": "owner",
"type": "address"
},
{
"name": "spender",
"type": "address"
},
{
"name": "value",
"type": "uint256"
},
{
"name": "nonce",
"type": "uint256"
},
{
"name": "deadline",
"type": "uint256"
}
]
}
}| Message and effect | encode_type and type_hash |
|---|---|
Permitapprove | Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9 |
Annotations#
| Address | Tag | Args |
|---|---|---|
Permit | intent | title: "Permit token spending"template: "Allow {spender} to spend up to {value} from {owner}. Signature expires: {deadline}." |
Permit | effect | kind: "approve"irreversible: falsegrant: { "spender": "spender", "amount": "value" } |
Permit(0). | amount | decimals: { "from": "query:decimals" }symbol: { "from": "query:symbol" }threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935" |
Permit(0). | describe | label: "Amount" |
Permit(0). | timestamp | unit: "s"epoch: "unix"threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935"message: "Never" |
Permit(0). | describe | label: "Signature expires" |
Permit(0). | describe | label: "Owner" |
Permit(0). | describe | label: "Spender" |
Permit(0). | describe | label: "Nonce" |
permit | intent | title: "Submit a token permit"template: "Set {spender}'s allowance from {owner} to {value} with {owner}'s signature. Signature expires: {deadline}." |
permit | effect | kind: "approve"irreversible: falsegrant: { "spender": "spender", "amount": "value" } |
permit(0). | amount | decimals: { "from": "query:decimals" }symbol: { "from": "query:symbol" }threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935" |
permit(0). | describe | label: "Amount" |
permit(0). | timestamp | unit: "s"epoch: "unix"threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935"message: "Never" |
permit(0). | describe | label: "Signature expires" |
permit(0). | describe | label: "Owner" |
permit(0). | describe | label: "Spender" |
permit(0). | describe | label: "Signature v" |
The threshold on both values and both deadlines is 2256 − 1: an
amount of that value displays as Unlimited and the symbol (the default message), and a
deadline of that value as Never. The grant of Permit and of permit names no
ends: the deadline bounds only when the signature may be used, and the allowance it sets never ends
(spec §5.3).
Permit2#
experimental Uniswap's Permit2, as a contract profile (kind: "contract",
origin: "third_party"), not a pack. File:
profile/packs/permit2.profile.json.
It describes four messages and no function: the AllowanceTransfer messages PermitSingle and
PermitBatch, which set allowances (approve), and the SignatureTransfer messages
PermitTransferFrom and PermitBatchTransferFrom, which authorize one transfer
(transfer irreversible). Each amount is in the token its message names
(amount.token): a consumer supplies that token's decimals() and symbol(), and
shows its address beside the symbol.
It is bound to the domain named Permit2, with no version, and to 0x0000…8BA3 on chains 1
and 42161, so a look-alike contract named "Permit2" gets none of these words (EE_DEPLOYMENT).
fact ERC-7730's own Permit2 example lists these deployments (spec §15.9); its other chains are open
item O10. fact (Uniswap permit2, Allowance.sol and
AllowanceTransfer.sol) "If the inputted expiration is 0, the allowance only lasts the duration of the
block", so an expiration of 0 reads Only in the block it is used; an allowance of
type(uint160).max is never decremented, so it reads Unlimited. Witness messages
(PermitWitnessTransferFrom) are left out: each protocol adds its own witness type
(open O9, on Open questions).
profile_id: profile_idcandid-profile:v0:sha256:d85261a93989a3f9cdaf7b8f3a93a3a508a388c9d8e808696bf867ace920b0c6- checked against (its struct types): interface_idcandid-profile:evm-abi:v0:sha256:e013c202cb76d4b7fefbb510ee6c1a806bbbdc1eee87dfb85c6e1a520c72d646
- validator: 0 error, 0 warning, 0 info; 4 methods (4 messages), 40 annotations
Domain and deployments#
"eip712": {
"domain": {
"name": "Permit2"
},
"types": {
…
}
},
"deployments": [
{
"chain_id": 1,
"address": "0x000000000022D473030F116dDEE9F6B43aC78BA3"
},
{
"chain_id": 42161,
"address": "0x000000000022D473030F116dDEE9F6B43aC78BA3"
}
]Messages#
Six struct types; the four that the profile tags with intent are messages, and
PermitDetails and TokenPermissions are types inside them. Each encode_type lists
the primary type, then the structs it refers to:
| Message and effect | encode_type and type_hash |
|---|---|
PermitSingleapprove | PermitSingle(PermitDetails details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)0xf3841cd1ff0085026a6327b620b67997ce40f282c88a8e905a7a5626e310f3d0 |
PermitBatchapprove | PermitBatch(PermitDetails[] details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)0xaf1b0d30d2cab0380e68f0689007e3254993c596f2fdd0aaa7f4d04f79440863 |
PermitTransferFromtransfer irreversible | PermitTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)0x939c21a48a8dbe3a9a2404a1d46691e4d39f6583d6ec6b35714604c986d80106 |
PermitBatchTransferFromtransfer irreversible | PermitBatchTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)0xfcf35f5ac6a2c28868dc44c302166470266239195f02b0ee408334829333b766 |
Annotations#
| Address | Tag | Args |
|---|---|---|
PermitSingle | intent | title: "Permit2: allow token spending"template: "Let {spender} spend up to {details.amount}. Allowance expires: {details.expiration}. Signature expires: {sigDeadline}." |
PermitSingle | effect | kind: "approve"irreversible: falsegrant: { "spender": "spender", "amount": "details.amount", "ends": ["details.expiration", "sigDeadline"] } |
PermitSingle(0). | describe | label: "Token" |
PermitSingle(0). | amount | token: "details.token"threshold: "1461501637330902918203684832716283019655932542975" |
PermitSingle(0). | describe | label: "Amount" |
PermitSingle(0). | timestamp | unit: "s"epoch: "unix"threshold: "281474976710655"message: "Never"labels: { "0": "Only in the block it is used" } |
PermitSingle(0). | describe | label: "Allowance expires" |
PermitSingle(0). | describe | label: "Nonce" |
PermitSingle(0). | describe | label: "Spender" |
PermitSingle(0). | timestamp | unit: "s"epoch: "unix"threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935"message: "Never" |
PermitSingle(0). | describe | label: "Signature expires" |
PermitBatch | intent | title: "Permit2: allow spending of several tokens"template: "Let {spender} spend up to {details[].amount}. Allowances expire, in the same order: {details[].expiration}. Signature expires: {sigDeadline}." |
PermitBatch | effect | kind: "approve"irreversible: false |
PermitBatch(0). | describe | label: "Token" |
PermitBatch(0). | amount | token: "details[].token"threshold: "1461501637330902918203684832716283019655932542975" |
PermitBatch(0). | describe | label: "Amount" |
PermitBatch(0). | timestamp | unit: "s"epoch: "unix"threshold: "281474976710655"message: "Never"labels: { "0": "Only in the block it is used" } |
PermitBatch(0). | describe | label: "Allowance expires" |
PermitBatch(0). | describe | label: "Nonce" |
PermitBatch(0). | describe | label: "Spender" |
PermitBatch(0). | timestamp | unit: "s"epoch: "unix"threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935"message: "Never" |
PermitBatch(0). | describe | label: "Signature expires" |
PermitTransferFrom | intent | title: "Permit2: allow one transfer"template: "Let {spender} transfer up to {permitted.amount} from you, once. Signature expires: {deadline}." |
PermitTransferFrom | effect | kind: "transfer"irreversible: truegrant: { "spender": "spender", "amount": "permitted.amount", "ends": ["deadline"] } |
PermitTransferFrom(0). | describe | label: "Token" |
PermitTransferFrom(0). | amount | token: "permitted.token" |
PermitTransferFrom(0). | describe | label: "Up to" |
PermitTransferFrom(0). | describe | label: "Spender" |
PermitTransferFrom(0). | describe | label: "Nonce" |
PermitTransferFrom(0). | timestamp | unit: "s"epoch: "unix"threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935"message: "Never" |
PermitTransferFrom(0). | describe | label: "Signature expires" |
PermitBatchTransferFrom | intent | title: "Permit2: allow one transfer of several tokens"template: "Let {spender} transfer up to {permitted[].amount} from you, once. Signature expires: {deadline}." |
PermitBatchTransferFrom | effect | kind: "transfer"irreversible: true |
PermitBatchTransferFrom(0). | describe | label: "Token" |
PermitBatchTransferFrom(0). | amount | token: "permitted[].token" |
PermitBatchTransferFrom(0). | describe | label: "Up to" |
PermitBatchTransferFrom(0). | describe | label: "Spender" |
PermitBatchTransferFrom(0). | describe | label: "Nonce" |
PermitBatchTransferFrom(0). | timestamp | unit: "s"epoch: "unix"threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935"message: "Never" |
PermitBatchTransferFrom(0). | describe | label: "Signature expires" |
The thresholds are the largest value of each type: 2160 − 1 for an allowance (uint160),
248 − 1 for an expiration (uint48) and 2256 − 1 for a signature deadline
(uint256). The two SignatureTransfer amounts have no threshold.
PermitSingle's grant ends at the latest of details.expiration and sigDeadline,
and PermitTransferFrom's at its deadline; each grant's token is the one its message names.
fact (Uniswap permit2 at main, read 2026-10-30,
src/libraries/Allowance.sol, as spec §15.9 records) 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 batch messages carry no grant: a grant covers one token.
DAI permit#
experimental DAI's permit on Ethereum mainnet, older than EIP-2612 and
different from it, as a contract profile (origin: "third_party"). File:
profile/packs/dai-permit.profile.json.
Only the message is described: DAI's functions are ERC-20's, and the ERC-20 pack covers them. It
is bound to the domain Dai Stablecoin version 1 and to 0x6B17…1d0F on chain 1
(fact MCD_DAI in the Sky chainlog, read 2026-10-28, as spec §15.9 records).
fact (MakerDAO dss/src/dai.sol, as spec §15.9 records) An expiry of 0 never
expires, allowed sets the allowance to uint(-1) or 0, and PERMIT_TYPEHASH is
0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb, which the resolver's
hash matches. So expiry reads Never at 0 (a label) and at 2256 − 1 (the
threshold). DAI's Permit shares its name with EIP-2612's and not its type hash, and a request is matched
by the hash. allowed reads through the template; a case-like reading of true and
false is open item O12. The grant's amount is allowed itself: true grants an
unlimited allowance and false grants nothing; expiry bounds only the signature, so the
grant never ends (spec §5.3).
profile_id: profile_idcandid-profile:v0:sha256:942de20c5e311b6cd32b02c80c6aec2a80eeee50f9bcc5846120d23fac690dc8- checked against (its struct type): interface_idcandid-profile:evm-abi:v0:sha256:3ed4a3aea8a76d5194413a0196ab608414c8bf9687592f591d8ecffb53255b46
- validator: 0 error, 0 warning, 0 info; 1 method (1 message), 8 annotations
Message#
| Message and effect | encode_type and type_hash |
|---|---|
Permitapprove | Permit(address holder,address spender,uint256 nonce,uint256 expiry,bool allowed)0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb |
Its binds is quoted whole on Profile and resolved form, under
binds.eip712.
Annotations#
| Address | Tag | Args |
|---|---|---|
Permit | intent | title: "Permit DAI spending"template: "Set the DAI allowance {holder} gives {spender} to all of it if allowed is true, to none if false (allowed: {allowed}). Signature expires: {expiry}." |
Permit | effect | kind: "approve"irreversible: falsegrant: { "spender": "spender", "amount": "allowed" } |
Permit(0). | describe | label: "Holder" |
Permit(0). | describe | label: "Spender" |
Permit(0). | describe | label: "Allowed (true: all, false: none)" |
Permit(0). | timestamp | unit: "s"epoch: "unix"threshold: "115792089237316195423570985008687907853269984665640564039457584007913129639935"message: "Never"labels: { "0": "Never" } |
Permit(0). | describe | label: "Signature expires" |
Permit(0). | describe | label: "Nonce" |