Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Case Study 2: Key Code Walkthrough

Open automated-liquidation-protection/automated-liquidation-protection-ts/main.ts (about 640 lines). All the confidential patterns from yesterday are reused here, so we’ll focus on what’s new in this case study: policy-as-secrets, deterministic guardrails backing up the LLM, and action sequencing.

1. Confidential Handler Registration (Recap)

import {
  CronCapability,
  HTTPClient,
  NITRO_REGIONS,
  Runner,
  handlerInTee,
  type TeeRuntime,
  type Workflow,
} from "@chainlink/cre-sdk";

export const initWorkflow = (config: Config): Workflow<Config> => {
  // ... config and secrets_ids validation ...

  const cron = new CronCapability();

  return [
    handlerInTee(
      cron.trigger({ schedule: config.schedule }),
      onCronTrigger,
      [{ tee: "nitro", regions: [NITRO_REGIONS[0]] }],
    ),
  ];
};

Exactly the same pattern as Case Study 1: CRON trigger + Nitro enclave execution. The differences start inside the callback.

2. Policy-as-Secrets: 1 getSecrets Call (10 Secrets)

This is the case study’s core design decision — keep every policy parameter in the Vault DON, not in the config file or the code. All 10 secrets are fetched together in a single batched getSecrets call rather than 10 separate getSecret round trips:

export const onCronTrigger = async (runtime: TeeRuntime<Config>): Promise<string> => {
  const { mock_base_url, openai_url, openai_model, secrets_ids } = runtime.config;

  const secrets = runtime
    .getSecrets([
      { id: secrets_ids.exchange_api_key_id },
      { id: secrets_ids.openai_api_key_id },
      { id: secrets_ids.liquidation_warning_action_threshold_secret_id },
      { id: secrets_ids.minimum_health_factor_secret_id },
      { id: secrets_ids.target_health_factor_secret_id },
      { id: secrets_ids.maximum_stablecoin_reserve_deployment_secret_id },
      { id: secrets_ids.minimum_stablecoin_reserve_balance_secret_id },
      { id: secrets_ids.maximum_collateral_allocation_secret_id },
      { id: secrets_ids.maximum_partial_debt_repayment_secret_id },
      { id: secrets_ids.preferred_venues_secret_id },
    ])
    .result();

  // ① Two traditional credentials
  const exchangeApiKey = secrets[secrets_ids.exchange_api_key_id].value;
  const openAiApiKey = secrets[secrets_ids.openai_api_key_id].value;

  // ② Seven numeric policy-parameter secrets
  const liquidationWarningActionThreshold = parseRequiredSecretNumber(
    secrets[secrets_ids.liquidation_warning_action_threshold_secret_id].value,
    secrets_ids.liquidation_warning_action_threshold_secret_id,
  );
  const minimumHealthFactor = parseRequiredSecretNumber(
    secrets[secrets_ids.minimum_health_factor_secret_id].value,
    secrets_ids.minimum_health_factor_secret_id,
  );
  const targetHealthFactor = parseRequiredSecretNumber(
    secrets[secrets_ids.target_health_factor_secret_id].value,
    secrets_ids.target_health_factor_secret_id,
  );
  const maxStablecoinReserveDeployment = parseRequiredSecretNumber(
    secrets[secrets_ids.maximum_stablecoin_reserve_deployment_secret_id].value,
    secrets_ids.maximum_stablecoin_reserve_deployment_secret_id,
  );
  const minStablecoinReserveBalance = parseRequiredSecretNumber(
    secrets[secrets_ids.minimum_stablecoin_reserve_balance_secret_id].value,
    secrets_ids.minimum_stablecoin_reserve_balance_secret_id,
  );
  const maxCollateralAllocation = parseRequiredSecretNumber(
    secrets[secrets_ids.maximum_collateral_allocation_secret_id].value,
    secrets_ids.maximum_collateral_allocation_secret_id,
  );
  const maxPartialDebtRepayment = parseRequiredSecretNumber(
    secrets[secrets_ids.maximum_partial_debt_repayment_secret_id].value,
    secrets_ids.maximum_partial_debt_repayment_secret_id,
  );

  // ③ NOT a secret — sequencing preference now comes straight from public workflow config
  const defensiveSequencePreference = parseExecutionSequencePreference(
    runtime.config.defensive_action_sequencing_preference,
  );  // "collateral-first" | "debt-first" | "balanced"

  // ④ List-typed preferred_venues secret has a dedicated parser
  const preferredVenues = parseVenueListSecret(
    secrets[secrets_ids.preferred_venues_secret_id].value,
  );  // ["binance", "onchain", "coinbase"]

  runtime.log("liquidation-getsecrets-ok");

Why do it this way?

  • Parameters written in the config are visible to anyone who can read the workflow configuration; placed in the Vault DON, they only ever appear inside the enclave.
  • Batching into one getSecrets call resolves all 10 secrets (2 credentials + 8 policy-parameter secrets) in a single round trip to the Vault DON, instead of 10 individual getSecret calls.
  • Numeric secrets are validated with parseRequiredSecretNumber (must be a finite number or it throws) — fail fast on malformed secret content instead of making decisions with a bad threshold.
  • defensive_action_sequencing_preference is the one exception: it’s read directly from runtime.config, not from getSecrets — it’s plain workflow configuration, not Vault DON-protected.
  • These values are then assembled into the in-memory Policy object used throughout the execution.

3. The Risk Snapshot and the Deterministic Risk Score

const client = new HTTPClient();
const risk = collectRiskSnapshot(runtime, client, mock_base_url, exchangeApiKey);
// GET /risk-state → prices, HF, liquidation proximity, LTV, volatility, reserves... (confidential HTTP)

// ... policy object assembled from the secrets fetched above ...

const riskScore = computeRiskScore(risk, policy);

computeRiskScore is a deterministic scoring function — no LLM involved, reproducible and auditable:

export const computeRiskScore = (risk: RiskState, policy: Policy): number => {
  // The closer to liquidation, the higher the score (+5 points per 1% closer)
  const proximityRisk = Math.max(0, policy.liquidation_warning_action_threshold - risk.liquidation_proximity_pct) * 5;
  // Adds points as LTV approaches the liquidation threshold (with a 5% buffer)
  const ltvBufferRisk = Math.max(0, risk.loan_to_value_pct - (risk.liquidation_threshold_pct - 5)) * 2;
  // Heavy penalty when the health factor is below the minimum (+1 point per 0.01 below)
  const healthRisk = Math.max(0, policy.minimum_health_factor - risk.collateral_health_factor) * 100;
  // Volatility weighting
  const volatilityRisk = risk.volatility_index * 25;

  return proximityRisk + ltvBufferRisk + healthRisk + volatilityRisk;
};

Let’s compute it with the mock data: proximity (18−12)×5=30 + LTV 0 + health (1.25−1.14)×100=11 + volatility 0.37×25=9.25 → 50.25. Meaningful risk — defense is warranted.

4. The LLM as a Policy Engine

const prompt = createOpenAiPrompt(risk, riskScore, policy);
// { objective: "Return strict JSON...", policy, risk, riskScore }

const llmResponse = postJson(
  runtime,
  client,
  openai_url,
  {
    model: openai_model,
    input: [
      {
        role: "system",
        content:
          "You are a liquidation-defense policy engine. Emit strict JSON only with key names exactly as requested.",
      },
      {
        role: "user",
        content: prompt,
      },
    ],
  },
  {
    ...JSON_HEADERS,
    // ← confidential header: only ever readable inside the enclave
    Authorization: `Bearer ${openAiApiKey}`,
  },
);

const decision = parseLlmDecision(extractOpenAiText(llmResponse));
// → { shouldDefend, reasoning, actions[] }

Notice the prompt design: the entire policy object is sent straight to the LLM. In a non-confidential workflow this would be dangerous (strategy leakage), but inside the enclave the request and response bodies stay confidential end to end — which is exactly why this case study must run confidentially.

5. Deterministic Guardrails for the LLM (The Gem of This Case Study) ⭐

The LLM can propose, but it is not trusted unconditionally. enforcePolicy applies deterministic rules to hard-check and correct the LLM’s output:

export const enforcePolicy = (
  decision: LiquidationDecision,
  policy: Policy,
  risk: RiskState,
): ExecutableAction[] => {
  if (!decision.shouldDefend || decision.actions.length === 0) {
    return [];
  }

  let projectedReserve = risk.usdc_reserve;
  const executable: ExecutableAction[] = [];

  for (const action of decision.actions) {
    const amount = Math.max(0, action.amountUsd ?? 0);
    const repayPctRaw = Math.max(0, action.repayPct ?? 0);
    const cappedRepayPct = Math.min(repayPctRaw, policy.max_partial_debt_repayment_pct);

    // ① Collateral actions: cap against max_reserve_deployment_usdc, then check the reserve floor
    if (
      action.type === "add_collateral" ||
      action.type === "bridge_and_add_collateral" ||
      action.type === "swap_reserve_to_collateral"
    ) {
      const capped = Math.min(amount, policy.max_reserve_deployment_usdc);
      projectedReserve -= capped;

      if (projectedReserve < policy.min_reserve_balance_usdc) {
        throw new Error(
          `action ${action.type} breaches reserve floor: projected ${projectedReserve.toFixed(2)} < floor ${policy.min_reserve_balance_usdc}`,
        );
      }

      executable.push({
        ...action,
        amountUsd: capped,
        repayPct: 0,
        venue: chooseVenue(action, policy.preferred_venues),
      });
      continue;
    }

    // ② Reserve-funded repayment actions: same cap + reserve floor check as ①
    if (
      action.type === "repay_with_reserves" ||
      action.type === "swap_reserve_to_borrowed_and_repay"
    ) {
      const capped = Math.min(amount, policy.max_reserve_deployment_usdc);
      projectedReserve -= capped;

      if (projectedReserve < policy.min_reserve_balance_usdc) {
        throw new Error(
          `action ${action.type} breaches reserve floor: projected ${projectedReserve.toFixed(2)} < floor ${policy.min_reserve_balance_usdc}`,
        );
      }

      executable.push({
        ...action,
        amountUsd: capped,
        repayPct: 0,
        venue: chooseVenue(action, policy.preferred_venues),
      });
      continue;
    }

    // ③ Partial repayment: % capped by max_partial_debt_repayment_pct; amount comes from outstanding debt, not the reserve
    if (action.type === "partial_debt_repayment") {
      const boundedRepayPct = Math.min(cappedRepayPct, 100);
      executable.push({
        ...action,
        amountUsd: (risk.outstanding_debt_usd * boundedRepayPct) / 100,
        repayPct: boundedRepayPct,
        venue: chooseVenue(action, policy.preferred_venues),
      });
      continue;
    }

    // ④ Full repayment: NOT capped by max_reserve_deployment_usdc — repays the entire debt — but still checked against the reserve floor
    if (action.type === "full_debt_repayment") {
      const amountUsd = risk.outstanding_debt_usd;
      projectedReserve -= amountUsd;

      if (projectedReserve < policy.min_reserve_balance_usdc) {
        throw new Error(
          `action ${action.type} breaches reserve floor: projected ${projectedReserve.toFixed(2)} < floor ${policy.min_reserve_balance_usdc}`,
        );
      }

      executable.push({
        ...action,
        amountUsd,
        repayPct: 100,
        venue: chooseVenue(action, policy.preferred_venues),
      });
      continue;
    }

    executable.push({
      ...action,
      amountUsd: amount,
      repayPct: 0,
      venue: chooseVenue(action, policy.preferred_venues),
    });
  }

  // ⑤ Order the actions per the sequencing preference
  return orderActions(executable, policy.execution_sequence_preference);
};

This is the classic “AI + rules” architecture: the LLM generates a plan in a complex situation, and deterministic code holds the line on capital safety. Even if the LLM hallucinates an action to “deploy $1,000,000,” it gets truncated by the cap or stopped by the red-line exception. Note the asymmetry on full_debt_repayment: it skips the deployment cap entirely (it must repay 100% of the debt, not a truncated amount), but it’s still stopped cold by the reserve-floor red line if the payoff would drain reserves too far.

Action Sequencing Preferences

const priorities = {
  "collateral-first": { add_collateral: 1, bridge_and_add_collateral: 2, ..., full_debt_repayment: 7 },
  "debt-first":       { repay_with_reserves: 1, ..., add_collateral: 5, ... },
  "balanced":         { partial_debt_repayment: 1, bridge_and_add_collateral: 2, repay_with_reserves: 3, ... },
};

The same defense plan can execute in a completely different order for different users, driven by defensive_action_sequencing_preference. Unlike the numeric thresholds and preferred-venues list, this value is read from public workflow config rather than the Vault DON — it’s the one policy parameter that isn’t a secret.

6. Execute the Defense, or Declare SAFE

// Liquidation proximity still above the warning line,
// or no executable actions after policy checks → SAFE
if (risk.liquidation_proximity_pct > policy.liquidation_warning_action_threshold || actions.length === 0) {
  runtime.log(
    `liquidation-no-action proximity=${risk.liquidation_proximity_pct.toFixed(3)} threshold=${policy.liquidation_warning_action_threshold} reason=${decision.reasoning}`,
  );
  return "SAFE";
}

// Otherwise, execute the defense plan
const defenseResponse = postJson(
  runtime,
  client,
  `${mock_base_url}/execute-defense`,
  {
    riskScore,
    liquidationProximityPct: risk.liquidation_proximity_pct,
    reasoning: decision.reasoning,
    actions,
  },
  {
    ...JSON_HEADERS,
    "x-exchange-api-key": exchangeApiKey,
  },
);

runtime.log(
  `liquidation-defense-executed action_count=${actions.length} execution_id=${asString(defenseResponse.execution_id, "unknown")}`,
);

return JSON.stringify({
  status: "DEFENDED",
  actionCount: actions.length,
  riskScore,
  executionId: asString(defenseResponse.execution_id, "unknown"),
});

Only two possible endings: SAFE (no action needed) or DEFENDED (N actions executed) — clear, monitorable, and alertable.

7. Side-by-Side with Case Study 1

DimensionCase 1: AI Audit FirewallCase 2: Liquidation Protection
Confidential assetsScanner + LLM credentialsCredentials + 8 policy-parameter secrets (sequencing preference is public config)
Decision-makingDual LLMs + conservative merge rulesLLM decision + deterministic guardrails
Capability restrictionspreHook allowlist (HTTP ≤ 8, Report ≤ 1, secrets ≤ 3)None declared (default limits)
Data leaving the enclaveVerdict code + risk mask (optionally onchain)Only the defense action instructions (to the execution endpoint)
Number of LLMs2 (Primary / Secondary)1 policy engine

Recap: Why Confidentiality Is Necessary

RiskRegular workflowConfidential Workflow
Risk thresholds (when you act)Visible to nodes → predictable and exploitableEnter the enclave only, as secrets
Capital caps (how much you can deploy)Visible to nodes → exposes your defensive boundaryEnter the enclave only, as secrets
Execution preferences (how you act)Visible to nodes → front-runnableRead from public workflow config, not a Vault secret — an intentional exception; capital caps and thresholds still stay enclave-only
Exchange / LLM credentialsPlaintext passes through node memoryReleased by the Vault DON directly into the enclave
Risk snapshot (your position and reserves)Visible to nodesConfidential HTTP responses stay inside the enclave

What’s Next

Time to run it! Start the mock server and watch the workflow reach a DEFENDED decision on the default risk data.