Skip to main content

How to create a multi-server discord economy bot

Intro

I will be referencing these libraries and technologies in the article

Single server economy bot

This bot listens to reactions and messages.

If a reaction is a currency emoji then we mint a currency and insert it into the wallet of the message author.

If a message is a command we execute the command. We check if the user has the permission or if application logic allows him to run this command.

Commands

/mint

React with a designated emoji to a user message to create a currency token and insert it into wallet of the message author.

/transfer @user amount

Transfer some amount of currency to another user.

/wallet

Check balanace.

/top

List top 5 users.

Pseudocode

Configuration is stored as environment variables.

type Configuration = {
emojiId: string // currency
roleId: string // top 5 users role
guildId: string // discord bot server
}

User model and mock users. Users collection is stored into a json file.

type User = {
id: string
balance: number
}

const users: User[] = JSON.parse(fs.readFile(`users.json`))
user.json
[
{"id": "00", "balance": 50 },
{"id": "01", "balance": 100},
{"id": "02", "balance": 25 },
{"id": "03", "balance": 30 },
{"id": "04", "balance": 1 },
]

Example EconomyService, with user in-memory storage. We create users lazily, only when they interact with the economy.

class EconomyService {
users: User[];
constructor(users: User[]) {
this.users = users;
}
mint(userId: string) {
const user = this.users.find(user => user.id === userId)
if(user) {
user.balance++;
}
else {
this.users.push({
id: userId,
balance: 1
})
}
}
transfer(from: string, to: string, amount: number) {
if(from === to) {
throw Error(`You can't send currency to yourself.`)
}
const fromUser = this.users.find(user => user.id === from)
if(!fromUser) {
throw Error(`User ${from} doesn't exist and can't transfer currency.`)
}
if(fromUser.balance >= amount) {
fromUser.balance -= amount;
}
else {
throw Error(`User doesn't have enough currency to complete this transfer.`)
}
let toUser = this.users.find(user => user.id === to)
if(toUser) {
toUser.balance += amount;
}
else {
this.users.push({
id: to,
balance: amount
})
}
}
wallet(userId: string) {
const user = this.users.find(user => user.id === userId)
return user?.balance || 0;
}
top(count: number = 5) {
return this.users.sort((a,b) => b.balance - a.balance).slice(0, count)
}
}

Multi-server discord economy bot

First step is to create a users database table from users collection. Then create guilds database table which will replace our static configuration and allow each guild to have it's own specific currency emoji, top role and bot nickname.

2

We will add new commands that guild admins will use to set currency emojis, rich roles and bot nickname.

  • /set_currency_emoji file_attachment currency name
  • /set_repost_channel #channel
  • /set_top_role @role
  • /set_nickname nickname
info

Multi-server multi-currency discord economy bot

We will now expand our bot to able to handle multiple currencies in the single guild and persist currencies across guilds to form a global economy in which users can trade between guilds.

Naive approach

Is to use existing tables to add new currencies and user wallets. Let's say our bot is in 2 guilds.

guild_idnicknamecurrencychannelrole
1broker#pinned@amazing
2banker💰#valuable@rich

Guild 2 wants to add a new currency 🩲. We have now duplicated all the original fields of the guild 2 and only replaced the emoji in the new row.

guild_idnicknamecurrencychannel_idrole_id
1broker#pinned@amazing
2banker💰#valuable@rich
2banker🩲#valuable@rich
caution

Problem happens when we want to get all guilds. Now we have do it with a sql distinct operator. Another problem happens when a guild admin wants to update some of the guild configuration like role or nickname. Now we have to update in two places, one time for each currency.

I won't even try to create a global currency using this table because it will be very ugly.


We might also be tempted to use users table to store currency balance.

userguild_idcurrencybalance
🧑2💰5
🧑2🩲2

This becomes confusing once our users interact with the economy from multiple guilds.

userguild_idcurrencybalance
🧑2💰5
🧑2🩲2
🧑13
🤶2💰2
🤶2🩲3
🤶14

For the balance of a global currency - guild_id will have to be a null value.

caution

Users table will inherit similar problems to the guilds table, because we are using our tables for two purposes at the same time.

Better approach

Separate currencies from the guilds table and separate wallets from the users table.

Use foreign - primary key relationships between internal ids instead of discord ids.

Distinguish between primary and secondary currencies, primary currency is used as the default transfer currency.

User guild memberships is added as a junction table which will track which guild is user a part of.

3

Lets revisit our EconomyService

class EconomyService {
createCurrency(discordGuildId: string, currencyEmojiId: string) {
/* create a new currency and make it
primary if it's the first currency created in this guild */
}
mint(userDiscordId: string, currencyEmojiId: string) {
/* 1. find user by userDiscordId
2. find currency by currencyEmojiId
3. find user's wallet by user.id and currency.id
4. update user's wallet balance */
}
transfer(
discordGuildId: string,
fromUserDiscordId: string,
toUserDiscordId: string,
amount: number,
currencyEmojiId?: string
) {
/* 1. find currency by currencyEmojiId
or get default currency by discordGuildId if currencyEmojiId is undefined
2. find users with ids fromUserDiscordId and toUserDiscordId
3. find wallets by user ids and currency id
4. validate that the sender has the correct amount of balance in his wallet
5. update sender and receiver wallets
*/
}
wallet(userId: string) {
/* find wallets by userId */
}
top(count: number = 5) {
/* implemented later when exchange rates between currencies are added */
}
}
caution

Now that we are dealing with a database, where a single command like transfer requires multiple updates to it, if any update fail we'll want to reverse all other updates.

Transactions and unit of work

Reversable updates with database transactions and unit of work pattern.

export class UnitOfWork {
constructor(private context: DBContext) {}

async execute<T>(work: (repository: Repository) => Promise<T>): Promise<T | null> {
return this.context.transaction(async (tx) => {
try {
return await work(new Repository(tx))
} catch (error) {
console.error(error)
tx.rollback()
return null
}
})
}
}
export const uow = new UnitOfWork(db)

Instead of injecting a DbContext into our EconomyService we inject a UnitOfWork and then write a execute handler which will gives us a Repository as an argument. Repository will do all CRUD operations on the DbContext. Outside of transactions we still use a Repository with the global DbContext.

import { repository } from 'database/repository.ts'

class EconomyService {
uow: UnitOfWork;
constructor(uow: UnitOfWork) {
this.uow = uow;
}
...
// this example lacks error handling
async transfer(
discordGuildId: string,
fromUserDiscordId: string,
toUserDiscordId: string,
amount: number,
currencyEmojiId?: string
) {
const currency = currencyEmojiId
? repository.getCurrency(currencyEmojiId)
: repository.getPrimaryCurrency(disocrdGuildId)
const sender = await repository.getUser(fromUserDiscordId)
const senderWallet = await repository.getWallet(sender.id, currency.id)
if(senderWallet.balance >= amount) {
/* if any of the create or update methods inside this transaction fail,
UnitOfWork will rollback all of them */
this.uow.execute(tx => {
const receiver = await this.tx.getOrCreateUser(toUserDiscordId)
const receiverWallet = await this.tx.getOrCreateWallet(receiver.id, currency.id)
senderWallet.balance -= amount
receiverWallet.balance += amount
await this.tx.updateWallets([senderWallet, receiverWallet])
})
}
else {
throw Error(`User ${sender.id} doesn't have enough currency ${currency.id} to complete this transfer.`)
}

}
...
}

Now that we can have many currencies inside of a single guild, lets allow users to exchange one currency for another by an exchange rate set by the guild admin. Exchange rate is an entity which describes an exchange of two currencies in single direction. From the base curency to the quote currency.

4

One of bot goals was to allow trading between guilds in a global economy.

To achieve this we'll need created a groups and guild group membership table. Groups table will "group" multiple guilds together through a junction table. If we bind our currencies to groups instead of guilds we could have global and local currencies.

5

Group types

We will have one "global" group, one "single" group per guild and variable amount of "local" groups of guilds.

global - global economy

Currencies that belong to the global group persist across guilds and can earned in each guild that is part of the global group.

Admin is the developer, who can create global currencies and their exchange rates.

local - local economy

A guilds which share group that has a currencies bound to that group.

Out of the scope for this guide.

single - isolated economy

Every guild is part of a it's own "single" group, and each currency that is bound to the guild's "single" group is isolated from other guilds. Admin is the guild admin.

info

Only the currencies of the same group can be exchanged for simplicity sake.

Fetch all members of a guild

We can no longer wait for users to interact with the economy to create them in our user's table. Certain work has to been done every time our bot is accepted into a guild. This is because a user could have the most amount of global currency in a new guild that he has joined, but will not show in the leaderboards of that guild because he didn't yet interact with the economy from inside of that guild.

...
async botAddedToGuild(guild) {
const commands = await getCommands()
await deployCommands(guild.id, commands)
const entity = await guildService.getOrCreateGuild(guild.id)
const members = await fetchAllMembers(guild.id)
await repository.addUserGuildMemberships(members, entity.id)
}
...

Fetching all guild members must be done in batches of max a 1000 users and the maximum number of guild members is 1,000,000

export const rest = new REST().setToken(token)

export async function fetchAllMembers(guildId: string) {
const query = new URLSearchParams()
query.set("limit", "1000")

let members: string[] = []
let batch: Collection<string, GuildMember> = new Collection()
do {
if (members.length > 0) {
query.set("after", members.at(-1))
}
const batch = (await rest.get(`/guilds/${guildId}/members`, {
query,
})) as GuildMember[]
members.push(...batch.map((member) => member.user.id))
} while (batch.size > 0)

return members
}

We need new commands to allow group admins to manage these new parts of the economy

  • /currencies
  • /make_currency_primary :currency_emoji_name:
  • /create_exchange_rate :from_currency: :to_currency: rate
  • /exchange :from_currency: :to_currency: amount
  • /transfer @user amount ?:currency:
  • /join_global_group

Joining a global group

When a guild joins a global guild group we must install all the global currencies in that guild. We install them using the discord.js REST API client.

async joinGlobalGroup(discordGuildId: string) {
const guild = await repository.getGuildByDiscordGuildId(discordGuildId)
await repository.createGuildMembership({
groupId: globalGroupId,
guildId: guild.id,
})

const currencies = await repository.getCurrenciesByGroupId(globalGroupId)
const emojis = await Promise.all(
currencies.map((currency) =>
fetch(rest.cdn.emoji(currency.discordEmojiId))
.then((response) => response.arrayBuffer())
.then((buffer) => createEmoji(guild.discordGuildId, currency.name, buffer))
)
)
}

Trade across guilds through a master guild or with bank notes

Currently users can trade their global currencies only if they are part of the same guild. We could refer all users wanting to trade in the global economy to the single "master" guild which could serve as a global marketplace.

Let's create a way for users to transfer currency even if they're not a part of the same guild using "bank notes".

Bank note should be a one-time consumable, secure and transparent object that is signed over to the a single recepient. It should be self-contained and sharable as text.

Bank note trade flow goes like this:

  1. Sender copies id of the discord user he wants to share this bank note to.
  2. Sender issues a command to the discord bot to create a bank note which contains recepient discord id and some amount of currency.
  3. Sender receives a token that he sends to the recepient in discord chat.
  4. Recepient has a permanently valid bank note which he can consume at any point by issuing a command to the bot. He will need to be in a server which has our bot present, but doesn't have to be in the same server as the sender.

JWT Web Token has these properties so we will use it's mechanism for our bank notes.

This is how an encoded JWT token looks like eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjdXJyZW5jeUlkIjoxLCJhbW91bnQiOjEsImp0aSI6Ijg5MGZjOTQ5LTBkMWMtNDdiZi05NjA5LTg5ZDg0NzMyMDYzOCIsInJlY2lwaWVudElkIjoiNTYzNDM3MzQ3ODk5OTY1NDU1In0.AinV8D0nPOtMvrdydrPi64F77U1dJ9GIcp3DuzYO_04

When we decoded it 3 parts are revealed

  1. Identifies which algorithm is used to generate the signature. In the below example, HS256 indicates that this token is signed using HMAC-SHA256.
header
{
"alg": "HS256",
"typ": "JWT"
}
  1. Describes our bank note.
payload
{
"currencyId": 1, // currency primary id key
"amount": 1, // fixed amount of currency
"jti": "asdfgh", // unique id
"recipientId": "12345" // discord user id
}
  1. The signature that allows a recipient to verify that the token was signed correctly and hasn’t been tampered with. The signature is calculated by encoding the header and payload using Base64url Encoding RFC 4648 and concatenating the two together with a period separator. That string is then run through the cryptographic algorithm specified in the header.
verify signature
const secretKey = 'static_string_stored_on_as_an_environment_variable'
const encoding = base64urlEncode(header) + '.' + base64urlEncode(payload);
const signature = HMAC_SHA256(encoding, secretKey);
const thirdPartOfToken = base64urlEncode(signature);

Bank note can be consumed only once. We will need a new database table which will hold all bank notes, with a column indicating if the bank note has been consumed or not.

6

Bank note is transparent because anyone can decode it to see the payload that contains the amount, currency and recepient.

Bank note is secure because it becomes invalid if the payload has been altered in any way.

Bank note can be consumed only by a single discord user choosen by the sender at the time when the bank note was created.

Leaderboard

Now we can implement our top 5 leaderboard for users of a guild.

It will sum all user wallets that can be directly exchanged to the target currency.

async top5(discordGuildId: string, currencyId?: number | undefined) {
let currency = currencyId
? await repository.getCurrencyById(currencyId)
: await this.getDefaultTransferCurrencyForGuild(discordGuildId)

const guildUsers = await db
.select({
discordId: users.discordId,
count: sql`
SUM(
CASE
WHEN ${wallets.currencyId} = ${currencyId}
THEN CAST(${wallets.count} AS NUMERIC)
WHEN ${exchangeRates.quoteCurrencyId} = ${currencyId}
THEN CAST(${wallets.count} AS NUMERIC) * CAST(${exchangeRates.rate} AS NUMERIC)
ELSE 0
END
)`.mapWith(Number).as("count"),
})
.from(users)
.innerJoin(userGuildMemberships, eq(userGuildMemberships.userId, users.id))
.innerJoin(guilds, eq(guilds.id, userGuildMemberships.guildId))
.innerJoin(wallets, eq(wallets.userId, users.id))
.innerJoin(currencies, eq(currencies.id, wallets.currencyId))
.leftJoin(exchangeRates, eq(exchangeRates.baseCurrencyId, currencies.id))
.where(
and(
currency.groupId === null
? isNull(currencies.groupId)
: eq(currencies.groupId, currency.groupId),
eq(guilds.discordGuildId, discordGuildId)
)
)
.groupBy(users.discordId)
.orderBy(({ count }) => desc(count))
.limit(5)

return { top: guildUsers, currency: currency }
}

Audit

To track the history of all economy activity we can create an audit table filled with events that are marked with these types:

  • minting
  • burning
  • exchanges
  • transfers
  • create bank note
  • consume bank note

When some of these actions happen we will create a audit object, add it to the queue and that queue will be persisted to the database every minute. That way we batch our inserts and prevent overwhelming the database.

class Queue<T> {
private array: T[] = []
constructor() {}
add(...items: T[]) {
this.array.unshift(...items)
}
pull(count: number): T[] {
let start = this.array.length - count
return this.array.splice(start < 0 ? 0 : start, count)
}
all(): ReadonlyArray<T> {
return this.array as ReadonlyArray<T>
}
get length(): number {
return this.array.length
}
}

export const transactionQueue = new Queue<Transaction>()

// insert 1000 transactions from the queue every minute
// if there is more than a 1000 transactions send an alert to a discord channel
setInterval(() => {
const batch = transactionQueue.pull(1000)
if (batch.length > 0) {
db.insert(transactions)
.values(batch)
.catch((err) => {
// on error return the batch to the queue
transactionQueue.add(...batch)
})
}
}, 60 * 1000)