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`))
[
{"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.
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
Checkout available slash commands argument option types.
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_id | nickname | currency | channel | role |
---|---|---|---|---|
1 | broker | ❤ | #pinned | @amazing |
2 | banker | 💰 | #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_id | nickname | currency | channel_id | role_id |
---|---|---|---|---|
1 | broker | ❤ | #pinned | @amazing |
2 | banker | 💰 | #valuable | @rich |
2 | banker | 🩲 | #valuable | @rich |
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.
user | guild_id | currency | balance |
---|---|---|---|
🧑 | 2 | 💰 | 5 |
🧑 | 2 | 🩲 | 2 |
This becomes confusing once our users interact with the economy from multiple guilds.
user | guild_id | currency | balance |
---|---|---|---|
🧑 | 2 | 💰 | 5 |
🧑 | 2 | 🩲 | 2 |
🧑 | 1 | ❤ | 3 |
🤶 | 2 | 💰 | 2 |
🤶 | 2 | 🩲 | 3 |
🤶 | 1 | ❤ | 4 |
For the balance of a global currency - guild_id
will have to be a null
value.
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.
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 */
}
}
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.
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.
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.
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:
- Sender copies id of the discord user he wants to share this bank note to.
- Sender issues a command to the discord bot to create a bank note which contains recepient discord id and some amount of currency.
- Sender receives a token that he sends to the recepient in discord chat.
- 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
- Identifies which algorithm is used to generate the signature. In the below example, HS256 indicates that this token is signed using HMAC-SHA256.
{
"alg": "HS256",
"typ": "JWT"
}
- Describes our bank note.
{
"currencyId": 1, // currency primary id key
"amount": 1, // fixed amount of currency
"jti": "asdfgh", // unique id
"recipientId": "12345" // discord user id
}
- 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.
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.
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)