Skip to main content

Encrypted vs Plain Calls

Every transaction and every eth_call on OXN is either encrypted or plain. This is a client-side decision, not a chain-side one. The chain accepts both. You should understand both, because sending a plain call when you meant to send an encrypted one is the single most common way apps accidentally leak data.

Default behavior by client

ClientDefault behavior
Bare ethers.js (unwrapped)Plain. Calldata on-chain in the clear.
ethers.js + OXN wrapperEncrypted. Automatic X25519 + Deoxys-II end-to-end.
Bare viemPlain.
viem + OXN wrapperEncrypted.
Raw JSON-RPC (eth_sendRawTransaction)Plain unless you crafted an encrypted envelope yourself.
MetaMask directlyPlain, until your dApp constructs the encrypted transaction.

The rule: encryption never happens by accident on the client side. If you can't point to a specific wrapper or SDK function that encrypted the call, it went out in the clear.

What each mode looks like on-chain

Plain call: tx.data is the standard ABI encoding — 4-byte function selector followed by ABI-encoded arguments. Anyone reading the block sees transfer(0x…, 100) directly.

Encrypted call: tx.data is a CBOR envelope. Its first byte is in the range 0xa0–0xbf (CBOR map major type). The envelope contains the caller's ephemeral X25519 public key, a random nonce, and a Deoxys-II ciphertext. Nobody reading the block can extract the function selector or arguments.

You can verify which mode a transaction used by inspecting tx.data:

const tx = await provider.getTransaction(txHash);
const firstByte = parseInt(tx.data.slice(2, 4), 16);
const looksEncrypted = (firstByte & 0xe0) === 0xa0;

When to use each

Use encrypted for anything that must stay confidential. This is the default for most contract interactions on OXN. If in doubt, encrypt.

Use plain when:

  • The call is inherently public (querying a public oracle, calling a plain relay contract).
  • The receiving contract is not confidential and reading calldata is expected.
  • You are debugging and want to inspect the exact bytes on-chain.

Use plain accidentally never. The most common mistake is initializing a Signer from raw ethers.js and forgetting to wrap it. The transaction goes through, and now sensitive arguments live in a public block forever.

Forcing encryption

import { ethers } from "ethers";
import { wrapEthersProvider, wrapEthersSigner } from "<oxn-ethers-wrapper>";

const raw = new ethers.JsonRpcProvider("https://rpc.bout.network");
const provider = wrapEthersProvider(raw);
const signer = wrapEthersSigner(new ethers.Wallet(PRIVATE_KEY, raw));

const contract = new ethers.Contract(addr, abi, signer);
await contract.transfer(recipient, amount); // Encrypted end-to-end

Every call through this wrapped signer is encrypted. No opt-in per function.

Forcing plain

Use the raw provider / signer without wrapping:

const raw = new ethers.JsonRpcProvider("https://rpc.bout.network");
const signer = new ethers.Wallet(PRIVATE_KEY, raw); // NOT wrapped
const contract = new ethers.Contract(addr, abi, signer);
await contract.publicMethod(...); // Plain

There is no in-between. A wrapped signer always encrypts; a bare signer always sends plain.

Common bugs

"My tests worked locally but leaked in production." Local Hardhat network is a plain EVM without a real enclave. Any test that "works" against Hardhat network says nothing about whether your production calls are encrypted. Always run integration tests against a real OXN network.

"I wrapped the provider but forgot the signer." wrapEthersProvider only encrypts read calls (eth_call). wrapEthersSigner encrypts transactions. You almost always need both.

"My library reconstructs a fresh signer internally." Some libraries create their own Wallet from a mnemonic under the hood. Even if you passed a wrapped signer in, the library might use its own bare one. Read the library source, or check that tx.data looks like a CBOR envelope after every transaction in your integration tests.

Next steps