From d37938120721e056a1e04517dfc87832ff0277bb Mon Sep 17 00:00:00 2001 From: Andrew Min Date: Wed, 5 Aug 2026 22:26:37 -0400 Subject: [PATCH 1/2] docs(policies): document address comparison case sensitivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wallet_account.address` is compared exactly, including case, while `eth.tx.to` / `eth.tx.from` and several other transaction fields are compared case-insensitively. Turnkey stores EVM wallet account addresses in EIP-55 checksummed form, so a condition written with an all-lowercase EVM address silently never matches — it stops granting on an EFFECT_ALLOW policy, and stops blocking on an EFFECT_DENY policy. Nothing in the docs said so, and the EIP-712 guidance actively pointed the other way: it told readers to make all hex-encoded strings lowercase. That advice is correct for `eth.eip_712` and `eth.tx` fields and wrong for `wallet_account.address`, so it is now explicitly scoped. Adds a Case sensitivity section covering the exhaustive list of case-insensitive fields, the stored casing of each address format, and the reason base58 and base64url addresses (Solana, Tron, Dogecoin, XRP, Bitcoin P2PKH/P2SH, TON) are compared case-sensitively — for those encodings, two strings differing only in case are two different addresses. Also documents `CaseInsensitiveString()`, which had no coverage anywhere in the docs despite being available in the grammar. It is documented in the wrap-both-sides form, which behaves identically before and after the engine change in tkhq/mono, so this page does not depend on that landing. Co-Authored-By: Claude Opus 5 --- features/policies/examples/access-control.mdx | 2 + .../policies/examples/signing-control.mdx | 2 + features/policies/examples/tempo.mdx | 2 + features/policies/language.mdx | 106 +++++++++++++++++- 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/features/policies/examples/access-control.mdx b/features/policies/examples/access-control.mdx index dcbb91af..e5b31d63 100644 --- a/features/policies/examples/access-control.mdx +++ b/features/policies/examples/access-control.mdx @@ -156,6 +156,8 @@ This policy can be used to say, only passkeys are allowed to sign transactions a #### Allow exporting only a specific wallet account address +Note: `wallet_account.address` is matched exactly, including case. Substitute `` with the address exactly as the API returns it (for EVM accounts that is the EIP-55 checksummed, mixed-case form) rather than lowercasing it. See [Case sensitivity](/features/policies/language#case-sensitivity). + ```json JSON { "policyName": "Allow exporting only wallet account ", diff --git a/features/policies/examples/signing-control.mdx b/features/policies/examples/signing-control.mdx index 3d659273..80719412 100644 --- a/features/policies/examples/signing-control.mdx +++ b/features/policies/examples/signing-control.mdx @@ -17,6 +17,8 @@ sidebarTitle: "Signing control" #### Allow a specific user to sign transactions with a specific wallet account address +Note: `wallet_account.address` is matched exactly, including case. Substitute `` with the address exactly as the API returns it (for EVM accounts that is the EIP-55 checksummed, mixed-case form) rather than lowercasing it. See [Case sensitivity](/features/policies/language#case-sensitivity). + ```json { "policyName": "Allow to sign transactions with ", diff --git a/features/policies/examples/tempo.mdx b/features/policies/examples/tempo.mdx index bb1bfd23..b9d61c1b 100644 --- a/features/policies/examples/tempo.mdx +++ b/features/policies/examples/tempo.mdx @@ -10,6 +10,8 @@ Note: see the [language section](/features/policies/language#tempo) for more det Tempo transactions are fully parsed by the policy engine via the `tempo.tx` namespace. Tempo natively supports batched calls — a single transaction can contain multiple calls that execute atomically. The `tempo.tx.calls` list gives you granular access to each call's destination, input data, and function selector. +Note: several examples below scope a policy with `wallet_account.address`, which is matched exactly, including case. Substitute `` with the address exactly as the API returns it (the EIP-55 checksummed, mixed-case form) rather than lowercasing it. See [Case sensitivity](/features/policies/language#case-sensitivity). + ## Transaction-level policies #### Allow Tempo transactions diff --git a/features/policies/language.mdx b/features/policies/language.mdx index 38164aed..a04d917d 100644 --- a/features/policies/language.mdx +++ b/features/policies/language.mdx @@ -28,6 +28,11 @@ operations: | function | x.contains(\) | "\[1,2,3].contains(1)" | (list\) -> bool | | function | x.count() | "\[1,2,3].count()" | (list\) -> int | | function | x.filter(item, \) | "\[1,2,3].filter(x, x == 1)" | (list\) -> (list\) | +| function | CaseInsensitiveString(x) | "CaseInsensitiveString('A')" | (string) -> string | + +String comparisons are case-sensitive unless stated otherwise. `CaseInsensitiveString` opts a +comparison out of that; see [Case sensitivity](#case-sensitivity) for details and for which fields +are already compared case-insensitively. ## Keywords @@ -95,7 +100,7 @@ The language is strongly typed which makes policies easy to author and maintain. | | imported | bool | Boolean indicating whether or not this wallet has been imported | | | exported | bool | Boolean indicating whether or not this wallet has been exported | | | label | string | The label of this wallet | -| **Wallet Account** | address | string | The wallet account address | +| **Wallet Account** | address | string | The wallet account address. Compared exactly, including case (uppercase and lowercase are not interchangeable) — see [Case sensitivity](#case-sensitivity) | | **PrivateKey** | id | string | The identifier of the private key | | | tags | list\ | The collection of tags for the private key | | | imported | bool | Boolean indicating whether or not this private key has been imported | @@ -159,6 +164,12 @@ The language is strongly typed which makes policies easy to author and maintain. Primitives section.{" "} + + `wallet_account.address` is matched **exactly, including case**, while `eth.tx.to` and + `eth.tx.from` are matched case-insensitively. Substitute the address exactly as the API returns it + rather than normalizing its casing yourself. See [Case sensitivity](#case-sensitivity). + + #### Nested structs | Struct | Field | Type | Description | @@ -499,6 +510,82 @@ wallet clause will fail (because a wallet isn't being passed into the policy eva Conversely, if you're trying to sign with a wallet, the private key clause will fail for the same reason. +### Case sensitivity + +String comparisons in the policy language are case-sensitive by default. The complete set of fields +compared case-insensitively is: + +- `eth.tx.to` and `eth.tx.from` +- `eth.eip_712.domain.verifying_contract`, and every string value within `eth.eip_712.message` +- `tempo.tx.from` and `tempo.tx.fee_token` +- `tempo.tx.calls[i].to` and `tempo.tx.calls[i].input` +- `address_type` on `bitcoin.tx.outputs` entries — the output's address derivation type, not an + address + +A literal compared against one of those fields matches in any casing. Every other string field is +compared exactly, including case. + +`wallet_account.address` is one of those exact-match fields: it is compared against the address as +Turnkey stores it. For EVM accounts Turnkey stores the +[EIP-55](https://eips.ethereum.org/EIPS/eip-55) checksummed (mixed-case) form, so a condition +written with an all-lowercase EVM address does not match. + + + `wallet_account.address` is matched **exactly, including case**, while `eth.tx.to` and + `eth.tx.from` are matched case-insensitively. This asymmetry matters: a `wallet_account.address` + literal whose casing does not match the stored address simply evaluates to `false`, which changes + what the policy does without producing an error. On an `EFFECT_ALLOW` policy the clause stops + granting access; on an `EFFECT_DENY` policy it stops blocking. Copy the address exactly as the API + returns it — for example the `address` field from + [List wallets accounts](/api-reference/queries/list-wallets-accounts) — instead of normalizing the + casing by hand. + + +For most non-EVM formats, exact matching is required for correctness rather than being a convention: +base58 (Solana, Tron, Dogecoin, XRP, and Bitcoin P2PKH/P2SH) and base64url (TON) use +case-significant alphabets, so two strings that differ only in case are two different addresses. As +the list above shows, no base58 or base64url address field is compared case-insensitively anywhere +in the policy engine, which is the correct behavior for those encodings. + +The casing Turnkey stores for each address format: + +| Address format | Stored casing | Notes | +| --------------------------------------------------- | ------------------------------- | --------------------------- | +| Ethereum / EVM | Mixed case (EIP-55 checksummed) | Do not lowercase | +| Solana, Tron, Dogecoin, XRP, Bitcoin P2PKH and P2SH | Mixed case (base58) | Case is part of the address | +| TON | Mixed case (base64url) | Case is part of the address | +| Sui, Aptos | Lowercase (hex) | | +| Cosmos, Sei, Spark, Bitcoin P2WPKH, P2WSH and P2TR | Lowercase (bech32 / bech32m) | | +| XLM | Uppercase (base32) | | + +#### CaseInsensitiveString + +`CaseInsensitiveString()` lowercases a string value. Wrap **both sides** of a comparison with +it to match two strings regardless of casing: + +```json +{ + "policyName": "Allow signing with a specific wallet account", + "effect": "EFFECT_ALLOW", + "condition": "activity.action == 'SIGN' && CaseInsensitiveString(wallet_account.address) == CaseInsensitiveString('0x40f008f4c17075efca092ae650655f6693aeced0')" +} +``` + +Written this way, the clause matches whichever casing you supply for the address literal. Without +the wrappers, the same condition has to spell the address in exactly its stored EIP-55 form, +`'0x40f008f4c17075EFcA092aE650655f6693AECEd0'`, for the clause to match. + +`CaseInsensitiveString` is intended for case-sensitive string fields such as +`wallet_account.address`. `eth.tx.to` and `eth.tx.from` do not need it, because they are already +matched case-insensitively. + + + Prefer `CaseInsensitiveString` for hex-based formats such as EVM addresses, where casing encodes a + checksum rather than a different address. Avoid it for base58 and base64url formats (Solana, Tron, + Dogecoin, XRP, Bitcoin P2PKH/P2SH, TON), where it would treat addresses that differ only in case + as equal even though they are distinct addresses. + + ### Activity parameters The `activity.params` field exposes specific request parameters based on the activity type. An @@ -597,11 +684,18 @@ See the [Ethereum policy examples](/features/policies/examples/ethereum) for sam #### EIP-712 -Our policy engine supports EIP-712 typed data signing (accessible via `eth.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. +Our policy engine supports EIP-712 typed data signing (accessible via `eth.eip_712`). When writing +conditions over `eth.eip_712` fields, please ensure that all hex-encoded strings you compare against +(e.g. addresses, function selectors, bytes) are **lowercase**. The policy engine expects lowercase +hex strings in these fields 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. + + + This lowercase convention is scoped to `eth.eip_712` and `eth.tx` fields. It does **not** apply to + `wallet_account.address`, which is compared exactly as stored: lowercasing an EVM address there + produces a clause that never matches. See [Case sensitivity](#case-sensitivity). + ### Solana From 2309a34d39ead391d6dc7f0b78566bc75ebd8f8a Mon Sep 17 00:00:00 2001 From: Andrew Min Date: Thu, 6 Aug 2026 17:03:22 -0400 Subject: [PATCH 2/2] docs(policies): clarify case-insensitive comparison behavior --- features/policies/language.mdx | 42 +++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/features/policies/language.mdx b/features/policies/language.mdx index a04d917d..2d503a3c 100644 --- a/features/policies/language.mdx +++ b/features/policies/language.mdx @@ -30,9 +30,9 @@ operations: | function | x.filter(item, \) | "\[1,2,3].filter(x, x == 1)" | (list\) -> (list\) | | function | CaseInsensitiveString(x) | "CaseInsensitiveString('A')" | (string) -> string | -String comparisons are case-sensitive unless stated otherwise. `CaseInsensitiveString` opts a -comparison out of that; see [Case sensitivity](#case-sensitivity) for details and for which fields -are already compared case-insensitively. +String comparisons are case-sensitive unless stated otherwise. Apply `CaseInsensitiveString` to +both operands to compare their lowercase forms. See [Case sensitivity](#case-sensitivity) for +details and for values that are already compared case-insensitively. ## Keywords @@ -512,18 +512,23 @@ reason. ### Case sensitivity -String comparisons in the policy language are case-sensitive by default. The complete set of fields -compared case-insensitively is: +String comparisons in the policy language are case-sensitive by default. The policy engine compares +the following values case-insensitively: - `eth.tx.to` and `eth.tx.from` -- `eth.eip_712.domain.verifying_contract`, and every string value within `eth.eip_712.message` +- String values in `eth.tx.contract_call_args` that parse as Ethereum addresses, including values + nested in lists and structs +- `eth.eip_712.domain.verifying_contract` +- Values in `eth.eip_712.message` whose declared Solidity type is `address`, `bytes`, or + `bytes1` through `bytes32`, including values nested in arrays and structs - `tempo.tx.from` and `tempo.tx.fee_token` - `tempo.tx.calls[i].to` and `tempo.tx.calls[i].input` - `address_type` on `bitcoin.tx.outputs` entries — the output's address derivation type, not an address -A literal compared against one of those fields matches in any casing. Every other string field is -compared exactly, including case. +A literal compared against one of those values matches in any casing. Every other string value is +compared exactly, including case. In particular, a Solidity `string` within `eth.eip_712.message` +remains case-sensitive, even when its contents look like a hexadecimal value. `wallet_account.address` is one of those exact-match fields: it is compared against the address as Turnkey stores it. For EVM accounts Turnkey stores the @@ -685,16 +690,21 @@ See the [Ethereum policy examples](/features/policies/examples/ethereum) for sam #### EIP-712 Our policy engine supports EIP-712 typed data signing (accessible via `eth.eip_712`). When writing -conditions over `eth.eip_712` fields, please ensure that all hex-encoded strings you compare against -(e.g. addresses, function selectors, bytes) are **lowercase**. The policy engine expects lowercase -hex strings in these fields 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. +conditions over `eth.eip_712.message`, comparison semantics follow the Solidity type declared in +the typed-data schema: + +- `address`, `bytes`, and `bytes1` through `bytes32` values compare case-insensitively, including + values nested in arrays and structs. +- `string` values compare exactly, including case. The policy engine does not normalize all EIP-712 + message strings to lowercase. + +`eth.eip_712.domain.verifying_contract` also compares case-insensitively. Literals for these +case-insensitive fields can use uppercase, lowercase, or checksummed hexadecimal characters. - This lowercase convention is scoped to `eth.eip_712` and `eth.tx` fields. It does **not** apply to - `wallet_account.address`, which is compared exactly as stored: lowercasing an EVM address there - produces a clause that never matches. See [Case sensitivity](#case-sensitivity). + These EIP-712 rules do **not** apply to `wallet_account.address`, which is compared exactly as + stored. Lowercasing an EVM address there produces a clause that never matches. See + [Case sensitivity](#case-sensitivity). ### Solana