Players deconstruct burger NFTs earned in the Rolling Burger game to claim ingredient tokens on-chain. Ingredient tokens are Mint Club V2 Bond-backed ERC-20s, all collateralised by the CHEF token.
sequenceDiagram
actor Player
participant Server as Game Server
participant BD as BurgerDeconstructor<br/>(holds CHEF)
participant Bond as MCV2_Bond
Player->>Server: 1. Deconstruct burger
Server-->>Player: 2. EIP-712 signature<br/>(ingredients, maxChefCost, deadline)
Player->>Player: 3. Approve GRAMPUS + NFT
Player->>BD: 4. deconstruct(burgerId, ingredients, maxChefCost, deadline, sig)
BD->>BD: Verify signature & collect 1000 GRAMPUS β treasury
BD->>BD: Burn NFT β 0xdead
loop For each ingredient token
BD->>Bond: mint(token, amount, maxReserve, player)
Bond-->>Player: Ingredient tokens
end
BD->>BD: Revert if totalChefSpent > maxChefCost
On deconstruct():
- GRAMPUS fee β 1,000 GRAMPUS transferred from player β
treasuryWallet - NFT burn β Rolling Burger ERC-721
burgerIdtransferred to0xdead - Ingredient mint β CHEF spent via
MCV2_Bond.mint(), tokens go directly to player - Slippage check β reverts if total CHEF spent > server-signed
maxChefCost(frontrun protection)
Replay protection: each burgerId can only be deconstructed once (NFT at dead address) + deadline expiry.
npm install
npx hardhat compile
npx hardhat test # requires RPC_BASE in .env (Base mainnet fork)npx hardhat ignition deploy ignition/modules/BurgerDeconstructor.ts \
--network base \
--parameters '{"BurgerDeconstructor": {"signerAddress": "0xSIGNER", "chefAddress": "0xCHEF", "treasuryWallet": "0xTREASURY"}}'After deploying, deposit CHEF tokens into the contract. The contract auto-approves the Bond at deploy time.
import { createPublicClient, http, parseAbi, parseEther } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
const SIGNER_KEY = process.env.SIGNER_PRIVATE_KEY as `0x${string}`;
const account = privateKeyToAccount(SIGNER_KEY);
const BURGER_DECONSTRUCTOR_ADDRESS = "0x..." as `0x${string}`;
const MCV2_BOND_ADDRESS = "0xc5a076cad94176c2996B32d8466Be1cE757FAa27";
const CHAIN_ID = 8453; // Base
const LETTUCE = "0x..." as `0x${string}`;
const TOMATO = "0x..." as `0x${string}`;
const CHEESE = "0x..." as `0x${string}`;
const publicClient = createPublicClient({ chain: base, transport: http() });
const MCV2BondABI = parseAbi([
"function getReserveForToken(address token, uint256 tokensToMint) external view returns (uint256 reserveAmount, uint256 royalty)",
]);
// Estimate total CHEF cost with a buffer (e.g. 10%)
async function estimateMaxChefCost(
ingredients: { token: `0x${string}`; amount: bigint }[],
bufferBps = 1000n // 10%
) {
let total = 0n;
for (const ing of ingredients) {
const [reserveAmount, royalty] = await publicClient.readContract({
address: MCV2_BOND_ADDRESS,
abi: MCV2BondABI,
functionName: "getReserveForToken",
args: [ing.token, ing.amount],
});
total += reserveAmount + royalty;
}
return total + (total * bufferBps) / 10000n;
}
async function generateDeconstructSignature(
playerAddress: `0x${string}`,
burgerId: bigint,
ingredients: { token: `0x${string}`; amount: bigint }[]
) {
const deadline = BigInt(Math.floor(Date.now() / 1000) + 300); // 5 min
const maxChefCost = await estimateMaxChefCost(ingredients);
const signature = await account.signTypedData({
domain: {
name: "BurgerDeconstructor",
version: "1",
chainId: CHAIN_ID,
verifyingContract: BURGER_DECONSTRUCTOR_ADDRESS,
},
types: {
Deconstruct: [
{ name: "player", type: "address" },
{ name: "burgerId", type: "uint256" },
{ name: "ingredients", type: "IngredientToken[]" },
{ name: "maxChefCost", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
IngredientToken: [
{ name: "token", type: "address" },
{ name: "amount", type: "uint256" },
],
},
primaryType: "Deconstruct",
message: { player: playerAddress, burgerId, ingredients, maxChefCost, deadline },
});
return { signature, maxChefCost: maxChefCost.toString(), deadline: deadline.toString(), ingredients };
}
// Example: player deconstructs burger #42
const result = await generateDeconstructSignature("0xPlayerAddress...", 42n, [
{ token: LETTUCE, amount: parseEther("100") },
{ token: TOMATO, amount: parseEther("50") },
{ token: CHEESE, amount: parseEther("30") },
]);Before calling deconstruct, the player must approve both GRAMPUS (ERC-20) and the Rolling Burger NFT (ERC-721) for the BurgerDeconstructor contract.
import { createWalletClient, http, parseAbi, erc20Abi, parseEther } from "viem";
import { base } from "viem/chains";
const BURGER_DECONSTRUCTOR_ADDRESS = "0x..." as `0x${string}`;
const GRAMPUS_ADDRESS = "0x856D602E73545deAA1491a3726cF628d49f74F51";
const BURGER_NFT_ADDRESS = "0xf51998994c0256723274727e57331817a17810a0";
const BurgerDeconstructorABI = parseAbi([
"function deconstruct(uint256 burgerId, (address token, uint256 amount)[] ingredients, uint256 maxChefCost, uint256 deadline, bytes signature) external",
]);
// ββ Step 1: One-time approvals (can be done once) βββββββββββββββββββ
async function approveForDeconstruct(walletClient: any) {
// Approve GRAMPUS (1000 per deconstruct β or use a larger allowance)
await walletClient.writeContract({
address: GRAMPUS_ADDRESS,
abi: erc20Abi,
functionName: "approve",
args: [BURGER_DECONSTRUCTOR_ADDRESS, parseEther("100000")], // enough for ~100 deconstructs
account: walletClient.account!,
chain: base,
});
// Approve all Rolling Burger NFTs
await walletClient.writeContract({
address: BURGER_NFT_ADDRESS,
abi: parseAbi(["function setApprovalForAll(address operator, bool approved) external"]),
functionName: "setApprovalForAll",
args: [BURGER_DECONSTRUCTOR_ADDRESS, true],
account: walletClient.account!,
chain: base,
});
}
// ββ Step 2: Call deconstruct with server signature βββββββββββββββββββ
async function deconstructBurger(
walletClient: any,
burgerId: bigint,
ingredients: { token: `0x${string}`; amount: bigint }[],
maxChefCost: bigint,
deadline: bigint,
signature: `0x${string}`
) {
const hash = await walletClient.writeContract({
address: BURGER_DECONSTRUCTOR_ADDRESS,
abi: BurgerDeconstructorABI,
functionName: "deconstruct",
args: [
burgerId,
ingredients.map((i) => ({ token: i.token, amount: i.amount })),
maxChefCost,
deadline,
signature,
],
account: walletClient.account!,
chain: base,
});
return hash;
}
// ββ Usage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// 1. Approve (once)
await approveForDeconstruct(walletClient);
// 2. Get signature from server
const res = await fetch("/api/burger/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ burgerId: 42 }),
});
const { signature, maxChefCost, deadline, ingredients } = await res.json();
// 3. Broadcast
await deconstructBurger(
walletClient,
42n,
ingredients.map((i: any) => ({ token: i.token, amount: BigInt(i.amount) })),
BigInt(maxChefCost),
BigInt(deadline),
signature as `0x${string}`
);All ingredient tokens live on the MCV2 bonding curve backed by CHEF. Players can swap freely: sell ingredients for CHEF, or buy ingredients with CHEF.
import { createPublicClient, http, parseAbi, erc20Abi } from "viem";
import { base } from "viem/chains";
const MCV2_BOND_ADDRESS = "0xc5a076cad94176c2996B32d8466Be1cE757FAa27";
const CHEF_ADDRESS = "0x..." as `0x${string}`;
const MCV2BondABI = parseAbi([
"function mint(address token, uint256 tokensToMint, uint256 maxReserveAmount, address receiver) external returns (uint256)",
"function burn(address token, uint256 tokensToBurn, uint256 minRefund, address receiver) external returns (uint256)",
"function getReserveForToken(address token, uint256 tokensToMint) external view returns (uint256 reserveAmount, uint256 royalty)",
"function getRefundForTokens(address token, uint256 tokensToBurn) external view returns (uint256 refundAmount, uint256 royalty)",
]);
const publicClient = createPublicClient({ chain: base, transport: http() });
// ββ Buy ingredient token with CHEF ββββββββββββββββββββββββββββββββββ
async function buyIngredient(
walletClient: any,
ingredientToken: `0x${string}`,
tokensToMint: bigint,
slippageBps = 100n
) {
const player = walletClient.account!.address;
const [reserveAmount, royalty] = await publicClient.readContract({
address: MCV2_BOND_ADDRESS,
abi: MCV2BondABI,
functionName: "getReserveForToken",
args: [ingredientToken, tokensToMint],
});
const maxReserve = reserveAmount + royalty + ((reserveAmount + royalty) * slippageBps) / 10000n;
await walletClient.writeContract({
address: CHEF_ADDRESS,
abi: erc20Abi,
functionName: "approve",
args: [MCV2_BOND_ADDRESS, maxReserve],
account: walletClient.account!,
chain: base,
});
return await walletClient.writeContract({
address: MCV2_BOND_ADDRESS,
abi: MCV2BondABI,
functionName: "mint",
args: [ingredientToken, tokensToMint, maxReserve, player],
account: walletClient.account!,
chain: base,
});
}
// ββ Sell ingredient token for CHEF ββββββββββββββββββββββββββββββββββ
async function sellIngredient(
walletClient: any,
ingredientToken: `0x${string}`,
tokensToBurn: bigint,
slippageBps = 100n
) {
const player = walletClient.account!.address;
const [refundAmount] = await publicClient.readContract({
address: MCV2_BOND_ADDRESS,
abi: MCV2BondABI,
functionName: "getRefundForTokens",
args: [ingredientToken, tokensToBurn],
});
const minRefund = refundAmount - (refundAmount * slippageBps) / 10000n;
await walletClient.writeContract({
address: ingredientToken,
abi: erc20Abi,
functionName: "approve",
args: [MCV2_BOND_ADDRESS, tokensToBurn],
account: walletClient.account!,
chain: base,
});
return await walletClient.writeContract({
address: MCV2_BOND_ADDRESS,
abi: MCV2BondABI,
functionName: "burn",
args: [ingredientToken, tokensToBurn, minRefund, player],
account: walletClient.account!,
chain: base,
});
}| Function | Access | Description |
|---|---|---|
deconstruct(burgerId, ingredients[], maxChefCost, deadline, signature) |
Player | Collect GRAMPUS fee β burn NFT β mint ingredients via Bond (slippage-protected) |
updateSigner(newSigner) |
Owner | Change the authorized server signer |
updateTreasuryWallet(newWallet) |
Owner | Change the GRAMPUS fee recipient |
withdrawToken(token, amount) |
Owner | Withdraw tokens (e.g. CHEF) from the contract |
getDigest(player, burgerId, ingredients[], maxChefCost, deadline) |
View | EIP-712 digest for off-chain verification |
DOMAIN_SEPARATOR() |
View | EIP-712 domain separator |
BURGER_NFT.ownerOf(burgerId) == DEAD_ADDRESS |
β | NFT at dead address = already deconstructed |
| Constant | Value |
|---|---|
BOND |
0xc5a076cad94176c2996B32d8466Be1cE757FAa27 (MCV2_Bond) |
GRAMPUS |
0x856D602E73545deAA1491a3726cF628d49f74F51 |
BURGER_NFT |
0xf51998994c0256723274727e57331817a17810a0 (Rolling Burger ERC-721) |
GRAMPUS_FEE |
1,000 GRAMPUS (18 decimals) |
DEAD_ADDRESS |
0x000000000000000000000000000000000000dEaD |
CHEF |
Set at deploy (immutable) |
treasuryWallet |
Set at deploy (updatable by owner) |