Coinbench

Crypto indexes fights

LIVE DEMO: https://coinben.ch (Solana Devnet)

Intro

It's November 2024. Given the "alt season" of sh*tcoins rising again, where people carry out personal battles to sponsor their favorite project as the ultimate currency that will rule the world, I thought "why not let people bet against each other regarding the performance of their favorite coins?".

TL;DR: The project didn't lift off, both the UI/UX designer and I run out time and left, and maybe the idea doesn't stand on its own.

Nonetheless here's the highlights of the project. Happy hacking.

1. The basic idea

The message sent on Discord while sitting on the toilette was:

For example, a user create a room where others can build a portfolio by picking their favorite coins. This room has a lobby time (let's say 2 hours), an entry bet (like 2 dollars) and a fight time (like 1 week). Once the fight starts, there's a graph tracking the performance of every portfolio, like an index in stock markets

People don't buy the coins for real, they only select them, and they are equally allocated

When the room ends there's a prize distribution based on the final result

So, let's say I join the room and select Bitcoin and Ethereum in my portfolio, the performance will be 1/2 times the performance (price difference) of Bitcoin from t0 to t1 plus 1/2 times the performance of Ethereum from t0 to t1. And so other's portfolios with their respective coins.

2. Sketching the UI

First of all I opened Whimsical to sketch the basic UI of the website.

2.1. Room creation

2.2. Room join

2.3. Portfolio creation

(Mock in HTML)

2.4. Room fight view

(Mock in HTML)

3. Looking for ingredients

3.1. Coins data

Nice mock, but now where can I find a complete list of actively traded coins with their name, symbol, logo and current price? The answer was super-easy: CoinMarketCap API.

From the /v1/cryptocurrency/map endpoint I can get a complete list of actively traded coins, already sorted by CMC, with their respective id that I can use later for every other endpoint. With the help of json-to-typescript I extracted the type interface of the responses, and then I started writing the request code:

async function mapid() {
  const params = new URLSearchParams({
    sort: "cmc_rank",
    aux: "platform,is_active",
  })
  
  return await fetch("https://pro-api.coinmarketcap.com/v1/cryptocurrency/map?" + params.toString(), { headers: { "X-CMC_PRO_API_KEY": process.env.CMC_API_KEY! }})
    .then((response) => response.json())
    .then((obj) => obj as CMCResponse<CMCCoin[]>)
    .then((obj) => {
      obj.data = filterDuplicateObjects(obj.data)
      return obj
    })
}

Although it's the complete list of coins, there's no marketCap nor logo fields, so I had to manually join the result one by one with a call to /v2/cryptocurrency/info for logos and selfReportedMarketCap, but also a call to /v2/cryptocurrency/quotes/latest if market cap was not self reported.

So, here's my final object parked on mongoDB:

{
  id: 1,
  name: 'Bitcoin',
  symbol: 'BTC',
  slug: 'bitcoin',
  logo: 'https://s2.coinmarketcap.com/static/img/coins/64x64/1.png',
  platform: {
    id: 0,
    name: 'Layer 1',
    symbol: 'L1',
    slug: 'layer1',
    tokenAddress: '0'
  },
  marketCapUSD: 1951446386859.1345
}

Nice and clear.

3.2. Coins quotes

But now here's the problem: how can I get the historical price of a coin, let's say the price of Bitcoin from Monday 1st to Friday 5th? Historical data (mainly OHLC) is really expensive everywhere in terms of API credits, and also, I need to refresh for new historical data at least every 5 minutes to get a linear graph.

Actually... I don't need historical data of the coins! If a room starts today, I can collect the current price of all the coins involved, store them in mongoDB with the date, and then repeat every 5 minutes. Also, CMC charge me 1 credit per 100 coins, so I can get 100 current prices of 100 coins in a single query, for a single credit. The only hard work will be the range query.

And so, here's the high level code of the solutions:

// Cron job fired every 5 minutes
async function fetchActiveCoinsQuotes() {
  const fightingRooms = await RoomDirectory.listFightingRooms()
  const allCoinIds = fightingRooms
    .map(room => room.portfolios)
    .flat() // [p1, p2, ..., pn]
    .map(portfolio => portfolio.coinIds)
    .flat() // [1, 23, 823, 1238, ...]

  // 1 credit every 100 quotes, so...
  for (let i = 0; i < allCoinIds.length; i += 100) {
    const params = new URLSearchParams({
      id: coinIds.slice(i, i + 100).join(","),
    })
    
    const quotes = await fetch("https://pro-api.coinmarketcap.com/v2/cryptocurrency/quotes/latest?" + params.toString(), options)
      .then((response) => response.json())
      .then((obj) => obj as CMCResponse<CMCQuotes>)

    const result: CoinQuote[] = []
    for (const key in quotes.data) {
      result.push({
        coinId: quotes.data[key].id,
        date: quotes.data[key].quote.USD.last_updated,
        priceUSD: quotes.data[key].quote.USD.price,
      })
    }
    await QuoteDirectory.saveMany(result)
    await sleep(2_100) // Because limit of 30 API calls per minute
  }
}

And here's the query range I made for MongoDB for a single coin:

async function getRange(coinId: CoinID, from: string, to: string) {
  return (await QuoteDirectory
    .aggregate([
      { $match: { coinId } },
      { $match: { date: { $gte: from } } },
      { $match: { date: { $lte: to } } },
      { $sort: { date: -1 } },
    ])
    .toArray()) as CoinQuote[]
}

async function seed() {
  // Create an index to speed up range queries
  if ((await QuoteDirectory.countDocuments()) === 0) {
    await QuoteDirectory.createIndex({ coinId: 1, date: 1 })
  }
}

I have the coins, I have the marketcaps and logos, I also have the historical prices, and I made an index to speed up range queries. Let's move on.

3.3. Room creation & bets

Now the pain point: setup the "join room" flow. Once a user selects the coins to bet on and win against everyone, I need to ask for the payment before the submission. What should I ask? Bitcoin? Tether? And should I use a smart contract to manage the whole game?

I immediately thought about Solana, since it was the go-to for new meme projects back in November 2024. But smart contracts required a lot of study, I've never wrote one, and also Solana's contracts are written in Rust, which I didn't study yet.

But hey, do I really need it? Let's just create a wallet on the fly and let people deposit their bet in. Not a decentralized solution but at least it's transparent... what would be the difference with a controlled oracle and voters?

One of the function involved in room creation:

async function createRoomWallet(roomId: number) {
  const kp = new Keypair()
  const ok = await SecretDirectory.add({
    roomId,
    encryptedSecretKey: Array.from(Crypto.encrypt(kp.secretKey)),
  })

  return kp.publicKey.toString()
}

And so, after a small deep dive in Solana (and chatGPT of course) I ended up with a button to connect the wallet and some controls for submitting the portfolio after a payment.

Let's start from the client code, here's the wallet-adapter ready out-of-the-box, simple copy paste:

function Wallet({ children }: Readonly<{ children: React.ReactNode }>) {
  // The network can be set to 'devnet', 'testnet', or 'mainnet-beta'.
  const network = WalletAdapterNetwork.Devnet

  // You can also provide a custom RPC endpoint.
  const endpoint = useMemo(() => clusterApiUrl(network), [network])

  return (
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider wallets={[]} autoConnect={true}>
        <WalletModalProvider>{children}</WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  )
}

export const WalletDynamic = dynamic(() => Promise.resolve(Wallet), {
  ssr: false,
})

Next part it's the search form to select the coins:

// React component
function SearchForm({
  room,
  add,
  del,
  portfolio,
}: {
  room: Room
  add: (arg: Coin) => void
  del: (arg: Coin) => void
  portfolio: Coin[]
}) {
  const [searchResult, setSearchResult] = useState<Coin[]>([])

  function search(query: string) {
    if (query.length < 2) {
      setSearchResult([])
      return
    }

    const params = new URLSearchParams({
      query: query,
      minmarketcap: String(room.minMarketCapUSD),
    })

    fetch("/api/v1/coins?" + params.toString())
      .then((response) => response.json())
      .then((obj) => obj as { error?: string; data: Coin[] })
      .then((obj) => {
        if (obj.error) {
          console.error(obj.error)
        } else {
          setSearchResult(obj.data)
        }
      })
  }

  const debouncedSearch = useDebouncedCallback(search, 300)
  ...
}

Here's a function from the front-end for handling portfolio submission:

function submitPortfolio(cs: Coin[]) {
  ...
  // Create transaction
  const transaction = new Transaction().add(
    SystemProgram.transfer({
      fromPubkey: userPublicKey,
      toPubkey: new PublicKey(room.address),
      lamports: room.betLamports,
    })
  )
  
  // Send the transaction (the browser wallet will prompt for confirmation)
  const {
    context: { slot: minContextSlot },
    value: { blockhash, lastValidBlockHeight },
  } = await connection.getLatestBlockhashAndContext()
  signature = await sendTransaction(transaction, connection, { minContextSlot })
  
  await connection.confirmTransaction({
    blockhash,
    lastValidBlockHeight,
    signature,
  })

  // Bet deposited, now sign portfolio and send it
  const encoder = new TextEncoder()
  const portfolioSignature = await signMessage(encoder.encode(String(room.id) + userPublicKey.toString()))

  // Submit portfolio with signature
  ...
}

And also some checks in the backend:

async function submitAction() {
  ...
  // Correct signature?
  const validSignature = Solana.verifySignature(String(portfolio.roomId) + portfolio.address, portfolio.signature, portfolio.address)
  
  await Solana.verifyTransaction(portfolio.txSignature, portfolio.address, room.address, room.betLamports)
  ...
}

Money sent, portfolio submitted. Last thing to do: distributing the prize at the end!

3.4. Prize distribution

I wrote a function to compute the performance of every portfolio in a room that returns a sorted copy of the list with the performance in addition. The second function is in charge of computing the percentage of the total prize that every user is expected to receive, based on the results computed previously. Here's the high level code:

// Cron job every 1 minute
async function closeRoomAndDistributePrize() {
  const endedRooms = await RoomDirectory.listEndedRooms()

  for (const room of endedRooms) {
    ...
    
    // Compute standing: 1°, 2°, 3°, ...
    const standings = await computeStanding(room)
    
    // Compute amount to send to each individual (75% of the prize to 1°, 20% to 2° and so on...)
    const distribution = createDistribution(standings)

    // Retrieve the room wallet
    const secret = await SecretDirectory.get(room.id)

    // Decrypt secret key
    const secretKey = Crypto.decrypt(Uint8Array.from(secret.encryptedSecretKey))

    // Make the transaction
    const { txSignature, srcBalance } = await Solana.distributeBalance(secretKey, distribution)

    ...
    await RoomDirectory.close(room)
    await sleep(1_100) // Solana RPC limit to 60 requests per minute
  }
}

4. The disaster, partial recovery, rush to end

The UI/UX designer just finished to port everything from Figma to code and adapting it to shadcn. In the meantime:

Since we wasted a lot of energies on this and we run out of free time, we took the decision to not develop the project further.

I was trashing the repo and forget about it, to make room for the next project, when I randomly crossed and old reading saved in my "Read archive" folder. The lovely post was the art of finishing.

After reading it again, I decided to not fall into the trap. Not anymore baby. A few weeks later, here it is: https://coinben.ch. It's not polished, it's not perfect, but it works!

5. Lessons learned

Peace!

Hard times in software, I'm open to work ^^