Skip to main content

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 in hardhat.config.js for 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:

hardhat.config.js
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],
},
},
};
Two settings that matter
  1. evmVersion: "paris" — OXN's runtime does not yet support the PUSH0 opcode introduced in Shanghai. Compiled contracts using PUSH0 will revert at runtime with invalid code. See EVM Compatibility.
  2. require("@oasisprotocol/sapphire-hardhat") must come AFTER hardhat-toolbox — otherwise the toolbox overrides the plugin's provider wrapping.

4. Write the contract

Create contracts/MyToken.sol:

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:

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:

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.

Set gasLimit explicitly on every . Recommended defaults:

Call typeSuggested gasLimit
Native TBLUAI transfer100_000n
ERC-20 transfer500_000n
Simple contract deploy3_000_000n
Large contract deploy5_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