Testing Confidential Behavior
Local emulators cannot test OXN's confidentiality features — encrypted calldata, encrypted storage, signed queries, and confidential precompiles all require the OXN runtime. This page covers how to build integration tests that actually exercise those paths.
Test target
Point your integration tests at OXN Testnet:
RPC URL: https://rpc.bout.network
Chain ID: 186
Yes, this means your tests use real network calls, real gas, and real (testnet) tokens from the faucet. That's the point — the whole confidentiality stack only exists on the real network.
Basic integration test structure
const { expect } = require("chai");
const { ethers } = require("hardhat");
const CHAIN_ID = 186;
describe("MyToken confidential behavior [integration]", function () {
let token;
let signer;
let rawProvider; // unwrapped, for reading raw on-chain data
this.timeout(120_000); // network calls are slower than local
before(async function () {
// The Hardhat plugin auto-wraps this signer for encryption
[signer] = await ethers.getSigners();
const chainId = (await ethers.provider.getNetwork()).chainId;
expect(chainId).to.equal(CHAIN_ID); // guard against running on local network
// Deploy
const Token = await ethers.getContractFactory("MyToken");
token = await Token.deploy(ethers.parseUnits("1000000", 18), { gasLimit: 3_000_000n });
await token.waitForDeployment();
// Raw provider for reading unencrypted on-chain data
rawProvider = new ethers.JsonRpcProvider("https://rpc.bout.network");
});
it("transfer calldata is encrypted on-chain", async function () {
const recipient = "0x000000000000000000000000000000000000dEaD";
const tx = await token.transfer(recipient, ethers.parseUnits("100", 18),
{ gasLimit: 500_000n });
await tx.wait();
// Fetch the raw on-chain transaction
const onchain = await rawProvider.getTransaction(tx.hash);
// If encrypted, tx.data starts with 0xa? (CBOR map). If plain, 0xa9059cbb (transfer).
const firstByte = parseInt(onchain.data.slice(2, 4), 16);
expect(firstByte & 0xe0).to.equal(0xa0);
expect(onchain.data.startsWith("0xa9059cbb")).to.equal(false);
});
});
Two things worth calling out:
- The
firstByte & 0xe0 === 0xa0assertion is the reliable way to confirm encryption. It checks that the tx data is a CBOR envelope. - The
rawProvideris the unwrapped ethers provider — you need it to see the raw on-chain data. If you useethers.provider(which is wrapped), you get decrypted data and the assertion is meaningless.
Testing signed queries
Signed queries populate msg.sender inside a view function. Verify the pattern works:
it("myBalance() returns caller's balance via signed query", async function () {
const [caller, other] = await ethers.getSigners();
// Deploy a token that assigns balances to specific accounts
const token = /* ... */;
// Wrap the contract with the caller's signer — this triggers signed queries
const asCaller = token.connect(caller);
const balance = await asCaller.myBalance();
expect(balance).to.equal(expectedCallerBalance);
// Same call, different signer
const asOther = token.connect(other);
const otherBalance = await asOther.myBalance();
expect(otherBalance).to.equal(0); // other has no tokens
});
The key move is token.connect(signer) — passing a signer (not a provider) is what tells the SDK to issue a signed query.
Testing that logs are decryptable only by the caller
it("emitted event is decryptable to the caller only", async function () {
const [caller, other] = await ethers.getSigners();
const tx = await token.connect(caller).transfer(other.address, 100, { gasLimit: 500_000n });
const receipt = await tx.wait();
// The caller's signer can decrypt events emitted by this call
const decodedByCaller = receipt.logs
.map((log) => { try { return token.interface.parseLog(log); } catch { return null; } })
.filter(Boolean);
expect(decodedByCaller.length).to.be.greaterThan(0);
// A raw provider reading the same tx sees ciphertext
const rawReceipt = await rawProvider.getTransactionReceipt(tx.hash);
for (const log of rawReceipt.logs) {
// topics and data are ciphertext bytes, not the standard Transfer signature
expect(log.topics[0]).to.not.equal(ethers.id("Transfer(address,address,uint256)"));
}
});
Managing testnet accounts in CI
Testnet integration tests need funded accounts. Options:
- Fund a set of accounts once, reuse them. Store the private keys as encrypted CI secrets.
- Fund a "manager" account, have it fund test accounts as needed. The manager holds a balance; each test run drains a small amount to fresh accounts.
- Use the faucet in CI. The faucet is not designed for programmatic use; contact the team via Support if you have a legitimate CI need.
Do not commit private keys, even for testnet accounts — the accounts may be reused across environments over time, or accidentally used on mainnet.
Testing gas costs
Since gas is set explicitly on OXN, you cannot assert exact gas values via estimation in tests the way you would on Ethereum. Test with generous fixed gasLimit values and assert that the transaction succeeds; separately, use receipt.gasUsed to log observed costs for regression tracking, but do not fail the build on small variations.
Common integration-test failures
"Test hangs then times out." The RPC endpoint or faucet may be under load. Retry, or check the status page.
"tx.data is plain even though I used wrapped signer." Verify that your Hardhat config require("@oasisprotocol/sapphire-hardhat") after require("@nomicfoundation/hardhat-toolbox"). Order matters.
"myBalance() returns 0 for all accounts." You sent a plain eth_call instead of a signed query. Ensure the Contract was constructed with a signer, not a provider.
Next steps
- Deploying Contracts
- Debugging & Troubleshooting
- Testing Contracts Locally — the local layer this complements