DEV Community

rayQu
rayQu

Posted on

Building a Confidential On-Chain Treasury Policy Engine with Oasis Sapphire

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     │
          │                    │
          └────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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:

  1. governance
  2. operator
  3. 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");
    _;
}
Enter fullscreen mode Exit fullscreen mode

Then:

function setOperator(address newOperator)
    external
    onlyGovernance
{
    require(newOperator != address(0), "zero operator");
    operator = newOperator;
}
Enter fullscreen mode Exit fullscreen mode

And:

function setExecutionGateway(address gateway)
    external
    onlyGovernance
{
    require(gateway != address(0), "zero gateway");
    executionGateway = gateway;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

And:

function setRecipientAllowed(
    address recipient,
    bool allowed
)
    external
    onlyGovernance
{
    allowedRecipients[recipient] = allowed;
}
Enter fullscreen mode Exit fullscreen mode

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
    });
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Then:

bytes32 commitment = keccak256(
    abi.encode(
        DOMAIN,
        address(this),
        block.chainid,
        policyVersion,
        request.requestId,
        request.asset,
        request.recipient,
        request.amount,
        request.nonce
    )
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

And:

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

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;
Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

And:

function emergencyStatus()
    external
    view
    returns (
        bool active,
        uint64 until
    )
{
    return (
        emergencyMode &&
        block.timestamp < emergencyUntil,
        emergencyUntil
    );
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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++;
}
Enter fullscreen mode Exit fullscreen mode

Then:

function setMaxPerTransaction(
    uint256 value
)
    external
    onlyGovernance
{
    maxPerTransaction = value;
    policyVersion++;
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

Even if the variables themselves are confidential, that event leaks them.

Instead:

event PolicyCommitted(
    uint256 indexed version,
    bytes32 indexed commitment
);
Enter fullscreen mode Exit fullscreen mode

Now the event is useful for indexing without exposing the policy.

15. Confidential Events

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

and:

if (block.timestamp / 1 days != currentDay) {
    currentDay = block.timestamp / 1 days;
    spentToday = 0;
}
Enter fullscreen mode Exit fullscreen mode

Then:

require(
    spentToday + request.amount <= dailyLimit,
    "daily limit"
);

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Suppose you have:

if (amount > secretLimit) {
    revert();
}
Enter fullscreen mode Exit fullscreen mode

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
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Then include:

ActionType actionType;
Enter fullscreen mode Exit fullscreen mode

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
    )
);
Enter fullscreen mode Exit fullscreen mode

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
    )
);
Enter fullscreen mode Exit fullscreen mode

Then emit:

event PolicyCommitted(
    uint256 indexed version,
    bytes32 indexed commitment
);
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Suppose the organization wants to reveal a policy after an incident.

They can publish:

{
  "version": 17,
  "maxPerTransaction": "100000000000",
  "dailyLimit": "500000000000",
  "emergencyLimit": "10000000000",
  "emergencyUntil": 1780000000
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

and:

{
  "version": 1,
  "limit": 100
}
Enter fullscreen mode Exit fullscreen mode

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
    )
);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

30. Replay Test

function testCannotReuseRequest()
    public
{
    PolicyTypes.Request memory request =
        validRequest();

    policy.evaluate(request);

    vm.expectRevert("request already consumed");

    policy.evaluate(request);
}

Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

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()
    );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

And potentially:

function policyCommitment()
    external
    view
    returns (bytes32)
{
    return currentPolicyCommitment;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then:

import {
  JsonRpcProvider,
  Wallet
} from "ethers";

import {
  wrapEthersProvider,
  wrapEthersSigner
} from "@oasisprotocol/sapphire-ethers-v6";
Enter fullscreen mode Exit fullscreen mode

Create the provider:

const rawProvider = new JsonRpcProvider(
  process.env.SAPPHIRE_RPC
);

const provider =
  wrapEthersProvider(rawProvider);

Enter fullscreen mode Exit fullscreen mode

And the signer:

const rawSigner =
  new Wallet(
    process.env.PRIVATE_KEY!,
    rawProvider
  );

const signer =
  wrapEthersSigner(rawSigner);
Enter fullscreen mode Exit fullscreen mode

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()
    )
  )
};
Enter fullscreen mode Exit fullscreen mode

Then:

const result =
  await policy.evaluate(request);
Enter fullscreen mode Exit fullscreen mode

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!
};
Enter fullscreen mode Exit fullscreen mode

For local development:

const localSapphire = {
  chainId: 23293,
  rpcUrl: "http://localhost:8545"
};
Enter fullscreen mode Exit fullscreen mode

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:

http://localhost:8545

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 = ...
)
Enter fullscreen mode Exit fullscreen mode

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?   │
    │                         │
    └─────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)