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..2d503a3c 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. 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 @@ -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,87 @@ 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 policy engine compares +the following values case-insensitively: + +- `eth.tx.to` and `eth.tx.from` +- 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 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 +[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 +689,23 @@ 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.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. + + + 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