This tutorial shows how to add privacy + verifiable off-chain compute to any EVM dApp by combining:
- Sapphire: Oasis’ confidential EVM (encrypted calldata + confidential contract state).
- OPL (Oasis Privacy Layer): message bridge pattern to “bolt privacy onto” existing EVM apps by offloading sensitive logic to Sapphire.
- ROFL (Runtime Off-Chain Logic): a TEE-managed app runtime (containers or single binaries) with secrets stored via built-in KMS and on-chain management through Sapphire.
Real use case
You run a lending/access-control dApp on a Home chain (Ethereum/L2). You want a wallet "Trust Score" derived from sensitive signals:
- private allowlists (KYC provider, internal risk flags),
- off-chain identity reputation (GitHub activity, paid subscription, enterprise customer),
- model inference results (fraud classifier).
You don’t want to leak:
- raw signals,
- model features,
- user identity mappings,
- or scoring logic.
Design goal:
Home chain contract gets a public, verifiable decision (e.g., score bucket/allow/deny/rate tier) while sensitive scoring happens privately and verifiably.
Architecture
High-level diagram
What each piece does:
- Home chain contract: minimal "public" state, accepts final decision and enforces it.
- Sapphire contract (the enclave): holds private state (scores, salts, user-to-signal mappings), verifies authorized sources, emits public commitments only.
- ROFL app: runs your scoring code in a TEE; keeps API keys/model weights/feature rules in ROFL secrets; posts updates on-chain.
ROFL apps are explicitly designed to run in TEEs and be managed via Sapphire, with built-in secrets support.
Repo blueprint
Monorepo layout:
oasis-trustscore-oracle/
README.md
.env.example
contracts/
hardhat.config.ts
package.json
contracts/
HomeHost.sol
SapphireEnclave.sol
scripts/
deploy-home.ts
deploy-sapphire.ts
link-endpoints.ts
tasks/
send-opl-message.ts
rofl/
compose.yaml
rofl.yaml
app/
Dockerfile
requirements.txt
main.py
scorer.py
signals.py
diagrams/
architecture.mmd
sequence.mmd
Networks & prerequisites
Sapphire Testnet settings (for dev)
From Oasis docs, Sapphire Testnet:
- RPC: https://testnet.sapphire.oasis.io
- Chain ID (decimal): 23295
- WSS: wss://testnet.sapphire.oasis.io/ws
You’ll also want the Sapphire wrapper for encrypted transactions (TS wrapper / ethers v6 wrapper).
ROFL prerequisites
ROFL quickstart expects:
- a containerized app (OCI image),
- Oasis CLI (latest),
- testnet tokens from the faucet
Step 1 - Smart contracts: Home chain + Sapphire enclave
1A) SapphireEnclave.sol (runs confidentially)
This contract runs on Sapphire and receives cross-chain requests via OPL. It:
- stores scores privately,
- accepts updates from ROFL,
- sends a public decision back to Home chain.
Minimal skeleton based on the official OPL SDK pattern (Enclave + endpoints)
// contracts/contracts/SapphireEnclave.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Enclave, Result, autoswitch} from "@oasisprotocol/sapphire-contracts/contracts/OPL.sol";
contract SapphireEnclave is Enclave {
// Private by design on Sapphire: state + calldata are confidential.
mapping(address => uint256) private score; // example: 0..100
// Optional: authorize a ROFL app address (or a set of them).
address public roflUpdater;
constructor(address homeContract, string memory homeChain)
Enclave(homeContract, autoswitch(homeChain))
{
registerEndpoint("requestScore", onRequestScore);
}
function setRoflUpdater(address _rofl) external {
// replace with proper access control for real deployments
roflUpdater = _rofl;
}
// ROFL posts score updates (confidential write).
function updateScore(address user, uint256 newScore) external returns (bool) {
require(msg.sender == roflUpdater, "not authorized");
score[user] = newScore;
return true;
}
// OPL endpoint: called when Home chain requests a score decision.
function onRequestScore(bytes calldata args) internal returns (Result) {
address user = abi.decode(args, (address));
uint256 s = score[user];
// Only return a coarse bucket publicly (avoid leaking exact score).
uint8 bucket =
s >= 80 ? 3 :
s >= 50 ? 2 :
s >= 20 ? 1 : 0;
// Send decision back to Home chain (publicly observable).
postMessage("scoreDecision", abi.encode(user, bucket));
return Result.Success;
}
}
Why this works:
- Sapphire is an EVM ParaTime built for confidential computation.
- OPL uses message bridges so your Home chain can trigger private computation on Sapphire.
1B) HomeHost.sol (your existing dApp stays here)
Based on official OPL Host pattern.
// contracts/contracts/HomeHost.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Host, Result} from "@oasisprotocol/sapphire-contracts/contracts/OPL.sol";
contract HomeHost is Host {
mapping(address => uint8) public lastBucket;
constructor(address sapphireContract) Host(sapphireContract) {
registerEndpoint("scoreDecision", onScoreDecision);
}
// User or app requests a score check.
function requestScore(address user) external payable {
postMessage("requestScore", abi.encode(user));
}
// Called by OPL bridge after Sapphire computed the decision.
function onScoreDecision(bytes calldata args) internal returns (Result) {
(address user, uint8 bucket) = abi.decode(args, (address, uint8));
lastBucket[user] = bucket;
return Result.Success;
}
function canBorrow(address user) external view returns (bool) {
return lastBucket[user] >= 2;
}
}
Step 2 - Hardhat setup (with Sapphire encryption)
Install the OPL/Sapphire contracts library and Sapphire tooling:
- OPL SDK lives in @oasisprotocol/sapphire-contracts.
- Sapphire wrapper guidance (TypeScript wrapper / ethers wrapper).
Your hardhat.config.ts should include:
- Home chain network (your choice),
- Sapphire testnet using the official RPC and chain id
Example network params:
// contracts/hardhat.config.ts (snippet)
networks: {
sapphire_testnet: {
url: "https://testnet.sapphire.oasis.io",
chainId: 23295,
accounts: [process.env.PRIVATE_KEY!],
},
// home: { ... } // e.g. sepolia/arbitrum etc.
}
Step 3 - Link OPL endpoints (the "wiring")
OPL requires that each side knows the other side and which endpoints exist (1–1 paired contracts).
In your scripts:
- deploy SapphireEnclave on Sapphire
- deploy HomeHost on Home chain
- set each other’s address + register endpoints appropriately
(Exactly how you do step (3) depends on your preferred OPL bridge route; the docs list multiple integration methods, including OPL SDK wrapper around Celer IM.)
Step 4 - ROFL app: compute score inside a TEE and push to Sapphire
ROFL gives you:
- TEE execution (SGX/TDX)
- app lifecycle managed via Oasis
- built-in secrets stored on-chain and injected securely
- ability to sign and submit special transactions back to Sapphire
ROFL gives you:
- TEE execution (SGX/TDX)
- app lifecycle managed via Oasis
- built-in secrets stored on-chain and injected securely
- ability to sign and submit special transactions back to Sapphire
4A) ROFL app code (Python example)
rofl/app/main.py:
- loads secrets (API keys)
- fetches signals
- runs scoring
- calls updateScore(user, score) on Sapphire
# rofl/app/main.py
import os
import time
from web3 import Web3
SAPPHIRE_RPC = os.environ["SAPPHIRE_RPC"]
ENCLAVE_ADDR = Web3.to_checksum_address(os.environ["ENCLAVE_ADDR"])
ROFL_SIGNER_KEY = os.environ["ROFL_SIGNER_KEY"] # stored as ROFL secret
ABI = [...] # contract ABI for updateScore
w3 = Web3(Web3.HTTPProvider(SAPPHIRE_RPC))
acct = w3.eth.account.from_key(ROFL_SIGNER_KEY)
enclave = w3.eth.contract(address=ENCLAVE_ADDR, abi=ABI)
def compute_score(user: str) -> int:
# pull private signals using secrets (tokens), then score
# keep it deterministic if you want auditability
return 82
def submit_score(user: str, s: int):
tx = enclave.functions.updateScore(Web3.to_checksum_address(user), s).build_transaction({
"from": acct.address,
"nonce": w3.eth.get_transaction_count(acct.address),
"gas": 500_000,
})
signed = acct.sign_transaction(tx)
return w3.eth.send_raw_transaction(signed.rawTransaction)
def main():
users = os.environ["USERS"].split(",")
while True:
for u in users:
s = compute_score(u)
txh = submit_score(u, s)
print("updated", u, s, txh.hex())
time.sleep(60)
if __name__ == "__main__":
main()
4B) Containerize + ROFLize
ROFL quickstart is literally the flow:
- oasis rofl init
- oasis rofl create --network testnet
- oasis rofl build
- oasis rofl secret set ...
- oasis rofl update
- oasis rofl deploy
Example secrets:
# inside rofl/
echo -n "0xYOUR_SIGNER_KEY" | oasis rofl secret set ROFL_SIGNER_KEY -
echo -n "https://testnet.sapphire.oasis.io" | oasis rofl secret set SAPPHIRE_RPC -
echo -n "0xYourEnclaveAddress" | oasis rofl secret set ENCLAVE_ADDR -
ROFL secrets are explicitly supported and meant for sensitive env vars like API keys.
Sequence diagram (end-to-end)
This is the core pattern: public app stays on its chain, sensitive logic runs confidentially, and you only export a minimal decision.
Threat model notes
- Don’t export raw scores: export buckets, booleans, or commitments.
- Secrets: keep API keys/model access tokens in ROFL secrets (not in Docker image).
- RPC trust: Oasis docs explicitly warn RPC endpoints are a trust point (rate limits, censorship, MITM); run your own if your security bar is high.
- Testnet disclaimer: Sapphire testnet confidentiality is not guaranteed and state may be wiped; treat it as public.


Top comments (0)