Skip to main content

Overview

The ISCL risk scoring algorithm quantifies transaction risk on a 0—100 integer scale. It is computed by computeRiskScore() in @clavion/preflight during the simulation phase of every transaction. The resulting score is passed to PolicyEngine.evaluate(), where it can trigger a require_approval decision if it exceeds the operator-configured maxRiskScore threshold. The algorithm is deliberately additive and transparent: each scoring factor contributes a fixed weight, and the triggered factors are returned as human-readable reasons strings alongside the numeric score. This makes risk outcomes auditable and explainable to both operators and end users reviewing approval prompts.
Key source files: packages/preflight/src/risk-scorer.ts (scoring factors, weights, constants), packages/preflight/src/preflight-service.ts (RiskContext construction, simulation flow), packages/policy/src/policy-engine.ts (policy decision based on risk score).

Scoring factors

The scorer evaluates seven independent factors. Each factor is a boolean test; when the condition is true, its fixed weight is added to the running total. The final score is capped at MAX_SCORE (100).
The theoretical maximum raw sum is 180 (all factors triggered), but the returned score is always Math.min(sum, 100).

Constants

RiskContext construction

The PreflightService.buildRiskContext() method assembles a RiskContext object from three sources: the TxIntent, the active PolicyConfig, and the simulation results.

Field resolution

contractInAllowlist — Derived from the intent action type. For approve actions, the contract is action.spender. For swap_exact_in / swap_exact_out, it is action.router. Transfer actions have no target contract, so contractInAllowlist defaults to true. If PolicyConfig.contractAllowlist is empty, the check is bypassed (treated as allowlisted). tokenInAllowlist — All token addresses are extracted from the intent: asset.address for transfers/approvals, both assetIn.address and assetOut.address for swaps. Every token must appear in PolicyConfig.tokenAllowlist. An empty allowlist bypasses the check (all tokens treated as allowlisted). slippageBps — Taken directly from intent.constraints.maxSlippageBps. simulationReverted — The inverse of the eth_call simulation success flag. gasEstimate — The result of eth_estimateGas. Falls back to 0n if estimation fails and the simulation also failed. valueWei — Populated based on action type: approvalAmount — Set only for approve actions, from action.amount. maxValueWei / maxApprovalAmount — Sourced from PolicyConfig. If the config value is "0" (the default), the corresponding context field is set to undefined, which disables the associated scoring factor.

Score in the policy pipeline

The risk score flows through the ISCL pipeline as follows:
1

Simulation

PreflightService.simulate(intent, buildPlan) runs eth_call and eth_estimateGas, then collects balance diffs and allowance changes.
2

Risk context assembly

buildRiskContext(intent, simulationSuccess, gasEstimate) combines intent data, policy config, and simulation results into a RiskContext.
3

Score computation

computeRiskScore(riskContext) evaluates 7 factors and returns { score, reasons }.
4

Preflight result

The score and reasons are packaged into a PreflightResult along with balance diffs, allowance changes, and warnings.
5

Policy evaluation

PolicyEngine.evaluate(intent, config, { riskScore }) checks if the score exceeds maxRiskScore. If so, the decision escalates to require_approval.
Within PolicyEngine.evaluate(), the risk score check is one of eight policy checks. It can only escalate the decision to require_approval; it never causes an outright deny. The decision priority is: deny > require_approval > allow. If any other check triggers a deny, the deny takes precedence regardless of risk score.

Configuration impact

All scoring thresholds are derived from PolicyConfig. Operators tune risk tolerance through configuration rather than code changes.

How each config field affects scoring

Allowlist behavior

Both contractAllowlist and tokenAllowlist use an empty-means-open semantic: an empty array means all addresses are considered allowlisted (no penalty). This is a deliberate design choice for ease of initial deployment. Once an operator populates an allowlist, only listed addresses avoid the penalty.
PolicyEngine also independently enforces allowlists as hard deny rules when the lists are non-empty. The risk scorer’s allowlist checks are softer — they add penalty points rather than blocking outright. This creates a layered defense: PolicyEngine can deny unlisted tokens entirely, while the risk scorer penalizes interactions with unlisted-but-not-denied addresses.

Worked examples

A native ETH transfer to a known recipient. No contract interaction, no tokens, low value.Final score: 0 — PolicyEngine decision: allow.
A swap_exact_in on a Uniswap V3 router that is in the contract allowlist, but the output token is not in the token allowlist, and slippage is set to 500 bps (5%).Final score: 35 — With default maxRiskScore: 50, PolicyEngine decision: allow.
An approve action granting MAX_UINT256 allowance to a spender that is not in the contract allowlist. The token is in the token allowlist. The approval targets a proxy contract with complex initialization, pushing gas above the 400k threshold.Final score: 75 (40 + 25 + 10) — With default maxRiskScore: 50, PolicyEngine decision: require_approval. The user sees all three triggered reasons in the approval prompt.
A swap_exact_in where the simulation reverts (e.g., insufficient liquidity), the router is not allowlisted, and the gas estimate is abnormally high.Final score: 90 — PolicyEngine decision: require_approval. The approval prompt warns "Transaction simulation reverted (+50)" and "Contract not in allowlist (+40)".
When gas estimation fails alongside a reverted simulation, gasEstimate falls back to 0n, so the abnormal gas factor does not trigger. The simulation revert factor alone carries a substantial +50 weight.

Customization strategies

Risk tolerance should be tuned through PolicyConfig rather than by modifying the scoring weights in code. The scoring weights are designed as sensible defaults; the config knobs provide the operational flexibility.
Set maxRiskScore: 20, populate both allowlists exhaustively, set maxValueWei and maxApprovalAmount to reasonable limits. Almost any non-trivial transaction will require human approval.

Key tuning relationships

  • Populating contractAllowlist is the single highest-impact configuration change, eliminating 40 points of potential risk for known contracts.
  • maxRiskScore: 50 (the default) means that any single high-weight factor (contract not allowlisted at +40 combined with any other factor, or simulation revert alone at +50) will trigger approval.
  • Setting maxValueWei to a non-zero value activates the “large value” factor, adding another layer of scrutiny for high-value transactions even when all other factors are clean.

Next steps