From 9e361df2e5bf7e3012fb3d72c04b6d9611ad7891 Mon Sep 17 00:00:00 2001 From: Anh Nguyen Date: Thu, 1 Aug 2024 17:03:31 +0700 Subject: [PATCH] feat: trading vault deposit --- src/adapters/mochi-pay.ts | 21 ++ src/commands/vault/info/processor.ts | 301 ++++++++++++++++++++++++++- src/commands/vault/info/slash.ts | 125 ++++++++++- 3 files changed, 442 insertions(+), 5 deletions(-) diff --git a/src/adapters/mochi-pay.ts b/src/adapters/mochi-pay.ts index 4a5965f8..97af33fb 100644 --- a/src/adapters/mochi-pay.ts +++ b/src/adapters/mochi-pay.ts @@ -431,6 +431,27 @@ class MochiPay extends Fetcher { }, ) } + + async depositToEarningVault({ + profileId, + vaultId, + amount, + tokenId, + }: { + profileId: string + vaultId: string + amount: string + tokenId: string + }): Promise { + return await this.jsonFetch( + `${MOCHI_PAY_API_BASE_URL}/profiles/${profileId}/syndicates/earning-vaults/${vaultId}/deposit`, + { + method: "POST", + headers: { Authorization: `Bearer ${MOCHI_BOT_SECRET}` }, + body: { amount, token_id: tokenId, platform: "discord" }, + }, + ) + } } export default new MochiPay() diff --git a/src/commands/vault/info/processor.ts b/src/commands/vault/info/processor.ts index a62eb213..e0e5fe4b 100644 --- a/src/commands/vault/info/processor.ts +++ b/src/commands/vault/info/processor.ts @@ -7,13 +7,16 @@ import { MessageButton, MessageSelectMenu, MessageAttachment, + SelectMenuInteraction, + MessageComponentInteraction, } from "discord.js" import { InternalError, OriginalMessage } from "errors" import { APIError } from "errors" -import { composeEmbedMessage2 } from "ui/discord/embed" +import { composeEmbedMessage, composeEmbedMessage2 } from "ui/discord/embed" import { EmojiKey, emojis, + equalIgnoreCase, getEmoji, getEmojiToken, getEmojiURL, @@ -23,7 +26,7 @@ import { TokenEmojiKey, } from "utils/common" import { HOMEPAGE_URL, VERTICAL_BAR } from "utils/constants" -import { formatUsdDigit } from "utils/defi" +import { formatDigit, formatUsdDigit } from "utils/defi" import { getDiscordRenderableByProfileId, getProfileIdByDiscord, @@ -31,7 +34,19 @@ import { import mochiPay from "adapters/mochi-pay" import moment from "moment" import { utils } from "@consolelabs/mochi-formatter" +import { utils as etherUtils } from "ethers" import { drawLineChart } from "utils/chart" +import { getBalances, isTokenSupported } from "utils/tip-bot" +import { getSlashCommand } from "utils/commands" +import { BigNumber } from "ethers" +import { checkCommitableOperation } from "commands/withdraw/index/processor" +import { parseUnits } from "ethers/lib/utils" + +type Params = { + amount: string + balance?: any + tokenSymbol?: string +} const getPnlIcon = (n: number) => (n >= 0 ? ":green_circle:" : ":red_circle:") @@ -436,6 +451,7 @@ export async function runGetVaultDetail({ sol: data.solana_wallet_address, }, vaultId: selectedVault, + vaultType: "trading", report, profileId, vaultName: data.name, @@ -461,6 +477,12 @@ export async function runGetVaultDetail({ .setEmoji(getEmoji("CALENDAR")) .setStyle("SECONDARY") .setCustomId("rounds"), + new MessageButton() + .setLabel("Deposit") + .setStyle("SECONDARY") + .setCustomId("trading_vault_deposit") + .setEmoji(getEmoji("MONEY")) + .setDisabled(!report.member_equity || !!interaction.ephemeral), ), ], }, @@ -837,3 +859,278 @@ export async function vaultClaim({ }, } } + +// select deposit token +export async function depositStep1(interaction: ButtonInteraction, ctx: any) { + const balances = await getBalances({ msgOrInteraction: interaction }) + if (balances.length === 1) { + const balance = balances.at(0) + + await isTokenSupported(balance.token.symbol) + + const { msgOpts } = await depositStep2(interaction, { + balance, + amount: "%0", + }) + + return { + context: { + token: balance.token.symbol, + tokenObj: balance, + amount: "%0", + }, + msgOpts, + } + } + + if (!balances.length) { + return { + msgOpts: { + embeds: [ + composeEmbedMessage(null, { + description: `${getEmoji( + "NO", + )} You have no balance. Try ${await getSlashCommand( + "deposit", + )} first`, + color: msgColors.ERROR, + }), + ], + }, + } + } + + // TODO: remove hardcode 1 + const { text } = formatView("compact", "filter-dust", balances, 0) + + const embed = composeEmbedMessage(null, { + author: ["Choose your money source", getEmojiURL(emojis.NFT2)], + description: text, + }).addFields(renderPreview({})) + + const isDuplicateSymbol = (s: string) => + balances.filter((b: any) => b.token.symbol.toUpperCase() === s).length > 1 + + return { + context: { + ...ctx, + depositAmount: "%0", + }, + msgOpts: { + attachments: [], + embeds: [ + embed, + // ...(!filteredBals.length && filterSymbol + // ? [ + // new MessageEmbed({ + // description: `${getEmoji("NO")} No token ${getEmojiToken( + // filterSymbol as TokenEmojiKey, + // )} **${filterSymbol}** found in your balance.`, + // color: msgColors.ERROR, + // }), + // ] + // : []), + ], + components: [ + new MessageActionRow().addComponents( + new MessageSelectMenu() + .setPlaceholder("💵 Choose money source (1/2)") + .setCustomId("select_token") + .setOptions( + balances.slice(0, 25).map((b: any) => ({ + label: `${b.token.symbol}${ + isDuplicateSymbol(b.token.symbol) + ? ` (${b.token.chain.symbol})` + : "" + }`, + value: b.id, + emoji: getEmojiToken(b.token.symbol), + })), + ), + ), + ], + }, + } +} + +function renderPreview(params: { + network?: string + token?: string + amount?: string +}) { + return { + name: "\u200b\nPreview", + value: [ + params.network && + `${getEmoji("SWAP_ROUTE")}\`Network. \`${params.network}`, + `${getEmoji("SWAP_ROUTE")}\`Source. \`Mochi wallet`, + params.token && + `${getEmoji("ANIMATED_COIN_1", true)}\`Token. \`${params.token}`, + params.token && + params.amount && + `${getEmoji("NFT2")}\`Amount. \`${getEmojiToken( + params.token as TokenEmojiKey, + )} **${params.amount} ${params.token}**`, + ] + .filter(Boolean) + .join("\n"), + inline: false, + } +} + +// select deposit amount +export async function depositStep2( + interaction: MessageComponentInteraction, + params: Params, +) { + const balances = await getBalances({ msgOrInteraction: interaction }) + const balance = + params.balance || + balances.find((b: any) => equalIgnoreCase(b.id, params.tokenSymbol)) + + let error: string | null = "" + + const tokenAmount = balance.amount + const tokenDecimal = balance.token.decimal ?? 0 + + const getPercentage = (percent: number) => + BigNumber.from(tokenAmount).mul(percent).div(100).toString() + let amount + + const isAll = + params.amount === "%100" || equalIgnoreCase(params.amount ?? "", "all") + if (params.amount?.startsWith("%") || isAll) { + const formatted = etherUtils.formatUnits( + getPercentage( + params.amount?.toLowerCase() === "all" + ? 100 + : Number(params.amount?.slice(1)), + ), + tokenDecimal, + ) + amount = formatDigit({ + value: formatted, + fractionDigits: isAll ? 2 : Number(formatted) >= 1000 ? 0 : 2, + }) + } else { + let valid + ;({ valid, error } = checkCommitableOperation( + balance.amount, + params.amount ?? "0", + balance.token, + )) + + if (valid) { + amount = formatUsdDigit(params.amount ?? "0") + } + } + + const { text } = formatView("compact", "filter-dust", [balance], 0) + const isNotEmpty = !!text + const emptyText = `${getEmoji( + "ANIMATED_POINTING_RIGHT", + true, + )} You have nothing yet, use ${await getSlashCommand( + "earn", + )} or ${await getSlashCommand("deposit")} ` + + const embed = composeEmbedMessage(null, { + author: [ + `How many ${balance.token.symbol} to withdraw ? `, + getEmojiURL(emojis.NFT2), + ], + description: isNotEmpty ? text : emptyText, + }).addFields( + renderPreview({ + network: balance.token.chain.name, + token: balance.token.symbol, + amount: String(error ? 0 : amount), + }), + ) + + return { + context: { + ...params, + token: balance.token.symbol, + balance, + amount, + }, + msgOpts: { + embeds: [ + embed, + ...(error + ? [ + composeEmbedMessage(null, { + description: `${getEmoji("NO")} **${error}**`, + color: msgColors.ERROR, + }), + ] + : []), + ], + components: [ + new MessageActionRow().addComponents( + ...[10, 25, 50].map((p) => + new MessageButton() + .setLabel(`${p}%`) + .setStyle("SECONDARY") + .setCustomId(`select_amount_${p}`), + ), + new MessageButton() + .setLabel("All") + .setStyle("SECONDARY") + .setCustomId(`select_amount_100`), + new MessageButton() + .setLabel("Custom") + .setStyle("SECONDARY") + .setCustomId("enter_amount"), + ), + new MessageActionRow().addComponents( + new MessageButton() + .setLabel("Confirm (2/2)") + .setCustomId("submit") + .setStyle("PRIMARY") + .setDisabled(!!error || Number(amount) <= 0), + ), + ], + }, + } +} + +export async function executeTradingVaultDeposit( + i: ButtonInteraction, + ctx: any, +) { + const { balance, amount, vaultId, profileId, vaultName } = ctx + const { ok, error } = await mochiPay.depositToEarningVault({ + profileId, + vaultId, + tokenId: balance.token.id, + amount: parseUnits( + amount.replaceAll(",", ""), + balance.token.decimal, + ).toString(), + }) + + if (!ok) { + throw new InternalError({ + msgOrInteraction: i, + title: "Failed to deposit", + description: error, + }) + } + + const embed = composeEmbedMessage2(i as any, { + author: ["Deposit successfully", getEmojiURL(emojis["MONEY"])], + description: `You have deposited ${getEmojiToken( + balance.token.symbol, + )} **${amount} ${balance.token.symbol}** to vault **${vaultName}**!`, + }) + + return { + msgOpts: { + embeds: [embed], + components: [], + attachments: [], + }, + } +} diff --git a/src/commands/vault/info/slash.ts b/src/commands/vault/info/slash.ts index 0c8ac3f2..32bcec9c 100644 --- a/src/commands/vault/info/slash.ts +++ b/src/commands/vault/info/slash.ts @@ -3,17 +3,28 @@ import { depositDetail, renderListDepositAddress, } from "commands/deposit/index/processor" -import { CommandInteraction, Message } from "discord.js" +import { + ButtonInteraction, + CommandInteraction, + Message, + MessageActionRow, + MessageButton, + Modal, + TextInputComponent, +} from "discord.js" import { SlashCommand } from "types/common" import { composeEmbedMessage } from "ui/discord/embed" import { chainTypes } from "utils/chain" -import { equalIgnoreCase } from "utils/common" +import { equalIgnoreCase, getEmoji, TokenEmojiKey } from "utils/common" import { GM_GITBOOK, SLASH_PREFIX } from "utils/constants" import { MachineConfig, route, RouterSpecialAction } from "utils/router" import { SlashCommandSubcommandBuilder } from "@discordjs/builders" import { + depositStep1, + depositStep2, + executeTradingVaultDeposit, handleVaultRounds, runGetVaultDetail, vaultClaim, @@ -79,6 +90,7 @@ export const machineConfig: ( }, ephemeral: { DEPOSIT: true, + TRADING_VAULT_DEPOSIT: true, }, ...context, }, @@ -109,6 +121,12 @@ export const machineConfig: ( ROUNDS: "vaultRounds", REPORT: "vaultReport", CLAIM: "vaultClaim", + TRADING_VAULT_DEPOSIT: { + target: "vaultInfo", + actions: { + type: "tradingVaultDeposit", + }, + }, DEPOSIT: { target: "vaultInfo", actions: { @@ -138,9 +156,10 @@ const command: SlashCommand = { autocomplete: async function (i) { const focusedValue = i.options.getFocused() const userProfile = await profile.getByDiscord(i.user.id) - const [spotVaults, tradingVaults] = await Promise.all([ + const [spotVaults, tradingVaults, publicVaults] = await Promise.all([ i.guildId ? config.vaultList(i.guildId, true) : [], i.guildId ? mochiPay.listEarningVaults(userProfile.id, i.guildId) : [], + await mochiPay.listGlobalEarningVault(userProfile.id), ]) const options = [ @@ -154,6 +173,11 @@ const command: SlashCommand = { v.name.toLowerCase().includes(focusedValue.toLowerCase()), ) .map((v: any) => ({ name: v.name, value: `trading_${v.id}` })), + ...publicVaults + .filter((v: any) => + v.name.toLowerCase().includes(focusedValue.toLowerCase()), + ) + .map((v: any) => ({ name: v.name, value: `trading_${v.id}` })), ] await i.respond(options) @@ -250,6 +274,101 @@ const command: SlashCommand = { }, }) }, + tradingVaultDeposit: async (_, event) => { + const i = event.interaction as ButtonInteraction + const { msgOpts, context: ctx } = await depositStep1(i, context) + const reply = (await i.editReply(msgOpts)) as Message + + route(reply, i, { + id: "tradingvaultdeposit", + initial: "depositStep1", + context: { + modal: { + ENTER_AMOUNT: true, + }, + ephemeral: { + ENTER_AMOUNT: true, + }, + select: { + depositStep2: (i, _ev, ctx) => + depositStep2(i, { + ...(ctx as any), + amount: ctx.depositAmount, + tokenSymbol: i.values[0] as TokenEmojiKey, + }), + }, + button: { + depositStep1: (i, _ev, ctx) => depositStep1(i, ctx), + depositStep2: async (i, ev, ctx) => { + if (ev === "ENTER_AMOUNT") { + const modal = new Modal() + .setCustomId("amount-form") + .setTitle("Amount") + .setComponents( + new MessageActionRow().setComponents([ + new TextInputComponent() + .setCustomId("custom_amount") + .setLabel("Value") + .setStyle("SHORT") + .setRequired(true), + ]), + ) + + await i.showModal(modal) + const submitted = await i + .awaitModalSubmit({ + time: 300000, + filter: (mi) => mi.user.id === i.user.id, + }) + .catch(() => null) + + if (!submitted) + return { + msgOpts: { + ...(i.message as Message), + attachments: undefined, + }, + } + + if (!submitted.deferred) { + await submitted.deferUpdate().catch(() => null) + } + + const amount = + submitted.fields.getTextInputValue("custom_amount") + return depositStep2(i, { ...(ctx as any), amount }) + } + return depositStep2(i, { + ...(ctx as any), + amount: `%${ev.split("_").at(-1)}`, + }) + }, + submit: (i, _ev, ctx) => executeTradingVaultDeposit(i, ctx), + }, + ...ctx, + }, + states: { + depositStep1: { + on: { + SELECT_TOKEN: "depositStep2", + }, + }, + depositStep2: { + on: { + SELECT_AMOUNT_10: "depositStep2", + SELECT_AMOUNT_25: "depositStep2", + SELECT_AMOUNT_50: "depositStep2", + SELECT_AMOUNT_100: "depositStep2", + ENTER_AMOUNT: "depositStep2", + SUBMIT: "submit", + }, + }, + submit: { + type: "final", + }, + }, + }) + }, }, }, )