Architecture
Thirteen contracts, six libraries, no proxies. The dependency graph is a strict DAG: lower-level contracts know nothing about higher-level callers. Every contract is deployed at a deterministic address derived from the deployer's nonce, except BnbCardsHook which is mined via CREATE2 to satisfy PancakeSwap v2's permission-bit address constraint (0x0EC0).
CardsToken (ERC-20)
↓
BnbCards (ERC-721) ← CardArt (lib) ← Palette (lib) ← SVGFont (lib)
↓
CardPacks ← Abilities (lib)
↓
Expedition ← Combat (lib)
↓
Tournament ← BnbCardsCore
↓
BnbCardsHook (PancakeSwap v2) ← HookMiner (lib)
↓
Treasury
Every byte of color in the entire system goes through one of Palette's 32 named indices. Every glyph in the bitmap-font name plate goes through SVGFont. Every card stat-table lookup goes through CardPacks._archetypeStats. The system has no escape hatches.
Combat algorithm
the on-chain combat resolver(deck1, deck2) is a pure function. Given two 8-card decks, it deterministically returns the winner address, the turn count, and a finalState bytes blob (the per-slot HP at end of match).
1. Sort each deck ascending by cost (initiative).
2. While any unit alive on both sides AND turn < MAX_TURNS:
for slot in 0..7:
if attacker[slot] alive AND defender[slot] alive:
apply pre-attack abilities (QUICK, HEAVY)
damage = attacker.atk × multiplier
apply defender abilities (SHIELD reduces, MIRROR reflects)
defender[slot].hp -= damage
apply post-hit abilities (DRAIN heals attacker, BURN sets dot)
if defender[slot].hp ≤ 0:
apply death abilities (SACRIFICE, GENESIS revive)
swap roles, increment turn
3. Survivor count determines winner; tie → defender.
Same inputs always give the same outputs. The test suite runs 100 matchups twice and asserts identical winners every time.
16 abilities
| ID | Name | Trigger | Effect |
|---|---|---|---|
| 00 | NONE | . | No special effect |
| 01 | QUICK | before strike | Strikes before defender's counter |
| 02 | HEAVY | first attack | First attack deals double damage |
| 03 | SHIELD | first hit taken | First hit halved (one-time) |
| 04 | DRAIN | on-hit | Heals self for damage dealt |
| 05 | BURN | on-hit | Defender burns 3 turns after hit |
| 06 | MULTI | each turn | Deals 1 damage to all enemies |
| 07 | SACRIFICE | on-death | Doubles next ally's ATK |
| 08 | MIRROR | on-hit | Reflects half damage taken |
| 09 | VALIDATE | match end | +5 bonus if survives match |
| 10 | NETWORK | each turn | Adjacent allies +1 ATK per net node |
| 11 | HALVING | every 4th | 4th attack deals 4× damage |
| 12 | GENESIS | on-death | Revives once at 50% HP |
| 13 | FORK | on-play | Summons 50% stat clone |
| 14 | WHALE | passive | Immune to attacks < 5 damage |
| 15 | HODL | turn start | Regenerates 2 HP at turn start |
Mood oracle
BnbCardsHook implements IHooks directly (no BaseHook inheritance). It registers permission bits 0x0EC0:
uint160 PERMISSIONS =
BEFORE_ADD_LIQUIDITY_FLAG // 0x0800
| AFTER_ADD_LIQUIDITY_FLAG // 0x0400
| BEFORE_REMOVE_LIQUIDITY_FLAG // 0x0200
| BEFORE_SWAP_FLAG // 0x0080
| AFTER_SWAP_FLAG; // 0x0040
// = 0x0EC0
The hook address must have these bits in its low 14 bits. We mine a CREATE2 salt using HookMiner.find until the resulting address satisfies the constraint. The salt is computed at deploy time and is verifiable from the address.
On every afterSwap, the hook compares the current $BNBC/BNB price tick against a running 24h average. The delta determines a 5-state mood: BULL_RUN / ACCUMULATION / SIDEWAYS / DIP / CAPITULATION. The mood is written to BnbCardsCore.mood, which gates expedition rewards and pack rare-rates.
Economy
CardsToken is a standard ERC-20 with ERC20Permit. The constructor mints exactly 21,000,000 tokens to the deployer. There is no mint() function. CardsToken.totalSupply() is constant.
Pack costs are denominated in $BNBC:
- Genesis pack: 100 $BNBC · 5 cards · weighted Common-heavy
- Halving pack: 500 $BNBC · 5 cards · 1 guaranteed Rare+
- Whale pack: 2,500 $BNBC · 5 cards · 1 guaranteed Epic+, 5% Mythic chance
Pack revenue routes through Treasury with a 50/25/15/10 split: LP rewards / tournament prize pool / week-1 airdrop / Genesis Card holders.
Halving cycle
BNB Chain halves block rewards every 210,000 blocks. We do too.
function rewardForEpoch(uint256 epoch) pure returns (uint256) {
if (epoch >= 64) return 0; // saturates after 64 halvings
return the base reward >> epoch;
}
At ~0.75s per BNB Chain block, 3.34M blocks ≈ 29 days. After epoch 64 (~5.1 years), the right shift saturates and the base reward becomes zero. By that point, treasury reserves built up from pack sales fund tournament prize pools indefinitely.
Threat model
What the protocol defends against:
- Pack roll manipulation:
block.prevrandaois the only randomness. A miner can withhold blocks but cannot rewrite past blocks. Worst case: a miner sees their roll outcome and decides not to publish, forfeiting the block reward (~$700) for a single pack. - Combat outcome lying: impossible. the on-chain combat resolver is pure; any client computes the same answer from the same decks.
- Hook front-running: the mood update happens in the same swap transaction as the trade. There's no MEV opportunity to predict the mood before it's set.
- Tournament bracket manipulation: bracket positions are deterministically derived from
keccak256(tour_id, entrant_addresses_sorted). Any change to the entrant set re-shuffles the bracket.
Verification
Etherscan addresses:
0xED8e50…9Ed9
BnbCards (NFT)
0x8A8b37…92b9
CardPacks
0xDC8b90…1Ee7
Tournament
0x622c95…3a07
Expedition
0xe70cfC…0307
Treasury
0x905508…8250
Core
0x878F53…0466
Hook (v4)
0xe98c2f…0ec0
All contracts have their source code published on Etherscan. The hook address ends in …8ec0, which is the PancakeSwap v2 permission-bit pattern (low 14 bits = 0x0EC0) the hook was mined to satisfy.
Glossary
- Mood
- 5-state market sentiment (BULL_RUN..CAPITULATION) computed by the mood oracle from $BNBC/BNB price action.
- Halving
- Roughly every 29 days the base expedition reward halves. Mirrors BNB Chain issuance.
- Genesis Card
- Auto-minted Mythic to the deployer at LP-seed; one-time, never reissued.
- Expedition
- Idle staking mode. Deposit cards for 1/7/30 days; earn $BNBC proportional to deck power × halving multiplier.
- Bracket
- 64-entrant single-elimination tournament tree. 6 rounds. Resolved one round per call.
- Permission bits
- 14-bit field in the lower bits of a mood oracle's address declaring which lifecycle methods it implements. We use 0x0EC0.