Policy Engine
Some deployments run a policy service alongside auth-svc. Supported signing requests are evaluated against policy before the MPC nodes co-sign them, and a refused request never produces a signature. rawBytes, mldsa, and bitcoin requests are not policy-evaluated; the server gives them the fixed verdicts described below.
The app does not make that decision. It labels each request so the server can select policy evaluation or a fixed verdict, and hides the dApp methods that cannot succeed. The verdict is server-side.
What changes in the app
| Area | With policy evaluation off | With policy evaluation on |
|---|---|---|
| Sign-in | POST /v2/auth/users/register | POST /v2/auth/users/register-with-policy |
| Signing requests | customTags slot 0 sent, nothing reads it | customTags slot 0 selects policy evaluation or a fixed server verdict |
| EVM signing | hash or full payload | full payload, except typed data |
| WalletConnect | all supported methods advertised | typed-data and Solana message signing withheld |
Turning it on
Policy evaluation is per deployment. It lives in the app's service config as policyEvaluation:
export const serviceFeatures = [
{
key: 'policyEvaluation',
title: 'Policy evaluation',
description: 'Register through policy service and evaluate signature against policy.',
},
] as const;
The Duo Demo and Trio Demo presets are seeded from EXPO_PUBLIC_SANDBOX_DUO_FEATURE_POLICY_EVALUATION and EXPO_PUBLIC_SANDBOX_TRIO_FEATURE_POLICY_EVALUATION, and are not editable in the app.
For your own backend, use the Policy evaluation switch in the Custom Backend form. This switch only changes the app's registration endpoint; it does not enable the service on your backend. Before turning it on, your backend operator must run policy-svc and configure auth-svc with FEATURE_POLICY_EVALUATION=true and a reachable POLICY_SVC_URL. The policy service and admin-dashboard deployment steps are outside this React Native guide. If those prerequisites are missing, sign-in fails.
The switch changes which register endpoint sign-in calls, so the app treats it as a different backend. Saving wipes the keys, wallet, and account data on the device, and you sign in again from scratch.
Registration
The sign-in screen picks its mutation from the flag:
const { mutateAsync, isPending } = policyEvaluation ? signInWithPolicy : signIn;
useSignInWithPolicy (src/queries/api/auth/useSignInWithPolicy.ts) matches useSignIn, except it also registers the user with the policy service. Every user needs that registration, even with no policy of their own.
Tagging requests
Every signer labels its request with a transaction type in customTags slot 0, whether or not policy evaluation is on:
export const policyTransactionTypes = {
eip1559, eip712, eip191, rawBytes, mldsa, solanaTransaction, bitcoin,
} as const;
The tag is attached in src/libs/chains/viem.ts, solana.ts, bitcoin.ts, and src/hooks/mpc/useMldsa.ts. See Multi-Chain Signing for where each one is set.
Slot 0 is a backend convention, not an SDK rule — the SDK only guarantees that slots 0–63 arrive. With policy evaluation on, a request without a readable slot 0 is rejected.
Message and transaction signing use signWithHash for the same reason: session.sign({ messageHash }) sends 32 bytes, and a policy cannot evaluate a hash. signTypedData is the exception — it hashes with hashTypedData and calls session.sign, which is why typed-data methods are withheld from WalletConnect.
Types that are never evaluated
Three tags get a fixed answer, with no payload decoding:
Slot 0 value | Result with policy evaluation on |
|---|---|
rawBytes | always denied |
mldsa | always allowed, not evaluated |
bitcoin | always allowed, not evaluated |
No policy is loaded for these, so a deny rule cannot constrain them.
WalletConnect
With policy evaluation on, the app advertises a shorter method list, so a dApp never asks for what cannot succeed:
| Namespace | Withheld |
|---|---|
eip155 | eth_signTypedData, eth_signTypedData_v3, eth_signTypedData_v4 |
solana | solana_signMessage |
The reasons differ. An EIP-712 payload cannot be recovered from its hash, so typed-data signing has nothing evaluable to send. Solana message signing is tagged rawBytes, which is denied outright.
A dApp that asks anyway gets a JSON-RPC error and a Request rejected toast. The signing screen never opens. See WalletConnect for the filtering code.
Setting a policy
Policies are edited in the admin dashboard that ships with the backend. Policies lists one row per group — the default group applies to all users — and opening one shows its rules beside a read-only JSON preview.
A rule has a Description, an Issuers list, an Action, a Chain, a Combine conditions setting, and one or more conditions. A condition has a Transaction type, an Attribute, an Operator, and a Value, plus an optional ABI fragment under Advanced.
The recording adds an Allow Eth rule: transaction type Native transfer, attribute nativeValue, operator greater than, value 0. Saving opens a Review changes before saving dialog with the JSON diff — here Rule 6 added — Allow Eth — and confirming applies it to every user immediately.
Amounts are written in the chain's smallest unit — wei for ETH, lamports for SOL. 0.00001 ETH is 10000000000000, and 1 SOL is 1000000000.
The Duo sandbox enforces the policy below, and the recordings run the same rules. Every rule uses Action Allow with Combine conditions AND, and Issuers is locked to Anyone (*) on the default policy — the policy service identifies users by a single wildcard issuer, so a narrower scope would never match.
Allow eth — Chain Ethereum, transaction type Native transfer
- Attribute
nativeValue, operator greater than, value0
Allow eth usdc — Chain Ethereum, transaction type ERC-20
- Attribute
receiver, operator equals, value0x036CbD53842c5426634e7929541eC2318f3dCF7e— the USDC contract on Base Sepolia. On an ERC-20 conditionreceiveris the token contract, not the recipient, so this rule fixes which token can move, not where it goes. - Attribute
amount, operator greater than, value0, with thetransferABI fragment attached.amountis not a built-in attribute — the engine reads any name it does not recognize from the ABI, which is why this rule carries one and the ETH rule does not.
Allow sol — Chain Solana, transaction type Native transfer
- Attribute
nativeValue, operator greater than, value0
Allow sol usdc — Chain Solana, transaction type Solana tx
- Attribute
splTransferAmount, operator greater than, value0 - Attribute
solanaAccountKeys, operator all of, value4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU - Attribute
splTokenMint, operator equals, the same address again
Allow wallet-connect signature generation — Chain Ethereum, transaction type EIP-191
- Attribute
message, operator not equal, value__empty__. Any real message passes, so dApp personal-sign requests are allowed.
Allow zknox verifier — Chain Ethereum, transaction type ERC-20
- Attribute
receiver, operator equals, value0x092c5d82069de997E34Ce2505CA7D5042f3721ef— the ZKNOX verifier contract used by post-quantum signing. Same mechanism as the USDC rule, here allowing any call to that one contract.
The full policy
{
"version": "1.0",
"description": "Default policy",
"rules": [
{
"action": "allow",
"chain_type": "ethereum",
"conditions": [
{
"operator": "gt",
"transaction_attr": "nativeValue",
"transaction_type": "nativeTransfer",
"value": "0"
}
],
"description": "Allow eth",
"issuer": [{ "id": "*", "type": "*" }],
"logic": "and"
},
{
"action": "allow",
"chain_type": "ethereum",
"conditions": [
{
"operator": "eq",
"transaction_attr": "receiver",
"transaction_type": "erc20",
"value": "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
},
{
"abi": {
"inputs": [
{ "name": "to", "type": "address" },
{ "name": "amount", "type": "uint256" }
],
"name": "transfer",
"outputs": [{ "name": "", "type": "bool" }],
"type": "function"
},
"operator": "gt",
"transaction_attr": "amount",
"transaction_type": "erc20",
"value": "0"
}
],
"description": "Allow eth usdc",
"issuer": [{ "id": "*", "type": "*" }],
"logic": "and"
},
{
"action": "allow",
"chain_type": "solana",
"conditions": [
{
"operator": "gt",
"transaction_attr": "nativeValue",
"transaction_type": "nativeTransfer",
"value": "0"
}
],
"description": "Allow sol",
"issuer": [{ "id": "*", "type": "*" }],
"logic": "and"
},
{
"action": "allow",
"chain_type": "solana",
"conditions": [
{
"operator": "gt",
"transaction_attr": "splTransferAmount",
"transaction_type": "solanaTransaction",
"value": "0"
},
{
"operator": "all",
"transaction_attr": "solanaAccountKeys",
"transaction_type": "solanaTransaction",
"value": ["4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"]
},
{
"operator": "eq",
"transaction_attr": "splTokenMint",
"transaction_type": "solanaTransaction",
"value": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
}
],
"description": "Allow sol usdc",
"issuer": [{ "id": "*", "type": "*" }],
"logic": "and"
},
{
"action": "allow",
"chain_type": "ethereum",
"conditions": [
{
"operator": "neq",
"transaction_attr": "message",
"transaction_type": "eip191",
"value": "__empty__"
}
],
"description": "Allow wallet-connect signature generation",
"issuer": [{ "id": "*", "type": "*" }],
"logic": "and"
},
{
"action": "allow",
"chain_type": "ethereum",
"conditions": [
{
"operator": "eq",
"transaction_attr": "receiver",
"transaction_type": "erc20",
"value": "0x092c5d82069de997E34Ce2505CA7D5042f3721ef"
}
],
"description": "Allow zknox verifier",
"issuer": [{ "id": "*", "type": "*" }],
"logic": "and"
}
]
}
Seeing it work
Every rule above requires a non-zero amount, so a transfer of 0 matches nothing.
The recording sends 0.00001 ETH, which succeeds, then 0 ETH, which is refused. The terminal beside the phone is the backend stack, so you can watch the decision arrive.
On success the transaction screen completes normally and offers View in explorer. On denial the screen stops with a red error and the app shows a Transaction Failed toast.
The app never shows the denial reason — signing fails, and the error surfaces as a generic transaction failure. To find out which rule refused a request, read the backend logs.
Reading the decision in the logs
policy-svc logs one line per rule. On the allowed transfer, Allow eth passes and the rest fail:
policy-svc evaluating condition attr=nativeValue operator=Gt
policy-svc evaluated condition passed=true
policy-svc evaluated rule rule=Allow eth action=Allow result=pass
policy-svc evaluated rule rule=Allow eth usdc action=Allow result=fail:
Operator Eq between actual String("0x2510…") and expected String("0x036C…") failed
policy-svc evaluated rule rule=Allow sol action=Allow result=fail:
Rule chain type Solana does not match transaction type EIP1559
duo-server INFO sigpair_node::setup::sign: DSG setup validation successful
auth-svc [INFO] src.hooks.handlers: Policy evaluation passed — allowing DSG setup
auth-svc [INFO] src.views.hooks_views: <<< HOOK ◀ DSG_SETUP_VALIDATION result=ok
Failing rules are normal. Five of the six fail here and the request still goes through — a rule only applies to the traffic it describes, and one passing allow is enough.
The denied transfer produces the same six lines, except that Allow eth now fails too:
policy-svc evaluated rule rule=Allow eth action=Allow result=fail:
Operator Gt between actual Number(0) and expected String("0") failed
policy-svc evaluated rule rule=Allow eth usdc action=Allow result=fail:
Operator Eq between actual String("0x2510…") and expected String("0x036C…") failed
policy-svc evaluated rule rule=Allow sol action=Allow result=fail:
Rule chain type Solana does not match transaction type EIP1559
policy-svc evaluated rule rule=Allow wallet-connect signature generation action=Allow result=fail:
No matching condition found for instruction at index 0
policy-svc evaluated rule rule=Allow zknox verifier action=Allow result=fail:
Operator Eq between actual String("0x2510…") and expected String("0x092c…") failed
duo-server ERROR sigpair_node::setup::sign: DSG setup validation rejected
auth-svc [INFO] src.views.hooks_views: <<< HOOK ◀ DSG_SETUP_VALIDATION result=reject
Allow eth is the line that changed: the value is 0, and 0 > 0 is false. Nothing else was ever going to match — the Solana rules do not apply to an EVM transaction, and the others name a different token contract, a different verifier contract, or a message rather than a transaction.
Anything that matches no rule is denied. No rule forbids an empty transfer; it fails because nothing permits it. A matching deny rule always beats an allow.
Where the decision happens
The app never decides. duo-server asks auth-svc to validate the signing request, auth-svc forwards it to the policy service, and the answer decides whether the MPC protocol continues.
This page covers the React Native integration and the demo policy only. Policy-service and admin-dashboard deployment are separate backend tasks; complete them before enabling Policy evaluation for a custom backend.