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).
#FactorWeightConditionReason String
1Contract not in allowlist+40!context.contractInAllowlist"Contract not in allowlist (+40)"
2Token not in allowlist+20!context.tokenInAllowlist"Token not in allowlist (+20)"
3High slippage+15context.slippageBps > 300"High slippage: {bps} bps > 300 bps (+15)"
4Large value relative to limit+20valueWei > maxValueWei / 2 (both defined, maxValueWei > 0)"Large value relative to limit (+20)"
5Unbounded approval+25approvalAmount === MAX_UINT256 or approvalAmount > maxApprovalAmount * 10"Unbounded or very large approval amount (+25)"
6Simulation reverted+50context.simulationReverted === true"Transaction simulation reverted (+50)"
7Abnormal gas estimate+10context.gasEstimate > 400_000"Abnormal gas estimate: {gas} (+10)"
The theoretical maximum raw sum is 180 (all factors triggered), but the returned score is always Math.min(sum, 100).
// packages/preflight/src/risk-scorer.ts (simplified)
export function computeRiskScore(context: RiskContext): RiskResult {
  let score = 0;
  const reasons: string[] = [];

  if (!context.contractInAllowlist)     { score += 40; reasons.push("Contract not in allowlist (+40)"); }
  if (!context.tokenInAllowlist)        { score += 20; reasons.push("Token not in allowlist (+20)"); }
  if (context.slippageBps > 300)        { score += 15; reasons.push(`High slippage ... (+15)`); }
  if (valueWei > maxValueWei / 2n)      { score += 20; reasons.push("Large value relative to limit (+20)"); }
  if (isUnboundedApproval)              { score += 25; reasons.push("Unbounded or very large approval amount (+25)"); }
  if (context.simulationReverted)       { score += 50; reasons.push("Transaction simulation reverted (+50)"); }
  if (context.gasEstimate > 400_000n)   { score += 10; reasons.push(`Abnormal gas estimate: ... (+10)`); }

  return { score: Math.min(score, 100), reasons };
}

Constants

ConstantValueTypePurpose
MAX_SCORE100numberUpper bound on returned risk score
HIGH_SLIPPAGE_BPS300numberSlippage threshold in basis points (3%)
ABNORMAL_GAS_THRESHOLD400_000nbigintGas estimate above which +10 is applied
MAX_UINT2562n ** 256n - 1nbigintUsed to detect unlimited ERC-20 approvals

RiskContext construction

The PreflightService.buildRiskContext() method assembles a RiskContext object from three sources: the TxIntent, the active PolicyConfig, and the simulation results.
// packages/types/src/index.ts
export interface RiskContext {
  contractInAllowlist: boolean;
  tokenInAllowlist: boolean;
  slippageBps: number;
  simulationReverted: boolean;
  gasEstimate: bigint;
  approvalAmount?: bigint;      // set for approve actions
  maxApprovalAmount?: bigint;    // from PolicyConfig
  valueWei?: bigint;             // set for transfers and swaps
  maxValueWei?: bigint;          // from PolicyConfig
}

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:
Action TypeSource Field
transferaction.amount
transfer_nativeaction.amount
swap_exact_inaction.amountIn
swap_exact_outaction.maxAmountIn
approvenot set (uses approvalAmount instead)
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.
TxIntent
  |
  v
PreflightService.simulate(intent, buildPlan)
  |-- 1. eth_call simulation (reverted?)
  |-- 2. eth_estimateGas
  |-- 3. collectBalanceDiffs
  |-- 4. collectAllowanceChanges
  |-- 5. buildRiskContext(intent, simulationSuccess, gasEstimate)
  |-- 6. computeRiskScore(riskContext) --> { score, reasons }
  |
  v
PreflightResult { riskScore, riskReasons, warnings, ... }
  |
  v
PolicyEngine.evaluate(intent, config, { riskScore })
  |-- Check 7 of 8: if riskScore > config.maxRiskScore
  |       --> decision = "require_approval"
  |
  v
PolicyDecision { decision: "allow" | "require_approval" | "deny" }
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

PolicyConfig FieldDefaultRisk Factor AffectedEffect
contractAllowlist[] (empty = all allowed)Contract not in allowlist (+40)Adding contracts to this list eliminates the +40 penalty for those contracts. An empty list disables the check entirely.
tokenAllowlist[] (empty = all allowed)Token not in allowlist (+20)Adding tokens eliminates the +20 penalty. An empty list disables the check.
maxValueWei"0" (disabled)Large value (+20)When set to a non-zero value, any transaction exceeding 50% of this limit triggers +20. Setting to "0" disables the factor.
maxApprovalAmount"0" (disabled)Unbounded approval (+25)When set to a non-zero value, approvals exceeding 10x this amount trigger +25. MAX_UINT256 approvals always trigger +25 regardless of this setting. Setting to "0" disables the 10x check (but MAX_UINT256 still triggers).
maxRiskScore50Policy decision thresholdScores above this value cause require_approval. Range: 0—100. Setting to 100 effectively disables risk-based approval requirements. Setting to 0 requires approval for any non-zero score.

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.
FactorTriggered?Detail
Contract not in allowlistNotransfer_native has no target contract; defaults to allowlisted
Token not in allowlistNoNo ERC-20 tokens involved; extractTokenAddresses returns []
High slippageNomaxSlippageBps: 50 (0.5%), below 300 threshold
Large valueNomaxValueWei: "0" in config, factor disabled
Unbounded approvalNoNot an approve action
Simulation revertedNoSimulation succeeds
Abnormal gasNoNative transfer gas ~21,000
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%).
FactorTriggered?WeightDetail
Contract not in allowlistNoRouter is in contractAllowlist
Token not in allowlistYes+20assetOut not in tokenAllowlist
High slippageYes+15500 bps > 300 bps threshold
Large valueNomaxValueWei: "0", factor disabled
Unbounded approvalNoNot an approve action
Simulation revertedNoSimulation succeeds
Abnormal gasNoGas estimate ~180,000
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.
FactorTriggered?WeightDetail
Contract not in allowlistYes+40Spender not in contractAllowlist
Token not in allowlistNoToken is in tokenAllowlist
High slippageNomaxSlippageBps: 50, below threshold
Large valueNoapprove actions do not set valueWei
Unbounded approvalYes+25approvalAmount === MAX_UINT256
Simulation revertedNoSimulation succeeds
Abnormal gasYes+10Gas estimate ~450,000 exceeds 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.
FactorTriggered?WeightDetail
Contract not in allowlistYes+40Router not in contractAllowlist
Token not in allowlistNoBoth tokens in tokenAllowlist
High slippageNomaxSlippageBps: 100, below 300 threshold
Large valueNomaxValueWei: "0", factor disabled
Unbounded approvalNoNot an approve action
Simulation revertedYes+50eth_call returned revert
Abnormal gasNoGas estimation failed, gasEstimate: 0
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.
{
  "version": "1",
  "maxValueWei": "1000000000000000000",
  "maxApprovalAmount": "1000000000000000000000",
  "contractAllowlist": ["0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"],
  "tokenAllowlist": [
    "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
    "0xdAC17F958D2ee523a2206206994597C13D831ec7"
  ],
  "allowedChains": [1],
  "recipientAllowlist": [],
  "maxRiskScore": 20,
  "requireApprovalAbove": { "valueWei": "100000000000000000" },
  "maxTxPerHour": 5
}

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