Four bridged (wrapped) ERC20 tokens built on OpenZeppelin Contracts 5.x, where the deployer is the admin and can mint and burn.
| Contract | Name | Symbol | Decimals |
|---|---|---|---|
BridgeUSDT |
Bridge USDT | USDT |
18 |
BridgeBNB |
Bridge BNB | BNB |
18 |
BridgeETH |
Bridge ETH | ETH |
18 |
BridgeBTC |
Bridge BTC | BTC |
18 |
All four inherit everything from contracts/token/BridgeToken.sol; the child contracts
only fix the name, symbol and decimals.
Built from OpenZeppelin ERC20, ERC20Burnable, ERC20Pausable, ERC20Permit
and AccessControl:
- Mint —
mint(to, amount)andmintBatch(recipients[], amounts[]), gated onMINTER_ROLE. - Burn —
adminBurn(from, amount)destroys any account's balance with no allowance, gated onBURNER_ROLE. - Holder burn — the standard
burn(amount)andburnFrom(owner, amount)stay allowance-based, so holders can exit the bridge themselves. - Pause —
pause()/unpause()freeze all transfers, mints and burns, gated onPAUSER_ROLE. - Permit — EIP-2612 gasless approvals.
- Rescue —
rescueERC20(token, to, amount)sweeps tokens sent to the contract by mistake, gated onDEFAULT_ADMIN_ROLE.
The address you pass to the constructor receives all four roles at deployment:
| Role | Can do |
|---|---|
DEFAULT_ADMIN_ROLE |
Grant/revoke every role, call rescueERC20 |
MINTER_ROLE |
mint, mintBatch |
BURNER_ROLE |
adminBurn on any account |
PAUSER_ROLE |
pause, unpause |
Because roles are separable, you can keep DEFAULT_ADMIN_ROLE on your own wallet
and later grant MINTER_ROLE / BURNER_ROLE to the bridge relayer contract:
token.grantRole(token.MINTER_ROLE(), bridgeRelayer);
token.revokeRole(token.MINTER_ROLE(), myOldWallet);npm install
cp .env.example .env # then fill it in
npm run build
npm testRequires Node 22+. Tested here on Node 24 / Windows.
npm run build is compile, then node scripts/write-init-code-hash.ts, then compile
again. The second pass is not superstition: the AMM in contracts/univ3 hardcodes a hash
of its own compiled pool bytecode, so the first compile is what the hash is computed
from and the second is what picks it up. Use npm run build rather than
hardhat compile after touching anything under contracts/univ3 — see
The V3 swap.
Contracts are grouped by folder under contracts/, and each folder has one Ignition
module of the same name in ignition/modules/. The exception is wnura, which
deploys contracts/token/WNURA.sol on its own. You deploy one group at a time with --sc:
npx hardhat deploy --sc token --network nurachain # only contracts/token (bridge tokens)
npx hardhat deploy --sc wnura --network nurachain # only contracts/token/WNURA.sol
npx hardhat deploy --sc airdrop --network nurachain # only contracts/airdrop
npx hardhat deploy --sc univ3 --network nurachain # only contracts/univ3
npx hardhat deploy --sc forecast --network nurachain # only contracts/forecast
npx hardhat deploy --sc profile --network nurachain # only contracts/profile
npx hardhat deploy --sc baziche --network nurachain # only contracts/baziche
npx hardhat deploy --sc bazicheshop --network nurachain # only contracts/baziche/ArcadeShop.sol
npx hardhat deploy --sc bazichetips --network nurachain # only contracts/baziche/ArcadeTips.sol
npx hardhat deploy --sc bazichemarkets --network nurachain # only contracts/baziche/ArcadeMarkets.sol
npx hardhat deploy --sc bazichelooks --network nurachain # only contracts/baziche/ArcadeLooks.sol--sc wnura deploys a new, separate WNURA. Nurachain already has the canonical one at
0xf0a4eC07916feBa4432121Ed5969887D9b939cD0, and univ3 uses that one unless you pass
{ "univ3": { "wnura": "0x..." } } in --parameters.
Or through npm:
npm run deploy:nurachain:token
npm run deploy:nurachain:wnura
npm run deploy:nurachain:airdrop
npm run deploy:nurachain:univ3
npm run deploy:nurachain:forecast
npm run deploy:nurachain:profile
npm run deploy:nurachain:baziche
npm run deploy:nurachain:bazicheshop
npm run deploy:nurachain:bazichetips
npm run deploy:nurachain:bazichemarkets
npm run deploy:nurachain:bazichelooks
# equivalent, passing the flag through npm — note the extra --
npm run deploy:nurachain -- --sc token
npm run deploy:nurachain --sc token(without the--) does not work. npm 11 parses--scas an abbreviation of its own--scopeconfig and drops it, so the flag never reaches Hardhat. The--is what forwards it.
Adding a new group is three steps: create contracts/<name>/, add
ignition/modules/<name>.ts, then add "<name>" to the DEPLOYABLE list at the top
of hardhat.config.ts. --sc validates against that list and errors with the valid
choices if you mistype it.
--reset wipes the previous deployment state before redeploying. That state is kept
per deployment id (ignition/deployments/chain-<chainId>/ by default), not per group,
so --reset also forgets the addresses of every other group deployed to that chain.
To redeploy one group from scratch, give it its own folder instead:
npx hardhat deploy --sc airdrop --network nurachain --deployment-id airdrop-v2--sc airdrop has two values it will not guess for you, and stops to ask:
contracts/airdrop needs a claim cap and a per-claim reward. The cap is immutable
once deployed, so neither has a default — answer, or re-run with --max-claims
and --reward.
Maximum number of claims: 50000
Reward per claim, in NURA: 200
Cap: 50,000 claims (immutable)
Reward: 200 NURA per claim
Pool: 10,000,000 NURA to cover every claim, sent to the
deployed address afterwards — this module does not fund it.
maxClaims is immutable in the contract — there is no setter, no upgrade, no second
chance — and the two together decide how much coin you are committing to fund. That is
why neither has a default: the wrong number here is not a number you get to change.
Answer at the prompt, or supply them up front and skip the questions:
npx hardhat deploy --sc airdrop --network nurachain --max-claims 50000 --reward 200--reward is in whole coin, not wei. Both are also read from the parameters file
below, and anything found there is not asked about. A run with no terminal to ask on —
CI, a piped shell — fails with that message rather than deploying a guess.
Both modules default the admin to the deploying account. To override, create
ignition/params.json (gitignored) keyed by module name:
{
"token": { "admin": "0xYourMultisig" },
"airdrop": {
"admin": "0xYourMultisig",
"signer": "0xYourBackendSignerAddress",
"maxClaims": "50000n",
"rewardAmount": "200000000000000000000n"
}
}then pass it:
npx hardhat deploy --sc airdrop --network nurachain --parameters ./ignition/params.jsonmaxClaims and rewardAmount there are optional — include them to deploy without
being asked, leave them out to answer at the prompt. Unlike --reward, rewardAmount
in this file is in wei, in Ignition's "<digits>n" spelling for bigints. --max-claims
and --reward win over the file if you pass both.
Nurachain is the only network this repo targets. hardhat.config.ts defines
nurachain and the in-process hardhatMainnet used by the tests, and nothing else.
Nurachain is not in any public chain registry, so its RPC URL and chain ID have to
come from Nurachain's own docs and go in your .env:
NURACHAIN_RPC_URL=https://...
NURACHAIN_CHAIN_ID=...The nurachain network is already defined in hardhat.config.ts and reads both.
Then:
# 1. Check the RPC, chain id, balance, and that the bytecode runs there
npm run preflight:nurachain
# 2. Deploy one group at a time
npm run deploy:nurachain:token
npm run deploy:nurachain:wnura
npm run deploy:nurachain:airdrop
npm run deploy:nurachain:univ3
npm run deploy:nurachain:forecast
npm run deploy:nurachain:profile
npm run deploy:nurachain:baziche
npm run deploy:nurachain:bazicheshop
npm run deploy:nurachain:bazichetips
npm run deploy:nurachain:bazichemarkets
npm run deploy:nurachain:bazichelooks
# 3. Register the profile registry's verifier extension and print its state
npm run setup:nurachain:profileRun the preflight first. It estimates deployment gas against the actual node, which executes the constructor — so if Nurachain does not support the Cancun opcodes this build targets, it fails there instead of after you have spent gas on a reverted deploy. It also refuses to continue if the deployer cannot cover the cost.
For Arcade's paid tables it measures what the vault will ask of the chain once it is
live: the block time over the last hundred blocks and how old the newest one is, the gas
price the Arcade referee will pay (never under 47 gwei), what one nine-seat game costs it
to lock and pay, and whether the node takes an open and a settle at two, three, four,
six and nine seats. Those are the largest requests the referee sends, a nine-seat open
being about 3 KB of calldata, and Nurachain's rpc once dropped request bodies over about
1.2 KB. A size the node refuses stops the preflight, because the referee could not lock or
pay a game that large there.
If preflight fails on opcodes, the fix is to drop to an older OpenZeppelin that
does not use mcopy (5.1.x) and set evmVersion: "paris" in hardhat.config.ts.
Ask and I'll do that downgrade.
Fund the deployer first with Nurachain's native gas token — the deployer is
whatever address DEPLOYER_PRIVATE_KEY corresponds to, and preflight prints it.
Deploying from a seed phrase instead of a private key: set DEPLOYER_MNEMONIC
(quoted, space-separated words) in .env. It takes precedence over
DEPLOYER_PRIVATE_KEY. The deployer is the account at
m/44'/60'/0'/0/<DEPLOYER_MNEMONIC_INDEX>, which defaults to index 0, the same address as
MetaMask's first account. Set DEPLOYER_MNEMONIC_PASSPHRASE only if the wallet uses a
BIP-39 passphrase. Run preflight to confirm the derived address before funding it.
Nurachain's explorer is explorer.nurachain.net — its
own software ("Nura Explorer", built on AzerothJS), not Blockscout and not an Etherscan
clone. hardhat.config.ts describes it under chainDescriptors, keyed by
NURACHAIN_CHAIN_ID, in the blockscout slot because that is the provider
hardhat-verify drives without an API key — and this explorer has no key to give. That
is also why ETHERSCAN_API_KEY is gone: nothing here uses it.
Automated verification does not work yet, and that is the explorer's side, not
this repo's. Its /api answers in the Etherscan shape but implements only the
account module; module=contract&action=verifysourcecode comes back
Error! Missing or unsupported module, so npx hardhat verify --network nurachain
and deploy --verify have nothing to talk to. Verified against the live endpoint —
recheck it if Nurachain announces verification support, at which point the config is
already pointed at the right place.
Until then, verify by hand — flatten the source and paste it into the explorer UI:
npx hardhat flatten contracts/token/BridgeUSDT.sol > flat.solcontracts/airdrop/Airdrop.sol pays a fixed amount of native NURA to the first N
eligible addresses that call getReward. One claim per address, ever. The amount and
the cap are chosen at deploy time — see the prompts above;
the worked examples below use 200 NURA and 50,000 claims.
Eligibility is proven with an EIP-712 signature from a backend key holding
SIGNER_ROLE. That is doing the real work: "one claim per address" on its own is
worthless, because anyone can generate unlimited addresses and take every slot. The
signature is what decides who is eligible; the on-chain checks only stop double claims
and cap overruns.
| Function | Who | What |
|---|---|---|
getReward(deadline, signature) |
anyone with a valid signature | Claims rewardAmount once |
fund() / plain transfer |
anyone | Adds to the payout pool |
setRewardAmount(uint256) |
DEFAULT_ADMIN_ROLE |
Changes the reward for future claims |
withdraw(to, amount) |
DEFAULT_ADMIN_ROLE |
Recovers leftover NURA |
pause() / unpause() |
PAUSER_ROLE |
Halts claiming |
Views: remainingClaims(), fundedClaims(), outstandingLiability(), hasClaimed(address),
and claimDigest(account, deadline) for debugging signatures.
The contract pays from its own native balance, so deploying is not enough. Covering
every claim needs maxClaims * rewardAmount sent to the deployed address — 10,000,000
NURA at 50,000 x 200. The deploy prints that figure once you have answered. It pays out
until the balance runs dry, then reverts with InsufficientBalance — so you can start
partially funded and top up as you go.
Your server signs an approval per eligible address. The signing key must hold
SIGNER_ROLE:
import { Wallet } from "ethers";
const signer = new Wallet(process.env.AIRDROP_SIGNER_KEY!);
const domain = {
name: "Airdrop",
version: "1",
chainId: 1020, // Nurachain
verifyingContract: AIRDROP_ADDRESS,
};
const types = {
Claim: [
{ name: "account", type: "address" },
{ name: "deadline", type: "uint256" },
],
};
// deadline is a unix timestamp; the signature is unusable after it
const deadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 3600);
const signature = await signer.signTypedData(domain, types, { account, deadline });
// hand { deadline, signature } to the frontend; the user calls:
// airdrop.getReward(deadline, signature)The domain binds each signature to this contract on this chain, so one cannot be
replayed against another deployment or a fork. Rotate the key any time with
grantRole(SIGNER_ROLE, newKey) / revokeRole(SIGNER_ROLE, oldKey) — in-flight
signatures from the old key stop working immediately.
- The signer key is the whole security model. Anyone who steals it can sign
themselves as many claims as the cap allows and drain the entire pool. Keep it off the
deployer machine, and prefer a separate key from
DEFAULT_ADMIN_ROLE. maxClaimsis immutable. It is set once, in the constructor, from what you answer at deploy time. Getting it wrong means redeploying and re-pointing everything at the new address.setRewardAmountis not retroactive. Lowering it after 10,000 people claimed does not claw anything back.- Claimers pay their own gas. They need a small NURA balance before they can claim, which is awkward if the airdrop is meant to be someone's first NURA. If you need gasless claiming, that is a relayer/meta-transaction change — ask and I'll add it.
contracts/univ3 is UniswapV3, vendored verbatim — the only AMM in this repo. Provenance
and every local modification are in
contracts/univ3/VENDORED.md; it is GPL-2.0 and carries its own
LICENSE, unlike the MIT contracts elsewhere here.
--sc univ3 deploys seven contracts:
| Contract | What it is |
|---|---|
UniswapV3Factory |
Creates one pool per (token pair, fee tier), with CREATE2 |
NFTDescriptor |
Library, linked into the descriptor below |
NonfungibleTokenPositionDescriptor |
Draws the position NFT's on-chain SVG |
NonfungiblePositionManager |
Liquidity positions as ERC721 tokens |
SwapRouter |
What wallets call: single and multi-hop swaps |
QuoterV2 |
Quotes, by simulating a swap and reverting with the result |
TickLens |
Bulk tick reads, for depth charts |
It reuses the WNURA and Multicall3 already live on Nurachain from the earlier AMM
deployment — a second wrapped-native contract would split every native pool in half.
(--sc wnura can deploy one, but univ3 only uses it if you pass its address as the
wnura parameter.)
Pools are not deployed here either; createAndInitializePoolIfNecessary makes them on
demand, the way pairs appear on the first addLiquidity anywhere else.
Concentrated liquidity means an LP picks a price range, so the fee is fixed per pool and immutable. The canonical four are enabled:
| Fee | Tick spacing | For |
|---|---|---|
| 0.01% | 1 | stablecoin pairs |
| 0.05% | 10 | correlated, high volume |
| 0.30% | 60 | the default for most pairs |
| 1.00% | 200 | exotic or volatile |
The factory's constructor enables 0.05/0.30/1.00; the module adds 0.01%. enableFeeAmount
is one-way — a tier can never be removed and its spacing can never be changed — so
the set enabled at launch is the set this chain lives with. Deviating from Uniswap's
pairing would fragment liquidity and break every V3 SDK, subgraph and aggregator that
assumes these four.
Nurachain enforces EIP-170 exactly. Simulating a deploy against the node returns
max code size exceeded: code size 24577 limit 24576, and:
UniswapV3Factory 24535 bytes 41 spare
NFTDescriptor 24541 bytes 35 spare
NonfungiblePositionManager 24384 bytes 192 spare
metadata.bytecodeHash: "none" alone is worth about 40 of those bytes. So the solc 0.7.6
settings in hardhat.config.ts — the per-file optimizer runs, the istanbul target, the
metadata flag — are copied from upstream's own configs rather than picked, and changing
any of them can make the factory undeployable. test/univ3/Build.test.ts asserts every
contract against the limit and npm run preflight:nurachain prints the margin, so this
fails in the test suite rather than on-chain.
The same settings are why our UniswapV3Pool hashes to
0xe34f199b…8b8b54 — byte-for-byte Uniswap's own published POOL_INIT_CODE_HASH.
npm run initcodehash regenerates it from our build, and equality with the published
constant is a free proof that nothing has drifted.
Every pool keeps a ring buffer of observations, one slot until someone pays to grow it. Nurachain's blocks are ~3.02s against Ethereum's ~12s, so the same TWAP window needs four times the slots:
30-minute TWAP Nurachain ~600 observations Ethereum ~150
pool.increaseObservationCardinalityNext(600) costs about 13.4M gas — comfortable inside
the 150M block limit, but not something that happens by accident. Copying mainnet's
cardinality onto this chain would silently give a seven-minute TWAP. And a deep buffer is
capacity, not history: a pool grown a second ago still has a second of history.
Do not price anything off these TWAPs yet. The chain's pools are shallow, so a V3 TWAP here is manipulable for near-zero capital until real liquidity exists.
npm run preflight:nurachain # gas estimates AND the per-contract size check
npm run deploy:nurachain:univ3Never pass
--reset. Ignition keys a deployment by chain id, so thev3module writes into the sameignition/deployments/chain-1020/folder that holds the live addresses of everything deployed before it (WNURA, Multicall3, the bridge tokens).--resetwipes that record; the contracts stay deployed but nothing in the repo knows where they are any more, and the folder is gitignored. Back it up first.
UniswapV3Factory sets owner to whoever deploys it, and that key can enable fee tiers
and switch on the protocol's cut of swap fees (up to 1/4). Moving it to a multisig is a
deliberate manual step — factory.setOwner(multisig): handing away control should not be
a side effect of a deploy script.
contracts/profile is a decentralized, extensible identity primitive: one profile per
address, a globally unique case-normalized username, localizable key/value fields, unlimited
websites / images / socials (and any item kind an application invents), owner-approved
operators, two-step transfer with a recovery address, and a curated registry of extension
contracts that attest data into their own namespace on a profile. It is UUPS-upgradeable
behind an ERC-1967 proxy, keeps its state in one ERC-7201 namespace, and compiles with
viaIR so it fits Nurachain's exact EIP-170 limit with room for future versions.
The design rule everything follows from: the core stores no schema. Every value is
addressed by (profile, key, language); every list item is a bag of such values under a
free-form kind. Adding a field, a language, a platform or a whole "wallets" section is a
transaction, not a redeploy. New logic (verification, reputation, achievements) is a
sidecar extension the core never calls into.
uint256 id = profile.createProfile("alice", "Alice Doe", "Builder", "ipfs://…avatar");
profile.setLocalizedField(id, "bio", "fa", "توسعهدهنده بلاکچین و سازنده Nura Chain");
profile.addWebsite(id, "https://nurachain.net", "Nura Chain");
profile.addSocial(id, "farcaster", "alice", "https://warpcast.com/alice");
profile.addItem(id, "wallet", [FieldInput("chain", "", "bitcoin"), FieldInput("address", "", "bc1q…")]);
lens.getFullProfile(aliceAddress, "fa"); // everything above, resolved in Persian with fallbackThree addresses matter: the proxy (NuraProfileProxy, writes + primitive reads, ABI
NuraProfile), the lens (NuraProfileLens, getProfile(address, lang), getFullProfile,
typed and paged collection getters — stateless and replaceable), and the reference
SocialVerifier extension (EIP-712-attested "this profile owns handle X on platform Y").
The contract admin can upgrade, curate extensions and reserve usernames — and has no path to
any user's content; the test suite asserts that function by function.
Deploy with npm run deploy:nurachain:profile, then npm run setup:nurachain:profile to
register the verifier; upgrade with npm run upgrade:nurachain:profile. The full design
rationale, data model, gas table, security review and frontend integration guide are in
contracts/profile/README.md; the per-function reference is
docs/contracts/NuraProfile.md.
contracts/baziche/ArcadeVault.sol holds the native NURA that Arcade's players stake on paid
tables. A player deposits once and withdraws what is free whenever they like; playing costs
them no transaction. For each paid game they sign an EIP-712 Stake, and the Arcade server,
holding REFEREE_ROLE, opens the game with every player's signature and pays it when the game
ends: the pot less the fee to the winners, evenly, with whatever the split leaves joining the
fee.
// a player
vault.deposit{value: 100 ether}();
vault.withdraw(40 ether);
// the Arcade server, with every player's signed Stake
vault.open(id, "ludo", 10 ether, 500, seats); // locks 10 NURA of each, all or nothing
vault.settle(id, 0b0101, secrets); // seats 0 and 2 split the pot, less 5%
vault.refund(id); // or every stake back, no fee
// anybody, three days after a game opened and was never paid
vault.reclaim(id);| Function | Who | What |
|---|---|---|
deposit() / plain transfer |
anybody | credits the sender's free balance |
deposit(referrer) |
anybody | the same, and names who brought the sender in, once and for good |
withdraw(amount) |
the balance's owner | pays them from what is free; never paused |
cancel(nonce) |
a player | spends a stake they signed, before it is used |
open(id, game, amount, feeBps, seats) |
REFEREE_ROLE |
locks one game: every signature valid, unexpired and unused, every balance enough, the fee the game's feeOf |
settle(id, winners, secrets) |
REFEREE_ROLE |
pays one game; every secret must open the commit its player signed; each referred seat's referrer gets a cut of the fee |
refund(id) |
REFEREE_ROLE |
every stake back, no fee |
reclaim(id) |
anybody | every stake back, from reclaimAfter after the game opened |
setReferralShare |
DEFAULT_ADMIN_ROLE |
the part of each seat's fee its player's referrer gets (1% to 75% of it), for games not yet opened |
setFee / setGameFee / setMaxStake / withdrawFees |
DEFAULT_ADMIN_ROLE |
the fee (1% to 15%, shared or per game) and the ceiling for games not yet opened; the fees taken |
setReferee(next) |
DEFAULT_ADMIN_ROLE |
moves REFEREE_ROLE from the current referee to next in one call |
beginDefaultAdminTransfer(next) / acceptDefaultAdminTransfer() |
the owner, then next |
hands the vault and its pauser role to a new owner; cancelDefaultAdminTransfer() takes the naming back |
pause() / unpause() |
PAUSER_ROLE |
stops deposits and new games, nothing else |
The referee is trusted to say who won, because the games are played on Arcade's server, and
the contract bounds the rest: it can lock only stakes their players signed, each once and
before its deadline (never more than a day out), at the amount and fee they signed, and pay a
pot only to that game's players, with the secrets that open their commits. It cannot touch a
free balance or stop a withdrawal, and if it never pays a game anybody can reclaim it. The
commits are what make a paid game's dice and cards checkable afterwards: the secrets settle
reveals, with the players' signatures from open, give the seed the game was drawn from.
It is not upgradeable. Deploy with npm run deploy:nurachain:baziche and the baziche
parameters (referee, fee, maxStake, reclaimAfter; see ignition/modules/baziche.ts).
The per-function reference, roles, flows and security analysis are in
docs/contracts/ArcadeVault.md.
contracts/baziche/ArcadeShop.sol sells Arcade's looks (dice, pawns, card backs, avatar
decorations and the rest) for native NURA. The Arcade server prices every sale: it signs an
EIP-712 Quote for one order, one buyer and one price, good for a short while, with the key
that holds SELLER_ROLE. The buyer pays that quote from their own wallet, and the server reads
the sale back with saleOf(order) before it hands the look over, so it never holds NURA and a
closed tab loses nothing.
// the buyer, with the quote and signature the server answered
shop.buy{value: 12 ether}(quote, signature); // exactly the quote's price, before its deadline
// whoever brought the buyer in
shop.claim(); // their share of every sale they were named on
// the owner
shop.withdraw(treasury, amount); // revenue only, never an inviter's credit
shop.refund(order); // the whole price back to the buyer| Function | Who | What |
|---|---|---|
buy(quote, signature) |
the quote's buyer | pays one order at exactly its price, once, before its deadline (never more than a day out), signed by a holder of SELLER_ROLE; credits the inviter referralBps of it and the owner the rest |
claim() |
an inviter | sends them their credit; never paused |
refund(order) |
DEFAULT_ADMIN_ROLE |
the whole price back to the buyer, the inviter's cut taken back while unclaimed |
withdraw(to, amount) / fund() |
DEFAULT_ADMIN_ROLE |
revenue out to the owner's choice; revenue topped up for a refund |
setSeller(next) |
DEFAULT_ADMIN_ROLE |
moves SELLER_ROLE to next in one call; the old key's quotes stop at once |
setReferralBps(bps) |
DEFAULT_ADMIN_ROLE |
the inviters' share of a sale, 0 to 30%, for sales from then on |
beginDefaultAdminTransfer(next) / acceptDefaultAdminTransfer() |
the owner, then next |
hands the shop and its pauser role to a new owner |
pause() / unpause() |
PAUSER_ROLE |
stops new sales, nothing else |
A wrong payment never leaves the buyer's wallet: another buyer, another price, an expired quote, an order already sold and a quote the seller did not sign are each refused by name. The seller's key can sign quotes and nothing else, so a leaked key gives looks away and never moves coin.
It is not upgradeable. Deploy with npm run deploy:nurachain:bazicheshop and the
bazicheshop parameters (seller, admin, referralBps; see
ignition/modules/bazicheshop.ts): a group of its own, so the vault and the achievements a
chain already holds are not deployed again. The per-function reference, roles, flows and
security analysis are in docs/contracts/ArcadeShop.md.
contracts/baziche/ArcadeTips.sol passes native NURA from somebody watching a game on Arcade to a
player of it. The Arcade server decides whether a gift may be made, writes it with an id of its own
and hands the giver's wallet the call; the contract sends the whole value to the player in that
call and records it under the giver and the id, which is how the server confirms it
(tipOf(giver, id)), never by a hash the page reports. It keeps nothing and takes no fee.
// the giver, with the id the server answered
tips.tip{value: 5 ether}(id, player, deadline); // all of it to the player, now, before the deadline| Function | Who | What |
|---|---|---|
tip(id, to, deadline) |
anybody | sends everything the call carries to to, recorded under the caller and id; refuses nobody, oneself, nothing, a call past its deadline and an id the caller already used |
tipOf(from, id) |
anybody | one gift: to whom, how much, when |
pause() / unpause() |
PAUSER_ROLE |
stops new gifts, nothing else |
Keyed by the giver as well as the id, so nobody can spend a giver's id first. It is not
upgradeable. Deploy with npm run deploy:nurachain:bazichetips and the bazichetips parameters
(admin; see ignition/modules/bazichetips.ts): a group of its own. The reference, roles, flows
and security analysis are in docs/contracts/ArcadeTips.md.
contracts/baziche/ArcadeMarkets.sol holds pari-mutuel markets on Arcade's games in native NURA:
many people back the outcomes of one game, each with a stake of their own, and when it is over
whoever backed the outcome that happened shares everything staked, by their stakes, less the fee
the market names (at most 5%, nothing unless the owner sets one). Nobody is matched with anybody
and nobody offers odds. The Arcade server decides who may bet: every position comes with a ticket
its key signed for one bettor, one market, one outcome and one exact amount, and names its
market's terms by their hash. The server settles a market from the game's result as it recorded
it, after the market closes; the contract cannot see a game and trusts its operator for that, and
a market nobody settles can be expired by anybody a week after its close, which refunds it.
// a bettor, with the terms and the ticket the server answered
markets.place{value: ticket.amount}(terms, ticket, signature);
// the server's key, once the game is over and the market closed
markets.settle(market, outcome); // or cancel(market) for a game with no result
// each bettor, for their own positions, whenever they like
markets.claim(tickets); // a winner's share, or a stake back| Function | Who | What |
|---|---|---|
place(terms, ticket, signature) |
the ticket's bettor | stakes exactly the ticket's amount on its outcome, once, before its deadline (never more than an hour out) and the market's close, within the position's, the bettor's and the pool's limits; the first ticket opens the market |
settle(market, outcome) |
OPERATOR_ROLE |
after the close: the backers of outcome share the pool less the fee; every stake back, no fee, when nobody backed it or too few people or outcomes were backed |
cancel(market) |
OPERATOR_ROLE |
every stake back, for a game with no result |
expire(market) |
anybody | a week after the close of a market nobody resolved: every stake back |
claim(tickets) |
each position's bettor | pays the caller's positions, one or many: left * stake / backingLeft, the last winner the exact remainder, once the market's dispute window has passed; a refund at once; never paused |
overturn(market) |
DEFAULT_ADMIN_ROLE |
in a settled market's dispute window: undoes the settlement, every stake and the fee back |
withdrawFees(to, amount) / fund() |
DEFAULT_ADMIN_ROLE |
fees out to the owner's choice, never a stake, a share or a refund; fees topped up so a settlement can be overturned after its fee went |
setDisputeWindow(seconds) |
DEFAULT_ADMIN_ROLE |
how long winners of markets settled from then on wait to claim, at most a day |
setOperator(next) |
DEFAULT_ADMIN_ROLE |
moves OPERATOR_ROLE to next in one call; the old key's tickets stop at once |
beginDefaultAdminTransfer(next) / acceptDefaultAdminTransfer() |
the owner, then next |
hands the contract and its pauser role to a new owner |
pause() / unpause() |
PAUSER_ROLE |
stops new positions, nothing else |
What the operator's key can do is sign tickets, settle and cancel: it can name the wrong outcome,
and it can never send a stake anywhere but to its own market's bettors. A wrong outcome is what
the dispute window is for: winners claim only once it has passed, and in it the owner can overturn
the settlement, which refunds everyone. A share nobody claims is never swept. It is not
upgradeable. Deploy with npm run deploy:nurachain:bazichemarkets and the bazichemarkets
parameters (admin, operator, disputeWindow; see ignition/modules/bazichemarkets.ts): a
group of its own. The reference, roles, flows and security analysis are in
docs/contracts/ArcadeMarkets.md.
contracts/baziche/ArcadeLooks.sol holds the looks Arcade's players own (dice, card backs, avatar
decorations, profile themes and the rest) as soulbound ERC-1155 tokens: one id per look,
uint256(keccak256(bytes(itemId))), at most one of each per wallet. A look is paid for in
ArcadeShop and owned where the Arcade server keeps it; once it is, the server's minter wallet
mints its token into the owner's wallet, and burns it when the shop refunds the look or it is
taken back. A gift is minted straight into the friend's wallet. Nobody, the holder included, can
move one.
// the Arcade server's minter wallet
looks.mint(owner, id); // once the look is theirs, while they hold none of it
looks.mintBatch(owner, ids); // a bundle, or what they owned before this contract was
looks.burn(owner, id); // a refund, or the look taken back| Function | Who | What |
|---|---|---|
mint(owner, id) / mintBatch(owner, ids) |
MINTER_ROLE |
one of each look to owner, refusing a look they hold already or one named twice (AlreadyHeld); all or nothing |
burn(owner, id) |
MINTER_ROLE |
takes a look back; refuses one not held (NotHeld) |
setURI(template) |
DEFAULT_ADMIN_ROLE |
the {id} metadata template |
holders(id) |
anybody | how many wallets hold a look now |
| any transfer | nobody | refused, single or batched (Soulbound) |
Where it parts from ArcadeAchievements: every token is soulbound, the minter can burn (a refund
takes the look back), and a wallet may be given a look again once it holds none of it (a refunded
look bought again). It is not upgradeable. Deploy with npm run deploy:nurachain:bazichelooks and
the bazichelooks parameters (admin, uri; see ignition/modules/bazichelooks.ts): a group of
its own. Then the admin grants MINTER_ROLE to the server's minter wallet, which is neither the
deployer nor the key that pays out tables. The reference is in
docs/contracts/ArcadeLooks.md.
npm test # everything: Mocha suite + Solidity fuzz/invariant suite
npm run test:mocha # TypeScript suite only, ~1 min
npm run test:solidity # Solidity property + invariant suite only, ~4 min
npm run coverage # line/branch coverage for the contracts in this repo
npm run typecheck # tsc --noEmit over config, scripts, modules and testsNarrower slices, for when you are working on one thing:
npm run test:fuzz # property tests only (256 randomised runs each)
npm run test:invariant # stateful invariant runs over the profile registry and the arcade vault
npm run test:profile # the profile registry, its lens, its extension and its upgrade path
npm run gas:profile # gas table for every profile operation
npm run test:security # signature binding, reentrancy, access control, admin bounds
npm run test:scripts # the deployment layer: input parsing + every Ignition module
npx hardhat test mocha test/Airdrop.test.ts # one file
npx hardhat test mocha --grep "protocol fee" # one describe
npx hardhat test solidity --grep testFuzz_capacity # one propertyTwo runners, because they are good at different things.
| Suite | Files | Covers |
|---|---|---|
| Mocha / TypeScript | test/**/*.test.ts |
Behaviour, events, revert reasons, access control, signature binding, reentrancy, AMM integration, and the deployment layer |
| Solidity | contracts/**/test/*.t.sol |
Property tests over the profile registry's inputs, plus stateful invariant runs against its item lists |
The Solidity suite is Hardhat 3's native runner. It needs no Foundry install and no
forge-std dependency — cheatcodes are reached through the hevm address directly, and
targetContracts() restricts the invariant fuzzer the way StdInvariant would.
Three groups are worth knowing about specifically:
test/scripts/params.test.ts— the pure functions every deployment input passes through, fromscripts/lib/params.ts. A reward parsed as wei instead of coin is a pool short by eighteen orders of magnitude, and a cap parsed loosely is immutable once the constructor runs, so they are tested away from any network.test/scripts/modules.test.ts— every Ignition module, actually deployed to the in-process chain and inspected. Without these a module could name a contract that no longer exists or pass constructor arguments in the wrong order and the whole suite would still be green, because everything else deploys withethers.deployContract.test/Profile.test.ts— the profile registry from every caller's point of view, plus an upgrade to a V2 mock that must keep every profile intact, and an "indexer" that replays the event stream and diffs the result against the lens.contracts/profile/test/*.t.soladds the properties (any two username spellings that fold to the same bytes collide; any value up to the cap round-trips) and the item-list invariants under random adds, removes and transfers.
Every test runs against Hardhat's in-process chain. Nothing reaches the network, reads a
key, or depends on the clock except through networkHelpers.time, which sets block
timestamps explicitly. Fixtures go through loadFixture, which snapshots and reverts
rather than re-running setup, so tests cannot leak state into each other.
Two rules worth keeping, both learned from breakage rather than taste:
- Never build an array of already-invoked contract calls.
for (const c of [a(), b()])creates every promise up front, so the later rejections sit unhandled until the loop reaches them — Node reports an unhandled rejection and kills the run, intermittently. Use thunks:for (const c of [() => a(), () => b()]) await expect(c()).... - Do not chain two async chai matchers.
.to.emit(...).and.to.changeTokenBalance(...)does not work; capture the transaction and assert against it twice.
contracts/token/BridgeToken.sol 100.00% lines 100.00% branches
contracts/airdrop/Airdrop.sol 100.00% lines 100.00% branches
contracts/profile/NuraProfile.sol 100.00% lines 100.00% statements
contracts/profile/NuraProfileLens.sol 100.00% lines 100.00% statements
contracts/profile/libraries/ProfileStrings.sol 100.00% lines 100.00% statements
contracts/profile/extensions/SocialVerifier.sol 100.00% lines 100.00% statements
contracts/baziche/ArcadeVault.sol 100.00% lines 100.00% statements
contracts/baziche/ArcadeShop.sol 100.00% lines 100.00% statements
npm run coverage is scoped to the contracts in this repo on purpose. Coverage instruments
the bytecode, which changes the UniswapV3Pool init code hash that
PoolAddress hardcodes — so every CREATE2-derived address moves and
the router computes addresses with no contract at them. Running coverage over the whole
tree fails the vendored-AMM tests for that reason alone. It is a property of the technique,
not a broken test: those suites run in full under npm run test:mocha.
.github/workflows/ci.yml runs four jobs on push and pull request: build and typecheck,
the Mocha suite, the Solidity suite, and coverage. None of them needs a secret — the
nurachain network reads its URL and key through configVariable(), which is lazy, so the
config loads without them as long as no job selects that network.
The build job also re-runs npm run initcodehash and fails if it changes anything. That is
the guard on the load-bearing constant: the pool init code hash is generated, so drift shows
up as a modified file. A change means a compiler setting moved, and every pool address a
router computes moves with it.
- The tokens are only as good as the backing. Nothing on-chain enforces that
minted supply matches assets locked on the source chain. A minter can create
unbacked supply at will. That trust sits entirely with whoever holds
MINTER_ROLE. adminBurncan destroy anyone's balance andpausecan freeze the token. Those are intentional operator powers, but they are also what an attacker gets if your admin key leaks. Move the roles to a multisig — and ideally a timelock — before the token holds meaningful value.- No replay protection on mints. If a relayer submits the same source-chain
deposit twice, it mints twice. Either make the relayer idempotent off-chain, or add
a
mapping(bytes32 => bool) processedkeyed by source tx hash tomint. Ask if you want that added. BridgeUSDTuses 18 decimals to match USDT on BNB Chain. USDT on Ethereum and Tron uses 6 — bridging from either means the relayer must scale amounts by1e12.BridgeBTCuses 18 decimals to match BTCB on BNB Chain. Native Bitcoin and WBTC use 8 — bridging from either means the relayer must scale amounts by1e10.- EVM target is
cancunfor the 0.8.28 contracts. OpenZeppelin 5.6 uses themcopyopcode, so those cannot targetparis. The vendored AMM is a separate matter: it is pinned toistanbulon purpose and must stay there. - UniswapV3 supports neither fee-on-transfer nor rebasing tokens, and has no variants. Its pools account by balance delta, so a token that moves a different amount than it was told to breaks them outright.
- The V3 factory owner is a key with real power. It can enable fee tiers permanently and take up to a quarter of every pool's swap fees. It starts as the deployer EOA and belongs behind a multisig before the pools hold anything.
- V3 TWAPs are not safe to price against yet. The oracle is only as expensive to manipulate as the liquidity behind it, and this chain's pools are shallow. See the oracle note under The V3 swap.
- The profile registry's owner key can upgrade it. UUPS means whoever holds the
NuraProfileowner can replace the implementation. Ownership is two-step and the owner has no path to user content, but an upgrade can add one. Move it to a multisig before users depend on it, and treatupgradeToAndCallas the most sensitive call in this repo. - The arcade vault's referee says who won. Its key lives on the Arcade server. The
contract keeps it to stakes players signed and to the players of each game, but whoever
holds it can pay a locked pot to the wrong player among those seated. Revoking
REFEREE_ROLEstops it at once, and an unpaid game can be reclaimed after the delay. The vault's admin can grant itself that role, so the admin belongs behind a multisig before the vault holds real value. - Playing for money is regulated or banned in many places. The vault is the mechanism, not a licence; check where the product is offered before the vault holds real value.
- Not audited.
contracts/token/
BridgeToken.sol shared base: roles, mint, burn, pause, permit, rescue
BridgeUSDT.sol Bridge USDT / USDT, 18 decimals
BridgeBNB.sol Bridge BNB / BNB, 18 decimals
BridgeETH.sol Bridge ETH / ETH, 18 decimals
BridgeBTC.sol Bridge BTC / BTC, 18 decimals
WNURA.sol wrapped native coin (Dapphub WETH9, renamed); already live
on Nurachain, deployed separately by --sc wnura
contracts/airdrop/
Airdrop.sol capped native-coin airdrop, EIP-712 signature gated
contracts/univ3/
core/** vendored UniswapV3 core, solc 0.7.6
periphery/** router, position manager, quoter, descriptor, lenses
vendor/** OpenZeppelin 3.4.2 subset, @uniswap/lib, base64-sol
test/** v3-core's own callback harnesses, dev-chain only
VENDORED.md, LICENSE upstream versions, the GPL-2.0, and the expired BUSL
contracts/mocks/
MockToken.sol dev-chain stand-in asset with optional faucet, tests only
contracts/airdrop/
mocks/AirdropMocks.sol re-entrant and payout-rejecting claimants, tests only
contracts/baziche/
ArcadeAchievements.sol ERC-1155 achievements, backend-minted; per-id metadata URI, soulbound or tradeable per id
ArcadeVault.sol native-coin stakes for Arcade's paid tables: signed stakes, referee-paid pots, reclaim
ArcadeShop.sol native-coin sales of Arcade's looks: server-signed quotes, inviters' shares, refunds
mocks/VaultMocks.sol re-entrant and refusing receivers, an ERC-1271 wallet, tests only
mocks/ShopMocks.sol a contract wallet that buys, invites, and refuses or re-enters a payment, tests only
test/VaultFuzz.t.sol properties over stakes, fees, seat counts and winners
test/VaultInvariant.t.sol the vault holds every wei it owes under random sequences
test/ShopFuzz.t.sol properties over prices, shares, refunds and forged quotes
test/ShopInvariant.t.sol the shop holds every wei it owes under random sequences
contracts/profile/
NuraProfile.sol UUPS profile registry: usernames, localized fields, items, extensions
NuraProfileProxy.sol the ERC-1967 proxy — the address everyone uses
NuraProfileLens.sol stateless read model: getProfile, getFullProfile, typed/paged getters
ProfileTypes.sol shared structs; ProfileErrors.sol: file-level custom errors
interfaces/ INuraProfile (full surface + events), IProfileExtension (handshake)
libraries/ ProfileKeys (well-known keys/kinds), ProfileStrings (validation, packing)
extensions/SocialVerifier.sol reference extension: EIP-712-attested platform handles
mocks/ProfileMocks.sol configurable extension, non-extension, V2 implementation, tests only
test/ProfileFuzz.t.sol properties over usernames, keys, languages, values, item lists
test/ProfileInvariant.t.sol item-list and ownership invariants under random sequences
README.md design, data model, gas, security review, frontend integration
ignition/modules/
token.ts deploys contracts/token (--sc token): the bridge tokens
wnura.ts deploys contracts/token/WNURA.sol alone (--sc wnura)
airdrop.ts deploys contracts/airdrop (--sc airdrop)
univ3.ts deploys contracts/univ3 (--sc univ3)
forecast.ts deploys contracts/forecast (--sc forecast)
profile.ts deploys contracts/profile (--sc profile): impl, proxy, lens, verifier
baziche.ts deploys contracts/baziche (--sc baziche): achievements and vault
bazicheshop.ts deploys contracts/baziche/ArcadeShop.sol alone (--sc bazicheshop)
bazichetips.ts deploys contracts/baziche/ArcadeTips.sol alone (--sc bazichetips)
bazichemarkets.ts deploys contracts/baziche/ArcadeMarkets.sol alone (--sc bazichemarkets)
bazichelooks.ts deploys contracts/baziche/ArcadeLooks.sol alone (--sc bazichelooks)
scripts/
preflight.ts pre-deploy check: chain id, funding, gas, contract sizes, paid tables
write-init-code-hash.ts regenerates the V3 pool init code hash
profile-setup.ts registers the verifier extension on the profile registry, prints state
profile-upgrade.ts UUPS upgrade with owner, size and proxiableUUID checks
profile-gas.ts gas table for every profile operation
lib/params.ts deployment input parsing and formatting, shared and tested
test/
BridgeToken.test.ts 49 tests: roles, mint, burn, pause, permit security, rescue
Airdrop.test.ts 44 tests: signature binding, replay, reentrancy, caps, admin
ArcadeVault.test.ts 39 tests: stakes, signatures, payouts, refunds, reclaims, roles, gas
ArcadeShop.test.ts 38 tests: quotes, signatures, inviters, claims, refunds, roles, reentrancy, gas
ArcadeMarkets.test.ts 49 tests: tickets, terms, limits, settling, refunds, disputes, claims, roles, reentrancy, gas
ArcadeLooks.test.ts 17 tests: mint, batches, repeats, burn, soulbound moves, template, ownership
univ3/Build.test.ts EIP-170 sizes and the pool init code hash
univ3/Factory.test.ts 18 tests: fee tiers, createPool, ownership
univ3/Pool.test.ts 26 tests: initialize, mint, burn, swap, flash, fees
univ3/Liquidity.test.ts 15 tests: ranges, tick crossing, fee shares, TickMath
univ3/Router.test.ts 20 tests: single/multi-hop, slippage, native, quoter
univ3/Positions.test.ts 20 tests: NFT lifecycle, permit, tokenURI
univ3/Oracle.test.ts 12 tests: observations, TWAP, cardinality at 3s blocks
Profile.test.ts 70 tests: usernames, localization, items, operators, transfer,
extensions, verifier, upgrade, event replay
scripts/params.test.ts 24 tests: deployment input parsing, away from any network
scripts/modules.test.ts Ignition module deployments: every module deployed and inspected
.github/workflows/ci.yml build, Mocha, Solidity fuzz/invariant, coverage
hardhat.config.ts three solc versions, networks, and the `deploy --sc` task
Each contracts/<folder> maps to ignition/modules/<folder>.ts and is deployed
independently with --sc <folder>. wnura, bazicheshop, bazichetips, bazichemarkets and bazichelooks are the five groups that are not folders.