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​