Private rules. Public commitments. Bounded emergency powers.
Treasury contracts usually have an uncomfortable design problem.
The treasury needs enough transparency that anyone can verify what happened, but it doesn't necessarily want to publish every piece of information that determines why a particular action was allowed.
Imagine a protocol treasury that manages $10 million across several assets.
The governance community might want the following guarantees:
- no transaction can exceed a daily spending limit
- a single operator cannot drain the treasury
- emergency powers automatically expire
- large transfers require additional approval
- certain destinations are prohibited
- policy changes are delayed
- every executed action leaves an auditable commitment
But there is another requirement that is rarely discussed:
the policy itself can be sensitive.
Publishing every internal threshold, destination rule, operational limit, or pending treasury decision can reveal how the treasury is managed before the organization is ready to disclose it.
A conventional Ethereum contract doesn't give you much room here.
- Its storage is public.
- Its calldata is public.
- Its events are public.
- Its execution path is observable.
That is where Oasis Sapphire becomes interesting.
Sapphire is an EVM-compatible confidential execution environment. Contract state is encrypted, and Sapphire provides end-to-end encryption for transactions and calls through its client tooling.
In this tutorial, we'll build something more interesting than a private vault:
a confidential treasury policy engine that evaluates whether a proposed treasury action is allowed, while publishing a cryptographic commitment that allows the system to remain auditable without revealing the policy itself.
The goal isn't to make the treasury invisible.
The goal is to separate:
what happened
from
the private policy that caused it to be allowed.
What We're Building
Our example system has three components:
The Policy Engine runs on Oasis Sapphire.
It stores sensitive treasury rules such as:
- maximum transfer
- daily budget
- per-destination limits
- allowed assets
- emergency expiry
- operator permissions
- risk tiers
A user submits a proposed action.
The policy engine evaluates it.
If the action is allowed, the engine produces an execution authorization and a cryptographic commitment.
The actual treasury operation can then happen on a public EVM chain.
That gives us a hybrid architecture:
CONFIDENTIAL
Oasis Sapphire
┌────────────────────┐
│ │
│ Private Policy │
│ ↓ │
│ Policy Evaluation │
│ ↓ │
│ Authorization │
│ ↓ │
│ Commitment │
│ │
└─────────┬──────────┘
│
│ authorization
▼
PUBLIC
Ethereum / Base
┌────────────────────┐
│ │
│ Treasury execution │
│ │
│ Events │
│ │
│ Asset movement │
│ │
└────────────────────┘
This is useful because not everything needs confidentiality.
- The amount actually transferred can remain public.
- The destination can remain public.
- The treasury balance can remain public.
What we're protecting is the policy machinery around those actions.
Why Not Just Put Everything in a Multisig?
A natural question is:
Why not use Safe + a timelock + a multisig?
For many treasuries, that's exactly the right answer.
This tutorial isn't claiming otherwise.
The problem appears when the treasury's operational policy becomes more complicated than:
3 of 5 signers approve transaction
Consider a treasury with rules like:
USDC:
daily limit = X
ETH:
daily limit = Y
New recipient:
requires additional approval
Known recipient:
lower approval requirement
Emergency mode:
withdrawals capped
expires after N blocks
Strategy contract:
allowed to rebalance
but not withdraw to arbitrary EOAs
Treasury operator:
can execute
but cannot modify policy
A traditional public contract can encode all of this.
But now everyone can inspect it.
That isn't automatically bad.
However, there are situations where revealing the exact operational policy is undesirable.
For example:
- market-making organizations
- grant programs
- institutional treasury operations
- DAO operational budgets
- private investment vehicles
- infrastructure operators
- trading organizations
The interesting design question becomes:
Can the control logic be confidential while the result of the control logic remains auditable?
That's what we'll explore.
Architecture
Our final architecture looks like this:
There are four important boundaries here.
Boundary 1: Policy
The policy is private.
Boundary 2: Authorization
An operator isn't automatically trusted simply because they can call the policy engine.
Boundary 3: Execution
The public treasury only accepts valid authorization artifacts.
Boundary 4: Auditability
Every accepted decision produces enough public information to reconstruct the fact that authorization occurred without necessarily exposing the private policy.
Repository
We'll use Foundry for the Solidity side and TypeScript for integration tests.
The repository structure will look like this:
confidential-treasury-policy/
├── src/
│ ├── ConfidentialPolicy.sol
│ ├── PolicyTypes.sol
│ ├── CommitmentRegistry.sol
│ ├── ExecutionGateway.sol
│ ├── Treasury.sol
│ └── interfaces/
│ ├── IPolicyEngine.sol
│ └── ITreasury.sol
│
├── script/
│ ├── Deploy.s.sol
│ ├── ConfigurePolicy.s.sol
│ └── RegisterGateway.s.sol
│
├── test/
│ ├── PolicyEngine.t.sol
│ ├── Emergency.t.sol
│ ├── Replay.t.sol
│ ├── Authorization.t.sol
│ └── Invariants.t.sol
│
├── integration/
│ ├── sapphire.ts
│ ├── treasury.ts
│ └── execute.ts
│
├── lib/
│ └── forge-std/
│
├── foundry.toml
├── package.json
└── README.md
Start with:
forge init confidential-treasury-policy
cd confidential-treasury-policy
For Sapphire development, Oasis provides Foundry and Hardhat integration because Sapphire is EVM-compatible.
1. Defining the Policy Model
Before writing Solidity, define what a policy actually controls.
We'll use:
struct Policy {
uint256 maxPerTransaction;
uint256 dailyLimit;
uint256 emergencyLimit;
uint64 emergencyUntil;
mapping(address => bool) allowedAsset;
mapping(address => bool) allowedRecipient;
bool emergencyMode;
}
But Solidity mappings inside structs make copying and hashing awkward.
So we'll store the components separately.
Our first version needs:
- maximum transaction amount
- daily budget
- emergency budget
- emergency expiry
- allowed assets
- allowed recipients
- policy version
The important thing is that policy versioning is explicit.
We'll never silently mutate a policy.
Instead:
Policy v1
↓
Policy v2
↓
Policy v3
Every authorization records the version that produced it.
That becomes extremely useful during audits.
2. PolicyTypes.sol
Create:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
library PolicyTypes {
enum Decision {
DENIED,
APPROVED
}
enum RiskLevel {
NORMAL,
ELEVATED,
EMERGENCY
}
struct Request {
address asset;
address recipient;
uint256 amount;
uint256 nonce;
bytes32 requestId;
}
struct DecisionRecord {
Decision decision;
RiskLevel risk;
uint256 policyVersion;
uint256 expiresAt;
bytes32 commitment;
}
}
The Request represents what someone wants to do.
The DecisionRecord represents what the policy engine decided.
Notice something important:
The policy itself isn't included in DecisionRecord.
That's intentional.
We don't want every authorization to reveal the complete policy.
3. The Confidential Policy Contract
Now create:
src/ConfidentialPolicy.sol
The basic contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./PolicyTypes.sol";
contract ConfidentialPolicy {
using PolicyTypes for *;
address public governance;
address public executionGateway;
uint256 public policyVersion;
uint256 private maxPerTransaction;
uint256 private dailyLimit;
uint256 private emergencyLimit;
uint64 private emergencyUntil;
bool private emergencyMode;
mapping(address => bool) private allowedAssets;
mapping(address => bool) private allowedRecipients;
mapping(bytes32 => bool) private consumedRequests;
constructor(address _governance) {
governance = _governance;
policyVersion = 1;
}
}
- The sensitive fields are private.
- On a normal EVM, private does not mean confidential.
- Anyone can inspect Ethereum storage.
On Sapphire, however, contract state is encrypted and accessible only through the contract's confidential execution environment.
This distinction is fundamental.
Don't write:
"private makes Solidity storage secret."
It doesn't.
The confidentiality comes from Sapphire's execution and encrypted state model.
4. Access Control
We need three roles:
- governance
- operator
- execution gateway
Governance changes policy.
Operators submit actions.
The execution gateway consumes authorizations.
Start with:
address public operator;
modifier onlyGovernance() {
require(msg.sender == governance, "not governance");
_;
}
modifier onlyOperator() {
require(msg.sender == operator, "not operator");
_;
}
modifier onlyGateway() {
require(msg.sender == executionGateway, "not gateway");
_;
}
Then:
function setOperator(address newOperator)
external
onlyGovernance
{
require(newOperator != address(0), "zero operator");
operator = newOperator;
}
And:
function setExecutionGateway(address gateway)
external
onlyGovernance
{
require(gateway != address(0), "zero gateway");
executionGateway = gateway;
}
This separation matters.
The person allowed to propose treasury actions shouldn't automatically be allowed to modify the policy.
5. Adding Assets and Recipients
We'll use allowlists.
function setAssetAllowed(
address asset,
bool allowed
)
external
onlyGovernance
{
allowedAssets[asset] = allowed;
}
And:
function setRecipientAllowed(
address recipient,
bool allowed
)
external
onlyGovernance
{
allowedRecipients[recipient] = allowed;
}
At this point we already have something interesting.
Suppose the treasury works with five counterparties.
On Ethereum, you could publish the complete list of addresses.
On Sapphire, the policy engine can keep that operational relationship confidential.
That doesn't necessarily mean the final transfer is private.
It means the decision infrastructure doesn't have to be.
6. The Core Policy Function
Now we get to the heart of the system.
function evaluate(
PolicyTypes.Request calldata request
)
external
onlyOperator
returns (PolicyTypes.DecisionRecord memory)
{
require(
!consumedRequests[request.requestId],
"request already consumed"
);
require(
allowedAssets[request.asset],
"asset not allowed"
);
require(
allowedRecipients[request.recipient],
"recipient not allowed"
);
uint256 limit = maxPerTransaction;
if (emergencyMode) {
require(
block.timestamp < emergencyUntil,
"emergency expired"
);
limit = emergencyLimit;
}
require(
request.amount <= limit,
"policy limit"
);
consumedRequests[request.requestId] = true;
bytes32 commitment = keccak256(
abi.encode(
request.requestId,
request.asset,
request.recipient,
request.amount,
policyVersion
)
);
return PolicyTypes.DecisionRecord({
decision: PolicyTypes.Decision.APPROVED,
risk: emergencyMode
? PolicyTypes.RiskLevel.EMERGENCY
: PolicyTypes.RiskLevel.NORMAL,
policyVersion: policyVersion,
expiresAt: block.timestamp + 60,
commitment: commitment
});
}
There are several important properties here.
The authorization is:
- tied to one request
- tied to one policy version
- short-lived
- single-use
- bounded by the policy
- committed cryptographically
7. Why Nonces Aren't Enough
A common mistake in authorization systems is using only:
uint256 nonce;
That protects against simple replay.
But a robust authorization should also bind:
- request ID
- asset
- recipient
- amount
- policy version
Otherwise an authorization for:
USDC → Alice → 10,000
could potentially be interpreted incorrectly by another component as:
USDC → Bob → 10,000
or:
USDC → Alice → 100,000
The commitment should therefore be domain-specific.
A stronger commitment looks like:
bytes32 constant DOMAIN =
keccak256("CONFIDENTIAL_TREASURY_POLICY_V1");
Then:
bytes32 commitment = keccak256(
abi.encode(
DOMAIN,
address(this),
block.chainid,
policyVersion,
request.requestId,
request.asset,
request.recipient,
request.amount,
request.nonce
)
);
Now an authorization isn't just a hash of data.
It's a hash of:
what system + what chain + what policy + what request + what action.
This dramatically reduces cross-context replay risk.
8. The Execution Gateway
Now we need a bridge between confidential authorization and public execution.
Create:
ExecutionGateway.sol
The gateway receives an authorization.
Conceptually:
The gateway is deliberately dumb.
This is important.
We don't want the public execution layer to recreate the entire private policy.
The policy engine says:
this action is authorized.
The gateway says:
this authorization has not expired, has not been consumed, and corresponds to this execution.
9. Authorization Expiry
Never create a confidential authorization that lasts indefinitely.
Our authorization has:
uint256 expiresAt;
And:
require(
block.timestamp <= authorization.expiresAt,
"authorization expired"
);
Why?
Imagine an operator obtains authorization at 10:00.
At 10:30, governance changes the policy.
If the old authorization remains valid forever, the policy change doesn't actually revoke the previous decision.
Short-lived authorizations reduce that window.
10. Emergency Mode
Now let's implement something more interesting.
The treasury needs emergency powers.
But we don't want:
emergency = true;
to mean:
administrator can do anything.
Instead, emergency mode changes a small number of parameters.
function activateEmergency(
uint64 duration
)
external
onlyGovernance
{
require(duration <= 1 hours, "duration too long");
emergencyMode = true;
emergencyUntil =
uint64(block.timestamp) + duration;
}
And:
function emergencyStatus()
external
view
returns (
bool active,
uint64 until
)
{
return (
emergencyMode &&
block.timestamp < emergencyUntil,
emergencyUntil
);
}
The key property is:
emergency authority expires automatically.
That is much stronger than documenting:
emergency mode should only be used temporarily.
11. Emergency Powers Should Be Narrow
Suppose normal mode allows:
maximum transfer = 100,000 USDC
Emergency mode might allow:
maximum transfer = 10,000 USDC
That sounds backwards at first.
But that's exactly the point.
An emergency system should often reduce capability, not increase it.
A safer mental model is:
Normal operation
│
│ incident
▼
Restricted operation
│
│ expiry
▼
Normal operation
Not:
Normal operation
│
▼
Admin can do anything
│
▼
Hopefully someone turns it off
12. The Policy Version
Whenever governance changes a policy:
function bumpPolicyVersion()
internal
{
policyVersion++;
}
Then:
function setMaxPerTransaction(
uint256 value
)
external
onlyGovernance
{
maxPerTransaction = value;
policyVersion++;
}
This creates a useful property:
Every authorization can answer:
Which policy version approved this?
That gives auditors a historical dimension.
13. Public Commitments
Now comes the most important architectural idea.
We want to expose enough information for an auditor to know that a decision occurred.
But we don't necessarily want to expose the entire policy.
We can publish:
- request ID
- policy version
- commitment
- execution timestamp
- execution result
without publishing:
- full policy
- private allowlists
- internal limits
- operational metadata
The commitment is:
H(
domain,
chain ID,
policy version,
request,
authorization
)
The public chain therefore sees a cryptographic fingerprint.
Think of it as:
The commitment doesn't reveal the input.
But if the system later needs to disclose the policy, an auditor can verify that the disclosed data corresponds to the commitment.
14. Why Not Just Emit the Policy?
Because events are public.
This is one of the most important Sapphire-specific considerations.
Sapphire contract state is encrypted, but Solidity events are not automatically private. Oasis explicitly documents that event payloads are public unless you use an encrypted-event design.
So this is dangerous:
event PolicyUpdated(
uint256 maxPerTransaction,
uint256 dailyLimit
);
Even if the variables themselves are confidential, that event leaks them.
Instead:
event PolicyCommitted(
uint256 indexed version,
bytes32 indexed commitment
);
Now the event is useful for indexing without exposing the policy.
15. Confidential Events
If you actually need to publish sensitive event payloads to an authorized consumer, Sapphire provides encrypted-event patterns.
For example, Oasis documents patterns based on symmetric keys and ECDH-derived keys for encrypted events.
The conceptual architecture becomes:
This gives you two information channels:
Public channel
Safe metadata.
Confidential channel
Authorized operational information.
That distinction is useful for enterprise systems.
16. The Daily Spending Limit
So far we've only implemented a per-transaction limit.
Let's add a daily limit.
The naïve implementation is:
uint256 spentToday;
uint256 currentDay;
and:
if (block.timestamp / 1 days != currentDay) {
currentDay = block.timestamp / 1 days;
spentToday = 0;
}
Then:
require(
spentToday + request.amount <= dailyLimit,
"daily limit"
);
spentToday += request.amount;
This works, but introduces a subtle privacy consideration.
If the execution cost or state access pattern depends strongly on private values, an observer may infer information.
Oasis explicitly warns that confidential execution can still leak information through timing, gas usage, and storage access patterns.
So confidential programming isn't:
"Put variables behind private."
It is:
"Design the entire observable execution surface."
17. Constant-Shaped Execution
Suppose you have:
if (amount > secretLimit) {
revert();
}
The result itself is private.
But the gas behavior may differ between branches.
A sufficiently capable observer may be able to infer information.
This is why confidential systems need a different mindset.
Instead of thinking:
state is secret
think:
state + execution + gas + storage access + events + timing
all contribute to the observable surface.
18. Building the Treasury
Now create a simple treasury.
contract Treasury {
address public immutable policyGateway;
constructor(address _policyGateway) {
policyGateway = _policyGateway;
}
modifier onlyGateway() {
require(
msg.sender == policyGateway,
"not gateway"
);
_;
}
function executeERC20(
address token,
address recipient,
uint256 amount
)
external
onlyGateway
{
IERC20(token).transfer(
recipient,
amount
);
}
}
Notice that the treasury itself doesn't know:
- daily limit
- recipient classification
- emergency rules
- operator permissions
It only knows:
the policy gateway is authorized to execute.
This separation makes the system easier to reason about.
19. Why the Treasury Should Not Reimplement Policy
Imagine this:
Policy Engine:
max transfer = 100k
Treasury:
max transfer = 250k
Now we have two policy engines.
That's dangerous.
The policy engine should be the authority.
The treasury should enforce the minimum mechanical conditions needed for safe execution.
This is a classic principle:
Don't duplicate security-critical policy across independent components unless you explicitly want defense in depth.
If you do duplicate it, test that the policies are equivalent.
20. Replaying an Authorization
Now imagine an attacker obtains:
authorization:
recipient = attacker
amount = 100,000
and tries to submit it twice.
The gateway needs:
mapping(bytes32 => bool) usedCommitments;
Then:
require(
!usedCommitments[authorization.commitment],
"already used"
);
usedCommitments[
authorization.commitment
] = true;
The ordering matters.
Do not mark it consumed after an external call.
Use:
check
↓
mark consumed
↓
external execution
rather than:
check
↓
external execution
↓
mark consumed
Otherwise a reentrant path may attempt to reuse it.
21. The Complete Execution Flow
The complete system now looks like:
This is our core protocol.
22. Adding an Action Type
Don't make authorization only about transfers.
Define:
enum ActionType {
TRANSFER,
REBALANCE,
GRANT,
REVOKE,
EMERGENCY
}
Then include:
ActionType actionType;
in the request.
Now the commitment binds the exact operation.
For example:
bytes32 commitment = keccak256(
abi.encode(
DOMAIN,
action.actionType,
action.asset,
action.recipient,
action.amount,
policyVersion,
action.nonce
)
);
This prevents an authorization for one semantic operation from being interpreted as another.
23. Adding Role Separation
Now we can make the architecture more realistic.
Governance
│
├── modifies policy
│
└── controls emergency mode
Operator
│
└── proposes actions
Policy Engine
│
└── evaluates actions
Gateway
│
└── executes authorized actions
Treasury
│
└── owns assets
No single component needs every permission.
That matters because the most dangerous failure mode in treasury architecture is often not a bug.
It's excessive authority.
24. A Real Use Case
Let's make this concrete.
Imagine a protocol treasury with:
- $4M USDC
- $2M ETH
- $1M governance token
- $500k operational budget
The organization has three operating groups:
- Operations
- Liquidity
- Grants
The treasury wants to keep its operational rules private.
Why?
Because publishing:
Liquidity strategy:
rebalance above 4%
maximum venue exposure = 22%
preferred counterparties = [...]
can reveal operational strategy.
Instead, governance can configure the policy privately.
The public chain sees:
Treasury:
0x...
Authorization:
commitment 0xabc...
policy version 17
executed at block ...
The actual treasury transfer remains observable.
But the private policy that approved it doesn't have to become public.
25. Adding Policy Commitments
We can make policy updates themselves auditable.
Instead of publicly exposing:
dailyLimit = 500,000
maxPerTx = 100,000
we calculate:
bytes32 policyCommitment = keccak256(
abi.encode(
policyVersion,
maxPerTransaction,
dailyLimit,
emergencyLimit,
emergencyUntil
)
);
Then emit:
event PolicyCommitted(
uint256 indexed version,
bytes32 indexed commitment
);
Now the public chain has:
version 17
commitment 0x...
An auditor with access to the policy can verify:
hash(disclosed_policy) == commitment
This is a powerful pattern.
The chain doesn't need to know the policy.
It only needs to know which policy commitment was active.
26. Why Hashing Isn't Magic
There is an important limitation.
A commitment does not automatically make something trustworthy.
Suppose the policy contains:
recipient = 0x123
and someone claims the commitment represents:
recipient = 0x456
Without knowing the original policy, you can't infer the truth.
The commitment provides:
integrity
not:
availability
or:
honesty
That's why governance, attestation, access control, and operational procedures still matter.
Confidentiality isn't a substitute for governance.
27. Policy Disclosure
Suppose the organization wants to reveal a policy after an incident.
They can publish:
{
"version": 17,
"maxPerTransaction": "100000000000",
"dailyLimit": "500000000000",
"emergencyLimit": "10000000000",
"emergencyUntil": 1780000000
}
and:
policyCommitment = keccak256(canonical(policy))
Anyone can recompute it.
That turns confidentiality into selective disclosure.
This is particularly useful for audits.
28. Canonical Encoding Matters
Don't hash arbitrary JSON.
These two objects:
{
"limit": 100,
"version": 1
}
and:
{
"version": 1,
"limit": 100
}
may represent the same logical policy but have different byte representations.
For cryptographic commitments, define a canonical encoding.
For Solidity:
keccak256(
abi.encode(
policyVersion,
maxPerTransaction,
dailyLimit,
emergencyLimit,
emergencyUntil
)
);
Keep the ordering fixed.
Document it.
Never change it silently.
29. Testing the Policy Engine
This is where the tutorial becomes more than a demo.
We need tests for:
- authorization
- replay
- expiry
- policy versioning
- emergency expiry
- asset allowlist
- recipient allowlist
- amount limits
- cross-domain replay
A basic test:
function testRejectsUnauthorizedRecipient()
public
{
PolicyTypes.Request memory request =
PolicyTypes.Request({
asset: USDC,
recipient: attacker,
amount: 1_000e6,
nonce: 1,
requestId: keccak256("request-1")
});
vm.expectRevert("recipient not allowed");
policy.evaluate(request);
}
30. Replay Test
function testCannotReuseRequest()
public
{
PolicyTypes.Request memory request =
validRequest();
policy.evaluate(request);
vm.expectRevert("request already consumed");
policy.evaluate(request);
}
Replay testing should not be an afterthought.
Authorization systems fail surprisingly often because developers test:
valid authorization
but not:
- same authorization
- different transaction
31. Expiry Test
function testAuthorizationExpires()
public
{
PolicyTypes.Request memory request =
validRequest();
PolicyTypes.DecisionRecord memory decision =
policy.evaluate(request);
vm.warp(decision.expiresAt + 1);
vm.expectRevert("authorization expired");
gateway.execute(
request,
decision
);
}
The exact mechanics depend on how the public gateway receives and verifies the authorization, but the invariant is simple:
An authorization must never become more powerful with age.
32. Emergency Expiry Test
function testEmergencyAutomaticallyExpires()
public
{
governanceActivateEmergency(30 minutes);
vm.warp(
block.timestamp + 31 minutes
);
assertFalse(
policy.emergencyStatus()
);
}
Notice we're not testing:
someone called disableEmergency()
We're testing:
time itself disables emergency mode
That's stronger.
33. Property-Based Thinking
Now let's move beyond individual tests.
A useful invariant is:
No authorization can approve more than the current policy allows.
Another:
Every authorization is unique.
Another:
An expired authorization can never execute.
Another:
An emergency authorization cannot exceed the emergency limit.
You can express these as invariant tests.
Conceptually:
∀ authorization A:
A.amount <= policyLimit(A.policyVersion)
A.expiresAt > now
consumed(A) == false
before execution
consumed(A) == true
after successful execution
This is the kind of testing that makes the project interesting for experienced Solidity developers.
34. Testing Confidentiality Is Different
Normal Solidity testing asks:
Did the function return the correct result?
Confidential Solidity needs additional questions:
- Did an event leak private information?
- Does gas usage reveal a secret branch?
- Does storage access reveal which user was selected?
- Does a public getter expose confidential state?
- Does an error message reveal policy information?
- Does an unauthorized eth_call reveal anything?
Oasis specifically documents that unsigned eth_call requests have msg.sender == address(0) on Sapphire, which is an important difference from assumptions developers may carry over from transparent EVMs.
So authentication needs to be designed deliberately.
35. View Functions Need Special Attention
Suppose we write:
function getPolicy()
external
view
returns (uint256)
{
return maxPerTransaction;
}
Congratulations.
You just defeated the point of the confidential policy.
The state may be encrypted internally, but your getter explicitly reveals it.
A confidential contract should instead expose only what the caller is authorized to see.
For example:
function policyVersion()
external
view
returns (uint256)
{
return policyVersion;
}
And potentially:
function policyCommitment()
external
view
returns (bytes32)
{
return currentPolicyCommitment;
}
36. The Public / Private Interface
A good way to design the system is to write two API surfaces.
Public API
policyVersion()
policyCommitment()
executionCommitment()
Confidential API
evaluate()
getPrivatePolicy()
setPrivatePolicy()
getInternalRiskState()
The public interface answers:
What can everyone know?
The confidential interface answers:
What should authorized participants know?
That distinction is useful well beyond treasuries.
37. Client-Side Sapphire Integration
For TypeScript applications, Oasis provides packages for Sapphire-aware clients. For ethers v6, the official integration is @oasisprotocol/sapphire-ethers-v6, which wraps providers and signers so calls and transactions can be encrypted appropriately.
Install:
npm install ethers
npm install @oasisprotocol/sapphire-ethers-v6
Then:
import {
JsonRpcProvider,
Wallet
} from "ethers";
import {
wrapEthersProvider,
wrapEthersSigner
} from "@oasisprotocol/sapphire-ethers-v6";
Create the provider:
const rawProvider = new JsonRpcProvider(
process.env.SAPPHIRE_RPC
);
const provider =
wrapEthersProvider(rawProvider);
And the signer:
const rawSigner =
new Wallet(
process.env.PRIVATE_KEY!,
rawProvider
);
const signer =
wrapEthersSigner(rawSigner);
This is important because ordinary EVM tooling isn't automatically enough for confidential transaction handling.
38. Requesting Authorization
Our TypeScript application can now construct a request.
const request = {
asset: USDC,
recipient: recipient,
amount: parseUnits("5000", 6),
nonce: Date.now(),
requestId: keccak256(
toUtf8Bytes(
crypto.randomUUID()
)
)
};
Then:
const result =
await policy.evaluate(request);
The returned authorization is passed to the execution gateway.
The public execution layer never needs the complete policy.
39. Network Configuration
For development, Oasis provides Sapphire Localnet and Testnet. The current official documentation lists:
Sapphire Mainnet: 23294
Sapphire Testnet: 23295
Sapphire Localnet: 23293
and documents the corresponding RPC configuration.
For example:
const sapphire = {
chainId: 23294,
rpcUrl: process.env.SAPPHIRE_RPC!
};
For local development:
const localSapphire = {
chainId: 23293,
rpcUrl: "http://localhost:8545"
};
Do not put real production secrets or production state on Sapphire Testnet. Oasis explicitly warns that Testnet state can be wiped and that confidentiality there should not be treated as guaranteed.
40. Running Sapphire Localnet
For fast development, Oasis provides a local Sapphire environment through Docker.
A typical workflow is:
docker run \
-it \
-p 8544-8548:8544-8548 \
ghcr.io/oasisprotocol/sapphire-localnet
Then point your test client at:
The advantage is that you can repeatedly:
- deploy
- test
- destroy
- restart
without depending on a public testnet.
41. Deployment
Your foundry.toml can contain:
[profile.default]
src = "src"
script = "script"
test = "test"
out = "out"
libs = ["lib"]
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
Then:
forge build
Run:
forge test -vvv
Once the local tests pass, deploy to Sapphire Testnet.
The exact RPC and account configuration should come from the current Oasis network documentation rather than hardcoding old endpoints into the repository.
42. Contract Verification
A production-oriented repository shouldn't stop at "deployment successful".
Verify the contract source and metadata.
Oasis documents Sourcify-based verification for Sapphire deployments, including selecting Sapphire Mainnet or Sapphire Testnet and providing the Foundry/Hardhat build information.
That matters particularly for confidential systems.
People need to be able to inspect:
- what code is running
- what compiler version was used
- what dependencies were included
- what deployment corresponds to which policy version
Confidentiality should never become an excuse for opaque engineering.
43. Threat Model
Now let's attack our own system.
Threat 1: Compromised operator
The operator can request actions.
But the policy engine limits:
- asset
- recipient
- amount
- frequency
- policy state
So the operator should not automatically be able to drain the treasury.
Threat 2: Replay
Mitigated by:
- requestId
- nonce
- commitment
- consumedRequests
Threat 3: Stale authorization
Mitigated by:
- expiresAt
- policyVersion
Threat 4: Emergency abuse
Mitigated by:
- maximum duration
- restricted limits
- automatic expiry
- separate governance authority
Threat 5: Event leakage
Mitigated by:
commitments instead of private values
and encrypted events where confidential event payloads are genuinely required.
Threat 6: Getter leakage
Mitigated by:
minimal public view API
Threat 7: Policy inference
This is harder.
Potential leakage can come from:
- gas usage
- execution timing
- storage access patterns
- error behavior
- frequency of policy decisions
Sapphire's documentation explicitly discusses these side-channel considerations.
This is why a serious confidential application needs a threat model beyond Solidity reentrancy.
44. What Sapphire Does Not Solve
This is worth stating clearly.
Sapphire does not magically solve:
bad business logic
It doesn't solve:
compromised governance
It doesn't solve:
malicious operators
It doesn't solve:
incorrect authorization semantics
It doesn't make an unsafe contract safe.
Instead, it changes the confidentiality properties of the execution environment.
The correct mental model is:
Ethereum EVM
+
confidential execution
+
encrypted state
+
encrypted client communication
not:
Ethereum but everything is magically private.
Oasis explicitly documents the differences developers need to account for when moving from Ethereum to Sapphire.
45. Why Keep the Treasury Public?
You might ask:
If the policy is private, why not keep the whole treasury on Sapphire?
You could, depending on the application.
But a hybrid architecture has an attractive property:
private decision
+
public settlement
The treasury can remain compatible with:
- public explorers
- existing ERC-20 infrastructure
- accounting systems
- public reporting
- external auditors
- existing DeFi protocols
The private component becomes the decision layer, rather than replacing the entire financial stack.
46. The Hybrid Architecture
The resulting design is:
This is the core pattern of the tutorial.
47. Policy as a Product
Once this works, the policy engine can become more general.
Instead of:
TreasuryPolicy
we can build:
PolicyEngine
with rules such as:
- allow(asset, recipient, amount)
- allow(strategy, market, exposure)
- allow(operator, action)
- allow(grant, recipient, budget)
- allow(payment, vendor, amount)
Now you're not building a treasury contract.
You're building a confidential authorization layer.
That's a much more general primitive.
48. Extending the Policy Model
For example:
struct Rule {
bytes32 ruleId;
uint256 limit;
uint64 validFrom;
uint64 validUntil;
bool enabled;
}
And:
mapping(bytes32 => Rule) private rules;
A request can contain:
- rule ID
- action type
- asset
- recipient
- amount
- context
- nonce
The policy engine then evaluates:
request
+
private rules
+
private state
authorization
This resembles a traditional enterprise policy engine.
Except the enforcement point is onchain.
49. Multi-Stage Authorization
We can go further.
Instead of:
operator → policy → execution
use:
operator
↓
risk policy
↓
budget policy
↓
emergency policy
↓
execution policy
↓
authorization
Each layer can have a separate policy version.
That gives auditors a much richer model.
50. Confidential Policies for Organizations
This pattern becomes especially interesting for organizations that don't want to publish operational rules.
For example:
Market maker
Private:
- inventory thresholds
- venue preferences
- rebalance bands
Public:
actual settlements
Grant organization
Private:
- evaluation thresholds
- reviewer assignments
- internal limits
Public:
- approved grants
- Infrastructure provider
Private:
- internal spending policies
- operator capabilities
- failover thresholds
Public:
- settlement events
- DAO treasury
Private:
- operational policy
- counterparty classifications
- emergency thresholds
Public:
final treasury movements
The same architecture applies to all four.
51. What About Governance?
Governance itself can remain public.
For example:
Proposal #182
↓
Policy commitment changed
↓
Version 18 activated
The actual contents of the operational policy can remain confidential until disclosure is appropriate.
This creates a useful separation:
Governance transparency
≠
Operational transparency
A DAO can disclose:
Governance approved policy version 18.
without necessarily disclosing:
every operational threshold contained in version 18.
52. The Audit Model
An auditor can be granted access to the confidential policy.
They receive:
Policy v18
Then calculate:
commitment(policy v18)
and compare it to:
onchain commitment(v18)
If they match:
Policy disclosed
↓
Hash
↓
Matches historical commitment
↓
Policy integrity established
That is a powerful property.
The system doesn't require everyone to know the policy.
It only requires that the organization cannot later pretend that a different policy was active.
53. Why This Is Different From a Normal Multisig
A multisig answers:
Did enough authorized humans sign this transaction?
Our policy engine answers:
Did this action satisfy a private, versioned, machine-enforced policy?
Those are different problems.
You can combine them.
For example:
Private Policy
↓
Authorization
↓
3-of-5 Multisig
↓
Public Treasury
Now the multisig provides human governance while the confidential policy engine provides operational constraints.
54. A Stronger Architecture
The production version could therefore look like:
Now compromise of one operator isn't enough.
Compromise of one signer isn't necessarily enough.
And compromise of the public gateway doesn't reveal the confidential policy.
55. Operational Monitoring
A production system should monitor:
- policy version changes
- emergency activation
- emergency expiry
- authorization volume
- failed authorization attempts
- repeated requests
- gateway failures
- unexpected recipients
But again, don't accidentally publish sensitive information.
A monitoring event might say:
PolicyVersionChanged(18)
rather than:
PolicyChanged(
maxTransfer = 123456,
internalRecipient = ...
)
Monitoring needs to respect the same confidentiality boundary as the application.
56. A Useful Rule
Here's the rule I'd use when designing a Sapphire application:
If a value is confidential, assume every output derived from that value may also be confidential.
That includes:
- events
- errors
- gas
- timing
- return values
- storage access
- authorization frequency
This is one of the biggest differences between writing ordinary Solidity and writing confidential Solidity.
57. Production Checklist
Before deploying a system like this, I'd run through:
*Contract security
*
- Reentrancy protection
- Replay protection
- Authorization expiry
- Domain separation
- Policy versioning
- Explicit role separation
- Emergency expiry
- Maximum emergency duration
- No arbitrary delegatecall
- No unrestricted asset approval
- Safe ERC-20 handling
Confidentiality
- No private data in normal events
- No sensitive public getters
- No sensitive revert strings
- Constant-shaped branches where necessary
- Storage access patterns reviewed
- Gas leakage considered
- Client-side encryption configured
Governance
- Policy changes are versioned
- Policy commitments are recorded
- Emergency powers are bounded
- Policy administrator is separate from operator
- Public disclosure process exists
Infrastructure
- Sapphire-aware client
- Key management
- RPC redundancy
- Contract verification
- Deployment reproducibility
- Monitoring
- Incident response
58. The Most Important Design Principle
The biggest lesson from this architecture isn't actually about Sapphire.
It's about separating confidentiality from authority.
A private policy doesn't automatically make an authority trustworthy.
A public commitment doesn't automatically make an organization honest.
A multisig doesn't automatically make a policy correct.
Each mechanism solves a different problem.
Think of the system as four independent properties:
SECURITY MODEL
┌─────────────────────────┐
│ │
│ AUTHORITY │
│ Who is allowed? │
│ │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ │
│ POLICY │
│ What is permitted? │
│ │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ │
│ CONFIDENTIALITY │
│ Who can know it? │
│ │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ │
│ AUDITABILITY │
│ What can be proven? │
│ │
└─────────────────────────┘
Oasis Sapphire is particularly useful for the third property.
But a serious protocol needs all four.
59. Where This Pattern Gets Interesting
Once you have:
private policy
+
public commitment
+
bounded authorization
+
public settlement
you can start building much more sophisticated systems.
For example:
Confidential treasury routing
The routing policy is private, but final settlement is public.
Private grant allocation
Eligibility rules remain private while successful grants become publicly auditable.
Institutional transaction policy
Employees can initiate transactions without learning the complete treasury policy.
Private market operations
Strategy constraints remain confidential while settlement remains verifiable.
DAO emergency controls
Emergency thresholds remain private while emergency activation and expiry are publicly observable.
The common architecture is always:
PRIVATE DECISION
↓
CRYPTOGRAPHIC COMMITMENT
↓
PUBLIC EXECUTION
↓
PUBLIC AUDIT TRAIL
60. Final Architecture
At the end of the tutorial, we have built:
The key property is that the public chain doesn't need to understand every private decision.
It only needs to enforce the final authorization boundary.
Conclusion
Most blockchain systems treat transparency as a binary property:
public chain = transparent.
But that's not really how production systems work.
There are multiple layers of information:
- assets
- transactions
- permissions
- policies
- strategies
- identities
- relationships
- internal state
Not all of those need the same visibility.
A treasury can be transparent about its assets while keeping operational policy confidential.
A DAO can publicly record that a policy changed without publishing every internal parameter.
An auditor can later verify that a disclosed policy corresponds to the policy commitment that existed when a transaction was authorized.
And an emergency mechanism can be technically bounded instead of relying on documentation saying:
"This function should only be used in emergencies."
That's the architectural idea behind the system we built here:
PRIVATE
│
▼
Policy Evaluation
│
▼
Authorization
│
▼
Commitment
│
▼
PUBLIC
│
▼
Treasury Settlement
│
▼
Audit Trail
Oasis Sapphire is useful here because it provides an EVM-compatible environment where contract state and client communication can be confidential, while still allowing developers to use familiar Solidity and Ethereum tooling.
But the important lesson isn't simply:
"Put your treasury on Sapphire."
It's:
Design confidentiality as a boundary around sensitive decision-making, while keeping the resulting financial actions independently auditable.
That distinction makes confidential blockchain applications much more interesting than simply hiding balances.
Repository Structure
The complete project from this tutorial should look like:
confidential-treasury-policy/
│
├── src/
│ ├── ConfidentialPolicy.sol
│ ├── PolicyTypes.sol
│ ├── CommitmentRegistry.sol
│ ├── ExecutionGateway.sol
│ ├── Treasury.sol
│ └── interfaces/
│ ├── IPolicyEngine.sol
│ └── ITreasury.sol
│
├── script/
│ ├── Deploy.s.sol
│ ├── ConfigurePolicy.s.sol
│ └── RegisterGateway.s.sol
│
├── test/
│ ├── PolicyEngine.t.sol
│ ├── Authorization.t.sol
│ ├── Emergency.t.sol
│ ├── Replay.t.sol
│ ├── Confidentiality.t.sol
│ └── Invariants.t.sol
│
├── integration/
│ ├── sapphire.ts
│ ├── policy.ts
│ └── execution.ts
│
├── foundry.toml
├── package.json
└── README.md
A natural next step would be to extend the prototype with multisig approval, encrypted events, policy disclosure proofs, and a public execution gateway on Base, turning the tutorial into a complete end-to-end treasury system rather than a single Sapphire contract.
Further reading
Oasis Sapphire developer documentation
Sapphire concepts and confidential state model
Sapphire security considerations
Sapphire encrypted events
Sapphire network information
Sapphire Foundry tooling
Top comments (0)