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:
- React upgraded from 18 to 19
- Next.js upgraded from 14 to 15
- Tailwind from v3 to v4
- ShadCN in between React 18/19 and Tailwind 3/4
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
- No matter the failure, just ship the project before starting a new one.
- The article about the "Art of finishing" was right, I was so proud of this project, and the happiness is lasting even longer
- Clean code and architecture is achieved from time to time, not from the foundations.
- The code is far from perfect and elegant to read. A lot of things changed during the development, I was only able to refactor it a little bit from week to week, but still ended up far from what I imagined I can accomplish in terms of quality. Needless to say, this project was rushed from start to end, very little time to think about it, and no money earned from that.
- Don't use plain drivers like
mongo
, but always use an ORM like library, for examplemongoose
- The extra time and extra boiler plate code is worth even with small collections and few fields
- Use
-p 127.0.0.1:portA:portB
when launching a new docker container.- My mongoDB got hacked 3 times in a row, even though I configured the
ufw
firewall. Turns out, the-p
flag alone exposes the port to the public, even with adefault deny incoming
, surpassing theufw
rules.
- My mongoDB got hacked 3 times in a row, even though I configured the
- Test every workflow early, from dev to prod.
Peace!
Hard times in software, I'm open to work ^^