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 BitCardsHook which is mined via CREATE2 to satisfy Uniswap v4's permission-bit address constraint (0x0EC0).
CardsToken (ERC-20)
↓
BitCards (ERC-721) ← CardArt (lib) ← Palette (lib) ← SVGFont (lib)
↓
CardPacks ← Abilities (lib)
↓
Expedition ← Combat (lib)
↓
Tournament ← BitCardsCore
↓
BitCardsHook (Uniswap v4) ← 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
Combat.resolveMatch(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. See test/Combat.t.sol.
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 | MINE | 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 |
v4 Hook
BitCardsHook 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 $CARDS/ETH 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 BitCardsCore.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 $CARDS:
- Genesis pack: 100 $CARDS · 5 cards · weighted Common-heavy
- Halving pack: 500 $CARDS · 5 cards · 1 guaranteed Rare+
- Whale pack: 2,500 $CARDS · 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
Bitcoin 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 INITIAL_BASE_REWARD >> epoch;
}
At ~12s per Ethereum block, 210k 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.
Combat.resolveMatchis 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 (post-deploy):
pending
pending
pending
pending
Source code is published verbatim to Etherscan. Bytecode hashes match contracts/out/*.json in the repo.
Glossary
- Mood
- 5-state market sentiment (BULL_RUN..CAPITULATION) computed by the v4 hook from $CARDS/ETH price action.
- Halving
- Every 210k blocks, base expedition reward halves via right-shift. Mirrors Bitcoin 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 $CARDS 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 v4 hook's address declaring which lifecycle methods it implements. We use 0x0EC0.