Deploying Contracts
Deployment on OXN is nearly identical to Ethereum, with a handful of practical adjustments. This page covers the patterns you actually use in production and the pitfalls that catch teams the first time.
The essentials
Every OXN contract deployment must:
- Compile with
evmVersion: "paris". - Set
gasLimitexplicitly (typically 3–5 million for a standard contract). - Use a wrapped signer if you want future calls to be encrypted — deployment itself is plain regardless.
- Wait for the transaction to be included before recording the address.
Hardhat deployment script
const hre = require("hardhat");
async function main() {
const [deployer] = await hre.ethers.getSigners();
console.log("Deployer:", deployer.address);
console.log("Balance :", (await hre.ethers.provider.getBalance(deployer.address)).toString());
const Contract = await hre.ethers.getContractFactory("MyContract");
const contract = await Contract.deploy(
/* constructor args */,
{ gasLimit: 3_000_000n }
);
await contract.waitForDeployment();
const address = await contract.getAddress();
console.log("Deployed:", address);
console.log("Tx hash :", contract.deploymentTransaction().hash);
// Verify code landed on-chain
const code = await hre.ethers.provider.getCode(address);
if (code === "0x") {
throw new Error("Deployment failed — no code at address");
}
console.log("Code size:", (code.length - 2) / 2, "bytes");
}
main().catch((e) => { console.error(e); process.exit(1); });
Run:
npx hardhat run scripts/deploy.js --network oxn_testnet
Foundry deployment
forge create src/MyContract.sol:MyContract \
--rpc-url oxn_testnet \
--private-key $PRIVATE_KEY \
--constructor-args "arg1" "arg2" \
--gas-limit 3000000
Or with a script:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Script.sol";
import "../src/MyContract.sol";
contract DeployScript is Script {
function run() external {
vm.startBroadcast();
new MyContract(/* args */);
vm.stopBroadcast();
}
}
Run:
forge script script/Deploy.s.sol --rpc-url oxn_testnet --broadcast --gas-limit 3000000
Deterministic deployment (CREATE2)
To get the same contract address across multiple environments, use CREATE2:
import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol";
contract Deployer {
function deploy(bytes32 salt, bytes memory bytecode) external returns (address) {
return Create2.deploy(0, salt, bytecode);
}
function computeAddress(bytes32 salt, bytes memory bytecode) external view returns (address) {
return Create2.computeAddress(salt, keccak256(bytecode));
}
}
With the same salt, bytecode, and deployer address, you get the same contract address on any EVM chain — useful for having identical contract addresses on multiple networks.
Contract constructors with encrypted initial state
Constructor arguments are part of deployment calldata, which is plain on OXN (bytecode + args are always public). So you cannot initialize secrets through the constructor.
Instead, initialize secrets in a separate encrypted call after deployment:
contract Vault {
address public owner;
bytes32 private secret;
constructor(address _owner) {
owner = _owner;
// Do NOT do: secret = _secret; <-- would be public in constructor calldata
}
function setSecret(bytes32 _secret) external {
require(msg.sender == owner, "not owner");
secret = _secret; // this call's calldata IS encrypted
}
}
Deploy plain, then call setSecret via a wrapped signer. The secret is then encrypted in storage.
Proxy patterns for upgradability
OpenZeppelin's Transparent and UUPS proxies work on OXN without modification. Each proxy contract's storage lives at the proxy's address; storage encryption keys derive from that address, so upgrades preserve the encrypted state.
const { upgrades } = require("hardhat");
const Impl = await ethers.getContractFactory("MyContractV1");
const proxy = await upgrades.deployProxy(Impl, [/* init args */], {
initializer: "initialize",
});
await proxy.waitForDeployment();
To upgrade:
const ImplV2 = await ethers.getContractFactory("MyContractV2");
await upgrades.upgradeProxy(proxyAddress, ImplV2);
Recording deployment addresses
Never hardcode contract addresses across environments. Recommended:
const fs = require("fs");
const path = require("path");
// ... deploy ...
const outputPath = path.join(__dirname, "..", "deployments", `${network.name}.json`);
const existing = fs.existsSync(outputPath) ? JSON.parse(fs.readFileSync(outputPath)) : {};
existing.MyContract = {
address: await contract.getAddress(),
txHash: contract.deploymentTransaction().hash,
deployedAt: new Date().toISOString(),
};
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(existing, null, 2));
console.log("Wrote", outputPath);
Commit deployments/oxn_testnet.json (and equivalents) to your repo. Every developer and every CI run reads the latest addresses from there.
Common deployment failures
"Contract deployed but getCode returns 0x." The transaction succeeded but the code did not land. Almost always a gasLimit too low. Bump to 5_000_000n and redeploy.
"Insufficient balance to pay fees." Faucet more TBLUAI or use gasPrice: 0n on testnet.
"Contract calls revert with invalid code." Compiled with the wrong evmVersion. Recompile with paris.
"Deployment succeeds but future calls fail with insufficient balance." The deployer had enough gas to deploy, but not enough for subsequent operations. Fund the account further, or use a dedicated deployer account.