DEV Community

rayQu
rayQu

Posted on

Building a Confidential On-Chain Credit Underwriting Engine with Oasis Sapphire + ROFL

What if a DeFi application could evaluate a wallet's creditworthiness without publishing the credit score, risk thresholds, underwriting rules, or internal decision variables on-chain?

That is the problem we are going to solve.

In this tutorial, we will build a confidential on-chain credit underwriting engine using Oasis Sapphire, Oasis ROFL, and Base.

The application will have a deliberately split architecture:

  • Base remains the public source of collateral and lending activity.
  • ROFL collects public blockchain data and performs the risk calculation inside a Trusted Execution Environment.
  • Sapphire stores the confidential underwriting state.
  • A Sapphire smart contract decides whether a borrower is eligible for a credit limit.
  • The borrower's exact score, policy thresholds, and internal risk parameters are never published as ordinary blockchain state.
  • The final authorization can be consumed by another application without exposing the complete underwriting model.

This is not intended to be a production lending protocol. It is an architectural reference showing how confidential computation can be combined with public blockchain state.

The interesting part is not hiding the blockchain.

The interesting part is deciding which parts should be public, which parts should be confidential, and which results should be verifiable without revealing the inputs that produced them.


1. The Problem

Imagine a crypto-native merchant financing protocol.

A merchant deposits collateral on Base and wants access to a short-term credit line.

A traditional DeFi lending protocol might calculate something like:

collateral value
+
borrowed value
+
liquidation history
+
wallet activity
=
credit limit
Enter fullscreen mode Exit fullscreen mode

The problem is that the underwriting process itself can become public information.

Suppose the protocol uses:

minimum_score = 720
max_utilization = 65%
max_drawdown = 30%
risk_weight = 0.42
Enter fullscreen mode Exit fullscreen mode

and publishes all of those values on-chain.

An adversary can now reverse-engineer the protocol.

Worse, if the protocol stores a user's individual score publicly:

wallet:
0x1234...

score:
783

creditLimit:
$42,000
Enter fullscreen mode Exit fullscreen mode

then the blockchain becomes a public credit database.

That is undesirable for several reasons.

A borrower may not want every observer to know their risk classification.

A protocol may not want competitors to know its underwriting methodology.

A risk provider may not want its proprietary model exposed.

And a sophisticated attacker may deliberately optimize behavior against the published thresholds.

The goal, therefore, is not:

"Make the blockchain private."

The goal is:

Keep the source data and underwriting logic confidential while publishing only the minimum information required to authorize a financial action.

That is a much more interesting architecture.


2. What We Are Building

Our example protocol will be called PrivateCredit.

A user interacts with the system through a normal wallet.

The user has collateral and activity on Base.

The application periodically requests an underwriting decision.

The ROFL worker reads public blockchain information, calculates a risk profile inside its TEE, and submits the resulting authorization to Sapphire.

Sapphire stores the confidential result and exposes only narrowly scoped functions to consuming applications.

The simplified flow looks like this:

flowchart LR

    U[Borrower Wallet]

    B[Base]

    R[ROFL Risk Engine]

    S[Sapphire]

    C[Credit Controller]

    L[Lending / Merchant App]

    U -->|Collateral + activity| B
    B -->|Public blockchain state| R

    R -->|Confidential underwriting result| S

    S --> C

    C -->|Authorized credit limit| L

    L -->|Credit action| U

There is an important boundary here.

Base does not become confidential.

Anyone can still inspect the borrower's public transactions.

Instead, we are protecting the derived intelligence.

That distinction is fundamental.


3. Why ROFL + Sapphire?

Sapphire is an EVM-compatible confidential runtime designed for confidential smart-contract state, encrypted transactions, and confidential computation.

ROFL extends that model to off-chain workloads.

A ROFL application executes inside a Trusted Execution Environment and can interact with external systems while retaining confidentiality and producing authenticated interactions with the Oasis ecosystem.

That gives us two different computational environments.

Sapphire

Use Sapphire for:

  • confidential contract state
  • authorization
  • encrypted inputs
  • access-controlled outputs
  • deterministic policy enforcement

ROFL

Use ROFL for:

  • fetching Base data
  • aggregating many transactions
  • calling external APIs
  • running more expensive calculations
  • maintaining private model parameters
  • processing data that would be awkward or expensive on-chain

This separation is important.

You don't want to perform a large historical risk calculation directly inside a smart contract.

And you don't want the final authorization decision to exist only inside an off-chain server.

So we use both.


4. Threat Model

Before writing code, define what we are protecting.

Our adversaries include:

Blockchain observers

They can see:

  • Base transactions
  • contract addresses
  • public balances
  • public collateral
  • public borrowing activity

We cannot hide those from someone who can inspect Base.

ROFL host/operator

The application executes inside a TEE.

The architecture therefore relies on the TEE and its attestation model rather than assuming that the underlying host operator should see the application's secrets.

ROFL's security model is based around TEEs, remote attestation, Oasis consensus, and registered applications.

Application users

Users should not automatically receive:

  • the complete risk model
  • other users' scores
  • internal thresholds
  • historical underwriting decisions

Our own frontend

The frontend should not become a privileged database.

This is why sensitive state belongs in the confidential execution layer rather than simply being hidden behind a web API.


5. What We Are NOT Protecting

This is equally important.

If a user's collateral is on Base, the collateral transaction is public.

If the user interacts with a public lending protocol, those interactions are public.

Sapphire does not magically erase public-chain history.

Our confidentiality boundary begins when public information is ingested into the underwriting system.

Think about the pipeline like this:

PUBLIC
    |
    | Base transactions
    v
+--------------------+
| ROFL TEE            |
|                    |
| private features   |
| risk model         |
| policy parameters  |
+--------------------+
    |
    | authorized result
    v
+--------------------+
| Sapphire           |
|                    |
| private score      |
| credit limit       |
| expiry             |
+--------------------+
    |
    | minimal output
    v
CONSUMING APPLICATION
Enter fullscreen mode Exit fullscreen mode

That is the architecture we want.


6. Repository Structure

The repository for the tutorial can be organized like this:

private-credit/
│
├── contracts/
│   ├── PrivateCredit.sol
│   ├── CreditController.sol
│   ├── PolicyRegistry.sol
│   └── interfaces/
│       └── IPrivateCredit.sol
│
├── rofl/
│   ├── src/
│   │   ├── index.ts
│   │   ├── base.ts
│   │   ├── scoring.ts
│   │   ├── features.ts
│   │   └── sapphire.ts
│   │
│   ├── Dockerfile
│   └── package.json
│
├── scripts/
│   ├── deploy.ts
│   ├── seed.ts
│   └── request-underwriting.ts
│
├── test/
│   ├── PrivateCredit.t.sol
│   ├── PolicyRegistry.t.sol
│   └── integration.t.sol
│
├── frontend/
│   └── ...
│
├── docker-compose.yml
├── hardhat.config.ts
├── foundry.toml
└── README.md
Enter fullscreen mode Exit fullscreen mode

The important design decision is that the risk engine and the smart contract are separate components.

The smart contract does not trust arbitrary off-chain results.

It verifies that the caller is an authorized ROFL application.


7. The Confidential Contract

Let's start with the Sapphire contract.

The contract needs to maintain a confidential credit record.

Conceptually:

struct CreditRecord {
    uint256 limit;
    uint256 score;
    uint64 expiresAt;
    uint64 nonce;
    bool active;
}
Enter fullscreen mode Exit fullscreen mode

We deliberately do not emit the score in a normal public event.

Instead, the record remains inside Sapphire's confidential state.

A simplified contract looks like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract PrivateCredit {
    struct CreditRecord {
        uint256 limit;
        uint256 score;
        uint64 expiresAt;
        uint64 nonce;
        bool active;
    }

    mapping(address => CreditRecord) private records;

    address public roflApp;

    modifier onlyROFL() {
        require(msg.sender == roflApp, "not authorized");
        _;
    }

    constructor(address _roflApp) {
        roflApp = _roflApp;
    }

    function updateCredit(
        address borrower,
        uint256 score,
        uint256 limit,
        uint64 expiresAt
    ) external onlyROFL {
        CreditRecord storage record = records[borrower];

        record.score = score;
        record.limit = limit;
        record.expiresAt = expiresAt;
        record.nonce++;
        record.active = true;
    }

    function revokeCredit(
        address borrower
    ) external onlyROFL {
        records[borrower].active = false;
    }

    function getCreditLimit(
        address borrower
    ) external view returns (uint256) {
        CreditRecord memory record = records[borrower];

        require(record.active, "inactive");
        require(block.timestamp <= record.expiresAt, "expired");

        return record.limit;
    }
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally simplified.

A production version should use Sapphire's recommended mechanisms for authenticating ROFL-originated calls rather than treating an ordinary msg.sender check as sufficient.

The important architectural idea is that the authorization boundary belongs to the confidential runtime.

ROFL's integration with Sapphire allows contracts to verify that transactions originate from registered ROFL applications.


8. Don't Store a "Global Score"

One easy mistake would be to create:

mapping(address => uint256) public score;
Enter fullscreen mode Exit fullscreen mode

and call it a privacy solution.

It isn't.

The public getter would make the value readable.

Even without public, you need to carefully consider which functions expose confidential information.

Instead, the application should expose purpose-specific queries.

For example:

function canBorrow(
    address borrower,
    uint256 amount
) external view returns (bool) {
    CreditRecord memory record = records[borrower];

    if (!record.active) {
        return false;
    }

    if (block.timestamp > record.expiresAt) {
        return false;
    }

    return amount <= record.limit;
}
Enter fullscreen mode Exit fullscreen mode

The consuming application learns:

true
Enter fullscreen mode Exit fullscreen mode

rather than:

score = 813
limit = $47,391
riskWeight = 0.371
Enter fullscreen mode Exit fullscreen mode

This is a very important privacy pattern:

Expose decisions rather than sensitive intermediate state whenever possible.


9. The Feature Pipeline

Now we move to ROFL.

The risk engine needs public information.

Suppose our underwriting model uses:

wallet_age
transaction_count
collateral_value
borrow_utilization
liquidation_count
repayment_ratio
portfolio_volatility
Enter fullscreen mode Exit fullscreen mode

The ROFL application can query Base RPC endpoints and construct a feature vector.

For example:

export interface BorrowerFeatures {
    walletAgeDays: number;
    transactionCount: number;
    collateralUsd: number;
    utilization: number;
    liquidations: number;
    repaymentRatio: number;
    volatility: number;
}
Enter fullscreen mode Exit fullscreen mode

The feature extraction layer should be deterministic.

That makes debugging substantially easier.

export function normalizeFeatures(
    raw: BorrowerFeatures
) {
    return {
        walletAge: Math.min(raw.walletAgeDays / 3650, 1),
        activity: Math.min(raw.transactionCount / 1000, 1),
        collateral: Math.min(raw.collateralUsd / 100000, 1),
        utilization: Math.min(raw.utilization, 1),
        liquidations: Math.min(raw.liquidations / 10, 1),
        repayment: raw.repaymentRatio,
        volatility: Math.min(raw.volatility, 1)
    };
}
Enter fullscreen mode Exit fullscreen mode

Notice that none of this is performed on Base.

It happens inside the ROFL application.


10. The Risk Model

For this tutorial, use a deliberately simple model.

The point isn't to invent a sophisticated credit algorithm.

The point is to demonstrate where the algorithm lives.

export function calculateScore(
    f: ReturnType<typeof normalizeFeatures>
): number {

    let score = 500;

    score += f.walletAge * 100;
    score += f.activity * 75;
    score += f.collateral * 125;

    score += f.repayment * 150;

    score -= f.utilization * 150;
    score -= f.liquidations * 200;
    score -= f.volatility * 100;

    return Math.max(
        0,
        Math.min(1000, Math.round(score))
    );
}
Enter fullscreen mode Exit fullscreen mode

A production system would likely use a much more sophisticated model.

It might include:

  • multiple assets
  • liquidation distance
  • realized volatility
  • counterparty exposure
  • stablecoin concentration
  • protocol-specific risk
  • historical repayment behavior
  • oracle confidence
  • time-weighted collateral
  • anomaly detection

But the important thing is that the model does not need to become blockchain state.


11. Keeping the Policy Confidential

Now comes the interesting part.

Suppose the protocol has these rules:

const POLICY = {
    minimumScore: 720,
    maximumUtilization: 0.68,
    maximumLimitUsd: 50000,
    liquidationPenalty: 180,
    scoreVersion: "2026-09"
};
Enter fullscreen mode Exit fullscreen mode

Publishing this policy would allow users to optimize directly against it.

Instead, it remains inside the ROFL application.

The underwriting function becomes:

export function underwrite(
    features: BorrowerFeatures
) {
    const normalized = normalizeFeatures(features);

    const score = calculateScore(normalized);

    if (score < POLICY.minimumScore) {
        return {
            approved: false,
            score
        };
    }

    const utilizationPenalty =
        normalized.utilization * POLICY.maximumLimitUsd;

    const limit =
        Math.max(
            0,
            Math.floor(
                POLICY.maximumLimitUsd - utilizationPenalty
            )
        );

    return {
        approved: true,
        score,
        limit
    };
}
Enter fullscreen mode Exit fullscreen mode

Again, this is illustrative rather than production-grade underwriting.

The important part is the confidentiality boundary.

The user does not need to know:

minimumScore = 720
Enter fullscreen mode Exit fullscreen mode

They only need to know:

approved = true
Enter fullscreen mode Exit fullscreen mode

or:

approved = false
Enter fullscreen mode Exit fullscreen mode

12. Why Not Just Use a Server?

A reasonable question is:

Why not put this behind an HTTPS API?

You could.

But then the trust model becomes:

user
 |
 v
centralized server
 |
 v
database
Enter fullscreen mode Exit fullscreen mode

The user must trust the server operator to:

  • run the correct code
  • protect the database
  • protect API credentials
  • not alter decisions
  • not leak scores
  • not secretly change policy

ROFL gives us a different architecture.

user
 |
 v
Oasis network
 |
 +--> registered ROFL application
          |
          v
        TEE
          |
          v
      risk engine
Enter fullscreen mode Exit fullscreen mode

The ROFL model is specifically designed for arbitrary off-chain computation that can interact with external resources while retaining confidentiality and providing verifiable integration with the Oasis ecosystem.

It doesn't mean "trust disappears."

It means the trust assumptions become explicit and tied to the TEE, attestation, application registration, and Oasis infrastructure.

That distinction matters.


13. Reading Base from ROFL

The ROFL application needs an RPC connection.

import { createPublicClient, http } from "viem";
import { base } from "viem/chains";

const baseClient = createPublicClient({
    chain: base,
    transport: http(process.env.BASE_RPC_URL)
});
Enter fullscreen mode Exit fullscreen mode

We can then inspect contracts.

For example:

const collateral = await baseClient.readContract({
    address: COLLATERAL_ADDRESS,
    abi: collateralAbi,
    functionName: "balanceOf",
    args: [borrower]
});
Enter fullscreen mode Exit fullscreen mode

The key architectural point is that the ROFL application can communicate with external networks.

ROFL applications can use authenticated network connections and can incorporate light clients or HTTPS/TLS connections depending on the application.

That makes ROFL especially useful for applications where the input data does not originate entirely inside Oasis.


14. Building the Underwriting Worker

The worker ties everything together.

async function processBorrower(
    borrower: `0x${string}`
) {
    const raw = await collectFeatures(
        borrower
    );

    const decision = underwrite(raw);

    if (!decision.approved) {
        await revokeCredit(borrower);
        return;
    }

    await updateCredit(
        borrower,
        decision.score,
        decision.limit
    );
}
Enter fullscreen mode Exit fullscreen mode

The production version should additionally include:

  • idempotency
  • nonce handling
  • stale-data detection
  • retry logic
  • RPC failover
  • transaction confirmation
  • policy versioning
  • model versioning
  • attestation-aware deployment
  • monitoring

The simple worker is enough to demonstrate the architecture.


15. Never Let a Stale Risk Decision Live Forever

One of the easiest mistakes in automated credit systems is forgetting that risk changes.

Suppose a borrower receives:

creditLimit = $40,000
Enter fullscreen mode Exit fullscreen mode

at 12:00.

At 12:10, their collateral collapses.

The previous authorization should not remain valid indefinitely.

This is why our record contains:

uint64 expiresAt;
Enter fullscreen mode Exit fullscreen mode

The consuming application can enforce:

require(
    block.timestamp <= record.expiresAt,
    "credit expired"
);
Enter fullscreen mode Exit fullscreen mode

This creates a temporal trust boundary.

Instead of:

"This address is trusted."

we have:

"This address was authorized for this amount until this time."

That is substantially safer.


16. Model Versioning

Now consider the underwriting model itself.

Suppose version 1 is:

model = credit-v1
Enter fullscreen mode Exit fullscreen mode

and version 2 changes the scoring function.

If the system does not track model versions, historical decisions become difficult to interpret.

So add:

struct CreditRecord {
    uint256 limit;
    uint256 score;
    uint64 expiresAt;
    uint64 nonce;
    bytes32 policyHash;
    bytes32 modelHash;
    bool active;
}
Enter fullscreen mode Exit fullscreen mode

The score remains confidential.

But the cryptographic identifiers of the policy/model can be retained as provenance metadata.

For example:

modelHash:
0x9f83...

policyHash:
0x72a1...
Enter fullscreen mode Exit fullscreen mode

This gives you an important property:

The implementation can change without making historical authorization impossible to audit.

You can distinguish:

What was decided?
Enter fullscreen mode Exit fullscreen mode

from:

Which policy generated that decision?
Enter fullscreen mode Exit fullscreen mode

without necessarily publishing the policy itself.


17. A Better Architecture

At this point our architecture becomes:

flowchart TB

    BASE[Base]

    RPC[Base RPC / Indexer]

    ROFL[ROFL TEE]

    FEATURES[Feature Extraction]

    MODEL[Private Risk Model]

    POLICY[Private Policy]

    SAPPHIRE[Sapphire]

    CREDIT[Private Credit Controller]

    APP[Merchant / Lending App]

    BASE --> RPC
    RPC --> ROFL

    ROFL --> FEATURES
    FEATURES --> MODEL
    MODEL --> POLICY

    POLICY -->|Decision| SAPPHIRE

    SAPPHIRE --> CREDIT
    CREDIT --> APP

There is a subtle but important property here.

The public blockchain is used as the source of evidence.

It is not used as the storage layer for private underwriting state.


18. Confidential Events

Sometimes an application needs event-driven architecture.

For example:

event CreditDecision(
    address indexed borrower,
    uint256 limit
);
Enter fullscreen mode Exit fullscreen mode

That would leak the limit.

Instead, Sapphire supports encrypted event payloads while keeping event topics available for indexing and off-chain triggering.

That lets us design an event such as:

topic:
CreditDecision(address)

encrypted payload:
{
    limit,
    score,
    expiresAt,
    modelHash
}
Enter fullscreen mode Exit fullscreen mode

An authorized consumer can decrypt the payload.

An arbitrary blockchain observer cannot simply read the sensitive values from the event.

This is a useful pattern for systems that still need event-driven infrastructure without turning every event into a public data leak.


19. Don't Encrypt Everything

A common privacy-design mistake is assuming:

More encryption = better architecture.

Not necessarily.

Some data should remain public.

For example:

CreditController deployed
ROFL application registered
Policy version changed
Model version changed
Credit authorization revoked
Enter fullscreen mode Exit fullscreen mode

These facts can often be public.

What should remain confidential are values such as:

individual score
risk features
private policy thresholds
exact credit limit
sensitive model parameters
Enter fullscreen mode Exit fullscreen mode

The correct architecture is therefore:

PUBLIC METADATA
        +
CONFIDENTIAL STATE
        +
MINIMAL DISCLOSURE
Enter fullscreen mode Exit fullscreen mode

not:

ENCRYPT EVERYTHING
Enter fullscreen mode Exit fullscreen mode

20. Authorization Is More Important Than Encryption

Suppose we correctly encrypt the credit record.

But then we expose:

function getScore(address borrower)
    external
    returns (uint256)
Enter fullscreen mode Exit fullscreen mode

Privacy is gone.

This is why confidentiality has to be treated as an application-level authorization problem, not just a cryptography problem.

Ask:

  1. Who can write the state?
  2. Who can read the state?
  3. Who can cause a decision?
  4. Who can revoke a decision?
  5. What can a caller infer from success/failure?
  6. What information leaks through events?
  7. What information leaks through timing?
  8. What information leaks through transaction metadata?

The last question is particularly important.

A confidential contract does not automatically make the surrounding application metadata confidential.


21. The Credit Controller

Now suppose a lending application wants to check whether a borrower can draw $10,000.

It doesn't need the score.

It needs a decision.

function canDraw(
    address borrower,
    uint256 amount
) external view returns (bool) {

    CreditRecord memory record =
        records[borrower];

    if (!record.active) {
        return false;
    }

    if (block.timestamp > record.expiresAt) {
        return false;
    }

    return amount <= record.limit;
}
Enter fullscreen mode Exit fullscreen mode

The result is:

true
Enter fullscreen mode Exit fullscreen mode

The application can then execute the action.

This pattern is called capability-oriented disclosure.

Instead of revealing the entire internal state, you reveal only whether a particular operation is authorized.

That is a much better primitive for privacy-sensitive financial applications.


22. What Happens When the User Is Rejected?

Do not assume that returning:

score = 694
Enter fullscreen mode Exit fullscreen mode

is harmless.

A user can repeatedly modify their behavior and probe the model.

If every request reveals a numerical score, they can potentially infer the decision boundary.

A better interface is:

eligible = false
Enter fullscreen mode Exit fullscreen mode

possibly combined with a coarse reason category:

INSUFFICIENT_COLLATERAL
Enter fullscreen mode Exit fullscreen mode

or:

TEMPORARILY_INELIGIBLE
Enter fullscreen mode Exit fullscreen mode

Even these should be designed carefully.

The goal is to minimize the information returned by the underwriting oracle.


23. Replay Protection

ROFL may process the same borrower more than once.

Transactions can be retried.

RPC responses can be delayed.

Workers can restart.

Therefore, the credit contract needs a monotonic nonce.

require(
    nonce > records[borrower].nonce,
    "stale decision"
);
Enter fullscreen mode Exit fullscreen mode

Now an old underwriting result cannot overwrite a newer one.

A better structure is:

struct CreditDecision {
    uint256 limit;
    uint64 expiresAt;
    uint64 nonce;
    bytes32 modelHash;
    bytes32 policyHash;
}
Enter fullscreen mode Exit fullscreen mode

and require:

new nonce > stored nonce
Enter fullscreen mode Exit fullscreen mode

This turns the authorization state into a monotonic state machine.


24. Failure Handling

Now imagine the Base RPC provider goes offline.

Should credit applications immediately fail?

Not necessarily.

The ROFL application should distinguish between:

NO DATA
Enter fullscreen mode Exit fullscreen mode

and:

LOW RISK
Enter fullscreen mode Exit fullscreen mode

These are completely different states.

A safe policy is:

RPC unavailable
    ↓
no new underwriting decision
    ↓
existing authorization remains valid
until expiry
Enter fullscreen mode Exit fullscreen mode

rather than:

RPC unavailable
    ↓
grant maximum credit
Enter fullscreen mode Exit fullscreen mode

This is an example of fail-closed financial automation.


25. The Full Lifecycle

The final system now behaves like this:

sequenceDiagram

    participant User
    participant Base
    participant ROFL
    participant Sapphire
    participant CreditApp

    User->>Base: Deposit collateral

    ROFL->>Base: Read collateral/activity

    Base-->>ROFL: Public state

    ROFL->>ROFL: Extract features

    ROFL->>ROFL: Run private model

    ROFL->>ROFL: Evaluate private policy

    ROFL->>Sapphire: Submit authorization

    Sapphire->>Sapphire: Verify ROFL origin

    Sapphire->>Sapphire: Store confidential decision

    CreditApp->>Sapphire: Can user draw X?

    Sapphire-->>CreditApp: true / false

    CreditApp->>User: Execute permitted action

Notice how little information crosses the boundary.

Base provides evidence.

ROFL produces a decision.

Sapphire enforces authorization.

The application receives only what it needs.


26. Testing the System

For the Solidity layer, Foundry is a good fit.

A basic test:

function testCreditExpires() public {
    vm.warp(block.timestamp + 2 hours);

    vm.expectRevert("expired");

    credit.getCreditLimit(borrower);
}
Enter fullscreen mode Exit fullscreen mode

Test stale updates:

function testRejectsStaleDecision() public {
    credit.updateCredit(
        borrower,
        800,
        40000,
        uint64(block.timestamp + 1 days),
        10
    );

    vm.expectRevert("stale decision");

    credit.updateCredit(
        borrower,
        900,
        50000,
        uint64(block.timestamp + 1 days),
        9
    );
}
Enter fullscreen mode Exit fullscreen mode

Test authorization:

function testOnlyROFLCanUpdate() public {
    vm.prank(attacker);

    vm.expectRevert("not authorized");

    credit.updateCredit(
        borrower,
        900,
        50000,
        uint64(block.timestamp + 1 days)
    );
}
Enter fullscreen mode Exit fullscreen mode

These tests should exist before the ROFL worker is connected.


27. Testing the Risk Engine Independently

Do not make the TEE the only place where the model can be tested.

The scoring function should have deterministic unit tests.

describe("credit scoring", () => {

    it("rewards repayment history", () => {

        const score = calculateScore({
            walletAge: 0.8,
            activity: 0.7,
            collateral: 0.9,
            utilization: 0.2,
            liquidations: 0,
            repayment: 1,
            volatility: 0.1
        });

        expect(score).toBeGreaterThan(700);
    });

});
Enter fullscreen mode Exit fullscreen mode

You want three separate testing layers:

model tests
     +
contract tests
     +
integration tests
Enter fullscreen mode Exit fullscreen mode

Otherwise a failure becomes difficult to localize.


28. What If the ROFL Application Is Compromised?

This is where threat modeling becomes more interesting.

You should not assume:

"ROFL means the application can never be wrong."

Instead, design defense in depth.

For example:

ROFL
  ↓
credit limit
  ↓
Sapphire policy guard
  ↓
maximum protocol-wide limit
  ↓
application-level collateral check
  ↓
execution
Enter fullscreen mode Exit fullscreen mode

Suppose ROFL accidentally returns:

$900,000
Enter fullscreen mode Exit fullscreen mode

The Sapphire contract could enforce:

require(
    limit <= protocolMaximum,
    "limit exceeds protocol cap"
);
Enter fullscreen mode Exit fullscreen mode

This gives us two independent layers:

off-chain underwriting
+
on-chain safety bounds
Enter fullscreen mode Exit fullscreen mode

That is much stronger than putting every security assumption into one component.


29. Private Policy, Public Bounds

This leads to an especially useful pattern.

The detailed policy can remain confidential:

private model
private thresholds
private weights
private features
Enter fullscreen mode Exit fullscreen mode

while broad safety bounds remain public:

maximum credit:
$50,000

maximum authorization:
24 hours

maximum utilization:
80%
Enter fullscreen mode Exit fullscreen mode

Now users can understand the outer safety envelope without learning the exact underwriting algorithm.

This is often the right compromise between privacy and auditability.


30. What Can Be Publicly Audited?

A confidential application still needs auditability.

We can publish:

contract addresses
ROFL application identifier
model hash
policy hash
deployment version
authorization expiry
protocol-wide limits
upgrade events
revocation events
Enter fullscreen mode Exit fullscreen mode

We don't need to publish:

every borrower score
private features
exact risk weights
individual underwriting inputs
Enter fullscreen mode Exit fullscreen mode

This produces a useful distinction:

Public integrity

People can verify:

Which version is running?

Private computation

People cannot necessarily see:

What was the user's exact internal score?

Public safety envelope

People can still verify:

What is the maximum authority the system can grant?

That is a much more nuanced security model than simply calling the application "private."


31. Adding an Audit Manifest

I would also put a machine-readable manifest in the repository:

{
  "protocol": "private-credit",
  "model": {
    "version": "credit-v1",
    "hash": "0x..."
  },
  "policy": {
    "version": "policy-v3",
    "hash": "0x..."
  },
  "limits": {
    "maximumCreditUsd": 50000,
    "maximumAuthorizationSeconds": 86400
  },
  "execution": {
    "runtime": "Oasis Sapphire",
    "worker": "Oasis ROFL"
  }
}
Enter fullscreen mode Exit fullscreen mode

The manifest doesn't expose the policy.

It identifies it.

That gives you reproducibility without sacrificing confidential parameters.


32. Why This Is More Interesting Than a Private Database

A private database can hide information.

But it does not necessarily establish a decentralized authorization path.

Our design instead creates a chain:

public evidence
      ↓
confidential computation
      ↓
confidential authorization
      ↓
bounded execution
Enter fullscreen mode Exit fullscreen mode

That is the interesting primitive.

The blockchain remains useful for:

  • settlement
  • collateral
  • ownership
  • authorization
  • audit trails

while confidential infrastructure handles:

  • private feature extraction
  • sensitive models
  • proprietary policy
  • individual risk state

This is exactly the kind of workload for which combining Sapphire and ROFL becomes compelling.


33. Where This Architecture Could Be Used

Although we built a credit engine, the same architecture can support other systems.

Private insurance underwriting

Public collateral and claims data can feed a confidential underwriting engine.

Institutional DeFi

An institution can receive a private risk classification without publishing its entire portfolio.

Merchant financing

A merchant's transaction history can influence a credit line without exposing the complete scoring model.

Private derivatives

Risk limits can remain confidential while settlement remains on-chain.

DAO treasury controls

A confidential policy engine could evaluate risk before authorizing certain treasury actions.

Cross-chain credit

ROFL can collect evidence from multiple chains and aggregate it before producing a Sapphire authorization.

The last one is particularly interesting.

The public blockchains become data sources.

Sapphire becomes the confidential policy layer.

ROFL becomes the computation and connectivity layer.


34. Extending It to Multiple Chains

Suppose the borrower has assets on:

Ethereum
Base
Arbitrum
Optimism
Enter fullscreen mode Exit fullscreen mode

ROFL can collect the relevant state from each network.

flowchart LR

    ETH[Ethereum]
    BASE[Base]
    ARB[Arbitrum]
    OP[Optimism]

    ROFL[ROFL Risk Engine]

    ETH --> ROFL
    BASE --> ROFL
    ARB --> ROFL
    OP --> ROFL

    ROFL --> S[Sapphire]

    S --> CREDIT[Private Credit Controller]

Now the user does not need to manually consolidate the information.

The risk engine sees the portfolio as a whole.

Again, this doesn't make those chains private.

It means the aggregation and derived risk state can remain confidential.


35. The Important Trust Assumption

There is no magic here.

A responsible implementation must clearly document its assumptions.

Our confidentiality depends on the security properties of the trusted execution environment and the Oasis infrastructure supporting ROFL/Sapphire.

The application also has to ensure that its deployed binaries, configuration, key handling, authorization logic, and update process are correct.

ROFL's current architecture explicitly uses TEEs, remote attestation, application registration, and Oasis consensus as parts of this trust model.

Therefore, the correct claim is not:

"Nobody can ever know this information."

A better claim is:

"The protocol is designed so that sensitive computation and state are processed inside the confidential execution environment, subject to the documented TEE and protocol trust assumptions."

That is the statement I would put in the README.


36. Production Hardening Checklist

Before calling this production-ready, I would add:

  • [ ] Sapphire-specific access-control primitives
  • [ ] ROFL application authentication
  • [ ] nonce/replay protection
  • [ ] authorization expiry
  • [ ] model versioning
  • [ ] policy versioning
  • [ ] policy hashes
  • [ ] maximum protocol-wide credit limits
  • [ ] stale-data detection
  • [ ] RPC failover
  • [ ] oracle freshness checks
  • [ ] deterministic model tests
  • [ ] Foundry contract tests
  • [ ] integration tests
  • [ ] deployment manifest
  • [ ] upgrade controls
  • [ ] emergency revocation
  • [ ] monitoring
  • [ ] attestation verification
  • [ ] key rotation procedure
  • [ ] incident-response procedure

The most important lesson is that confidentiality does not eliminate ordinary smart-contract security.

You still need:

access control
+
replay protection
+
bounds
+
expiry
+
monitoring
+
upgrade discipline
Enter fullscreen mode Exit fullscreen mode

Privacy is another security property, not a replacement for the others.


37. The Bigger Design Pattern

The most useful takeaway from this project isn't the credit score.

It's the architecture.

We can think of a modern decentralized application as three different layers:

                 ┌───────────────────────┐
                 │      PUBLIC WORLD     │
                 │                       │
                 │ Ethereum / Base / L2s │
                 │ settlement / assets   │
                 └───────────┬───────────┘
                             │
                             ▼
                 ┌───────────────────────┐
                 │   CONFIDENT COMPUTE   │
                 │                       │
                 │       ROFL TEE        │
                 │ private algorithms    │
                 │ external data         │
                 │ sensitive processing  │
                 └───────────┬───────────┘
                             │
                             ▼
                 ┌───────────────────────┐
                 │ CONFIDENT ON-CHAIN    │
                 │                       │
                 │       Sapphire       │
                 │ private state        │
                 │ authorization         │
                 │ policy enforcement    │
                 └───────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The mistake is trying to force all three jobs into the same environment.

Public blockchains are excellent at settlement and transparent state.

Confidential runtimes are useful when state should not be globally observable.

TEE-backed off-chain computation is useful when workloads are too expensive, complex, or externally connected to execute directly on-chain.

ROFL and Sapphire allow those pieces to be composed.


38. Final Thoughts

The most interesting privacy applications aren't necessarily applications where everything is hidden.

They are applications where the confidentiality boundary is deliberate.

For our credit engine:

Collateral:
PUBLIC

Transactions:
PUBLIC

Settlement:
PUBLIC

Risk features:
CONFIDENTIAL

Underwriting model:
CONFIDENTIAL

Policy parameters:
CONFIDENTIAL

Credit decision:
CONFIDENTIAL

Safety limits:
PUBLIC

Model identity:
PUBLIC

Authorization status:
MINIMALLY DISCLOSED
Enter fullscreen mode Exit fullscreen mode

That is a much more useful way to think about confidential blockchain applications.

Instead of asking:

"Can we put this entire application on a private chain?"

ask:

"Which information actually needs confidentiality?"

Then ask:

"Where should that information be processed?"

And finally:

"What is the smallest result that needs to cross the confidentiality boundary?"

In this architecture, Base provides the public evidence layer.

ROFL performs the expensive and sensitive underwriting computation.

Sapphire provides confidential state and policy enforcement.

The resulting system doesn't try to make public blockchains disappear.

It makes them more useful by adding a confidential computation layer where transparency would otherwise become a liability.


Repository Blueprint

The complete project can follow this structure:

private-credit/
├── contracts/
│   ├── PrivateCredit.sol
│   ├── CreditController.sol
│   ├── PolicyRegistry.sol
│   └── interfaces/
│       └── IPrivateCredit.sol
│
├── rofl/
│   ├── src/
│   │   ├── index.ts
│   │   ├── base.ts
│   │   ├── features.ts
│   │   ├── scoring.ts
│   │   └── sapphire.ts
│   ├── Dockerfile
│   └── package.json
│
├── scripts/
│   ├── deploy.ts
│   ├── seed.ts
│   └── request-underwriting.ts
│
├── test/
│   ├── PrivateCredit.t.sol
│   ├── CreditController.t.sol
│   └── integration.t.sol
│
├── frontend/
│
├── manifest/
│   └── protocol.json
│
├── foundry.toml
├── hardhat.config.ts
└── README.md
Enter fullscreen mode Exit fullscreen mode

The official Oasis documentation provides the current Sapphire development documentation, ROFL workflow, and examples for integrating ROFL applications with confidential Sapphire contracts.

For developers wanting to experiment with the pattern, the official ROFL documentation also includes a trustless price-oracle example that demonstrates a ROFL application communicating with a confidential Sapphire contract.


Resources

Oasis Sapphire
https://docs.oasis.io/build/sapphire/

Develop on Sapphire
https://docs.oasis.io/build/sapphire/develop/

Oasis ROFL
https://docs.oasis.io/build/rofl/

ROFL Workflow and Architecture
https://docs.oasis.io/build/rofl/workflow/

Encrypted Events
https://docs.oasis.io/build/sapphire/develop/encrypted-events/

Oasis Build Documentation
https://docs.oasis.io/build/

The official documentation is the best place to verify the current APIs and deployment workflow because the Oasis tooling continues to evolve.

The core idea is simple:

Don't make the blockchain private.

Make the parts that shouldn't be public confidential and make the boundary between public evidence and private computation explicit.

That is where confidential Web3 infrastructure becomes much more interesting than simply hiding a smart contract's storage.

Top comments (0)