Skip to content

Bella-DeFinTech/TossGame

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TossGame

Powered by Randcast, TossGame is an onchain game that allows users to toss a coin and win prizes. The game uses gasless transactions through EIP712 signatures for better UX.

Overview

TossGame supports both ETH and ERC20 tokens, with three main operations:

  • Deposit tokens with permit
  • Toss coin with signature
  • Withdraw tokens with signature

Contract Deployment

$ forge build --sizes
$ FOUNDRY_PROFILE=test forge test

Integration Guide

EIP712 Domain

const domain = {
  name: "TossGame",
  version: "1",
  chainId: chainId,
  verifyingContract: gameAddress,
};

1. Deposit Tokens (ERC20)

Permit Type Definition

const PERMIT_TYPE = {
  Permit: [
    { name: "owner", type: "address" },
    { name: "spender", type: "address" },
    { name: "value", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint256" },
  ],
};

Generate Permit Signature

async function getPermitSignature(
  token: Contract,
  owner: string,
  spender: string,
  value: BigNumber,
  deadline: number
) {
  const nonce = await token.nonces(owner);

  const permitDomain = {
    name: await token.name(),
    version: "1",
    chainId: chainId,
    verifyingContract: token.address,
  };

  const signature = await signer._signTypedData(permitDomain, PERMIT_TYPE, {
    owner,
    spender,
    value,
    nonce,
    deadline,
  });

  return ethers.utils.splitSignature(signature);
}

Note:

  1. Owner here is the user who is depositing the tokens. Spender is the game contract address.
  2. All amounts/values(and in the context below) are in token decimals. like input with 100, the token decimal is 18, then the amount is 100e18.

2. Toss Coin

Type Definition

const TYPES = {
  // Matches exact TOSS_TYPEHASH from contract
  TossCoin: [
    { name: "user", type: "address" },
    { name: "token", type: "address" },
    { name: "tokenAmount", type: "uint256" },
    { name: "tokenPrice", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint256" },
    { name: "tossResult", type: "bool" },
  ],
};

Generate Toss Signature

async function getTossSignature(
  game: Contract,
  user: string,
  token: string,
  amount: BigNumber,
  tokenPrice: BigNumber,
  tossResult: boolean
) {
  const domain = {
    name: "TossGame",
    version: "1",
    chainId: await getChainId(),
    verifyingContract: game.address,
  };

  const nonce = await game.nonces(user);
  const deadline = Math.floor(Date.now() / 1000) + 3600;

  // Match exact order from TOSS_TYPEHASH
  const value = {
    user,
    token,
    tokenAmount: amount,
    tokenPrice,
    nonce,
    deadline,
    tossResult,
  };

  const signature = await signer._signTypedData(
    domain,
    { TossCoin: TYPES.TossCoin },
    value
  );

  return {
    ...value,
    ...ethers.utils.splitSignature(signature),
  };
}

3. Withdraw

Type Definition

const TYPES = {
  // Matches exact WITHDRAW_TYPEHASH from contract
  Withdraw: [
    { name: "user", type: "address" },
    { name: "token", type: "address" },
    { name: "tokenAmount", type: "uint256" },
    { name: "tokenPrice", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint256" },
  ],
};

Generate Withdraw Signature

async function getWithdrawSignature(
  game: Contract,
  user: string,
  token: string,
  amount: BigNumber,
  tokenPrice: BigNumber
) {
  const domain = {
    name: "TossGame",
    version: "1",
    chainId: await getChainId(),
    verifyingContract: game.address,
  };

  const nonce = await game.nonces(user);
  const deadline = Math.floor(Date.now() / 1000) + 3600;

  // Match exact order from WITHDRAW_TYPEHASH
  const value = {
    user,
    token,
    tokenAmount: amount,
    tokenPrice,
    nonce,
    deadline,
  };

  const signature = await signer._signTypedData(
    domain,
    { Withdraw: TYPES.Withdraw },
    value
  );

  return {
    ...value,
    ...ethers.utils.splitSignature(signature),
  };
}

Required Inputs

Token Price

Gas Overheads

DEPOSIT_OPERATOR_GAS_OVERHEAD = 120000
WITHDRAW_OPERATOR_GAS_OVERHEAD = 60000
TOSS_OPERATOR_GAS_OVERHEAD = 220000

Fee Calculation

// Calculate operator gas cost in ETH
const operatorGas = OPERATOR_GAS_OVERHEAD * gasPrice;

// Convert to token amount
const gasFeeInToken = (operatorGas * 1e18) / tokenPrice;

// Toss fee (2.5% by default)
const tossFee = (amount * tossFeeBPS) / 10000;

Example Usage

// 1. Deposit
const depositAmount = ethers.utils.parseEther("100");
const depositSig = await getPermitSignature(
  tokenContract,
  userAddress,
  gameAddress,
  depositAmount,
  Math.floor(Date.now() / 1000) + 3600
);

await operatorAPI.depositToken({
  user: userAddress,
  token: tokenAddress,
  tokenAmount: depositAmount,
  tokenPrice: currentTokenPrice,
  deadline: depositSig.deadline,
  v: depositSig.v,
  r: depositSig.r,
  s: depositSig.s,
});

// 2. Toss
const tossAmount = ethers.utils.parseEther("10");
const tossSig = await getTossSignature(
  gameContract,
  userAddress,
  tokenAddress,
  tossAmount,
  currentTokenPrice,
  true // betting on heads
);

await operatorAPI.tossCoin(tossSig);

// 3. Withdraw
const withdrawAmount = ethers.utils.parseEther("50");
const withdrawSig = await getWithdrawSignature(
  gameContract,
  userAddress,
  tokenAddress,
  withdrawAmount,
  currentTokenPrice
);

await operatorAPI.withdrawToken(withdrawSig);

Events to Monitor

// Result of toss
contract.on("CoinTossResult", (requestId, amountWon, tossResult, isWon) => {});

// Stats update
contract.on("StatsUpdated", (user, winCount, tossCount, prize) => {});

// Leaderboard changes
contract.on(
  "LeaderboardUpdated",
  (user, rank, winCount, tossCount, prize) => {}
);

Error Handling

Common errors to handle:

  • InvalidSignature: Signature verification failed
  • InsufficientBalance: Not enough tokens
  • InsufficientFundForGasFee: Amount too small to cover gas
  • UnsupportedToken: Token not supported by game

Setup

1. Initialize Provider and Signer

// Using ethers v5
import { ethers } from "ethers";

// Check if MetaMask is installed
if (!window.ethereum) {
  throw new Error("Please install MetaMask!");
}

// Request MetaMask connection
async function connectWallet() {
  try {
    // Request account access
    await window.ethereum.request({ method: "eth_requestAccounts" });

    // Initialize provider and signer
    const provider = new ethers.providers.Web3Provider(window.ethereum);
    const signer = provider.getSigner();
    const userAddress = await signer.getAddress();

    // Listen for account changes
    window.ethereum.on("accountsChanged", (accounts: string[]) => {
      if (accounts.length === 0) {
        // Handle disconnection
        console.log("Please connect to MetaMask");
      } else {
        // Handle account change
        console.log("Account changed to:", accounts[0]);
      }
    });

    // Listen for chain changes
    window.ethereum.on("chainChanged", (chainId: string) => {
      // Handle chain change (usually by reloading the page)
      window.location.reload();
    });

    return { provider, signer, userAddress };
  } catch (error) {
    if (error.code === 4001) {
      throw new Error("Please connect to MetaMask");
    }
    throw error;
  }
}

// Or with private key (backend/testing)
const privateKey = process.env.PRIVATE_KEY;
const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
const signer = new ethers.Wallet(privateKey, provider);

2. Contract Setup

// Contract addresses (replace with your deployed addresses)
const GAME_ADDRESS = "0x...";
const TOKEN_ADDRESS = "0x...";

// Import ABIs
import GAME_ABI from "./abis/TossGame.json";
import TOKEN_ABI from "./abis/ERC20.json";

// Create contract instances
async function setupContracts(provider: ethers.providers.Provider) {
  const gameContract = new ethers.Contract(GAME_ADDRESS, GAME_ABI, provider);
  const tokenContract = new ethers.Contract(TOKEN_ADDRESS, TOKEN_ABI, provider);

  // Get chain ID
  const { chainId } = await provider.getNetwork();

  return { gameContract, tokenContract, chainId };
}

3. Domain and Types Setup

// EIP712 Domain and Types
const setupEIP712 = (chainId: number, gameAddress: string) => {
  // Domain for TossGame
  const domain = {
    name: "TossGame",
    version: "1",
    chainId: chainId,
    verifyingContract: gameAddress,
  };

  // Types matching contract's type hashes
  const TYPES = {
    TossCoin: [
      { name: "user", type: "address" },
      { name: "token", type: "address" },
      { name: "tokenAmount", type: "uint256" },
      { name: "tokenPrice", type: "uint256" },
      { name: "nonce", type: "uint256" },
      { name: "deadline", type: "uint256" },
      { name: "tossResult", type: "bool" },
    ],
    Withdraw: [
      { name: "user", type: "address" },
      { name: "token", type: "address" },
      { name: "tokenAmount", type: "uint256" },
      { name: "tokenPrice", type: "uint256" },
      { name: "nonce", type: "uint256" },
      { name: "deadline", type: "uint256" },
    ],
  };

  return { domain, TYPES };
};

4. Signature Manager

class SignatureManager {
  private signer: ethers.Signer;
  private domain: any;
  private types: any;
  private gameContract: ethers.Contract;

  constructor(
    signer: ethers.Signer,
    domain: any,
    types: any,
    gameContract: ethers.Contract
  ) {
    this.signer = signer;
    this.domain = domain;
    this.types = types;
    this.gameContract = gameContract;
  }

  async requestSignature(
    type: "TossCoin" | "Withdraw",
    value: any
  ): Promise<any> {
    try {
      // Add nonce and deadline if not present
      if (!value.nonce) {
        value.nonce = await this.gameContract.nonces(
          await this.signer.getAddress()
        );
      }
      if (!value.deadline) {
        value.deadline = Math.floor(Date.now() / 1000) + 3600;
      }

      // Request signature from MetaMask
      const signature = await this.signer._signTypedData(
        this.domain,
        { [type]: this.types[type] },
        value
      );

      return {
        ...value,
        ...ethers.utils.splitSignature(signature),
      };
    } catch (error) {
      if (error.code === 4001) {
        throw new Error("User rejected signature request");
      }
      throw error;
    }
  }
}

5. Complete Setup Example

async function initializeTossGame() {
  try {
    // 1. Connect wallet
    const { provider, signer, userAddress } = await connectWallet();

    // 2. Setup contracts
    const { gameContract, tokenContract, chainId } = await setupContracts(
      provider
    );

    // 3. Setup EIP712
    const { domain, TYPES } = setupEIP712(chainId, gameContract.address);

    // 4. Create signature manager
    const signatureManager = new SignatureManager(
      signer,
      domain,
      TYPES,
      gameContract
    );

    return {
      provider,
      signer,
      userAddress,
      gameContract,
      tokenContract,
      signatureManager,
    };
  } catch (error) {
    console.error("Failed to initialize:", error);
    throw error;
  }
}

// Usage example
const game = await initializeTossGame();

// Request toss signature
const tossSig = await game.signatureManager.requestSignature("TossCoin", {
  user: game.userAddress,
  token: TOKEN_ADDRESS,
  tokenAmount: ethers.utils.parseEther("1"),
  tokenPrice: await getTokenPrice(TOKEN_ADDRESS),
  tossResult: true,
});

// Submit to operator
await operatorAPI.tossCoin(tossSig);

Note: signer._signTypedData should pop up a MetaMask prompt to sign the message.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published