Before Multi-party Compute signs a transaction, it evaluates the transaction against the transaction policies in your instance. This document describes which policies are evaluated, how a policy moves through its lifecycle, what a policy can check, and why a transaction is denied.
How a transaction is evaluated
A transaction policy is a single Cedar
permit or forbid statement. When a Signer calls CreateTransaction, the
instance decodes the unsigned transaction in Transaction.payload with the
parser for Transaction.schema, and evaluates every enforced policy against
the transaction before any key share is used. A policy is enforced when its
state is ACTIVE or PENDING_DISABLED. For more information, see
Policy lifecycle.
The decision follows three rules:
- Deny by default. A transaction is permitted only if at least one
permitpolicy matches it. With no enforced policies, every transaction is denied. - Forbid wins. If any
forbidpolicy matches, the transaction is denied, even ifpermitpolicies also match. - A policy that fails to evaluate doesn't match. For example, a policy
that reads an optional context attribute that the transaction doesn't have
fails to evaluate. The instance records the error, skips that policy, and
decides with the remaining enforced policies. This applies to
forbidpolicies too, so writeforbidpolicies that can't fail on a missing attribute. For an example, see Scenario 8 and Scenario 9.
A transaction that contains several transfers, such as a Solana transaction with several transfer instructions, is evaluated once for each transfer. The transaction is permitted only if every transfer is permitted. If any transfer is denied or can't be decoded, the whole transaction is denied.
Each evaluation, permit or deny, produces an
evaluation record that is returned on the transaction in
Transaction.policyEvaluationRecord.
The decision controls whether the instance can sign the transaction without waiting for more signers:
PERMIT: if theCreateTransactionrequest already carries enough signer authorizations to meet the KeyRing's key policy, the transaction moves toSIGNINGright away. Otherwise it's created inPENDING_AUTHORIZATIONand waits for more signers.DENY: the instance doesn't sign the transaction automatically. The transaction is created inPENDING_AUTHORIZATIONwithpolicyEvaluationRecord.decisionset toDENY. Signers can review the evaluation record and deny the transaction (RecordSignerDecisionwithDENY) or authorize it anyway (AUTHORIZE). The transaction is signed only if enough signers authorize it to meet the key policy.
A transaction policy is a guardrail on automatic signing, not a block on
signers: signers can authorize a transaction that the policies deny. The
original PolicyEvaluationRecord stays attached to the transaction for
auditing.
Policy lifecycle
Transaction policies can't be changed. TransactionPolicy has no update
method, and its rule is immutable. To change a rule, disable the policy with
DisableTransactionPolicy and create a new policy with
CreateTransactionPolicy.
Activating or disabling a policy needs approvals from a quorum of Policy
Authors (Users with an active POLICY_AUTHOR role in User.roleStatuses).
Each Policy Author signs a TransactionPolicySigningPayload that names the
policy (or, for CreateTransactionPolicy, the instance), the action
(ACTIVATE or DISABLE), and rule.cedarDocument. The request that proposes
a change counts as the first approval. Other Policy Authors approve with
ApproveTransactionPolicy. The number of approvals needed is in
activationInfo.requiredApprovals and deactivationInfo.requiredApprovals. If
the instance needs only one approval, the change takes effect immediately.
The following table describes each policy state and whether policies in that state are enforced:
| State | Meaning | Enforced? |
|---|---|---|
PENDING_ACTIVE |
The policy was proposed and is waiting for Policy Author approvals. | No |
ACTIVE |
The policy has activationInfo.requiredApprovals approvals. |
Yes |
PENDING_DISABLED |
A Policy Author asked to disable the policy, and the request is waiting for approvals. | Yes, until the request has deactivationInfo.requiredApprovals approvals |
DISABLED |
The policy was disabled, or was withdrawn before it became active. Disabled policies are kept for auditing. | No |
A policy moves through these states in one direction:
PENDING_ACTIVEtoACTIVE: the activation has enough approvals.PENDING_ACTIVEtoDISABLED: a Policy Author withdraws the policy withDisableTransactionPolicybefore it becomes active.ACTIVEtoPENDING_DISABLED: a Policy Author callsDisableTransactionPolicy.PENDING_DISABLEDtoDISABLED: the disable request has enough approvals.
A policy enters each pending state at most once. A disabled policy can't be enabled again; create a new policy instead.
For who can call these methods, see User roles.
Policy set version
The policy set is the set of policies that are enforced (ACTIVE or
PENDING_DISABLED) at a point in time. Each time an activation or a disable
completes, the instance gives that transition the next number in a counter for
the instance, and records it in activationInfo.transitionSequence or
deactivationInfo.transitionSequence. That number is the policy set version
produced by the transition.
The following table shows how the policy set version changes over a sequence of events:
| Event | Transition sequence | Enforced policies |
|---|---|---|
| Policy A is activated. | A: activationInfo.transitionSequence = 1 |
A |
| Policy B is activated. | B: activationInfo.transitionSequence = 2 |
A, B |
Policy C is proposed (PENDING_ACTIVE), then withdrawn. |
None | A, B |
| A disable request for policy A is approved. | A: deactivationInfo.transitionSequence = 3 |
B |
Withdrawing a PENDING_ACTIVE policy never changes the set of enforced
policies, so it gets no transition sequence and doesn't change the policy set
version. While a transition is waiting for approvals, its transitionSequence
is unset.
Each evaluation reads the policies, KeyLists, and keys as of the transaction's
createTime, and records the highest completed transition sequence at that
time in policyEvaluationRecord.policySetVersion. If two evaluations have the
same policy set version, they used the same policies.
The Cedar request
A Cedar request has a principal, an action, a resource, and a context. For example:
permit (
principal == User::"alice@example.com",
action == Action::"buy_transaction",
resource == Resource::"transaction_management"
)
when {
context.principal_keyring == "institutional-treasury" &&
context has fiat_amount && context.fiat_amount <= 10000
};
How a User maps to the Cedar principal
The principal is the transaction's initiator: the User whose principal
matches the authenticated identity of the caller of CreateTransaction. That
User must have an active SIGNER role. In Cedar, the principal is a User
entity whose ID is the User's principal field, for example
User::"alice@example.com".
The instance doesn't pass any entity data to Cedar. A User entity has no
attributes and no parents, so:
principal == User::"alice@example.com"matches transactions that Alice initiates.principal in Team::"treasury"never matches. To limit who can sign with a key, use the KeyRing's key policy. To scope a rule to a group of keys, checkcontext.principal_keyring.- A rule that reads an attribute of
principal, such asprincipal.team, fails to evaluate.
A User's principal can't be changed, so a rule that names a principal keeps
referring to the same identity.
Action and resource
action: the value ofTransaction.actionin theCreateTransactionrequest, formatted as a CedarActionentity.CreateTransactionaccepts the following actions:Action Used for Action::"buy_transaction"A transaction that acquires an asset. Action::"sell_transaction"A transaction that disposes of an asset. Action::"internal_transfer_transaction"A transfer of an asset, such as a payment or a sweep between keys. The initiator chooses the action; Multi-party Compute doesn't infer it from the payload. A rule can match one action (
action == Action::"buy_transaction"), a list of actions (action in [Action::"buy_transaction", Action::"sell_transaction"]), or leaveactionunconstrained to match every action.resource: alwaysResource::"transaction_management"for transaction evaluations. A rule can checkresource == Resource::"transaction_management"or leaveresourceunconstrained.
Context attributes
The context holds the attributes that policies check. They come from two places:
- The transaction payload and the signing key. The instance decodes
Transaction.payloadand adds the IDs of the signing key and its KeyRing. These attributes areprincipal_keyring,principal_key,blockchain,schema,secondary_key,amount_token_scaled,token_symbol,contract_address,chain_id,max_fee_atomic,payload, andtime_stamp. For the full table and the behavior for each blockchain, see Write transaction policy rules. - The transaction's
metadatafield, which the initiator sets inCreateTransaction.
The following table describes the metadata attributes:
Transaction.metadata field |
Cedar attribute | Cedar type | Value |
|---|---|---|---|
fiatAmount |
context.fiat_amount |
Long |
The value of the transaction in whole US dollars (google.type.Money). currencyCode must be USD, units must be zero or more, and nanos must be 0. |
validity |
context.validity |
Long |
How long the transaction is valid (google.protobuf.Duration), in whole seconds. Must be more than 0 seconds, with no fractional seconds. |
principalValue1 |
context.principal_value1 |
String |
Any value that you define, up to 256 characters, for example a trading desk or cost center. |
principalValue2 |
context.principal_value2 |
String |
Any value that you define, up to 256 characters, for example an internal ticket or counterparty ID. |
The following rules apply to metadata:
- Unset fields aren't in the context. If a transaction has no
fiatAmount,context.fiat_amountdoesn't exist, and a policy that reads it fails to evaluate. Check for the attribute withhasfirst, as incontext has fiat_amount && context.fiat_amount <= 10000. - Invalid metadata fails the request. If a field breaks the rules in the
preceding table, for example a
fiatAmountinEURor with non-zeronanos, avalidityof 0 seconds, or aprincipalValue1longer than 256 characters,CreateTransactionfails withINVALID_ARGUMENTand no policy is evaluated. - The instance doesn't check metadata against the transaction.
fiatAmountis what the initiator says the transaction is worth. Other signers see the metadata inTransaction.metadataandpolicyEvaluationRecord.evaluationContextbefore they authorize it. To limit on-chain amounts independently of the metadata, checkcontext.amount_token_scaledandcontext.contract_address. - Metadata can't be changed after the transaction is created.
KeyRings
A KeyRing groups keys. Every transaction's context
has principal_keyring and principal_key, so a rule can apply to every key
in a KeyRing, including keys created later, and a KeyRing can be an entry in a
KeyList. For more information and an example rule, see
KeyRings.
KeyLists
A KeyList is a named list of blockchain addresses that policies can refer to, for example as an allowlist of destinations. Each entry is one of the following:
- A manual entry (
manualEntry): an address that the instance doesn't manage, with optionalsourceandreasonfields. - A key in the instance (
key): matches the key's addresses. - A KeyRing in the instance (
keyRing): matches the addresses of every active key in the KeyRing, including keys created after the entry was added.
KeyLists can change after a policy is approved. When you add or remove an entry
with AddKeyListEntries or RemoveKeyListEntries, every policy that refers to
the KeyList uses the new entries from the next evaluation, without new Policy
Author approvals. A KeyList can't be deleted with DeleteKeyList while a
PENDING_ACTIVE, ACTIVE, or PENDING_DISABLED policy refers to it.
KeyLists in the Cedar context
At evaluation time, the instance expands each KeyList into a flat Set of
String blockchain addresses: the manual entries, plus the addresses derived
from every key and KeyRing entry. Each set is passed to Cedar as
context.key_lists["<var>KEY_LIST_ID</var>"], where
KEY_LIST_ID is the {keyList} ID in the KeyList's resource name,
for example trusted-destinations for
projects/my-project/locations/us-central1/instances/my-instance/keyLists/trusted-destinations.
A rule tests membership with the Cedar contains operator against the
decoded destination address, context.secondary_key. For example, to permit
transfers only to addresses in the trusted-destinations KeyList:
// Assuming you created a KeyList with ID "trusted-destinations":
permit (principal, action, resource)
when { context.key_lists["trusted-destinations"].contains(context.secondary_key) };
To deny transfers to any address in the blocked-addresses KeyList:
// Assuming you created a KeyList with ID "blocked-addresses":
forbid (principal, action, resource)
when { context.key_lists["blocked-addresses"].contains(context.secondary_key) };
Keep the following in mind:
- Use bracket syntax, not dot syntax, for the KeyList ID. Cedar's dot
syntax (
context.key_lists.trusted-destinations) fails to parse when the ID contains a hyphen, which Cedar reads as subtraction, or starts with a digit, as a server-generated UUID can. Bracket syntax,context.key_lists["<var>KEY_LIST_ID</var>"], works for every ID. key_listsis always present, and a rule can read a KeyList in it directly.CreateTransactionPolicyverifies that every KeyList that the rule refers to exists, andDeleteKeyListrefuses to delete a KeyList while a policy refers to it. Usecontext.key_lists has "<var>KEY_LIST_ID</var>"only when a rule refers to a KeyList that might not exist yet.- Addresses are compared as strings.
secondary_keyuses the format that Write transaction policy rules describes for each blockchain, for example mixed-case EIP-55 checksum format for Ethereum. Add manual entries in the same format. - A Solana token transfer's
secondary_keyis the destination token account, not the owner's wallet. To allow a wallet, add its token account for each mint that you allow.
Each evaluation resolves KeyList membership as of the transaction's
createTime, the same time as the policies, and records the result in
policyEvaluationRecord.keyListMemberships: the KeyList, the address tested,
whether it was a member, and the entry that matched.
Because a KeyList changes what a policy permits, limit who can change it.
KeyList methods need roles/mpc.admin. For more information, see
IAM permissions.
Deny reasons
When policyEvaluationRecord.decision is DENY,
policyEvaluationRecord.denyReason says why. The following table describes
each deny reason:
| Deny reason | When | Determining policies |
|---|---|---|
FORBID_RULE_MATCHED |
At least one enforced forbid policy matched. |
The forbid policies that matched. |
NO_PERMIT_RULE_MATCHED |
The payload decoded, no forbid policy matched, no policy failed to evaluate, and no permit policy matched. |
None |
EVALUATION_ERROR |
No permit or forbid policy matched, and at least one policy failed to evaluate, for example because it reads an optional attribute that isn't in the context. |
None. evaluationErrors lists the errors. |
UNRECOGNIZED_PAYLOAD |
The instance couldn't decode Transaction.payload or project it into a context, for example because of malformed bytes, an unsupported instruction or contract operation, or a Bitcoin transaction with several recipients. No policy is evaluated. |
None. evaluationErrors lists the parser error. |
The reasons are decided in the following order:
- If the payload can't be decoded or projected into a context, the decision
is
DENYwithUNRECOGNIZED_PAYLOAD. - Otherwise, every enforced policy is evaluated. If any
forbidpolicy matches, the decision isDENYwithFORBID_RULE_MATCHED, even ifpermitpolicies also matched or other policies failed to evaluate. - Otherwise, if at least one
permitpolicy matches, the decision isPERMIT. Errors from other policies are still recorded inevaluationErrors. - Otherwise, the decision is
DENYwithEVALUATION_ERRORif any policy failed to evaluate, orNO_PERMIT_RULE_MATCHEDif every policy evaluated without matching.
Errors are recorded even when the decision is PERMIT, so check the
evaluation record for policies that never match because they always fail.
Scenarios
In Scenarios 1 to 9, Alice (User::"alice@example.com") creates a valid
Ethereum transfer with action set to
Action::"internal_transfer_transaction" and this metadata, unless the
scenario says otherwise:
"metadata": {
"fiatAmount": {"currencyCode": "USD", "units": "5000"},
"principalValue1": "treasury-desk"
}
The transaction has no validity and no principalValue2.
The following table summarizes each scenario:
| Scenario | Enforced policies or payload | Decision | Deny reason |
|---|---|---|---|
| 1 | None | DENY |
NO_PERMIT_RULE_MATCHED |
| 2 | One permit that matches |
PERMIT |
|
| 3 | One permit that doesn't match |
DENY |
NO_PERMIT_RULE_MATCHED |
| 4 | Two permit, one matches |
PERMIT |
|
| 5 | A permit and a forbid that both match |
DENY |
FORBID_RULE_MATCHED |
| 6 | A permit that reads a missing attribute |
DENY |
EVALUATION_ERROR |
| 7 | The same permit, with has |
DENY |
NO_PERMIT_RULE_MATCHED |
| 8 | A forbid that reads a missing attribute, and a permit that matches |
PERMIT |
|
| 9 | A forbid that requires an attribute |
DENY |
FORBID_RULE_MATCHED |
| 10 | A payload that can't be decoded | DENY |
UNRECOGNIZED_PAYLOAD |
Scenario 1: No enforced policies
The instance has no ACTIVE or PENDING_DISABLED policies.
Decision: DENY, NO_PERMIT_RULE_MATCHED. Without a matching permit
policy, every transaction is denied. In a new instance, no transaction is
signed automatically until Policy Authors activate a permit policy; signers
must authorize each one.
Scenario 2: One permit that matches
permit (principal, action, resource)
when { context has fiat_amount && context.fiat_amount <= 10000 };
Decision: PERMIT. The amount, 5,000, is at most 10,000.
determiningPolicyIds lists this policy.
Scenario 3: One permit that doesn't match
permit (principal, action, resource)
when { context has fiat_amount && context.fiat_amount <= 1000 };
Decision: DENY, NO_PERMIT_RULE_MATCHED. The amount, 5,000, is more than
1,000, and no other policy permits the transaction.
Scenario 4: Two permits, one matches
Policy A:
permit (principal, action, resource)
when { context has fiat_amount && context.fiat_amount <= 1000 };
Policy B:
permit (
principal == User::"alice@example.com",
action == Action::"internal_transfer_transaction",
resource == Resource::"transaction_management"
)
when {
context has fiat_amount && context.fiat_amount <= 25000 &&
context has principal_value1 && context.principal_value1 == "treasury-desk"
};
Decision: PERMIT. Policy A doesn't match, but Policy B does. One matching
permit policy is enough. determiningPolicyIds lists only Policy B.
Scenario 5: A forbid overrides a permit
Policy A:
permit (principal, action, resource)
when { context has fiat_amount && context.fiat_amount <= 10000 };
Policy B:
forbid (principal, action, resource)
when { context has principal_value1 && context.principal_value1 == "treasury-desk" };
Decision: DENY, FORBID_RULE_MATCHED. Both policies match, and forbid
wins. determiningPolicyIds lists only Policy B.
Scenario 6: A permit reads a missing attribute
permit (principal, action, resource)
when { context.validity <= 3600 };
Decision: DENY, EVALUATION_ERROR. The transaction has no validity, so
context.validity doesn't exist and the policy fails to evaluate. No other
policy permits the transaction. evaluationErrors lists the error.
Scenario 7: The same permit, with has
permit (principal, action, resource)
when { context has validity && context.validity <= 3600 };
Decision: DENY, NO_PERMIT_RULE_MATCHED. context has validity is
false, so the policy evaluates to false instead of failing. The transaction is
still denied, but the reason shows that the policy evaluated and didn't match.
Scenario 8: A forbid reads a missing attribute
Policy A:
permit (principal, action, resource)
when { context has fiat_amount && context.fiat_amount <= 10000 };
Policy B:
forbid (principal, action, resource)
when { context.principal_value2 == "sanctioned-counterparty" };
Decision: PERMIT. The transaction has no principalValue2, so Policy B
fails to evaluate and is skipped. Policy A matches. determiningPolicyIds
lists Policy A, and evaluationErrors lists the error from Policy B.
Scenario 9: A forbid that requires an attribute
Policy A is the permit policy from Scenario 8. Policy B:
forbid (principal, action, resource)
unless { context has fiat_amount };
Decision: PERMIT for the transaction in these scenarios, which has a
fiatAmount. If Alice leaves out fiatAmount, the decision is DENY,
FORBID_RULE_MATCHED: context has fiat_amount is false, so the forbid
policy matches. This policy can't fail to evaluate, because it only uses has.
To combine a required attribute with a check on its value in one forbid
policy, check for the attribute first:
forbid (principal, action, resource)
unless { context has fiat_amount && context.fiat_amount <= 10000 };
Scenario 10: A payload that can't be decoded
Alice creates a transaction whose payload can't be decoded by the parser for
Transaction.schema, for example truncated RLP bytes, a Bitcoin PSBT with
several recipients, or a Solana transaction with an unsupported instruction.
Decision: DENY, UNRECOGNIZED_PAYLOAD. No policy is evaluated.
evaluationErrors lists the parser error, and determiningPolicyIds is
empty.
Evaluation record
Each evaluation produces a PolicyEvaluationRecord that can't be changed. It
is returned in Transaction.policyEvaluationRecord and has the following
fields:
| Field | Value |
|---|---|
decision |
PERMIT or DENY. |
denyReason |
One of the deny reasons. Unset when the decision is PERMIT. |
determiningPolicyIds |
On PERMIT, the IDs of the permit policies that matched. On FORBID_RULE_MATCHED, the IDs of the forbid policies that matched. Otherwise empty. |
evaluationErrors |
The errors from policies that failed to evaluate, or the parser error when denyReason is UNRECOGNIZED_PAYLOAD. |
evaluationContext |
The Cedar context that the policies were evaluated against, as a google.protobuf.Struct. 64-bit integers are stored as decimal strings to keep their precision. |
policySetVersion |
The policy set version that the evaluation used. |
keyListMemberships |
The KeyList memberships resolved during the evaluation. |
intentDigest |
The lowercase hex-encoded digest of the transaction payload that was evaluated. The hash depends on the payload's schema: Keccak-256 of the transaction for ETHEREUM_TX_V2, the sighash of the first input for BITCOIN_TX_V2, SHA-256 of the unsigned message for SOLANA_TX_V1, and the EIP-712 signing digest, keccak256(0x1901 ++ domainSeparator ++ hashStruct(message)), for EIP712_TYPED_DATA_V1. |
evaluationTime |
When the evaluation ran. |