Own the player. Win the game. Rule the pitch.
StormX is a fully on-chain football player tokenization platform built on the Initia axi-cms rollup. Real Ligue 1 players become tradeable ERC-20 tokens. Fans stake their tokens in head-to-head matchups, buy club-branded merchandise NFTs, and purchase match tickets — all settled transparently on-chain.
StormX turns every Ligue 1 player into a deployed ERC-20 smart contract. Fans stake player tokens in head-to-head games where real on-chain performance scores — derived from live player stats (goals, assists, tackles) stored in each contract — determine the winner. Beyond gameplay, fans spend club fan tokens on ERC-721 merchandise NFTs and match tickets, creating a two-token economy where player performance and club loyalty are independent on-chain positions, all on the gasless Initia axi-cms rollup.
-
The Custom Implementation: The core original logic is
PlayerToken.calculatePerformance()— a pure on-chain scoring function that converts real-world Ligue 1 stats into a 0–10 position-weighted score. The game contract calls this for each of the 10 staked tokens and determines the winner entirely in Solidity with no oracle callback. A stat oracle script feeds live match data into each player's individual ERC-20 contract (351 total), creating a direct link between real-world match outcomes and on-chain token economics. A bonding curve in each PlayerToken scales purchase price with demand, and a season-end reward pool splits 80% to token holders and 20% to the actual player. -
The Native Feature: The Interwoven Bridge (
openBridgefromuseInterwovenKit) is wired to a Bridge button in the top nav. Before this, new users had no in-app path to acquire assets on axi-cms — they needed to arrive with liquidity already on the rollup. The bridge removes that cold-start problem entirely: one click opens the InterwovenKit modal, the user picks a source chain and asset, and funds arrive on axi-cms ready to use in the game or marketplace without ever leaving the app. Initia Usernames (useUsernameQuery) are also integrated, replacing raw hex addresses with.initnames in the nav and throughout the game flow.
- Start your axi-cms node — ensure it is reachable at
http://localhost:8545before continuing. - Install dependencies — run
npm installinside bothweb/andcontracts/. - Create
web/.env.local— setNEXT_PUBLIC_AXICMS_RPC_URL,NEXT_PUBLIC_AXICMS_CHAIN_ID=2177282315500993,NEXT_PUBLIC_GAME_CONTRACT_ADDRESS, andDEPLOYER_PRIVATE_KEY. - Start the frontend — run
npm run devinsideweb/, then openhttp://localhost:3000. Connect MetaMask to axi-cms (Chain ID2177282315500993, symbolGAS), click Faucet for test tokens, and click Bridge to open the Interwoven Bridge modal.
Football fans have no real stake in the game. Existing fan engagement tools — loyalty apps, digital collectibles, fantasy leagues — are either centralized, offer no real ownership, or don't reflect on-field performance in any meaningful economic way.
Specifically:
- No true ownership. Digital fan products live inside walled-garden apps. Fans can't trade, transfer, or actually own what they buy.
- Performance is decorative. A player's real-world stats — goals, assists, minutes played — have zero bearing on the value of fan products tied to them.
- Payments are opaque. Ticket and merchandise transactions go through intermediaries with no verifiable audit trail.
- Fan loyalty is siloed. Club tokens, player tokens, and marketplace economies don't talk to each other.
StormX turns every Ligue 1 player into a deployed ERC-20 smart contract. Fans hold tokens that represent real players. Those tokens have utility:
| Action | Mechanism |
|---|---|
| Play | Stake 5 player tokens in a head-to-head game. Winner takes all 10. |
| Shop | Buy limited merchandise NFTs using club fan tokens (PAR, MON, MAR…). |
| Attend | Purchase on-chain match tickets for Ligue 1 fixtures using club fan tokens. |
| Collect | View your NFTs, tickets, and token portfolio in your on-chain profile. |
Everything — game outcomes, token transfers, NFT minting, ticket purchases — is recorded on the Initia axi-cms rollup with zero gas fees.
The platform also integrates three native Initia ecosystem features:
| Feature | What it does |
|---|---|
| Initia Usernames (.init) | Connected wallets display their .init username in the nav instead of a raw hex address. |
| Interwoven Bridge | A one-click Bridge button opens the native InterwovenKit bridge modal, letting users move assets from Initia L1 and other rollups into axi-cms. |
| Auto-signing (Session UX) | Users enable a session key once; subsequent game transactions (approve, createGame, joinGame) sign automatically without a MetaMask popup per action. |
StormX was designed around one core belief: fan engagement should have real economic stakes, not just cosmetic ones.
The platform is built on axi-cms — a custom appchain built on the Initia L1 using the Interwoven Rollup stack. axi-cms runs a full EVM execution environment with gasless transactions and deterministic finality, inheriting security from Initia's validator set while operating as an independent rollup. It was specifically chosen for its ability to handle the scale of 351 individual player token contracts without gas friction. Each Ligue 1 player has their own deployed ERC-20 contract, making the token economy granular by design.
Club fan tokens (PAR, MON, MAR, LYO, LIL, LEN, NIC, REN…) were introduced as a separate payment layer for the marketplace and ticketing, creating a deliberate two-token model: player tokens for gameplay staking, club tokens for commerce. This separation means a fan's loyalty to a club and their interest in a specific player are both reflected on-chain independently.
┌─────────────────────────────────────────────────────────────────┐
│ User Browser │
│ │
│ Next.js 15 + React 19 + Wagmi + InterwovenKit │
│ ┌──────────┐ ┌───────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ /game │ │/marketplace│ │ /tickets │ │ /claim │ │
│ │ Create │ │ MerchNFT │ │MatchTicket│ │ Profile/NFTs │ │
│ │ Join │ │ Buy w/ Fan│ │ Buy w/ Fan│ │ Club Tokens │ │
│ │ Battle │ │ Tokens │ │ Tokens │ │ My Tickets │ │
│ └────┬─────┘ └─────┬─────┘ └─────┬────┘ └───────────────┘ │
└────────┼─────────────┼─────────────┼───────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Next.js API Routes (/api/*) │
│ │
│ /game/create /game/join /game/status │
│ /merch/seed /tickets/seed │
│ /faucet /club-faucet │
│ /tokens /player /standings │
│ /blockchain/* /health │
└────────────────────────────────┬────────────────────────────────┘
│ viem (RPC calls)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Initia axi-cms EVM Rollup │
│ Chain ID: 2177282315500993 │
│ RPC: http://localhost:8545 │ Gas: 0 │
│ │
│ ┌──────────────────────┐ ┌──────────────────────────────┐ │
│ │ GameContractMulti │ │ PlayerToken (×351) │ │
│ │ Token │ │ ERC-20 per Ligue 1 player │ │
│ │ │ │ 0 decimals │ │
│ │ • createGame() │ │ • mint() (onlyOwner) │ │
│ │ • joinGame() │ │ • Metadata: goals, assists │ │
│ │ • 5 tokens × 200 │ │ appearances, season stats │ │
│ │ • winner-take-all │ └──────────────────────────────┘ │
│ └──────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────────────┐ │
│ │ MerchNFT │ │ TicketContract │ │
│ │ ERC-721 │ │ │ │
│ │ │ │ • listTickets() │ │
│ │ • createMerch() │ │ • buyTicket() │ │
│ │ • buyMerch() │ │ • ERC-20 approve → buy │ │
│ │ • IPFS metadata │ │ • TicketPurchased event │ │
│ │ • ERC-20 payment │ └──────────────────────────────┘ │
│ └──────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ FanToken (×19 clubs) ERC-20, 18 decimals │ │
│ │ PAR · MON · MAR · LYO · LIL · LEN · NIC · REN · … │ │
│ │ Used as payment for MerchNFT and TicketContract │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────┐
│ Player Tokens │
│ (0 decimals) │
│ 1 per player │
│ 351 contracts │
└──────────┬──────────┘
│ stake 5 × 200
▼
┌─────────────────────┐
│ GameContract │
│ Head-to-head game │
│ Winner takes all │
└─────────────────────┘
┌─────────────────────┐
│ Club Fan Tokens │
│ (18 decimals) │
│ 19 clubs │
└──────┬──────────────┘
│ approve → buy
┌──────────┴──────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ MerchNFT │ │ TicketContract │
│ 6 items │ │ 6 fixtures │
│ PAR/MON/MAR/LYO │ │ Per home club │
└──────────────────┘ └──────────────────┘
Each merch item and ticket fixture is priced in the relevant club's fan token:
| Item / Fixture | Payment Token | Price |
|---|---|---|
| Match Day VIP Pass | PAR | 80 |
| PSG Signed Jersey NFT | PAR | 150 |
| Goal of the Season | MON | 300 |
| Fantasy Starter Pack | LYO | 50 |
| Hall of Fame Card | MAR | 200 |
| Monaco Special Edition | MON | 100 |
| PSG vs Marseille (ticket) | PAR | 80 |
| Monaco vs Lyon (ticket) | MON | 60 |
| Lille vs Lens (ticket) | LIL | 50 |
| Nice vs Rennes (ticket) | NIC | 45 |
| PSG vs Monaco (ticket) | PAR | 120 |
| Marseille vs Lyon (ticket) | MAR | 55 |
| Layer | Technology |
|---|---|
| Framework | Next.js 15, React 19 |
| Language | TypeScript 5 |
| Styling | Tailwind CSS 4 |
| Animations | Framer Motion |
| 3D Scene | Three.js |
| Wallet | Wagmi 2, Viem 2, InterwovenKit v2.6 |
| Initia Native | Usernames (useUsernameQuery), Bridge (openBridge) |
| UI Components | Radix UI, shadcn/ui |
| Charts | Recharts |
| IPFS | Pinata Web3 |
| Layer | Technology |
|---|---|
| Language | Solidity ^0.8.20 |
| Framework | Hardhat |
| Libraries | OpenZeppelin 5 (ERC20, ERC721, Ownable) |
| Type Safety | TypeChain |
| Randomness | Block entropy (SimpleRandom.sol) |
| Property | Value |
|---|---|
| Network | axi-cms (custom Interwoven Rollup on Initia L1) |
| Chain ID | 2177282315500993 |
| RPC | http://localhost:8545 |
| Native Token | GAS |
| Gas Price | 0 (gasless) |
| Execution | EVM-compatible |
| Security | Inherited from Initia validator set |
The core game engine. A player selects 5 player token contracts and calls createGame(). An opponent selects their 5 tokens and calls joinGame(). Both parties must hold ≥ 200 tokens per contract and approve the game contract to spend them. On join, all 10 token positions are staked and a winner is determined. The winner receives all staked tokens.
createGame(address[5] tokenContracts)
joinGame(bytes32 gameCode, address[5] tokenContracts)
getGameDetails(bytes32 gameCode) → GameState
userToGameCode(address) → bytes32
One ERC-20 contract per player. 0 decimals. Stores on-chain player metadata: position, nationality, team, season stats (goals, assists, appearances, shots, duels, tackles). Owner can update stats via oracle scripts that pull from API Football.
Minimal ERC-20 with 18 decimals. One per Ligue 1 club. mint() is owner-only — the server faucet (/api/club-faucet) mints 1,000 tokens per request for testing.
ERC-721 with IPFS metadata. Items created by the deployer via createMerch(). Users buy with buyMerch(id) after approving the correct club fan token. Each item tracks minted vs supply count.
Lists match tickets with listTickets(matchId, price, qty, paymentToken). Fans buy with buyTicket(matchId) after approving the home club's fan token. Emits TicketPurchased(matchId, buyer, price, timestamp) — used to build the "My Tickets" profile tab.
The connect button resolves the connected wallet's initiaAddress to its .init username using InterwovenKit's useUsernameQuery hook. If the user has registered a .init name on Initia testnet, it replaces the shortened hex address in the nav. Falls back to the shortened address when no username exists.
// web/app/components/ConnectButton.tsx
const { data: username } = useUsernameQuery()
// Displays "alice.init" instead of "0x1234...abcd"
<span>{username ?? shortenAddress(initiaAddress)}</span>A Bridge button in the top nav opens the native InterwovenKit bridge modal, which handles routing between Initia L1 (initiation-2), other Interwoven Rollups, and external ecosystems. If the user isn't connected, clicking it triggers the connect flow first.
// web/app/components/BridgeButton.tsx
openBridge({ srcChainId: 'initiation-2', srcDenom: 'uinit' })Local dev note: The bridge modal resolves chains from the Initia public registry. Your local axi-cms instance may not appear as a destination until it is registered. The UI and connect flow work regardless.
InterwovenKit's autosign feature derives a dedicated session wallet from the connected wallet's signature. The user approves a set of permitted message types once — InterwovenKitProvider is configured with enableAutoSign to whitelist /minievm.evm.v1.MsgCall (the Cosmos-level message wrapping every EVM contract call on axi-cms). After that, all game transactions — token approvals, createGame, and joinGame — are signed and broadcast automatically via submitTxBlock without triggering a MetaMask popup for each action.
Permissions are time-limited, scoped to the exact message types listed, and revocable at any time from the wallet. The session wallet is derived per app origin, so it cannot be reused across other sites.
// web/app/components/Provider.tsx
<InterwovenKitProvider
{...TESTNET}
defaultChainId="axi-cms"
customChain={axicmsChain}
enableAutoSign={{
'axi-cms': ['/minievm.evm.v1.MsgCall'],
}}
>
// web/app/components/GameCreation.tsx — game tx without popup
const { transactionHash } = await submitTxBlock({ messages, fee })UX impact: A game requires up to 6 sequential MetaMask prompts (5 approvals + createGame). With autosign enabled that collapses to one setup prompt at session start, after which the entire game flow runs in the background without interruption.
InterwovenKitProvider is configured with {...TESTNET} spread first, then overridden with the custom axi-cms chain. This ensures all Initia service endpoints (registry, router, username module, bridge) are available while keeping axi-cms as the default chain.
// web/app/components/Provider.tsx
<InterwovenKitProvider
{...TESTNET} // testnet registry, router, username module, bridge
defaultChainId="axi-cms" // override home chain to our rollup
customChain={axicmsChain} // inject axi-cms chain definition
>Follow these steps in order. Each step depends on the previous one completing successfully.
Make sure you have the following before continuing:
| Requirement | Version / Notes |
|---|---|
| Node.js | 18 or higher |
| MetaMask | Browser extension installed |
| Initia axi-cms node | Must be running and reachable at http://localhost:8545 |
If your axi-cms node isn't running yet, start it with Weave CLI before proceeding. The frontend will fail to load any on-chain data without it.
git clone https://github.com/charlesms1246/StormX
cd StormXInstall frontend and contract dependencies separately:
# Frontend
cd web && npm install
# Contracts
cd ../contracts && npm installCreate web/.env.local with the following values. Do not skip this — the app will not connect to the chain without it.
# axi-cms RPC and chain config
NEXT_PUBLIC_AXICMS_RPC_URL=http://localhost:8545
NEXT_PUBLIC_AXICMS_CHAIN_ID=2177282315500993
NEXT_PUBLIC_AXICMS_REST_URL=http://localhost:1317
# Deployed contract addresses
NEXT_PUBLIC_GAME_CONTRACT_ADDRESS=0x90DB0A1790D5CAA481052DbbF7b1F201ab8f0387
NEXT_PUBLIC_PLAYER_TOKEN_ADDRESS=0xA3C35867060244534F51A92a205496003cC4592E
# Server-side keys (not exposed to the browser)
DEPLOYER_PRIVATE_KEY=<your-deployer-private-key>
API_FOOTBALL_KEY=<your-api-sports-io-key>Open MetaMask → Settings → Networks → Add a network manually:
| Field | Value |
|---|---|
| Network Name | Initia axi-cms |
| RPC URL | http://localhost:8545 |
| Chain ID | 2177282315500993 |
| Currency Symbol | GAS |
MetaMask will switch to this network automatically when you connect on the site, but adding it manually first avoids a prompt on first load.
If contracts are already deployed and addresses match the ones in .env.local, skip this step.
cd contracts
# 1. Core game contract + reference player token
npx hardhat run scripts/deploy.ts --network axicms
# 2. All 19 club fan tokens (PAR, MON, MAR, LYO, LIL, LEN, NIC, REN…)
npx hardhat run scripts/deploy-team-tokens.ts --network axicms
# 3. MerchNFT + TicketContract with fan token payment integration
npx hardhat run scripts/deploy-merch-tickets-v2.ts --network axicmsDeployed addresses are printed to the console and saved to contracts/deployments/axicms.json.
Start the frontend first, then call the seed endpoints once to populate merch items and ticket fixtures on-chain:
# In one terminal — start the frontend
cd web && npm run dev
# In another terminal — seed data (run once only)
curl -X POST http://localhost:3000/api/merch/seed
curl -X POST http://localhost:3000/api/tickets/seedYou should see success responses listing the seeded items. After this, the marketplace and tickets pages will show live on-chain inventory.
Navigate to http://localhost:3000.
First-time checklist:
- MetaMask is installed and unlocked
- axi-cms network is selected in MetaMask (or will be prompted on connect)
- Click Connect Wallet — your
.initusername appears in the nav if you have one registered - Click Faucet to get player tokens for testing
- Click Bridge to open the Interwoven bridge modal (connects to Initia testnet)
Player A Player B
│ │
│ 1. Select 5 player tokens │
│ 2. POST /api/game/create │
│ 3. Approve + createGame() │
│ on-chain via MetaMask │
│ 4. Receive gameCode ──────────► │
│ │ 5. Enter gameCode
│ │ 6. Select 5 player tokens
│ │ 7. Approve + joinGame()
│ │ on-chain via MetaMask
│ │
│ ◄──────── Winner Determined ───►│
│ (on-chain resolution) │
│ │
└── Winner receives all 10 token positions ──┘
User
│
├─ 1. Connect MetaMask to axi-cms
├─ 2. Check balance of required club fan token
├─ 3. (if needed) Click faucet → POST /api/club-faucet → 1,000 tokens minted
├─ 4. MetaMask: approve(MerchNFT/TicketContract, price)
├─ 5. MetaMask: buyMerch(id) / buyTicket(matchId)
└─ 6. NFT / ticket event logged → visible in /claim profile
The /claim page shows four tabs for a connected wallet:
| Tab | Data Source |
|---|---|
| Player Tokens | /api/tokens — live balances from axi-cms |
| Merchandise | ERC-721 Transfer event logs on MerchNFT contract |
| My Tickets | TicketPurchased event logs on TicketContract |
| Club Tokens | balanceOf calls across all 19 FanToken contracts |
| Contract | Address |
|---|---|
| GameContractMultiToken | 0x90DB0A1790D5CAA481052DbbF7b1F201ab8f0387 |
| MerchNFT | 0x18336FF9391De2fFBDe32B4c37d57e86801E3188 |
| TicketContract | 0x97A9D1E4f3861acda77F084803Edf0da49C1bC90 |
| PAR (PSG Fan Token) | 0x69996CBeFF6aC7CdE6e9820Ac9221B29d7D89dA8 |
| MON (Monaco Fan Token) | 0xC25184Ba7A8cA4a146A350d8895bDA75FDA3973e |
| MAR (Marseille Fan Token) | 0x45563BA6e572284389F0D8FfD03eD677dB36C3eC |
| LYO (Lyon Fan Token) | 0x52e7d0669832Be8d0cfcF8AD74cf4E46BBb1050A |
| LIL (Lille Fan Token) | 0x93376EE5cf615CC68D9C8E32d9927Ac559BcC1BB |
| LEN (Lens Fan Token) | 0x79d28F02977632c184d50D5b8c75c610bf970105 |
| NIC (Nice Fan Token) | 0x8E925a17e4661dF282CfB1Be49578b264E2070c9 |
| REN (Rennes Fan Token) | 0x3863Ce8FF53d0EaA69340f4B0B143987fD885f0b |
StormX is hardcoded to talk to the axi-cms rollup. If you want to spin up a fresh rollup and wire the project to it, follow these steps.
Go to app.initia.xyz/rollup/create and create a new Interwoven Rollup with the following settings:
| Field | Recommended value |
|---|---|
| VM Type | EVM |
| Gas Token | Your own (e.g. GAS) |
| Gas Price | 0 (gasless) |
Once created, the dashboard gives you:
- RPC URL — e.g.
https://rpc.your-rollup.initia.xyz - Chain ID — a unique integer, e.g.
1234567890 - Deployer private key — fund it from the faucet on the dashboard
Open contracts/hardhat.config.ts and update the axicms network block:
axicms: {
url: "https://rpc.your-rollup.initia.xyz",
chainId: 1234567890, // your rollup's chain ID
accounts: [process.env.PRIVATE_KEY!],
gasPrice: 0,
}cd contracts
# Set your deployer key
export PRIVATE_KEY=0x<your-deployer-key>
# Deploy game contract + a reference player token
npx hardhat run scripts/deploy.ts --network axicms
# Deploy all 19 club fan tokens
npx hardhat run scripts/deploy-team-tokens.ts --network axicms
# Deploy MerchNFT + TicketContract
npx hardhat run scripts/deploy-merch-tickets-v2.ts --network axicmsEach script prints deployed addresses. They are also saved to contracts/deployments/axicms.json.
After deployment, update these files with the new addresses:
web/.env.local
NEXT_PUBLIC_AXICMS_RPC_URL=https://rpc.your-rollup.initia.xyz
NEXT_PUBLIC_AXICMS_CHAIN_ID=1234567890
NEXT_PUBLIC_GAME_CONTRACT_ADDRESS=<new GameContractMultiToken address>
DEPLOYER_PRIVATE_KEY=<your-deployer-key>web/lib/contract-config.ts — update GAME_CONTRACT_ADDRESS.
web/lib/const.ts — update MerchContractAddress, TicketingContractAddress, and the teamFanTokens map (one address per club symbol).
web/lib/merch.ts — update paymentTokenAddress for each merch item to match the newly deployed club fan token addresses.
web/app/tickets/page.tsx — update paymentTokenAddress inside FIXTURE_META for each fixture.
Open web/lib/client.ts and update the chain object:
export const axicms = defineChain({
id: 1234567890, // your chain ID
name: "My Rollup",
rpcUrls: {
default: { http: ["https://rpc.your-rollup.initia.xyz"] },
},
nativeCurrency: { name: "GAS", symbol: "GAS", decimals: 18 },
});| Field | Value |
|---|---|
| Network Name | Your rollup name |
| RPC URL | https://rpc.your-rollup.initia.xyz |
| Chain ID | 1234567890 |
| Currency Symbol | GAS |
With the frontend running (npm run dev), call the seed endpoints once to populate merch items and ticket fixtures on-chain:
curl -X POST http://localhost:3000/api/merch/seed
curl -X POST http://localhost:3000/api/tickets/seedAfter this the marketplace and ticket pages will show live on-chain inventory.
StormX/
├── web/ # Next.js frontend
│ ├── app/
│ │ ├── page.tsx # Landing page (3D scene + features)
│ │ ├── game/ # Game create, join, battle, history
│ │ ├── marketplace/ # Merchandise NFT store
│ │ ├── tickets/ # Match ticket listings
│ │ ├── claim/ # Player profile (tokens, NFTs, tickets)
│ │ ├── leagues/ # Ligue 1 standings + team browser
│ │ ├── player/ # Individual player pages
│ │ ├── team/ # Team squad pages
│ │ ├── components/
│ │ │ ├── ConnectButton.tsx # Wallet button — shows .init username
│ │ │ ├── BridgeButton.tsx # Interwoven Bridge modal trigger
│ │ │ └── Provider.tsx # InterwovenKit + Wagmi + TESTNET preset
│ │ └── api/ # 14 Next.js API routes
│ ├── lib/
│ │ ├── client.ts # viem chain + wallet client
│ │ ├── const.ts # ABI definitions + contract addresses
│ │ ├── contract-config.ts # Game contract config
│ │ ├── merch.ts # Merch display metadata
│ │ └── contractReaders.ts # Read helpers
│ └── docs/
│ ├── API_REFERENCE.md
│ └── API_CONTRACT.md
└── contracts/
├── contracts/
│ ├── Game.sol # GameContractMultiToken
│ ├── PlayerToken.sol # Per-player ERC-20
│ ├── FanToken.sol # Club ERC-20 (18 decimals)
│ ├── Merch.sol # MerchNFT (ERC-721)
│ ├── Ticketing.sol # TicketContract
│ └── SimpleRandom.sol # Block-based randomness
├── scripts/
│ ├── deploy.ts # Core contract deployment
│ ├── deploy-team-tokens.ts # 19 club fan tokens
│ └── deploy-merch-tickets-v2.ts # Merch + ticket contracts
└── deployments/
└── axicms.json # Deployed addresses