Skip to content

Steemhunt/rolling-burger-example

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

1 Commit
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Rolling Burger - BurgerDeconstructor

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.

Architecture

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
Loading

On deconstruct():

  1. GRAMPUS fee β€” 1,000 GRAMPUS transferred from player β†’ treasuryWallet
  2. NFT burn β€” Rolling Burger ERC-721 burgerId transferred to 0xdead
  3. Ingredient mint β€” CHEF spent via MCV2_Bond.mint(), tokens go directly to player
  4. 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.


Setup

npm install
npx hardhat compile
npx hardhat test   # requires RPC_BASE in .env (Base mainnet fork)

Deploy

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.


Client Example Code

1. Server-side: Generate EIP-712 Signature

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") },
]);

2. Client-side: Approve & Broadcast deconstruct

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}`
);

3. Swap Ingredient Tokens ↔ CHEF via Mint Club V2 Bond

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,
  });
}

Contract Summary

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)

About

Burger Deconstructor contract and example support for the Rolling Burger project πŸ”

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors