1. Summary
BUMS is a 1,024-supply pixel character and audio collection. Each token is rendered as a 128×128 SVG and ships with a procedurally generated WAV signature, both encoded directly in the contract — no IPFS, no hosted reveal.
The collection is built on a hybrid ERC-20 / ERC-721 base, so every BUM is simultaneously a unique non-fungible token and a unit of fungible value. Half of the supply (512) starts wrapped as BUMS and seeds a Uniswap v4 pool plus a vault used for liquidity-backed mechanics. The other half (512) is a free public claim pool, drained one token at a time on a global cooldown.
Swap volume through the v4 pool is metered by a custom hook that awards spray points. Spray points subsidize the unwrap surcharge or pay for non-destructive rerolls, giving liquidity participants a recurring utility loop after launch.
2. Contracts at a glance
| Contract | Role |
|---|---|
wavNFT | Hybrid ERC-20 / ERC-721 base class. BUMS is wavNFT. |
BUMS | Hybrid token core. Free claims, rerolls, admin controls, per-token salt locking. |
WrappedBUMS | ERC-20 wrapper contract. The wrapped token name and symbol are both BUMS. |
BUMSMetadata | Trait derivation, JSON metadata, image + audio URI assembly. |
BUMSArt | On-chain SVG image helper using BumRenderer. |
BUMSAudio | On-chain WAV helper using AudioSynth. |
SprayPointsRegistry | Canonical spray points ledger with an allowlist for earning hooks. |
BumsSprayHook | Uniswap v4 hook that reports swap-earned spray points into the registry. |
BumRenderer | Packed 4-bit 128×128 pixel SVG renderer (library). |
BumPalette | Face, background, and hat palette helpers (library). |
No ERC-2981 royalties are implemented.
3. Supply & token IDs
Total supply is fixed at 1,024. The hybrid base exposes NFT token IDs in the range 1025 .. 2048. On deployment, the BUMS contract owns the entire NFT supply. initialize() distributes them as follows:
| ID range | Count | Initial destination | Purpose |
|---|---|---|---|
1025 .. 1536 | 512 | WrappedBUMS vault | Backs BUMS supply |
1537 .. 2048 | 512 | BUMS claim pool | Free public claim inventory |
Initialization mints 512 × 1e18 BUMS to the BUMS contract, which the deployment script then uses to seed the v4 pool as a single-sided BUMS sell wall — the position is placed entirely below the initial tick, so the pool starts with 0 ETH and 512 BUMS, then accumulates ETH as buyers arrive. No deployer ETH is paired into liquidity at launch.
4. Free claim pool
Free claims are handled by BUMS.claim(). Rules:
- The claim pool starts with 512 tokens.
- Each claim returns one random token from the remaining pool. Random index is
uint256(blockhash(block.number - 1)) % claimPoolLength. - Per-token salt is locked before the transfer, so any indexer reading metadata after the claim sees the final identity.
- One claim per wallet. Once an address has claimed, it cannot claim again. Tracked in
hasClaimed[address]. The immutabledeployeris exempt — see Section 5. - A randomized cooldown of 90–110 blocks (inclusive, ~20–25 minutes) gates the next claim. The cooldown is global, not per wallet — only one claim can clear per cooldown gate.
- The cooldown roll mixes
blockhash,claimer,tokenId, and remaining pool length. It is unpredictable but not user-controllable. - Claims stop when
claimPoolLength == 0.
5. Claim-control switches
Two one-way admin switches replace the previous claim-cancellation mechanism. Each is irreversible once flipped, and neither moves any tokens directly — they only change which gates apply on subsequent claim() calls. This means the deployer can never sweep unclaimed supply in a single transaction.
onlyDeployerCanClaim()
- Admin-only. Sets
deployerOnly = true. - From this point on, only the immutable
deployeraddress can callclaim(). - The inter-claim cooldown gate continues to apply, so the deployer drains remaining supply at the same pace as the public would have. To remove the cooldown gate as well, also call
endCooldown(). - Emits
DeployerOnlyClaimsEnabled(admin).
endCooldown()
- Admin-only. Sets
cooldownEnded = true. nextClaimBlockis no longer checked. Claims can clear back-to-back in the same block.- The per-wallet limit still applies, so non-deployer wallets remain capped at one BUM each.
- Emits
CooldownEnded(admin).
Combined effect: with endCooldown() alone, the remaining supply opens for a public free-for-all where each wallet may claim at most one. With both onlyDeployerCanClaim() and endCooldown() set, the deployer can drain the rest of the pool in rapid succession. There is no path that lets the deployer mass-sweep unclaimed supply in a single transaction.
6. BUMS, wrap, unwrap
WrappedBUMS is an 18-decimal ERC-20 wrapper for BUM NFTs. Its token name and symbol are both BUMS.
Wrap
wrap(tokenIds):
- Transfers the listed BUM NFTs from the caller into the wrapper vault.
- Mints
1 × 1e18BUMS per NFT. - Requires NFT approval to the wrapper.
Standard unwrap
unwrap(count):
- Burns
count × 1e18BUMS as redemption. - Burns an additional 10% surcharge (
UNWRAP_BURN_BPS = 1000). Total burn iscount × 1.1e18. - Releases
countrandom NFTs from the wrapper vault. - Locks per-token salt on first user receipt.
The surcharge makes a wrap → unwrap round-trip cost 0.1 BUMS per NFT and over-collateralizes the vault relative to outstanding BUMS over time.
Spray-assisted unwrap
unwrapWithSpray(count, maxPointsToSpend):
- Same random vault release as standard unwrap.
- Burns the base
count × 1e18BUMS. - Spray points cover some or all of the 10% surcharge. Conversion rate:
10,000 points = 1 BUMSof surcharge coverage. - Points are capped by
maxPointsToSpend, the user's available points, and the surcharge needed. - For one unwrap, the full
0.1 BUMSsurcharge is fully covered by1,000points.
7. Rerolls
Rerolls keep the same token ID and locked salt but increment rerollNonce[tokenId]. Because the metadata seed includes the nonce, the rendered image and audio change while the token identity (ID + salt) stays stable.
BUMS reroll
BUMS.reroll(tokenId):
- Caller must own
tokenId. - Burns
1 × 1e18BUMS from the caller. - Destroys one random vault token by transferring it to
BURN_ADDR. - Increments
rerollNonce[tokenId]. - Emits
RerolledandMetadataUpdate.
Spray reroll
BUMS.sprayReroll(tokenId):
- Caller must own
tokenId. - Costs
10,000spendable spray points. - Does not burn BUMS. Does not destroy a vault token.
- Increments
rerollNonce[tokenId]. - Emits
RerolledandMetadataUpdate.
8. Spray points & v4 hook
BumsSprayHook is a Uniswap v4 hook with two active permissions: beforeInitialize and afterSwap. All other hook entrypoints (beforeAddLiquidity, etc.) are explicitly disabled and revert. The hook is swap-only by design, and it reports earned points into SprayPointsRegistry rather than holding balances itself.
Pool binding
On the first beforeInitialize call, the hook binds itself to one expected pool, checking:
- Expected
currency0andcurrency1 - Fee tier
- Tick spacing
- Hook address
Any other initialization reverts. After binding, only swaps in the bound pool can earn points.
Point award
After every swap, the hook computes absolute ETH-side volume and awards:
1,000 points per 1 ETH of ETH-side volume
| ETH-side volume | Points |
|---|---|
0.001 ETH | 1 |
0.1 ETH | 100 |
1 ETH | 1,000 |
10 ETH | 10,000 |
If hookData is exactly 32 bytes encoding a non-zero address, points credit to that beneficiary. Otherwise, points credit to the swap sender.
Balances and spending
SprayPointsRegistry tracks two balances per user:
lifetimePoints[user]— total earned, never decreases.spendablePoints[user]— unspent balance.
Only approved earning hooks can call earn(...). Only BUMS and WrappedBUMS can call spend(...). This keeps point balances stable even if a future hook/pool replacement is approved.
Spend use cases:
| Use case | ID | Cost |
|---|---|---|
| Unwrap surcharge coverage | 1 | variable, capped by surcharge |
| Spray reroll | 2 | 10,000 points |
9. Token identity, salt, metadata
Seed
Each token's metadata seed is computed as:
keccak256(abi.encodePacked("Seed:", tokenId, effectiveSalt, rerollNonce))
effectiveSalt is:
tokenSalt[tokenId]once locked.- The global
saltonly for provisional preview before first claim or first vault unwrap.
Salt locking
Per-token salt locks on free claim, including deployer-only claim drains, or first unwrap from the wrapper vault. Salt preimage:
keccak256(abi.encodePacked(
"BUMS-token-salt:",
block.prevrandao,
recipient,
block.number,
tokenId,
address(this)
))
The address(this) term binds salts to the deploying contract instance. block.prevrandao is the post-Merge randomness source.
Stickiness
Once locked, a salt is sticky:
- Direct ERC-721 transfers preserve it.
- Wrap → unwrap round trips preserve it.
- Rerolls preserve it; only
rerollNonce[tokenId]increments.
Metadata refresh
Because the rendered seed changes when salt locks (preview → canonical), marketplaces that aggressively cache tokenURI can show a stale preview. Two public utilities exist for cache busting:
refreshMetadata(tokenId)— emitsMetadataUpdate(tokenId)for one token.refreshMetadataBatch(fromId, toId)— emitsBatchMetadataUpdate(fromId, toId).
Both are public, not admin-gated. Anyone can ping a token to force marketplaces to re-fetch metadata. There is no spam vector beyond the caller's own gas. MetadataUpdate is also emitted automatically on every reroll.
10. Traits
The rendered image is a 128×128 on-chain SVG. Trait derivation reads bytes from the seed and from the audio mode pair (m, n):
| Trait | Cardinality | Derivation |
|---|---|---|
| Hat shape | 2 | m % 2 == 0 ? Cap : Beanie |
| Eyes | 4 | n band: closed / XX / dots / sideeye |
| Mouth | 5 | seed[3] % 5 |
| Face color | 3 | seed[8] % 3 (warm tones) |
| Hat color | 6 | seed[10] % 6 (neon pool) |
| Background color | 6 | (m + n) % 6 (neon pool) |
| Star count | 5 | seed[11] % 5 → 0..4 stars |
| Star layout | 8 | seed[12] % 8 |
Background and face are split by construction so the head is always legible against its field.
11. Audio
Each BUM has a procedurally generated WAV signature. The mode pair (m, n) is derived from the seed:
m = (seed[0] & 0x7) + 2 // range 2..9
n = (seed[1] & 0x7) + 2 // range 2..9
if (n == m) {
if (n == 9) n = 2;
else n = n + 1;
}
The bass frequency for the pair is:
f_mn = 6.875 × (m² + n²) Hz
For gas reasons, the contract stores f × 256 (i.e. 1760 × (m² + n²)) and divides on display.
Token metadata is fully on-chain, but split into a light default and a heavy on-demand variant:
tokenURI(tokenId)returns UTF-8 JSON with image + traits.imageisdata:image/svg+xml;base64,....animation_urlis not included in default metadata — keeping marketplaces from inlining the WAV blob and slowing every render.audioURI(tokenId)returnsdata:audio/wav;base64,.... The Studio reads this directly for live playback.fullTokenURI(tokenId)returns the heavy image + audio version for high-gas read calls (explorers, archival, deep links).
No IPFS or hosted reveal is required for image or audio metadata.
12. Trust model & admin
BUMS is admin-mutable in narrow ways. There is no multisig handoff and no plan to renounce admin. The admin key is the deployer EOA and remains so indefinitely. This is the trust posture for the project's lifetime — users should evaluate that before participating.
BUMS admin powers
| Function | Access | Cardinality |
|---|---|---|
setInitialAddresses(wrapper, metadata) | admin | once |
initialize() | admin | once |
setMetadata(newMetadata) | admin | repeatable |
setSprayPoints(sprayPoints) | admin | once |
setAdmin(newAdmin) | admin | repeatable |
seedInitialLiquidity(...) | admin | once |
onlyDeployerCanClaim() | admin | once (one-way) |
endCooldown() | admin | once (one-way) |
Metadata mutability
setMetadata swaps the entire BUMSMetadata contract address. This is the upgrade path for trait derivation, image rendering, audio synthesis, and JSON assembly. After a swap, every token's image and audio output reflects the new metadata contract. The admin retains this power for the lifetime of the project.
WrappedBUMS
The wrapper has one admin-style power: setSprayPoints(...), callable only by the BUMS contract, only once. Wrap, unwrap, and spray-assisted unwrap are otherwise permissionless.
SprayPointsRegistry
The registry admin can approve or revoke earning hooks with setEarningHook(hook, allowed) and can transfer registry admin with setAdmin(newAdmin). Existing points remain in the registry if a new hook and pool are deployed later.
Public utilities
refreshMetadata and refreshMetadataBatch are public, not admin-gated. They exist as marketplace cache-busting utilities — see Section 9.
Not present
There is no royalty admin path. ERC-2981 is not implemented. There is no pause function. There is no upgrade proxy on BUMS or WrappedBUMS — only the metadata contract is hot-swappable.
13. Deployment
Primary deployment script: launch/script/DeployWithLP.s.sol. Sequence:
- Deploy
BUMS. No ETH is attached at construction — the launch uses a single-sided BUMS LP, not an ETH-paired one. - Deploy
WrappedBUMS. - Deploy
BUMSArt. - Deploy
BUMSAudio. - Deploy
BUMSMetadata(bums, art, audio). - Set wrapper and metadata on
BUMSviasetInitialAddresses. - Distribute the supply via chunked initialization: 8 calls to
BUMS.initializeChunk(64, 64)followed byBUMS.finalizeInitialize(). Chunked because a monolithicinitialize()would exceed the post-Fusaka per-tx gas cap of16,777,216(EIP-7825). - Deploy
SprayPointsRegistry(admin, bums, wrapper). - CREATE2-mine and deploy the v4 hook through the canonical CREATE2 deployer at
0x4e59…956C. The hook address low bits encode the required permissions:BEFORE_INITIALIZE | AFTER_SWAP = (1 << 13) | (1 << 6). Mining loop is bounded at 100,000 iterations. - Configure
BUMSandWrappedBUMSto spend from the registry. - Approve the deployed hook as a registry earning hook.
- Initialize the v4 pool through the hook (which validates the pool binding).
- Mint a single-sided LP position from
MIN_TICK = -887200toMAX_TICK = 62400, escrowing512 BUMSand0 ETH. Because the position sits entirely below the initial tick (INIT_TICK = 62600), it acts as a pure BUMS sell wall — buyers swap ETH in, the pool accumulates that ETH inside the position. No ETH seed capital required from the deployer. - Post-deploy: admin remains with the deployer EOA. There is no scripted handoff and no planned renouncement.
Launch constants
| Item | Value |
|---|---|
| v4 fee tier | 1% (10,000 bps) |
| Tick spacing | 200 |
| LP composition | 512 BUMS, 0 ETH (single-sided) |
| Initial tick | 62600 (≈ 525 BUMS / ETH) |
| LP tick range | -887200 → 62400 (entirely below initial tick) |
| LP liquidity | 22_611_997_192_205_801_033 |
| Init-chunk size | 64 wraps + 64 claim-pool slots per call (8 chunks total) |
External addresses
The deploy script targets Ethereum mainnet and references the canonical Uniswap v4, Permit2, and CREATE2 deployments:
| PoolManager | 0x000000000004444c5dc75cB358380D2e3dE08A90 |
| PositionManager | 0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e |
| Permit2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
| CREATE2 deployer | 0x4e59b44847b379578588920cA78FbF26c0B4956C |
Last updated: 2026-05-04. Primary truth: launch/src/, launch/test/, launch/script/DeployWithLP.s.sol. If anything in this document conflicts with the on-chain contracts, the contracts are final.