ETH integration

See smart contract for mainnet addresses, audit, and summary.

Try out the testnet app at staging.megapot.io to get a feel for all of its pages.

Testnet contract

Our testnet lottery is not automatically run every 24h - ping us to run it for you. To do it yourself:

  • Fetch the lottery fee at Blastscan > Read Proxy Contract > getLotteryFee. Convert this from WEI to ETH, thus, divide by 10^18

  • Go to Sepolia Blastscan > Write Proxy Contract > RunLottery

  • For runLottery - payableAmount (ether), enter in the lottery fee in WEI.

  • For userRandomNumber - bytes32, enter in 0x55fb29339a98ca25bedb7b5aa225041f669ca1407e926a95ce4a9b080ac66907 (note: reusing this is fine, Pyth generates randomness, this seed further maximizes randomness - generate your own with Web3.utils.randomHex(32))

Get the current lottery details

Time remaining: lastLotteryEndTime + roundDurationInSeconds - currentTime

Jackpot size: Use the larger of userPoolTotal or lpPoolTotal

  • If the lpPoolTotal is $100, and a user buys 1 ticket, that ticket ($0.85) goes into userPoolTotal. lpPoolTotal stays the same!

  • Unless users buy >$100 of tickets (post-fees), then the jackpot stays at $100

Odds of winning: Jackpot size / (ticketPrice * (1 - feeBps/10000))

  • A user buys a ticket worth 0.001 ETH, but after 15% fees, only 0.00085 ETH is “entered” into the lottery pool.

  • Thus, if the jackpot is 1 ETH, we want to show that your odds are winning are 1 in 1176 tickets, rather than 1 in 1000. This is how we calculate this:

export const calculateTotalTicketCount = (prize: number, feeBps: number): bigint => {
 return BigInt((prize * (10000 / (10000 - toDecimal(feeBps).toNumber())) * 1000).toFixed(0));
};

To get prices, we use the Coinbase API since it requires no API key and their rate limits are per IP address.

https://api.coinbase.com/v2/prices/ETH-USD/spot

Here is how we display the countdown timer, plus a grace period for the 5 minutes it takes to run the lottery:

import { Box, Heading, HStack, Text, VStack } from "@chakra-ui/react";
import { type CountdownRenderProps } from "react-countdown";
import Countdown, { zeroPad } from "react-countdown";

export const CountdownGridRenderer = ({
  days,
  hours,
  minutes,
  seconds,
  completed,
  endTime,
}: CountdownRenderProps & { endTime: number }) => {
  if (completed) {
    const currentTime = Date.now();
    const lotteryRunTime = endTime + 5 * 60 * 1000; // 5 minutes after endTime

    if (currentTime > lotteryRunTime) {
      return (
        <VStack spacing={2} align="center">
          <Text>An error occurred. Please contact @patricklung on Telegram!</Text>
        </VStack>
      );
    }

    return (
      <Countdown
        date={lotteryRunTime}
        zeroPadTime={2}
        renderer={({ minutes, seconds, completed: lotteryCompleted }) => {
          if (lotteryCompleted) {
            return (
              <VStack spacing={2} align="center">
                <Text>Lottery completed</Text>
                <Text fontSize="sm">Please refresh the page to see results!</Text>
              </VStack>
            );
          } else {
            return (
              <VStack spacing={2} align="center">
                <Text>
                  Running lottery, {minutes}:{zeroPad(seconds)} remaining
                </Text>
              </VStack>
            );
          }
        }}
      />
    );
  } else {
    return (
      <HStack justifyContent="center">
        {days > 0 && (
          <Box minWidth="70px" textAlign="center">
            <Heading>{days}</Heading>
            <Text>DAYS</Text>
          </Box>
        )}
        {hours > 0 && (
          <Box minWidth="70px" textAlign="center">
            <Heading>{hours}</Heading>
            <Text>HRS</Text>
          </Box>
        )}
        <Box minWidth="70px" textAlign="center">
          <Heading>{minutes}</Heading>
          <Text>MIN</Text>
        </Box>
        <Box minWidth="70px" textAlign="center">
          <Heading>{seconds}</Heading>
          <Text>SEC</Text>
        </Box>
      </HStack>
    );
  }
};

This is how the grace period looks in the app, otherwise, it'll show 0h 0m:

You should do the same for lottery history as well:

Buy tickets

Call purchaseTickets with a value that’s a multiple of ticketPrice and set your wallet address as the referrer.

This wallet will need to:

  • Call a function to withdraw fees

  • Sign up with Blast to earn Blast Points and Gold

We recommend showing a loading indicator for the ticket purchase.

import { useState } from "react";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { CustomButton } from "@/components/Common/CustomButton";
import { parseEther } from "viem";

export const PoolCardSmartContract = ({ ticketPrice, contract }) => {
  const [ticketAmount, setTicketAmount] = useState(1);

  const { data: hash, writeContract, isLoading } = useWriteContract();

  const { isSuccess, isFetching } = useWaitForTransactionReceipt({
    hash,
  });

  const handleBuyTicket = () => {
    const totalCost = ticketPrice * ticketAmount;
    writeContract({
      ...contract,
      functionName: "purchaseTickets",
      args: ["0x0000000000000000000000000000000000000000"], // referrer address
      value: parseEther(totalCost.toString()),
    });
  };

  return (
    <>
      {/* Ticket amount input would go here */}
      <CustomButton
        onClick={handleBuyTicket}
        isLoading={isLoading || isFetching}
        isDisabled={ticketAmount === 0}
      >
        {isLoading || isFetching ? "Buying..." : "BUY NOW"}
      </CustomButton>
    </>
  );
};

Check user's current lottery entries

Call userInfo with their address, it returns these three fields

    struct User {
        // Total tickets purchased by the user for current lottery, multiplied by 10000, resets each lottery
        uint256 ticketsPurchasedTotalBps;
        // Tracks the total win amount in ETH (how much the user can withdraw)
        uint256 winningsClaimable;
        // Whether or not the user is participating in the current lottery
        bool active;
    }

This is how we calculate # of tickets purchased per user, as well as if they have any winnings claimable

// Purchased tickets are in BPS, so we need to divide by 10,0000 to get the post-fee number
   // and then divide by (1 - fee percentage) to get the pre-fee number
   ticketsPurchased =
     toDecimal(userInfoData[0]).div(10000).toNumber() /
     ((100 - TICKET_PURCHASE_FEE_PERCENTAGE) / 100);

   winningsClaimable = toDecimal(userInfoData[1], 18).toNumber();

Check lottery result and past users' entries

You can view the latest RevealedWithCallback event (example) from Pyth Entropy, scroll to the bottom of this page to see the events returned. Here's an example result log:

time : 1720469297
winner : 0x450EE356Ad372Cc77D3fC329A472326633232B30
winningTicket : 347739
winAmount : 1357591681236673772
ticketsPurchasedTotalBps : 858500

Here is the API call we make to Blastscan to fetch lottery results and all users' ticket purchases, then add it into each user's ticket history card.

import { type Address } from "viem";
import { blast, blastSepolia } from "viem/chains";

import { TICKET_PURCHASE_FEE_PERCENTAGE } from "@/utils/constants";
const LOTTERY_CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS as Address;
const BLAST_LOTTERY_ADDRESS = process.env.NEXT_PUBLIC_BLAST_LOTTERY_ADDRESS as Address;
const CHAIN = process.env.NEXT_PUBLIC_CHAIN || "testnet";
const API_URL =
  CHAIN == "mainnet"
    ? blast.blockExplorers.default.apiUrl
    : blastSepolia.blockExplorers.default.apiUrl;
const ETH_USER_TICKET_PURCHASE_TOPIC =
  "0xedc823804e4431d900483237575344cd2db13eaacc836fb481c86a0f51d2c182";
const BLAST_USER_TICKET_PURCHASE_TOPIC =
  "0xb0cf329104ca766d6830968f6a926d915e398d06541a13106c6b372bb6834df3";
const LOTTERY_RUN_TOPIC = "0x4718ede503b98b63750fe9c8b5b8410db1b336e47ba2172e8c04abe4a6c8a1fb";
const BLASTSCAN_API_KEY = process.env.BLASTSCAN_API_KEY as string;

// fetch ticket purchases for all users
type TicketPurchaseInfo = { blockNumber: number; ticketsPurchased: number };
const fetchTicketPurchases = async (token: string) => {
  const batchSize = 1000; // Maximum allowed by the API
  let allEvents: any[] = [];
  let page = 1;
  let hasMoreEvents = true;

  while (hasMoreEvents) {
    const urlParams = new URLSearchParams({
      module: "logs",
      action: "getLogs",
      address: token === "Blast" ? BLAST_LOTTERY_ADDRESS : LOTTERY_CONTRACT_ADDRESS,
      topic0: token === "Blast" ? BLAST_USER_TICKET_PURCHASE_TOPIC : ETH_USER_TICKET_PURCHASE_TOPIC,
      fromBlock: "0",
      toBlock: "latest",
      page: page.toString(),
      offset: batchSize.toString(),
      apikey: BLASTSCAN_API_KEY,
    });

    const request = `${API_URL}?${urlParams}`;
    const response = await fetch(request);
    const body = await response?.json();

    if (body.status === "1" && body.result && body.result.length > 0) {
      allEvents = allEvents.concat(body.result);
      if (body.result.length < batchSize) {
        hasMoreEvents = false;
      } else {
        page++;
        // Add a small delay to avoid rate limiting
        await new Promise((resolve) => setTimeout(resolve, 200));
      }
    } else {
      hasMoreEvents = false;
    }
  }

  const ticketPurchases = new Map<string, TicketPurchaseInfo[]>();
  allEvents.forEach((event: any) => {
    const userAddress = `0x${event.topics[1].substring(26, 66)}`;
    let ticketsPurchased;
    if (token === "Blast") {
      // For Blast, the tickets purchased is the first 32 bytes of the data
      ticketsPurchased =
        parseInt(event.data.substring(0, 66), 16) /
        10000 /
        ((100 - TICKET_PURCHASE_FEE_PERCENTAGE) / 100);
      // The referrer address is the next 32 bytes, if you need to use it
      // const referrerAddress = `0x${event.data.substring(66, 130)}`;
    } else {
      // For ETH, the entire data field is the tickets purchased
      ticketsPurchased =
        parseInt(event.data, 16) / 10000 / ((100 - TICKET_PURCHASE_FEE_PERCENTAGE) / 100);
    }
    const blockNumber = parseInt(event.blockNumber, 16);
    if (!ticketPurchases.has(userAddress)) {
      ticketPurchases.set(userAddress, []);
    }
    ticketPurchases.get(userAddress)?.push({ blockNumber, ticketsPurchased });
  });

  return ticketPurchases;
};

// Add this function to get the current block number
const getCurrentBlockNumber = async () => {
  const urlParams = new URLSearchParams({
    module: "block",
    action: "getblocknobytime",
    timestamp: Math.floor(Date.now() / 1000).toString(),
    closest: "before",
    apikey: BLASTSCAN_API_KEY,
  });
  const request = `${API_URL}?${urlParams}`;
  const response = await fetch(request);
  const body = await response?.json();
  return parseInt(body.result);
};

// fetch lottery info (winnerAddress, winAmount, ticketsPurchasedByWinner, blockNumber, transactionHash) for all lotteries
type LotteryInfo = {
  winnerAddress: string;
  winAmount: number;
  ticketsPurchasedByWinner: number;
  blockNumber: number;
  transactionHash: string;
  estimatedTimestamp: number;
};
const fetchLotteryInfo = async (token: string) => {
  const TICKET_PURCHASE_FEE_PERCENTAGE = 10;
  const urlParams = new URLSearchParams({
    module: "logs",
    action: "getLogs",
    address: token == "Blast" ? BLAST_LOTTERY_ADDRESS : LOTTERY_CONTRACT_ADDRESS,
    topic0: LOTTERY_RUN_TOPIC,
    apiKey: BLASTSCAN_API_KEY,
  });
  const request = `${API_URL}?${urlParams}`;
  const response = await fetch(request);
  const body = await response?.json();
  const events = body.result;
  const currentBlockNumber = await getCurrentBlockNumber();
  const currentTimestamp = Math.floor(Date.now() / 1000);

  const lotteryInfos: LotteryInfo[] = [];
  events.forEach((event: any) => {
    // field 1 [66:130]
    const winner = event.data.substring(66, 130);
    const winnerAddress =
      parseInt(winner, 16) === 0 ? "LP_WINNER" : `0x${winner.substring(24, 64)}`;
    // field 3 [194:258]
    const winAmount = parseInt(event.data.substring(194, 258), 16) / 10 ** 18;
    // field 4 [258:322]
    const ticketsPurchasedByWinner =
      parseInt(event.data.substring(258, 322), 16) /
      10000 /
      ((100 - TICKET_PURCHASE_FEE_PERCENTAGE) / 100);
    const blockNumber = parseInt(event.blockNumber, 16);
    const transactionHash = event.transactionHash;
    const estimatedTimestamp = currentTimestamp - (currentBlockNumber - blockNumber) * 2; // 2 seconds per block

    const lotteryInfo = {
      winnerAddress,
      winAmount,
      ticketsPurchasedByWinner,
      blockNumber,
      transactionHash,
      estimatedTimestamp,
    };
    lotteryInfos.push(lotteryInfo);
  });

  return lotteryInfos;
};

// build lottery history for a user address by fetching lottery info and merging with user's ticket purchase count
type UserLotteryInfo = {
  winnerAddress: string;
  winAmount: number;
  ticketsPurchasedByWinner: number;
  blockNumber: number;
  transactionHash: string;
  ticketsPurchasedByUser: number;
  estimatedTimestamp: number;
};
const buildUserLotteryHistory = async (userAddress: string | undefined, token: string) => {
  const lotteryInfos = await fetchLotteryInfo(token);

  if (!userAddress) {
    return lotteryInfos.map((info) => ({
      ...info,
      ticketsPurchasedByUser: 0,
    }));
  }

  const ticketPurchases = await fetchTicketPurchases(token);
  const userTicketPurchases = ticketPurchases.get(userAddress.toLowerCase()) || [];
  const userLotteryHistory: UserLotteryInfo[] = [];
  let prevBlock = 0;
  lotteryInfos.forEach((lotteryInfo) => {
    let ticketsPurchasedByUser = 0;
    userTicketPurchases.forEach((userTicketPurchase) => {
      if (
        prevBlock <= userTicketPurchase.blockNumber &&
        userTicketPurchase.blockNumber < lotteryInfo.blockNumber
      ) {
        ticketsPurchasedByUser += userTicketPurchase.ticketsPurchased;
      }
    });
    prevBlock = lotteryInfo.blockNumber;
    userLotteryHistory.push({
      ...lotteryInfo,
      ticketsPurchasedByUser,
    });
  });

  return userLotteryHistory;
};

export { buildUserLotteryHistory, type UserLotteryInfo };

We highlight recent lottery wins and only show lottery results when a user actually entered. This filters out lottery history that's irrelevant for a user and demoralizing since LPs win most of the time:

const filteredLotteryHistory = lotteryHistory
    .filter((info) => {
      const date = new Date(info.estimatedTimestamp * 1000);
      return (
        date.getTime() >= new Date("2024-07-07").getTime() &&
        (info.winnerAddress !== "LP_WINNER" || info.ticketsPurchasedByUser > 0)
      );
    })
    .sort((a, b) => b.estimatedTimestamp - a.estimatedTimestamp);

This is how that looks like:

Let a user claim their winnings

Have them call withdrawWinnings from their wallet. They can claim at any time.

How you can claim your fees

Call withdrawReferralFees from the wallet you set as the referrer

Last updated