Deploy Your First Contract (Hardhat)
This guide walks through deploying a simple ERC-20 token to OXN Testnet using Hardhat. Estimated time: 5 minutes once your wallet is funded.
Prerequisites
- Node.js 18+ and npm
- A wallet with testnet TBLUAI — see Set Up Your Wallet and Get Testnet Tokens
- Your private key exported to an environment variable named
PRIVATE_KEY(or hardcoded inhardhat.config.jsfor local testing only)
1. Create the project
mkdir my-oxn-dapp && cd my-oxn-dapp
npm init -y
npm install --save-dev hardhat@^2.22.0
npx hardhat init
# Choose "Create a JavaScript project", accept all defaults
2. Install the OXN Hardhat plugin and OpenZeppelin
npm install --save-dev @oasisprotocol/sapphire-hardhat@^2.22.0 @openzeppelin/contracts
The plugin automatically wraps Hardhat's provider and signers, so every --network oxn_testnet transaction is encrypted end-to-end without any per-call opt-in.
3. Configure hardhat.config.js
Replace the generated file with:
require("@nomicfoundation/hardhat-toolbox");
require("@oasisprotocol/sapphire-hardhat"); // must be required AFTER toolbox
const PRIVATE_KEY = process.env.PRIVATE_KEY
|| "0x0000000000000000000000000000000000000000000000000000000000000001"; // replace!
module.exports = {
solidity: {
version: "0.8.24",
settings: {
evmVersion: "paris", // required — OXN does not yet support PUSH0
optimizer: { enabled: true, runs: 200 },
},
},
networks: {
oxn_testnet: {
url: "https://rpc.bout.network",
chainId: 186,
accounts: [PRIVATE_KEY],
},
},
};
evmVersion: "paris"— OXN's runtime does not yet support thePUSH0opcode introduced in Shanghai. Compiled contracts using PUSH0 will revert at runtime withinvalid code. See EVM Compatibility.require("@oasisprotocol/sapphire-hardhat")must come AFTERhardhat-toolbox— otherwise the toolbox overrides the plugin's provider wrapping.
4. Write the contract
Create contracts/MyToken.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor(uint256 initialSupply) ERC20("MyToken", "MTK") {
_mint(msg.sender, initialSupply);
}
}
5. Write the deployment script
Create scripts/deploy.js:
const hre = require("hardhat");
async function main() {
const [deployer] = await hre.ethers.getSigners();
console.log("deployer:", deployer.address);
const Token = await hre.ethers.getContractFactory("MyToken");
// Always pass an explicit gasLimit — this is the recommended path on OXN.
// Always pass gasLimit explicitly.
const token = await Token.deploy(
hre.ethers.parseUnits("1000000", 18),
{ gasLimit: 3_000_000n }
);
await token.waitForDeployment();
console.log("MyToken deployed at:", await token.getAddress());
}
main().catch((e) => { console.error(e); process.exit(1); });
6. Compile and deploy
export PRIVATE_KEY=0x... # your funded testnet account
npx hardhat compile
npx hardhat run scripts/deploy.js --network oxn_testnet
Expected output:
deployer: 0xYourAddress...
MyToken deployed at: 0xNewContractAddress...
Note the contract address — you'll use it in the next step. You can also view the deployment transaction on the block explorer.
7. Interact with the contract
Create scripts/transfer.js:
const hre = require("hardhat");
// Fill in the address from step 6
const TOKEN_ADDR = "0xYourDeployedTokenAddress";
const RECIPIENT = "0x000000000000000000000000000000000000dEaD";
async function main() {
const [sender] = await hre.ethers.getSigners();
const token = await hre.ethers.getContractAt("MyToken", TOKEN_ADDR);
console.log("sender:", sender.address);
console.log("balance before:", (await token.balanceOf(sender.address)).toString());
const tx = await token.transfer(
RECIPIENT,
hre.ethers.parseUnits("100", 18),
{ gasLimit: 500_000n }
);
console.log("tx hash:", tx.hash);
await tx.wait();
console.log("balance after:", (await token.balanceOf(sender.address)).toString());
}
main().catch((e) => { console.error(e); process.exit(1); });
Run it:
npx hardhat run scripts/transfer.js --network oxn_testnet
8. Verify the transaction was encrypted
The whole point of using OXN is that your transfer(recipient, amount) calldata is not visible on-chain. Confirm this by looking at the raw transaction:
Open explorer.bout.network, search your transaction hash, and check the Input Data field. You should see a hex blob starting with 0xa2 or 0xa3 (a CBOR envelope), not a plain 0xa9059cbb (which is the transfer function selector).
Or programmatically:
const raw = new hre.ethers.JsonRpcProvider("https://rpc.bout.network");
const onchain = await raw.getTransaction(tx.hash);
const firstByte = parseInt(onchain.data.slice(2, 4), 16);
console.assert((firstByte & 0xe0) === 0xa0, "not encrypted!");
If the assertion fails, the plugin's wrapping did not apply — check that you require("@oasisprotocol/sapphire-hardhat") after hardhat-toolbox in hardhat.config.js.
Recommended gas limits
Set gasLimit explicitly on every . Recommended defaults:
| Call type | Suggested gasLimit |
|---|---|
| Native TBLUAI transfer | 100_000n |
| ERC-20 transfer | 500_000n |
| Simple contract deploy | 3_000_000n |
| Large contract deploy | 5_000_000n+ |
Unused gas is refunded, so setting the limit generously is safe.
Common issues
"MyToken deployed at: null" — Deployment succeeded but getCode returned empty. Increase gasLimit to 5_000_000n and redeploy.
Contract calls revert with invalid code — You compiled with evmVersion newer than paris. Contracts using PUSH0 fail on some code paths. Set evmVersion: "paris" in hardhat.config.js and recompile.
"Insufficient balance to pay fees" — Faucet the address again, or use gasPrice: 0n for testing (accepted on the testnet chain layer).
Transactions succeed but data appears plain on-chain — The plugin wrapping did not apply. Verify that require("@oasisprotocol/sapphire-hardhat") is present in hardhat.config.js and comes after require("@nomicfoundation/hardhat-toolbox").
Next steps
- Confidentiality Model — understand what got encrypted and what did not
- Signed Queries — read private state as a specific caller
- Building a Confidential ERC-20 — hide balances and transfer amounts