Grammar
The grammar has been designed for flexibility and expressiveness. We currently support the following operations:Operator precedence
For example, suppose that you want to match any transaction to address<A>, OR a zero-value
transaction to address <B>. The following condition does not match these transactions.(eth.tx.to == '<A>' || eth.tx.to == '<B>') && eth.tx.value == 0. As a result, the condition
matches only zero-value transactions. A non-zero transaction to <A> does not match.To match the transactions that you want, use parentheses:- The policy engine groups comparisons before
&&and||. For example,a == 1 && b == 2means(a == 1) && (b == 2). !and-apply only to the single operand immediately after them, so!x == ymeans(!x) == y. To negate a larger expression, use parentheses:!(x == y).- The policy engine always evaluates both operands of
&&and||, even when the left operand already decides the result. See Policy evaluation.
Keywords
Keywords are reserved words that are dynamically interchanged for real values at evaluation time. Each field supports a different set of keywords.Consensus
Condition
Time
The optional top-level
time field gates when a policy is active. See
Time-based policies for the authoring guide, and
Time expressions below for the Timestamp and CronSpan reference.The <tz> argument of CronSpan is an IANA time zone name (e.g. America/New_York) — see the
IANA time zone list for the full set of names.Types
The language is strongly typed which makes policies easy to author and maintain.Primitive
Struct
The
ContractArgument type, used in documentation for ABI and IDL arguments represents an enum
indicating this type could be any one of the string, number, array or struct types listed in our
Primitives section. Nested structs
Time expressions
Two functions are available when authoring the top-leveltime field. This section is the reference
for their signatures and constraints; see
Time-based policies for the model behind them and worked
examples.Timestamp
Timestamp('<rfc3339>') constructs a timestamp from an RFC 3339 string. Timestamps must have a
zero UTC offset: the evaluator parses RFC 3339 and accepts any literal whose offset is zero —
Z, z, +00:00, or -00:00. Non-zero offsets (e.g. -05:00) are rejected.timestamp values support the comparison operators (<, >, <=, >=, ==, !=), so they can
be compared against time.now to bound when a policy is active:CronSpan
CronSpan('<cron>', '<duration>', '<tz>') returns true when time.now falls inside any window
opened by the schedule. See
Active time spans for the
fire-plus-duration model and its daylight-saving behavior.Cron expressions use a strict 5-field subset:
minute hour day-of-month month day-of-week,
where day-of-week is numeric 0-6 and 0 is Sunday. Numeric fields, ranges (1-5), lists
(1,3,5), and * are supported. The following are not supported: step values (*/n), macros
(@daily, @hourly), month and day names (JAN, MON), and a seconds field.Day-of-month and day-of-week are OR’d, not AND’d: when both fields are restricted, the cron
fires when either matches, and this subset has no modifier to force AND semantics. This can broaden
the window in which a policy is active — see
Active time spans for details.Durations use a custom whole-number format composed of days, hours, and minutes (d, h, m)
— for example 8h, 90m, or 1d12h. Fractional values and a seconds component are not allowed,
and the total duration must be greater than zero and no more than 7 days.The <tz> argument accepts any IANA time zone name — see the
IANA time zone list for the full set of names.Activity breakdown
Activity resources and actions
Validactivity.resource values are AUTH, AUTH_PROXY, CONFIG, CREDENTIAL,
FIAT_TRANSACTION, INVITATION, MFA_POLICY, ON_RAMP_CREDENTIALS, ORGANIZATION, OTP,
PAYMENT_METHOD, POLICY, PRIVATE_KEY, RECOVERY, SESSION_PROFILE,
SMART_CONTRACT_INTERFACE, SPONSORSHIP, SUBSCRIPTION, TVC_APP, TVC_DEPLOYMENT,
TVC_OPERATOR, TVC_QUORUM_KEY, USER, WALLET, and WEBHOOK_ENDPOINT.Valid activity.action values are CREATE, UPDATE, DELETE, SIGN, EXPORT, IMPORT,
VERIFY, and ENCRYPT.Activity types by resource and action
** In the Activity breakdown table above,
** marks legacy features deprecated in the latest
SDKs. This note applies only to that table.Activity kinds
activity.kind groups all versions of an activity under one version-agnostic value. The table below
lists each kind and the activity types it matches.The kinds for root quorum activities can be matched by MFA policy
conditions and session profile scopes, but a policy can’t allow those activities.
Prefer
activity.kind over activity.type when you want a policy to apply across all versions of
an activity. activity.type targets one exact version and will not match newer versions
introduced later.Choosing between type, kind, and resource + action
There are three ways to target activities in a policy condition. Pick based on how broad you want
the match to be:Rule of thumb: use
activity.kind to target a single activity (version-agnostic), and
activity.resource + activity.action to target a broad category. Reach for activity.type only
when you deliberately need one exact version.Evaluation errors and short-circuiting
A policycondition causes an evaluation error when it uses a keyword or field that the activity
does not populate. For example, eth.eip_7702_authorization on ACTIVITY_TYPE_SIGN_TRANSACTION_V2,
or an activity parameter that the activity does not expose.If a policy’s condition causes an evaluation error, the policy is skipped:- An
ALLOWpolicy with an error does not allow the activity. - A
DENYpolicy with an error does not deny the activity, so anotherALLOWpolicy can still allow it.
Short-circuiting
Note that our policy engine does not short-circuit during evaluation. Specifically, the policy engine evaluates every clause of a condition, because&& and || do not
short-circuit. If any clause causes an error, the entire policy will error, even if another passes.For example, suppose you have a policy with the condition as wallet.id == '<WALLET_ID>' || private_key.id == '<PRIVATE_KEY_ID>'. This will, by construction,
always cause an error, because a signing activity can use a wallet or a private key, but not both: when you sign with a
private key, the wallet.id clause causes an error. When you sign with a wallet, the
private_key.id clause causes an error. Short-circuiting in this scenario means that if the first clause passes, the second clause would not be evaluated, thereby preventing such an error — our policy engine does not behave in this way.Example: one policy for two activities
This policy tries to allow two things: signing Type 2 transactions, and signing EIP-7702 authorizations for one trusted delegate contract:- For
ACTIVITY_TYPE_SIGN_TRANSACTION_V2, the policy engine also evaluateseth.eip_7702_authorization.address, which this activity does not populate. - For
ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2, the policy engine also evaluateseth.tx.type, which this activity does not populate.
Guidelines
- Keep related clauses for one activity, such as the
addressandchain_idlimits of one allowlist, in one policy joined with&&. - Do not join clauses for different activities with
||. Write a separate policy for each activity. - A
DENYpolicy that uses a keyword that only some activities populate has no effect on the other activities. For example, aDENYpolicy with the conditioneth.eip_7702_authorization.chain_id == 0does not deny anything thatACTIVITY_TYPE_SIGN_RAW_PAYLOADSsigns.
Appendix
Activity parameters
Theactivity.params field exposes specific request parameters based on the activity type. An
activity may have API request parameters without exposing them through activity.params; only the
fields listed below are available to policy conditions.Absence from this table does not mean that an activity is unsupported or cannot be targeted with
activity.type, activity.kind, or activity.resource and activity.action. It means that the
policy evaluator exposes no request fields for that activity. Attempting to access an unavailable
field produces a MissingField evaluation error. See Evaluation errors and short-circuiting for more details. To request an additional policy-exposed parameter,
contact help@turnkey.com.For swap and earn policies, prefer
activity.kind
(for example CREATE_SWAP_QUOTE, EXECUTE_SWAP, EARN_DEPOSIT) when you want version-stable
conditions. Use activity.type when you must pin a specific API version—for example,
destination_address is available on ACTIVITY_TYPE_CREATE_SWAP_QUOTE_V2 and
ACTIVITY_TYPE_EXECUTE_SWAP_V3, not on the earlier quote or execute activity types.
Swap and Earn numeric params (input_amount, slippage_bps, assets, client_fee_bps, and
similar) compare as uint values; empty or non-decimal strings fail evaluation with a
parse error rather than matching a cap as zero. Earn amount_value stays a string
because withdrawals may use the literal MAX.
See the Swaps and Earn policy examples for allowlists,
sponsorship controls, slippage caps, and administrative Earn rules.Examples
To deny signing raw payloads with the NO_OP hash function (except for EIP-712):Root quorum activities
There are a select few activities that are not governed by policies, but rather by an organization’s root quorum. These activities are:ACTIVITY_TYPE_UPDATE_ROOT_QUORUM, ACTIVITY_TYPE_SET_ORGANIZATION_FEATURE,
ACTIVITY_TYPE_REMOVE_ORGANIZATION_FEATURE, ACTIVITY_TYPE_UPDATE_ORGANIZATION_NAME. For example,
if a policy is added that allows a specific non-root user to perform
ACTIVITY_TYPE_SET_ORGANIZATION_FEATURE activities, these requests will still fail as they are
subject specifically to root quorum.Policies can’t allow these activities, but MFA policy
conditions and session profile scopes still apply to
them. Both can reference:activity.typeandactivity.kindactivity.resource, which isORGANIZATIONactivity.action, which isUPDATE
activity.params. A condition that references activity.params.* can’t be
evaluated for them: an MFA policy that uses one is skipped, and a session profile scope that uses one
denies the request.A session profile scope only narrows what a session can do. A request whose scope matches still needs
root quorum approval.Activities that target a root user
Users outside the root quorum can’t modify users in the root quorum. If a non-root user submits any of the activities below against a root quorum user, the request is denied before any policy is evaluated, so no policy can allow it:ACTIVITY_TYPE_CREATE_AUTHENTICATORS_V2,ACTIVITY_TYPE_DELETE_AUTHENTICATORSACTIVITY_TYPE_CREATE_API_KEYS,ACTIVITY_TYPE_CREATE_API_KEYS_V2,ACTIVITY_TYPE_DELETE_API_KEYSACTIVITY_TYPE_CREATE_OAUTH_PROVIDERS,ACTIVITY_TYPE_CREATE_OAUTH_PROVIDERS_V2,ACTIVITY_TYPE_DELETE_OAUTH_PROVIDERSACTIVITY_TYPE_UPDATE_USER,ACTIVITY_TYPE_UPDATE_USER_NAME,ACTIVITY_TYPE_UPDATE_USER_EMAIL,ACTIVITY_TYPE_UPDATE_USER_PHONE_NUMBERACTIVITY_TYPE_DELETE_USERS, when any of the users being deleted is a root user
CREATE_AUTHENTICATORS.Activities a user can perform on themselves
A user can manage their own authenticators, API keys and MFA policies without a policy granting it. When no policy reaches a decision, the engine allows the activities below as long as the user submitting the request is targeting themselves.ACTIVITY_TYPE_CREATE_AUTHENTICATORS_V2,ACTIVITY_TYPE_DELETE_AUTHENTICATORSACTIVITY_TYPE_CREATE_API_KEYS_V2,ACTIVITY_TYPE_DELETE_API_KEYSACTIVITY_TYPE_RECOVER_USERACTIVITY_TYPE_CREATE_MFA_POLICY,ACTIVITY_TYPE_UPDATE_MFA_POLICY,ACTIVITY_TYPE_DELETE_MFA_POLICYACTIVITY_TYPE_CREATE_READ_ONLY_SESSION, for any requesting user, since it only creates a session for that user
- No policy produced a decision. For non-root users an explicit allow or deny stands, so a policy
that denies
ACTIVITY_TYPE_CREATE_API_KEYS_V2denies it for the user’s own keys too. - The request targets the organization it is submitted to. A parent organization user acting on a sub-organization user is not allowed by default and needs a policy.
- The requesting user’s MFA policy, if any, is satisfied. Otherwise the activity requires additional authenticators before it runs.
- The request did not already meet the root quorum. A request that does is allowed before policies are evaluated, so root users rarely need the allowance. A root user below the threshold can still act on their own credentials through it. For anything else, a root user below the threshold acts alone only when a policy allows it. Any other outcome for a root user, including an explicit deny, becomes a request for quorum approval rather than a denial.
CREATE_AUTHENTICATORS_V2 and the API key activities to a fixed user
with activity.params.user_id, but neither can refer to the user submitting the request. The
approvers and credentials keywords identify the requester and are not available in a condition
or scope, and a consensus expression cannot read activity.params. A session scoped only to the
activity kind therefore adds authenticators to the session holder alone, as long as no policy
allows the activity for other users and the request does not meet the root quorum.Wallet and private key import activities and
ACTIVITY_TYPE_CREATE_READ_WRITE_SESSION must
always target the requesting user. A request for another user is denied before policies are
evaluated, and no policy can allow it.Ethereum
Our Ethereum policy language (accessible viaeth.tx) allows for the granular governance of signing
Ethereum (EVM-compatible) transactions. Our policy engine exposes a
fairly standard set of properties
belonging to a transaction.See the Ethereum policy examples for sample scenarios. The policy engine’s
int type is limited to 128 bits (i128). Ethereum smart contracts support
signed integers up to 256 bits (int256), but values exceeding the 128-bit signed range cannot be
used in policy conditions. EIP-712
Our policy engine supports EIP-712 typed data signing (accessible viaeth.eip_712). When defining
policies for EIP-712 messages, please ensure that all hex-encoded strings (e.g. addresses, function
selectors, bytes) are lowercase. The policy engine expects lowercase hex strings to conform to
Ethereum’s standard convention. Using uppercase hex strings may result in errors or policy
rejection. Note that EIP-712 messages passed in as transactions will be normalized to lowercase.EIP-7702
eth.eip_7702_authorization is an EIP-7702 authorization that the signing key signs as a message.
It does not describe the authorization list of a Type 4 transaction. Policies on transactions use
eth.tx (see Type 4 transactions). The policy engine populates
eth.eip_7702_authorization only for these activities:ACTIVITY_TYPE_SIGN_RAW_PAYLOADandACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2. The policy engine reads the payload as an authorization for all encodings, so anALLOWpolicy must also containactivity.params.encoding == 'PAYLOAD_ENCODING_EIP7702_AUTHORIZATION'. Without this check, the policy also allows EIP-712 typed data, such as a token permit, that containsaddress,chainId, andnoncefields.ACTIVITY_TYPE_ETH_SEND_TRANSACTIONandACTIVITY_TYPE_ETH_SEND_TRANSACTION_V2, when Turnkey builds an authorization to its gas delegate contract. This occurs for sponsored transactions and for non-sponsoredACTIVITY_TYPE_ETH_SEND_TRANSACTION_V2requests with more than one call, when the account is not yet delegated. A request without an authorization causes an evaluation error, so do not requireeth.eip_7702_authorizationinALLOWpolicies for these activities.ACTIVITY_TYPE_ETH_UNDELEGATE_7702.
eth.eip_7702_authorizations keyword — if you attempt to create a policy with it, it’ll get rejected.What a signed authorization allows
Anyone who has a signed authorization can send it in a Type 4 transaction from any account, with no other signature from the key. The network gets the delegated account from the authorization signature, not from the sender. After that, calls to the account run the code ataddress until
another authorization replaces it. If that code does not verify the caller, anyone can move the
funds of the account. Write the ALLOW policy as an allowlist:address: Only delegate contracts that you trust, with==orin. A list of bad contracts in aDENYpolicy does not work, because an attacker can deploy a new contract.chain_id: The chains that you use. An authorization withchain_id0 is valid on all EVM chains.nonce: An authorization is usable only when the account nonce is equal to itsnonce, so an authorization with a future nonce stays usable until the account nonce is more than itsnonce.
address in the EIP-55 checksummed (mixed-case) form, and the comparison is
case-sensitive. Write each address in your policy in the checksummed form. An address in another
case never matches, so an ALLOW policy does not allow the authorization and a DENY policy does not
deny it.Raw payload signing with other encodings can also sign an authorization. A
PAYLOAD_ENCODING_HEXADECIMAL payload can be the authorization hash (HASH_FUNCTION_NO_OP) or the
authorization message (HASH_FUNCTION_KECCAK256). A PAYLOAD_ENCODING_TEXT_UTF8 payload can be the
authorization hash if the delegate address makes that hash valid UTF-8. These payloads do not parse
as authorizations, so eth.eip_7702_authorization causes an evaluation error, and a limit on
activity.params.hash_function does not stop this. An ALLOW policy that permits these encodings can sign authorizations.If a key must not sign authorizations, allow raw payload signing only with
activity.params.encoding == 'PAYLOAD_ENCODING_EIP712'. Do not deny encodings one at a time. This
also blocks signMessage in @turnkey/viem and @turnkey/ethers, which signs
PAYLOAD_ENCODING_HEXADECIMAL payloads with HASH_FUNCTION_NO_OP. No policy can stop a key that
signs these payloads from signing an authorization. If you need both, use separate keys. To let
a key sign only the authorizations that your policy allows, also allow
PAYLOAD_ENCODING_EIP7702_AUTHORIZATION, but only for ACTIVITY_TYPE_SIGN_RAW_PAYLOAD and
ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2. Also, ACTIVITY_TYPE_ETH_SEND_TRANSACTION(_V2) requests that
include an authorization sign one to the Turnkey gas delegate contract, and
ACTIVITY_TYPE_ETH_UNDELEGATE_7702 signs one to the zero address.For examples, see
Allow signing of EIP-7702 authorizations.Type 4 transactions
ACTIVITY_TYPE_SIGN_TRANSACTION_V2 does not populate eth.eip_7702_authorization, and the policy
engine does not expose the authorization list of a Type 4 transaction. Signing a Type 4 transaction
does not delegate the signer, because each entry in the list has its own signature from its
authority, over a different message format. To control Type 4 transactions, use eth.tx.type. For
more information, see EIP-7702 (type 4) support.Solana
Similarly, our Solana policy language (accessible viasolana.tx) allows for control over signing
Solana transactions. Note that there are some fundamental differences between the architecture of
the two types of transactions, hence the resulting differences in policy structure.Notably, within our policy engine, a Solana transaction contains a list of Transfers, currently
corresponding to native SOL transfers. Each transfer within a transaction is considered a separate
entity. Each entity represents only top-level transfers that directly invoke supported System
Program transfer instructions and do not include transfers performed indirectly inside other
programs, or via unsupported System Program instructions.Similarly, the policy engine exposes solana.tx.spl_transfers, which contains only top-level SPL
transfers using the supported Token and Token-2022 instructions Transfer, TransferChecked, and
TransferCheckedWithFee. SPL transfers performed indirectly inside other programs or via
unsupported Token and Token-2022 instructions are not detected and do not appear in this list.Here are some approaches you might take to govern transfers:- All transfers need to match the policy condition. Useful for allowlists (example)
- Just one transfer needs to match the policy condition. Useful for blocklists (example)
- Only match if there is a single transfer in the transaction, and that transfer meets the criteria (example). This is the most secure approach, and thus most restrictive.
Solana V1 config
See Solana transactions for wire-format requirements.solana.tx.version returns the wire version of unsignedTransaction as a case-sensitive string: LEGACY, V0, or V1.
Sponsorship preserves this version. It is independent of the Turnkey activity version.The following fields expose the V1 transaction config in unsignedTransaction, before sponsorship fills missing values:priority_fee_lamports is a total priority fee, not a per-CU price. It excludes base fees and rent.
An explicit zero remains zero in the policy value.
An absent resource limit is unavailable, rather than zero or a runtime default.
For legacy/V0 transactions, access to any of these four fields produces a FieldNotUsed evaluation error.Account address lookups
V0 transactions can reference onchain address lookup tables for account addresses. Legacy and V1 transactions have no ALTs; theiraddress_table_lookups lists are empty.
Turnkey surfaces any account address pulled from a lookup table as the literal string ADDRESS_TABLE_LOOKUP
in Solana address fields (account_keys, instruction accounts, transfers, and spl_transfers).
The solana.tx.address_table_lookups array indicates when lookups are present, but the specific
addresses are not resolved. If you rely on allowlists or denylists of addresses, add a guard for
this placeholder (for example, require solana.tx.address_table_lookups.count == 0 before comparing
addresses, or explicitly deny when ADDRESS_TABLE_LOOKUP appears with something like
solana.tx.transfers.any(t, t.to == 'ADDRESS_TABLE_LOOKUP')) so that dynamic lookups cannot bypass
or unexpectedly fail your policy. See the
address table lookup examples
for examples on how to enforce this.Program address lookups
Turnkey rejects Solana transactions where program addresses are resolved via address lookup tables. Program IDs must be statically defined in the transaction to enable static analysis of instructions without requiring onchain data lookups. If your transaction references a program via an address table lookup, the signing request will fail. Account addresses (non-program) can still be dynamically resolved via lookup tables as described above.See the Solana policy examples for sample scenarios.Tron
Our Tron policy language (accessible viatron.tx) allows for policy control over signing Tron
transactions. Our policy language supports the standard fields in a Tron transaction:
https://developers.tron.network/docs/tron-protocol-transaction. To reference a Contract within a
Transaction you should use tron.tx.contract[0].field_name in your policy where field_name is some
field of the contract used in your transaction. While Tron only currently supports 1 contract per
transaction this could change in the future, and we’re ready for it if it does! The policy engine
currently supports the following Tron contract types:- TransferContract (TRX transfers)
- TriggerSmartContract (Smart contract, including, but not limited to TRC-20, invocations)
- DelegateResourceContract
- UnDelegateResourceContract
- FreezeBalanceV2Contract
- UnfreezeBalanceV2Contract
- AccountPermissionUpdateContract
Bitcoin
Our Bitcoin policy language (accessible viabitcoin.tx) allows for policy control over signing
Bitcoin transactions.NOTE: While our SIGN TRANSACTION endpoint takes in a Partially Signed Bitcoin Transaction (PSBT)
as required for signing context — our policy language supports only the standard fields inside a
Bitcoin transaction: https://learnmeabitcoin.com/technical/transaction/#structureFor further reference on how Turnkey handles Bitcoin transactions in our policy-enabled transaction
signing flow, check out this section in our
Bitcoin Network Support
page.See the Bitcoin policy examples for sample Bitcoin policies.Spark
Spark-specific activities expose their request data throughactivity.params. The
activity parameter table lists the top-level fields for all four Spark
activities, and the structs below describe the nested values available to policies.Spark optional string fields evaluate to an empty string when omitted. SparkKeyDerivation supports
five variants: identity, signing_leaf, deposit, static_deposit, and htlc_preimage. Only
signing_leaf and static_deposit have variant-specific data, so those are the only variants with
nested fields (see table below).At policy runtime, inactive variant fields evaluate to their type defaults rather than producing an
evaluation error. For example, by default signing_leaf.leaf_id would an empty string, and
static_deposit.index would return 0. These defaults do not indicate that the variant is
active. We’d recommend checking SparkKeyDerivation.type before targeting a variant-specific
field. Note: the type field is an empty string when unset, and 0 is a valid static deposit
index.See the Spark policy examples for sample policies covering
receiver allowlists, FROST key derivations, static deposits, and receive-only delegated access.
Tempo
Our Tempo policy language (accessible viatempo.tx) allows for policy control over signing Tempo
transactions. Tempo transactions natively support batched calls — a single transaction can contain
one or more calls that execute atomically. The tempo.tx.calls list gives you access to each
individual call, including its destination and raw input data.The tempo.tx namespace exposes the following transaction-level fields: chain_id, from,
nonce, gas_limit, max_fee_per_gas, max_priority_fee_per_gas, fee_token, valid_before,
and valid_after. Each call in tempo.tx.calls exposes: to, input, and function_signature.You can also govern Tempo transactions at the activity level using
activity.params.type == 'TRANSACTION_TYPE_TEMPO', combined with other fields such as specific
wallets, private keys, or consensus rules.See the Tempo policy examples for sample scenarios.Secrets
Export secrets requests expose two keywords:secret, the secret being evaluated, and request_context,
the string key-value pairs the request binds to that secret. Each secret in a batch is evaluated
separately, and batch export is all-or-nothing. Read both by key and
compare them with strings:- Reading a key the request does not carry is an evaluation error. An erroring
EFFECT_ALLOWpolicy does not allow, and an erroringEFFECT_DENYpolicy does not deny, so a deny policy onrequest_contextcan be skipped by omitting the key. Prefer allow policies when gating on request context. - An export that repeats a key within one secret’s request context is rejected before policy evaluation.